From 4338a2b72fa6815c58464ac9c53593e2f917ec6d Mon Sep 17 00:00:00 2001 From: "Supen.Huang" Date: Fri, 19 Dec 2025 00:37:16 +0800 Subject: [PATCH] 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 --- modules/structs/project.go | 95 +- 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 | 89 +- templates/swagger/v1_json.tmpl | 1723 ++++++++++---------- tests/integration/api_repo_project_test.go | 608 +++++++ 8 files changed, 2395 insertions(+), 886 deletions(-) create mode 100644 routers/api/v1/repo/project.go create mode 100644 routers/api/v1/swagger/project.go create mode 100644 tests/integration/api_repo_project_test.go diff --git a/modules/structs/project.go b/modules/structs/project.go index 5feb122767..b2a386e70b 100644 --- a/modules/structs/project.go +++ b/modules/structs/project.go @@ -1,4 +1,4 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. +// Copyright 2025 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package structs @@ -10,24 +10,83 @@ import ( // Project represents a project // swagger:model type Project struct { - // ID is the unique identifier for the project - ID int64 `json:"id"` - // Title is the title of the project - Title string `json:"title"` - // Description provides details about the project - Description string `json:"description"` - // OwnerID is the owner of the project (for org-level projects) - OwnerID int64 `json:"owner_id,omitempty"` - // RepoID is the repository this project belongs to (for repo-level projects) - RepoID int64 `json:"repo_id,omitempty"` - // CreatorID is the user who created the project - CreatorID int64 `json:"creator_id"` - // IsClosed indicates if the project is closed - IsClosed bool `json:"is_closed"` + ID int64 `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + OwnerID int64 `json:"owner_id,omitempty"` + RepoID int64 `json:"repo_id,omitempty"` + CreatorID int64 `json:"creator_id"` + IsClosed bool `json:"is_closed"` + TemplateType int `json:"template_type"` + CardType int `json:"card_type"` + Type int `json:"type"` + NumOpenIssues int64 `json:"num_open_issues,omitempty"` + NumClosedIssues int64 `json:"num_closed_issues,omitempty"` + NumIssues int64 `json:"num_issues,omitempty"` // swagger:strfmt date-time - Created time.Time `json:"created_at"` + Created time.Time `json:"created"` // swagger:strfmt date-time - Updated time.Time `json:"updated_at"` + Updated time.Time `json:"updated"` // swagger:strfmt date-time - Closed *time.Time `json:"closed_at,omitempty"` + ClosedDate *time.Time `json:"closed_date,omitempty"` + 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"` + Description string `json:"description"` + TemplateType int `json:"template_type"` + CardType int `json:"card_type"` +} + +// EditProjectOption represents options for editing a project +// swagger:model +type EditProjectOption struct { + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + CardType *int `json:"card_type,omitempty"` + IsClosed *bool `json:"is_closed,omitempty"` +} + +// ProjectColumn represents a project column (board) +// swagger:model +type ProjectColumn struct { + ID int64 `json:"id"` + Title string `json:"title"` + Default bool `json:"default"` + Sorting int `json:"sorting"` + Color string `json:"color,omitempty"` + ProjectID int64 `json:"project_id"` + CreatorID int64 `json:"creator_id"` + NumIssues int64 `json:"num_issues,omitempty"` + // swagger:strfmt date-time + Created time.Time `json:"created"` + // 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"` + Color string `json:"color,omitempty"` +} + +// EditProjectColumnOption represents options for editing a project column +// swagger:model +type EditProjectColumnOption struct { + Title *string `json:"title,omitempty"` + Color *string `json:"color,omitempty"` + Sorting *int `json:"sorting,omitempty"` +} + +// AddIssueToProjectColumnOption represents options for adding issues to a project +// swagger:model +type AddIssueToProjectColumnOption struct { + // required: true + IssueIDs []int64 `json:"issue_ids" binding:"Required"` +} \ No newline at end of file diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index a8bfa0965e..3d52ae211c 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1575,6 +1575,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 1a442d1146..327d188aa9 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -233,4 +233,17 @@ type swaggerParameterBodies struct { // in:body LockIssueOption api.LockIssueOption + + // in:body + CreateProjectOption api.CreateProjectOption + // in:body + EditProjectOption api.EditProjectOption + + // in:body + CreateProjectColumnOption api.CreateProjectColumnOption + // in:body + EditProjectColumnOption api.EditProjectColumnOption + + // in:body + 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 index b66de746ca..71f590591e 100644 --- a/services/convert/project.go +++ b/services/convert/project.go @@ -1,37 +1,86 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. +// 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" ) -// ToAPIProject converts a Project to API format -func ToAPIProject(p *project_model.Project) *api.Project { - apiProject := &api.Project{ - ID: p.ID, - Title: p.Title, - Description: p.Description, - OwnerID: p.OwnerID, - RepoID: p.RepoID, - CreatorID: p.CreatorID, - IsClosed: p.IsClosed, - Created: p.CreatedUnix.AsTime(), - Updated: p.UpdatedUnix.AsTime(), +// 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 } - if p.IsClosed && p.ClosedDateUnix > 0 { - apiProject.Closed = p.ClosedDateUnix.AsTimePtr() + 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(), } - return apiProject + if p.ClosedDateUnix > 0 { + t := p.ClosedDateUnix.AsTime() + project.ClosedDate = &t + } + 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 } -// ToAPIProjectList converts a list of Projects to API format -func ToAPIProjectList(projects []*project_model.Project) []*api.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 := range projects { - result[i] = ToAPIProject(projects[i]) + 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 +} \ No newline at end of file diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 26d45940f2..2ac8463de1 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -1,9 +1,11 @@ { "consumes": [ - "application/json" + "application/json", + "text/plain" ], "produces": [ - "application/json" + "application/json", + "text/html" ], "schemes": [ "https", @@ -74,17 +76,9 @@ ], "summary": "Get all runners", "operationId": "getAdminRunners", - "parameters": [ - { - "type": "boolean", - "description": "filter by disabled status (true or false)", - "name": "disabled", - "in": "query" - } - ], "responses": { "200": { - "$ref": "#/responses/RunnerList" + "$ref": "#/definitions/ActionRunnersResponse" }, "400": { "$ref": "#/responses/error" @@ -133,7 +127,7 @@ ], "responses": { "200": { - "$ref": "#/responses/Runner" + "$ref": "#/definitions/ActionRunner" }, "400": { "$ref": "#/responses/error" @@ -172,49 +166,6 @@ "$ref": "#/responses/notFound" } } - }, - "patch": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Update a global runner", - "operationId": "updateAdminRunner", - "parameters": [ - { - "type": "string", - "description": "id of the runner", - "name": "runner_id", - "in": "path", - "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/EditActionRunnerOption" - } - } - ], - "responses": { - "200": { - "$ref": "#/responses/Runner" - }, - "400": { - "$ref": "#/responses/error" - }, - "404": { - "$ref": "#/responses/notFound" - }, - "422": { - "$ref": "#/responses/validationError" - } - } } }, "/admin/actions/runs": { @@ -1996,17 +1947,11 @@ "name": "org", "in": "path", "required": true - }, - { - "type": "boolean", - "description": "filter by disabled status (true or false)", - "name": "disabled", - "in": "query" } ], "responses": { "200": { - "$ref": "#/responses/RunnerList" + "$ref": "#/definitions/ActionRunnersResponse" }, "400": { "$ref": "#/responses/error" @@ -2071,7 +2016,7 @@ ], "responses": { "200": { - "$ref": "#/responses/Runner" + "$ref": "#/definitions/ActionRunner" }, "400": { "$ref": "#/responses/error" @@ -2117,56 +2062,6 @@ "$ref": "#/responses/notFound" } } - }, - "patch": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "organization" - ], - "summary": "Update an org-level runner", - "operationId": "updateOrgRunner", - "parameters": [ - { - "type": "string", - "description": "name of the organization", - "name": "org", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "id of the runner", - "name": "runner_id", - "in": "path", - "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/EditActionRunnerOption" - } - } - ], - "responses": { - "200": { - "$ref": "#/responses/Runner" - }, - "400": { - "$ref": "#/responses/error" - }, - "404": { - "$ref": "#/responses/notFound" - }, - "422": { - "$ref": "#/responses/validationError" - } - } } }, "/orgs/{org}/actions/runs": { @@ -3633,39 +3528,6 @@ "$ref": "#/responses/notFound" } } - }, - "delete": { - "produces": [ - "application/json" - ], - "tags": [ - "organization" - ], - "summary": "Delete all repositories in an organization", - "operationId": "orgDeleteRepos", - "parameters": [ - { - "type": "string", - "description": "name of the organization", - "name": "org", - "in": "path", - "required": true - } - ], - "responses": { - "202": { - "$ref": "#/responses/empty" - }, - "204": { - "$ref": "#/responses/empty" - }, - "403": { - "$ref": "#/responses/forbidden" - }, - "404": { - "$ref": "#/responses/notFound" - } - } } }, "/orgs/{org}/teams": { @@ -3868,7 +3730,6 @@ "rpm", "rubygems", "swift", - "terraform", "vagrant" ], "type": "string", @@ -3946,44 +3807,6 @@ "$ref": "#/responses/notFound" } } - }, - "delete": { - "tags": [ - "package" - ], - "summary": "Delete a package", - "operationId": "deletePackage", - "parameters": [ - { - "type": "string", - "description": "owner of the package", - "name": "owner", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "type of the package", - "name": "type", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "name of the package", - "name": "name", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "$ref": "#/responses/empty" - }, - "404": { - "$ref": "#/responses/notFound" - } - } } }, "/packages/{owner}/{type}/{name}/-/latest": { @@ -4169,8 +3992,8 @@ "tags": [ "package" ], - "summary": "Delete a package version", - "operationId": "deletePackageVersion", + "summary": "Delete a package", + "operationId": "deletePackage", "parameters": [ { "type": "string", @@ -5049,17 +4872,11 @@ "name": "repo", "in": "path", "required": true - }, - { - "type": "boolean", - "description": "filter by disabled status (true or false)", - "name": "disabled", - "in": "query" } ], "responses": { "200": { - "$ref": "#/responses/RunnerList" + "$ref": "#/definitions/ActionRunnersResponse" }, "400": { "$ref": "#/responses/error" @@ -5111,7 +4928,7 @@ "tags": [ "repository" ], - "summary": "Get a repo-level runner", + "summary": "Get an repo-level runner", "operationId": "getRepoRunner", "parameters": [ { @@ -5138,7 +4955,7 @@ ], "responses": { "200": { - "$ref": "#/responses/Runner" + "$ref": "#/definitions/ActionRunner" }, "400": { "$ref": "#/responses/error" @@ -5155,7 +4972,7 @@ "tags": [ "repository" ], - "summary": "Delete a repo-level runner", + "summary": "Delete an repo-level runner", "operationId": "deleteRepoRunner", "parameters": [ { @@ -5191,63 +5008,6 @@ "$ref": "#/responses/notFound" } } - }, - "patch": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "repository" - ], - "summary": "Update a repo-level runner", - "operationId": "updateRepoRunner", - "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": "string", - "description": "id of the runner", - "name": "runner_id", - "in": "path", - "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/EditActionRunnerOption" - } - } - ], - "responses": { - "200": { - "$ref": "#/responses/Runner" - }, - "400": { - "$ref": "#/responses/error" - }, - "404": { - "$ref": "#/responses/notFound" - }, - "422": { - "$ref": "#/responses/validationError" - } - } } }, "/repos/{owner}/{repo}/actions/runs": { @@ -5357,7 +5117,7 @@ "required": true }, { - "type": "integer", + "type": "string", "description": "id of the run", "name": "run", "in": "path", @@ -5473,130 +5233,6 @@ } } }, - "/repos/{owner}/{repo}/actions/runs/{run}/attempts/{attempt}": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "repository" - ], - "summary": "Gets a specific workflow run attempt", - "operationId": "getWorkflowRunAttempt", - "parameters": [ - { - "type": "string", - "description": "owner of the repo", - "name": "owner", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "name of the repository", - "name": "repo", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "id of the run", - "name": "run", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "logical attempt number of the run", - "name": "attempt", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/responses/WorkflowRun" - }, - "400": { - "$ref": "#/responses/error" - }, - "404": { - "$ref": "#/responses/notFound" - } - } - } - }, - "/repos/{owner}/{repo}/actions/runs/{run}/attempts/{attempt}/jobs": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "repository" - ], - "summary": "Lists all jobs for a workflow run attempt", - "operationId": "listWorkflowRunAttemptJobs", - "parameters": [ - { - "type": "string", - "description": "owner of the repo", - "name": "owner", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "name of the repository", - "name": "repo", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "id of the workflow run", - "name": "run", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "logical attempt number of the run", - "name": "attempt", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "workflow status (pending, queued, in_progress, failure, success, skipped)", - "name": "status", - "in": "query" - }, - { - "type": "integer", - "description": "page number of results to return (1-based)", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "page size of results", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "$ref": "#/responses/WorkflowJobsList" - }, - "400": { - "$ref": "#/responses/error" - }, - "404": { - "$ref": "#/responses/notFound" - } - } - } - }, "/repos/{owner}/{repo}/actions/runs/{run}/jobs": { "get": { "produces": [ @@ -5714,9 +5350,6 @@ "404": { "$ref": "#/responses/notFound" }, - "409": { - "$ref": "#/responses/error" - }, "422": { "$ref": "#/responses/validationError" } @@ -5769,61 +5402,6 @@ "404": { "$ref": "#/responses/notFound" }, - "409": { - "$ref": "#/responses/error" - }, - "422": { - "$ref": "#/responses/validationError" - } - } - } - }, - "/repos/{owner}/{repo}/actions/runs/{run}/rerun-failed-jobs": { - "post": { - "tags": [ - "repository" - ], - "summary": "Reruns all failed jobs in a workflow run", - "operationId": "rerunFailedWorkflowRun", - "parameters": [ - { - "type": "string", - "description": "owner of the repo", - "name": "owner", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "name of the repository", - "name": "repo", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "id of the run", - "name": "run", - "in": "path", - "required": true - } - ], - "responses": { - "201": { - "$ref": "#/responses/empty" - }, - "400": { - "$ref": "#/responses/error" - }, - "403": { - "$ref": "#/responses/forbidden" - }, - "404": { - "$ref": "#/responses/notFound" - }, - "409": { - "$ref": "#/responses/error" - }, "422": { "$ref": "#/responses/validationError" } @@ -10433,7 +10011,7 @@ } ], "responses": { - "204": { + "200": { "$ref": "#/responses/empty" }, "403": { @@ -10567,7 +10145,6 @@ } }, "patch": { - "description": "Pass `content_version` to enable optimistic locking on body edits.\nIf the version doesn't match the current value, the request fails with 409 Conflict.\n", "consumes": [ "application/json" ], @@ -12173,7 +11750,7 @@ } ], "responses": { - "204": { + "200": { "$ref": "#/responses/empty" }, "403": { @@ -13948,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": [ @@ -14470,75 +14569,6 @@ } } }, - "/repos/{owner}/{repo}/pulls/{index}/comments/{id}/replies": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "repository" - ], - "summary": "Reply to a pull request review comment", - "operationId": "repoCreatePullReviewCommentReply", - "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": "index of the pull request", - "name": "index", - "in": "path", - "required": true - }, - { - "type": "integer", - "format": "int64", - "description": "id of the review comment to reply to", - "name": "id", - "in": "path", - "required": true - }, - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/CreatePullReviewCommentReplyOptions" - } - } - ], - "responses": { - "201": { - "$ref": "#/responses/PullReviewComment" - }, - "400": { - "$ref": "#/responses/validationError" - }, - "404": { - "$ref": "#/responses/notFound" - }, - "422": { - "$ref": "#/responses/validationError" - } - } - } - }, "/repos/{owner}/{repo}/pulls/{index}/commits": { "get": { "produces": [ @@ -14768,9 +14798,6 @@ "200": { "$ref": "#/responses/empty" }, - "403": { - "$ref": "#/responses/forbidden" - }, "404": { "$ref": "#/responses/notFound" }, @@ -15007,7 +15034,7 @@ "tags": [ "repository" ], - "summary": "Create a review to a pull request", + "summary": "Create a review to an pull request", "operationId": "repoCreatePullReview", "parameters": [ { @@ -15112,7 +15139,7 @@ "tags": [ "repository" ], - "summary": "Submit a pending review to a pull request", + "summary": "Submit a pending review to an pull request", "operationId": "repoSubmitPullReview", "parameters": [ { @@ -15777,7 +15804,7 @@ }, { "type": "boolean", - "description": "filter (exclude / include) drafts, if you don't have repo write access none will show", + "description": "filter (exclude / include) drafts, if you dont have repo write access none will show", "name": "draft", "in": "query" }, @@ -18936,17 +18963,9 @@ ], "summary": "Get user-level runners", "operationId": "getUserRunners", - "parameters": [ - { - "type": "boolean", - "description": "filter by disabled status (true or false)", - "name": "disabled", - "in": "query" - } - ], "responses": { "200": { - "$ref": "#/responses/RunnerList" + "$ref": "#/definitions/ActionRunnersResponse" }, "400": { "$ref": "#/responses/error" @@ -18965,7 +18984,7 @@ "tags": [ "user" ], - "summary": "Get a user's actions runner registration token", + "summary": "Get an user's actions runner registration token", "operationId": "userCreateRunnerRegistrationToken", "responses": { "200": { @@ -18982,7 +19001,7 @@ "tags": [ "user" ], - "summary": "Get a user-level runner", + "summary": "Get an user-level runner", "operationId": "getUserRunner", "parameters": [ { @@ -18995,7 +19014,7 @@ ], "responses": { "200": { - "$ref": "#/responses/Runner" + "$ref": "#/definitions/ActionRunner" }, "400": { "$ref": "#/responses/error" @@ -19012,7 +19031,7 @@ "tags": [ "user" ], - "summary": "Delete a user-level runner", + "summary": "Delete an user-level runner", "operationId": "deleteUserRunner", "parameters": [ { @@ -19034,49 +19053,6 @@ "$ref": "#/responses/notFound" } } - }, - "patch": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "Update a user-level runner", - "operationId": "updateUserRunner", - "parameters": [ - { - "type": "string", - "description": "id of the runner", - "name": "runner_id", - "in": "path", - "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/EditActionRunnerOption" - } - } - ], - "responses": { - "200": { - "$ref": "#/responses/Runner" - }, - "400": { - "$ref": "#/responses/error" - }, - "404": { - "$ref": "#/responses/notFound" - }, - "422": { - "$ref": "#/responses/validationError" - } - } } }, "/user/actions/runs": { @@ -19556,9 +19532,6 @@ "200": { "$ref": "#/responses/OAuth2Application" }, - "400": { - "$ref": "#/responses/error" - }, "404": { "$ref": "#/responses/notFound" } @@ -21664,10 +21637,6 @@ "type": "boolean", "x-go-name": "Busy" }, - "disabled": { - "type": "boolean", - "x-go-name": "Disabled" - }, "ephemeral": { "type": "boolean", "x-go-name": "Ephemeral" @@ -22098,11 +22067,6 @@ "type": "string", "x-go-name": "Path" }, - "previous_attempt_url": { - "description": "PreviousAttemptURL is the API URL of the previous attempt of this run, e.g. \".../actions/runs/{run_id}/attempts/{attempt-1}\".\nIt is set only when the current attempt is \u003e 1 (i.e. a rerun). For the first attempt, or for legacy runs that pre-date ActionRunAttempt, it is null.", - "type": "string", - "x-go-name": "PreviousAttemptURL" - }, "repository": { "$ref": "#/definitions/Repository" }, @@ -22112,7 +22076,6 @@ "x-go-name": "RepositoryID" }, "run_attempt": { - "description": "RunAttempt is 1-based for runs created after ActionRunAttempt was introduced.\nA value of 0 is a legacy-only sentinel for runs created before attempts existed\nand indicates no corresponding /attempts/{n} resource is available.", "type": "integer", "format": "int64", "x-go-name": "RunAttempt" @@ -22299,19 +22262,33 @@ "type": "object", "properties": { "permission": { - "description": "Permission level to grant the collaborator\nread RepoWritePermissionRead\nwrite RepoWritePermissionWrite\nadmin RepoWritePermissionAdmin", "type": "string", "enum": [ "read", "write", "admin" ], - "x-go-enum-desc": "read RepoWritePermissionRead\nwrite RepoWritePermissionWrite\nadmin RepoWritePermissionAdmin", "x-go-name": "Permission" } }, "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", @@ -23748,11 +23725,6 @@ }, "x-go-name": "Events" }, - "name": { - "description": "Optional human-readable name for the webhook", - "type": "string", - "x-go-name": "Name" - }, "type": { "type": "string", "enum": [ @@ -23841,15 +23813,6 @@ "format": "int64", "x-go-name": "Milestone" }, - "projects": { - "description": "list of project ids", - "type": "array", - "items": { - "type": "integer", - "format": "int64" - }, - "x-go-name": "Projects" - }, "ref": { "type": "string", "x-go-name": "Ref" @@ -24042,14 +24005,13 @@ "x-go-name": "UserName" }, "visibility": { - "description": "possible values are `public` (default), `limited` or `private`\npublic UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", + "description": "possible values are `public` (default), `limited` or `private`", "type": "string", "enum": [ "public", "limited", "private" ], - "x-go-enum-desc": "public UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "x-go-name": "Visibility" }, "website": { @@ -24060,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", @@ -24169,17 +24181,6 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, - "CreatePullReviewCommentReplyOptions": { - "description": "CreatePullReviewCommentReplyOptions are options to reply to a pull request review comment", - "type": "object", - "properties": { - "body": { - "type": "string", - "x-go-name": "Body" - } - }, - "x-go-package": "code.gitea.io/gitea/modules/structs" - }, "CreatePullReviewOptions": { "description": "CreatePullReviewOptions are options to create a pull request review", "type": "object", @@ -24200,16 +24201,7 @@ "x-go-name": "CommitID" }, "event": { - "type": "string", - "enum": [ - "APPROVED", - "PENDING", - "COMMENT", - "REQUEST_CHANGES", - "REQUEST_REVIEW" - ], - "x-go-enum-desc": "APPROVED ReviewStateApproved ReviewStateApproved pr is approved\nPENDING ReviewStatePending ReviewStatePending pr state is pending\nCOMMENT ReviewStateComment ReviewStateComment is a comment review\nREQUEST_CHANGES ReviewStateRequestChanges ReviewStateRequestChanges changes for pr are requested\nREQUEST_REVIEW ReviewStateRequestReview ReviewStateRequestReview review is requested from user", - "x-go-name": "Event" + "$ref": "#/definitions/ReviewStateType" } }, "x-go-package": "code.gitea.io/gitea/modules/structs" @@ -24334,13 +24326,12 @@ "x-go-name": "Name" }, "object_format_name": { - "description": "ObjectFormatName of the underlying git repository, empty string for default (sha1)\nsha1 ObjectFormatSHA1\nsha256 ObjectFormatSHA256", + "description": "ObjectFormatName of the underlying git repository, empty string for default (sha1)", "type": "string", "enum": [ "sha1", "sha256" ], - "x-go-enum-desc": "sha1 ObjectFormatSHA1\nsha256 ObjectFormatSHA256", "x-go-name": "ObjectFormatName" }, "private": { @@ -24493,7 +24484,6 @@ "write", "admin" ], - "x-go-enum-desc": "read RepoWritePermissionRead\nwrite RepoWritePermissionWrite\nadmin RepoWritePermissionAdmin", "x-go-name": "Permission" }, "units": { @@ -24588,14 +24578,8 @@ "x-go-name": "Username" }, "visibility": { - "description": "User visibility level: public, limited, or private\npublic UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", + "description": "User visibility level: public, limited, or private", "type": "string", - "enum": [ - "public", - "limited", - "private" - ], - "x-go-enum-desc": "public UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "x-go-name": "Visibility" } }, @@ -24809,20 +24793,6 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, - "EditActionRunnerOption": { - "type": "object", - "title": "EditActionRunnerOption represents the editable fields for a runner.", - "required": [ - "disabled" - ], - "properties": { - "disabled": { - "type": "boolean", - "x-go-name": "Disabled" - } - }, - "x-go-package": "code.gitea.io/gitea/modules/structs" - }, "EditAttachmentOptions": { "description": "EditAttachmentOptions options for editing attachments", "type": "object", @@ -25048,11 +25018,6 @@ "type": "string" }, "x-go-name": "Events" - }, - "name": { - "description": "Optional human-readable name", - "type": "string", - "x-go-name": "Name" } }, "x-go-package": "code.gitea.io/gitea/modules/structs" @@ -25091,12 +25056,6 @@ "type": "string", "x-go-name": "Body" }, - "content_version": { - "description": "The current version of the issue content to detect conflicts during editing", - "type": "integer", - "format": "int64", - "x-go-name": "ContentVersion" - }, "due_date": { "type": "string", "format": "date-time", @@ -25107,15 +25066,6 @@ "format": "int64", "x-go-name": "Milestone" }, - "projects": { - "description": "list of project ids to set (replaces existing projects)", - "type": "array", - "items": { - "type": "integer", - "format": "int64" - }, - "x-go-name": "Projects" - }, "ref": { "type": "string", "x-go-name": "Ref" @@ -25185,10 +25135,6 @@ "state": { "description": "State indicates the updated state of the milestone", "type": "string", - "enum": [ - "open", - "closed" - ], "x-go-name": "State" }, "title": { @@ -25209,7 +25155,7 @@ "x-go-name": "Description" }, "email": { - "description": "The email address of the organization; use empty string to clear", + "description": "The email address of the organization", "type": "string", "x-go-name": "Email" }, @@ -25229,14 +25175,13 @@ "x-go-name": "RepoAdminChangeTeamAccess" }, "visibility": { - "description": "possible values are `public`, `limited` or `private`\npublic UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", + "description": "possible values are `public`, `limited` or `private`", "type": "string", "enum": [ "public", "limited", "private" ], - "x-go-enum-desc": "public UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "x-go-name": "Visibility" }, "website": { @@ -25247,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", @@ -25279,12 +25275,6 @@ "type": "string", "x-go-name": "Body" }, - "content_version": { - "description": "The current version of the pull request content to detect conflicts during editing", - "type": "integer", - "format": "int64", - "x-go-name": "ContentVersion" - }, "due_date": { "type": "string", "format": "date-time", @@ -25615,7 +25605,6 @@ "write", "admin" ], - "x-go-enum-desc": "read RepoWritePermissionRead\nwrite RepoWritePermissionWrite\nadmin RepoWritePermissionAdmin", "x-go-name": "Permission" }, "units": { @@ -25746,14 +25735,8 @@ "x-go-name": "SourceID" }, "visibility": { - "description": "User visibility level: public, limited, or private\npublic UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", + "description": "User visibility level: public, limited, or private", "type": "string", - "enum": [ - "public", - "limited", - "private" - ], - "x-go-enum-desc": "public UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "x-go-name": "Visibility" }, "website": { @@ -26505,11 +26488,6 @@ "format": "int64", "x-go-name": "ID" }, - "name": { - "description": "Optional human-readable name for the webhook", - "type": "string", - "x-go-name": "Name" - }, "type": { "description": "The type of the webhook (e.g., gitea, slack, discord)", "type": "string", @@ -26597,12 +26575,6 @@ "format": "int64", "x-go-name": "Comments" }, - "content_version": { - "description": "The version of the issue content for optimistic locking", - "type": "integer", - "format": "int64", - "x-go-name": "ContentVersion" - }, "created_at": { "type": "string", "format": "date-time", @@ -26655,13 +26627,6 @@ "format": "int64", "x-go-name": "PinOrder" }, - "projects": { - "type": "array", - "items": { - "$ref": "#/definitions/Project" - }, - "x-go-name": "Projects" - }, "pull_request": { "$ref": "#/definitions/PullRequestMeta" }, @@ -26673,13 +26638,7 @@ "$ref": "#/definitions/RepositoryMeta" }, "state": { - "type": "string", - "enum": [ - "open", - "closed" - ], - "x-go-enum-desc": "open StateOpen StateOpen pr is opened\nclosed StateClosed StateClosed pr is closed", - "x-go-name": "State" + "$ref": "#/definitions/StateType" }, "time_estimate": { "type": "integer", @@ -26780,16 +26739,7 @@ "x-go-name": "ID" }, "type": { - "type": "string", - "enum": [ - "markdown", - "textarea", - "input", - "dropdown", - "checkboxes" - ], - "x-go-enum-desc": "markdown IssueFormFieldTypeMarkdown\ntextarea IssueFormFieldTypeTextarea\ninput IssueFormFieldTypeInput\ndropdown IssueFormFieldTypeDropdown\ncheckboxes IssueFormFieldTypeCheckboxes", - "x-go-name": "Type" + "$ref": "#/definitions/IssueFormFieldType" }, "validations": { "type": "object", @@ -26799,18 +26749,23 @@ "visible": { "type": "array", "items": { - "type": "string", - "enum": [ - "form", - "content" - ], - "x-go-enum-desc": "form IssueFormFieldVisibleForm\ncontent IssueFormFieldVisibleContent" + "$ref": "#/definitions/IssueFormFieldVisible" }, "x-go-name": "Visible" } }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "IssueFormFieldType": { + "type": "string", + "title": "IssueFormFieldType defines issue form field type, can be \"markdown\", \"textarea\", \"input\", \"dropdown\" or \"checkboxes\"", + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "IssueFormFieldVisible": { + "description": "IssueFormFieldVisible defines issue form field visible", + "type": "string", + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "IssueLabelsOption": { "description": "IssueLabelsOption a collection of labels", "type": "object", @@ -27085,14 +27040,10 @@ "description": "MergePullRequestForm form for merging Pull Request", "type": "object", "required": [ - "do" + "Do" ], "properties": { - "delete_branch_after_merge": { - "type": "boolean", - "x-go-name": "DeleteBranchAfterMerge" - }, - "do": { + "Do": { "type": "string", "enum": [ "merge", @@ -27101,8 +27052,20 @@ "squash", "fast-forward-only", "manually-merged" - ], - "x-go-name": "Do" + ] + }, + "MergeCommitID": { + "type": "string" + }, + "MergeMessageField": { + "type": "string" + }, + "MergeTitleField": { + "type": "string" + }, + "delete_branch_after_merge": { + "type": "boolean", + "x-go-name": "DeleteBranchAfterMerge" }, "force_merge": { "type": "boolean", @@ -27112,18 +27075,6 @@ "type": "string", "x-go-name": "HeadCommitID" }, - "merge_commit_id": { - "type": "string", - "x-go-name": "MergeCommitID" - }, - "merge_message_field": { - "type": "string", - "x-go-name": "MergeMessageField" - }, - "merge_title_field": { - "type": "string", - "x-go-name": "MergeTitleField" - }, "merge_when_checks_succeed": { "type": "boolean", "x-go-name": "MergeWhenChecksSucceed" @@ -27312,14 +27263,7 @@ "x-go-name": "OpenIssues" }, "state": { - "description": "State indicates if the milestone is open or closed\nopen StateOpen StateOpen pr is opened\nclosed StateClosed StateClosed pr is closed", - "type": "string", - "enum": [ - "open", - "closed" - ], - "x-go-enum-desc": "open StateOpen StateOpen pr is opened\nclosed StateClosed StateClosed pr is closed", - "x-go-name": "State" + "$ref": "#/definitions/StateType" }, "title": { "description": "Title is the title of the milestone", @@ -27533,15 +27477,7 @@ "x-go-name": "LatestCommentURL" }, "state": { - "description": "State indicates the current state of the notification subject\nopen NotifySubjectStateOpen NotifySubjectStateOpen is an open subject\nclosed NotifySubjectStateClosed NotifySubjectStateClosed is a closed subject\nmerged NotifySubjectStateMerged NotifySubjectStateMerged is a merged pull request", - "type": "string", - "enum": [ - "open", - "closed", - "merged" - ], - "x-go-enum-desc": "open NotifySubjectStateOpen NotifySubjectStateOpen is an open subject\nclosed NotifySubjectStateClosed NotifySubjectStateClosed is a closed subject\nmerged NotifySubjectStateMerged NotifySubjectStateMerged is a merged pull request", - "x-go-name": "State" + "$ref": "#/definitions/StateType" }, "title": { "description": "Title is the title of the notification subject", @@ -27549,16 +27485,7 @@ "x-go-name": "Title" }, "type": { - "description": "Type indicates the type of the notification subject\nIssue NotifySubjectIssue NotifySubjectIssue a issue is subject of an notification\nPull NotifySubjectPull NotifySubjectPull a pull is subject of an notification\nCommit NotifySubjectCommit NotifySubjectCommit a commit is subject of an notification\nRepository NotifySubjectRepository NotifySubjectRepository a repository is subject of an notification", - "type": "string", - "enum": [ - "Issue", - "Pull", - "Commit", - "Repository" - ], - "x-go-enum-desc": "Issue NotifySubjectIssue NotifySubjectIssue a issue is subject of an notification\nPull NotifySubjectPull NotifySubjectPull a pull is subject of an notification\nCommit NotifySubjectCommit NotifySubjectCommit a commit is subject of an notification\nRepository NotifySubjectRepository NotifySubjectRepository a repository is subject of an notification", - "x-go-name": "Type" + "$ref": "#/definitions/NotifySubjectType" }, "url": { "description": "URL is the API URL for the notification subject", @@ -27608,6 +27535,11 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "NotifySubjectType": { + "description": "NotifySubjectType represent type of notification subject", + "type": "string", + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "OAuth2Application": { "type": "object", "title": "OAuth2Application represents an OAuth2 application.", @@ -27711,14 +27643,8 @@ "x-go-name": "UserName" }, "visibility": { - "description": "The visibility level of the organization (public, limited, private)\npublic UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", + "description": "The visibility level of the organization (public, limited, private)", "type": "string", - "enum": [ - "public", - "limited", - "private" - ], - "x-go-enum-desc": "public UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "x-go-name": "Visibility" }, "website": { @@ -28018,56 +27944,164 @@ "description": "Project represents a project", "type": "object", "properties": { - "closed_at": { + "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": "Closed" + "x-go-name": "ClosedDate" }, - "created_at": { + "created": { + "description": "Created time", "type": "string", "format": "date-time", "x-go-name": "Created" }, "creator_id": { - "description": "CreatorID is the user who created the project", + "description": "Creator ID", "type": "integer", "format": "int64", "x-go-name": "CreatorID" }, "description": { - "description": "Description provides details about the project", + "description": "Project description", "type": "string", "x-go-name": "Description" }, "id": { - "description": "ID is the unique identifier for the project", + "description": "Unique identifier of the project", "type": "integer", "format": "int64", "x-go-name": "ID" }, "is_closed": { - "description": "IsClosed indicates if the project 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": "OwnerID is the owner of the project (for org-level projects)", + "description": "Owner ID (for organization or user projects)", "type": "integer", "format": "int64", "x-go-name": "OwnerID" }, "repo_id": { - "description": "RepoID is the repository this project belongs to (for repo-level projects)", + "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": "Title is the title of the project", + "description": "Project title", "type": "string", "x-go-name": "Title" }, - "updated_at": { + "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" @@ -28183,12 +28217,6 @@ "format": "int64", "x-go-name": "Comments" }, - "content_version": { - "description": "The version of the pull request content for optimistic locking", - "type": "integer", - "format": "int64", - "x-go-name": "ContentVersion" - }, "created_at": { "type": "string", "format": "date-time", @@ -28313,14 +28341,7 @@ "x-go-name": "ReviewComments" }, "state": { - "description": "The current state of the pull request\nopen StateOpen StateOpen pr is opened\nclosed StateClosed StateClosed pr is closed", - "type": "string", - "enum": [ - "open", - "closed" - ], - "x-go-enum-desc": "open StateOpen StateOpen pr is opened\nclosed StateClosed StateClosed pr is closed", - "x-go-name": "State" + "$ref": "#/definitions/StateType" }, "title": { "description": "The title of the pull request", @@ -28412,16 +28433,7 @@ "x-go-name": "Stale" }, "state": { - "type": "string", - "enum": [ - "APPROVED", - "PENDING", - "COMMENT", - "REQUEST_CHANGES", - "REQUEST_REVIEW" - ], - "x-go-enum-desc": "APPROVED ReviewStateApproved ReviewStateApproved pr is approved\nPENDING ReviewStatePending ReviewStatePending pr state is pending\nCOMMENT ReviewStateComment ReviewStateComment is a comment review\nREQUEST_CHANGES ReviewStateRequestChanges ReviewStateRequestChanges changes for pr are requested\nREQUEST_REVIEW ReviewStateRequestReview ReviewStateRequestReview review is requested from user", - "x-go-name": "State" + "$ref": "#/definitions/ReviewStateType" }, "submitted_at": { "type": "string", @@ -28763,16 +28775,8 @@ "type": "object", "properties": { "permission": { - "description": "Permission level of the collaborator\nnone AccessLevelNameNone\nread AccessLevelNameRead\nwrite AccessLevelNameWrite\nadmin AccessLevelNameAdmin\nowner AccessLevelNameOwner", + "description": "Permission level of the collaborator", "type": "string", - "enum": [ - "none", - "read", - "write", - "admin", - "owner" - ], - "x-go-enum-desc": "none AccessLevelNameNone\nread AccessLevelNameRead\nwrite AccessLevelNameWrite\nadmin AccessLevelNameAdmin\nowner AccessLevelNameOwner", "x-go-name": "Permission" }, "role_name": { @@ -29049,13 +29053,12 @@ "x-go-name": "Name" }, "object_format_name": { - "description": "ObjectFormatName of the underlying git repository\nsha1 ObjectFormatSHA1\nsha256 ObjectFormatSHA256", + "description": "ObjectFormatName of the underlying git repository", "type": "string", "enum": [ "sha1", "sha256" ], - "x-go-enum-desc": "sha1 ObjectFormatSHA1\nsha256 ObjectFormatSHA256", "x-go-name": "ObjectFormatName" }, "open_issues_count": { @@ -29167,6 +29170,11 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "ReviewStateType": { + "description": "ReviewStateType review state type", + "type": "string", + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "RunDetails": { "description": "RunDetails returns workflow_dispatch runid and url", "type": "object", @@ -29241,6 +29249,11 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "StateType": { + "description": "StateType issue state type", + "type": "string", + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "StopWatch": { "description": "StopWatch represent a running stopwatch", "type": "object", @@ -29294,16 +29307,7 @@ "x-go-name": "Body" }, "event": { - "type": "string", - "enum": [ - "APPROVED", - "PENDING", - "COMMENT", - "REQUEST_CHANGES", - "REQUEST_REVIEW" - ], - "x-go-enum-desc": "APPROVED ReviewStateApproved ReviewStateApproved pr is approved\nPENDING ReviewStatePending ReviewStatePending pr state is pending\nCOMMENT ReviewStateComment ReviewStateComment is a comment review\nREQUEST_CHANGES ReviewStateRequestChanges ReviewStateRequestChanges changes for pr are requested\nREQUEST_REVIEW ReviewStateRequestReview ReviewStateRequestReview review is requested from user", - "x-go-name": "Event" + "$ref": "#/definitions/ReviewStateType" } }, "x-go-package": "code.gitea.io/gitea/modules/structs" @@ -29429,7 +29433,6 @@ "admin", "owner" ], - "x-go-enum-desc": "none AccessLevelNameNone\nread AccessLevelNameRead\nwrite AccessLevelNameWrite\nadmin AccessLevelNameAdmin\nowner AccessLevelNameOwner", "x-go-name": "Permission" }, "units": { @@ -29976,14 +29979,8 @@ "x-go-name": "StarredRepos" }, "visibility": { - "description": "User visibility level option: public, limited, private\npublic UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", + "description": "User visibility level option: public, limited, private", "type": "string", - "enum": [ - "public", - "limited", - "private" - ], - "x-go-enum-desc": "public UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "x-go-name": "Visibility" }, "website": { @@ -30904,6 +30901,36 @@ } } }, + "Project": { + "description": "Project", + "schema": { + "$ref": "#/definitions/Project" + } + }, + "ProjectColumn": { + "description": "ProjectColumn", + "schema": { + "$ref": "#/definitions/ProjectColumn" + } + }, + "ProjectColumnList": { + "description": "ProjectColumnList", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/ProjectColumn" + } + } + }, + "ProjectList": { + "description": "ProjectList", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Project" + } + } + }, "PublicKey": { "description": "PublicKey", "schema": { @@ -31371,7 +31398,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) +}