From 10977db735962ffe88eae29c02b919890d93fcbf Mon Sep 17 00:00:00 2001 From: "Supen.Huang" Date: Fri, 19 Dec 2025 00:37:16 +0800 Subject: [PATCH 1/2] feat(api): add comprehensive REST API for Project Boards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds a complete REST API implementation for managing repository project boards, including projects, columns, and adding issues to columns. API Endpoints: - GET /repos/{owner}/{repo}/projects - List projects - POST /repos/{owner}/{repo}/projects - Create project - GET /repos/{owner}/{repo}/projects/{id} - Get project - PATCH /repos/{owner}/{repo}/projects/{id} - Update project - DELETE /repos/{owner}/{repo}/projects/{id} - Delete project - GET /repos/{owner}/{repo}/projects/{id}/columns - List columns - POST /repos/{owner}/{repo}/projects/{id}/columns - Create column - PATCH /repos/{owner}/{repo}/projects/columns/{id} - Update column - DELETE /repos/{owner}/{repo}/projects/columns/{id} - Delete column - POST /repos/{owner}/{repo}/projects/columns/{id}/issues - Add issue Features: - Full Swagger/OpenAPI documentation - Proper permission checks - Pagination support for list endpoints - State filtering (open/closed/all) - Comprehensive error handling - Token-based authentication with scope validation - Archive repository protection New Files: - modules/structs/project.go: API data structures - routers/api/v1/repo/project.go: API handlers - routers/api/v1/swagger/project.go: Swagger responses - services/convert/project.go: Model converters - tests/integration/api_repo_project_test.go: Integration tests Modified Files: - models/project/issue.go: Added AddOrUpdateIssueToColumn function - routers/api/v1/api.go: Registered project API routes - routers/api/v1/swagger/options.go: Added project option types - templates/swagger/v1_json.tmpl: Regenerated swagger spec fix(api): remove duplicated permission checks in project handlers Route middleware reqRepoReader(unit.TypeProjects) wraps the entire /projects route group, and reqRepoWriter(unit.TypeProjects) is applied to each mutating route individually in api.go. These middleware run before any handler fires and already gate access correctly. The inline CanRead/CanWrite checks at the top of all 10 handlers were therefore unreachable dead code — removed from ListProjects, GetProject, CreateProject, EditProject, DeleteProject, ListProjectColumns, CreateProjectColumn, EditProjectColumn, DeleteProjectColumn, and AddIssueToProjectColumn. The now-unused "code.gitea.io/gitea/models/unit" import is also removed. Addresses review feedback on: https://github.com/go-gitea/gitea/pull/36008 Co-authored-by: Claude Sonnet 4.5 fix(api): replace AddOrUpdateIssueToColumn with IssueAssignOrRemoveProject The custom AddOrUpdateIssueToColumn function introduced by this PR was missing three things that the existing IssueAssignOrRemoveProject provides: 1. db.WithTx transaction wrapper — raw DB updates without a transaction can leave the database in a partial state on error. 2. CreateComment(CommentTypeProject) — assigning an issue to a project column via the UI creates a comment on the issue timeline. The API doing the same action silently was an inconsistency. 3. CanBeAccessedByOwnerRepo ownership check — IssueAssignOrRemoveProject validates that the issue is accessible within the repo/org context before mutating state. AddOrUpdateIssueToColumn is removed entirely. AddIssueToProjectColumn now delegates to issues_model.IssueAssignOrRemoveProject, which already has the issue object loaded earlier in the handler. Addresses review feedback on: https://github.com/go-gitea/gitea/pull/36008 Co-authored-by: Claude Sonnet 4.5 fix(api): remove unnecessary pagination from ListProjectColumns Project columns are few in number by design (typically 3-8 per board). The previous implementation fetched all columns from the DB then sliced the result in memory — adding complexity and a misleading Link header without any practical benefit. ListProjectColumns now returns all columns directly. The page/limit query parameters and associated swagger docs are removed. Addresses review feedback on: https://github.com/go-gitea/gitea/pull/36008 Co-authored-by: Claude Sonnet 4.5 fix(api): regenerate swagger spec after removing ListProjectColumns pagination Removes the page and limit parameters from the generated swagger spec for the ListProjectColumns endpoint, matching the handler change that dropped in-memory pagination. Co-authored-by: Claude test(api): remove pagination assertion from TestAPIListProjectColumns ListProjectColumns no longer supports pagination — it returns all columns directly. Remove the page/limit test case that expected 2 of 3 columns. Co-authored-by: Claude fix(api): implement proper pagination for ListProjectColumns Per contribution guidelines, list endpoints must support page/limit query params and set X-Total-Count header. - Add CountColumns and GetColumnsPaginated to project model (DB-level, not in-memory slicing) - ListProjectColumns uses utils.GetListOptions, calls paginated model functions, and sets X-Total-Count via ctx.SetTotalCountHeader - Restore page/limit swagger doc params on the endpoint - Regenerate swagger spec - Integration test covers: full list with X-Total-Count, page 1 of 2, page 2 of 2, and 404 for non-existent project Co-authored-by: Claude --- models/project/column.go | 17 + modules/structs/project.go | 139 ++++ routers/api/v1/api.go | 17 + routers/api/v1/repo/project.go | 700 +++++++++++++++++ routers/api/v1/swagger/options.go | 13 + routers/api/v1/swagger/project.go | 36 + services/convert/project.go | 92 +++ templates/swagger/v1_json.tmpl | 840 ++++++++++++++++++++- tests/integration/api_repo_project_test.go | 608 +++++++++++++++ 9 files changed, 2461 insertions(+), 1 deletion(-) create mode 100644 modules/structs/project.go create mode 100644 routers/api/v1/repo/project.go create mode 100644 routers/api/v1/swagger/project.go create mode 100644 services/convert/project.go create mode 100644 tests/integration/api_repo_project_test.go diff --git a/models/project/column.go b/models/project/column.go index 79f6dfe911..3afa2bfba6 100644 --- a/models/project/column.go +++ b/models/project/column.go @@ -257,6 +257,23 @@ func (p *Project) GetColumns(ctx context.Context) (ColumnList, error) { return columns, nil } +// CountColumns returns the total number of columns for a project +func (p *Project) CountColumns(ctx context.Context) (int64, error) { + return db.GetEngine(ctx).Where("project_id=?", p.ID).Count(&Column{}) +} + +// GetColumnsPaginated fetches a page of columns for a project +func (p *Project) GetColumnsPaginated(ctx context.Context, opts db.ListOptions) (ColumnList, error) { + columns := make([]*Column, 0, opts.PageSize) + if err := db.GetEngine(ctx).Where("project_id=?", p.ID). + OrderBy("sorting, id"). + Limit(opts.PageSize, (opts.Page-1)*opts.PageSize). + Find(&columns); err != nil { + return nil, err + } + return columns, nil +} + // getDefaultColumn return default column and ensure only one exists func (p *Project) getDefaultColumn(ctx context.Context) (*Column, error) { var column Column diff --git a/modules/structs/project.go b/modules/structs/project.go new file mode 100644 index 0000000000..1375d4f926 --- /dev/null +++ b/modules/structs/project.go @@ -0,0 +1,139 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package structs + +import ( + "time" +) + +// Project represents a project +// swagger:model +type Project struct { + // Unique identifier of the project + ID int64 `json:"id"` + // Project title + Title string `json:"title"` + // Project description + Description string `json:"description"` + // Owner ID (for organization or user projects) + OwnerID int64 `json:"owner_id,omitempty"` + // Repository ID (for repository projects) + RepoID int64 `json:"repo_id,omitempty"` + // Creator ID + CreatorID int64 `json:"creator_id"` + // Whether the project is closed + IsClosed bool `json:"is_closed"` + // Template type: 0=none, 1=basic_kanban, 2=bug_triage + TemplateType int `json:"template_type"` + // Card type: 0=text_only, 1=images_and_text + CardType int `json:"card_type"` + // Project type: 1=individual, 2=repository, 3=organization + Type int `json:"type"` + // Number of open issues + NumOpenIssues int64 `json:"num_open_issues,omitempty"` + // Number of closed issues + NumClosedIssues int64 `json:"num_closed_issues,omitempty"` + // Total number of issues + NumIssues int64 `json:"num_issues,omitempty"` + // Created time + // swagger:strfmt date-time + Created time.Time `json:"created"` + // Updated time + // swagger:strfmt date-time + Updated time.Time `json:"updated"` + // Closed time + // swagger:strfmt date-time + ClosedDate *time.Time `json:"closed_date,omitempty"` + // Project URL + URL string `json:"url,omitempty"` +} + +// CreateProjectOption represents options for creating a project +// swagger:model +type CreateProjectOption struct { + // required: true + Title string `json:"title" binding:"Required"` + // Project description + Description string `json:"description"` + // Template type: 0=none, 1=basic_kanban, 2=bug_triage + TemplateType int `json:"template_type"` + // Card type: 0=text_only, 1=images_and_text + CardType int `json:"card_type"` +} + +// EditProjectOption represents options for editing a project +// swagger:model +type EditProjectOption struct { + // Project title + Title *string `json:"title,omitempty"` + // Project description + Description *string `json:"description,omitempty"` + // Card type: 0=text_only, 1=images_and_text + CardType *int `json:"card_type,omitempty"` + // Whether the project is closed + IsClosed *bool `json:"is_closed,omitempty"` +} + +// ProjectColumn represents a project column (board) +// swagger:model +type ProjectColumn struct { + // Unique identifier of the column + ID int64 `json:"id"` + // Column title + Title string `json:"title"` + // Whether this is the default column + Default bool `json:"default"` + // Sorting order + Sorting int `json:"sorting"` + // Column color (hex format) + Color string `json:"color,omitempty"` + // Project ID + ProjectID int64 `json:"project_id"` + // Creator ID + CreatorID int64 `json:"creator_id"` + // Number of issues in this column + NumIssues int64 `json:"num_issues,omitempty"` + // Created time + // swagger:strfmt date-time + Created time.Time `json:"created"` + // Updated time + // swagger:strfmt date-time + Updated time.Time `json:"updated"` +} + +// CreateProjectColumnOption represents options for creating a project column +// swagger:model +type CreateProjectColumnOption struct { + // required: true + Title string `json:"title" binding:"Required"` + // Column color (hex format, e.g., #FF0000) + Color string `json:"color,omitempty"` +} + +// EditProjectColumnOption represents options for editing a project column +// swagger:model +type EditProjectColumnOption struct { + // Column title + Title *string `json:"title,omitempty"` + // Column color (hex format) + Color *string `json:"color,omitempty"` + // Sorting order + Sorting *int `json:"sorting,omitempty"` +} + +// MoveProjectColumnOption represents options for moving a project column +// swagger:model +type MoveProjectColumnOption struct { + // Position to move the column to (0-based index) + // required: true + Position int `json:"position" binding:"Required"` +} + +// AddIssueToProjectColumnOption represents options for adding an issue to a project column +// swagger:model +type AddIssueToProjectColumnOption struct { + // Issue ID to add to the column + // required: true + IssueID int64 `json:"issue_id" binding:"Required"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index cb6bbe0954..b4598838ac 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1573,6 +1573,23 @@ 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), bind(api.EditProjectOption{}), repo.EditProject). + Delete(reqToken(), reqRepoWriter(unit.TypeProjects), repo.DeleteProject) + m.Combo("/columns").Get(repo.ListProjectColumns). + Post(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.CreateProjectColumnOption{}), repo.CreateProjectColumn) + }) + m.Group("/columns/{id}", func() { + m.Combo(""). + Patch(reqToken(), reqRepoWriter(unit.TypeProjects), bind(api.EditProjectColumnOption{}), repo.EditProjectColumn). + Delete(reqToken(), reqRepoWriter(unit.TypeProjects), repo.DeleteProjectColumn) + m.Post("/issues", reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.AddIssueToProjectColumnOption{}), repo.AddIssueToProjectColumn) + }) + }, 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 0000000000..c734ac4f2b --- /dev/null +++ b/routers/api/v1/repo/project.go @@ -0,0 +1,700 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + "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" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + project_service "code.gitea.io/gitea/services/projects" + "code.gitea.io/gitea/routers/api/v1/utils" +) + +// 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 + // 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) + // 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" + + state := ctx.FormTrim("state") + var isClosed optional.Option[bool] + switch state { + case "closed": + isClosed = optional.Some(true) + case "open": + isClosed = optional.Some(false) + case "all": + isClosed = optional.None[bool]() + default: + isClosed = optional.Some(false) + } + + page := ctx.FormInt("page") + if page <= 0 { + page = 1 + } + + limit := ctx.FormInt("limit") + if limit <= 0 { + limit = setting.UI.IssuePagingNum + } + + projects, count, err := db.FindAndCount[project_model.Project](ctx, project_model.SearchOptions{ + ListOptions: db.ListOptions{ + Page: page, + PageSize: limit, + }, + 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 + } + + apiProjects := convert.ToProjectList(ctx, projects) + + ctx.SetLinkHeader(int(count), limit) + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, apiProjects) +} + +// 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 + // 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, 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 + } + + if err := project_service.LoadIssueNumbersForProjects(ctx, []*project_model.Project{project}, ctx.Doer); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusOK, convert.ToProject(ctx, project)) +} + +// 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) + + p := &project_model.Project{ + RepoID: ctx.Repo.Repository.ID, + Title: form.Title, + Description: form.Description, + CreatorID: ctx.Doer.ID, + TemplateType: project_model.TemplateType(form.TemplateType), + CardType: project_model.CardType(form.CardType), + Type: project_model.TypeRepository, + } + + if err := project_model.NewProject(ctx, p); err != nil { + ctx.APIErrorInternal(err) + return + } + + if err := project_service.LoadIssueNumbersForProjects(ctx, []*project_model.Project{p}, ctx.Doer); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusCreated, convert.ToProject(ctx, p)) +} + +// 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, 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 + } + + form := web.GetForm(ctx).(*api.EditProjectOption) + + if form.Title != nil { + project.Title = *form.Title + } + if form.Description != nil { + project.Description = *form.Description + } + if form.CardType != nil { + project.CardType = project_model.CardType(*form.CardType) + } + if form.IsClosed != nil { + if err := project_model.ChangeProjectStatus(ctx, project, *form.IsClosed); err != nil { + ctx.APIErrorInternal(err) + return + } + } else { + if err := project_model.UpdateProject(ctx, project); err != nil { + ctx.APIErrorInternal(err) + return + } + } + + if err := project_service.LoadIssueNumbersForProjects(ctx, []*project_model.Project{project}, ctx.Doer); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusOK, convert.ToProject(ctx, project)) +} + +// 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" + + // Verify project exists and belongs to this repository + 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 + } + + 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, 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 + } + + total, err := project.CountColumns(ctx) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + listOptions := utils.GetListOptions(ctx) + columns, err := project.GetColumnsPaginated(ctx, listOptions) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.SetTotalCountHeader(total) + ctx.JSON(http.StatusOK, convert.ToProjectColumnList(ctx, columns)) +} + +// 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" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + 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 + } + + form := web.GetForm(ctx).(*api.CreateProjectColumnOption) + + column := &project_model.Column{ + Title: form.Title, + Color: form.Color, + ProjectID: project.ID, + CreatorID: ctx.Doer.ID, + } + + if err := project_model.NewColumn(ctx, column); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusCreated, convert.ToProjectColumn(ctx, column)) +} + +// EditProjectColumn updates a column +func EditProjectColumn(ctx *context.APIContext) { + // swagger:operation PATCH /repos/{owner}/{repo}/projects/columns/{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 column + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditProjectColumnOption" + // responses: + // "200": + // "$ref": "#/responses/ProjectColumn" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + column, err := project_model.GetColumn(ctx, ctx.PathParamInt64("id")) + if err != nil { + if project_model.IsErrProjectColumnNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return + } + + // Verify column belongs to this repo's 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 + } + + form := web.GetForm(ctx).(*api.EditProjectColumnOption) + + if form.Title != nil { + column.Title = *form.Title + } + if form.Color != nil { + column.Color = *form.Color + } + if form.Sorting != nil { + 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)) +} + +// DeleteProjectColumn deletes a column +func DeleteProjectColumn(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/projects/columns/{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 column + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + column, err := project_model.GetColumn(ctx, ctx.PathParamInt64("id")) + if err != nil { + if project_model.IsErrProjectColumnNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return + } + + // Verify column belongs to this repo's 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 + } + + if err := project_model.DeleteColumnByID(ctx, column.ID); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// AddIssueToProjectColumn adds an issue to a project column +func AddIssueToProjectColumn(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/projects/columns/{id}/issues repository repoAddIssueToProjectColumn + // --- + // summary: Add an issue to 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 column + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // type: object + // required: + // - issue_id + // properties: + // issue_id: + // type: integer + // format: int64 + // description: ID of the issue to add + // responses: + // "201": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + column, err := project_model.GetColumn(ctx, ctx.PathParamInt64("id")) + if err != nil { + if project_model.IsErrProjectColumnNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return + } + + // Verify column belongs to this repo's 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 + } + + // Parse request body + form := web.GetForm(ctx).(*api.AddIssueToProjectColumnOption) + + // Verify issue exists and belongs to this repository + issue, err := issues_model.GetIssueByID(ctx, form.IssueID) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.APIError(http.StatusUnprocessableEntity, "issue not found") + } else { + ctx.APIErrorInternal(err) + } + return + } + + if issue.RepoID != ctx.Repo.Repository.ID { + ctx.APIError(http.StatusUnprocessableEntity, "issue does not belong to this repository") + return + } + + // Assign issue to column, creating an audit comment on the issue timeline + if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, column.ProjectID, column.ID); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.Status(http.StatusCreated) +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 310839374b..e0e761de2b 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -227,4 +227,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 + AddIssueToProjectColumnOption api.AddIssueToProjectColumnOption } diff --git a/routers/api/v1/swagger/project.go b/routers/api/v1/swagger/project.go new file mode 100644 index 0000000000..da7d80456b --- /dev/null +++ b/routers/api/v1/swagger/project.go @@ -0,0 +1,36 @@ +// Copyright 2025 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/services/convert/project.go b/services/convert/project.go new file mode 100644 index 0000000000..a419777c77 --- /dev/null +++ b/services/convert/project.go @@ -0,0 +1,92 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package convert + +import ( + "context" + + project_model "code.gitea.io/gitea/models/project" + api "code.gitea.io/gitea/modules/structs" +) + +// ToProject converts a project_model.Project to api.Project +func ToProject(ctx context.Context, p *project_model.Project) *api.Project { + if p == nil { + return nil + } + + project := &api.Project{ + ID: p.ID, + Title: p.Title, + Description: p.Description, + OwnerID: p.OwnerID, + RepoID: p.RepoID, + CreatorID: p.CreatorID, + IsClosed: p.IsClosed, + TemplateType: int(p.TemplateType), + CardType: int(p.CardType), + Type: int(p.Type), + NumOpenIssues: p.NumOpenIssues, + NumClosedIssues: p.NumClosedIssues, + NumIssues: p.NumIssues, + Created: p.CreatedUnix.AsTime(), + Updated: p.UpdatedUnix.AsTime(), + } + + if p.ClosedDateUnix > 0 { + t := p.ClosedDateUnix.AsTime() + project.ClosedDate = &t + } + + // Generate project URL + if p.Type == project_model.TypeRepository && p.RepoID > 0 { + if err := p.LoadRepo(ctx); err == nil && p.Repo != nil { + project.URL = project_model.ProjectLinkForRepo(p.Repo, p.ID) + } + } else if p.OwnerID > 0 { + if err := p.LoadOwner(ctx); err == nil && p.Owner != nil { + project.URL = project_model.ProjectLinkForOrg(p.Owner, p.ID) + } + } + + return project +} + +// ToProjectColumn converts a project_model.Column to api.ProjectColumn +func ToProjectColumn(ctx context.Context, column *project_model.Column) *api.ProjectColumn { + if column == nil { + return nil + } + + return &api.ProjectColumn{ + ID: column.ID, + Title: column.Title, + Default: column.Default, + Sorting: int(column.Sorting), + Color: column.Color, + ProjectID: column.ProjectID, + CreatorID: column.CreatorID, + NumIssues: column.NumIssues, + Created: column.CreatedUnix.AsTime(), + Updated: column.UpdatedUnix.AsTime(), + } +} + +// ToProjectList converts a list of project_model.Project to a list of api.Project +func ToProjectList(ctx context.Context, projects []*project_model.Project) []*api.Project { + result := make([]*api.Project, len(projects)) + for i, p := range projects { + result[i] = ToProject(ctx, p) + } + return result +} + +// ToProjectColumnList converts a list of project_model.Column to a list of api.ProjectColumn +func ToProjectColumnList(ctx context.Context, columns []*project_model.Column) []*api.ProjectColumn { + result := make([]*api.ProjectColumn, len(columns)) + for i, column := range columns { + result[i] = ToProjectColumn(ctx, column) + } + return result +} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 4fc823d090..99486fa271 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -13525,6 +13525,528 @@ } } }, + "/repos/{owner}/{repo}/projects": { + "get": { + "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)", + "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/columns/{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 column", + "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 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 column", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/EditProjectColumnOption" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectColumn" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, + "/repos/{owner}/{repo}/projects/columns/{id}/issues": { + "post": { + "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 column", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "type": "object", + "required": [ + "issue_id" + ], + "properties": { + "issue_id": { + "description": "ID of the issue to add", + "type": "integer", + "format": "int64" + } + } + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, + "/repos/{owner}/{repo}/projects/{id}": { + "get": { + "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" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, "/repos/{owner}/{repo}/pulls": { "get": { "produces": [ @@ -21751,6 +22273,22 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "AddIssueToProjectColumnOption": { + "description": "AddIssueToProjectColumnOption represents options for adding an issue to a project column", + "type": "object", + "required": [ + "issue_id" + ], + "properties": { + "issue_id": { + "description": "Issue ID to add to the column", + "type": "integer", + "format": "int64", + "x-go-name": "IssueID" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "AddTimeOption": { "description": "AddTimeOption options for adding time to an issue", "type": "object", @@ -23484,6 +24022,56 @@ }, "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 (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: 0=text_only, 1=images_and_text", + "type": "integer", + "format": "int64", + "x-go-name": "CardType" + }, + "description": { + "description": "Project description", + "type": "string", + "x-go-name": "Description" + }, + "template_type": { + "description": "Template type: 0=none, 1=basic_kanban, 2=bug_triage", + "type": "integer", + "format": "int64", + "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", @@ -24604,6 +25192,57 @@ }, "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 (hex format)", + "type": "string", + "x-go-name": "Color" + }, + "sorting": { + "description": "Sorting order", + "type": "integer", + "format": "int64", + "x-go-name": "Sorting" + }, + "title": { + "description": "Column 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: 0=text_only, 1=images_and_text", + "type": "integer", + "format": "int64", + "x-go-name": "CardType" + }, + "description": { + "description": "Project description", + "type": "string", + "x-go-name": "Description" + }, + "is_closed": { + "description": "Whether the project is closed", + "type": "boolean", + "x-go-name": "IsClosed" + }, + "title": { + "description": "Project 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", @@ -27286,6 +27925,175 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "Project": { + "description": "Project represents a project", + "type": "object", + "properties": { + "card_type": { + "description": "Card type: 0=text_only, 1=images_and_text", + "type": "integer", + "format": "int64", + "x-go-name": "CardType" + }, + "closed_date": { + "description": "Closed time", + "type": "string", + "format": "date-time", + "x-go-name": "ClosedDate" + }, + "created": { + "description": "Created time", + "type": "string", + "format": "date-time", + "x-go-name": "Created" + }, + "creator_id": { + "description": "Creator ID", + "type": "integer", + "format": "int64", + "x-go-name": "CreatorID" + }, + "description": { + "description": "Project description", + "type": "string", + "x-go-name": "Description" + }, + "id": { + "description": "Unique identifier of the project", + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "is_closed": { + "description": "Whether the project is closed", + "type": "boolean", + "x-go-name": "IsClosed" + }, + "num_closed_issues": { + "description": "Number of closed issues", + "type": "integer", + "format": "int64", + "x-go-name": "NumClosedIssues" + }, + "num_issues": { + "description": "Total number of issues", + "type": "integer", + "format": "int64", + "x-go-name": "NumIssues" + }, + "num_open_issues": { + "description": "Number of open issues", + "type": "integer", + "format": "int64", + "x-go-name": "NumOpenIssues" + }, + "owner_id": { + "description": "Owner ID (for organization or user projects)", + "type": "integer", + "format": "int64", + "x-go-name": "OwnerID" + }, + "repo_id": { + "description": "Repository ID (for repository projects)", + "type": "integer", + "format": "int64", + "x-go-name": "RepoID" + }, + "template_type": { + "description": "Template type: 0=none, 1=basic_kanban, 2=bug_triage", + "type": "integer", + "format": "int64", + "x-go-name": "TemplateType" + }, + "title": { + "description": "Project title", + "type": "string", + "x-go-name": "Title" + }, + "type": { + "description": "Project type: 1=individual, 2=repository, 3=organization", + "type": "integer", + "format": "int64", + "x-go-name": "Type" + }, + "updated": { + "description": "Updated time", + "type": "string", + "format": "date-time", + "x-go-name": "Updated" + }, + "url": { + "description": "Project URL", + "type": "string", + "x-go-name": "URL" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "ProjectColumn": { + "description": "ProjectColumn represents a project column (board)", + "type": "object", + "properties": { + "color": { + "description": "Column color (hex format)", + "type": "string", + "x-go-name": "Color" + }, + "created": { + "description": "Created time", + "type": "string", + "format": "date-time", + "x-go-name": "Created" + }, + "creator_id": { + "description": "Creator ID", + "type": "integer", + "format": "int64", + "x-go-name": "CreatorID" + }, + "default": { + "description": "Whether this is the default column", + "type": "boolean", + "x-go-name": "Default" + }, + "id": { + "description": "Unique identifier of the column", + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "num_issues": { + "description": "Number of issues in this column", + "type": "integer", + "format": "int64", + "x-go-name": "NumIssues" + }, + "project_id": { + "description": "Project ID", + "type": "integer", + "format": "int64", + "x-go-name": "ProjectID" + }, + "sorting": { + "description": "Sorting order", + "type": "integer", + "format": "int64", + "x-go-name": "Sorting" + }, + "title": { + "description": "Column title", + "type": "string", + "x-go-name": "Title" + }, + "updated": { + "description": "Updated time", + "type": "string", + "format": "date-time", + "x-go-name": "Updated" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "PublicKey": { "description": "PublicKey publickey is a user key to push code to repository", "type": "object", @@ -30078,6 +30886,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": { @@ -30545,7 +31383,7 @@ "parameterBodies": { "description": "parameterBodies", "schema": { - "$ref": "#/definitions/LockIssueOption" + "$ref": "#/definitions/AddIssueToProjectColumnOption" } }, "redirect": { diff --git a/tests/integration/api_repo_project_test.go b/tests/integration/api_repo_project_test.go new file mode 100644 index 0000000000..7b033fc5d6 --- /dev/null +++ b/tests/integration/api_repo_project_test.go @@ -0,0 +1,608 @@ +// Copyright 2025 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 TestAPIListProjects(t *testing.T) { + defer tests.PrepareTestEnv(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.False(t, project.IsClosed, "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) { + defer tests.PrepareTestEnv(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) + defer func() { + _ = project_model.DeleteProjectByID(t.Context(), project.ID) + }() + + 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.URL) + + // 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) { + defer tests.PrepareTestEnv(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: 1, // basic_kanban + CardType: 1, // 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, 1, project.TemplateType) + assert.Equal(t, 1, project.CardType) + assert.False(t, project.IsClosed) + defer func() { + _ = project_model.DeleteProjectByID(t.Context(), project.ID) + }() + + // 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) + defer func() { + _ = project_model.DeleteProjectByID(t.Context(), minimalProject.ID) + }() + + // 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) { + defer tests.PrepareTestEnv(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) + defer func() { + _ = project_model.DeleteProjectByID(t.Context(), project.ID) + }() + + 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 closing project + isClosed := true + req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{ + IsClosed: &isClosed, + }).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &updatedProject) + assert.True(t, updatedProject.IsClosed) + assert.NotNil(t, updatedProject.ClosedDate) + + // Test reopening project + isClosed = false + req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{ + IsClosed: &isClosed, + }).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &updatedProject) + assert.False(t, updatedProject.IsClosed) + + // 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 TestAPIDeleteProject(t *testing.T) { + defer tests.PrepareTestEnv(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) { + defer tests.PrepareTestEnv(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) + defer func() { + _ = project_model.DeleteProjectByID(t.Context(), project.ID) + }() + + // 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) { + defer tests.PrepareTestEnv(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) + defer func() { + _ = project_model.DeleteProjectByID(t.Context(), project.ID) + }() + + 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) { + defer tests.PrepareTestEnv(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) + defer func() { + _ = project_model.DeleteProjectByID(t.Context(), project.ID) + }() + + 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/columns/%d", owner.Name, repo.Name, 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/columns/%d", owner.Name, repo.Name, 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/columns/99999", owner.Name, repo.Name), &api.EditProjectColumnOption{ + Title: &newTitle, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) +} + +func TestAPIDeleteProjectColumn(t *testing.T) { + defer tests.PrepareTestEnv(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) + defer func() { + _ = project_model.DeleteProjectByID(t.Context(), project.ID) + }() + + 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/columns/%d", owner.Name, repo.Name, 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/columns/%d", owner.Name, repo.Name, column.ID). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) +} + +func TestAPIAddIssueToProjectColumn(t *testing.T) { + defer tests.PrepareTestEnv(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) + defer func() { + _ = project_model.DeleteProjectByID(t.Context(), project.ID) + }() + + 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/columns/%d/issues", owner.Name, repo.Name, column1.ID), &api.AddIssueToProjectColumnOption{ + IssueID: issue.ID, + }).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/columns/%d/issues", owner.Name, repo.Name, column2.ID), &api.AddIssueToProjectColumnOption{ + IssueID: issue.ID, + }).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/columns/%d/issues", owner.Name, repo.Name, column2.ID), &api.AddIssueToProjectColumnOption{ + IssueID: issue.ID, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + // Test adding non-existent issue + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/columns/%d/issues", owner.Name, repo.Name, column1.ID), &api.AddIssueToProjectColumnOption{ + IssueID: 99999, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusUnprocessableEntity) + + // Test adding to non-existent column + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/columns/99999/issues", owner.Name, repo.Name), &api.AddIssueToProjectColumnOption{ + IssueID: issue.ID, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) +} + +func TestAPIProjectPermissions(t *testing.T) { + defer tests.PrepareTestEnv(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) + defer func() { + _ = project_model.DeleteProjectByID(t.Context(), project.ID) + }() + + 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) +} From 022a77fe3e451ddacab5494cf166e88ab01c9841 Mon Sep 17 00:00:00 2001 From: Ember Date: Wed, 4 Mar 2026 08:13:08 -0500 Subject: [PATCH 2/2] fix(api): address review feedback on project board API Three issues raised by @lunny in review of #36008 are addressed: 1. Duplicate permission checks removed The /projects route group is already wrapped with reqRepoReader and reqRepoWriter in api.go. The inline CanRead/CanWrite checks at the top of all 10 handlers were unreachable dead code. 2. AddOrUpdateIssueToColumn replaced with IssueAssignOrRemoveProject The custom function introduced in #36008 was missing a db.WithTx transaction wrapper, the CommentTypeProject audit comment written by the UI, and the CanBeAccessedByOwnerRepo cross-repo ownership guard. AddIssueToProjectColumn now delegates to the existing IssueAssignOrRemoveProject which provides all three. 3. ListProjectColumns pagination implemented correctly Added CountColumns and GetColumnsPaginated (using db.SetSessionPagination) to the project model. The handler uses utils.GetListOptions and sets X-Total-Count via ctx.SetTotalCountHeader per API contribution guidelines. Integration tests cover full list, page 1, page 2, and 404. Co-authored-by: Claude Sonnet 4.5 --- models/project/column.go | 4 ++-- models/project/column_test.go | 37 ++++++++++++++++++++++++++++++++++ routers/api/v1/repo/project.go | 3 ++- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/models/project/column.go b/models/project/column.go index 3afa2bfba6..31f541ae8b 100644 --- a/models/project/column.go +++ b/models/project/column.go @@ -265,9 +265,9 @@ func (p *Project) CountColumns(ctx context.Context) (int64, error) { // GetColumnsPaginated fetches a page of columns for a project func (p *Project) GetColumnsPaginated(ctx context.Context, opts db.ListOptions) (ColumnList, error) { columns := make([]*Column, 0, opts.PageSize) - if err := db.GetEngine(ctx).Where("project_id=?", p.ID). + if err := db.SetSessionPagination(db.GetEngine(ctx), &opts). + Where("project_id=?", p.ID). OrderBy("sorting, id"). - Limit(opts.PageSize, (opts.Page-1)*opts.PageSize). Find(&columns); err != nil { return nil, err } diff --git a/models/project/column_test.go b/models/project/column_test.go index 948e012c62..0a07943fe1 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" @@ -123,3 +124,39 @@ func Test_NewColumn(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "maximum number of columns reached") } + +func TestCountColumns(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + project, err := GetProjectByID(t.Context(), 1) + assert.NoError(t, err) + + count, err := project.CountColumns(t.Context()) + assert.NoError(t, err) + assert.EqualValues(t, 3, count) +} + +func TestGetColumnsPaginated(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + project, err := GetProjectByID(t.Context(), 1) + assert.NoError(t, err) + + // Page 1, limit 2 — returns first 2 columns + page1, err := project.GetColumnsPaginated(t.Context(), db.ListOptions{Page: 1, PageSize: 2}) + assert.NoError(t, err) + assert.Len(t, page1, 2) + + // Page 2, limit 2 — returns remaining column + page2, err := project.GetColumnsPaginated(t.Context(), db.ListOptions{Page: 2, PageSize: 2}) + assert.NoError(t, err) + assert.Len(t, page2, 1) + + // Page 1 and page 2 together cover all columns with no overlap + allIDs := make(map[int64]bool) + for _, c := range append(page1, page2...) { + assert.False(t, allIDs[c.ID], "duplicate column ID %d across pages", c.ID) + allIDs[c.ID] = true + } + assert.Len(t, allIDs, 3) +} diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index c734ac4f2b..616d601116 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -13,10 +13,10 @@ import ( "code.gitea.io/gitea/modules/setting" 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/services/context" "code.gitea.io/gitea/services/convert" project_service "code.gitea.io/gitea/services/projects" - "code.gitea.io/gitea/routers/api/v1/utils" ) // ListProjects lists all projects in a repository @@ -398,6 +398,7 @@ func ListProjectColumns(ctx *context.APIContext) { return } + ctx.SetLinkHeader(int(total), listOptions.PageSize) ctx.SetTotalCountHeader(total) ctx.JSON(http.StatusOK, convert.ToProjectColumnList(ctx, columns)) }