Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 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
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
24 changes: 0 additions & 24 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 @@ -351,20 +341,6 @@ func UpdateColumnSorting(ctx context.Context, cl ColumnList) 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"
)

// CountColumns 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, 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
}
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
123 changes: 123 additions & 0 deletions modules/structs/project.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package structs

import (
"time"
)

// Project represents a project
// swagger:model
type Project struct {
// Unique identifier of the project
ID int64 `json:"id"`
// Project title
Title string `json:"title"`
// Project description
Description string `json:"description"`
// Owner ID (for organization or user projects)
OwnerID int64 `json:"owner_id,omitempty"`
// Repository ID (for repository projects)
RepoID int64 `json:"repo_id,omitempty"`
// Creator ID
CreatorID int64 `json:"creator_id"`
// Whether the project is closed
IsClosed bool `json:"is_closed"`
// Template type: 0=none, 1=basic_kanban, 2=bug_triage
TemplateType int `json:"template_type"`
// Card type: 0=text_only, 1=images_and_text
CardType int `json:"card_type"`
// Project type: 1=individual, 2=repository, 3=organization
Type int `json:"type"`
// Number of open issues
NumOpenIssues int64 `json:"num_open_issues,omitempty"`
// Number of closed issues
NumClosedIssues int64 `json:"num_closed_issues,omitempty"`
// Total number of issues
NumIssues int64 `json:"num_issues,omitempty"`
// Created time
// swagger:strfmt date-time
Created time.Time `json:"created"`
// Updated time
// swagger:strfmt date-time
Updated time.Time `json:"updated"`
// Closed time
// swagger:strfmt date-time
ClosedDate *time.Time `json:"closed_date,omitempty"`
// Project URL
URL string `json:"url,omitempty"`
}

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

// EditProjectOption represents options for editing a project
// swagger:model
type EditProjectOption struct {
// Project title
Title *string `json:"title,omitempty"`
// Project description
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)
// swagger:model
type ProjectColumn struct {
// Unique identifier of the column
ID int64 `json:"id"`
// Column title
Title string `json:"title"`
// Whether this is the default column
Default bool `json:"default"`
// Sorting order
Sorting int `json:"sorting"`
// Column color (hex format)
Color string `json:"color,omitempty"`
// Project ID
ProjectID int64 `json:"project_id"`
// Creator ID
CreatorID int64 `json:"creator_id"`
// Number of issues in this column
NumIssues int64 `json:"num_issues,omitempty"`
// Created time
// swagger:strfmt date-time
Created time.Time `json:"created"`
// Updated time
// swagger:strfmt date-time
Updated time.Time `json:"updated"`
}

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

// EditProjectColumnOption represents options for editing a project column
// swagger:model
type EditProjectColumnOption struct {
// Column title
Title *string `json:"title,omitempty"`
// Column color (hex format)
Color *string `json:"color,omitempty"`
// Sorting order
Sorting *int `json:"sorting,omitempty"`
}
19 changes: 19 additions & 0 deletions routers/api/v1/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -1571,6 +1571,25 @@ 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)
})
})
}, reqRepoReader(unit.TypeProjects))
}, repoAssignment(), checkTokenPublicOnly())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue))

Expand Down
Loading