diff --git a/modules/structs/repo_actions.go b/modules/structs/repo_actions.go index 4592c18ed6..dbbbd8d795 100644 --- a/modules/structs/repo_actions.go +++ b/modules/structs/repo_actions.go @@ -117,23 +117,48 @@ type ActionWorkflowRun struct { // RunAttempt is 1-based for runs created after ActionRunAttempt was introduced. // A value of 0 is a legacy-only sentinel for runs created before attempts existed // and indicates no corresponding /attempts/{n} resource is available. - RunAttempt int64 `json:"run_attempt"` - RunNumber int64 `json:"run_number"` - RepositoryID int64 `json:"repository_id,omitempty"` - HeadSha string `json:"head_sha"` - HeadBranch string `json:"head_branch,omitempty"` - Status string `json:"status"` - Actor *User `json:"actor,omitempty"` - TriggerActor *User `json:"trigger_actor,omitempty"` - Repository *Repository `json:"repository,omitempty"` - HeadRepository *Repository `json:"head_repository,omitempty"` - Conclusion string `json:"conclusion,omitempty"` + RunAttempt int64 `json:"run_attempt"` + RunNumber int64 `json:"run_number"` + RepositoryID int64 `json:"repository_id,omitempty"` + HeadSha string `json:"head_sha"` + HeadBranch string `json:"head_branch,omitempty"` + Status string `json:"status"` + Actor *User `json:"actor,omitempty"` + TriggerActor *User `json:"trigger_actor,omitempty"` + Repository *Repository `json:"repository,omitempty"` + HeadRepository *Repository `json:"head_repository,omitempty"` + Conclusion string `json:"conclusion,omitempty"` + PullRequests []*PullRequestMinimal `json:"pull_requests"` // swagger:strfmt date-time StartedAt time.Time `json:"started_at"` // swagger:strfmt date-time CompletedAt time.Time `json:"completed_at"` } +// PullRequestMinimal is the minimal information about a pull request, as +// returned in the `pull_requests` field of a workflow run. +type PullRequestMinimal struct { + ID int64 `json:"id"` + Number int64 `json:"number"` + URL string `json:"url"` + Head PullRequestMinimalHead `json:"head"` + Base PullRequestMinimalHead `json:"base"` +} + +// PullRequestMinimalHead is a minimal description of one side of a pull request. +type PullRequestMinimalHead struct { + Ref string `json:"ref"` + SHA string `json:"sha"` + Repo PullRequestMinimalHeadRepo `json:"repo"` +} + +// PullRequestMinimalHeadRepo is a minimal description of the repository on one side of a pull request. +type PullRequestMinimalHeadRepo struct { + ID int64 `json:"id"` + URL string `json:"url"` + Name string `json:"name"` +} + // ActionWorkflowRunsResponse returns ActionWorkflowRuns type ActionWorkflowRunsResponse struct { Entries []*ActionWorkflowRun `json:"workflow_runs"` diff --git a/routers/api/v1/admin/action.go b/routers/api/v1/admin/action.go index 62e0c6addc..544026cdbd 100644 --- a/routers/api/v1/admin/action.go +++ b/routers/api/v1/admin/action.go @@ -89,5 +89,5 @@ func ListWorkflowRuns(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - shared.ListRuns(ctx, 0, 0) + shared.ListRuns(ctx, 0, 0, "") } diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index a8bfa0965e..f4020b0fdb 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1165,6 +1165,7 @@ func Routes() *web.Router { m.Group("/actions/workflows", func() { m.Get("", repo.ActionsListRepositoryWorkflows) m.Get("/{workflow_id}", repo.ActionsGetWorkflow) + m.Get("/{workflow_id}/runs", repo.ActionsListWorkflowRuns) m.Put("/{workflow_id}/disable", reqRepoWriter(unit.TypeActions), repo.ActionsDisableWorkflow) m.Put("/{workflow_id}/enable", reqRepoWriter(unit.TypeActions), repo.ActionsEnableWorkflow) m.Post("/{workflow_id}/dispatches", reqRepoWriter(unit.TypeActions), bind(api.CreateActionWorkflowDispatch{}), repo.ActionsDispatchWorkflow) diff --git a/routers/api/v1/org/action.go b/routers/api/v1/org/action.go index d218c19fd4..285da78c6a 100644 --- a/routers/api/v1/org/action.go +++ b/routers/api/v1/org/action.go @@ -679,7 +679,7 @@ func (Action) ListWorkflowRuns(ctx *context.APIContext) { // "$ref": "#/responses/error" // "404": // "$ref": "#/responses/notFound" - shared.ListRuns(ctx, ctx.Org.Organization.ID, 0) + shared.ListRuns(ctx, ctx.Org.Organization.ID, 0, "") } var _ actions_service.API = new(Action) diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index d23fc849ac..f3803b66d0 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -763,6 +763,11 @@ func (Action) ListWorkflowRuns(ctx *context.APIContext) { // description: triggering sha of the workflow run // type: string // required: false + // - name: exclude_pull_requests + // in: query + // description: if true, the `pull_requests` field on each returned run is emptied + // type: boolean + // required: false // - name: page // in: query // description: page number of results to return (1-based) @@ -781,7 +786,7 @@ func (Action) ListWorkflowRuns(ctx *context.APIContext) { repoID := ctx.Repo.Repository.ID - shared.ListRuns(ctx, 0, repoID) + shared.ListRuns(ctx, 0, repoID, "") } var _ actions_service.API = new(Action) @@ -958,6 +963,101 @@ func ActionsGetWorkflow(ctx *context.APIContext) { ctx.JSON(http.StatusOK, workflow) } +func ActionsListWorkflowRuns(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs repository ActionsListWorkflowRuns + // --- + // summary: List runs for a workflow + // 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 repo + // type: string + // required: true + // - name: workflow_id + // in: path + // description: id of the workflow, must be the workflow file name (e.g. `build.yml`) + // type: string + // required: true + // - name: event + // in: query + // description: workflow event name + // type: string + // required: false + // - name: branch + // in: query + // description: workflow branch + // type: string + // required: false + // - name: status + // in: query + // description: workflow status (pending, queued, in_progress, failure, success, skipped) + // type: string + // required: false + // - name: actor + // in: query + // description: triggered by user + // type: string + // required: false + // - name: head_sha + // in: query + // description: triggering sha of the workflow run + // type: string + // required: false + // - name: exclude_pull_requests + // in: query + // description: if true, the `pull_requests` field on each returned run is emptied + // type: boolean + // required: false + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/WorkflowRunsList" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + workflowID := ctx.PathParam("workflow_id") + // Existing runs prove the workflow is/was valid and cover historical workflows + // whose file was later removed. Fall back to a git lookup for never-run workflows. + runExists, err := db.Exist[actions_model.ActionRun](ctx, actions_model.FindRunOptions{ + RepoID: ctx.Repo.Repository.ID, + WorkflowID: workflowID, + }.ToConds()) + if err != nil { + ctx.APIErrorInternal(err) + return + } + if !runExists { + if _, err := convert.GetActionWorkflow(ctx, ctx.Repo.GitRepo, ctx.Repo.Repository, workflowID); err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.APIError(http.StatusNotFound, err) + } else { + ctx.APIErrorInternal(err) + } + return + } + } + + shared.ListRuns(ctx, 0, ctx.Repo.Repository.ID, workflowID) +} + func ActionsDisableWorkflow(ctx *context.APIContext) { // swagger:operation PUT /repos/{owner}/{repo}/actions/workflows/{workflow_id}/disable repository ActionsDisableWorkflow // --- @@ -1232,7 +1332,7 @@ func GetWorkflowRun(ctx *context.APIContext) { return } - convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil) + convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil, false) if err != nil { ctx.APIErrorInternal(err) return @@ -1281,7 +1381,7 @@ func GetWorkflowRunAttempt(ctx *context.APIContext) { return } - convertedRun, err := convert.ToActionWorkflowRun(ctx, run, attempt) + convertedRun, err := convert.ToActionWorkflowRun(ctx, run, attempt, false) if err != nil { ctx.APIErrorInternal(err) return @@ -1336,7 +1436,7 @@ func RerunWorkflowRun(ctx *context.APIContext) { return } - convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil) + convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil, false) if err != nil { ctx.APIErrorInternal(err) return diff --git a/routers/api/v1/shared/action.go b/routers/api/v1/shared/action.go index 6f0c024843..e3a2383280 100644 --- a/routers/api/v1/shared/action.go +++ b/routers/api/v1/shared/action.go @@ -129,8 +129,9 @@ func convertToInternal(s string) ([]actions_model.Status, error) { // ownerID == 0 and repoID != 0 means all runs for the given repo // ownerID != 0 and repoID == 0 means all runs for the given user/org // ownerID != 0 and repoID != 0 undefined behavior +// workflowID filters runs by workflow file name (e.g. "build.yml"), empty means no filter // Access rights are checked at the API route level -func ListRuns(ctx *context.APIContext, ownerID, repoID int64) { +func ListRuns(ctx *context.APIContext, ownerID, repoID int64, workflowID string) { if ownerID != 0 && repoID != 0 { setting.PanicInDevOrTesting("ownerID and repoID should not be both set") } @@ -138,6 +139,7 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64) { opts := actions_model.FindRunOptions{ OwnerID: ownerID, RepoID: repoID, + WorkflowID: workflowID, ListOptions: listOptions, } @@ -166,6 +168,7 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64) { if headSHA := ctx.FormString("head_sha"); headSHA != "" { opts.CommitSHA = headSHA } + excludePullRequests := ctx.FormBool("exclude_pull_requests") runs, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts) if err != nil { @@ -197,7 +200,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], nil, excludePullRequests) if err != nil { ctx.APIErrorInternal(err) return diff --git a/routers/api/v1/user/action.go b/routers/api/v1/user/action.go index 4de0b30d98..6e9685a5fc 100644 --- a/routers/api/v1/user/action.go +++ b/routers/api/v1/user/action.go @@ -407,7 +407,7 @@ func ListWorkflowRuns(ctx *context.APIContext) { // "$ref": "#/responses/error" // "404": // "$ref": "#/responses/notFound" - shared.ListRuns(ctx, ctx.Doer.ID, 0) + shared.ListRuns(ctx, ctx.Doer.ID, 0, "") } // ListWorkflowJobs lists workflow jobs diff --git a/services/actions/notifier.go b/services/actions/notifier.go index 4b2e87afad..a8c09f2712 100644 --- a/services/actions/notifier.go +++ b/services/actions/notifier.go @@ -816,7 +816,7 @@ func (n *actionsNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *rep return } run.Repo = repo - convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil) + convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil, false) 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..6bcd56e066 100644 --- a/services/convert/action_test.go +++ b/services/convert/action_test.go @@ -121,7 +121,7 @@ func TestToActionWorkflowRun_UsesTriggerEvent(t *testing.T) { run.Event = "push" run.TriggerEvent = "schedule" - apiRun, err := ToActionWorkflowRun(t.Context(), run, nil) + apiRun, err := ToActionWorkflowRun(t.Context(), run, nil, false) require.NoError(t, err) assert.Equal(t, "schedule", apiRun.Event) } diff --git a/services/convert/convert.go b/services/convert/convert.go index dae0587ec4..259ce13dd9 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -33,6 +33,7 @@ import ( "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" + webhook_module "code.gitea.io/gitea/modules/webhook" asymkey_service "code.gitea.io/gitea/services/asymkey" "code.gitea.io/gitea/services/gitdiff" @@ -251,11 +252,8 @@ func ToActionTask(ctx context.Context, t *actions_model.ActionTask) (*api.Action }, nil } -func ToActionWorkflowRun(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt) (_ *api.ActionWorkflowRun, err error) { - if err := run.LoadRepo(ctx); err != nil { - return nil, err - } - if err := run.LoadTriggerUser(ctx); err != nil { +func ToActionWorkflowRun(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, excludePullRequests bool) (_ *api.ActionWorkflowRun, err error) { + if err := run.LoadAttributes(ctx); err != nil { return nil, err } @@ -288,7 +286,15 @@ 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", run.Repo.APIURL(ctx), run.ID, attempt.Attempt-1) + previousAttemptURL = &url + } + } + pullRequests := []*api.PullRequestMinimal{} + if !excludePullRequests { + pullRequests, err = loadPullRequestsForRun(ctx, run) + if err != nil { + return nil, err } } @@ -311,6 +317,89 @@ func ToActionWorkflowRun(ctx context.Context, run *actions_model.ActionRun, atte Repository: ToRepo(ctx, run.Repo, access_model.Permission{AccessMode: perm.AccessModeNone}), TriggerActor: ToUser(ctx, triggerUser, nil), Actor: ToUser(ctx, actor, nil), + PullRequests: pullRequests, + }, nil +} + +// loadPullRequestsForRun returns the pull requests associated with a run, matching +// GitHub's `pull_requests` field on workflow run responses: +// - For pull_request / pull_request_review events, the PR whose ref triggered the run. +// - For push events, open PRs whose head branch matches the pushed ref in the same repo. +// - For other events, no PRs. +func loadPullRequestsForRun(ctx context.Context, run *actions_model.ActionRun) ([]*api.PullRequestMinimal, error) { + result := []*api.PullRequestMinimal{} + refName := git.RefName(run.Ref) + var prs issues_model.PullRequestList + switch { + case run.Event.IsPullRequest() || run.Event.IsPullRequestReview(): + index, err := strconv.ParseInt(refName.PullName(), 10, 64) + if err != nil { + return result, nil + } + pr, err := issues_model.GetPullRequestByIndex(ctx, run.RepoID, index) + if err != nil { + if issues_model.IsErrPullRequestNotExist(err) { + return result, nil + } + return nil, err + } + prs = issues_model.PullRequestList{pr} + case run.Event == webhook_module.HookEventPush: + branch := refName.BranchName() + if branch == "" { + return result, nil + } + var err error + prs, err = issues_model.GetUnmergedPullRequestsByHeadInfo(ctx, run.RepoID, branch) + if err != nil { + return nil, err + } + default: + return result, nil + } + for _, pr := range prs { + minimal, err := toPullRequestMinimal(ctx, run.Repo, pr, run.CommitSHA) + if err != nil { + return nil, err + } + result = append(result, minimal) + } + return result, nil +} + +func toPullRequestMinimal(ctx context.Context, repo *repo_model.Repository, pr *issues_model.PullRequest, headSHA string) (*api.PullRequestMinimal, error) { + if err := pr.LoadBaseRepo(ctx); err != nil { + return nil, err + } + if err := pr.LoadHeadRepo(ctx); err != nil { + return nil, err + } + headRepo := pr.HeadRepo + if headRepo == nil { + headRepo = pr.BaseRepo + } + return &api.PullRequestMinimal{ + ID: pr.ID, + Number: pr.Index, + URL: fmt.Sprintf("%s/pulls/%d", repo.APIURL(ctx), pr.Index), + Head: api.PullRequestMinimalHead{ + Ref: pr.HeadBranch, + SHA: headSHA, + Repo: api.PullRequestMinimalHeadRepo{ + ID: headRepo.ID, + URL: headRepo.APIURL(ctx), + Name: headRepo.Name, + }, + }, + Base: api.PullRequestMinimalHead{ + Ref: pr.BaseBranch, + SHA: pr.MergeBase, + Repo: api.PullRequestMinimalHeadRepo{ + ID: pr.BaseRepo.ID, + URL: pr.BaseRepo.APIURL(ctx), + Name: pr.BaseRepo.Name, + }, + }, }, nil } @@ -507,6 +596,9 @@ func ListActionWorkflows(ctx context.Context, gitrepo *git.Repository, repo *rep } func GetActionWorkflow(ctx context.Context, gitrepo *git.Repository, repo *repo_model.Repository, workflowID string) (*api.ActionWorkflow, error) { + if repo.DefaultBranch == "" { + return nil, util.NewNotExistErrorf("workflow %q not found", workflowID) + } defaultBranchCommit, err := gitrepo.GetBranchCommit(repo.DefaultBranch) if err != nil { return nil, err diff --git a/services/webhook/notifier.go b/services/webhook/notifier.go index 7627935a32..794473dd9e 100644 --- a/services/webhook/notifier.go +++ b/services/webhook/notifier.go @@ -1044,7 +1044,7 @@ func (*webhookNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_ } run.Repo = repo - convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil) + convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil, false) 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..a0975fb502 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -5305,6 +5305,12 @@ "name": "head_sha", "in": "query" }, + { + "type": "boolean", + "description": "if true, the `pull_requests` field on each returned run is emptied", + "name": "exclude_pull_requests", + "in": "query" + }, { "type": "integer", "description": "page number of results to return (1-based)", @@ -6577,6 +6583,103 @@ } } }, + "/repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "List runs for a workflow", + "operationId": "ActionsListWorkflowRuns", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "id of the workflow, must be the workflow file name (e.g. `build.yml`)", + "name": "workflow_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "workflow event name", + "name": "event", + "in": "query" + }, + { + "type": "string", + "description": "workflow branch", + "name": "branch", + "in": "query" + }, + { + "type": "string", + "description": "workflow status (pending, queued, in_progress, failure, success, skipped)", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "triggered by user", + "name": "actor", + "in": "query" + }, + { + "type": "string", + "description": "triggering sha of the workflow run", + "name": "head_sha", + "in": "query" + }, + { + "type": "boolean", + "description": "if true, the `pull_requests` field on each returned run is emptied", + "name": "exclude_pull_requests", + "in": "query" + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/WorkflowRunsList" + }, + "400": { + "$ref": "#/responses/error" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/{owner}/{repo}/activities/feeds": { "get": { "produces": [ @@ -22103,6 +22206,13 @@ "type": "string", "x-go-name": "PreviousAttemptURL" }, + "pull_requests": { + "type": "array", + "items": { + "$ref": "#/definitions/PullRequestMinimal" + }, + "x-go-name": "PullRequests" + }, "repository": { "$ref": "#/definitions/Repository" }, @@ -28367,6 +28477,71 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "PullRequestMinimal": { + "description": "PullRequestMinimal is the minimal information about a pull request, as\nreturned in the `pull_requests` field of a workflow run.", + "type": "object", + "properties": { + "base": { + "$ref": "#/definitions/PullRequestMinimalHead" + }, + "head": { + "$ref": "#/definitions/PullRequestMinimalHead" + }, + "id": { + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "number": { + "type": "integer", + "format": "int64", + "x-go-name": "Number" + }, + "url": { + "type": "string", + "x-go-name": "URL" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "PullRequestMinimalHead": { + "type": "object", + "title": "PullRequestMinimalHead is a minimal description of one side of a pull request.", + "properties": { + "ref": { + "type": "string", + "x-go-name": "Ref" + }, + "repo": { + "$ref": "#/definitions/PullRequestMinimalHeadRepo" + }, + "sha": { + "type": "string", + "x-go-name": "SHA" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "PullRequestMinimalHeadRepo": { + "type": "object", + "title": "PullRequestMinimalHeadRepo is a minimal description of the repository on one side of a pull request.", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "name": { + "type": "string", + "x-go-name": "Name" + }, + "url": { + "type": "string", + "x-go-name": "URL" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "PullReview": { "description": "PullReview represents a pull request review", "type": "object", diff --git a/templates/swagger/v1_openapi3_json.tmpl b/templates/swagger/v1_openapi3_json.tmpl index 33adff75e0..271edcf79a 100644 --- a/templates/swagger/v1_openapi3_json.tmpl +++ b/templates/swagger/v1_openapi3_json.tmpl @@ -2344,6 +2344,13 @@ "type": "string", "x-go-name": "PreviousAttemptURL" }, + "pull_requests": { + "items": { + "$ref": "#/components/schemas/PullRequestMinimal" + }, + "type": "array", + "x-go-name": "PullRequests" + }, "repository": { "$ref": "#/components/schemas/Repository" }, @@ -8612,6 +8619,73 @@ "type": "object", "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "PullRequestMinimal": { + "description": "PullRequestMinimal is the minimal information about a pull request, as\nreturned in the `pull_requests` field of a workflow run.", + "properties": { + "base": { + "$ref": "#/components/schemas/PullRequestMinimalHead" + }, + "head": { + "$ref": "#/components/schemas/PullRequestMinimalHead" + }, + "id": { + "format": "int64", + "type": "integer", + "x-go-name": "ID" + }, + "number": { + "format": "int64", + "type": "integer", + "x-go-name": "Number" + }, + "url": { + "format": "uri", + "type": "string", + "x-go-name": "URL" + } + }, + "type": "object", + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "PullRequestMinimalHead": { + "properties": { + "ref": { + "type": "string", + "x-go-name": "Ref" + }, + "repo": { + "$ref": "#/components/schemas/PullRequestMinimalHeadRepo" + }, + "sha": { + "type": "string", + "x-go-name": "SHA" + } + }, + "title": "PullRequestMinimalHead is a minimal description of one side of a pull request.", + "type": "object", + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "PullRequestMinimalHeadRepo": { + "properties": { + "id": { + "format": "int64", + "type": "integer", + "x-go-name": "ID" + }, + "name": { + "type": "string", + "x-go-name": "Name" + }, + "url": { + "format": "uri", + "type": "string", + "x-go-name": "URL" + } + }, + "title": "PullRequestMinimalHeadRepo is a minimal description of the repository on one side of a pull request.", + "type": "object", + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "PullReview": { "description": "PullReview represents a pull request review", "properties": { @@ -16140,6 +16214,14 @@ "type": "string" } }, + { + "description": "if true, the `pull_requests` field on each returned run is emptied", + "in": "query", + "name": "exclude_pull_requests", + "schema": { + "type": "boolean" + } + }, { "description": "page number of results to return (1-based)", "in": "query", @@ -17524,6 +17606,122 @@ ] } }, + "/repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs": { + "get": { + "operationId": "ActionsListWorkflowRuns", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the workflow, must be the workflow file name (e.g. `build.yml`)", + "in": "path", + "name": "workflow_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "workflow event name", + "in": "query", + "name": "event", + "schema": { + "type": "string" + } + }, + { + "description": "workflow branch", + "in": "query", + "name": "branch", + "schema": { + "type": "string" + } + }, + { + "description": "workflow status (pending, queued, in_progress, failure, success, skipped)", + "in": "query", + "name": "status", + "schema": { + "type": "string" + } + }, + { + "description": "triggered by user", + "in": "query", + "name": "actor", + "schema": { + "type": "string" + } + }, + { + "description": "triggering sha of the workflow run", + "in": "query", + "name": "head_sha", + "schema": { + "type": "string" + } + }, + { + "description": "if true, the `pull_requests` field on each returned run is emptied", + "in": "query", + "name": "exclude_pull_requests", + "schema": { + "type": "boolean" + } + }, + { + "description": "page number of results to return (1-based)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "page size of results", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/WorkflowRunsList" + }, + "400": { + "$ref": "#/components/responses/error" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "List runs for a workflow", + "tags": [ + "repository" + ] + } + }, "/repos/{owner}/{repo}/activities/feeds": { "get": { "operationId": "repoListActivityFeeds", diff --git a/tests/integration/workflow_run_api_check_test.go b/tests/integration/workflow_run_api_check_test.go index d7390b3ac1..eb01bd1bb5 100644 --- a/tests/integration/workflow_run_api_check_test.go +++ b/tests/integration/workflow_run_api_check_test.go @@ -9,11 +9,15 @@ import ( "net/url" "testing" + actions_model "code.gitea.io/gitea/models/actions" auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" api "code.gitea.io/gitea/modules/structs" + webhook_module "code.gitea.io/gitea/modules/webhook" "code.gitea.io/gitea/tests" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestAPIWorkflowRun(t *testing.T) { @@ -29,6 +33,107 @@ func TestAPIWorkflowRun(t *testing.T) { t.Run("RepoRuns", func(t *testing.T) { testAPIWorkflowRunBasic(t, "/api/v1/repos/org3/repo5/actions", "User2", 802, auth_model.AccessTokenScopeReadRepository) }) + t.Run("RepoWorkflowRuns", func(t *testing.T) { + testAPIWorkflowRunsByWorkflowID(t, "org3", "repo5", "test.yaml", "User2", 802, auth_model.AccessTokenScopeReadRepository) + }) + t.Run("PullRequestsField", testAPIWorkflowRunsPullRequestsField) +} + +// testAPIWorkflowRunsPullRequestsField exercises the `pull_requests` field and the +// `exclude_pull_requests` toggle by associating an inserted run with fixture PR +// user2/repo1#3 (head: branch2, base: master). +func testAPIWorkflowRunsPullRequestsField(t *testing.T) { + defer tests.PrepareTestEnv(t)() + ctx := t.Context() + + run := &actions_model.ActionRun{ + RepoID: 1, + OwnerID: 2, + TriggerUserID: 2, + WorkflowID: "pr-assoc.yaml", + Index: 99001, + Ref: "refs/pull/3/head", + CommitSHA: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + Event: webhook_module.HookEventPullRequest, + TriggerEvent: "pull_request_target", + Status: actions_model.StatusSuccess, + } + require.NoError(t, db.Insert(ctx, run)) + + token := getUserToken(t, "User2", auth_model.AccessTokenScopeReadRepository) + runsURL := "/api/v1/repos/user2/repo1/actions/workflows/pr-assoc.yaml/runs" + + req := NewRequest(t, "GET", runsURL).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + list := api.ActionWorkflowRunsResponse{} + DecodeJSON(t, resp, &list) + + var got *api.ActionWorkflowRun + for _, r := range list.Entries { + if r.ID == run.ID { + got = r + break + } + } + require.NotNil(t, got, "inserted PR-triggered run not returned") + require.Len(t, got.PullRequests, 1) + pr := got.PullRequests[0] + assert.Equal(t, int64(3), pr.Number) + assert.Equal(t, "branch2", pr.Head.Ref) + assert.Equal(t, "master", pr.Base.Ref) + assert.Equal(t, int64(1), pr.Base.Repo.ID) + assert.Equal(t, "repo1", pr.Base.Repo.Name) + + req = NewRequest(t, "GET", runsURL+"?exclude_pull_requests=true").AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + excluded := api.ActionWorkflowRunsResponse{} + DecodeJSON(t, resp, &excluded) + for _, r := range excluded.Entries { + if r.ID == run.ID { + assert.Empty(t, r.PullRequests) + } + } +} + +func testAPIWorkflowRunsByWorkflowID(t *testing.T, owner, repo, workflowID, userUsername string, expectedRunID int64, scope ...auth_model.AccessTokenScope) { + defer tests.PrepareTestEnv(t)() + token := getUserToken(t, userUsername, scope...) + + workflowRunsURL := fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/%s/runs", owner, repo, workflowID) + + req := NewRequest(t, "GET", workflowRunsURL).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + runList := api.ActionWorkflowRunsResponse{} + DecodeJSON(t, resp, &runList) + + found := false + for _, run := range runList.Entries { + verifyWorkflowRunCanbeFoundWithStatusFilter(t, workflowRunsURL, token, run.ID, "", run.Status, "", "", "", "") + verifyWorkflowRunCanbeFoundWithStatusFilter(t, workflowRunsURL, token, run.ID, "", "", "", run.HeadBranch, "", "") + verifyWorkflowRunCanbeFoundWithStatusFilter(t, workflowRunsURL, token, run.ID, "", "", run.Event, "", "", "") + verifyWorkflowRunCanbeFoundWithStatusFilter(t, workflowRunsURL, token, run.ID, "", "", "", "", run.TriggerActor.UserName, "") + verifyWorkflowRunCanbeFoundWithStatusFilter(t, workflowRunsURL, token, run.ID, "", "", "", "", run.TriggerActor.UserName, run.HeadSha) + if run.ID == expectedRunID { + found = true + } + } + assert.True(t, found, "expected to find run with ID %d in workflow %s runs", expectedRunID, workflowID) + + req = NewRequest(t, "GET", workflowRunsURL+"?exclude_pull_requests=true").AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + excludedList := api.ActionWorkflowRunsResponse{} + DecodeJSON(t, resp, &excludedList) + excludedFound := false + for _, run := range excludedList.Entries { + assert.Empty(t, run.PullRequests, "expected pull_requests to be empty when excluded") + if run.ID == expectedRunID { + excludedFound = true + } + } + assert.True(t, excludedFound, "expected to find run with ID %d when excluding pull requests", expectedRunID) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/nonexistent.yaml/runs", owner, repo)).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) } func testAPIWorkflowRunBasic(t *testing.T, apiRootURL, userUsername string, runID int64, scope ...auth_model.AccessTokenScope) {