Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 14 additions & 10 deletions models/actions/run_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,17 @@ 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
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 {
Expand Down Expand Up @@ -83,6 +84,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": string(webhook_module.HookEventPullRequest)})
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it really match GitHub's document?

If I understand correctly, ExcludePullRequests means "no PRs in the response", but doesn't mean filtering the runs.

Image

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, original review was a hallucination. Fixing it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't want to be right.

I really hope maintainers can be more careful for the AI outputs, don't blindly trust them.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review was from lobotomized Opus 4.6, sorry. And yes we need to be more careful.

Copy link
Copy Markdown
Member

@silverwind silverwind Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding this seems to considerably increase this PR's scope, not sure it's worth. I'll push it, then we can decide whether to keep it or omit that flag for now.

Copy link
Copy Markdown
Member

@silverwind silverwind Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 1e0262f. @lunny you should also re-review please for this PullRequestMinimal introduction.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missed the right meaning of ExcludePullRequests

if len(opts.ConcurrencyGroup) > 0 {
if opts.RepoID == 0 {
panic("Invalid FindRunOptions: repo_id is required")
Expand Down
2 changes: 1 addition & 1 deletion routers/api/v1/admin/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,5 +89,5 @@ func ListWorkflowRuns(ctx *context.APIContext) {
// "404":
// "$ref": "#/responses/notFound"

shared.ListRuns(ctx, 0, 0)
shared.ListRuns(ctx, 0, 0, "")
}
1 change: 1 addition & 0 deletions routers/api/v1/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion routers/api/v1/org/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
102 changes: 101 additions & 1 deletion routers/api/v1/repo/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -952,6 +957,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, 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)
// 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
// ---
Expand Down
5 changes: 4 additions & 1 deletion routers/api/v1/shared/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,15 +117,17 @@ 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")
}
listOptions := utils.GetListOptions(ctx)
opts := actions_model.FindRunOptions{
OwnerID: ownerID,
RepoID: repoID,
WorkflowID: workflowID,
ListOptions: listOptions,
}

Expand Down Expand Up @@ -154,6 +156,7 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64) {
if headSHA := ctx.FormString("head_sha"); headSHA != "" {
opts.CommitSHA = headSHA
}
opts.ExcludePullRequests = ctx.FormBool("exclude_pull_requests")

runs, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion routers/api/v1/user/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
103 changes: 103 additions & 0 deletions templates/swagger/v1_json.tmpl

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

44 changes: 44 additions & 0 deletions tests/integration/workflow_run_api_check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,50 @@ 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, "", "", "")
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.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)
}

func testAPIWorkflowRunBasic(t *testing.T, apiRootURL, userUsername string, runID int64, scope ...auth_model.AccessTokenScope) {
Expand Down
Loading