0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-04-04 03:35:05 +02:00

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) <noreply@anthropic.com>
This commit is contained in:
silverwind 2026-03-26 19:24:46 +01:00
parent d39f321583
commit c0cdc8b118
No known key found for this signature in database
GPG Key ID: 2E62B41C93869443
5 changed files with 78 additions and 282 deletions

View File

@ -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)

View File

@ -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)
})

View File

@ -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

View File

@ -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",

View File

@ -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)