From 054eb6d8a5aab6233eb7de23b4485897cf2084ff Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 2 Mar 2026 22:34:06 +0100 Subject: [PATCH] feat: Add Actions API rerun endpoints for runs and jobs (#36768) This PR adds official REST API endpoints to rerun Gitea Actions workflow runs and individual jobs: * POST /api/v1/repos/{owner}/{repo}/actions/runs/{run}/rerun * POST /api/v1/repos/{owner}/{repo}/actions/runs/{run}/jobs/{job_id}/rerun It reuses the existing rerun behavior from the web UI and exposes it through stable API routes. --------- Signed-off-by: wxiaoguang Co-authored-by: wxiaoguang Co-authored-by: Giteabot --- models/repo/repo.go | 49 ++--- models/repo/repo_unit.go | 2 + routers/api/v1/api.go | 2 + routers/api/v1/repo/action.go | 186 +++++++++++++++--- routers/web/repo/actions/view.go | 138 ++----------- services/actions/rerun.go | 141 +++++++++++++ templates/swagger/v1_json.tmpl | 111 +++++++++++ tests/integration/actions_concurrency_test.go | 11 +- tests/integration/api_actions_run_test.go | 123 ++++++++++++ 9 files changed, 580 insertions(+), 183 deletions(-) diff --git a/models/repo/repo.go b/models/repo/repo.go index 7b7f5adb41..25207cc28b 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -422,52 +422,37 @@ 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 { return ru } - + 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} 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: // other units don't have config + } + 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 &RepoUnit{ - Type: tp, - Config: new(UnitConfig), - } + return ru } // GetUnit returns a RepoUnit object diff --git a/models/repo/repo_unit.go b/models/repo/repo_unit.go index d03d5e1e6a..1058a18a85 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/routers/api/v1/api.go b/routers/api/v1/api.go index 50626cebbf..cb6bbe0954 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1259,7 +1259,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 4c3a0dceff..13da5aa815 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" @@ -1103,6 +1104,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 @@ -1134,19 +1162,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) + run := getCurrentRepoActionRunByID(ctx) + if ctx.Written() { return } - if !has || job.RepoID != ctx.Repo.Repository.ID { - ctx.APIErrorNotFound(util.ErrNotExist) - 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 @@ -1154,6 +1175,133 @@ 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" + + run, jobs := getCurrentRepoActionRunJobsByID(ctx) + if ctx.Written() { + 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" + + run, jobs := getCurrentRepoActionRunJobsByID(ctx) + if ctx.Written() { + return + } + + jobID := ctx.PathParamInt64("job_id") + 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 + } + + 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 @@ -1198,9 +1346,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 { @@ -1300,10 +1446,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, @@ -1364,15 +1508,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 4c023d9252..0eaa6cab41 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 { @@ -53,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" @@ -211,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() { @@ -405,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 { @@ -431,130 +426,29 @@ 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)) + ctx.ServerError("GetRunJobsByRunID", 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) + var targetJob *actions_model.ActionRunJob // nil means rerun all jobs + 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_model.UpdateRun(ctx, run, "started", "stopped", "previous_duration", "status", "concurrency_group", "concurrency_cancel"); err != nil { - ctx.ServerError("UpdateRun", err) + + if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobs, targetJob); err != nil { + ctx.ServerError("RerunWorkflowRunJobs", err) return } - if err := run.LoadAttributes(ctx); err != nil { - ctx.ServerError("run.LoadAttributes", err) - return - } - notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, run.TriggerUser, run) - - job, jobs := getRunJobs(ctx, runIndex, jobIndex) - if ctx.Written() { - 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 - } - } - 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 - } - } - 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") @@ -715,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) { @@ -740,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/services/actions/rerun.go b/services/actions/rerun.go index 60f6650905..277da39b82 100644 --- a/services/actions/rerun.go +++ b/services/actions/rerun.go @@ -4,8 +4,20 @@ package actions import ( + "context" + "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 +48,132 @@ 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") + } + + 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) + } + + // 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 644c9b3f83..4fc823d090 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -5297,6 +5297,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/actions_concurrency_test.go b/tests/integration/actions_concurrency_test.go index b904230a95..f1baa68b71 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) @@ -1064,11 +1064,10 @@ jobs: }) // rerun cancel true 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) - 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 +1083,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) diff --git a/tests/integration/api_actions_run_test.go b/tests/integration/api_actions_run_test.go index 4838409560..205f3f02ff 100644 --- a/tests/integration/api_actions_run_test.go +++ b/tests/integration/api_actions_run_test.go @@ -169,3 +169,126 @@ 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)() + + 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) + + 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 TestAPIActionsRerunWorkflowJob(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/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}) + 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) + }) +}