0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-05-13 21:56:10 +02:00

feat(api): add sort and order query parameters to job list endpoints (#37672)

Adds `sort` and `order` query parameters to all action job list API
endpoints (`/admin/actions/jobs`, `/repos/{owner}/{repo}/actions/jobs`,
`/repos/{owner}/{repo}/actions/runs/{run}/jobs`, `/user/actions/jobs`),
following the existing `OrderByMap` pattern used by repo/user search
endpoints.

- Default is `id` / `asc` (backwards compatible — matches previous DB
natural order)
- Only `id` sort field for now; the map is extensible for future fields
- Returns 422 for invalid sort/order values
- `ToOrders()` returns empty string when `OrderBy` is unset, so internal
callers (webhook dispatch, concurrency checks) are unaffected

Closes: #37666
Supersedes: #37667
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
Matt Schoen 2026-05-13 06:11:02 -07:00 committed by GitHub
parent 187daac598
commit a564f0587a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 306 additions and 35 deletions

View File

@ -98,6 +98,12 @@ type FindRunJobOptions struct {
Statuses []Status
UpdatedBefore timeutil.TimeStamp
ConcurrencyGroup string
OrderBy db.SearchOrderBy
}
var JobOrderByMap = map[string]map[string]db.SearchOrderBy{
"asc": {"id": "`action_run_job`.id ASC"},
"desc": {"id": "`action_run_job`.id DESC"},
}
func (opts FindRunJobOptions) ToConds() builder.Cond {
@ -140,3 +146,9 @@ func (opts FindRunJobOptions) ToJoins() []db.JoinFunc {
}
return nil
}
func (opts FindRunJobOptions) ToOrders() string {
return string(opts.OrderBy)
}
var _ db.FindOptionsOrder = FindRunJobOptions{}

View File

@ -29,6 +29,14 @@ func ListWorkflowJobs(ctx *context.APIContext) {
// in: query
// description: page size of results
// type: integer
// - name: sort
// in: query
// description: sort jobs by attribute. Supported values are "id". Default is "id"
// type: string
// - name: order
// in: query
// description: sort order, either "asc" (ascending) or "desc" (descending). Default is "asc"
// type: string
// responses:
// "200":
// "$ref": "#/responses/WorkflowJobsList"
@ -36,6 +44,8 @@ func ListWorkflowJobs(ctx *context.APIContext) {
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
shared.ListJobs(ctx, 0, 0, 0, nil)
}

View File

@ -464,24 +464,9 @@ func SearchUsers(ctx *context.APIContext) {
listOptions := utils.GetListOptions(ctx)
orderBy := db.SearchOrderByAlphabetically
sortMode := ctx.FormString("sort")
if len(sortMode) > 0 {
sortOrder := ctx.FormString("order")
if len(sortOrder) == 0 {
sortOrder = "asc"
}
if searchModeMap, ok := user_model.AdminUserOrderByMap[sortOrder]; ok {
if order, ok := searchModeMap[sortMode]; ok {
orderBy = order
} else {
ctx.APIError(http.StatusUnprocessableEntity, fmt.Errorf("Invalid sort mode: \"%s\"", sortMode))
return
}
} else {
ctx.APIError(http.StatusUnprocessableEntity, fmt.Errorf("Invalid sort order: \"%s\"", sortOrder))
return
}
orderBy, ok := utils.ResolveSortOrder(ctx, user_model.AdminUserOrderByMap, db.SearchOrderByAlphabetically)
if !ok {
return
}
var visible []api.VisibleType

View File

@ -707,6 +707,14 @@ func (Action) ListWorkflowJobs(ctx *context.APIContext) {
// in: query
// description: page size of results
// type: integer
// - name: sort
// in: query
// description: sort jobs by attribute. Supported values are "id". Default is "id"
// type: string
// - name: order
// in: query
// description: sort order, either "asc" (ascending) or "desc" (descending). Default is "asc"
// type: string
// responses:
// "200":
// "$ref": "#/responses/WorkflowJobsList"
@ -714,6 +722,8 @@ func (Action) ListWorkflowJobs(ctx *context.APIContext) {
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
repoID := ctx.Repo.Repository.ID
@ -1527,6 +1537,14 @@ func ListWorkflowRunJobs(ctx *context.APIContext) {
// in: query
// description: page size of results
// type: integer
// - name: sort
// in: query
// description: sort jobs by attribute. Supported values are "id". Default is "id"
// type: string
// - name: order
// in: query
// description: sort order, either "asc" (ascending) or "desc" (descending). Default is "asc"
// type: string
// responses:
// "200":
// "$ref": "#/responses/WorkflowJobsList"
@ -1534,6 +1552,8 @@ func ListWorkflowRunJobs(ctx *context.APIContext) {
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
repoID, runID := ctx.Repo.Repository.ID, ctx.PathParamInt64("run")

View File

@ -187,24 +187,11 @@ func Search(ctx *context.APIContext) {
opts.IsPrivate = optional.Some(ctx.FormBool("is_private"))
}
sortMode := ctx.FormString("sort")
if len(sortMode) > 0 {
sortOrder := ctx.FormString("order")
if len(sortOrder) == 0 {
sortOrder = "asc"
}
if searchModeMap, ok := repo_model.OrderByMap[sortOrder]; ok {
if orderBy, ok := searchModeMap[sortMode]; ok {
opts.OrderBy = orderBy
} else {
ctx.APIError(http.StatusUnprocessableEntity, fmt.Errorf("Invalid sort mode: \"%s\"", sortMode))
return
}
} else {
ctx.APIError(http.StatusUnprocessableEntity, fmt.Errorf("Invalid sort order: \"%s\"", sortOrder))
return
}
orderBy, ok := utils.ResolveSortOrder(ctx, repo_model.OrderByMap, "")
if !ok {
return
}
opts.OrderBy = orderBy
repos, count, err := repo_model.SearchRepository(ctx, opts)
if err != nil {

View File

@ -36,11 +36,16 @@ func ListJobs(ctx *context.APIContext, ownerID, repoID, runID int64, runAttemptI
setting.PanicInDevOrTesting("ownerID and repoID should not be both set")
}
listOptions := utils.GetListOptions(ctx)
orderBy, ok := utils.ResolveSortOrder(ctx, actions_model.JobOrderByMap, actions_model.JobOrderByMap["asc"]["id"])
if !ok {
return
}
opts := actions_model.FindRunJobOptions{
OwnerID: ownerID,
RepoID: repoID,
RunID: runID,
ListOptions: listOptions,
OrderBy: orderBy,
}
if runID > 0 {
opts.RunAttemptID = runAttemptID

View File

@ -429,6 +429,14 @@ func ListWorkflowJobs(ctx *context.APIContext) {
// in: query
// description: page size of results
// type: integer
// - name: sort
// in: query
// description: sort jobs by attribute. Supported values are "id". Default is "id"
// type: string
// - name: order
// in: query
// description: sort order, either "asc" (ascending) or "desc" (descending). Default is "asc"
// type: string
// produces:
// - application/json
// responses:
@ -438,6 +446,8 @@ func ListWorkflowJobs(ctx *context.APIContext) {
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
shared.ListJobs(ctx, ctx.Doer.ID, 0, 0, nil)
}

View File

@ -0,0 +1,38 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package utils
import (
"fmt"
"net/http"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/services/context"
)
// ResolveSortOrder reads "sort" and "order" query params and returns the matching
// SearchOrderBy from orderByMap. When "sort" is absent, returns defaultOrder.
// On invalid input it writes a 422 response and returns ok=false; callers should
// then return immediately.
func ResolveSortOrder(ctx *context.APIContext, orderByMap map[string]map[string]db.SearchOrderBy, defaultOrder db.SearchOrderBy) (db.SearchOrderBy, bool) {
sortMode := ctx.FormString("sort")
if sortMode == "" {
return defaultOrder, true
}
sortOrder := ctx.FormString("order")
if sortOrder == "" {
sortOrder = "asc"
}
orderMap, ok := orderByMap[sortOrder]
if !ok {
ctx.APIError(http.StatusUnprocessableEntity, fmt.Errorf("Invalid sort order: %q", sortOrder))
return "", false
}
orderBy, ok := orderMap[sortMode]
if !ok {
ctx.APIError(http.StatusUnprocessableEntity, fmt.Errorf("Invalid sort mode: %q", sortMode))
return "", false
}
return orderBy, true
}

View File

@ -0,0 +1,44 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package utils
import (
"net/http"
"testing"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/services/contexttest"
"github.com/stretchr/testify/assert"
)
func TestResolveSortOrder(t *testing.T) {
m := map[string]map[string]db.SearchOrderBy{
"asc": {"id": "id ASC"},
"desc": {"id": "id DESC"},
}
defaultOrder := db.SearchOrderBy("default")
cases := []struct {
path string
wantOK bool
wantOrder db.SearchOrderBy
wantStatus int
}{
{"GET /", true, defaultOrder, 0},
{"GET /?sort=id", true, "id ASC", 0},
{"GET /?sort=id&order=desc", true, "id DESC", 0},
{"GET /?sort=bogus", false, "", http.StatusUnprocessableEntity},
{"GET /?sort=id&order=bogus", false, "", http.StatusUnprocessableEntity},
}
for _, tc := range cases {
t.Run(tc.path, func(t *testing.T) {
ctx, _ := contexttest.MockAPIContext(t, tc.path)
got, ok := ResolveSortOrder(ctx, m, defaultOrder)
assert.Equal(t, tc.wantOK, ok)
assert.Equal(t, tc.wantOrder, got)
assert.Equal(t, tc.wantStatus, ctx.Resp.WrittenStatus())
})
}
}

View File

@ -49,6 +49,18 @@
"description": "page size of results",
"name": "limit",
"in": "query"
},
{
"type": "string",
"description": "sort jobs by attribute. Supported values are \"id\". Default is \"id\"",
"name": "sort",
"in": "query"
},
{
"type": "string",
"description": "sort order, either \"asc\" (ascending) or \"desc\" (descending). Default is \"asc\"",
"name": "order",
"in": "query"
}
],
"responses": {
@ -60,6 +72,9 @@
},
"404": {
"$ref": "#/responses/notFound"
},
"422": {
"$ref": "#/responses/validationError"
}
}
}
@ -4918,6 +4933,18 @@
"description": "page size of results",
"name": "limit",
"in": "query"
},
{
"type": "string",
"description": "sort jobs by attribute. Supported values are \"id\". Default is \"id\"",
"name": "sort",
"in": "query"
},
{
"type": "string",
"description": "sort order, either \"asc\" (ascending) or \"desc\" (descending). Default is \"asc\"",
"name": "order",
"in": "query"
}
],
"responses": {
@ -4929,6 +4956,9 @@
},
"404": {
"$ref": "#/responses/notFound"
},
"422": {
"$ref": "#/responses/validationError"
}
}
}
@ -5646,6 +5676,18 @@
"description": "page size of results",
"name": "limit",
"in": "query"
},
{
"type": "string",
"description": "sort jobs by attribute. Supported values are \"id\". Default is \"id\"",
"name": "sort",
"in": "query"
},
{
"type": "string",
"description": "sort order, either \"asc\" (ascending) or \"desc\" (descending). Default is \"asc\"",
"name": "order",
"in": "query"
}
],
"responses": {
@ -5657,6 +5699,9 @@
},
"404": {
"$ref": "#/responses/notFound"
},
"422": {
"$ref": "#/responses/validationError"
}
}
}
@ -18911,6 +18956,18 @@
"description": "page size of results",
"name": "limit",
"in": "query"
},
{
"type": "string",
"description": "sort jobs by attribute. Supported values are \"id\". Default is \"id\"",
"name": "sort",
"in": "query"
},
{
"type": "string",
"description": "sort order, either \"asc\" (ascending) or \"desc\" (descending). Default is \"asc\"",
"name": "order",
"in": "query"
}
],
"responses": {
@ -18922,6 +18979,9 @@
},
"404": {
"$ref": "#/responses/notFound"
},
"422": {
"$ref": "#/responses/validationError"
}
}
}

View File

@ -10630,6 +10630,22 @@
"schema": {
"type": "integer"
}
},
{
"description": "sort jobs by attribute. Supported values are \"id\". Default is \"id\"",
"in": "query",
"name": "sort",
"schema": {
"type": "string"
}
},
{
"description": "sort order, either \"asc\" (ascending) or \"desc\" (descending). Default is \"asc\"",
"in": "query",
"name": "order",
"schema": {
"type": "string"
}
}
],
"responses": {
@ -10641,6 +10657,9 @@
},
"404": {
"$ref": "#/components/responses/notFound"
},
"422": {
"$ref": "#/components/responses/validationError"
}
},
"summary": "Lists all jobs",
@ -15728,6 +15747,22 @@
"schema": {
"type": "integer"
}
},
{
"description": "sort jobs by attribute. Supported values are \"id\". Default is \"id\"",
"in": "query",
"name": "sort",
"schema": {
"type": "string"
}
},
{
"description": "sort order, either \"asc\" (ascending) or \"desc\" (descending). Default is \"asc\"",
"in": "query",
"name": "order",
"schema": {
"type": "string"
}
}
],
"responses": {
@ -15739,6 +15774,9 @@
},
"404": {
"$ref": "#/components/responses/notFound"
},
"422": {
"$ref": "#/components/responses/validationError"
}
},
"summary": "Lists all jobs for a repository",
@ -16526,6 +16564,22 @@
"schema": {
"type": "integer"
}
},
{
"description": "sort jobs by attribute. Supported values are \"id\". Default is \"id\"",
"in": "query",
"name": "sort",
"schema": {
"type": "string"
}
},
{
"description": "sort order, either \"asc\" (ascending) or \"desc\" (descending). Default is \"asc\"",
"in": "query",
"name": "order",
"schema": {
"type": "string"
}
}
],
"responses": {
@ -16537,6 +16591,9 @@
},
"404": {
"$ref": "#/components/responses/notFound"
},
"422": {
"$ref": "#/components/responses/validationError"
}
},
"summary": "Lists all jobs for a workflow run",
@ -30884,6 +30941,22 @@
"schema": {
"type": "integer"
}
},
{
"description": "sort jobs by attribute. Supported values are \"id\". Default is \"id\"",
"in": "query",
"name": "sort",
"schema": {
"type": "string"
}
},
{
"description": "sort order, either \"asc\" (ascending) or \"desc\" (descending). Default is \"asc\"",
"in": "query",
"name": "order",
"schema": {
"type": "string"
}
}
],
"responses": {
@ -30895,6 +30968,9 @@
},
"404": {
"$ref": "#/components/responses/notFound"
},
"422": {
"$ref": "#/components/responses/validationError"
}
},
"summary": "Get workflow jobs",

View File

@ -327,6 +327,30 @@ func testAPIActionsListUserWorkflows(t *testing.T) {
assert.NotEmpty(t, job.HTMLURL, "html_url should be populated via batch-loaded repo")
}
})
t.Run("JobsDefaultOrderAsc", func(t *testing.T) {
req := NewRequest(t, "GET", "/api/v1/user/actions/jobs").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
jobs := DecodeJSON(t, resp, &api.ActionWorkflowJobsResponse{})
assert.GreaterOrEqual(t, len(jobs.Entries), 2, "need at least 2 jobs to verify ordering")
for i := 1; i < len(jobs.Entries); i++ {
assert.Less(t, jobs.Entries[i-1].ID, jobs.Entries[i].ID,
"jobs should be ordered by ID ascending by default")
}
})
t.Run("JobsOrderedByIDDesc", func(t *testing.T) {
req := NewRequest(t, "GET", "/api/v1/user/actions/jobs?sort=id&order=desc").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
jobs := DecodeJSON(t, resp, &api.ActionWorkflowJobsResponse{})
assert.GreaterOrEqual(t, len(jobs.Entries), 2, "need at least 2 jobs to verify ordering")
for i := 1; i < len(jobs.Entries); i++ {
assert.Greater(t, jobs.Entries[i-1].ID, jobs.Entries[i].ID,
"jobs should be ordered by ID descending")
}
})
}
func testAPIActionsListRepoWorkflows(t *testing.T) {