Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
10977db
feat(api): add comprehensive REST API for Project Boards
SupenBysz Dec 18, 2025
022a77f
fix(api): address review feedback on project board API
Mar 4, 2026
b8a654a
Merge branch 'main' into fix/project-board-api-review-feedback
silverwind Mar 10, 2026
c508fb6
Merge branch 'fix/project-board-api-review-feedback' of github.com:ha…
lunny Mar 18, 2026
8565da9
Fix lint
lunny Mar 19, 2026
84cb665
Merge branch 'fix/project-board-api-review-feedback' of github.com:ha…
lunny Mar 21, 2026
11b9593
improvements
lunny Mar 21, 2026
506236f
Merge branch 'main' into fix/project-board-api-review-feedback
lunny Mar 22, 2026
67c5029
Merge branch 'main' into fix/project-board-api-review-feedback
lunny Mar 23, 2026
2f27c75
Merge branch 'main' into fix/project-board-api-review-feedback
lunny Mar 25, 2026
bca683f
Apply suggestion from @silverwind
silverwind Mar 26, 2026
17cb8e9
Apply suggestion from @silverwind
silverwind Mar 26, 2026
35a4116
Apply suggestion from @silverwind
silverwind Mar 26, 2026
d39f321
Apply suggestion from @silverwind
silverwind Mar 26, 2026
c0cdc8b
Simplify project board API: use state field, extract helpers, remove …
silverwind Mar 26, 2026
e583e56
Merge branch 'fix/project-board-api-review-feedback' of github.com:ha…
lunny Apr 3, 2026
d425b43
refactor
lunny Apr 3, 2026
f08a738
Merge branch 'main' into hanism01-fix/project-board-api-review-feedback
lunny Apr 3, 2026
7512157
fix
lunny Apr 3, 2026
5c141d3
some improvements
lunny Apr 3, 2026
d2dbc96
update swagger
lunny Apr 3, 2026
631e733
Merge branch 'fix/project-board-api-review-feedback' of github.com:ha…
lunny Apr 3, 2026
f7b3810
Fix test
lunny Apr 3, 2026
0fbf248
Merge branch 'main' into hanism01-fix/project-board-api-review-feedback
lunny Apr 3, 2026
3554572
improvement
lunny Apr 3, 2026
d1cfe87
Merge branch 'main' into hanism01-fix/project-board-api-review-feedback
lunny Apr 3, 2026
3fc8d79
Merge branch 'fix/project-board-api-review-feedback' of github.com:ha…
lunny Apr 4, 2026
199162c
improvement
lunny Apr 4, 2026
c4038f1
Fix test
lunny Apr 5, 2026
4fea94c
merge tests
wxiaoguang Apr 5, 2026
7e680f1
fix AI slop
wxiaoguang Apr 5, 2026
bc56ecc
Merge remote-tracking branch 'origin/main' into fix/project-board-api…
silverwind Apr 27, 2026
7b3de22
Address remaining review feedback
silverwind Apr 27, 2026
413603c
Simplify
silverwind Apr 27, 2026
f0b6fa2
Fix review feedback bugs
silverwind Apr 27, 2026
43ca8be
Simplify v332 migration and add cross-DB test
silverwind Apr 27, 2026
9c5cad4
Tighten v332 migration cleanup pass
silverwind Apr 27, 2026
bb52c62
Drop v332 migration test
silverwind Apr 27, 2026
5c2c6e2
Document the SQLite skip in v332
silverwind Apr 27, 2026
0783004
Tighten project board API for GitHub-style consumers
silverwind Apr 27, 2026
6da8027
Fix inconsistent disabled styling on logged-out repo header buttons (…
silverwind Apr 27, 2026
cc1adec
Merge branch 'fix/project-board-api-review-feedback' of github.com:ha…
lunny Apr 27, 2026
b276435
make code simple
lunny Apr 27, 2026
49b0df0
Drop v332 migration, keep project_board.sorting as int8
silverwind Apr 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions models/issues/issue_search.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ import (
"xorm.io/xorm"
)

const ScopeSortPrefix = "scope-"
const (
ScopeSortPrefix = "scope-"
// SortTypeProjectColumnSorting orders issues within a project column by their project_issue.sorting value.
SortTypeProjectColumnSorting = "project-column-sorting"
)

// IssuesOptions represents options of an issue.
type IssuesOptions struct { //nolint:revive // export stutter
Expand Down Expand Up @@ -122,7 +126,7 @@ func applySorts(sess *xorm.Session, sortType string, priorityRepoID int64) {
"ELSE 2 END ASC", priorityRepoID).
Desc("issue.created_unix").
Desc("issue.id")
case "project-column-sorting":
case SortTypeProjectColumnSorting:
sess.Asc("project_issue.sorting").Desc("issue.created_unix").Desc("issue.id")
default:
sess.Desc("issue.created_unix").Desc("issue.id")
Expand Down
34 changes: 12 additions & 22 deletions models/project/column.go
Original file line number Diff line number Diff line change
Expand Up @@ -247,16 +247,6 @@ func UpdateColumn(ctx context.Context, column *Column) error {
return err
}

// GetColumns fetches all columns related to a project
func (p *Project) GetColumns(ctx context.Context) (ColumnList, error) {
columns := make([]*Column, 0, 5)
if err := db.GetEngine(ctx).Where("project_id=?", p.ID).OrderBy("sorting, id").Find(&columns); err != nil {
return nil, err
}

return columns, nil
}

// getDefaultColumnWithFallback return default column if one exists
// otherwise return the first column by sorting and set it as default column
func (p *Project) getDefaultColumnWithFallback(ctx context.Context) (*Column, error) {
Expand Down Expand Up @@ -337,18 +327,18 @@ func SetDefaultColumn(ctx context.Context, projectID, columnID int64) error {
})
}

func GetColumnsByIDs(ctx context.Context, projectID int64, columnsIDs []int64) (ColumnList, error) {
columns := make([]*Column, 0, 5)
if len(columnsIDs) == 0 {
return columns, nil
}
if err := db.GetEngine(ctx).
Where("project_id =?", projectID).
In("id", columnsIDs).
OrderBy("sorting").Find(&columns); err != nil {
return nil, err
}
return columns, nil
// UpdateColumnSorting update project column sorting
func UpdateColumnSorting(ctx context.Context, cl ColumnList) error {
return db.WithTx(ctx, func(ctx context.Context) error {
for i := range cl {
if _, err := db.GetEngine(ctx).ID(cl[i].ID).Cols(
"sorting",
).Update(cl[i]); err != nil {
return err
}
}
return nil
})
}

// MoveColumnsOnProject sorts columns in a project
Expand Down
42 changes: 42 additions & 0 deletions models/project/column_list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package project

import (
"context"

"code.gitea.io/gitea/models/db"
)

// CountProjectColumns returns the total number of columns for a project
func CountProjectColumns(ctx context.Context, projectID int64) (int64, error) {
return db.GetEngine(ctx).Where("project_id=?", projectID).Count(&Column{})
}

// GetProjectColumns returns a list of columns for a project with pagination
func GetProjectColumns(ctx context.Context, projectID int64, opts db.ListOptions) (ColumnList, error) {
columns := make([]*Column, 0, opts.PageSize)
s := db.GetEngine(ctx).Where("project_id=?", projectID).OrderBy("sorting, id")
if !opts.IsListAll() {
db.SetSessionPagination(s, &opts)
}
if err := s.Find(&columns); err != nil {
return nil, err
}
return columns, nil
}

func GetColumnsByIDs(ctx context.Context, projectID int64, columnsIDs []int64) (ColumnList, error) {
columns := make([]*Column, 0, len(columnsIDs))
if len(columnsIDs) == 0 {
return columns, nil
}
if err := db.GetEngine(ctx).
Where("project_id =?", projectID).
In("id", columnsIDs).
OrderBy("sorting").Find(&columns); err != nil {
return nil, err
}
return columns, nil
}
66 changes: 66 additions & 0 deletions models/project/column_list_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package project

import (
"testing"

"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest"

"github.com/stretchr/testify/assert"
)

func TestProjectColumns(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
t.Run("CountProjectColumns", testCountProjectColumns)
t.Run("GetProjectColumns", testGetProjectColumns)
t.Run("GetColumnsByIDs", testGetColumnsByIDs)
}

func testCountProjectColumns(t *testing.T) {
project, err := GetProjectByID(t.Context(), 1)
assert.NoError(t, err)

count, err := CountProjectColumns(t.Context(), project.ID)
assert.NoError(t, err)
assert.EqualValues(t, 3, count)
}

func testGetProjectColumns(t *testing.T) {
project, err := GetProjectByID(t.Context(), 1)
assert.NoError(t, err)

// Page 1, limit 2 — returns first 2 columns
page1, err := GetProjectColumns(t.Context(), project.ID, db.ListOptions{Page: 1, PageSize: 2})
assert.NoError(t, err)
assert.Len(t, page1, 2)

// Page 2, limit 2 — returns remaining column
page2, err := GetProjectColumns(t.Context(), project.ID, db.ListOptions{Page: 2, PageSize: 2})
assert.NoError(t, err)
assert.Len(t, page2, 1)

// Page 1 and page 2 together cover all columns with no overlap
allIDs := make(map[int64]bool)
for _, c := range append(page1, page2...) {
assert.False(t, allIDs[c.ID], "duplicate column ID %d across pages", c.ID)
allIDs[c.ID] = true
}
assert.Len(t, allIDs, 3)
}

func testGetColumnsByIDs(t *testing.T) {
project, err := GetProjectByID(t.Context(), 1)
assert.NoError(t, err)

columns, err := GetColumnsByIDs(t.Context(), project.ID, []int64{1, 3, 4})
assert.NoError(t, err)
assert.Len(t, columns, 2)
assert.ElementsMatch(t, []int64{1, 3}, []int64{columns[0].ID, columns[1].ID})

empty, err := GetColumnsByIDs(t.Context(), project.ID, nil)
assert.NoError(t, err)
assert.Empty(t, empty)
}
7 changes: 4 additions & 3 deletions models/project/column_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"testing"

"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -79,7 +80,7 @@ func Test_MoveColumnsOnProject(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())

project1 := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1})
columns, err := project1.GetColumns(t.Context())
columns, err := GetProjectColumns(t.Context(), project1.ID, db.ListOptionsAll)
assert.NoError(t, err)
assert.Len(t, columns, 3)
assert.EqualValues(t, 0, columns[0].Sorting) // even if there is no default sorting, the code should also work
Expand All @@ -93,7 +94,7 @@ func Test_MoveColumnsOnProject(t *testing.T) {
})
assert.NoError(t, err)

columnsAfter, err := project1.GetColumns(t.Context())
columnsAfter, err := GetProjectColumns(t.Context(), project1.ID, db.ListOptionsAll)
assert.NoError(t, err)
assert.Len(t, columnsAfter, 3)
assert.Equal(t, columns[1].ID, columnsAfter[0].ID)
Expand All @@ -105,7 +106,7 @@ func Test_NewColumn(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())

project1 := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1})
columns, err := project1.GetColumns(t.Context())
columns, err := GetProjectColumns(t.Context(), project1.ID, db.ListOptionsAll)
assert.NoError(t, err)
assert.Len(t, columns, 3)

Expand Down
8 changes: 8 additions & 0 deletions models/project/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ func deleteProjectIssuesByProjectID(ctx context.Context, projectID int64) error
return err
}

func IsIssueInColumn(ctx context.Context, issueID, projectID, columnID int64) (bool, error) {
return db.GetEngine(ctx).Exist(&ProjectIssue{
IssueID: issueID,
ProjectID: projectID,
ProjectColumnID: columnID,
})
}

// GetColumnIssueNextSorting returns the sorting value to append an issue at the end of the column.
func GetColumnIssueNextSorting(ctx context.Context, projectID, columnID int64) (int64, error) {
res := struct {
Expand Down
2 changes: 1 addition & 1 deletion modules/indexer/issues/dboptions.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp
searchOpt.SortBy = SortByDeadlineAsc
case "farduedate":
searchOpt.SortBy = SortByDeadlineDesc
case "priority", "priorityrepo", "project-column-sorting":
case "priority", "priorityrepo", issues_model.SortTypeProjectColumnSorting:
// Unsupported sort type for search
fallthrough
default:
Expand Down
107 changes: 107 additions & 0 deletions modules/structs/project.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package structs

import (
"time"
)

// Project represents a project.
//
// Gitea projects can only contain issues — note cards and pull requests are
// not modeled as project items.
//
// swagger:model
type Project struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
OwnerID int64 `json:"owner_id,omitempty"`
RepoID int64 `json:"repo_id,omitempty"`
Creator *User `json:"creator,omitempty"`
State StateType `json:"state"`
// Template type: "none", "basic_kanban" or "bug_triage"
TemplateType string `json:"template_type"`
// Card type: "text_only" or "images_and_text"
CardType string `json:"card_type"`
// Project type: "individual", "repository" or "organization"
Type string `json:"type"`
NumOpenIssues int64 `json:"num_open_issues,omitempty"`
NumClosedIssues int64 `json:"num_closed_issues,omitempty"`
NumIssues int64 `json:"num_issues,omitempty"`
// swagger:strfmt date-time
CreatedAt time.Time `json:"created_at"`
// swagger:strfmt date-time
UpdatedAt time.Time `json:"updated_at"`
// swagger:strfmt date-time
ClosedAt *time.Time `json:"closed_at,omitempty"`
HTMLURL string `json:"html_url,omitempty"`
}

// CreateProjectOption represents options for creating a project
// swagger:model
type CreateProjectOption struct {
// required: true
Title string `json:"title" binding:"Required"`
Description string `json:"description"`
// Template type: "none", "basic_kanban" or "bug_triage"
TemplateType string `json:"template_type"`
// Card type: "text_only" or "images_and_text"
CardType string `json:"card_type"`
}

// EditProjectOption represents options for editing a project
// swagger:model
type EditProjectOption struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
// Card type: "text_only" or "images_and_text"
CardType *string `json:"card_type,omitempty"`
State *StateType `json:"state,omitempty"`
}

// ProjectColumn represents a project column (board)
// swagger:model
type ProjectColumn struct {
ID int64 `json:"id"`
Title string `json:"title"`
Default bool `json:"default"`
Sorting int `json:"sorting"`
Color string `json:"color,omitempty"`
ProjectID int64 `json:"project_id"`
Creator *User `json:"creator,omitempty"`
NumIssues int64 `json:"num_issues,omitempty"`
// swagger:strfmt date-time
CreatedAt time.Time `json:"created_at"`
// swagger:strfmt date-time
UpdatedAt time.Time `json:"updated_at"`
}

// CreateProjectColumnOption represents options for creating a project column
// swagger:model
type CreateProjectColumnOption struct {
// required: true
Title string `json:"title" binding:"Required"`
// Column color in 6-digit hex format, e.g. #FF0000
Color string `json:"color,omitempty"`
}

// EditProjectColumnOption represents options for editing a project column
// swagger:model
type EditProjectColumnOption struct {
Title *string `json:"title,omitempty"`
// Column color in 6-digit hex format, e.g. #FF0000
Color *string `json:"color,omitempty"`
Sorting *int `json:"sorting,omitempty"`
}

// MoveProjectIssueOption represents options for moving an issue between columns
// swagger:model
type MoveProjectIssueOption struct {
// Target column to move the issue into
// required: true
ColumnID int64 `json:"column_id" binding:"Required"`
// Optional sorting position within the target column
Sorting *int64 `json:"sorting,omitempty"`
}
20 changes: 20 additions & 0 deletions routers/api/v1/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -1575,6 +1575,26 @@ func Routes() *web.Router {
Patch(reqToken(), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), bind(api.EditMilestoneOption{}), repo.EditMilestone).
Delete(reqToken(), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), repo.DeleteMilestone)
})
m.Group("/projects", func() {
m.Combo("").Get(repo.ListProjects).
Post(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.CreateProjectOption{}), repo.CreateProject)
m.Group("/{id}", func() {
m.Combo("").Get(repo.GetProject).
Patch(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.EditProjectOption{}), repo.EditProject).
Delete(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, repo.DeleteProject)
m.Combo("/columns").Get(repo.ListProjectColumns).
Post(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.CreateProjectColumnOption{}), repo.CreateProjectColumn)
m.Group("/columns/{column_id}", func() {
m.Combo("").
Patch(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.EditProjectColumnOption{}), repo.EditProjectColumn).
Delete(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, repo.DeleteProjectColumn)
m.Get("/issues", repo.ListProjectColumnIssues)
m.Post("/issues/{issue_id}", reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, repo.AddIssueToProjectColumn)
m.Delete("/issues/{issue_id}", reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, repo.RemoveIssueFromProjectColumn)
})
m.Post("/issues/{issue_id}/move", reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.MoveProjectIssueOption{}), repo.MoveProjectIssue)
})
}, reqRepoReader(unit.TypeProjects))
Comment thread
wxiaoguang marked this conversation as resolved.
}, repoAssignment(), checkTokenPublicOnly())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue))

Expand Down
Loading