diff --git a/models/issues/issue_search.go b/models/issues/issue_search.go index f905e629e380a..554b11e4bfeaa 100644 --- a/models/issues/issue_search.go +++ b/models/issues/issue_search.go @@ -22,7 +22,11 @@ import ( "xorm.io/xorm" ) -const ScopeSortPrefix = "scope-" +const ( + ScopeSortPrefix = "scope-" + // SortTypeProjectColumnSorting orders issues within a project column by their project_issue.sorting value. + SortTypeProjectColumnSorting = "project-column-sorting" +) // IssuesOptions represents options of an issue. type IssuesOptions struct { //nolint:revive // export stutter @@ -122,7 +126,7 @@ func applySorts(sess *xorm.Session, sortType string, priorityRepoID int64) { "ELSE 2 END ASC", priorityRepoID). Desc("issue.created_unix"). Desc("issue.id") - case "project-column-sorting": + case SortTypeProjectColumnSorting: sess.Asc("project_issue.sorting").Desc("issue.created_unix").Desc("issue.id") default: sess.Desc("issue.created_unix").Desc("issue.id") diff --git a/models/project/column.go b/models/project/column.go index 9c9abb4599dbe..6f4452984e7f3 100644 --- a/models/project/column.go +++ b/models/project/column.go @@ -337,20 +337,6 @@ func SetDefaultColumn(ctx context.Context, projectID, columnID int64) error { }) } -func GetColumnsByIDs(ctx context.Context, projectID int64, columnsIDs []int64) (ColumnList, error) { - columns := make([]*Column, 0, 5) - if len(columnsIDs) == 0 { - return columns, nil - } - if err := db.GetEngine(ctx). - Where("project_id =?", projectID). - In("id", columnsIDs). - OrderBy("sorting").Find(&columns); err != nil { - return nil, err - } - return columns, nil -} - // MoveColumnsOnProject sorts columns in a project func MoveColumnsOnProject(ctx context.Context, project *Project, sortedColumnIDs map[int64]int64) error { return db.WithTx(ctx, func(ctx context.Context) error { diff --git a/models/project/column_list.go b/models/project/column_list.go new file mode 100644 index 0000000000000..2016db3357d9d --- /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" + + "code.gitea.io/gitea/models/db" +) + +// CountProjectColumns returns the total number of columns for a project +func CountProjectColumns(ctx context.Context, projectID int64) (int64, error) { + return db.GetEngine(ctx).Where("project_id=?", projectID).Count(&Column{}) +} + +// GetProjectColumns returns a list of columns for a project with pagination +func GetProjectColumns(ctx context.Context, projectID int64, opts db.ListOptions) (ColumnList, error) { + columns := make([]*Column, 0, opts.PageSize) + s := db.GetEngine(ctx).Where("project_id=?", projectID).OrderBy("sorting, id") + if !opts.IsListAll() { + db.SetSessionPagination(s, &opts) + } + if err := s.Find(&columns); err != nil { + return nil, err + } + return columns, nil +} + +func GetColumnsByIDs(ctx context.Context, projectID int64, columnsIDs []int64) (ColumnList, error) { + columns := make([]*Column, 0, len(columnsIDs)) + if len(columnsIDs) == 0 { + return columns, nil + } + if err := db.GetEngine(ctx). + Where("project_id =?", projectID). + In("id", columnsIDs). + OrderBy("sorting").Find(&columns); err != nil { + return nil, err + } + return columns, nil +} diff --git a/models/project/column_list_test.go b/models/project/column_list_test.go new file mode 100644 index 0000000000000..adc134725e88b --- /dev/null +++ b/models/project/column_list_test.go @@ -0,0 +1,66 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package project + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + + "github.com/stretchr/testify/assert" +) + +func TestProjectColumns(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + t.Run("CountProjectColumns", testCountProjectColumns) + t.Run("GetProjectColumns", testGetProjectColumns) + t.Run("GetColumnsByIDs", testGetColumnsByIDs) +} + +func testCountProjectColumns(t *testing.T) { + project, err := GetProjectByID(t.Context(), 1) + assert.NoError(t, err) + + count, err := CountProjectColumns(t.Context(), project.ID) + assert.NoError(t, err) + assert.EqualValues(t, 3, count) +} + +func testGetProjectColumns(t *testing.T) { + project, err := GetProjectByID(t.Context(), 1) + assert.NoError(t, err) + + // Page 1, limit 2 — returns first 2 columns + page1, err := GetProjectColumns(t.Context(), project.ID, db.ListOptions{Page: 1, PageSize: 2}) + assert.NoError(t, err) + assert.Len(t, page1, 2) + + // Page 2, limit 2 — returns remaining column + page2, err := GetProjectColumns(t.Context(), project.ID, db.ListOptions{Page: 2, PageSize: 2}) + assert.NoError(t, err) + assert.Len(t, page2, 1) + + // Page 1 and page 2 together cover all columns with no overlap + allIDs := make(map[int64]bool) + for _, c := range append(page1, page2...) { + assert.False(t, allIDs[c.ID], "duplicate column ID %d across pages", c.ID) + allIDs[c.ID] = true + } + assert.Len(t, allIDs, 3) +} + +func testGetColumnsByIDs(t *testing.T) { + project, err := GetProjectByID(t.Context(), 1) + assert.NoError(t, err) + + columns, err := GetColumnsByIDs(t.Context(), project.ID, []int64{1, 3, 4}) + assert.NoError(t, err) + assert.Len(t, columns, 2) + assert.ElementsMatch(t, []int64{1, 3}, []int64{columns[0].ID, columns[1].ID}) + + empty, err := GetColumnsByIDs(t.Context(), project.ID, nil) + assert.NoError(t, err) + assert.Empty(t, empty) +} diff --git a/models/project/column_test.go b/models/project/column_test.go index 6437a764ed3f5..d6196989655b0 100644 --- a/models/project/column_test.go +++ b/models/project/column_test.go @@ -7,6 +7,7 @@ import ( "fmt" "testing" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/unittest" "github.com/stretchr/testify/assert" @@ -79,7 +80,7 @@ func Test_MoveColumnsOnProject(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) project1 := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1}) - columns, err := project1.GetColumns(t.Context()) + columns, err := GetProjectColumns(t.Context(), project1.ID, db.ListOptionsAll) assert.NoError(t, err) assert.Len(t, columns, 3) assert.EqualValues(t, 0, columns[0].Sorting) // even if there is no default sorting, the code should also work @@ -93,7 +94,7 @@ func Test_MoveColumnsOnProject(t *testing.T) { }) assert.NoError(t, err) - columnsAfter, err := project1.GetColumns(t.Context()) + columnsAfter, err := GetProjectColumns(t.Context(), project1.ID, db.ListOptionsAll) assert.NoError(t, err) assert.Len(t, columnsAfter, 3) assert.Equal(t, columns[1].ID, columnsAfter[0].ID) @@ -105,7 +106,7 @@ func Test_NewColumn(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) project1 := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1}) - columns, err := project1.GetColumns(t.Context()) + columns, err := GetProjectColumns(t.Context(), project1.ID, db.ListOptionsAll) assert.NoError(t, err) assert.Len(t, columns, 3) diff --git a/models/project/issue.go b/models/project/issue.go index c89f5243054b6..9af3254aa0388 100644 --- a/models/project/issue.go +++ b/models/project/issue.go @@ -33,6 +33,14 @@ func deleteProjectIssuesByProjectID(ctx context.Context, projectID int64) error return err } +func IsIssueInColumn(ctx context.Context, issueID, projectID, columnID int64) (bool, error) { + return db.GetEngine(ctx).Exist(&ProjectIssue{ + IssueID: issueID, + ProjectID: projectID, + ProjectColumnID: columnID, + }) +} + // GetColumnIssueNextSorting returns the sorting value to append an issue at the end of the column. func GetColumnIssueNextSorting(ctx context.Context, projectID, columnID int64) (int64, error) { res := struct { @@ -87,3 +95,19 @@ func DeleteAllProjectIssueByIssueIDsAndProjectIDs(ctx context.Context, issueIDs, _, err := db.GetEngine(ctx).In("project_id", projectIDs).In("issue_id", issueIDs).Delete(&ProjectIssue{}) return err } + +// MoveIssueToColumn moves a single issue to a specific column within a project. +func MoveIssueToColumn(ctx context.Context, issueID, projectID, columnID int64) error { + nextSorting, err := GetColumnIssueNextSorting(ctx, projectID, columnID) + if err != nil { + return err + } + _, err = db.GetEngine(ctx). + Where("issue_id=? AND project_id=?", issueID, projectID). + Cols("project_board_id", "sorting"). + Update(&ProjectIssue{ + ProjectColumnID: columnID, + Sorting: nextSorting, + }) + return err +} diff --git a/modules/indexer/issues/dboptions.go b/modules/indexer/issues/dboptions.go index f4582d38dd87d..213a94284c917 100644 --- a/modules/indexer/issues/dboptions.go +++ b/modules/indexer/issues/dboptions.go @@ -97,7 +97,7 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp searchOpt.SortBy = SortByDeadlineAsc case "farduedate": searchOpt.SortBy = SortByDeadlineDesc - case "priority", "priorityrepo", "project-column-sorting": + case "priority", "priorityrepo", issues_model.SortTypeProjectColumnSorting: // Unsupported sort type for search fallthrough default: diff --git a/modules/structs/project.go b/modules/structs/project.go index 5feb122767bb9..372d58ef976f1 100644 --- a/modules/structs/project.go +++ b/modules/structs/project.go @@ -7,27 +7,101 @@ import ( "time" ) -// Project represents a project +// Project represents a project. +// +// Gitea projects can only contain issues — note cards and pull requests are +// not modeled as project items. +// // swagger:model type Project struct { - // ID is the unique identifier for the project - ID int64 `json:"id"` - // Title is the title of the project - Title string `json:"title"` - // Description provides details about the project - Description string `json:"description"` - // OwnerID is the owner of the project (for org-level projects) - OwnerID int64 `json:"owner_id,omitempty"` - // RepoID is the repository this project belongs to (for repo-level projects) - RepoID int64 `json:"repo_id,omitempty"` - // CreatorID is the user who created the project - CreatorID int64 `json:"creator_id"` - // IsClosed indicates if the project is closed - IsClosed bool `json:"is_closed"` + ID int64 `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + OwnerID int64 `json:"owner_id,omitempty"` + RepoID int64 `json:"repo_id,omitempty"` + Creator *User `json:"creator,omitempty"` + State StateType `json:"state"` + // Template type: "none", "basic_kanban" or "bug_triage" + TemplateType string `json:"template_type"` + // Card type: "text_only" or "images_and_text" + CardType string `json:"card_type"` + // Project type: "individual", "repository" or "organization" + Type string `json:"type"` + NumOpenIssues int64 `json:"num_open_issues,omitempty"` + NumClosedIssues int64 `json:"num_closed_issues,omitempty"` + NumIssues int64 `json:"num_issues,omitempty"` + // swagger:strfmt date-time + CreatedAt time.Time `json:"created_at"` + // swagger:strfmt date-time + UpdatedAt time.Time `json:"updated_at"` // swagger:strfmt date-time - Created time.Time `json:"created_at"` + ClosedAt *time.Time `json:"closed_at,omitempty"` + HTMLURL string `json:"html_url,omitempty"` +} + +// CreateProjectOption represents options for creating a project +// swagger:model +type CreateProjectOption struct { + // required: true + Title string `json:"title" binding:"Required"` + Description string `json:"description"` + // Template type: "none", "basic_kanban" or "bug_triage" + TemplateType string `json:"template_type"` + // Card type: "text_only" or "images_and_text" + CardType string `json:"card_type"` +} + +// EditProjectOption represents options for editing a project +// swagger:model +type EditProjectOption struct { + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + // Card type: "text_only" or "images_and_text" + CardType *string `json:"card_type,omitempty"` + State *StateType `json:"state,omitempty"` +} + +// ProjectColumn represents a project column (board) +// swagger:model +type ProjectColumn struct { + ID int64 `json:"id"` + Title string `json:"title"` + Default bool `json:"default"` + Sorting int `json:"sorting"` + Color string `json:"color,omitempty"` + ProjectID int64 `json:"project_id"` + Creator *User `json:"creator,omitempty"` + NumIssues int64 `json:"num_issues,omitempty"` // swagger:strfmt date-time - Updated time.Time `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` // swagger:strfmt date-time - Closed *time.Time `json:"closed_at,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +// CreateProjectColumnOption represents options for creating a project column +// swagger:model +type CreateProjectColumnOption struct { + // required: true + Title string `json:"title" binding:"Required"` + // Column color in 6-digit hex format, e.g. #FF0000 + Color string `json:"color,omitempty"` +} + +// EditProjectColumnOption represents options for editing a project column +// swagger:model +type EditProjectColumnOption struct { + Title *string `json:"title,omitempty"` + // Column color in 6-digit hex format, e.g. #FF0000 + Color *string `json:"color,omitempty"` + Sorting *int `json:"sorting,omitempty"` +} + +// MoveProjectIssueOption represents options for moving an issue between columns +// swagger:model +type MoveProjectIssueOption struct { + // Target column to move the issue into + // required: true + ColumnID int64 `json:"column_id" binding:"Required"` + // Optional sorting position within the target column + Sorting *int64 `json:"sorting,omitempty"` } diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index a8bfa0965ece6..4ec826b8c201f 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1575,6 +1575,26 @@ func Routes() *web.Router { Patch(reqToken(), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), bind(api.EditMilestoneOption{}), repo.EditMilestone). Delete(reqToken(), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), repo.DeleteMilestone) }) + m.Group("/projects", func() { + m.Combo("").Get(repo.ListProjects). + Post(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.CreateProjectOption{}), repo.CreateProject) + m.Group("/{id}", func() { + m.Combo("").Get(repo.GetProject). + Patch(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.EditProjectOption{}), repo.EditProject). + Delete(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, repo.DeleteProject) + m.Combo("/columns").Get(repo.ListProjectColumns). + Post(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.CreateProjectColumnOption{}), repo.CreateProjectColumn) + m.Group("/columns/{column_id}", func() { + m.Combo(""). + Patch(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.EditProjectColumnOption{}), repo.EditProjectColumn). + Delete(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, repo.DeleteProjectColumn) + m.Get("/issues", repo.ListProjectColumnIssues) + m.Post("/issues/{issue_id}", reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, repo.AddIssueToProjectColumn) + m.Delete("/issues/{issue_id}", reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, repo.RemoveIssueFromProjectColumn) + }) + m.Post("/issues/{issue_id}/move", reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.MoveProjectIssueOption{}), repo.MoveProjectIssue) + }) + }, reqRepoReader(unit.TypeProjects)) }, repoAssignment(), checkTokenPublicOnly()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue)) diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go new file mode 100644 index 0000000000000..1cbea1c400620 --- /dev/null +++ b/routers/api/v1/repo/project.go @@ -0,0 +1,1002 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "net/http" + "slices" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + project_model "code.gitea.io/gitea/models/project" + "code.gitea.io/gitea/modules/optional" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/routers/common" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + project_service "code.gitea.io/gitea/services/projects" +) + +func getRepoProjectByID(ctx *context.APIContext) *project_model.Project { + project, err := project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return nil + } + project.Repo = ctx.Repo.Repository + return project +} + +func getRepoProjectColumn(ctx *context.APIContext) (*project_model.Project, *project_model.Column) { + column, err := project_model.GetColumn(ctx, ctx.PathParamInt64("column_id")) + if err != nil { + if project_model.IsErrProjectColumnNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return nil, nil + } + project, err := project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, column.ProjectID) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return nil, nil + } + if project.ID != ctx.PathParamInt64("id") { + ctx.APIErrorNotFound() + return nil, nil + } + project.Repo = ctx.Repo.Repository + return project, column +} + +func rejectIfClosed(ctx *context.APIContext, project *project_model.Project) bool { + if project.IsClosed { + ctx.APIError(http.StatusForbidden, "project is closed") + return true + } + return false +} + +func validateColumnColor(ctx *context.APIContext, color string) bool { + if color == "" { + return true + } + if !project_model.ColumnColorPattern.MatchString(color) { + ctx.APIError(http.StatusUnprocessableEntity, "color must be a 6-digit hex string like #FF0000") + return false + } + return true +} + +// ListProjects lists all projects in a repository +func ListProjects(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/projects repository repoListProjects + // --- + // summary: List projects in a repository + // description: Gitea projects only contain issues — note cards and pull requests are not modeled as project items. + // 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 + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/ProjectList" + // "404": + // "$ref": "#/responses/notFound" + + isClosed := common.ParseIssueFilterStateIsClosed(ctx.FormTrim("state")) + + listOptions := utils.GetListOptions(ctx) + + projects, count, err := db.FindAndCount[project_model.Project](ctx, project_model.SearchOptions{ + ListOptions: listOptions, + RepoID: ctx.Repo.Repository.ID, + IsClosed: isClosed, + Type: project_model.TypeRepository, + }) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + if err := project_service.LoadIssueNumbersForProjects(ctx, projects, ctx.Doer); err != nil { + ctx.APIErrorInternal(err) + return + } + + for _, p := range projects { + p.Repo = ctx.Repo.Repository + } + + ctx.SetLinkHeader(count, listOptions.PageSize) + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, convert.ToProjectList(ctx, projects, ctx.Doer)) +} + +// GetProject gets a single project +func GetProject(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/projects/{id} repository repoGetProject + // --- + // summary: Get a single project + // description: Gitea projects only contain issues — note cards and pull requests are not modeled as project items. + // 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" + + project := getRepoProjectByID(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)) +} + +// CreateProject creates a new project +func CreateProject(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/projects repository repoCreateProject + // --- + // summary: Create a new 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: body + // in: body + // schema: + // "$ref": "#/definitions/CreateProjectOption" + // responses: + // "201": + // "$ref": "#/responses/Project" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + 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 + } + + p := &project_model.Project{ + RepoID: ctx.Repo.Repository.ID, + Title: form.Title, + Description: form.Description, + CreatorID: ctx.Doer.ID, + TemplateType: templateType, + CardType: cardType, + Type: project_model.TypeRepository, + } + + if err := project_model.NewProject(ctx, p); err != nil { + ctx.APIErrorInternal(err) + return + } + + p.Repo = ctx.Repo.Repository + ctx.JSON(http.StatusCreated, convert.ToProject(ctx, p, ctx.Doer)) +} + +// EditProject updates a project +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" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + project := getRepoProjectByID(ctx) + if ctx.Written() { + return + } + + form := web.GetForm(ctx).(*api.EditProjectOption) + + 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)) +} + +// DeleteProject deletes a project +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" + + project := getRepoProjectByID(ctx) + if ctx.Written() { + return + } + + if err := project_model.DeleteProjectByID(ctx, project.ID); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// ListProjectColumns lists all columns in a project +func ListProjectColumns(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/projects/{id}/columns repository repoListProjectColumns + // --- + // summary: List columns in 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 + // - 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 := getRepoProjectByID(ctx) + if ctx.Written() { + return + } + + total, err := project_model.CountProjectColumns(ctx, project.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + listOptions := utils.GetListOptions(ctx) + columns, err := project_model.GetProjectColumns(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)) +} + +// CreateProjectColumn creates a new column in a project +func CreateProjectColumn(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/projects/{id}/columns repository repoCreateProjectColumn + // --- + // summary: Create a new 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" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + project := getRepoProjectByID(ctx) + if ctx.Written() { + return + } + if rejectIfClosed(ctx, project) { + return + } + + form := web.GetForm(ctx).(*api.CreateProjectColumnOption) + if !validateColumnColor(ctx, form.Color) { + return + } + + 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.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusCreated, convert.ToProjectColumn(ctx, column, ctx.Doer)) +} + +// EditProjectColumn updates a column +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" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + project, column := getRepoProjectColumn(ctx) + if ctx.Written() { + return + } + if rejectIfClosed(ctx, project) { + return + } + + form := web.GetForm(ctx).(*api.EditProjectColumnOption) + + if form.Color != nil && !validateColumnColor(ctx, *form.Color) { + return + } + + if form.Title != nil { + column.Title = *form.Title + } + if form.Color != nil { + column.Color = *form.Color + } + if form.Sorting != nil { + if *form.Sorting < -128 || *form.Sorting > 127 { + ctx.APIError(http.StatusBadRequest, "sorting value out of range, must be between -128 and 127") + return + } + column.Sorting = int8(*form.Sorting) + } + + if err := project_model.UpdateColumn(ctx, column); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusOK, convert.ToProjectColumn(ctx, column, ctx.Doer)) +} + +// DeleteProjectColumn deletes a column +func DeleteProjectColumn(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/projects/{id}/columns/{column_id} repository repoDeleteProjectColumn + // --- + // summary: Delete 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 + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + project, column := getRepoProjectColumn(ctx) + if ctx.Written() { + return + } + if rejectIfClosed(ctx, project) { + return + } + + if err := project_model.DeleteColumnByID(ctx, column.ID); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// ListProjectColumnIssues lists all issues in a project column +func ListProjectColumnIssues(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/projects/{id}/columns/{column_id}/issues repository repoListProjectColumnIssues + // --- + // summary: List issues in a project column + // description: Gitea projects only contain issues — note cards and pull requests are not modeled as project items. + // 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" + + _, column := getRepoProjectColumn(ctx) + if ctx.Written() { + return + } + + listOptions := utils.GetListOptions(ctx) + issuesOpts := &issues_model.IssuesOptions{ + Paginator: &listOptions, + RepoIDs: []int64{ctx.Repo.Repository.ID}, + ProjectIDs: []int64{column.ProjectID}, + SortType: issues_model.SortTypeProjectColumnSorting, + } + + 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)) +} + +// AddIssueToProjectColumn adds an issue to a project column +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: Gitea projects only contain issues — note cards and pull requests cannot be added. + // 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: issue_id + // in: path + // description: id of the issue + // type: integer + // format: int64 + // required: true + // responses: + // "201": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + assignIssueToProjectColumn(ctx, true) +} + +// RemoveIssueFromProjectColumn remove an issue from a project column +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 + // 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: issue_id + // in: path + // description: id of the issue + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + assignIssueToProjectColumn(ctx, false) +} + +// assignIssueToProjectColumn assigns an issue to a project column when add is true, +// or removes the issue from any project assignment when add is false. +func assignIssueToProjectColumn(ctx *context.APIContext, add bool) { + project, column := getRepoProjectColumn(ctx) + if ctx.Written() { + return + } + if rejectIfClosed(ctx, project) { + return + } + + issue, err := issues_model.GetIssueByRepoID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("issue_id")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return + } + + if err := issue.LoadProjects(ctx); err != nil { + ctx.APIErrorInternal(err) + return + } + currentProjectIDs := make([]int64, 0, len(issue.Projects)) + for _, p := range issue.Projects { + currentProjectIDs = append(currentProjectIDs, p.ID) + } + if !add { + exists, err := project_model.IsIssueInColumn(ctx, issue.ID, column.ProjectID, column.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + if !exists { + ctx.APIErrorNotFound() + return + } + newProjectIDs := make([]int64, 0, len(currentProjectIDs)) + for _, id := range currentProjectIDs { + if id != column.ProjectID { + newProjectIDs = append(newProjectIDs, id) + } + } + if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, newProjectIDs); err != nil { + ctx.APIErrorInternal(err) + return + } + } else { + // Check if issue is already in this project + alreadyInProject := slices.Contains(currentProjectIDs, column.ProjectID) + if !alreadyInProject { + // Add to project first (lands in default column) + newProjectIDs := make([]int64, len(currentProjectIDs)+1) + copy(newProjectIDs, currentProjectIDs) + newProjectIDs[len(currentProjectIDs)] = column.ProjectID + if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, newProjectIDs); err != nil { + ctx.APIErrorInternal(err) + return + } + } + // Move to target column + if err := project_model.MoveIssueToColumn(ctx, issue.ID, column.ProjectID, column.ID); err != nil { + ctx.APIErrorInternal(err) + return + } + } + + if add { + ctx.Status(http.StatusCreated) + } else { + ctx.Status(http.StatusNoContent) + } +} + +// MoveProjectIssue moves an issue between columns of the same project (and optionally sets sorting). +func MoveProjectIssue(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/projects/{id}/issues/{issue_id}/move repository repoMoveProjectIssue + // --- + // summary: Move an issue between columns of a project + // description: Atomically moves an existing project issue into a different column, optionally setting its sorting position. + // 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: issue_id + // in: path + // description: id of the issue + // type: integer + // format: int64 + // 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" + + project := getRepoProjectByID(ctx) + if ctx.Written() { + return + } + if rejectIfClosed(ctx, project) { + return + } + + form := web.GetForm(ctx).(*api.MoveProjectIssueOption) + + column, err := project_model.GetColumn(ctx, form.ColumnID) + if err != nil { + if project_model.IsErrProjectColumnNotExist(err) { + ctx.APIError(http.StatusUnprocessableEntity, "target column does not exist") + } else { + ctx.APIErrorInternal(err) + } + return + } + if column.ProjectID != project.ID { + ctx.APIError(http.StatusUnprocessableEntity, "target column does not belong to this project") + return + } + + issue, err := issues_model.GetIssueByRepoID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("issue_id")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return + } + + var sorting int64 + if form.Sorting != nil { + sorting = *form.Sorting + } else { + next, err := project_model.GetColumnIssueNextSorting(ctx, project.ID, column.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + sorting = next + } + + if err := project_service.MoveIssuesOnProjectColumn(ctx, ctx.Doer, column, map[int64]int64{sorting: issue.ID}); err != nil { + if errors.Is(err, project_service.ErrIssueNotInProject) { + ctx.APIErrorNotFound() + return + } + ctx.APIErrorInternal(err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 1a442d11466d1..1598cc039b483 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -233,4 +233,17 @@ 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 + MoveProjectIssueOption api.MoveProjectIssueOption } diff --git a/routers/api/v1/swagger/project.go b/routers/api/v1/swagger/project.go new file mode 100644 index 0000000000000..99f367beb0209 --- /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 "code.gitea.io/gitea/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 ae32be05757de..f3cc970162d7a 100644 --- a/routers/web/org/projects.go +++ b/routers/web/org/projects.go @@ -309,7 +309,7 @@ func ViewProject(ctx *context.Context) { return } - columns, err := project.GetColumns(ctx) + columns, err := project_model.GetProjectColumns(ctx, project.ID, db.ListOptionsAll) if err != nil { ctx.ServerError("GetProjectColumns", err) return diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index 8690e754637ec..a517498fbc0fd 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -449,6 +449,14 @@ func UpdateIssueProject(ctx *context.Context) { } projectIDs := ctx.FormStringInt64s("id") + // Remove zero values - id=0 means "remove from all projects" + filteredIDs := make([]int64, 0, len(projectIDs)) + for _, id := range projectIDs { + if id != 0 { + filteredIDs = append(filteredIDs, id) + } + } + projectIDs = filteredIDs var failedIssues []int64 for _, issue := range issues { if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectIDs); err != nil { diff --git a/services/convert/issue.go b/services/convert/issue.go index 8e3adaa82dff3..08cd9d71c8bd9 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 b66de746cad21..cd6109fd196d9 100644 --- a/services/convert/project.go +++ b/services/convert/project.go @@ -4,34 +4,179 @@ package convert import ( + "context" + "fmt" + project_model "code.gitea.io/gitea/models/project" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/container" api "code.gitea.io/gitea/modules/structs" ) -// 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" + } +} + +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) } - if p.IsClosed && p.ClosedDateUnix > 0 { - apiProject.Closed = p.ClosedDateUnix.AsTimePtr() +} + +func ProjectCardTypeToString(t project_model.CardType) string { + switch t { + case project_model.CardTypeImagesAndText: + return "images_and_text" + default: + return "text_only" } - return apiProject } -// ToAPIProjectList converts a list of Projects to API format -func ToAPIProjectList(projects []*project_model.Project) []*api.Project { +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 creators for the given projects + columns and +// returns a map keyed by user ID. Errors are surfaced; missing users are silently +// skipped (their creator field stays nil), matching the convention of other list +// converters that tolerate deleted users. +func loadProjectCreators(ctx context.Context, projects []*project_model.Project, columns []*project_model.Column) (map[int64]*user_model.User, error) { + 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 map[int64]*user_model.User{}, nil + } + return user_model.GetUsersMapByIDs(ctx, idSet.Values()) +} + +// 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 := api.StateOpen + if p.IsClosed { + state = api.StateClosed + } + + project := &api.Project{ + ID: p.ID, + Title: p.Title, + Description: p.Description, + OwnerID: p.OwnerID, + RepoID: p.RepoID, + State: state, + 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: p.UpdatedUnix.AsTime(), + } + + if p.ClosedDateUnix > 0 { + t := p.ClosedDateUnix.AsTime() + project.ClosedAt = &t + } + + if creator, ok := creators[p.CreatorID]; ok { + project.Creator = ToUser(ctx, creator, doer) + } + + if p.Type == project_model.TypeRepository && p.Repo != nil { + project.HTMLURL = p.Repo.HTMLURL() + fmt.Sprintf("/projects/%d", p.ID) + } else if p.Owner != nil { + project.HTMLURL = p.Owner.HTMLURL(ctx) + fmt.Sprintf("/-/projects/%d", p.ID) + } + + 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, + NumIssues: column.NumIssues, + CreatedAt: column.CreatedUnix.AsTime(), + UpdatedAt: column.UpdatedUnix.AsTime(), + } + 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 5c691a95eb878..3655a000ce5e1 100644 --- a/services/projects/issue.go +++ b/services/projects/issue.go @@ -17,6 +17,10 @@ import ( "code.gitea.io/gitea/modules/optional" ) +// ErrIssueNotInProject is returned when MoveIssuesOnProjectColumn is asked to move +// issues that aren't yet attached to the column's project. +var ErrIssueNotInProject = errors.New("all issues have to be added to a project first") + // 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 { @@ -32,7 +36,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) @@ -63,7 +67,6 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum if err != nil { return err } - projectColumnID := projectColumnMap[column.ProjectID] if projectColumnID != column.ID { @@ -82,16 +85,7 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum } } - // Update the column and sorting for this specific issue in this specific project. - // 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=?", column.ID, sorting, issueID) if err != nil { return err } @@ -129,7 +123,7 @@ func LoadIssuesAssigneesForProject(ctx context.Context, issuesMap map[int64]issu func LoadIssuesFromProject(ctx context.Context, project *project_model.Project, opts *issues_model.IssuesOptions) (results map[int64]issues_model.IssueList, _ error) { issueList, err := issues_model.Issues(ctx, opts.Copy(func(o *issues_model.IssuesOptions) { o.ProjectIDs = []int64{project.ID} - o.SortType = "project-column-sorting" + o.SortType = issues_model.SortTypeProjectColumnSorting })) if err != nil { return nil, err diff --git a/services/projects/project.go b/services/projects/project.go new file mode 100644 index 0000000000000..8d4296cfddfb5 --- /dev/null +++ b/services/projects/project.go @@ -0,0 +1,44 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package project + +import ( + "context" + + "code.gitea.io/gitea/models/db" + project_model "code.gitea.io/gitea/models/project" + "code.gitea.io/gitea/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 { + if opts.Title.Has() { + project.Title = opts.Title.Value() + } + if opts.Description.Has() { + project.Description = opts.Description.Value() + } + if opts.CardType.Has() { + project.CardType = opts.CardType.Value() + } + 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_json.tmpl b/templates/swagger/v1_json.tmpl index 26d45940f2572..86d201a984029 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -13948,6 +13948,756 @@ } } }, + "/repos/{owner}/{repo}/projects": { + "get": { + "description": "Gitea projects only contain issues — note cards and pull requests are not modeled as project items.", + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "List projects in a repository", + "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", + "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 new project", + "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" + } + } + } + }, + "/repos/{owner}/{repo}/projects/{id}": { + "get": { + "description": "Gitea projects only contain issues — note cards and pull requests are not modeled as project items.", + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Get a single 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" + } + } + }, + "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" + } + } + } + }, + "/repos/{owner}/{repo}/projects/{id}/columns": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "List columns in a project", + "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 new 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" + } + } + } + }, + "/repos/{owner}/{repo}/projects/{id}/columns/{column_id}": { + "delete": { + "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" + } + } + }, + "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" + } + } + } + }, + "/repos/{owner}/{repo}/projects/{id}/columns/{column_id}/issues": { + "get": { + "description": "Gitea projects only contain issues — note cards and pull requests are not modeled as project items.", + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "List 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": "Gitea projects only contain issues — note cards and pull requests cannot be added.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "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": "id of the issue", + "name": "issue_id", + "in": "path", + "required": true + } + ], + "responses": { + "201": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + }, + "delete": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "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": "id of the issue", + "name": "issue_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, + "/repos/{owner}/{repo}/projects/{id}/issues/{issue_id}/move": { + "post": { + "description": "Atomically moves an existing project issue into a different column, optionally setting its sorting position.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Move an issue between columns of a project", + "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": "id of the issue", + "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" + } + } + } + }, "/repos/{owner}/{repo}/pulls": { "get": { "produces": [ @@ -24060,6 +24810,53 @@ }, "x-go-package": "code.gitea.io/gitea/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": "code.gitea.io/gitea/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": "code.gitea.io/gitea/modules/structs" + }, "CreatePullRequestOption": { "description": "CreatePullRequestOption options when creating a pull request", "type": "object", @@ -25247,6 +26044,56 @@ }, "x-go-package": "code.gitea.io/gitea/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": { + "type": "integer", + "format": "int64", + "x-go-name": "Sorting" + }, + "title": { + "type": "string", + "x-go-name": "Title" + } + }, + "x-go-package": "code.gitea.io/gitea/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 StateOpen pr is opened\nclosed StateClosed StateClosed pr is closed", + "x-go-name": "State" + }, + "title": { + "type": "string", + "x-go-name": "Title" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "EditPullRequestOption": { "description": "EditPullRequestOption options when modify pull request", "type": "object", @@ -27334,6 +28181,28 @@ }, "x-go-package": "code.gitea.io/gitea/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": "Optional sorting position within the target column", + "type": "integer", + "format": "int64", + "x-go-name": "Sorting" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "NewIssuePinsAllowed": { "description": "NewIssuePinsAllowed represents an API response that says if new Issue Pins are allowed", "type": "object", @@ -28015,62 +28884,145 @@ "x-go-package": "code.gitea.io/gitea/modules/structs" }, "Project": { - "description": "Project represents a project", + "description": "Gitea projects can only contain issues — note cards and pull requests are\nnot modeled as project items.", "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_id": { - "description": "CreatorID is the user who created the project", - "type": "integer", - "format": "int64", - "x-go-name": "CreatorID" + "creator": { + "$ref": "#/definitions/User" }, "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", - "type": "boolean", - "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 StateOpen pr is opened\nclosed StateClosed 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": { + "type": "string", + "format": "date-time", + "x-go-name": "UpdatedAt" + } + }, + "x-go-package": "code.gitea.io/gitea/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" + }, + "num_issues": { + "type": "integer", + "format": "int64", + "x-go-name": "NumIssues" + }, + "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": { "type": "string", "format": "date-time", - "x-go-name": "Updated" + "x-go-name": "UpdatedAt" } }, "x-go-package": "code.gitea.io/gitea/modules/structs" @@ -30904,6 +31856,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": { @@ -31371,7 +32353,7 @@ "parameterBodies": { "description": "parameterBodies", "schema": { - "$ref": "#/definitions/LockIssueOption" + "$ref": "#/definitions/MoveProjectIssueOption" } }, "redirect": { diff --git a/templates/swagger/v1_openapi3_json.tmpl b/templates/swagger/v1_openapi3_json.tmpl index 33adff75e0da0..687e08b832a72 100644 --- a/templates/swagger/v1_openapi3_json.tmpl +++ b/templates/swagger/v1_openapi3_json.tmpl @@ -980,6 +980,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": { @@ -1690,7 +1736,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LockIssueOption" + "$ref": "#/components/schemas/MoveProjectIssueOption" } } }, @@ -4317,6 +4363,53 @@ "type": "object", "x-go-package": "code.gitea.io/gitea/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": "code.gitea.io/gitea/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": "code.gitea.io/gitea/modules/structs" + }, "CreatePullRequestOption": { "description": "CreatePullRequestOption options when creating a pull request", "properties": { @@ -5476,6 +5569,50 @@ "type": "object", "x-go-package": "code.gitea.io/gitea/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": { + "format": "int64", + "type": "integer", + "x-go-name": "Sorting" + }, + "title": { + "type": "string", + "x-go-name": "Title" + } + }, + "type": "object", + "x-go-package": "code.gitea.io/gitea/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": "code.gitea.io/gitea/modules/structs" + }, "EditPullRequestOption": { "description": "EditPullRequestOption options when modify pull request", "properties": { @@ -7562,6 +7699,28 @@ "type": "object", "x-go-package": "code.gitea.io/gitea/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": "Optional sorting position within the target column", + "format": "int64", + "type": "integer", + "x-go-name": "Sorting" + } + }, + "required": [ + "column_id" + ], + "type": "object", + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "NewIssuePinsAllowed": { "description": "NewIssuePinsAllowed represents an API response that says if new Issue Pins are allowed", "properties": { @@ -8256,61 +8415,139 @@ "x-go-package": "code.gitea.io/gitea/modules/structs" }, "Project": { - "description": "Project represents a project", + "description": "Gitea projects can only contain issues — note cards and pull requests are\nnot modeled as project items.", "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_id": { - "description": "CreatorID is the user who created the project", - "format": "int64", - "type": "integer", - "x-go-name": "CreatorID" + "creator": { + "$ref": "#/components/schemas/User" }, "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", - "type": "boolean", - "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": { - "description": "Title is the title of the project", "type": "string", "x-go-name": "Title" }, + "type": { + "description": "Project type: \"individual\", \"repository\" or \"organization\"", + "type": "string", + "x-go-name": "Type" + }, "updated_at": { "format": "date-time", "type": "string", - "x-go-name": "Updated" + "x-go-name": "UpdatedAt" + } + }, + "title": "Project represents a project.", + "type": "object", + "x-go-package": "code.gitea.io/gitea/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" + }, + "num_issues": { + "format": "int64", + "type": "integer", + "x-go-name": "NumIssues" + }, + "project_id": { + "format": "int64", + "type": "integer", + "x-go-name": "ProjectID" + }, + "sorting": { + "format": "int64", + "type": "integer", + "x-go-name": "Sorting" + }, + "title": { + "type": "string", + "x-go-name": "Title" + }, + "updated_at": { + "format": "date-time", + "type": "string", + "x-go-name": "UpdatedAt" } }, "type": "object", @@ -25509,6 +25746,821 @@ ] } }, + "/repos/{owner}/{repo}/projects": { + "get": { + "description": "Gitea projects only contain issues — note cards and pull requests are not modeled as project items.", + "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", + "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 projects in a repository", + "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" + } + }, + "summary": "Create a new project", + "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" + } + }, + "summary": "Delete a project", + "tags": [ + "repository" + ] + }, + "get": { + "description": "Gitea projects only contain issues — note cards and pull requests are not modeled as project items.", + "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 single 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" + } + }, + "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 columns in a project", + "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" + } + }, + "summary": "Create a new column in a project", + "tags": [ + "repository" + ] + } + }, + "/repos/{owner}/{repo}/projects/{id}/columns/{column_id}": { + "delete": { + "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" + } + }, + "summary": "Delete 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" + } + }, + "summary": "Edit a project column", + "tags": [ + "repository" + ] + } + }, + "/repos/{owner}/{repo}/projects/{id}/columns/{column_id}/issues": { + "get": { + "description": "Gitea projects only contain issues — note cards and pull requests are not modeled as project items.", + "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 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": "id of the issue", + "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" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Remove an issue from a project column", + "tags": [ + "repository" + ] + }, + "post": { + "description": "Gitea projects only contain issues — note cards and pull requests cannot be added.", + "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": "id of the issue", + "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" + } + }, + "summary": "Add an issue to a project column", + "tags": [ + "repository" + ] + } + }, + "/repos/{owner}/{repo}/projects/{id}/issues/{issue_id}/move": { + "post": { + "description": "Atomically moves an existing project issue into a different column, optionally setting its sorting position.", + "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": "id of the issue", + "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" + } + }, + "summary": "Move an issue between columns of a project", + "tags": [ + "repository" + ] + } + }, "/repos/{owner}/{repo}/pulls": { "get": { "operationId": "repoListPullRequests", diff --git a/tests/integration/api_repo_project_test.go b/tests/integration/api_repo_project_test.go new file mode 100644 index 0000000000000..ec62d4e96206d --- /dev/null +++ b/tests/integration/api_repo_project_test.go @@ -0,0 +1,686 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + issues_model "code.gitea.io/gitea/models/issues" + project_model "code.gitea.io/gitea/models/project" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIProjects(t *testing.T) { + defer tests.PrepareTestEnv(t)() + t.Run("ListProjects", testAPIListProjects) + t.Run("GetProject", testAPIGetProject) + t.Run("CreateProject", testAPICreateProject) + t.Run("UpdateProject", testAPIUpdateProject) + t.Run("ChangeProjectStatus", testAPIChangeProjectStatus) + t.Run("DeleteProject", testAPIDeleteProject) + t.Run("ListProjectColumns", testAPIListProjectColumns) + t.Run("CreateProjectColumn", testAPICreateProjectColumn) + t.Run("UpdateProjectColumn", testAPIUpdateProjectColumn) + t.Run("DeleteProjectColumn", testAPIDeleteProjectColumn) + t.Run("AddIssueToProjectColumn", testAPIAddIssueToProjectColumn) + t.Run("RemoveIssueFromProjectColumn", testAPIRemoveIssueFromProjectColumn) + t.Run("ListProjectColumnIssues", testAPIListProjectColumnIssues) + t.Run("Permissions", testAPIProjectPermissions) +} + +func testAPIListProjects(t *testing.T) { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue) + + // Test listing all projects + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects", owner.Name, repo.Name). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var projects []*api.Project + DecodeJSON(t, resp, &projects) + + // Test state filter - open + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects?state=open", owner.Name, repo.Name). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &projects) + for _, project := range projects { + assert.Equal(t, api.StateOpen, project.State, "Project should be open") + } + + // Test state filter - all + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects?state=all", owner.Name, repo.Name). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &projects) + + // Test pagination + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects?page=1&limit=5", owner.Name, repo.Name). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) +} + +func testAPIGetProject(t *testing.T) { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + // Create a test project + project := &project_model.Project{ + Title: "Test Project for API", + RepoID: repo.ID, + Type: project_model.TypeRepository, + CreatorID: owner.ID, + TemplateType: project_model.TemplateTypeNone, + } + err := project_model.NewProject(t.Context(), project) + assert.NoError(t, err) + token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue) + + // Test getting the project + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var apiProject api.Project + DecodeJSON(t, resp, &apiProject) + assert.Equal(t, project.Title, apiProject.Title) + assert.Equal(t, project.ID, apiProject.ID) + assert.Equal(t, repo.ID, apiProject.RepoID) + assert.NotEmpty(t, apiProject.HTMLURL) + + // Test getting non-existent project + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/99999", owner.Name, repo.Name). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) +} + +func testAPICreateProject(t *testing.T) { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) + + // Test creating a project + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects", owner.Name, repo.Name), &api.CreateProjectOption{ + Title: "API Created Project", + Description: "This is a test project created via API", + TemplateType: "basic_kanban", + CardType: "images_and_text", + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + + var project api.Project + DecodeJSON(t, resp, &project) + assert.Equal(t, "API Created Project", project.Title) + assert.Equal(t, "This is a test project created via API", project.Description) + assert.Equal(t, "basic_kanban", project.TemplateType) + assert.Equal(t, "images_and_text", project.CardType) + assert.Equal(t, api.StateOpen, project.State) + + // Test creating with minimal data + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects", owner.Name, repo.Name), &api.CreateProjectOption{ + Title: "Minimal Project", + }).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusCreated) + + var minimalProject api.Project + DecodeJSON(t, resp, &minimalProject) + assert.Equal(t, "Minimal Project", minimalProject.Title) + + // Test creating without authentication + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects", owner.Name, repo.Name), &api.CreateProjectOption{ + Title: "Unauthorized Project", + }) + MakeRequest(t, req, http.StatusUnauthorized) + + // Test creating with invalid data (empty title) + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects", owner.Name, repo.Name), &api.CreateProjectOption{ + Title: "", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusUnprocessableEntity) +} + +func testAPIUpdateProject(t *testing.T) { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + // Create a test project + project := &project_model.Project{ + Title: "Project to Update", + Description: "Original description", + RepoID: repo.ID, + Type: project_model.TypeRepository, + CreatorID: owner.ID, + TemplateType: project_model.TemplateTypeNone, + } + err := project_model.NewProject(t.Context(), project) + assert.NoError(t, err) + + token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) + + // Test updating project title and description + newTitle := "Updated Project Title" + newDesc := "Updated description" + req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{ + Title: &newTitle, + Description: &newDesc, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var updatedProject api.Project + DecodeJSON(t, resp, &updatedProject) + assert.Equal(t, newTitle, updatedProject.Title) + assert.Equal(t, newDesc, updatedProject.Description) + + // Test updating non-existent project + req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/99999", owner.Name, repo.Name), &api.EditProjectOption{ + Title: &newTitle, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) +} + +func testAPIChangeProjectStatus(t *testing.T) { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + project := &project_model.Project{ + Title: "Project to Close", + Description: "Project to close and reopen", + RepoID: repo.ID, + Type: project_model.TypeRepository, + CreatorID: owner.ID, + TemplateType: project_model.TemplateTypeNone, + } + err := project_model.NewProject(t.Context(), project) + assert.NoError(t, err) + + token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) + + closed := api.StateClosed + req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{ + State: &closed, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var updatedProject api.Project + DecodeJSON(t, resp, &updatedProject) + assert.Equal(t, api.StateClosed, updatedProject.State) + assert.NotNil(t, updatedProject.ClosedAt) + + open := api.StateOpen + req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{ + State: &open, + }).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &updatedProject) + assert.Equal(t, api.StateOpen, updatedProject.State) + + bogus := api.StateType("reopen") + req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{ + State: &bogus, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusUnprocessableEntity) +} + +func testAPIDeleteProject(t *testing.T) { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + // Create a test project + project := &project_model.Project{ + Title: "Project to Delete", + RepoID: repo.ID, + Type: project_model.TypeRepository, + CreatorID: owner.ID, + TemplateType: project_model.TemplateTypeNone, + } + err := project_model.NewProject(t.Context(), project) + assert.NoError(t, err) + + token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) + + // Test deleting the project + req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + // Test deleting non-existent project (including the one we just deleted) + req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) +} + +func testAPIListProjectColumns(t *testing.T) { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + // Create a test project + project := &project_model.Project{ + Title: "Project for Columns Test", + RepoID: repo.ID, + Type: project_model.TypeRepository, + CreatorID: owner.ID, + TemplateType: project_model.TemplateTypeNone, + } + err := project_model.NewProject(t.Context(), project) + assert.NoError(t, err) + + // Create test columns + for i := 1; i <= 3; i++ { + column := &project_model.Column{ + Title: fmt.Sprintf("Column %d", i), + ProjectID: project.ID, + CreatorID: owner.ID, + } + err = project_model.NewColumn(t.Context(), column) + assert.NoError(t, err) + } + + token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue) + + // Test listing all columns — X-Total-Count must equal 3 + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns", owner.Name, repo.Name, project.ID). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var columns []*api.ProjectColumn + DecodeJSON(t, resp, &columns) + assert.Len(t, columns, 3) + assert.Equal(t, "Column 1", columns[0].Title) + assert.Equal(t, "Column 2", columns[1].Title) + assert.Equal(t, "Column 3", columns[2].Title) + assert.Equal(t, "3", resp.Header().Get("X-Total-Count")) + + // Test pagination: page 1 with limit 2 returns first 2 columns, total count still 3 + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns?page=1&limit=2", owner.Name, repo.Name, project.ID). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &columns) + assert.Len(t, columns, 2) + assert.Equal(t, "Column 1", columns[0].Title) + assert.Equal(t, "Column 2", columns[1].Title) + assert.Equal(t, "3", resp.Header().Get("X-Total-Count")) + + // Test pagination: page 2 with limit 2 returns remaining column + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns?page=2&limit=2", owner.Name, repo.Name, project.ID). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &columns) + assert.Len(t, columns, 1) + assert.Equal(t, "Column 3", columns[0].Title) + assert.Equal(t, "3", resp.Header().Get("X-Total-Count")) + + // Test listing columns for non-existent project + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/99999/columns", owner.Name, repo.Name). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) +} + +func testAPICreateProjectColumn(t *testing.T) { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + // Create a test project + project := &project_model.Project{ + Title: "Project for Column Creation", + RepoID: repo.ID, + Type: project_model.TypeRepository, + CreatorID: owner.ID, + TemplateType: project_model.TemplateTypeNone, + } + err := project_model.NewProject(t.Context(), project) + assert.NoError(t, err) + + token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) + + // Test creating a column with color + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns", owner.Name, repo.Name, project.ID), &api.CreateProjectColumnOption{ + Title: "New Column", + Color: "#FF5733", + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + + var column api.ProjectColumn + DecodeJSON(t, resp, &column) + assert.Equal(t, "New Column", column.Title) + assert.Equal(t, "#FF5733", column.Color) + assert.Equal(t, project.ID, column.ProjectID) + + // Test creating a column without color + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns", owner.Name, repo.Name, project.ID), &api.CreateProjectColumnOption{ + Title: "Simple Column", + }).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusCreated) + + DecodeJSON(t, resp, &column) + assert.Equal(t, "Simple Column", column.Title) + + // Test creating with empty title + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns", owner.Name, repo.Name, project.ID), &api.CreateProjectColumnOption{ + Title: "", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusUnprocessableEntity) + + // Test creating for non-existent project + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/99999/columns", owner.Name, repo.Name), &api.CreateProjectColumnOption{ + Title: "Orphan Column", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) +} + +func testAPIUpdateProjectColumn(t *testing.T) { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + // Create a test project and column + project := &project_model.Project{ + Title: "Project for Column Update", + RepoID: repo.ID, + Type: project_model.TypeRepository, + CreatorID: owner.ID, + TemplateType: project_model.TemplateTypeNone, + } + err := project_model.NewProject(t.Context(), project) + assert.NoError(t, err) + column := &project_model.Column{ + Title: "Original Column", + ProjectID: project.ID, + CreatorID: owner.ID, + Color: "#000000", + } + err = project_model.NewColumn(t.Context(), column) + assert.NoError(t, err) + + token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) + + // Test updating column title + newTitle := "Updated Column" + req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d", owner.Name, repo.Name, project.ID, column.ID), &api.EditProjectColumnOption{ + Title: &newTitle, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var updatedColumn api.ProjectColumn + DecodeJSON(t, resp, &updatedColumn) + assert.Equal(t, newTitle, updatedColumn.Title) + + // Test updating column color + newColor := "#FF0000" + req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d", owner.Name, repo.Name, project.ID, column.ID), &api.EditProjectColumnOption{ + Color: &newColor, + }).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &updatedColumn) + assert.Equal(t, newColor, updatedColumn.Color) + + // Test updating non-existent column + req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/99999", owner.Name, repo.Name, project.ID), &api.EditProjectColumnOption{ + Title: &newTitle, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) +} + +func testAPIDeleteProjectColumn(t *testing.T) { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + // Create a test project and column + project := &project_model.Project{ + Title: "Project for Column Deletion", + RepoID: repo.ID, + Type: project_model.TypeRepository, + CreatorID: owner.ID, + TemplateType: project_model.TemplateTypeNone, + } + err := project_model.NewProject(t.Context(), project) + assert.NoError(t, err) + column := &project_model.Column{ + Title: "Column to Delete", + ProjectID: project.ID, + CreatorID: owner.ID, + } + err = project_model.NewColumn(t.Context(), column) + assert.NoError(t, err) + + token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) + + // Test deleting the column + req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/projects/%d/columns/%d", owner.Name, repo.Name, project.ID, column.ID). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + // Test deleting non-existent column (including the one we just deleted) + req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/projects/%d/columns/%d", owner.Name, repo.Name, project.ID, column.ID). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) +} + +func testAPIAddIssueToProjectColumn(t *testing.T) { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID}) + + // Create a test project and column + project := &project_model.Project{ + Title: "Project for Issue Assignment", + RepoID: repo.ID, + Type: project_model.TypeRepository, + CreatorID: owner.ID, + TemplateType: project_model.TemplateTypeNone, + } + err := project_model.NewProject(t.Context(), project) + assert.NoError(t, err) + + column1 := &project_model.Column{ + Title: "Column 1", + ProjectID: project.ID, + CreatorID: owner.ID, + } + err = project_model.NewColumn(t.Context(), column1) + assert.NoError(t, err) + + column2 := &project_model.Column{ + Title: "Column 2", + ProjectID: project.ID, + CreatorID: owner.ID, + } + err = project_model.NewColumn(t.Context(), column2) + assert.NoError(t, err) + + token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) + + // Test adding issue to column + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, column1.ID, issue.ID), nil).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + // Verify issue is in the column + projectIssue := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ + ProjectID: project.ID, + IssueID: issue.ID, + }) + assert.Equal(t, column1.ID, projectIssue.ProjectColumnID) + + // Test moving issue to another column + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, column2.ID, issue.ID), nil).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + // Verify issue moved to new column + projectIssue = unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ + ProjectID: project.ID, + IssueID: issue.ID, + }) + assert.Equal(t, column2.ID, projectIssue.ProjectColumnID) + + // Test adding same issue to same column (should be idempotent) + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, column2.ID, issue.ID), nil).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + // Test adding non-existent issue + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, column1.ID, 99999), nil).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + + // Test adding to non-existent column + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/99999/issues/%d", owner.Name, repo.Name, project.ID, issue.ID), nil).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) +} + +func testAPIListProjectColumnIssues(t *testing.T) { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) + pull := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2}) + + project := &project_model.Project{ + Title: "Project for Column Issues", + RepoID: repo.ID, + Type: project_model.TypeRepository, + CreatorID: owner.ID, + TemplateType: project_model.TemplateTypeNone, + } + err := project_model.NewProject(t.Context(), project) + assert.NoError(t, err) + + column := &project_model.Column{ + Title: "Column for Issues", + ProjectID: project.ID, + CreatorID: owner.ID, + } + err = project_model.NewColumn(t.Context(), column) + assert.NoError(t, err) + + err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue, owner, []int64{project.ID}) + assert.NoError(t, err) + err = issues_model.IssueAssignOrRemoveProject(t.Context(), pull, owner, []int64{project.ID}) + assert.NoError(t, err) + + token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue) + + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns/%d/issues", owner.Name, repo.Name, project.ID, column.ID). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var issues []api.Issue + DecodeJSON(t, resp, &issues) + assert.Len(t, issues, 2) + + issueIDs := make(map[int64]struct{}, len(issues)) + for _, apiIssue := range issues { + issueIDs[apiIssue.ID] = struct{}{} + } + assert.Contains(t, issueIDs, issue.ID) + assert.Contains(t, issueIDs, pull.ID) +} + +func testAPIRemoveIssueFromProjectColumn(t *testing.T) { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID}) + + project := &project_model.Project{ + Title: "Project for Issue Removal", + RepoID: repo.ID, + Type: project_model.TypeRepository, + CreatorID: owner.ID, + TemplateType: project_model.TemplateTypeNone, + } + err := project_model.NewProject(t.Context(), project) + assert.NoError(t, err) + + column := &project_model.Column{ + Title: "Column for Issue Removal", + ProjectID: project.ID, + CreatorID: owner.ID, + } + err = project_model.NewColumn(t.Context(), column) + assert.NoError(t, err) + + otherColumn := &project_model.Column{ + Title: "Other Column", + ProjectID: project.ID, + CreatorID: owner.ID, + } + err = project_model.NewColumn(t.Context(), otherColumn) + assert.NoError(t, err) + + err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue, owner, []int64{project.ID}) + assert.NoError(t, err) + + token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) + + // Removing via a column the issue does not live in must 404 and not detach the issue + req := NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, otherColumn.ID, issue.ID), nil). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ + ProjectID: project.ID, + IssueID: issue.ID, + }) + + req = NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, column.ID, issue.ID), nil). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + unittest.AssertNotExistsBean(t, &project_model.ProjectIssue{ + ProjectID: project.ID, + IssueID: issue.ID, + }) +} + +func testAPIProjectPermissions(t *testing.T) { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + nonCollaborator := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user5"}) + + // Create a test project + project := &project_model.Project{ + Title: "Permission Test Project", + RepoID: repo.ID, + Type: project_model.TypeRepository, + CreatorID: owner.ID, + TemplateType: project_model.TemplateTypeNone, + } + err := project_model.NewProject(t.Context(), project) + assert.NoError(t, err) + + ownerToken := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) + nonCollaboratorToken := getUserToken(t, nonCollaborator.Name, auth_model.AccessTokenScopeWriteIssue) + + // Owner should be able to read + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID). + AddTokenAuth(ownerToken) + MakeRequest(t, req, http.StatusOK) + + // Owner should be able to update + newTitle := "Updated by Owner" + req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{ + Title: &newTitle, + }).AddTokenAuth(ownerToken) + MakeRequest(t, req, http.StatusOK) + + // Non-collaborator should not be able to update + anotherTitle := "Updated by Non-collaborator" + req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{ + Title: &anotherTitle, + }).AddTokenAuth(nonCollaboratorToken) + MakeRequest(t, req, http.StatusForbidden) + + // Non-collaborator should not be able to delete + req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID). + AddTokenAuth(nonCollaboratorToken) + MakeRequest(t, req, http.StatusForbidden) +} diff --git a/tests/integration/project_test.go b/tests/integration/project_test.go index 516f2acec705f..adef22b8e0192 100644 --- a/tests/integration/project_test.go +++ b/tests/integration/project_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" project_model "code.gitea.io/gitea/models/project" repo_model "code.gitea.io/gitea/models/repo" @@ -60,7 +61,7 @@ func TestMoveRepoProjectColumns(t *testing.T) { assert.NoError(t, err) } - columns, err := project1.GetColumns(t.Context()) + columns, err := project_model.GetProjectColumns(t.Context(), project1.ID, db.ListOptionsAll) assert.NoError(t, err) assert.Len(t, columns, 3) assert.EqualValues(t, 0, columns[0].Sorting) @@ -80,7 +81,7 @@ func TestMoveRepoProjectColumns(t *testing.T) { }) sess.MakeRequest(t, req, http.StatusOK) - columnsAfter, err := project1.GetColumns(t.Context()) + columnsAfter, err := project_model.GetProjectColumns(t.Context(), project1.ID, db.ListOptionsAll) assert.NoError(t, err) assert.Len(t, columnsAfter, 3) assert.Equal(t, columns[1].ID, columnsAfter[0].ID) @@ -90,26 +91,6 @@ func TestMoveRepoProjectColumns(t *testing.T) { assert.NoError(t, project_model.DeleteProjectByID(t.Context(), project1.ID)) } -func TestUpdateIssueProject(t *testing.T) { - defer tests.PrepareTestEnv(t)() - - sess := loginUser(t, "user2") - - t.Run("AssignAndRemove", func(t *testing.T) { - req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=2", map[string]string{ - "id": "1", - }) - sess.MakeRequest(t, req, http.StatusOK) - unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{IssueID: 2, ProjectID: 1}) - - req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=2", map[string]string{ - "id": "", - }) - sess.MakeRequest(t, req, http.StatusOK) - unittest.AssertNotExistsBean(t, &project_model.ProjectIssue{IssueID: 2, ProjectID: 1}) - }) -} - func TestUpdateIssueProjectColumn(t *testing.T) { defer tests.PrepareTestEnv(t)() @@ -158,7 +139,7 @@ func TestUpdateIssueProjectColumn(t *testing.T) { Title: "other column", ProjectID: project2.ID, })) - columns, err := project2.GetColumns(t.Context()) + columns, err := project_model.GetProjectColumns(t.Context(), project2.ID, db.ListOptionsAll) require.NoError(t, err) require.NotEmpty(t, columns) @@ -180,13 +161,13 @@ func TestIssueSidebarProjectColumn(t *testing.T) { resp := sess.MakeRequest(t, req, http.StatusOK) htmlDoc := NewHTMLParser(t, resp.Body) - cards := htmlDoc.Find(".flex-relaxed-list > .item.sidebar-project-card") + cards := htmlDoc.Find(".sidebar-project-card") assert.Equal(t, 1, cards.Length()) - title := cards.Find("a span.gt-ellipsis") + title := cards.Find(".sidebar-project-card a.suppressed .gt-ellipsis") assert.Contains(t, strings.TrimSpace(title.Text()), "First project") - columnCombo := cards.Find(".issue-sidebar-combo.sidebar-project-column-combo") + columnCombo := cards.Find(".sidebar-project-column-combo") assert.Equal(t, 1, columnCombo.Length()) defaultItem := columnCombo.Find(`.menu .item[data-value="1"]`) @@ -201,14 +182,16 @@ func TestIssueSidebarProjectColumn(t *testing.T) { assert.True(t, exists) assert.Equal(t, "3", comboVal) - req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=5", map[string]string{"id": ""}) + req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=5", map[string]string{ + "id": "0", + }) sess.MakeRequest(t, req, http.StatusOK) req = NewRequest(t, "GET", "/user2/repo1/issues/4") resp = sess.MakeRequest(t, req, http.StatusOK) htmlDoc = NewHTMLParser(t, resp.Body) - cards = htmlDoc.Find(".flex-relaxed-list > .item.sidebar-project-card") + cards = htmlDoc.Find(".sidebar-project-card") assert.Equal(t, 0, cards.Length()) } @@ -311,6 +294,11 @@ func TestOrgProjectFilterByMilestone(t *testing.T) { } require.NoError(t, project_model.NewProject(t.Context(), &project)) + // Get the default column + columns, err := project_model.GetProjectColumns(t.Context(), project.ID, db.ListOptionsAll) + require.NoError(t, err) + require.NotEmpty(t, columns) + // Add issues to the project require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue16, user1, []int64{project.ID})) require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue17, user1, []int64{project.ID}))