Files
gitea/routers/api/v1/repo/project.go
Supen.Huang 4338a2b72f feat(api): add comprehensive REST API for Project Boards
This adds a complete REST API implementation for managing repository
project boards, including projects, columns, and adding issues to columns.

API Endpoints:
- GET    /repos/{owner}/{repo}/projects          - List projects
- POST   /repos/{owner}/{repo}/projects          - Create project
- GET    /repos/{owner}/{repo}/projects/{id}     - Get project
- PATCH  /repos/{owner}/{repo}/projects/{id}     - Update project
- DELETE /repos/{owner}/{repo}/projects/{id}     - Delete project
- GET    /repos/{owner}/{repo}/projects/{id}/columns    - List columns
- POST   /repos/{owner}/{repo}/projects/{id}/columns    - Create column
- PATCH  /repos/{owner}/{repo}/projects/columns/{id}    - Update column
- DELETE /repos/{owner}/{repo}/projects/columns/{id}    - Delete column
- POST   /repos/{owner}/{repo}/projects/columns/{id}/issues - Add issue

Features:
- Full Swagger/OpenAPI documentation
- Proper permission checks
- Pagination support for list endpoints
- State filtering (open/closed/all)
- Comprehensive error handling
- Token-based authentication with scope validation
- Archive repository protection

New Files:
- modules/structs/project.go: API data structures
- routers/api/v1/repo/project.go: API handlers
- routers/api/v1/swagger/project.go: Swagger responses
- services/convert/project.go: Model converters
- tests/integration/api_repo_project_test.go: Integration tests

Modified Files:
- models/project/issue.go: Added AddOrUpdateIssueToColumn function
- routers/api/v1/api.go: Registered project API routes
- routers/api/v1/swagger/options.go: Added project option types
- templates/swagger/v1_json.tmpl: Regenerated swagger spec

fix(api): remove duplicated permission checks in project handlers

Route middleware reqRepoReader(unit.TypeProjects) wraps the entire
/projects route group, and reqRepoWriter(unit.TypeProjects) is applied
to each mutating route individually in api.go. These middleware run
before any handler fires and already gate access correctly.

The inline CanRead/CanWrite checks at the top of all 10 handlers were
therefore unreachable dead code — removed from ListProjects, GetProject,
CreateProject, EditProject, DeleteProject, ListProjectColumns,
CreateProjectColumn, EditProjectColumn, DeleteProjectColumn, and
AddIssueToProjectColumn.

The now-unused "code.gitea.io/gitea/models/unit" import is also removed.

Addresses review feedback on: https://github.com/go-gitea/gitea/pull/36008

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>

fix(api): replace AddOrUpdateIssueToColumn with IssueAssignOrRemoveProject

The custom AddOrUpdateIssueToColumn function introduced by this PR was
missing three things that the existing IssueAssignOrRemoveProject provides:

1. db.WithTx transaction wrapper — raw DB updates without a transaction
   can leave the database in a partial state on error.

2. CreateComment(CommentTypeProject) — assigning an issue to a project
   column via the UI creates a comment on the issue timeline. The API
   doing the same action silently was an inconsistency.

3. CanBeAccessedByOwnerRepo ownership check — IssueAssignOrRemoveProject
   validates that the issue is accessible within the repo/org context
   before mutating state.

AddOrUpdateIssueToColumn is removed entirely. AddIssueToProjectColumn
now delegates to issues_model.IssueAssignOrRemoveProject, which already
has the issue object loaded earlier in the handler.

Addresses review feedback on: https://github.com/go-gitea/gitea/pull/36008

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>

fix(api): remove unnecessary pagination from ListProjectColumns

Project columns are few in number by design (typically 3-8 per board).
The previous implementation fetched all columns from the DB then sliced
the result in memory — adding complexity and a misleading Link header
without any practical benefit.

ListProjectColumns now returns all columns directly. The page/limit
query parameters and associated swagger docs are removed.

Addresses review feedback on: https://github.com/go-gitea/gitea/pull/36008

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>

fix(api): regenerate swagger spec after removing ListProjectColumns pagination

Removes the page and limit parameters from the generated swagger spec
for the ListProjectColumns endpoint, matching the handler change that
dropped in-memory pagination.

Co-authored-by: Claude <noreply@anthropic.com>

test(api): remove pagination assertion from TestAPIListProjectColumns

ListProjectColumns no longer supports pagination — it returns all columns
directly. Remove the page/limit test case that expected 2 of 3 columns.

Co-authored-by: Claude <noreply@anthropic.com>

fix(api): implement proper pagination for ListProjectColumns

Per contribution guidelines, list endpoints must support page/limit
query params and set X-Total-Count header.

- Add CountColumns and GetColumnsPaginated to project model (DB-level,
  not in-memory slicing)
- ListProjectColumns uses utils.GetListOptions, calls paginated model
  functions, and sets X-Total-Count via ctx.SetTotalCountHeader
- Restore page/limit swagger doc params on the endpoint
- Regenerate swagger spec
- Integration test covers: full list with X-Total-Count, page 1 of 2,
  page 2 of 2, and 404 for non-existent project

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-03 14:32:33 +05:30

701 lines
17 KiB
Go

// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"net/http"
"code.gitea.io/gitea/models/db"
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/services/context"
"code.gitea.io/gitea/services/convert"
project_service "code.gitea.io/gitea/services/projects"
"code.gitea.io/gitea/routers/api/v1/utils"
)
// ListProjects lists all projects in a repository
func ListProjects(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/projects repository repoListProjects
// ---
// summary: List projects in a repository
// 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: state
// in: query
// description: State of the project (open, closed)
// type: string
// enum: [open, closed, all]
// default: open
// - name: page
// in: query
// description: page number of results
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// "$ref": "#/responses/ProjectList"
// "404":
// "$ref": "#/responses/notFound"
state := ctx.FormTrim("state")
var isClosed optional.Option[bool]
switch state {
case "closed":
isClosed = optional.Some(true)
case "open":
isClosed = optional.Some(false)
case "all":
isClosed = optional.None[bool]()
default:
isClosed = optional.Some(false)
}
page := ctx.FormInt("page")
if page <= 0 {
page = 1
}
limit := ctx.FormInt("limit")
if limit <= 0 {
limit = setting.UI.IssuePagingNum
}
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,
})
if err != nil {
ctx.APIErrorInternal(err)
return
}
if err := project_service.LoadIssueNumbersForProjects(ctx, projects, ctx.Doer); err != nil {
ctx.APIErrorInternal(err)
return
}
apiProjects := convert.ToProjectList(ctx, projects)
ctx.SetLinkHeader(int(count), limit)
ctx.SetTotalCountHeader(count)
ctx.JSON(http.StatusOK, apiProjects)
}
// GetProject gets a single project
func GetProject(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/projects/{id} repository repoGetProject
// ---
// summary: Get a single 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"
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
}
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))
}
// CreateProject creates a new project
func CreateProject(ctx *context.APIContext) {
// swagger:operation POST /repos/{owner}/{repo}/projects repository repoCreateProject
// ---
// summary: Create a new project
// consumes:
// - application/json
// 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: body
// in: body
// schema:
// "$ref": "#/definitions/CreateProjectOption"
// responses:
// "201":
// "$ref": "#/responses/Project"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
form := web.GetForm(ctx).(*api.CreateProjectOption)
p := &project_model.Project{
RepoID: ctx.Repo.Repository.ID,
Title: form.Title,
Description: form.Description,
CreatorID: ctx.Doer.ID,
TemplateType: project_model.TemplateType(form.TemplateType),
CardType: project_model.CardType(form.CardType),
Type: project_model.TypeRepository,
}
if err := project_model.NewProject(ctx, p); err != nil {
ctx.APIErrorInternal(err)
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))
}
// EditProject updates a project
func EditProject(ctx *context.APIContext) {
// swagger:operation PATCH /repos/{owner}/{repo}/projects/{id} repository repoEditProject
// ---
// summary: Edit a project
// consumes:
// - application/json
// 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
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/EditProjectOption"
// responses:
// "200":
// "$ref": "#/responses/Project"
// "404":
// "$ref": "#/responses/notFound"
// "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)
}
return
}
form := web.GetForm(ctx).(*api.EditProjectOption)
if form.Title != nil {
project.Title = *form.Title
}
if form.Description != nil {
project.Description = *form.Description
}
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 {
ctx.APIErrorInternal(err)
return
}
} else {
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))
}
// DeleteProject deletes a project
func DeleteProject(ctx *context.APIContext) {
// swagger:operation DELETE /repos/{owner}/{repo}/projects/{id} repository repoDeleteProject
// ---
// summary: Delete a project
// 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:
// "204":
// "$ref": "#/responses/empty"
// "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)
}
return
}
if err := project_model.DeleteProjectByID(ctx, project.ID); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.Status(http.StatusNoContent)
}
// ListProjectColumns lists all columns in a project
func ListProjectColumns(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/projects/{id}/columns repository repoListProjectColumns
// ---
// summary: List columns in 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
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// "$ref": "#/responses/ProjectColumnList"
// "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)
}
return
}
total, err := project.CountColumns(ctx)
if err != nil {
ctx.APIErrorInternal(err)
return
}
listOptions := utils.GetListOptions(ctx)
columns, err := project.GetColumnsPaginated(ctx, listOptions)
if err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.SetTotalCountHeader(total)
ctx.JSON(http.StatusOK, convert.ToProjectColumnList(ctx, columns))
}
// CreateProjectColumn creates a new column in a project
func CreateProjectColumn(ctx *context.APIContext) {
// swagger:operation POST /repos/{owner}/{repo}/projects/{id}/columns repository repoCreateProjectColumn
// ---
// summary: Create a new column in a project
// consumes:
// - application/json
// 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
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/CreateProjectColumnOption"
// responses:
// "201":
// "$ref": "#/responses/ProjectColumn"
// "404":
// "$ref": "#/responses/notFound"
// "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)
}
return
}
form := web.GetForm(ctx).(*api.CreateProjectColumnOption)
column := &project_model.Column{
Title: form.Title,
Color: form.Color,
ProjectID: project.ID,
CreatorID: ctx.Doer.ID,
}
if err := project_model.NewColumn(ctx, column); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusCreated, convert.ToProjectColumn(ctx, column))
}
// EditProjectColumn updates a column
func EditProjectColumn(ctx *context.APIContext) {
// swagger:operation PATCH /repos/{owner}/{repo}/projects/columns/{id} repository repoEditProjectColumn
// ---
// summary: Edit a project column
// consumes:
// - application/json
// 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 column
// type: integer
// format: int64
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/EditProjectColumnOption"
// responses:
// "200":
// "$ref": "#/responses/ProjectColumn"
// "404":
// "$ref": "#/responses/notFound"
// "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)
}
return
}
form := web.GetForm(ctx).(*api.EditProjectColumnOption)
if form.Title != nil {
column.Title = *form.Title
}
if form.Color != nil {
column.Color = *form.Color
}
if form.Sorting != nil {
column.Sorting = int8(*form.Sorting)
}
if err := project_model.UpdateColumn(ctx, column); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, convert.ToProjectColumn(ctx, column))
}
// DeleteProjectColumn deletes a column
func DeleteProjectColumn(ctx *context.APIContext) {
// swagger:operation DELETE /repos/{owner}/{repo}/projects/columns/{id} repository repoDeleteProjectColumn
// ---
// summary: Delete a project column
// 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 column
// type: integer
// format: int64
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "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)
}
return
}
if err := project_model.DeleteColumnByID(ctx, column.ID); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.Status(http.StatusNoContent)
}
// AddIssueToProjectColumn adds an issue to a project column
func AddIssueToProjectColumn(ctx *context.APIContext) {
// swagger:operation POST /repos/{owner}/{repo}/projects/columns/{id}/issues repository repoAddIssueToProjectColumn
// ---
// summary: Add an issue to a project column
// consumes:
// - application/json
// 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 column
// type: integer
// format: int64
// required: true
// - name: body
// in: body
// schema:
// type: object
// required:
// - issue_id
// properties:
// issue_id:
// type: integer
// format: int64
// description: ID of the issue to add
// responses:
// "201":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
// "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)
}
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) {
ctx.APIError(http.StatusUnprocessableEntity, "issue not found")
} else {
ctx.APIErrorInternal(err)
}
return
}
if issue.RepoID != ctx.Repo.Repository.ID {
ctx.APIError(http.StatusUnprocessableEntity, "issue does not belong to this repository")
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
}
ctx.Status(http.StatusCreated)
}