From dd1e18771ff351172eda1dd76145677121cdae0e Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sat, 28 Feb 2026 15:08:02 +0100 Subject: [PATCH 01/10] feat: Add Actions API rerun endpoints for runs and jobs --- routers/api/v1/api.go | 2 + routers/api/v1/repo/action.go | 154 ++++++++++++++++++++++ routers/web/repo/actions/view.go | 125 ++---------------- services/actions/rerun.go | 152 +++++++++++++++++++++ templates/swagger/v1_json.tmpl | 111 ++++++++++++++++ tests/integration/api_actions_run_test.go | 127 ++++++++++++++++++ 6 files changed, 559 insertions(+), 112 deletions(-) diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 359d5af4c4bc4..fcc6d1d72faa5 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1271,7 +1271,9 @@ func Routes() *web.Router { m.Group("/{run}", func() { m.Get("", repo.GetWorkflowRun) m.Delete("", reqToken(), reqRepoWriter(unit.TypeActions), repo.DeleteActionRun) + m.Post("/rerun", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunWorkflowRun) m.Get("/jobs", repo.ListWorkflowRunJobs) + m.Post("/jobs/{job_id}/rerun", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunWorkflowJob) m.Get("/artifacts", repo.GetArtifactsOfRun) }) }) diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 03ce0d3aab4a4..1a982fa6ca15a 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -1158,6 +1158,160 @@ func GetWorkflowRun(ctx *context.APIContext) { ctx.JSON(http.StatusOK, convertedRun) } +// RerunWorkflowRun Reruns an entire workflow run. +func RerunWorkflowRun(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/actions/runs/{run}/rerun repository rerunWorkflowRun + // --- + // summary: Reruns an entire workflow run + // 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 + // responses: + // "201": + // "$ref": "#/responses/WorkflowRun" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + runID := ctx.PathParamInt64("run") + run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID) + if errors.Is(err, util.ErrNotExist) { + ctx.APIErrorNotFound(err) + return + } else if err != nil { + ctx.APIErrorInternal(err) + return + } + + jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobs, nil); err != nil { + handleWorkflowRerunError(ctx, err) + return + } + + convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, run) + if err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.JSON(http.StatusCreated, convertedRun) +} + +// 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 + // --- + // summary: Reruns a specific workflow job in a run + // 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: job_id + // in: path + // description: id of the job + // type: integer + // required: true + // responses: + // "201": + // "$ref": "#/responses/WorkflowJob" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + runID := ctx.PathParamInt64("run") + run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID) + if errors.Is(err, util.ErrNotExist) { + ctx.APIErrorNotFound(err) + return + } else if err != nil { + ctx.APIErrorInternal(err) + return + } + + jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + jobID := ctx.PathParamInt64("job_id") + var targetJob *actions_model.ActionRunJob + for _, job := range jobs { + if job.ID == jobID { + targetJob = job + break + } + } + if targetJob == nil { + ctx.APIErrorNotFound(util.NewNotExistErrorf("workflow job with id %d", jobID)) + return + } + + if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobs, targetJob); err != nil { + handleWorkflowRerunError(ctx, err) + return + } + + convertedJob, err := convert.ToActionWorkflowJob(ctx, ctx.Repo.Repository, nil, targetJob) + if err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.JSON(http.StatusCreated, convertedJob) +} + +func handleWorkflowRerunError(ctx *context.APIContext, err error) { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.APIError(http.StatusBadRequest, err) + return + } + ctx.APIErrorInternal(err) +} + // ListWorkflowRunJobs Lists all jobs for a workflow run. func ListWorkflowRunJobs(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run}/jobs repository listWorkflowRunJobs diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 33c1e73aa43e1..1ee64242931a1 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -36,8 +36,6 @@ import ( notify_service "code.gitea.io/gitea/services/notify" "github.com/nektos/act/pkg/model" - "go.yaml.in/yaml/v4" - "xorm.io/builder" ) func getRunIndex(ctx *context_module.Context) int64 { @@ -431,130 +429,33 @@ func Rerun(ctx *context_module.Context) { return } - // reset run's start and stop time - run.PreviousDuration = run.Duration() - run.Started = 0 - run.Stopped = 0 - run.Status = actions_model.StatusWaiting - - vars, err := actions_model.GetVariablesOfRun(ctx, run) + jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) if err != nil { - ctx.ServerError("GetVariablesOfRun", fmt.Errorf("get run %d variables: %w", run.ID, err)) - return - } - - if run.RawConcurrency != "" { - var rawConcurrency model.RawConcurrency - if err := yaml.Unmarshal([]byte(run.RawConcurrency), &rawConcurrency); err != nil { - ctx.ServerError("UnmarshalRawConcurrency", fmt.Errorf("unmarshal raw concurrency: %w", err)) - return - } - - err = actions_service.EvaluateRunConcurrencyFillModel(ctx, run, &rawConcurrency, vars, nil) - if err != nil { - ctx.ServerError("EvaluateRunConcurrencyFillModel", err) - return - } - - run.Status, err = actions_service.PrepareToStartRunWithConcurrency(ctx, run) - if err != nil { - ctx.ServerError("PrepareToStartRunWithConcurrency", err) - return - } - } - if err := actions_model.UpdateRun(ctx, run, "started", "stopped", "previous_duration", "status", "concurrency_group", "concurrency_cancel"); err != nil { - ctx.ServerError("UpdateRun", err) - return - } - - if err := run.LoadAttributes(ctx); err != nil { - ctx.ServerError("run.LoadAttributes", err) + ctx.ServerError("GetRunJobsByRunID", err) return } - notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, run.TriggerUser, run) - - job, jobs := getRunJobs(ctx, runIndex, jobIndex) - if ctx.Written() { + if len(jobs) == 0 { + ctx.NotFound(nil) return } - isRunBlocked := run.Status == actions_model.StatusBlocked - if jobIndexStr == "" { // rerun all jobs - for _, j := range jobs { - // if the job has needs, it should be set to "blocked" status to wait for other jobs - shouldBlockJob := len(j.Needs) > 0 || isRunBlocked - if err := rerunJob(ctx, j, shouldBlockJob); err != nil { - ctx.ServerError("RerunJob", err) - return - } + var targetJob *actions_model.ActionRunJob + if jobIndexStr != "" { + if jobIndex >= 0 && jobIndex < int64(len(jobs)) { + targetJob = jobs[jobIndex] + } else { + targetJob = jobs[0] } - ctx.JSONOK() - return } - rerunJobs := actions_service.GetAllRerunJobs(job, jobs) - - for _, j := range rerunJobs { - // jobs other than the specified one should be set to "blocked" status - shouldBlockJob := j.JobID != job.JobID || isRunBlocked - if err := rerunJob(ctx, j, shouldBlockJob); err != nil { - ctx.ServerError("RerunJob", err) - return - } + if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobs, targetJob); err != nil { + ctx.ServerError("RerunWorkflowRunJobs", err) + return } ctx.JSONOK() } -func rerunJob(ctx *context_module.Context, job *actions_model.ActionRunJob, shouldBlock bool) error { - status := job.Status - if !status.IsDone() { - return nil - } - - job.TaskID = 0 - job.Status = util.Iif(shouldBlock, actions_model.StatusBlocked, actions_model.StatusWaiting) - job.Started = 0 - job.Stopped = 0 - - job.ConcurrencyGroup = "" - job.ConcurrencyCancel = false - job.IsConcurrencyEvaluated = false - if err := job.LoadRun(ctx); err != nil { - return err - } - - vars, err := actions_model.GetVariablesOfRun(ctx, job.Run) - if err != nil { - return fmt.Errorf("get run %d variables: %w", job.Run.ID, err) - } - - if job.RawConcurrency != "" && !shouldBlock { - err = actions_service.EvaluateJobConcurrencyFillModel(ctx, job.Run, job, vars, nil) - if err != nil { - return fmt.Errorf("evaluate job concurrency: %w", err) - } - - job.Status, err = actions_service.PrepareToStartJobWithConcurrency(ctx, job) - if err != nil { - return err - } - } - - if err := db.WithTx(ctx, func(ctx context.Context) error { - updateCols := []string{"task_id", "status", "started", "stopped", "concurrency_group", "concurrency_cancel", "is_concurrency_evaluated"} - _, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"status": status}, updateCols...) - return err - }); err != nil { - return err - } - - actions_service.CreateCommitStatusForRunJobs(ctx, job.Run, job) - notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) - - return nil -} - func Logs(ctx *context_module.Context) { runIndex := getRunIndex(ctx) jobIndex := ctx.PathParamInt64("job") diff --git a/services/actions/rerun.go b/services/actions/rerun.go index 60f66509058f5..c052bb9e07c2a 100644 --- a/services/actions/rerun.go +++ b/services/actions/rerun.go @@ -4,8 +4,21 @@ package actions import ( + "context" + "errors" + "fmt" + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/util" + notify_service "code.gitea.io/gitea/services/notify" + + "github.com/nektos/act/pkg/model" + "go.yaml.in/yaml/v4" + "xorm.io/builder" ) // GetAllRerunJobs get all jobs that need to be rerun when job should be rerun @@ -36,3 +49,142 @@ 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. + if !run.Status.IsDone() { + return util.NewInvalidArgumentErrorf("this workflow run is not done") + } + + // Rerun is not allowed when workflow is disabled. + cfgUnit, err := repo.GetUnit(ctx, unit.TypeActions) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + // Keep behavior compatible with MustGetUnit: treat missing unit as default ActionsConfig. + cfgUnit = &repo_model.RepoUnit{ + Type: unit.TypeActions, + Config: new(repo_model.ActionsConfig), + } + } else { + return err + } + } + cfg := cfgUnit.ActionsConfig() + if cfg.IsWorkflowDisabled(run.WorkflowID) { + return util.NewInvalidArgumentErrorf("workflow %s is disabled", run.WorkflowID) + } + + // Reset run's timestamps and status. + run.PreviousDuration = run.Duration() + run.Started = 0 + run.Stopped = 0 + run.Status = actions_model.StatusWaiting + + vars, err := actions_model.GetVariablesOfRun(ctx, run) + if err != nil { + return 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) + } + + if err := EvaluateRunConcurrencyFillModel(ctx, run, &rawConcurrency, vars, nil); err != nil { + return err + } + + run.Status, err = PrepareToStartRunWithConcurrency(ctx, run) + if err != nil { + return err + } + } + + if err := actions_model.UpdateRun(ctx, run, "started", "stopped", "previous_duration", "status", "concurrency_group", "concurrency_cancel"); err != nil { + return err + } + + if err := run.LoadAttributes(ctx); err != nil { + return err + } + + for _, job := range jobs { + job.Run = run + } + + notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, run.TriggerUser, run) + + isRunBlocked := run.Status == actions_model.StatusBlocked + + 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 + } + } + 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 + if err := rerunWorkflowJob(ctx, job, shouldBlockJob); err != nil { + return err + } + } + + return nil +} + +func rerunWorkflowJob(ctx context.Context, job *actions_model.ActionRunJob, shouldBlock bool) error { + status := job.Status + if !status.IsDone() { + return nil + } + + job.TaskID = 0 + job.Status = util.Iif(shouldBlock, actions_model.StatusBlocked, actions_model.StatusWaiting) + job.Started = 0 + job.Stopped = 0 + job.ConcurrencyGroup = "" + job.ConcurrencyCancel = false + job.IsConcurrencyEvaluated = false + + if err := job.LoadRun(ctx); err != nil { + return err + } + + vars, err := actions_model.GetVariablesOfRun(ctx, job.Run) + if err != nil { + return fmt.Errorf("get run %d variables: %w", job.Run.ID, err) + } + + if job.RawConcurrency != "" && !shouldBlock { + if err := EvaluateJobConcurrencyFillModel(ctx, job.Run, job, vars, nil); err != nil { + return fmt.Errorf("evaluate job concurrency: %w", err) + } + + job.Status, err = PrepareToStartJobWithConcurrency(ctx, job) + if err != nil { + return err + } + } + + if err := db.WithTx(ctx, func(ctx context.Context) error { + updateCols := []string{"task_id", "status", "started", "stopped", "concurrency_group", "concurrency_cancel", "is_concurrency_evaluated"} + _, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"status": status}, updateCols...) + return err + }); err != nil { + return err + } + + CreateCommitStatusForRunJobs(ctx, job.Run, job) + notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) + return nil +} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 212046f8e66f6..d1ea40d6fc02b 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -5438,6 +5438,117 @@ } } }, + "/repos/{owner}/{repo}/actions/runs/{run}/jobs/{job_id}/rerun": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Reruns a specific workflow job in a run", + "operationId": "rerunWorkflowJob", + "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 + }, + { + "type": "integer", + "description": "id of the job", + "name": "job_id", + "in": "path", + "required": true + } + ], + "responses": { + "201": { + "$ref": "#/responses/WorkflowJob" + }, + "400": { + "$ref": "#/responses/error" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, + "/repos/{owner}/{repo}/actions/runs/{run}/rerun": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Reruns an entire workflow run", + "operationId": "rerunWorkflowRun", + "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/WorkflowRun" + }, + "400": { + "$ref": "#/responses/error" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, "/repos/{owner}/{repo}/actions/secrets": { "get": { "produces": [ diff --git a/tests/integration/api_actions_run_test.go b/tests/integration/api_actions_run_test.go index 4838409560290..aa64a7f6f1dcc 100644 --- a/tests/integration/api_actions_run_test.go +++ b/tests/integration/api_actions_run_test.go @@ -169,3 +169,130 @@ func testAPIActionsDeleteRunListTasks(t *testing.T, repo *repo_model.Repository, assert.Equal(t, expected, findTask1) assert.Equal(t, expected, findTask2) } + +func TestAPIActionsRerunWorkflowRun(t *testing.T) { + defer prepareTestEnvActionsArtifacts(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, user.Name) + + writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + readToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + + t.Run("Success", func(t *testing.T) { + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/rerun", repo.FullName())). + AddTokenAuth(writeToken) + resp := MakeRequest(t, req, http.StatusCreated) + + var rerunResp api.ActionWorkflowRun + err := json.Unmarshal(resp.Body.Bytes(), &rerunResp) + require.NoError(t, err) + assert.Equal(t, int64(795), rerunResp.ID) + assert.Equal(t, "queued", rerunResp.Status) + assert.Equal(t, "c2d72f548424103f01ee1dc02889c1e2bff816b0", rerunResp.HeadSha) + + run, err := actions_model.GetRunByRepoAndID(t.Context(), repo.ID, 795) + require.NoError(t, err) + assert.Equal(t, actions_model.StatusWaiting, run.Status) + assert.Equal(t, timeutil.TimeStamp(0), run.Started) + assert.Equal(t, timeutil.TimeStamp(0), run.Stopped) + + job198, err := actions_model.GetRunJobByID(t.Context(), 198) + require.NoError(t, err) + assert.Equal(t, actions_model.StatusWaiting, job198.Status) + assert.Equal(t, int64(0), job198.TaskID) + + job199, err := actions_model.GetRunJobByID(t.Context(), 199) + require.NoError(t, err) + assert.Equal(t, actions_model.StatusWaiting, job199.Status) + assert.Equal(t, int64(0), job199.TaskID) + }) + + t.Run("ForbiddenWithoutWriteScope", func(t *testing.T) { + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/rerun", repo.FullName())). + AddTokenAuth(readToken) + MakeRequest(t, req, http.StatusForbidden) + }) + + t.Run("NotFound", func(t *testing.T) { + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/999999/rerun", repo.FullName())). + AddTokenAuth(writeToken) + MakeRequest(t, req, http.StatusNotFound) + }) +} + +func TestAPIActionsRerunWorkflowRunNotDone(t *testing.T) { + defer prepareTestEnvActionsArtifacts(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, user.Name) + writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/793/rerun", repo.FullName())). + AddTokenAuth(writeToken) + MakeRequest(t, req, http.StatusBadRequest) +} + +func TestAPIActionsRerunWorkflowJob(t *testing.T) { + defer prepareTestEnvActionsArtifacts(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, user.Name) + + writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + readToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + + t.Run("Success", func(t *testing.T) { + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs/199/rerun", repo.FullName())). + AddTokenAuth(writeToken) + resp := MakeRequest(t, req, http.StatusCreated) + + var rerunResp api.ActionWorkflowJob + err := json.Unmarshal(resp.Body.Bytes(), &rerunResp) + require.NoError(t, err) + assert.Equal(t, int64(199), rerunResp.ID) + assert.Equal(t, "queued", rerunResp.Status) + + run, err := actions_model.GetRunByRepoAndID(t.Context(), repo.ID, 795) + require.NoError(t, err) + assert.Equal(t, actions_model.StatusWaiting, run.Status) + + job198, err := actions_model.GetRunJobByID(t.Context(), 198) + require.NoError(t, err) + assert.Equal(t, actions_model.StatusSuccess, job198.Status) + assert.Equal(t, int64(53), job198.TaskID) + + job199, err := actions_model.GetRunJobByID(t.Context(), 199) + require.NoError(t, err) + assert.Equal(t, actions_model.StatusWaiting, job199.Status) + assert.Equal(t, int64(0), job199.TaskID) + }) + + t.Run("ForbiddenWithoutWriteScope", func(t *testing.T) { + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs/199/rerun", repo.FullName())). + AddTokenAuth(readToken) + MakeRequest(t, req, http.StatusForbidden) + }) + + t.Run("NotFoundJob", func(t *testing.T) { + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs/999999/rerun", repo.FullName())). + AddTokenAuth(writeToken) + MakeRequest(t, req, http.StatusNotFound) + }) +} + +func TestAPIActionsRerunWorkflowJobRunNotDone(t *testing.T) { + defer prepareTestEnvActionsArtifacts(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, user.Name) + writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/793/jobs/194/rerun", repo.FullName())). + AddTokenAuth(writeToken) + MakeRequest(t, req, http.StatusBadRequest) +} From 074221c93fc053939d9b3207587d5b435ae0116a Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 2 Mar 2026 15:28:34 +0800 Subject: [PATCH 02/10] fix repo unit fallback --- models/repo/repo.go | 50 +++++++++++++-------------------------- models/repo/repo_unit.go | 2 ++ services/actions/rerun.go | 12 ++-------- 3 files changed, 21 insertions(+), 43 deletions(-) diff --git a/models/repo/repo.go b/models/repo/repo.go index 7b7f5adb41379..01235d7d9897d 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -422,52 +422,36 @@ func (repo *Repository) UnitEnabled(ctx context.Context, tp unit.Type) bool { return false } -// MustGetUnit always returns a RepoUnit object +// MustGetUnit always returns a RepoUnit object even if the unit doesn't exist (not enabled) func (repo *Repository) MustGetUnit(ctx context.Context, tp unit.Type) *RepoUnit { ru, err := repo.GetUnit(ctx, tp) - if err == nil { + if errors.Is(err, util.ErrNotExist) { return ru + } else if err != nil { + setting.PanicInDevOrTesting("Failed to get unit %q for repository %d: %v", tp, repo.ID, err) } - + ru = &RepoUnit{RepoID: repo.ID, Type: tp} switch tp { case unit.TypeExternalWiki: - return &RepoUnit{ - Type: tp, - Config: new(ExternalWikiConfig), - } + ru.Config = new(ExternalWikiConfig) case unit.TypeExternalTracker: - return &RepoUnit{ - Type: tp, - Config: new(ExternalTrackerConfig), - } + ru.Config = new(ExternalTrackerConfig) case unit.TypePullRequests: - return &RepoUnit{ - Type: tp, - Config: new(PullRequestsConfig), - } + ru.Config = new(PullRequestsConfig) case unit.TypeIssues: - return &RepoUnit{ - Type: tp, - Config: new(IssuesConfig), - } + ru.Config = new(IssuesConfig) case unit.TypeActions: - return &RepoUnit{ - Type: tp, - Config: new(ActionsConfig), - } + ru.Config = new(ActionsConfig) case unit.TypeProjects: - cfg := new(ProjectsConfig) - cfg.ProjectsMode = ProjectsModeNone - return &RepoUnit{ - Type: tp, - Config: cfg, - } + ru.Config = new(ProjectsConfig) + default: + panic("unknown unit type") } - - return &RepoUnit{ - Type: tp, - Config: new(UnitConfig), + err = ru.Config.FromDB([]byte("{}")) + if err != nil { + setting.PanicInDevOrTesting("Failed to load default config for unit %q of repository %d: %v", tp, repo.ID, err) } + return ru } // GetUnit returns a RepoUnit object diff --git a/models/repo/repo_unit.go b/models/repo/repo_unit.go index d03d5e1e6a4ab..1058a18a85f4a 100644 --- a/models/repo/repo_unit.go +++ b/models/repo/repo_unit.go @@ -241,6 +241,8 @@ type ProjectsConfig struct { // FromDB fills up a ProjectsConfig from serialized format. func (cfg *ProjectsConfig) FromDB(bs []byte) error { + // TODO: remove GetProjectsMode, only use ProjectsMode + cfg.ProjectsMode = ProjectsModeAll return json.UnmarshalHandleDoubleEncode(bs, &cfg) } diff --git a/services/actions/rerun.go b/services/actions/rerun.go index c052bb9e07c2a..cc6ce704a84a1 100644 --- a/services/actions/rerun.go +++ b/services/actions/rerun.go @@ -5,7 +5,6 @@ package actions import ( "context" - "errors" "fmt" actions_model "code.gitea.io/gitea/models/actions" @@ -61,16 +60,9 @@ func RerunWorkflowRunJobs(ctx context.Context, repo *repo_model.Repository, run // Rerun is not allowed when workflow is disabled. cfgUnit, err := repo.GetUnit(ctx, unit.TypeActions) if err != nil { - if errors.Is(err, util.ErrNotExist) { - // Keep behavior compatible with MustGetUnit: treat missing unit as default ActionsConfig. - cfgUnit = &repo_model.RepoUnit{ - Type: unit.TypeActions, - Config: new(repo_model.ActionsConfig), - } - } else { - return err - } + return err } + cfg := cfgUnit.ActionsConfig() if cfg.IsWorkflowDisabled(run.WorkflowID) { return util.NewInvalidArgumentErrorf("workflow %s is disabled", run.WorkflowID) From 6ad67278d39af745243f59869f7b268d044b25b7 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 2 Mar 2026 15:52:51 +0800 Subject: [PATCH 03/10] refactor --- models/repo/repo.go | 6 +- routers/api/v1/repo/action.go | 100 +++++++++++++------------------ routers/web/repo/actions/view.go | 17 ++---- 3 files changed, 51 insertions(+), 72 deletions(-) diff --git a/models/repo/repo.go b/models/repo/repo.go index 01235d7d9897d..73d05da35cdb4 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -428,7 +428,7 @@ func (repo *Repository) MustGetUnit(ctx context.Context, tp unit.Type) *RepoUnit if errors.Is(err, util.ErrNotExist) { return ru } else if err != nil { - setting.PanicInDevOrTesting("Failed to get unit %q for repository %d: %v", tp, repo.ID, err) + setting.PanicInDevOrTesting("Failed to get unit %v for repository %d: %v", tp, repo.ID, err) } ru = &RepoUnit{RepoID: repo.ID, Type: tp} switch tp { @@ -447,9 +447,9 @@ func (repo *Repository) MustGetUnit(ctx context.Context, tp unit.Type) *RepoUnit default: panic("unknown unit type") } - err = ru.Config.FromDB([]byte("{}")) + err = ru.Config.FromDB(nil) if err != nil { - setting.PanicInDevOrTesting("Failed to load default config for unit %q of repository %d: %v", tp, repo.ID, err) + setting.PanicInDevOrTesting("Failed to load default config for unit %v of repository %d: %v", tp, repo.ID, err) } return ru } diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 1a982fa6ca15a..cc6eda14721fc 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -12,6 +12,7 @@ import ( "fmt" "net/http" "net/url" + "slices" "strconv" "strings" "time" @@ -1107,6 +1108,33 @@ func ActionsEnableWorkflow(ctx *context.APIContext) { ctx.Status(http.StatusNoContent) } +func getCurrentRepoActionRunByID(ctx *context.APIContext) *actions_model.ActionRun { + runID := ctx.PathParamInt64("run") + run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID) + if errors.Is(err, util.ErrNotExist) { + ctx.APIErrorNotFound(err) + return nil + } else if err != nil { + ctx.APIErrorInternal(err) + return nil + } + return run +} + +func getCurrentRepoActionRunJobsByID(ctx *context.APIContext) (*actions_model.ActionRun, actions_model.ActionJobList) { + run := getCurrentRepoActionRunByID(ctx) + if ctx.Written() { + return nil, nil + } + + jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) + if err != nil { + ctx.APIErrorInternal(err) + return nil, nil + } + return run, jobs +} + // GetWorkflowRun Gets a specific workflow run. func GetWorkflowRun(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run} repository GetWorkflowRun @@ -1138,19 +1166,12 @@ func GetWorkflowRun(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - runID := ctx.PathParamInt64("run") - job, has, err := db.GetByID[actions_model.ActionRun](ctx, runID) - if err != nil { - ctx.APIErrorInternal(err) - return - } - - if !has || job.RepoID != ctx.Repo.Repository.ID { - ctx.APIErrorNotFound(util.ErrNotExist) + run := getCurrentRepoActionRunByID(ctx) + if ctx.Written() { return } - convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, job) + convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, run) if err != nil { ctx.APIErrorInternal(err) return @@ -1193,19 +1214,8 @@ func RerunWorkflowRun(ctx *context.APIContext) { // "422": // "$ref": "#/responses/validationError" - runID := ctx.PathParamInt64("run") - run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID) - if errors.Is(err, util.ErrNotExist) { - ctx.APIErrorNotFound(err) - return - } else if err != nil { - ctx.APIErrorInternal(err) - return - } - - jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) - if err != nil { - ctx.APIErrorInternal(err) + run, jobs := getCurrentRepoActionRunJobsByID(ctx) + if ctx.Written() { return } @@ -1262,35 +1272,19 @@ func RerunWorkflowJob(ctx *context.APIContext) { // "422": // "$ref": "#/responses/validationError" - runID := ctx.PathParamInt64("run") - run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID) - if errors.Is(err, util.ErrNotExist) { - ctx.APIErrorNotFound(err) - return - } else if err != nil { - ctx.APIErrorInternal(err) - return - } - - jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) - if err != nil { - ctx.APIErrorInternal(err) + run, jobs := getCurrentRepoActionRunJobsByID(ctx) + if ctx.Written() { return } jobID := ctx.PathParamInt64("job_id") - var targetJob *actions_model.ActionRunJob - for _, job := range jobs { - if job.ID == jobID { - targetJob = job - break - } - } - if targetJob == nil { + jobIdx := slices.IndexFunc(jobs, func(job *actions_model.ActionRunJob) bool { return job.ID == jobID }) + if jobIdx == -1 { ctx.APIErrorNotFound(util.NewNotExistErrorf("workflow job with id %d", jobID)) return } + targetJob := jobs[jobIdx] if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobs, targetJob); err != nil { handleWorkflowRerunError(ctx, err) return @@ -1356,9 +1350,7 @@ func ListWorkflowRunJobs(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - repoID := ctx.Repo.Repository.ID - - runID := ctx.PathParamInt64("run") + repoID, runID := ctx.Repo.Repository.ID, ctx.PathParamInt64("run") // Avoid the list all jobs functionality for this api route to be used with a runID == 0. if runID <= 0 { @@ -1458,10 +1450,8 @@ func GetArtifactsOfRun(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - repoID := ctx.Repo.Repository.ID artifactName := ctx.Req.URL.Query().Get("name") - - runID := ctx.PathParamInt64("run") + repoID, runID := ctx.Repo.Repository.ID, ctx.PathParamInt64("run") artifacts, total, err := db.FindAndCount[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{ RepoID: repoID, @@ -1522,15 +1512,11 @@ func DeleteActionRun(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - runID := ctx.PathParamInt64("run") - run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID) - if errors.Is(err, util.ErrNotExist) { - ctx.APIErrorNotFound(err) - return - } else if err != nil { - ctx.APIErrorInternal(err) + run := getCurrentRepoActionRunByID(ctx) + if ctx.Written() { return } + if !run.Status.IsDone() { ctx.APIError(http.StatusBadRequest, "this workflow run is not done") return diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 1ee64242931a1..1110ca25d214d 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -403,11 +403,8 @@ func convertToViewModel(ctx context.Context, locale translation.Locale, cursors // If jobIndexStr is a blank string, it means rerun all jobs func Rerun(ctx *context_module.Context) { runIndex := getRunIndex(ctx) - jobIndexStr := ctx.PathParam("job") - var jobIndex int64 - if jobIndexStr != "" { - jobIndex, _ = strconv.ParseInt(jobIndexStr, 10, 64) - } + jobIndexHas := ctx.PathParam("job") != "" + jobIndex := ctx.PathParamInt("job") run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex) if err != nil { @@ -439,13 +436,9 @@ func Rerun(ctx *context_module.Context) { return } - var targetJob *actions_model.ActionRunJob - if jobIndexStr != "" { - if jobIndex >= 0 && jobIndex < int64(len(jobs)) { - targetJob = jobs[jobIndex] - } else { - targetJob = jobs[0] - } + var targetJob *actions_model.ActionRunJob // nil means rerun all jobs + if jobIndexHas && jobIndex >= 0 && jobIndex < len(jobs) { + targetJob = jobs[jobIndex] } if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobs, targetJob); err != nil { From fc247947a6d230a4ee61eec7c30ca19e87c4d513 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 2 Mar 2026 15:59:38 +0800 Subject: [PATCH 04/10] fix MustGetUnit --- models/repo/repo.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/models/repo/repo.go b/models/repo/repo.go index 73d05da35cdb4..d125e4f5d9215 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -425,9 +425,10 @@ func (repo *Repository) UnitEnabled(ctx context.Context, tp unit.Type) bool { // MustGetUnit always returns a RepoUnit object even if the unit doesn't exist (not enabled) func (repo *Repository) MustGetUnit(ctx context.Context, tp unit.Type) *RepoUnit { ru, err := repo.GetUnit(ctx, tp) - if errors.Is(err, util.ErrNotExist) { + if err == nil { return ru - } else if err != nil { + } + if !errors.Is(err, util.ErrNotExist) { setting.PanicInDevOrTesting("Failed to get unit %v for repository %d: %v", tp, repo.ID, err) } ru = &RepoUnit{RepoID: repo.ID, Type: tp} @@ -444,8 +445,7 @@ func (repo *Repository) MustGetUnit(ctx context.Context, tp unit.Type) *RepoUnit ru.Config = new(ActionsConfig) case unit.TypeProjects: ru.Config = new(ProjectsConfig) - default: - panic("unknown unit type") + default: // other units don't have config } err = ru.Config.FromDB(nil) if err != nil { From d3ac6daa2eac4addcf888336335c94218d2f5241 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 2 Mar 2026 16:35:51 +0800 Subject: [PATCH 05/10] fix comment and GetUnit --- services/actions/rerun.go | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/services/actions/rerun.go b/services/actions/rerun.go index cc6ce704a84a1..277da39b82acf 100644 --- a/services/actions/rerun.go +++ b/services/actions/rerun.go @@ -57,12 +57,9 @@ func RerunWorkflowRunJobs(ctx context.Context, repo *repo_model.Repository, run return util.NewInvalidArgumentErrorf("this workflow run is not done") } - // Rerun is not allowed when workflow is disabled. - cfgUnit, err := repo.GetUnit(ctx, unit.TypeActions) - if err != nil { - return err - } + cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions) + // 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) From b33773801c75831bb29dc129336bf12de2b1e07c Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 2 Mar 2026 16:48:18 +0800 Subject: [PATCH 06/10] fix --- models/repo/repo.go | 7 ++++--- routers/web/repo/actions/view.go | 12 ++++++------ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/models/repo/repo.go b/models/repo/repo.go index d125e4f5d9215..25207cc28b02c 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -447,9 +447,10 @@ func (repo *Repository) MustGetUnit(ctx context.Context, tp unit.Type) *RepoUnit ru.Config = new(ProjectsConfig) default: // other units don't have config } - err = ru.Config.FromDB(nil) - if err != nil { - setting.PanicInDevOrTesting("Failed to load default config for unit %v of repository %d: %v", tp, repo.ID, err) + if ru.Config != nil { + if err = ru.Config.FromDB(nil); err != nil { + setting.PanicInDevOrTesting("Failed to load default config for unit %v of repository %d: %v", tp, repo.ID, err) + } } return ru } diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 1110ca25d214d..ef1ac71238a03 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -431,14 +431,14 @@ func Rerun(ctx *context_module.Context) { ctx.ServerError("GetRunJobsByRunID", err) return } - if len(jobs) == 0 { - ctx.NotFound(nil) - return - } var targetJob *actions_model.ActionRunJob // nil means rerun all jobs - if jobIndexHas && jobIndex >= 0 && jobIndex < len(jobs) { - targetJob = jobs[jobIndex] + if jobIndexHas { + if jobIndex < 0 || jobIndex >= len(jobs) { + ctx.JSONError(ctx.Locale.Tr("error.not_found")) + return + } + targetJob = jobs[jobIndex] // only rerun the selected job } if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobs, targetJob); err != nil { From a19a06eb88b8218561a3b396ffdb700fb0b018e0 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 2 Mar 2026 17:34:54 +0800 Subject: [PATCH 07/10] clarify job index base --- routers/web/repo/actions/view.go | 8 ++++---- tests/integration/actions_concurrency_test.go | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index ef1ac71238a03..8bf5f184f9b21 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -51,7 +51,7 @@ func getRunIndex(ctx *context_module.Context) int64 { func View(ctx *context_module.Context) { ctx.Data["PageIsActions"] = true runIndex := getRunIndex(ctx) - jobIndex := ctx.PathParamInt64("job") + jobIndex := ctx.PathParamInt("job") ctx.Data["RunIndex"] = runIndex ctx.Data["JobIndex"] = jobIndex ctx.Data["ActionsURL"] = ctx.Repo.RepoLink + "/actions" @@ -209,7 +209,7 @@ func getActionsViewArtifacts(ctx context.Context, repoID, runIndex int64) (artif func ViewPost(ctx *context_module.Context) { req := web.GetForm(ctx).(*ViewRequest) runIndex := getRunIndex(ctx) - jobIndex := ctx.PathParamInt64("job") + jobIndex := ctx.PathParamInt("job") current, jobs := getRunJobs(ctx, runIndex, jobIndex) if ctx.Written() { @@ -609,7 +609,7 @@ func Delete(ctx *context_module.Context) { // getRunJobs gets the jobs of runIndex, and returns jobs[jobIndex], jobs. // Any error will be written to the ctx. // It never returns a nil job of an empty jobs, if the jobIndex is out of range, it will be treated as 0. -func getRunJobs(ctx *context_module.Context, runIndex, jobIndex int64) (*actions_model.ActionRunJob, []*actions_model.ActionRunJob) { +func getRunJobs(ctx *context_module.Context, runIndex int64, jobIndex int) (*actions_model.ActionRunJob, []*actions_model.ActionRunJob) { run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex) if err != nil { if errors.Is(err, util.ErrNotExist) { @@ -634,7 +634,7 @@ func getRunJobs(ctx *context_module.Context, runIndex, jobIndex int64) (*actions v.Run = run } - if jobIndex >= 0 && jobIndex < int64(len(jobs)) { + if jobIndex >= 0 && jobIndex < len(jobs) { return jobs[jobIndex], jobs } return jobs[0], jobs diff --git a/tests/integration/actions_concurrency_test.go b/tests/integration/actions_concurrency_test.go index b904230a95c33..77714970807ef 100644 --- a/tests/integration/actions_concurrency_test.go +++ b/tests/integration/actions_concurrency_test.go @@ -1064,7 +1064,7 @@ jobs: }) // rerun cancel true scenario - + // FIXME: is the jobIndex 0-based or 1-based? From code, it should be 0-based, but here it uses 1, and cause failure req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, apiRepo.Name, run2.Index, 1)) _ = session.MakeRequest(t, req, http.StatusOK) From 52cea03833cd94d5896285af6edea1624ca45baa Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 2 Mar 2026 17:44:59 +0800 Subject: [PATCH 08/10] try to fix test --- tests/integration/actions_concurrency_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/integration/actions_concurrency_test.go b/tests/integration/actions_concurrency_test.go index 77714970807ef..ac51299087146 100644 --- a/tests/integration/actions_concurrency_test.go +++ b/tests/integration/actions_concurrency_test.go @@ -643,7 +643,7 @@ jobs: assert.Equal(t, "job-main-v1.24.0", wf2Job2Rerun1Job.ConcurrencyGroup) // rerun wf2-job2 - req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, repo.Name, wf2Run.Index, 1)) + req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/1/rerun", user2.Name, repo.Name, wf2Run.Index)) _ = session.MakeRequest(t, req, http.StatusOK) // (rerun2) fetch and exec wf2-job2 wf2Job2Rerun2Task := runner1.fetchTask(t) @@ -1065,10 +1065,10 @@ jobs: // rerun cancel true scenario // FIXME: is the jobIndex 0-based or 1-based? From code, it should be 0-based, but here it uses 1, and cause failure - req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, apiRepo.Name, run2.Index, 1)) + req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/0/rerun", user2.Name, apiRepo.Name, run2.Index)) _ = session.MakeRequest(t, req, http.StatusOK) - req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, apiRepo.Name, run4.Index, 1)) + req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/0/rerun", user2.Name, apiRepo.Name, run4.Index)) _ = session.MakeRequest(t, req, http.StatusOK) task5 := runner.fetchTask(t) @@ -1084,13 +1084,13 @@ jobs: // rerun cancel false scenario - req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, apiRepo.Name, run2.Index, 1)) + req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/0/rerun", user2.Name, apiRepo.Name, run2.Index)) _ = session.MakeRequest(t, req, http.StatusOK) run2_2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run2.ID}) assert.Equal(t, actions_model.StatusWaiting, run2_2.Status) - req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, apiRepo.Name, run2.Index+1, 1)) + req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/0/rerun", user2.Name, apiRepo.Name, run2.Index+1)) _ = session.MakeRequest(t, req, http.StatusOK) task6 := runner.fetchTask(t) From b7ba5070d295c0825f3f6f8e4618ca7a3d6425a6 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 2 Mar 2026 17:55:55 +0800 Subject: [PATCH 09/10] merge tests --- tests/integration/api_actions_run_test.go | 46 +++++++++++------------ 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/tests/integration/api_actions_run_test.go b/tests/integration/api_actions_run_test.go index aa64a7f6f1dcc..205f3f02ffd57 100644 --- a/tests/integration/api_actions_run_test.go +++ b/tests/integration/api_actions_run_test.go @@ -173,6 +173,17 @@ func testAPIActionsDeleteRunListTasks(t *testing.T, repo *repo_model.Repository, func TestAPIActionsRerunWorkflowRun(t *testing.T) { defer prepareTestEnvActionsArtifacts(t)() + t.Run("NotDone", func(t *testing.T) { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, user.Name) + writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/793/rerun", repo.FullName())). + AddTokenAuth(writeToken) + MakeRequest(t, req, http.StatusBadRequest) + }) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) session := loginUser(t, user.Name) @@ -222,21 +233,19 @@ func TestAPIActionsRerunWorkflowRun(t *testing.T) { }) } -func TestAPIActionsRerunWorkflowRunNotDone(t *testing.T) { +func TestAPIActionsRerunWorkflowJob(t *testing.T) { defer prepareTestEnvActionsArtifacts(t)() - repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) - user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) - session := loginUser(t, user.Name) - writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) - - req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/793/rerun", repo.FullName())). - AddTokenAuth(writeToken) - MakeRequest(t, req, http.StatusBadRequest) -} + t.Run("NotDone", func(t *testing.T) { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, user.Name) + writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) -func TestAPIActionsRerunWorkflowJob(t *testing.T) { - defer prepareTestEnvActionsArtifacts(t)() + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/793/jobs/194/rerun", repo.FullName())). + AddTokenAuth(writeToken) + MakeRequest(t, req, http.StatusBadRequest) + }) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) @@ -283,16 +292,3 @@ func TestAPIActionsRerunWorkflowJob(t *testing.T) { MakeRequest(t, req, http.StatusNotFound) }) } - -func TestAPIActionsRerunWorkflowJobRunNotDone(t *testing.T) { - defer prepareTestEnvActionsArtifacts(t)() - - repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) - user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) - session := loginUser(t, user.Name) - writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) - - req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/793/jobs/194/rerun", repo.FullName())). - AddTokenAuth(writeToken) - MakeRequest(t, req, http.StatusBadRequest) -} From 4a99eb81f3cca0de3964322f0bdb349ebf2b107e Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 3 Mar 2026 03:26:04 +0800 Subject: [PATCH 10/10] Update tests/integration/actions_concurrency_test.go Signed-off-by: wxiaoguang --- tests/integration/actions_concurrency_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/integration/actions_concurrency_test.go b/tests/integration/actions_concurrency_test.go index ac51299087146..f1baa68b714de 100644 --- a/tests/integration/actions_concurrency_test.go +++ b/tests/integration/actions_concurrency_test.go @@ -1064,7 +1064,6 @@ jobs: }) // rerun cancel true scenario - // FIXME: is the jobIndex 0-based or 1-based? From code, it should be 0-based, but here it uses 1, and cause failure req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/0/rerun", user2.Name, apiRepo.Name, run2.Index)) _ = session.MakeRequest(t, req, http.StatusOK)