Skip to content

Add GitHub API compatibility for workflow runs filtering #34894

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
35 changes: 26 additions & 9 deletions models/actions/run_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package actions

import (
"context"
"time"

"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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})
Copy link
Member

Choose a reason for hiding this comment

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

There is no such column at the moment.

Copy link
Author

Choose a reason for hiding this comment

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

So, take that out, that small part of the GitHub API compatibility won't exist, but it's a pretty minor aspect, I think?

Copy link
Contributor

Choose a reason for hiding this comment

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

It seem that this PR is written by AI, and the non-existing column is caused by AI hallucination?

We need to make sure every line you proposed to change should be fully understood and has a clear meaning, I think?

Copy link
Contributor

Choose a reason for hiding this comment

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

To be clear: AI is good, I also use AI. I mean: I think we shouldn't keep the non-existing column in code, we need to leave a clear comment for this case.

}
return cond
}

Expand Down
104 changes: 104 additions & 0 deletions models/actions/run_list_test.go
Original file line number Diff line number Diff line change
@@ -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>=")
}
1 change: 1 addition & 0 deletions routers/api/v1/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
79 changes: 79 additions & 0 deletions routers/api/v1/repo/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -1100,6 +1100,85 @@ 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: actor
// in: query
// description: Returns someone's workflow runs. Use the login for the user who created the push associated with the check suite or workflow run.
// type: string
// - name: branch
// in: query
// description: Returns workflow runs associated with a branch. Use the name of the branch of the push.
// type: string
// - name: event
// in: query
// description: Returns workflow run triggered by the event you specify. For example, push, pull_request or issue.
// type: string
// - name: status
// in: query
// description: Returns workflow runs with the check run status or conclusion that you specify. Can be one of completed, action_required, cancelled, failure, neutral, skipped, stale, success, timed_out, in_progress, queued, requested, waiting, pending
// type: string
// - name: created
// in: query
// description: Returns workflow runs created within the given date-time range. For more information on the syntax, see "Understanding the search syntax".
// type: string
// - name: exclude_pull_requests
// in: query
// description: If true pull requests are omitted from the response (empty array).
// type: boolean
// default: false
// - name: check_suite_id
// in: query
// description: Returns workflow runs with the check_suite_id that you specify.
// type: integer
// - name: head_sha
// in: query
// description: Only returns workflow runs that are associated with the specified head_sha.
// type: string
// - 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
Expand Down
102 changes: 102 additions & 0 deletions routers/api/v1/shared/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -20,6 +22,27 @@ import (
"code.gitea.io/gitea/services/convert"
)

// parseISO8601DateRange parses flexible date formats: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ (ISO8601)
func parseISO8601DateRange(dateStr string) (time.Time, error) {
// Try ISO8601 format first: 2017-01-01T01:00:00+07:00 or 2016-03-21T14:11:00Z
if strings.Contains(dateStr, "T") {
// Try with timezone offset (RFC3339)
if t, err := time.Parse(time.RFC3339, dateStr); err == nil {
return t, nil
}
// Try with Z suffix (UTC)
if t, err := time.Parse("2006-01-02T15:04:05Z", dateStr); err == nil {
return t, nil
}
// Try without timezone
if t, err := time.Parse("2006-01-02T15:04:05", dateStr); err == nil {
return t, nil
}
}
// Try simple date format: YYYY-MM-DD
return time.Parse("2006-01-02", dateStr)
}

// ListJobs lists jobs for api route validated ownerID and repoID
// ownerID == 0 and repoID == 0 means all jobs
// ownerID == 0 and repoID != 0 means all jobs for the given repo
Expand Down Expand Up @@ -123,6 +146,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),
}

Expand Down Expand Up @@ -152,6 +176,84 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64) {
opts.CommitSHA = headSHA
}

// Handle exclude_pull_requests parameter
if ctx.FormBool("exclude_pull_requests") {
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
// Supports ISO8601 date formats: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ
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, "..") {
// Range format: "2023-01-01..2023-12-31"
dateRange := strings.Split(created, "..")
if len(dateRange) == 2 {
startDate, err := parseISO8601DateRange(dateRange[0])
if err == nil {
opts.CreatedAfter = startDate
}

endDate, err := parseISO8601DateRange(dateRange[1])
if err == nil {
// Set to end of day if only date provided
if !strings.Contains(dateRange[1], "T") {
endDate = endDate.Add(24*time.Hour - time.Second)
}
opts.CreatedBefore = endDate
}
}
} else if after, ok := strings.CutPrefix(created, ">="); ok {
// Greater than or equal format: ">=2023-01-01"
startDate, err := parseISO8601DateRange(after)
if err == nil {
opts.CreatedAfter = startDate
}
} else if after, ok := strings.CutPrefix(created, ">"); ok {
// Greater than format: ">2023-01-01"
startDate, err := parseISO8601DateRange(after)
if err == nil {
if strings.Contains(after, "T") {
opts.CreatedAfter = startDate.Add(time.Second)
} else {
opts.CreatedAfter = startDate.Add(24 * time.Hour)
}
}
} else if after, ok := strings.CutPrefix(created, "<="); ok {
// Less than or equal format: "<=2023-12-31"
endDate, err := parseISO8601DateRange(after)
if err == nil {
// Set to end of day if only date provided
if !strings.Contains(after, "T") {
endDate = endDate.Add(24*time.Hour - time.Second)
}
opts.CreatedBefore = endDate
}
} else if after, ok := strings.CutPrefix(created, "<"); ok {
// Less than format: "<2023-12-31"
endDate, err := parseISO8601DateRange(after)
if err == nil {
if strings.Contains(after, "T") {
opts.CreatedBefore = endDate.Add(-time.Second)
} else {
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 {
ctx.APIErrorInternal(err)
Expand Down
Loading