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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user