diff --git a/modules/structs/repo_actions.go b/modules/structs/repo_actions.go index 4592c18ed6..5e9fb9832c 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"` diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index a8bfa0965e..86823d918b 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1261,8 +1261,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 09b0cc5b2d..6895631e76 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/repo/actions_run.go b/routers/api/v1/repo/actions_run.go index 64ac1a3ad5..730771c17c 100644 --- a/routers/api/v1/repo/actions_run.go +++ b/routers/api/v1/repo/actions_run.go @@ -5,11 +5,16 @@ package repo import ( "errors" + "net/http" + "os" actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" "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 +71,280 @@ 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 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 + } +} 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 diff --git a/routers/common/actions.go b/routers/common/actions.go index 2b83e5d842..29d4b9b752 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,76 @@ 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 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 + } + + // 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) + + if err := func() error { + reader, err := actions.OpenLogs(ctx, task.LogInStorage, task.LogFilename) + if err != nil { + return err + } + defer 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 +} + 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 +123,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/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 3ce4337fbc..30e95e1550 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -756,27 +756,10 @@ func Cancel(ctx *context_module.Context) { return } - var updatedJobs []*actions_model.ActionRunJob - - if err := db.WithTx(ctx, func(ctx context.Context) error { - cancelledJobs, err := actions_model.CancelJobs(ctx, jobs) - if err != nil { - return fmt.Errorf("cancel jobs: %w", err) - } - updatedJobs = append(updatedJobs, cancelledJobs...) - return nil - }); err != nil { - ctx.ServerError("StopTask", err) + if err := actions_service.CancelRun(ctx, run, jobs); err != nil { + ctx.ServerError("CancelRun", err) return } - - actions_service.CreateCommitStatusForRunJobs(ctx, run, jobs...) - actions_service.EmitJobsIfReadyByJobs(updatedJobs) - - actions_service.NotifyWorkflowJobsStatusUpdate(ctx, updatedJobs...) - if len(updatedJobs) > 0 { - actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, run.RepoID, run.ID) - } ctx.JSONOK() } diff --git a/services/actions/cancel.go b/services/actions/cancel.go new file mode 100644 index 0000000000..be6f29249d --- /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 "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/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/action_test.go b/services/convert/action_test.go index 5d56d10a48..1403eff0f0 100644 --- a/services/convert/action_test.go +++ b/services/convert/action_test.go @@ -116,12 +116,11 @@ func TestToActionWorkflowRun_UsesTriggerEvent(t *testing.T) { repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 803}) - run.Repo = repo // Scheduled runs keep Event as the registration event (push) and use TriggerEvent as the real trigger. run.Event = "push" run.TriggerEvent = "schedule" - apiRun, err := ToActionWorkflowRun(t.Context(), run, nil) + apiRun, err := ToActionWorkflowRun(t.Context(), repo, run, nil) require.NoError(t, err) assert.Equal(t, "schedule", apiRun.Event) } diff --git a/services/convert/convert.go b/services/convert/convert.go index dae0587ec4..75ec7281fb 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -234,6 +234,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, @@ -244,25 +245,26 @@ 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(), }, 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 +func ToActionWorkflowRun(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt) (*api.ActionWorkflowRun, error) { + if run.Repo == nil { + run.Repo = repo } if err := run.LoadTriggerUser(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 } } @@ -288,17 +290,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(ctx), 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(ctx), run.ID), PreviousAttemptURL: previousAttemptURL, HTMLURL: run.HTMLURL(ctx), RunNumber: run.Index, RunAttempt: runAttempt, + CreatedAt: run.Created.AsLocalTime(), StartedAt: startedAt, CompletedAt: completedAt, Event: run.TriggerEvent, @@ -308,7 +312,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 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 diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 26d45940f2..65cc5bcc6d 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,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": [ @@ -22066,6 +22257,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 33adff75e0..5bb49a14e4 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,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 22fa6ba2ea..983c4b22ef 100644 --- a/tests/integration/api_actions_run_test.go +++ b/tests/integration/api_actions_run_test.go @@ -4,10 +4,13 @@ package integration import ( + "encoding/base64" "fmt" "net/http" + "net/url" "slices" "testing" + "time" actions_model "code.gitea.io/gitea/models/actions" auth_model "code.gitea.io/gitea/models/auth" @@ -15,35 +18,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 +59,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 +75,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 +94,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 +104,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 +121,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 +141,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 +173,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 +195,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 +239,144 @@ 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 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) + 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 +396,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,7 +440,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) @@ -301,7 +450,9 @@ func testAPIActionsListUserWorkflows(t *testing.T) { t.Run("Runs", func(t *testing.T) { req := NewRequest(t, "GET", "/api/v1/user/actions/runs").AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) - runs := DecodeJSON(t, resp, &api.ActionWorkflowRunsResponse{}) + 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) @@ -317,7 +468,9 @@ func testAPIActionsListUserWorkflows(t *testing.T) { t.Run("Jobs", func(t *testing.T) { req := NewRequest(t, "GET", "/api/v1/user/actions/jobs").AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) - jobs := DecodeJSON(t, resp, &api.ActionWorkflowJobsResponse{}) + 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) @@ -329,7 +482,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) @@ -337,7 +492,9 @@ func testAPIActionsListRepoWorkflows(t *testing.T) { req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs", repo.FullName())).AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) - runs := DecodeJSON(t, resp, &api.ActionWorkflowRunsResponse{}) + 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) @@ -348,3 +505,46 @@ 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("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) + }) + + 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) + }) +}