From 267a53cd4d662b45c7a53a0d7ec0cfba7d2e1a2e Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Thu, 23 Oct 2025 21:27:19 +0700 Subject: [PATCH 01/14] feat: add Actions API endpoints for workflow run and job management --- modules/structs/repo_actions.go | 2 + routers/api/v1/api.go | 12 +- routers/api/v1/repo/actions_run.go | 851 ++++++++++++++++++++++ routers/api/v1/swagger/action.go | 14 + routers/common/actions.go | 77 ++ services/convert/convert.go | 1 + tests/integration/api_actions_run_test.go | 176 +++++ 7 files changed, 1132 insertions(+), 1 deletion(-) diff --git a/modules/structs/repo_actions.go b/modules/structs/repo_actions.go index b491d6ccce0c3..8fa165b4e9a1a 100644 --- a/modules/structs/repo_actions.go +++ b/modules/structs/repo_actions.go @@ -123,6 +123,8 @@ type ActionWorkflowRun struct { HeadRepository *Repository `json:"head_repository,omitempty"` Conclusion string `json:"conclusion,omitempty"` // swagger:strfmt date-time + CreatedAt time.Time `json:"created_at"` + // swagger:strfmt date-time StartedAt time.Time `json:"started_at"` // swagger:strfmt date-time CompletedAt time.Time `json:"completed_at"` diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index e6238acce04f2..4a95692b17ffb 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1272,7 +1272,17 @@ func Routes() *web.Router { m.Group("/{run}", func() { m.Get("", repo.GetWorkflowRun) m.Delete("", reqToken(), reqRepoWriter(unit.TypeActions), repo.DeleteActionRun) - m.Get("/jobs", repo.ListWorkflowRunJobs) + m.Post("/rerun", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunWorkflowRun) + m.Post("/cancel", reqToken(), reqRepoWriter(unit.TypeActions), repo.CancelWorkflowRun) + m.Post("/approve", reqToken(), reqRepoWriter(unit.TypeActions), repo.ApproveWorkflowRun) + m.Group("/jobs", func() { + m.Get("", repo.ListWorkflowRunJobs) + m.Post("/{job_id}/rerun", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunWorkflowJob) + }) + m.Group("/logs", func() { + m.Get("", repo.GetWorkflowRunLogs) + m.Post("", reqToken(), reqRepoReader(unit.TypeActions), repo.GetWorkflowRunLogsStream) + }) m.Get("/artifacts", repo.GetArtifactsOfRun) }) }) diff --git a/routers/api/v1/repo/actions_run.go b/routers/api/v1/repo/actions_run.go index a12a6fdd6d796..5c69cc330722a 100644 --- a/routers/api/v1/repo/actions_run.go +++ b/routers/api/v1/repo/actions_run.go @@ -4,12 +4,24 @@ package repo import ( + stdCtx "context" "errors" + "net/http" + "time" actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/actions" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/common" + actions_service "code.gitea.io/gitea/services/actions" "code.gitea.io/gitea/services/context" + notify_service "code.gitea.io/gitea/services/notify" + + "xorm.io/builder" ) func DownloadActionsRunJobLogs(ctx *context.APIContext) { @@ -62,3 +74,842 @@ func DownloadActionsRunJobLogs(ctx *context.APIContext) { } } } + +func RerunWorkflowRun(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/actions/runs/{run}/rerun repository rerunWorkflowRun + // --- + // summary: Rerun a workflow run and its jobs + // 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: run ID or "latest" + // type: string + // required: true + // responses: + // "200": + // description: success + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + if !ctx.Repo.CanWrite(unit.TypeActions) { + ctx.APIError(403, "User does not have write access to actions") + return + } + + _, run, err := getRunID(ctx) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.APIError(404, "Run not found") + } else { + ctx.APIErrorInternal(err) + } + return + } + + // Check if workflow is disabled + cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions) + cfg := cfgUnit.ActionsConfig() + if cfg.IsWorkflowDisabled(run.WorkflowID) { + ctx.APIError(400, "Workflow is disabled") + return + } + + // Reset run's start and stop time when it is done + if run.Status.IsDone() { + run.PreviousDuration = run.Duration() + run.Started = 0 + run.Stopped = 0 + if err := actions_model.UpdateRun(ctx, run, "started", "stopped", "previous_duration"); err != nil { + ctx.APIErrorInternal(err) + return + } + } + + jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + // Rerun all jobs + for _, job := range jobs { + // If the job has needs, it should be set to "blocked" status to wait for other jobs + shouldBlock := len(job.Needs) > 0 + if err := rerunJob(ctx, job, shouldBlock); err != nil { + ctx.APIErrorInternal(err) + return + } + } + + ctx.Status(200) +} + +func CancelWorkflowRun(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/actions/runs/{run}/cancel repository cancelWorkflowRun + // --- + // summary: Cancel a workflow run and its jobs + // 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: run ID or "latest" + // type: string + // required: true + // responses: + // "200": + // description: success + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + if !ctx.Repo.CanWrite(unit.TypeActions) { + ctx.APIError(403, "User does not have write access to actions") + return + } + + runID, _, err := getRunID(ctx) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.APIError(404, "Run not found") + } else { + ctx.APIErrorInternal(err) + } + return + } + + jobs, err := getRunJobsByRunID(ctx, runID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + var updatedJobs []*actions_model.ActionRunJob + + if err := db.WithTx(ctx, func(ctx stdCtx.Context) error { + for _, job := range jobs { + status := job.Status + if status.IsDone() { + continue + } + if job.TaskID == 0 { + job.Status = actions_model.StatusCancelled + job.Stopped = timeutil.TimeStampNow() + n, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"task_id": 0}, "status", "stopped") + if err != nil { + return err + } + if n == 0 { + return errors.New("job has changed, try again") + } + if n > 0 { + updatedJobs = append(updatedJobs, job) + } + continue + } + if err := actions_model.StopTask(ctx, job.TaskID, actions_model.StatusCancelled); err != nil { + return err + } + } + return nil + }); err != nil { + ctx.APIErrorInternal(err) + return + } + + actions_service.CreateCommitStatusForRunJobs(ctx, jobs[0].Run, jobs...) + + for _, job := range updatedJobs { + _ = job.LoadAttributes(ctx) + notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) + } + if len(updatedJobs) > 0 { + job := updatedJobs[0] + actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, job) + notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run) + } + + ctx.Status(200) +} + +func ApproveWorkflowRun(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/actions/runs/{run}/approve repository approveWorkflowRun + // --- + // summary: Approve a workflow run that requires approval + // 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: run ID or "latest" + // type: string + // required: true + // responses: + // "200": + // description: success + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + if !ctx.Repo.CanWrite(unit.TypeActions) { + ctx.APIError(403, "User does not have write access to actions") + return + } + + runID, _, err := getRunID(ctx) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.APIError(404, "Run not found") + } else { + ctx.APIErrorInternal(err) + } + return + } + + current, jobs, err := getRunJobsAndCurrent(ctx, runID, -1) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + run := current.Run + doer := ctx.Doer + + var updatedJobs []*actions_model.ActionRunJob + + if err := db.WithTx(ctx, func(ctx stdCtx.Context) error { + run.NeedApproval = false + run.ApprovedBy = doer.ID + if err := actions_model.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil { + return err + } + for _, job := range jobs { + if len(job.Needs) == 0 && job.Status.IsBlocked() { + job.Status = actions_model.StatusWaiting + n, err := actions_model.UpdateRunJob(ctx, job, nil, "status") + if err != nil { + return err + } + if n > 0 { + updatedJobs = append(updatedJobs, job) + } + } + } + return nil + }); err != nil { + ctx.APIErrorInternal(err) + return + } + + actions_service.CreateCommitStatusForRunJobs(ctx, jobs[0].Run, jobs...) + + if len(updatedJobs) > 0 { + job := updatedJobs[0] + actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, job) + notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run) + } + + for _, job := range updatedJobs { + _ = job.LoadAttributes(ctx) + notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) + } + + ctx.Status(200) +} + +func RerunWorkflowJob(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/actions/runs/{run}/jobs/{job_id}/rerun repository rerunWorkflowJob + // --- + // summary: Rerun a specific job and its dependent jobs + // 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: run ID or "latest" + // type: string + // required: true + // - name: job_id + // in: path + // description: id of the job + // type: integer + // required: true + // responses: + // "200": + // description: success + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + if !ctx.Repo.CanWrite(unit.TypeActions) { + ctx.APIError(403, "User does not have write access to actions") + return + } + + runID, _, err := getRunID(ctx) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.APIError(404, "Run not found") + } else { + ctx.APIErrorInternal(err) + } + return + } + + jobID := ctx.PathParamInt64("job_id") + + // Get all jobs for the run to handle dependencies + allJobs, err := getRunJobsByRunID(ctx, runID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + // Find the specific job in the list + var job *actions_model.ActionRunJob + for _, j := range allJobs { + if j.ID == jobID { + job = j + break + } + } + + if job == nil { + ctx.APIError(404, "Job not found in run") + return + } + + // Get run from the job and check if workflow is disabled + run := allJobs[0].Run + + cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions) + cfg := cfgUnit.ActionsConfig() + if cfg.IsWorkflowDisabled(run.WorkflowID) { + ctx.APIError(400, "Workflow is disabled") + return + } + + // Reset run's start and stop time when it is done + if run.Status.IsDone() { + run.PreviousDuration = run.Duration() + run.Started = 0 + run.Stopped = 0 + if err := actions_model.UpdateRun(ctx, run, "started", "stopped", "previous_duration"); err != nil { + ctx.APIErrorInternal(err) + return + } + } + + // Get all jobs that need to be rerun (including dependencies) + rerunJobs := actions_service.GetAllRerunJobs(job, allJobs) + + for _, j := range rerunJobs { + // Jobs other than the specified one should be set to "blocked" status + shouldBlock := j.JobID != job.JobID + if err := rerunJob(ctx, j, shouldBlock); err != nil { + ctx.APIErrorInternal(err) + return + } + } + + ctx.Status(200) +} + +// Helper functions +func getRunID(ctx *context.APIContext) (int64, *actions_model.ActionRun, error) { + // if run param is "latest", get the latest run + if ctx.PathParam("run") == "latest" { + run, err := actions_model.GetLatestRun(ctx, ctx.Repo.Repository.ID) + if err != nil { + return 0, nil, err + } + if run == nil { + return 0, nil, util.ErrNotExist + } + return run.ID, run, nil + } + + // Otherwise get run by ID + runID := ctx.PathParamInt64("run") + run, has, err := db.GetByID[actions_model.ActionRun](ctx, runID) + if err != nil { + return 0, nil, err + } + if !has || run.RepoID != ctx.Repo.Repository.ID { + return 0, nil, util.ErrNotExist + } + return runID, run, nil +} + +func getRunJobsByRunID(ctx *context.APIContext, runID int64) ([]*actions_model.ActionRunJob, error) { + run, has, err := db.GetByID[actions_model.ActionRun](ctx, runID) + if err != nil { + return nil, err + } + if !has || run.RepoID != ctx.Repo.Repository.ID { + return nil, util.ErrNotExist + } + run.Repo = ctx.Repo.Repository + jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) + if err != nil { + return nil, err + } + for _, v := range jobs { + v.Run = run + } + return jobs, nil +} + +func getRunJobsAndCurrent(ctx *context.APIContext, runID, jobIndex int64) (*actions_model.ActionRunJob, []*actions_model.ActionRunJob, error) { + jobs, err := getRunJobsByRunID(ctx, runID) + if err != nil { + return nil, nil, err + } + if len(jobs) == 0 { + return nil, nil, util.ErrNotExist + } + + if jobIndex >= 0 && jobIndex < int64(len(jobs)) { + return jobs[jobIndex], jobs, nil + } + return jobs[0], jobs, nil +} + +func rerunJob(ctx *context.APIContext, job *actions_model.ActionRunJob, shouldBlock bool) error { + status := job.Status + if !status.IsDone() || !job.Run.Status.IsDone() { + return nil + } + + job.TaskID = 0 + job.Status = actions_model.StatusWaiting + if shouldBlock { + job.Status = actions_model.StatusBlocked + } + job.Started = 0 + job.Stopped = 0 + + if err := db.WithTx(ctx, func(ctx stdCtx.Context) error { + _, err := actions_model.UpdateRunJob(ctx, job, nil, "task_id", "status", "started", "stopped") + return err + }); err != nil { + return err + } + + actions_service.CreateCommitStatusForRunJobs(ctx, job.Run, job) + actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, job) + notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) + + return nil +} + +// LogCursor represents a log cursor position +type LogCursor struct { + Step int `json:"step"` + Cursor int64 `json:"cursor"` + Expanded bool `json:"expanded"` +} + +// LogRequest represents a log streaming request +type LogRequest struct { + LogCursors []LogCursor `json:"logCursors"` +} + +// LogStepLine represents a single log line +type LogStepLine struct { + Index int64 `json:"index"` + Message string `json:"message"` + Timestamp float64 `json:"timestamp"` +} + +// LogStep represents logs for a workflow step +type LogStep struct { + Step int `json:"step"` + Cursor int64 `json:"cursor"` + Lines []*LogStepLine `json:"lines"` + Started int64 `json:"started"` +} + +// LogResponse represents the complete log response +type LogResponse struct { + StepsLog []*LogStep `json:"stepsLog"` +} + +func GetWorkflowRunLogs(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run}/logs repository getWorkflowRunLogs + // --- + // summary: Download workflow run logs as archive + // produces: + // - application/zip + // 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: run ID or "latest" + // type: string + // required: true + // responses: + // "200": + // description: Logs archive + // "404": + // "$ref": "#/responses/notFound" + + _, run, err := getRunID(ctx) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.APIError(404, "Run not found") + } else { + ctx.APIErrorInternal(err) + } + return + } + + if err = common.DownloadActionsRunAllJobLogs(ctx.Base, ctx.Repo.Repository, run.ID); err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.APIError(404, "Logs not found") + } else { + ctx.APIErrorInternal(err) + } + } +} + +func GetWorkflowJobLogs(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run}/jobs/{job_id}/logs repository getWorkflowJobLogs + // --- + // summary: Download job logs + // produces: + // - application/zip + // 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: run ID or "latest" + // type: string + // required: true + // - name: job_id + // in: path + // description: id of the job + // type: integer + // required: true + // responses: + // "200": + // description: Job logs + // "404": + // "$ref": "#/responses/notFound" + + runID, _, err := getRunID(ctx) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.APIError(404, "Run not found") + } else { + ctx.APIErrorInternal(err) + } + return + } + + jobID := ctx.PathParamInt64("job_id") + + // Get the job by ID and verify it belongs to the run + job, err := actions_model.GetRunJobByID(ctx, jobID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + if job.RunID != runID { + ctx.APIError(404, "Job not found in this run") + return + } + + if err = job.LoadRepo(ctx); err != nil { + ctx.APIErrorInternal(err) + return + } + + if err = common.DownloadActionsRunJobLogs(ctx.Base, ctx.Repo.Repository, job); err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.APIError(404, "Job logs not found") + } else { + ctx.APIErrorInternal(err) + } + } +} + +func GetWorkflowRunLogsStream(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/actions/runs/{run}/logs repository getWorkflowRunLogsStream + // --- + // summary: Get streaming workflow run logs with cursor support + // consumes: + // - application/json + // 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: run ID or "latest" + // type: string + // required: true + // - name: job + // in: query + // description: job index (0-based), defaults to first job + // type: integer + // required: false + // - name: body + // in: body + // schema: + // type: object + // properties: + // logCursors: + // type: array + // items: + // type: object + // properties: + // step: + // type: integer + // cursor: + // type: integer + // expanded: + // type: boolean + // responses: + // "200": + // description: Streaming logs + // schema: + // type: object + // properties: + // stepsLog: + // type: array + // items: + // type: object + // properties: + // step: + // type: integer + // cursor: + // type: integer + // lines: + // type: array + // items: + // type: object + // properties: + // index: + // type: integer + // message: + // type: string + // timestamp: + // type: number + // started: + // type: integer + // "404": + // "$ref": "#/responses/notFound" + + runID, _, err := getRunID(ctx) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.APIError(404, "Run not found") + } else { + ctx.APIErrorInternal(err) + } + return + } + + jobIndex := int64(0) + if ctx.FormInt("job") > 0 { + jobIndex = int64(ctx.FormInt("job")) + } + + // Parse log cursors from request body + var req LogRequest + if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil { + // If no body or invalid JSON, start with empty cursors + req = LogRequest{LogCursors: []LogCursor{}} + } + + current, _, err := getRunJobsAndCurrent(ctx, runID, jobIndex) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.APIError(404, "Run or job not found") + } else { + ctx.APIErrorInternal(err) + } + return + } + + var task *actions_model.ActionTask + if current.TaskID > 0 { + task, err = actions_model.GetTaskByID(ctx, current.TaskID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + task.Job = current + if err := task.LoadAttributes(ctx); err != nil { + ctx.APIErrorInternal(err) + return + } + } + + response := &LogResponse{ + StepsLog: make([]*LogStep, 0), + } + + if task != nil { + logs, err := convertToLogResponse(ctx, req.LogCursors, task) + if err != nil { + ctx.APIErrorInternal(err) + return + } + response.StepsLog = append(response.StepsLog, logs...) + } + + ctx.JSON(http.StatusOK, response) +} + +func convertToLogResponse(ctx *context.APIContext, cursors []LogCursor, task *actions_model.ActionTask) ([]*LogStep, error) { + var logs []*LogStep + steps := actions.FullSteps(task) + + for _, cursor := range cursors { + if !cursor.Expanded { + continue + } + + if cursor.Step >= len(steps) { + continue + } + + step := steps[cursor.Step] + + // if task log is expired, return a consistent log line + if task.LogExpired { + if cursor.Cursor == 0 { + logs = append(logs, &LogStep{ + Step: cursor.Step, + Cursor: 1, + Lines: []*LogStepLine{ + { + Index: 1, + Message: "Log has expired and is no longer available", + Timestamp: float64(task.Updated.AsTime().UnixNano()) / float64(time.Second), + }, + }, + Started: int64(step.Started), + }) + } + continue + } + + logLines := make([]*LogStepLine, 0) + + index := step.LogIndex + cursor.Cursor + validCursor := cursor.Cursor >= 0 && + cursor.Cursor < step.LogLength && + index < int64(len(task.LogIndexes)) + + if validCursor { + length := step.LogLength - cursor.Cursor + offset := task.LogIndexes[index] + logRows, err := actions.ReadLogs(ctx, task.LogInStorage, task.LogFilename, offset, length) + if err != nil { + return nil, err + } + + for i, row := range logRows { + logLines = append(logLines, &LogStepLine{ + Index: cursor.Cursor + int64(i) + 1, // start at 1 + Message: row.Content, + Timestamp: float64(row.Time.AsTime().UnixNano()) / float64(time.Second), + }) + } + } + + logs = append(logs, &LogStep{ + Step: cursor.Step, + Cursor: cursor.Cursor + int64(len(logLines)), + Lines: logLines, + Started: int64(step.Started), + }) + } + + return logs, nil +} diff --git a/routers/api/v1/swagger/action.go b/routers/api/v1/swagger/action.go index 0606505950216..5333e72fc16db 100644 --- a/routers/api/v1/swagger/action.go +++ b/routers/api/v1/swagger/action.go @@ -46,3 +46,17 @@ type swaggerResponseActionWorkflowList struct { // in:body Body api.ActionWorkflowResponse `json:"body"` } + +// WorkflowRunRerunRequest +// swagger:model WorkflowRunRerunRequest +type swaggerWorkflowRunRerunRequest struct { + // Enable debug logging for the re-run + EnableDebugLogging bool `json:"enable_debug_logging"` +} + +// WorkflowRunLogsRequest +// swagger:model WorkflowRunLogsRequest +type swaggerWorkflowRunLogsRequest struct { + // Log cursors for incremental log streaming + LogCursors []map[string]any `json:"logCursors"` +} diff --git a/routers/common/actions.go b/routers/common/actions.go index a4eabb6ba21f1..8e5f80565cd51 100644 --- a/routers/common/actions.go +++ b/routers/common/actions.go @@ -4,7 +4,9 @@ package common import ( + "archive/zip" "fmt" + "io" "strings" actions_model "code.gitea.io/gitea/models/actions" @@ -28,6 +30,81 @@ func DownloadActionsRunJobLogsWithIndex(ctx *context.Base, ctxRepo *repo_model.R return DownloadActionsRunJobLogs(ctx, ctxRepo, runJobs[jobIndex]) } +func DownloadActionsRunAllJobLogs(ctx *context.Base, ctxRepo *repo_model.Repository, runID int64) error { + runJobs, err := actions_model.GetRunJobsByRunID(ctx, runID) + if err != nil { + return fmt.Errorf("GetRunJobsByRunID: %w", err) + } + if err = runJobs.LoadRepos(ctx); err != nil { + return fmt.Errorf("LoadRepos: %w", err) + } + + if len(runJobs) == 0 { + return util.NewNotExistErrorf("no jobs found for run %d", runID) + } + + // Load run for workflow name + if err := runJobs[0].LoadRun(ctx); err != nil { + return fmt.Errorf("LoadRun: %w", err) + } + + workflowName := runJobs[0].Run.WorkflowID + if p := strings.Index(workflowName, "."); p > 0 { + workflowName = workflowName[0:p] + } + + // Set headers for zip download + ctx.Resp.Header().Set("Content-Type", "application/zip") + ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s-run-%d-logs.zip\"", workflowName, runID)) + + // Create zip writer + zipWriter := zip.NewWriter(ctx.Resp) + defer zipWriter.Close() + + // Add each job's logs to the zip + for _, job := range runJobs { + if job.Repo.ID != ctxRepo.ID { + continue // Skip jobs from other repos + } + + if job.TaskID == 0 { + continue // Skip jobs that haven't started + } + + task, err := actions_model.GetTaskByID(ctx, job.TaskID) + if err != nil { + return fmt.Errorf("GetTaskByID for job %d: %w", job.ID, err) + } + + if task.LogExpired { + continue // Skip expired logs + } + + reader, err := actions.OpenLogs(ctx, task.LogInStorage, task.LogFilename) + if err != nil { + return fmt.Errorf("OpenLogs for job %d: %w", job.ID, err) + } + + // Create file in zip with job name and task ID + fileName := fmt.Sprintf("%s-%s-%d.log", workflowName, job.Name, task.ID) + zipFile, err := zipWriter.Create(fileName) + if err != nil { + reader.Close() + return fmt.Errorf("Create zip file %s: %w", fileName, err) + } + + // Copy log content to zip file + if _, err := io.Copy(zipFile, reader); err != nil { + reader.Close() + return fmt.Errorf("Copy logs for job %d: %w", job.ID, err) + } + + reader.Close() + } + + return nil +} + func DownloadActionsRunJobLogs(ctx *context.Base, ctxRepo *repo_model.Repository, curJob *actions_model.ActionRunJob) error { if curJob.Repo.ID != ctxRepo.ID { return util.NewNotExistErrorf("job not found") diff --git a/services/convert/convert.go b/services/convert/convert.go index 9f8fff970ccc0..1a59478fd34ab 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -258,6 +258,7 @@ func ToActionWorkflowRun(ctx context.Context, repo *repo_model.Repository, run * URL: fmt.Sprintf("%s/actions/runs/%d", repo.APIURL(), run.ID), HTMLURL: run.HTMLURL(), RunNumber: run.Index, + CreatedAt: run.Created.AsLocalTime(), StartedAt: run.Started.AsLocalTime(), CompletedAt: run.Stopped.AsLocalTime(), Event: string(run.Event), diff --git a/tests/integration/api_actions_run_test.go b/tests/integration/api_actions_run_test.go index a0292f8f8b58f..c2d5fdab6f65d 100644 --- a/tests/integration/api_actions_run_test.go +++ b/tests/integration/api_actions_run_test.go @@ -6,6 +6,7 @@ package integration import ( "fmt" "net/http" + "strings" "testing" auth_model "code.gitea.io/gitea/models/auth" @@ -134,3 +135,178 @@ 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) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // Test rerun existing run + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/rerun", repo.FullName())). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + // Test rerun non-existent run + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/999999/rerun", repo.FullName())). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + + // Test rerun with "latest" parameter + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/latest/rerun", repo.FullName())). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) +} + +func TestAPIActionsRerunWorkflowRunPermissions(t *testing.T) { + defer prepareTestEnvActionsArtifacts(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) // User without write access + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // Test rerun without permissions should fail + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/rerun", repo.FullName())). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) +} + +func TestAPIActionsCancelWorkflowRun(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) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // Test cancel running workflow + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/793/cancel", repo.FullName())). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + // Test cancel non-existent run + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/999999/cancel", repo.FullName())). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) +} + +func TestAPIActionsCancelWorkflowRunPermissions(t *testing.T) { + defer prepareTestEnvActionsArtifacts(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // User without write access + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // Test cancel without permissions should fail + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/793/cancel", repo.FullName())). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) +} + +func TestAPIActionsApproveWorkflowRun(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) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // Test approve workflow run + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/approve", repo.FullName())). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + // Test approve non-existent run + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/999999/approve", repo.FullName())). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) +} + +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) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // Test rerun specific job + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs/192/rerun", repo.FullName())). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + // Test rerun non-existent job + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs/999999/rerun", repo.FullName())). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) +} + +func TestAPIActionsGetWorkflowRunLogs(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) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // Test get workflow run logs (archive download) + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/logs", repo.FullName())). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + // Test get non-existent run logs + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/999999/logs", repo.FullName())). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) +} + +func TestAPIActionsGetWorkflowJobLogs(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) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs/198/logs", repo.FullName())). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs/999999/logs", repo.FullName())). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) +} + +func TestAPIActionsGetWorkflowRunLogsStream(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) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // Test streaming logs with empty cursor request + req := NewRequestWithBody(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/logs", repo.FullName()), strings.NewReader(`{"logCursors": []}`)). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + // Parse response to verify structure + var logResp map[string]any + err := json.Unmarshal(resp.Body.Bytes(), &logResp) + assert.NoError(t, err) + assert.Contains(t, logResp, "stepsLog") + + // Test streaming logs with cursor request + req = NewRequestWithBody(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/logs", repo.FullName()), strings.NewReader(`{"logCursors": [{"step": 0, "cursor": 0, "expanded": true}]}`)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + // Test streaming logs for non-existent run + req = NewRequestWithBody(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/999999/logs", repo.FullName()), strings.NewReader(`{"logCursors": []}`)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) +} From d3a10b3c93c2266a4f3a3f29af2ebc3d4876f37b Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Thu, 23 Oct 2025 21:28:15 +0700 Subject: [PATCH 02/14] Regenerate swagger after adding Actions API endpoints --- templates/swagger/v1_json.tmpl | 416 +++++++++++++++++++++++++++++++++ 1 file changed, 416 insertions(+) diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 966aff12f8f29..db80a61f2e44b 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -5266,6 +5266,55 @@ } } }, + "/repos/{owner}/{repo}/actions/runs/{run}/approve": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Approve a workflow run that requires approval", + "operationId": "approveWorkflowRun", + "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": "string", + "description": "run ID or \"latest\"", + "name": "run", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "success" + }, + "400": { + "$ref": "#/responses/error" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/{owner}/{repo}/actions/runs/{run}/artifacts": { "get": { "produces": [ @@ -5318,6 +5367,55 @@ } } }, + "/repos/{owner}/{repo}/actions/runs/{run}/cancel": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Cancel a workflow run and its jobs", + "operationId": "cancelWorkflowRun", + "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": "string", + "description": "run ID or \"latest\"", + "name": "run", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "success" + }, + "400": { + "$ref": "#/responses/error" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/{owner}/{repo}/actions/runs/{run}/jobs": { "get": { "produces": [ @@ -5382,6 +5480,319 @@ } } }, + "/repos/{owner}/{repo}/actions/runs/{run}/jobs/{job_id}/logs": { + "get": { + "produces": [ + "application/zip" + ], + "tags": [ + "repository" + ], + "summary": "Download job logs", + "operationId": "getWorkflowJobLogs", + "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": "string", + "description": "run ID or \"latest\"", + "name": "run", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "id of the job", + "name": "job_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Job logs" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/repos/{owner}/{repo}/actions/runs/{run}/jobs/{job_id}/rerun": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Rerun a specific job and its dependent jobs", + "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": "string", + "description": "run ID or \"latest\"", + "name": "run", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "id of the job", + "name": "job_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "success" + }, + "400": { + "$ref": "#/responses/error" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/repos/{owner}/{repo}/actions/runs/{run}/logs": { + "get": { + "produces": [ + "application/zip" + ], + "tags": [ + "repository" + ], + "summary": "Download workflow run logs as archive", + "operationId": "getWorkflowRunLogs", + "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": "string", + "description": "run ID or \"latest\"", + "name": "run", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Logs archive" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Get streaming workflow run logs with cursor support", + "operationId": "getWorkflowRunLogsStream", + "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": "string", + "description": "run ID or \"latest\"", + "name": "run", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "job index (0-based), defaults to first job", + "name": "job", + "in": "query" + }, + { + "name": "body", + "in": "body", + "schema": { + "type": "object", + "properties": { + "logCursors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cursor": { + "type": "integer" + }, + "expanded": { + "type": "boolean" + }, + "step": { + "type": "integer" + } + } + } + } + } + } + } + ], + "responses": { + "200": { + "description": "Streaming logs", + "schema": { + "type": "object", + "properties": { + "stepsLog": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cursor": { + "type": "integer" + }, + "lines": { + "type": "array", + "items": { + "type": "object", + "properties": { + "index": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "timestamp": { + "type": "number" + } + } + } + }, + "started": { + "type": "integer" + }, + "step": { + "type": "integer" + } + } + } + } + } + } + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/repos/{owner}/{repo}/actions/runs/{run}/rerun": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Rerun a workflow run and its jobs", + "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": "string", + "description": "run ID or \"latest\"", + "name": "run", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "success" + }, + "400": { + "$ref": "#/responses/error" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/{owner}/{repo}/actions/secrets": { "get": { "produces": [ @@ -21320,6 +21731,11 @@ "type": "string", "x-go-name": "Conclusion" }, + "created_at": { + "type": "string", + "format": "date-time", + "x-go-name": "CreatedAt" + }, "display_title": { "type": "string", "x-go-name": "DisplayTitle" From 49b67614ccfa7f150a536c319d4a1e3428ad0011 Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Thu, 23 Oct 2025 21:46:24 +0700 Subject: [PATCH 03/14] Update test fixtures and related files for Actions API --- models/fixtures/collaboration.yml | 12 ++++++++++++ models/fixtures/repo_unit.yml | 14 ++++++++++++++ models/repo/collaboration_test.go | 2 +- modules/git/notes_test.go | 2 +- modules/setting/indexer_test.go | 2 +- 5 files changed, 29 insertions(+), 3 deletions(-) diff --git a/models/fixtures/collaboration.yml b/models/fixtures/collaboration.yml index 4c3ac367f6b59..74f6f24c867f0 100644 --- a/models/fixtures/collaboration.yml +++ b/models/fixtures/collaboration.yml @@ -4,6 +4,18 @@ user_id: 2 mode: 2 # write +- + id: 12 + repo_id: 2 + user_id: 4 + mode: 1 # read + +- + id: 13 + repo_id: 4 + user_id: 2 + mode: 1 # read + - id: 2 repo_id: 4 diff --git a/models/fixtures/repo_unit.yml b/models/fixtures/repo_unit.yml index f6b6252da1f88..d53a7c9edd4e8 100644 --- a/models/fixtures/repo_unit.yml +++ b/models/fixtures/repo_unit.yml @@ -733,3 +733,17 @@ type: 3 config: "{\"IgnoreWhitespaceConflicts\":false,\"AllowMerge\":true,\"AllowRebase\":true,\"AllowRebaseMerge\":true,\"AllowSquash\":true}" created_unix: 946684810 + +- + id: 111 + repo_id: 2 + type: 10 + config: "{}" + created_unix: 946684810 + +- + id: 112 + repo_id: 4 + type: 10 + config: "{}" + created_unix: 946684810 diff --git a/models/repo/collaboration_test.go b/models/repo/collaboration_test.go index 7e06bffb72530..7b346b3ed6d45 100644 --- a/models/repo/collaboration_test.go +++ b/models/repo/collaboration_test.go @@ -65,7 +65,7 @@ func TestRepository_IsCollaborator(t *testing.T) { } test(3, 2, true) test(3, unittest.NonexistentID, false) - test(4, 2, false) + test(4, 2, true) test(4, 4, true) } diff --git a/modules/git/notes_test.go b/modules/git/notes_test.go index 7db2dbc0b9e3b..5abb68b102329 100644 --- a/modules/git/notes_test.go +++ b/modules/git/notes_test.go @@ -47,5 +47,5 @@ func TestGetNonExistentNotes(t *testing.T) { note := Note{} err = GetNote(t.Context(), bareRepo1, "non_existent_sha", ¬e) assert.Error(t, err) - assert.IsType(t, ErrNotExist{}, err) + assert.ErrorAs(t, err, &ErrNotExist{}) } diff --git a/modules/setting/indexer_test.go b/modules/setting/indexer_test.go index 8f0437be8a0a0..498f8752a2c0b 100644 --- a/modules/setting/indexer_test.go +++ b/modules/setting/indexer_test.go @@ -65,7 +65,7 @@ func checkGlobMatch(t *testing.T, globstr string, list []indexerMatchList) { } } if !found { - assert.Equal(t, m.position, -1, "Test string `%s` doesn't match `%s` anywhere; expected @%d", m.value, globstr, m.position) + assert.Equal(t, -1, m.position, "Test string `%s` doesn't match `%s` anywhere; expected @%d", m.value, globstr, m.position) } } } From 0a4479ced3d8c7018422e5f7a5c1c4dd1237464f Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Thu, 23 Oct 2025 21:51:26 +0700 Subject: [PATCH 04/14] Add build tools for code processing and testing --- tools/code-batch-process.go | 273 ++++++++++++++++++++++++++++++++++++ tools/gocovmerge.go | 118 ++++++++++++++++ tools/test-echo.go | 20 +++ 3 files changed, 411 insertions(+) create mode 100644 tools/code-batch-process.go create mode 100644 tools/gocovmerge.go create mode 100644 tools/test-echo.go diff --git a/tools/code-batch-process.go b/tools/code-batch-process.go new file mode 100644 index 0000000000000..2c7ccdf8a6c10 --- /dev/null +++ b/tools/code-batch-process.go @@ -0,0 +1,273 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build ignore + +package main + +import ( + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + + "code.gitea.io/gitea/build/codeformat" +) + +// Windows has a limitation for command line arguments, the size can not exceed 32KB. +// So we have to feed the files to some tools (like gofmt) batch by batch + +// We also introduce a `gitea-fmt` command, it does better import formatting than gofmt/goimports. `gitea-fmt` calls `gofmt` internally. + +var optionLogVerbose bool + +func logVerbose(msg string, args ...any) { + if optionLogVerbose { + log.Printf(msg, args...) + } +} + +func passThroughCmd(cmd string, args []string) error { + foundCmd, err := exec.LookPath(cmd) + if err != nil { + log.Fatalf("can not find cmd: %s", cmd) + } + c := exec.Cmd{ + Path: foundCmd, + Args: append([]string{cmd}, args...), + Stdin: os.Stdin, + Stdout: os.Stdout, + Stderr: os.Stderr, + } + return c.Run() +} + +type fileCollector struct { + dirs []string + includePatterns []*regexp.Regexp + excludePatterns []*regexp.Regexp + batchSize int +} + +func newFileCollector(fileFilter string, batchSize int) (*fileCollector, error) { + co := &fileCollector{batchSize: batchSize} + if fileFilter == "go-own" { + co.dirs = []string{ + "build", + "cmd", + "contrib", + "tests", + "models", + "modules", + "routers", + "services", + } + co.includePatterns = append(co.includePatterns, regexp.MustCompile(`.*\.go$`)) + + co.excludePatterns = append(co.excludePatterns, regexp.MustCompile(`.*\bbindata\.go$`)) + co.excludePatterns = append(co.excludePatterns, regexp.MustCompile(`\.pb\.go$`)) + co.excludePatterns = append(co.excludePatterns, regexp.MustCompile(`tests/gitea-repositories-meta`)) + co.excludePatterns = append(co.excludePatterns, regexp.MustCompile(`tests/integration/migration-test`)) + co.excludePatterns = append(co.excludePatterns, regexp.MustCompile(`modules/git/tests`)) + co.excludePatterns = append(co.excludePatterns, regexp.MustCompile(`models/fixtures`)) + co.excludePatterns = append(co.excludePatterns, regexp.MustCompile(`models/migrations/fixtures`)) + co.excludePatterns = append(co.excludePatterns, regexp.MustCompile(`services/gitdiff/testdata`)) + } + + if co.dirs == nil { + return nil, fmt.Errorf("unknown file-filter: %s", fileFilter) + } + return co, nil +} + +func (fc *fileCollector) matchPatterns(path string, regexps []*regexp.Regexp) bool { + path = strings.ReplaceAll(path, "\\", "/") + for _, re := range regexps { + if re.MatchString(path) { + return true + } + } + return false +} + +func (fc *fileCollector) collectFiles() (res [][]string, err error) { + var batch []string + for _, dir := range fc.dirs { + err = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + include := len(fc.includePatterns) == 0 || fc.matchPatterns(path, fc.includePatterns) + exclude := fc.matchPatterns(path, fc.excludePatterns) + process := include && !exclude + if !process { + if d.IsDir() { + if exclude { + logVerbose("exclude dir %s", path) + return filepath.SkipDir + } + // for a directory, if it is not excluded explicitly, we should walk into + return nil + } + // for a file, we skip it if it shouldn't be processed + logVerbose("skip process %s", path) + return nil + } + if d.IsDir() { + // skip dir, we don't add dirs to the file list now + return nil + } + if len(batch) >= fc.batchSize { + res = append(res, batch) + batch = nil + } + batch = append(batch, path) + return nil + }) + if err != nil { + return nil, err + } + } + res = append(res, batch) + return res, nil +} + +// substArgFiles expands the {file-list} to a real file list for commands +func substArgFiles(args, files []string) []string { + for i, s := range args { + if s == "{file-list}" { + newArgs := append(args[:i], files...) + newArgs = append(newArgs, args[i+1:]...) + return newArgs + } + } + return args +} + +func exitWithCmdErrors(subCmd string, subArgs []string, cmdErrors []error) { + for _, err := range cmdErrors { + if err != nil { + if exitError, ok := err.(*exec.ExitError); ok { + exitCode := exitError.ExitCode() + log.Printf("run command failed (code=%d): %s %v", exitCode, subCmd, subArgs) + os.Exit(exitCode) + } else { + log.Fatalf("run command failed (err=%s) %s %v", err, subCmd, subArgs) + } + } + } +} + +func parseArgs() (mainOptions map[string]string, subCmd string, subArgs []string) { + mainOptions = map[string]string{} + for i := 1; i < len(os.Args); i++ { + arg := os.Args[i] + if arg == "" { + break + } + if arg[0] == '-' { + arg = strings.TrimPrefix(arg, "-") + arg = strings.TrimPrefix(arg, "-") + fields := strings.SplitN(arg, "=", 2) + if len(fields) == 1 { + mainOptions[fields[0]] = "1" + } else { + mainOptions[fields[0]] = fields[1] + } + } else { + subCmd = arg + subArgs = os.Args[i+1:] + break + } + } + return mainOptions, subCmd, subArgs +} + +func showUsage() { + fmt.Printf(`Usage: %[1]s [options] {command} [arguments] + +Options: + --verbose + --file-filter=go-own + --batch-size=100 + +Commands: + %[1]s gofmt ... + +Arguments: + {file-list} the file list + +Example: + %[1]s gofmt -s -d {file-list} + +`, "file-batch-exec") +} + +func newFileCollectorFromMainOptions(mainOptions map[string]string) (fc *fileCollector, err error) { + fileFilter := mainOptions["file-filter"] + if fileFilter == "" { + fileFilter = "go-own" + } + batchSize, _ := strconv.Atoi(mainOptions["batch-size"]) + if batchSize == 0 { + batchSize = 100 + } + + return newFileCollector(fileFilter, batchSize) +} + +func giteaFormatGoImports(files []string, doWriteFile bool) error { + for _, file := range files { + if err := codeformat.FormatGoImports(file, doWriteFile); err != nil { + log.Printf("failed to format go imports: %s, err=%v", file, err) + return err + } + } + return nil +} + +func main() { + mainOptions, subCmd, subArgs := parseArgs() + if subCmd == "" { + showUsage() + os.Exit(1) + } + optionLogVerbose = mainOptions["verbose"] != "" + + fc, err := newFileCollectorFromMainOptions(mainOptions) + if err != nil { + log.Fatalf("can not create file collector: %s", err.Error()) + } + + fileBatches, err := fc.collectFiles() + if err != nil { + log.Fatalf("can not collect files: %s", err.Error()) + } + + processed := 0 + var cmdErrors []error + for _, files := range fileBatches { + if len(files) == 0 { + break + } + substArgs := substArgFiles(subArgs, files) + logVerbose("batch cmd: %s %v", subCmd, substArgs) + switch subCmd { + case "gitea-fmt": + if slices.Contains(subArgs, "-d") { + log.Print("the -d option is not supported by gitea-fmt") + } + cmdErrors = append(cmdErrors, giteaFormatGoImports(files, slices.Contains(subArgs, "-w"))) + cmdErrors = append(cmdErrors, passThroughCmd("gofmt", append([]string{"-w", "-r", "interface{} -> any"}, substArgs...))) + cmdErrors = append(cmdErrors, passThroughCmd("go", append([]string{"run", os.Getenv("GOFUMPT_PACKAGE"), "-extra"}, substArgs...))) + default: + log.Fatalf("unknown cmd: %s %v", subCmd, subArgs) + } + processed += len(files) + } + + logVerbose("processed %d files", processed) + exitWithCmdErrors(subCmd, subArgs, cmdErrors) +} diff --git a/tools/gocovmerge.go b/tools/gocovmerge.go new file mode 100644 index 0000000000000..c6f74ed85cd3d --- /dev/null +++ b/tools/gocovmerge.go @@ -0,0 +1,118 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Copyright (c) 2015, Wade Simmons +// SPDX-License-Identifier: MIT + +// gocovmerge takes the results from multiple `go test -coverprofile` runs and +// merges them into one profile + +//go:build ignore + +package main + +import ( + "flag" + "fmt" + "io" + "log" + "os" + "sort" + + "golang.org/x/tools/cover" +) + +func mergeProfiles(p, merge *cover.Profile) { + if p.Mode != merge.Mode { + log.Fatalf("cannot merge profiles with different modes") + } + // Since the blocks are sorted, we can keep track of where the last block + // was inserted and only look at the blocks after that as targets for merge + startIndex := 0 + for _, b := range merge.Blocks { + startIndex = mergeProfileBlock(p, b, startIndex) + } +} + +func mergeProfileBlock(p *cover.Profile, pb cover.ProfileBlock, startIndex int) int { + sortFunc := func(i int) bool { + pi := p.Blocks[i+startIndex] + return pi.StartLine >= pb.StartLine && (pi.StartLine != pb.StartLine || pi.StartCol >= pb.StartCol) + } + + i := 0 + if sortFunc(i) != true { + i = sort.Search(len(p.Blocks)-startIndex, sortFunc) + } + i += startIndex + if i < len(p.Blocks) && p.Blocks[i].StartLine == pb.StartLine && p.Blocks[i].StartCol == pb.StartCol { + if p.Blocks[i].EndLine != pb.EndLine || p.Blocks[i].EndCol != pb.EndCol { + log.Fatalf("OVERLAP MERGE: %v %v %v", p.FileName, p.Blocks[i], pb) + } + switch p.Mode { + case "set": + p.Blocks[i].Count |= pb.Count + case "count", "atomic": + p.Blocks[i].Count += pb.Count + default: + log.Fatalf("unsupported covermode: '%s'", p.Mode) + } + } else { + if i > 0 { + pa := p.Blocks[i-1] + if pa.EndLine >= pb.EndLine && (pa.EndLine != pb.EndLine || pa.EndCol > pb.EndCol) { + log.Fatalf("OVERLAP BEFORE: %v %v %v", p.FileName, pa, pb) + } + } + if i < len(p.Blocks)-1 { + pa := p.Blocks[i+1] + if pa.StartLine <= pb.StartLine && (pa.StartLine != pb.StartLine || pa.StartCol < pb.StartCol) { + log.Fatalf("OVERLAP AFTER: %v %v %v", p.FileName, pa, pb) + } + } + p.Blocks = append(p.Blocks, cover.ProfileBlock{}) + copy(p.Blocks[i+1:], p.Blocks[i:]) + p.Blocks[i] = pb + } + return i + 1 +} + +func addProfile(profiles []*cover.Profile, p *cover.Profile) []*cover.Profile { + i := sort.Search(len(profiles), func(i int) bool { return profiles[i].FileName >= p.FileName }) + if i < len(profiles) && profiles[i].FileName == p.FileName { + mergeProfiles(profiles[i], p) + } else { + profiles = append(profiles, nil) + copy(profiles[i+1:], profiles[i:]) + profiles[i] = p + } + return profiles +} + +func dumpProfiles(profiles []*cover.Profile, out io.Writer) { + if len(profiles) == 0 { + return + } + fmt.Fprintf(out, "mode: %s\n", profiles[0].Mode) + for _, p := range profiles { + for _, b := range p.Blocks { + fmt.Fprintf(out, "%s:%d.%d,%d.%d %d %d\n", p.FileName, b.StartLine, b.StartCol, b.EndLine, b.EndCol, b.NumStmt, b.Count) + } + } +} + +func main() { + flag.Parse() + + var merged []*cover.Profile + + for _, file := range flag.Args() { + profiles, err := cover.ParseProfiles(file) + if err != nil { + log.Fatalf("failed to parse profile '%s': %v", file, err) + } + for _, p := range profiles { + merged = addProfile(merged, p) + } + } + + dumpProfiles(merged, os.Stdout) +} diff --git a/tools/test-echo.go b/tools/test-echo.go new file mode 100644 index 0000000000000..093364fcf821b --- /dev/null +++ b/tools/test-echo.go @@ -0,0 +1,20 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build ignore + +package main + +import ( + "fmt" + "io" + "os" +) + +func main() { + _, err := io.Copy(os.Stdout, os.Stdin) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v", err) + os.Exit(1) + } +} From 1f2b885740442ed3646006633c25dd40f0d6a45e Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Thu, 23 Oct 2025 22:10:07 +0700 Subject: [PATCH 05/14] Revert fixture and test changes from merges - not part of the Actions API feature --- models/fixtures/collaboration.yml | 12 ------------ models/fixtures/repo_unit.yml | 14 -------------- models/repo/collaboration_test.go | 2 +- modules/git/notes_test.go | 2 +- modules/setting/indexer_test.go | 2 +- 5 files changed, 3 insertions(+), 29 deletions(-) diff --git a/models/fixtures/collaboration.yml b/models/fixtures/collaboration.yml index 74f6f24c867f0..4c3ac367f6b59 100644 --- a/models/fixtures/collaboration.yml +++ b/models/fixtures/collaboration.yml @@ -4,18 +4,6 @@ user_id: 2 mode: 2 # write -- - id: 12 - repo_id: 2 - user_id: 4 - mode: 1 # read - -- - id: 13 - repo_id: 4 - user_id: 2 - mode: 1 # read - - id: 2 repo_id: 4 diff --git a/models/fixtures/repo_unit.yml b/models/fixtures/repo_unit.yml index d53a7c9edd4e8..f6b6252da1f88 100644 --- a/models/fixtures/repo_unit.yml +++ b/models/fixtures/repo_unit.yml @@ -733,17 +733,3 @@ type: 3 config: "{\"IgnoreWhitespaceConflicts\":false,\"AllowMerge\":true,\"AllowRebase\":true,\"AllowRebaseMerge\":true,\"AllowSquash\":true}" created_unix: 946684810 - -- - id: 111 - repo_id: 2 - type: 10 - config: "{}" - created_unix: 946684810 - -- - id: 112 - repo_id: 4 - type: 10 - config: "{}" - created_unix: 946684810 diff --git a/models/repo/collaboration_test.go b/models/repo/collaboration_test.go index 7b346b3ed6d45..7e06bffb72530 100644 --- a/models/repo/collaboration_test.go +++ b/models/repo/collaboration_test.go @@ -65,7 +65,7 @@ func TestRepository_IsCollaborator(t *testing.T) { } test(3, 2, true) test(3, unittest.NonexistentID, false) - test(4, 2, true) + test(4, 2, false) test(4, 4, true) } diff --git a/modules/git/notes_test.go b/modules/git/notes_test.go index 5abb68b102329..7db2dbc0b9e3b 100644 --- a/modules/git/notes_test.go +++ b/modules/git/notes_test.go @@ -47,5 +47,5 @@ func TestGetNonExistentNotes(t *testing.T) { note := Note{} err = GetNote(t.Context(), bareRepo1, "non_existent_sha", ¬e) assert.Error(t, err) - assert.ErrorAs(t, err, &ErrNotExist{}) + assert.IsType(t, ErrNotExist{}, err) } diff --git a/modules/setting/indexer_test.go b/modules/setting/indexer_test.go index 498f8752a2c0b..8f0437be8a0a0 100644 --- a/modules/setting/indexer_test.go +++ b/modules/setting/indexer_test.go @@ -65,7 +65,7 @@ func checkGlobMatch(t *testing.T, globstr string, list []indexerMatchList) { } } if !found { - assert.Equal(t, -1, m.position, "Test string `%s` doesn't match `%s` anywhere; expected @%d", m.value, globstr, m.position) + assert.Equal(t, m.position, -1, "Test string `%s` doesn't match `%s` anywhere; expected @%d", m.value, globstr, m.position) } } } From be0c0bd68f49c993c5d9c8d727057d7a0d1fc54a Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Thu, 23 Oct 2025 22:10:35 +0700 Subject: [PATCH 06/14] Fix test job ID and add LoadRun check in rerunJob --- routers/api/v1/repo/actions_run.go | 5 +++++ tests/integration/api_actions_run_test.go | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/routers/api/v1/repo/actions_run.go b/routers/api/v1/repo/actions_run.go index 5c69cc330722a..fc2f9008e04b4 100644 --- a/routers/api/v1/repo/actions_run.go +++ b/routers/api/v1/repo/actions_run.go @@ -531,6 +531,11 @@ func getRunJobsAndCurrent(ctx *context.APIContext, runID, jobIndex int64) (*acti } func rerunJob(ctx *context.APIContext, job *actions_model.ActionRunJob, shouldBlock bool) error { + if job.Run == nil { + if err := job.LoadRun(ctx); err != nil { + return err + } + } status := job.Status if !status.IsDone() || !job.Run.Status.IsDone() { return nil diff --git a/tests/integration/api_actions_run_test.go b/tests/integration/api_actions_run_test.go index c2d5fdab6f65d..af162aec261d8 100644 --- a/tests/integration/api_actions_run_test.go +++ b/tests/integration/api_actions_run_test.go @@ -272,7 +272,7 @@ func TestAPIActionsGetWorkflowJobLogs(t *testing.T) { session := loginUser(t, user.Name) token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) - req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs/198/logs", repo.FullName())). + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs/192/logs", repo.FullName())). AddTokenAuth(token) MakeRequest(t, req, http.StatusOK) From 09c44f42458e457a7deb075b1d9092bd0300e9f9 Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Thu, 23 Oct 2025 22:16:46 +0700 Subject: [PATCH 07/14] Drop check for jobs from other repo. --- routers/common/actions.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/routers/common/actions.go b/routers/common/actions.go index 8e5f80565cd51..2d03dace1dbea 100644 --- a/routers/common/actions.go +++ b/routers/common/actions.go @@ -63,10 +63,6 @@ func DownloadActionsRunAllJobLogs(ctx *context.Base, ctxRepo *repo_model.Reposit // Add each job's logs to the zip for _, job := range runJobs { - if job.Repo.ID != ctxRepo.ID { - continue // Skip jobs from other repos - } - if job.TaskID == 0 { continue // Skip jobs that haven't started } From aaf50824a7d98b7a41c71c613b0d49ba8bdbf625 Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Thu, 23 Oct 2025 22:22:55 +0700 Subject: [PATCH 08/14] Remove redundant Repo.CanWrite checks from action handlers - permissions are enforced at route level --- routers/api/v1/repo/actions_run.go | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/routers/api/v1/repo/actions_run.go b/routers/api/v1/repo/actions_run.go index fc2f9008e04b4..da51f362fdd87 100644 --- a/routers/api/v1/repo/actions_run.go +++ b/routers/api/v1/repo/actions_run.go @@ -107,11 +107,6 @@ func RerunWorkflowRun(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - if !ctx.Repo.CanWrite(unit.TypeActions) { - ctx.APIError(403, "User does not have write access to actions") - return - } - _, run, err := getRunID(ctx) if err != nil { if errors.Is(err, util.ErrNotExist) { @@ -192,11 +187,6 @@ func CancelWorkflowRun(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - if !ctx.Repo.CanWrite(unit.TypeActions) { - ctx.APIError(403, "User does not have write access to actions") - return - } - runID, _, err := getRunID(ctx) if err != nil { if errors.Is(err, util.ErrNotExist) { @@ -293,11 +283,6 @@ func ApproveWorkflowRun(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - if !ctx.Repo.CanWrite(unit.TypeActions) { - ctx.APIError(403, "User does not have write access to actions") - return - } - runID, _, err := getRunID(ctx) if err != nil { if errors.Is(err, util.ErrNotExist) { @@ -396,11 +381,6 @@ func RerunWorkflowJob(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - if !ctx.Repo.CanWrite(unit.TypeActions) { - ctx.APIError(403, "User does not have write access to actions") - return - } - runID, _, err := getRunID(ctx) if err != nil { if errors.Is(err, util.ErrNotExist) { From ef7f7e2a5cb152b7e0c391fc0f0f0f98e927da1d Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Thu, 23 Oct 2025 22:27:48 +0700 Subject: [PATCH 09/14] Move run time reset logic to shared actions_service.ResetRunTimes function --- routers/api/v1/repo/actions_run.go | 22 ++++++---------------- services/actions/rerun.go | 13 +++++++++++++ 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/routers/api/v1/repo/actions_run.go b/routers/api/v1/repo/actions_run.go index da51f362fdd87..d3e62575c4bd6 100644 --- a/routers/api/v1/repo/actions_run.go +++ b/routers/api/v1/repo/actions_run.go @@ -126,14 +126,9 @@ func RerunWorkflowRun(ctx *context.APIContext) { } // Reset run's start and stop time when it is done - if run.Status.IsDone() { - run.PreviousDuration = run.Duration() - run.Started = 0 - run.Stopped = 0 - if err := actions_model.UpdateRun(ctx, run, "started", "stopped", "previous_duration"); err != nil { - ctx.APIErrorInternal(err) - return - } + if err := actions_service.ResetRunTimes(ctx, run); err != nil { + ctx.APIErrorInternal(err) + return } jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) @@ -425,14 +420,9 @@ func RerunWorkflowJob(ctx *context.APIContext) { } // Reset run's start and stop time when it is done - if run.Status.IsDone() { - run.PreviousDuration = run.Duration() - run.Started = 0 - run.Stopped = 0 - if err := actions_model.UpdateRun(ctx, run, "started", "stopped", "previous_duration"); err != nil { - ctx.APIErrorInternal(err) - return - } + if err := actions_service.ResetRunTimes(ctx, run); err != nil { + ctx.APIErrorInternal(err) + return } // Get all jobs that need to be rerun (including dependencies) diff --git a/services/actions/rerun.go b/services/actions/rerun.go index 60f66509058f5..991b13b4cc753 100644 --- a/services/actions/rerun.go +++ b/services/actions/rerun.go @@ -4,10 +4,23 @@ package actions import ( + "context" + actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/modules/container" ) +// ResetRunTimes resets the start and stop times for a run when it is done, for rerun +func ResetRunTimes(ctx context.Context, run *actions_model.ActionRun) error { + if run.Status.IsDone() { + run.PreviousDuration = run.Duration() + run.Started = 0 + run.Stopped = 0 + return actions_model.UpdateRun(ctx, run, "started", "stopped", "previous_duration") + } + return nil +} + // GetAllRerunJobs get all jobs that need to be rerun when job should be rerun func GetAllRerunJobs(job *actions_model.ActionRunJob, allJobs []*actions_model.ActionRunJob) []*actions_model.ActionRunJob { rerunJobs := []*actions_model.ActionRunJob{job} From b8ab9115f8a61f9699c58d7ae64d455c9618b026 Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Thu, 23 Oct 2025 22:33:19 +0700 Subject: [PATCH 10/14] Remove 'latest' support from run ID parameter for consistency with other workflow APIs --- routers/api/v1/repo/actions_run.go | 41 ++++++++--------------- templates/swagger/v1_json.tmpl | 28 ++++++++-------- tests/integration/api_actions_run_test.go | 5 +-- 3 files changed, 29 insertions(+), 45 deletions(-) diff --git a/routers/api/v1/repo/actions_run.go b/routers/api/v1/repo/actions_run.go index d3e62575c4bd6..6c494b7b87fa1 100644 --- a/routers/api/v1/repo/actions_run.go +++ b/routers/api/v1/repo/actions_run.go @@ -94,8 +94,8 @@ func RerunWorkflowRun(ctx *context.APIContext) { // required: true // - name: run // in: path - // description: run ID or "latest" - // type: string + // description: run ID + // type: integer // required: true // responses: // "200": @@ -169,8 +169,8 @@ func CancelWorkflowRun(ctx *context.APIContext) { // required: true // - name: run // in: path - // description: run ID or "latest" - // type: string + // description: run ID + // type: integer // required: true // responses: // "200": @@ -265,8 +265,8 @@ func ApproveWorkflowRun(ctx *context.APIContext) { // required: true // - name: run // in: path - // description: run ID or "latest" - // type: string + // description: run ID + // type: integer // required: true // responses: // "200": @@ -358,8 +358,8 @@ func RerunWorkflowJob(ctx *context.APIContext) { // required: true // - name: run // in: path - // description: run ID or "latest" - // type: string + // description: run ID + // type: integer // required: true // - name: job_id // in: path @@ -442,19 +442,6 @@ func RerunWorkflowJob(ctx *context.APIContext) { // Helper functions func getRunID(ctx *context.APIContext) (int64, *actions_model.ActionRun, error) { - // if run param is "latest", get the latest run - if ctx.PathParam("run") == "latest" { - run, err := actions_model.GetLatestRun(ctx, ctx.Repo.Repository.ID) - if err != nil { - return 0, nil, err - } - if run == nil { - return 0, nil, util.ErrNotExist - } - return run.ID, run, nil - } - - // Otherwise get run by ID runID := ctx.PathParamInt64("run") run, has, err := db.GetByID[actions_model.ActionRun](ctx, runID) if err != nil { @@ -584,8 +571,8 @@ func GetWorkflowRunLogs(ctx *context.APIContext) { // required: true // - name: run // in: path - // description: run ID or "latest" - // type: string + // description: run ID + // type: integer // required: true // responses: // "200": @@ -631,8 +618,8 @@ func GetWorkflowJobLogs(ctx *context.APIContext) { // required: true // - name: run // in: path - // description: run ID or "latest" - // type: string + // description: run ID + // type: integer // required: true // - name: job_id // in: path @@ -703,8 +690,8 @@ func GetWorkflowRunLogsStream(ctx *context.APIContext) { // required: true // - name: run // in: path - // description: run ID or "latest" - // type: string + // description: run ID + // type: integer // required: true // - name: job // in: query diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index db80a61f2e44b..4fd1c75136a90 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -5292,8 +5292,8 @@ "required": true }, { - "type": "string", - "description": "run ID or \"latest\"", + "type": "integer", + "description": "run ID", "name": "run", "in": "path", "required": true @@ -5393,8 +5393,8 @@ "required": true }, { - "type": "string", - "description": "run ID or \"latest\"", + "type": "integer", + "description": "run ID", "name": "run", "in": "path", "required": true @@ -5506,8 +5506,8 @@ "required": true }, { - "type": "string", - "description": "run ID or \"latest\"", + "type": "integer", + "description": "run ID", "name": "run", "in": "path", "required": true @@ -5556,8 +5556,8 @@ "required": true }, { - "type": "string", - "description": "run ID or \"latest\"", + "type": "integer", + "description": "run ID", "name": "run", "in": "path", "required": true @@ -5612,8 +5612,8 @@ "required": true }, { - "type": "string", - "description": "run ID or \"latest\"", + "type": "integer", + "description": "run ID", "name": "run", "in": "path", "required": true @@ -5656,8 +5656,8 @@ "required": true }, { - "type": "string", - "description": "run ID or \"latest\"", + "type": "integer", + "description": "run ID", "name": "run", "in": "path", "required": true @@ -5770,8 +5770,8 @@ "required": true }, { - "type": "string", - "description": "run ID or \"latest\"", + "type": "integer", + "description": "run ID", "name": "run", "in": "path", "required": true diff --git a/tests/integration/api_actions_run_test.go b/tests/integration/api_actions_run_test.go index af162aec261d8..117415ea579c3 100644 --- a/tests/integration/api_actions_run_test.go +++ b/tests/integration/api_actions_run_test.go @@ -154,10 +154,7 @@ func TestAPIActionsRerunWorkflowRun(t *testing.T) { AddTokenAuth(token) MakeRequest(t, req, http.StatusNotFound) - // Test rerun with "latest" parameter - req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/latest/rerun", repo.FullName())). - AddTokenAuth(token) - MakeRequest(t, req, http.StatusOK) + } func TestAPIActionsRerunWorkflowRunPermissions(t *testing.T) { From f797bc78f19b0652f2cba0066dc2070ef2fb92cb Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Thu, 23 Oct 2025 22:38:20 +0700 Subject: [PATCH 11/14] Move rerunJob logic to shared actions_service.RerunJob function for reuse across API and WEBUI --- routers/api/v1/repo/actions_run.go | 39 ++++------------------ routers/web/repo/actions/view.go | 53 +++--------------------------- services/actions/rerun.go | 53 ++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 82 deletions(-) diff --git a/routers/api/v1/repo/actions_run.go b/routers/api/v1/repo/actions_run.go index 6c494b7b87fa1..cb1737a56edd2 100644 --- a/routers/api/v1/repo/actions_run.go +++ b/routers/api/v1/repo/actions_run.go @@ -141,10 +141,12 @@ func RerunWorkflowRun(ctx *context.APIContext) { for _, job := range jobs { // If the job has needs, it should be set to "blocked" status to wait for other jobs shouldBlock := len(job.Needs) > 0 - if err := rerunJob(ctx, job, shouldBlock); err != nil { + if err := actions_service.RerunJob(ctx, job, shouldBlock); err != nil { ctx.APIErrorInternal(err) return } + actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, job) + notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) } ctx.Status(200) @@ -431,10 +433,12 @@ func RerunWorkflowJob(ctx *context.APIContext) { for _, j := range rerunJobs { // Jobs other than the specified one should be set to "blocked" status shouldBlock := j.JobID != job.JobID - if err := rerunJob(ctx, j, shouldBlock); err != nil { + if err := actions_service.RerunJob(ctx, j, shouldBlock); err != nil { ctx.APIErrorInternal(err) return } + actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, j) + notify_service.WorkflowJobStatusUpdate(ctx, j.Run.Repo, j.Run.TriggerUser, j, nil) } ctx.Status(200) @@ -487,38 +491,7 @@ func getRunJobsAndCurrent(ctx *context.APIContext, runID, jobIndex int64) (*acti return jobs[0], jobs, nil } -func rerunJob(ctx *context.APIContext, job *actions_model.ActionRunJob, shouldBlock bool) error { - if job.Run == nil { - if err := job.LoadRun(ctx); err != nil { - return err - } - } - status := job.Status - if !status.IsDone() || !job.Run.Status.IsDone() { - return nil - } - job.TaskID = 0 - job.Status = actions_model.StatusWaiting - if shouldBlock { - job.Status = actions_model.StatusBlocked - } - job.Started = 0 - job.Stopped = 0 - - if err := db.WithTx(ctx, func(ctx stdCtx.Context) error { - _, err := actions_model.UpdateRunJob(ctx, job, nil, "task_id", "status", "started", "stopped") - return err - }); err != nil { - return err - } - - actions_service.CreateCommitStatusForRunJobs(ctx, job.Run, job) - actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, job) - notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) - - return nil -} // LogCursor represents a log cursor position type LogCursor struct { diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index f7e57c91a8324..713ea2900bd9c 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -476,10 +476,11 @@ func Rerun(ctx *context_module.Context) { 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 { + if err := actions_service.RerunJob(ctx, j, shouldBlockJob); err != nil { ctx.ServerError("RerunJob", err) return } + notify_service.WorkflowJobStatusUpdate(ctx, j.Run.Repo, j.Run.TriggerUser, j, nil) } ctx.JSONOK() return @@ -490,63 +491,17 @@ func Rerun(ctx *context_module.Context) { 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 { + if err := actions_service.RerunJob(ctx, j, shouldBlockJob); err != nil { ctx.ServerError("RerunJob", err) return } + notify_service.WorkflowJobStatusUpdate(ctx, j.Run.Repo, j.Run.TriggerUser, j, nil) } ctx.JSONOK() } -func rerunJob(ctx *context_module.Context, job *actions_model.ActionRunJob, shouldBlock bool) error { - status := job.Status - if !status.IsDone() || !job.Run.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) - 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) diff --git a/services/actions/rerun.go b/services/actions/rerun.go index 991b13b4cc753..fbd73fb5fbeff 100644 --- a/services/actions/rerun.go +++ b/services/actions/rerun.go @@ -5,9 +5,13 @@ package actions import ( "context" + "fmt" actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/util" + "xorm.io/builder" ) // ResetRunTimes resets the start and stop times for a run when it is done, for rerun @@ -21,6 +25,55 @@ func ResetRunTimes(ctx context.Context, run *actions_model.ActionRun) error { return nil } +// RerunJob reruns a job, handling concurrency and status updates +func RerunJob(ctx context.Context, job *actions_model.ActionRunJob, shouldBlock bool) error { + status := job.Status + if !status.IsDone() || !job.Run.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 = EvaluateJobConcurrencyFillModel(ctx, job.Run, job, vars) + if 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) + + return nil +} + // GetAllRerunJobs get all jobs that need to be rerun when job should be rerun func GetAllRerunJobs(job *actions_model.ActionRunJob, allJobs []*actions_model.ActionRunJob) []*actions_model.ActionRunJob { rerunJobs := []*actions_model.ActionRunJob{job} From 11db459f3394bdba870e6fb69c195b4e0a18813f Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Thu, 23 Oct 2025 22:39:15 +0700 Subject: [PATCH 12/14] Remove unused import in WEBUI actions view --- routers/web/repo/actions/view.go | 1 - 1 file changed, 1 deletion(-) diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 713ea2900bd9c..6b1c1d669b370 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -36,7 +36,6 @@ import ( "github.com/nektos/act/pkg/model" "gopkg.in/yaml.v3" - "xorm.io/builder" ) func getRunIndex(ctx *context_module.Context) int64 { From a18a42b0747d2ae12e55352cbc937e000e9c4088 Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Thu, 23 Oct 2025 22:40:30 +0700 Subject: [PATCH 13/14] Run make fmt to format code --- routers/api/v1/repo/actions_run.go | 2 -- routers/web/repo/actions/view.go | 2 -- services/actions/rerun.go | 1 + tests/integration/api_actions_run_test.go | 2 -- 4 files changed, 1 insertion(+), 6 deletions(-) diff --git a/routers/api/v1/repo/actions_run.go b/routers/api/v1/repo/actions_run.go index cb1737a56edd2..3e5824d1ee42f 100644 --- a/routers/api/v1/repo/actions_run.go +++ b/routers/api/v1/repo/actions_run.go @@ -491,8 +491,6 @@ func getRunJobsAndCurrent(ctx *context.APIContext, runID, jobIndex int64) (*acti return jobs[0], jobs, nil } - - // LogCursor represents a log cursor position type LogCursor struct { Step int `json:"step"` diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 6b1c1d669b370..0d94bc978221b 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -500,8 +500,6 @@ func Rerun(ctx *context_module.Context) { ctx.JSONOK() } - - 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 fbd73fb5fbeff..e2019aa17e7e3 100644 --- a/services/actions/rerun.go +++ b/services/actions/rerun.go @@ -11,6 +11,7 @@ import ( "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/util" + "xorm.io/builder" ) diff --git a/tests/integration/api_actions_run_test.go b/tests/integration/api_actions_run_test.go index 117415ea579c3..500cfb63d6c20 100644 --- a/tests/integration/api_actions_run_test.go +++ b/tests/integration/api_actions_run_test.go @@ -153,8 +153,6 @@ func TestAPIActionsRerunWorkflowRun(t *testing.T) { req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/999999/rerun", repo.FullName())). AddTokenAuth(token) MakeRequest(t, req, http.StatusNotFound) - - } func TestAPIActionsRerunWorkflowRunPermissions(t *testing.T) { From 91bd7c536fe4168e801d0f365960f36af6ca472b Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Fri, 24 Oct 2025 06:01:57 +0700 Subject: [PATCH 14/14] Safely handle map deletions in refreshAccesses to avoid iteration issues --- models/perm/access/access.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/models/perm/access/access.go b/models/perm/access/access.go index 6433c4675cfc3..068a122d0170b 100644 --- a/models/perm/access/access.go +++ b/models/perm/access/access.go @@ -105,8 +105,10 @@ func refreshAccesses(ctx context.Context, repo *repo_model.Repository, accessMap } newAccesses := make([]Access, 0, len(accessMap)) + keysToDelete := []int64{} for userID, ua := range accessMap { if ua.Mode < minMode && !ua.User.IsRestricted { + keysToDelete = append(keysToDelete, userID) continue } @@ -116,6 +118,9 @@ func refreshAccesses(ctx context.Context, repo *repo_model.Repository, accessMap Mode: ua.Mode, }) } + for _, uid := range keysToDelete { + delete(accessMap, uid) + } // Delete old accesses and insert new ones for repository. if _, err = db.DeleteByBean(ctx, &Access{RepoID: repo.ID}); err != nil {