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

Merge aff4e64eca88350da531941a935ef018a08aeba0 into 6599efb3b1400ac06d06e1c8b68ae6037fbb7952

This commit is contained in:
Brice Ruth 2025-07-12 16:21:06 +09:00 committed by GitHub
commit c85bf7015c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 563 additions and 17 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,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 {
@ -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})
}
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})
}
return cond
}

View File

@ -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>=")
}

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,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

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"
@ -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
@ -116,13 +139,12 @@ func convertToInternal(s string) ([]actions_model.Status, error) {
// ownerID != 0 and repoID == 0 means all runs for the given user/org
// ownerID != 0 and repoID != 0 undefined behavior
// Access rights are checked at the API route level
func ListRuns(ctx *context.APIContext, ownerID, repoID int64) {
if ownerID != 0 && repoID != 0 {
setting.PanicInDevOrTesting("ownerID and repoID should not be both set")
}
// buildRunOptions builds the FindRunOptions from context parameters
func buildRunOptions(ctx *context.APIContext, ownerID, repoID int64) (actions_model.FindRunOptions, error) {
opts := actions_model.FindRunOptions{
OwnerID: ownerID,
RepoID: repoID,
WorkflowID: ctx.PathParam("workflow_id"),
ListOptions: utils.GetListOptions(ctx),
}
@ -135,16 +157,14 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64) {
for _, status := range ctx.FormStrings("status") {
values, err := convertToInternal(status)
if err != nil {
ctx.APIError(http.StatusBadRequest, fmt.Errorf("Invalid status %s", status))
return
return opts, fmt.Errorf("Invalid status %s", status)
}
opts.Status = append(opts.Status, values...)
}
if actor := ctx.FormString("actor"); actor != "" {
user, err := user_model.GetUserByName(ctx, actor)
if err != nil {
ctx.APIErrorInternal(err)
return
return opts, err
}
opts.TriggerUserID = user.ID
}
@ -152,6 +172,93 @@ 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)
}
}
}
return opts, nil
}
func ListRuns(ctx *context.APIContext, ownerID, repoID int64) {
if ownerID != 0 && repoID != 0 {
setting.PanicInDevOrTesting("ownerID and repoID should not be both set")
}
opts, err := buildRunOptions(ctx, ownerID, repoID)
if err != nil {
ctx.APIError(http.StatusBadRequest, err)
return
}
runs, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts)
if err != nil {
ctx.APIErrorInternal(err)

View File

@ -0,0 +1,142 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package shared
import (
"testing"
"time"
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/services/contexttest"
"github.com/stretchr/testify/assert"
)
func TestMain(m *testing.M) {
unittest.MainTest(m)
}
// 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")
opts, err := buildRunOptions(ctx, 0, ctx.Repo.Repository.ID)
assert.NoError(t, err)
// 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, err := buildRunOptions(ctx2, 0, ctx2.Repo.Repository.ID)
assert.NoError(t, err)
// 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?exclude_pull_requests=true")
contexttest.LoadRepo(t, ctx, 1)
contexttest.LoadUser(t, ctx, 2)
// Call the actual production logic
opts, err := buildRunOptions(ctx, 0, ctx.Repo.Repository.ID)
assert.NoError(t, err)
// 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?exclude_pull_requests=1")
contexttest.LoadRepo(t, ctx2, 1)
contexttest.LoadUser(t, ctx2, 2)
opts2, err := buildRunOptions(ctx2, 0, ctx2.Repo.Repository.ID)
assert.NoError(t, err)
// 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?exclude_pull_requests=false")
contexttest.LoadRepo(t, ctx3, 1)
contexttest.LoadUser(t, ctx3, 2)
opts3, err := buildRunOptions(ctx3, 0, ctx3.Repo.Repository.ID)
assert.NoError(t, err)
// Verify the ExcludePullRequests is NOT set for "false" value
assert.False(t, opts3.ExcludePullRequests)
}
// 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?created=2023-01-01..2023-12-31")
contexttest.LoadRepo(t, ctx, 1)
contexttest.LoadUser(t, ctx, 2)
opts, err := buildRunOptions(ctx, 0, ctx.Repo.Repository.ID)
assert.NoError(t, err)
// 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?created=>=2023-01-01")
contexttest.LoadRepo(t, ctx2, 1)
contexttest.LoadUser(t, ctx2, 2)
opts2, err := buildRunOptions(ctx2, 0, ctx2.Repo.Repository.ID)
assert.NoError(t, err)
// 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?created=2023-06-15")
contexttest.LoadRepo(t, ctx3, 1)
contexttest.LoadUser(t, ctx3, 2)
opts3, err := buildRunOptions(ctx3, 0, ctx3.Repo.Repository.ID)
assert.NoError(t, err)
// 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)
}

View File

@ -6120,6 +6120,122 @@
}
}
},
"/repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs": {
"get": {
"produces": [
"application/json"
],
"tags": [
"repository"
],
"summary": "List workflow runs for a workflow",
"operationId": "ActionsListWorkflowRuns",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repo",
"name": "repo",
"in": "path",
"required": true
},
{
"type": "string",
"description": "id of the workflow",
"name": "workflow_id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Returns someone's workflow runs. Use the login for the user who created the push associated with the check suite or workflow run.",
"name": "actor",
"in": "query"
},
{
"type": "string",
"description": "Returns workflow runs associated with a branch. Use the name of the branch of the push.",
"name": "branch",
"in": "query"
},
{
"type": "string",
"description": "Returns workflow run triggered by the event you specify. For example, push, pull_request or issue.",
"name": "event",
"in": "query"
},
{
"type": "string",
"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",
"name": "status",
"in": "query"
},
{
"type": "string",
"description": "Returns workflow runs created within the given date-time range. For more information on the syntax, see \"Understanding the search syntax\".",
"name": "created",
"in": "query"
},
{
"type": "boolean",
"default": false,
"description": "If true pull requests are omitted from the response (empty array).",
"name": "exclude_pull_requests",
"in": "query"
},
{
"type": "integer",
"description": "Not supported in Gitea API. (GitHub API compatibility - parameter ignored).",
"name": "check_suite_id",
"in": "query"
},
{
"type": "string",
"description": "Only returns workflow runs that are associated with the specified head_sha.",
"name": "head_sha",
"in": "query"
},
{
"type": "integer",
"description": "page number of results to return (1-based)",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "page size of results",
"name": "limit",
"in": "query"
}
],
"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"
}
}
}
},
"/repos/{owner}/{repo}/activities/feeds": {
"get": {
"produces": [