From 80f8b9e6ccd6cd2b3df5d99556f1da5859ca1f89 Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Sat, 2 May 2026 09:22:14 +0700 Subject: [PATCH 01/13] feat: add Actions API endpoints for workflow run and job management - Cancel and approve workflow runs via POST /runs/{run}/cancel|approve - Download all job logs as zip via GET /runs/{run}/logs - Download individual job log via GET /runs/{run}/jobs/{job_id}/logs - Stream live log cursors via POST /runs/{run}/logs - Add CreatedAt field to ActionWorkflowRun API response - Extract shared log streaming and cancel logic into services/actions - Move streaming log types to modules/structs - Add Swagger documentation for all new endpoints - Add integration tests with subtests for all new endpoints Co-Authored-By: Claude Sonnet 4.6 --- modules/structs/repo_actions.go | 34 ++ routers/api/v1/api.go | 15 +- routers/api/v1/repo/actions_run.go | 444 ++++++++++++++++++++ routers/common/actions.go | 84 ++++ services/actions/cancel.go | 36 ++ services/actions/log.go | 80 ++++ services/actions/notifier.go | 3 +- services/convert/convert.go | 50 +-- templates/swagger/v1_json.tmpl | 471 +++++++++++++++------- tests/integration/api_actions_run_test.go | 356 +++++++++++----- 10 files changed, 1280 insertions(+), 293 deletions(-) create mode 100644 services/actions/cancel.go create mode 100644 services/actions/log.go diff --git a/modules/structs/repo_actions.go b/modules/structs/repo_actions.go index 4592c18ed6..7535fbf593 100644 --- a/modules/structs/repo_actions.go +++ b/modules/structs/repo_actions.go @@ -129,6 +129,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"` @@ -226,3 +228,35 @@ type RunDetails struct { RunURL string `json:"run_url"` HTMLURL string `json:"html_url"` } + +// ActionLogCursor represents a cursor position within a step's log +type ActionLogCursor struct { + Step int `json:"step"` + Cursor int64 `json:"cursor"` + Expanded bool `json:"expanded"` +} + +// ActionLogRequest is the request body for the streaming log endpoint +type ActionLogRequest struct { + LogCursors []ActionLogCursor `json:"logCursors"` +} + +// ActionLogStepLine represents a single log line within a step +type ActionLogStepLine struct { + Index int64 `json:"index"` + Message string `json:"message"` + Timestamp float64 `json:"timestamp"` +} + +// ActionLogStep represents log lines for a single step with cursor state +type ActionLogStep struct { + Step int `json:"step"` + Cursor int64 `json:"cursor"` + Lines []*ActionLogStepLine `json:"lines"` + Started int64 `json:"started"` +} + +// ActionLogResponse is the response body for the streaming log endpoint +type ActionLogResponse struct { + StepsLog []*ActionLogStep `json:"stepsLog"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index a8bfa0965e..846195893d 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1261,8 +1261,17 @@ func Routes() *web.Router { m.Delete("", reqToken(), reqRepoWriter(unit.TypeActions), repo.DeleteActionRun) m.Post("/rerun", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunWorkflowRun) m.Post("/rerun-failed-jobs", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunFailedWorkflowRun) - m.Get("/jobs", repo.ListWorkflowRunJobs) - m.Post("/jobs/{job_id}/rerun", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunWorkflowJob) + m.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.Get("/{job_id}/logs", repo.GetWorkflowJobLogs) + 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) }) }) @@ -1272,7 +1281,7 @@ func Routes() *web.Router { m.Delete("", reqRepoWriter(unit.TypeActions), repo.DeleteArtifact) }) m.Get("/artifacts/{artifact_id}/zip", repo.DownloadArtifact) - }, reqRepoReader(unit.TypeActions)) + }, reqRepoReader(unit.TypeActions), context.ReferencesGitRepo(true)) m.Group("/keys", func() { m.Combo("").Get(repo.ListDeployKeys). Post(bind(api.CreateKeyOption{}), repo.CreateDeployKey) diff --git a/routers/api/v1/repo/actions_run.go b/routers/api/v1/repo/actions_run.go index 64ac1a3ad5..e27cd469af 100644 --- a/routers/api/v1/repo/actions_run.go +++ b/routers/api/v1/repo/actions_run.go @@ -5,11 +5,19 @@ package repo import ( "errors" + "io" + "net/http" + "os" actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/json" + api "code.gitea.io/gitea/modules/structs" "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" + "code.gitea.io/gitea/services/convert" ) func DownloadActionsRunJobLogs(ctx *context.APIContext) { @@ -66,3 +74,439 @@ func DownloadActionsRunJobLogs(ctx *context.APIContext) { } } } + +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 + // type: integer + // required: true + // responses: + // "200": + // description: success + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + _, run, err := getRunID(ctx) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.APIErrorNotFound(err) + } else { + ctx.APIErrorInternal(err) + } + return + } + + jobs, err := getRunJobs(ctx, run) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + if err := actions_service.CancelRun(ctx, run, jobs); err != nil { + ctx.APIErrorInternal(err) + return + } + + updatedRun, has, err := db.GetByID[actions_model.ActionRun](ctx, run.ID) + if err != nil || !has { + ctx.APIErrorInternal(err) + return + } + + convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, updatedRun, nil) + if err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.JSON(http.StatusOK, convertedRun) +} + +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 + // type: integer + // required: true + // responses: + // "200": + // description: success + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + runID, run, err := getRunID(ctx) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.APIErrorNotFound(err) + } else { + ctx.APIErrorInternal(err) + } + return + } + + if !run.NeedApproval { + ctx.APIError(http.StatusBadRequest, "Run does not require approval") + return + } + + if err := actions_service.ApproveRuns(ctx, ctx.Repo.Repository, ctx.Doer, []int64{runID}); err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.APIErrorNotFound(err) + } else { + ctx.APIErrorInternal(err) + } + return + } + + // Reload run to reflect post-approval state. + updatedRun, has, err := db.GetByID[actions_model.ActionRun](ctx, runID) + if err != nil || !has { + ctx.APIErrorInternal(err) + return + } + + convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, updatedRun, nil) + if err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.JSON(http.StatusOK, convertedRun) +} + +func getRunID(ctx *context.APIContext) (int64, *actions_model.ActionRun, error) { + 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 getRunJobs(ctx *context.APIContext, run *actions_model.ActionRun) ([]*actions_model.ActionRunJob, error) { + run.Repo = ctx.Repo.Repository + jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID) + if err != nil { + return nil, err + } + for _, v := range jobs { + v.Run = run + } + return jobs, nil +} + +func getRunJobsAndCurrent(ctx *context.APIContext, run *actions_model.ActionRun, jobIndex int64) (*actions_model.ActionRunJob, []*actions_model.ActionRunJob, error) { + jobs, err := getRunJobs(ctx, run) + if err != nil { + return nil, nil, err + } + if len(jobs) == 0 { + return nil, nil, util.ErrNotExist + } + + if jobIndex >= 0 { + if jobIndex >= int64(len(jobs)) { + return nil, nil, util.ErrNotExist + } + return jobs[jobIndex], jobs, nil + } + return jobs[0], jobs, nil +} + +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 + // type: integer + // 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.APIErrorNotFound(err) + } 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.APIErrorNotFound(err) + } else { + ctx.APIErrorInternal(err) + } + return + } +} + +func GetWorkflowJobLogs(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run}/jobs/{job_id}/logs repository getWorkflowJobLogs + // --- + // summary: Download job logs as plain text + // produces: + // - text/plain + // 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 + // type: integer + // 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.APIErrorNotFound(err) + } else { + ctx.APIErrorInternal(err) + } + return + } + + jobID := ctx.PathParamInt64("job_id") + + job, err := actions_model.GetRunJobByRunAndID(ctx, runID, jobID) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.APIErrorNotFound(err) + } else { + ctx.APIErrorInternal(err) + } + return + } + + job.Repo = ctx.Repo.Repository + + if err = common.DownloadActionsRunJobLogs(ctx.Base, ctx.Repo.Repository, job); err != nil { + if errors.Is(err, util.ErrNotExist) || errors.Is(err, os.ErrNotExist) { + ctx.APIErrorNotFound(err) + } else { + ctx.APIErrorInternal(err) + } + return + } +} + +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 + // type: integer + // 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" + + _, run, err := getRunID(ctx) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.APIErrorNotFound(err) + } else { + ctx.APIErrorInternal(err) + } + return + } + + jobIndex := int64(-1) + if ctx.FormString("job") != "" { + jobIndex = int64(ctx.FormInt("job")) + } + + var req api.ActionLogRequest + if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil { + if errors.Is(err, io.EOF) { + req = api.ActionLogRequest{LogCursors: []api.ActionLogCursor{}} + } else { + ctx.APIError(http.StatusBadRequest, "Invalid request body") + return + } + } + + current, _, err := getRunJobsAndCurrent(ctx, run, jobIndex) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.APIErrorNotFound(err) + } 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 := &api.ActionLogResponse{ + StepsLog: make([]*api.ActionLogStep, 0), + } + + if task != nil { + logs, err := actions_service.ReadStepLogs(ctx, req.LogCursors, task, "Log has expired and is no longer available") + if err != nil { + ctx.APIErrorInternal(err) + return + } + response.StepsLog = append(response.StepsLog, logs...) + } + + ctx.JSON(http.StatusOK, response) +} diff --git a/routers/common/actions.go b/routers/common/actions.go index 2b83e5d842..bf514c81da 100644 --- a/routers/common/actions.go +++ b/routers/common/actions.go @@ -4,7 +4,11 @@ package common import ( + "archive/zip" + "errors" "fmt" + "io" + "os" "strings" actions_model "code.gitea.io/gitea/models/actions" @@ -26,6 +30,79 @@ func DownloadActionsRunJobLogsWithID(ctx *context.Base, ctxRepo *repo_model.Repo return DownloadActionsRunJobLogs(ctx, ctxRepo, job) } +func DownloadActionsRunAllJobLogs(ctx *context.Base, ctxRepo *repo_model.Repository, runID int64) error { + runJobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, ctxRepo.ID, runID) + if err != nil { + return fmt.Errorf("GetLatestAttemptJobsByRepoAndRunID: %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] + } + safeWorkflowName := strings.NewReplacer(`"`, "", "\r", "", "\n", "", "/", "-", `\`, "-").Replace(workflowName) + + // 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"`, safeWorkflowName, 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.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 || task.LogLength == 0 { + continue + } + + 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; sanitize to prevent Zip Slip + safeJobName := strings.NewReplacer("/", "-", `\`, "-", "..", "__").Replace(job.Name) + fileName := fmt.Sprintf("%s-%s-%d.log", safeWorkflowName, safeJobName, 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") @@ -49,8 +126,15 @@ func DownloadActionsRunJobLogs(ctx *context.Base, ctxRepo *repo_model.Repository return util.NewNotExistErrorf("logs have been cleaned up") } + if task.LogLength == 0 { + return util.NewNotExistErrorf("logs not found") + } + reader, err := actions.OpenLogs(ctx, task.LogInStorage, task.LogFilename) if err != nil { + if errors.Is(err, os.ErrNotExist) || errors.Is(err, util.ErrNotExist) { + return util.NewNotExistErrorf("logs not found") + } return fmt.Errorf("OpenLogs: %w", err) } defer reader.Close() diff --git a/services/actions/cancel.go b/services/actions/cancel.go new file mode 100644 index 0000000000..9289acde4a --- /dev/null +++ b/services/actions/cancel.go @@ -0,0 +1,36 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + "fmt" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" +) + +// CancelRun cancels all cancellable jobs in a run, updates commit statuses, +// and fires downstream notifications including job-emitter queue entries. +func CancelRun(ctx context.Context, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) error { + var updatedJobs []*actions_model.ActionRunJob + if err := db.WithTx(ctx, func(ctx context.Context) error { + cancelled, err := actions_model.CancelJobs(ctx, jobs) + if err != nil { + return fmt.Errorf("CancelJobs: %w", err) + } + updatedJobs = cancelled + return nil + }); err != nil { + return err + } + + CreateCommitStatusForRunJobs(ctx, run, jobs...) + EmitJobsIfReadyByJobs(updatedJobs) + NotifyWorkflowJobsStatusUpdate(ctx, updatedJobs...) + if len(updatedJobs) > 0 { + NotifyWorkflowRunStatusUpdateWithReload(ctx, run.RepoID, run.ID) + } + return nil +} diff --git a/services/actions/log.go b/services/actions/log.go new file mode 100644 index 0000000000..088a1f512b --- /dev/null +++ b/services/actions/log.go @@ -0,0 +1,80 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + "fmt" + "time" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/modules/actions" + api "code.gitea.io/gitea/modules/structs" +) + +// ReadStepLogs reads log lines for the given cursor positions from a task. +// expiredMessage is used as the log content when the task's logs have expired. +func ReadStepLogs(ctx context.Context, cursors []api.ActionLogCursor, task *actions_model.ActionTask, expiredMessage string) ([]*api.ActionLogStep, error) { + var logs []*api.ActionLogStep + steps := actions.FullSteps(task) + + for _, cursor := range cursors { + if !cursor.Expanded { + continue + } + if cursor.Step >= len(steps) { + continue + } + step := steps[cursor.Step] + + if task.LogExpired { + if cursor.Cursor == 0 { + logs = append(logs, &api.ActionLogStep{ + Step: cursor.Step, + Cursor: 1, + Lines: []*api.ActionLogStepLine{{ + Index: 1, + Message: expiredMessage, + Timestamp: float64(task.Updated.AsTime().UnixNano()) / float64(time.Second), + }}, + Started: int64(step.Started), + }) + } + continue + } + + logLines := make([]*api.ActionLogStepLine, 0) + index := step.LogIndex + cursor.Cursor + validCursor := cursor.Cursor >= 0 && + // !(cursor.Cursor < step.LogLength) when the frontend tries to fetch the next + // line before it's ready — return same cursor and empty lines to let caller retry. + cursor.Cursor < step.LogLength && + // !(index < len(task.LogIndexes)) when task data is older than step data. + 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, fmt.Errorf("actions.ReadLogs: %w", err) + } + for i, row := range logRows { + logLines = append(logLines, &api.ActionLogStepLine{ + Index: cursor.Cursor + int64(i) + 1, // 1-based + Message: row.Content, + Timestamp: float64(row.Time.AsTime().UnixNano()) / float64(time.Second), + }) + } + } + + logs = append(logs, &api.ActionLogStep{ + Step: cursor.Step, + Cursor: cursor.Cursor + int64(len(logLines)), + Lines: logLines, + Started: int64(step.Started), + }) + } + return logs, nil +} diff --git a/services/actions/notifier.go b/services/actions/notifier.go index 4b2e87afad..c3b2003b3c 100644 --- a/services/actions/notifier.go +++ b/services/actions/notifier.go @@ -815,8 +815,7 @@ func (n *actionsNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *rep log.Error("GetActionWorkflow: %v", err) return } - run.Repo = repo - convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil) + convertedRun, err := convert.ToActionWorkflowRun(ctx, repo, run, nil) if err != nil { log.Error("ToActionWorkflowRun: %v", err) return diff --git a/services/convert/convert.go b/services/convert/convert.go index d73f0aafff..6cace02678 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -28,7 +28,6 @@ import ( "code.gitea.io/gitea/modules/actions" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/git" - "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" @@ -227,18 +226,14 @@ func ToTag(repo *repo_model.Repository, t *git.Tag) *api.Tag { } } -// ToActionTask convert an actions_model.ActionTask to an api.ActionTask +// ToActionTask convert a actions_model.ActionTask to an api.ActionTask func ToActionTask(ctx context.Context, t *actions_model.ActionTask) (*api.ActionTask, error) { - // don't need Steps here, only need to load job and its run - if err := t.LoadJob(ctx); err != nil { - return nil, err - } - if err := t.Job.LoadRun(ctx); err != nil { - return nil, err - } - if err := t.Job.Run.LoadRepo(ctx); err != nil { + if err := t.LoadAttributes(ctx); err != nil { return nil, err } + + url := strings.TrimSuffix(setting.AppURL, "/") + t.GetRunLink() + return &api.ActionTask{ ID: t.ID, Name: t.Job.Name, @@ -249,25 +244,23 @@ func ToActionTask(ctx context.Context, t *actions_model.ActionTask) (*api.Action DisplayTitle: t.Job.Run.Title, Status: t.Status.String(), WorkflowID: t.Job.Run.WorkflowID, - URL: httplib.MakeAbsoluteURL(ctx, t.Job.Run.Link()), + URL: url, CreatedAt: t.Created.AsLocalTime(), UpdatedAt: t.Updated.AsLocalTime(), RunStartedAt: t.Started.AsLocalTime(), }, nil } -func ToActionWorkflowRun(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt) (_ *api.ActionWorkflowRun, err error) { - if err := run.LoadRepo(ctx); err != nil { - return nil, err - } - if err := run.LoadTriggerUser(ctx); err != nil { +func ToActionWorkflowRun(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt) (*api.ActionWorkflowRun, error) { + if err := run.LoadAttributes(ctx); err != nil { return nil, err } if attempt == nil { - attempt, _, err = run.GetLatestAttempt(ctx) - if err != nil { + if latestAttempt, has, err := run.GetLatestAttempt(ctx); err != nil { return nil, err + } else if has { + attempt = latestAttempt } } @@ -283,7 +276,6 @@ func ToActionWorkflowRun(ctx context.Context, run *actions_model.ActionRun, atte var previousAttemptURL *string if attempt != nil { - attempt.Run = run if err := attempt.LoadAttributes(ctx); err != nil { return nil, err } @@ -293,17 +285,19 @@ func ToActionWorkflowRun(ctx context.Context, run *actions_model.ActionRun, atte completedAt = attempt.Stopped.AsLocalTime() triggerUser = attempt.TriggerUser if attempt.Attempt > 1 { - previousAttemptURL = new(fmt.Sprintf("%s/actions/runs/%d/attempts/%d", run.Repo.APIURL(ctx), run.ID, attempt.Attempt-1)) + url := fmt.Sprintf("%s/actions/runs/%d/attempts/%d", repo.APIURL(), run.ID, attempt.Attempt-1) + previousAttemptURL = &url } } return &api.ActionWorkflowRun{ ID: run.ID, - URL: fmt.Sprintf("%s/actions/runs/%d", run.Repo.APIURL(ctx), run.ID), + URL: fmt.Sprintf("%s/actions/runs/%d", repo.APIURL(), run.ID), PreviousAttemptURL: previousAttemptURL, - HTMLURL: run.HTMLURL(ctx), + HTMLURL: run.HTMLURL(), RunNumber: run.Index, RunAttempt: runAttempt, + CreatedAt: run.Created.AsLocalTime(), StartedAt: startedAt, CompletedAt: completedAt, Event: run.TriggerEvent, @@ -313,7 +307,7 @@ func ToActionWorkflowRun(ctx context.Context, run *actions_model.ActionRun, atte Status: status, Conclusion: conclusion, Path: fmt.Sprintf("%s@%s", run.WorkflowID, run.Ref), - Repository: ToRepo(ctx, run.Repo, access_model.Permission{AccessMode: perm.AccessModeNone}), + Repository: ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeNone}), TriggerActor: ToUser(ctx, triggerUser, nil), Actor: ToUser(ctx, actor, nil), }, nil @@ -411,11 +405,11 @@ func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, task return &api.ActionWorkflowJob{ ID: job.ID, // missing api endpoint for this location - URL: fmt.Sprintf("%s/actions/jobs/%d", repo.APIURL(ctx), job.ID), - HTMLURL: fmt.Sprintf("%s/jobs/%d", job.Run.HTMLURL(ctx), job.ID), + URL: fmt.Sprintf("%s/actions/jobs/%d", repo.APIURL(), job.ID), + HTMLURL: fmt.Sprintf("%s/jobs/%d", job.Run.HTMLURL(), job.ID), RunID: job.RunID, // Missing api endpoint for this location, artifacts are available under a nested url - RunURL: fmt.Sprintf("%s/actions/runs/%d", repo.APIURL(ctx), job.RunID), + RunURL: fmt.Sprintf("%s/actions/runs/%d", repo.APIURL(), job.RunID), Name: job.Name, Labels: job.RunsOn, RunAttempt: job.Attempt, @@ -715,7 +709,7 @@ func ToOrganization(ctx context.Context, org *organization.Organization) *api.Or Description: org.Description, Website: org.Website, Location: org.Location, - Visibility: api.UserVisibility(org.Visibility.String()), + Visibility: org.Visibility.String(), RepoAdminChangeTeamAccess: org.RepoAdminChangeTeamAccess, } } @@ -744,7 +738,7 @@ func ToTeams(ctx context.Context, teams []*organization.Team, loadOrgs bool) ([] Description: t.Description, IncludesAllRepositories: t.IncludesAllRepositories, CanCreateOrgRepo: t.CanCreateOrgRepo, - Permission: api.AccessLevelName(t.AccessMode.ToString()), + Permission: t.AccessMode.ToString(), Units: t.GetUnitNames(), UnitsMap: t.GetUnitsMap(), } diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 312eb25fd8..1491333ce3 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -5451,6 +5451,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": "integer", + "description": "run ID", + "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": [ @@ -5627,6 +5676,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": "integer", + "description": "run ID", + "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": [ @@ -5706,6 +5804,56 @@ } } }, + "/repos/{owner}/{repo}/actions/runs/{run}/jobs/{job_id}/logs": { + "get": { + "produces": [ + "text/plain" + ], + "tags": [ + "repository" + ], + "summary": "Download job logs as plain text", + "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": "integer", + "description": "run ID", + "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": [ @@ -5768,6 +5916,164 @@ } } }, + "/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": "integer", + "description": "run ID", + "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": "integer", + "description": "run ID", + "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": [ @@ -22126,6 +22432,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" @@ -22359,14 +22670,12 @@ "type": "object", "properties": { "permission": { - "description": "Permission level to grant the collaborator\nread RepoWritePermissionRead\nwrite RepoWritePermissionWrite\nadmin RepoWritePermissionAdmin", "type": "string", "enum": [ "read", "write", "admin" ], - "x-go-enum-desc": "read RepoWritePermissionRead\nwrite RepoWritePermissionWrite\nadmin RepoWritePermissionAdmin", "x-go-name": "Permission" } }, @@ -23937,15 +24246,6 @@ "format": "int64", "x-go-name": "Milestone" }, - "projects": { - "description": "list of project ids", - "type": "array", - "items": { - "type": "integer", - "format": "int64" - }, - "x-go-name": "Projects" - }, "ref": { "type": "string", "x-go-name": "Ref" @@ -24138,14 +24438,13 @@ "x-go-name": "UserName" }, "visibility": { - "description": "possible values are `public` (default), `limited` or `private`\npublic UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", + "description": "possible values are `public` (default), `limited` or `private`", "type": "string", "enum": [ "public", "limited", "private" ], - "x-go-enum-desc": "public UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "x-go-name": "Visibility" }, "website": { @@ -24430,13 +24729,12 @@ "x-go-name": "Name" }, "object_format_name": { - "description": "ObjectFormatName of the underlying git repository, empty string for default (sha1)\nsha1 ObjectFormatSHA1\nsha256 ObjectFormatSHA256", + "description": "ObjectFormatName of the underlying git repository, empty string for default (sha1)", "type": "string", "enum": [ "sha1", "sha256" ], - "x-go-enum-desc": "sha1 ObjectFormatSHA1\nsha256 ObjectFormatSHA256", "x-go-name": "ObjectFormatName" }, "private": { @@ -24589,7 +24887,6 @@ "write", "admin" ], - "x-go-enum-desc": "read RepoWritePermissionRead\nwrite RepoWritePermissionWrite\nadmin RepoWritePermissionAdmin", "x-go-name": "Permission" }, "units": { @@ -24684,14 +24981,8 @@ "x-go-name": "Username" }, "visibility": { - "description": "User visibility level: public, limited, or private\npublic UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", + "description": "User visibility level: public, limited, or private", "type": "string", - "enum": [ - "public", - "limited", - "private" - ], - "x-go-enum-desc": "public UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "x-go-name": "Visibility" } }, @@ -25221,15 +25512,6 @@ "format": "int64", "x-go-name": "Milestone" }, - "projects": { - "description": "list of project ids to set (replaces existing projects)", - "type": "array", - "items": { - "type": "integer", - "format": "int64" - }, - "x-go-name": "Projects" - }, "ref": { "type": "string", "x-go-name": "Ref" @@ -25343,14 +25625,13 @@ "x-go-name": "RepoAdminChangeTeamAccess" }, "visibility": { - "description": "possible values are `public`, `limited` or `private`\npublic UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", + "description": "possible values are `public`, `limited` or `private`", "type": "string", "enum": [ "public", "limited", "private" ], - "x-go-enum-desc": "public UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "x-go-name": "Visibility" }, "website": { @@ -25634,21 +25915,6 @@ "type": "string", "x-go-name": "MirrorInterval" }, - "mirror_password": { - "description": "authentication password for the remote repository (mirrors)", - "type": "string", - "x-go-name": "MirrorPassword" - }, - "mirror_token": { - "description": "authentication token for the remote repository (mirrors)", - "type": "string", - "x-go-name": "MirrorToken" - }, - "mirror_username": { - "description": "authentication username for the remote repository (mirrors)", - "type": "string", - "x-go-name": "MirrorUsername" - }, "name": { "description": "name of the repository", "type": "string", @@ -25739,7 +26005,6 @@ "write", "admin" ], - "x-go-enum-desc": "read RepoWritePermissionRead\nwrite RepoWritePermissionWrite\nadmin RepoWritePermissionAdmin", "x-go-name": "Permission" }, "units": { @@ -25870,14 +26135,8 @@ "x-go-name": "SourceID" }, "visibility": { - "description": "User visibility level: public, limited, or private\npublic UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", + "description": "User visibility level: public, limited, or private", "type": "string", - "enum": [ - "public", - "limited", - "private" - ], - "x-go-enum-desc": "public UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "x-go-name": "Visibility" }, "website": { @@ -26779,13 +27038,6 @@ "format": "int64", "x-go-name": "PinOrder" }, - "projects": { - "type": "array", - "items": { - "$ref": "#/definitions/Project" - }, - "x-go-name": "Projects" - }, "pull_request": { "$ref": "#/definitions/PullRequestMeta" }, @@ -27835,14 +28087,8 @@ "x-go-name": "UserName" }, "visibility": { - "description": "The visibility level of the organization (public, limited, private)\npublic UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", + "description": "The visibility level of the organization (public, limited, private)", "type": "string", - "enum": [ - "public", - "limited", - "private" - ], - "x-go-enum-desc": "public UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "x-go-name": "Visibility" }, "website": { @@ -28138,67 +28384,6 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, - "Project": { - "description": "Project represents a project", - "type": "object", - "properties": { - "closed_at": { - "type": "string", - "format": "date-time", - "x-go-name": "Closed" - }, - "created_at": { - "type": "string", - "format": "date-time", - "x-go-name": "Created" - }, - "creator_id": { - "description": "CreatorID is the user who created the project", - "type": "integer", - "format": "int64", - "x-go-name": "CreatorID" - }, - "description": { - "description": "Description provides details about the project", - "type": "string", - "x-go-name": "Description" - }, - "id": { - "description": "ID is the unique identifier for the project", - "type": "integer", - "format": "int64", - "x-go-name": "ID" - }, - "is_closed": { - "description": "IsClosed indicates if the project is closed", - "type": "boolean", - "x-go-name": "IsClosed" - }, - "owner_id": { - "description": "OwnerID is the owner of the project (for org-level projects)", - "type": "integer", - "format": "int64", - "x-go-name": "OwnerID" - }, - "repo_id": { - "description": "RepoID is the repository this project belongs to (for repo-level projects)", - "type": "integer", - "format": "int64", - "x-go-name": "RepoID" - }, - "title": { - "description": "Title is the title of the project", - "type": "string", - "x-go-name": "Title" - }, - "updated_at": { - "type": "string", - "format": "date-time", - "x-go-name": "Updated" - } - }, - "x-go-package": "code.gitea.io/gitea/modules/structs" - }, "PublicKey": { "description": "PublicKey publickey is a user key to push code to repository", "type": "object", @@ -28887,16 +29072,8 @@ "type": "object", "properties": { "permission": { - "description": "Permission level of the collaborator\nnone AccessLevelNameNone\nread AccessLevelNameRead\nwrite AccessLevelNameWrite\nadmin AccessLevelNameAdmin\nowner AccessLevelNameOwner", + "description": "Permission level of the collaborator", "type": "string", - "enum": [ - "none", - "read", - "write", - "admin", - "owner" - ], - "x-go-enum-desc": "none AccessLevelNameNone\nread AccessLevelNameRead\nwrite AccessLevelNameWrite\nadmin AccessLevelNameAdmin\nowner AccessLevelNameOwner", "x-go-name": "Permission" }, "role_name": { @@ -29186,13 +29363,12 @@ "x-go-name": "Name" }, "object_format_name": { - "description": "ObjectFormatName of the underlying git repository\nsha1 ObjectFormatSHA1\nsha256 ObjectFormatSHA256", + "description": "ObjectFormatName of the underlying git repository", "type": "string", "enum": [ "sha1", "sha256" ], - "x-go-enum-desc": "sha1 ObjectFormatSHA1\nsha256 ObjectFormatSHA256", "x-go-name": "ObjectFormatName" }, "open_issues_count": { @@ -29566,7 +29742,6 @@ "admin", "owner" ], - "x-go-enum-desc": "none AccessLevelNameNone\nread AccessLevelNameRead\nwrite AccessLevelNameWrite\nadmin AccessLevelNameAdmin\nowner AccessLevelNameOwner", "x-go-name": "Permission" }, "units": { @@ -30113,14 +30288,8 @@ "x-go-name": "StarredRepos" }, "visibility": { - "description": "User visibility level option: public, limited, private\npublic UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", + "description": "User visibility level option: public, limited, private", "type": "string", - "enum": [ - "public", - "limited", - "private" - ], - "x-go-enum-desc": "public UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "x-go-name": "Visibility" }, "website": { diff --git a/tests/integration/api_actions_run_test.go b/tests/integration/api_actions_run_test.go index 88a9c50f72..1efb18c41c 100644 --- a/tests/integration/api_actions_run_test.go +++ b/tests/integration/api_actions_run_test.go @@ -4,10 +4,14 @@ package integration import ( + "encoding/base64" "fmt" "net/http" + "net/url" "slices" + "strings" "testing" + "time" actions_model "code.gitea.io/gitea/models/actions" auth_model "code.gitea.io/gitea/models/auth" @@ -15,35 +19,17 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/json" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/timeutil" - "code.gitea.io/gitea/tests" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestAPIActionsWorkflowRun(t *testing.T) { +func TestAPIActionsGetWorkflowRun(t *testing.T) { defer prepareTestEnvActionsArtifacts(t)() - t.Run("GetWorkflowRun", testAPIActionsGetWorkflowRun) - t.Run("GetWorkflowJob", testAPIActionsGetWorkflowJob) - t.Run("ListUserWorkflows", testAPIActionsListUserWorkflows) - t.Run("ListRepoWorkflows", testAPIActionsListRepoWorkflows) - t.Run("DeleteRunCheckPermission", testAPIActionsDeleteRunCheckPermission) - t.Run("DeleteRunRunning", testAPIActionsDeleteRunRunning) - t.Run("DeleteRunGeneral", testAPIActionsDeleteRunGeneral) - t.Run("RerunWorkflowRun", func(t *testing.T) { - defer tests.PrepareTestEnv(t)() - testAPIActionsRerunWorkflowRun(t) - }) - t.Run("RerunWorkflowJob", func(t *testing.T) { - defer tests.PrepareTestEnv(t)() - testAPIActionsRerunWorkflowJob(t) - }) -} - -func testAPIActionsGetWorkflowRun(t *testing.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) @@ -74,9 +60,13 @@ func testAPIActionsGetWorkflowRun(t *testing.T) { }) require.NoError(t, err) - req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs", repo.FullName())).AddTokenAuth(token) + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs", repo.FullName())). + AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) - jobList := DecodeJSON(t, resp, &api.ActionWorkflowJobsResponse{}) + + var jobList api.ActionWorkflowJobsResponse + err = json.Unmarshal(resp.Body.Bytes(), &jobList) + require.NoError(t, err) job198Idx := slices.IndexFunc(jobList.Entries, func(job *api.ActionWorkflowJob) bool { return job.ID == 198 }) require.NotEqual(t, -1, job198Idx, "expected to find job 198 in run 795 jobs list") @@ -86,7 +76,9 @@ func testAPIActionsGetWorkflowRun(t *testing.T) { }) } -func testAPIActionsGetWorkflowJob(t *testing.T) { +func TestAPIActionsGetWorkflowJob(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) @@ -103,7 +95,9 @@ func testAPIActionsGetWorkflowJob(t *testing.T) { MakeRequest(t, req, http.StatusNotFound) } -func testAPIActionsDeleteRunCheckPermission(t *testing.T) { +func TestAPIActionsDeleteRunCheckPermission(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) @@ -111,7 +105,9 @@ func testAPIActionsDeleteRunCheckPermission(t *testing.T) { testAPIActionsDeleteRun(t, repo, token, http.StatusNotFound) } -func testAPIActionsDeleteRunGeneral(t *testing.T) { +func TestAPIActionsDeleteRun(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) @@ -126,7 +122,9 @@ func testAPIActionsDeleteRunGeneral(t *testing.T) { testAPIActionsDeleteRun(t, repo, token, http.StatusNotFound) } -func testAPIActionsDeleteRunRunning(t *testing.T) { +func TestAPIActionsDeleteRunRunning(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) @@ -144,17 +142,22 @@ func testAPIActionsDeleteRun(t *testing.T, repo *repo_model.Repository, token st } func testAPIActionsDeleteRunListArtifacts(t *testing.T, repo *repo_model.Repository, token string, artifacts int) { - req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/artifacts", repo.FullName())).AddTokenAuth(token) + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/artifacts", repo.FullName())). + AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) - listResp := DecodeJSON(t, resp, &api.ActionArtifactsResponse{}) + var listResp api.ActionArtifactsResponse + err := json.Unmarshal(resp.Body.Bytes(), &listResp) + assert.NoError(t, err) assert.Len(t, listResp.Entries, artifacts) } func testAPIActionsDeleteRunListTasks(t *testing.T, repo *repo_model.Repository, token string, expected bool) { - req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/tasks", repo.FullName())).AddTokenAuth(token) + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/tasks", repo.FullName())). + AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) - listResp := DecodeJSON(t, resp, &api.ActionTaskResponse{}) - + var listResp api.ActionTaskResponse + err := json.Unmarshal(resp.Body.Bytes(), &listResp) + assert.NoError(t, err) findTask1 := false findTask2 := false for _, entry := range listResp.Entries { @@ -171,7 +174,9 @@ func testAPIActionsDeleteRunListTasks(t *testing.T, repo *repo_model.Repository, assert.Equal(t, expected, findTask2) } -func testAPIActionsRerunWorkflowRun(t *testing.T) { +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}) @@ -191,10 +196,13 @@ func testAPIActionsRerunWorkflowRun(t *testing.T) { 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) + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/rerun", repo.FullName())). + AddTokenAuth(writeToken) resp := MakeRequest(t, req, http.StatusCreated) - rerunResp := DecodeJSON(t, resp, &api.ActionWorkflowRun{}) + 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) @@ -232,7 +240,139 @@ func testAPIActionsRerunWorkflowRun(t *testing.T) { }) } -func testAPIActionsRerunWorkflowJob(t *testing.T) { +func TestAPIActionsCancelWorkflowRun(t *testing.T) { + defer prepareTestEnvActionsArtifacts(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + ownerSession := loginUser(t, owner.Name) + ownerToken := getTokenForLoggedInUser(t, ownerSession, auth_model.AccessTokenScopeWriteRepository) + + t.Run("Success", func(t *testing.T) { + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/793/cancel", repo.FullName())). + AddTokenAuth(ownerToken) + MakeRequest(t, req, http.StatusOK) + }) + + t.Run("NotFound", func(t *testing.T) { + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/999999/cancel", repo.FullName())). + AddTokenAuth(ownerToken) + MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("ForbiddenWithoutPermission", func(t *testing.T) { + // user2 is not the owner of repo4 (owned by user5) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + user2Session := loginUser(t, user2.Name) + user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/793/cancel", repo.FullName())). + AddTokenAuth(user2Token) + MakeRequest(t, req, http.StatusForbidden) + }) +} + +func TestAPIActionsApproveWorkflowRun(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + // user2 is the owner of the base repo + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + user2Session := loginUser(t, user2.Name) + user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + // user4 is the owner of the fork repo + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + user4Token := getTokenForLoggedInUser(t, loginUser(t, user4.Name), auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + + apiBaseRepo := createActionsTestRepo(t, user2Token, "approve-workflow-run", false) + baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiBaseRepo.ID}) + user2APICtx := NewAPITestContext(t, baseRepo.OwnerName, baseRepo.Name, auth_model.AccessTokenScopeWriteRepository) + defer doAPIDeleteRepository(user2APICtx)(t) + + runner := newMockRunner() + runner.registerAsRepoRunner(t, baseRepo.OwnerName, baseRepo.Name, "mock-runner", []string{"ubuntu-latest"}, false) + + // init workflow + wfTreePath := ".gitea/workflows/approve.yml" + wfFileContent := `name: Approve +on: pull_request +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo test +` + opts := getWorkflowCreateFileOptions(user2, baseRepo.DefaultBranch, "create %s"+wfTreePath, wfFileContent) + createWorkflowFile(t, user2Token, baseRepo.OwnerName, baseRepo.Name, wfTreePath, opts) + + // user4 forks the repo + forkName := "approve-workflow-run-fork" + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/forks", baseRepo.OwnerName, baseRepo.Name), + &api.CreateForkOption{ + Name: &forkName, + }).AddTokenAuth(user4Token) + resp := MakeRequest(t, req, http.StatusAccepted) + apiForkRepo := DecodeJSON(t, resp, &api.Repository{}) + forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiForkRepo.ID}) + user4APICtx := NewAPITestContext(t, user4.Name, forkRepo.Name, auth_model.AccessTokenScopeWriteRepository) + defer doAPIDeleteRepository(user4APICtx)(t) + + // user4 creates a pull request from a branch + doAPICreateFile(user4APICtx, "test.txt", &api.CreateFileOptions{ + FileOptions: api.FileOptions{ + NewBranchName: "feature/test", + Message: "create test.txt", + Author: api.Identity{ + Name: user4.Name, + Email: user4.Email, + }, + Committer: api.Identity{ + Name: user4.Name, + Email: user4.Email, + }, + Dates: api.CommitDateOptions{ + Author: time.Now(), + Committer: time.Now(), + }, + }, + ContentBase64: base64.StdEncoding.EncodeToString([]byte("test")), + })(t) + _, err := doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":feature/test")(t) + assert.NoError(t, err) + + // check run + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, TriggerUserID: user4.ID}) + assert.True(t, run.NeedApproval) + assert.Equal(t, actions_model.StatusBlocked, run.Status) + + // Test approve workflow run via API + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/approve", baseRepo.FullName(), run.ID)). + AddTokenAuth(user2Token) + MakeRequest(t, req, http.StatusOK) + + // Verify run was approved and jobs unblocked + updatedRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID}) + assert.False(t, updatedRun.NeedApproval) + assert.Equal(t, user2.ID, updatedRun.ApprovedBy) + jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(t.Context(), baseRepo.ID, run.ID) + require.NoError(t, err) + for _, job := range jobs { + assert.Equal(t, actions_model.StatusWaiting, job.Status) + } + + // Test approve non-existent run + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/999999/approve", baseRepo.FullName())). + AddTokenAuth(user2Token) + MakeRequest(t, req, http.StatusNotFound) + + // Test approve by non-owner (user4 should get forbidden) + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/approve", baseRepo.FullName(), run.ID)). + AddTokenAuth(user4Token) + MakeRequest(t, req, http.StatusForbidden) + }) +} + +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}) @@ -252,10 +392,13 @@ func testAPIActionsRerunWorkflowJob(t *testing.T) { 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) + 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) - rerunResp := DecodeJSON(t, resp, &api.ActionWorkflowJob{}) + var rerunResp api.ActionWorkflowJob + err := json.Unmarshal(resp.Body.Bytes(), &rerunResp) + require.NoError(t, err) job199Rerun := getLatestAttemptJobByTemplateJobID(t, 795, 199) assert.Equal(t, job199Rerun.ID, rerunResp.ID) assert.Equal(t, "queued", rerunResp.Status) @@ -293,82 +436,77 @@ func testAPIActionsRerunWorkflowJob(t *testing.T) { }) } -func testAPIActionsListUserWorkflows(t *testing.T) { - user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) - session := loginUser(t, user.Name) - token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser) +func TestAPIActionsGetWorkflowRunLogs(t *testing.T) { + defer prepareTestEnvActionsArtifacts(t)() - t.Run("Runs", func(t *testing.T) { - req := NewRequest(t, "GET", "/api/v1/user/actions/runs").AddTokenAuth(token) - resp := MakeRequest(t, req, http.StatusOK) - runs := DecodeJSON(t, resp, &api.ActionWorkflowRunsResponse{}) - - assert.Positive(t, runs.TotalCount) - assert.NotEmpty(t, runs.Entries) - - for _, run := range runs.Entries { - assert.NotEmpty(t, run.DisplayTitle, "display_title should be populated") - assert.NotNil(t, run.Repository, "repository should be populated via batch loading") - assert.NotEmpty(t, run.Repository.FullName, "repository full_name should be populated") - assert.NotNil(t, run.TriggerActor, "trigger_actor should be populated via batch loading") - } - }) - - t.Run("Jobs", func(t *testing.T) { - req := NewRequest(t, "GET", "/api/v1/user/actions/jobs").AddTokenAuth(token) - resp := MakeRequest(t, req, http.StatusOK) - jobs := DecodeJSON(t, resp, &api.ActionWorkflowJobsResponse{}) - - assert.Positive(t, jobs.TotalCount) - assert.NotEmpty(t, jobs.Entries) - - for _, job := range jobs.Entries { - assert.NotEmpty(t, job.Name, "job name should be populated") - assert.NotEmpty(t, job.HTMLURL, "html_url should be populated via batch-loaded repo") - } - }) - - t.Run("JobsDefaultOrderAsc", func(t *testing.T) { - req := NewRequest(t, "GET", "/api/v1/user/actions/jobs").AddTokenAuth(token) - resp := MakeRequest(t, req, http.StatusOK) - jobs := DecodeJSON(t, resp, &api.ActionWorkflowJobsResponse{}) - - assert.GreaterOrEqual(t, len(jobs.Entries), 2, "need at least 2 jobs to verify ordering") - for i := 1; i < len(jobs.Entries); i++ { - assert.Less(t, jobs.Entries[i-1].ID, jobs.Entries[i].ID, - "jobs should be ordered by ID ascending by default") - } - }) - - t.Run("JobsOrderedByIDDesc", func(t *testing.T) { - req := NewRequest(t, "GET", "/api/v1/user/actions/jobs?sort=id&order=desc").AddTokenAuth(token) - resp := MakeRequest(t, req, http.StatusOK) - jobs := DecodeJSON(t, resp, &api.ActionWorkflowJobsResponse{}) - - assert.GreaterOrEqual(t, len(jobs.Entries), 2, "need at least 2 jobs to verify ordering") - for i := 1; i < len(jobs.Entries); i++ { - assert.Greater(t, jobs.Entries[i-1].ID, jobs.Entries[i].ID, - "jobs should be ordered by ID descending") - } - }) -} - -func testAPIActionsListRepoWorkflows(t *testing.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.AccessTokenScopeReadRepository) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) - req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs", repo.FullName())).AddTokenAuth(token) - resp := MakeRequest(t, req, http.StatusOK) - runs := DecodeJSON(t, resp, &api.ActionWorkflowRunsResponse{}) + t.Run("Success", func(t *testing.T) { + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/logs", repo.FullName())). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + }) - assert.Positive(t, runs.TotalCount) - assert.NotEmpty(t, runs.Entries) - - for _, run := range runs.Entries { - assert.NotNil(t, run.Repository, "repository should be populated from ctx.Repo") - assert.Equal(t, repo.FullName(), run.Repository.FullName, "repository full_name should match") - assert.NotNil(t, run.TriggerActor, "trigger_actor should be populated") - } + t.Run("NotFound", func(t *testing.T) { + 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) + + t.Run("NoLogFile", func(t *testing.T) { + // Job 198 exists but has no log file in the test fixture + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs/198/logs", repo.FullName())). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("JobNotFound", func(t *testing.T) { + 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) + + t.Run("EmptyCursors", func(t *testing.T) { + 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) + + var logResp map[string]any + err := json.Unmarshal(resp.Body.Bytes(), &logResp) + assert.NoError(t, err) + assert.Contains(t, logResp, "stepsLog") + }) + + t.Run("WithCursor", func(t *testing.T) { + 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) + }) + + t.Run("NotFound", func(t *testing.T) { + 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 36d855c7f43b05769f99e328788139c52a07e1f0 Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Sat, 2 May 2026 09:57:01 +0700 Subject: [PATCH 02/13] Fix type cast and signature issues after rebase onto upstream/main - Cast org.Visibility.String() to api.UserVisibility in ToOrganization - Cast t.AccessMode.ToString() to api.AccessLevelName in ToTeams - Update webhook notifier to pass repo to ToActionWorkflowRun Co-Authored-By: Claude Sonnet 4.6 --- services/convert/convert.go | 4 ++-- services/webhook/notifier.go | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/services/convert/convert.go b/services/convert/convert.go index 6cace02678..86beaa5b1b 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -709,7 +709,7 @@ func ToOrganization(ctx context.Context, org *organization.Organization) *api.Or Description: org.Description, Website: org.Website, Location: org.Location, - Visibility: org.Visibility.String(), + Visibility: api.UserVisibility(org.Visibility.String()), RepoAdminChangeTeamAccess: org.RepoAdminChangeTeamAccess, } } @@ -738,7 +738,7 @@ func ToTeams(ctx context.Context, teams []*organization.Team, loadOrgs bool) ([] Description: t.Description, IncludesAllRepositories: t.IncludesAllRepositories, CanCreateOrgRepo: t.CanCreateOrgRepo, - Permission: t.AccessMode.ToString(), + Permission: api.AccessLevelName(t.AccessMode.ToString()), Units: t.GetUnitNames(), UnitsMap: t.GetUnitsMap(), } diff --git a/services/webhook/notifier.go b/services/webhook/notifier.go index 7627935a32..d2575e9931 100644 --- a/services/webhook/notifier.go +++ b/services/webhook/notifier.go @@ -1043,8 +1043,7 @@ func (*webhookNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_ return } - run.Repo = repo - convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil) + convertedRun, err := convert.ToActionWorkflowRun(ctx, repo, run, nil) if err != nil { log.Error("ToActionWorkflowRun: %v", err) return From fe5c72072c9e86a255e1bde21eb98a49e73d3e02 Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Sat, 2 May 2026 10:23:15 +0700 Subject: [PATCH 03/13] Fix remaining ToActionWorkflowRun call sites after signature change Pass repo parameter to ToActionWorkflowRun in action.go and shared/action.go. Co-Authored-By: Claude Sonnet 4.6 --- routers/api/v1/repo/action.go | 6 +++--- routers/api/v1/shared/action.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 666d4f98ef..ac70338dd5 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -1243,7 +1243,7 @@ func GetWorkflowRun(ctx *context.APIContext) { return } - convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil) + convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, run, nil) if err != nil { ctx.APIErrorInternal(err) return @@ -1292,7 +1292,7 @@ func GetWorkflowRunAttempt(ctx *context.APIContext) { return } - convertedRun, err := convert.ToActionWorkflowRun(ctx, run, attempt) + convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, run, attempt) if err != nil { ctx.APIErrorInternal(err) return @@ -1347,7 +1347,7 @@ func RerunWorkflowRun(ctx *context.APIContext) { return } - convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil) + convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, run, nil) if err != nil { ctx.APIErrorInternal(err) return diff --git a/routers/api/v1/shared/action.go b/routers/api/v1/shared/action.go index 5aae9d6418..d2a42e191a 100644 --- a/routers/api/v1/shared/action.go +++ b/routers/api/v1/shared/action.go @@ -202,7 +202,7 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64) { res.Entries = make([]*api.ActionWorkflowRun, len(runs)) for i := range runs { // TODO: load run attempts in batch - convertedRun, err := convert.ToActionWorkflowRun(ctx, runs[i], nil) + convertedRun, err := convert.ToActionWorkflowRun(ctx, runs[i].Repo, runs[i], nil) if err != nil { ctx.APIErrorInternal(err) return From e82ef95890c597d7fca8020852f4f61cc0e49051 Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Sat, 2 May 2026 10:51:47 +0700 Subject: [PATCH 04/13] Fix ToActionWorkflowRun call in action_test.go after signature change Co-Authored-By: Claude Sonnet 4.6 --- services/convert/action_test.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/services/convert/action_test.go b/services/convert/action_test.go index 5d56d10a48..1403eff0f0 100644 --- a/services/convert/action_test.go +++ b/services/convert/action_test.go @@ -116,12 +116,11 @@ func TestToActionWorkflowRun_UsesTriggerEvent(t *testing.T) { repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 803}) - run.Repo = repo // Scheduled runs keep Event as the registration event (push) and use TriggerEvent as the real trigger. run.Event = "push" run.TriggerEvent = "schedule" - apiRun, err := ToActionWorkflowRun(t.Context(), run, nil) + apiRun, err := ToActionWorkflowRun(t.Context(), repo, run, nil) require.NoError(t, err) assert.Equal(t, "schedule", apiRun.Event) } From 11dee4c8a54534e6bce6936dc9ece7a09fefbd75 Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Sat, 2 May 2026 11:02:34 +0700 Subject: [PATCH 05/13] Regenerate swagger and OpenAPI3 specs Co-Authored-By: Claude Sonnet 4.6 --- templates/swagger/v1_json.tmpl | 160 +++++++++++- templates/swagger/v1_openapi3_json.tmpl | 334 ++++++++++++++++++++++++ 2 files changed, 485 insertions(+), 9 deletions(-) diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 1491333ce3..20a36c9422 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -22670,12 +22670,14 @@ "type": "object", "properties": { "permission": { + "description": "Permission level to grant the collaborator\nread RepoWritePermissionRead\nwrite RepoWritePermissionWrite\nadmin RepoWritePermissionAdmin", "type": "string", "enum": [ "read", "write", "admin" ], + "x-go-enum-desc": "read RepoWritePermissionRead\nwrite RepoWritePermissionWrite\nadmin RepoWritePermissionAdmin", "x-go-name": "Permission" } }, @@ -24246,6 +24248,15 @@ "format": "int64", "x-go-name": "Milestone" }, + "projects": { + "description": "list of project ids", + "type": "array", + "items": { + "type": "integer", + "format": "int64" + }, + "x-go-name": "Projects" + }, "ref": { "type": "string", "x-go-name": "Ref" @@ -24438,13 +24449,14 @@ "x-go-name": "UserName" }, "visibility": { - "description": "possible values are `public` (default), `limited` or `private`", + "description": "possible values are `public` (default), `limited` or `private`\npublic UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "type": "string", "enum": [ "public", "limited", "private" ], + "x-go-enum-desc": "public UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "x-go-name": "Visibility" }, "website": { @@ -24729,12 +24741,13 @@ "x-go-name": "Name" }, "object_format_name": { - "description": "ObjectFormatName of the underlying git repository, empty string for default (sha1)", + "description": "ObjectFormatName of the underlying git repository, empty string for default (sha1)\nsha1 ObjectFormatSHA1\nsha256 ObjectFormatSHA256", "type": "string", "enum": [ "sha1", "sha256" ], + "x-go-enum-desc": "sha1 ObjectFormatSHA1\nsha256 ObjectFormatSHA256", "x-go-name": "ObjectFormatName" }, "private": { @@ -24887,6 +24900,7 @@ "write", "admin" ], + "x-go-enum-desc": "read RepoWritePermissionRead\nwrite RepoWritePermissionWrite\nadmin RepoWritePermissionAdmin", "x-go-name": "Permission" }, "units": { @@ -24981,8 +24995,14 @@ "x-go-name": "Username" }, "visibility": { - "description": "User visibility level: public, limited, or private", + "description": "User visibility level: public, limited, or private\npublic UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "type": "string", + "enum": [ + "public", + "limited", + "private" + ], + "x-go-enum-desc": "public UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "x-go-name": "Visibility" } }, @@ -25512,6 +25532,15 @@ "format": "int64", "x-go-name": "Milestone" }, + "projects": { + "description": "list of project ids to set (replaces existing projects)", + "type": "array", + "items": { + "type": "integer", + "format": "int64" + }, + "x-go-name": "Projects" + }, "ref": { "type": "string", "x-go-name": "Ref" @@ -25625,13 +25654,14 @@ "x-go-name": "RepoAdminChangeTeamAccess" }, "visibility": { - "description": "possible values are `public`, `limited` or `private`", + "description": "possible values are `public`, `limited` or `private`\npublic UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "type": "string", "enum": [ "public", "limited", "private" ], + "x-go-enum-desc": "public UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "x-go-name": "Visibility" }, "website": { @@ -25915,6 +25945,21 @@ "type": "string", "x-go-name": "MirrorInterval" }, + "mirror_password": { + "description": "authentication password for the remote repository (mirrors)", + "type": "string", + "x-go-name": "MirrorPassword" + }, + "mirror_token": { + "description": "authentication token for the remote repository (mirrors)", + "type": "string", + "x-go-name": "MirrorToken" + }, + "mirror_username": { + "description": "authentication username for the remote repository (mirrors)", + "type": "string", + "x-go-name": "MirrorUsername" + }, "name": { "description": "name of the repository", "type": "string", @@ -26005,6 +26050,7 @@ "write", "admin" ], + "x-go-enum-desc": "read RepoWritePermissionRead\nwrite RepoWritePermissionWrite\nadmin RepoWritePermissionAdmin", "x-go-name": "Permission" }, "units": { @@ -26135,8 +26181,14 @@ "x-go-name": "SourceID" }, "visibility": { - "description": "User visibility level: public, limited, or private", + "description": "User visibility level: public, limited, or private\npublic UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "type": "string", + "enum": [ + "public", + "limited", + "private" + ], + "x-go-enum-desc": "public UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "x-go-name": "Visibility" }, "website": { @@ -27038,6 +27090,13 @@ "format": "int64", "x-go-name": "PinOrder" }, + "projects": { + "type": "array", + "items": { + "$ref": "#/definitions/Project" + }, + "x-go-name": "Projects" + }, "pull_request": { "$ref": "#/definitions/PullRequestMeta" }, @@ -28087,8 +28146,14 @@ "x-go-name": "UserName" }, "visibility": { - "description": "The visibility level of the organization (public, limited, private)", + "description": "The visibility level of the organization (public, limited, private)\npublic UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "type": "string", + "enum": [ + "public", + "limited", + "private" + ], + "x-go-enum-desc": "public UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "x-go-name": "Visibility" }, "website": { @@ -28384,6 +28449,67 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "Project": { + "description": "Project represents a project", + "type": "object", + "properties": { + "closed_at": { + "type": "string", + "format": "date-time", + "x-go-name": "Closed" + }, + "created_at": { + "type": "string", + "format": "date-time", + "x-go-name": "Created" + }, + "creator_id": { + "description": "CreatorID is the user who created the project", + "type": "integer", + "format": "int64", + "x-go-name": "CreatorID" + }, + "description": { + "description": "Description provides details about the project", + "type": "string", + "x-go-name": "Description" + }, + "id": { + "description": "ID is the unique identifier for the project", + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "is_closed": { + "description": "IsClosed indicates if the project is closed", + "type": "boolean", + "x-go-name": "IsClosed" + }, + "owner_id": { + "description": "OwnerID is the owner of the project (for org-level projects)", + "type": "integer", + "format": "int64", + "x-go-name": "OwnerID" + }, + "repo_id": { + "description": "RepoID is the repository this project belongs to (for repo-level projects)", + "type": "integer", + "format": "int64", + "x-go-name": "RepoID" + }, + "title": { + "description": "Title is the title of the project", + "type": "string", + "x-go-name": "Title" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "x-go-name": "Updated" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "PublicKey": { "description": "PublicKey publickey is a user key to push code to repository", "type": "object", @@ -29072,8 +29198,16 @@ "type": "object", "properties": { "permission": { - "description": "Permission level of the collaborator", + "description": "Permission level of the collaborator\nnone AccessLevelNameNone\nread AccessLevelNameRead\nwrite AccessLevelNameWrite\nadmin AccessLevelNameAdmin\nowner AccessLevelNameOwner", "type": "string", + "enum": [ + "none", + "read", + "write", + "admin", + "owner" + ], + "x-go-enum-desc": "none AccessLevelNameNone\nread AccessLevelNameRead\nwrite AccessLevelNameWrite\nadmin AccessLevelNameAdmin\nowner AccessLevelNameOwner", "x-go-name": "Permission" }, "role_name": { @@ -29363,12 +29497,13 @@ "x-go-name": "Name" }, "object_format_name": { - "description": "ObjectFormatName of the underlying git repository", + "description": "ObjectFormatName of the underlying git repository\nsha1 ObjectFormatSHA1\nsha256 ObjectFormatSHA256", "type": "string", "enum": [ "sha1", "sha256" ], + "x-go-enum-desc": "sha1 ObjectFormatSHA1\nsha256 ObjectFormatSHA256", "x-go-name": "ObjectFormatName" }, "open_issues_count": { @@ -29742,6 +29877,7 @@ "admin", "owner" ], + "x-go-enum-desc": "none AccessLevelNameNone\nread AccessLevelNameRead\nwrite AccessLevelNameWrite\nadmin AccessLevelNameAdmin\nowner AccessLevelNameOwner", "x-go-name": "Permission" }, "units": { @@ -30288,8 +30424,14 @@ "x-go-name": "StarredRepos" }, "visibility": { - "description": "User visibility level option: public, limited, private", + "description": "User visibility level option: public, limited, private\npublic UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "type": "string", + "enum": [ + "public", + "limited", + "private" + ], + "x-go-enum-desc": "public UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "x-go-name": "Visibility" }, "website": { diff --git a/templates/swagger/v1_openapi3_json.tmpl b/templates/swagger/v1_openapi3_json.tmpl index 04b8bd2d62..1202f1c3e6 100644 --- a/templates/swagger/v1_openapi3_json.tmpl +++ b/templates/swagger/v1_openapi3_json.tmpl @@ -2305,6 +2305,11 @@ "type": "string", "x-go-name": "Conclusion" }, + "created_at": { + "format": "date-time", + "type": "string", + "x-go-name": "CreatedAt" + }, "display_title": { "type": "string", "x-go-name": "DisplayTitle" @@ -16385,6 +16390,58 @@ ] } }, + "/repos/{owner}/{repo}/actions/runs/{run}/approve": { + "post": { + "operationId": "approveWorkflowRun", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repository", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "run ID", + "in": "path", + "name": "run", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "success" + }, + "400": { + "$ref": "#/components/responses/error" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Approve a workflow run that requires approval", + "tags": [ + "repository" + ] + } + }, "/repos/{owner}/{repo}/actions/runs/{run}/artifacts": { "get": { "operationId": "getArtifactsOfRun", @@ -16582,6 +16639,58 @@ ] } }, + "/repos/{owner}/{repo}/actions/runs/{run}/cancel": { + "post": { + "operationId": "cancelWorkflowRun", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repository", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "run ID", + "in": "path", + "name": "run", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "success" + }, + "400": { + "$ref": "#/components/responses/error" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Cancel a workflow run and its jobs", + "tags": [ + "repository" + ] + } + }, "/repos/{owner}/{repo}/actions/runs/{run}/jobs": { "get": { "operationId": "listWorkflowRunJobs", @@ -16674,6 +16783,61 @@ ] } }, + "/repos/{owner}/{repo}/actions/runs/{run}/jobs/{job_id}/logs": { + "get": { + "operationId": "getWorkflowJobLogs", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repository", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "run ID", + "in": "path", + "name": "run", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "description": "id of the job", + "in": "path", + "name": "job_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Job logs" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Download job logs as plain text", + "tags": [ + "repository" + ] + } + }, "/repos/{owner}/{repo}/actions/runs/{run}/jobs/{job_id}/rerun": { "post": { "operationId": "rerunWorkflowJob", @@ -16741,6 +16905,176 @@ ] } }, + "/repos/{owner}/{repo}/actions/runs/{run}/logs": { + "get": { + "operationId": "getWorkflowRunLogs", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repository", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "run ID", + "in": "path", + "name": "run", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Logs archive" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Download workflow run logs as archive", + "tags": [ + "repository" + ] + }, + "post": { + "operationId": "getWorkflowRunLogsStream", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repository", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "run ID", + "in": "path", + "name": "run", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "description": "job index (0-based), defaults to first job", + "in": "query", + "name": "job", + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "logCursors": { + "items": { + "properties": { + "cursor": { + "type": "integer" + }, + "expanded": { + "type": "boolean" + }, + "step": { + "type": "integer" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "stepsLog": { + "items": { + "properties": { + "cursor": { + "type": "integer" + }, + "lines": { + "items": { + "properties": { + "index": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "timestamp": { + "type": "number" + } + }, + "type": "object" + }, + "type": "array" + }, + "started": { + "type": "integer" + }, + "step": { + "type": "integer" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + } + } + }, + "description": "Streaming logs" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Get streaming workflow run logs with cursor support", + "tags": [ + "repository" + ] + } + }, "/repos/{owner}/{repo}/actions/runs/{run}/rerun": { "post": { "operationId": "rerunWorkflowRun", From 451286986434fca27f7f62ae6a71cc70f620350b Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Sun, 3 May 2026 09:50:24 +0700 Subject: [PATCH 06/13] Fix review issues: auth on log routes, LoadRepos removal, test coverage - Add reqToken/reqRepoReader to GET log download endpoints for consistency with the POST streaming endpoint - Remove spurious LoadRepos call in DownloadActionsRunAllJobLogs; jobs are already scoped to the repo by the query and Repo is never read - Refactor reader.Close() in zip loop to use a closure with defer - Update copyright year to 2026 on new services/actions/{cancel,log}.go - Add TestAPIActionsListUserWorkflows and TestAPIActionsListRepoWorkflows as standalone top-level tests (were dropped when breaking up the orchestrator) - Add idempotency assertion to TestAPIActionsApproveWorkflowRun: approving an already-approved run returns 400 Co-Authored-By: Claude Sonnet --- routers/api/v1/api.go | 4 +- routers/common/actions.go | 35 +++++------ services/actions/cancel.go | 2 +- services/actions/log.go | 2 +- tests/integration/api_actions_run_test.go | 71 +++++++++++++++++++++++ 5 files changed, 91 insertions(+), 23 deletions(-) diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 846195893d..ede987cd66 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1265,11 +1265,11 @@ func Routes() *web.Router { m.Post("/approve", reqToken(), reqRepoWriter(unit.TypeActions), repo.ApproveWorkflowRun) m.Group("/jobs", func() { m.Get("", repo.ListWorkflowRunJobs) - m.Get("/{job_id}/logs", repo.GetWorkflowJobLogs) + m.Get("/{job_id}/logs", reqToken(), reqRepoReader(unit.TypeActions), repo.GetWorkflowJobLogs) m.Post("/{job_id}/rerun", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunWorkflowJob) }) m.Group("/logs", func() { - m.Get("", repo.GetWorkflowRunLogs) + m.Get("", reqToken(), reqRepoReader(unit.TypeActions), repo.GetWorkflowRunLogs) m.Post("", reqToken(), reqRepoReader(unit.TypeActions), repo.GetWorkflowRunLogsStream) }) m.Get("/artifacts", repo.GetArtifactsOfRun) diff --git a/routers/common/actions.go b/routers/common/actions.go index bf514c81da..29d4b9b752 100644 --- a/routers/common/actions.go +++ b/routers/common/actions.go @@ -35,9 +35,6 @@ func DownloadActionsRunAllJobLogs(ctx *context.Base, ctxRepo *repo_model.Reposit if err != nil { return fmt.Errorf("GetLatestAttemptJobsByRepoAndRunID: %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) @@ -77,27 +74,27 @@ func DownloadActionsRunAllJobLogs(ctx *context.Base, ctxRepo *repo_model.Reposit continue } - 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; sanitize to prevent Zip Slip safeJobName := strings.NewReplacer("/", "-", `\`, "-", "..", "__").Replace(job.Name) fileName := fmt.Sprintf("%s-%s-%d.log", safeWorkflowName, safeJobName, 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) - } + if err := func() error { + reader, err := actions.OpenLogs(ctx, task.LogInStorage, task.LogFilename) + if err != nil { + return err + } + defer reader.Close() - reader.Close() + zipFile, err := zipWriter.Create(fileName) + if err != nil { + return err + } + + _, err = io.Copy(zipFile, reader) + return err + }(); err != nil { + return fmt.Errorf("job %d: %w", job.ID, err) + } } return nil diff --git a/services/actions/cancel.go b/services/actions/cancel.go index 9289acde4a..be6f29249d 100644 --- a/services/actions/cancel.go +++ b/services/actions/cancel.go @@ -1,4 +1,4 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. +// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package actions diff --git a/services/actions/log.go b/services/actions/log.go index 088a1f512b..e5206eabe8 100644 --- a/services/actions/log.go +++ b/services/actions/log.go @@ -1,4 +1,4 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. +// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package actions diff --git a/tests/integration/api_actions_run_test.go b/tests/integration/api_actions_run_test.go index 1efb18c41c..32b5410fd4 100644 --- a/tests/integration/api_actions_run_test.go +++ b/tests/integration/api_actions_run_test.go @@ -358,6 +358,11 @@ jobs: assert.Equal(t, actions_model.StatusWaiting, job.Status) } + // Test approve already approved run (idempotency) + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/approve", baseRepo.FullName(), run.ID)). + AddTokenAuth(user2Token) + MakeRequest(t, req, http.StatusBadRequest) + // Test approve non-existent run req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/999999/approve", baseRepo.FullName())). AddTokenAuth(user2Token) @@ -436,6 +441,72 @@ func TestAPIActionsRerunWorkflowJob(t *testing.T) { }) } +func TestAPIActionsListUserWorkflows(t *testing.T) { + defer prepareTestEnvActionsArtifacts(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser) + + t.Run("Runs", func(t *testing.T) { + req := NewRequest(t, "GET", "/api/v1/user/actions/runs").AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var runs api.ActionWorkflowRunsResponse + err := json.Unmarshal(resp.Body.Bytes(), &runs) + require.NoError(t, err) + + assert.Positive(t, runs.TotalCount) + assert.NotEmpty(t, runs.Entries) + + for _, run := range runs.Entries { + assert.NotEmpty(t, run.DisplayTitle, "display_title should be populated") + assert.NotNil(t, run.Repository, "repository should be populated via batch loading") + assert.NotEmpty(t, run.Repository.FullName, "repository full_name should be populated") + assert.NotNil(t, run.TriggerActor, "trigger_actor should be populated via batch loading") + } + }) + + t.Run("Jobs", func(t *testing.T) { + req := NewRequest(t, "GET", "/api/v1/user/actions/jobs").AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var jobs api.ActionWorkflowJobsResponse + err := json.Unmarshal(resp.Body.Bytes(), &jobs) + require.NoError(t, err) + + assert.Positive(t, jobs.TotalCount) + assert.NotEmpty(t, jobs.Entries) + + for _, job := range jobs.Entries { + assert.NotEmpty(t, job.Name, "job name should be populated") + assert.NotEmpty(t, job.HTMLURL, "html_url should be populated via batch-loaded repo") + } + }) +} + +func TestAPIActionsListRepoWorkflows(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.AccessTokenScopeReadRepository) + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs", repo.FullName())).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var runs api.ActionWorkflowRunsResponse + err := json.Unmarshal(resp.Body.Bytes(), &runs) + require.NoError(t, err) + + assert.Positive(t, runs.TotalCount) + assert.NotEmpty(t, runs.Entries) + + for _, run := range runs.Entries { + assert.NotNil(t, run.Repository, "repository should be populated from ctx.Repo") + assert.Equal(t, repo.FullName(), run.Repository.FullName, "repository full_name should match") + assert.NotNil(t, run.TriggerActor, "trigger_actor should be populated") + } +} + func TestAPIActionsGetWorkflowRunLogs(t *testing.T) { defer prepareTestEnvActionsArtifacts(t)() From cc3535b1e53bfb095c838e78bb7a6a8594c0cfa9 Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Sun, 3 May 2026 10:55:49 +0700 Subject: [PATCH 07/13] Move streaming log endpoint to separate branch GetWorkflowRunLogsStream (POST /runs/{run}/logs) and its supporting code are moved to feature/runner-logs-stream-api for a standalone PR where the POST-vs-GET design question can be resolved independently. Removed from this branch: - GetWorkflowRunLogsStream handler and route - getRunJobsAndCurrent helper (only used by the stream handler) - services/actions/log.go (ReadStepLogs) - ActionLogCursor/Request/StepLine/Step/Response structs - TestAPIActionsGetWorkflowRunLogsStream integration test - Regenerated swagger specs accordingly Co-Authored-By: Claude Sonnet --- modules/structs/repo_actions.go | 32 ----- routers/api/v1/api.go | 5 +- routers/api/v1/repo/actions_run.go | 162 ---------------------- services/actions/log.go | 80 ----------- templates/swagger/v1_json.tmpl | 115 --------------- templates/swagger/v1_openapi3_json.tmpl | 124 ----------------- tests/integration/api_actions_run_test.go | 33 ----- 7 files changed, 1 insertion(+), 550 deletions(-) delete mode 100644 services/actions/log.go diff --git a/modules/structs/repo_actions.go b/modules/structs/repo_actions.go index 7535fbf593..5e9fb9832c 100644 --- a/modules/structs/repo_actions.go +++ b/modules/structs/repo_actions.go @@ -228,35 +228,3 @@ type RunDetails struct { RunURL string `json:"run_url"` HTMLURL string `json:"html_url"` } - -// ActionLogCursor represents a cursor position within a step's log -type ActionLogCursor struct { - Step int `json:"step"` - Cursor int64 `json:"cursor"` - Expanded bool `json:"expanded"` -} - -// ActionLogRequest is the request body for the streaming log endpoint -type ActionLogRequest struct { - LogCursors []ActionLogCursor `json:"logCursors"` -} - -// ActionLogStepLine represents a single log line within a step -type ActionLogStepLine struct { - Index int64 `json:"index"` - Message string `json:"message"` - Timestamp float64 `json:"timestamp"` -} - -// ActionLogStep represents log lines for a single step with cursor state -type ActionLogStep struct { - Step int `json:"step"` - Cursor int64 `json:"cursor"` - Lines []*ActionLogStepLine `json:"lines"` - Started int64 `json:"started"` -} - -// ActionLogResponse is the response body for the streaming log endpoint -type ActionLogResponse struct { - StepsLog []*ActionLogStep `json:"stepsLog"` -} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index ede987cd66..3fce86cda9 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1268,10 +1268,7 @@ func Routes() *web.Router { m.Get("/{job_id}/logs", reqToken(), reqRepoReader(unit.TypeActions), repo.GetWorkflowJobLogs) m.Post("/{job_id}/rerun", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunWorkflowJob) }) - m.Group("/logs", func() { - m.Get("", reqToken(), reqRepoReader(unit.TypeActions), repo.GetWorkflowRunLogs) - m.Post("", reqToken(), reqRepoReader(unit.TypeActions), repo.GetWorkflowRunLogsStream) - }) + m.Get("/logs", reqToken(), reqRepoReader(unit.TypeActions), repo.GetWorkflowRunLogs) m.Get("/artifacts", repo.GetArtifactsOfRun) }) }) diff --git a/routers/api/v1/repo/actions_run.go b/routers/api/v1/repo/actions_run.go index e27cd469af..730771c17c 100644 --- a/routers/api/v1/repo/actions_run.go +++ b/routers/api/v1/repo/actions_run.go @@ -5,14 +5,11 @@ package repo import ( "errors" - "io" "net/http" "os" actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" - "code.gitea.io/gitea/modules/json" - api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/common" actions_service "code.gitea.io/gitea/services/actions" @@ -237,24 +234,6 @@ func getRunJobs(ctx *context.APIContext, run *actions_model.ActionRun) ([]*actio return jobs, nil } -func getRunJobsAndCurrent(ctx *context.APIContext, run *actions_model.ActionRun, jobIndex int64) (*actions_model.ActionRunJob, []*actions_model.ActionRunJob, error) { - jobs, err := getRunJobs(ctx, run) - if err != nil { - return nil, nil, err - } - if len(jobs) == 0 { - return nil, nil, util.ErrNotExist - } - - if jobIndex >= 0 { - if jobIndex >= int64(len(jobs)) { - return nil, nil, util.ErrNotExist - } - return jobs[jobIndex], jobs, nil - } - return jobs[0], jobs, nil -} - func GetWorkflowRunLogs(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run}/logs repository getWorkflowRunLogs // --- @@ -369,144 +348,3 @@ func GetWorkflowJobLogs(ctx *context.APIContext) { return } } - -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 - // type: integer - // 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" - - _, run, err := getRunID(ctx) - if err != nil { - if errors.Is(err, util.ErrNotExist) { - ctx.APIErrorNotFound(err) - } else { - ctx.APIErrorInternal(err) - } - return - } - - jobIndex := int64(-1) - if ctx.FormString("job") != "" { - jobIndex = int64(ctx.FormInt("job")) - } - - var req api.ActionLogRequest - if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil { - if errors.Is(err, io.EOF) { - req = api.ActionLogRequest{LogCursors: []api.ActionLogCursor{}} - } else { - ctx.APIError(http.StatusBadRequest, "Invalid request body") - return - } - } - - current, _, err := getRunJobsAndCurrent(ctx, run, jobIndex) - if err != nil { - if errors.Is(err, util.ErrNotExist) { - ctx.APIErrorNotFound(err) - } 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 := &api.ActionLogResponse{ - StepsLog: make([]*api.ActionLogStep, 0), - } - - if task != nil { - logs, err := actions_service.ReadStepLogs(ctx, req.LogCursors, task, "Log has expired and is no longer available") - if err != nil { - ctx.APIErrorInternal(err) - return - } - response.StepsLog = append(response.StepsLog, logs...) - } - - ctx.JSON(http.StatusOK, response) -} diff --git a/services/actions/log.go b/services/actions/log.go deleted file mode 100644 index e5206eabe8..0000000000 --- a/services/actions/log.go +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package actions - -import ( - "context" - "fmt" - "time" - - actions_model "code.gitea.io/gitea/models/actions" - "code.gitea.io/gitea/modules/actions" - api "code.gitea.io/gitea/modules/structs" -) - -// ReadStepLogs reads log lines for the given cursor positions from a task. -// expiredMessage is used as the log content when the task's logs have expired. -func ReadStepLogs(ctx context.Context, cursors []api.ActionLogCursor, task *actions_model.ActionTask, expiredMessage string) ([]*api.ActionLogStep, error) { - var logs []*api.ActionLogStep - steps := actions.FullSteps(task) - - for _, cursor := range cursors { - if !cursor.Expanded { - continue - } - if cursor.Step >= len(steps) { - continue - } - step := steps[cursor.Step] - - if task.LogExpired { - if cursor.Cursor == 0 { - logs = append(logs, &api.ActionLogStep{ - Step: cursor.Step, - Cursor: 1, - Lines: []*api.ActionLogStepLine{{ - Index: 1, - Message: expiredMessage, - Timestamp: float64(task.Updated.AsTime().UnixNano()) / float64(time.Second), - }}, - Started: int64(step.Started), - }) - } - continue - } - - logLines := make([]*api.ActionLogStepLine, 0) - index := step.LogIndex + cursor.Cursor - validCursor := cursor.Cursor >= 0 && - // !(cursor.Cursor < step.LogLength) when the frontend tries to fetch the next - // line before it's ready — return same cursor and empty lines to let caller retry. - cursor.Cursor < step.LogLength && - // !(index < len(task.LogIndexes)) when task data is older than step data. - 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, fmt.Errorf("actions.ReadLogs: %w", err) - } - for i, row := range logRows { - logLines = append(logLines, &api.ActionLogStepLine{ - Index: cursor.Cursor + int64(i) + 1, // 1-based - Message: row.Content, - Timestamp: float64(row.Time.AsTime().UnixNano()) / float64(time.Second), - }) - } - } - - logs = append(logs, &api.ActionLogStep{ - Step: cursor.Step, - Cursor: cursor.Cursor + int64(len(logLines)), - Lines: logLines, - Started: int64(step.Started), - }) - } - return logs, nil -} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 20a36c9422..0190e33bd6 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -5957,121 +5957,6 @@ "$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": "integer", - "description": "run ID", - "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": { diff --git a/templates/swagger/v1_openapi3_json.tmpl b/templates/swagger/v1_openapi3_json.tmpl index 1202f1c3e6..15a889316c 100644 --- a/templates/swagger/v1_openapi3_json.tmpl +++ b/templates/swagger/v1_openapi3_json.tmpl @@ -16949,130 +16949,6 @@ "tags": [ "repository" ] - }, - "post": { - "operationId": "getWorkflowRunLogsStream", - "parameters": [ - { - "description": "owner of the repo", - "in": "path", - "name": "owner", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "name of the repository", - "in": "path", - "name": "repo", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "run ID", - "in": "path", - "name": "run", - "required": true, - "schema": { - "type": "integer" - } - }, - { - "description": "job index (0-based), defaults to first job", - "in": "query", - "name": "job", - "schema": { - "type": "integer" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "properties": { - "logCursors": { - "items": { - "properties": { - "cursor": { - "type": "integer" - }, - "expanded": { - "type": "boolean" - }, - "step": { - "type": "integer" - } - }, - "type": "object" - }, - "type": "array" - } - }, - "type": "object" - } - } - }, - "x-originalParamName": "body" - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "properties": { - "stepsLog": { - "items": { - "properties": { - "cursor": { - "type": "integer" - }, - "lines": { - "items": { - "properties": { - "index": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "timestamp": { - "type": "number" - } - }, - "type": "object" - }, - "type": "array" - }, - "started": { - "type": "integer" - }, - "step": { - "type": "integer" - } - }, - "type": "object" - }, - "type": "array" - } - }, - "type": "object" - } - } - }, - "description": "Streaming logs" - }, - "404": { - "$ref": "#/components/responses/notFound" - } - }, - "summary": "Get streaming workflow run logs with cursor support", - "tags": [ - "repository" - ] } }, "/repos/{owner}/{repo}/actions/runs/{run}/rerun": { diff --git a/tests/integration/api_actions_run_test.go b/tests/integration/api_actions_run_test.go index 32b5410fd4..983c4b22ef 100644 --- a/tests/integration/api_actions_run_test.go +++ b/tests/integration/api_actions_run_test.go @@ -9,7 +9,6 @@ import ( "net/http" "net/url" "slices" - "strings" "testing" "time" @@ -549,35 +548,3 @@ func TestAPIActionsGetWorkflowJobLogs(t *testing.T) { 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) - - t.Run("EmptyCursors", func(t *testing.T) { - 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) - - var logResp map[string]any - err := json.Unmarshal(resp.Body.Bytes(), &logResp) - assert.NoError(t, err) - assert.Contains(t, logResp, "stepsLog") - }) - - t.Run("WithCursor", func(t *testing.T) { - 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) - }) - - t.Run("NotFound", func(t *testing.T) { - 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 503622dfd5cdfe92f9394d88a0a40acf2a5bd284 Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Mon, 4 May 2026 06:44:06 +0700 Subject: [PATCH 08/13] refactor: use actions_service.CancelRun in web Cancel handler Replace the duplicated inline cancel logic in the web Cancel handler with a call to actions_service.CancelRun, which encapsulates the same transaction, commit status update, and notification logic. Co-Authored-By: Claude Sonnet (claude-sonnet-4-6) --- routers/web/repo/actions/view.go | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 99f15a4605..37b4d27ddb 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -756,27 +756,10 @@ func Cancel(ctx *context_module.Context) { return } - var updatedJobs []*actions_model.ActionRunJob - - if err := db.WithTx(ctx, func(ctx context.Context) error { - cancelledJobs, err := actions_model.CancelJobs(ctx, jobs) - if err != nil { - return fmt.Errorf("cancel jobs: %w", err) - } - updatedJobs = append(updatedJobs, cancelledJobs...) - return nil - }); err != nil { - ctx.ServerError("StopTask", err) + if err := actions_service.CancelRun(ctx, run, jobs); err != nil { + ctx.ServerError("CancelRun", err) return } - - actions_service.CreateCommitStatusForRunJobs(ctx, run, jobs...) - actions_service.EmitJobsIfReadyByJobs(updatedJobs) - - actions_service.NotifyWorkflowJobsStatusUpdate(ctx, updatedJobs...) - if len(updatedJobs) > 0 { - actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, run.RepoID, run.ID) - } ctx.JSONOK() } From 6ecbe0f77100ab00c6e01efe105a1be3ce13acbc Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Mon, 4 May 2026 17:06:56 +0700 Subject: [PATCH 09/13] fix: restore ctx in URL construction and correct load strategies - Remove spurious context.ReferencesGitRepo(true) from /actions route group - ToActionTask: restore targeted loads (LoadJob/LoadRun/LoadRepo) instead of LoadAttributes which unnecessarily loads task steps; use httplib.MakeAbsoluteURL(ctx, ...) for the URL field - ToActionWorkflowRun: replace run.LoadAttributes with direct repo assignment + LoadTriggerUser to avoid redundant language-stats query; restore attempt.Run = run before attempt.LoadAttributes to prevent redundant DB re-fetch; restore ctx to APIURL/HTMLURL calls - ToActionWorkflowJob: restore ctx to APIURL and HTMLURL calls Co-Authored-By: Claude --- routers/api/v1/api.go | 2 +- services/convert/convert.go | 34 ++++++++++++++++++++++------------ 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 3fce86cda9..86823d918b 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1278,7 +1278,7 @@ func Routes() *web.Router { m.Delete("", reqRepoWriter(unit.TypeActions), repo.DeleteArtifact) }) m.Get("/artifacts/{artifact_id}/zip", repo.DownloadArtifact) - }, reqRepoReader(unit.TypeActions), context.ReferencesGitRepo(true)) + }, reqRepoReader(unit.TypeActions)) m.Group("/keys", func() { m.Combo("").Get(repo.ListDeployKeys). Post(bind(api.CreateKeyOption{}), repo.CreateDeployKey) diff --git a/services/convert/convert.go b/services/convert/convert.go index 86beaa5b1b..9a93484ca7 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -28,6 +28,7 @@ import ( "code.gitea.io/gitea/modules/actions" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" @@ -226,13 +227,18 @@ func ToTag(repo *repo_model.Repository, t *git.Tag) *api.Tag { } } -// ToActionTask convert a actions_model.ActionTask to an api.ActionTask +// ToActionTask convert an actions_model.ActionTask to an api.ActionTask func ToActionTask(ctx context.Context, t *actions_model.ActionTask) (*api.ActionTask, error) { - if err := t.LoadAttributes(ctx); err != nil { + // don't need Steps here, only need to load job and its run + if err := t.LoadJob(ctx); err != nil { + return nil, err + } + if err := t.Job.LoadRun(ctx); err != nil { + return nil, err + } + if err := t.Job.Run.LoadRepo(ctx); err != nil { return nil, err } - - url := strings.TrimSuffix(setting.AppURL, "/") + t.GetRunLink() return &api.ActionTask{ ID: t.ID, @@ -244,7 +250,7 @@ func ToActionTask(ctx context.Context, t *actions_model.ActionTask) (*api.Action DisplayTitle: t.Job.Run.Title, Status: t.Status.String(), WorkflowID: t.Job.Run.WorkflowID, - URL: url, + URL: httplib.MakeAbsoluteURL(ctx, t.GetRunLink()), CreatedAt: t.Created.AsLocalTime(), UpdatedAt: t.Updated.AsLocalTime(), RunStartedAt: t.Started.AsLocalTime(), @@ -252,7 +258,10 @@ func ToActionTask(ctx context.Context, t *actions_model.ActionTask) (*api.Action } func ToActionWorkflowRun(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt) (*api.ActionWorkflowRun, error) { - if err := run.LoadAttributes(ctx); err != nil { + if run.Repo == nil { + run.Repo = repo + } + if err := run.LoadTriggerUser(ctx); err != nil { return nil, err } @@ -276,6 +285,7 @@ func ToActionWorkflowRun(ctx context.Context, repo *repo_model.Repository, run * var previousAttemptURL *string if attempt != nil { + attempt.Run = run if err := attempt.LoadAttributes(ctx); err != nil { return nil, err } @@ -285,16 +295,16 @@ func ToActionWorkflowRun(ctx context.Context, repo *repo_model.Repository, run * completedAt = attempt.Stopped.AsLocalTime() triggerUser = attempt.TriggerUser if attempt.Attempt > 1 { - url := fmt.Sprintf("%s/actions/runs/%d/attempts/%d", repo.APIURL(), run.ID, attempt.Attempt-1) + url := fmt.Sprintf("%s/actions/runs/%d/attempts/%d", repo.APIURL(ctx), run.ID, attempt.Attempt-1) previousAttemptURL = &url } } return &api.ActionWorkflowRun{ ID: run.ID, - URL: fmt.Sprintf("%s/actions/runs/%d", repo.APIURL(), run.ID), + URL: fmt.Sprintf("%s/actions/runs/%d", repo.APIURL(ctx), run.ID), PreviousAttemptURL: previousAttemptURL, - HTMLURL: run.HTMLURL(), + HTMLURL: run.HTMLURL(ctx), RunNumber: run.Index, RunAttempt: runAttempt, CreatedAt: run.Created.AsLocalTime(), @@ -405,11 +415,11 @@ func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, task return &api.ActionWorkflowJob{ ID: job.ID, // missing api endpoint for this location - URL: fmt.Sprintf("%s/actions/jobs/%d", repo.APIURL(), job.ID), - HTMLURL: fmt.Sprintf("%s/jobs/%d", job.Run.HTMLURL(), job.ID), + URL: fmt.Sprintf("%s/actions/jobs/%d", repo.APIURL(ctx), job.ID), + HTMLURL: fmt.Sprintf("%s/jobs/%d", job.Run.HTMLURL(ctx), job.ID), RunID: job.RunID, // Missing api endpoint for this location, artifacts are available under a nested url - RunURL: fmt.Sprintf("%s/actions/runs/%d", repo.APIURL(), job.RunID), + RunURL: fmt.Sprintf("%s/actions/runs/%d", repo.APIURL(ctx), job.RunID), Name: job.Name, Labels: job.RunsOn, RunAttempt: job.Attempt, From 8c90b491e5fc7b88522874c80698b85fd281fa79 Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Sun, 17 May 2026 10:59:49 +0700 Subject: [PATCH 10/13] fix(actions): address review issues for Actions API endpoints - Use EffectiveTaskID() for rerun jobs in DownloadActionsRunAllJobLogs - Add nil guard for deleted repos in shared.ListRuns - Make ApproveWorkflowRun idempotent (return 200 on re-approve) - Remove stale reload in ApproveWorkflowRun, use in-memory updates - Log zip errors instead of corrupting response mid-stream - Fix misleading Zip Slip comment - Revert DecodeJSON -> json.Unmarshal changes in tests - Add test coverage: cancel state assertions, zip headers, writer-but-non-admin approve, rerun job logs - Fix ToActionWorkflowRun to use repo parameter consistently Co-Authored-By: Ross Golder Co-Authored-By: OpenCode Agent --- routers/api/v1/repo/actions_run.go | 21 +++--- routers/api/v1/shared/action.go | 7 +- routers/common/actions.go | 11 +-- services/convert/convert.go | 5 +- tests/integration/api_actions_run_test.go | 81 ++++++++++++++--------- 5 files changed, 78 insertions(+), 47 deletions(-) diff --git a/routers/api/v1/repo/actions_run.go b/routers/api/v1/repo/actions_run.go index 730771c17c..2ba3a36eec 100644 --- a/routers/api/v1/repo/actions_run.go +++ b/routers/api/v1/repo/actions_run.go @@ -181,8 +181,14 @@ func ApproveWorkflowRun(ctx *context.APIContext) { return } + // GitHub-compatible: return 200 if already approved (idempotent) if !run.NeedApproval { - ctx.APIError(http.StatusBadRequest, "Run does not require approval") + convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, run, nil) + if err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.JSON(http.StatusOK, convertedRun) return } @@ -195,14 +201,13 @@ func ApproveWorkflowRun(ctx *context.APIContext) { return } - // Reload run to reflect post-approval state. - updatedRun, has, err := db.GetByID[actions_model.ActionRun](ctx, runID) - if err != nil || !has { - ctx.APIErrorInternal(err) - return - } + // Update known-changed fields on the run object in memory. + // Note: the overall run status is updated asynchronously by the notifier, + // so the status field may still reflect the pre-approval state. + run.NeedApproval = false + run.ApprovedBy = ctx.Doer.ID - convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, updatedRun, nil) + convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, run, nil) if err != nil { ctx.APIErrorInternal(err) return diff --git a/routers/api/v1/shared/action.go b/routers/api/v1/shared/action.go index d2a42e191a..583ba8fbde 100644 --- a/routers/api/v1/shared/action.go +++ b/routers/api/v1/shared/action.go @@ -199,15 +199,18 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64) { return } - res.Entries = make([]*api.ActionWorkflowRun, len(runs)) + res.Entries = make([]*api.ActionWorkflowRun, 0, len(runs)) for i := range runs { + if runs[i].Repo == nil { + continue // skip runs whose repository has been deleted + } // TODO: load run attempts in batch convertedRun, err := convert.ToActionWorkflowRun(ctx, runs[i].Repo, runs[i], nil) if err != nil { ctx.APIErrorInternal(err) return } - res.Entries[i] = convertedRun + res.Entries = append(res.Entries, convertedRun) } ctx.SetLinkHeader(total, listOptions.PageSize) ctx.SetTotalCountHeader(total) diff --git a/routers/common/actions.go b/routers/common/actions.go index 29d4b9b752..c90108966a 100644 --- a/routers/common/actions.go +++ b/routers/common/actions.go @@ -15,6 +15,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/actions" "code.gitea.io/gitea/modules/httplib" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/context" ) @@ -61,11 +62,12 @@ func DownloadActionsRunAllJobLogs(ctx *context.Base, ctxRepo *repo_model.Reposit // Add each job's logs to the zip for _, job := range runJobs { - if job.TaskID == 0 { + taskID := job.EffectiveTaskID() + if taskID == 0 { continue // Skip jobs that haven't started } - task, err := actions_model.GetTaskByID(ctx, job.TaskID) + task, err := actions_model.GetTaskByID(ctx, taskID) if err != nil { return fmt.Errorf("GetTaskByID for job %d: %w", job.ID, err) } @@ -74,7 +76,7 @@ func DownloadActionsRunAllJobLogs(ctx *context.Base, ctxRepo *repo_model.Reposit continue } - // Create file in zip with job name and task ID; sanitize to prevent Zip Slip + // Create file in zip with job name and task ID; sanitize job names for safe zip entry paths safeJobName := strings.NewReplacer("/", "-", `\`, "-", "..", "__").Replace(job.Name) fileName := fmt.Sprintf("%s-%s-%d.log", safeWorkflowName, safeJobName, task.ID) @@ -93,7 +95,8 @@ func DownloadActionsRunAllJobLogs(ctx *context.Base, ctxRepo *repo_model.Reposit _, err = io.Copy(zipFile, reader) return err }(); err != nil { - return fmt.Errorf("job %d: %w", job.ID, err) + log.Error("Failed to add logs for job %d to zip: %v", job.ID, err) + continue } } diff --git a/services/convert/convert.go b/services/convert/convert.go index 9a93484ca7..eb5c757b14 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -258,9 +258,8 @@ func ToActionTask(ctx context.Context, t *actions_model.ActionTask) (*api.Action } func ToActionWorkflowRun(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt) (*api.ActionWorkflowRun, error) { - if run.Repo == nil { - run.Repo = repo - } + // caller-provided repo is the single source of truth for URL construction + run.Repo = repo if err := run.LoadTriggerUser(ctx); err != nil { return nil, err } diff --git a/tests/integration/api_actions_run_test.go b/tests/integration/api_actions_run_test.go index 983c4b22ef..60df50e9ce 100644 --- a/tests/integration/api_actions_run_test.go +++ b/tests/integration/api_actions_run_test.go @@ -15,10 +15,10 @@ import ( actions_model "code.gitea.io/gitea/models/actions" auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/perm" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/json" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/timeutil" @@ -63,9 +63,7 @@ func TestAPIActionsGetWorkflowRun(t *testing.T) { AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) - var jobList api.ActionWorkflowJobsResponse - err = json.Unmarshal(resp.Body.Bytes(), &jobList) - require.NoError(t, err) + jobList := DecodeJSON(t, resp, &api.ActionWorkflowJobsResponse{}) job198Idx := slices.IndexFunc(jobList.Entries, func(job *api.ActionWorkflowJob) bool { return job.ID == 198 }) require.NotEqual(t, -1, job198Idx, "expected to find job 198 in run 795 jobs list") @@ -144,9 +142,7 @@ func testAPIActionsDeleteRunListArtifacts(t *testing.T, repo *repo_model.Reposit req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/artifacts", repo.FullName())). AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) - var listResp api.ActionArtifactsResponse - err := json.Unmarshal(resp.Body.Bytes(), &listResp) - assert.NoError(t, err) + listResp := DecodeJSON(t, resp, &api.ActionArtifactsResponse{}) assert.Len(t, listResp.Entries, artifacts) } @@ -154,9 +150,7 @@ func testAPIActionsDeleteRunListTasks(t *testing.T, repo *repo_model.Repository, req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/tasks", repo.FullName())). AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) - var listResp api.ActionTaskResponse - err := json.Unmarshal(resp.Body.Bytes(), &listResp) - assert.NoError(t, err) + listResp := DecodeJSON(t, resp, &api.ActionTaskResponse{}) findTask1 := false findTask2 := false for _, entry := range listResp.Entries { @@ -199,9 +193,7 @@ func TestAPIActionsRerunWorkflowRun(t *testing.T) { AddTokenAuth(writeToken) resp := MakeRequest(t, req, http.StatusCreated) - var rerunResp api.ActionWorkflowRun - err := json.Unmarshal(resp.Body.Bytes(), &rerunResp) - require.NoError(t, err) + rerunResp := DecodeJSON(t, resp, &api.ActionWorkflowRun{}) assert.Equal(t, int64(795), rerunResp.ID) assert.Equal(t, "queued", rerunResp.Status) assert.Equal(t, "c2d72f548424103f01ee1dc02889c1e2bff816b0", rerunResp.HeadSha) @@ -250,7 +242,11 @@ func TestAPIActionsCancelWorkflowRun(t *testing.T) { t.Run("Success", func(t *testing.T) { req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/793/cancel", repo.FullName())). AddTokenAuth(ownerToken) - MakeRequest(t, req, http.StatusOK) + resp := MakeRequest(t, req, http.StatusOK) + cancelledRun := DecodeJSON(t, resp, &api.ActionWorkflowRun{}) + assert.Equal(t, int64(793), cancelledRun.ID) + assert.Equal(t, "completed", cancelledRun.Status) + assert.Equal(t, "cancelled", cancelledRun.Conclusion) }) t.Run("NotFound", func(t *testing.T) { @@ -357,20 +353,32 @@ jobs: assert.Equal(t, actions_model.StatusWaiting, job.Status) } - // Test approve already approved run (idempotency) + // Test approve already approved run (idempotency — returns 200 like GitHub) req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/approve", baseRepo.FullName(), run.ID)). AddTokenAuth(user2Token) - MakeRequest(t, req, http.StatusBadRequest) + resp = MakeRequest(t, req, http.StatusOK) + idempotentRun := DecodeJSON(t, resp, &api.ActionWorkflowRun{}) + assert.NotEqual(t, "waiting", idempotentRun.Status, "already-approved run should not be blocked") // Test approve non-existent run req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/999999/approve", baseRepo.FullName())). AddTokenAuth(user2Token) MakeRequest(t, req, http.StatusNotFound) - // Test approve by non-owner (user4 should get forbidden) + // Test approve by non-owner (user4 should get forbidden before being added as collaborator) req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/approve", baseRepo.FullName(), run.ID)). AddTokenAuth(user4Token) MakeRequest(t, req, http.StatusForbidden) + + // Add user4 as a collaborator with write access + doAPIAddCollaborator(user2APICtx, user4.Name, perm.AccessModeWrite)(t) + + // Test approve by writer-but-non-admin (user4 should now succeed) + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/approve", baseRepo.FullName(), run.ID)). + AddTokenAuth(user4Token) + resp = MakeRequest(t, req, http.StatusOK) + approvedRun := DecodeJSON(t, resp, &api.ActionWorkflowRun{}) + assert.NotEqual(t, "waiting", approvedRun.Status, "approved run should not be blocked") }) } @@ -400,9 +408,7 @@ func TestAPIActionsRerunWorkflowJob(t *testing.T) { AddTokenAuth(writeToken) resp := MakeRequest(t, req, http.StatusCreated) - var rerunResp api.ActionWorkflowJob - err := json.Unmarshal(resp.Body.Bytes(), &rerunResp) - require.NoError(t, err) + rerunResp := DecodeJSON(t, resp, &api.ActionWorkflowJob{}) job199Rerun := getLatestAttemptJobByTemplateJobID(t, 795, 199) assert.Equal(t, job199Rerun.ID, rerunResp.ID) assert.Equal(t, "queued", rerunResp.Status) @@ -450,9 +456,7 @@ func TestAPIActionsListUserWorkflows(t *testing.T) { t.Run("Runs", func(t *testing.T) { req := NewRequest(t, "GET", "/api/v1/user/actions/runs").AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) - var runs api.ActionWorkflowRunsResponse - err := json.Unmarshal(resp.Body.Bytes(), &runs) - require.NoError(t, err) + runs := DecodeJSON(t, resp, &api.ActionWorkflowRunsResponse{}) assert.Positive(t, runs.TotalCount) assert.NotEmpty(t, runs.Entries) @@ -468,9 +472,7 @@ func TestAPIActionsListUserWorkflows(t *testing.T) { t.Run("Jobs", func(t *testing.T) { req := NewRequest(t, "GET", "/api/v1/user/actions/jobs").AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) - var jobs api.ActionWorkflowJobsResponse - err := json.Unmarshal(resp.Body.Bytes(), &jobs) - require.NoError(t, err) + jobs := DecodeJSON(t, resp, &api.ActionWorkflowJobsResponse{}) assert.Positive(t, jobs.TotalCount) assert.NotEmpty(t, jobs.Entries) @@ -492,9 +494,7 @@ func TestAPIActionsListRepoWorkflows(t *testing.T) { req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs", repo.FullName())).AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) - var runs api.ActionWorkflowRunsResponse - err := json.Unmarshal(resp.Body.Bytes(), &runs) - require.NoError(t, err) + runs := DecodeJSON(t, resp, &api.ActionWorkflowRunsResponse{}) assert.Positive(t, runs.TotalCount) assert.NotEmpty(t, runs.Entries) @@ -517,7 +517,28 @@ func TestAPIActionsGetWorkflowRunLogs(t *testing.T) { t.Run("Success", func(t *testing.T) { req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/logs", repo.FullName())). AddTokenAuth(token) - MakeRequest(t, req, http.StatusOK) + resp := MakeRequest(t, req, http.StatusOK) + assert.Equal(t, "application/zip", resp.Header().Get("Content-Type")) + assert.Contains(t, resp.Header().Get("Content-Disposition"), "attachment") + assert.Contains(t, resp.Header().Get("Content-Disposition"), ".zip") + body := resp.Body.Bytes() + require.NotEmpty(t, body) + assert.Equal(t, "PK", string(body[:2]), "response should be a valid zip file") + }) + + t.Run("RerunJobLogs", func(t *testing.T) { + // Rerun the workflow so latest-attempt jobs have SourceTaskID instead of TaskID + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/rerun", repo.FullName())). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + // Download logs for the latest attempt — should include rerun job logs via EffectiveTaskID + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/logs", repo.FullName())). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + body := resp.Body.Bytes() + require.NotEmpty(t, body) + assert.Equal(t, "PK", string(body[:2]), "response should be a valid zip file") }) t.Run("NotFound", func(t *testing.T) { From 459f2766f672a82ee7cb46f62fefc6f7a57a24bf Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 15 Jun 2026 23:20:20 +0200 Subject: [PATCH 11/13] APIErrorAuto --- routers/api/v1/repo/actions_run.go | 50 +++++------------------------- 1 file changed, 8 insertions(+), 42 deletions(-) diff --git a/routers/api/v1/repo/actions_run.go b/routers/api/v1/repo/actions_run.go index 4e1f440aaf..872a6bdf1d 100644 --- a/routers/api/v1/repo/actions_run.go +++ b/routers/api/v1/repo/actions_run.go @@ -4,9 +4,7 @@ package repo import ( - "errors" "net/http" - "os" actions_model "gitea.dev/models/actions" "gitea.dev/models/db" @@ -98,11 +96,7 @@ func CancelWorkflowRun(ctx *context.APIContext) { _, run, err := getRunID(ctx) if err != nil { - if errors.Is(err, util.ErrNotExist) { - ctx.APIErrorNotFound(err.Error()) - } else { - ctx.APIErrorInternal(err) - } + ctx.APIErrorAuto(err) return } @@ -166,11 +160,7 @@ func ApproveWorkflowRun(ctx *context.APIContext) { runID, run, err := getRunID(ctx) if err != nil { - if errors.Is(err, util.ErrNotExist) { - ctx.APIErrorNotFound(err.Error()) - } else { - ctx.APIErrorInternal(err) - } + ctx.APIErrorAuto(err) return } @@ -187,11 +177,7 @@ func ApproveWorkflowRun(ctx *context.APIContext) { } if err := actions_service.ApproveRuns(ctx, ctx.Repo.Repository, ctx.Doer, []int64{runID}); err != nil { - if errors.Is(err, util.ErrNotExist) { - ctx.APIErrorNotFound(err.Error()) - } else { - ctx.APIErrorInternal(err) - } + ctx.APIErrorAuto(err) return } @@ -264,20 +250,12 @@ func GetWorkflowRunLogs(ctx *context.APIContext) { _, run, err := getRunID(ctx) if err != nil { - if errors.Is(err, util.ErrNotExist) { - ctx.APIErrorNotFound(err.Error()) - } else { - ctx.APIErrorInternal(err) - } + ctx.APIErrorAuto(err) return } if err = common.DownloadActionsRunAllJobLogs(ctx.Base, ctx.Repo.Repository, run.ID); err != nil { - if errors.Is(err, util.ErrNotExist) { - ctx.APIErrorNotFound(err.Error()) - } else { - ctx.APIErrorInternal(err) - } + ctx.APIErrorAuto(err) return } } @@ -317,11 +295,7 @@ func GetWorkflowJobLogs(ctx *context.APIContext) { runID, _, err := getRunID(ctx) if err != nil { - if errors.Is(err, util.ErrNotExist) { - ctx.APIErrorNotFound(err.Error()) - } else { - ctx.APIErrorInternal(err) - } + ctx.APIErrorAuto(err) return } @@ -329,22 +303,14 @@ func GetWorkflowJobLogs(ctx *context.APIContext) { job, err := actions_model.GetRunJobByRunAndID(ctx, runID, jobID) if err != nil { - if errors.Is(err, util.ErrNotExist) { - ctx.APIErrorNotFound(err.Error()) - } else { - ctx.APIErrorInternal(err) - } + ctx.APIErrorAuto(err) return } job.Repo = ctx.Repo.Repository if err = common.DownloadActionsRunJobLogs(ctx.Base, ctx.Repo.Repository, job); err != nil { - if errors.Is(err, util.ErrNotExist) || errors.Is(err, os.ErrNotExist) { - ctx.APIErrorNotFound(err.Error()) - } else { - ctx.APIErrorInternal(err) - } + ctx.APIErrorAuto(err) return } } From 473f54076766989f94f4549e3f8a3391cfc4af9b Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 15 Jun 2026 23:24:55 +0200 Subject: [PATCH 12/13] simplify --- routers/api/v1/repo/actions_run.go | 85 +++++------------------ routers/common/actions.go | 53 +++++++------- tests/integration/api_actions_run_test.go | 1 - 3 files changed, 41 insertions(+), 98 deletions(-) diff --git a/routers/api/v1/repo/actions_run.go b/routers/api/v1/repo/actions_run.go index 872a6bdf1d..49f700b5ff 100644 --- a/routers/api/v1/repo/actions_run.go +++ b/routers/api/v1/repo/actions_run.go @@ -8,7 +8,6 @@ import ( actions_model "gitea.dev/models/actions" "gitea.dev/models/db" - "gitea.dev/modules/util" "gitea.dev/routers/common" actions_service "gitea.dev/services/actions" "gitea.dev/services/context" @@ -94,15 +93,8 @@ func CancelWorkflowRun(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - _, run, err := getRunID(ctx) - if err != nil { - ctx.APIErrorAuto(err) - return - } - - jobs, err := getRunJobs(ctx, run) - if err != nil { - ctx.APIErrorInternal(err) + run, jobs := getCurrentRepoActionRunJobsByID(ctx) + if ctx.Written() { return } @@ -118,12 +110,7 @@ func CancelWorkflowRun(ctx *context.APIContext) { } updatedRun.Repo = ctx.Repo.Repository - convertedRun, err := convert.ToActionWorkflowRun(ctx, updatedRun, nil, false) - if err != nil { - ctx.APIErrorInternal(err) - return - } - ctx.JSON(http.StatusOK, convertedRun) + respondActionWorkflowRun(ctx, updatedRun) } func ApproveWorkflowRun(ctx *context.APIContext) { @@ -158,35 +145,30 @@ func ApproveWorkflowRun(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - runID, run, err := getRunID(ctx) - if err != nil { - ctx.APIErrorAuto(err) + run := getCurrentRepoActionRunByID(ctx) + if ctx.Written() { return } // GitHub-compatible: return 200 if already approved (idempotent) if !run.NeedApproval { - run.Repo = ctx.Repo.Repository - convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil, false) - if err != nil { - ctx.APIErrorInternal(err) - return - } - ctx.JSON(http.StatusOK, convertedRun) + respondActionWorkflowRun(ctx, run) return } - if err := actions_service.ApproveRuns(ctx, ctx.Repo.Repository, ctx.Doer, []int64{runID}); err != nil { + if err := actions_service.ApproveRuns(ctx, ctx.Repo.Repository, ctx.Doer, []int64{run.ID}); err != nil { ctx.APIErrorAuto(err) return } - // Update known-changed fields on the run object in memory. // Note: the overall run status is updated asynchronously by the notifier, // so the status field may still reflect the pre-approval state. run.NeedApproval = false run.ApprovedBy = ctx.Doer.ID + respondActionWorkflowRun(ctx, run) +} +func respondActionWorkflowRun(ctx *context.APIContext, run *actions_model.ActionRun) { run.Repo = ctx.Repo.Repository convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil, false) if err != nil { @@ -196,30 +178,6 @@ func ApproveWorkflowRun(ctx *context.APIContext) { ctx.JSON(http.StatusOK, convertedRun) } -func getRunID(ctx *context.APIContext) (int64, *actions_model.ActionRun, error) { - 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 getRunJobs(ctx *context.APIContext, run *actions_model.ActionRun) ([]*actions_model.ActionRunJob, error) { - run.Repo = ctx.Repo.Repository - jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID) - if err != nil { - return nil, err - } - for _, v := range jobs { - v.Run = run - } - return jobs, nil -} - func GetWorkflowRunLogs(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run}/logs repository getWorkflowRunLogs // --- @@ -248,13 +206,12 @@ func GetWorkflowRunLogs(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - _, run, err := getRunID(ctx) - if err != nil { - ctx.APIErrorAuto(err) + run := getCurrentRepoActionRunByID(ctx) + if ctx.Written() { return } - if err = common.DownloadActionsRunAllJobLogs(ctx.Base, ctx.Repo.Repository, run.ID); err != nil { + if err := common.DownloadActionsRunAllJobLogs(ctx.Base, ctx.Repo.Repository, run.ID); err != nil { ctx.APIErrorAuto(err) return } @@ -293,23 +250,13 @@ func GetWorkflowJobLogs(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - runID, _, err := getRunID(ctx) - if err != nil { - ctx.APIErrorAuto(err) + run := getCurrentRepoActionRunByID(ctx) + if ctx.Written() { return } jobID := ctx.PathParamInt64("job_id") - - job, err := actions_model.GetRunJobByRunAndID(ctx, runID, jobID) - if err != nil { - ctx.APIErrorAuto(err) - return - } - - job.Repo = ctx.Repo.Repository - - if err = common.DownloadActionsRunJobLogs(ctx.Base, ctx.Repo.Repository, job); err != nil { + if err := common.DownloadActionsRunJobLogsWithID(ctx.Base, ctx.Repo.Repository, run.ID, jobID); err != nil { ctx.APIErrorAuto(err) return } diff --git a/routers/common/actions.go b/routers/common/actions.go index 73e75270d3..b7ec8a5a38 100644 --- a/routers/common/actions.go +++ b/routers/common/actions.go @@ -20,6 +20,13 @@ import ( "gitea.dev/services/context" ) +func actionsWorkflowBaseName(workflowID string) string { + if p := strings.Index(workflowID, "."); p > 0 { + return workflowID[:p] + } + return workflowID +} + func DownloadActionsRunJobLogsWithID(ctx *context.Base, ctxRepo *repo_model.Repository, runID, jobID int64) error { job, err := actions_model.GetRunJobByRunAndID(ctx, runID, jobID) if err != nil { @@ -46,21 +53,16 @@ func DownloadActionsRunAllJobLogs(ctx *context.Base, ctxRepo *repo_model.Reposit return fmt.Errorf("LoadRun: %w", err) } - workflowName := runJobs[0].Run.WorkflowID - if p := strings.Index(workflowName, "."); p > 0 { - workflowName = workflowName[0:p] - } + workflowName := actionsWorkflowBaseName(runJobs[0].Run.WorkflowID) safeWorkflowName := strings.NewReplacer(`"`, "", "\r", "", "\n", "", "/", "-", `\`, "-").Replace(workflowName) - // 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"`, safeWorkflowName, runID)) + ctx.Resp.Header().Set("Content-Disposition", httplib.EncodeContentDispositionAttachment(fmt.Sprintf("%s-run-%d-logs.zip", safeWorkflowName, runID))) - // Create zip writer zipWriter := zip.NewWriter(ctx.Resp) defer zipWriter.Close() - // Add each job's logs to the zip + jobNameReplacer := strings.NewReplacer("/", "-", `\`, "-", "..", "__") for _, job := range runJobs { taskID := job.EffectiveTaskID() if taskID == 0 { @@ -76,28 +78,26 @@ func DownloadActionsRunAllJobLogs(ctx *context.Base, ctxRepo *repo_model.Reposit continue } - // Create file in zip with job name and task ID; sanitize job names for safe zip entry paths - safeJobName := strings.NewReplacer("/", "-", `\`, "-", "..", "__").Replace(job.Name) + safeJobName := jobNameReplacer.Replace(job.Name) fileName := fmt.Sprintf("%s-%s-%d.log", safeWorkflowName, safeJobName, task.ID) - if err := func() error { - reader, err := actions.OpenLogs(ctx, task.LogInStorage, task.LogFilename) - if err != nil { - return err - } - defer reader.Close() + reader, err := actions.OpenLogs(ctx, task.LogInStorage, task.LogFilename) + if err != nil { + log.Error("Failed to open logs for job %d: %v", job.ID, err) + continue + } - zipFile, err := zipWriter.Create(fileName) - if err != nil { - return err - } - - _, err = io.Copy(zipFile, reader) - return err - }(); err != nil { + zipFile, err := zipWriter.Create(fileName) + if err != nil { + reader.Close() log.Error("Failed to add logs for job %d to zip: %v", job.ID, err) continue } + + if _, err = io.Copy(zipFile, reader); err != nil { + log.Error("Failed to add logs for job %d to zip: %v", job.ID, err) + } + reader.Close() } return nil @@ -139,10 +139,7 @@ func DownloadActionsRunJobLogs(ctx *context.Base, ctxRepo *repo_model.Repository } defer reader.Close() - workflowName := curJob.Run.WorkflowID - if p := strings.Index(workflowName, "."); p > 0 { - workflowName = workflowName[0:p] - } + workflowName := actionsWorkflowBaseName(curJob.Run.WorkflowID) ctx.ServeContent(reader, context.ServeHeaderOptions{ Filename: fmt.Sprintf("%v-%v-%v.log", workflowName, curJob.Name, task.ID), ContentLength: &task.LogSize, diff --git a/tests/integration/api_actions_run_test.go b/tests/integration/api_actions_run_test.go index 4a988fef8f..1a0250dde6 100644 --- a/tests/integration/api_actions_run_test.go +++ b/tests/integration/api_actions_run_test.go @@ -21,7 +21,6 @@ import ( user_model "gitea.dev/models/user" api "gitea.dev/modules/structs" "gitea.dev/modules/timeutil" - "gitea.dev/tests" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" From 60e491663a594efe07551ab0ad37d6453042a0c9 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 17 Jun 2026 17:09:48 +0200 Subject: [PATCH 13/13] cleanup --- models/actions/run_list.go | 9 +- routers/api/v1/repo/action.go | 17 +- routers/api/v1/repo/actions_run.go | 38 +---- routers/api/v1/shared/action.go | 7 +- routers/common/actions.go | 190 +++++++++++++--------- tests/integration/api_actions_run_test.go | 44 +++-- 6 files changed, 172 insertions(+), 133 deletions(-) diff --git a/models/actions/run_list.go b/models/actions/run_list.go index 88f3d3dd82..0e61279e7d 100644 --- a/models/actions/run_list.go +++ b/models/actions/run_list.go @@ -98,7 +98,14 @@ func (opts FindRunOptions) ToConds() builder.Cond { func (opts FindRunOptions) ToJoins() []db.JoinFunc { if opts.OwnerID > 0 { return []db.JoinFunc{func(sess db.Engine) error { - sess.Join("INNER", "repository", "repository.id = repo_id AND repository.owner_id = ?", opts.OwnerID) + sess.Join("INNER", "repository", "repository.id = action_run.repo_id AND repository.owner_id = ?", opts.OwnerID) + return nil + }} + } + if opts.RepoID == 0 { + // Exclude runs whose repository has been deleted. + return []db.JoinFunc{func(sess db.Engine) error { + sess.Join("INNER", "repository", "repository.id = action_run.repo_id") return nil }} } diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 3b920ac551..35c0530029 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -1298,6 +1298,16 @@ func getCurrentRepoActionRunAttemptByNumber(ctx *context.APIContext) (*actions_m return run, attempt } +func respondRepoActionWorkflowRun(ctx *context.APIContext, run *actions_model.ActionRun) { + run.Repo = ctx.Repo.Repository + convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil, false) + if err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.JSON(http.StatusOK, convertedRun) +} + // GetWorkflowRun Gets a specific workflow run. func GetWorkflowRun(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run} repository GetWorkflowRun @@ -1334,12 +1344,7 @@ func GetWorkflowRun(ctx *context.APIContext) { return } - convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil, false) - if err != nil { - ctx.APIErrorInternal(err) - return - } - ctx.JSON(http.StatusOK, convertedRun) + respondRepoActionWorkflowRun(ctx, run) } // GetWorkflowRunAttempt Gets a specific workflow run attempt. diff --git a/routers/api/v1/repo/actions_run.go b/routers/api/v1/repo/actions_run.go index 49f700b5ff..8e666578d8 100644 --- a/routers/api/v1/repo/actions_run.go +++ b/routers/api/v1/repo/actions_run.go @@ -4,14 +4,10 @@ package repo import ( - "net/http" - actions_model "gitea.dev/models/actions" - "gitea.dev/models/db" "gitea.dev/routers/common" actions_service "gitea.dev/services/actions" "gitea.dev/services/context" - "gitea.dev/services/convert" ) func DownloadActionsRunJobLogs(ctx *context.APIContext) { @@ -50,11 +46,6 @@ func DownloadActionsRunJobLogs(ctx *context.APIContext) { ctx.APIErrorAuto(err) return } - if err = curJob.LoadRepo(ctx); err != nil { - ctx.APIErrorInternal(err) - return - } - err = common.DownloadActionsRunJobLogs(ctx.Base, ctx.Repo.Repository, curJob) if err != nil { ctx.APIErrorAuto(err) @@ -99,18 +90,15 @@ func CancelWorkflowRun(ctx *context.APIContext) { } if err := actions_service.CancelRun(ctx, run, jobs); err != nil { - ctx.APIErrorInternal(err) + ctx.APIErrorAuto(err) return } - updatedRun, has, err := db.GetByID[actions_model.ActionRun](ctx, run.ID) - if err != nil || !has { - ctx.APIErrorInternal(err) + run = getCurrentRepoActionRunByID(ctx) + if ctx.Written() { return } - - updatedRun.Repo = ctx.Repo.Repository - respondActionWorkflowRun(ctx, updatedRun) + respondRepoActionWorkflowRun(ctx, run) } func ApproveWorkflowRun(ctx *context.APIContext) { @@ -152,7 +140,7 @@ func ApproveWorkflowRun(ctx *context.APIContext) { // GitHub-compatible: return 200 if already approved (idempotent) if !run.NeedApproval { - respondActionWorkflowRun(ctx, run) + respondRepoActionWorkflowRun(ctx, run) return } @@ -161,21 +149,11 @@ func ApproveWorkflowRun(ctx *context.APIContext) { return } - // Note: the overall run status is updated asynchronously by the notifier, - // so the status field may still reflect the pre-approval state. - run.NeedApproval = false - run.ApprovedBy = ctx.Doer.ID - respondActionWorkflowRun(ctx, run) -} - -func respondActionWorkflowRun(ctx *context.APIContext, run *actions_model.ActionRun) { - run.Repo = ctx.Repo.Repository - convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil, false) - if err != nil { - ctx.APIErrorInternal(err) + run = getCurrentRepoActionRunByID(ctx) + if ctx.Written() { return } - ctx.JSON(http.StatusOK, convertedRun) + respondRepoActionWorkflowRun(ctx, run) } func GetWorkflowRunLogs(ctx *context.APIContext) { diff --git a/routers/api/v1/shared/action.go b/routers/api/v1/shared/action.go index ded1bd4948..d62d0d3a22 100644 --- a/routers/api/v1/shared/action.go +++ b/routers/api/v1/shared/action.go @@ -203,18 +203,15 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64, workflowID string) return } - res.Entries = make([]*api.ActionWorkflowRun, 0, len(runs)) + res.Entries = make([]*api.ActionWorkflowRun, len(runs)) for i := range runs { - if runs[i].Repo == nil { - continue // skip runs whose repository has been deleted - } // TODO: load run attempts in batch convertedRun, err := convert.ToActionWorkflowRun(ctx, runs[i], nil, excludePullRequests) if err != nil { ctx.APIErrorInternal(err) return } - res.Entries = append(res.Entries, convertedRun) + res.Entries[i] = convertedRun } ctx.SetLinkHeader(total, listOptions.PageSize) ctx.SetTotalCountHeader(total) diff --git a/routers/common/actions.go b/routers/common/actions.go index b7ec8a5a38..02ffc2d129 100644 --- a/routers/common/actions.go +++ b/routers/common/actions.go @@ -5,6 +5,7 @@ package common import ( "archive/zip" + "context" "errors" "fmt" "io" @@ -17,28 +18,99 @@ import ( "gitea.dev/modules/httplib" "gitea.dev/modules/log" "gitea.dev/modules/util" - "gitea.dev/services/context" + context_module "gitea.dev/services/context" ) -func actionsWorkflowBaseName(workflowID string) string { +var ( + workflowNameReplacer = strings.NewReplacer(`"`, "", "\r", "", "\n", "", "/", "-", `\`, "-") + jobNameReplacer = strings.NewReplacer("/", "-", `\`, "-", "..", "__") +) + +func sanitizeWorkflowFileName(workflowID string) string { if p := strings.Index(workflowID, "."); p > 0 { - return workflowID[:p] + workflowID = workflowID[:p] } - return workflowID + return workflowNameReplacer.Replace(workflowID) } -func DownloadActionsRunJobLogsWithID(ctx *context.Base, ctxRepo *repo_model.Repository, runID, jobID int64) error { +func sanitizeJobFileName(name string) string { + return jobNameReplacer.Replace(name) +} + +func jobLogFileName(workflowID, jobName string, taskID int64) string { + return fmt.Sprintf("%s-%s-%d.log", sanitizeWorkflowFileName(workflowID), sanitizeJobFileName(jobName), taskID) +} + +func resolveJobLogTask(ctx context.Context, job *actions_model.ActionRunJob) (*actions_model.ActionTask, error) { + taskID := job.EffectiveTaskID() + if taskID == 0 { + return nil, util.NewNotExistErrorf("job not started") + } + + task, err := actions_model.GetTaskByID(ctx, taskID) + if err != nil { + return nil, fmt.Errorf("GetTaskByID: %w", err) + } + + if task.LogExpired { + return nil, util.NewNotExistErrorf("logs have been cleaned up") + } + if task.LogLength == 0 { + return nil, util.NewNotExistErrorf("logs not found") + } + return task, nil +} + +func openTaskLogs(ctx context.Context, task *actions_model.ActionTask) (io.ReadSeekCloser, error) { + reader, err := actions.OpenLogs(ctx, task.LogInStorage, task.LogFilename) + if err != nil { + if errors.Is(err, fs.ErrNotExist) || errors.Is(err, util.ErrNotExist) { + return nil, util.NewNotExistErrorf("logs not found") + } + return nil, fmt.Errorf("OpenLogs: %w", err) + } + return reader, nil +} + +func openJobTaskLogs(ctx context.Context, job *actions_model.ActionRunJob) (io.ReadSeekCloser, *actions_model.ActionTask, error) { + task, err := resolveJobLogTask(ctx, job) + if err != nil { + return nil, nil, err + } + + reader, err := openTaskLogs(ctx, task) + if err != nil { + return nil, nil, err + } + return reader, task, nil +} + +func appendJobLogToZip(ctx context.Context, zipWriter *zip.Writer, workflowID string, job *actions_model.ActionRunJob, task *actions_model.ActionTask) error { + reader, err := openTaskLogs(ctx, task) + if err != nil { + return err + } + defer reader.Close() + + zipFile, err := zipWriter.Create(jobLogFileName(workflowID, job.Name, task.ID)) + if err != nil { + return fmt.Errorf("Create zip entry for job %d: %w", job.ID, err) + } + if _, err = io.Copy(zipFile, reader); err != nil { + return fmt.Errorf("Write job %d logs to zip: %w", job.ID, err) + } + return nil +} + +func DownloadActionsRunJobLogsWithID(ctx *context_module.Base, ctxRepo *repo_model.Repository, runID, jobID int64) error { job, err := actions_model.GetRunJobByRunAndID(ctx, runID, jobID) if err != nil { return err } - if err := job.LoadRepo(ctx); err != nil { - return fmt.Errorf("LoadRepo: %w", err) - } return DownloadActionsRunJobLogs(ctx, ctxRepo, job) } -func DownloadActionsRunAllJobLogs(ctx *context.Base, ctxRepo *repo_model.Repository, runID int64) error { +func DownloadActionsRunAllJobLogs(ctx *context_module.Base, ctxRepo *repo_model.Repository, runID int64) error { runJobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, ctxRepo.ID, runID) if err != nil { return fmt.Errorf("GetLatestAttemptJobsByRepoAndRunID: %w", err) @@ -48,100 +120,66 @@ func DownloadActionsRunAllJobLogs(ctx *context.Base, ctxRepo *repo_model.Reposit 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) } + workflowID := runJobs[0].Run.WorkflowID - workflowName := actionsWorkflowBaseName(runJobs[0].Run.WorkflowID) - safeWorkflowName := strings.NewReplacer(`"`, "", "\r", "", "\n", "", "/", "-", `\`, "-").Replace(workflowName) + type jobLogEntry struct { + job *actions_model.ActionRunJob + task *actions_model.ActionTask + } + logEntries := make([]jobLogEntry, 0, len(runJobs)) + for _, job := range runJobs { + task, err := resolveJobLogTask(ctx, job) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + continue + } + return err + } + logEntries = append(logEntries, jobLogEntry{job: job, task: task}) + } + if len(logEntries) == 0 { + return util.NewNotExistErrorf("logs not found") + } ctx.Resp.Header().Set("Content-Type", "application/zip") - ctx.Resp.Header().Set("Content-Disposition", httplib.EncodeContentDispositionAttachment(fmt.Sprintf("%s-run-%d-logs.zip", safeWorkflowName, runID))) + ctx.Resp.Header().Set("Content-Disposition", httplib.EncodeContentDispositionAttachment( + fmt.Sprintf("%s-run-%d-logs.zip", sanitizeWorkflowFileName(workflowID), runID), + )) zipWriter := zip.NewWriter(ctx.Resp) defer zipWriter.Close() - jobNameReplacer := strings.NewReplacer("/", "-", `\`, "-", "..", "__") - for _, job := range runJobs { - taskID := job.EffectiveTaskID() - if taskID == 0 { - continue // Skip jobs that haven't started - } - - task, err := actions_model.GetTaskByID(ctx, taskID) - if err != nil { - return fmt.Errorf("GetTaskByID for job %d: %w", job.ID, err) - } - - if task.LogExpired || task.LogLength == 0 { + // Best-effort: the response headers and zip stream are already committed, so a + // failure to read one job's logs must not abort the whole archive. Log and skip. + for _, entry := range logEntries { + if err := appendJobLogToZip(ctx, zipWriter, workflowID, entry.job, entry.task); err != nil { + log.Error("Failed to add logs for job %d to zip: %v", entry.job.ID, err) continue } - - safeJobName := jobNameReplacer.Replace(job.Name) - fileName := fmt.Sprintf("%s-%s-%d.log", safeWorkflowName, safeJobName, task.ID) - - reader, err := actions.OpenLogs(ctx, task.LogInStorage, task.LogFilename) - if err != nil { - log.Error("Failed to open logs for job %d: %v", job.ID, err) - continue - } - - zipFile, err := zipWriter.Create(fileName) - if err != nil { - reader.Close() - log.Error("Failed to add logs for job %d to zip: %v", job.ID, err) - continue - } - - if _, err = io.Copy(zipFile, reader); err != nil { - log.Error("Failed to add logs for job %d to zip: %v", 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 { +func DownloadActionsRunJobLogs(ctx *context_module.Base, ctxRepo *repo_model.Repository, curJob *actions_model.ActionRunJob) error { + if curJob.RepoID != ctxRepo.ID { return util.NewNotExistErrorf("job not found") } - taskID := curJob.EffectiveTaskID() - if taskID == 0 { - return util.NewNotExistErrorf("job not started") - } - if err := curJob.LoadRun(ctx); err != nil { return fmt.Errorf("LoadRun: %w", err) } - task, err := actions_model.GetTaskByID(ctx, taskID) + reader, task, err := openJobTaskLogs(ctx, curJob) if err != nil { - return fmt.Errorf("GetTaskByID: %w", err) - } - - if task.LogExpired { - return util.NewNotExistErrorf("logs have been cleaned up") - } - - if task.LogLength == 0 { - return util.NewNotExistErrorf("logs not found") - } - - reader, err := actions.OpenLogs(ctx, task.LogInStorage, task.LogFilename) - if err != nil { - if errors.Is(err, fs.ErrNotExist) || errors.Is(err, util.ErrNotExist) { - return util.NewNotExistErrorf("logs not found") - } - return fmt.Errorf("OpenLogs: %w", err) + return err } defer reader.Close() - workflowName := actionsWorkflowBaseName(curJob.Run.WorkflowID) - ctx.ServeContent(reader, context.ServeHeaderOptions{ - Filename: fmt.Sprintf("%v-%v-%v.log", workflowName, curJob.Name, task.ID), + ctx.ServeContent(reader, context_module.ServeHeaderOptions{ + Filename: jobLogFileName(curJob.Run.WorkflowID, curJob.Name, task.ID), ContentLength: &task.LogSize, ContentType: "text/plain; charset=utf-8", ContentDisposition: httplib.ContentDispositionAttachment, diff --git a/tests/integration/api_actions_run_test.go b/tests/integration/api_actions_run_test.go index 1a0250dde6..3d7a66ea5b 100644 --- a/tests/integration/api_actions_run_test.go +++ b/tests/integration/api_actions_run_test.go @@ -482,6 +482,30 @@ func TestAPIActionsListUserWorkflows(t *testing.T) { assert.NotEmpty(t, job.HTMLURL, "html_url should be populated via batch-loaded repo") } }) + + t.Run("JobsDefaultOrderAsc", func(t *testing.T) { + req := NewRequest(t, "GET", "/api/v1/user/actions/jobs").AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + jobs := DecodeJSON(t, resp, &api.ActionWorkflowJobsResponse{}) + + assert.GreaterOrEqual(t, len(jobs.Entries), 2, "need at least 2 jobs to verify ordering") + for i := 1; i < len(jobs.Entries); i++ { + assert.Less(t, jobs.Entries[i-1].ID, jobs.Entries[i].ID, + "jobs should be ordered by ID ascending by default") + } + }) + + t.Run("JobsOrderedByIDDesc", func(t *testing.T) { + req := NewRequest(t, "GET", "/api/v1/user/actions/jobs?sort=id&order=desc").AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + jobs := DecodeJSON(t, resp, &api.ActionWorkflowJobsResponse{}) + + assert.GreaterOrEqual(t, len(jobs.Entries), 2, "need at least 2 jobs to verify ordering") + for i := 1; i < len(jobs.Entries); i++ { + assert.Greater(t, jobs.Entries[i-1].ID, jobs.Entries[i].ID, + "jobs should be ordered by ID descending") + } + }) } func TestAPIActionsListRepoWorkflows(t *testing.T) { @@ -514,31 +538,21 @@ func TestAPIActionsGetWorkflowRunLogs(t *testing.T) { session := loginUser(t, user.Name) token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) - t.Run("Success", func(t *testing.T) { + t.Run("NoLogs", func(t *testing.T) { + // Run 795 has jobs but fixture tasks have no log output in storage. req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/logs", repo.FullName())). AddTokenAuth(token) - resp := MakeRequest(t, req, http.StatusOK) - assert.Equal(t, "application/zip", resp.Header().Get("Content-Type")) - assert.Contains(t, resp.Header().Get("Content-Disposition"), "attachment") - assert.Contains(t, resp.Header().Get("Content-Disposition"), ".zip") - body := resp.Body.Bytes() - require.NotEmpty(t, body) - assert.Equal(t, "PK", string(body[:2]), "response should be a valid zip file") + MakeRequest(t, req, http.StatusNotFound) }) - t.Run("RerunJobLogs", func(t *testing.T) { - // Rerun the workflow so latest-attempt jobs have SourceTaskID instead of TaskID + t.Run("NoLogsAfterRerun", func(t *testing.T) { req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/rerun", repo.FullName())). AddTokenAuth(token) MakeRequest(t, req, http.StatusCreated) - // Download logs for the latest attempt — should include rerun job logs via EffectiveTaskID req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/logs", repo.FullName())). AddTokenAuth(token) - resp := MakeRequest(t, req, http.StatusOK) - body := resp.Body.Bytes() - require.NotEmpty(t, body) - assert.Equal(t, "PK", string(body[:2]), "response should be a valid zip file") + MakeRequest(t, req, http.StatusNotFound) }) t.Run("NotFound", func(t *testing.T) {