From 70763fa6d468b5613088cd0cf210a6ccf7d10f05 Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Sat, 2 May 2026 09:22:14 +0700 Subject: [PATCH 1/6] 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 | 332 +++++++++++---- 10 files changed, 1280 insertions(+), 269 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 a7622644d8..82312056e3 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -29,7 +29,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" @@ -223,18 +222,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, @@ -245,25 +240,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 } } @@ -279,7 +272,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 } @@ -289,17 +281,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, @@ -309,7 +303,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 @@ -407,11 +401,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, @@ -711,7 +705,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, } } @@ -740,7 +734,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 26d45940f2..34728e24e7 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -5421,6 +5421,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": [ @@ -5597,6 +5646,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": [ @@ -5661,6 +5759,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": [ @@ -5723,6 +5871,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": [ @@ -22066,6 +22372,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" @@ -22299,14 +22610,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" } }, @@ -23841,15 +24150,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" @@ -24042,14 +24342,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": { @@ -24334,13 +24633,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": { @@ -24493,7 +24791,6 @@ "write", "admin" ], - "x-go-enum-desc": "read RepoWritePermissionRead\nwrite RepoWritePermissionWrite\nadmin RepoWritePermissionAdmin", "x-go-name": "Permission" }, "units": { @@ -24588,14 +24885,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" } }, @@ -25107,15 +25398,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" @@ -25229,14 +25511,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": { @@ -25510,21 +25791,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", @@ -25615,7 +25881,6 @@ "write", "admin" ], - "x-go-enum-desc": "read RepoWritePermissionRead\nwrite RepoWritePermissionWrite\nadmin RepoWritePermissionAdmin", "x-go-name": "Permission" }, "units": { @@ -25746,14 +26011,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": { @@ -26655,13 +26914,6 @@ "format": "int64", "x-go-name": "PinOrder" }, - "projects": { - "type": "array", - "items": { - "$ref": "#/definitions/Project" - }, - "x-go-name": "Projects" - }, "pull_request": { "$ref": "#/definitions/PullRequestMeta" }, @@ -27711,14 +27963,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": { @@ -28014,67 +28260,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", @@ -28763,16 +28948,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": { @@ -29049,13 +29226,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": { @@ -29429,7 +29605,6 @@ "admin", "owner" ], - "x-go-enum-desc": "none AccessLevelNameNone\nread AccessLevelNameRead\nwrite AccessLevelNameWrite\nadmin AccessLevelNameAdmin\nowner AccessLevelNameOwner", "x-go-name": "Permission" }, "units": { @@ -29976,14 +30151,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 22fa6ba2ea..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,58 +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") - } - }) -} - -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 365d49ca5212da0ef74cc9a98f943e0b97fa51b9 Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Sat, 2 May 2026 09:57:01 +0700 Subject: [PATCH 2/6] 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 82312056e3..7c0dd55530 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -705,7 +705,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, } } @@ -734,7 +734,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 37bbccb0ebb1dfe977b9c25bcc3e61a584bc827b Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Sat, 2 May 2026 10:23:15 +0700 Subject: [PATCH 3/6] 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 1d38cc2f53..d456aed554 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -1233,7 +1233,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 @@ -1282,7 +1282,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 @@ -1337,7 +1337,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 6f0c024843..27414b1695 100644 --- a/routers/api/v1/shared/action.go +++ b/routers/api/v1/shared/action.go @@ -197,7 +197,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 674e8acd46b9e396a518a671b29e1cd3e8f4ba84 Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Sat, 2 May 2026 10:51:47 +0700 Subject: [PATCH 4/6] 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 9efc0e36a8..d660f71778 100644 --- a/services/convert/action_test.go +++ b/services/convert/action_test.go @@ -115,12 +115,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 cefecc59fcabd7070e620f387900db2f00dd0f1a Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Sat, 2 May 2026 11:02:34 +0700 Subject: [PATCH 5/6] 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 34728e24e7..3ec0b4c7c9 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -22610,12 +22610,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" } }, @@ -24150,6 +24152,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" @@ -24342,13 +24353,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": { @@ -24633,12 +24645,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": { @@ -24791,6 +24804,7 @@ "write", "admin" ], + "x-go-enum-desc": "read RepoWritePermissionRead\nwrite RepoWritePermissionWrite\nadmin RepoWritePermissionAdmin", "x-go-name": "Permission" }, "units": { @@ -24885,8 +24899,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" } }, @@ -25398,6 +25418,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" @@ -25511,13 +25540,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": { @@ -25791,6 +25821,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", @@ -25881,6 +25926,7 @@ "write", "admin" ], + "x-go-enum-desc": "read RepoWritePermissionRead\nwrite RepoWritePermissionWrite\nadmin RepoWritePermissionAdmin", "x-go-name": "Permission" }, "units": { @@ -26011,8 +26057,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": { @@ -26914,6 +26966,13 @@ "format": "int64", "x-go-name": "PinOrder" }, + "projects": { + "type": "array", + "items": { + "$ref": "#/definitions/Project" + }, + "x-go-name": "Projects" + }, "pull_request": { "$ref": "#/definitions/PullRequestMeta" }, @@ -27963,8 +28022,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": { @@ -28260,6 +28325,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", @@ -28948,8 +29074,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": { @@ -29226,12 +29360,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": { @@ -29605,6 +29740,7 @@ "admin", "owner" ], + "x-go-enum-desc": "none AccessLevelNameNone\nread AccessLevelNameRead\nwrite AccessLevelNameWrite\nadmin AccessLevelNameAdmin\nowner AccessLevelNameOwner", "x-go-name": "Permission" }, "units": { @@ -30151,8 +30287,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 33adff75e0..b43aa84ca9 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" @@ -16270,6 +16275,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", @@ -16467,6 +16524,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", @@ -16540,6 +16649,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", @@ -16607,6 +16771,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 0200bf674c23be00d641b660446749e10b2500d0 Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Sun, 3 May 2026 09:50:24 +0700 Subject: [PATCH 6/6] 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)()