From c0cdc8b118b47c781f3fb64c1eace9561aefc4ce Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 26 Mar 2026 19:24:46 +0100 Subject: [PATCH] Simplify project board API: use state field, extract helpers, remove dead code - Replace separate POST /close and /reopen endpoints with a state field on EditProjectOption, matching the milestone and issue API patterns - Extract getRepoProjectByID and getRepoProjectColumn helpers to deduplicate repeated lookup-and-error-handle patterns - Use LoadIssueNumbersForProject (singular) for single-project handlers - Remove unnecessary LoadIssueNumbersForProjects call on CreateProject since a new project always has zero issues - Remove unnecessary WHAT comments - Fix copyright year in routers/api/v1/repo/project.go Co-Authored-By: Claude (claude-opus-4-6) --- modules/structs/project.go | 2 + routers/api/v1/api.go | 2 - routers/api/v1/repo/project.go | 249 +++++---------------- templates/swagger/v1_json.tmpl | 93 +------- tests/integration/api_repo_project_test.go | 14 +- 5 files changed, 78 insertions(+), 282 deletions(-) diff --git a/modules/structs/project.go b/modules/structs/project.go index 12672c81a0..558395aff8 100644 --- a/modules/structs/project.go +++ b/modules/structs/project.go @@ -71,6 +71,8 @@ type EditProjectOption struct { Description *string `json:"description,omitempty"` // Card type: 0=text_only, 1=images_and_text CardType *int `json:"card_type,omitempty"` + // State of the project (open or closed) + State *string `json:"state,omitempty"` } // ProjectColumn represents a project column (board) diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 2594054acf..4c797b7a71 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1580,8 +1580,6 @@ 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 19a27143f0..6ae5554bee 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -1,4 +1,4 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. +// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package repo @@ -18,6 +18,41 @@ import ( project_service "code.gitea.io/gitea/services/projects" ) +func getRepoProjectByID(ctx *context.APIContext) *project_model.Project { + 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 nil + } + return project +} + +func getRepoProjectColumn(ctx *context.APIContext) *project_model.Column { + column, err := project_model.GetColumn(ctx, ctx.PathParamInt64("id")) + if err != nil { + if project_model.IsErrProjectColumnNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return nil + } + _, err = project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, column.ProjectID) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return nil + } + return column +} + // ListProjects lists all projects in a repository func ListProjects(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/projects repository repoListProjects @@ -124,17 +159,12 @@ func GetProject(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - 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) - } + project := getRepoProjectByID(ctx) + if ctx.Written() { return } - if err := project_service.LoadIssueNumbersForProjects(ctx, []*project_model.Project{project}, ctx.Doer); err != nil { + if err := project_service.LoadIssueNumbersForProject(ctx, project, ctx.Doer); err != nil { ctx.APIErrorInternal(err) return } @@ -191,11 +221,6 @@ func CreateProject(ctx *context.APIContext) { return } - if err := project_service.LoadIssueNumbersForProjects(ctx, []*project_model.Project{p}, ctx.Doer); err != nil { - ctx.APIErrorInternal(err) - return - } - ctx.JSON(http.StatusCreated, convert.ToProject(ctx, p)) } @@ -237,13 +262,8 @@ func EditProject(ctx *context.APIContext) { // "422": // "$ref": "#/responses/validationError" - 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) - } + project := getRepoProjectByID(ctx) + if ctx.Written() { return } @@ -263,97 +283,17 @@ func EditProject(ctx *context.APIContext) { 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) + if form.State != nil { + isClosed := *form.State == string(api.StateClosed) + if isClosed != project.IsClosed { + if err := project_model.ChangeProjectStatus(ctx, project, isClosed); 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 { + if err := project_service.LoadIssueNumbersForProject(ctx, project, ctx.Doer); err != nil { ctx.APIErrorInternal(err) return } @@ -389,14 +329,8 @@ func DeleteProject(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - // Verify project exists and belongs to this repository - 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) - } + project := getRepoProjectByID(ctx) + if ctx.Written() { return } @@ -446,13 +380,8 @@ func ListProjectColumns(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - 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) - } + project := getRepoProjectByID(ctx) + if ctx.Written() { return } @@ -512,13 +441,8 @@ func CreateProjectColumn(ctx *context.APIContext) { // "422": // "$ref": "#/responses/validationError" - 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) - } + project := getRepoProjectByID(ctx) + if ctx.Written() { return } @@ -577,24 +501,8 @@ func EditProjectColumn(ctx *context.APIContext) { // "422": // "$ref": "#/responses/validationError" - column, err := project_model.GetColumn(ctx, ctx.PathParamInt64("id")) - if err != nil { - if project_model.IsErrProjectColumnNotExist(err) { - ctx.APIErrorNotFound() - } else { - ctx.APIErrorInternal(err) - } - return - } - - // Verify column belongs to this repo's project - _, err = project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, column.ProjectID) - if err != nil { - if project_model.IsErrProjectNotExist(err) { - ctx.APIErrorNotFound() - } else { - ctx.APIErrorInternal(err) - } + column := getRepoProjectColumn(ctx) + if ctx.Written() { return } @@ -651,24 +559,8 @@ func DeleteProjectColumn(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - column, err := project_model.GetColumn(ctx, ctx.PathParamInt64("id")) - if err != nil { - if project_model.IsErrProjectColumnNotExist(err) { - ctx.APIErrorNotFound() - } else { - ctx.APIErrorInternal(err) - } - return - } - - // Verify column belongs to this repo's project - _, err = project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, column.ProjectID) - if err != nil { - if project_model.IsErrProjectNotExist(err) { - ctx.APIErrorNotFound() - } else { - ctx.APIErrorInternal(err) - } + column := getRepoProjectColumn(ctx) + if ctx.Written() { return } @@ -720,31 +612,13 @@ func AddIssueToProjectColumn(ctx *context.APIContext) { // "422": // "$ref": "#/responses/validationError" - column, err := project_model.GetColumn(ctx, ctx.PathParamInt64("id")) - if err != nil { - if project_model.IsErrProjectColumnNotExist(err) { - ctx.APIErrorNotFound() - } else { - ctx.APIErrorInternal(err) - } + column := getRepoProjectColumn(ctx) + if ctx.Written() { return } - // Verify column belongs to this repo's project - _, err = project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, column.ProjectID) - if err != nil { - if project_model.IsErrProjectNotExist(err) { - ctx.APIErrorNotFound() - } else { - ctx.APIErrorInternal(err) - } - return - } - - // Parse request body form := web.GetForm(ctx).(*api.AddIssueToProjectColumnOption) - // Verify issue exists and belongs to this repository issue, err := issues_model.GetIssueByID(ctx, form.IssueID) if err != nil { if issues_model.IsErrIssueNotExist(err) { @@ -760,7 +634,6 @@ func AddIssueToProjectColumn(ctx *context.APIContext) { return } - // Assign issue to column, creating an audit comment on the issue timeline if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, column.ProjectID, column.ID); err != nil { ctx.APIErrorInternal(err) return diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index a60488c8c1..2f882ce791 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -14145,50 +14145,6 @@ } } }, - "/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": [ @@ -14300,50 +14256,6 @@ } } }, - "/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": [ @@ -25596,6 +25508,11 @@ "type": "string", "x-go-name": "Description" }, + "state": { + "description": "State of the project (open or closed)", + "type": "string", + "x-go-name": "State" + }, "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 64e044fe69..78d50753aa 100644 --- a/tests/integration/api_repo_project_test.go +++ b/tests/integration/api_repo_project_test.go @@ -217,8 +217,11 @@ func TestAPIChangeProjectStatus(t *testing.T) { 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) + // Close via PATCH with state=closed + closed := "closed" + req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{ + State: &closed, + }).AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) var updatedProject api.Project @@ -226,8 +229,11 @@ func TestAPIChangeProjectStatus(t *testing.T) { 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) + // Reopen via PATCH with state=open + open := "open" + req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{ + State: &open, + }).AddTokenAuth(token) resp = MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &updatedProject)