diff --git a/models/actions/run_list.go b/models/actions/run_list.go index 726157b12e..8b8c132a48 100644 --- a/models/actions/run_list.go +++ b/models/actions/run_list.go @@ -45,17 +45,16 @@ func (runs RunList) LoadTriggerUser(ctx context.Context) error { type FindRunOptions struct { db.ListOptions - RepoID int64 - OwnerID int64 - WorkflowID string - Ref string // the commit/tag/… that caused this workflow - TriggerUserID int64 - TriggerEvent webhook_module.HookEventType - Approved bool // not util.OptionalBool, it works only when it's true - Status []Status - ConcurrencyGroup string - CommitSHA string - ExcludePullRequests bool + RepoID int64 + OwnerID int64 + WorkflowID string + Ref string // the commit/tag/… that caused this workflow + TriggerUserID int64 + TriggerEvent webhook_module.HookEventType + Approved bool // not util.OptionalBool, it works only when it's true + Status []Status + ConcurrencyGroup string + CommitSHA string } func (opts FindRunOptions) ToConds() builder.Cond { @@ -84,9 +83,6 @@ func (opts FindRunOptions) ToConds() builder.Cond { if opts.CommitSHA != "" { cond = cond.And(builder.Eq{"`action_run`.commit_sha": opts.CommitSHA}) } - if opts.ExcludePullRequests { - cond = cond.And(builder.Neq{"`action_run`.trigger_event": string(webhook_module.HookEventPullRequest)}) - } if len(opts.ConcurrencyGroup) > 0 { if opts.RepoID == 0 { panic("Invalid FindRunOptions: repo_id is required") diff --git a/modules/structs/repo_actions.go b/modules/structs/repo_actions.go index 92ca9bccce..54a15a20e6 100644 --- a/modules/structs/repo_actions.go +++ b/modules/structs/repo_actions.go @@ -105,29 +105,54 @@ type ActionArtifact struct { // ActionWorkflowRun represents a WorkflowRun type ActionWorkflowRun struct { - ID int64 `json:"id"` - URL string `json:"url"` - HTMLURL string `json:"html_url"` - DisplayTitle string `json:"display_title"` - Path string `json:"path"` - Event string `json:"event"` - 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"` + ID int64 `json:"id"` + URL string `json:"url"` + HTMLURL string `json:"html_url"` + DisplayTitle string `json:"display_title"` + Path string `json:"path"` + Event string `json:"event"` + 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/repo/action.go b/routers/api/v1/repo/action.go index b05a4e3ad1..9e473b34f5 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -765,7 +765,7 @@ func (Action) ListWorkflowRuns(ctx *context.APIContext) { // required: false // - name: exclude_pull_requests // in: query - // description: if true, pull request events are omitted from the results + // description: if true, the `pull_requests` field on each returned run is emptied // type: boolean // required: false // - name: page @@ -1006,7 +1006,7 @@ func ActionsListWorkflowRuns(ctx *context.APIContext) { // required: false // - name: exclude_pull_requests // in: query - // description: if true, pull request events are omitted from the results + // description: if true, the `pull_requests` field on each returned run is emptied // type: boolean // required: false // - name: page @@ -1307,7 +1307,7 @@ func GetWorkflowRun(ctx *context.APIContext) { return } - convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, run) + convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, run, false) if err != nil { ctx.APIErrorInternal(err) return @@ -1360,7 +1360,7 @@ func RerunWorkflowRun(ctx *context.APIContext) { return } - convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, run) + convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, run, 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 dde724c6ee..926e940fbb 100644 --- a/routers/api/v1/shared/action.go +++ b/routers/api/v1/shared/action.go @@ -156,7 +156,7 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64, workflowID string) if headSHA := ctx.FormString("head_sha"); headSHA != "" { opts.CommitSHA = headSHA } - opts.ExcludePullRequests = ctx.FormBool("exclude_pull_requests") + excludePullRequests := ctx.FormBool("exclude_pull_requests") runs, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts) if err != nil { @@ -181,7 +181,7 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64, workflowID string) } } - convertedRun, err := convert.ToActionWorkflowRun(ctx, repository, runs[i]) + convertedRun, err := convert.ToActionWorkflowRun(ctx, repository, runs[i], excludePullRequests) if err != nil { ctx.APIErrorInternal(err) return diff --git a/services/actions/notifier.go b/services/actions/notifier.go index 5f7ee6fcea..c7fdf6d5fe 100644 --- a/services/actions/notifier.go +++ b/services/actions/notifier.go @@ -815,7 +815,7 @@ func (n *actionsNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *rep log.Error("GetActionWorkflow: %v", err) return } - convertedRun, err := convert.ToActionWorkflowRun(ctx, repo, run) + convertedRun, err := convert.ToActionWorkflowRun(ctx, repo, run, false) if err != nil { log.Error("ToActionWorkflowRun: %v", err) return diff --git a/services/convert/convert.go b/services/convert/convert.go index f7a207622b..4f5c54e682 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" @@ -247,12 +248,19 @@ func ToActionTask(ctx context.Context, t *actions_model.ActionTask) (*api.Action }, nil } -func ToActionWorkflowRun(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun) (*api.ActionWorkflowRun, error) { +func ToActionWorkflowRun(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, excludePullRequests bool) (*api.ActionWorkflowRun, error) { err := run.LoadAttributes(ctx) if err != nil { return nil, err } status, conclusion := ToActionsStatus(run.Status) + pullRequests := []*api.PullRequestMinimal{} + if !excludePullRequests { + pullRequests, err = loadPullRequestsForRun(ctx, repo, run) + if err != nil { + return nil, err + } + } return &api.ActionWorkflowRun{ ID: run.ID, URL: fmt.Sprintf("%s/actions/runs/%d", repo.APIURL(), run.ID), @@ -270,7 +278,90 @@ func ToActionWorkflowRun(ctx context.Context, repo *repo_model.Repository, run * Repository: ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeNone}), TriggerActor: ToUser(ctx, run.TriggerUser, nil), // We do not have a way to get a different User for the actor than the trigger user - Actor: ToUser(ctx, run.TriggerUser, nil), + Actor: ToUser(ctx, run.TriggerUser, 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, repo *repo_model.Repository, 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, 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(), pr.Index), + Head: api.PullRequestMinimalHead{ + Ref: pr.HeadBranch, + SHA: headSHA, + Repo: api.PullRequestMinimalHeadRepo{ + ID: headRepo.ID, + URL: headRepo.APIURL(), + Name: headRepo.Name, + }, + }, + Base: api.PullRequestMinimalHead{ + Ref: pr.BaseBranch, + SHA: pr.MergeBase, + Repo: api.PullRequestMinimalHeadRepo{ + ID: pr.BaseRepo.ID, + URL: pr.BaseRepo.APIURL(), + Name: pr.BaseRepo.Name, + }, + }, }, nil } diff --git a/services/webhook/notifier.go b/services/webhook/notifier.go index 2b301d4d58..c1d2ee063c 100644 --- a/services/webhook/notifier.go +++ b/services/webhook/notifier.go @@ -1043,7 +1043,7 @@ func (*webhookNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_ return } - convertedRun, err := convert.ToActionWorkflowRun(ctx, repo, run) + convertedRun, err := convert.ToActionWorkflowRun(ctx, repo, run, 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 85a654f271..ea8e161a85 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -5307,7 +5307,7 @@ }, { "type": "boolean", - "description": "if true, pull request events are omitted from the results", + "description": "if true, the `pull_requests` field on each returned run is emptied", "name": "exclude_pull_requests", "in": "query" }, @@ -6514,7 +6514,7 @@ }, { "type": "boolean", - "description": "if true, pull request events are omitted from the results", + "description": "if true, the `pull_requests` field on each returned run is emptied", "name": "exclude_pull_requests", "in": "query" }, @@ -21996,6 +21996,13 @@ "type": "string", "x-go-name": "Path" }, + "pull_requests": { + "type": "array", + "items": { + "$ref": "#/definitions/PullRequestMinimal" + }, + "x-go-name": "PullRequests" + }, "repository": { "$ref": "#/definitions/Repository" }, @@ -28122,6 +28129,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/tests/integration/workflow_run_api_check_test.go b/tests/integration/workflow_run_api_check_test.go index db0a8d074a..51faeee6dd 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) { @@ -32,6 +36,63 @@ func TestAPIWorkflowRun(t *testing.T) { 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: string(webhook_module.HookEventPullRequest), + 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) { @@ -64,7 +125,7 @@ func testAPIWorkflowRunsByWorkflowID(t *testing.T, owner, repo, workflowID, user DecodeJSON(t, resp, &excludedList) excludedFound := false for _, run := range excludedList.Entries { - assert.NotEqual(t, "pull_request", run.Event) + assert.Empty(t, run.PullRequests, "expected pull_requests to be empty when excluded") if run.ID == expectedRunID { excludedFound = true }