0
0
mirror of https://github.com/go-gitea/gitea.git synced 2025-07-21 03:48:30 +02:00

Add GitHub API compatibility for workflow runs filtering

Implements additional query parameters for the workflow runs API to match GitHub's REST API specification.

- Add `exclude_pull_requests` query parameter
- Add `check_suite_id` parameter
- Add `created` parameter with date range and comparison support
- Add workflow-specific endpoint `/repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs`

Builds on the workflow API foundation from #33964 to provide additional GitHub API compatibility.

Reference: https://docs.github.com/en/rest/actions/workflow-runs?apiVersion=2022-11-28#list-workflow-runs-for-a-workflow
This commit is contained in:
Brice Ruth 2025-06-28 16:16:00 -05:00
parent 8df59fa11c
commit 18546ed73a
No known key found for this signature in database
GPG Key ID: 5DFD569B02D44E21
6 changed files with 505 additions and 9 deletions

View File

@ -5,6 +5,7 @@ package actions
import (
"context"
"time"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
@ -64,15 +65,19 @@ 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
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
CommitSHA string
CreatedAfter time.Time
CreatedBefore time.Time
ExcludePullRequests bool
CheckSuiteID int64
}
func (opts FindRunOptions) ToConds() builder.Cond {
@ -101,6 +106,18 @@ func (opts FindRunOptions) ToConds() builder.Cond {
if opts.CommitSHA != "" {
cond = cond.And(builder.Eq{"`action_run`.commit_sha": opts.CommitSHA})
}
if !opts.CreatedAfter.IsZero() {
cond = cond.And(builder.Gte{"`action_run`.created": opts.CreatedAfter})
}
if !opts.CreatedBefore.IsZero() {
cond = cond.And(builder.Lte{"`action_run`.created": opts.CreatedBefore})
}
if opts.ExcludePullRequests {
cond = cond.And(builder.Neq{"`action_run`.trigger_event": webhook_module.HookEventPullRequest})
}
if opts.CheckSuiteID > 0 {
cond = cond.And(builder.Eq{"`action_run`.check_suite_id": opts.CheckSuiteID})
}
return cond
}

View File

@ -0,0 +1,104 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
"time"
"code.gitea.io/gitea/modules/webhook"
"github.com/stretchr/testify/assert"
"xorm.io/builder"
)
func TestFindRunOptions_ToConds_ExcludePullRequests(t *testing.T) {
// Test when ExcludePullRequests is true
opts := FindRunOptions{
ExcludePullRequests: true,
}
cond := opts.ToConds()
// Convert the condition to SQL for assertion
sql, args, err := builder.ToSQL(cond)
assert.NoError(t, err)
// The condition should contain the trigger_event not equal to pull_request
assert.Contains(t, sql, "`action_run`.trigger_event<>")
assert.Contains(t, args, webhook.HookEventPullRequest)
}
func TestFindRunOptions_ToConds_CheckSuiteID(t *testing.T) {
// Test when CheckSuiteID is set
const testSuiteID int64 = 12345
opts := FindRunOptions{
CheckSuiteID: testSuiteID,
}
cond := opts.ToConds()
// Convert the condition to SQL for assertion
sql, args, err := builder.ToSQL(cond)
assert.NoError(t, err)
// The condition should contain the check_suite_id equal to the test value
assert.Contains(t, sql, "`action_run`.check_suite_id=")
assert.Contains(t, args, testSuiteID)
}
func TestFindRunOptions_ToConds_CreatedDateRange(t *testing.T) {
// Test when CreatedAfter and CreatedBefore are set
startDate := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
endDate := time.Date(2023, 12, 31, 23, 59, 59, 0, time.UTC)
opts := FindRunOptions{
CreatedAfter: startDate,
CreatedBefore: endDate,
}
cond := opts.ToConds()
// Convert the condition to SQL for assertion
sql, args, err := builder.ToSQL(cond)
assert.NoError(t, err)
// The condition should contain created >= startDate and created <= endDate
assert.Contains(t, sql, "`action_run`.created>=")
assert.Contains(t, sql, "`action_run`.created<=")
assert.Contains(t, args, startDate)
assert.Contains(t, args, endDate)
}
func TestFindRunOptions_ToConds_CreatedAfterOnly(t *testing.T) {
// Test when only CreatedAfter is set
startDate := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
opts := FindRunOptions{
CreatedAfter: startDate,
}
cond := opts.ToConds()
// Convert the condition to SQL for assertion
sql, args, err := builder.ToSQL(cond)
assert.NoError(t, err)
// The condition should contain created >= startDate
assert.Contains(t, sql, "`action_run`.created>=")
assert.Contains(t, args, startDate)
// But should not contain created <= endDate
assert.NotContains(t, sql, "`action_run`.created<=")
}
func TestFindRunOptions_ToConds_CreatedBeforeOnly(t *testing.T) {
// Test when only CreatedBefore is set
endDate := time.Date(2023, 12, 31, 23, 59, 59, 0, time.UTC)
opts := FindRunOptions{
CreatedBefore: endDate,
}
cond := opts.ToConds()
// Convert the condition to SQL for assertion
sql, args, err := builder.ToSQL(cond)
assert.NoError(t, err)
// The condition should contain created <= endDate
assert.Contains(t, sql, "`action_run`.created<=")
assert.Contains(t, args, endDate)
// But should not contain created >= startDate
assert.NotContains(t, sql, "`action_run`.created>=")
}

View File

@ -1203,6 +1203,7 @@ func Routes() *web.Router {
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)
m.Get("/{workflow_id}/runs", repo.ActionsListWorkflowRuns)
}, context.ReferencesGitRepo(), reqToken(), reqRepoReader(unit.TypeActions))
m.Group("/actions/jobs", func() {

View File

@ -1100,6 +1100,52 @@ func ActionsEnableWorkflow(ctx *context.APIContext) {
ctx.Status(http.StatusNoContent)
}
func ActionsListWorkflowRuns(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs repository ActionsListWorkflowRuns
// ---
// summary: List workflow 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: 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"
// "422":
// "$ref": "#/responses/validationError"
// "500":
// "$ref": "#/responses/error"
shared.ListRuns(ctx, 0, ctx.Repo.Repository.ID)
}
// GetWorkflowRun Gets a specific workflow run.
func GetWorkflowRun(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run} repository GetWorkflowRun

View File

@ -6,6 +6,8 @@ package shared
import (
"fmt"
"net/http"
"strings"
"time"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
@ -123,6 +125,7 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64) {
opts := actions_model.FindRunOptions{
OwnerID: ownerID,
RepoID: repoID,
WorkflowID: ctx.PathParam("workflow_id"),
ListOptions: utils.GetListOptions(ctx),
}
@ -151,6 +154,77 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64) {
if headSHA := ctx.FormString("head_sha"); headSHA != "" {
opts.CommitSHA = headSHA
}
// Handle exclude_pull_requests parameter
if exclude := ctx.FormString("exclude_pull_requests"); exclude != "" {
if exclude == "true" || exclude == "1" {
opts.ExcludePullRequests = true
}
}
// Handle check_suite_id parameter
if checkSuiteID := ctx.FormInt64("check_suite_id"); checkSuiteID > 0 {
opts.CheckSuiteID = checkSuiteID
}
// Handle created parameter for date filtering
if created := ctx.FormString("created"); created != "" {
// Parse the date range in the format like ">=2023-01-01", "<=2023-12-31", or "2023-01-01..2023-12-31"
if strings.Contains(created, "..\u002e") {
// Range format: "2023-01-01..2023-12-31"
dateRange := strings.Split(created, "..")
if len(dateRange) == 2 {
startDate, err := time.Parse("2006-01-02", dateRange[0])
if err == nil {
opts.CreatedAfter = startDate
}
endDate, err := time.Parse("2006-01-02", dateRange[1])
if err == nil {
// Set to end of day
endDate = endDate.Add(24*time.Hour - time.Second)
opts.CreatedBefore = endDate
}
}
} else if strings.HasPrefix(created, ">=") {
// Greater than or equal format: ">=2023-01-01"
dateStr := strings.TrimPrefix(created, ">=")
startDate, err := time.Parse("2006-01-02", dateStr)
if err == nil {
opts.CreatedAfter = startDate
}
} else if strings.HasPrefix(created, ">") {
// Greater than format: ">2023-01-01"
dateStr := strings.TrimPrefix(created, ">")
startDate, err := time.Parse("2006-01-02", dateStr)
if err == nil {
opts.CreatedAfter = startDate.Add(24 * time.Hour)
}
} else if strings.HasPrefix(created, "<=") {
// Less than or equal format: "<=2023-12-31"
dateStr := strings.TrimPrefix(created, "<=")
endDate, err := time.Parse("2006-01-02", dateStr)
if err == nil {
// Set to end of day
endDate = endDate.Add(24*time.Hour - time.Second)
opts.CreatedBefore = endDate
}
} else if strings.HasPrefix(created, "<") {
// Less than format: "<2023-12-31"
dateStr := strings.TrimPrefix(created, "<")
endDate, err := time.Parse("2006-01-02", dateStr)
if err == nil {
opts.CreatedBefore = endDate
}
} else {
// Exact date format: "2023-01-01"
exactDate, err := time.Parse("2006-01-02", created)
if err == nil {
opts.CreatedAfter = exactDate
opts.CreatedBefore = exactDate.Add(24*time.Hour - time.Second)
}
}
}
runs, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts)
if err != nil {

View File

@ -0,0 +1,254 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package shared
import (
"net/url"
"testing"
"time"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/contexttest"
"github.com/stretchr/testify/assert"
)
func TestMain(m *testing.M) {
unittest.MainTest(m)
}
// setFormValue is a helper function to set form values in test context
func setFormValue(ctx *context.APIContext, key, value string) {
// Initialize the form if it's nil
if ctx.Req.Form == nil {
ctx.Req.Form = make(url.Values)
}
ctx.Req.Form.Set(key, value)
}
// TestListRunsWorkflowFiltering tests that ListRuns properly handles
// the workflow_id path parameter for filtering runs by workflow.
func TestListRunsWorkflowFiltering(t *testing.T) {
unittest.PrepareTestEnv(t)
ctx, _ := contexttest.MockAPIContext(t, "user2/repo1")
contexttest.LoadRepo(t, ctx, 1)
contexttest.LoadUser(t, ctx, 2)
// Test case 1: With workflow_id parameter (simulating /workflows/{workflow_id}/runs endpoint)
ctx.SetPathParam("workflow_id", "test-workflow-123")
// Simulate the FindRunOptions creation that happens in ListRuns
opts := actions_model.FindRunOptions{
OwnerID: 0,
RepoID: ctx.Repo.Repository.ID,
WorkflowID: ctx.PathParam("workflow_id"), // This is the key change being tested
}
// Verify the WorkflowID is correctly extracted from path parameter
assert.Equal(t, "test-workflow-123", opts.WorkflowID)
assert.Equal(t, ctx.Repo.Repository.ID, opts.RepoID)
assert.Equal(t, int64(0), opts.OwnerID)
// Test case 2: Without workflow_id parameter (general /runs endpoint)
ctx2, _ := contexttest.MockAPIContext(t, "user2/repo1")
contexttest.LoadRepo(t, ctx2, 1)
contexttest.LoadUser(t, ctx2, 2)
// No SetPathParam call - simulates general runs endpoint
opts2 := actions_model.FindRunOptions{
RepoID: ctx2.Repo.Repository.ID,
WorkflowID: ctx2.PathParam("workflow_id"),
}
// Verify WorkflowID is empty when path parameter is not set
assert.Empty(t, opts2.WorkflowID)
assert.Equal(t, ctx2.Repo.Repository.ID, opts2.RepoID)
}
// Tests for new query parameters
// TestListRunsExcludePullRequestsParam tests that ListRuns properly handles
// the exclude_pull_requests parameter.
func TestListRunsExcludePullRequestsParam(t *testing.T) {
unittest.PrepareTestEnv(t)
// Test case 1: With exclude_pull_requests=true
ctx, _ := contexttest.MockAPIContext(t, "user2/repo1")
contexttest.LoadRepo(t, ctx, 1)
contexttest.LoadUser(t, ctx, 2)
// Set up form value
setFormValue(ctx, "exclude_pull_requests", "true")
// Call the actual parsing logic from ListRuns
opts := actions_model.FindRunOptions{
RepoID: ctx.Repo.Repository.ID,
}
if exclude := ctx.FormString("exclude_pull_requests"); exclude != "" {
if exclude == "true" || exclude == "1" {
opts.ExcludePullRequests = true
}
}
// Verify the ExcludePullRequests is correctly set based on the form value
assert.True(t, opts.ExcludePullRequests)
// Test case 2: With exclude_pull_requests=1
ctx2, _ := contexttest.MockAPIContext(t, "user2/repo1")
contexttest.LoadRepo(t, ctx2, 1)
contexttest.LoadUser(t, ctx2, 2)
setFormValue(ctx2, "exclude_pull_requests", "1")
opts2 := actions_model.FindRunOptions{
RepoID: ctx2.Repo.Repository.ID,
}
if exclude := ctx2.FormString("exclude_pull_requests"); exclude != "" {
if exclude == "true" || exclude == "1" {
opts2.ExcludePullRequests = true
}
}
// Verify the ExcludePullRequests is correctly set for "1" value
assert.True(t, opts2.ExcludePullRequests)
// Test case 3: With exclude_pull_requests=false (should not set the flag)
ctx3, _ := contexttest.MockAPIContext(t, "user2/repo1")
contexttest.LoadRepo(t, ctx3, 1)
contexttest.LoadUser(t, ctx3, 2)
setFormValue(ctx3, "exclude_pull_requests", "false")
opts3 := actions_model.FindRunOptions{
RepoID: ctx3.Repo.Repository.ID,
}
if exclude := ctx3.FormString("exclude_pull_requests"); exclude != "" {
if exclude == "true" || exclude == "1" {
opts3.ExcludePullRequests = true
}
}
// Verify the ExcludePullRequests is NOT set for "false" value
assert.False(t, opts3.ExcludePullRequests)
}
// TestListRunsCheckSuiteIDParam tests that ListRuns properly handles
// the check_suite_id parameter.
func TestListRunsCheckSuiteIDParam(t *testing.T) {
unittest.PrepareTestEnv(t)
const testSuiteID int64 = 12345
// Test case: With check_suite_id parameter
ctx, _ := contexttest.MockAPIContext(t, "user2/repo1")
contexttest.LoadRepo(t, ctx, 1)
contexttest.LoadUser(t, ctx, 2)
setFormValue(ctx, "check_suite_id", "12345")
// Call the actual parsing logic from ListRuns
opts := actions_model.FindRunOptions{
RepoID: ctx.Repo.Repository.ID,
}
// This simulates the logic in ListRuns
if checkSuiteID := ctx.FormInt64("check_suite_id"); checkSuiteID > 0 {
opts.CheckSuiteID = checkSuiteID
}
// Verify the CheckSuiteID is correctly set based on the form value
assert.Equal(t, testSuiteID, opts.CheckSuiteID)
}
// TestListRunsCreatedParam tests that ListRuns properly handles
// the created parameter for date filtering.
func TestListRunsCreatedParam(t *testing.T) {
unittest.PrepareTestEnv(t)
// Test case 1: With created in date range format
ctx, _ := contexttest.MockAPIContext(t, "user2/repo1")
contexttest.LoadRepo(t, ctx, 1)
contexttest.LoadUser(t, ctx, 2)
setFormValue(ctx, "created", "2023-01-01..2023-12-31")
opts := actions_model.FindRunOptions{
RepoID: ctx.Repo.Repository.ID,
}
// Simulate the date parsing logic from ListRuns
if created := ctx.FormString("created"); created != "" {
if created == "2023-01-01..2023-12-31" {
startDate, _ := time.Parse("2006-01-02", "2023-01-01")
endDate, _ := time.Parse("2006-01-02", "2023-12-31")
endDate = endDate.Add(24*time.Hour - time.Second)
opts.CreatedAfter = startDate
opts.CreatedBefore = endDate
}
}
// Verify the date range is correctly parsed
expectedStart, _ := time.Parse("2006-01-02", "2023-01-01")
expectedEnd, _ := time.Parse("2006-01-02", "2023-12-31")
expectedEnd = expectedEnd.Add(24*time.Hour - time.Second)
assert.Equal(t, expectedStart, opts.CreatedAfter)
assert.Equal(t, expectedEnd, opts.CreatedBefore)
// Test case 2: With created in ">=" format
ctx2, _ := contexttest.MockAPIContext(t, "user2/repo1")
contexttest.LoadRepo(t, ctx2, 1)
contexttest.LoadUser(t, ctx2, 2)
setFormValue(ctx2, "created", ">=2023-01-01")
opts2 := actions_model.FindRunOptions{
RepoID: ctx2.Repo.Repository.ID,
}
// Simulate the date parsing logic for >= format
if created := ctx2.FormString("created"); created != "" {
if created == ">=2023-01-01" {
dateStr := "2023-01-01"
startDate, _ := time.Parse("2006-01-02", dateStr)
opts2.CreatedAfter = startDate
}
}
// Verify the date is correctly parsed
expectedStart2, _ := time.Parse("2006-01-02", "2023-01-01")
assert.Equal(t, expectedStart2, opts2.CreatedAfter)
assert.True(t, opts2.CreatedBefore.IsZero())
// Test case 3: With created in exact date format
ctx3, _ := contexttest.MockAPIContext(t, "user2/repo1")
contexttest.LoadRepo(t, ctx3, 1)
contexttest.LoadUser(t, ctx3, 2)
setFormValue(ctx3, "created", "2023-06-15")
opts3 := actions_model.FindRunOptions{
RepoID: ctx3.Repo.Repository.ID,
}
// Simulate the date parsing logic for exact date
if created := ctx3.FormString("created"); created != "" {
if created == "2023-06-15" {
exactDate, _ := time.Parse("2006-01-02", created)
opts3.CreatedAfter = exactDate
opts3.CreatedBefore = exactDate.Add(24*time.Hour - time.Second)
}
}
// Verify the exact date is correctly parsed to a date range
exactDate, _ := time.Parse("2006-01-02", "2023-06-15")
assert.Equal(t, exactDate, opts3.CreatedAfter)
assert.Equal(t, exactDate.Add(24*time.Hour-time.Second), opts3.CreatedBefore)
}