0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-03-26 02:58:51 +01:00

Feature: Add button to re-run failed jobs in Actions (#36924)

Fixes #35997

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
bircni 2026-03-21 22:27:13 +01:00 committed by GitHub
parent ee009ebec8
commit b22123ef86
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 332 additions and 45 deletions

View File

@ -81,6 +81,7 @@
"retry": "Retry",
"rerun": "Re-run",
"rerun_all": "Re-run all jobs",
"rerun_failed": "Re-run failed jobs",
"save": "Save",
"add": "Add",
"add_all": "Add All",

View File

@ -1259,6 +1259,7 @@ func Routes() *web.Router {
m.Get("", repo.GetWorkflowRun)
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)
m.Get("/jobs", repo.ListWorkflowRunJobs)
m.Post("/jobs/{job_id}/rerun", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunWorkflowJob)
m.Get("/artifacts", repo.GetArtifactsOfRun)

View File

@ -1255,7 +1255,7 @@ func RerunWorkflowRun(ctx *context.APIContext) {
return
}
if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobs, nil); err != nil {
if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobs); err != nil {
handleWorkflowRerunError(ctx, err)
return
}
@ -1268,6 +1268,52 @@ func RerunWorkflowRun(ctx *context.APIContext) {
ctx.JSON(http.StatusCreated, convertedRun)
}
// RerunFailedWorkflowRun Reruns all failed jobs in a workflow run.
func RerunFailedWorkflowRun(ctx *context.APIContext) {
// swagger:operation POST /repos/{owner}/{repo}/actions/runs/{run}/rerun-failed-jobs repository rerunFailedWorkflowRun
// ---
// summary: Reruns all failed jobs in a workflow run
// 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
// responses:
// "201":
// "$ref": "#/responses/empty"
// "400":
// "$ref": "#/responses/error"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
run, jobs := getCurrentRepoActionRunJobsByID(ctx)
if ctx.Written() {
return
}
if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, actions_service.GetFailedRerunJobs(jobs)); err != nil {
handleWorkflowRerunError(ctx, err)
return
}
ctx.Status(http.StatusCreated)
}
// RerunWorkflowJob Reruns a specific workflow job in a run.
func RerunWorkflowJob(ctx *context.APIContext) {
// swagger:operation POST /repos/{owner}/{repo}/actions/runs/{run}/jobs/{job_id}/rerun repository rerunWorkflowJob
@ -1321,7 +1367,7 @@ func RerunWorkflowJob(ctx *context.APIContext) {
}
targetJob := jobs[jobIdx]
if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobs, targetJob); err != nil {
if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, actions_service.GetAllRerunJobs(targetJob, jobs)); err != nil {
handleWorkflowRerunError(ctx, err)
return
}

View File

@ -75,6 +75,7 @@ func MockActionsRunsJobs(ctx *context.Context) {
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"

View File

@ -122,6 +122,7 @@ type ViewResponse struct {
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"`
@ -238,6 +239,14 @@ func ViewPost(ctx *context_module.Context) {
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)
if resp.State.Run.CanRerun {
for _, job := range jobs {
if job.Status == actions_model.StatusFailure || job.Status == actions_model.StatusCancelled {
resp.State.Run.CanRerunFailed = true
break
}
}
}
resp.State.Run.Done = run.Status.IsDone()
resp.State.Run.WorkflowID = run.WorkflowID
resp.State.Run.WorkflowLink = run.WorkflowLink()
@ -398,6 +407,22 @@ func convertToViewModel(ctx context.Context, locale translation.Locale, cursors
return viewJobs, logs, nil
}
// checkRunRerunAllowed checks whether a rerun is permitted for the given run,
// writing the appropriate JSON error to ctx and returning false when it is not.
func checkRunRerunAllowed(ctx *context_module.Context, run *actions_model.ActionRun) bool {
if !run.Status.IsDone() {
ctx.JSONError(ctx.Locale.Tr("actions.runs.not_done"))
return false
}
cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions)
cfg := cfgUnit.ActionsConfig()
if cfg.IsWorkflowDisabled(run.WorkflowID) {
ctx.JSONError(ctx.Locale.Tr("actions.workflow.disabled"))
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) {
@ -408,26 +433,39 @@ func Rerun(ctx *context_module.Context) {
return
}
// rerun is not allowed if the run is not done
if !run.Status.IsDone() {
ctx.JSONError(ctx.Locale.Tr("actions.runs.not_done"))
if !checkRunRerunAllowed(ctx, run) {
return
}
// can not rerun job when workflow is disabled
cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions)
cfg := cfgUnit.ActionsConfig()
if cfg.IsWorkflowDisabled(run.WorkflowID) {
ctx.JSONError(ctx.Locale.Tr("actions.workflow.disabled"))
return
}
var targetJob *actions_model.ActionRunJob // nil means rerun all jobs
var jobsToRerun []*actions_model.ActionRunJob
if ctx.PathParam("job") != "" {
targetJob = currentJob
jobsToRerun = actions_service.GetAllRerunJobs(currentJob, jobs)
} else {
jobsToRerun = jobs
}
if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobs, targetJob); err != nil {
if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobsToRerun); err != nil {
ctx.ServerError("RerunWorkflowRunJobs", err)
return
}
ctx.JSONOK()
}
// RerunFailed reruns all failed jobs in the given run
func RerunFailed(ctx *context_module.Context) {
runID := getRunID(ctx)
run, jobs, _ := getRunJobsAndCurrentJob(ctx, runID)
if ctx.Written() {
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)
return
}

View File

@ -1529,6 +1529,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Get("/artifacts/{artifact_name}", actions.ArtifactsDownloadView)
m.Delete("/artifacts/{artifact_name}", reqRepoActionsWriter, actions.ArtifactsDeleteView)
m.Post("/rerun", reqRepoActionsWriter, actions.Rerun)
m.Post("/rerun-failed", reqRepoActionsWriter, actions.RerunFailed)
})
m.Group("/workflows/{workflow_name}", func() {
m.Get("/badge.svg", webAuth.AllowBasic, webAuth.AllowOAuth2, actions.GetWorkflowBadge)

View File

@ -20,7 +20,27 @@ import (
"xorm.io/builder"
)
// GetAllRerunJobs get all jobs that need to be rerun when job should be rerun
// GetFailedRerunJobs returns all failed jobs and their downstream dependent jobs that need to be rerun
func GetFailedRerunJobs(allJobs []*actions_model.ActionRunJob) []*actions_model.ActionRunJob {
rerunJobIDSet := make(container.Set[int64])
var jobsToRerun []*actions_model.ActionRunJob
for _, job := range allJobs {
if job.Status == actions_model.StatusFailure || job.Status == actions_model.StatusCancelled {
for _, j := range GetAllRerunJobs(job, allJobs) {
if !rerunJobIDSet.Contains(j.ID) {
rerunJobIDSet.Add(j.ID)
jobsToRerun = append(jobsToRerun, j)
}
}
}
}
return jobsToRerun
}
// GetAllRerunJobs returns the target job and all jobs that transitively depend on it.
// Downstream jobs are included regardless of their current status.
func GetAllRerunJobs(job *actions_model.ActionRunJob, allJobs []*actions_model.ActionRunJob) []*actions_model.ActionRunJob {
rerunJobs := []*actions_model.ActionRunJob{job}
rerunJobsIDSet := make(container.Set[string])
@ -49,12 +69,12 @@ func GetAllRerunJobs(job *actions_model.ActionRunJob, allJobs []*actions_model.A
return rerunJobs
}
// RerunWorkflowRunJobs reruns all done jobs of a workflow run,
// or reruns a selected job and all of its downstream jobs when targetJob is specified.
func RerunWorkflowRunJobs(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob, targetJob *actions_model.ActionRunJob) error {
// Rerun is not allowed if the run is not done.
// prepareRunRerun validates the run, resets its state, handles concurrency, persists the
// updated run, and fires a status-update notification.
// It returns isRunBlocked (true when the run itself is held by a concurrency group).
func prepareRunRerun(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) (isRunBlocked bool, err error) {
if !run.Status.IsDone() {
return util.NewInvalidArgumentErrorf("this workflow run is not done")
return false, util.NewInvalidArgumentErrorf("this workflow run is not done")
}
cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions)
@ -62,7 +82,7 @@ func RerunWorkflowRunJobs(ctx context.Context, repo *repo_model.Repository, run
// Rerun is not allowed when workflow is disabled.
cfg := cfgUnit.ActionsConfig()
if cfg.IsWorkflowDisabled(run.WorkflowID) {
return util.NewInvalidArgumentErrorf("workflow %s is disabled", run.WorkflowID)
return false, util.NewInvalidArgumentErrorf("workflow %s is disabled", run.WorkflowID)
}
// Reset run's timestamps and status.
@ -73,31 +93,31 @@ func RerunWorkflowRunJobs(ctx context.Context, repo *repo_model.Repository, run
vars, err := actions_model.GetVariablesOfRun(ctx, run)
if err != nil {
return fmt.Errorf("get run %d variables: %w", run.ID, err)
return false, fmt.Errorf("get run %d variables: %w", run.ID, err)
}
if run.RawConcurrency != "" {
var rawConcurrency model.RawConcurrency
if err := yaml.Unmarshal([]byte(run.RawConcurrency), &rawConcurrency); err != nil {
return fmt.Errorf("unmarshal raw concurrency: %w", err)
return false, fmt.Errorf("unmarshal raw concurrency: %w", err)
}
if err := EvaluateRunConcurrencyFillModel(ctx, run, &rawConcurrency, vars, nil); err != nil {
return err
return false, err
}
run.Status, err = PrepareToStartRunWithConcurrency(ctx, run)
if err != nil {
return err
return false, err
}
}
if err := actions_model.UpdateRun(ctx, run, "started", "stopped", "previous_duration", "status", "concurrency_group", "concurrency_cancel"); err != nil {
return err
return false, err
}
if err := run.LoadAttributes(ctx); err != nil {
return err
return false, err
}
for _, job := range jobs {
@ -106,23 +126,38 @@ func RerunWorkflowRunJobs(ctx context.Context, repo *repo_model.Repository, run
notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, run.TriggerUser, run)
isRunBlocked := run.Status == actions_model.StatusBlocked
return run.Status == actions_model.StatusBlocked, nil
}
if targetJob == nil {
for _, job := range jobs {
// If the job has needs, it should be blocked to wait for its dependencies.
shouldBlockJob := len(job.Needs) > 0 || isRunBlocked
if err := rerunWorkflowJob(ctx, job, shouldBlockJob); err != nil {
return err
}
}
// RerunWorkflowRunJobs reruns the given jobs of a workflow run.
// jobsToRerun must include all jobs to be rerun (the target job and its transitively dependent jobs).
// A job is blocked (waiting for dependencies) if the run itself is blocked or if any of its
// needs are also being rerun.
func RerunWorkflowRunJobs(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, jobsToRerun []*actions_model.ActionRunJob) error {
if len(jobsToRerun) == 0 {
return nil
}
rerunJobs := GetAllRerunJobs(targetJob, jobs)
for _, job := range rerunJobs {
// Jobs other than the selected one should wait for dependencies.
shouldBlockJob := job.JobID != targetJob.JobID || isRunBlocked
isRunBlocked, err := prepareRunRerun(ctx, repo, run, jobsToRerun)
if err != nil {
return err
}
rerunJobIDs := make(container.Set[string])
for _, j := range jobsToRerun {
rerunJobIDs.Add(j.JobID)
}
for _, job := range jobsToRerun {
shouldBlockJob := isRunBlocked
if !shouldBlockJob {
for _, need := range job.Needs {
if rerunJobIDs.Contains(need) {
shouldBlockJob = true
break
}
}
}
if err := rerunWorkflowJob(ctx, job, shouldBlockJob); err != nil {
return err
}

View File

@ -4,11 +4,14 @@
package actions
import (
"context"
"testing"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/modules/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetAllRerunJobs(t *testing.T) {
@ -46,3 +49,97 @@ func TestGetAllRerunJobs(t *testing.T) {
assert.ElementsMatch(t, tc.rerunJobs, rerunJobs)
}
}
func TestGetFailedRerunJobs(t *testing.T) {
// IDs must be non-zero to distinguish jobs in the dedup set.
makeJob := func(id int64, jobID string, status actions_model.Status, needs ...string) *actions_model.ActionRunJob {
return &actions_model.ActionRunJob{ID: id, JobID: jobID, Status: status, Needs: needs}
}
t.Run("no failed jobs returns empty", func(t *testing.T) {
jobs := []*actions_model.ActionRunJob{
makeJob(1, "job1", actions_model.StatusSuccess),
makeJob(2, "job2", actions_model.StatusSkipped, "job1"),
}
assert.Empty(t, GetFailedRerunJobs(jobs))
})
t.Run("single failed job with no dependents", func(t *testing.T) {
job1 := makeJob(1, "job1", actions_model.StatusFailure)
job2 := makeJob(2, "job2", actions_model.StatusSuccess)
jobs := []*actions_model.ActionRunJob{job1, job2}
result := GetFailedRerunJobs(jobs)
assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1}, result)
})
t.Run("failed job pulls in downstream dependents", func(t *testing.T) {
// job1 failed; job2 depends on job1 (skipped); job3 depends on job2 (skipped)
job1 := makeJob(1, "job1", actions_model.StatusFailure)
job2 := makeJob(2, "job2", actions_model.StatusSkipped, "job1")
job3 := makeJob(3, "job3", actions_model.StatusSkipped, "job2")
job4 := makeJob(4, "job4", actions_model.StatusSuccess) // unrelated, must not appear
jobs := []*actions_model.ActionRunJob{job1, job2, job3, job4}
result := GetFailedRerunJobs(jobs)
assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2, job3}, result)
})
t.Run("multiple independent failed jobs each pull in their own dependents", func(t *testing.T) {
// job1 failed -> job3 depends on job1
// job2 failed -> job4 depends on job2
job1 := makeJob(1, "job1", actions_model.StatusFailure)
job2 := makeJob(2, "job2", actions_model.StatusFailure)
job3 := makeJob(3, "job3", actions_model.StatusSkipped, "job1")
job4 := makeJob(4, "job4", actions_model.StatusSkipped, "job2")
jobs := []*actions_model.ActionRunJob{job1, job2, job3, job4}
result := GetFailedRerunJobs(jobs)
assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2, job3, job4}, result)
})
t.Run("shared downstream dependent is not duplicated", func(t *testing.T) {
// job1 and job2 both failed; job3 depends on both
job1 := makeJob(1, "job1", actions_model.StatusFailure)
job2 := makeJob(2, "job2", actions_model.StatusFailure)
job3 := makeJob(3, "job3", actions_model.StatusSkipped, "job1", "job2")
jobs := []*actions_model.ActionRunJob{job1, job2, job3}
result := GetFailedRerunJobs(jobs)
assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2, job3}, result)
assert.Len(t, result, 3) // job3 must appear exactly once
})
t.Run("successful downstream job of a failed job is still included", func(t *testing.T) {
// job1 failed; job2 succeeded but depends on job1 — downstream is always rerun
// regardless of its own status (GetAllRerunJobs includes all transitive dependents)
job1 := makeJob(1, "job1", actions_model.StatusFailure)
job2 := makeJob(2, "job2", actions_model.StatusSuccess, "job1")
jobs := []*actions_model.ActionRunJob{job1, job2}
result := GetFailedRerunJobs(jobs)
assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2}, result)
})
}
func TestRerunValidation(t *testing.T) {
runningRun := &actions_model.ActionRun{Status: actions_model.StatusRunning}
t.Run("RerunWorkflowRunJobs rejects a non-done run", func(t *testing.T) {
jobs := []*actions_model.ActionRunJob{
{ID: 1, JobID: "job1"},
}
err := RerunWorkflowRunJobs(context.Background(), nil, runningRun, jobs)
require.Error(t, err)
assert.ErrorIs(t, err, util.ErrInvalidArgument)
})
t.Run("RerunWorkflowRunJobs rejects a non-done run when failed jobs exist", func(t *testing.T) {
jobs := []*actions_model.ActionRunJob{
{ID: 1, JobID: "job1", Status: actions_model.StatusFailure},
}
err := RerunWorkflowRunJobs(context.Background(), nil, runningRun, GetFailedRerunJobs(jobs))
require.Error(t, err)
assert.ErrorIs(t, err, util.ErrInvalidArgument)
})
}

View File

@ -7,6 +7,7 @@
data-locale-cancel="{{ctx.Locale.Tr "actions.runs.cancel"}}"
data-locale-rerun="{{ctx.Locale.Tr "rerun"}}"
data-locale-rerun-all="{{ctx.Locale.Tr "rerun_all"}}"
data-locale-rerun-failed="{{ctx.Locale.Tr "rerun_failed"}}"
data-locale-runs-scheduled="{{ctx.Locale.Tr "actions.runs.scheduled"}}"
data-locale-runs-commit="{{ctx.Locale.Tr "actions.runs.commit"}}"
data-locale-runs-pushed-by="{{ctx.Locale.Tr "actions.runs.pushed_by"}}"

View File

@ -5578,6 +5578,55 @@
}
}
},
"/repos/{owner}/{repo}/actions/runs/{run}/rerun-failed-jobs": {
"post": {
"tags": [
"repository"
],
"summary": "Reruns all failed jobs in a workflow run",
"operationId": "rerunFailedWorkflowRun",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repository",
"name": "repo",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "id of the run",
"name": "run",
"in": "path",
"required": true
}
],
"responses": {
"201": {
"$ref": "#/responses/empty"
},
"400": {
"$ref": "#/responses/error"
},
"403": {
"$ref": "#/responses/forbidden"
},
"404": {
"$ref": "#/responses/notFound"
},
"422": {
"$ref": "#/responses/validationError"
}
}
}
},
"/repos/{owner}/{repo}/actions/secrets": {
"get": {
"produces": [

View File

@ -147,6 +147,7 @@ export default defineComponent({
canCancel: false,
canApprove: false,
canRerun: false,
canRerunFailed: false,
canDeleteArtifact: false,
done: false,
workflowID: '',
@ -512,9 +513,24 @@ export default defineComponent({
<button class="ui basic small compact button red" @click="cancelRun()" v-else-if="run.canCancel">
{{ locale.cancel }}
</button>
<button class="ui basic small compact button link-action" :data-url="`${run.link}/rerun`" v-else-if="run.canRerun">
{{ locale.rerun_all }}
</button>
<template v-else-if="run.canRerun">
<div v-if="run.canRerunFailed" class="ui small compact buttons">
<button class="ui basic small compact button link-action" :data-url="`${run.link}/rerun-failed`">
{{ locale.rerun_failed }}
</button>
<div class="ui basic small compact dropdown icon button">
<SvgIcon name="octicon-triangle-down" :size="14"/>
<div class="menu">
<div class="item link-action" :data-url="`${run.link}/rerun`">
{{ locale.rerun_all }}
</div>
</div>
</div>
</div>
<button v-else class="ui basic small compact button link-action" :data-url="`${run.link}/rerun`">
{{ locale.rerun_all }}
</button>
</template>
</div>
</div>
<div class="action-commit-summary">

View File

@ -19,6 +19,7 @@ export function initRepositoryActionView() {
cancel: el.getAttribute('data-locale-cancel'),
rerun: el.getAttribute('data-locale-rerun'),
rerun_all: el.getAttribute('data-locale-rerun-all'),
rerun_failed: el.getAttribute('data-locale-rerun-failed'),
scheduled: el.getAttribute('data-locale-runs-scheduled'),
commit: el.getAttribute('data-locale-runs-commit'),
pushedBy: el.getAttribute('data-locale-runs-pushed-by'),