From 11b9593ccf841f73a6536fa7239c6c18cfb39a1e Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Sat, 21 Mar 2026 15:30:24 -0700 Subject: [PATCH] improvements --- modules/structs/project.go | 10 -- routers/api/v1/api.go | 2 + routers/api/v1/repo/project.go | 139 +++++++++++++++------ templates/swagger/v1_json.tmpl | 105 +++++++++++++--- tests/integration/api_repo_project_test.go | 60 +++++---- 5 files changed, 234 insertions(+), 82 deletions(-) diff --git a/modules/structs/project.go b/modules/structs/project.go index 1375d4f926..fd150cbdf6 100644 --- a/modules/structs/project.go +++ b/modules/structs/project.go @@ -71,8 +71,6 @@ type EditProjectOption struct { Description *string `json:"description,omitempty"` // Card type: 0=text_only, 1=images_and_text CardType *int `json:"card_type,omitempty"` - // Whether the project is closed - IsClosed *bool `json:"is_closed,omitempty"` } // ProjectColumn represents a project column (board) @@ -122,14 +120,6 @@ type EditProjectColumnOption struct { Sorting *int `json:"sorting,omitempty"` } -// MoveProjectColumnOption represents options for moving a project column -// swagger:model -type MoveProjectColumnOption struct { - // Position to move the column to (0-based index) - // required: true - Position int `json:"position" binding:"Required"` -} - // AddIssueToProjectColumnOption represents options for adding an issue to a project column // swagger:model type AddIssueToProjectColumnOption struct { diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 4c797b7a71..2594054acf 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1580,6 +1580,8 @@ func Routes() *web.Router { m.Combo("").Get(repo.GetProject). Patch(reqToken(), reqRepoWriter(unit.TypeProjects), bind(api.EditProjectOption{}), repo.EditProject). Delete(reqToken(), reqRepoWriter(unit.TypeProjects), repo.DeleteProject) + m.Post("/close", reqToken(), reqRepoWriter(unit.TypeProjects), repo.CloseProject) + m.Post("/reopen", reqToken(), reqRepoWriter(unit.TypeProjects), repo.ReopenProject) m.Combo("/columns").Get(repo.ListProjectColumns). Post(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.CreateProjectColumnOption{}), repo.CreateProjectColumn) }) diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index 14e08d9ac6..19a27143f0 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -10,7 +10,6 @@ import ( issues_model "code.gitea.io/gitea/models/issues" project_model "code.gitea.io/gitea/models/project" "code.gitea.io/gitea/modules/optional" - "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" @@ -70,24 +69,13 @@ func ListProjects(ctx *context.APIContext) { isClosed = optional.Some(false) } - page := ctx.FormInt("page") - if page <= 0 { - page = 1 - } - - limit := ctx.FormInt("limit") - if limit <= 0 { - limit = setting.UI.IssuePagingNum - } + listOptions := utils.GetListOptions(ctx) projects, count, err := db.FindAndCount[project_model.Project](ctx, project_model.SearchOptions{ - ListOptions: db.ListOptions{ - Page: page, - PageSize: limit, - }, - RepoID: ctx.Repo.Repository.ID, - IsClosed: isClosed, - Type: project_model.TypeRepository, + ListOptions: listOptions, + RepoID: ctx.Repo.Repository.ID, + IsClosed: isClosed, + Type: project_model.TypeRepository, }) if err != nil { ctx.APIErrorInternal(err) @@ -101,7 +89,7 @@ func ListProjects(ctx *context.APIContext) { apiProjects := convert.ToProjectList(ctx, projects) - ctx.SetLinkHeader(count, limit) + ctx.SetLinkHeader(count, listOptions.PageSize) ctx.SetTotalCountHeader(count) ctx.JSON(http.StatusOK, apiProjects) } @@ -270,16 +258,99 @@ func EditProject(ctx *context.APIContext) { if form.CardType != nil { project.CardType = project_model.CardType(*form.CardType) } - if form.IsClosed != nil { - if err := project_model.ChangeProjectStatus(ctx, project, *form.IsClosed); err != nil { + if err := project_model.UpdateProject(ctx, project); err != nil { + ctx.APIErrorInternal(err) + return + } + + if err := project_service.LoadIssueNumbersForProjects(ctx, []*project_model.Project{project}, ctx.Doer); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusOK, convert.ToProject(ctx, project)) +} + +// CloseProject closes a project +func CloseProject(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/projects/{id}/close repository repoCloseProject + // --- + // summary: Close a project + // 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: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/Project" + // "404": + // "$ref": "#/responses/notFound" + + changeProjectStatus(ctx, true) +} + +// ReopenProject reopens a project +func ReopenProject(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/projects/{id}/reopen repository repoReopenProject + // --- + // summary: Reopen a project + // 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: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/Project" + // "404": + // "$ref": "#/responses/notFound" + + changeProjectStatus(ctx, false) +} + +func changeProjectStatus(ctx *context.APIContext, isClosed bool) { + project, err := project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.APIErrorNotFound() + } else { ctx.APIErrorInternal(err) - return - } - } else { - if err := project_model.UpdateProject(ctx, project); err != nil { - ctx.APIErrorInternal(err) - return } + return + } + + if err := project_model.ChangeProjectStatus(ctx, project, isClosed); err != nil { + ctx.APIErrorInternal(err) + return } if err := project_service.LoadIssueNumbersForProjects(ctx, []*project_model.Project{project}, ctx.Doer); err != nil { @@ -536,7 +607,12 @@ func EditProjectColumn(ctx *context.APIContext) { column.Color = *form.Color } if form.Sorting != nil { - column.Sorting = int8(*form.Sorting) + sorting := int8(*form.Sorting) + if int(sorting) != *form.Sorting { + ctx.APIError(http.StatusUnprocessableEntity, "sorting out of range") + return + } + column.Sorting = sorting } if err := project_model.UpdateColumn(ctx, column); err != nil { @@ -633,14 +709,7 @@ func AddIssueToProjectColumn(ctx *context.APIContext) { // - name: body // in: body // schema: - // type: object - // required: - // - issue_id - // properties: - // issue_id: - // type: integer - // format: int64 - // description: ID of the issue to add + // "$ref": "#/definitions/AddIssueToProjectColumnOption" // responses: // "201": // "$ref": "#/responses/empty" diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index a95fb08f13..a60488c8c1 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -13987,17 +13987,7 @@ "name": "body", "in": "body", "schema": { - "type": "object", - "required": [ - "issue_id" - ], - "properties": { - "issue_id": { - "description": "ID of the issue to add", - "type": "integer", - "format": "int64" - } - } + "$ref": "#/definitions/AddIssueToProjectColumnOption" } } ], @@ -14155,6 +14145,50 @@ } } }, + "/repos/{owner}/{repo}/projects/{id}/close": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Close a project", + "operationId": "repoCloseProject", + "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": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/Project" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/{owner}/{repo}/projects/{id}/columns": { "get": { "produces": [ @@ -14266,6 +14300,50 @@ } } }, + "/repos/{owner}/{repo}/projects/{id}/reopen": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Reopen a project", + "operationId": "repoReopenProject", + "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": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/Project" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/{owner}/{repo}/pulls": { "get": { "produces": [ @@ -25518,11 +25596,6 @@ "type": "string", "x-go-name": "Description" }, - "is_closed": { - "description": "Whether the project is closed", - "type": "boolean", - "x-go-name": "IsClosed" - }, "title": { "description": "Project title", "type": "string", diff --git a/tests/integration/api_repo_project_test.go b/tests/integration/api_repo_project_test.go index 7b033fc5d6..edfe715001 100644 --- a/tests/integration/api_repo_project_test.go +++ b/tests/integration/api_repo_project_test.go @@ -188,27 +188,6 @@ func TestAPIUpdateProject(t *testing.T) { assert.Equal(t, newTitle, updatedProject.Title) assert.Equal(t, newDesc, updatedProject.Description) - // Test closing project - isClosed := true - req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{ - IsClosed: &isClosed, - }).AddTokenAuth(token) - resp = MakeRequest(t, req, http.StatusOK) - - DecodeJSON(t, resp, &updatedProject) - assert.True(t, updatedProject.IsClosed) - assert.NotNil(t, updatedProject.ClosedDate) - - // Test reopening project - isClosed = false - req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{ - IsClosed: &isClosed, - }).AddTokenAuth(token) - resp = MakeRequest(t, req, http.StatusOK) - - DecodeJSON(t, resp, &updatedProject) - assert.False(t, updatedProject.IsClosed) - // Test updating non-existent project req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/99999", owner.Name, repo.Name), &api.EditProjectOption{ Title: &newTitle, @@ -216,6 +195,45 @@ func TestAPIUpdateProject(t *testing.T) { MakeRequest(t, req, http.StatusNotFound) } +func TestAPIChangeProjectStatus(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + project := &project_model.Project{ + Title: "Project to Close", + Description: "Project to close and reopen", + RepoID: repo.ID, + Type: project_model.TypeRepository, + CreatorID: owner.ID, + TemplateType: project_model.TemplateTypeNone, + } + err := project_model.NewProject(t.Context(), project) + assert.NoError(t, err) + defer func() { + _ = project_model.DeleteProjectByID(t.Context(), project.ID) + }() + + token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) + + req := NewRequestf(t, "POST", "/api/v1/repos/%s/%s/projects/%d/close", owner.Name, repo.Name, project.ID). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var updatedProject api.Project + DecodeJSON(t, resp, &updatedProject) + assert.True(t, updatedProject.IsClosed) + assert.NotNil(t, updatedProject.ClosedDate) + + req = NewRequestf(t, "POST", "/api/v1/repos/%s/%s/projects/%d/reopen", owner.Name, repo.Name, project.ID). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &updatedProject) + assert.False(t, updatedProject.IsClosed) +} + func TestAPIDeleteProject(t *testing.T) { defer tests.PrepareTestEnv(t)()