feat(api): Add GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs (#37196)

- Add GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs
endpoint, matching the
https://docs.github.com/en/rest/actions/workflow-runs?apiVersion=2026-03-10#list-workflow-runs-for-a-workflow

---------

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: bircni <bircni@icloud.com>
This commit is contained in:
bn-zr
2026-06-11 19:12:30 +02:00
committed by GitHub
parent 250a38abb5
commit fefb6f3219
15 changed files with 721 additions and 30 deletions
+1 -1
View File
@@ -99,5 +99,5 @@ func ListWorkflowRuns(ctx *context.APIContext) {
// "404":
// "$ref": "#/responses/notFound"
shared.ListRuns(ctx, 0, 0)
shared.ListRuns(ctx, 0, 0, "")
}
+1
View File
@@ -1188,6 +1188,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)
+1 -1
View File
@@ -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)
+100 -4
View File
@@ -772,6 +772,11 @@ func (Action) ListWorkflowRuns(ctx *context.APIContext) {
// description: triggering sha of the workflow run
// type: string
// required: false
// - name: exclude_pull_requests
// in: query
// description: if true, the `pull_requests` field on each returned run is emptied
// type: boolean
// required: false
// - name: page
// in: query
// description: page number of results to return (1-based)
@@ -790,7 +795,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)
@@ -967,6 +972,97 @@ func ActionsGetWorkflow(ctx *context.APIContext) {
ctx.JSON(http.StatusOK, workflow)
}
func ActionsListWorkflowRuns(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs repository ActionsListWorkflowRuns
// ---
// summary: List runs for a workflow
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// - name: workflow_id
// in: path
// description: id of the workflow, must be the workflow file name (e.g. `build.yml`)
// type: string
// required: true
// - name: event
// in: query
// description: workflow event name
// type: string
// required: false
// - name: branch
// in: query
// description: workflow branch
// type: string
// required: false
// - name: status
// in: query
// description: workflow status (pending, queued, in_progress, failure, success, skipped)
// type: string
// required: false
// - name: actor
// in: query
// description: triggered by user
// type: string
// required: false
// - name: head_sha
// in: query
// description: triggering sha of the workflow run
// type: string
// required: false
// - name: exclude_pull_requests
// in: query
// description: if true, the `pull_requests` field on each returned run is emptied
// type: boolean
// required: false
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// "$ref": "#/responses/WorkflowRunsList"
// "400":
// "$ref": "#/responses/error"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
workflowID := ctx.PathParam("workflow_id")
// Existing runs prove the workflow is/was valid and cover historical workflows
// whose file was later removed. Fall back to a git lookup for never-run workflows.
runExists, err := db.Exist[actions_model.ActionRun](ctx, actions_model.FindRunOptions{
RepoID: ctx.Repo.Repository.ID,
WorkflowID: workflowID,
}.ToConds())
if err != nil {
ctx.APIErrorInternal(err)
return
}
if !runExists {
if _, err := convert.GetActionWorkflow(ctx, ctx.Repo.GitRepo, ctx.Repo.Repository, workflowID); err != nil {
ctx.APIErrorAuto(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
// ---
@@ -1238,7 +1334,7 @@ func GetWorkflowRun(ctx *context.APIContext) {
return
}
convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil)
convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil, false)
if err != nil {
ctx.APIErrorInternal(err)
return
@@ -1287,7 +1383,7 @@ func GetWorkflowRunAttempt(ctx *context.APIContext) {
return
}
convertedRun, err := convert.ToActionWorkflowRun(ctx, run, attempt)
convertedRun, err := convert.ToActionWorkflowRun(ctx, run, attempt, false)
if err != nil {
ctx.APIErrorInternal(err)
return
@@ -1342,7 +1438,7 @@ func RerunWorkflowRun(ctx *context.APIContext) {
return
}
convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil)
convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil, false)
if err != nil {
ctx.APIErrorInternal(err)
return
+5 -2
View File
@@ -135,8 +135,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")
}
@@ -144,6 +145,7 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64) {
opts := actions_model.FindRunOptions{
OwnerID: ownerID,
RepoID: repoID,
WorkflowID: workflowID,
ListOptions: listOptions,
}
@@ -172,6 +174,7 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64) {
if headSHA := ctx.FormString("head_sha"); headSHA != "" {
opts.CommitSHA = headSHA
}
excludePullRequests := ctx.FormBool("exclude_pull_requests")
runs, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts)
if err != nil {
@@ -203,7 +206,7 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64) {
res.Entries = make([]*api.ActionWorkflowRun, len(runs))
for i := range runs {
// TODO: load run attempts in batch
convertedRun, err := convert.ToActionWorkflowRun(ctx, runs[i], nil)
convertedRun, err := convert.ToActionWorkflowRun(ctx, runs[i], nil, excludePullRequests)
if err != nil {
ctx.APIErrorInternal(err)
return
+1 -1
View File
@@ -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