0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-05-14 13:08:11 +02:00

Implement exclude_pull_requests per GitHub's actual semantics

The previous implementation interpreted exclude_pull_requests=true as
"filter out runs whose trigger event is pull_request", but GitHub's
parameter empties the pull_requests field on each run object instead —
the runs themselves are always returned.

Add a pull_requests field to ActionWorkflowRun matching GitHub's
minimal schema (id, number, url, head{ref,sha,repo{id,url,name}},
base{…}). Populate it in ToActionWorkflowRun from the PR that triggered
the run (pull_request / pull_request_review events) or from open PRs
whose head branch matches the push ref (push events). When the list
endpoint receives exclude_pull_requests=true, the flag short-circuits
the PR lookup so the field stays empty.

The removal of ExcludePullRequests from FindRunOptions undoes the wrong
trigger_event filter.

Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
This commit is contained in:
silverwind 2026-04-21 03:21:16 +02:00
parent 897e97ef6e
commit 1e0262f5ab
No known key found for this signature in database
GPG Key ID: 2E62B41C93869443
9 changed files with 289 additions and 44 deletions

View File

@ -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")

View File

@ -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"`

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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

View File

@ -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",

View File

@ -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
}