feat(api): add project APIs (#38691)

Adds REST APIs for project boards for repo, org and user scopes, using
as much shared code as possible for all 3 scopes.

Fixes: https://github.com/go-gitea/gitea/issues/14299
Fixes: https://github.com/go-gitea/gitea/issues/31769
Fixes: https://github.com/go-gitea/gitea/issues/35921
Replaces: https://github.com/go-gitea/gitea/pull/37518
Replaces: https://github.com/go-gitea/gitea/pull/36008
Replaces: https://github.com/go-gitea/gitea/pull/28111
Replaces: https://github.com/go-gitea/gitea/pull/31768
Signed-off-by: silverwind <me@silverwind.io>
Co-authored-by: Supen.Huang <supen.huang@qq.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Ember <ember@mubergacres.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: beardev-in <abhinav.edulakanti@gmail.com>
This commit is contained in:
silverwind
2026-08-08 15:53:28 +02:00
committed by GitHub
parent 8163139ec0
commit 7fae3d5db3
29 changed files with 9107 additions and 732 deletions
+4 -3
View File
@@ -27,7 +27,8 @@ func (issue *Issue) LoadProjects(ctx context.Context) (err error) {
return err
}
func (issue *Issue) projectIDs(ctx context.Context) (projectIDs []int64, _ error) {
// ProjectIDs lists the IDs of the projects this issue belongs to.
func (issue *Issue) ProjectIDs(ctx context.Context) (projectIDs []int64, _ error) {
err := db.GetEngine(ctx).Table("project_issue").Where("issue_id = ?", issue.ID).Cols("project_id").Find(&projectIDs)
return projectIDs, err
}
@@ -72,7 +73,7 @@ func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_mo
return err
}
oldProjectIDs, err := issue.projectIDs(ctx)
oldProjectIDs, err := issue.ProjectIDs(ctx)
if err != nil {
return err
}
@@ -119,7 +120,7 @@ func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_mo
return err
}
newSorting, err := project_model.GetColumnIssueNextSorting(ctx, projectID, defaultColumn.ID)
newSorting, err := project_model.GetColumnIssueNextSorting(ctx, defaultColumn)
if err != nil {
return err
}
+30 -46
View File
@@ -7,6 +7,7 @@ import (
"context"
"errors"
"fmt"
"math"
"regexp"
"gitea.dev/models/db"
@@ -37,6 +38,13 @@ const (
// ColumnColorPattern is a regexp witch can validate ColumnColor
var ColumnColorPattern = regexp.MustCompile("^#[0-9a-fA-F]{6}$")
func validateColumnColor(color string) error {
if len(color) != 0 && !ColumnColorPattern.MatchString(color) {
return util.ErrorWrap(util.ErrUnprocessableContent, "invalid column color %q, expected a 6-digit hex string like #FF0000", color)
}
return nil
}
// Column is used to represent column on a project
type Column struct {
ID int64 `xorm:"pk autoincr"`
@@ -134,9 +142,10 @@ const maxProjectColumns = 20
// NewColumn adds a new project column to a given project
func NewColumn(ctx context.Context, column *Column) error {
if len(column.Color) != 0 && !ColumnColorPattern.MatchString(column.Color) {
return fmt.Errorf("bad color code: %s", column.Color)
if err := validateColumnColor(column.Color); err != nil {
return err
}
column.Title = util.EllipsisDisplayString(column.Title, 255)
res := struct {
MaxSorting int64
@@ -147,9 +156,10 @@ func NewColumn(ctx context.Context, column *Column) error {
return err
}
if res.ColumnCount >= maxProjectColumns {
return errors.New("NewBoard: maximum number of columns reached")
return util.ErrorWrap(util.ErrUnprocessableContent, "maximum number of columns reached")
}
column.Sorting = int8(util.Iif(res.ColumnCount > 0, res.MaxSorting+1, 0))
// MaxInt8+1 would wrap the appended column to the front
column.Sorting = int8(min(util.Iif(res.ColumnCount > 0, res.MaxSorting+1, 0), math.MaxInt8))
_, err := db.GetEngine(ctx).Insert(column)
return err
}
@@ -161,6 +171,10 @@ func DeleteColumnByID(ctx context.Context, columnID int64) error {
})
}
// errColumnIsDefault is returned when deleting the column new issues land in, which would
// leave the project without a landing column.
var errColumnIsDefault = util.ErrorWrap(util.ErrUnprocessableContent, "cannot delete the default column")
func deleteColumnByID(ctx context.Context, columnID int64) error {
column, err := GetColumn(ctx, columnID)
if err != nil {
@@ -172,7 +186,7 @@ func deleteColumnByID(ctx context.Context, columnID int64) error {
}
if column.Default {
return errors.New("deleteColumnByID: cannot delete default column")
return errColumnIsDefault
}
// move all issues to the default column
@@ -225,38 +239,17 @@ func GetColumnByIDAndProjectID(ctx context.Context, columnID, projectID int64) (
return column, nil
}
// UpdateColumn updates a project column
// UpdateColumn writes the column's title, sorting and color. Callers load the column
// first, so every field carries a deliberate value, including a sorting of 0.
func UpdateColumn(ctx context.Context, column *Column) error {
var fieldToUpdate []string
if column.Sorting != 0 {
fieldToUpdate = append(fieldToUpdate, "sorting")
if err := validateColumnColor(column.Color); err != nil {
return err
}
if column.Title != "" {
fieldToUpdate = append(fieldToUpdate, "title")
}
if len(column.Color) != 0 && !ColumnColorPattern.MatchString(column.Color) {
return fmt.Errorf("bad color code: %s", column.Color)
}
fieldToUpdate = append(fieldToUpdate, "color")
_, err := db.GetEngine(ctx).ID(column.ID).Cols(fieldToUpdate...).Update(column)
column.Title = util.EllipsisDisplayString(column.Title, 255)
_, err := db.GetEngine(ctx).ID(column.ID).Cols("title", "sorting", "color").Update(column)
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) {
@@ -337,22 +330,13 @@ 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 {
for sorting := range sortedColumnIDs {
if sorting < math.MinInt8 || sorting > math.MaxInt8 {
return util.ErrorWrap(util.ErrUnprocessableContent, "column sorting %d is out of range", sorting)
}
}
return db.WithTx(ctx, func(ctx context.Context) error {
sess := db.GetEngine(ctx)
columnIDs := util.ValuesOfMap(sortedColumnIDs)
+42
View File
@@ -0,0 +1,42 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
"context"
"gitea.dev/models/db"
)
// CountColumns returns the total number of columns for a project
func CountColumns(ctx context.Context, projectID int64) (int64, error) {
return db.GetEngine(ctx).Where("project_id=?", projectID).Count(&Column{})
}
// GetColumns returns a list of columns for a project with pagination
func GetColumns(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
}
+40
View File
@@ -0,0 +1,40 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
"testing"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
"github.com/stretchr/testify/assert"
)
func TestGetColumnsPaginated(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
const projectID = 1
count, err := CountColumns(t.Context(), projectID)
assert.NoError(t, err)
assert.EqualValues(t, 3, count)
// Page 1, limit 2 — returns first 2 columns
page1, err := GetColumns(t.Context(), projectID, db.ListOptions{Page: 1, PageSize: 2})
assert.NoError(t, err)
assert.Len(t, page1, 2)
// Page 2, limit 2 — returns remaining column
page2, err := GetColumns(t.Context(), projectID, 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)
}
+33 -3
View File
@@ -5,9 +5,12 @@ package project
import (
"fmt"
"math"
"testing"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
"gitea.dev/modules/util"
"github.com/stretchr/testify/assert"
)
@@ -79,7 +82,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 := GetColumns(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
@@ -93,19 +96,22 @@ func Test_MoveColumnsOnProject(t *testing.T) {
})
assert.NoError(t, err)
columnsAfter, err := project1.GetColumns(t.Context())
columnsAfter, err := GetColumns(t.Context(), project1.ID, db.ListOptionsAll)
assert.NoError(t, err)
assert.Len(t, columnsAfter, 3)
assert.Equal(t, columns[1].ID, columnsAfter[0].ID)
assert.Equal(t, columns[2].ID, columnsAfter[1].ID)
assert.Equal(t, columns[0].ID, columnsAfter[2].ID)
err = MoveColumnsOnProject(t.Context(), project1, map[int64]int64{200: columns[0].ID})
assert.ErrorIs(t, err, util.ErrUnprocessableContent) // int8 column, 200 would wrap
}
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 := GetColumns(t.Context(), project1.ID, db.ListOptionsAll)
assert.NoError(t, err)
assert.Len(t, columns, 3)
@@ -123,3 +129,27 @@ func Test_NewColumn(t *testing.T) {
assert.Error(t, err)
assert.Contains(t, err.Error(), "maximum number of columns reached")
}
func Test_ColumnSorting(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
t.Run("appending an issue counts the legacy rows the default column renders", func(t *testing.T) {
_, err := db.Exec(t.Context(), "UPDATE `project_issue` SET sorting=9 WHERE project_id=1 AND project_board_id=0")
assert.NoError(t, err)
defaultColumn, err := GetColumn(t.Context(), 1)
assert.NoError(t, err)
next, err := GetColumnIssueNextSorting(t.Context(), defaultColumn)
assert.NoError(t, err)
assert.EqualValues(t, 10, next)
})
t.Run("appending a column at the int8 maximum does not wrap to the front", func(t *testing.T) {
_, err := db.Exec(t.Context(), "UPDATE `project_board` SET sorting=? WHERE id=3", math.MaxInt8)
assert.NoError(t, err)
appended := &Column{Title: "appended", ProjectID: 1}
assert.NoError(t, NewColumn(t.Context(), appended))
assert.EqualValues(t, math.MaxInt8, appended.Sorting)
})
}
+33 -5
View File
@@ -17,7 +17,7 @@ type ProjectIssue struct { //revive:disable-line:exported
IssueID int64 `xorm:"INDEX"`
ProjectID int64 `xorm:"INDEX"`
// ProjectColumnID should not be zero since 1.22. If it's zero, the issue will not be displayed on UI and it might result in errors.
// ProjectColumnID should not be zero since 1.22. Legacy zero rows render in the default column.
ProjectColumnID int64 `xorm:"'project_board_id' INDEX"`
// the sorting order on the column
@@ -33,16 +33,44 @@ func deleteProjectIssuesByProjectID(ctx context.Context, projectID int64) error
return err
}
// columnIssueIDs lists the project_board_id values a column claims. Rows written before
// 1.22 carry 0, which the board renders in the default column, so the default column has
// to claim them too.
func columnIssueIDs(column *Column) []int64 {
if column.Default {
return []int64{column.ID, 0}
}
return []int64{column.ID}
}
// IsIssueInColumn reports whether the issue is placed in the column.
func IsIssueInColumn(ctx context.Context, issueID int64, column *Column) (bool, error) {
return db.GetEngine(ctx).
Where("issue_id=?", issueID).
And("project_id=?", column.ProjectID).
In("project_board_id", columnIssueIDs(column)).
Exist(new(ProjectIssue))
}
// GetColumnIssueIDs returns the IDs of the issues placed in a column.
func GetColumnIssueIDs(ctx context.Context, column *Column) ([]int64, error) {
issueIDs := make([]int64, 0, 10)
return issueIDs, db.GetEngine(ctx).Table("project_issue").
Where("project_id=?", column.ProjectID).
In("project_board_id", columnIssueIDs(column)).
Cols("issue_id").Find(&issueIDs)
}
// 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) {
func GetColumnIssueNextSorting(ctx context.Context, column *Column) (int64, error) {
res := struct {
MaxSorting int64
IssueCount int64
}{}
if _, err := db.GetEngine(ctx).Select("max(sorting) AS max_sorting, count(*) AS issue_count").
Table("project_issue").
Where("project_id=?", projectID).
And("project_board_id=?", columnID).
Where("project_id=?", column.ProjectID).
In("project_board_id", columnIssueIDs(column)).
Get(&res); err != nil {
return 0, err
}
@@ -66,7 +94,7 @@ func moveIssuesToAnotherColumn(ctx context.Context, oldColumn, newColumn *Column
return nil
}
nextSorting, err := GetColumnIssueNextSorting(ctx, newColumn.ProjectID, newColumn.ID)
nextSorting, err := GetColumnIssueNextSorting(ctx, newColumn)
if err != nil {
return err
}
+104 -16
View File
@@ -7,27 +7,115 @@ import (
"time"
)
// Project represents a project
// Project represents a project.
//
// Projects track issues and pull requests, standalone note cards are not supported.
//
// 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
ID int64 `json:"id"`
Title string `json:"title"`
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
OwnerID int64 `json:"owner_id"`
RepoID int64 `json:"repo_id"`
Creator *User `json:"creator,omitempty"`
// Deprecated: use Creator instead
CreatorID int64 `json:"creator_id"`
State StateType `json:"state"`
// Deprecated: use State instead
IsClosed bool `json:"is_closed"`
// 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"`
NumClosedIssues int64 `json:"num_closed_issues"`
NumIssues int64 `json:"num_issues"`
// swagger:strfmt date-time
Created time.Time `json:"created_at"`
CreatedAt time.Time `json:"created_at"`
// null only for legacy rows that carry no update timestamp
// swagger:strfmt date-time
Updated time.Time `json:"updated_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
// swagger:strfmt date-time
Closed *time.Time `json:"closed_at,omitempty"`
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"`
// swagger:strfmt date-time
CreatedAt time.Time `json:"created_at"`
// null only for legacy rows that carry no update timestamp
// swagger:strfmt date-time
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
// 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"`
// Position of the column within the project, between -128 and 127
Sorting *int `json:"sorting,omitempty"`
}
// MoveProjectColumnsOption represents options for reordering a project's columns
// swagger:model
type MoveProjectColumnsOption struct {
// Every column ID of the project, in the desired left-to-right order
// required: true
ColumnIDs []int64 `json:"column_ids" binding:"Required"`
}
// 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"`
// Position within the column, ascending. Omit to append. Negative values sort above
// the rest, equal values are ordered newest first.
Sorting *int64 `json:"sorting,omitempty"`
}
+73
View File
@@ -88,6 +88,7 @@ import (
"gitea.dev/routers/api/v1/packages"
"gitea.dev/routers/api/v1/repo"
"gitea.dev/routers/api/v1/settings"
"gitea.dev/routers/api/v1/shared"
"gitea.dev/routers/api/v1/token"
"gitea.dev/routers/api/v1/user"
"gitea.dev/routers/common"
@@ -799,6 +800,67 @@ func mustEnableWiki(ctx *context.APIContext) {
}
}
// reqProjectsUnitAccess mirrors the web's reqUnitAccess for the Projects unit. Org
// visibility is too permissive for reads, org ownership too strict for writes.
func reqProjectsUnitAccess(accessMode perm.AccessMode) func(ctx *context.APIContext) {
return func(ctx *context.APIContext) {
// "/users/{username}/projects" also accepts an organization, where checkTokenPublicOnly
// does nothing because IsTokenAccessAllowed is false for orgs. Enforce it here, before
// the admin bypass, so both spellings of the route answer alike.
if ctx.PublicOnly && ctx.ContextUser.IsOrganization() && !ctx.ContextUser.Visibility.IsPublic() {
ctx.APIError(http.StatusForbidden, "token scope is limited to public orgs")
return
}
if ctx.IsUserSiteAdmin() {
return
}
// individual visibility is handled by individualPermsChecker
if ctx.ContextUser.IsOrganization() &&
organization.OrgFromUser(ctx.ContextUser).UnitPermission(ctx, ctx.Doer, unit.TypeProjects) < accessMode {
ctx.APIErrorNotFound()
}
}
}
// addProjectRoutes registers a scope's project tree, "writeChecks" guard every mutation.
func addProjectRoutes(m *web.Router, writeChecks ...any) {
m.Get("", shared.ListProjects)
m.Group("/{id}", func() {
m.Get("", shared.GetProject)
m.Get("/columns", shared.ListProjectColumns)
m.Group("/columns/{column_id}", func() {
m.Get("", shared.GetProjectColumn)
m.Get("/issues", shared.ListProjectColumnIssues)
})
})
m.Group("", func() {
m.Post("", bind(api.CreateProjectOption{}), shared.CreateProject)
m.Group("/{id}", func() {
m.Patch("", bind(api.EditProjectOption{}), shared.EditProject)
m.Delete("", shared.DeleteProject)
m.Post("/columns", bind(api.CreateProjectColumnOption{}), shared.CreateProjectColumn)
m.Post("/columns/move", bind(api.MoveProjectColumnsOption{}), shared.MoveProjectColumns)
m.Group("/columns/{column_id}", func() {
m.Patch("", bind(api.EditProjectColumnOption{}), shared.EditProjectColumn)
m.Delete("", shared.DeleteProjectColumn)
m.Post("/default", shared.SetDefaultProjectColumn)
m.Post("/issues/{issue_id}", shared.AddIssueToProjectColumn)
m.Delete("/issues/{issue_id}", shared.RemoveIssueFromProjectColumn)
})
m.Post("/issues/{issue_id}/move", bind(api.MoveProjectIssueOption{}), shared.MoveProjectIssue)
})
}, writeChecks...)
}
// mustEnableRepoProjects mirrors repo.MustEnableRepoProjects: the Projects unit can be
// readable while repo-level boards are disallowed, and the web UI then hides them entirely.
func mustEnableRepoProjects(ctx *context.APIContext) {
projectsUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeProjects)
if !projectsUnit.ProjectsConfig().IsProjectsAllowed(repo_model.ProjectsModeRepo) {
ctx.APIErrorNotFound()
}
}
// FIXME: for consistency, maybe most mustNotBeArchived checks should be replaced with mustEnableEditor
func mustNotBeArchived(ctx *context.APIContext) {
if ctx.Repo.Repository.IsArchived {
@@ -1077,6 +1139,8 @@ func Routes() *web.Router {
}
m.Get("/repos", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), reqExploreSignIn(), user.ListUserRepos)
m.Get("/projects", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue), reqExploreSignIn(),
reqProjectsUnitAccess(perm.AccessModeRead), shared.ListProjects)
m.Group("/tokens", func() {
m.Combo("").Get(user.ListAccessTokens).
Post(bind(api.CreateAccessTokenOption{}), reqToken(), user.CreateAccessToken)
@@ -1112,6 +1176,9 @@ func Routes() *web.Router {
m.Get("", user.GetUserSettings)
m.Patch("", bind(api.UserSettingsOptions{}), user.UpdateUserSettings)
}, rejectPublicOnly())
m.Group("/projects", func() {
addProjectRoutes(m, reqToken())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue))
// Email addresses are always private account data.
m.Combo("/emails", rejectPublicOnly()).
Get(user.ListEmails).
@@ -1690,6 +1757,9 @@ 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() {
addProjectRoutes(m, reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived)
}, reqRepoReader(unit.TypeProjects), mustEnableRepoProjects)
}, repoAssignment(), checkTokenPublicOnly())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue))
@@ -1753,6 +1823,9 @@ func Routes() *web.Router {
m.Post("", reqOrgOwnership(), bind(api.CreateTeamOption{}), org.CreateTeam)
m.Get("/search", org.SearchTeam)
}, reqToken(), reqOrgMembership())
m.Group("/projects", func() {
addProjectRoutes(m, reqToken(), reqProjectsUnitAccess(perm.AccessModeWrite))
}, reqProjectsUnitAccess(perm.AccessModeRead), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue))
m.Group("/labels", func() {
m.Get("", org.ListLabels)
m.Post("", reqToken(), reqOrgOwnership(), bind(api.CreateLabelOption{}), org.CreateLabel)
File diff suppressed because it is too large Load Diff
+16
View File
@@ -237,6 +237,22 @@ type swaggerParameterBodies struct {
// in:body
LockIssueOption api.LockIssueOption
// in:body
CreateProjectOption api.CreateProjectOption
// in:body
EditProjectOption api.EditProjectOption
// in:body
CreateProjectColumnOption api.CreateProjectColumnOption
// in:body
EditProjectColumnOption api.EditProjectColumnOption
// in:body
MoveProjectColumnsOption api.MoveProjectColumnsOption
// in:body
MoveProjectIssueOption api.MoveProjectIssueOption
// in:body
MergeUpstreamRequest api.MergeUpstreamRequest
}
+36
View File
@@ -0,0 +1,36 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package swagger
import (
api "gitea.dev/modules/structs"
)
// Project
// swagger:response Project
type swaggerResponseProject struct {
// in:body
Body api.Project `json:"body"`
}
// ProjectList
// swagger:response ProjectList
type swaggerResponseProjectList struct {
// in:body
Body []api.Project `json:"body"`
}
// ProjectColumn
// swagger:response ProjectColumn
type swaggerResponseProjectColumn struct {
// in:body
Body api.ProjectColumn `json:"body"`
}
// ProjectColumnList
// swagger:response ProjectColumnList
type swaggerResponseProjectColumnList struct {
// in:body
Body []api.ProjectColumn `json:"body"`
}
+2 -190
View File
@@ -4,7 +4,6 @@
package org
import (
"errors"
"fmt"
"net/http"
"strings"
@@ -14,7 +13,6 @@ import (
project_model "gitea.dev/models/project"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/modules/json"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
@@ -309,9 +307,9 @@ func ViewProject(ctx *context.Context) {
return
}
columns, err := project.GetColumns(ctx)
columns, err := project_model.GetColumns(ctx, project.ID, db.ListOptionsAll)
if err != nil {
ctx.ServerError("GetProjectColumns", err)
ctx.ServerError("GetColumns", err)
return
}
@@ -474,189 +472,3 @@ func ViewProject(ctx *context.Context) {
ctx.HTML(http.StatusOK, tplProjectsView)
}
// DeleteProjectColumn allows for the deletion of a project column
func DeleteProjectColumn(ctx *context.Context) {
if ctx.Doer == nil {
ctx.JSON(http.StatusForbidden, map[string]string{
"message": "Only signed in users are allowed to perform this action.",
})
return
}
project, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
if err != nil {
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
return
}
_, err = project_model.GetColumnByIDAndProjectID(ctx, ctx.PathParamInt64("columnID"), project.ID)
if err != nil {
ctx.NotFoundOrServerError("GetColumnByIDAndProjectID", project_model.IsErrProjectColumnNotExist, err)
return
}
if err := project_model.DeleteColumnByID(ctx, ctx.PathParamInt64("columnID")); err != nil {
ctx.ServerError("DeleteProjectColumnByID", err)
return
}
ctx.JSONOK()
}
// AddColumnToProjectPost allows a new column to be added to a project.
func AddColumnToProjectPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.EditProjectColumnForm)
project, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
if err != nil {
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
return
}
if err := project_model.NewColumn(ctx, &project_model.Column{
ProjectID: project.ID,
Title: form.Title,
Color: form.Color,
CreatorID: ctx.Doer.ID,
}); err != nil {
ctx.ServerError("NewProjectColumn", err)
return
}
ctx.JSONOK()
}
// CheckProjectColumnChangePermissions check permission
func CheckProjectColumnChangePermissions(ctx *context.Context) (*project_model.Project, *project_model.Column) {
if ctx.Doer == nil {
ctx.JSON(http.StatusForbidden, map[string]string{
"message": "Only signed in users are allowed to perform this action.",
})
return nil, nil
}
project, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
if err != nil {
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
return nil, nil
}
column, err := project_model.GetColumnByIDAndProjectID(ctx, ctx.PathParamInt64("columnID"), project.ID)
if err != nil {
ctx.NotFoundOrServerError("GetColumnByIDAndProjectID", project_model.IsErrProjectColumnNotExist, err)
return nil, nil
}
return project, column
}
// EditProjectColumn allows a project column's to be updated
func EditProjectColumn(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.EditProjectColumnForm)
_, column := CheckProjectColumnChangePermissions(ctx)
if ctx.Written() {
return
}
if form.Title != "" {
column.Title = form.Title
}
column.Color = form.Color
if form.Sorting != 0 {
column.Sorting = form.Sorting
}
if err := project_model.UpdateColumn(ctx, column); err != nil {
ctx.ServerError("UpdateProjectColumn", err)
return
}
ctx.JSONOK()
}
// SetDefaultProjectColumn set default column for uncategorized issues/pulls
func SetDefaultProjectColumn(ctx *context.Context) {
project, column := CheckProjectColumnChangePermissions(ctx)
if ctx.Written() {
return
}
if err := project_model.SetDefaultColumn(ctx, project.ID, column.ID); err != nil {
ctx.ServerError("SetDefaultColumn", err)
return
}
ctx.JSONOK()
}
// MoveIssues moves or keeps issues in a column and sorts them inside that column
func MoveIssues(ctx *context.Context) {
if ctx.Doer == nil {
ctx.JSON(http.StatusForbidden, map[string]string{
"message": "Only signed in users are allowed to perform this action.",
})
return
}
project, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
if err != nil {
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
return
}
column, err := project_model.GetColumnByIDAndProjectID(ctx, ctx.PathParamInt64("columnID"), project.ID)
if err != nil {
ctx.NotFoundOrServerError("GetColumnByIDAndProjectID", project_model.IsErrProjectColumnNotExist, err)
return
}
type movedIssuesForm struct {
Issues []struct {
IssueID int64 `json:"issueID"`
Sorting int64 `json:"sorting"`
} `json:"issues"`
}
form := &movedIssuesForm{}
if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
ctx.ServerError("DecodeMovedIssuesForm", err)
return
}
issueIDs := make([]int64, 0, len(form.Issues))
sortedIssueIDs := make(map[int64]int64)
for _, issue := range form.Issues {
issueIDs = append(issueIDs, issue.IssueID)
sortedIssueIDs[issue.Sorting] = issue.IssueID
}
movedIssues, err := issues_model.GetIssuesByIDs(ctx, issueIDs)
if err != nil {
ctx.NotFoundOrServerError("GetIssueByID", issues_model.IsErrIssueNotExist, err)
return
}
if len(movedIssues) != len(form.Issues) {
ctx.ServerError("some issues do not exist", errors.New("some issues do not exist"))
return
}
if _, err = movedIssues.LoadRepositories(ctx); err != nil {
ctx.ServerError("LoadRepositories", err)
return
}
for _, issue := range movedIssues {
if issue.RepoID != project.RepoID && issue.Repo.OwnerID != project.OwnerID {
ctx.ServerError("Some issue's repoID is not equal to project's repoID", errors.New("Some issue's repoID is not equal to project's repoID"))
return
}
}
if err = project_service.MoveIssuesOnProjectColumn(ctx, ctx.Doer, column, sortedIssueIDs); err != nil {
ctx.ServerError("MoveIssuesOnProjectColumn", err)
return
}
ctx.JSONOK()
}
-29
View File
@@ -8,28 +8,12 @@ import (
"testing"
"gitea.dev/models/unittest"
"gitea.dev/modules/web"
"gitea.dev/routers/web/org"
"gitea.dev/services/contexttest"
"gitea.dev/services/forms"
"github.com/stretchr/testify/assert"
)
func TestCheckProjectColumnChangePermissions(t *testing.T) {
unittest.PrepareTestEnv(t)
ctx, _ := contexttest.MockContext(t, "user2/-/projects/4/4")
contexttest.LoadUser(t, ctx, 2)
ctx.ContextUser = ctx.Doer // user2
ctx.SetPathParam("id", "4")
ctx.SetPathParam("columnID", "4")
project, column := org.CheckProjectColumnChangePermissions(ctx)
assert.NotNil(t, project)
assert.NotNil(t, column)
assert.False(t, ctx.Written())
}
func TestChangeProjectStatusRejectsForeignProjects(t *testing.T) {
unittest.PrepareTestEnv(t)
// project 4 is owned by user2 not user1
@@ -43,16 +27,3 @@ func TestChangeProjectStatusRejectsForeignProjects(t *testing.T) {
assert.Equal(t, http.StatusNotFound, ctx.Resp.WrittenStatus())
}
func TestAddColumnToProjectPostRejectsForeignProjects(t *testing.T) {
unittest.PrepareTestEnv(t)
ctx, _ := contexttest.MockContext(t, "user1/-/projects/4/columns/new")
contexttest.LoadUser(t, ctx, 1)
ctx.ContextUser = ctx.Doer
ctx.SetPathParam("id", "4")
web.SetForm(ctx, &forms.EditProjectColumnForm{Title: "foreign"})
org.AddColumnToProjectPost(ctx)
assert.Equal(t, http.StatusNotFound, ctx.Resp.WrittenStatus())
}
+2 -2
View File
@@ -187,9 +187,9 @@ func (d *IssuePageMetaData) retrieveProjectCardsForExistingIssue(ctx *context.Co
// Build project cards for each project
d.ProjectsData.ProjectCards = make([]*issueSidebarProjectCardData, 0, len(d.Issue.Projects))
for _, project := range d.Issue.Projects {
columns, err := project.GetColumns(ctx)
columns, err := project_model.GetColumns(ctx, project.ID, db.ListOptionsAll)
if err != nil {
ctx.ServerError("GetProjectColumns", err)
ctx.ServerError("GetColumns", err)
return
}
+6 -289
View File
@@ -5,18 +5,16 @@ package repo
import (
"errors"
"fmt"
"net/http"
"slices"
"strings"
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/perm"
project_model "gitea.dev/models/project"
"gitea.dev/models/renderhelper"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
"gitea.dev/modules/markup/markdown"
"gitea.dev/modules/optional"
@@ -293,9 +291,9 @@ func ViewProject(ctx *context.Context) {
return
}
columns, err := project.GetColumns(ctx)
columns, err := project_model.GetColumns(ctx, project.ID, db.ListOptionsAll)
if err != nil {
ctx.ServerError("GetProjectColumns", err)
ctx.ServerError("GetColumns", err)
return
}
@@ -486,295 +484,14 @@ func UpdateIssueProjectColumn(ctx *context.Context) {
return
}
issueProjects := issue.Projects
// it must make sure the requested column is in this issue's projects
var columnProject *project_model.Project
for _, project := range issueProjects {
if column.ProjectID == project.ID {
columnProject = project
break
}
}
if columnProject == nil {
if !slices.ContainsFunc(issue.Projects, func(p *project_model.Project) bool { return p.ID == column.ProjectID }) {
ctx.NotFound(nil)
return
}
// append to the end of the target column so we don't collide with existing sorting values
newSorting, err := project_model.GetColumnIssueNextSorting(ctx, columnProject.ID, column.ID)
if err != nil {
ctx.ServerError("GetColumnIssueNextSorting", err)
return
}
if err := project_service.MoveIssuesOnProjectColumn(ctx, ctx.Doer, column, map[int64]int64{newSorting: issue.ID}); err != nil {
ctx.ServerError("MoveIssuesOnProjectColumn", err)
return
}
ctx.JSONOK()
}
// DeleteProjectColumn allows for the deletion of a project column
func DeleteProjectColumn(ctx *context.Context) {
if ctx.Doer == nil {
ctx.JSON(http.StatusForbidden, map[string]string{
"message": "Only signed in users are allowed to perform this action.",
})
return
}
if !ctx.Repo.Permission.IsOwner() && !ctx.Repo.Permission.IsAdmin() && !ctx.Repo.Permission.CanAccess(perm.AccessModeWrite, unit.TypeProjects) {
ctx.JSON(http.StatusForbidden, map[string]string{
"message": "Only authorized users are allowed to perform this action.",
})
return
}
project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64("id"))
if err != nil {
if project_model.IsErrProjectNotExist(err) {
ctx.NotFound(nil)
} else {
ctx.ServerError("GetProjectByID", err)
}
return
}
pb, err := project_model.GetColumn(ctx, ctx.PathParamInt64("columnID"))
if err != nil {
ctx.ServerError("GetProjectColumn", err)
return
}
if pb.ProjectID != ctx.PathParamInt64("id") {
ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
"message": fmt.Sprintf("ProjectColumn[%d] is not in Project[%d] as expected", pb.ID, project.ID),
})
return
}
if project.RepoID != ctx.Repo.Repository.ID {
ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
"message": fmt.Sprintf("ProjectColumn[%d] is not in Repository[%d] as expected", pb.ID, ctx.Repo.Repository.ID),
})
return
}
if err := project_model.DeleteColumnByID(ctx, ctx.PathParamInt64("columnID")); err != nil {
ctx.ServerError("DeleteProjectColumnByID", err)
return
}
ctx.JSONOK()
}
// AddColumnToProjectPost allows a new column to be added to a project.
func AddColumnToProjectPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.EditProjectColumnForm)
if !ctx.Repo.Permission.IsOwner() && !ctx.Repo.Permission.IsAdmin() && !ctx.Repo.Permission.CanAccess(perm.AccessModeWrite, unit.TypeProjects) {
ctx.JSON(http.StatusForbidden, map[string]string{
"message": "Only authorized users are allowed to perform this action.",
})
return
}
project, err := project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id"))
if err != nil {
if project_model.IsErrProjectNotExist(err) {
ctx.NotFound(nil)
} else {
ctx.ServerError("GetProjectByID", err)
}
return
}
if err := project_model.NewColumn(ctx, &project_model.Column{
ProjectID: project.ID,
Title: form.Title,
Color: form.Color,
CreatorID: ctx.Doer.ID,
}); err != nil {
ctx.ServerError("NewProjectColumn", err)
return
}
ctx.JSONOK()
}
func checkProjectColumnChangePermissions(ctx *context.Context) (*project_model.Project, *project_model.Column) {
if ctx.Doer == nil {
ctx.JSON(http.StatusForbidden, map[string]string{
"message": "Only signed in users are allowed to perform this action.",
})
return nil, nil
}
if !ctx.Repo.Permission.IsOwner() && !ctx.Repo.Permission.IsAdmin() && !ctx.Repo.Permission.CanAccess(perm.AccessModeWrite, unit.TypeProjects) {
ctx.JSON(http.StatusForbidden, map[string]string{
"message": "Only authorized users are allowed to perform this action.",
})
return nil, nil
}
project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64("id"))
if err != nil {
if project_model.IsErrProjectNotExist(err) {
ctx.NotFound(nil)
} else {
ctx.ServerError("GetProjectByID", err)
}
return nil, nil
}
column, err := project_model.GetColumn(ctx, ctx.PathParamInt64("columnID"))
if err != nil {
ctx.ServerError("GetProjectColumn", err)
return nil, nil
}
if column.ProjectID != ctx.PathParamInt64("id") {
ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
"message": fmt.Sprintf("ProjectColumn[%d] is not in Project[%d] as expected", column.ID, project.ID),
})
return nil, nil
}
if project.RepoID != ctx.Repo.Repository.ID {
ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
"message": fmt.Sprintf("ProjectColumn[%d] is not in Repository[%d] as expected", column.ID, ctx.Repo.Repository.ID),
})
return nil, nil
}
return project, column
}
// EditProjectColumn allows a project column's to be updated
func EditProjectColumn(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.EditProjectColumnForm)
_, column := checkProjectColumnChangePermissions(ctx)
if ctx.Written() {
return
}
if form.Title != "" {
column.Title = form.Title
}
column.Color = form.Color
if form.Sorting != 0 {
column.Sorting = form.Sorting
}
if err := project_model.UpdateColumn(ctx, column); err != nil {
ctx.ServerError("UpdateProjectColumn", err)
return
}
ctx.JSONOK()
}
// SetDefaultProjectColumn set default column for uncategorized issues/pulls
func SetDefaultProjectColumn(ctx *context.Context) {
project, column := checkProjectColumnChangePermissions(ctx)
if ctx.Written() {
return
}
if err := project_model.SetDefaultColumn(ctx, project.ID, column.ID); err != nil {
ctx.ServerError("SetDefaultColumn", err)
return
}
ctx.JSONOK()
}
// MoveIssues moves or keeps issues in a column and sorts them inside that column
func MoveIssues(ctx *context.Context) {
if ctx.Doer == nil {
ctx.JSON(http.StatusForbidden, map[string]string{
"message": "Only signed in users are allowed to perform this action.",
})
return
}
if !ctx.Repo.Permission.IsOwner() && !ctx.Repo.Permission.IsAdmin() && !ctx.Repo.Permission.CanAccess(perm.AccessModeWrite, unit.TypeProjects) {
ctx.JSON(http.StatusForbidden, map[string]string{
"message": "Only authorized users are allowed to perform this action.",
})
return
}
project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64("id"))
if err != nil {
if project_model.IsErrProjectNotExist(err) {
ctx.NotFound(nil)
} else {
ctx.ServerError("GetProjectByID", err)
}
return
}
if project.RepoID != ctx.Repo.Repository.ID {
ctx.NotFound(nil)
return
}
column, err := project_model.GetColumn(ctx, ctx.PathParamInt64("columnID"))
if err != nil {
if project_model.IsErrProjectColumnNotExist(err) {
ctx.NotFound(nil)
} else {
ctx.ServerError("GetProjectColumn", err)
}
return
}
if column.ProjectID != project.ID {
ctx.NotFound(nil)
return
}
type movedIssuesForm struct {
Issues []struct {
IssueID int64 `json:"issueID"`
Sorting int64 `json:"sorting"`
} `json:"issues"`
}
form := &movedIssuesForm{}
if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
ctx.ServerError("DecodeMovedIssuesForm", err)
return
}
issueIDs := make([]int64, 0, len(form.Issues))
sortedIssueIDs := make(map[int64]int64)
for _, issue := range form.Issues {
issueIDs = append(issueIDs, issue.IssueID)
sortedIssueIDs[issue.Sorting] = issue.IssueID
}
movedIssues, err := issues_model.GetIssuesByIDs(ctx, issueIDs)
if err != nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.NotFound(nil)
} else {
ctx.ServerError("GetIssueByID", err)
}
return
}
if len(movedIssues) != len(form.Issues) {
ctx.ServerError("some issues do not exist", errors.New("some issues do not exist"))
return
}
for _, issue := range movedIssues {
if issue.RepoID != project.RepoID {
ctx.ServerError("Some issue's repoID is not equal to project's repoID", errors.New("Some issue's repoID is not equal to project's repoID"))
return
}
}
if err = project_service.MoveIssuesOnProjectColumn(ctx, ctx.Doer, column, sortedIssueIDs); err != nil {
ctx.ServerError("MoveIssuesOnProjectColumn", err)
if err := project_service.MoveIssueToColumn(ctx, ctx.Doer, issue, column, optional.None[int64]()); err != nil {
ctx.ServerError("MoveIssueToColumn", err)
return
}
-27
View File
@@ -1,27 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"testing"
"gitea.dev/models/unittest"
"gitea.dev/services/contexttest"
"github.com/stretchr/testify/assert"
)
func TestCheckProjectColumnChangePermissions(t *testing.T) {
unittest.PrepareTestEnv(t)
ctx, _ := contexttest.MockContext(t, "user2/repo1/projects/1/2")
contexttest.LoadUser(t, ctx, 2)
contexttest.LoadRepo(t, ctx, 1)
ctx.SetPathParam("id", "1")
ctx.SetPathParam("columnID", "2")
project, column := checkProjectColumnChangePermissions(ctx)
assert.NotNil(t, project)
assert.NotNil(t, column)
assert.False(t, ctx.Written())
}
-48
View File
@@ -1,48 +0,0 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
project_model "gitea.dev/models/project"
"gitea.dev/modules/json"
"gitea.dev/services/context"
)
// MoveColumns moves or keeps columns in a project and sorts them inside that project
func MoveColumns(ctx *context.Context) {
project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64("id"))
if err != nil {
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
return
}
if !project.CanBeAccessedByOwnerRepo(ctx.ContextUser.ID, ctx.Repo.Repository) {
ctx.NotFound(nil)
return
}
type movedColumnsForm struct {
Columns []struct {
ColumnID int64 `json:"columnID"`
Sorting int64 `json:"sorting"`
} `json:"columns"`
}
form := &movedColumnsForm{}
if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
ctx.ServerError("DecodeMovedColumnsForm", err)
return
}
sortedColumnIDs := make(map[int64]int64)
for _, column := range form.Columns {
sortedColumnIDs[column.Sorting] = column.ColumnID
}
if err = project_model.MoveColumnsOnProject(ctx, project, sortedColumnIDs); err != nil {
ctx.ServerError("MoveColumnsOnProject", err)
return
}
ctx.JSONOK()
}
+205
View File
@@ -0,0 +1,205 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
"errors"
issues_model "gitea.dev/models/issues"
project_model "gitea.dev/models/project"
"gitea.dev/modules/json"
"gitea.dev/modules/web"
"gitea.dev/services/context"
"gitea.dev/services/forms"
project_service "gitea.dev/services/projects"
)
// findProject loads the "id" path param, scoped to whichever owner the route assigned:
// anyone else's ID reads as not found. Write permission is enforced by the route.
func findProject(ctx *context.Context) *project_model.Project {
var project *project_model.Project
var err error
if ctx.Repo != nil && ctx.Repo.Repository != nil {
project, err = project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id"))
} else {
project, err = project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
}
if err != nil {
ctx.NotFoundOrServerError("GetProject", project_model.IsErrProjectNotExist, err)
return nil
}
return project
}
func findColumn(ctx *context.Context) (*project_model.Project, *project_model.Column) {
project := findProject(ctx)
if ctx.Written() {
return nil, nil
}
column, err := project_model.GetColumnByIDAndProjectID(ctx, ctx.PathParamInt64("columnID"), project.ID)
if err != nil {
ctx.NotFoundOrServerError("GetColumnByIDAndProjectID", project_model.IsErrProjectColumnNotExist, err)
return nil, nil
}
return project, column
}
func MoveColumns(ctx *context.Context) {
project := findProject(ctx)
if ctx.Written() {
return
}
type movedColumnsForm struct {
Columns []struct {
ColumnID int64 `json:"columnID"`
Sorting int64 `json:"sorting"`
} `json:"columns"`
}
form := &movedColumnsForm{}
if err := json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
ctx.ServerError("DecodeMovedColumnsForm", err)
return
}
sortedColumnIDs := make(map[int64]int64)
for _, column := range form.Columns {
sortedColumnIDs[column.Sorting] = column.ColumnID
}
if err := project_model.MoveColumnsOnProject(ctx, project, sortedColumnIDs); err != nil {
ctx.ServerError("MoveColumnsOnProject", err)
return
}
ctx.JSONOK()
}
func AddColumnToProjectPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.EditProjectColumnForm)
project := findProject(ctx)
if ctx.Written() {
return
}
if err := project_model.NewColumn(ctx, &project_model.Column{
ProjectID: project.ID,
Title: form.Title,
Color: form.Color,
CreatorID: ctx.Doer.ID,
}); err != nil {
ctx.ServerError("NewProjectColumn", err)
return
}
ctx.JSONOK()
}
func EditProjectColumn(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.EditProjectColumnForm)
_, column := findColumn(ctx)
if ctx.Written() {
return
}
if form.Title != "" {
column.Title = form.Title
}
column.Color = form.Color
if form.Sorting != 0 {
column.Sorting = form.Sorting
}
if err := project_model.UpdateColumn(ctx, column); err != nil {
ctx.ServerError("UpdateProjectColumn", err)
return
}
ctx.JSONOK()
}
func DeleteProjectColumn(ctx *context.Context) {
_, column := findColumn(ctx)
if ctx.Written() {
return
}
if err := project_model.DeleteColumnByID(ctx, column.ID); err != nil {
ctx.ServerError("DeleteProjectColumnByID", err)
return
}
ctx.JSONOK()
}
func SetDefaultProjectColumn(ctx *context.Context) {
project, column := findColumn(ctx)
if ctx.Written() {
return
}
if err := project_model.SetDefaultColumn(ctx, project.ID, column.ID); err != nil {
ctx.ServerError("SetDefaultColumn", err)
return
}
ctx.JSONOK()
}
func MoveIssues(ctx *context.Context) {
project, column := findColumn(ctx)
if ctx.Written() {
return
}
type movedIssuesForm struct {
Issues []struct {
IssueID int64 `json:"issueID"`
Sorting int64 `json:"sorting"`
} `json:"issues"`
}
form := &movedIssuesForm{}
if err := json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
ctx.ServerError("DecodeMovedIssuesForm", err)
return
}
issueIDs := make([]int64, 0, len(form.Issues))
sortedIssueIDs := make(map[int64]int64)
for _, issue := range form.Issues {
issueIDs = append(issueIDs, issue.IssueID)
sortedIssueIDs[issue.Sorting] = issue.IssueID
}
movedIssues, err := issues_model.GetIssuesByIDs(ctx, issueIDs)
if err != nil {
ctx.NotFoundOrServerError("GetIssueByID", issues_model.IsErrIssueNotExist, err)
return
}
if len(movedIssues) != len(form.Issues) {
ctx.ServerError("some issues do not exist", errors.New("some issues do not exist"))
return
}
if _, err = movedIssues.LoadRepositories(ctx); err != nil {
ctx.ServerError("LoadRepositories", err)
return
}
for _, issue := range movedIssues {
if !project.CanBeAccessedByOwnerRepo(issue.Repo.OwnerID, issue.Repo) {
ctx.ServerError("Some issue's repoID is not equal to project's repoID", errors.New("Some issue's repoID is not equal to project's repoID"))
return
}
}
if err = project_service.MoveIssuesOnProjectColumn(ctx, ctx.Doer, column, sortedIssueIDs); err != nil {
ctx.ServerError("MoveIssuesOnProjectColumn", err)
return
}
ctx.JSONOK()
}
@@ -0,0 +1,60 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
"net/http"
"testing"
"gitea.dev/models/unittest"
"gitea.dev/services/contexttest"
"github.com/stretchr/testify/assert"
)
func TestMain(m *testing.M) {
unittest.MainTest(m)
}
// TestFindColumn covers the scoping rule every board handler depends on: a request only
// resolves projects owned by the scope its route assigned, so an ID belonging to anyone
// else reads as not found rather than leaking across owners.
func TestFindColumn(t *testing.T) {
for _, tc := range []struct {
name string
projectID string
columnID string
doerID int64
repoScoped bool
resolves bool
}{
{"repository project", "1", "2", 2, true, true},
{"owner project", "4", "4", 2, false, true},
{"repository board cannot reach an owner project", "4", "1", 2, true, false},
{"owner board cannot reach another owner's project", "4", "4", 1, false, false},
} {
t.Run(tc.name, func(t *testing.T) {
unittest.PrepareTestEnv(t)
ctx, _ := contexttest.MockContext(t, "user2/-/projects")
contexttest.LoadUser(t, ctx, tc.doerID)
if tc.repoScoped {
contexttest.LoadRepo(t, ctx, 1)
} else {
ctx.ContextUser = ctx.Doer
}
ctx.SetPathParam("id", tc.projectID)
ctx.SetPathParam("columnID", tc.columnID)
project, column := findColumn(ctx)
assert.Equal(t, tc.resolves, project != nil)
assert.Equal(t, tc.resolves, column != nil)
if tc.resolves {
assert.False(t, ctx.Written())
} else {
// a foreign ID must read as not found, never as a 500
assert.Equal(t, http.StatusNotFound, ctx.Resp.WrittenStatus())
}
})
}
}
+18 -20
View File
@@ -326,6 +326,20 @@ func Routes() *web.Router {
// Such requests are not cross-origin requests, so disable CrossOriginProtection.
var optSignInFromAnyOrigin = verifyAuthWithOptions(&common.VerifyOptions{DisableCrossOriginProtection: true})
// addProjectBoardRoutes registers a board's column and card routes, shared by the
// repository and owner mount points.
func addProjectBoardRoutes(m *web.Router) {
// TODO: improper name. Others are "delete project", "edit project", but this one is "move columns"
m.Post("/move", project.MoveColumns)
m.Post("/columns/new", web.Bind(forms.EditProjectColumnForm{}), project.AddColumnToProjectPost)
m.Group("/{columnID}", func() {
m.Put("", web.Bind(forms.EditProjectColumnForm{}), project.EditProjectColumn)
m.Delete("", project.DeleteProjectColumn)
m.Post("/default", project.SetDefaultProjectColumn)
m.Post("/move", project.MoveIssues)
})
}
// registerWebRoutes register routes
func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
// required to be signed in or signed out
@@ -1115,7 +1129,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Get("", org.Projects)
m.Get("/{id}", org.ViewProject)
}, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true))
m.Group("", func() { //nolint:dupl // duplicates lines 1421-1441
m.Group("", func() {
m.Get("/new", org.RenderNewProject)
m.Post("/new", web.Bind(forms.CreateProjectForm{}), org.NewProjectPost)
m.Group("/{id}", func() {
@@ -1125,15 +1139,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Post("/edit", web.Bind(forms.CreateProjectForm{}), org.EditProjectPost)
m.Post("/{action:open|close}", org.ChangeProjectStatus)
// TODO: improper name. Others are "delete project", "edit project", but this one is "move columns"
m.Post("/move", project.MoveColumns)
m.Post("/columns/new", web.Bind(forms.EditProjectColumnForm{}), org.AddColumnToProjectPost)
m.Group("/{columnID}", func() {
m.Put("", web.Bind(forms.EditProjectColumnForm{}), org.EditProjectColumn)
m.Delete("", org.DeleteProjectColumn)
m.Post("/default", org.SetDefaultProjectColumn)
m.Post("/move", org.MoveIssues)
})
addProjectBoardRoutes(m)
})
}, reqSignIn, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), func(ctx *context.Context) {
if ctx.ContextUser.IsIndividual() && ctx.ContextUser.ID != ctx.Doer.ID {
@@ -1521,7 +1527,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Group("/{username}/{reponame}/projects", func() {
m.Get("", repo.Projects)
m.Get("/{id}", repo.ViewProject)
m.Group("", func() { //nolint:dupl // duplicates lines 1034-1054
m.Group("", func() {
m.Get("/new", repo.RenderNewProject)
m.Post("/new", web.Bind(forms.CreateProjectForm{}), repo.NewProjectPost)
m.Group("/{id}", func() {
@@ -1531,15 +1537,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Post("/edit", web.Bind(forms.CreateProjectForm{}), repo.EditProjectPost)
m.Post("/{action:open|close}", repo.ChangeProjectStatus)
// TODO: improper name. Others are "delete project", "edit project", but this one is "move columns"
m.Post("/move", project.MoveColumns)
m.Post("/columns/new", web.Bind(forms.EditProjectColumnForm{}), repo.AddColumnToProjectPost)
m.Group("/{columnID}", func() {
m.Put("", web.Bind(forms.EditProjectColumnForm{}), repo.EditProjectColumn)
m.Delete("", repo.DeleteProjectColumn)
m.Post("/default", repo.SetDefaultProjectColumn)
m.Post("/move", repo.MoveIssues)
})
addProjectBoardRoutes(m)
})
}, reqRepoProjectsWriter, context.RepoMustNotBeArchived())
}, optSignIn, context.RepoAssignment, reqRepoProjectsReader, repo.MustEnableRepoProjects)
+1 -1
View File
@@ -99,7 +99,7 @@ func toIssue(ctx context.Context, doer *user_model.User, issue *issues_model.Iss
return &api.Issue{}
}
if len(issue.Projects) > 0 {
apiIssue.Projects = ToAPIProjectList(issue.Projects)
apiIssue.Projects = ToProjectList(ctx, issue.Projects, doer)
}
if err := issue.LoadAssignees(ctx); err != nil {
+180 -20
View File
@@ -4,34 +4,194 @@
package convert
import (
"context"
"fmt"
"time"
project_model "gitea.dev/models/project"
user_model "gitea.dev/models/user"
"gitea.dev/modules/container"
"gitea.dev/modules/httplib"
"gitea.dev/modules/log"
api "gitea.dev/modules/structs"
"gitea.dev/modules/timeutil"
)
// ToAPIProject converts a Project to API format
func ToAPIProject(p *project_model.Project) *api.Project {
apiProject := &api.Project{
ID: p.ID,
Title: p.Title,
Description: p.Description,
OwnerID: p.OwnerID,
RepoID: p.RepoID,
CreatorID: p.CreatorID,
IsClosed: p.IsClosed,
Created: p.CreatedUnix.AsTime(),
Updated: p.UpdatedUnix.AsTime(),
func projectTemplateTypeToString(t project_model.TemplateType) string {
switch t {
case project_model.TemplateTypeBasicKanban:
return "basic_kanban"
case project_model.TemplateTypeBugTriage:
return "bug_triage"
default:
return "none"
}
if p.IsClosed && p.ClosedDateUnix > 0 {
apiProject.Closed = p.ClosedDateUnix.AsTimePtr()
}
return apiProject
}
// ToAPIProjectList converts a list of Projects to API format
func ToAPIProjectList(projects []*project_model.Project) []*api.Project {
func ProjectTemplateTypeFromString(s string) (project_model.TemplateType, error) {
switch s {
case "", "none":
return project_model.TemplateTypeNone, nil
case "basic_kanban":
return project_model.TemplateTypeBasicKanban, nil
case "bug_triage":
return project_model.TemplateTypeBugTriage, nil
default:
return 0, fmt.Errorf("invalid template_type %q (expected none, basic_kanban, bug_triage)", s)
}
}
func projectCardTypeToString(t project_model.CardType) string {
switch t {
case project_model.CardTypeImagesAndText:
return "images_and_text"
default:
return "text_only"
}
}
func ProjectCardTypeFromString(s string) (project_model.CardType, error) {
switch s {
case "", "text_only":
return project_model.CardTypeTextOnly, nil
case "images_and_text":
return project_model.CardTypeImagesAndText, nil
default:
return 0, fmt.Errorf("invalid card_type %q (expected text_only, images_and_text)", s)
}
}
func projectTypeToString(t project_model.Type) string {
switch t {
case project_model.TypeIndividual:
return "individual"
case project_model.TypeRepository:
return "repository"
case project_model.TypeOrganization:
return "organization"
default:
return ""
}
}
// loadProjectCreators batch-fetches the creators of the given projects and columns, keyed by
// user ID. Enrichment is best-effort: on a lookup failure, or for creators that no longer
// exist, the creator field stays nil rather than failing the whole conversion.
func loadProjectCreators(ctx context.Context, projects []*project_model.Project, columns []*project_model.Column) map[int64]*user_model.User {
idSet := container.Set[int64]{}
for _, p := range projects {
if p.CreatorID > 0 {
idSet.Add(p.CreatorID)
}
}
for _, c := range columns {
if c.CreatorID > 0 {
idSet.Add(c.CreatorID)
}
}
if len(idSet) == 0 {
return nil
}
creators, err := user_model.GetUsersMapByIDs(ctx, idSet.Values())
if err != nil {
log.Error("GetUsersMapByIDs: %v", err)
return nil
}
return creators
}
// timeStampPtr returns nil for the zero timestamp, so a missing timestamp is not
// reported to API clients as the unix epoch.
func timeStampPtr(ts timeutil.TimeStamp) *time.Time {
if ts == 0 {
return nil
}
return ts.AsTimePtr()
}
// ToProject converts a project_model.Project to api.Project.
// Caller is expected to preload p.Repo / p.Owner to avoid N+1 lookups.
func ToProject(ctx context.Context, p *project_model.Project, doer *user_model.User) *api.Project {
creators := loadProjectCreators(ctx, []*project_model.Project{p}, nil)
return toProject(ctx, p, doer, creators)
}
func toProject(ctx context.Context, p *project_model.Project, doer *user_model.User, creators map[int64]*user_model.User) *api.Project {
state, closedAt := api.StateOpen, (*time.Time)(nil)
if p.IsClosed {
// changeProjectStatus stamps ClosedDateUnix on reopen too, so it only means
// anything while the project is closed
state, closedAt = api.StateClosed, timeStampPtr(p.ClosedDateUnix)
}
project := &api.Project{
ID: p.ID,
Title: p.Title,
Description: p.Description,
OwnerID: p.OwnerID,
RepoID: p.RepoID,
CreatorID: p.CreatorID,
State: state,
IsClosed: p.IsClosed,
TemplateType: projectTemplateTypeToString(p.TemplateType),
CardType: projectCardTypeToString(p.CardType),
Type: projectTypeToString(p.Type),
NumOpenIssues: p.NumOpenIssues,
NumClosedIssues: p.NumClosedIssues,
NumIssues: p.NumIssues,
CreatedAt: p.CreatedUnix.AsTime(),
UpdatedAt: timeStampPtr(p.UpdatedUnix),
ClosedAt: closedAt,
}
if creator, ok := creators[p.CreatorID]; ok {
project.Creator = ToUser(ctx, creator, doer)
}
// the caller preloads Repo/Owner, so Link stays free of lazy lookups
if link := p.Link(ctx); link != "" {
project.HTMLURL = httplib.MakeAbsoluteURL(ctx, link)
}
return project
}
func ToProjectColumn(ctx context.Context, column *project_model.Column, doer *user_model.User) *api.ProjectColumn {
creators := loadProjectCreators(ctx, nil, []*project_model.Column{column})
return toProjectColumn(ctx, column, doer, creators)
}
func toProjectColumn(ctx context.Context, column *project_model.Column, doer *user_model.User, creators map[int64]*user_model.User) *api.ProjectColumn {
apiColumn := &api.ProjectColumn{
ID: column.ID,
Title: column.Title,
Default: column.Default,
Sorting: int(column.Sorting),
Color: column.Color,
ProjectID: column.ProjectID,
CreatedAt: column.CreatedUnix.AsTime(),
UpdatedAt: timeStampPtr(column.UpdatedUnix),
}
if creator, ok := creators[column.CreatorID]; ok {
apiColumn.Creator = ToUser(ctx, creator, doer)
}
return apiColumn
}
func ToProjectList(ctx context.Context, projects []*project_model.Project, doer *user_model.User) []*api.Project {
creators := loadProjectCreators(ctx, projects, nil)
result := make([]*api.Project, len(projects))
for i := range projects {
result[i] = ToAPIProject(projects[i])
for i, p := range projects {
result[i] = toProject(ctx, p, doer, creators)
}
return result
}
func ToProjectColumnList(ctx context.Context, columns []*project_model.Column, doer *user_model.User) []*api.ProjectColumn {
creators := loadProjectCreators(ctx, nil, columns)
result := make([]*api.ProjectColumn, len(columns))
for i, column := range columns {
result[i] = toProjectColumn(ctx, column, doer, creators)
}
return result
}
+62 -8
View File
@@ -5,7 +5,6 @@ package project
import (
"context"
"errors"
"slices"
"strings"
@@ -14,10 +13,69 @@ import (
project_model "gitea.dev/models/project"
user_model "gitea.dev/models/user"
"gitea.dev/modules/optional"
"gitea.dev/modules/util"
"xorm.io/builder"
)
// ErrIssueNotInProject unwraps as ErrUnprocessableContent, not ErrNotExist: ctx.ServerError
// diverts ErrNotExist to a 404, which would hide this from the web caller's logs.
var ErrIssueNotInProject = util.ErrorWrap(util.ErrUnprocessableContent, "all issues have to be added to a project first")
// AddIssueToColumn assigns the issue to the column's project if needed, then places it in
// the column. One transaction, so a failure cannot strand it in the default column.
func AddIssueToColumn(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, column *project_model.Column) error {
return db.WithTx(ctx, func(ctx context.Context) error {
projectIDs, err := issue.ProjectIDs(ctx)
if err != nil {
return err
}
if !slices.Contains(projectIDs, column.ProjectID) {
// lands in the default column, the move below puts it in the requested one
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, doer, append(projectIDs, column.ProjectID)); err != nil {
return err
}
}
return MoveIssueToColumn(ctx, doer, issue, column, optional.None[int64]())
})
}
// MoveIssueToColumn places an issue already in the project into a column, appending it
// when sorting is absent.
func MoveIssueToColumn(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, column *project_model.Column, sorting optional.Option[int64]) error {
return db.WithTx(ctx, func(ctx context.Context) error {
position := sorting.Value()
if !sorting.Has() {
next, err := project_model.GetColumnIssueNextSorting(ctx, column)
if err != nil {
return err
}
position = next
}
return MoveIssuesOnProjectColumn(ctx, doer, column, map[int64]int64{position: issue.ID})
})
}
// RemoveIssueFromColumn detaches the issue from the column's project, reporting a
// not-exist error when it is not in that column.
func RemoveIssueFromColumn(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, column *project_model.Column) error {
return db.WithTx(ctx, func(ctx context.Context) error {
exists, err := project_model.IsIssueInColumn(ctx, issue.ID, column)
if err != nil {
return err
}
if !exists {
return util.NewNotExistErrorf("issue %d is not in column %d", issue.ID, column.ID)
}
projectIDs, err := issue.ProjectIDs(ctx)
if err != nil {
return err
}
remaining := util.SliceRemoveAll(projectIDs, column.ProjectID)
return issues_model.IssueAssignOrRemoveProject(ctx, issue, doer, remaining)
})
}
// MoveIssuesOnProjectColumn moves or keeps issues in a column and sorts them inside that column
func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, column *project_model.Column, sortedIssueIDs map[int64]int64) error {
return db.WithTx(ctx, func(ctx context.Context) error {
@@ -33,7 +91,7 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum
return err
}
if int(count) != len(sortedIssueIDs) {
return errors.New("all issues have to be added to a project first")
return ErrIssueNotInProject
}
issues, err := issues_model.GetIssuesByIDs(ctx, issueIDs)
@@ -87,12 +145,8 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum
// IMPORTANT: The WHERE clause must include both issue_id AND project_id to ensure
// that moving an issue's column in one project doesn't affect its column in other
// projects when the issue is assigned to multiple projects.
_, err = db.GetEngine(ctx).Table("project_issue").
Where("issue_id = ? AND project_id = ?", issueID, column.ProjectID).
Update(map[string]any{
"project_board_id": column.ID,
"sorting": sorting,
})
_, err = db.Exec(ctx, "UPDATE `project_issue` SET project_board_id=?, sorting=? WHERE issue_id=? AND project_id=?",
column.ID, sorting, issueID, column.ProjectID)
if err != nil {
return err
}
+39
View File
@@ -4,6 +4,7 @@
package project
import (
"strconv"
"testing"
"gitea.dev/models/db"
@@ -169,6 +170,44 @@ func Test_Projects(t *testing.T) {
})
})
t.Run("Moving an issue in one project keeps its column in other projects", func(t *testing.T) {
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
// issue 11 is in repo1 but in no fixture project, so reconciling its memberships disturbs nothing
issue11 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 11})
projects := make([]*project_model.Project, 2)
for i := range projects {
projects[i] = &project_model.Project{
Title: "multi-project isolation " + strconv.Itoa(i),
RepoID: repo1.ID,
Type: project_model.TypeRepository,
TemplateType: project_model.TemplateTypeBasicKanban,
}
assert.NoError(t, project_model.NewProject(t.Context(), projects[i]))
defer func() {
assert.NoError(t, project_model.DeleteProjectByID(t.Context(), projects[i].ID))
}()
}
assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue11, user2, []int64{projects[0].ID, projects[1].ID}))
// the column the issue must stay in for the second project
otherColumn, err := projects[1].MustDefaultColumn(t.Context())
assert.NoError(t, err)
// move the issue into a non-default column of the first project only
targetColumn := &project_model.Column{Title: "target", ProjectID: projects[0].ID}
assert.NoError(t, project_model.NewColumn(t.Context(), targetColumn))
assert.NoError(t, MoveIssuesOnProjectColumn(t.Context(), user2, targetColumn, map[int64]int64{0: issue11.ID}))
unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{
IssueID: issue11.ID, ProjectID: projects[0].ID, ProjectColumnID: targetColumn.ID,
})
unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{
IssueID: issue11.ID, ProjectID: projects[1].ID, ProjectColumnID: otherColumn.ID,
})
})
t.Run("Repository projects", func(t *testing.T) {
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+38
View File
@@ -0,0 +1,38 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
"context"
"gitea.dev/models/db"
project_model "gitea.dev/models/project"
"gitea.dev/modules/optional"
)
// UpdateProjectOptions represents updatable project fields. Fields with no value are left unchanged.
type UpdateProjectOptions struct {
Title optional.Option[string]
Description optional.Option[string]
CardType optional.Option[project_model.CardType]
IsClosed optional.Option[bool]
}
// UpdateProject applies the provided options to the project atomically.
func UpdateProject(ctx context.Context, project *project_model.Project, opts UpdateProjectOptions) error {
return db.WithTx(ctx, func(ctx context.Context) error {
project.Title = opts.Title.ValueOrDefault(project.Title)
project.Description = opts.Description.ValueOrDefault(project.Description)
project.CardType = opts.CardType.ValueOrDefault(project.CardType)
if err := project_model.UpdateProject(ctx, project); err != nil {
return err
}
if opts.IsClosed.Has() && opts.IsClosed.Value() != project.IsClosed {
if err := project_model.ChangeProjectStatus(ctx, project, opts.IsClosed.Value()); err != nil {
return err
}
}
return nil
})
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+320
View File
@@ -0,0 +1,320 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/http"
"slices"
"strconv"
"testing"
auth_model "gitea.dev/models/auth"
issues_model "gitea.dev/models/issues"
project_model "gitea.dev/models/project"
"gitea.dev/models/unittest"
api "gitea.dev/modules/structs"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// projectScope runs the same lifecycle against each owner type, so scope-specific routing
// or permission mistakes surface without triplicating the test body.
type projectScope struct {
name string
base string
// an issue whose repository the scope's owner may place on its board
issueID int64
// a second such issue, deliberately left off the board
foreignIssueID int64
}
func TestAPIProjects(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// user2 owns repo1 and is on org3's Owners team, so one token covers all three scopes
token := getUserToken(t, "user2", auth_model.AccessTokenScopeWriteIssue, auth_model.AccessTokenScopeWriteOrganization,
auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteRepository)
// user5 is signed in but is neither an org member nor a repo1 collaborator, and is scoped
// generously so that permissions rather than token scopes are what denies below
outsider := getUserToken(t, "user5", auth_model.AccessTokenScopeWriteIssue,
auth_model.AccessTokenScopeWriteOrganization, auth_model.AccessTokenScopeReadUser)
for _, scope := range []projectScope{
{"Repository", "/api/v1/repos/user2/repo1/projects", 1, 11},
{"Organization", "/api/v1/orgs/org3/projects", 16, 17},
{"User", "/api/v1/user/projects", 1, 11},
} {
t.Run(scope.name, func(t *testing.T) {
testProjectLifecycle(t, scope, token)
})
}
t.Run("ListOtherUserProjects", func(t *testing.T) {
// no fixture project is individual, so create one: without it the route answers with an
// empty list and the assertions below would pass vacuously
req := NewRequestWithJSON(t, "POST", "/api/v1/user/projects", &api.CreateProjectOption{Title: "individual"}).AddTokenAuth(token)
created := DecodeJSON(t, MakeRequest(t, req, http.StatusCreated), &api.Project{})
req = NewRequest(t, "GET", "/api/v1/users/user2/projects").AddTokenAuth(token)
projects := *DecodeJSON(t, MakeRequest(t, req, http.StatusOK), &[]*api.Project{})
require.Len(t, projects, 1)
assert.Equal(t, created.ID, projects[0].ID)
assert.Equal(t, "individual", projects[0].Type)
})
t.Run("DefaultColumnHoldsUnassignedIssues", func(t *testing.T) {
// fixture issue 2 carries project_board_id=0, which the board shows in the default column
defaultColumn := unittest.AssertExistsAndLoadBean(t, &project_model.Column{ProjectID: 1, Default: true})
req := NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/projects/1/columns/%d/issues", defaultColumn.ID).AddTokenAuth(token)
issueIDs := make([]int64, 0)
for _, issue := range *DecodeJSON(t, MakeRequest(t, req, http.StatusOK), &[]api.Issue{}) {
issueIDs = append(issueIDs, issue.ID)
}
assert.Contains(t, issueIDs, int64(2))
// and the same column must accept removing what it lists
req = NewRequestf(t, "DELETE", "/api/v1/repos/user2/repo1/projects/1/columns/%d/issues/2", defaultColumn.ID).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
unittest.AssertNotExistsBean(t, &project_model.ProjectIssue{ProjectID: 1, IssueID: 2})
})
t.Run("Permissions", func(t *testing.T) { testAPIProjectPermissions(t, token, outsider) })
t.Run("Visibility", func(t *testing.T) { testAPIProjectVisibility(t, outsider) })
t.Run("RepoProjectsModeOwner", func(t *testing.T) { testAPIRepoProjectsModeOwner(t, token) })
}
func testProjectLifecycle(t *testing.T, scope projectScope, token string) {
req := NewRequestWithJSON(t, "POST", scope.base, &api.CreateProjectOption{
Title: "lifecycle",
Description: "created via API",
TemplateType: "basic_kanban",
CardType: "images_and_text",
}).AddTokenAuth(token)
project := DecodeJSON(t, MakeRequest(t, req, http.StatusCreated), &api.Project{})
assert.Equal(t, "lifecycle", project.Title)
assert.Equal(t, "basic_kanban", project.TemplateType)
assert.Equal(t, "images_and_text", project.CardType)
assert.Equal(t, api.StateOpen, project.State)
assert.NotEmpty(t, project.HTMLURL)
projectURL := fmt.Sprintf("%s/%d", scope.base, project.ID)
req = NewRequest(t, "GET", scope.base+"?state=open").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.NotEmpty(t, resp.Header().Get("X-Total-Count"))
listedIDs := make([]int64, 0)
for _, listed := range *DecodeJSON(t, resp, &[]*api.Project{}) {
assert.Equal(t, api.StateOpen, listed.State)
listedIDs = append(listedIDs, listed.ID)
}
assert.Contains(t, listedIDs, project.ID, "created project must appear in the scope's list")
assert.IsDecreasing(t, listedIDs, "list must be ordered so pagination is stable")
req = NewRequest(t, "GET", projectURL).AddTokenAuth(token)
assert.Equal(t, project.ID, DecodeJSON(t, MakeRequest(t, req, http.StatusOK), &api.Project{}).ID)
newTitle, closed := "renamed", api.StateClosed
req = NewRequestWithJSON(t, "PATCH", projectURL, &api.EditProjectOption{Title: &newTitle, State: &closed}).AddTokenAuth(token)
updated := DecodeJSON(t, MakeRequest(t, req, http.StatusOK), &api.Project{})
assert.Equal(t, newTitle, updated.Title)
assert.Equal(t, api.StateClosed, updated.State)
assert.NotNil(t, updated.ClosedAt)
// a closed project is read-only
req = NewRequestWithJSON(t, "POST", projectURL+"/columns", &api.CreateProjectColumnOption{Title: "nope"}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusForbidden)
req = NewRequestWithJSON(t, "PATCH", projectURL, map[string]string{"title": ""}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusUnprocessableEntity)
unittest.AssertExistsAndLoadBean(t, &project_model.Project{ID: project.ID, Title: newTitle})
open := api.StateOpen
req = NewRequestWithJSON(t, "PATCH", projectURL, &api.EditProjectOption{State: &open}).AddTokenAuth(token)
reopened := DecodeJSON(t, MakeRequest(t, req, http.StatusOK), &api.Project{})
assert.Equal(t, api.StateOpen, reopened.State)
assert.Nil(t, reopened.ClosedAt, "reopening must clear closed_at")
columnIDs := make([]int64, 0, 2)
for _, title := range []string{"todo", "doing"} {
req = NewRequestWithJSON(t, "POST", projectURL+"/columns", &api.CreateProjectColumnOption{Title: title, Color: "#FF5733"}).AddTokenAuth(token)
column := DecodeJSON(t, MakeRequest(t, req, http.StatusCreated), &api.ProjectColumn{})
assert.Equal(t, title, column.Title)
assert.Equal(t, "#FF5733", column.Color)
columnIDs = append(columnIDs, column.ID)
}
req = NewRequestWithJSON(t, "POST", projectURL+"/columns", &api.CreateProjectColumnOption{Title: "bad", Color: "red"}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusUnprocessableEntity)
req = NewRequest(t, "GET", projectURL+"/columns").AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
allColumns := *DecodeJSON(t, resp, &[]*api.ProjectColumn{})
// basic_kanban seeds columns of its own, so only assert on the two just added
require.GreaterOrEqual(t, len(allColumns), 2)
assert.Equal(t, strconv.Itoa(len(allColumns)), resp.Header().Get("X-Total-Count"))
columnURL := fmt.Sprintf("%s/columns/%d", projectURL, columnIDs[0])
req = NewRequest(t, "GET", columnURL).AddTokenAuth(token)
assert.Equal(t, "todo", DecodeJSON(t, MakeRequest(t, req, http.StatusOK), &api.ProjectColumn{}).Title)
renamed, sorting := "todo!", 3
req = NewRequestWithJSON(t, "PATCH", columnURL, &api.EditProjectColumnOption{Title: &renamed, Sorting: &sorting}).AddTokenAuth(token)
editedColumn := DecodeJSON(t, MakeRequest(t, req, http.StatusOK), &api.ProjectColumn{})
assert.Equal(t, renamed, editedColumn.Title)
assert.Equal(t, sorting, editedColumn.Sorting)
tooLarge := 1000
req = NewRequestWithJSON(t, "PATCH", columnURL, &api.EditProjectColumnOption{Sorting: &tooLarge}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusUnprocessableEntity)
// 0 is in range and is how a column moves first, so it must reach the database
zero := 0
req = NewRequestWithJSON(t, "PATCH", columnURL, &api.EditProjectColumnOption{Sorting: &zero}).AddTokenAuth(token)
assert.Equal(t, 0, DecodeJSON(t, MakeRequest(t, req, http.StatusOK), &api.ProjectColumn{}).Sorting)
assert.EqualValues(t, 0, unittest.AssertExistsAndLoadBean(t, &project_model.Column{ID: columnIDs[0]}).Sorting)
req = NewRequestWithJSON(t, "PATCH", columnURL, map[string]string{"title": ""}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusUnprocessableEntity)
req = NewRequest(t, "POST", columnURL+"/default").AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
unittest.AssertExistsAndLoadBean(t, &project_model.Column{ID: columnIDs[0], Default: true})
reversed := make([]int64, 0, len(allColumns))
for _, column := range slices.Backward(allColumns) {
reversed = append(reversed, column.ID)
}
req = NewRequestWithJSON(t, "POST", projectURL+"/columns/move", &api.MoveProjectColumnsOption{ColumnIDs: reversed}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
req = NewRequest(t, "GET", projectURL+"/columns").AddTokenAuth(token)
reordered := *DecodeJSON(t, MakeRequest(t, req, http.StatusOK), &[]*api.ProjectColumn{})
gotOrder := make([]int64, 0, len(reordered))
for _, column := range reordered {
gotOrder = append(gotOrder, column.ID)
}
assert.Equal(t, reversed, gotOrder)
// a partial list would silently drop columns, so it must be rejected
req = NewRequestWithJSON(t, "POST", projectURL+"/columns/move", &api.MoveProjectColumnsOption{ColumnIDs: reversed[:1]}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusUnprocessableEntity)
// a non-default column forces assign *and* move, the path that writes the timeline entry
req = NewRequest(t, "POST", fmt.Sprintf("%s/columns/%d/issues/%d", projectURL, columnIDs[1], scope.issueID)).AddTokenAuth(token)
MakeRequest(t, req, http.StatusCreated)
unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{
ProjectID: project.ID, IssueID: scope.issueID, ProjectColumnID: columnIDs[1],
})
unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{
Type: issues_model.CommentTypeProjectColumn, IssueID: scope.issueID, ProjectID: project.ID,
})
req = NewRequest(t, "GET", fmt.Sprintf("%s/columns/%d/issues", projectURL, columnIDs[1])).AddTokenAuth(token)
issues := *DecodeJSON(t, MakeRequest(t, req, http.StatusOK), &[]api.Issue{})
require.Len(t, issues, 1)
assert.Equal(t, scope.issueID, issues[0].ID)
// the sibling column must not report the same issue
req = NewRequest(t, "GET", fmt.Sprintf("%s/columns/%d/issues", projectURL, columnIDs[0])).AddTokenAuth(token)
assert.Empty(t, *DecodeJSON(t, MakeRequest(t, req, http.StatusOK), &[]api.Issue{}))
// an issue that is not in the project cannot be moved within it
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("%s/issues/%d/move", projectURL, scope.foreignIssueID),
&api.MoveProjectIssueOption{ColumnID: columnIDs[0]}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusUnprocessableEntity)
sortPos := int64(7)
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("%s/issues/%d/move", projectURL, scope.issueID),
&api.MoveProjectIssueOption{ColumnID: columnIDs[0], Sorting: &sortPos}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
moved := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: project.ID, IssueID: scope.issueID})
assert.Equal(t, columnIDs[0], moved.ProjectColumnID)
assert.Equal(t, sortPos, moved.Sorting)
// removing through a column the issue no longer occupies must not detach it
req = NewRequest(t, "DELETE", fmt.Sprintf("%s/columns/%d/issues/%d", projectURL, columnIDs[1], scope.issueID)).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: project.ID, IssueID: scope.issueID})
req = NewRequest(t, "DELETE", fmt.Sprintf("%s/columns/%d/issues/%d", projectURL, columnIDs[0], scope.issueID)).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
unittest.AssertNotExistsBean(t, &project_model.ProjectIssue{ProjectID: project.ID, IssueID: scope.issueID})
// the default column cannot go while it is still the landing column
req = NewRequest(t, "DELETE", columnURL).AddTokenAuth(token)
MakeRequest(t, req, http.StatusUnprocessableEntity)
secondColumnURL := fmt.Sprintf("%s/columns/%d", projectURL, columnIDs[1])
req = NewRequest(t, "DELETE", secondColumnURL).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
req = NewRequest(t, "GET", secondColumnURL).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
req = NewRequest(t, "DELETE", projectURL).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
req = NewRequest(t, "GET", projectURL).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIProjectPermissions(t *testing.T, ownerToken, outsiderToken string) {
// fixture project 1 belongs to repo1, so this needs no project of its own
const projectURL = "/api/v1/repos/user2/repo1/projects/1"
title := "hijacked"
req := NewRequestWithJSON(t, "PATCH", projectURL, &api.EditProjectOption{Title: &title}).AddTokenAuth(outsiderToken)
MakeRequest(t, req, http.StatusForbidden)
MakeRequest(t, NewRequest(t, "DELETE", projectURL).AddTokenAuth(outsiderToken), http.StatusForbidden)
// a project ID from another owner must not be reachable through this repo's path
MakeRequest(t, NewRequest(t, "GET", "/api/v1/repos/user2/repo1/projects/4").AddTokenAuth(ownerToken), http.StatusNotFound)
}
// testAPIProjectVisibility pins the permission boundary of the listing routes: a private
// organization's boards must stay invisible to outsiders, and a signed-in user with plain
// repository read access must not see fewer issues than an anonymous one.
func testAPIProjectVisibility(t *testing.T, outsider string) {
for _, url := range []string{"/api/v1/orgs/privated_org/projects", "/api/v1/users/privated_org/projects"} {
MakeRequest(t, NewRequest(t, "GET", url).AddTokenAuth(outsider), http.StatusNotFound)
}
// column 2 of repo1's public board holds issue 3
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/projects/1/columns/2/issues").AddTokenAuth(outsider)
assert.NotEmpty(t, *DecodeJSON(t, MakeRequest(t, req, http.StatusOK), &[]api.Issue{}))
// user1 is on private_org35's Owners team, so permissions are not what must deny here
publicOnly := getUserToken(t, "user1", auth_model.AccessTokenScopeReadUser,
auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopeReadIssue,
auth_model.AccessTokenScopePublicOnly)
for _, url := range []string{"/api/v1/orgs/private_org35/projects", "/api/v1/users/private_org35/projects"} {
MakeRequest(t, NewRequest(t, "GET", url).AddTokenAuth(publicOnly), http.StatusForbidden)
}
// a public org's board is readable by an outsider but not writable
MakeRequest(t, NewRequest(t, "GET", "/api/v1/orgs/org3/projects").AddTokenAuth(outsider), http.StatusOK)
req = NewRequestWithJSON(t, "POST", "/api/v1/orgs/org3/projects", &api.CreateProjectOption{Title: "outsider"}).AddTokenAuth(outsider)
MakeRequest(t, req, http.StatusNotFound)
}
// testAPIRepoProjectsModeOwner pins the API to the same Projects-unit mode the web honours:
// with repo-level boards switched off the UI 404s, so the API must not keep serving them.
func testAPIRepoProjectsModeOwner(t *testing.T, token string) {
hasProjects, ownerMode := true, "owner"
req := NewRequestWithJSON(t, "PATCH", "/api/v1/repos/user2/repo1", &api.EditRepoOption{
HasProjects: &hasProjects, ProjectsMode: &ownerMode,
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
MakeRequest(t, NewRequest(t, "GET", "/api/v1/repos/user2/repo1/projects").AddTokenAuth(token), http.StatusNotFound)
req = NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/projects", &api.CreateProjectOption{Title: "hidden"}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
// restore the fixture mode, so this subtest does not constrain sibling ordering
allMode := "all"
req = NewRequestWithJSON(t, "PATCH", "/api/v1/repos/user2/repo1", &api.EditRepoOption{
HasProjects: &hasProjects, ProjectsMode: &allMode,
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
}
+3 -3
View File
@@ -62,7 +62,7 @@ func TestMoveRepoProjectColumns(t *testing.T) {
assert.NoError(t, err)
}
columns, err := project1.GetColumns(t.Context())
columns, err := project_model.GetColumns(t.Context(), project1.ID, db.ListOptionsAll)
assert.NoError(t, err)
assert.Len(t, columns, 3)
assert.EqualValues(t, 0, columns[0].Sorting)
@@ -82,7 +82,7 @@ func TestMoveRepoProjectColumns(t *testing.T) {
})
sess.MakeRequest(t, req, http.StatusOK)
columnsAfter, err := project1.GetColumns(t.Context())
columnsAfter, err := project_model.GetColumns(t.Context(), project1.ID, db.ListOptionsAll)
assert.NoError(t, err)
assert.Len(t, columnsAfter, 3)
assert.Equal(t, columns[1].ID, columnsAfter[0].ID)
@@ -160,7 +160,7 @@ func TestUpdateIssueProjectColumn(t *testing.T) {
Title: "other column",
ProjectID: project2.ID,
}))
columns, err := project2.GetColumns(t.Context())
columns, err := project_model.GetColumns(t.Context(), project2.ID, db.ListOptionsAll)
require.NoError(t, err)
require.NotEmpty(t, columns)