Introduce ActionRunAttempt to represent each execution of a run (#37119)
This PR introduces a new `ActionRunAttempt` model and makes Actions
execution attempt-scoped.
**Main Changes**
- Each workflow run trigger generates a new `ActionRunAttempt`. The
triggered jobs are then associated with this new `ActionRunAttempt`
record.
- Each rerun now creates:
- a new `ActionRunAttempt` record for the workflow run
- a full new set of `ActionRunJob` records for the new
`ActionRunAttempt`
- For jobs that need to be rerun, the new job records are created as
runnable jobs in the new attempt.
- For jobs that do not need to be rerun, new job records are still
created in the new attempt, but they reuse the result of the previous
attempt instead of executing again.
- Introduce `rerunPlan` to manage each rerun and refactored rerun flow
into a two-phase plan-based model:
- `buildRerunPlan`
- `execRerunPlan`
- `RerunFailedWorkflowRun` and `RerunFailed` no longer directly derives
all jobs that need to be rerun; this step is now handled by
`buildRerunPlan`.
- Converted artifacts from run-scoped to attempt-scoped:
- uploads are now associated with `RunAttemptID`
- listing, download, and deletion resolve against the current attempt
- Added attempt-aware web Actions views:
- the default run page shows the latest attempt
(`/actions/runs/{run_id}`)
- previous attempt pages show jobs and artifacts for that attempt
(`/actions/runs/{run_id}/attempts/{attempt_num}`)
- New APIs:
- `/repos/{owner}/{repo}/actions/runs/{run}/attempts/{attempt}`
- `/repos/{owner}/{repo}/actions/runs/{run}/attempts/{attempt}/jobs`
- New configuration `MAX_RERUN_ATTEMPTS`
- https://gitea.com/gitea/docs/pulls/383
**Compatibility**
- Existing legacy runs use `LatestAttemptID = 0` and legacy jobs use
`RunAttemptID = 0`. Therefore, these fields can be used to identify
legacy runs and jobs and provide backward compatibility.
- If a legacy run is rerun, an `ActionRunAttempt` with `attempt=1` will
be created to represent the original execution. Then a new
`ActionRunAttempt` with `attempt=2` will be created for the real rerun.
- Existing artifact records are not backfilled; legacy artifacts
continue to use `RunAttemptID = 0`.
**Improvements**
- It is now easier to inspect and download logs from previous attempts.
-
[`run_attempt`](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#github-context)
semantics are now aligned with GitHub.
- > A unique number for each attempt of a particular workflow run in a
repository. This number begins at 1 for the workflow run's first
attempt, and increments with each re-run.
- Rerun behavior is now clearer and more explicit.
- Instead of mutating the status of previous jobs in place, each rerun
creates a new attempt with a full new set of job records.
- Artifacts produced by different reruns can now be listed separately.
Signed-off-by: Zettat123 <zettat123@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Giteabot <teabot@gitea.io>
This commit is contained in:
@@ -74,6 +74,7 @@ import (
|
||||
"code.gitea.io/gitea/modules/httplib"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/storage"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
@@ -310,7 +311,7 @@ func (ar artifactRoutes) confirmUploadArtifact(ctx *ArtifactContext) {
|
||||
ctx.HTTPError(http.StatusBadRequest, "Error artifact name is empty")
|
||||
return
|
||||
}
|
||||
if err := mergeChunksForRun(ctx, ar.fs, runID, artifactName); err != nil {
|
||||
if err := mergeChunksForRun(ctx, ar.fs, runID, ctx.ActionTask.Job.RunAttemptID, artifactName); err != nil {
|
||||
log.Error("Error merge chunks: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, "Error merge chunks")
|
||||
return
|
||||
@@ -338,8 +339,9 @@ func (ar artifactRoutes) listArtifacts(ctx *ArtifactContext) {
|
||||
}
|
||||
|
||||
artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{
|
||||
RunID: runID,
|
||||
Status: int(actions.ArtifactStatusUploadConfirmed),
|
||||
RunID: runID,
|
||||
RunAttemptID: optional.Some(ctx.ActionTask.Job.RunAttemptID),
|
||||
Status: int(actions.ArtifactStatusUploadConfirmed),
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("Error getting artifacts: %v", err)
|
||||
@@ -404,6 +406,7 @@ func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) {
|
||||
|
||||
artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{
|
||||
RunID: runID,
|
||||
RunAttemptID: optional.Some(ctx.ActionTask.Job.RunAttemptID),
|
||||
ArtifactName: itemPath,
|
||||
Status: int(actions.ArtifactStatusUploadConfirmed),
|
||||
})
|
||||
@@ -477,6 +480,11 @@ func (ar artifactRoutes) downloadArtifact(ctx *ArtifactContext) {
|
||||
ctx.HTTPError(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if ctx.ActionTask.Job.RunAttemptID > 0 && artifact.RunAttemptID != ctx.ActionTask.Job.RunAttemptID {
|
||||
log.Error("Error mismatch runAttemptID and artifactID, task: %v, artifact: %v", ctx.ActionTask.Job.RunAttemptID, artifactID)
|
||||
ctx.HTTPError(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if artifact.Status != actions.ArtifactStatusUploadConfirmed {
|
||||
log.Error("Error artifact not found: %s", artifact.Status.ToString())
|
||||
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"code.gitea.io/gitea/models/actions"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/storage"
|
||||
)
|
||||
@@ -257,10 +258,11 @@ func listOrderedChunksForArtifact(st storage.ObjectStorage, runID, artifactID in
|
||||
return emptyListAsError(chunks)
|
||||
}
|
||||
|
||||
func mergeChunksForRun(ctx *ArtifactContext, st storage.ObjectStorage, runID int64, artifactName string) error {
|
||||
func mergeChunksForRun(ctx *ArtifactContext, st storage.ObjectStorage, runID, runAttemptID int64, artifactName string) error {
|
||||
// read all db artifacts by name
|
||||
artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{
|
||||
RunID: runID,
|
||||
RunAttemptID: optional.Some(runAttemptID),
|
||||
ArtifactName: artifactName,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -107,6 +107,7 @@ import (
|
||||
"code.gitea.io/gitea/modules/actions"
|
||||
"code.gitea.io/gitea/modules/httplib"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/storage"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
@@ -266,9 +267,9 @@ func (r *artifactV4Routes) verifySignature(ctx *ArtifactContext, endp string) (*
|
||||
return task, artifactName, true
|
||||
}
|
||||
|
||||
func (r *artifactV4Routes) getArtifactByName(ctx *ArtifactContext, runID int64, name string) (*actions_model.ActionArtifact, error) {
|
||||
func (r *artifactV4Routes) getArtifactByName(ctx *ArtifactContext, runID, runAttemptID int64, name string) (*actions_model.ActionArtifact, error) {
|
||||
var art actions_model.ActionArtifact
|
||||
has, err := db.GetEngine(ctx).Where(builder.Eq{"run_id": runID, "artifact_name": name}, builder.Like{"content_encoding", "%/%"}).Get(&art)
|
||||
has, err := db.GetEngine(ctx).Where(builder.Eq{"run_id": runID, "run_attempt_id": runAttemptID, "artifact_name": name}, builder.Like{"content_encoding", "%/%"}).Get(&art)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
@@ -388,7 +389,7 @@ func (r *artifactV4Routes) uploadArtifact(ctx *ArtifactContext) {
|
||||
switch comp {
|
||||
case "block", "appendBlock":
|
||||
// get artifact by name
|
||||
artifact, err := r.getArtifactByName(ctx, task.Job.RunID, artifactName)
|
||||
artifact, err := r.getArtifactByName(ctx, task.Job.RunID, task.Job.RunAttemptID, artifactName)
|
||||
if err != nil {
|
||||
log.Error("Error artifact not found: %v", err)
|
||||
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
||||
@@ -475,7 +476,7 @@ func (r *artifactV4Routes) finalizeArtifact(ctx *ArtifactContext) {
|
||||
}
|
||||
|
||||
// get artifact by name
|
||||
artifact, err := r.getArtifactByName(ctx, runID, req.Name)
|
||||
artifact, err := r.getArtifactByName(ctx, runID, ctx.ActionTask.Job.RunAttemptID, req.Name)
|
||||
if err != nil {
|
||||
log.Error("Error artifact not found: %v", err)
|
||||
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
||||
@@ -589,6 +590,7 @@ func (r *artifactV4Routes) listArtifacts(ctx *ArtifactContext) {
|
||||
|
||||
artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{
|
||||
RunID: runID,
|
||||
RunAttemptID: optional.Some(ctx.ActionTask.Job.RunAttemptID),
|
||||
Status: int(actions_model.ArtifactStatusUploadConfirmed),
|
||||
FinalizedArtifactsV4: true,
|
||||
})
|
||||
@@ -642,7 +644,7 @@ func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) {
|
||||
artifactName := req.Name
|
||||
|
||||
// get artifact by name
|
||||
artifact, err := r.getArtifactByName(ctx, runID, artifactName)
|
||||
artifact, err := r.getArtifactByName(ctx, runID, ctx.ActionTask.Job.RunAttemptID, artifactName)
|
||||
if err != nil {
|
||||
log.Error("Error artifact not found: %v", err)
|
||||
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
||||
@@ -676,7 +678,7 @@ func (r *artifactV4Routes) downloadArtifact(ctx *ArtifactContext) {
|
||||
}
|
||||
|
||||
// get artifact by name
|
||||
artifact, err := r.getArtifactByName(ctx, task.Job.RunID, artifactName)
|
||||
artifact, err := r.getArtifactByName(ctx, task.Job.RunID, task.Job.RunAttemptID, artifactName)
|
||||
if err != nil {
|
||||
log.Error("Error artifact not found: %v", err)
|
||||
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
||||
@@ -707,14 +709,14 @@ func (r *artifactV4Routes) deleteArtifact(ctx *ArtifactContext) {
|
||||
}
|
||||
|
||||
// get artifact by name
|
||||
artifact, err := r.getArtifactByName(ctx, runID, req.Name)
|
||||
artifact, err := r.getArtifactByName(ctx, runID, ctx.ActionTask.Job.RunAttemptID, req.Name)
|
||||
if err != nil {
|
||||
log.Error("Error artifact not found: %v", err)
|
||||
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
||||
return
|
||||
}
|
||||
|
||||
err = actions_model.SetArtifactNeedDelete(ctx, runID, req.Name)
|
||||
err = actions_model.SetArtifactNeedDeleteByRunAttempt(ctx, runID, ctx.ActionTask.Job.RunAttemptID, req.Name)
|
||||
if err != nil {
|
||||
log.Error("Error deleting artifacts: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, err.Error())
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
actions_service "code.gitea.io/gitea/services/actions"
|
||||
notify_service "code.gitea.io/gitea/services/notify"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
"code.gitea.io/actions-proto-go/runner/v1/runnerv1connect"
|
||||
@@ -224,7 +223,7 @@ func (s *Service) UpdateTask(
|
||||
actions_service.CreateCommitStatusForRunJobs(ctx, task.Job.Run, task.Job)
|
||||
|
||||
if task.Status.IsDone() {
|
||||
notify_service.WorkflowJobStatusUpdate(ctx, task.Job.Run.Repo, task.Job.Run.TriggerUser, task.Job, task)
|
||||
actions_service.NotifyWorkflowJobStatusUpdateWithTask(ctx, task.Job, task)
|
||||
}
|
||||
|
||||
if req.Msg.State.Result != runnerv1.Result_RESULT_UNSPECIFIED {
|
||||
@@ -232,7 +231,7 @@ func (s *Service) UpdateTask(
|
||||
log.Error("Emit ready jobs of run %d: %v", task.Job.RunID, err)
|
||||
}
|
||||
if task.Job.Run.Status.IsDone() {
|
||||
actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, task.Job)
|
||||
actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, task.Job.RepoID, task.Job.RunID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ func ListWorkflowJobs(ctx *context.APIContext) {
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
shared.ListJobs(ctx, 0, 0, 0)
|
||||
shared.ListJobs(ctx, 0, 0, 0, nil)
|
||||
}
|
||||
|
||||
// ListWorkflowRuns Lists all runs
|
||||
|
||||
@@ -1255,6 +1255,10 @@ func Routes() *web.Router {
|
||||
m.Group("/runs", func() {
|
||||
m.Group("/{run}", func() {
|
||||
m.Get("", repo.GetWorkflowRun)
|
||||
m.Group("/attempts/{attempt}", func() {
|
||||
m.Get("", repo.GetWorkflowRunAttempt)
|
||||
m.Get("/jobs", repo.ListWorkflowRunAttemptJobs)
|
||||
})
|
||||
m.Delete("", reqToken(), reqRepoWriter(unit.TypeActions), repo.DeleteActionRun)
|
||||
m.Post("/rerun", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunWorkflowRun)
|
||||
m.Post("/rerun-failed-jobs", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunFailedWorkflowRun)
|
||||
|
||||
@@ -624,7 +624,7 @@ func (Action) ListWorkflowJobs(ctx *context.APIContext) {
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
shared.ListJobs(ctx, ctx.Org.Organization.ID, 0, 0)
|
||||
shared.ListJobs(ctx, ctx.Org.Organization.ID, 0, 0, nil)
|
||||
}
|
||||
|
||||
func (Action) ListWorkflowRuns(ctx *context.APIContext) {
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
secret_model "code.gitea.io/gitea/models/secret"
|
||||
"code.gitea.io/gitea/modules/actions"
|
||||
"code.gitea.io/gitea/modules/httplib"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
@@ -676,7 +677,7 @@ func (Action) UpdateRunner(ctx *context.APIContext) {
|
||||
shared.UpdateRunner(ctx, 0, ctx.Repo.Repository.ID, ctx.PathParamInt64("runner_id"))
|
||||
}
|
||||
|
||||
// GetWorkflowRunJobs Lists all jobs for a workflow run.
|
||||
// ListWorkflowJobs Lists all jobs for a repository.
|
||||
func (Action) ListWorkflowJobs(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/actions/jobs repository listWorkflowJobs
|
||||
// ---
|
||||
@@ -717,7 +718,7 @@ func (Action) ListWorkflowJobs(ctx *context.APIContext) {
|
||||
|
||||
repoID := ctx.Repo.Repository.ID
|
||||
|
||||
shared.ListJobs(ctx, 0, repoID, 0)
|
||||
shared.ListJobs(ctx, 0, repoID, 0, nil)
|
||||
}
|
||||
|
||||
// ListWorkflowRuns Lists all runs for a repository run.
|
||||
@@ -1163,7 +1164,7 @@ func getCurrentRepoActionRunJobsByID(ctx *context.APIContext) (*actions_model.Ac
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID)
|
||||
jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return nil, nil
|
||||
@@ -1171,6 +1172,24 @@ func getCurrentRepoActionRunJobsByID(ctx *context.APIContext) (*actions_model.Ac
|
||||
return run, jobs
|
||||
}
|
||||
|
||||
func getCurrentRepoActionRunAttemptByNumber(ctx *context.APIContext) (*actions_model.ActionRun, *actions_model.ActionRunAttempt) {
|
||||
run := getCurrentRepoActionRunByID(ctx)
|
||||
if ctx.Written() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
attemptNum := ctx.PathParamInt64("attempt")
|
||||
attempt, err := actions_model.GetRunAttemptByRunIDAndAttemptNum(ctx, run.ID, attemptNum)
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return nil, nil
|
||||
}
|
||||
return run, attempt
|
||||
}
|
||||
|
||||
// GetWorkflowRun Gets a specific workflow run.
|
||||
func GetWorkflowRun(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run} repository GetWorkflowRun
|
||||
@@ -1207,7 +1226,56 @@ func GetWorkflowRun(ctx *context.APIContext) {
|
||||
return
|
||||
}
|
||||
|
||||
convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, run)
|
||||
convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, run, nil)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, convertedRun)
|
||||
}
|
||||
|
||||
// GetWorkflowRunAttempt Gets a specific workflow run attempt.
|
||||
func GetWorkflowRunAttempt(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run}/attempts/{attempt} repository getWorkflowRunAttempt
|
||||
// ---
|
||||
// summary: Gets a specific workflow run attempt
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repository
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: run
|
||||
// in: path
|
||||
// description: id of the run
|
||||
// type: integer
|
||||
// required: true
|
||||
// - name: attempt
|
||||
// in: path
|
||||
// description: logical attempt number of the run
|
||||
// type: integer
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/WorkflowRun"
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
run, attempt := getCurrentRepoActionRunAttemptByNumber(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, run, attempt)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
@@ -1247,6 +1315,8 @@ func RerunWorkflowRun(ctx *context.APIContext) {
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "409":
|
||||
// "$ref": "#/responses/error"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
@@ -1255,12 +1325,12 @@ func RerunWorkflowRun(ctx *context.APIContext) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobs); err != nil {
|
||||
if _, err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, ctx.Doer, jobs); err != nil {
|
||||
handleWorkflowRerunError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, run)
|
||||
convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, run, nil)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
@@ -1298,6 +1368,8 @@ func RerunFailedWorkflowRun(ctx *context.APIContext) {
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "409":
|
||||
// "$ref": "#/responses/error"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
@@ -1306,7 +1378,7 @@ func RerunFailedWorkflowRun(ctx *context.APIContext) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, actions_service.GetFailedRerunJobs(jobs)); err != nil {
|
||||
if _, err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, ctx.Doer, actions_service.GetFailedJobsForRerun(jobs)); err != nil {
|
||||
handleWorkflowRerunError(ctx, err)
|
||||
return
|
||||
}
|
||||
@@ -1351,6 +1423,8 @@ func RerunWorkflowJob(ctx *context.APIContext) {
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "409":
|
||||
// "$ref": "#/responses/error"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
@@ -1367,12 +1441,28 @@ func RerunWorkflowJob(ctx *context.APIContext) {
|
||||
}
|
||||
|
||||
targetJob := jobs[jobIdx]
|
||||
if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, actions_service.GetAllRerunJobs(targetJob, jobs)); err != nil {
|
||||
newAttempt, err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, ctx.Doer, []*actions_model.ActionRunJob{targetJob})
|
||||
if err != nil {
|
||||
handleWorkflowRerunError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
convertedJob, err := convert.ToActionWorkflowJob(ctx, ctx.Repo.Repository, nil, targetJob)
|
||||
// Legacy jobs had AttemptJobID=0 before the rerun; createOriginalAttemptForLegacyRun inside
|
||||
// RerunWorkflowRunJobs has since backfilled it in the DB, so reload only in that case.
|
||||
if targetJob.AttemptJobID == 0 {
|
||||
targetJob, err = actions_model.GetRunJobByRepoAndID(ctx, run.RepoID, targetJob.ID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
rerunJob, err := actions_model.GetRunJobByAttemptJobID(ctx, run.ID, newAttempt.ID, targetJob.AttemptJobID)
|
||||
if err != nil {
|
||||
handleWorkflowRerunError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
convertedJob, err := convert.ToActionWorkflowJob(ctx, ctx.Repo.Repository, nil, rerunJob)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
@@ -1384,6 +1474,12 @@ func handleWorkflowRerunError(ctx *context.APIContext, err error) {
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
return
|
||||
} else if errors.Is(err, util.ErrAlreadyExist) {
|
||||
ctx.APIError(http.StatusConflict, err)
|
||||
return
|
||||
} else if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIError(http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
@@ -1440,9 +1536,75 @@ func ListWorkflowRunJobs(ctx *context.APIContext) {
|
||||
return
|
||||
}
|
||||
|
||||
run, err := actions_model.GetRunByRepoAndID(ctx, repoID, runID)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
// runID is used as an additional filter next to repoID to ensure that we only list jobs for the specified repoID and runID.
|
||||
// no additional checks for runID are needed here
|
||||
shared.ListJobs(ctx, 0, repoID, runID)
|
||||
shared.ListJobs(ctx, 0, repoID, runID, optional.Some(run.LatestAttemptID))
|
||||
}
|
||||
|
||||
// ListWorkflowRunAttemptJobs Lists all jobs for a workflow run attempt.
|
||||
func ListWorkflowRunAttemptJobs(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run}/attempts/{attempt}/jobs repository listWorkflowRunAttemptJobs
|
||||
// ---
|
||||
// summary: Lists all jobs for a workflow run attempt
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repository
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: run
|
||||
// in: path
|
||||
// description: id of the workflow run
|
||||
// type: integer
|
||||
// required: true
|
||||
// - name: attempt
|
||||
// in: path
|
||||
// description: logical attempt number of the run
|
||||
// type: integer
|
||||
// required: true
|
||||
// - name: status
|
||||
// in: query
|
||||
// description: workflow status (pending, queued, in_progress, failure, success, skipped)
|
||||
// type: string
|
||||
// required: false
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results to return (1-based)
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/WorkflowJobsList"
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
run, attempt := getCurrentRepoActionRunAttemptByNumber(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
shared.ListJobs(ctx, 0, run.RepoID, run.ID, optional.Some(attempt.ID))
|
||||
}
|
||||
|
||||
// GetWorkflowJob Gets a specific workflow job for a workflow run.
|
||||
@@ -1758,7 +1920,7 @@ func DeleteArtifact(ctx *context.APIContext) {
|
||||
}
|
||||
|
||||
if actions.IsArtifactV4(art) {
|
||||
if err := actions_model.SetArtifactNeedDelete(ctx, art.RunID, art.ArtifactName); err != nil {
|
||||
if err := actions_model.SetArtifactNeedDeleteByID(ctx, art.ID); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/webhook"
|
||||
@@ -27,8 +28,9 @@ import (
|
||||
// ownerID != 0 and repoID != 0 undefined behavior
|
||||
// runID == 0 means all jobs
|
||||
// runID is used as an additional filter together with ownerID and repoID to only return jobs for the given run
|
||||
// runAttemptID, when set, additionally limits the result to jobs of the specified run attempt. Only takes effect when runID > 0.
|
||||
// Access rights are checked at the API route level
|
||||
func ListJobs(ctx *context.APIContext, ownerID, repoID, runID int64) {
|
||||
func ListJobs(ctx *context.APIContext, ownerID, repoID, runID int64, runAttemptID optional.Option[int64]) {
|
||||
if ownerID != 0 && repoID != 0 {
|
||||
setting.PanicInDevOrTesting("ownerID and repoID should not be both set")
|
||||
}
|
||||
@@ -39,6 +41,9 @@ func ListJobs(ctx *context.APIContext, ownerID, repoID, runID int64) {
|
||||
RunID: runID,
|
||||
ListOptions: listOptions,
|
||||
}
|
||||
if runID > 0 {
|
||||
opts.RunAttemptID = runAttemptID
|
||||
}
|
||||
for _, status := range ctx.FormStrings("status") {
|
||||
values, err := convertToInternal(status)
|
||||
if err != nil {
|
||||
@@ -178,7 +183,7 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64) {
|
||||
}
|
||||
}
|
||||
|
||||
convertedRun, err := convert.ToActionWorkflowRun(ctx, repository, runs[i])
|
||||
convertedRun, err := convert.ToActionWorkflowRun(ctx, repository, runs[i], nil)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
|
||||
@@ -439,5 +439,5 @@ func ListWorkflowJobs(ctx *context.APIContext) {
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
shared.ListJobs(ctx, ctx.Doer.ID, 0, 0)
|
||||
shared.ListJobs(ctx, ctx.Doer.ID, 0, 0, nil)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@ func DownloadActionsRunJobLogs(ctx *context.Base, ctxRepo *repo_model.Repository
|
||||
return util.NewNotExistErrorf("job not found")
|
||||
}
|
||||
|
||||
if curJob.TaskID == 0 {
|
||||
taskID := curJob.EffectiveTaskID()
|
||||
if taskID == 0 {
|
||||
return util.NewNotExistErrorf("job not started")
|
||||
}
|
||||
|
||||
@@ -39,7 +40,7 @@ func DownloadActionsRunJobLogs(ctx *context.Base, ctxRepo *repo_model.Repository
|
||||
return fmt.Errorf("LoadRun: %w", err)
|
||||
}
|
||||
|
||||
task, err := actions_model.GetTaskByID(ctx, curJob.TaskID)
|
||||
task, err := actions_model.GetTaskByID(ctx, taskID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetTaskByID: %w", err)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package devtest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
mathRand "math/rand/v2"
|
||||
"net/http"
|
||||
"slices"
|
||||
@@ -12,7 +13,9 @@ import (
|
||||
"time"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/routers/web/repo/actions"
|
||||
@@ -59,13 +62,18 @@ func generateMockStepsLog(logCur actions.LogCursor, opts generateMockStepsLogOpt
|
||||
}
|
||||
|
||||
func MockActionsView(ctx *context.Context) {
|
||||
ctx.Data["RunID"] = ctx.PathParamInt64("run")
|
||||
if runID := ctx.PathParamInt64("run"); runID == 0 {
|
||||
ctx.Redirect("/repo-action-view/runs/10")
|
||||
return
|
||||
}
|
||||
ctx.Data["JobID"] = ctx.PathParamInt64("job")
|
||||
ctx.Data["ActionsViewURL"] = ctx.Req.URL.Path
|
||||
ctx.HTML(http.StatusOK, "devtest/repo-action-view")
|
||||
}
|
||||
|
||||
func MockActionsRunsJobs(ctx *context.Context) {
|
||||
runID := ctx.PathParamInt64("run")
|
||||
attemptID := ctx.PathParamInt64("attempt")
|
||||
|
||||
alignTime := func(v, unit int64) int64 {
|
||||
return (v + unit) / unit * unit
|
||||
@@ -74,16 +82,9 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
||||
resp.State.Run.RepoID = 12345
|
||||
resp.State.Run.TitleHTML = `mock run title <a href="/">link</a>`
|
||||
resp.State.Run.Link = setting.AppSubURL + "/devtest/repo-action-view/runs/" + strconv.FormatInt(runID, 10)
|
||||
resp.State.Run.Status = actions_model.StatusRunning.String()
|
||||
resp.State.Run.CanCancel = runID == 10
|
||||
resp.State.Run.CanApprove = runID == 20
|
||||
resp.State.Run.CanRerun = runID == 30
|
||||
resp.State.Run.CanRerunFailed = runID == 30
|
||||
resp.State.Run.CanDeleteArtifact = true
|
||||
resp.State.Run.WorkflowID = "workflow-id"
|
||||
resp.State.Run.WorkflowLink = "./workflow-link"
|
||||
resp.State.Run.Duration = "1h 23m 45s"
|
||||
resp.State.Run.TriggeredAt = time.Now().Add(-time.Hour).Unix()
|
||||
resp.State.Run.TriggerEvent = "push"
|
||||
resp.State.Run.Commit = actions.ViewCommit{
|
||||
ShortSha: "ccccdddd",
|
||||
@@ -98,6 +99,88 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
||||
IsDeleted: false,
|
||||
},
|
||||
}
|
||||
now := time.Now()
|
||||
currentAttemptNum := int64(1)
|
||||
if attemptID > 0 {
|
||||
currentAttemptNum = attemptID
|
||||
}
|
||||
user2 := &user_model.User{Name: "user2"}
|
||||
user3 := &user_model.User{Name: "user3"}
|
||||
attempts := []*actions_model.ActionRunAttempt{{
|
||||
Attempt: 1,
|
||||
Status: actions_model.StatusSuccess,
|
||||
Created: timeutil.TimeStamp(now.Add(-time.Hour).Unix()),
|
||||
TriggerUserID: 2,
|
||||
TriggerUser: user2,
|
||||
}}
|
||||
if runID == 10 {
|
||||
attempts = []*actions_model.ActionRunAttempt{
|
||||
{
|
||||
Attempt: 3,
|
||||
Status: actions_model.StatusSuccess,
|
||||
Created: timeutil.TimeStamp(alignTime(now.Add(-time.Hour).Unix(), 3600)),
|
||||
TriggerUserID: 2,
|
||||
TriggerUser: user2,
|
||||
},
|
||||
{
|
||||
Attempt: 2,
|
||||
Status: actions_model.StatusFailure,
|
||||
Created: timeutil.TimeStamp(alignTime(now.Add(-2*time.Hour).Unix(), 3600)),
|
||||
TriggerUserID: 1,
|
||||
TriggerUser: user3,
|
||||
},
|
||||
{
|
||||
Attempt: 1,
|
||||
Status: actions_model.StatusSuccess,
|
||||
Created: timeutil.TimeStamp(alignTime(now.Add(-3*time.Hour).Unix(), 3600)),
|
||||
TriggerUserID: 2,
|
||||
TriggerUser: user2,
|
||||
},
|
||||
}
|
||||
if attemptID == 0 {
|
||||
currentAttemptNum = 3
|
||||
}
|
||||
}
|
||||
|
||||
latestAttempt := attempts[0]
|
||||
resp.State.Run.RunAttempt = currentAttemptNum
|
||||
resp.State.Run.Done = latestAttempt.Status.IsDone()
|
||||
resp.State.Run.Status = latestAttempt.Status.String()
|
||||
resp.State.Run.Duration = "1h 23m 45s"
|
||||
resp.State.Run.TriggeredAt = latestAttempt.Created.AsTime().Unix()
|
||||
resp.State.Run.ViewLink = resp.State.Run.Link
|
||||
for _, attempt := range attempts {
|
||||
link := resp.State.Run.Link
|
||||
if attempt.Attempt != latestAttempt.Attempt {
|
||||
link = fmt.Sprintf("%s/attempts/%d", resp.State.Run.Link, attempt.Attempt)
|
||||
}
|
||||
current := attempt.Attempt == currentAttemptNum
|
||||
if current {
|
||||
resp.State.Run.Status = attempt.Status.String()
|
||||
resp.State.Run.Done = attempt.Status.IsDone()
|
||||
resp.State.Run.TriggeredAt = attempt.Created.AsTime().Unix()
|
||||
if attempt.Attempt != latestAttempt.Attempt {
|
||||
resp.State.Run.ViewLink = link
|
||||
}
|
||||
}
|
||||
resp.State.Run.Attempts = append(resp.State.Run.Attempts, &actions.ViewRunAttempt{
|
||||
Attempt: attempt.Attempt,
|
||||
Status: attempt.Status.String(),
|
||||
Done: attempt.Status.IsDone(),
|
||||
Link: link,
|
||||
Current: current,
|
||||
Latest: attempt.Attempt == latestAttempt.Attempt,
|
||||
TriggeredAt: attempt.Created.AsTime().Unix(),
|
||||
TriggerUserName: attempt.TriggerUser.GetDisplayName(),
|
||||
TriggerUserLink: attempt.TriggerUser.HomeLink(),
|
||||
})
|
||||
}
|
||||
isLatestAttempt := currentAttemptNum == latestAttempt.Attempt
|
||||
resp.State.Run.CanCancel = runID == 10 && isLatestAttempt
|
||||
resp.State.Run.CanApprove = runID == 20 && isLatestAttempt
|
||||
resp.State.Run.CanRerun = runID == 30 && isLatestAttempt
|
||||
resp.State.Run.CanRerunFailed = runID == 30 && isLatestAttempt
|
||||
|
||||
resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{
|
||||
Name: "artifact-a",
|
||||
Size: 100 * 1024,
|
||||
@@ -123,8 +206,13 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
||||
ExpiresUnix: 0,
|
||||
})
|
||||
|
||||
jobLink := func(jobID int64) string {
|
||||
return fmt.Sprintf("%s/jobs/%d", resp.State.Run.Link, jobID)
|
||||
}
|
||||
|
||||
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
|
||||
ID: runID * 10,
|
||||
Link: jobLink(runID * 10),
|
||||
JobID: "job-100",
|
||||
Name: "job 100 (testsubname)",
|
||||
Status: actions_model.StatusRunning.String(),
|
||||
@@ -133,6 +221,7 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
||||
})
|
||||
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
|
||||
ID: runID*10 + 1,
|
||||
Link: jobLink(runID*10 + 1),
|
||||
JobID: "job-101",
|
||||
Name: "job 101",
|
||||
Status: actions_model.StatusWaiting.String(),
|
||||
@@ -142,6 +231,7 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
||||
})
|
||||
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
|
||||
ID: runID*10 + 2,
|
||||
Link: jobLink(runID*10 + 2),
|
||||
JobID: "job-102",
|
||||
Name: "ULTRA LOOOOOOOOOOOONG job name 102 that exceeds the limit",
|
||||
Status: actions_model.StatusFailure.String(),
|
||||
@@ -151,6 +241,7 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
||||
})
|
||||
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
|
||||
ID: runID*10 + 3,
|
||||
Link: jobLink(runID*10 + 3),
|
||||
JobID: "job-103",
|
||||
Name: "job 103",
|
||||
Status: actions_model.StatusCancelled.String(),
|
||||
@@ -162,8 +253,10 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
||||
// add more jobs to a run for UI testing
|
||||
if resp.State.Run.CanCancel {
|
||||
for i := range 10 {
|
||||
jobID := runID*1000 + int64(i)
|
||||
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
|
||||
ID: runID*1000 + int64(i),
|
||||
ID: jobID,
|
||||
Link: jobLink(jobID),
|
||||
JobID: "job-dup-test-" + strconv.Itoa(i),
|
||||
Name: "job dup test " + strconv.Itoa(i),
|
||||
Status: actions_model.StatusSuccess.String(),
|
||||
@@ -184,6 +277,14 @@ func fillViewRunResponseCurrentJob(ctx *context.Context, resp *actions.ViewRespo
|
||||
return
|
||||
}
|
||||
|
||||
for _, job := range resp.State.Run.Jobs {
|
||||
if job.ID == jobID {
|
||||
resp.State.CurrentJob.Title = job.Name
|
||||
resp.State.CurrentJob.Detail = job.Status
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
req := web.GetForm(ctx).(*actions.ViewRequest)
|
||||
var mockLogOptions []generateMockStepsLogOptions
|
||||
resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{
|
||||
|
||||
@@ -311,7 +311,7 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo) {
|
||||
if !run.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning) {
|
||||
continue
|
||||
}
|
||||
jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID)
|
||||
jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetRunJobsByRunID", err)
|
||||
return
|
||||
|
||||
@@ -34,7 +34,6 @@ import (
|
||||
"code.gitea.io/gitea/routers/common"
|
||||
actions_service "code.gitea.io/gitea/services/actions"
|
||||
context_module "code.gitea.io/gitea/services/context"
|
||||
notify_service "code.gitea.io/gitea/services/notify"
|
||||
|
||||
"github.com/nektos/act/pkg/model"
|
||||
)
|
||||
@@ -166,7 +165,7 @@ func resolveCurrentRunForView(ctx *context_module.Context) *actions_model.Action
|
||||
return nil
|
||||
}
|
||||
if run != nil {
|
||||
jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID)
|
||||
jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetRunJobsByRunID", err)
|
||||
return nil
|
||||
@@ -203,9 +202,23 @@ func View(ctx *context_module.Context) {
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
ctx.Data["RunID"] = run.ID
|
||||
ctx.Data["JobID"] = ctx.PathParamInt64("job") // it can be 0 when no job (e.g.: run summary view)
|
||||
ctx.Data["ActionsURL"] = ctx.Repo.RepoLink + "/actions"
|
||||
run.Repo = ctx.Repo.Repository
|
||||
|
||||
jobID := ctx.PathParamInt64("job")
|
||||
ctx.Data["JobID"] = jobID // it can be 0 when no job (e.g.: run summary view)
|
||||
|
||||
attemptNum := ctx.PathParamInt64("attempt")
|
||||
|
||||
// ActionsViewURL is the endpoint for viewing a run (job summary), a job, or a job attempt.
|
||||
// It's POST method handler can provide the state data for the frontend rendering.
|
||||
switch {
|
||||
case attemptNum > 0:
|
||||
ctx.Data["ActionsViewURL"] = fmt.Sprintf("%s/attempts/%d", run.Link(), attemptNum)
|
||||
case jobID > 0:
|
||||
ctx.Data["ActionsViewURL"] = fmt.Sprintf("%s/jobs/%d", run.Link(), jobID)
|
||||
default:
|
||||
ctx.Data["ActionsViewURL"] = run.Link()
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, tplViewActions)
|
||||
}
|
||||
@@ -259,22 +272,30 @@ type ViewResponse struct {
|
||||
|
||||
State struct {
|
||||
Run struct {
|
||||
RepoID int64 `json:"repoId"`
|
||||
Link string `json:"link"`
|
||||
Title string `json:"title"`
|
||||
TitleHTML template.HTML `json:"titleHTML"`
|
||||
Status string `json:"status"`
|
||||
CanCancel bool `json:"canCancel"`
|
||||
CanApprove bool `json:"canApprove"` // the run needs an approval and the doer has permission to approve
|
||||
CanRerun bool `json:"canRerun"`
|
||||
CanRerunFailed bool `json:"canRerunFailed"`
|
||||
CanDeleteArtifact bool `json:"canDeleteArtifact"`
|
||||
Done bool `json:"done"`
|
||||
WorkflowID string `json:"workflowID"`
|
||||
WorkflowLink string `json:"workflowLink"`
|
||||
IsSchedule bool `json:"isSchedule"`
|
||||
Jobs []*ViewJob `json:"jobs"`
|
||||
Commit ViewCommit `json:"commit"`
|
||||
RepoID int64 `json:"repoId"`
|
||||
// Link is the canonical HTML URL of the run, e.g. "/owner/repo/actions/runs/123".
|
||||
// Used as the base for composing sub-resource URLs (cancel, rerun, artifacts, jobs) that are not attempt-scoped.
|
||||
Link string `json:"link"`
|
||||
// ViewLink is the attempt-aware URL for navigation, e.g. "/owner/repo/actions/runs/123" for the latest attempt
|
||||
// or "/owner/repo/actions/runs/123/attempts/2" for a historical attempt.
|
||||
// Use this when the target should reflect the currently-viewed attempt.
|
||||
ViewLink string `json:"viewLink"`
|
||||
Title string `json:"title"`
|
||||
TitleHTML template.HTML `json:"titleHTML"`
|
||||
Status string `json:"status"`
|
||||
CanCancel bool `json:"canCancel"`
|
||||
CanApprove bool `json:"canApprove"` // the run needs an approval and the doer has permission to approve
|
||||
CanRerun bool `json:"canRerun"`
|
||||
CanRerunFailed bool `json:"canRerunFailed"`
|
||||
CanDeleteArtifact bool `json:"canDeleteArtifact"`
|
||||
Done bool `json:"done"`
|
||||
WorkflowID string `json:"workflowID"`
|
||||
WorkflowLink string `json:"workflowLink"`
|
||||
IsSchedule bool `json:"isSchedule"`
|
||||
RunAttempt int64 `json:"runAttempt"`
|
||||
Attempts []*ViewRunAttempt `json:"attempts"`
|
||||
Jobs []*ViewJob `json:"jobs"`
|
||||
Commit ViewCommit `json:"commit"`
|
||||
// Summary view: run duration and trigger time/event
|
||||
Duration string `json:"duration"`
|
||||
TriggeredAt int64 `json:"triggeredAt"` // unix seconds for relative time
|
||||
@@ -293,6 +314,7 @@ type ViewResponse struct {
|
||||
|
||||
type ViewJob struct {
|
||||
ID int64 `json:"id"`
|
||||
Link string `json:"link"`
|
||||
JobID string `json:"jobId,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
@@ -301,6 +323,18 @@ type ViewJob struct {
|
||||
Needs []string `json:"needs,omitempty"`
|
||||
}
|
||||
|
||||
type ViewRunAttempt struct {
|
||||
Attempt int64 `json:"attempt"`
|
||||
Status string `json:"status"`
|
||||
Done bool `json:"done"`
|
||||
Link string `json:"link"`
|
||||
Current bool `json:"current"`
|
||||
Latest bool `json:"latest"`
|
||||
TriggeredAt int64 `json:"triggeredAt"`
|
||||
TriggerUserName string `json:"triggerUserName"`
|
||||
TriggerUserLink string `json:"triggerUserLink"`
|
||||
}
|
||||
|
||||
type ViewCommit struct {
|
||||
ShortSha string `json:"shortSHA"`
|
||||
Link string `json:"link"`
|
||||
@@ -338,24 +372,8 @@ type ViewStepLogLine struct {
|
||||
Timestamp float64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
func getActionsViewArtifacts(ctx context.Context, repoID, runID int64) (artifactsViewItems []*ArtifactsViewItem, err error) {
|
||||
artifacts, err := actions_model.ListUploadedArtifactsMeta(ctx, repoID, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, art := range artifacts {
|
||||
artifactsViewItems = append(artifactsViewItems, &ArtifactsViewItem{
|
||||
Name: art.ArtifactName,
|
||||
Size: art.FileSize,
|
||||
Status: util.Iif(art.Status == actions_model.ArtifactStatusExpired, "expired", "completed"),
|
||||
ExpiresUnix: int64(art.ExpiredUnix),
|
||||
})
|
||||
}
|
||||
return artifactsViewItems, nil
|
||||
}
|
||||
|
||||
func ViewPost(ctx *context_module.Context) {
|
||||
run, jobs := getCurrentRunJobsByPathParam(ctx)
|
||||
run, attempt, jobs := getCurrentRunJobsByPathParam(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
@@ -365,7 +383,7 @@ func ViewPost(ctx *context_module.Context) {
|
||||
}
|
||||
|
||||
resp := &ViewResponse{}
|
||||
fillViewRunResponseSummary(ctx, resp, run, jobs)
|
||||
fillViewRunResponseSummary(ctx, resp, run, attempt, jobs)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
@@ -376,23 +394,33 @@ func ViewPost(ctx *context_module.Context) {
|
||||
ctx.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) {
|
||||
var err error
|
||||
resp.Artifacts, err = getActionsViewArtifacts(ctx, ctx.Repo.Repository.ID, run.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("getActionsViewArtifacts", err)
|
||||
return
|
||||
}
|
||||
func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, jobs []*actions_model.ActionRunJob) {
|
||||
// Latest when the run has no attempts yet (legacy) or the viewed attempt is the run's latest.
|
||||
isLatestAttempt := run.LatestAttemptID == 0 || (attempt != nil && attempt.ID == run.LatestAttemptID)
|
||||
|
||||
resp.State.Run.RepoID = ctx.Repo.Repository.ID
|
||||
// the title for the "run" is from the commit message
|
||||
resp.State.Run.Title = run.Title
|
||||
resp.State.Run.TitleHTML = templates.NewRenderUtils(ctx).RenderCommitMessage(run.Title, ctx.Repo.Repository)
|
||||
resp.State.Run.Link = run.Link()
|
||||
resp.State.Run.CanCancel = !run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions)
|
||||
resp.State.Run.CanApprove = run.NeedApproval && ctx.Repo.CanWrite(unit.TypeActions)
|
||||
resp.State.Run.CanRerun = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions)
|
||||
resp.State.Run.CanDeleteArtifact = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions)
|
||||
resp.State.Run.ViewLink = getRunViewLink(run, attempt)
|
||||
resp.State.Run.Attempts = make([]*ViewRunAttempt, 0)
|
||||
if attempt != nil {
|
||||
resp.State.Run.RunAttempt = attempt.Attempt
|
||||
resp.State.Run.Status = attempt.Status.String()
|
||||
resp.State.Run.Done = attempt.Status.IsDone()
|
||||
resp.State.Run.Duration = attempt.Duration().String()
|
||||
resp.State.Run.TriggeredAt = attempt.Created.AsTime().Unix()
|
||||
} else {
|
||||
resp.State.Run.Status = run.Status.String()
|
||||
resp.State.Run.Done = run.Status.IsDone()
|
||||
resp.State.Run.Duration = run.Duration().String()
|
||||
resp.State.Run.TriggeredAt = run.Created.AsTime().Unix()
|
||||
}
|
||||
resp.State.Run.CanCancel = isLatestAttempt && !resp.State.Run.Done && ctx.Repo.CanWrite(unit.TypeActions)
|
||||
resp.State.Run.CanApprove = isLatestAttempt && run.NeedApproval && ctx.Repo.CanWrite(unit.TypeActions)
|
||||
resp.State.Run.CanRerun = isLatestAttempt && resp.State.Run.Done && ctx.Repo.CanWrite(unit.TypeActions)
|
||||
resp.State.Run.CanDeleteArtifact = resp.State.Run.Done && ctx.Repo.CanWrite(unit.TypeActions)
|
||||
if resp.State.Run.CanRerun {
|
||||
for _, job := range jobs {
|
||||
if job.Status == actions_model.StatusFailure || job.Status == actions_model.StatusCancelled {
|
||||
@@ -401,15 +429,16 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse,
|
||||
}
|
||||
}
|
||||
}
|
||||
resp.State.Run.Done = run.Status.IsDone()
|
||||
resp.State.Run.WorkflowID = run.WorkflowID
|
||||
resp.State.Run.WorkflowLink = run.WorkflowLink()
|
||||
if isLatestAttempt {
|
||||
resp.State.Run.WorkflowLink = run.WorkflowLink()
|
||||
}
|
||||
resp.State.Run.IsSchedule = run.IsSchedule()
|
||||
resp.State.Run.Jobs = make([]*ViewJob, 0, len(jobs)) // marshal to '[]' instead fo 'null' in json
|
||||
resp.State.Run.Status = run.Status.String()
|
||||
for _, v := range jobs {
|
||||
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &ViewJob{
|
||||
ID: v.ID,
|
||||
Link: fmt.Sprintf("%s/jobs/%d", run.Link(), v.ID),
|
||||
JobID: v.JobID,
|
||||
Name: v.Name,
|
||||
Status: v.Status.String(),
|
||||
@@ -419,6 +448,29 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse,
|
||||
})
|
||||
}
|
||||
|
||||
attempts, err := actions_model.ListRunAttemptsByRunID(ctx, run.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("ListRunAttemptsByRunID", err)
|
||||
return
|
||||
}
|
||||
if err := attempts.LoadTriggerUser(ctx); err != nil {
|
||||
ctx.ServerError("LoadTriggerUser", err)
|
||||
return
|
||||
}
|
||||
for _, runAttempt := range attempts {
|
||||
resp.State.Run.Attempts = append(resp.State.Run.Attempts, &ViewRunAttempt{
|
||||
Attempt: runAttempt.Attempt,
|
||||
Status: runAttempt.Status.String(),
|
||||
Done: runAttempt.Status.IsDone(),
|
||||
Link: getRunViewLink(run, runAttempt),
|
||||
Current: runAttempt.ID == attempt.ID,
|
||||
Latest: runAttempt.ID == run.LatestAttemptID,
|
||||
TriggeredAt: runAttempt.Created.AsTime().Unix(),
|
||||
TriggerUserName: runAttempt.TriggerUser.GetDisplayName(),
|
||||
TriggerUserLink: runAttempt.TriggerUser.HomeLink(),
|
||||
})
|
||||
}
|
||||
|
||||
pusher := ViewUser{
|
||||
DisplayName: run.TriggerUser.GetDisplayName(),
|
||||
Link: run.TriggerUser.HomeLink(),
|
||||
@@ -443,9 +495,27 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse,
|
||||
Pusher: pusher,
|
||||
Branch: branch,
|
||||
}
|
||||
resp.State.Run.Duration = run.Duration().String()
|
||||
resp.State.Run.TriggeredAt = run.Created.AsTime().Unix()
|
||||
resp.State.Run.TriggerEvent = run.TriggerEvent
|
||||
|
||||
// Legacy runs (LatestAttemptID == 0) have no attempt; their artifacts all share run_attempt_id=0,
|
||||
// so passing 0 here scopes to this run's legacy artifacts only.
|
||||
var runAttemptID int64
|
||||
if attempt != nil {
|
||||
runAttemptID = attempt.ID
|
||||
}
|
||||
arts, err := actions_model.ListUploadedArtifactsMetaByRunAttempt(ctx, ctx.Repo.Repository.ID, run.ID, runAttemptID)
|
||||
if err != nil {
|
||||
ctx.ServerError("ListUploadedArtifactsMetaByRunAttempt", err)
|
||||
return
|
||||
}
|
||||
resp.Artifacts = make([]*ArtifactsViewItem, 0, len(arts))
|
||||
for _, art := range arts {
|
||||
resp.Artifacts = append(resp.Artifacts, &ArtifactsViewItem{
|
||||
Name: art.ArtifactName,
|
||||
Size: art.FileSize,
|
||||
Status: util.Iif(art.Status == actions_model.ArtifactStatusExpired, "expired", "completed"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func fillViewRunResponseCurrentJob(ctx *context_module.Context, resp *ViewResponse, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) {
|
||||
@@ -459,9 +529,9 @@ func fillViewRunResponseCurrentJob(ctx *context_module.Context, resp *ViewRespon
|
||||
}
|
||||
|
||||
var task *actions_model.ActionTask
|
||||
if current.TaskID > 0 {
|
||||
if effectiveTaskID := current.EffectiveTaskID(); effectiveTaskID > 0 {
|
||||
var err error
|
||||
task, err = actions_model.GetTaskByID(ctx, current.TaskID)
|
||||
task, err = actions_model.GetTaskByID(ctx, effectiveTaskID)
|
||||
if err != nil {
|
||||
ctx.ServerError("actions_model.GetTaskByID", err)
|
||||
return
|
||||
@@ -589,13 +659,24 @@ func checkRunRerunAllowed(ctx *context_module.Context, run *actions_model.Action
|
||||
return true
|
||||
}
|
||||
|
||||
func checkLatestAttempt(ctx *context_module.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt) bool {
|
||||
if attempt != nil && run.LatestAttemptID != attempt.ID {
|
||||
ctx.NotFound(nil)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Rerun will rerun jobs in the given run
|
||||
// If jobIDStr is a blank string, it means rerun all jobs
|
||||
func Rerun(ctx *context_module.Context) {
|
||||
run, jobs := getCurrentRunJobsByPathParam(ctx)
|
||||
run, attempt, jobs := getCurrentRunJobsByPathParam(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
if !checkLatestAttempt(ctx, run, attempt) {
|
||||
return
|
||||
}
|
||||
if !checkRunRerunAllowed(ctx, run) {
|
||||
return
|
||||
}
|
||||
@@ -608,35 +689,48 @@ func Rerun(ctx *context_module.Context) {
|
||||
|
||||
var jobsToRerun []*actions_model.ActionRunJob
|
||||
if currentJob != nil {
|
||||
jobsToRerun = actions_service.GetAllRerunJobs(currentJob, jobs)
|
||||
} else {
|
||||
jobsToRerun = jobs
|
||||
jobsToRerun = []*actions_model.ActionRunJob{currentJob}
|
||||
}
|
||||
|
||||
if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobsToRerun); err != nil {
|
||||
ctx.ServerError("RerunWorkflowRunJobs", err)
|
||||
if _, err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, ctx.Doer, jobsToRerun); err != nil {
|
||||
handleWorkflowRerunError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSONOK()
|
||||
ctx.JSONRedirect(run.Link())
|
||||
}
|
||||
|
||||
// RerunFailed reruns all failed jobs in the given run
|
||||
func RerunFailed(ctx *context_module.Context) {
|
||||
run, jobs := getCurrentRunJobsByPathParam(ctx)
|
||||
run, attempt, jobs := getCurrentRunJobsByPathParam(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
if !checkLatestAttempt(ctx, run, attempt) {
|
||||
return
|
||||
}
|
||||
if !checkRunRerunAllowed(ctx, run) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, actions_service.GetFailedRerunJobs(jobs)); err != nil {
|
||||
ctx.ServerError("RerunWorkflowRunJobs", err)
|
||||
if _, err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, ctx.Doer, actions_service.GetFailedJobsForRerun(jobs)); err != nil {
|
||||
handleWorkflowRerunError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSONOK()
|
||||
ctx.JSONRedirect(run.Link())
|
||||
}
|
||||
|
||||
func handleWorkflowRerunError(ctx *context_module.Context, err error) {
|
||||
if errors.Is(err, util.ErrAlreadyExist) {
|
||||
ctx.JSON(http.StatusConflict, map[string]any{"message": err.Error()})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
ctx.JSON(http.StatusBadRequest, map[string]any{"message": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.ServerError("RerunWorkflowRunJobs", err)
|
||||
}
|
||||
|
||||
func Logs(ctx *context_module.Context) {
|
||||
@@ -654,10 +748,13 @@ func Logs(ctx *context_module.Context) {
|
||||
}
|
||||
|
||||
func Cancel(ctx *context_module.Context) {
|
||||
run, jobs := getCurrentRunJobsByPathParam(ctx)
|
||||
run, attempt, jobs := getCurrentRunJobsByPathParam(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
if !checkLatestAttempt(ctx, run, attempt) {
|
||||
return
|
||||
}
|
||||
|
||||
var updatedJobs []*actions_model.ActionRunJob
|
||||
|
||||
@@ -676,13 +773,9 @@ func Cancel(ctx *context_module.Context) {
|
||||
actions_service.CreateCommitStatusForRunJobs(ctx, run, jobs...)
|
||||
actions_service.EmitJobsIfReadyByJobs(updatedJobs)
|
||||
|
||||
for _, job := range updatedJobs {
|
||||
_ = job.LoadAttributes(ctx)
|
||||
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
|
||||
}
|
||||
actions_service.NotifyWorkflowJobsStatusUpdate(ctx, updatedJobs...)
|
||||
if len(updatedJobs) > 0 {
|
||||
job := updatedJobs[0]
|
||||
actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, job)
|
||||
actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, run.RepoID, run.ID)
|
||||
}
|
||||
ctx.JSONOK()
|
||||
}
|
||||
@@ -692,78 +785,14 @@ func Approve(ctx *context_module.Context) {
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
approveRuns(ctx, []int64{run.ID})
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSONOK()
|
||||
}
|
||||
|
||||
func approveRuns(ctx *context_module.Context, runIDs []int64) {
|
||||
doer := ctx.Doer
|
||||
repo := ctx.Repo.Repository
|
||||
|
||||
updatedJobs := make([]*actions_model.ActionRunJob, 0)
|
||||
runMap := make(map[int64]*actions_model.ActionRun, len(runIDs))
|
||||
runJobs := make(map[int64][]*actions_model.ActionRunJob, len(runIDs))
|
||||
|
||||
err := db.WithTx(ctx, func(ctx context.Context) (err error) {
|
||||
for _, runID := range runIDs {
|
||||
run, err := actions_model.GetRunByRepoAndID(ctx, repo.ID, runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runMap[run.ID] = run
|
||||
run.Repo = repo
|
||||
run.NeedApproval = false
|
||||
run.ApprovedBy = doer.ID
|
||||
if err := actions_model.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil {
|
||||
return err
|
||||
}
|
||||
jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runJobs[run.ID] = jobs
|
||||
for _, job := range jobs {
|
||||
job.Status, err = actions_service.PrepareToStartJobWithConcurrency(ctx, job)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if job.Status == actions_model.StatusWaiting {
|
||||
n, err := actions_model.UpdateRunJob(ctx, job, nil, "status")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
updatedJobs = append(updatedJobs, job)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError("approveRuns", func(err error) bool {
|
||||
if err := actions_service.ApproveRuns(ctx, ctx.Repo.Repository, ctx.Doer, []int64{run.ID}); err != nil {
|
||||
ctx.NotFoundOrServerError("ApproveRuns", func(err error) bool {
|
||||
return errors.Is(err, util.ErrNotExist)
|
||||
}, err)
|
||||
return
|
||||
}
|
||||
|
||||
for runID, run := range runMap {
|
||||
actions_service.CreateCommitStatusForRunJobs(ctx, run, runJobs[runID]...)
|
||||
}
|
||||
|
||||
if len(updatedJobs) > 0 {
|
||||
job := updatedJobs[0]
|
||||
actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, job)
|
||||
}
|
||||
|
||||
for _, job := range updatedJobs {
|
||||
_ = job.LoadAttributes(ctx)
|
||||
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
|
||||
}
|
||||
ctx.JSONOK()
|
||||
}
|
||||
|
||||
func Delete(ctx *context_module.Context) {
|
||||
@@ -785,28 +814,108 @@ func Delete(ctx *context_module.Context) {
|
||||
ctx.JSONOK()
|
||||
}
|
||||
|
||||
// getRunJobs loads the run and its jobs for runID
|
||||
func getRunViewLink(run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt) string {
|
||||
if attempt == nil || run.LatestAttemptID == attempt.ID {
|
||||
return run.Link()
|
||||
}
|
||||
return fmt.Sprintf("%s/attempts/%d", run.Link(), attempt.Attempt)
|
||||
}
|
||||
|
||||
// getCurrentRunJobsByPathParam resolves the current run view context from path parameters, including the run, optional attempt, and jobs to render.
|
||||
// Any error will be written to the ctx, empty jobs will also result in 404 error, then the return values are all nil.
|
||||
func getCurrentRunJobsByPathParam(ctx *context_module.Context) (*actions_model.ActionRun, []*actions_model.ActionRunJob) {
|
||||
func getCurrentRunJobsByPathParam(ctx *context_module.Context) (*actions_model.ActionRun, *actions_model.ActionRunAttempt, []*actions_model.ActionRunJob) {
|
||||
run := getCurrentRunByPathParam(ctx)
|
||||
if ctx.Written() {
|
||||
return nil, nil
|
||||
return nil, nil, nil
|
||||
}
|
||||
run.Repo = ctx.Repo.Repository
|
||||
jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID)
|
||||
|
||||
var err error
|
||||
var selectedJob *actions_model.ActionRunJob
|
||||
if ctx.PathParam("job") != "" {
|
||||
jobID := ctx.PathParamInt64("job")
|
||||
selectedJob, err = actions_model.GetRunJobByRunAndID(ctx, run.ID, jobID)
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError("GetRunJobByRepoAndID", func(err error) bool {
|
||||
return errors.Is(err, util.ErrNotExist)
|
||||
}, err)
|
||||
return nil, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the attempt to display.
|
||||
// Priority: explicit path param (/attempts/:num) > job's attempt (when navigating to a specific job) > latest attempt.
|
||||
// attempt may be nil for legacy runs that pre-date ActionRunAttempt; callers must handle that case.
|
||||
attemptNum := ctx.PathParamInt64("attempt")
|
||||
var attempt *actions_model.ActionRunAttempt
|
||||
switch {
|
||||
case attemptNum > 0:
|
||||
// Explicit attempt number in the URL — user is viewing a historical attempt.
|
||||
attempt, err = actions_model.GetRunAttemptByRunIDAndAttemptNum(ctx, run.ID, attemptNum)
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError("GetRunAttemptByRunIDAndAttempt", func(err error) bool {
|
||||
return errors.Is(err, util.ErrNotExist)
|
||||
}, err)
|
||||
return nil, nil, nil
|
||||
}
|
||||
case selectedJob != nil && selectedJob.RunAttemptID > 0:
|
||||
// No explicit attempt in the URL, but the requested job belongs to a known attempt — resolve via the job.
|
||||
attempt, err = actions_model.GetRunAttemptByRepoAndID(ctx, selectedJob.RepoID, selectedJob.RunAttemptID)
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError("GetRunAttemptByRepoAndID", func(err error) bool {
|
||||
return errors.Is(err, util.ErrNotExist)
|
||||
}, err)
|
||||
return nil, nil, nil
|
||||
}
|
||||
default:
|
||||
// No attempt context at all — show the latest attempt (nil for legacy runs).
|
||||
attempt, _, err = run.GetLatestAttempt(ctx)
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError("GetLatestAttempt", func(err error) bool {
|
||||
return errors.Is(err, util.ErrNotExist)
|
||||
}, err)
|
||||
return nil, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the jobs for the resolved attempt.
|
||||
// When attempt is nil (legacy run or legacy job), jobs are stored with run_attempt_id=0.
|
||||
var resolvedAttemptID int64
|
||||
if attempt != nil {
|
||||
resolvedAttemptID = attempt.ID
|
||||
}
|
||||
jobs, err := actions_model.GetRunJobsByRunAndAttemptID(ctx, run.ID, resolvedAttemptID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetRunJobsByRunID", err)
|
||||
return nil, nil
|
||||
ctx.ServerError("get current jobs", err)
|
||||
return nil, nil, nil
|
||||
}
|
||||
if len(jobs) == 0 {
|
||||
ctx.NotFound(nil)
|
||||
return nil, nil
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
for _, job := range jobs {
|
||||
job.Run = run
|
||||
}
|
||||
return run, jobs
|
||||
return run, attempt, jobs
|
||||
}
|
||||
|
||||
// resolveArtifactAttemptIDFromQuery resolves the run_attempt_id used to scope artifact lookups.
|
||||
// If the `attempt` query parameter is present and valid, it returns the matching attempt's ID.
|
||||
// Otherwise it falls back to run.LatestAttemptID, which is 0 only for legacy runs created before ActionRunAttempt existed.
|
||||
func resolveArtifactAttemptIDFromQuery(ctx *context_module.Context, run *actions_model.ActionRun) (int64, error) {
|
||||
if ctx.FormString("attempt") == "" {
|
||||
return run.LatestAttemptID, nil
|
||||
}
|
||||
attemptNum := ctx.FormInt64("attempt")
|
||||
if attemptNum <= 0 {
|
||||
return 0, util.ErrNotExist
|
||||
}
|
||||
attempt, err := actions_model.GetRunAttemptByRunIDAndAttemptNum(ctx, run.ID, attemptNum)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return attempt.ID, nil
|
||||
}
|
||||
|
||||
func ArtifactsDeleteView(ctx *context_module.Context) {
|
||||
@@ -814,9 +923,16 @@ func ArtifactsDeleteView(ctx *context_module.Context) {
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
resolvedAttemptID, err := resolveArtifactAttemptIDFromQuery(ctx, run)
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError("resolveArtifactAttemptIDFromQuery", func(err error) bool {
|
||||
return errors.Is(err, util.ErrNotExist)
|
||||
}, err)
|
||||
return
|
||||
}
|
||||
artifactName := ctx.PathParam("artifact_name")
|
||||
if err := actions_model.SetArtifactNeedDelete(ctx, run.ID, artifactName); err != nil {
|
||||
ctx.ServerError("SetArtifactNeedDelete", err)
|
||||
if err := actions_model.SetArtifactNeedDeleteByRunAttempt(ctx, run.ID, resolvedAttemptID, artifactName); err != nil {
|
||||
ctx.ServerError("SetArtifactNeedDeleteByRunAttempt", err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, struct{}{})
|
||||
@@ -827,14 +943,17 @@ func ArtifactsDownloadView(ctx *context_module.Context) {
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
artifactName := ctx.PathParam("artifact_name")
|
||||
artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{
|
||||
RunID: run.ID,
|
||||
ArtifactName: artifactName,
|
||||
})
|
||||
resolvedAttemptID, err := resolveArtifactAttemptIDFromQuery(ctx, run)
|
||||
if err != nil {
|
||||
ctx.ServerError("FindArtifacts", err)
|
||||
ctx.NotFoundOrServerError("resolveArtifactAttemptIDFromQuery", func(err error) bool {
|
||||
return errors.Is(err, util.ErrNotExist)
|
||||
}, err)
|
||||
return
|
||||
}
|
||||
artifactName := ctx.PathParam("artifact_name")
|
||||
artifacts, err := actions_model.GetArtifactsByRunAttemptAndName(ctx, run.ID, resolvedAttemptID, artifactName)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetArtifactsByRunAttemptAndName", err)
|
||||
return
|
||||
}
|
||||
if len(artifacts) == 0 {
|
||||
@@ -931,8 +1050,10 @@ func ApproveAllChecks(ctx *context_module.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
approveRuns(ctx, runIDs)
|
||||
if ctx.Written() {
|
||||
if err := actions_service.ApproveRuns(ctx, repo, ctx.Doer, runIDs); err != nil {
|
||||
ctx.NotFoundOrServerError("ApproveRuns", func(err error) bool {
|
||||
return errors.Is(err, util.ErrNotExist)
|
||||
}, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1539,6 +1539,11 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Combo("").
|
||||
Get(actions.View).
|
||||
Post(web.Bind(actions.ViewRequest{}), actions.ViewPost)
|
||||
m.Group("/attempts/{attempt}", func() {
|
||||
m.Combo("").
|
||||
Get(actions.View).
|
||||
Post(web.Bind(actions.ViewRequest{}), actions.ViewPost)
|
||||
})
|
||||
m.Group("/jobs/{job}", func() {
|
||||
m.Combo("").
|
||||
Get(actions.View).
|
||||
@@ -1754,8 +1759,10 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Any("/mail-preview/*", devtest.MailPreviewRender)
|
||||
m.Any("/{sub}", devtest.TmplCommon)
|
||||
m.Get("/repo-action-view/runs/{run}", devtest.MockActionsView)
|
||||
m.Get("/repo-action-view/runs/{run}/attempts/{attempt}", devtest.MockActionsView)
|
||||
m.Get("/repo-action-view/runs/{run}/jobs/{job}", devtest.MockActionsView)
|
||||
m.Post("/repo-action-view/runs/{run}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs)
|
||||
m.Post("/repo-action-view/runs/{run}/attempts/{attempt}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs)
|
||||
m.Post("/repo-action-view/runs/{run}/jobs/{job}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user