Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
4338a2b
feat(api): add comprehensive REST API for Project Boards
SupenBysz Dec 18, 2025
f59ebbc
fix(api): address review feedback on project board API
Mar 4, 2026
a1d1274
Fix lint
lunny Mar 19, 2026
d17a2b0
improvements
lunny Mar 21, 2026
cc50750
Apply suggestion from @silverwind
silverwind Mar 26, 2026
7dfff41
Apply suggestion from @silverwind
silverwind Mar 26, 2026
e2ac418
Apply suggestion from @silverwind
silverwind Mar 26, 2026
2e2ca07
Apply suggestion from @silverwind
silverwind Mar 26, 2026
95d2a05
Simplify project board API: use state field, extract helpers, remove …
silverwind Mar 26, 2026
6827575
refactor
lunny Apr 3, 2026
478d068
fix
lunny Apr 3, 2026
5b663c4
some improvements
lunny Apr 3, 2026
815fe10
update swagger
lunny Apr 3, 2026
e0c53b3
Fix test
lunny Apr 3, 2026
3557c10
improvement
lunny Apr 3, 2026
d06fdf6
improvement
lunny Apr 4, 2026
cd23481
Fix test
lunny Apr 5, 2026
bc0412c
merge tests
wxiaoguang Apr 5, 2026
795c6bc
fix AI slop
wxiaoguang Apr 5, 2026
45832f4
Address remaining review feedback
silverwind Apr 27, 2026
438f367
Simplify
silverwind Apr 27, 2026
efe4388
Fix review feedback bugs
silverwind Apr 27, 2026
d6ae7c1
Simplify v332 migration and add cross-DB test
silverwind Apr 27, 2026
d18464f
Tighten v332 migration cleanup pass
silverwind Apr 27, 2026
6a104f4
Drop v332 migration test
silverwind Apr 27, 2026
ed0ed68
Document the SQLite skip in v332
silverwind Apr 27, 2026
8fd9536
Tighten project board API for GitHub-style consumers
silverwind Apr 27, 2026
608b271
make code simple
lunny Apr 27, 2026
63b6e71
Drop v332 migration, keep project_board.sorting as int8
silverwind Apr 27, 2026
2d37f77
fix: adapt to upstream refactor changes
beardev-in May 3, 2026
060d44e
fix: adapt to upstream refactor, fix IssueAssignOrRemoveProject signa…
beardev-in May 3, 2026
ac8466f
fix: add sorting range validation, reject out-of-range int8 values wi…
beardev-in May 3, 2026
abeda11
chore: regenerate swagger spec
beardev-in May 3, 2026
542eb39
fix: address lint errors - import grouping, slices.Contains, append s…
beardev-in May 3, 2026
555bec4
fix: fix import grouping and use slices.Contains
beardev-in May 3, 2026
5646120
fix: backend-check lint errors
beardev-in May 3, 2026
ae9fff3
fix: handle id=0 as remove-from-all-projects in UpdateIssueProject
beardev-in May 3, 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 @@ -22,7 +22,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
14 changes: 0 additions & 14 deletions models/project/column.go
Original file line number Diff line number Diff line change
Expand Up @@ -337,20 +337,6 @@ 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
}

// MoveColumnsOnProject sorts columns in a project
func MoveColumnsOnProject(ctx context.Context, project *Project, sortedColumnIDs map[int64]int64) error {
return db.WithTx(ctx, func(ctx context.Context) error {
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
24 changes: 24 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 Expand Up @@ -87,3 +95,19 @@ func DeleteAllProjectIssueByIssueIDsAndProjectIDs(ctx context.Context, issueIDs,
_, err := db.GetEngine(ctx).In("project_id", projectIDs).In("issue_id", issueIDs).Delete(&ProjectIssue{})
return err
}

// MoveIssueToColumn moves a single issue to a specific column within a project.
func MoveIssueToColumn(ctx context.Context, issueID, projectID, columnID int64) error {
nextSorting, err := GetColumnIssueNextSorting(ctx, projectID, columnID)
if err != nil {
return err
}
_, err = db.GetEngine(ctx).
Where("issue_id=? AND project_id=?", issueID, projectID).
Cols("project_board_id", "sorting").
Update(&ProjectIssue{
ProjectColumnID: columnID,
Sorting: nextSorting,
})
return err
}
2 changes: 1 addition & 1 deletion modules/indexer/issues/dboptions.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,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
110 changes: 92 additions & 18 deletions modules/structs/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,101 @@ import (
"time"
)

// Project represents a project
// 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 is the unique identifier for the project
ID int64 `json:"id"`
// Title is the title of the project
Title string `json:"title"`
// Description provides details about the project
Description string `json:"description"`
// OwnerID is the owner of the project (for org-level projects)
OwnerID int64 `json:"owner_id,omitempty"`
// RepoID is the repository this project belongs to (for repo-level projects)
RepoID int64 `json:"repo_id,omitempty"`
// CreatorID is the user who created the project
CreatorID int64 `json:"creator_id"`
// IsClosed indicates if the project is closed
IsClosed bool `json:"is_closed"`
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
Created time.Time `json:"created_at"`
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
Updated time.Time `json:"updated_at"`
CreatedAt time.Time `json:"created_at"`
// swagger:strfmt date-time
Closed *time.Time `json:"closed_at,omitempty"`
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))
}, repoAssignment(), checkTokenPublicOnly())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue))

Expand Down
Loading