mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-09 05:38:54 +09:00
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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
+2925
-11
File diff suppressed because it is too large
Load Diff
+2714
-11
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user