Skip to content
Open
31 changes: 22 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,18 @@ 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
}

func (opts FindRunOptions) ToConds() builder.Cond {
Expand Down Expand Up @@ -101,6 +105,15 @@ 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})
Copy link
Member

Choose a reason for hiding this comment

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

I don't think this will work. It should be opts.CreatedAfter.Unix().

}
if !opts.CreatedBefore.IsZero() {
cond = cond.And(builder.Lte{"`action_run`.created": opts.CreatedBefore})
Copy link
Member

Choose a reason for hiding this comment

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

The same as above.

}
if opts.ExcludePullRequests {
cond = cond.And(builder.Neq{"`action_run`.trigger_event": webhook_module.HookEventPullRequest})
}
return cond
}

Expand Down
88 changes: 88 additions & 0 deletions models/actions/run_list_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// 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_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: Not supported in Gitea API. (GitHub API compatibility - parameter ignored).
// 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
97 changes: 97 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,79 @@ 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 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