From 3538141345feabc51e2daa674a935c6049de9a21 Mon Sep 17 00:00:00 2001 From: "Supen.Huang" Date: Thu, 27 Nov 2025 04:09:36 +0800 Subject: [PATCH 1/2] feat(api): add comprehensive REST API for Project Boards 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 Modified Files: - models/project/issue.go: Added AddIssueToColumn 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 New Files: - modules/structs/project.go: API request/response structs - routers/api/v1/repo/project.go: API handlers - routers/api/v1/swagger/project.go: Swagger definitions - services/convert/project.go: Model to API conversion - tests/integration/api_repo_project_test.go: Integration tests Signed-off-by: SupenBysz --- models/project/issue.go | 61 ++ modules/structs/project.go | 139 ++++ routers/api/v1/api.go | 17 + routers/api/v1/repo/project.go | 772 +++++++++++++++++++ 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 | 595 +++++++++++++++ 9 files changed, 2564 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/issue.go b/models/project/issue.go index 47d1537ec7389..562387e1b4830 100644 --- a/models/project/issue.go +++ b/models/project/issue.go @@ -80,3 +80,64 @@ func DeleteAllProjectIssueByIssueIDsAndProjectIDs(ctx context.Context, issueIDs, _, err := db.GetEngine(ctx).In("project_id", projectIDs).In("issue_id", issueIDs).Delete(&ProjectIssue{}) return err } + +// AddIssueToColumn adds an issue to a project column +func AddIssueToColumn(ctx context.Context, issueID int64, column *Column) error { + // Check if the issue is already in this project + existingPI := &ProjectIssue{} + has, err := db.GetEngine(ctx).Where("project_id=? AND issue_id=?", column.ProjectID, issueID).Get(existingPI) + if err != nil { + return err + } + + // If already exists, just update the column + if has { + if existingPI.ProjectColumnID == column.ID { + // Already in this column, nothing to do + return nil + } + // Move to new column - need to update sorting + res := struct { + MaxSorting int64 + IssueCount int64 + }{} + if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as issue_count"). + Table("project_issue"). + Where("project_id=?", column.ProjectID). + And("project_board_id=?", column.ID). + Get(&res); err != nil { + return err + } + + nextSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0) + existingPI.ProjectColumnID = column.ID + existingPI.Sorting = nextSorting + _, err = db.GetEngine(ctx).ID(existingPI.ID).Cols("project_board_id", "sorting").Update(existingPI) + return err + } + + // Calculate next sorting value + res := struct { + MaxSorting int64 + IssueCount int64 + }{} + if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as issue_count"). + Table("project_issue"). + Where("project_id=?", column.ProjectID). + And("project_board_id=?", column.ID). + Get(&res); err != nil { + return err + } + + nextSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0) + + // Create new ProjectIssue + pi := &ProjectIssue{ + IssueID: issueID, + ProjectID: column.ProjectID, + ProjectColumnID: column.ID, + Sorting: nextSorting, + } + + return db.Insert(ctx, pi) +} diff --git a/modules/structs/project.go b/modules/structs/project.go new file mode 100644 index 0000000000000..1375d4f926de1 --- /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 8e07685759803..3266490c3168e 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1585,6 +1585,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 0000000000000..534b4db53fc4a --- /dev/null +++ b/routers/api/v1/repo/project.go @@ -0,0 +1,772 @@ +// 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/models/unit" + "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" +) + +// 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" + + if !ctx.Repo.CanRead(unit.TypeProjects) { + ctx.APIErrorNotFound() + return + } + + 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" + + if !ctx.Repo.CanRead(unit.TypeProjects) { + ctx.APIErrorNotFound() + return + } + + 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" + + if !ctx.Repo.CanWrite(unit.TypeProjects) { + ctx.APIError(http.StatusForbidden, "no permission") + return + } + + 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" + + if !ctx.Repo.CanWrite(unit.TypeProjects) { + ctx.APIError(http.StatusForbidden, "no permission") + return + } + + 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" + + if !ctx.Repo.CanWrite(unit.TypeProjects) { + ctx.APIError(http.StatusForbidden, "no permission") + return + } + + // 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 + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/ProjectColumnList" + // "404": + // "$ref": "#/responses/notFound" + + if !ctx.Repo.CanRead(unit.TypeProjects) { + ctx.APIErrorNotFound() + return + } + + 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 + } + + // Get all columns + allColumns, err := project.GetColumns(ctx) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + totalCount := int64(len(allColumns)) + + // Parse pagination parameters + page := ctx.FormInt("page") + if page <= 0 { + page = 1 + } + + limit := ctx.FormInt("limit") + if limit <= 0 { + limit = setting.UI.IssuePagingNum + } + + // Apply pagination + start := (page - 1) * limit + end := start + limit + + var columns project_model.ColumnList + if start < len(allColumns) { + if end > len(allColumns) { + end = len(allColumns) + } + columns = allColumns[start:end] + } else { + columns = make([]*project_model.Column, 0) + } + + ctx.SetLinkHeader(int(totalCount), limit) + ctx.SetTotalCountHeader(totalCount) + 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" + + if !ctx.Repo.CanWrite(unit.TypeProjects) { + ctx.APIError(http.StatusForbidden, "no permission") + return + } + + 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" + + if !ctx.Repo.CanWrite(unit.TypeProjects) { + ctx.APIError(http.StatusForbidden, "no permission") + return + } + + 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" + + if !ctx.Repo.CanWrite(unit.TypeProjects) { + ctx.APIError(http.StatusForbidden, "no permission") + return + } + + 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" + + if !ctx.Repo.CanWrite(unit.TypeProjects) { + ctx.APIError(http.StatusForbidden, "no permission") + return + } + + 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 + } + + // Add issue to column + if err := project_model.AddIssueToColumn(ctx, form.IssueID, column); 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 b80a9c14ba027..c0902cdd794fa 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -225,4 +225,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 0000000000000..da7d80456b27b --- /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 0000000000000..a419777c77214 --- /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 0cefa6795f4f5..87971add97e5c 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -13420,6 +13420,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", + "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": [ @@ -21573,6 +22095,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", @@ -23306,6 +23844,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", @@ -24421,6 +25009,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", @@ -27103,6 +27742,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", @@ -29847,6 +30655,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": { @@ -30308,7 +31146,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 0000000000000..793670d542ffd --- /dev/null +++ b/tests/integration/api_repo_project_test.go @@ -0,0 +1,595 @@ +// 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 columns + 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) + + // Test pagination + 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) + + // 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 4ebe67ff4a5adca94243ea9360e2a2e30798cf15 Mon Sep 17 00:00:00 2001 From: "Supen.Huang" Date: Sun, 30 Nov 2025 20:01:25 +0800 Subject: [PATCH 2/2] project: rename AddIssueToColumn helper --- models/project/issue.go | 4 ++-- routers/api/v1/repo/project.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/models/project/issue.go b/models/project/issue.go index 562387e1b4830..24b92472cb2ff 100644 --- a/models/project/issue.go +++ b/models/project/issue.go @@ -81,8 +81,8 @@ func DeleteAllProjectIssueByIssueIDsAndProjectIDs(ctx context.Context, issueIDs, return err } -// AddIssueToColumn adds an issue to a project column -func AddIssueToColumn(ctx context.Context, issueID int64, column *Column) error { +// AddOrUpdateIssueToColumn adds an issue to a project column or moves an existing one +func AddOrUpdateIssueToColumn(ctx context.Context, issueID int64, column *Column) error { // Check if the issue is already in this project existingPI := &ProjectIssue{} has, err := db.GetEngine(ctx).Where("project_id=? AND issue_id=?", column.ProjectID, issueID).Get(existingPI) diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index 534b4db53fc4a..ca199236e504d 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -762,8 +762,8 @@ func AddIssueToProjectColumn(ctx *context.APIContext) { return } - // Add issue to column - if err := project_model.AddIssueToColumn(ctx, form.IssueID, column); err != nil { + // Add or update issue in column + if err := project_model.AddOrUpdateIssueToColumn(ctx, form.IssueID, column); err != nil { ctx.APIErrorInternal(err) return }