diff --git a/models/issues/issue_project.go b/models/issues/issue_project.go index 894eede22c3..59824f613b1 100644 --- a/models/issues/issue_project.go +++ b/models/issues/issue_project.go @@ -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 } diff --git a/models/project/column.go b/models/project/column.go index 06b59897f84..6cf9d76792f 100644 --- a/models/project/column.go +++ b/models/project/column.go @@ -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) diff --git a/models/project/column_list.go b/models/project/column_list.go new file mode 100644 index 00000000000..33b3a4356bb --- /dev/null +++ b/models/project/column_list.go @@ -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 +} diff --git a/models/project/column_list_test.go b/models/project/column_list_test.go new file mode 100644 index 00000000000..6e4ecdb2819 --- /dev/null +++ b/models/project/column_list_test.go @@ -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) +} diff --git a/models/project/column_test.go b/models/project/column_test.go index ef6016a50d6..731d3c42d87 100644 --- a/models/project/column_test.go +++ b/models/project/column_test.go @@ -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) + }) +} diff --git a/models/project/issue.go b/models/project/issue.go index cf1619a7738..84a8675f2ee 100644 --- a/models/project/issue.go +++ b/models/project/issue.go @@ -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 } diff --git a/modules/structs/project.go b/modules/structs/project.go index 5feb122767b..4e85ad77971 100644 --- a/modules/structs/project.go +++ b/modules/structs/project.go @@ -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"` } diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 31e7da2d5e6..d55d8ffdc48 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -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) diff --git a/routers/api/v1/shared/project.go b/routers/api/v1/shared/project.go new file mode 100644 index 00000000000..c037f61fbdb --- /dev/null +++ b/routers/api/v1/shared/project.go @@ -0,0 +1,2121 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package shared + +import ( + "math" + "net/http" + + "gitea.dev/models/db" + issues_model "gitea.dev/models/issues" + access_model "gitea.dev/models/perm/access" + project_model "gitea.dev/models/project" + repo_model "gitea.dev/models/repo" + user_model "gitea.dev/models/user" + "gitea.dev/modules/container" + "gitea.dev/modules/optional" + api "gitea.dev/modules/structs" + "gitea.dev/modules/util" + "gitea.dev/modules/web" + "gitea.dev/routers/api/v1/utils" + "gitea.dev/routers/common" + "gitea.dev/services/context" + "gitea.dev/services/convert" + project_service "gitea.dev/services/projects" +) + +// projectScope identifies whose projects a route operates on. Exactly one of Repo / +// Owner is set. Access rights are checked at the route level. +type projectScope struct { + Type project_model.Type + Repo *repo_model.Repository + Owner *user_model.User +} + +// projectScopeFromContext derives the scope from the route's assignment. Organization +// routes assign the org as the context user, so only the repository case is distinct. +func projectScopeFromContext(ctx *context.APIContext) projectScope { + if ctx.Repo != nil && ctx.Repo.Repository != nil { + return projectScope{Type: project_model.TypeRepository, Repo: ctx.Repo.Repository} + } + owner := util.IfZero(ctx.ContextUser, ctx.Doer) // "/user/projects" acts on the authenticated user + return projectScope{Type: util.Iif(owner.IsOrganization(), project_model.TypeOrganization, project_model.TypeIndividual), Owner: owner} +} + +func (s projectScope) repoID() int64 { + if s.Repo != nil { + return s.Repo.ID + } + return 0 +} + +func (s projectScope) ownerID() int64 { + if s.Owner != nil { + return s.Owner.ID + } + return 0 +} + +// attach preloads the relation the converter needs for HTMLURL, sparing a lookup per project. +func (s projectScope) attach(projects ...*project_model.Project) { + for _, project := range projects { + project.Repo, project.Owner = s.Repo, s.Owner + } +} + +// findProject loads the "id" path param, scoped to this owner: another owner's ID reads +// as not found. +func (s projectScope) findProject(ctx *context.APIContext) *project_model.Project { + var project *project_model.Project + var err error + if s.Repo != nil { + project, err = project_model.GetProjectForRepoByID(ctx, s.Repo.ID, ctx.PathParamInt64("id")) + } else { + project, err = project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), s.Owner.ID) + } + if err != nil { + ctx.APIErrorAuto(err) + return nil + } + s.attach(project) + return project +} + +// columnIn resolves the "column_id" path param inside an already-resolved project. +func columnIn(ctx *context.APIContext, project *project_model.Project) *project_model.Column { + column, err := project_model.GetColumnByIDAndProjectID(ctx, ctx.PathParamInt64("column_id"), project.ID) + if err != nil { + ctx.APIErrorAuto(err) + return nil + } + return column +} + +func (s projectScope) findColumn(ctx *context.APIContext) (*project_model.Project, *project_model.Column) { + project := s.findProject(ctx) + if ctx.Written() { + return nil, nil + } + return project, columnIn(ctx, project) +} + +// findColumnIssue additionally resolves the "issue_id" path param, rejecting closed projects. +func (s projectScope) findColumnIssue(ctx *context.APIContext) (*project_model.Column, *issues_model.Issue) { + _, column := s.findOpenColumn(ctx) + if ctx.Written() { + return nil, nil + } + return column, s.findIssue(ctx) +} + +// findIssue resolves an issue addressable within this scope. Owner-level boards span +// repositories, so the issue is looked up globally and gated on the doer's read access. +func (s projectScope) findIssue(ctx *context.APIContext) *issues_model.Issue { + issueID := ctx.PathParamInt64("issue_id") + var issue *issues_model.Issue + var err error + if s.Repo != nil { + issue, err = issues_model.GetIssueByRepoID(ctx, s.Repo.ID, issueID) + } else { + issue, err = issues_model.GetIssueByID(ctx, issueID) + } + if err != nil { + ctx.APIErrorAuto(err) + return nil + } + if s.Repo == nil { + if err := issue.LoadRepo(ctx); err != nil { + ctx.APIErrorInternal(err) + return nil + } + perm, err := access_model.GetDoerRepoPermission(ctx, issue.Repo, ctx.Doer) + if err != nil { + ctx.APIErrorInternal(err) + return nil + } + // hide the issue's existence rather than reporting it as forbidden + if !perm.CanReadIssuesOrPulls(issue.IsPull) { + ctx.APIErrorNotFound() + return nil + } + // the repo route group has mustNotBeArchived, owner boards have to check per issue + if issue.Repo.IsArchived { + ctx.APIError(http.StatusLocked, "repo is archived") + return nil + } + } + return issue +} + +// findOpenProject is findProject for mutating endpoints: a closed project is read-only. +func (s projectScope) findOpenProject(ctx *context.APIContext) *project_model.Project { + project := s.findProject(ctx) + if ctx.Written() { + return nil + } + if project.IsClosed { + ctx.APIError(http.StatusForbidden, "project is closed") + return nil + } + return project +} + +// findOpenColumn is findColumn with the same closed-project rejection. +func (s projectScope) findOpenColumn(ctx *context.APIContext) (*project_model.Project, *project_model.Column) { + project := s.findOpenProject(ctx) + if ctx.Written() { + return nil, nil + } + return project, columnIn(ctx, project) +} + +func ListProjects(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/projects repository repoListProjects + // --- + // summary: List a repository's projects + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: state + // in: query + // description: State of the project (open, closed, all) + // type: string + // enum: [open, closed, all] + // default: open + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/ProjectList" + // "404": + // "$ref": "#/responses/notFound" + + // swagger:operation GET /orgs/{org}/projects organization orgListProjects + // --- + // summary: List an organization's projects + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: state + // in: query + // description: State of the project (open, closed, all) + // type: string + // enum: [open, closed, all] + // default: open + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/ProjectList" + // "404": + // "$ref": "#/responses/notFound" + + // swagger:operation GET /user/projects user userCurrentListProjects + // --- + // summary: List your projects + // produces: + // - application/json + // parameters: + // - name: state + // in: query + // description: State of the project (open, closed, all) + // type: string + // enum: [open, closed, all] + // default: open + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/ProjectList" + + // swagger:operation GET /users/{username}/projects user userListProjects + // --- + // summary: List a user's projects + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of the user + // type: string + // required: true + // - name: state + // in: query + // description: State of the project (open, closed, all) + // type: string + // enum: [open, closed, all] + // default: open + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/ProjectList" + // "404": + // "$ref": "#/responses/notFound" + + scope := projectScopeFromContext(ctx) + listOptions := utils.GetListOptions(ctx) + projects, count, err := db.FindAndCount[project_model.Project](ctx, project_model.SearchOptions{ + ListOptions: listOptions, + RepoID: scope.repoID(), + OwnerID: scope.ownerID(), + IsClosed: common.ParseIssueFilterStateIsClosed(ctx.FormTrim("state")), + Type: scope.Type, + OrderBy: db.SearchOrderByIDReverse, // unique, so pagination cannot repeat or skip rows + }) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + // attach first, else LoadOwner re-queries the owner this scope already holds, per project + scope.attach(projects...) + if err := project_service.LoadIssueNumbersForProjects(ctx, projects, ctx.Doer); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.SetLinkHeader(count, listOptions.PageSize) + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, convert.ToProjectList(ctx, projects, ctx.Doer)) +} + +func GetProject(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/projects/{id} repository repoGetProject + // --- + // summary: Get a project + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/Project" + // "404": + // "$ref": "#/responses/notFound" + + // swagger:operation GET /orgs/{org}/projects/{id} organization orgGetProject + // --- + // summary: Get a project + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/Project" + // "404": + // "$ref": "#/responses/notFound" + + // swagger:operation GET /user/projects/{id} user userCurrentGetProject + // --- + // summary: Get a project + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/Project" + // "404": + // "$ref": "#/responses/notFound" + + project := projectScopeFromContext(ctx).findProject(ctx) + if ctx.Written() { + return + } + if err := project_service.LoadIssueNumbersForProject(ctx, project, ctx.Doer); err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.JSON(http.StatusOK, convert.ToProject(ctx, project, ctx.Doer)) +} + +func CreateProject(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/projects repository repoCreateProject + // --- + // summary: Create a project owned by a repository + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateProjectOption" + // responses: + // "201": + // "$ref": "#/responses/Project" + // "422": + // "$ref": "#/responses/validationError" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + // swagger:operation POST /orgs/{org}/projects organization orgCreateProject + // --- + // summary: Create a project owned by an organization + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateProjectOption" + // responses: + // "201": + // "$ref": "#/responses/Project" + // "422": + // "$ref": "#/responses/validationError" + // "404": + // "$ref": "#/responses/notFound" + + // swagger:operation POST /user/projects user userCurrentCreateProject + // --- + // summary: Create a project owned by the authenticated user + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateProjectOption" + // responses: + // "201": + // "$ref": "#/responses/Project" + // "422": + // "$ref": "#/responses/validationError" + + scope := projectScopeFromContext(ctx) + form := web.GetForm(ctx).(*api.CreateProjectOption) + + templateType, err := convert.ProjectTemplateTypeFromString(form.TemplateType) + if err != nil { + ctx.APIError(http.StatusUnprocessableEntity, err.Error()) + return + } + cardType, err := convert.ProjectCardTypeFromString(form.CardType) + if err != nil { + ctx.APIError(http.StatusUnprocessableEntity, err.Error()) + return + } + + project := &project_model.Project{ + RepoID: scope.repoID(), + OwnerID: scope.ownerID(), + Title: form.Title, + Description: form.Description, + CreatorID: ctx.Doer.ID, + TemplateType: templateType, + CardType: cardType, + Type: scope.Type, + } + if err := project_model.NewProject(ctx, project); err != nil { + ctx.APIErrorInternal(err) + return + } + + scope.attach(project) + ctx.JSON(http.StatusCreated, convert.ToProject(ctx, project, ctx.Doer)) +} + +func EditProject(ctx *context.APIContext) { + // swagger:operation PATCH /repos/{owner}/{repo}/projects/{id} repository repoEditProject + // --- + // summary: Edit a project + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditProjectOption" + // responses: + // "200": + // "$ref": "#/responses/Project" + // "422": + // "$ref": "#/responses/validationError" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + // swagger:operation PATCH /orgs/{org}/projects/{id} organization orgEditProject + // --- + // summary: Edit a project + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditProjectOption" + // responses: + // "200": + // "$ref": "#/responses/Project" + // "422": + // "$ref": "#/responses/validationError" + // "404": + // "$ref": "#/responses/notFound" + + // swagger:operation PATCH /user/projects/{id} user userCurrentEditProject + // --- + // summary: Edit a project + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditProjectOption" + // responses: + // "200": + // "$ref": "#/responses/Project" + // "422": + // "$ref": "#/responses/validationError" + // "404": + // "$ref": "#/responses/notFound" + + project := projectScopeFromContext(ctx).findProject(ctx) + if ctx.Written() { + return + } + + form := web.GetForm(ctx).(*api.EditProjectOption) + if form.Title != nil && util.IsEmptyString(*form.Title) { + ctx.APIError(http.StatusUnprocessableEntity, "title must not be empty") + return + } + opts := project_service.UpdateProjectOptions{ + Title: optional.FromPtr(form.Title), + Description: optional.FromPtr(form.Description), + } + if form.CardType != nil { + cardType, err := convert.ProjectCardTypeFromString(*form.CardType) + if err != nil { + ctx.APIError(http.StatusUnprocessableEntity, err.Error()) + return + } + opts.CardType = optional.Some(cardType) + } + if form.State != nil { + switch *form.State { + case api.StateOpen: + opts.IsClosed = optional.Some(false) + case api.StateClosed: + opts.IsClosed = optional.Some(true) + default: + ctx.APIError(http.StatusUnprocessableEntity, "state must be 'open' or 'closed'") + return + } + } + if err := project_service.UpdateProject(ctx, project, opts); err != nil { + ctx.APIErrorInternal(err) + return + } + + if err := project_service.LoadIssueNumbersForProject(ctx, project, ctx.Doer); err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.JSON(http.StatusOK, convert.ToProject(ctx, project, ctx.Doer)) +} + +func DeleteProject(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/projects/{id} repository repoDeleteProject + // --- + // summary: Delete a project + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + // swagger:operation DELETE /orgs/{org}/projects/{id} organization orgDeleteProject + // --- + // summary: Delete a project + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + // swagger:operation DELETE /user/projects/{id} user userCurrentDeleteProject + // --- + // summary: Delete a project + // parameters: + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + project := projectScopeFromContext(ctx).findProject(ctx) + if ctx.Written() { + return + } + if err := project_model.DeleteProjectByID(ctx, project.ID); err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.Status(http.StatusNoContent) +} + +func ListProjectColumns(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/projects/{id}/columns repository repoListProjectColumns + // --- + // summary: List a project's columns + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/ProjectColumnList" + // "404": + // "$ref": "#/responses/notFound" + + // swagger:operation GET /orgs/{org}/projects/{id}/columns organization orgListProjectColumns + // --- + // summary: List a project's columns + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/ProjectColumnList" + // "404": + // "$ref": "#/responses/notFound" + + // swagger:operation GET /user/projects/{id}/columns user userCurrentListProjectColumns + // --- + // summary: List a project's columns + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/ProjectColumnList" + // "404": + // "$ref": "#/responses/notFound" + + project := projectScopeFromContext(ctx).findProject(ctx) + if ctx.Written() { + return + } + + total, err := project_model.CountColumns(ctx, project.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + listOptions := utils.GetListOptions(ctx) + columns, err := project_model.GetColumns(ctx, project.ID, listOptions) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.SetLinkHeader(total, listOptions.PageSize) + ctx.SetTotalCountHeader(total) + ctx.JSON(http.StatusOK, convert.ToProjectColumnList(ctx, columns, ctx.Doer)) +} + +func CreateProjectColumn(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/projects/{id}/columns repository repoCreateProjectColumn + // --- + // summary: Create a column in a project + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateProjectColumnOption" + // responses: + // "201": + // "$ref": "#/responses/ProjectColumn" + // "403": + // "$ref": "#/responses/forbidden" + // "422": + // "$ref": "#/responses/validationError" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + // swagger:operation POST /orgs/{org}/projects/{id}/columns organization orgCreateProjectColumn + // --- + // summary: Create a column in a project + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateProjectColumnOption" + // responses: + // "201": + // "$ref": "#/responses/ProjectColumn" + // "403": + // "$ref": "#/responses/forbidden" + // "422": + // "$ref": "#/responses/validationError" + // "404": + // "$ref": "#/responses/notFound" + + // swagger:operation POST /user/projects/{id}/columns user userCurrentCreateProjectColumn + // --- + // summary: Create a column in a project + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateProjectColumnOption" + // responses: + // "201": + // "$ref": "#/responses/ProjectColumn" + // "403": + // "$ref": "#/responses/forbidden" + // "422": + // "$ref": "#/responses/validationError" + // "404": + // "$ref": "#/responses/notFound" + + project := projectScopeFromContext(ctx).findOpenProject(ctx) + if ctx.Written() { + return + } + + form := web.GetForm(ctx).(*api.CreateProjectColumnOption) + column := &project_model.Column{ + Title: form.Title, + Color: form.Color, + ProjectID: project.ID, + CreatorID: ctx.Doer.ID, + } + if err := project_model.NewColumn(ctx, column); err != nil { + ctx.APIErrorAuto(err) + return + } + ctx.JSON(http.StatusCreated, convert.ToProjectColumn(ctx, column, ctx.Doer)) +} + +func GetProjectColumn(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/projects/{id}/columns/{column_id} repository repoGetProjectColumn + // --- + // summary: Get a project column + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: column_id + // in: path + // description: id of the column + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/ProjectColumn" + // "404": + // "$ref": "#/responses/notFound" + + // swagger:operation GET /orgs/{org}/projects/{id}/columns/{column_id} organization orgGetProjectColumn + // --- + // summary: Get a project column + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: column_id + // in: path + // description: id of the column + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/ProjectColumn" + // "404": + // "$ref": "#/responses/notFound" + + // swagger:operation GET /user/projects/{id}/columns/{column_id} user userCurrentGetProjectColumn + // --- + // summary: Get a project column + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: column_id + // in: path + // description: id of the column + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/ProjectColumn" + // "404": + // "$ref": "#/responses/notFound" + + _, column := projectScopeFromContext(ctx).findColumn(ctx) + if ctx.Written() { + return + } + ctx.JSON(http.StatusOK, convert.ToProjectColumn(ctx, column, ctx.Doer)) +} + +func EditProjectColumn(ctx *context.APIContext) { + // swagger:operation PATCH /repos/{owner}/{repo}/projects/{id}/columns/{column_id} repository repoEditProjectColumn + // --- + // summary: Edit a project column + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: column_id + // in: path + // description: id of the column + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditProjectColumnOption" + // responses: + // "200": + // "$ref": "#/responses/ProjectColumn" + // "403": + // "$ref": "#/responses/forbidden" + // "422": + // "$ref": "#/responses/validationError" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + // swagger:operation PATCH /orgs/{org}/projects/{id}/columns/{column_id} organization orgEditProjectColumn + // --- + // summary: Edit a project column + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: column_id + // in: path + // description: id of the column + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditProjectColumnOption" + // responses: + // "200": + // "$ref": "#/responses/ProjectColumn" + // "403": + // "$ref": "#/responses/forbidden" + // "422": + // "$ref": "#/responses/validationError" + // "404": + // "$ref": "#/responses/notFound" + + // swagger:operation PATCH /user/projects/{id}/columns/{column_id} user userCurrentEditProjectColumn + // --- + // summary: Edit a project column + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: column_id + // in: path + // description: id of the column + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditProjectColumnOption" + // responses: + // "200": + // "$ref": "#/responses/ProjectColumn" + // "403": + // "$ref": "#/responses/forbidden" + // "422": + // "$ref": "#/responses/validationError" + // "404": + // "$ref": "#/responses/notFound" + + _, column := projectScopeFromContext(ctx).findOpenColumn(ctx) + if ctx.Written() { + return + } + + form := web.GetForm(ctx).(*api.EditProjectColumnOption) + if form.Title != nil { + if util.IsEmptyString(*form.Title) { + ctx.APIError(http.StatusUnprocessableEntity, "title must not be empty") + return + } + column.Title = *form.Title + } + if form.Color != nil { + column.Color = *form.Color + } + if form.Sorting != nil { + if *form.Sorting < math.MinInt8 || *form.Sorting > math.MaxInt8 { + ctx.APIError(http.StatusUnprocessableEntity, "sorting must be between -128 and 127") + return + } + column.Sorting = int8(*form.Sorting) + } + + if err := project_model.UpdateColumn(ctx, column); err != nil { + ctx.APIErrorAuto(err) + return + } + ctx.JSON(http.StatusOK, convert.ToProjectColumn(ctx, column, ctx.Doer)) +} + +func DeleteProjectColumn(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/projects/{id}/columns/{column_id} repository repoDeleteProjectColumn + // --- + // summary: Delete a project column + // description: The default column cannot be deleted while it is still the column new issues land in. + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: column_id + // in: path + // description: id of the column + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "422": + // "$ref": "#/responses/validationError" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + // swagger:operation DELETE /orgs/{org}/projects/{id}/columns/{column_id} organization orgDeleteProjectColumn + // --- + // summary: Delete a project column + // description: The default column cannot be deleted while it is still the column new issues land in. + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: column_id + // in: path + // description: id of the column + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "422": + // "$ref": "#/responses/validationError" + // "404": + // "$ref": "#/responses/notFound" + + // swagger:operation DELETE /user/projects/{id}/columns/{column_id} user userCurrentDeleteProjectColumn + // --- + // summary: Delete a project column + // description: The default column cannot be deleted while it is still the column new issues land in. + // parameters: + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: column_id + // in: path + // description: id of the column + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "422": + // "$ref": "#/responses/validationError" + // "404": + // "$ref": "#/responses/notFound" + + _, column := projectScopeFromContext(ctx).findOpenColumn(ctx) + if ctx.Written() { + return + } + if err := project_model.DeleteColumnByID(ctx, column.ID); err != nil { + ctx.APIErrorAuto(err) + return + } + ctx.Status(http.StatusNoContent) +} + +func SetDefaultProjectColumn(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/projects/{id}/columns/{column_id}/default repository repoSetDefaultProjectColumn + // --- + // summary: Set a project's default column + // description: The default column is where newly assigned issues land. + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: column_id + // in: path + // description: id of the column + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + // swagger:operation POST /orgs/{org}/projects/{id}/columns/{column_id}/default organization orgSetDefaultProjectColumn + // --- + // summary: Set a project's default column + // description: The default column is where newly assigned issues land. + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: column_id + // in: path + // description: id of the column + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + // swagger:operation POST /user/projects/{id}/columns/{column_id}/default user userCurrentSetDefaultProjectColumn + // --- + // summary: Set a project's default column + // description: The default column is where newly assigned issues land. + // parameters: + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: column_id + // in: path + // description: id of the column + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + project, column := projectScopeFromContext(ctx).findOpenColumn(ctx) + if ctx.Written() { + return + } + if err := project_model.SetDefaultColumn(ctx, project.ID, column.ID); err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.Status(http.StatusNoContent) +} + +func MoveProjectColumns(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/projects/{id}/columns/move repository repoMoveProjectColumns + // --- + // summary: Reorder a project's columns + // description: Reorders every column of the project at once. The body lists all column IDs in their new order. + // consumes: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/MoveProjectColumnsOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "422": + // "$ref": "#/responses/validationError" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + // swagger:operation POST /orgs/{org}/projects/{id}/columns/move organization orgMoveProjectColumns + // --- + // summary: Reorder a project's columns + // description: Reorders every column of the project at once. The body lists all column IDs in their new order. + // consumes: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/MoveProjectColumnsOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "422": + // "$ref": "#/responses/validationError" + // "404": + // "$ref": "#/responses/notFound" + + // swagger:operation POST /user/projects/{id}/columns/move user userCurrentMoveProjectColumns + // --- + // summary: Reorder a project's columns + // description: Reorders every column of the project at once. The body lists all column IDs in their new order. + // consumes: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/MoveProjectColumnsOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "422": + // "$ref": "#/responses/validationError" + // "404": + // "$ref": "#/responses/notFound" + + project := projectScopeFromContext(ctx).findOpenProject(ctx) + if ctx.Written() { + return + } + + form := web.GetForm(ctx).(*api.MoveProjectColumnsOption) + columns, err := project_model.GetColumns(ctx, project.ID, db.ListOptionsAll) + if err != nil { + ctx.APIErrorInternal(err) + return + } + existingIDs := container.FilterSlice(columns, func(column *project_model.Column) (int64, bool) { return column.ID, true }) + if !util.SliceSortedEqual(form.ColumnIDs, existingIDs) { + ctx.APIError(http.StatusUnprocessableEntity, "column_ids must list every column of the project exactly once") + return + } + sortedColumnIDs := make(map[int64]int64, len(form.ColumnIDs)) + for position, columnID := range form.ColumnIDs { + sortedColumnIDs[int64(position)] = columnID + } + + if err := project_model.MoveColumnsOnProject(ctx, project, sortedColumnIDs); err != nil { + ctx.APIErrorAuto(err) + return + } + ctx.Status(http.StatusNoContent) +} + +func ListProjectColumnIssues(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/projects/{id}/columns/{column_id}/issues repository repoListProjectColumnIssues + // --- + // summary: List the issues in a project column + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: column_id + // in: path + // description: id of the column + // type: integer + // format: int64 + // required: true + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/IssueList" + // "404": + // "$ref": "#/responses/notFound" + + // swagger:operation GET /orgs/{org}/projects/{id}/columns/{column_id}/issues organization orgListProjectColumnIssues + // --- + // summary: List the issues in a project column + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: column_id + // in: path + // description: id of the column + // type: integer + // format: int64 + // required: true + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/IssueList" + // "404": + // "$ref": "#/responses/notFound" + + // swagger:operation GET /user/projects/{id}/columns/{column_id}/issues user userCurrentListProjectColumnIssues + // --- + // summary: List the issues in a project column + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: column_id + // in: path + // description: id of the column + // type: integer + // format: int64 + // required: true + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/IssueList" + // "404": + // "$ref": "#/responses/notFound" + + scope := projectScopeFromContext(ctx) + project, column := scope.findColumn(ctx) + if ctx.Written() { + return + } + + issueIDs, err := project_model.GetColumnIssueIDs(ctx, column) + if err != nil { + ctx.APIErrorInternal(err) + return + } + if len(issueIDs) == 0 { + ctx.SetTotalCountHeader(0) + ctx.JSON(http.StatusOK, []*api.Issue{}) + return + } + + listOptions := utils.GetListOptions(ctx) + issuesOpts := &issues_model.IssuesOptions{ + Paginator: &listOptions, + IssueIDs: issueIDs, + ProjectIDs: []int64{project.ID}, // joins project_issue so the column sorting applies + SortType: "project-column-sorting", + } + if scope.Repo != nil { + // the route already established repo read access, and every issue here is that repo's + issuesOpts.RepoIDs = []int64{scope.Repo.ID} + } else { + // an owner-level board spans repositories, so filter to what this doer may see + issuesOpts.Owner = scope.Owner + issuesOpts.Doer = ctx.Doer + issuesOpts.AllPublic = ctx.Doer == nil + if ctx.PublicOnly { + issuesOpts.AllPublic = true + issuesOpts.Doer = nil // a public-only token must not reach the doer's private repos + } + } + + count, err := issues_model.CountIssues(ctx, issuesOpts) + if err != nil { + ctx.APIErrorInternal(err) + return + } + issues, err := issues_model.Issues(ctx, issuesOpts) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.SetLinkHeader(count, listOptions.PageSize) + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, ctx.Doer, issues)) +} + +func AddIssueToProjectColumn(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/projects/{id}/columns/{column_id}/issues/{issue_id} repository repoAddIssueToProjectColumn + // --- + // summary: Add an issue to a project column + // description: Assigns the issue to the project if it is not a member yet, then places it in the column. + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: column_id + // in: path + // description: id of the column + // type: integer + // format: int64 + // required: true + // - name: issue_id + // in: path + // description: global id of the issue, not the repository-local index + // type: integer + // format: int64 + // required: true + // responses: + // "201": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "422": + // "$ref": "#/responses/validationError" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + // swagger:operation POST /orgs/{org}/projects/{id}/columns/{column_id}/issues/{issue_id} organization orgAddIssueToProjectColumn + // --- + // summary: Add an issue to a project column + // description: Assigns the issue to the project if it is not a member yet, then places it in the column. + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: column_id + // in: path + // description: id of the column + // type: integer + // format: int64 + // required: true + // - name: issue_id + // in: path + // description: global id of the issue, not the repository-local index + // type: integer + // format: int64 + // required: true + // responses: + // "201": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "422": + // "$ref": "#/responses/validationError" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + // swagger:operation POST /user/projects/{id}/columns/{column_id}/issues/{issue_id} user userCurrentAddIssueToProjectColumn + // --- + // summary: Add an issue to a project column + // description: Assigns the issue to the project if it is not a member yet, then places it in the column. + // parameters: + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: column_id + // in: path + // description: id of the column + // type: integer + // format: int64 + // required: true + // - name: issue_id + // in: path + // description: global id of the issue, not the repository-local index + // type: integer + // format: int64 + // required: true + // responses: + // "201": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "422": + // "$ref": "#/responses/validationError" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + column, issue := projectScopeFromContext(ctx).findColumnIssue(ctx) + if ctx.Written() { + return + } + if err := project_service.AddIssueToColumn(ctx, ctx.Doer, issue, column); err != nil { + ctx.APIErrorAuto(err) + return + } + ctx.Status(http.StatusCreated) +} + +func RemoveIssueFromProjectColumn(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/projects/{id}/columns/{column_id}/issues/{issue_id} repository repoRemoveIssueFromProjectColumn + // --- + // summary: Remove an issue from a project column + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: column_id + // in: path + // description: id of the column + // type: integer + // format: int64 + // required: true + // - name: issue_id + // in: path + // description: global id of the issue, not the repository-local index + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + // swagger:operation DELETE /orgs/{org}/projects/{id}/columns/{column_id}/issues/{issue_id} organization orgRemoveIssueFromProjectColumn + // --- + // summary: Remove an issue from a project column + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: column_id + // in: path + // description: id of the column + // type: integer + // format: int64 + // required: true + // - name: issue_id + // in: path + // description: global id of the issue, not the repository-local index + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + // swagger:operation DELETE /user/projects/{id}/columns/{column_id}/issues/{issue_id} user userCurrentRemoveIssueFromProjectColumn + // --- + // summary: Remove an issue from a project column + // parameters: + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: column_id + // in: path + // description: id of the column + // type: integer + // format: int64 + // required: true + // - name: issue_id + // in: path + // description: global id of the issue, not the repository-local index + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + column, issue := projectScopeFromContext(ctx).findColumnIssue(ctx) + if ctx.Written() { + return + } + if err := project_service.RemoveIssueFromColumn(ctx, ctx.Doer, issue, column); err != nil { + ctx.APIErrorAuto(err) + return + } + ctx.Status(http.StatusNoContent) +} + +func MoveProjectIssue(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/projects/{id}/issues/{issue_id}/move repository repoMoveProjectIssue + // --- + // summary: Move an issue between a project's columns + // consumes: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: issue_id + // in: path + // description: global id of the issue, not the repository-local index + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/MoveProjectIssueOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "422": + // "$ref": "#/responses/validationError" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + // swagger:operation POST /orgs/{org}/projects/{id}/issues/{issue_id}/move organization orgMoveProjectIssue + // --- + // summary: Move an issue between a project's columns + // consumes: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: issue_id + // in: path + // description: global id of the issue, not the repository-local index + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/MoveProjectIssueOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "422": + // "$ref": "#/responses/validationError" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + // swagger:operation POST /user/projects/{id}/issues/{issue_id}/move user userCurrentMoveProjectIssue + // --- + // summary: Move an issue between a project's columns + // consumes: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: issue_id + // in: path + // description: global id of the issue, not the repository-local index + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/MoveProjectIssueOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "422": + // "$ref": "#/responses/validationError" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + scope := projectScopeFromContext(ctx) + project := scope.findOpenProject(ctx) + if ctx.Written() { + return + } + + form := web.GetForm(ctx).(*api.MoveProjectIssueOption) + column, err := project_model.GetColumnByIDAndProjectID(ctx, form.ColumnID, project.ID) + if err != nil { + if project_model.IsErrProjectColumnNotExist(err) { + ctx.APIError(http.StatusUnprocessableEntity, "target column does not belong to this project") + return + } + ctx.APIErrorInternal(err) + return + } + + issue := scope.findIssue(ctx) + if ctx.Written() { + return + } + + if err := project_service.MoveIssueToColumn(ctx, ctx.Doer, issue, column, optional.FromPtr(form.Sorting)); err != nil { + ctx.APIErrorAuto(err) + return + } + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 9a97d31eb6c..e87b1761ad8 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -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 } diff --git a/routers/api/v1/swagger/project.go b/routers/api/v1/swagger/project.go new file mode 100644 index 00000000000..6374c850c16 --- /dev/null +++ b/routers/api/v1/swagger/project.go @@ -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"` +} diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go index 3c0048b29b4..cecaab65e53 100644 --- a/routers/web/org/projects.go +++ b/routers/web/org/projects.go @@ -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() -} diff --git a/routers/web/org/projects_test.go b/routers/web/org/projects_test.go index 6bb827d5312..88832a4891d 100644 --- a/routers/web/org/projects_test.go +++ b/routers/web/org/projects_test.go @@ -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()) -} diff --git a/routers/web/repo/issue_page_meta.go b/routers/web/repo/issue_page_meta.go index 3ef8826ce7b..5f3b71a90ce 100644 --- a/routers/web/repo/issue_page_meta.go +++ b/routers/web/repo/issue_page_meta.go @@ -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 } diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index 7abcd372dbd..3212fe14011 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -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 } diff --git a/routers/web/repo/projects_test.go b/routers/web/repo/projects_test.go deleted file mode 100644 index ad24b293eee..00000000000 --- a/routers/web/repo/projects_test.go +++ /dev/null @@ -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()) -} diff --git a/routers/web/shared/project/column.go b/routers/web/shared/project/column.go deleted file mode 100644 index 8589bf7280d..00000000000 --- a/routers/web/shared/project/column.go +++ /dev/null @@ -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() -} diff --git a/routers/web/shared/project/project.go b/routers/web/shared/project/project.go new file mode 100644 index 00000000000..9ce59418e76 --- /dev/null +++ b/routers/web/shared/project/project.go @@ -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() +} diff --git a/routers/web/shared/project/project_test.go b/routers/web/shared/project/project_test.go new file mode 100644 index 00000000000..64017c226ff --- /dev/null +++ b/routers/web/shared/project/project_test.go @@ -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()) + } + }) + } +} diff --git a/routers/web/web.go b/routers/web/web.go index e2a90c0d733..b7193da8cdf 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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) diff --git a/services/convert/issue.go b/services/convert/issue.go index ab45bb7c033..c775ce93773 100644 --- a/services/convert/issue.go +++ b/services/convert/issue.go @@ -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 { diff --git a/services/convert/project.go b/services/convert/project.go index d984548e1b6..2b3f9340489 100644 --- a/services/convert/project.go +++ b/services/convert/project.go @@ -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 } diff --git a/services/projects/issue.go b/services/projects/issue.go index ad9abe9d4a8..d8119460c8d 100644 --- a/services/projects/issue.go +++ b/services/projects/issue.go @@ -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 } diff --git a/services/projects/issue_test.go b/services/projects/issue_test.go index 7f353ca5b24..df9f9c1c244 100644 --- a/services/projects/issue_test.go +++ b/services/projects/issue_test.go @@ -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}) diff --git a/services/projects/project.go b/services/projects/project.go new file mode 100644 index 00000000000..ceb4b3ff7b1 --- /dev/null +++ b/services/projects/project.go @@ -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 + }) +} diff --git a/templates/swagger/v1-openapi3.generated.json b/templates/swagger/v1-openapi3.generated.json index 4b7d25b6fb7..824949a5dca 100644 --- a/templates/swagger/v1-openapi3.generated.json +++ b/templates/swagger/v1-openapi3.generated.json @@ -970,6 +970,52 @@ }, "description": "PackageList" }, + "Project": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + }, + "description": "Project" + }, + "ProjectColumn": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectColumn" + } + } + }, + "description": "ProjectColumn" + }, + "ProjectColumnList": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ProjectColumn" + }, + "type": "array" + } + } + }, + "description": "ProjectColumnList" + }, + "ProjectList": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/Project" + }, + "type": "array" + } + } + }, + "description": "ProjectList" + }, "PublicKey": { "content": { "application/json": { @@ -4363,6 +4409,53 @@ "type": "object", "x-go-package": "gitea.dev/modules/structs" }, + "CreateProjectColumnOption": { + "description": "CreateProjectColumnOption represents options for creating a project column", + "properties": { + "color": { + "description": "Column color in 6-digit hex format, e.g. #FF0000", + "type": "string", + "x-go-name": "Color" + }, + "title": { + "type": "string", + "x-go-name": "Title" + } + }, + "required": [ + "title" + ], + "type": "object", + "x-go-package": "gitea.dev/modules/structs" + }, + "CreateProjectOption": { + "description": "CreateProjectOption represents options for creating a project", + "properties": { + "card_type": { + "description": "Card type: \"text_only\" or \"images_and_text\"", + "type": "string", + "x-go-name": "CardType" + }, + "description": { + "type": "string", + "x-go-name": "Description" + }, + "template_type": { + "description": "Template type: \"none\", \"basic_kanban\" or \"bug_triage\"", + "type": "string", + "x-go-name": "TemplateType" + }, + "title": { + "type": "string", + "x-go-name": "Title" + } + }, + "required": [ + "title" + ], + "type": "object", + "x-go-package": "gitea.dev/modules/structs" + }, "CreatePullRequestOption": { "description": "CreatePullRequestOption options when creating a pull request", "properties": { @@ -5609,6 +5702,51 @@ "type": "object", "x-go-package": "gitea.dev/modules/structs" }, + "EditProjectColumnOption": { + "description": "EditProjectColumnOption represents options for editing a project column", + "properties": { + "color": { + "description": "Column color in 6-digit hex format, e.g. #FF0000", + "type": "string", + "x-go-name": "Color" + }, + "sorting": { + "description": "Position of the column within the project, between -128 and 127", + "format": "int64", + "type": "integer", + "x-go-name": "Sorting" + }, + "title": { + "type": "string", + "x-go-name": "Title" + } + }, + "type": "object", + "x-go-package": "gitea.dev/modules/structs" + }, + "EditProjectOption": { + "description": "EditProjectOption represents options for editing a project", + "properties": { + "card_type": { + "description": "Card type: \"text_only\" or \"images_and_text\"", + "type": "string", + "x-go-name": "CardType" + }, + "description": { + "type": "string", + "x-go-name": "Description" + }, + "state": { + "$ref": "#/components/schemas/StateType" + }, + "title": { + "type": "string", + "x-go-name": "Title" + } + }, + "type": "object", + "x-go-package": "gitea.dev/modules/structs" + }, "EditPullRequestOption": { "description": "EditPullRequestOption options when modify pull request", "properties": { @@ -7739,6 +7877,47 @@ "type": "object", "x-go-package": "gitea.dev/modules/structs" }, + "MoveProjectColumnsOption": { + "description": "MoveProjectColumnsOption represents options for reordering a project's columns", + "properties": { + "column_ids": { + "description": "Every column ID of the project, in the desired left-to-right order", + "items": { + "format": "int64", + "type": "integer" + }, + "type": "array", + "x-go-name": "ColumnIDs" + } + }, + "required": [ + "column_ids" + ], + "type": "object", + "x-go-package": "gitea.dev/modules/structs" + }, + "MoveProjectIssueOption": { + "description": "MoveProjectIssueOption represents options for moving an issue between columns", + "properties": { + "column_id": { + "description": "Target column to move the issue into", + "format": "int64", + "type": "integer", + "x-go-name": "ColumnID" + }, + "sorting": { + "description": "Position within the column, ascending. Omit to append. Negative values sort above\nthe rest, equal values are ordered newest first.", + "format": "int64", + "type": "integer", + "x-go-name": "Sorting" + } + }, + "required": [ + "column_id" + ], + "type": "object", + "x-go-package": "gitea.dev/modules/structs" + }, "NewIssuePinsAllowed": { "description": "NewIssuePinsAllowed represents an API response that says if new Issue Pins are allowed", "properties": { @@ -8299,61 +8478,151 @@ "x-go-package": "gitea.dev/modules/structs" }, "Project": { - "description": "Project represents a project", + "description": "Projects track issues and pull requests, standalone note cards are not supported.", "properties": { + "card_type": { + "description": "Card type: \"text_only\" or \"images_and_text\"", + "type": "string", + "x-go-name": "CardType" + }, "closed_at": { "format": "date-time", "type": "string", - "x-go-name": "Closed" + "x-go-name": "ClosedAt" }, "created_at": { "format": "date-time", "type": "string", - "x-go-name": "Created" + "x-go-name": "CreatedAt" + }, + "creator": { + "$ref": "#/components/schemas/User" }, "creator_id": { - "description": "CreatorID is the user who created the project", + "deprecated": true, + "description": "Deprecated: use Creator instead", "format": "int64", "type": "integer", + "x-deprecated": true, "x-go-name": "CreatorID" }, "description": { - "description": "Description provides details about the project", "type": "string", "x-go-name": "Description" }, + "html_url": { + "format": "uri", + "type": "string", + "x-go-name": "HTMLURL" + }, "id": { - "description": "ID is the unique identifier for the project", "format": "int64", "type": "integer", "x-go-name": "ID" }, "is_closed": { - "description": "IsClosed indicates if the project is closed", + "deprecated": true, + "description": "Deprecated: use State instead", "type": "boolean", + "x-deprecated": true, "x-go-name": "IsClosed" }, + "num_closed_issues": { + "format": "int64", + "type": "integer", + "x-go-name": "NumClosedIssues" + }, + "num_issues": { + "format": "int64", + "type": "integer", + "x-go-name": "NumIssues" + }, + "num_open_issues": { + "format": "int64", + "type": "integer", + "x-go-name": "NumOpenIssues" + }, "owner_id": { - "description": "OwnerID is the owner of the project (for org-level projects)", "format": "int64", "type": "integer", "x-go-name": "OwnerID" }, "repo_id": { - "description": "RepoID is the repository this project belongs to (for repo-level projects)", "format": "int64", "type": "integer", "x-go-name": "RepoID" }, + "state": { + "$ref": "#/components/schemas/StateType" + }, + "template_type": { + "description": "Template type: \"none\", \"basic_kanban\" or \"bug_triage\"", + "type": "string", + "x-go-name": "TemplateType" + }, + "title": { + "type": "string", + "x-go-name": "Title" + }, + "type": { + "description": "Project type: \"individual\", \"repository\" or \"organization\"", + "type": "string", + "x-go-name": "Type" + }, + "updated_at": { + "description": "null only for legacy rows that carry no update timestamp", + "format": "date-time", + "type": "string", + "x-go-name": "UpdatedAt" + } + }, + "title": "Project represents a project.", + "type": "object", + "x-go-package": "gitea.dev/modules/structs" + }, + "ProjectColumn": { + "description": "ProjectColumn represents a project column (board)", + "properties": { + "color": { + "type": "string", + "x-go-name": "Color" + }, + "created_at": { + "format": "date-time", + "type": "string", + "x-go-name": "CreatedAt" + }, + "creator": { + "$ref": "#/components/schemas/User" + }, + "default": { + "type": "boolean", + "x-go-name": "Default" + }, + "id": { + "format": "int64", + "type": "integer", + "x-go-name": "ID" + }, + "project_id": { + "format": "int64", + "type": "integer", + "x-go-name": "ProjectID" + }, + "sorting": { + "format": "int64", + "type": "integer", + "x-go-name": "Sorting" + }, "title": { - "description": "Title is the title of the project", "type": "string", "x-go-name": "Title" }, "updated_at": { + "description": "null only for legacy rows that carry no update timestamp", "format": "date-time", "type": "string", - "x-go-name": "Updated" + "x-go-name": "UpdatedAt" } }, "type": "object", @@ -14208,6 +14477,863 @@ ] } }, + "/orgs/{org}/projects": { + "get": { + "operationId": "orgListProjects", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "State of the project (open, closed, all)", + "in": "query", + "name": "state", + "schema": { + "default": "open", + "enum": [ + "open", + "closed", + "all" + ], + "type": "string" + } + }, + { + "description": "page number of results to return (1-based)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "page size of results", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ProjectList" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "List an organization's projects", + "tags": [ + "organization" + ] + }, + "post": { + "operationId": "orgCreateProject", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProjectOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "201": { + "$ref": "#/components/responses/Project" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Create a project owned by an organization", + "tags": [ + "organization" + ] + } + }, + "/orgs/{org}/projects/{id}": { + "delete": { + "operationId": "orgDeleteProject", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Delete a project", + "tags": [ + "organization" + ] + }, + "get": { + "operationId": "orgGetProject", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Project" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Get a project", + "tags": [ + "organization" + ] + }, + "patch": { + "operationId": "orgEditProject", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditProjectOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "200": { + "$ref": "#/components/responses/Project" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Edit a project", + "tags": [ + "organization" + ] + } + }, + "/orgs/{org}/projects/{id}/columns": { + "get": { + "operationId": "orgListProjectColumns", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "page number of results to return (1-based)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "page size of results", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ProjectColumnList" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "List a project's columns", + "tags": [ + "organization" + ] + }, + "post": { + "operationId": "orgCreateProjectColumn", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProjectColumnOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "201": { + "$ref": "#/components/responses/ProjectColumn" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Create a column in a project", + "tags": [ + "organization" + ] + } + }, + "/orgs/{org}/projects/{id}/columns/move": { + "post": { + "description": "Reorders every column of the project at once. The body lists all column IDs in their new order.", + "operationId": "orgMoveProjectColumns", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MoveProjectColumnsOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Reorder a project's columns", + "tags": [ + "organization" + ] + } + }, + "/orgs/{org}/projects/{id}/columns/{column_id}": { + "delete": { + "description": "The default column cannot be deleted while it is still the column new issues land in.", + "operationId": "orgDeleteProjectColumn", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Delete a project column", + "tags": [ + "organization" + ] + }, + "get": { + "operationId": "orgGetProjectColumn", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ProjectColumn" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Get a project column", + "tags": [ + "organization" + ] + }, + "patch": { + "operationId": "orgEditProjectColumn", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditProjectColumnOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "200": { + "$ref": "#/components/responses/ProjectColumn" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Edit a project column", + "tags": [ + "organization" + ] + } + }, + "/orgs/{org}/projects/{id}/columns/{column_id}/default": { + "post": { + "description": "The default column is where newly assigned issues land.", + "operationId": "orgSetDefaultProjectColumn", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Set a project's default column", + "tags": [ + "organization" + ] + } + }, + "/orgs/{org}/projects/{id}/columns/{column_id}/issues": { + "get": { + "operationId": "orgListProjectColumnIssues", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "page number of results to return (1-based)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "page size of results", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/IssueList" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "List the issues in a project column", + "tags": [ + "organization" + ] + } + }, + "/orgs/{org}/projects/{id}/columns/{column_id}/issues/{issue_id}": { + "delete": { + "operationId": "orgRemoveIssueFromProjectColumn", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "global id of the issue, not the repository-local index", + "in": "path", + "name": "issue_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "423": { + "$ref": "#/components/responses/repoArchivedError" + } + }, + "summary": "Remove an issue from a project column", + "tags": [ + "organization" + ] + }, + "post": { + "description": "Assigns the issue to the project if it is not a member yet, then places it in the column.", + "operationId": "orgAddIssueToProjectColumn", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "global id of the issue, not the repository-local index", + "in": "path", + "name": "issue_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "201": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + }, + "423": { + "$ref": "#/components/responses/repoArchivedError" + } + }, + "summary": "Add an issue to a project column", + "tags": [ + "organization" + ] + } + }, + "/orgs/{org}/projects/{id}/issues/{issue_id}/move": { + "post": { + "operationId": "orgMoveProjectIssue", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "global id of the issue, not the repository-local index", + "in": "path", + "name": "issue_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MoveProjectIssueOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + }, + "423": { + "$ref": "#/components/responses/repoArchivedError" + } + }, + "summary": "Move an issue between a project's columns", + "tags": [ + "organization" + ] + } + }, "/orgs/{org}/public_members": { "get": { "operationId": "orgListPublicMembers", @@ -26360,6 +27486,1031 @@ ] } }, + "/repos/{owner}/{repo}/projects": { + "get": { + "operationId": "repoListProjects", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "State of the project (open, closed, all)", + "in": "query", + "name": "state", + "schema": { + "default": "open", + "enum": [ + "open", + "closed", + "all" + ], + "type": "string" + } + }, + { + "description": "page number of results to return (1-based)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "page size of results", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ProjectList" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "List a repository's projects", + "tags": [ + "repository" + ] + }, + "post": { + "operationId": "repoCreateProject", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProjectOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "201": { + "$ref": "#/components/responses/Project" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + }, + "423": { + "$ref": "#/components/responses/repoArchivedError" + } + }, + "summary": "Create a project owned by a repository", + "tags": [ + "repository" + ] + } + }, + "/repos/{owner}/{repo}/projects/{id}": { + "delete": { + "operationId": "repoDeleteProject", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "423": { + "$ref": "#/components/responses/repoArchivedError" + } + }, + "summary": "Delete a project", + "tags": [ + "repository" + ] + }, + "get": { + "operationId": "repoGetProject", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Project" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Get a project", + "tags": [ + "repository" + ] + }, + "patch": { + "operationId": "repoEditProject", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditProjectOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "200": { + "$ref": "#/components/responses/Project" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + }, + "423": { + "$ref": "#/components/responses/repoArchivedError" + } + }, + "summary": "Edit a project", + "tags": [ + "repository" + ] + } + }, + "/repos/{owner}/{repo}/projects/{id}/columns": { + "get": { + "operationId": "repoListProjectColumns", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "page number of results to return (1-based)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "page size of results", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ProjectColumnList" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "List a project's columns", + "tags": [ + "repository" + ] + }, + "post": { + "operationId": "repoCreateProjectColumn", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProjectColumnOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "201": { + "$ref": "#/components/responses/ProjectColumn" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + }, + "423": { + "$ref": "#/components/responses/repoArchivedError" + } + }, + "summary": "Create a column in a project", + "tags": [ + "repository" + ] + } + }, + "/repos/{owner}/{repo}/projects/{id}/columns/move": { + "post": { + "description": "Reorders every column of the project at once. The body lists all column IDs in their new order.", + "operationId": "repoMoveProjectColumns", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MoveProjectColumnsOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + }, + "423": { + "$ref": "#/components/responses/repoArchivedError" + } + }, + "summary": "Reorder a project's columns", + "tags": [ + "repository" + ] + } + }, + "/repos/{owner}/{repo}/projects/{id}/columns/{column_id}": { + "delete": { + "description": "The default column cannot be deleted while it is still the column new issues land in.", + "operationId": "repoDeleteProjectColumn", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + }, + "423": { + "$ref": "#/components/responses/repoArchivedError" + } + }, + "summary": "Delete a project column", + "tags": [ + "repository" + ] + }, + "get": { + "operationId": "repoGetProjectColumn", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ProjectColumn" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Get a project column", + "tags": [ + "repository" + ] + }, + "patch": { + "operationId": "repoEditProjectColumn", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditProjectColumnOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "200": { + "$ref": "#/components/responses/ProjectColumn" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + }, + "423": { + "$ref": "#/components/responses/repoArchivedError" + } + }, + "summary": "Edit a project column", + "tags": [ + "repository" + ] + } + }, + "/repos/{owner}/{repo}/projects/{id}/columns/{column_id}/default": { + "post": { + "description": "The default column is where newly assigned issues land.", + "operationId": "repoSetDefaultProjectColumn", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "423": { + "$ref": "#/components/responses/repoArchivedError" + } + }, + "summary": "Set a project's default column", + "tags": [ + "repository" + ] + } + }, + "/repos/{owner}/{repo}/projects/{id}/columns/{column_id}/issues": { + "get": { + "operationId": "repoListProjectColumnIssues", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "page number of results to return (1-based)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "page size of results", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/IssueList" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "List the issues in a project column", + "tags": [ + "repository" + ] + } + }, + "/repos/{owner}/{repo}/projects/{id}/columns/{column_id}/issues/{issue_id}": { + "delete": { + "operationId": "repoRemoveIssueFromProjectColumn", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "global id of the issue, not the repository-local index", + "in": "path", + "name": "issue_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "423": { + "$ref": "#/components/responses/repoArchivedError" + } + }, + "summary": "Remove an issue from a project column", + "tags": [ + "repository" + ] + }, + "post": { + "description": "Assigns the issue to the project if it is not a member yet, then places it in the column.", + "operationId": "repoAddIssueToProjectColumn", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "global id of the issue, not the repository-local index", + "in": "path", + "name": "issue_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "201": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + }, + "423": { + "$ref": "#/components/responses/repoArchivedError" + } + }, + "summary": "Add an issue to a project column", + "tags": [ + "repository" + ] + } + }, + "/repos/{owner}/{repo}/projects/{id}/issues/{issue_id}/move": { + "post": { + "operationId": "repoMoveProjectIssue", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "global id of the issue, not the repository-local index", + "in": "path", + "name": "issue_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MoveProjectIssueOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + }, + "423": { + "$ref": "#/components/responses/repoArchivedError" + } + }, + "summary": "Move an issue between a project's columns", + "tags": [ + "repository" + ] + } + }, "/repos/{owner}/{repo}/pulls": { "get": { "operationId": "repoListPullRequests", @@ -33266,6 +35417,711 @@ ] } }, + "/user/projects": { + "get": { + "operationId": "userCurrentListProjects", + "parameters": [ + { + "description": "State of the project (open, closed, all)", + "in": "query", + "name": "state", + "schema": { + "default": "open", + "enum": [ + "open", + "closed", + "all" + ], + "type": "string" + } + }, + { + "description": "page number of results to return (1-based)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "page size of results", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ProjectList" + } + }, + "summary": "List your projects", + "tags": [ + "user" + ] + }, + "post": { + "operationId": "userCurrentCreateProject", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProjectOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "201": { + "$ref": "#/components/responses/Project" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Create a project owned by the authenticated user", + "tags": [ + "user" + ] + } + }, + "/user/projects/{id}": { + "delete": { + "operationId": "userCurrentDeleteProject", + "parameters": [ + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Delete a project", + "tags": [ + "user" + ] + }, + "get": { + "operationId": "userCurrentGetProject", + "parameters": [ + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Project" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Get a project", + "tags": [ + "user" + ] + }, + "patch": { + "operationId": "userCurrentEditProject", + "parameters": [ + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditProjectOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "200": { + "$ref": "#/components/responses/Project" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Edit a project", + "tags": [ + "user" + ] + } + }, + "/user/projects/{id}/columns": { + "get": { + "operationId": "userCurrentListProjectColumns", + "parameters": [ + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "page number of results to return (1-based)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "page size of results", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ProjectColumnList" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "List a project's columns", + "tags": [ + "user" + ] + }, + "post": { + "operationId": "userCurrentCreateProjectColumn", + "parameters": [ + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProjectColumnOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "201": { + "$ref": "#/components/responses/ProjectColumn" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Create a column in a project", + "tags": [ + "user" + ] + } + }, + "/user/projects/{id}/columns/move": { + "post": { + "description": "Reorders every column of the project at once. The body lists all column IDs in their new order.", + "operationId": "userCurrentMoveProjectColumns", + "parameters": [ + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MoveProjectColumnsOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Reorder a project's columns", + "tags": [ + "user" + ] + } + }, + "/user/projects/{id}/columns/{column_id}": { + "delete": { + "description": "The default column cannot be deleted while it is still the column new issues land in.", + "operationId": "userCurrentDeleteProjectColumn", + "parameters": [ + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Delete a project column", + "tags": [ + "user" + ] + }, + "get": { + "operationId": "userCurrentGetProjectColumn", + "parameters": [ + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ProjectColumn" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Get a project column", + "tags": [ + "user" + ] + }, + "patch": { + "operationId": "userCurrentEditProjectColumn", + "parameters": [ + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditProjectColumnOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "200": { + "$ref": "#/components/responses/ProjectColumn" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Edit a project column", + "tags": [ + "user" + ] + } + }, + "/user/projects/{id}/columns/{column_id}/default": { + "post": { + "description": "The default column is where newly assigned issues land.", + "operationId": "userCurrentSetDefaultProjectColumn", + "parameters": [ + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Set a project's default column", + "tags": [ + "user" + ] + } + }, + "/user/projects/{id}/columns/{column_id}/issues": { + "get": { + "operationId": "userCurrentListProjectColumnIssues", + "parameters": [ + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "page number of results to return (1-based)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "page size of results", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/IssueList" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "List the issues in a project column", + "tags": [ + "user" + ] + } + }, + "/user/projects/{id}/columns/{column_id}/issues/{issue_id}": { + "delete": { + "operationId": "userCurrentRemoveIssueFromProjectColumn", + "parameters": [ + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "global id of the issue, not the repository-local index", + "in": "path", + "name": "issue_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "423": { + "$ref": "#/components/responses/repoArchivedError" + } + }, + "summary": "Remove an issue from a project column", + "tags": [ + "user" + ] + }, + "post": { + "description": "Assigns the issue to the project if it is not a member yet, then places it in the column.", + "operationId": "userCurrentAddIssueToProjectColumn", + "parameters": [ + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "global id of the issue, not the repository-local index", + "in": "path", + "name": "issue_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "201": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + }, + "423": { + "$ref": "#/components/responses/repoArchivedError" + } + }, + "summary": "Add an issue to a project column", + "tags": [ + "user" + ] + } + }, + "/user/projects/{id}/issues/{issue_id}/move": { + "post": { + "operationId": "userCurrentMoveProjectIssue", + "parameters": [ + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "global id of the issue, not the repository-local index", + "in": "path", + "name": "issue_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MoveProjectIssueOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + }, + "423": { + "$ref": "#/components/responses/repoArchivedError" + } + }, + "summary": "Move an issue between a project's columns", + "tags": [ + "user" + ] + } + }, "/user/repos": { "get": { "operationId": "userCurrentListRepos", @@ -34151,6 +37007,64 @@ ] } }, + "/users/{username}/projects": { + "get": { + "operationId": "userListProjects", + "parameters": [ + { + "description": "username of the user", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "State of the project (open, closed, all)", + "in": "query", + "name": "state", + "schema": { + "default": "open", + "enum": [ + "open", + "closed", + "all" + ], + "type": "string" + } + }, + { + "description": "page number of results to return (1-based)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "page size of results", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ProjectList" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "List a user's projects", + "tags": [ + "user" + ] + } + }, "/users/{username}/repos": { "get": { "operationId": "userListRepos", diff --git a/templates/swagger/v1-swagger.generated.json b/templates/swagger/v1-swagger.generated.json index bc1acf80ec1..bc4a38f21e6 100644 --- a/templates/swagger/v1-swagger.generated.json +++ b/templates/swagger/v1-swagger.generated.json @@ -3378,6 +3378,796 @@ } } }, + "/orgs/{org}/projects": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "List an organization's projects", + "operationId": "orgListProjects", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "enum": [ + "open", + "closed", + "all" + ], + "type": "string", + "default": "open", + "description": "State of the project (open, closed, all)", + "name": "state", + "in": "query" + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectList" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Create a project owned by an organization", + "operationId": "orgCreateProject", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/CreateProjectOption" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/Project" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, + "/orgs/{org}/projects/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Get a project", + "operationId": "orgGetProject", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/Project" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "delete": { + "tags": [ + "organization" + ], + "summary": "Delete a project", + "operationId": "orgDeleteProject", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Edit a project", + "operationId": "orgEditProject", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/EditProjectOption" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/Project" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, + "/orgs/{org}/projects/{id}/columns": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "List a project's columns", + "operationId": "orgListProjectColumns", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectColumnList" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Create a column in a project", + "operationId": "orgCreateProjectColumn", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/CreateProjectColumnOption" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/ProjectColumn" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, + "/orgs/{org}/projects/{id}/columns/move": { + "post": { + "description": "Reorders every column of the project at once. The body lists all column IDs in their new order.", + "consumes": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Reorder a project's columns", + "operationId": "orgMoveProjectColumns", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/MoveProjectColumnsOption" + } + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, + "/orgs/{org}/projects/{id}/columns/{column_id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Get a project column", + "operationId": "orgGetProjectColumn", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectColumn" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "delete": { + "description": "The default column cannot be deleted while it is still the column new issues land in.", + "tags": [ + "organization" + ], + "summary": "Delete a project column", + "operationId": "orgDeleteProjectColumn", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + }, + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Edit a project column", + "operationId": "orgEditProjectColumn", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/EditProjectColumnOption" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectColumn" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, + "/orgs/{org}/projects/{id}/columns/{column_id}/default": { + "post": { + "description": "The default column is where newly assigned issues land.", + "tags": [ + "organization" + ], + "summary": "Set a project's default column", + "operationId": "orgSetDefaultProjectColumn", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/orgs/{org}/projects/{id}/columns/{column_id}/issues": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "List the issues in a project column", + "operationId": "orgListProjectColumnIssues", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/IssueList" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/orgs/{org}/projects/{id}/columns/{column_id}/issues/{issue_id}": { + "post": { + "description": "Assigns the issue to the project if it is not a member yet, then places it in the column.", + "tags": [ + "organization" + ], + "summary": "Add an issue to a project column", + "operationId": "orgAddIssueToProjectColumn", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "global id of the issue, not the repository-local index", + "name": "issue_id", + "in": "path", + "required": true + } + ], + "responses": { + "201": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + }, + "delete": { + "tags": [ + "organization" + ], + "summary": "Remove an issue from a project column", + "operationId": "orgRemoveIssueFromProjectColumn", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "global id of the issue, not the repository-local index", + "name": "issue_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, + "/orgs/{org}/projects/{id}/issues/{issue_id}/move": { + "post": { + "consumes": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Move an issue between a project's columns", + "operationId": "orgMoveProjectIssue", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "global id of the issue, not the repository-local index", + "name": "issue_id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/MoveProjectIssueOption" + } + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, "/orgs/{org}/public_members": { "get": { "produces": [ @@ -14554,6 +15344,932 @@ } } }, + "/repos/{owner}/{repo}/projects": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "List a repository's projects", + "operationId": "repoListProjects", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "enum": [ + "open", + "closed", + "all" + ], + "type": "string", + "default": "open", + "description": "State of the project (open, closed, all)", + "name": "state", + "in": "query" + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectList" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Create a project owned by a repository", + "operationId": "repoCreateProject", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/CreateProjectOption" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/Project" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, + "/repos/{owner}/{repo}/projects/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Get a project", + "operationId": "repoGetProject", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/Project" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "delete": { + "tags": [ + "repository" + ], + "summary": "Delete a project", + "operationId": "repoDeleteProject", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + }, + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Edit a project", + "operationId": "repoEditProject", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/EditProjectOption" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/Project" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, + "/repos/{owner}/{repo}/projects/{id}/columns": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "List a project's columns", + "operationId": "repoListProjectColumns", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectColumnList" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Create a column in a project", + "operationId": "repoCreateProjectColumn", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/CreateProjectColumnOption" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/ProjectColumn" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, + "/repos/{owner}/{repo}/projects/{id}/columns/move": { + "post": { + "description": "Reorders every column of the project at once. The body lists all column IDs in their new order.", + "consumes": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Reorder a project's columns", + "operationId": "repoMoveProjectColumns", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/MoveProjectColumnsOption" + } + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, + "/repos/{owner}/{repo}/projects/{id}/columns/{column_id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Get a project column", + "operationId": "repoGetProjectColumn", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectColumn" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "delete": { + "description": "The default column cannot be deleted while it is still the column new issues land in.", + "tags": [ + "repository" + ], + "summary": "Delete a project column", + "operationId": "repoDeleteProjectColumn", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + }, + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Edit a project column", + "operationId": "repoEditProjectColumn", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/EditProjectColumnOption" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectColumn" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, + "/repos/{owner}/{repo}/projects/{id}/columns/{column_id}/default": { + "post": { + "description": "The default column is where newly assigned issues land.", + "tags": [ + "repository" + ], + "summary": "Set a project's default column", + "operationId": "repoSetDefaultProjectColumn", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, + "/repos/{owner}/{repo}/projects/{id}/columns/{column_id}/issues": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "List the issues in a project column", + "operationId": "repoListProjectColumnIssues", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/IssueList" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/repos/{owner}/{repo}/projects/{id}/columns/{column_id}/issues/{issue_id}": { + "post": { + "description": "Assigns the issue to the project if it is not a member yet, then places it in the column.", + "tags": [ + "repository" + ], + "summary": "Add an issue to a project column", + "operationId": "repoAddIssueToProjectColumn", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "global id of the issue, not the repository-local index", + "name": "issue_id", + "in": "path", + "required": true + } + ], + "responses": { + "201": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + }, + "delete": { + "tags": [ + "repository" + ], + "summary": "Remove an issue from a project column", + "operationId": "repoRemoveIssueFromProjectColumn", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "global id of the issue, not the repository-local index", + "name": "issue_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, + "/repos/{owner}/{repo}/projects/{id}/issues/{issue_id}/move": { + "post": { + "consumes": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Move an issue between a project's columns", + "operationId": "repoMoveProjectIssue", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "global id of the issue, not the repository-local index", + "name": "issue_id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/MoveProjectIssueOption" + } + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, "/repos/{owner}/{repo}/pulls": { "get": { "produces": [ @@ -21074,6 +22790,678 @@ } } }, + "/user/projects": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "List your projects", + "operationId": "userCurrentListProjects", + "parameters": [ + { + "enum": [ + "open", + "closed", + "all" + ], + "type": "string", + "default": "open", + "description": "State of the project (open, closed, all)", + "name": "state", + "in": "query" + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectList" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Create a project owned by the authenticated user", + "operationId": "userCurrentCreateProject", + "parameters": [ + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/CreateProjectOption" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/Project" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, + "/user/projects/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Get a project", + "operationId": "userCurrentGetProject", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/Project" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "delete": { + "tags": [ + "user" + ], + "summary": "Delete a project", + "operationId": "userCurrentDeleteProject", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Edit a project", + "operationId": "userCurrentEditProject", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/EditProjectOption" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/Project" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, + "/user/projects/{id}/columns": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "List a project's columns", + "operationId": "userCurrentListProjectColumns", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectColumnList" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Create a column in a project", + "operationId": "userCurrentCreateProjectColumn", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/CreateProjectColumnOption" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/ProjectColumn" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, + "/user/projects/{id}/columns/move": { + "post": { + "description": "Reorders every column of the project at once. The body lists all column IDs in their new order.", + "consumes": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Reorder a project's columns", + "operationId": "userCurrentMoveProjectColumns", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/MoveProjectColumnsOption" + } + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, + "/user/projects/{id}/columns/{column_id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Get a project column", + "operationId": "userCurrentGetProjectColumn", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectColumn" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "delete": { + "description": "The default column cannot be deleted while it is still the column new issues land in.", + "tags": [ + "user" + ], + "summary": "Delete a project column", + "operationId": "userCurrentDeleteProjectColumn", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + }, + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Edit a project column", + "operationId": "userCurrentEditProjectColumn", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/EditProjectColumnOption" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectColumn" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, + "/user/projects/{id}/columns/{column_id}/default": { + "post": { + "description": "The default column is where newly assigned issues land.", + "tags": [ + "user" + ], + "summary": "Set a project's default column", + "operationId": "userCurrentSetDefaultProjectColumn", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/user/projects/{id}/columns/{column_id}/issues": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "List the issues in a project column", + "operationId": "userCurrentListProjectColumnIssues", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/IssueList" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/user/projects/{id}/columns/{column_id}/issues/{issue_id}": { + "post": { + "description": "Assigns the issue to the project if it is not a member yet, then places it in the column.", + "tags": [ + "user" + ], + "summary": "Add an issue to a project column", + "operationId": "userCurrentAddIssueToProjectColumn", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "global id of the issue, not the repository-local index", + "name": "issue_id", + "in": "path", + "required": true + } + ], + "responses": { + "201": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + }, + "delete": { + "tags": [ + "user" + ], + "summary": "Remove an issue from a project column", + "operationId": "userCurrentRemoveIssueFromProjectColumn", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "global id of the issue, not the repository-local index", + "name": "issue_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, + "/user/projects/{id}/issues/{issue_id}/move": { + "post": { + "consumes": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Move an issue between a project's columns", + "operationId": "userCurrentMoveProjectIssue", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "global id of the issue, not the repository-local index", + "name": "issue_id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/MoveProjectIssueOption" + } + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, "/user/repos": { "get": { "produces": [ @@ -21914,6 +24302,59 @@ } } }, + "/users/{username}/projects": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "List a user's projects", + "operationId": "userListProjects", + "parameters": [ + { + "type": "string", + "description": "username of the user", + "name": "username", + "in": "path", + "required": true + }, + { + "enum": [ + "open", + "closed", + "all" + ], + "type": "string", + "default": "open", + "description": "State of the project (open, closed, all)", + "name": "state", + "in": "query" + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectList" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/users/{username}/repos": { "get": { "produces": [ @@ -24820,6 +27261,53 @@ }, "x-go-package": "gitea.dev/modules/structs" }, + "CreateProjectColumnOption": { + "description": "CreateProjectColumnOption represents options for creating a project column", + "type": "object", + "required": [ + "title" + ], + "properties": { + "color": { + "description": "Column color in 6-digit hex format, e.g. #FF0000", + "type": "string", + "x-go-name": "Color" + }, + "title": { + "type": "string", + "x-go-name": "Title" + } + }, + "x-go-package": "gitea.dev/modules/structs" + }, + "CreateProjectOption": { + "description": "CreateProjectOption represents options for creating a project", + "type": "object", + "required": [ + "title" + ], + "properties": { + "card_type": { + "description": "Card type: \"text_only\" or \"images_and_text\"", + "type": "string", + "x-go-name": "CardType" + }, + "description": { + "type": "string", + "x-go-name": "Description" + }, + "template_type": { + "description": "Template type: \"none\", \"basic_kanban\" or \"bug_triage\"", + "type": "string", + "x-go-name": "TemplateType" + }, + "title": { + "type": "string", + "x-go-name": "Title" + } + }, + "x-go-package": "gitea.dev/modules/structs" + }, "CreatePullRequestOption": { "description": "CreatePullRequestOption options when creating a pull request", "type": "object", @@ -26096,6 +28584,57 @@ }, "x-go-package": "gitea.dev/modules/structs" }, + "EditProjectColumnOption": { + "description": "EditProjectColumnOption represents options for editing a project column", + "type": "object", + "properties": { + "color": { + "description": "Column color in 6-digit hex format, e.g. #FF0000", + "type": "string", + "x-go-name": "Color" + }, + "sorting": { + "description": "Position of the column within the project, between -128 and 127", + "type": "integer", + "format": "int64", + "x-go-name": "Sorting" + }, + "title": { + "type": "string", + "x-go-name": "Title" + } + }, + "x-go-package": "gitea.dev/modules/structs" + }, + "EditProjectOption": { + "description": "EditProjectOption represents options for editing a project", + "type": "object", + "properties": { + "card_type": { + "description": "Card type: \"text_only\" or \"images_and_text\"", + "type": "string", + "x-go-name": "CardType" + }, + "description": { + "type": "string", + "x-go-name": "Description" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed" + ], + "x-go-enum-desc": "open StateOpen pr is opened\nclosed StateClosed pr is closed", + "x-go-name": "State" + }, + "title": { + "type": "string", + "x-go-name": "Title" + } + }, + "x-go-package": "gitea.dev/modules/structs" + }, "EditPullRequestOption": { "description": "EditPullRequestOption options when modify pull request", "type": "object", @@ -28231,6 +30770,47 @@ }, "x-go-package": "gitea.dev/modules/structs" }, + "MoveProjectColumnsOption": { + "description": "MoveProjectColumnsOption represents options for reordering a project's columns", + "type": "object", + "required": [ + "column_ids" + ], + "properties": { + "column_ids": { + "description": "Every column ID of the project, in the desired left-to-right order", + "type": "array", + "items": { + "type": "integer", + "format": "int64" + }, + "x-go-name": "ColumnIDs" + } + }, + "x-go-package": "gitea.dev/modules/structs" + }, + "MoveProjectIssueOption": { + "description": "MoveProjectIssueOption represents options for moving an issue between columns", + "type": "object", + "required": [ + "column_id" + ], + "properties": { + "column_id": { + "description": "Target column to move the issue into", + "type": "integer", + "format": "int64", + "x-go-name": "ColumnID" + }, + "sorting": { + "description": "Position within the column, ascending. Omit to append. Negative values sort above\nthe rest, equal values are ordered newest first.", + "type": "integer", + "format": "int64", + "x-go-name": "Sorting" + } + }, + "x-go-package": "gitea.dev/modules/structs" + }, "NewIssuePinsAllowed": { "description": "NewIssuePinsAllowed represents an API response that says if new Issue Pins are allowed", "type": "object", @@ -28778,62 +31358,155 @@ "x-go-package": "gitea.dev/modules/structs" }, "Project": { - "description": "Project represents a project", + "description": "Projects track issues and pull requests, standalone note cards are not supported.", "type": "object", + "title": "Project represents a project.", "properties": { + "card_type": { + "description": "Card type: \"text_only\" or \"images_and_text\"", + "type": "string", + "x-go-name": "CardType" + }, "closed_at": { "type": "string", "format": "date-time", - "x-go-name": "Closed" + "x-go-name": "ClosedAt" }, "created_at": { "type": "string", "format": "date-time", - "x-go-name": "Created" + "x-go-name": "CreatedAt" + }, + "creator": { + "$ref": "#/definitions/User" }, "creator_id": { - "description": "CreatorID is the user who created the project", + "description": "Deprecated: use Creator instead", "type": "integer", "format": "int64", + "x-deprecated": true, "x-go-name": "CreatorID" }, "description": { - "description": "Description provides details about the project", "type": "string", "x-go-name": "Description" }, + "html_url": { + "type": "string", + "x-go-name": "HTMLURL" + }, "id": { - "description": "ID is the unique identifier for the project", "type": "integer", "format": "int64", "x-go-name": "ID" }, "is_closed": { - "description": "IsClosed indicates if the project is closed", + "description": "Deprecated: use State instead", "type": "boolean", + "x-deprecated": true, "x-go-name": "IsClosed" }, + "num_closed_issues": { + "type": "integer", + "format": "int64", + "x-go-name": "NumClosedIssues" + }, + "num_issues": { + "type": "integer", + "format": "int64", + "x-go-name": "NumIssues" + }, + "num_open_issues": { + "type": "integer", + "format": "int64", + "x-go-name": "NumOpenIssues" + }, "owner_id": { - "description": "OwnerID is the owner of the project (for org-level projects)", "type": "integer", "format": "int64", "x-go-name": "OwnerID" }, "repo_id": { - "description": "RepoID is the repository this project belongs to (for repo-level projects)", "type": "integer", "format": "int64", "x-go-name": "RepoID" }, + "state": { + "type": "string", + "enum": [ + "open", + "closed" + ], + "x-go-enum-desc": "open StateOpen pr is opened\nclosed StateClosed pr is closed", + "x-go-name": "State" + }, + "template_type": { + "description": "Template type: \"none\", \"basic_kanban\" or \"bug_triage\"", + "type": "string", + "x-go-name": "TemplateType" + }, + "title": { + "type": "string", + "x-go-name": "Title" + }, + "type": { + "description": "Project type: \"individual\", \"repository\" or \"organization\"", + "type": "string", + "x-go-name": "Type" + }, + "updated_at": { + "description": "null only for legacy rows that carry no update timestamp", + "type": "string", + "format": "date-time", + "x-go-name": "UpdatedAt" + } + }, + "x-go-package": "gitea.dev/modules/structs" + }, + "ProjectColumn": { + "description": "ProjectColumn represents a project column (board)", + "type": "object", + "properties": { + "color": { + "type": "string", + "x-go-name": "Color" + }, + "created_at": { + "type": "string", + "format": "date-time", + "x-go-name": "CreatedAt" + }, + "creator": { + "$ref": "#/definitions/User" + }, + "default": { + "type": "boolean", + "x-go-name": "Default" + }, + "id": { + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "project_id": { + "type": "integer", + "format": "int64", + "x-go-name": "ProjectID" + }, + "sorting": { + "type": "integer", + "format": "int64", + "x-go-name": "Sorting" + }, "title": { - "description": "Title is the title of the project", "type": "string", "x-go-name": "Title" }, "updated_at": { + "description": "null only for legacy rows that carry no update timestamp", "type": "string", "format": "date-time", - "x-go-name": "Updated" + "x-go-name": "UpdatedAt" } }, "x-go-package": "gitea.dev/modules/structs" @@ -31789,6 +34462,36 @@ } } }, + "Project": { + "description": "Project", + "schema": { + "$ref": "#/definitions/Project" + } + }, + "ProjectColumn": { + "description": "ProjectColumn", + "schema": { + "$ref": "#/definitions/ProjectColumn" + } + }, + "ProjectColumnList": { + "description": "ProjectColumnList", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/ProjectColumn" + } + } + }, + "ProjectList": { + "description": "ProjectList", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Project" + } + } + }, "PublicKey": { "description": "PublicKey", "schema": { diff --git a/tests/integration/api_project_test.go b/tests/integration/api_project_test.go new file mode 100644 index 00000000000..c3851745a4d --- /dev/null +++ b/tests/integration/api_project_test.go @@ -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) +} diff --git a/tests/integration/project_test.go b/tests/integration/project_test.go index 3515c57e5e3..0ace73ea4ac 100644 --- a/tests/integration/project_test.go +++ b/tests/integration/project_test.go @@ -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)