From 028df22aade0cd21485c7e2e28cb4edbc8acba28 Mon Sep 17 00:00:00 2001 From: Nicolas Bircks Date: Mon, 13 Apr 2026 13:11:57 +0200 Subject: [PATCH 01/12] add new api sub path --- routers/api/v1/admin/action.go | 2 +- routers/api/v1/api.go | 1 + routers/api/v1/org/action.go | 2 +- routers/api/v1/repo/action.go | 73 ++++++++++++++++++++++++++++++++- routers/api/v1/shared/action.go | 4 +- routers/api/v1/user/action.go | 2 +- 6 files changed, 79 insertions(+), 5 deletions(-) diff --git a/routers/api/v1/admin/action.go b/routers/api/v1/admin/action.go index 2fbb8e1a95..2f1c1a2b5c 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 2d80692fef..e6f8f8bc80 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1166,6 +1166,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 01b57b3fac..bfc109e5e5 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 7ac8a10575..37ea45e14e 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -781,7 +781,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) @@ -952,6 +952,77 @@ 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 + // 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: 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") + repoID := ctx.Repo.Repository.ID + + shared.ListRuns(ctx, 0, repoID, workflowID) +} + func ActionsDisableWorkflow(ctx *context.APIContext) { // swagger:operation PUT /repos/{owner}/{repo}/actions/workflows/{workflow_id}/disable repository ActionsDisableWorkflow // --- diff --git a/routers/api/v1/shared/action.go b/routers/api/v1/shared/action.go index 715e76c355..8f2db38f7c 100644 --- a/routers/api/v1/shared/action.go +++ b/routers/api/v1/shared/action.go @@ -117,8 +117,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") } @@ -126,6 +127,7 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64) { opts := actions_model.FindRunOptions{ OwnerID: ownerID, RepoID: repoID, + WorkflowID: workflowID, ListOptions: listOptions, } diff --git a/routers/api/v1/user/action.go b/routers/api/v1/user/action.go index 573e2e4dd0..f105f1c956 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 From 614cb6bcbdb0f98eef8cb72793969a89170aada9 Mon Sep 17 00:00:00 2001 From: Nicolas Bircks Date: Mon, 13 Apr 2026 13:32:29 +0200 Subject: [PATCH 02/12] fix openapi specs --- templates/swagger/v1_json.tmpl | 91 ++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 703a25336f..cf62394dbc 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -6444,6 +6444,97 @@ } } }, + "/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", + "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": "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": [ From 08c320a4b55f39eb69d3662f90c577dff69f61e1 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 13 Apr 2026 19:45:21 +0200 Subject: [PATCH 03/12] add test --- .../workflow_run_api_check_test.go | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/integration/workflow_run_api_check_test.go b/tests/integration/workflow_run_api_check_test.go index 6a80bb5118..3c7e605c68 100644 --- a/tests/integration/workflow_run_api_check_test.go +++ b/tests/integration/workflow_run_api_check_test.go @@ -29,6 +29,39 @@ 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) + }) +} + +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, "", "", "") + if run.ID == expectedRunID { + found = true + break + } + } + assert.True(t, found, "expected to find run with ID %d in workflow %s runs", expectedRunID, workflowID) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/nonexistent.yaml/runs", owner, repo)).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + emptyList := api.ActionWorkflowRunsResponse{} + DecodeJSON(t, resp, &emptyList) + assert.Empty(t, emptyList.Entries, "nonexistent workflow should return no runs") } func testAPIWorkflowRunBasic(t *testing.T, apiRootURL, userUsername string, runID int64, scope ...auth_model.AccessTokenScope) { From e15b1c2addff895ac0c6b4cd42d0701822ec91b5 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 13 Apr 2026 20:55:23 +0200 Subject: [PATCH 04/12] address review comments on workflow runs API - Update workflow_id swagger description to clarify filename-only - Add exclude_pull_requests query parameter - Return 404 for nonexistent workflow in ActionsListWorkflowRuns - Remove break from test loop so all runs are verified - Add actor and head_sha filter coverage to testAPIWorkflowRunsByWorkflowID - Update nonexistent workflow test to expect 404 --- models/actions/run_list.go | 24 +++++++++++-------- routers/api/v1/repo/action.go | 21 +++++++++++++++- routers/api/v1/shared/action.go | 3 +++ templates/swagger/v1_json.tmpl | 14 ++++++++++- .../workflow_run_api_check_test.go | 8 +++---- 5 files changed, 53 insertions(+), 17 deletions(-) diff --git a/models/actions/run_list.go b/models/actions/run_list.go index 2628c4712f..e948d49665 100644 --- a/models/actions/run_list.go +++ b/models/actions/run_list.go @@ -64,16 +64,17 @@ func (runs RunList) LoadRepos(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 + 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 } func (opts FindRunOptions) ToConds() builder.Cond { @@ -102,6 +103,9 @@ 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": "pull_request"}) + } if len(opts.ConcurrencyGroup) > 0 { if opts.RepoID == 0 { panic("Invalid FindRunOptions: repo_id is required") diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 37ea45e14e..cd24d1bb2d 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, pull request events are omitted from the results + // type: boolean + // required: false // - name: page // in: query // description: page number of results to return (1-based) @@ -971,7 +976,7 @@ func ActionsListWorkflowRuns(ctx *context.APIContext) { // required: true // - name: workflow_id // in: path - // description: id of the workflow + // description: id of the workflow, must be the workflow file name (e.g. `build.yml`) // type: string // required: true // - name: event @@ -999,6 +1004,11 @@ func ActionsListWorkflowRuns(ctx *context.APIContext) { // description: triggering sha of the workflow run // type: string // required: false + // - name: exclude_pull_requests + // in: query + // description: if true, pull request events are omitted from the results + // type: boolean + // required: false // - name: page // in: query // description: page number of results to return (1-based) @@ -1018,6 +1028,15 @@ func ActionsListWorkflowRuns(ctx *context.APIContext) { // "$ref": "#/responses/notFound" workflowID := ctx.PathParam("workflow_id") + 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 + } + repoID := ctx.Repo.Repository.ID shared.ListRuns(ctx, 0, repoID, workflowID) diff --git a/routers/api/v1/shared/action.go b/routers/api/v1/shared/action.go index 8f2db38f7c..9a1e807865 100644 --- a/routers/api/v1/shared/action.go +++ b/routers/api/v1/shared/action.go @@ -156,6 +156,9 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64, workflowID string) if headSHA := ctx.FormString("head_sha"); headSHA != "" { opts.CommitSHA = headSHA } + if ctx.FormString("exclude_pull_requests") == "true" { + opts.ExcludePullRequests = true + } runs, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts) if err != nil { diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index cf62394dbc..899be8ae2d 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, pull request events are omitted from the results", + "name": "exclude_pull_requests", + "in": "query" + }, { "type": "integer", "description": "page number of results to return (1-based)", @@ -6471,7 +6477,7 @@ }, { "type": "string", - "description": "id of the workflow", + "description": "id of the workflow, must be the workflow file name (e.g. `build.yml`)", "name": "workflow_id", "in": "path", "required": true @@ -6506,6 +6512,12 @@ "name": "head_sha", "in": "query" }, + { + "type": "boolean", + "description": "if true, pull request events are omitted from the results", + "name": "exclude_pull_requests", + "in": "query" + }, { "type": "integer", "description": "page number of results to return (1-based)", diff --git a/tests/integration/workflow_run_api_check_test.go b/tests/integration/workflow_run_api_check_test.go index 3c7e605c68..f518c161a0 100644 --- a/tests/integration/workflow_run_api_check_test.go +++ b/tests/integration/workflow_run_api_check_test.go @@ -50,18 +50,16 @@ func testAPIWorkflowRunsByWorkflowID(t *testing.T, owner, repo, workflowID, user 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 - break } } assert.True(t, found, "expected to find run with ID %d in workflow %s runs", expectedRunID, workflowID) req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/nonexistent.yaml/runs", owner, repo)).AddTokenAuth(token) - resp = MakeRequest(t, req, http.StatusOK) - emptyList := api.ActionWorkflowRunsResponse{} - DecodeJSON(t, resp, &emptyList) - assert.Empty(t, emptyList.Entries, "nonexistent workflow should return no runs") + MakeRequest(t, req, http.StatusNotFound) } func testAPIWorkflowRunBasic(t *testing.T, apiRootURL, userUsername string, runID int64, scope ...auth_model.AccessTokenScope) { From ab5fd3387b5e341480b1dbe3c274f4f1b6c4752b Mon Sep 17 00:00:00 2001 From: Nicolas Date: Tue, 14 Apr 2026 20:41:19 +0200 Subject: [PATCH 05/12] fixes --- routers/api/v1/repo/action.go | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index cd24d1bb2d..5be2ae9c50 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -1028,14 +1028,23 @@ func ActionsListWorkflowRuns(ctx *context.APIContext) { // "$ref": "#/responses/notFound" workflowID := ctx.PathParam("workflow_id") - 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) - } + runExists, err := db.GetEngine(ctx). + Where("repo_id = ? AND workflow_id = ?", ctx.Repo.Repository.ID, workflowID). + Exist(&actions_model.ActionRun{}) + 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 + } + } repoID := ctx.Repo.Repository.ID From 8d3d074e4f537db81fc0bba61d83d5533c3acc96 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Tue, 14 Apr 2026 21:35:23 +0200 Subject: [PATCH 06/12] try fix edge cases --- routers/api/v1/repo/action.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 5be2ae9c50..7f798b4d7e 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -22,6 +22,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" secret_model "code.gitea.io/gitea/models/secret" "code.gitea.io/gitea/modules/actions" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" @@ -1037,7 +1038,7 @@ func ActionsListWorkflowRuns(ctx *context.APIContext) { } if !runExists { if _, err := convert.GetActionWorkflow(ctx, ctx.Repo.GitRepo, ctx.Repo.Repository, workflowID); err != nil { - if errors.Is(err, util.ErrNotExist) { + if errors.Is(err, util.ErrNotExist) || git.IsErrNotExist(err) { ctx.APIError(http.StatusNotFound, err) } else { ctx.APIErrorInternal(err) From dfe28a029b68ef349e60b8c3e2d45fef9a21cec3 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sun, 19 Apr 2026 13:14:19 +0200 Subject: [PATCH 07/12] try fix sqlite --- routers/api/v1/repo/action.go | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 7f798b4d7e..143f4b64bd 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -22,7 +22,6 @@ import ( repo_model "code.gitea.io/gitea/models/repo" secret_model "code.gitea.io/gitea/models/secret" "code.gitea.io/gitea/modules/actions" - "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" @@ -1037,14 +1036,8 @@ func ActionsListWorkflowRuns(ctx *context.APIContext) { return } if !runExists { - if _, err := convert.GetActionWorkflow(ctx, ctx.Repo.GitRepo, ctx.Repo.Repository, workflowID); err != nil { - if errors.Is(err, util.ErrNotExist) || git.IsErrNotExist(err) { - ctx.APIError(http.StatusNotFound, err) - } else { - ctx.APIErrorInternal(err) - } - return - } + ctx.APIError(http.StatusNotFound, util.NewNotExistErrorf("workflow %q not found", workflowID)) + return } repoID := ctx.Repo.Repository.ID From 897e97ef6eaea5050ad7e4eff0620fbddf998a26 Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 21 Apr 2026 01:10:44 +0200 Subject: [PATCH 08/12] Address review comments on workflow runs API Fix false 404s from ActionsListWorkflowRuns: the previous check returned 404 whenever no runs existed, so a valid workflow that hadn't been triggered yet was reported as missing. Accept either existing runs or a workflow file on the default branch as proof of existence; run lookup runs first since it's cheaper than parsing workflow YAML on every call. Also use the HookEventPullRequest constant instead of a raw string literal for the trigger_event filter, switch the new exclude_pull_requests query param to ctx.FormBool, and add a test asserting exclude_pull_requests filters out pull_request-event runs. Co-Authored-By: Claude (Opus 4.7) --- models/actions/run_list.go | 2 +- routers/api/v1/repo/action.go | 23 ++++++++++++------- routers/api/v1/shared/action.go | 4 +--- .../workflow_run_api_check_test.go | 13 +++++++++++ 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/models/actions/run_list.go b/models/actions/run_list.go index 496cb669b1..726157b12e 100644 --- a/models/actions/run_list.go +++ b/models/actions/run_list.go @@ -85,7 +85,7 @@ func (opts FindRunOptions) ToConds() builder.Cond { cond = cond.And(builder.Eq{"`action_run`.commit_sha": opts.CommitSHA}) } if opts.ExcludePullRequests { - cond = cond.And(builder.Neq{"`action_run`.trigger_event": "pull_request"}) + cond = cond.And(builder.Neq{"`action_run`.trigger_event": string(webhook_module.HookEventPullRequest)}) } if len(opts.ConcurrencyGroup) > 0 { if opts.RepoID == 0 { diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 143f4b64bd..b05a4e3ad1 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -1028,21 +1028,28 @@ func ActionsListWorkflowRuns(ctx *context.APIContext) { // "$ref": "#/responses/notFound" workflowID := ctx.PathParam("workflow_id") - runExists, err := db.GetEngine(ctx). - Where("repo_id = ? AND workflow_id = ?", ctx.Repo.Repository.ID, workflowID). - Exist(&actions_model.ActionRun{}) + // 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 { - ctx.APIError(http.StatusNotFound, util.NewNotExistErrorf("workflow %q not found", workflowID)) - return + 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 + } } - repoID := ctx.Repo.Repository.ID - - shared.ListRuns(ctx, 0, repoID, workflowID) + shared.ListRuns(ctx, 0, ctx.Repo.Repository.ID, workflowID) } func ActionsDisableWorkflow(ctx *context.APIContext) { diff --git a/routers/api/v1/shared/action.go b/routers/api/v1/shared/action.go index 9a1e807865..dde724c6ee 100644 --- a/routers/api/v1/shared/action.go +++ b/routers/api/v1/shared/action.go @@ -156,9 +156,7 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64, workflowID string) if headSHA := ctx.FormString("head_sha"); headSHA != "" { opts.CommitSHA = headSHA } - if ctx.FormString("exclude_pull_requests") == "true" { - opts.ExcludePullRequests = true - } + opts.ExcludePullRequests = ctx.FormBool("exclude_pull_requests") runs, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts) if err != nil { diff --git a/tests/integration/workflow_run_api_check_test.go b/tests/integration/workflow_run_api_check_test.go index f518c161a0..db0a8d074a 100644 --- a/tests/integration/workflow_run_api_check_test.go +++ b/tests/integration/workflow_run_api_check_test.go @@ -58,6 +58,19 @@ func testAPIWorkflowRunsByWorkflowID(t *testing.T, owner, repo, workflowID, user } 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.NotEqual(t, "pull_request", run.Event) + 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) } From 1e0262f5abd083a00c8511ad3caef3e3bdc57ae3 Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 21 Apr 2026 03:21:16 +0200 Subject: [PATCH 09/12] Implement exclude_pull_requests per GitHub's actual semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- models/actions/run_list.go | 24 ++--- modules/structs/repo_actions.go | 59 ++++++++---- routers/api/v1/repo/action.go | 8 +- routers/api/v1/shared/action.go | 4 +- services/actions/notifier.go | 2 +- services/convert/convert.go | 95 ++++++++++++++++++- services/webhook/notifier.go | 2 +- templates/swagger/v1_json.tmpl | 76 ++++++++++++++- .../workflow_run_api_check_test.go | 63 +++++++++++- 9 files changed, 289 insertions(+), 44 deletions(-) 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 } From 082b95fc8b2479502741cf7874d06edad1b6d07c Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 8 May 2026 22:43:33 +0200 Subject: [PATCH 10/12] fix PR association --- services/convert/convert.go | 8 ++------ tests/integration/workflow_run_api_check_test.go | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/services/convert/convert.go b/services/convert/convert.go index 5df9b2d8db..744153d8c3 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -329,13 +329,9 @@ func ToActionWorkflowRun(ctx context.Context, run *actions_model.ActionRun, atte func loadPullRequestsForRun(ctx context.Context, run *actions_model.ActionRun) ([]*api.PullRequestMinimal, error) { result := []*api.PullRequestMinimal{} refName := git.RefName(run.Ref) - event := webhook_module.HookEventType(run.TriggerEvent) - if event == "" { - event = run.Event - } var prs issues_model.PullRequestList switch { - case event.IsPullRequest() || event.IsPullRequestReview(): + case run.Event.IsPullRequest() || run.Event.IsPullRequestReview(): index, err := strconv.ParseInt(refName.PullName(), 10, 64) if err != nil { return result, nil @@ -348,7 +344,7 @@ func loadPullRequestsForRun(ctx context.Context, run *actions_model.ActionRun) ( return nil, err } prs = issues_model.PullRequestList{pr} - case event == webhook_module.HookEventPush: + case run.Event == webhook_module.HookEventPush: branch := refName.BranchName() if branch == "" { return result, nil diff --git a/tests/integration/workflow_run_api_check_test.go b/tests/integration/workflow_run_api_check_test.go index cf427f964b..eb01bd1bb5 100644 --- a/tests/integration/workflow_run_api_check_test.go +++ b/tests/integration/workflow_run_api_check_test.go @@ -55,7 +55,7 @@ func testAPIWorkflowRunsPullRequestsField(t *testing.T) { Ref: "refs/pull/3/head", CommitSHA: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", Event: webhook_module.HookEventPullRequest, - TriggerEvent: string(webhook_module.HookEventPullRequest), + TriggerEvent: "pull_request_target", Status: actions_model.StatusSuccess, } require.NoError(t, db.Insert(ctx, run)) From 86e81a721679e08a4cf9a3c9d6ee37885336d953 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 8 May 2026 22:55:34 +0200 Subject: [PATCH 11/12] fix docs --- templates/swagger/v1_json.tmpl | 12 +- templates/swagger/v1_openapi3_json.tmpl | 198 ++++++++++++++++++++++++ 2 files changed, 204 insertions(+), 6 deletions(-) diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index dba77fbf64..a0975fb502 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -22201,6 +22201,11 @@ "type": "string", "x-go-name": "Path" }, + "previous_attempt_url": { + "description": "PreviousAttemptURL is the API URL of the previous attempt of this run, e.g. \".../actions/runs/{run_id}/attempts/{attempt-1}\".\nIt is set only when the current attempt is \u003e 1 (i.e. a rerun). For the first attempt, or for legacy runs that pre-date ActionRunAttempt, it is null.", + "type": "string", + "x-go-name": "PreviousAttemptURL" + }, "pull_requests": { "type": "array", "items": { @@ -22208,11 +22213,6 @@ }, "x-go-name": "PullRequests" }, - "previous_attempt_url": { - "description": "PreviousAttemptURL is the API URL of the previous attempt of this run, e.g. \".../actions/runs/{run_id}/attempts/{attempt-1}\".\nIt is set only when the current attempt is \u003e 1 (i.e. a rerun). For the first attempt, or for legacy runs that pre-date ActionRunAttempt, it is null.", - "type": "string", - "x-go-name": "PreviousAttemptURL" - }, "repository": { "$ref": "#/definitions/Repository" }, @@ -31645,4 +31645,4 @@ "TOTPHeader": [] } ] -} +} \ No newline at end of file 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", From e0dc7ac0b819be75a84f352bf4f78d7c1458c6db Mon Sep 17 00:00:00 2001 From: silverwind Date: Sat, 9 May 2026 00:24:28 +0200 Subject: [PATCH 12/12] fix(api): return 404 for workflow lookup on repo with empty default branch Under the gogit build tag, an empty repo.DefaultBranch causes GetBranchCommit to fail go-git's reference-name validation with an "invalid reference name" error that does not unwrap to util.ErrNotExist, so the workflow runs API returns 500 instead of 404. Short-circuit when DefaultBranch is empty to match GitHub's 404 for missing workflows. Co-Authored-By: Claude (Opus 4.7) --- services/convert/convert.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/services/convert/convert.go b/services/convert/convert.go index 744153d8c3..259ce13dd9 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -596,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