diff --git a/models/actions/run_job_list.go b/models/actions/run_job_list.go index 50c860bf8b..5f4e482720 100644 --- a/models/actions/run_job_list.go +++ b/models/actions/run_job_list.go @@ -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{} diff --git a/routers/api/v1/admin/action.go b/routers/api/v1/admin/action.go index 62e0c6addc..4c9c1ed58c 100644 --- a/routers/api/v1/admin/action.go +++ b/routers/api/v1/admin/action.go @@ -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) } diff --git a/routers/api/v1/admin/user.go b/routers/api/v1/admin/user.go index 8a6924b4a3..3fb5856e33 100644 --- a/routers/api/v1/admin/user.go +++ b/routers/api/v1/admin/user.go @@ -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 diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 09b5ce2db5..666d4f98ef 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -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") diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index 8b0dc7c863..361a80abcc 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -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 { diff --git a/routers/api/v1/shared/action.go b/routers/api/v1/shared/action.go index 6f0c024843..5aae9d6418 100644 --- a/routers/api/v1/shared/action.go +++ b/routers/api/v1/shared/action.go @@ -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 diff --git a/routers/api/v1/user/action.go b/routers/api/v1/user/action.go index 4de0b30d98..82638d2f62 100644 --- a/routers/api/v1/user/action.go +++ b/routers/api/v1/user/action.go @@ -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) } diff --git a/routers/api/v1/utils/sort.go b/routers/api/v1/utils/sort.go new file mode 100644 index 0000000000..4e4cd10915 --- /dev/null +++ b/routers/api/v1/utils/sort.go @@ -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 +} diff --git a/routers/api/v1/utils/sort_test.go b/routers/api/v1/utils/sort_test.go new file mode 100644 index 0000000000..b0dc81e50f --- /dev/null +++ b/routers/api/v1/utils/sort_test.go @@ -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()) + }) + } +} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 8b21b53dc6..3c4be0d9ac 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -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" } } } diff --git a/templates/swagger/v1_openapi3_json.tmpl b/templates/swagger/v1_openapi3_json.tmpl index ec04a0a1ee..b337bb4b54 100644 --- a/templates/swagger/v1_openapi3_json.tmpl +++ b/templates/swagger/v1_openapi3_json.tmpl @@ -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", diff --git a/tests/integration/api_actions_run_test.go b/tests/integration/api_actions_run_test.go index 22fa6ba2ea..88a9c50f72 100644 --- a/tests/integration/api_actions_run_test.go +++ b/tests/integration/api_actions_run_test.go @@ -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) {