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:
parent
187daac598
commit
a564f0587a
@ -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{}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
38
routers/api/v1/utils/sort.go
Normal file
38
routers/api/v1/utils/sort.go
Normal 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
|
||||
}
|
||||
44
routers/api/v1/utils/sort_test.go
Normal file
44
routers/api/v1/utils/sort_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
60
templates/swagger/v1_json.tmpl
generated
60
templates/swagger/v1_json.tmpl
generated
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user