Skip to content

feat(api): add REST API for repository project boards#37518

Draft
beardev-in wants to merge 37 commits intogo-gitea:mainfrom
beardev-in:fix/project-board-api-review-feedback
Draft

feat(api): add REST API for repository project boards#37518
beardev-in wants to merge 37 commits intogo-gitea:mainfrom
beardev-in:fix/project-board-api-review-feedback

Conversation

@beardev-in
Copy link
Copy Markdown

@beardev-in beardev-in commented May 3, 2026

Summary

Adds a complete REST API for managing repository project boards.

This is a rebase of #36831 onto current main, fully adapted for
the multi-project-per-issue model introduced in #36784.

Previous work on this feature:

  • #36008 — original feature proposal and discussion
  • #36831 — prior implementation attempt (superseded by this PR)
  • #36784 — merged: multiple projects per issue (this PR rebases onto it)

Endpoints

Method Path Description
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/{issue_id} Add/move issue to column
DELETE /repos/{owner}/{repo}/projects/columns/{id}/issues/{issue_id} Remove issue from column
GET /repos/{owner}/{repo}/projects/columns/{id}/issues List issues in column
POST /repos/{owner}/{repo}/projects/columns/{id}/issues/{issue_id}/move Move issue between columns

Usage

Users and integrations can manage repository project boards entirely via API; create and update projects, manage columns, assign issues, and move issues between columns. All list endpoints support pagination and return X-Total-Count.

Testing

All 14 integration tests pass (TestAPIProjects). Tests cover:

  • CRUD for projects and columns
  • Adding, moving, and removing issues across columns
  • Permission enforcement (reader vs writer)

Implementation notes

  • Issue-to-project assignment delegates to IssueAssignOrRemoveProject (Allow multiple projects per issue and pull requests #36784), which reconciles the issue's current project memberships against the desired set. When adding an issue to a specific column, the handler checks if the issue is already in the project and moves it to the target column if so, or assigns it fresh otherwise.
  • EditProject separates field updates from status changes - title, description, and card type are written via UpdateProject, while open/close state is handled via ChangeProjectStatus only when state is present. This avoids the bug where sending both would silently drop field updates.
  • Column sorting is validated against the int8 DB range (-128 to 127) before assignment; out-of-range values return 400 Bad Request.
  • Swagger spec regenerated from annotations.

AI disclosure

This PR was developed with AI assistance (Claude - Sonnet-4.6). All code has been manually reviewed, tested, and is understood by the contributor.

SupenBysz and others added 30 commits May 3, 2026 14:32
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: go-gitea#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: go-gitea#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: go-gitea#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>
Three issues raised by @lunny in review of go-gitea#36008 are addressed:

1. Duplicate permission checks removed
   The /projects route group is already wrapped with reqRepoReader and
   reqRepoWriter in api.go. The inline CanRead/CanWrite checks at the
   top of all 10 handlers were unreachable dead code.

2. AddOrUpdateIssueToColumn replaced with IssueAssignOrRemoveProject
   The custom function introduced in go-gitea#36008 was missing a db.WithTx
   transaction wrapper, the CommentTypeProject audit comment written by
   the UI, and the CanBeAccessedByOwnerRepo cross-repo ownership guard.
   AddIssueToProjectColumn now delegates to the existing
   IssueAssignOrRemoveProject which provides all three.

3. ListProjectColumns pagination implemented correctly
   Added CountColumns and GetColumnsPaginated (using
   db.SetSessionPagination) to the project model. The handler uses
   utils.GetListOptions and sets X-Total-Count via
   ctx.SetTotalCountHeader per API contribution guidelines.
   Integration tests cover full list, page 1, page 2, and 404.

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Signed-off-by: silverwind <me@silverwind.io>
Signed-off-by: silverwind <me@silverwind.io>
Signed-off-by: silverwind <me@silverwind.io>
Signed-off-by: silverwind <me@silverwind.io>
…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>
- Use ParseIssueFilterStateIsClosed for ListProjects state parsing
- Add SortTypeProjectColumnSorting const, replace magic string
- Use GetIssueByRepoID and dedupe Add/Remove issue handlers
- Migrate Column.Sorting from int8 to int (drops 127-column limit, allows
  the API to expose a normal int without truncation)
- Introduce project_service.UpdateProject with optional.Option fields,
  use it from the API EditProject handler

Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
- Drop lazy LoadRepo/LoadOwner in convert.ToProject; rely on caller
  preloading. ListProjects sets Repo from ctx.Repo.Repository on each
  project; CreateProject does the same on the new project. Avoids
  N+1 queries for repo-scoped list endpoints.
- Strip redundant API struct field comments that just restate the
  field name; keep the ones that document enum values.
- Pre-allocate GetColumnsByIDs result slice with len(columnsIDs).
- Fix CountProjectColumns doc comment (was "CountColumns").

Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
- EditProject: wrap field updates and ChangeProjectStatus in db.WithTx so
  a status-change failure doesn't leave a partially applied PATCH.
- Validate EditProjectOption.State against open/closed; 422 on other
  values instead of silently treating them as open.
- Align missing-issue status to 404 (the URL targets a missing resource);
  update existing test that was asserting the old 422.
- RemoveIssueFromProjectColumn: verify the project_issue row matches the
  URL column before clearing the issue's project assignment, since
  IssueAssignOrRemoveProject(projectID=0) detaches the issue from any
  project regardless of column. Returns 404 if the issue isn't in this
  column. New test covers the cross-column case.

Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
Tested against the CI image versions (postgres:14, bitnamilegacy/mysql:8.0,
mcr.microsoft.com/mssql/server:2019-latest) plus SQLite. Both the original
implementation (per-dialect SQL) and a naive `base.ModifyColumn` with
`DefaultIsEmpty: false` were tried. Findings:

- DefaultIsEmpty: false fails on MSSQL with "Incorrect syntax near the
  keyword 'DEFAULT'" because MSSQL's ALTER COLUMN does not accept inline
  DEFAULT (it lives in a separate constraint object).
- DefaultIsEmpty: true succeeds on MSSQL (existing default constraint
  unaffected) and Postgres (DEFAULT constraint is independent of TYPE)
  but drops the DEFAULT on MySQL because MODIFY COLUMN rewrites all
  column attributes.

Settled on the minimal cross-DB form: base.ModifyColumn with
DefaultIsEmpty: true to widen the type, then a MySQL-only follow-up
`ALTER ... SET DEFAULT 0` to restore the default that MODIFY COLUMN drops.

The new test seeds rows at the int8 boundary (0 and 127), runs the
migration, asserts the column type widened, the rows preserved, and that
inserting a value > 127 succeeds afterward.

Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
- Use setting.Database.Type.IsSQLite3() / IsMySQL() for dialect checks
  to match the rest of models/migrations/.
- Trim the migration's comment to the load-bearing why (MSSQL rejects
  inline DEFAULT, MySQL drops it on MODIFY COLUMN), drop the discovery
  narrative.
- Trim test comments and tighten the type-name assertion list to the
  values dialects actually emit (verified empirically against the CI
  image versions: MySQL/MSSQL report "INT", Postgres reports "INTEGER";
  "INT4" never surfaces, removed).

Re-tested against postgres:14, bitnamilegacy/mysql:8.0, mssql:2019-latest,
and SQLite — all pass.

Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
- Rename response timestamps to created_at / updated_at / closed_at
- Replace is_closed bool with state ("open" / "closed") via api.StateType
- Switch template_type / card_type / type to string enums with input validation
- Embed creator User object on Project and ProjectColumn (batched lookup)
- Add absolute html_url; drop relative url
- Add POST /repos/.../projects/{id}/issues/{issue_id}/move with optional sorting
- Validate column hex color and reject writes to closed projects
- Document issue-only project scope in swagger
- Push project-issue existence check into project_model.IsIssueInColumn
- Add project_service.ErrIssueNotInProject sentinel for the move endpoint

Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
Per review feedback, the 127-column cap is intentional (maxProjectColumns
is 20), so the DB schema is left as-is and no migration is needed. Reverts
the Column.Sorting widening to match.

Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
@GiteaBot GiteaBot added the lgtm/need 2 This PR needs two approvals by maintainers to be considered for merging. label May 3, 2026
Comment on lines +452 to +458
// Remove zero values - id=0 means "remove from all projects"
filteredIDs := make([]int64, 0, len(projectIDs))
for _, id := range projectIDs {
if id != 0 {
filteredIDs = append(filteredIDs, id)
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why 0 should appear in the id input?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The frontend sidebar sends id=0 when deselecting all projects from an issue, it's the form's "no selection" value. Before #36784, IssueAssignOrRemoveProject took a single int64 where 0 explicitly meant "remove". After #36784 changed the signature to []int64, the zero is no longer meaningful and causes a lookup failure for project ID 0.
That said, I agree the filter is the wrong layer. The cleaner fix is to change ctx.FormStringInt64s("id") to strip zeros at the source, or better yet, treat an empty/zero submission as an empty slice directly. I'll update the fix.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The frontend sidebar sends id=0 when deselecting all projects from an issue, it's the form's "no selection" value.

Is it true for the code on main branch? If yes, it is a bug, must be fixed, but not bypassed.

Copy link
Copy Markdown
Author

@beardev-in beardev-in May 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, id=0 occurs on main - FormStringInt64s("id") calls strconv.ParseInt("0") in UpdateIssueProject (/routers/web/repo/projects.go) which produces [0]. This is the frontend's convention for "no project selected." The correct fix is to strip non-positive values at the point of reading, before passing to IssueAssignOrRemoveProject

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the frontend's convention for "no project selected."

Which part of frontend code emits "id=0"?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will get back to you on this.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I verified the request payload using DevTools.

When clearing all projects from an issue, the request contains id=undefined, not id=0.

FormStringInt64s("id") parses this value using strconv.ParseInt. The parse fails for "undefined", but the error is ignored and the zero value (0) is used, resulting in [0] being passed to IssueAssignOrRemoveProject.

In the previous implementation, 0 had a special meaning ("remove from all projects"). After the API change to []int64, this is no longer valid and is treated as a real project ID, leading to the lookup failure.

I'll fix this at the parsing layer by skipping invalid or non-positive values (including parse errors), so clearing projects results in an empty slice instead of [0]. I'll also remove the filtering logic from the handler.

image

Copy link
Copy Markdown
Contributor

@wxiaoguang wxiaoguang May 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the previous implementation, 0 had a special meaning ("remove from all projects"). After the API change to []int64, this is no longer valid and is treated as a real project ID, leading to the lookup failure.

No, 0 doesn't have such a special meaning. You can pass any non-existing ID to "remove from all projects"

The comment of IssueAssignOrRemoveProject is clear: the new (valid) IDs will be added, the assigned projects don't exist in the IDs list will be removed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll fix this at the parsing layer by skipping invalid or non-positive values (including parse errors), so clearing projects results in an empty slice instead of [0].

I don't see it needs any fix, unless there is a bug and the fix is clear.

(frontend passing "undefined" is a trivial problem, and can be fixed separately)

I'll also remove the filtering logic from the handler.

Yes, unnecessary logic should be removed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually , I didn't see any "undefined"

Are you sure you are on the correct branch and using correct frontend assets & build?

image


form := web.GetForm(ctx).(*api.MoveProjectIssueOption)

column, err := project_model.GetColumn(ctx, form.ColumnID)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need GetColumnByProjectID(ctx, projectID, columnID)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

acknowledged, will fix.


sess := loginUser(t, "user2")

t.Run("AssignAndRemove", func(t *testing.T) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why deleted?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test was removed during the rebase, will add it back

resp := MakeRequest(t, req, http.StatusOK)

var apiProject api.Project
DecodeJSON(t, resp, &apiProject)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All DecodeJSON can be refactored to one line:

v := DecodeJSON(t, resp, &T{})

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

acknowledged

@wxiaoguang wxiaoguang marked this pull request as draft May 3, 2026 17:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lgtm/need 2 This PR needs two approvals by maintainers to be considered for merging.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants