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/modules/structs/repo_actions.go b/modules/structs/repo_actions.go index dbbbd8d795..ff674324ca 100644 --- a/modules/structs/repo_actions.go +++ b/modules/structs/repo_actions.go @@ -130,6 +130,8 @@ type ActionWorkflowRun struct { Conclusion string `json:"conclusion,omitempty"` PullRequests []*PullRequestMinimal `json:"pull_requests"` // swagger:strfmt date-time + CreatedAt time.Time `json:"created_at"` + // swagger:strfmt date-time StartedAt time.Time `json:"started_at"` // swagger:strfmt date-time CompletedAt time.Time `json:"completed_at"` diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 02cabc55d1..75ba86a55f 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1341,8 +1341,14 @@ 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", reqToken(), reqRepoReader(unit.TypeActions), repo.GetWorkflowJobLogs) + m.Post("/{job_id}/rerun", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunWorkflowJob) + }) + m.Get("/logs", reqToken(), reqRepoReader(unit.TypeActions), repo.GetWorkflowRunLogs) m.Get("/artifacts", repo.GetArtifactsOfRun) }) }) 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 1765ed564d..8e666578d8 100644 --- a/routers/api/v1/repo/actions_run.go +++ b/routers/api/v1/repo/actions_run.go @@ -6,6 +6,7 @@ package repo import ( actions_model "gitea.dev/models/actions" "gitea.dev/routers/common" + actions_service "gitea.dev/services/actions" "gitea.dev/services/context" ) @@ -45,13 +46,196 @@ 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) } } + +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, jobs := getCurrentRepoActionRunJobsByID(ctx) + if ctx.Written() { + return + } + + if err := actions_service.CancelRun(ctx, run, jobs); err != nil { + ctx.APIErrorAuto(err) + return + } + + run = getCurrentRepoActionRunByID(ctx) + if ctx.Written() { + return + } + respondRepoActionWorkflowRun(ctx, run) +} + +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" + + run := getCurrentRepoActionRunByID(ctx) + if ctx.Written() { + return + } + + // GitHub-compatible: return 200 if already approved (idempotent) + if !run.NeedApproval { + respondRepoActionWorkflowRun(ctx, run) + return + } + + if err := actions_service.ApproveRuns(ctx, ctx.Repo.Repository, ctx.Doer, []int64{run.ID}); err != nil { + ctx.APIErrorAuto(err) + return + } + + run = getCurrentRepoActionRunByID(ctx) + if ctx.Written() { + return + } + respondRepoActionWorkflowRun(ctx, run) +} + +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 := getCurrentRepoActionRunByID(ctx) + if ctx.Written() { + return + } + + if err := common.DownloadActionsRunAllJobLogs(ctx.Base, ctx.Repo.Repository, run.ID); err != nil { + ctx.APIErrorAuto(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" + + run := getCurrentRepoActionRunByID(ctx) + if ctx.Written() { + return + } + + jobID := ctx.PathParamInt64("job_id") + 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 4c3c212928..02ffc2d129 100644 --- a/routers/common/actions.go +++ b/routers/common/actions.go @@ -4,8 +4,11 @@ package common import ( + "archive/zip" + "context" "errors" "fmt" + "io" "io/fs" "strings" @@ -13,59 +16,170 @@ import ( repo_model "gitea.dev/models/repo" "gitea.dev/modules/actions" "gitea.dev/modules/httplib" + "gitea.dev/modules/log" "gitea.dev/modules/util" - "gitea.dev/services/context" + context_module "gitea.dev/services/context" ) -func DownloadActionsRunJobLogsWithID(ctx *context.Base, ctxRepo *repo_model.Repository, runID, jobID int64) error { +var ( + workflowNameReplacer = strings.NewReplacer(`"`, "", "\r", "", "\n", "", "/", "-", `\`, "-") + jobNameReplacer = strings.NewReplacer("/", "-", `\`, "-", "..", "__") +) + +func sanitizeWorkflowFileName(workflowID string) string { + if p := strings.Index(workflowID, "."); p > 0 { + workflowID = workflowID[:p] + } + return workflowNameReplacer.Replace(workflowID) +} + +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 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") +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) } - taskID := curJob.EffectiveTaskID() - if taskID == 0 { - return util.NewNotExistErrorf("job not started") + if len(runJobs) == 0 { + return util.NewNotExistErrorf("no jobs found for run %d", runID) + } + + if err := runJobs[0].LoadRun(ctx); err != nil { + return fmt.Errorf("LoadRun: %w", err) + } + workflowID := runJobs[0].Run.WorkflowID + + 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", sanitizeWorkflowFileName(workflowID), runID), + )) + + zipWriter := zip.NewWriter(ctx.Resp) + defer zipWriter.Close() + + // 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 + } + } + return nil +} + +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") } 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") - } - - reader, err := actions.OpenLogs(ctx, task.LogInStorage, task.LogFilename) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return util.NewNotExistErrorf("logs not found") - } - return fmt.Errorf("OpenLogs: %w", err) + return err } defer reader.Close() - workflowName := curJob.Run.WorkflowID - if p := strings.Index(workflowName, "."); p > 0 { - workflowName = workflowName[0:p] - } - 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/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 2f4f1950ec..530581861a 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -950,27 +950,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() } diff --git a/services/actions/cancel.go b/services/actions/cancel.go new file mode 100644 index 0000000000..32de16d107 --- /dev/null +++ b/services/actions/cancel.go @@ -0,0 +1,36 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + "fmt" + + actions_model "gitea.dev/models/actions" + "gitea.dev/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/convert/action_test.go b/services/convert/action_test.go index 0cf72d28a4..f34bc09fec 100644 --- a/services/convert/action_test.go +++ b/services/convert/action_test.go @@ -116,10 +116,10 @@ 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" + run.Repo = repo apiRun, err := ToActionWorkflowRun(t.Context(), run, nil, false) require.NoError(t, err) diff --git a/services/convert/convert.go b/services/convert/convert.go index d1437de286..f7624f69ca 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -240,6 +240,7 @@ func ToActionTask(ctx context.Context, t *actions_model.ActionTask) (*api.Action if err := t.Job.Run.LoadRepo(ctx); err != nil { return nil, err } + return &api.ActionTask{ ID: t.ID, Name: t.Job.Name, @@ -250,7 +251,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: httplib.MakeAbsoluteURL(ctx, t.Job.Run.Link()), + URL: httplib.MakeAbsoluteURL(ctx, t.GetRunLink()), CreatedAt: t.Created.AsLocalTime(), UpdatedAt: t.Updated.AsLocalTime(), RunStartedAt: t.Started.AsLocalTime(), @@ -263,9 +264,10 @@ func ToActionWorkflowRun(ctx context.Context, run *actions_model.ActionRun, atte } 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 } } @@ -310,6 +312,7 @@ func ToActionWorkflowRun(ctx context.Context, run *actions_model.ActionRun, atte HTMLURL: run.HTMLURL(ctx), RunNumber: run.Index, RunAttempt: runAttempt, + CreatedAt: run.Created.AsLocalTime(), StartedAt: startedAt, CompletedAt: completedAt, Event: run.TriggerEvent, diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 37a358a26b..5afa181548 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -5457,6 +5457,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": [ @@ -5633,6 +5682,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": [ @@ -5712,6 +5810,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": [ @@ -5774,6 +5922,49 @@ } } }, + "/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" + } + } + } + }, "/repos/{owner}/{repo}/actions/runs/{run}/rerun": { "post": { "produces": [ @@ -22505,6 +22696,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" diff --git a/templates/swagger/v1_openapi3_json.tmpl b/templates/swagger/v1_openapi3_json.tmpl index 7829544526..b7b3fb1c62 100644 --- a/templates/swagger/v1_openapi3_json.tmpl +++ b/templates/swagger/v1_openapi3_json.tmpl @@ -2315,6 +2315,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" @@ -16582,6 +16587,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", @@ -16779,6 +16836,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", @@ -16871,6 +16980,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", @@ -16938,6 +17102,52 @@ ] } }, + "/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" + ] + } + }, "/repos/{owner}/{repo}/actions/runs/{run}/rerun": { "post": { "operationId": "rerunWorkflowRun", diff --git a/tests/integration/api_actions_run_test.go b/tests/integration/api_actions_run_test.go index fbb34ca4a7..3d7a66ea5b 100644 --- a/tests/integration/api_actions_run_test.go +++ b/tests/integration/api_actions_run_test.go @@ -4,46 +4,31 @@ package integration import ( + "encoding/base64" "fmt" "net/http" + "net/url" "slices" "testing" + "time" actions_model "gitea.dev/models/actions" auth_model "gitea.dev/models/auth" "gitea.dev/models/db" + "gitea.dev/models/perm" repo_model "gitea.dev/models/repo" "gitea.dev/models/unittest" 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" ) -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,8 +59,10 @@ 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{}) job198Idx := slices.IndexFunc(jobList.Entries, func(job *api.ActionWorkflowJob) bool { return job.ID == 198 }) @@ -86,7 +73,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 +92,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 +102,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 +119,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 +139,18 @@ 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{}) 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{}) - findTask1 := false findTask2 := false for _, entry := range listResp.Entries { @@ -171,7 +167,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 +189,11 @@ 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{}) + 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) @@ -232,7 +231,160 @@ 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) + 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) { + 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 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) + 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 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") + }) +} + +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 +404,11 @@ 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{}) + rerunResp := DecodeJSON(t, resp, &api.ActionWorkflowJob{}) job199Rerun := getLatestAttemptJobByTemplateJobID(t, 795, 199) assert.Equal(t, job199Rerun.ID, rerunResp.ID) assert.Equal(t, "queued", rerunResp.Status) @@ -293,7 +446,9 @@ func testAPIActionsRerunWorkflowJob(t *testing.T) { }) } -func testAPIActionsListUserWorkflows(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) @@ -353,7 +508,9 @@ func testAPIActionsListUserWorkflows(t *testing.T) { }) } -func testAPIActionsListRepoWorkflows(t *testing.T) { +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) @@ -372,3 +529,57 @@ func testAPIActionsListRepoWorkflows(t *testing.T) { assert.NotNil(t, run.TriggerActor, "trigger_actor should be populated") } } + +func TestAPIActionsGetWorkflowRunLogs(t *testing.T) { + defer prepareTestEnvActionsArtifacts(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + 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) + MakeRequest(t, req, http.StatusNotFound) + }) + + 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) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/logs", repo.FullName())). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + }) + + 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) + }) +}