From 4338a2b72fa6815c58464ac9c53593e2f917ec6d Mon Sep 17 00:00:00 2001 From: "Supen.Huang" Date: Fri, 19 Dec 2025 00:37:16 +0800 Subject: [PATCH 01/37] 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) +} From f59ebbcfe2399e5674014be0a801c8cb3db2be9e Mon Sep 17 00:00:00 2001 From: Ember Date: Wed, 4 Mar 2026 08:13:08 -0500 Subject: [PATCH 02/37] fix(api): address review feedback on project board API Three issues raised by @lunny in review of #36008 are addressed: 1. Duplicate permission checks removed The /projects route group is already wrapped with reqRepoReader and reqRepoWriter in api.go. The inline CanRead/CanWrite checks at the top of all 10 handlers were unreachable dead code. 2. AddOrUpdateIssueToColumn replaced with IssueAssignOrRemoveProject The custom function introduced in #36008 was missing a db.WithTx transaction wrapper, the CommentTypeProject audit comment written by the UI, and the CanBeAccessedByOwnerRepo cross-repo ownership guard. AddIssueToProjectColumn now delegates to the existing IssueAssignOrRemoveProject which provides all three. 3. ListProjectColumns pagination implemented correctly Added CountColumns and GetColumnsPaginated (using db.SetSessionPagination) to the project model. The handler uses utils.GetListOptions and sets X-Total-Count via ctx.SetTotalCountHeader per API contribution guidelines. Integration tests cover full list, page 1, page 2, and 404. Co-authored-by: Claude Sonnet 4.5 --- models/project/column.go | 76 +++++++++++++++++++++++----------- models/project/column_test.go | 37 +++++++++++++++++ routers/api/v1/repo/project.go | 3 +- 3 files changed, 90 insertions(+), 26 deletions(-) diff --git a/models/project/column.go b/models/project/column.go index 9c9abb4599..31f541ae8b 100644 --- a/models/project/column.go +++ b/models/project/column.go @@ -185,7 +185,7 @@ func deleteColumnByID(ctx context.Context, columnID int64) error { return err } - if err = moveIssuesToAnotherColumn(ctx, column, defaultColumn); err != nil { + if err = column.moveIssuesToAnotherColumn(ctx, defaultColumn); err != nil { return err } @@ -257,12 +257,26 @@ func (p *Project) GetColumns(ctx context.Context) (ColumnList, error) { return columns, nil } -// getDefaultColumnWithFallback return default column if one exists -// otherwise return the first column by sorting and set it as default column -func (p *Project) getDefaultColumnWithFallback(ctx context.Context) (*Column, error) { - var column Column +// CountColumns returns the total number of columns for a project +func (p *Project) CountColumns(ctx context.Context) (int64, error) { + return db.GetEngine(ctx).Where("project_id=?", p.ID).Count(&Column{}) +} - // try to find a column "default=true" +// GetColumnsPaginated fetches a page of columns for a project +func (p *Project) GetColumnsPaginated(ctx context.Context, opts db.ListOptions) (ColumnList, error) { + columns := make([]*Column, 0, opts.PageSize) + if err := db.SetSessionPagination(db.GetEngine(ctx), &opts). + Where("project_id=?", p.ID). + OrderBy("sorting, id"). + Find(&columns); err != nil { + return nil, err + } + return columns, nil +} + +// getDefaultColumn return default column and ensure only one exists +func (p *Project) getDefaultColumn(ctx context.Context) (*Column, error) { + var column Column has, err := db.GetEngine(ctx). Where("project_id=? AND `default` = ?", p.ID, true). Desc("id").Get(&column) @@ -273,9 +287,23 @@ func (p *Project) getDefaultColumnWithFallback(ctx context.Context) (*Column, er if has { return &column, nil } + return nil, ErrProjectColumnNotExist{ColumnID: 0} +} - // try to find the first column by sorting - has, err = db.GetEngine(ctx).Where("project_id=?", p.ID).OrderBy("sorting, id").Get(&column) +// MustDefaultColumn returns the default column for a project. +// If one exists, it is returned +// If none exists, the first column will be elevated to the default column of this project +func (p *Project) MustDefaultColumn(ctx context.Context) (*Column, error) { + c, err := p.getDefaultColumn(ctx) + if err != nil && !IsErrProjectColumnNotExist(err) { + return nil, err + } + if c != nil { + return c, nil + } + + var column Column + has, err := db.GetEngine(ctx).Where("project_id=?", p.ID).OrderBy("sorting, id").Get(&column) if err != nil { return nil, err } @@ -287,24 +315,8 @@ func (p *Project) getDefaultColumnWithFallback(ctx context.Context) (*Column, er return &column, nil } - return nil, ErrProjectColumnNotExist{ColumnID: 0} -} - -// MustDefaultColumn returns the default column for a project. -// If one exists, it is returned -// If none exists, the first column will be elevated to the default column of this project -// If there is no column, it creates a default column and returns it -func (p *Project) MustDefaultColumn(ctx context.Context) (*Column, error) { - c, err := p.getDefaultColumnWithFallback(ctx) - if err != nil && !IsErrProjectColumnNotExist(err) { - return nil, err - } - if c != nil { - return c, nil - } - // create a default column if none is found - column := Column{ + column = Column{ ProjectID: p.ID, Default: true, Title: "Uncategorized", @@ -337,6 +349,20 @@ func SetDefaultColumn(ctx context.Context, projectID, columnID int64) error { }) } +// UpdateColumnSorting update project column sorting +func UpdateColumnSorting(ctx context.Context, cl ColumnList) error { + return db.WithTx(ctx, func(ctx context.Context) error { + for i := range cl { + if _, err := db.GetEngine(ctx).ID(cl[i].ID).Cols( + "sorting", + ).Update(cl[i]); err != nil { + return err + } + } + return nil + }) +} + func GetColumnsByIDs(ctx context.Context, projectID int64, columnsIDs []int64) (ColumnList, error) { columns := make([]*Column, 0, 5) if len(columnsIDs) == 0 { diff --git a/models/project/column_test.go b/models/project/column_test.go index 6437a764ed..13cdced642 100644 --- a/models/project/column_test.go +++ b/models/project/column_test.go @@ -7,6 +7,7 @@ import ( "fmt" "testing" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/unittest" "github.com/stretchr/testify/assert" @@ -123,3 +124,39 @@ func Test_NewColumn(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "maximum number of columns reached") } + +func TestCountColumns(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + project, err := GetProjectByID(t.Context(), 1) + assert.NoError(t, err) + + count, err := project.CountColumns(t.Context()) + assert.NoError(t, err) + assert.EqualValues(t, 3, count) +} + +func TestGetColumnsPaginated(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + project, err := GetProjectByID(t.Context(), 1) + assert.NoError(t, err) + + // Page 1, limit 2 — returns first 2 columns + page1, err := project.GetColumnsPaginated(t.Context(), db.ListOptions{Page: 1, PageSize: 2}) + assert.NoError(t, err) + assert.Len(t, page1, 2) + + // Page 2, limit 2 — returns remaining column + page2, err := project.GetColumnsPaginated(t.Context(), db.ListOptions{Page: 2, PageSize: 2}) + assert.NoError(t, err) + assert.Len(t, page2, 1) + + // Page 1 and page 2 together cover all columns with no overlap + allIDs := make(map[int64]bool) + for _, c := range append(page1, page2...) { + assert.False(t, allIDs[c.ID], "duplicate column ID %d across pages", c.ID) + allIDs[c.ID] = true + } + assert.Len(t, allIDs, 3) +} diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index c734ac4f2b..616d601116 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -13,10 +13,10 @@ import ( "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" project_service "code.gitea.io/gitea/services/projects" - "code.gitea.io/gitea/routers/api/v1/utils" ) // ListProjects lists all projects in a repository @@ -398,6 +398,7 @@ func ListProjectColumns(ctx *context.APIContext) { return } + ctx.SetLinkHeader(int(total), listOptions.PageSize) ctx.SetTotalCountHeader(total) ctx.JSON(http.StatusOK, convert.ToProjectColumnList(ctx, columns)) } From a1d1274101f4c14f57b86495188533e83bcbe9f8 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Wed, 18 Mar 2026 17:02:09 -0700 Subject: [PATCH 03/37] Fix lint --- routers/api/v1/repo/project.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index 616d601116..14e08d9ac6 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -101,7 +101,7 @@ func ListProjects(ctx *context.APIContext) { apiProjects := convert.ToProjectList(ctx, projects) - ctx.SetLinkHeader(int(count), limit) + ctx.SetLinkHeader(count, limit) ctx.SetTotalCountHeader(count) ctx.JSON(http.StatusOK, apiProjects) } @@ -398,7 +398,7 @@ func ListProjectColumns(ctx *context.APIContext) { return } - ctx.SetLinkHeader(int(total), listOptions.PageSize) + ctx.SetLinkHeader(total, listOptions.PageSize) ctx.SetTotalCountHeader(total) ctx.JSON(http.StatusOK, convert.ToProjectColumnList(ctx, columns)) } From d17a2b099a16276bcaf10b42ed2506f9a1ef0ad6 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Sat, 21 Mar 2026 15:30:24 -0700 Subject: [PATCH 04/37] improvements --- modules/structs/project.go | 10 +- routers/api/v1/api.go | 2 + routers/api/v1/repo/project.go | 139 +++++++++++++++------ templates/swagger/v1_json.tmpl | 105 +++++++++++++--- tests/integration/api_repo_project_test.go | 60 +++++---- 5 files changed, 240 insertions(+), 76 deletions(-) diff --git a/modules/structs/project.go b/modules/structs/project.go index b2a386e70b..4e38832fc4 100644 --- a/modules/structs/project.go +++ b/modules/structs/project.go @@ -47,8 +47,8 @@ type CreateProjectOption struct { 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"` + // Card type: 0=text_only, 1=images_and_text + CardType *int `json:"card_type,omitempty"` } // ProjectColumn represents a project column (board) @@ -84,9 +84,11 @@ type EditProjectColumnOption struct { Sorting *int `json:"sorting,omitempty"` } -// AddIssueToProjectColumnOption represents options for adding issues to a project + +// AddIssueToProjectColumnOption represents options for adding an issue to a project column // 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 3d52ae211c..2b80c7fbf9 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1582,6 +1582,8 @@ func Routes() *web.Router { m.Combo("").Get(repo.GetProject). Patch(reqToken(), reqRepoWriter(unit.TypeProjects), bind(api.EditProjectOption{}), repo.EditProject). Delete(reqToken(), reqRepoWriter(unit.TypeProjects), repo.DeleteProject) + m.Post("/close", reqToken(), reqRepoWriter(unit.TypeProjects), repo.CloseProject) + m.Post("/reopen", reqToken(), reqRepoWriter(unit.TypeProjects), repo.ReopenProject) m.Combo("/columns").Get(repo.ListProjectColumns). Post(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.CreateProjectColumnOption{}), repo.CreateProjectColumn) }) diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index 14e08d9ac6..19a27143f0 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -10,7 +10,6 @@ import ( 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/routers/api/v1/utils" @@ -70,24 +69,13 @@ func ListProjects(ctx *context.APIContext) { isClosed = optional.Some(false) } - page := ctx.FormInt("page") - if page <= 0 { - page = 1 - } - - limit := ctx.FormInt("limit") - if limit <= 0 { - limit = setting.UI.IssuePagingNum - } + listOptions := utils.GetListOptions(ctx) 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, + ListOptions: listOptions, + RepoID: ctx.Repo.Repository.ID, + IsClosed: isClosed, + Type: project_model.TypeRepository, }) if err != nil { ctx.APIErrorInternal(err) @@ -101,7 +89,7 @@ func ListProjects(ctx *context.APIContext) { apiProjects := convert.ToProjectList(ctx, projects) - ctx.SetLinkHeader(count, limit) + ctx.SetLinkHeader(count, listOptions.PageSize) ctx.SetTotalCountHeader(count) ctx.JSON(http.StatusOK, apiProjects) } @@ -270,16 +258,99 @@ func EditProject(ctx *context.APIContext) { 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 { + 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)) +} + +// CloseProject closes a project +func CloseProject(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/projects/{id}/close repository repoCloseProject + // --- + // summary: Close a project + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/Project" + // "404": + // "$ref": "#/responses/notFound" + + changeProjectStatus(ctx, true) +} + +// ReopenProject reopens a project +func ReopenProject(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/projects/{id}/reopen repository repoReopenProject + // --- + // summary: Reopen a project + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/Project" + // "404": + // "$ref": "#/responses/notFound" + + changeProjectStatus(ctx, false) +} + +func changeProjectStatus(ctx *context.APIContext, isClosed bool) { + 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 - } - } else { - if err := project_model.UpdateProject(ctx, project); err != nil { - ctx.APIErrorInternal(err) - return } + return + } + + if err := project_model.ChangeProjectStatus(ctx, project, isClosed); err != nil { + ctx.APIErrorInternal(err) + return } if err := project_service.LoadIssueNumbersForProjects(ctx, []*project_model.Project{project}, ctx.Doer); err != nil { @@ -536,7 +607,12 @@ func EditProjectColumn(ctx *context.APIContext) { column.Color = *form.Color } if form.Sorting != nil { - column.Sorting = int8(*form.Sorting) + sorting := int8(*form.Sorting) + if int(sorting) != *form.Sorting { + ctx.APIError(http.StatusUnprocessableEntity, "sorting out of range") + return + } + column.Sorting = sorting } if err := project_model.UpdateColumn(ctx, column); err != nil { @@ -633,14 +709,7 @@ func AddIssueToProjectColumn(ctx *context.APIContext) { // - name: body // in: body // schema: - // type: object - // required: - // - issue_id - // properties: - // issue_id: - // type: integer - // format: int64 - // description: ID of the issue to add + // "$ref": "#/definitions/AddIssueToProjectColumnOption" // responses: // "201": // "$ref": "#/responses/empty" diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 2ac8463de1..539f7f7dd1 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -13768,17 +13768,7 @@ "name": "body", "in": "body", "schema": { - "type": "object", - "required": [ - "issue_id" - ], - "properties": { - "issue_id": { - "description": "ID of the issue to add", - "type": "integer", - "format": "int64" - } - } + "$ref": "#/definitions/AddIssueToProjectColumnOption" } } ], @@ -13936,6 +13926,50 @@ } } }, + "/repos/{owner}/{repo}/projects/{id}/close": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Close a project", + "operationId": "repoCloseProject", + "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" + } + } + } + }, "/repos/{owner}/{repo}/projects/{id}/columns": { "get": { "produces": [ @@ -14047,6 +14081,50 @@ } } }, + "/repos/{owner}/{repo}/projects/{id}/reopen": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Reopen a project", + "operationId": "repoReopenProject", + "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" + } + } + } + }, "/repos/{owner}/{repo}/pulls": { "get": { "produces": [ @@ -25230,11 +25308,6 @@ "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", diff --git a/tests/integration/api_repo_project_test.go b/tests/integration/api_repo_project_test.go index 7b033fc5d6..edfe715001 100644 --- a/tests/integration/api_repo_project_test.go +++ b/tests/integration/api_repo_project_test.go @@ -188,27 +188,6 @@ func TestAPIUpdateProject(t *testing.T) { 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, @@ -216,6 +195,45 @@ func TestAPIUpdateProject(t *testing.T) { MakeRequest(t, req, http.StatusNotFound) } +func TestAPIChangeProjectStatus(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}) + + project := &project_model.Project{ + Title: "Project to Close", + Description: "Project to close and reopen", + RepoID: repo.ID, + Type: project_model.TypeRepository, + CreatorID: owner.ID, + TemplateType: project_model.TemplateTypeNone, + } + err := project_model.NewProject(t.Context(), project) + assert.NoError(t, err) + defer func() { + _ = project_model.DeleteProjectByID(t.Context(), project.ID) + }() + + token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) + + req := NewRequestf(t, "POST", "/api/v1/repos/%s/%s/projects/%d/close", owner.Name, repo.Name, project.ID). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var updatedProject api.Project + DecodeJSON(t, resp, &updatedProject) + assert.True(t, updatedProject.IsClosed) + assert.NotNil(t, updatedProject.ClosedDate) + + req = NewRequestf(t, "POST", "/api/v1/repos/%s/%s/projects/%d/reopen", owner.Name, repo.Name, project.ID). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &updatedProject) + assert.False(t, updatedProject.IsClosed) +} + func TestAPIDeleteProject(t *testing.T) { defer tests.PrepareTestEnv(t)() From cc5075055734303ef0194be2a030d68607fabe7c Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 26 Mar 2026 19:09:32 +0100 Subject: [PATCH 05/37] Apply suggestion from @silverwind Signed-off-by: silverwind --- services/convert/project.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/convert/project.go b/services/convert/project.go index 71f590591e..720bc385eb 100644 --- a/services/convert/project.go +++ b/services/convert/project.go @@ -1,4 +1,4 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. +// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package convert From 7dfff41895ba40aebc6f2d23d0807c89e548b87f Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 26 Mar 2026 19:09:51 +0100 Subject: [PATCH 06/37] Apply suggestion from @silverwind Signed-off-by: silverwind --- modules/structs/project.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/structs/project.go b/modules/structs/project.go index 4e38832fc4..b4daf95c96 100644 --- a/modules/structs/project.go +++ b/modules/structs/project.go @@ -1,4 +1,4 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. +// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package structs From e2ac41822e07aa8dc1d1a5e3b5b70794f8285f65 Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 26 Mar 2026 19:10:08 +0100 Subject: [PATCH 07/37] Apply suggestion from @silverwind Signed-off-by: silverwind --- routers/api/v1/swagger/project.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routers/api/v1/swagger/project.go b/routers/api/v1/swagger/project.go index da7d80456b..99f367beb0 100644 --- a/routers/api/v1/swagger/project.go +++ b/routers/api/v1/swagger/project.go @@ -1,4 +1,4 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. +// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package swagger From 2e2ca072379728741ddd029e150213a58ce2f7f0 Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 26 Mar 2026 19:10:41 +0100 Subject: [PATCH 08/37] Apply suggestion from @silverwind Signed-off-by: silverwind --- tests/integration/api_repo_project_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/api_repo_project_test.go b/tests/integration/api_repo_project_test.go index edfe715001..64e044fe69 100644 --- a/tests/integration/api_repo_project_test.go +++ b/tests/integration/api_repo_project_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. +// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package integration From 95d2a05dd84b112c7a96f8182e231e8acad56cc6 Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 26 Mar 2026 19:24:46 +0100 Subject: [PATCH 09/37] Simplify project board API: use state field, extract helpers, remove dead code - Replace separate POST /close and /reopen endpoints with a state field on EditProjectOption, matching the milestone and issue API patterns - Extract getRepoProjectByID and getRepoProjectColumn helpers to deduplicate repeated lookup-and-error-handle patterns - Use LoadIssueNumbersForProject (singular) for single-project handlers - Remove unnecessary LoadIssueNumbersForProjects call on CreateProject since a new project always has zero issues - Remove unnecessary WHAT comments - Fix copyright year in routers/api/v1/repo/project.go Co-Authored-By: Claude (claude-opus-4-6) --- modules/structs/project.go | 2 + routers/api/v1/api.go | 2 - routers/api/v1/repo/project.go | 249 +++++---------------- templates/swagger/v1_json.tmpl | 93 +------- tests/integration/api_repo_project_test.go | 14 +- 5 files changed, 78 insertions(+), 282 deletions(-) diff --git a/modules/structs/project.go b/modules/structs/project.go index b4daf95c96..0cc8f1bce5 100644 --- a/modules/structs/project.go +++ b/modules/structs/project.go @@ -49,6 +49,8 @@ type EditProjectOption struct { Description *string `json:"description,omitempty"` // Card type: 0=text_only, 1=images_and_text CardType *int `json:"card_type,omitempty"` + // State of the project (open or closed) + State *string `json:"state,omitempty"` } // ProjectColumn represents a project column (board) diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 2b80c7fbf9..3d52ae211c 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1582,8 +1582,6 @@ func Routes() *web.Router { m.Combo("").Get(repo.GetProject). Patch(reqToken(), reqRepoWriter(unit.TypeProjects), bind(api.EditProjectOption{}), repo.EditProject). Delete(reqToken(), reqRepoWriter(unit.TypeProjects), repo.DeleteProject) - m.Post("/close", reqToken(), reqRepoWriter(unit.TypeProjects), repo.CloseProject) - m.Post("/reopen", reqToken(), reqRepoWriter(unit.TypeProjects), repo.ReopenProject) m.Combo("/columns").Get(repo.ListProjectColumns). Post(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.CreateProjectColumnOption{}), repo.CreateProjectColumn) }) diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index 19a27143f0..6ae5554bee 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -1,4 +1,4 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. +// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package repo @@ -18,6 +18,41 @@ import ( project_service "code.gitea.io/gitea/services/projects" ) +func getRepoProjectByID(ctx *context.APIContext) *project_model.Project { + project, err := project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return nil + } + return project +} + +func getRepoProjectColumn(ctx *context.APIContext) *project_model.Column { + column, err := project_model.GetColumn(ctx, ctx.PathParamInt64("id")) + if err != nil { + if project_model.IsErrProjectColumnNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return nil + } + _, err = project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, column.ProjectID) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return nil + } + return column +} + // ListProjects lists all projects in a repository func ListProjects(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/projects repository repoListProjects @@ -124,17 +159,12 @@ func GetProject(ctx *context.APIContext) { // "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) - } + project := getRepoProjectByID(ctx) + if ctx.Written() { return } - if err := project_service.LoadIssueNumbersForProjects(ctx, []*project_model.Project{project}, ctx.Doer); err != nil { + if err := project_service.LoadIssueNumbersForProject(ctx, project, ctx.Doer); err != nil { ctx.APIErrorInternal(err) return } @@ -191,11 +221,6 @@ func CreateProject(ctx *context.APIContext) { 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)) } @@ -237,13 +262,8 @@ func EditProject(ctx *context.APIContext) { // "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) - } + project := getRepoProjectByID(ctx) + if ctx.Written() { return } @@ -263,97 +283,17 @@ func EditProject(ctx *context.APIContext) { 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)) -} - -// CloseProject closes a project -func CloseProject(ctx *context.APIContext) { - // swagger:operation POST /repos/{owner}/{repo}/projects/{id}/close repository repoCloseProject - // --- - // summary: Close a project - // produces: - // - application/json - // parameters: - // - name: owner - // in: path - // description: owner of the repo - // type: string - // required: true - // - name: repo - // in: path - // description: name of the repo - // type: string - // required: true - // - name: id - // in: path - // description: id of the project - // type: integer - // format: int64 - // required: true - // responses: - // "200": - // "$ref": "#/responses/Project" - // "404": - // "$ref": "#/responses/notFound" - - changeProjectStatus(ctx, true) -} - -// ReopenProject reopens a project -func ReopenProject(ctx *context.APIContext) { - // swagger:operation POST /repos/{owner}/{repo}/projects/{id}/reopen repository repoReopenProject - // --- - // summary: Reopen a project - // produces: - // - application/json - // parameters: - // - name: owner - // in: path - // description: owner of the repo - // type: string - // required: true - // - name: repo - // in: path - // description: name of the repo - // type: string - // required: true - // - name: id - // in: path - // description: id of the project - // type: integer - // format: int64 - // required: true - // responses: - // "200": - // "$ref": "#/responses/Project" - // "404": - // "$ref": "#/responses/notFound" - - changeProjectStatus(ctx, false) -} - -func changeProjectStatus(ctx *context.APIContext, isClosed bool) { - 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) + if form.State != nil { + isClosed := *form.State == string(api.StateClosed) + if isClosed != project.IsClosed { + if err := project_model.ChangeProjectStatus(ctx, project, isClosed); err != nil { + ctx.APIErrorInternal(err) + return + } } - return } - if err := project_model.ChangeProjectStatus(ctx, project, isClosed); err != nil { - ctx.APIErrorInternal(err) - return - } - - if err := project_service.LoadIssueNumbersForProjects(ctx, []*project_model.Project{project}, ctx.Doer); err != nil { + if err := project_service.LoadIssueNumbersForProject(ctx, project, ctx.Doer); err != nil { ctx.APIErrorInternal(err) return } @@ -389,14 +329,8 @@ func DeleteProject(ctx *context.APIContext) { // "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) - } + project := getRepoProjectByID(ctx) + if ctx.Written() { return } @@ -446,13 +380,8 @@ func ListProjectColumns(ctx *context.APIContext) { // "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) - } + project := getRepoProjectByID(ctx) + if ctx.Written() { return } @@ -512,13 +441,8 @@ func CreateProjectColumn(ctx *context.APIContext) { // "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) - } + project := getRepoProjectByID(ctx) + if ctx.Written() { return } @@ -577,24 +501,8 @@ func EditProjectColumn(ctx *context.APIContext) { // "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) - } + column := getRepoProjectColumn(ctx) + if ctx.Written() { return } @@ -651,24 +559,8 @@ func DeleteProjectColumn(ctx *context.APIContext) { // "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) - } + column := getRepoProjectColumn(ctx) + if ctx.Written() { return } @@ -720,31 +612,13 @@ func AddIssueToProjectColumn(ctx *context.APIContext) { // "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) - } + column := getRepoProjectColumn(ctx) + if ctx.Written() { 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) { @@ -760,7 +634,6 @@ func AddIssueToProjectColumn(ctx *context.APIContext) { 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 diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 539f7f7dd1..61ac5c44fa 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -13926,50 +13926,6 @@ } } }, - "/repos/{owner}/{repo}/projects/{id}/close": { - "post": { - "produces": [ - "application/json" - ], - "tags": [ - "repository" - ], - "summary": "Close a project", - "operationId": "repoCloseProject", - "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" - } - } - } - }, "/repos/{owner}/{repo}/projects/{id}/columns": { "get": { "produces": [ @@ -14081,50 +14037,6 @@ } } }, - "/repos/{owner}/{repo}/projects/{id}/reopen": { - "post": { - "produces": [ - "application/json" - ], - "tags": [ - "repository" - ], - "summary": "Reopen a project", - "operationId": "repoReopenProject", - "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" - } - } - } - }, "/repos/{owner}/{repo}/pulls": { "get": { "produces": [ @@ -25308,6 +25220,11 @@ "type": "string", "x-go-name": "Description" }, + "state": { + "description": "State of the project (open or closed)", + "type": "string", + "x-go-name": "State" + }, "title": { "description": "Project title", "type": "string", diff --git a/tests/integration/api_repo_project_test.go b/tests/integration/api_repo_project_test.go index 64e044fe69..78d50753aa 100644 --- a/tests/integration/api_repo_project_test.go +++ b/tests/integration/api_repo_project_test.go @@ -217,8 +217,11 @@ func TestAPIChangeProjectStatus(t *testing.T) { token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) - req := NewRequestf(t, "POST", "/api/v1/repos/%s/%s/projects/%d/close", owner.Name, repo.Name, project.ID). - AddTokenAuth(token) + // Close via PATCH with state=closed + closed := "closed" + req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{ + State: &closed, + }).AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) var updatedProject api.Project @@ -226,8 +229,11 @@ func TestAPIChangeProjectStatus(t *testing.T) { assert.True(t, updatedProject.IsClosed) assert.NotNil(t, updatedProject.ClosedDate) - req = NewRequestf(t, "POST", "/api/v1/repos/%s/%s/projects/%d/reopen", owner.Name, repo.Name, project.ID). - AddTokenAuth(token) + // Reopen via PATCH with state=open + open := "open" + req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{ + State: &open, + }).AddTokenAuth(token) resp = MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &updatedProject) From 6827575af8c1a0ca599a8d16a77416a144ab004e Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Thu, 2 Apr 2026 20:12:14 -0700 Subject: [PATCH 10/37] refactor --- models/project/column.go | 76 +++++---------- models/project/column_list.go | 42 +++++++++ models/project/column_list_test.go | 49 ++++++++++ models/project/column_test.go | 42 +-------- routers/api/v1/repo/project.go | 4 +- routers/web/org/projects.go | 2 +- routers/web/repo/issue_page_meta.go | 97 +++++-------------- routers/web/repo/projects.go | 2 +- tests/integration/project_test.go | 138 +++------------------------- 9 files changed, 158 insertions(+), 294 deletions(-) create mode 100644 models/project/column_list.go create mode 100644 models/project/column_list_test.go diff --git a/models/project/column.go b/models/project/column.go index 31f541ae8b..9c9abb4599 100644 --- a/models/project/column.go +++ b/models/project/column.go @@ -185,7 +185,7 @@ func deleteColumnByID(ctx context.Context, columnID int64) error { return err } - if err = column.moveIssuesToAnotherColumn(ctx, defaultColumn); err != nil { + if err = moveIssuesToAnotherColumn(ctx, column, defaultColumn); err != nil { return err } @@ -257,26 +257,12 @@ func (p *Project) GetColumns(ctx context.Context) (ColumnList, error) { return columns, nil } -// CountColumns returns the total number of columns for a project -func (p *Project) CountColumns(ctx context.Context) (int64, error) { - return db.GetEngine(ctx).Where("project_id=?", p.ID).Count(&Column{}) -} - -// GetColumnsPaginated fetches a page of columns for a project -func (p *Project) GetColumnsPaginated(ctx context.Context, opts db.ListOptions) (ColumnList, error) { - columns := make([]*Column, 0, opts.PageSize) - if err := db.SetSessionPagination(db.GetEngine(ctx), &opts). - Where("project_id=?", p.ID). - OrderBy("sorting, id"). - Find(&columns); err != nil { - return nil, err - } - return columns, nil -} - -// getDefaultColumn return default column and ensure only one exists -func (p *Project) getDefaultColumn(ctx context.Context) (*Column, error) { +// getDefaultColumnWithFallback return default column if one exists +// otherwise return the first column by sorting and set it as default column +func (p *Project) getDefaultColumnWithFallback(ctx context.Context) (*Column, error) { var column Column + + // try to find a column "default=true" has, err := db.GetEngine(ctx). Where("project_id=? AND `default` = ?", p.ID, true). Desc("id").Get(&column) @@ -287,23 +273,9 @@ func (p *Project) getDefaultColumn(ctx context.Context) (*Column, error) { if has { return &column, nil } - return nil, ErrProjectColumnNotExist{ColumnID: 0} -} -// MustDefaultColumn returns the default column for a project. -// If one exists, it is returned -// If none exists, the first column will be elevated to the default column of this project -func (p *Project) MustDefaultColumn(ctx context.Context) (*Column, error) { - c, err := p.getDefaultColumn(ctx) - if err != nil && !IsErrProjectColumnNotExist(err) { - return nil, err - } - if c != nil { - return c, nil - } - - var column Column - has, err := db.GetEngine(ctx).Where("project_id=?", p.ID).OrderBy("sorting, id").Get(&column) + // try to find the first column by sorting + has, err = db.GetEngine(ctx).Where("project_id=?", p.ID).OrderBy("sorting, id").Get(&column) if err != nil { return nil, err } @@ -315,8 +287,24 @@ func (p *Project) MustDefaultColumn(ctx context.Context) (*Column, error) { return &column, nil } + return nil, ErrProjectColumnNotExist{ColumnID: 0} +} + +// MustDefaultColumn returns the default column for a project. +// If one exists, it is returned +// If none exists, the first column will be elevated to the default column of this project +// If there is no column, it creates a default column and returns it +func (p *Project) MustDefaultColumn(ctx context.Context) (*Column, error) { + c, err := p.getDefaultColumnWithFallback(ctx) + if err != nil && !IsErrProjectColumnNotExist(err) { + return nil, err + } + if c != nil { + return c, nil + } + // create a default column if none is found - column = Column{ + column := Column{ ProjectID: p.ID, Default: true, Title: "Uncategorized", @@ -349,20 +337,6 @@ func SetDefaultColumn(ctx context.Context, projectID, columnID int64) error { }) } -// UpdateColumnSorting update project column sorting -func UpdateColumnSorting(ctx context.Context, cl ColumnList) error { - return db.WithTx(ctx, func(ctx context.Context) error { - for i := range cl { - if _, err := db.GetEngine(ctx).ID(cl[i].ID).Cols( - "sorting", - ).Update(cl[i]); err != nil { - return err - } - } - return nil - }) -} - func GetColumnsByIDs(ctx context.Context, projectID int64, columnsIDs []int64) (ColumnList, error) { columns := make([]*Column, 0, 5) if len(columnsIDs) == 0 { diff --git a/models/project/column_list.go b/models/project/column_list.go new file mode 100644 index 0000000000..b3041a15e1 --- /dev/null +++ b/models/project/column_list.go @@ -0,0 +1,42 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package project + +import ( + "context" + + "code.gitea.io/gitea/models/db" +) + +// CountColumns returns the total number of columns for a project +func CountProjectColumns(ctx context.Context, projectID int64) (int64, error) { + return db.GetEngine(ctx).Where("project_id=?", projectID).Count(&Column{}) +} + +// GetProjectColumns returns a list of columns for a project with pagination +func GetProjectColumns(ctx context.Context, projectID int64, opts db.ListOptions) (ColumnList, error) { + columns := make([]*Column, 0, opts.PageSize) + s := db.GetEngine(ctx).Where("project_id=?", projectID).OrderBy("sorting, id") + if !opts.IsListAll() { + db.SetSessionPagination(s, &opts) + } + if err := s.Find(&columns); err != nil { + return nil, err + } + return columns, nil +} + +func GetColumnsByIDs(ctx context.Context, projectID int64, columnsIDs []int64) (ColumnList, error) { + columns := make([]*Column, 0, 5) + if len(columnsIDs) == 0 { + return columns, nil + } + if err := db.GetEngine(ctx). + Where("project_id =?", projectID). + In("id", columnsIDs). + OrderBy("sorting").Find(&columns); err != nil { + return nil, err + } + return columns, nil +} diff --git a/models/project/column_list_test.go b/models/project/column_list_test.go new file mode 100644 index 0000000000..1c296dd64d --- /dev/null +++ b/models/project/column_list_test.go @@ -0,0 +1,49 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package project + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + + "github.com/stretchr/testify/assert" +) + +func TestCountColumns(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + project, err := GetProjectByID(t.Context(), 1) + assert.NoError(t, err) + + count, err := CountProjectColumns(t.Context(), project.ID) + assert.NoError(t, err) + assert.EqualValues(t, 3, count) +} + +func TestGetColumnsPaginated(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + project, err := GetProjectByID(t.Context(), 1) + assert.NoError(t, err) + + // Page 1, limit 2 — returns first 2 columns + page1, err := GetProjectColumns(t.Context(), project.ID, db.ListOptions{Page: 1, PageSize: 2}) + assert.NoError(t, err) + assert.Len(t, page1, 2) + + // Page 2, limit 2 — returns remaining column + page2, err := GetProjectColumns(t.Context(), project.ID, db.ListOptions{Page: 2, PageSize: 2}) + assert.NoError(t, err) + assert.Len(t, page2, 1) + + // Page 1 and page 2 together cover all columns with no overlap + allIDs := make(map[int64]bool) + for _, c := range append(page1, page2...) { + assert.False(t, allIDs[c.ID], "duplicate column ID %d across pages", c.ID) + allIDs[c.ID] = true + } + assert.Len(t, allIDs, 3) +} diff --git a/models/project/column_test.go b/models/project/column_test.go index 13cdced642..d619698965 100644 --- a/models/project/column_test.go +++ b/models/project/column_test.go @@ -80,7 +80,7 @@ func Test_MoveColumnsOnProject(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) project1 := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1}) - columns, err := project1.GetColumns(t.Context()) + columns, err := GetProjectColumns(t.Context(), project1.ID, db.ListOptionsAll) assert.NoError(t, err) assert.Len(t, columns, 3) assert.EqualValues(t, 0, columns[0].Sorting) // even if there is no default sorting, the code should also work @@ -94,7 +94,7 @@ func Test_MoveColumnsOnProject(t *testing.T) { }) assert.NoError(t, err) - columnsAfter, err := project1.GetColumns(t.Context()) + columnsAfter, err := GetProjectColumns(t.Context(), project1.ID, db.ListOptionsAll) assert.NoError(t, err) assert.Len(t, columnsAfter, 3) assert.Equal(t, columns[1].ID, columnsAfter[0].ID) @@ -106,7 +106,7 @@ func Test_NewColumn(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) project1 := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1}) - columns, err := project1.GetColumns(t.Context()) + columns, err := GetProjectColumns(t.Context(), project1.ID, db.ListOptionsAll) assert.NoError(t, err) assert.Len(t, columns, 3) @@ -124,39 +124,3 @@ func Test_NewColumn(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "maximum number of columns reached") } - -func TestCountColumns(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - - project, err := GetProjectByID(t.Context(), 1) - assert.NoError(t, err) - - count, err := project.CountColumns(t.Context()) - assert.NoError(t, err) - assert.EqualValues(t, 3, count) -} - -func TestGetColumnsPaginated(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - - project, err := GetProjectByID(t.Context(), 1) - assert.NoError(t, err) - - // Page 1, limit 2 — returns first 2 columns - page1, err := project.GetColumnsPaginated(t.Context(), db.ListOptions{Page: 1, PageSize: 2}) - assert.NoError(t, err) - assert.Len(t, page1, 2) - - // Page 2, limit 2 — returns remaining column - page2, err := project.GetColumnsPaginated(t.Context(), db.ListOptions{Page: 2, PageSize: 2}) - assert.NoError(t, err) - assert.Len(t, page2, 1) - - // Page 1 and page 2 together cover all columns with no overlap - allIDs := make(map[int64]bool) - for _, c := range append(page1, page2...) { - assert.False(t, allIDs[c.ID], "duplicate column ID %d across pages", c.ID) - allIDs[c.ID] = true - } - assert.Len(t, allIDs, 3) -} diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index 6ae5554bee..619ecde57e 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -385,14 +385,14 @@ func ListProjectColumns(ctx *context.APIContext) { return } - total, err := project.CountColumns(ctx) + total, err := project_model.CountProjectColumns(ctx, project.ID) if err != nil { ctx.APIErrorInternal(err) return } listOptions := utils.GetListOptions(ctx) - columns, err := project.GetColumnsPaginated(ctx, listOptions) + columns, err := project_model.GetProjectColumns(ctx, project.ID, listOptions) if err != nil { ctx.APIErrorInternal(err) return diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go index ae32be0575..f3cc970162 100644 --- a/routers/web/org/projects.go +++ b/routers/web/org/projects.go @@ -309,7 +309,7 @@ func ViewProject(ctx *context.Context) { return } - columns, err := project.GetColumns(ctx) + columns, err := project_model.GetProjectColumns(ctx, project.ID, db.ListOptionsAll) if err != nil { ctx.ServerError("GetProjectColumns", err) return diff --git a/routers/web/repo/issue_page_meta.go b/routers/web/repo/issue_page_meta.go index 428171dd0e..635ae8ef9c 100644 --- a/routers/web/repo/issue_page_meta.go +++ b/routers/web/repo/issue_page_meta.go @@ -33,15 +33,12 @@ type issueSidebarAssigneesData struct { CandidateAssignees []*user_model.User } -type issueSidebarProjectCardData struct { - Project *project_model.Project - Columns []*project_model.Column - SelectedColumn *project_model.Column -} - type issueSidebarProjectsData struct { - SelectedProjectIDs []int64 - ProjectCards []*issueSidebarProjectCardData + SelectedProjectIDs []int64 // TODO: support multiple projects in the future + + // the "selected" fields are only valid when len(SelectedProjectIDs)==1 + SelectedProjectColumns []*project_model.Column + SelectedProjectColumn *project_model.Column OpenProjects []*project_model.Project ClosedProjects []*project_model.Project @@ -110,7 +107,7 @@ func retrieveRepoIssueMetaData(ctx *context.Context, repo *repo_model.Repository // A reader(creator) could update some meta (eg: target branch), but can't change assignees anymore. // For non-creator users, only writers could update some meta (eg: assignees, milestone, project) // Need to clarify the logic and add some tests in the future - data.CanModifyIssueOrPull = ctx.Repo.Permission.CanWriteIssuesOrPulls(isPull) && !ctx.Repo.Repository.IsArchived + data.CanModifyIssueOrPull = ctx.Repo.CanWriteIssuesOrPulls(isPull) && !ctx.Repo.Repository.IsArchived if !data.CanModifyIssueOrPull { return data } @@ -171,80 +168,34 @@ func (d *IssuePageMetaData) retrieveAssigneesData(ctx *context.Context) { ctx.Data["Assignees"] = d.AssigneesData.CandidateAssignees } -func (d *IssuePageMetaData) retrieveProjectCardsForExistingIssue(ctx *context.Context) { - if err := d.Issue.LoadProjects(ctx); err != nil { - ctx.ServerError("LoadProjects", err) - return - } - - // Load column mappings for all projects - projectColumnMap, err := d.Issue.ProjectColumnMap(ctx) - if err != nil { - ctx.ServerError("ProjectColumnMap", err) - return - } - - // Build project cards for each project - d.ProjectsData.ProjectCards = make([]*issueSidebarProjectCardData, 0, len(d.Issue.Projects)) - for _, project := range d.Issue.Projects { - columns, err := project.GetColumns(ctx) - if err != nil { - ctx.ServerError("GetProjectColumns", err) - return - } - - var selectedColumn *project_model.Column - columnID := projectColumnMap[project.ID] - for _, col := range columns { - if col.ID == columnID { - selectedColumn = col - break - } - } - - if selectedColumn == nil { - selectedColumn, err = project.MustDefaultColumn(ctx) - if err != nil { - ctx.ServerError("MustDefaultColumn", err) - return - } - } - d.ProjectsData.ProjectCards = append(d.ProjectsData.ProjectCards, &issueSidebarProjectCardData{ - Project: project, - Columns: columns, - SelectedColumn: selectedColumn, - }) - } - d.ProjectsData.SelectedProjectIDs = make([]int64, 0, len(d.ProjectsData.ProjectCards)) - for _, card := range d.ProjectsData.ProjectCards { - d.ProjectsData.SelectedProjectIDs = append(d.ProjectsData.SelectedProjectIDs, card.Project.ID) - } -} - func (d *IssuePageMetaData) retrieveProjectData(ctx *context.Context) { - if d.Issue == nil { + if d.Issue == nil || d.Issue.Project == nil { return } - d.retrieveProjectCardsForExistingIssue(ctx) -} - -func (d *IssuePageMetaData) SetSelectedProjectIDs(ids []int64) { - allProjects := map[int64]*project_model.Project{} - for _, p := range d.ProjectsData.OpenProjects { - allProjects[p.ID] = p + d.ProjectsData.SelectedProjectIDs = []int64{d.Issue.Project.ID} + columns, err := project_model.GetProjectColumns(ctx, d.Issue.Project.ID, db.ListOptionsAll) + if err != nil { + ctx.ServerError("GetProjectColumns", err) + return } - for _, p := range d.ProjectsData.ClosedProjects { - allProjects[p.ID] = p + d.ProjectsData.SelectedProjectColumns = columns + columnID, err := d.Issue.ProjectColumnID(ctx) + if err != nil { + ctx.ServerError("ProjectColumnID", err) + return } - for _, id := range ids { - if project, ok := allProjects[id]; ok { - d.ProjectsData.ProjectCards = append(d.ProjectsData.ProjectCards, &issueSidebarProjectCardData{Project: project}) + for _, col := range columns { + if col.ID == columnID { + d.ProjectsData.SelectedProjectColumn = col + break } } - d.ProjectsData.SelectedProjectIDs = ids } func (d *IssuePageMetaData) retrieveProjectsDataForIssueWriter(ctx *context.Context) { + if d.Issue != nil && d.Issue.Project != nil { + d.ProjectsData.SelectedProjectIDs = []int64{d.Issue.Project.ID} + } d.ProjectsData.OpenProjects, d.ProjectsData.ClosedProjects = retrieveProjectsInternal(ctx, ctx.Repo.Repository) } diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index 8690e75463..e623bff752 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -293,7 +293,7 @@ func ViewProject(ctx *context.Context) { return } - columns, err := project.GetColumns(ctx) + columns, err := project_model.GetProjectColumns(ctx, project.ID, db.ListOptionsAll) if err != nil { ctx.ServerError("GetProjectColumns", err) return diff --git a/tests/integration/project_test.go b/tests/integration/project_test.go index 516f2acec7..46254ea44e 100644 --- a/tests/integration/project_test.go +++ b/tests/integration/project_test.go @@ -7,9 +7,9 @@ import ( "fmt" "net/http" "strconv" - "strings" "testing" + "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" project_model "code.gitea.io/gitea/models/project" repo_model "code.gitea.io/gitea/models/repo" @@ -60,7 +60,7 @@ func TestMoveRepoProjectColumns(t *testing.T) { assert.NoError(t, err) } - columns, err := project1.GetColumns(t.Context()) + columns, err := project_model.GetProjectColumns(t.Context(), project1.ID, db.ListOptionsAll) assert.NoError(t, err) assert.Len(t, columns, 3) assert.EqualValues(t, 0, columns[0].Sorting) @@ -80,7 +80,7 @@ func TestMoveRepoProjectColumns(t *testing.T) { }) sess.MakeRequest(t, req, http.StatusOK) - columnsAfter, err := project1.GetColumns(t.Context()) + columnsAfter, err := project_model.GetProjectColumns(t.Context(), project1.ID, db.ListOptionsAll) assert.NoError(t, err) assert.Len(t, columnsAfter, 3) assert.Equal(t, columns[1].ID, columnsAfter[0].ID) @@ -90,128 +90,6 @@ func TestMoveRepoProjectColumns(t *testing.T) { assert.NoError(t, project_model.DeleteProjectByID(t.Context(), project1.ID)) } -func TestUpdateIssueProject(t *testing.T) { - defer tests.PrepareTestEnv(t)() - - sess := loginUser(t, "user2") - - t.Run("AssignAndRemove", func(t *testing.T) { - req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=2", map[string]string{ - "id": "1", - }) - sess.MakeRequest(t, req, http.StatusOK) - unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{IssueID: 2, ProjectID: 1}) - - req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=2", map[string]string{ - "id": "", - }) - sess.MakeRequest(t, req, http.StatusOK) - unittest.AssertNotExistsBean(t, &project_model.ProjectIssue{IssueID: 2, ProjectID: 1}) - }) -} - -func TestUpdateIssueProjectColumn(t *testing.T) { - defer tests.PrepareTestEnv(t)() - - // fixture: issue 3 is in project 1 of repo user2/repo1, column "In Progress" (id=2) - issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3}) - assert.EqualValues(t, 1, issue.RepoID) - - sess := loginUser(t, "user2") - - t.Run("MoveColumn", func(t *testing.T) { - req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects/column", map[string]string{ - "issue_id": "3", - "id": "3", - }) - sess.MakeRequest(t, req, http.StatusOK) - - pi := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{IssueID: 3}) - assert.EqualValues(t, 3, pi.ProjectColumnID) - }) - - t.Run("InvalidIssueID", func(t *testing.T) { - req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects/column", map[string]string{ - "issue_id": "0", - "id": "3", - }) - sess.MakeRequest(t, req, http.StatusNotFound) - }) - - t.Run("WrongRepo", func(t *testing.T) { - req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects/column", map[string]string{ - "issue_id": "6", - "id": "3", - }) - sess.MakeRequest(t, req, http.StatusNotFound) - }) - - t.Run("WrongProject", func(t *testing.T) { - project2 := project_model.Project{ - Title: "second project on repo1", - RepoID: 1, - Type: project_model.TypeRepository, - TemplateType: project_model.TemplateTypeNone, - } - require.NoError(t, project_model.NewProject(t.Context(), &project2)) - require.NoError(t, project_model.NewColumn(t.Context(), &project_model.Column{ - Title: "other column", - ProjectID: project2.ID, - })) - columns, err := project2.GetColumns(t.Context()) - require.NoError(t, err) - require.NotEmpty(t, columns) - - req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects/column", map[string]string{ - "issue_id": "1", - "id": strconv.FormatInt(columns[0].ID, 10), - }) - sess.MakeRequest(t, req, http.StatusNotFound) - }) -} - -func TestIssueSidebarProjectColumn(t *testing.T) { - defer tests.PrepareTestEnv(t)() - - // fixture: issue 5 (index=4) is in project 1 of repo user2/repo1, column "Done" (id=3) - sess := loginUser(t, "user2") - - req := NewRequest(t, "GET", "/user2/repo1/issues/4") - resp := sess.MakeRequest(t, req, http.StatusOK) - htmlDoc := NewHTMLParser(t, resp.Body) - - cards := htmlDoc.Find(".flex-relaxed-list > .item.sidebar-project-card") - assert.Equal(t, 1, cards.Length()) - - title := cards.Find("a span.gt-ellipsis") - assert.Contains(t, strings.TrimSpace(title.Text()), "First project") - - columnCombo := cards.Find(".issue-sidebar-combo.sidebar-project-column-combo") - assert.Equal(t, 1, columnCombo.Length()) - - defaultItem := columnCombo.Find(`.menu .item[data-value="1"]`) - assert.Equal(t, 1, defaultItem.Length()) - - inProgressItem := columnCombo.Find(`.menu .item[data-value="2"]`) - assert.Equal(t, 1, inProgressItem.Length()) - doneItem := columnCombo.Find(`.menu .item[data-value="3"]`) - assert.Equal(t, 1, doneItem.Length()) - - comboVal, exists := columnCombo.Find("input.combo-value").Attr("value") - assert.True(t, exists) - assert.Equal(t, "3", comboVal) - - req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=5", map[string]string{"id": ""}) - sess.MakeRequest(t, req, http.StatusOK) - - req = NewRequest(t, "GET", "/user2/repo1/issues/4") - resp = sess.MakeRequest(t, req, http.StatusOK) - htmlDoc = NewHTMLParser(t, resp.Body) - - cards = htmlDoc.Find(".flex-relaxed-list > .item.sidebar-project-card") - assert.Equal(t, 0, cards.Length()) -} - // getProjectIssueIDs returns the set of issue IDs rendered as cards on the project board page. func getProjectIssueIDs(t *testing.T, htmlDoc *HTMLDoc) map[int64]struct{} { t.Helper() @@ -311,9 +189,15 @@ func TestOrgProjectFilterByMilestone(t *testing.T) { } require.NoError(t, project_model.NewProject(t.Context(), &project)) + // Get the default column + columns, err := project_model.GetProjectColumns(t.Context(), project.ID, db.ListOptionsAll) + require.NoError(t, err) + require.NotEmpty(t, columns) + defaultColumnID := columns[0].ID + // Add issues to the project - require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue16, user1, []int64{project.ID})) - require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue17, user1, []int64{project.ID})) + require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue16, user1, project.ID, defaultColumnID)) + require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue17, user1, project.ID, defaultColumnID)) sess := loginUser(t, "user1") projectURL := fmt.Sprintf("/org3/-/projects/%d", project.ID) From 478d0684979cf71cd99da78803a1a2da91890f84 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Thu, 2 Apr 2026 20:24:39 -0700 Subject: [PATCH 11/37] fix --- models/project/column_list_test.go | 20 ++++++++++++++++++-- routers/api/v1/api.go | 8 ++++---- services/convert/project.go | 12 +++++------- 3 files changed, 27 insertions(+), 13 deletions(-) diff --git a/models/project/column_list_test.go b/models/project/column_list_test.go index 1c296dd64d..af6e830e74 100644 --- a/models/project/column_list_test.go +++ b/models/project/column_list_test.go @@ -12,7 +12,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestCountColumns(t *testing.T) { +func TestCountProjectColumns(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) project, err := GetProjectByID(t.Context(), 1) @@ -23,7 +23,7 @@ func TestCountColumns(t *testing.T) { assert.EqualValues(t, 3, count) } -func TestGetColumnsPaginated(t *testing.T) { +func TestGetProjectColumns(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) project, err := GetProjectByID(t.Context(), 1) @@ -47,3 +47,19 @@ func TestGetColumnsPaginated(t *testing.T) { } assert.Len(t, allIDs, 3) } + +func TestGetColumnsByIDs(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + project, err := GetProjectByID(t.Context(), 1) + assert.NoError(t, err) + + columns, err := GetColumnsByIDs(t.Context(), project.ID, []int64{1, 3, 4}) + assert.NoError(t, err) + assert.Len(t, columns, 2) + assert.ElementsMatch(t, []int64{1, 3}, []int64{columns[0].ID, columns[1].ID}) + + empty, err := GetColumnsByIDs(t.Context(), project.ID, nil) + assert.NoError(t, err) + assert.Empty(t, empty) +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 3d52ae211c..01cf0c2ca7 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1580,15 +1580,15 @@ func Routes() *web.Router { 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) + Patch(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.EditProjectOption{}), repo.EditProject). + Delete(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, repo.DeleteProject) m.Combo("/columns").Get(repo.ListProjectColumns). Post(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.CreateProjectColumnOption{}), repo.CreateProjectColumn) }) m.Group("/columns/{id}", func() { m.Combo(""). - Patch(reqToken(), reqRepoWriter(unit.TypeProjects), bind(api.EditProjectColumnOption{}), repo.EditProjectColumn). - Delete(reqToken(), reqRepoWriter(unit.TypeProjects), repo.DeleteProjectColumn) + Patch(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.EditProjectColumnOption{}), repo.EditProjectColumn). + Delete(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, repo.DeleteProjectColumn) m.Post("/issues", reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.AddIssueToProjectColumnOption{}), repo.AddIssueToProjectColumn) }) }, reqRepoReader(unit.TypeProjects)) diff --git a/services/convert/project.go b/services/convert/project.go index 720bc385eb..e28aa25b96 100644 --- a/services/convert/project.go +++ b/services/convert/project.go @@ -12,9 +12,6 @@ import ( // ToProject converts a project_model.Project to api.Project func ToProject(ctx context.Context, p *project_model.Project) *api.Project { - if p == nil { - return nil - } project := &api.Project{ ID: p.ID, Title: p.Title, @@ -32,10 +29,13 @@ func ToProject(ctx context.Context, p *project_model.Project) *api.Project { Created: p.CreatedUnix.AsTime(), Updated: p.UpdatedUnix.AsTime(), } + if p.ClosedDateUnix > 0 { t := p.ClosedDateUnix.AsTime() project.ClosedDate = &t } + + // Generate project URL if p.Type == project_model.TypeRepository && p.RepoID > 0 { if err := p.LoadRepo(ctx); err == nil && p.Repo != nil { project.URL = project_model.ProjectLinkForRepo(p.Repo, p.ID) @@ -45,14 +45,12 @@ func ToProject(ctx context.Context, p *project_model.Project) *api.Project { project.URL = project_model.ProjectLinkForOrg(p.Owner, p.ID) } } + return project } // ToProjectColumn converts a project_model.Column to api.ProjectColumn func ToProjectColumn(ctx context.Context, column *project_model.Column) *api.ProjectColumn { - if column == nil { - return nil - } return &api.ProjectColumn{ ID: column.ID, Title: column.Title, @@ -83,4 +81,4 @@ func ToProjectColumnList(ctx context.Context, columns []*project_model.Column) [ result[i] = ToProjectColumn(ctx, column) } return result -} \ No newline at end of file +} From 5b663c413e293c10e0ef6c9f320a335c0f3fd7dd Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Thu, 2 Apr 2026 20:58:09 -0700 Subject: [PATCH 12/37] some improvements --- modules/structs/project.go | 113 +++++++++------ routers/api/v1/api.go | 4 +- routers/api/v1/repo/project.go | 156 +++++++++++++++++++-- routers/api/v1/swagger/options.go | 3 - tests/integration/api_repo_project_test.go | 131 +++++++++++++++-- 5 files changed, 337 insertions(+), 70 deletions(-) diff --git a/modules/structs/project.go b/modules/structs/project.go index 0cc8f1bce5..5966ef0065 100644 --- a/modules/structs/project.go +++ b/modules/structs/project.go @@ -10,42 +10,64 @@ import ( // Project represents a project // swagger:model type Project struct { - 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"` + // Unique identifier of the project + ID int64 `json:"id"` + // Project title + Title string `json:"title"` + // Project description + Description string `json:"description"` + // Owner ID (for organization or user projects) + OwnerID int64 `json:"owner_id,omitempty"` + // Repository ID (for repository projects) + RepoID int64 `json:"repo_id,omitempty"` + // Creator ID + CreatorID int64 `json:"creator_id"` + // Whether the project is closed + IsClosed bool `json:"is_closed"` + // Template type: 0=none, 1=basic_kanban, 2=bug_triage + TemplateType int `json:"template_type"` + // Card type: 0=text_only, 1=images_and_text + CardType int `json:"card_type"` + // Project type: 1=individual, 2=repository, 3=organization + Type int `json:"type"` + // Number of open issues + NumOpenIssues int64 `json:"num_open_issues,omitempty"` + // Number of closed issues + NumClosedIssues int64 `json:"num_closed_issues,omitempty"` + // Total number of issues + NumIssues int64 `json:"num_issues,omitempty"` + // Created time // swagger:strfmt date-time - Created time.Time `json:"created"` + Created time.Time `json:"created"` + // Updated time // swagger:strfmt date-time - Updated time.Time `json:"updated"` + Updated time.Time `json:"updated"` + // Closed time // swagger:strfmt date-time ClosedDate *time.Time `json:"closed_date,omitempty"` - URL string `json:"url,omitempty"` + // Project URL + URL string `json:"url,omitempty"` } // CreateProjectOption represents options for creating a project // swagger:model type CreateProjectOption struct { // required: true - Title string `json:"title" binding:"Required"` - Description string `json:"description"` - TemplateType int `json:"template_type"` - CardType int `json:"card_type"` + Title string `json:"title" binding:"Required"` + // Project description + Description string `json:"description"` + // Template type: 0=none, 1=basic_kanban, 2=bug_triage + TemplateType int `json:"template_type"` + // Card type: 0=text_only, 1=images_and_text + CardType int `json:"card_type"` } // EditProjectOption represents options for editing a project // swagger:model type EditProjectOption struct { - Title *string `json:"title,omitempty"` + // Project title + Title *string `json:"title,omitempty"` + // Project description Description *string `json:"description,omitempty"` // Card type: 0=text_only, 1=images_and_text CardType *int `json:"card_type,omitempty"` @@ -56,18 +78,28 @@ type EditProjectOption struct { // 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"` + // Unique identifier of the column + ID int64 `json:"id"` + // Column title + Title string `json:"title"` + // Whether this is the default column + Default bool `json:"default"` + // Sorting order + Sorting int `json:"sorting"` + // Column color (hex format) + Color string `json:"color,omitempty"` + // Project ID + ProjectID int64 `json:"project_id"` + // Creator ID + CreatorID int64 `json:"creator_id"` + // Number of issues in this column + NumIssues int64 `json:"num_issues,omitempty"` + // Created time // swagger:strfmt date-time - Created time.Time `json:"created"` + Created time.Time `json:"created"` + // Updated time // swagger:strfmt date-time - Updated time.Time `json:"updated"` + Updated time.Time `json:"updated"` } // CreateProjectColumnOption represents options for creating a project column @@ -75,22 +107,17 @@ type ProjectColumn struct { type CreateProjectColumnOption struct { // required: true Title string `json:"title" binding:"Required"` + // Column color (hex format, e.g., #FF0000) Color string `json:"color,omitempty"` } // EditProjectColumnOption represents options for editing a project column // swagger:model type EditProjectColumnOption struct { - Title *string `json:"title,omitempty"` - Color *string `json:"color,omitempty"` - Sorting *int `json:"sorting,omitempty"` + // Column title + Title *string `json:"title,omitempty"` + // Column color (hex format) + Color *string `json:"color,omitempty"` + // Sorting order + Sorting *int `json:"sorting,omitempty"` } - - -// AddIssueToProjectColumnOption represents options for adding an issue to a project column -// swagger:model -type AddIssueToProjectColumnOption struct { - // required: true - IssueIDs []int64 `json:"issue_ids" binding:"Required"` -} - diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 01cf0c2ca7..2fbb177ba2 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1589,7 +1589,9 @@ func Routes() *web.Router { m.Combo(""). Patch(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.EditProjectColumnOption{}), repo.EditProjectColumn). Delete(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, repo.DeleteProjectColumn) - m.Post("/issues", reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.AddIssueToProjectColumnOption{}), repo.AddIssueToProjectColumn) + m.Get("/issues", repo.ListProjectColumnIssues) + m.Post("/issues/{issue_id}", reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, repo.AddIssueToProjectColumn) + m.Delete("/issues/{issue_id}", reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, repo.RemoveIssueFromProjectColumn) }) }, reqRepoReader(unit.TypeProjects)) }, repoAssignment(), checkTokenPublicOnly()) diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index 619ecde57e..17c194c7fc 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -572,9 +572,78 @@ func DeleteProjectColumn(ctx *context.APIContext) { ctx.Status(http.StatusNoContent) } +// ListProjectColumnIssues lists all issues in a project column +func ListProjectColumnIssues(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/projects/columns/{id}/issues repository repoListProjectColumnIssues + // --- + // summary: List issues in a project column + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the column + // type: integer + // format: int64 + // required: true + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/IssueList" + // "404": + // "$ref": "#/responses/notFound" + + column := getRepoProjectColumn(ctx) + if ctx.Written() { + return + } + + listOptions := utils.GetListOptions(ctx) + issuesOpts := &issues_model.IssuesOptions{ + Paginator: &listOptions, + RepoIDs: []int64{ctx.Repo.Repository.ID}, + ProjectID: column.ProjectID, + ProjectColumnID: column.ID, + SortType: "project-column-sorting", + } + + count, err := issues_model.CountIssues(ctx, issuesOpts) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + issues, err := issues_model.Issues(ctx, issuesOpts) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.SetLinkHeader(count, listOptions.PageSize) + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, ctx.Doer, issues)) +} + // AddIssueToProjectColumn adds an issue to a project column func AddIssueToProjectColumn(ctx *context.APIContext) { - // swagger:operation POST /repos/{owner}/{repo}/projects/columns/{id}/issues repository repoAddIssueToProjectColumn + // swagger:operation POST /repos/{owner}/{repo}/projects/columns/{id}/issues/{issue_id} repository repoAddIssueToProjectColumn // --- // summary: Add an issue to a project column // consumes: @@ -598,10 +667,12 @@ func AddIssueToProjectColumn(ctx *context.APIContext) { // type: integer // format: int64 // required: true - // - name: body - // in: body - // schema: - // "$ref": "#/definitions/AddIssueToProjectColumnOption" + // - name: issue_id + // in: path + // description: id of the issue + // type: integer + // format: int64 + // required: true // responses: // "201": // "$ref": "#/responses/empty" @@ -617,9 +688,7 @@ func AddIssueToProjectColumn(ctx *context.APIContext) { return } - form := web.GetForm(ctx).(*api.AddIssueToProjectColumnOption) - - issue, err := issues_model.GetIssueByID(ctx, form.IssueID) + issue, err := issues_model.GetIssueByID(ctx, ctx.PathParamInt64("issue_id")) if err != nil { if issues_model.IsErrIssueNotExist(err) { ctx.APIError(http.StatusUnprocessableEntity, "issue not found") @@ -641,3 +710,74 @@ func AddIssueToProjectColumn(ctx *context.APIContext) { ctx.Status(http.StatusCreated) } + +// RemoveIssueFromProjectColumn remove an issue from a project column +func RemoveIssueFromProjectColumn(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/projects/columns/{id}/issues/{issue_id} 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: issue_id + // in: path + // description: id of the issue + // type: integer + // format: int64 + // required: true + // responses: + // "201": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + column := getRepoProjectColumn(ctx) + if ctx.Written() { + return + } + + issue, err := issues_model.GetIssueByID(ctx, ctx.PathParamInt64("issue_id")) + 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 + } + + // 0 means remove + if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, 0, column.ID); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 327d188aa9..0fd6e36747 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -243,7 +243,4 @@ type swaggerParameterBodies struct { CreateProjectColumnOption api.CreateProjectColumnOption // in:body EditProjectColumnOption api.EditProjectColumnOption - - // in:body - AddIssueToProjectColumnOption api.AddIssueToProjectColumnOption } diff --git a/tests/integration/api_repo_project_test.go b/tests/integration/api_repo_project_test.go index 78d50753aa..0cbf66d4a2 100644 --- a/tests/integration/api_repo_project_test.go +++ b/tests/integration/api_repo_project_test.go @@ -538,9 +538,7 @@ func TestAPIAddIssueToProjectColumn(t *testing.T) { 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) + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/columns/%d/issues/%d", owner.Name, repo.Name, column1.ID, issue.ID), nil).AddTokenAuth(token) MakeRequest(t, req, http.StatusCreated) // Verify issue is in the column @@ -551,9 +549,7 @@ func TestAPIAddIssueToProjectColumn(t *testing.T) { 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) + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/columns/%d/issues/%d", owner.Name, repo.Name, column2.ID, issue.ID), nil).AddTokenAuth(token) MakeRequest(t, req, http.StatusCreated) // Verify issue moved to new column @@ -564,24 +560,129 @@ func TestAPIAddIssueToProjectColumn(t *testing.T) { 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) + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/columns/%d/issues/%d", owner.Name, repo.Name, column2.ID, issue.ID), nil).AddTokenAuth(token) MakeRequest(t, req, http.StatusCreated) // Test adding non-existent issue - req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/columns/%d/issues", owner.Name, repo.Name, column1.ID), &api.AddIssueToProjectColumnOption{ - IssueID: 99999, - }).AddTokenAuth(token) + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/columns/%d/issues/%d", owner.Name, repo.Name, column1.ID, 99999), nil).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) + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/columns/99999/issues/%d", owner.Name, repo.Name, issue.ID), nil).AddTokenAuth(token) MakeRequest(t, req, http.StatusNotFound) } +func TestAPIListProjectColumnIssues(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, IsPull: false}) + pull := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID, IsPull: true}) + + project := &project_model.Project{ + Title: "Project for Column Issues", + RepoID: repo.ID, + Type: project_model.TypeRepository, + CreatorID: owner.ID, + TemplateType: project_model.TemplateTypeNone, + } + err := project_model.NewProject(t.Context(), project) + assert.NoError(t, err) + defer func() { + _ = project_model.DeleteProjectByID(t.Context(), project.ID) + }() + + column := &project_model.Column{ + Title: "Column for Issues", + ProjectID: project.ID, + CreatorID: owner.ID, + } + err = project_model.NewColumn(t.Context(), column) + assert.NoError(t, err) + + err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue, owner, project.ID, column.ID) + assert.NoError(t, err) + err = issues_model.IssueAssignOrRemoveProject(t.Context(), pull, owner, project.ID, column.ID) + assert.NoError(t, err) + + token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue) + + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/columns/%d/issues", owner.Name, repo.Name, column.ID). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var issues []api.Issue + DecodeJSON(t, resp, &issues) + assert.Len(t, issues, 2) + + issueIDs := make(map[int64]struct{}, len(issues)) + for _, apiIssue := range issues { + issueIDs[apiIssue.ID] = struct{}{} + } + assert.Contains(t, issueIDs, issue.ID) + assert.Contains(t, issueIDs, pull.ID) + + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/columns/%d/issues?type=issues", owner.Name, repo.Name, column.ID). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &issues) + assert.Len(t, issues, 1) + assert.Equal(t, issue.ID, issues[0].ID) + + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/columns/%d/issues?type=pulls", owner.Name, repo.Name, column.ID). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &issues) + assert.Len(t, issues, 1) + assert.Equal(t, pull.ID, issues[0].ID) +} + +func TestAPIRemoveIssueFromProjectColumn(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}) + + project := &project_model.Project{ + Title: "Project for Issue Removal", + RepoID: repo.ID, + Type: project_model.TypeRepository, + CreatorID: owner.ID, + TemplateType: project_model.TemplateTypeNone, + } + err := project_model.NewProject(t.Context(), project) + assert.NoError(t, err) + defer func() { + _ = project_model.DeleteProjectByID(t.Context(), project.ID) + }() + + column := &project_model.Column{ + Title: "Column for Issue Removal", + ProjectID: project.ID, + CreatorID: owner.ID, + } + err = project_model.NewColumn(t.Context(), column) + assert.NoError(t, err) + + err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue, owner, project.ID, column.ID) + assert.NoError(t, err) + + token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) + + req := NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/projects/columns/%d/issues/%d", owner.Name, repo.Name, column.ID, issue.ID), nil). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + unittest.AssertNotExistsBean(t, &project_model.ProjectIssue{ + ProjectID: project.ID, + IssueID: issue.ID, + }) +} + func TestAPIProjectPermissions(t *testing.T) { defer tests.PrepareTestEnv(t)() From 815fe10eabd035d3fad500e107adb59769230841 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Thu, 2 Apr 2026 20:59:47 -0700 Subject: [PATCH 13/37] update swagger --- templates/swagger/v1_json.tmpl | 85 +++++++++++++++++++++++++--------- 1 file changed, 63 insertions(+), 22 deletions(-) diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 61ac5c44fa..3847cbaae5 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -13729,6 +13729,62 @@ } }, "/repos/{owner}/{repo}/projects/columns/{id}/issues": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "List issues in a project column", + "operationId": "repoListProjectColumnIssues", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "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/IssueList" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/repos/{owner}/{repo}/projects/columns/{id}/issues/{issue_id}": { "post": { "consumes": [ "application/json" @@ -13765,11 +13821,12 @@ "required": true }, { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/AddIssueToProjectColumnOption" - } + "type": "integer", + "format": "int64", + "description": "id of the issue", + "name": "issue_id", + "in": "path", + "required": true } ], "responses": { @@ -22263,22 +22320,6 @@ }, "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", @@ -31388,7 +31429,7 @@ "parameterBodies": { "description": "parameterBodies", "schema": { - "$ref": "#/definitions/AddIssueToProjectColumnOption" + "$ref": "#/definitions/EditProjectColumnOption" } }, "redirect": { From e0c53b3ba8889b8ec4b2ae8fe683963122cc7862 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Fri, 3 Apr 2026 10:46:15 -0700 Subject: [PATCH 14/37] Fix test --- tests/integration/api_repo_project_test.go | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/tests/integration/api_repo_project_test.go b/tests/integration/api_repo_project_test.go index 0cbf66d4a2..a41f7a3da2 100644 --- a/tests/integration/api_repo_project_test.go +++ b/tests/integration/api_repo_project_test.go @@ -577,8 +577,8 @@ func TestAPIListProjectColumnIssues(t *testing.T) { repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) - issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID, IsPull: false}) - pull := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID, IsPull: true}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) + pull := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2}) project := &project_model.Project{ Title: "Project for Column Issues", @@ -622,22 +622,6 @@ func TestAPIListProjectColumnIssues(t *testing.T) { } assert.Contains(t, issueIDs, issue.ID) assert.Contains(t, issueIDs, pull.ID) - - req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/columns/%d/issues?type=issues", owner.Name, repo.Name, column.ID). - AddTokenAuth(token) - resp = MakeRequest(t, req, http.StatusOK) - - DecodeJSON(t, resp, &issues) - assert.Len(t, issues, 1) - assert.Equal(t, issue.ID, issues[0].ID) - - req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/columns/%d/issues?type=pulls", owner.Name, repo.Name, column.ID). - AddTokenAuth(token) - resp = MakeRequest(t, req, http.StatusOK) - - DecodeJSON(t, resp, &issues) - assert.Len(t, issues, 1) - assert.Equal(t, pull.ID, issues[0].ID) } func TestAPIRemoveIssueFromProjectColumn(t *testing.T) { From 3557c100c24b07d7b423a5821427fb406ae64fb0 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Fri, 3 Apr 2026 11:23:31 -0700 Subject: [PATCH 15/37] improvement --- routers/api/v1/repo/project.go | 7 ++-- templates/swagger/v1_json.tmpl | 59 ++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index 17c194c7fc..ea4f3f8825 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -28,6 +28,7 @@ func getRepoProjectByID(ctx *context.APIContext) *project_model.Project { } return nil } + project.Repo = ctx.Repo.Repository return project } @@ -713,9 +714,9 @@ func AddIssueToProjectColumn(ctx *context.APIContext) { // RemoveIssueFromProjectColumn remove an issue from a project column func RemoveIssueFromProjectColumn(ctx *context.APIContext) { - // swagger:operation POST /repos/{owner}/{repo}/projects/columns/{id}/issues/{issue_id} repository repoAddIssueToProjectColumn + // swagger:operation DELETE /repos/{owner}/{repo}/projects/columns/{id}/issues/{issue_id} repository repoRemoveIssueFromProjectColumn // --- - // summary: Add an issue to a project column + // summary: Remove an issue from a project column // consumes: // - application/json // produces: @@ -744,7 +745,7 @@ func RemoveIssueFromProjectColumn(ctx *context.APIContext) { // format: int64 // required: true // responses: - // "201": + // "204": // "$ref": "#/responses/empty" // "403": // "$ref": "#/responses/forbidden" diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 3847cbaae5..71ed0ea380 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -13843,6 +13843,65 @@ "$ref": "#/responses/validationError" } } + }, + "delete": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Remove an issue from a project column", + "operationId": "repoRemoveIssueFromProjectColumn", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the issue", + "name": "issue_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } } }, "/repos/{owner}/{repo}/projects/{id}": { From d06fdf6454eb5d2028c0d327f0c084e6cd25b1c7 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Sat, 4 Apr 2026 16:37:41 -0700 Subject: [PATCH 16/37] improvement --- routers/api/v1/api.go | 16 +- routers/api/v1/repo/project.go | 49 ++- templates/swagger/v1_json.tmpl | 584 ++++++++++++++++++--------------- 3 files changed, 362 insertions(+), 287 deletions(-) diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 2fbb177ba2..33ade037b6 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1584,14 +1584,14 @@ func Routes() *web.Router { Delete(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, repo.DeleteProject) m.Combo("/columns").Get(repo.ListProjectColumns). Post(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.CreateProjectColumnOption{}), repo.CreateProjectColumn) - }) - m.Group("/columns/{id}", func() { - m.Combo(""). - Patch(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.EditProjectColumnOption{}), repo.EditProjectColumn). - Delete(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, repo.DeleteProjectColumn) - m.Get("/issues", repo.ListProjectColumnIssues) - m.Post("/issues/{issue_id}", reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, repo.AddIssueToProjectColumn) - m.Delete("/issues/{issue_id}", reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, repo.RemoveIssueFromProjectColumn) + m.Group("/columns/{column_id}", func() { + m.Combo(""). + Patch(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.EditProjectColumnOption{}), repo.EditProjectColumn). + Delete(reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, repo.DeleteProjectColumn) + m.Get("/issues", repo.ListProjectColumnIssues) + m.Post("/issues/{issue_id}", reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, repo.AddIssueToProjectColumn) + m.Delete("/issues/{issue_id}", reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, repo.RemoveIssueFromProjectColumn) + }) }) }, reqRepoReader(unit.TypeProjects)) }, repoAssignment(), checkTokenPublicOnly()) diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index ea4f3f8825..ee16800125 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -33,7 +33,7 @@ func getRepoProjectByID(ctx *context.APIContext) *project_model.Project { } func getRepoProjectColumn(ctx *context.APIContext) *project_model.Column { - column, err := project_model.GetColumn(ctx, ctx.PathParamInt64("id")) + column, err := project_model.GetColumn(ctx, ctx.PathParamInt64("column_id")) if err != nil { if project_model.IsErrProjectColumnNotExist(err) { ctx.APIErrorNotFound() @@ -42,7 +42,7 @@ func getRepoProjectColumn(ctx *context.APIContext) *project_model.Column { } return nil } - _, err = project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, column.ProjectID) + p, err := project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, column.ProjectID) if err != nil { if project_model.IsErrProjectNotExist(err) { ctx.APIErrorNotFound() @@ -51,6 +51,11 @@ func getRepoProjectColumn(ctx *context.APIContext) *project_model.Column { } return nil } + if p.ID != ctx.PathParamInt64("id") { + ctx.APIErrorNotFound() + return nil + } + return column } @@ -466,7 +471,7 @@ func CreateProjectColumn(ctx *context.APIContext) { // EditProjectColumn updates a column func EditProjectColumn(ctx *context.APIContext) { - // swagger:operation PATCH /repos/{owner}/{repo}/projects/columns/{id} repository repoEditProjectColumn + // swagger:operation PATCH /repos/{owner}/{repo}/projects/{id}/columns/{column_id} repository repoEditProjectColumn // --- // summary: Edit a project column // consumes: @@ -486,6 +491,12 @@ func EditProjectColumn(ctx *context.APIContext) { // required: true // - name: id // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: column_id + // in: path // description: id of the column // type: integer // format: int64 @@ -534,7 +545,7 @@ func EditProjectColumn(ctx *context.APIContext) { // DeleteProjectColumn deletes a column func DeleteProjectColumn(ctx *context.APIContext) { - // swagger:operation DELETE /repos/{owner}/{repo}/projects/columns/{id} repository repoDeleteProjectColumn + // swagger:operation DELETE /repos/{owner}/{repo}/projects/{id}/columns/{column_id} repository repoDeleteProjectColumn // --- // summary: Delete a project column // parameters: @@ -550,6 +561,12 @@ func DeleteProjectColumn(ctx *context.APIContext) { // required: true // - name: id // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: column_id + // in: path // description: id of the column // type: integer // format: int64 @@ -575,7 +592,7 @@ func DeleteProjectColumn(ctx *context.APIContext) { // ListProjectColumnIssues lists all issues in a project column func ListProjectColumnIssues(ctx *context.APIContext) { - // swagger:operation GET /repos/{owner}/{repo}/projects/columns/{id}/issues repository repoListProjectColumnIssues + // swagger:operation GET /repos/{owner}/{repo}/projects/{id}/columns/{column_id}/issues repository repoListProjectColumnIssues // --- // summary: List issues in a project column // produces: @@ -593,6 +610,12 @@ func ListProjectColumnIssues(ctx *context.APIContext) { // required: true // - name: id // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: column_id + // in: path // description: id of the column // type: integer // format: int64 @@ -644,7 +667,7 @@ func ListProjectColumnIssues(ctx *context.APIContext) { // AddIssueToProjectColumn adds an issue to a project column func AddIssueToProjectColumn(ctx *context.APIContext) { - // swagger:operation POST /repos/{owner}/{repo}/projects/columns/{id}/issues/{issue_id} repository repoAddIssueToProjectColumn + // swagger:operation POST /repos/{owner}/{repo}/projects/{id}/columns/{column_id}/issues/{issue_id} repository repoAddIssueToProjectColumn // --- // summary: Add an issue to a project column // consumes: @@ -664,6 +687,12 @@ func AddIssueToProjectColumn(ctx *context.APIContext) { // required: true // - name: id // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: column_id + // in: path // description: id of the column // type: integer // format: int64 @@ -714,7 +743,7 @@ func AddIssueToProjectColumn(ctx *context.APIContext) { // RemoveIssueFromProjectColumn remove an issue from a project column func RemoveIssueFromProjectColumn(ctx *context.APIContext) { - // swagger:operation DELETE /repos/{owner}/{repo}/projects/columns/{id}/issues/{issue_id} repository repoRemoveIssueFromProjectColumn + // swagger:operation DELETE /repos/{owner}/{repo}/projects/{id}/columns/{column_id}/issues/{issue_id} repository repoRemoveIssueFromProjectColumn // --- // summary: Remove an issue from a project column // consumes: @@ -734,6 +763,12 @@ func RemoveIssueFromProjectColumn(ctx *context.APIContext) { // required: true // - name: id // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: column_id + // in: path // description: id of the column // type: integer // format: int64 diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 71ed0ea380..d895017e04 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -13632,278 +13632,6 @@ } } }, - "/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": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "repository" - ], - "summary": "List issues in a project column", - "operationId": "repoListProjectColumnIssues", - "parameters": [ - { - "type": "string", - "description": "owner of the repo", - "name": "owner", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "name of the repo", - "name": "repo", - "in": "path", - "required": true - }, - { - "type": "integer", - "format": "int64", - "description": "id of the column", - "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/IssueList" - }, - "404": { - "$ref": "#/responses/notFound" - } - } - } - }, - "/repos/{owner}/{repo}/projects/columns/{id}/issues/{issue_id}": { - "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 - }, - { - "type": "integer", - "format": "int64", - "description": "id of the issue", - "name": "issue_id", - "in": "path", - "required": true - } - ], - "responses": { - "201": { - "$ref": "#/responses/empty" - }, - "403": { - "$ref": "#/responses/forbidden" - }, - "404": { - "$ref": "#/responses/notFound" - }, - "422": { - "$ref": "#/responses/validationError" - } - } - }, - "delete": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "repository" - ], - "summary": "Remove an issue from a project column", - "operationId": "repoRemoveIssueFromProjectColumn", - "parameters": [ - { - "type": "string", - "description": "owner of the repo", - "name": "owner", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "name of the repo", - "name": "repo", - "in": "path", - "required": true - }, - { - "type": "integer", - "format": "int64", - "description": "id of the column", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "integer", - "format": "int64", - "description": "id of the issue", - "name": "issue_id", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "$ref": "#/responses/empty" - }, - "403": { - "$ref": "#/responses/forbidden" - }, - "404": { - "$ref": "#/responses/notFound" - }, - "422": { - "$ref": "#/responses/validationError" - } - } - } - }, "/repos/{owner}/{repo}/projects/{id}": { "get": { "produces": [ @@ -14153,6 +13881,318 @@ } } }, + "/repos/{owner}/{repo}/projects/{id}/columns/{column_id}": { + "delete": { + "tags": [ + "repository" + ], + "summary": "Delete a project column", + "operationId": "repoDeleteProjectColumn", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Edit a project column", + "operationId": "repoEditProjectColumn", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/EditProjectColumnOption" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectColumn" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, + "/repos/{owner}/{repo}/projects/{id}/columns/{column_id}/issues": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "List issues in a project column", + "operationId": "repoListProjectColumnIssues", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/IssueList" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/repos/{owner}/{repo}/projects/{id}/columns/{column_id}/issues/{issue_id}": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Add an issue to a project column", + "operationId": "repoAddIssueToProjectColumn", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the issue", + "name": "issue_id", + "in": "path", + "required": true + } + ], + "responses": { + "201": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + }, + "delete": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Remove an issue from a project column", + "operationId": "repoRemoveIssueFromProjectColumn", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the issue", + "name": "issue_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, "/repos/{owner}/{repo}/pulls": { "get": { "produces": [ From cd2348167340f626d819c57e7402093812cbc226 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Sat, 4 Apr 2026 19:05:15 -0700 Subject: [PATCH 17/37] Fix test --- tests/integration/api_repo_project_test.go | 24 +++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/integration/api_repo_project_test.go b/tests/integration/api_repo_project_test.go index a41f7a3da2..6dc145512c 100644 --- a/tests/integration/api_repo_project_test.go +++ b/tests/integration/api_repo_project_test.go @@ -431,7 +431,7 @@ func TestAPIUpdateProjectColumn(t *testing.T) { // 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{ + req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d", owner.Name, repo.Name, project.ID, column.ID), &api.EditProjectColumnOption{ Title: &newTitle, }).AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) @@ -442,7 +442,7 @@ func TestAPIUpdateProjectColumn(t *testing.T) { // 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{ + req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d", owner.Name, repo.Name, project.ID, column.ID), &api.EditProjectColumnOption{ Color: &newColor, }).AddTokenAuth(token) resp = MakeRequest(t, req, http.StatusOK) @@ -451,7 +451,7 @@ func TestAPIUpdateProjectColumn(t *testing.T) { 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{ + req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/99999", owner.Name, repo.Name, project.ID), &api.EditProjectColumnOption{ Title: &newTitle, }).AddTokenAuth(token) MakeRequest(t, req, http.StatusNotFound) @@ -488,12 +488,12 @@ func TestAPIDeleteProjectColumn(t *testing.T) { 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). + req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/projects/%d/columns/%d", owner.Name, repo.Name, project.ID, column.ID). AddTokenAuth(token) MakeRequest(t, req, http.StatusNoContent) // Test deleting non-existent column (including the one we just deleted) - req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/projects/columns/%d", owner.Name, repo.Name, column.ID). + req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/projects/%d/columns/%d", owner.Name, repo.Name, project.ID, column.ID). AddTokenAuth(token) MakeRequest(t, req, http.StatusNotFound) } @@ -538,7 +538,7 @@ func TestAPIAddIssueToProjectColumn(t *testing.T) { 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/%d", owner.Name, repo.Name, column1.ID, issue.ID), nil).AddTokenAuth(token) + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, column1.ID, issue.ID), nil).AddTokenAuth(token) MakeRequest(t, req, http.StatusCreated) // Verify issue is in the column @@ -549,7 +549,7 @@ func TestAPIAddIssueToProjectColumn(t *testing.T) { 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/%d", owner.Name, repo.Name, column2.ID, issue.ID), nil).AddTokenAuth(token) + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, column2.ID, issue.ID), nil).AddTokenAuth(token) MakeRequest(t, req, http.StatusCreated) // Verify issue moved to new column @@ -560,15 +560,15 @@ func TestAPIAddIssueToProjectColumn(t *testing.T) { 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/%d", owner.Name, repo.Name, column2.ID, issue.ID), nil).AddTokenAuth(token) + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, column2.ID, issue.ID), nil).AddTokenAuth(token) MakeRequest(t, req, http.StatusCreated) // Test adding non-existent issue - req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/columns/%d/issues/%d", owner.Name, repo.Name, column1.ID, 99999), nil).AddTokenAuth(token) + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, column1.ID, 99999), nil).AddTokenAuth(token) MakeRequest(t, req, http.StatusUnprocessableEntity) // Test adding to non-existent column - req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/columns/99999/issues/%d", owner.Name, repo.Name, issue.ID), nil).AddTokenAuth(token) + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/99999/issues/%d", owner.Name, repo.Name, project.ID, issue.ID), nil).AddTokenAuth(token) MakeRequest(t, req, http.StatusNotFound) } @@ -608,7 +608,7 @@ func TestAPIListProjectColumnIssues(t *testing.T) { token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue) - req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/columns/%d/issues", owner.Name, repo.Name, column.ID). + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns/%d/issues", owner.Name, repo.Name, project.ID, column.ID). AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) @@ -657,7 +657,7 @@ func TestAPIRemoveIssueFromProjectColumn(t *testing.T) { token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) - req := NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/projects/columns/%d/issues/%d", owner.Name, repo.Name, column.ID, issue.ID), nil). + req := NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, column.ID, issue.ID), nil). AddTokenAuth(token) MakeRequest(t, req, http.StatusNoContent) From bc0412ce41555e30959dee144411c77baa3f9aed Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 5 Apr 2026 10:52:18 +0800 Subject: [PATCH 18/37] merge tests --- models/project/column_list_test.go | 15 ++--- tests/integration/api_repo_project_test.go | 70 ++++++++++------------ 2 files changed, 38 insertions(+), 47 deletions(-) diff --git a/models/project/column_list_test.go b/models/project/column_list_test.go index af6e830e74..adc134725e 100644 --- a/models/project/column_list_test.go +++ b/models/project/column_list_test.go @@ -12,9 +12,14 @@ import ( "github.com/stretchr/testify/assert" ) -func TestCountProjectColumns(t *testing.T) { +func TestProjectColumns(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) + t.Run("CountProjectColumns", testCountProjectColumns) + t.Run("GetProjectColumns", testGetProjectColumns) + t.Run("GetColumnsByIDs", testGetColumnsByIDs) +} +func testCountProjectColumns(t *testing.T) { project, err := GetProjectByID(t.Context(), 1) assert.NoError(t, err) @@ -23,9 +28,7 @@ func TestCountProjectColumns(t *testing.T) { assert.EqualValues(t, 3, count) } -func TestGetProjectColumns(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - +func testGetProjectColumns(t *testing.T) { project, err := GetProjectByID(t.Context(), 1) assert.NoError(t, err) @@ -48,9 +51,7 @@ func TestGetProjectColumns(t *testing.T) { assert.Len(t, allIDs, 3) } -func TestGetColumnsByIDs(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - +func testGetColumnsByIDs(t *testing.T) { project, err := GetProjectByID(t.Context(), 1) assert.NoError(t, err) diff --git a/tests/integration/api_repo_project_test.go b/tests/integration/api_repo_project_test.go index 6dc145512c..41ece43b18 100644 --- a/tests/integration/api_repo_project_test.go +++ b/tests/integration/api_repo_project_test.go @@ -20,9 +20,25 @@ import ( "github.com/stretchr/testify/assert" ) -func TestAPIListProjects(t *testing.T) { +func TestAPIProjects(t *testing.T) { defer tests.PrepareTestEnv(t)() + t.Run("ListProjects", testAPIListProjects) + t.Run("GetProject", testAPIGetProject) + t.Run("CreateProject", testAPICreateProject) + t.Run("UpdateProject", testAPIUpdateProject) + t.Run("ChangeProjectStatus", testAPIChangeProjectStatus) + t.Run("DeleteProject", testAPIDeleteProject) + t.Run("ListProjectColumns", testAPIListProjectColumns) + t.Run("CreateProjectColumn", testAPICreateProjectColumn) + t.Run("UpdateProjectColumn", testAPIUpdateProjectColumn) + t.Run("DeleteProjectColumn", testAPIDeleteProjectColumn) + t.Run("AddIssueToProjectColumn", testAPIAddIssueToProjectColumn) + t.Run("RemoveIssueFromProjectColumn", testAPIRemoveIssueFromProjectColumn) + t.Run("ListProjectColumnIssues", testAPIListProjectColumnIssues) + t.Run("Permissions", testAPIProjectPermissions) +} +func testAPIListProjects(t *testing.T) { repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) @@ -57,9 +73,7 @@ func TestAPIListProjects(t *testing.T) { MakeRequest(t, req, http.StatusOK) } -func TestAPIGetProject(t *testing.T) { - defer tests.PrepareTestEnv(t)() - +func testAPIGetProject(t *testing.T) { repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) @@ -97,9 +111,7 @@ func TestAPIGetProject(t *testing.T) { MakeRequest(t, req, http.StatusNotFound) } -func TestAPICreateProject(t *testing.T) { - defer tests.PrepareTestEnv(t)() - +func testAPICreateProject(t *testing.T) { repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) @@ -151,9 +163,7 @@ func TestAPICreateProject(t *testing.T) { MakeRequest(t, req, http.StatusUnprocessableEntity) } -func TestAPIUpdateProject(t *testing.T) { - defer tests.PrepareTestEnv(t)() - +func testAPIUpdateProject(t *testing.T) { repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) @@ -195,9 +205,7 @@ func TestAPIUpdateProject(t *testing.T) { MakeRequest(t, req, http.StatusNotFound) } -func TestAPIChangeProjectStatus(t *testing.T) { - defer tests.PrepareTestEnv(t)() - +func testAPIChangeProjectStatus(t *testing.T) { repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) @@ -240,9 +248,7 @@ func TestAPIChangeProjectStatus(t *testing.T) { assert.False(t, updatedProject.IsClosed) } -func TestAPIDeleteProject(t *testing.T) { - defer tests.PrepareTestEnv(t)() - +func testAPIDeleteProject(t *testing.T) { repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) @@ -270,9 +276,7 @@ func TestAPIDeleteProject(t *testing.T) { MakeRequest(t, req, http.StatusNotFound) } -func TestAPIListProjectColumns(t *testing.T) { - defer tests.PrepareTestEnv(t)() - +func testAPIListProjectColumns(t *testing.T) { repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) @@ -341,9 +345,7 @@ func TestAPIListProjectColumns(t *testing.T) { MakeRequest(t, req, http.StatusNotFound) } -func TestAPICreateProjectColumn(t *testing.T) { - defer tests.PrepareTestEnv(t)() - +func testAPICreateProjectColumn(t *testing.T) { repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) @@ -398,9 +400,7 @@ func TestAPICreateProjectColumn(t *testing.T) { MakeRequest(t, req, http.StatusNotFound) } -func TestAPIUpdateProjectColumn(t *testing.T) { - defer tests.PrepareTestEnv(t)() - +func testAPIUpdateProjectColumn(t *testing.T) { repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) @@ -457,9 +457,7 @@ func TestAPIUpdateProjectColumn(t *testing.T) { MakeRequest(t, req, http.StatusNotFound) } -func TestAPIDeleteProjectColumn(t *testing.T) { - defer tests.PrepareTestEnv(t)() - +func testAPIDeleteProjectColumn(t *testing.T) { repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) @@ -498,9 +496,7 @@ func TestAPIDeleteProjectColumn(t *testing.T) { MakeRequest(t, req, http.StatusNotFound) } -func TestAPIAddIssueToProjectColumn(t *testing.T) { - defer tests.PrepareTestEnv(t)() - +func testAPIAddIssueToProjectColumn(t *testing.T) { repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID}) @@ -572,9 +568,7 @@ func TestAPIAddIssueToProjectColumn(t *testing.T) { MakeRequest(t, req, http.StatusNotFound) } -func TestAPIListProjectColumnIssues(t *testing.T) { - defer tests.PrepareTestEnv(t)() - +func testAPIListProjectColumnIssues(t *testing.T) { repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) @@ -624,9 +618,7 @@ func TestAPIListProjectColumnIssues(t *testing.T) { assert.Contains(t, issueIDs, pull.ID) } -func TestAPIRemoveIssueFromProjectColumn(t *testing.T) { - defer tests.PrepareTestEnv(t)() - +func testAPIRemoveIssueFromProjectColumn(t *testing.T) { repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID}) @@ -667,9 +659,7 @@ func TestAPIRemoveIssueFromProjectColumn(t *testing.T) { }) } -func TestAPIProjectPermissions(t *testing.T) { - defer tests.PrepareTestEnv(t)() - +func testAPIProjectPermissions(t *testing.T) { repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) nonCollaborator := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user5"}) From 795c6bc944c37937e6e0938279d57d02e61dd98a Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 5 Apr 2026 10:58:04 +0800 Subject: [PATCH 19/37] fix AI slop --- tests/integration/api_repo_project_test.go | 42 ---------------------- 1 file changed, 42 deletions(-) diff --git a/tests/integration/api_repo_project_test.go b/tests/integration/api_repo_project_test.go index 41ece43b18..6785f8d276 100644 --- a/tests/integration/api_repo_project_test.go +++ b/tests/integration/api_repo_project_test.go @@ -87,10 +87,6 @@ func testAPIGetProject(t *testing.T) { } 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 @@ -133,9 +129,6 @@ func testAPICreateProject(t *testing.T) { 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{ @@ -146,9 +139,6 @@ func testAPICreateProject(t *testing.T) { 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{ @@ -178,9 +168,6 @@ func testAPIUpdateProject(t *testing.T) { } 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) @@ -219,9 +206,6 @@ func testAPIChangeProjectStatus(t *testing.T) { } 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) @@ -290,9 +274,6 @@ func testAPIListProjectColumns(t *testing.T) { } 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++ { @@ -359,9 +340,6 @@ func testAPICreateProjectColumn(t *testing.T) { } 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) @@ -414,10 +392,6 @@ func testAPIUpdateProjectColumn(t *testing.T) { } 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, @@ -471,10 +445,6 @@ func testAPIDeleteProjectColumn(t *testing.T) { } 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, @@ -511,9 +481,6 @@ func testAPIAddIssueToProjectColumn(t *testing.T) { } 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", @@ -583,9 +550,6 @@ func testAPIListProjectColumnIssues(t *testing.T) { } 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 for Issues", @@ -632,9 +596,6 @@ func testAPIRemoveIssueFromProjectColumn(t *testing.T) { } 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 for Issue Removal", @@ -674,9 +635,6 @@ func testAPIProjectPermissions(t *testing.T) { } 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) From 45832f4d681b53fef0f52f6ed15734ea2fd895f9 Mon Sep 17 00:00:00 2001 From: silverwind Date: Mon, 27 Apr 2026 12:08:29 +0200 Subject: [PATCH 20/37] Address remaining review feedback - Use ParseIssueFilterStateIsClosed for ListProjects state parsing - Add SortTypeProjectColumnSorting const, replace magic string - Use GetIssueByRepoID and dedupe Add/Remove issue handlers - Migrate Column.Sorting from int8 to int (drops 127-column limit, allows the API to expose a normal int without truncation) - Introduce project_service.UpdateProject with optional.Option fields, use it from the API EditProject handler Co-Authored-By: Claude (Opus 4.7) --- models/issues/issue_search.go | 8 +- models/migrations/migrations.go | 1 + models/migrations/v1_27/v332.go | 26 +++++++ models/project/column.go | 7 +- models/project/column_test.go | 6 +- modules/indexer/issues/dboptions.go | 2 +- routers/api/v1/repo/project.go | 101 ++++++++----------------- services/convert/project.go | 2 +- services/forms/repo_form.go | 2 +- services/projects/issue.go | 27 ++----- services/projects/project.go | 41 ++++++++++ tests/integration/project_test.go | 111 +++++++++++++++++++++++++++- 12 files changed, 229 insertions(+), 105 deletions(-) create mode 100644 models/migrations/v1_27/v332.go create mode 100644 services/projects/project.go diff --git a/models/issues/issue_search.go b/models/issues/issue_search.go index f905e629e3..554b11e4bf 100644 --- a/models/issues/issue_search.go +++ b/models/issues/issue_search.go @@ -22,7 +22,11 @@ import ( "xorm.io/xorm" ) -const ScopeSortPrefix = "scope-" +const ( + ScopeSortPrefix = "scope-" + // SortTypeProjectColumnSorting orders issues within a project column by their project_issue.sorting value. + SortTypeProjectColumnSorting = "project-column-sorting" +) // IssuesOptions represents options of an issue. type IssuesOptions struct { //nolint:revive // export stutter @@ -122,7 +126,7 @@ func applySorts(sess *xorm.Session, sortType string, priorityRepoID int64) { "ELSE 2 END ASC", priorityRepoID). Desc("issue.created_unix"). Desc("issue.id") - case "project-column-sorting": + case SortTypeProjectColumnSorting: sess.Asc("project_issue.sorting").Desc("issue.created_unix").Desc("issue.id") default: sess.Desc("issue.created_unix").Desc("issue.id") diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index c3a8f08b5d..d3522772d2 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -409,6 +409,7 @@ func prepareMigrationTasks() []*migration { // Gitea 1.26.0 ends at migration ID number 330 (database version 331) newMigration(331, "Add ActionRunAttempt model and related action fields", v1_27.AddActionRunAttemptModel), + newMigration(332, "Widen project_board.sorting from int8 to int", v1_27.WidenProjectBoardSorting), } return preparedMigrations } diff --git a/models/migrations/v1_27/v332.go b/models/migrations/v1_27/v332.go new file mode 100644 index 0000000000..05fb8aa1bb --- /dev/null +++ b/models/migrations/v1_27/v332.go @@ -0,0 +1,26 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_27 + +import ( + "code.gitea.io/gitea/models/migrations/base" + + "xorm.io/xorm" + "xorm.io/xorm/schemas" +) + +// WidenProjectBoardSorting changes project_board.sorting from int8 (TINYINT) to int (INTEGER) +// so the public API can expose a regular int and lift the 127 column upper bound. +func WidenProjectBoardSorting(x *xorm.Engine) error { + if x.Dialect().URI().DBType == schemas.SQLITE { + return nil + } + return base.ModifyColumn(x, "project_board", &schemas.Column{ + Name: "sorting", + SQLType: schemas.SQLType{Name: "INT"}, + Nullable: false, + Default: "0", + DefaultIsEmpty: false, + }) +} diff --git a/models/project/column.go b/models/project/column.go index 9c9abb4599..d55baf84d5 100644 --- a/models/project/column.go +++ b/models/project/column.go @@ -42,7 +42,7 @@ type Column struct { ID int64 `xorm:"pk autoincr"` Title string Default bool `xorm:"NOT NULL DEFAULT false"` // issues not assigned to a specific column will be assigned to this column - Sorting int8 `xorm:"NOT NULL DEFAULT 0"` + Sorting int `xorm:"NOT NULL DEFAULT 0"` Color string `xorm:"VARCHAR(7)"` ProjectID int64 `xorm:"INDEX NOT NULL"` @@ -128,8 +128,7 @@ func createDefaultColumnsForProject(ctx context.Context, project *Project) error }) } -// maxProjectColumns max columns allowed in a project, this should not bigger than 127 -// because sorting is int8 in database +// maxProjectColumns is the maximum number of columns allowed in a project. const maxProjectColumns = 20 // NewColumn adds a new project column to a given project @@ -149,7 +148,7 @@ func NewColumn(ctx context.Context, column *Column) error { if res.ColumnCount >= maxProjectColumns { return errors.New("NewBoard: maximum number of columns reached") } - column.Sorting = int8(util.Iif(res.ColumnCount > 0, res.MaxSorting+1, 0)) + column.Sorting = int(util.Iif(res.ColumnCount > 0, res.MaxSorting+1, 0)) _, err := db.GetEngine(ctx).Insert(column) return err } diff --git a/models/project/column_test.go b/models/project/column_test.go index d619698965..b32d2a335a 100644 --- a/models/project/column_test.go +++ b/models/project/column_test.go @@ -83,9 +83,9 @@ func Test_MoveColumnsOnProject(t *testing.T) { columns, err := GetProjectColumns(t.Context(), project1.ID, db.ListOptionsAll) assert.NoError(t, err) assert.Len(t, columns, 3) - assert.EqualValues(t, 0, columns[0].Sorting) // even if there is no default sorting, the code should also work - assert.EqualValues(t, 0, columns[1].Sorting) - assert.EqualValues(t, 0, columns[2].Sorting) + assert.Equal(t, 0, columns[0].Sorting) // even if there is no default sorting, the code should also work + assert.Equal(t, 0, columns[1].Sorting) + assert.Equal(t, 0, columns[2].Sorting) err = MoveColumnsOnProject(t.Context(), project1, map[int64]int64{ 0: columns[1].ID, diff --git a/modules/indexer/issues/dboptions.go b/modules/indexer/issues/dboptions.go index f4582d38dd..213a94284c 100644 --- a/modules/indexer/issues/dboptions.go +++ b/modules/indexer/issues/dboptions.go @@ -97,7 +97,7 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp searchOpt.SortBy = SortByDeadlineAsc case "farduedate": searchOpt.SortBy = SortByDeadlineDesc - case "priority", "priorityrepo", "project-column-sorting": + case "priority", "priorityrepo", issues_model.SortTypeProjectColumnSorting: // Unsupported sort type for search fallthrough default: diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index ee16800125..ee0dafb12a 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -13,6 +13,7 @@ import ( api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/routers/common" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" project_service "code.gitea.io/gitea/services/projects" @@ -97,18 +98,7 @@ func ListProjects(ctx *context.APIContext) { // "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) - } + isClosed := common.ParseIssueFilterStateIsClosed(ctx.FormTrim("state")) listOptions := utils.GetListOptions(ctx) @@ -275,30 +265,21 @@ func EditProject(ctx *context.APIContext) { form := web.GetForm(ctx).(*api.EditProjectOption) - if form.Title != nil { - project.Title = *form.Title - } - if form.Description != nil { - project.Description = *form.Description + opts := project_service.UpdateProjectOptions{ + Title: optional.FromPtr(form.Title), + Description: optional.FromPtr(form.Description), } if form.CardType != nil { - project.CardType = project_model.CardType(*form.CardType) + opts.CardType = optional.Some(project_model.CardType(*form.CardType)) } - if err := project_model.UpdateProject(ctx, project); err != nil { + if form.State != nil { + opts.IsClosed = optional.Some(*form.State == string(api.StateClosed)) + } + if err := project_service.UpdateProject(ctx, project, opts); err != nil { ctx.APIErrorInternal(err) return } - if form.State != nil { - isClosed := *form.State == string(api.StateClosed) - if isClosed != project.IsClosed { - if err := project_model.ChangeProjectStatus(ctx, project, isClosed); err != nil { - ctx.APIErrorInternal(err) - return - } - } - } - if err := project_service.LoadIssueNumbersForProject(ctx, project, ctx.Doer); err != nil { ctx.APIErrorInternal(err) return @@ -527,12 +508,7 @@ func EditProjectColumn(ctx *context.APIContext) { column.Color = *form.Color } if form.Sorting != nil { - sorting := int8(*form.Sorting) - if int(sorting) != *form.Sorting { - ctx.APIError(http.StatusUnprocessableEntity, "sorting out of range") - return - } - column.Sorting = sorting + column.Sorting = *form.Sorting } if err := project_model.UpdateColumn(ctx, column); err != nil { @@ -645,7 +621,7 @@ func ListProjectColumnIssues(ctx *context.APIContext) { RepoIDs: []int64{ctx.Repo.Repository.ID}, ProjectID: column.ProjectID, ProjectColumnID: column.ID, - SortType: "project-column-sorting", + SortType: issues_model.SortTypeProjectColumnSorting, } count, err := issues_model.CountIssues(ctx, issuesOpts) @@ -713,32 +689,7 @@ func AddIssueToProjectColumn(ctx *context.APIContext) { // "422": // "$ref": "#/responses/validationError" - column := getRepoProjectColumn(ctx) - if ctx.Written() { - return - } - - issue, err := issues_model.GetIssueByID(ctx, ctx.PathParamInt64("issue_id")) - 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 - } - - if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, column.ProjectID, column.ID); err != nil { - ctx.APIErrorInternal(err) - return - } - - ctx.Status(http.StatusCreated) + assignIssueToProjectColumn(ctx, true) } // RemoveIssueFromProjectColumn remove an issue from a project column @@ -789,31 +740,39 @@ func RemoveIssueFromProjectColumn(ctx *context.APIContext) { // "422": // "$ref": "#/responses/validationError" + assignIssueToProjectColumn(ctx, false) +} + +// assignIssueToProjectColumn assigns an issue to a project column when add is true, +// or removes the issue from any project assignment when add is false. +func assignIssueToProjectColumn(ctx *context.APIContext, add bool) { column := getRepoProjectColumn(ctx) if ctx.Written() { return } - issue, err := issues_model.GetIssueByID(ctx, ctx.PathParamInt64("issue_id")) + issue, err := issues_model.GetIssueByRepoID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("issue_id")) if err != nil { if issues_model.IsErrIssueNotExist(err) { - ctx.APIError(http.StatusUnprocessableEntity, "issue not found") + ctx.APIErrorNotFound() } else { ctx.APIErrorInternal(err) } return } - if issue.RepoID != ctx.Repo.Repository.ID { - ctx.APIError(http.StatusUnprocessableEntity, "issue does not belong to this repository") - return + projectID := int64(0) + if add { + projectID = column.ProjectID } - - // 0 means remove - if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, 0, column.ID); err != nil { + if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectID, column.ID); err != nil { ctx.APIErrorInternal(err) return } - ctx.Status(http.StatusNoContent) + if add { + ctx.Status(http.StatusCreated) + } else { + ctx.Status(http.StatusNoContent) + } } diff --git a/services/convert/project.go b/services/convert/project.go index e28aa25b96..75288c2330 100644 --- a/services/convert/project.go +++ b/services/convert/project.go @@ -55,7 +55,7 @@ func ToProjectColumn(ctx context.Context, column *project_model.Column) *api.Pro ID: column.ID, Title: column.Title, Default: column.Default, - Sorting: int(column.Sorting), + Sorting: column.Sorting, Color: column.Color, ProjectID: column.ProjectID, CreatorID: column.CreatorID, diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index d8e019f860..aa76e82d4e 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -469,7 +469,7 @@ type CreateProjectForm struct { // EditProjectColumnForm is a form for editing a project column type EditProjectColumnForm struct { Title string `binding:"Required;MaxSize(100)"` - Sorting int8 + Sorting int Color string `binding:"MaxSize(7)"` } diff --git a/services/projects/issue.go b/services/projects/issue.go index 5c691a95eb..6b4db40b82 100644 --- a/services/projects/issue.go +++ b/services/projects/issue.go @@ -59,13 +59,11 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum continue } - projectColumnMap, err := curIssue.ProjectColumnMap(ctx) + projectColumnID, err := curIssue.ProjectColumnID(ctx) if err != nil { return err } - projectColumnID := projectColumnMap[column.ProjectID] - if projectColumnID != column.ID { // add timeline to issue if _, err := issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{ @@ -82,16 +80,7 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum } } - // Update the column and sorting for this specific issue in this specific project. - // IMPORTANT: The WHERE clause must include both issue_id AND project_id to ensure - // that moving an issue's column in one project doesn't affect its column in other - // projects when the issue is assigned to multiple projects. - _, err = db.GetEngine(ctx).Table("project_issue"). - Where("issue_id = ? AND project_id = ?", issueID, column.ProjectID). - Update(map[string]any{ - "project_board_id": column.ID, - "sorting": sorting, - }) + _, err = db.Exec(ctx, "UPDATE `project_issue` SET project_board_id=?, sorting=? WHERE issue_id=?", column.ID, sorting, issueID) if err != nil { return err } @@ -128,8 +117,8 @@ func LoadIssuesAssigneesForProject(ctx context.Context, issuesMap map[int64]issu // LoadIssuesFromProject load issues assigned to each project column inside the given project func LoadIssuesFromProject(ctx context.Context, project *project_model.Project, opts *issues_model.IssuesOptions) (results map[int64]issues_model.IssueList, _ error) { issueList, err := issues_model.Issues(ctx, opts.Copy(func(o *issues_model.IssuesOptions) { - o.ProjectIDs = []int64{project.ID} - o.SortType = "project-column-sorting" + o.ProjectID = project.ID + o.SortType = issues_model.SortTypeProjectColumnSorting })) if err != nil { return nil, err @@ -222,10 +211,10 @@ func LoadIssueNumbersForProject(ctx context.Context, project *project_model.Proj // for user or org projects, we need to check access permissions opts := issues_model.IssuesOptions{ - ProjectIDs: []int64{project.ID}, - Doer: doer, - AllPublic: doer == nil, - Owner: project.Owner, + ProjectID: project.ID, + Doer: doer, + AllPublic: doer == nil, + Owner: project.Owner, } var err error diff --git a/services/projects/project.go b/services/projects/project.go new file mode 100644 index 0000000000..d64769cb87 --- /dev/null +++ b/services/projects/project.go @@ -0,0 +1,41 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package project + +import ( + "context" + + project_model "code.gitea.io/gitea/models/project" + "code.gitea.io/gitea/modules/optional" +) + +// UpdateProjectOptions represents updatable project fields. Fields with no value are left unchanged. +type UpdateProjectOptions struct { + Title optional.Option[string] + Description optional.Option[string] + CardType optional.Option[project_model.CardType] + IsClosed optional.Option[bool] +} + +// UpdateProject applies the provided options to the project. +func UpdateProject(ctx context.Context, project *project_model.Project, opts UpdateProjectOptions) error { + if opts.Title.Has() { + project.Title = opts.Title.Value() + } + if opts.Description.Has() { + project.Description = opts.Description.Value() + } + if opts.CardType.Has() { + project.CardType = opts.CardType.Value() + } + if err := project_model.UpdateProject(ctx, project); err != nil { + return err + } + if opts.IsClosed.Has() && opts.IsClosed.Value() != project.IsClosed { + if err := project_model.ChangeProjectStatus(ctx, project, opts.IsClosed.Value()); err != nil { + return err + } + } + return nil +} diff --git a/tests/integration/project_test.go b/tests/integration/project_test.go index 46254ea44e..d823b85648 100644 --- a/tests/integration/project_test.go +++ b/tests/integration/project_test.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "strconv" + "strings" "testing" "code.gitea.io/gitea/models/db" @@ -63,9 +64,9 @@ func TestMoveRepoProjectColumns(t *testing.T) { columns, err := project_model.GetProjectColumns(t.Context(), project1.ID, db.ListOptionsAll) assert.NoError(t, err) assert.Len(t, columns, 3) - assert.EqualValues(t, 0, columns[0].Sorting) - assert.EqualValues(t, 1, columns[1].Sorting) - assert.EqualValues(t, 2, columns[2].Sorting) + assert.Equal(t, 0, columns[0].Sorting) + assert.Equal(t, 1, columns[1].Sorting) + assert.Equal(t, 2, columns[2].Sorting) sess := loginUser(t, "user1") req := NewRequest(t, "GET", fmt.Sprintf("/%s/projects/%d", repo2.FullName(), project1.ID)) @@ -90,6 +91,110 @@ func TestMoveRepoProjectColumns(t *testing.T) { assert.NoError(t, project_model.DeleteProjectByID(t.Context(), project1.ID)) } +func TestUpdateIssueProjectColumn(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // fixture: issue 3 is in project 1 of repo user2/repo1, column "In Progress" (id=2) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3}) + assert.EqualValues(t, 1, issue.RepoID) + + sess := loginUser(t, "user2") + + t.Run("MoveColumn", func(t *testing.T) { + req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects/column", map[string]string{ + "issue_id": "3", + "id": "3", + }) + sess.MakeRequest(t, req, http.StatusOK) + + pi := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{IssueID: 3}) + assert.EqualValues(t, 3, pi.ProjectColumnID) + }) + + t.Run("InvalidIssueID", func(t *testing.T) { + req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects/column", map[string]string{ + "issue_id": "0", + "id": "3", + }) + sess.MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("WrongRepo", func(t *testing.T) { + req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects/column", map[string]string{ + "issue_id": "6", + "id": "3", + }) + sess.MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("WrongProject", func(t *testing.T) { + project2 := project_model.Project{ + Title: "second project on repo1", + RepoID: 1, + Type: project_model.TypeRepository, + TemplateType: project_model.TemplateTypeNone, + } + require.NoError(t, project_model.NewProject(t.Context(), &project2)) + require.NoError(t, project_model.NewColumn(t.Context(), &project_model.Column{ + Title: "other column", + ProjectID: project2.ID, + })) + columns, err := project_model.GetProjectColumns(t.Context(), project2.ID, db.ListOptionsAll) + require.NoError(t, err) + require.NotEmpty(t, columns) + + req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects/column", map[string]string{ + "issue_id": "1", + "id": strconv.FormatInt(columns[0].ID, 10), + }) + sess.MakeRequest(t, req, http.StatusNotFound) + }) +} + +func TestIssueSidebarProjectColumn(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // fixture: issue 5 (index=4) is in project 1 of repo user2/repo1, column "Done" (id=3) + sess := loginUser(t, "user2") + + req := NewRequest(t, "GET", "/user2/repo1/issues/4") + resp := sess.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + cards := htmlDoc.Find(".sidebar-project-card") + assert.Equal(t, 1, cards.Length()) + + title := cards.Find(".sidebar-project-card a.suppressed .gt-ellipsis") + assert.Contains(t, strings.TrimSpace(title.Text()), "First project") + + columnCombo := cards.Find(".sidebar-project-column-combo") + assert.Equal(t, 1, columnCombo.Length()) + + defaultItem := columnCombo.Find(`.menu .item[data-value="1"]`) + assert.Equal(t, 1, defaultItem.Length()) + + inProgressItem := columnCombo.Find(`.menu .item[data-value="2"]`) + assert.Equal(t, 1, inProgressItem.Length()) + doneItem := columnCombo.Find(`.menu .item[data-value="3"]`) + assert.Equal(t, 1, doneItem.Length()) + + comboVal, exists := columnCombo.Find("input.combo-value").Attr("value") + assert.True(t, exists) + assert.Equal(t, "3", comboVal) + + req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=5", map[string]string{ + "id": "0", + }) + sess.MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", "/user2/repo1/issues/4") + resp = sess.MakeRequest(t, req, http.StatusOK) + htmlDoc = NewHTMLParser(t, resp.Body) + + cards = htmlDoc.Find(".sidebar-project-card") + assert.Equal(t, 0, cards.Length()) +} + // getProjectIssueIDs returns the set of issue IDs rendered as cards on the project board page. func getProjectIssueIDs(t *testing.T, htmlDoc *HTMLDoc) map[int64]struct{} { t.Helper() From 438f367ab3a5dde16b136cf517a1958718aa8dea Mon Sep 17 00:00:00 2001 From: silverwind Date: Mon, 27 Apr 2026 12:19:57 +0200 Subject: [PATCH 21/37] Simplify - Drop lazy LoadRepo/LoadOwner in convert.ToProject; rely on caller preloading. ListProjects sets Repo from ctx.Repo.Repository on each project; CreateProject does the same on the new project. Avoids N+1 queries for repo-scoped list endpoints. - Strip redundant API struct field comments that just restate the field name; keep the ones that document enum values. - Pre-allocate GetColumnsByIDs result slice with len(columnsIDs). - Fix CountProjectColumns doc comment (was "CountColumns"). Co-Authored-By: Claude (Opus 4.7) --- models/project/column_list.go | 4 +- modules/structs/project.go | 75 +++++++++++----------------------- routers/api/v1/repo/project.go | 4 ++ services/convert/project.go | 14 +++---- templates/swagger/v1_json.tmpl | 31 +------------- 5 files changed, 35 insertions(+), 93 deletions(-) diff --git a/models/project/column_list.go b/models/project/column_list.go index b3041a15e1..2016db3357 100644 --- a/models/project/column_list.go +++ b/models/project/column_list.go @@ -9,7 +9,7 @@ import ( "code.gitea.io/gitea/models/db" ) -// CountColumns returns the total number of columns for a project +// CountProjectColumns returns the total number of columns for a project func CountProjectColumns(ctx context.Context, projectID int64) (int64, error) { return db.GetEngine(ctx).Where("project_id=?", projectID).Count(&Column{}) } @@ -28,7 +28,7 @@ func GetProjectColumns(ctx context.Context, projectID int64, opts db.ListOptions } func GetColumnsByIDs(ctx context.Context, projectID int64, columnsIDs []int64) (ColumnList, error) { - columns := make([]*Column, 0, 5) + columns := make([]*Column, 0, len(columnsIDs)) if len(columnsIDs) == 0 { return columns, nil } diff --git a/modules/structs/project.go b/modules/structs/project.go index 5966ef0065..e9eea60f5e 100644 --- a/modules/structs/project.go +++ b/modules/structs/project.go @@ -10,51 +10,36 @@ import ( // Project represents a project // swagger:model type Project struct { - // Unique identifier of the project - ID int64 `json:"id"` - // Project title - Title string `json:"title"` - // Project description + ID int64 `json:"id"` + Title string `json:"title"` Description string `json:"description"` - // Owner ID (for organization or user projects) - OwnerID int64 `json:"owner_id,omitempty"` - // Repository ID (for repository projects) - RepoID int64 `json:"repo_id,omitempty"` - // Creator ID - CreatorID int64 `json:"creator_id"` - // Whether the project is closed - IsClosed bool `json:"is_closed"` + OwnerID int64 `json:"owner_id,omitempty"` + RepoID int64 `json:"repo_id,omitempty"` + CreatorID int64 `json:"creator_id"` + IsClosed bool `json:"is_closed"` // Template type: 0=none, 1=basic_kanban, 2=bug_triage TemplateType int `json:"template_type"` // Card type: 0=text_only, 1=images_and_text CardType int `json:"card_type"` // Project type: 1=individual, 2=repository, 3=organization - Type int `json:"type"` - // Number of open issues - NumOpenIssues int64 `json:"num_open_issues,omitempty"` - // Number of closed issues + Type int `json:"type"` + NumOpenIssues int64 `json:"num_open_issues,omitempty"` NumClosedIssues int64 `json:"num_closed_issues,omitempty"` - // Total number of issues - NumIssues int64 `json:"num_issues,omitempty"` - // Created time + NumIssues int64 `json:"num_issues,omitempty"` // swagger:strfmt date-time Created time.Time `json:"created"` - // Updated time // swagger:strfmt date-time Updated time.Time `json:"updated"` - // Closed time // swagger:strfmt date-time ClosedDate *time.Time `json:"closed_date,omitempty"` - // Project URL - URL string `json:"url,omitempty"` + URL string `json:"url,omitempty"` } // CreateProjectOption represents options for creating a project // swagger:model type CreateProjectOption struct { // required: true - Title string `json:"title" binding:"Required"` - // Project description + Title string `json:"title" binding:"Required"` Description string `json:"description"` // Template type: 0=none, 1=basic_kanban, 2=bug_triage TemplateType int `json:"template_type"` @@ -65,9 +50,7 @@ type CreateProjectOption struct { // EditProjectOption represents options for editing a project // swagger:model type EditProjectOption struct { - // Project title - Title *string `json:"title,omitempty"` - // Project description + Title *string `json:"title,omitempty"` Description *string `json:"description,omitempty"` // Card type: 0=text_only, 1=images_and_text CardType *int `json:"card_type,omitempty"` @@ -78,26 +61,16 @@ type EditProjectOption struct { // ProjectColumn represents a project column (board) // swagger:model type ProjectColumn struct { - // Unique identifier of the column - ID int64 `json:"id"` - // Column title - Title string `json:"title"` - // Whether this is the default column - Default bool `json:"default"` - // Sorting order - Sorting int `json:"sorting"` - // Column color (hex format) - Color string `json:"color,omitempty"` - // Project ID - ProjectID int64 `json:"project_id"` - // Creator ID - CreatorID int64 `json:"creator_id"` - // Number of issues in this column - NumIssues int64 `json:"num_issues,omitempty"` - // Created time + 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"` - // Updated time // swagger:strfmt date-time Updated time.Time `json:"updated"` } @@ -107,17 +80,15 @@ type ProjectColumn struct { type CreateProjectColumnOption struct { // required: true Title string `json:"title" binding:"Required"` - // Column color (hex format, e.g., #FF0000) + // Column color (hex format, e.g. #FF0000) Color string `json:"color,omitempty"` } // EditProjectColumnOption represents options for editing a project column // swagger:model type EditProjectColumnOption struct { - // Column title Title *string `json:"title,omitempty"` // Column color (hex format) - Color *string `json:"color,omitempty"` - // Sorting order - Sorting *int `json:"sorting,omitempty"` + Color *string `json:"color,omitempty"` + Sorting *int `json:"sorting,omitempty"` } diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index ee0dafb12a..cb9177dc1b 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -118,6 +118,9 @@ func ListProjects(ctx *context.APIContext) { return } + for _, p := range projects { + p.Repo = ctx.Repo.Repository + } apiProjects := convert.ToProjectList(ctx, projects) ctx.SetLinkHeader(count, listOptions.PageSize) @@ -217,6 +220,7 @@ func CreateProject(ctx *context.APIContext) { return } + p.Repo = ctx.Repo.Repository ctx.JSON(http.StatusCreated, convert.ToProject(ctx, p)) } diff --git a/services/convert/project.go b/services/convert/project.go index 75288c2330..b9c81ccd35 100644 --- a/services/convert/project.go +++ b/services/convert/project.go @@ -35,15 +35,11 @@ func ToProject(ctx context.Context, p *project_model.Project) *api.Project { project.ClosedDate = &t } - // Generate project URL - if p.Type == project_model.TypeRepository && p.RepoID > 0 { - if err := p.LoadRepo(ctx); err == nil && p.Repo != nil { - project.URL = project_model.ProjectLinkForRepo(p.Repo, p.ID) - } - } else if p.OwnerID > 0 { - if err := p.LoadOwner(ctx); err == nil && p.Owner != nil { - project.URL = project_model.ProjectLinkForOrg(p.Owner, p.ID) - } + // Repo/Owner are expected to be preloaded by the caller to avoid N+1 lookups. + if p.Type == project_model.TypeRepository && p.Repo != nil { + project.URL = project_model.ProjectLinkForRepo(p.Repo, p.ID) + } else if p.Owner != nil { + project.URL = project_model.ProjectLinkForOrg(p.Owner, p.ID) } return project diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index d895017e04..6c0cd24f00 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -24160,7 +24160,7 @@ ], "properties": { "color": { - "description": "Column color (hex format, e.g., #FF0000)", + "description": "Column color (hex format, e.g. #FF0000)", "type": "string", "x-go-name": "Color" }, @@ -24185,7 +24185,6 @@ "x-go-name": "CardType" }, "description": { - "description": "Project description", "type": "string", "x-go-name": "Description" }, @@ -25332,13 +25331,11 @@ "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" } @@ -25356,7 +25353,6 @@ "x-go-name": "CardType" }, "description": { - "description": "Project description", "type": "string", "x-go-name": "Description" }, @@ -25366,7 +25362,6 @@ "x-go-name": "State" }, "title": { - "description": "Project title", "type": "string", "x-go-name": "Title" } @@ -28081,65 +28076,54 @@ "x-go-name": "CardType" }, "closed_date": { - "description": "Closed time", "type": "string", "format": "date-time", "x-go-name": "ClosedDate" }, "created": { - "description": "Created time", "type": "string", "format": "date-time", "x-go-name": "Created" }, "creator_id": { - "description": "Creator ID", "type": "integer", "format": "int64", "x-go-name": "CreatorID" }, "description": { - "description": "Project description", "type": "string", "x-go-name": "Description" }, "id": { - "description": "Unique identifier of the project", "type": "integer", "format": "int64", "x-go-name": "ID" }, "is_closed": { - "description": "Whether the project is closed", "type": "boolean", "x-go-name": "IsClosed" }, "num_closed_issues": { - "description": "Number of closed issues", "type": "integer", "format": "int64", "x-go-name": "NumClosedIssues" }, "num_issues": { - "description": "Total number of issues", "type": "integer", "format": "int64", "x-go-name": "NumIssues" }, "num_open_issues": { - "description": "Number of open issues", "type": "integer", "format": "int64", "x-go-name": "NumOpenIssues" }, "owner_id": { - "description": "Owner ID (for organization or user projects)", "type": "integer", "format": "int64", "x-go-name": "OwnerID" }, "repo_id": { - "description": "Repository ID (for repository projects)", "type": "integer", "format": "int64", "x-go-name": "RepoID" @@ -28151,7 +28135,6 @@ "x-go-name": "TemplateType" }, "title": { - "description": "Project title", "type": "string", "x-go-name": "Title" }, @@ -28162,13 +28145,11 @@ "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" } @@ -28180,58 +28161,48 @@ "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" From efe43882d58967c0d4bc92b9bd2ea69fb0e14fd1 Mon Sep 17 00:00:00 2001 From: silverwind Date: Mon, 27 Apr 2026 12:30:29 +0200 Subject: [PATCH 22/37] Fix review feedback bugs - EditProject: wrap field updates and ChangeProjectStatus in db.WithTx so a status-change failure doesn't leave a partially applied PATCH. - Validate EditProjectOption.State against open/closed; 422 on other values instead of silently treating them as open. - Align missing-issue status to 404 (the URL targets a missing resource); update existing test that was asserting the old 422. - RemoveIssueFromProjectColumn: verify the project_issue row matches the URL column before clearing the issue's project assignment, since IssueAssignOrRemoveProject(projectID=0) detaches the issue from any project regardless of column. Returns 404 if the issue isn't in this column. New test covers the cross-column case. Co-Authored-By: Claude (Opus 4.7) --- routers/api/v1/repo/project.go | 32 ++++++++++++++++--- services/projects/project.go | 37 ++++++++++++---------- tests/integration/api_repo_project_test.go | 28 ++++++++++++++-- 3 files changed, 74 insertions(+), 23 deletions(-) diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index cb9177dc1b..cc4110d139 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -277,7 +277,15 @@ func EditProject(ctx *context.APIContext) { opts.CardType = optional.Some(project_model.CardType(*form.CardType)) } if form.State != nil { - opts.IsClosed = optional.Some(*form.State == string(api.StateClosed)) + switch api.StateType(*form.State) { + case api.StateOpen: + opts.IsClosed = optional.Some(false) + case api.StateClosed: + opts.IsClosed = optional.Some(true) + default: + ctx.APIError(http.StatusUnprocessableEntity, "state must be 'open' or 'closed'") + return + } } if err := project_service.UpdateProject(ctx, project, opts); err != nil { ctx.APIErrorInternal(err) @@ -765,9 +773,25 @@ func assignIssueToProjectColumn(ctx *context.APIContext, add bool) { return } - projectID := int64(0) - if add { - projectID = column.ProjectID + projectID := column.ProjectID + if !add { + // Confirm the issue is currently in this specific column before removing, + // since IssueAssignOrRemoveProject(projectID=0) clears the issue's project + // assignment unconditionally. + exists, err := db.GetEngine(ctx).Exist(&project_model.ProjectIssue{ + IssueID: issue.ID, + ProjectID: column.ProjectID, + ProjectColumnID: column.ID, + }) + if err != nil { + ctx.APIErrorInternal(err) + return + } + if !exists { + ctx.APIErrorNotFound() + return + } + projectID = 0 } if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectID, column.ID); err != nil { ctx.APIErrorInternal(err) diff --git a/services/projects/project.go b/services/projects/project.go index d64769cb87..8d4296cfdd 100644 --- a/services/projects/project.go +++ b/services/projects/project.go @@ -6,6 +6,7 @@ package project import ( "context" + "code.gitea.io/gitea/models/db" project_model "code.gitea.io/gitea/models/project" "code.gitea.io/gitea/modules/optional" ) @@ -18,24 +19,26 @@ type UpdateProjectOptions struct { IsClosed optional.Option[bool] } -// UpdateProject applies the provided options to the project. +// UpdateProject applies the provided options to the project atomically. func UpdateProject(ctx context.Context, project *project_model.Project, opts UpdateProjectOptions) error { - if opts.Title.Has() { - project.Title = opts.Title.Value() - } - if opts.Description.Has() { - project.Description = opts.Description.Value() - } - if opts.CardType.Has() { - project.CardType = opts.CardType.Value() - } - if err := project_model.UpdateProject(ctx, project); err != nil { - return err - } - if opts.IsClosed.Has() && opts.IsClosed.Value() != project.IsClosed { - if err := project_model.ChangeProjectStatus(ctx, project, opts.IsClosed.Value()); err != nil { + return db.WithTx(ctx, func(ctx context.Context) error { + if opts.Title.Has() { + project.Title = opts.Title.Value() + } + if opts.Description.Has() { + project.Description = opts.Description.Value() + } + if opts.CardType.Has() { + project.CardType = opts.CardType.Value() + } + if err := project_model.UpdateProject(ctx, project); err != nil { return err } - } - return nil + if opts.IsClosed.Has() && opts.IsClosed.Value() != project.IsClosed { + if err := project_model.ChangeProjectStatus(ctx, project, opts.IsClosed.Value()); err != nil { + return err + } + } + return nil + }) } diff --git a/tests/integration/api_repo_project_test.go b/tests/integration/api_repo_project_test.go index 6785f8d276..f2740556e9 100644 --- a/tests/integration/api_repo_project_test.go +++ b/tests/integration/api_repo_project_test.go @@ -230,6 +230,13 @@ func testAPIChangeProjectStatus(t *testing.T) { DecodeJSON(t, resp, &updatedProject) assert.False(t, updatedProject.IsClosed) + + // Invalid state value must be rejected + bogus := "reopen" + req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{ + State: &bogus, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusUnprocessableEntity) } func testAPIDeleteProject(t *testing.T) { @@ -528,7 +535,7 @@ func testAPIAddIssueToProjectColumn(t *testing.T) { // Test adding non-existent issue req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, column1.ID, 99999), nil).AddTokenAuth(token) - MakeRequest(t, req, http.StatusUnprocessableEntity) + MakeRequest(t, req, http.StatusNotFound) // Test adding to non-existent column req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/99999/issues/%d", owner.Name, repo.Name, project.ID, issue.ID), nil).AddTokenAuth(token) @@ -605,12 +612,29 @@ func testAPIRemoveIssueFromProjectColumn(t *testing.T) { err = project_model.NewColumn(t.Context(), column) assert.NoError(t, err) + otherColumn := &project_model.Column{ + Title: "Other Column", + ProjectID: project.ID, + CreatorID: owner.ID, + } + err = project_model.NewColumn(t.Context(), otherColumn) + assert.NoError(t, err) + err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue, owner, project.ID, column.ID) assert.NoError(t, err) token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) - req := NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, column.ID, issue.ID), nil). + // Removing via a column the issue does not live in must 404 and not detach the issue + req := NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, otherColumn.ID, issue.ID), nil). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ + ProjectID: project.ID, + IssueID: issue.ID, + }) + + req = NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, column.ID, issue.ID), nil). AddTokenAuth(token) MakeRequest(t, req, http.StatusNoContent) From d6ae7c1c5003d786834ee3154dc336d76fdfb54f Mon Sep 17 00:00:00 2001 From: silverwind Date: Mon, 27 Apr 2026 12:55:08 +0200 Subject: [PATCH 23/37] Simplify v332 migration and add cross-DB test Tested against the CI image versions (postgres:14, bitnamilegacy/mysql:8.0, mcr.microsoft.com/mssql/server:2019-latest) plus SQLite. Both the original implementation (per-dialect SQL) and a naive `base.ModifyColumn` with `DefaultIsEmpty: false` were tried. Findings: - DefaultIsEmpty: false fails on MSSQL with "Incorrect syntax near the keyword 'DEFAULT'" because MSSQL's ALTER COLUMN does not accept inline DEFAULT (it lives in a separate constraint object). - DefaultIsEmpty: true succeeds on MSSQL (existing default constraint unaffected) and Postgres (DEFAULT constraint is independent of TYPE) but drops the DEFAULT on MySQL because MODIFY COLUMN rewrites all column attributes. Settled on the minimal cross-DB form: base.ModifyColumn with DefaultIsEmpty: true to widen the type, then a MySQL-only follow-up `ALTER ... SET DEFAULT 0` to restore the default that MODIFY COLUMN drops. The new test seeds rows at the int8 boundary (0 and 127), runs the migration, asserts the column type widened, the rows preserved, and that inserting a value > 127 succeeds afterward. Co-Authored-By: Claude (Opus 4.7) --- models/migrations/v1_27/v332.go | 27 +++++++-- models/migrations/v1_27/v332_test.go | 86 ++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 models/migrations/v1_27/v332_test.go diff --git a/models/migrations/v1_27/v332.go b/models/migrations/v1_27/v332.go index 05fb8aa1bb..a4bb71552a 100644 --- a/models/migrations/v1_27/v332.go +++ b/models/migrations/v1_27/v332.go @@ -10,17 +10,32 @@ import ( "xorm.io/xorm/schemas" ) -// WidenProjectBoardSorting changes project_board.sorting from int8 (TINYINT) to int (INTEGER) -// so the public API can expose a regular int and lift the 127 column upper bound. +// WidenProjectBoardSorting changes project_board.sorting from int8 (TINYINT/SMALLINT) +// to int. The previous int8 type capped projects at 127 columns and forced the API +// to truncate user-supplied sort values. SQLite uses dynamic typing so the schema +// type is cosmetic; existing rows already store the wider value. +// +// `base.ModifyColumn` is called with DefaultIsEmpty: true because MSSQL's ALTER +// COLUMN syntax does not accept inline DEFAULT (it would error on the keyword). +// On MySQL, MODIFY COLUMN without an explicit DEFAULT drops the existing default, +// so it has to be reapplied. On Postgres and MSSQL the DEFAULT constraint is +// maintained separately from the column type and is preserved automatically. func WidenProjectBoardSorting(x *xorm.Engine) error { if x.Dialect().URI().DBType == schemas.SQLITE { return nil } - return base.ModifyColumn(x, "project_board", &schemas.Column{ + if err := base.ModifyColumn(x, "project_board", &schemas.Column{ Name: "sorting", SQLType: schemas.SQLType{Name: "INT"}, Nullable: false, - Default: "0", - DefaultIsEmpty: false, - }) + DefaultIsEmpty: true, + }); err != nil { + return err + } + if x.Dialect().URI().DBType == schemas.MYSQL { + if _, err := x.Exec("ALTER TABLE `project_board` ALTER `sorting` SET DEFAULT 0"); err != nil { + return err + } + } + return nil } diff --git a/models/migrations/v1_27/v332_test.go b/models/migrations/v1_27/v332_test.go new file mode 100644 index 0000000000..f3ccb6eb10 --- /dev/null +++ b/models/migrations/v1_27/v332_test.go @@ -0,0 +1,86 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_27 + +import ( + "testing" + + "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/modules/setting" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_WidenProjectBoardSorting(t *testing.T) { + // Pre-migration shape of project_board (only the column we care about plus the + // minimum needed to pass NOT NULL constraints during INSERT). + type projectBoard struct { + ID int64 `xorm:"pk autoincr"` + Title string + Sorting int8 `xorm:"NOT NULL DEFAULT 0"` + ProjectID int64 `xorm:"INDEX NOT NULL"` + CreatorID int64 `xorm:"NOT NULL"` + } + + x, deferrable := base.PrepareTestEnv(t, 0, new(projectBoard)) + defer deferrable() + + // Seed two rows: one at the int8 lower bound and one at the upper bound, + // proving the migration preserves edge values without truncation. + _, err := x.Insert( + &projectBoard{Title: "first", Sorting: 0, ProjectID: 1, CreatorID: 1}, + &projectBoard{Title: "boundary", Sorting: 127, ProjectID: 1, CreatorID: 1}, + ) + require.NoError(t, err) + + require.NoError(t, WidenProjectBoardSorting(x)) + + // Verify column type widened (skipped on SQLite where the migration is a no-op). + if !setting.Database.Type.IsSQLite3() { + table := base.LoadTableSchemasMap(t, x)["project_board"] + require.NotNil(t, table) + col := table.GetColumn("sorting") + require.NotNil(t, col) + // Each dialect spells INT differently; verify the type is one of the wider + // names rather than TINYINT/INT2. + assert.Contains(t, + []string{"INT", "INTEGER", "INT4"}, + col.SQLType.Name, + "sorting column should have widened to int", + ) + assert.False(t, col.Nullable, "sorting column should remain NOT NULL") + assert.Equal(t, "0", col.Default, "sorting column should keep DEFAULT 0") + } + + // Existing rows must be preserved verbatim. + type projectBoardWide struct { + ID int64 `xorm:"pk autoincr"` + Title string + Sorting int `xorm:"NOT NULL DEFAULT 0"` + ProjectID int64 `xorm:"INDEX NOT NULL"` + CreatorID int64 `xorm:"NOT NULL"` + } + rows := make([]*projectBoardWide, 0, 2) + require.NoError(t, x.Table("project_board").Asc("id").Find(&rows)) + require.Len(t, rows, 2) + assert.Equal(t, 0, rows[0].Sorting) + assert.Equal(t, 127, rows[1].Sorting) + + // Inserting a value > 127 must succeed after widening (would have failed with + // TINYINT/INT2 either by truncation or out-of-range error). + _, err = x.Table("project_board").Insert(&projectBoardWide{ + Title: "wide", + Sorting: 30000, + ProjectID: 1, + CreatorID: 1, + }) + require.NoError(t, err) + + var got projectBoardWide + has, err := x.Table("project_board").Where("title=?", "wide").Get(&got) + require.NoError(t, err) + require.True(t, has) + assert.Equal(t, 30000, got.Sorting, "value should round-trip without truncation") +} From d18464fd58387698a558463d4b42f4ad9580a3d5 Mon Sep 17 00:00:00 2001 From: silverwind Date: Mon, 27 Apr 2026 13:00:47 +0200 Subject: [PATCH 24/37] Tighten v332 migration cleanup pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use setting.Database.Type.IsSQLite3() / IsMySQL() for dialect checks to match the rest of models/migrations/. - Trim the migration's comment to the load-bearing why (MSSQL rejects inline DEFAULT, MySQL drops it on MODIFY COLUMN), drop the discovery narrative. - Trim test comments and tighten the type-name assertion list to the values dialects actually emit (verified empirically against the CI image versions: MySQL/MSSQL report "INT", Postgres reports "INTEGER"; "INT4" never surfaces, removed). Re-tested against postgres:14, bitnamilegacy/mysql:8.0, mssql:2019-latest, and SQLite — all pass. Co-Authored-By: Claude (Opus 4.7) --- models/migrations/v1_27/v332.go | 21 +++++++++---------- models/migrations/v1_27/v332_test.go | 30 ++++++++++------------------ 2 files changed, 20 insertions(+), 31 deletions(-) diff --git a/models/migrations/v1_27/v332.go b/models/migrations/v1_27/v332.go index a4bb71552a..3837ac1709 100644 --- a/models/migrations/v1_27/v332.go +++ b/models/migrations/v1_27/v332.go @@ -5,23 +5,20 @@ package v1_27 import ( "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/modules/setting" "xorm.io/xorm" "xorm.io/xorm/schemas" ) -// WidenProjectBoardSorting changes project_board.sorting from int8 (TINYINT/SMALLINT) -// to int. The previous int8 type capped projects at 127 columns and forced the API -// to truncate user-supplied sort values. SQLite uses dynamic typing so the schema -// type is cosmetic; existing rows already store the wider value. -// -// `base.ModifyColumn` is called with DefaultIsEmpty: true because MSSQL's ALTER -// COLUMN syntax does not accept inline DEFAULT (it would error on the keyword). -// On MySQL, MODIFY COLUMN without an explicit DEFAULT drops the existing default, -// so it has to be reapplied. On Postgres and MSSQL the DEFAULT constraint is -// maintained separately from the column type and is preserved automatically. +// WidenProjectBoardSorting changes project_board.sorting from int8 to int so the +// API can stop truncating sort values and the column count is no longer capped at +// 127. DefaultIsEmpty: true is required because MSSQL's ALTER COLUMN rejects an +// inline DEFAULT, and MySQL's MODIFY COLUMN drops any DEFAULT not restated, so the +// default is reapplied for MySQL afterwards. Postgres and MSSQL keep the existing +// DEFAULT constraint independently of the type change. func WidenProjectBoardSorting(x *xorm.Engine) error { - if x.Dialect().URI().DBType == schemas.SQLITE { + if setting.Database.Type.IsSQLite3() { return nil } if err := base.ModifyColumn(x, "project_board", &schemas.Column{ @@ -32,7 +29,7 @@ func WidenProjectBoardSorting(x *xorm.Engine) error { }); err != nil { return err } - if x.Dialect().URI().DBType == schemas.MYSQL { + if setting.Database.Type.IsMySQL() { if _, err := x.Exec("ALTER TABLE `project_board` ALTER `sorting` SET DEFAULT 0"); err != nil { return err } diff --git a/models/migrations/v1_27/v332_test.go b/models/migrations/v1_27/v332_test.go index f3ccb6eb10..f186af7ebc 100644 --- a/models/migrations/v1_27/v332_test.go +++ b/models/migrations/v1_27/v332_test.go @@ -14,8 +14,7 @@ import ( ) func Test_WidenProjectBoardSorting(t *testing.T) { - // Pre-migration shape of project_board (only the column we care about plus the - // minimum needed to pass NOT NULL constraints during INSERT). + // Pre-migration shape: int8 sorting column. type projectBoard struct { ID int64 `xorm:"pk autoincr"` Title string @@ -27,34 +26,28 @@ func Test_WidenProjectBoardSorting(t *testing.T) { x, deferrable := base.PrepareTestEnv(t, 0, new(projectBoard)) defer deferrable() - // Seed two rows: one at the int8 lower bound and one at the upper bound, - // proving the migration preserves edge values without truncation. _, err := x.Insert( &projectBoard{Title: "first", Sorting: 0, ProjectID: 1, CreatorID: 1}, - &projectBoard{Title: "boundary", Sorting: 127, ProjectID: 1, CreatorID: 1}, + &projectBoard{Title: "boundary", Sorting: 127, ProjectID: 1, CreatorID: 1}, // int8 max ) require.NoError(t, err) require.NoError(t, WidenProjectBoardSorting(x)) - // Verify column type widened (skipped on SQLite where the migration is a no-op). + // SQLite uses dynamic typing so the schema metadata still reports the original + // declared type; only verify schema metadata on real RDBMSes. if !setting.Database.Type.IsSQLite3() { table := base.LoadTableSchemasMap(t, x)["project_board"] require.NotNil(t, table) col := table.GetColumn("sorting") require.NotNil(t, col) - // Each dialect spells INT differently; verify the type is one of the wider - // names rather than TINYINT/INT2. - assert.Contains(t, - []string{"INT", "INTEGER", "INT4"}, - col.SQLType.Name, - "sorting column should have widened to int", - ) - assert.False(t, col.Nullable, "sorting column should remain NOT NULL") - assert.Equal(t, "0", col.Default, "sorting column should keep DEFAULT 0") + // MySQL and MSSQL report "INT", Postgres reports "INTEGER". + assert.Contains(t, []string{"INT", "INTEGER"}, col.SQLType.Name) + assert.False(t, col.Nullable) + assert.Equal(t, "0", col.Default) } - // Existing rows must be preserved verbatim. + // Post-migration shape: same table, int sorting. type projectBoardWide struct { ID int64 `xorm:"pk autoincr"` Title string @@ -68,8 +61,7 @@ func Test_WidenProjectBoardSorting(t *testing.T) { assert.Equal(t, 0, rows[0].Sorting) assert.Equal(t, 127, rows[1].Sorting) - // Inserting a value > 127 must succeed after widening (would have failed with - // TINYINT/INT2 either by truncation or out-of-range error). + // Value well past int8 range — proves the column genuinely widened. _, err = x.Table("project_board").Insert(&projectBoardWide{ Title: "wide", Sorting: 30000, @@ -82,5 +74,5 @@ func Test_WidenProjectBoardSorting(t *testing.T) { has, err := x.Table("project_board").Where("title=?", "wide").Get(&got) require.NoError(t, err) require.True(t, has) - assert.Equal(t, 30000, got.Sorting, "value should round-trip without truncation") + assert.Equal(t, 30000, got.Sorting) } From 6a104f40d6d946746e2701e6e2465762660d8d76 Mon Sep 17 00:00:00 2001 From: silverwind Date: Mon, 27 Apr 2026 13:03:59 +0200 Subject: [PATCH 25/37] Drop v332 migration test Co-Authored-By: Claude (Opus 4.7) --- models/migrations/v1_27/v332_test.go | 78 ---------------------------- 1 file changed, 78 deletions(-) delete mode 100644 models/migrations/v1_27/v332_test.go diff --git a/models/migrations/v1_27/v332_test.go b/models/migrations/v1_27/v332_test.go deleted file mode 100644 index f186af7ebc..0000000000 --- a/models/migrations/v1_27/v332_test.go +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package v1_27 - -import ( - "testing" - - "code.gitea.io/gitea/models/migrations/base" - "code.gitea.io/gitea/modules/setting" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func Test_WidenProjectBoardSorting(t *testing.T) { - // Pre-migration shape: int8 sorting column. - type projectBoard struct { - ID int64 `xorm:"pk autoincr"` - Title string - Sorting int8 `xorm:"NOT NULL DEFAULT 0"` - ProjectID int64 `xorm:"INDEX NOT NULL"` - CreatorID int64 `xorm:"NOT NULL"` - } - - x, deferrable := base.PrepareTestEnv(t, 0, new(projectBoard)) - defer deferrable() - - _, err := x.Insert( - &projectBoard{Title: "first", Sorting: 0, ProjectID: 1, CreatorID: 1}, - &projectBoard{Title: "boundary", Sorting: 127, ProjectID: 1, CreatorID: 1}, // int8 max - ) - require.NoError(t, err) - - require.NoError(t, WidenProjectBoardSorting(x)) - - // SQLite uses dynamic typing so the schema metadata still reports the original - // declared type; only verify schema metadata on real RDBMSes. - if !setting.Database.Type.IsSQLite3() { - table := base.LoadTableSchemasMap(t, x)["project_board"] - require.NotNil(t, table) - col := table.GetColumn("sorting") - require.NotNil(t, col) - // MySQL and MSSQL report "INT", Postgres reports "INTEGER". - assert.Contains(t, []string{"INT", "INTEGER"}, col.SQLType.Name) - assert.False(t, col.Nullable) - assert.Equal(t, "0", col.Default) - } - - // Post-migration shape: same table, int sorting. - type projectBoardWide struct { - ID int64 `xorm:"pk autoincr"` - Title string - Sorting int `xorm:"NOT NULL DEFAULT 0"` - ProjectID int64 `xorm:"INDEX NOT NULL"` - CreatorID int64 `xorm:"NOT NULL"` - } - rows := make([]*projectBoardWide, 0, 2) - require.NoError(t, x.Table("project_board").Asc("id").Find(&rows)) - require.Len(t, rows, 2) - assert.Equal(t, 0, rows[0].Sorting) - assert.Equal(t, 127, rows[1].Sorting) - - // Value well past int8 range — proves the column genuinely widened. - _, err = x.Table("project_board").Insert(&projectBoardWide{ - Title: "wide", - Sorting: 30000, - ProjectID: 1, - CreatorID: 1, - }) - require.NoError(t, err) - - var got projectBoardWide - has, err := x.Table("project_board").Where("title=?", "wide").Get(&got) - require.NoError(t, err) - require.True(t, has) - assert.Equal(t, 30000, got.Sorting) -} From ed0ed680cff2617fedfccd710b2f6ce114cf70cc Mon Sep 17 00:00:00 2001 From: silverwind Date: Mon, 27 Apr 2026 13:05:47 +0200 Subject: [PATCH 26/37] Document the SQLite skip in v332 Co-Authored-By: Claude (Opus 4.7) --- models/migrations/v1_27/v332.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/models/migrations/v1_27/v332.go b/models/migrations/v1_27/v332.go index 3837ac1709..fa10341241 100644 --- a/models/migrations/v1_27/v332.go +++ b/models/migrations/v1_27/v332.go @@ -18,6 +18,10 @@ import ( // default is reapplied for MySQL afterwards. Postgres and MSSQL keep the existing // DEFAULT constraint independently of the type change. func WidenProjectBoardSorting(x *xorm.Engine) error { + // SQLite uses type affinity rather than strict types: a column declared TINYINT + // already stores any 64-bit int, so the widening is a no-op. Updating the + // declared type would require recreating the table (no ALTER COLUMN in SQLite) + // for no behavioral gain. if setting.Database.Type.IsSQLite3() { return nil } From 8fd95361506d6430d838da2973b32e103f715bb2 Mon Sep 17 00:00:00 2001 From: silverwind Date: Mon, 27 Apr 2026 13:43:07 +0200 Subject: [PATCH 27/37] Tighten project board API for GitHub-style consumers - Rename response timestamps to created_at / updated_at / closed_at - Replace is_closed bool with state ("open" / "closed") via api.StateType - Switch template_type / card_type / type to string enums with input validation - Embed creator User object on Project and ProjectColumn (batched lookup) - Add absolute html_url; drop relative url - Add POST /repos/.../projects/{id}/issues/{issue_id}/move with optional sorting - Validate column hex color and reject writes to closed projects - Document issue-only project scope in swagger - Push project-issue existence check into project_model.IsIssueInColumn - Add project_service.ErrIssueNotInProject sentinel for the move endpoint Co-Authored-By: Claude (Opus 4.7) --- models/project/issue.go | 8 + modules/structs/project.go | 81 ++++---- routers/api/v1/api.go | 1 + routers/api/v1/repo/project.go | 223 ++++++++++++++++++--- routers/api/v1/swagger/options.go | 3 + services/convert/project.go | 163 ++++++++++++--- services/projects/issue.go | 6 +- templates/swagger/v1_json.tmpl | 204 ++++++++++++++----- tests/integration/api_repo_project_test.go | 29 ++- 9 files changed, 563 insertions(+), 155 deletions(-) diff --git a/models/project/issue.go b/models/project/issue.go index c89f524305..85456b515c 100644 --- a/models/project/issue.go +++ b/models/project/issue.go @@ -33,6 +33,14 @@ func deleteProjectIssuesByProjectID(ctx context.Context, projectID int64) error return err } +func IsIssueInColumn(ctx context.Context, issueID, projectID, columnID int64) (bool, error) { + return db.GetEngine(ctx).Exist(&ProjectIssue{ + IssueID: issueID, + ProjectID: projectID, + ProjectColumnID: columnID, + }) +} + // GetColumnIssueNextSorting returns the sorting value to append an issue at the end of the column. func GetColumnIssueNextSorting(ctx context.Context, projectID, columnID int64) (int64, error) { res := struct { diff --git a/modules/structs/project.go b/modules/structs/project.go index e9eea60f5e..372d58ef97 100644 --- a/modules/structs/project.go +++ b/modules/structs/project.go @@ -7,32 +7,36 @@ import ( "time" ) -// Project represents a project +// Project represents a project. +// +// Gitea projects can only contain issues — note cards and pull requests are +// not modeled as project items. +// // swagger:model type Project struct { - ID 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"` - // Template type: 0=none, 1=basic_kanban, 2=bug_triage - TemplateType int `json:"template_type"` - // Card type: 0=text_only, 1=images_and_text - CardType int `json:"card_type"` - // Project type: 1=individual, 2=repository, 3=organization - Type int `json:"type"` - NumOpenIssues int64 `json:"num_open_issues,omitempty"` - NumClosedIssues int64 `json:"num_closed_issues,omitempty"` - NumIssues int64 `json:"num_issues,omitempty"` + ID int64 `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + OwnerID int64 `json:"owner_id,omitempty"` + RepoID int64 `json:"repo_id,omitempty"` + Creator *User `json:"creator,omitempty"` + State StateType `json:"state"` + // Template type: "none", "basic_kanban" or "bug_triage" + TemplateType string `json:"template_type"` + // Card type: "text_only" or "images_and_text" + CardType string `json:"card_type"` + // Project type: "individual", "repository" or "organization" + Type string `json:"type"` + NumOpenIssues int64 `json:"num_open_issues,omitempty"` + NumClosedIssues int64 `json:"num_closed_issues,omitempty"` + NumIssues int64 `json:"num_issues,omitempty"` // swagger:strfmt date-time - Created time.Time `json:"created"` + CreatedAt time.Time `json:"created_at"` // swagger:strfmt date-time - Updated time.Time `json:"updated"` + UpdatedAt time.Time `json:"updated_at"` // swagger:strfmt date-time - ClosedDate *time.Time `json:"closed_date,omitempty"` - URL string `json:"url,omitempty"` + ClosedAt *time.Time `json:"closed_at,omitempty"` + HTMLURL string `json:"html_url,omitempty"` } // CreateProjectOption represents options for creating a project @@ -41,10 +45,10 @@ type CreateProjectOption struct { // required: true Title string `json:"title" binding:"Required"` Description string `json:"description"` - // Template type: 0=none, 1=basic_kanban, 2=bug_triage - TemplateType int `json:"template_type"` - // Card type: 0=text_only, 1=images_and_text - CardType int `json:"card_type"` + // Template type: "none", "basic_kanban" or "bug_triage" + TemplateType string `json:"template_type"` + // Card type: "text_only" or "images_and_text" + CardType string `json:"card_type"` } // EditProjectOption represents options for editing a project @@ -52,10 +56,9 @@ type CreateProjectOption struct { type EditProjectOption struct { Title *string `json:"title,omitempty"` Description *string `json:"description,omitempty"` - // Card type: 0=text_only, 1=images_and_text - CardType *int `json:"card_type,omitempty"` - // State of the project (open or closed) - State *string `json:"state,omitempty"` + // Card type: "text_only" or "images_and_text" + CardType *string `json:"card_type,omitempty"` + State *StateType `json:"state,omitempty"` } // ProjectColumn represents a project column (board) @@ -67,12 +70,12 @@ type ProjectColumn struct { Sorting int `json:"sorting"` Color string `json:"color,omitempty"` ProjectID int64 `json:"project_id"` - CreatorID int64 `json:"creator_id"` + Creator *User `json:"creator,omitempty"` NumIssues int64 `json:"num_issues,omitempty"` // swagger:strfmt date-time - Created time.Time `json:"created"` + CreatedAt time.Time `json:"created_at"` // swagger:strfmt date-time - Updated time.Time `json:"updated"` + UpdatedAt time.Time `json:"updated_at"` } // CreateProjectColumnOption represents options for creating a project column @@ -80,7 +83,7 @@ type ProjectColumn struct { type CreateProjectColumnOption struct { // required: true Title string `json:"title" binding:"Required"` - // Column color (hex format, e.g. #FF0000) + // Column color in 6-digit hex format, e.g. #FF0000 Color string `json:"color,omitempty"` } @@ -88,7 +91,17 @@ type CreateProjectColumnOption struct { // swagger:model type EditProjectColumnOption struct { Title *string `json:"title,omitempty"` - // Column color (hex format) + // Column color in 6-digit hex format, e.g. #FF0000 Color *string `json:"color,omitempty"` Sorting *int `json:"sorting,omitempty"` } + +// MoveProjectIssueOption represents options for moving an issue between columns +// swagger:model +type MoveProjectIssueOption struct { + // Target column to move the issue into + // required: true + ColumnID int64 `json:"column_id" binding:"Required"` + // Optional sorting position within the target column + Sorting *int64 `json:"sorting,omitempty"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 33ade037b6..4ec826b8c2 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1592,6 +1592,7 @@ func Routes() *web.Router { m.Post("/issues/{issue_id}", reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, repo.AddIssueToProjectColumn) m.Delete("/issues/{issue_id}", reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, repo.RemoveIssueFromProjectColumn) }) + m.Post("/issues/{issue_id}/move", reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.MoveProjectIssueOption{}), repo.MoveProjectIssue) }) }, reqRepoReader(unit.TypeProjects)) }, repoAssignment(), checkTokenPublicOnly()) diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index cc4110d139..ac323fefb8 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -4,6 +4,7 @@ package repo import ( + "errors" "net/http" "code.gitea.io/gitea/models/db" @@ -33,7 +34,7 @@ func getRepoProjectByID(ctx *context.APIContext) *project_model.Project { return project } -func getRepoProjectColumn(ctx *context.APIContext) *project_model.Column { +func getRepoProjectColumn(ctx *context.APIContext) (*project_model.Project, *project_model.Column) { column, err := project_model.GetColumn(ctx, ctx.PathParamInt64("column_id")) if err != nil { if project_model.IsErrProjectColumnNotExist(err) { @@ -41,23 +42,42 @@ func getRepoProjectColumn(ctx *context.APIContext) *project_model.Column { } else { ctx.APIErrorInternal(err) } - return nil + return nil, nil } - p, err := project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, column.ProjectID) + project, err := project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, column.ProjectID) if err != nil { if project_model.IsErrProjectNotExist(err) { ctx.APIErrorNotFound() } else { ctx.APIErrorInternal(err) } - return nil + return nil, nil } - if p.ID != ctx.PathParamInt64("id") { + if project.ID != ctx.PathParamInt64("id") { ctx.APIErrorNotFound() - return nil + return nil, nil } + project.Repo = ctx.Repo.Repository + return project, column +} - return column +func rejectIfClosed(ctx *context.APIContext, project *project_model.Project) bool { + if project.IsClosed { + ctx.APIError(http.StatusForbidden, "project is closed") + return true + } + return false +} + +func validateColumnColor(ctx *context.APIContext, color string) bool { + if color == "" { + return true + } + if !project_model.ColumnColorPattern.MatchString(color) { + ctx.APIError(http.StatusUnprocessableEntity, "color must be a 6-digit hex string like #FF0000") + return false + } + return true } // ListProjects lists all projects in a repository @@ -65,6 +85,7 @@ func ListProjects(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/projects repository repoListProjects // --- // summary: List projects in a repository + // description: Gitea projects only contain issues — note cards and pull requests are not modeled as project items. // produces: // - application/json // parameters: @@ -80,7 +101,7 @@ func ListProjects(ctx *context.APIContext) { // required: true // - name: state // in: query - // description: State of the project (open, closed) + // description: State of the project (open, closed, all) // type: string // enum: [open, closed, all] // default: open @@ -121,11 +142,10 @@ func ListProjects(ctx *context.APIContext) { for _, p := range projects { p.Repo = ctx.Repo.Repository } - apiProjects := convert.ToProjectList(ctx, projects) ctx.SetLinkHeader(count, listOptions.PageSize) ctx.SetTotalCountHeader(count) - ctx.JSON(http.StatusOK, apiProjects) + ctx.JSON(http.StatusOK, convert.ToProjectList(ctx, projects, ctx.Doer)) } // GetProject gets a single project @@ -133,6 +153,7 @@ func GetProject(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/projects/{id} repository repoGetProject // --- // summary: Get a single project + // description: Gitea projects only contain issues — note cards and pull requests are not modeled as project items. // produces: // - application/json // parameters: @@ -168,7 +189,7 @@ func GetProject(ctx *context.APIContext) { return } - ctx.JSON(http.StatusOK, convert.ToProject(ctx, project)) + ctx.JSON(http.StatusOK, convert.ToProject(ctx, project, ctx.Doer)) } // CreateProject creates a new project @@ -205,13 +226,24 @@ func CreateProject(ctx *context.APIContext) { form := web.GetForm(ctx).(*api.CreateProjectOption) + templateType, err := convert.ProjectTemplateTypeFromString(form.TemplateType) + if err != nil { + ctx.APIError(http.StatusUnprocessableEntity, err.Error()) + return + } + cardType, err := convert.ProjectCardTypeFromString(form.CardType) + if err != nil { + ctx.APIError(http.StatusUnprocessableEntity, err.Error()) + return + } + p := &project_model.Project{ RepoID: ctx.Repo.Repository.ID, Title: form.Title, Description: form.Description, CreatorID: ctx.Doer.ID, - TemplateType: project_model.TemplateType(form.TemplateType), - CardType: project_model.CardType(form.CardType), + TemplateType: templateType, + CardType: cardType, Type: project_model.TypeRepository, } @@ -221,7 +253,7 @@ func CreateProject(ctx *context.APIContext) { } p.Repo = ctx.Repo.Repository - ctx.JSON(http.StatusCreated, convert.ToProject(ctx, p)) + ctx.JSON(http.StatusCreated, convert.ToProject(ctx, p, ctx.Doer)) } // EditProject updates a project @@ -274,10 +306,15 @@ func EditProject(ctx *context.APIContext) { Description: optional.FromPtr(form.Description), } if form.CardType != nil { - opts.CardType = optional.Some(project_model.CardType(*form.CardType)) + cardType, err := convert.ProjectCardTypeFromString(*form.CardType) + if err != nil { + ctx.APIError(http.StatusUnprocessableEntity, err.Error()) + return + } + opts.CardType = optional.Some(cardType) } if form.State != nil { - switch api.StateType(*form.State) { + switch *form.State { case api.StateOpen: opts.IsClosed = optional.Some(false) case api.StateClosed: @@ -297,7 +334,7 @@ func EditProject(ctx *context.APIContext) { return } - ctx.JSON(http.StatusOK, convert.ToProject(ctx, project)) + ctx.JSON(http.StatusOK, convert.ToProject(ctx, project, ctx.Doer)) } // DeleteProject deletes a project @@ -399,7 +436,7 @@ func ListProjectColumns(ctx *context.APIContext) { ctx.SetLinkHeader(total, listOptions.PageSize) ctx.SetTotalCountHeader(total) - ctx.JSON(http.StatusOK, convert.ToProjectColumnList(ctx, columns)) + ctx.JSON(http.StatusOK, convert.ToProjectColumnList(ctx, columns, ctx.Doer)) } // CreateProjectColumn creates a new column in a project @@ -435,6 +472,8 @@ func CreateProjectColumn(ctx *context.APIContext) { // responses: // "201": // "$ref": "#/responses/ProjectColumn" + // "403": + // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" // "422": @@ -444,8 +483,14 @@ func CreateProjectColumn(ctx *context.APIContext) { if ctx.Written() { return } + if rejectIfClosed(ctx, project) { + return + } form := web.GetForm(ctx).(*api.CreateProjectColumnOption) + if !validateColumnColor(ctx, form.Color) { + return + } column := &project_model.Column{ Title: form.Title, @@ -459,7 +504,7 @@ func CreateProjectColumn(ctx *context.APIContext) { return } - ctx.JSON(http.StatusCreated, convert.ToProjectColumn(ctx, column)) + ctx.JSON(http.StatusCreated, convert.ToProjectColumn(ctx, column, ctx.Doer)) } // EditProjectColumn updates a column @@ -501,18 +546,27 @@ func EditProjectColumn(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/ProjectColumn" + // "403": + // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" // "422": // "$ref": "#/responses/validationError" - column := getRepoProjectColumn(ctx) + project, column := getRepoProjectColumn(ctx) if ctx.Written() { return } + if rejectIfClosed(ctx, project) { + return + } form := web.GetForm(ctx).(*api.EditProjectColumnOption) + if form.Color != nil && !validateColumnColor(ctx, *form.Color) { + return + } + if form.Title != nil { column.Title = *form.Title } @@ -528,7 +582,7 @@ func EditProjectColumn(ctx *context.APIContext) { return } - ctx.JSON(http.StatusOK, convert.ToProjectColumn(ctx, column)) + ctx.JSON(http.StatusOK, convert.ToProjectColumn(ctx, column, ctx.Doer)) } // DeleteProjectColumn deletes a column @@ -562,13 +616,18 @@ func DeleteProjectColumn(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" - column := getRepoProjectColumn(ctx) + project, column := getRepoProjectColumn(ctx) if ctx.Written() { return } + if rejectIfClosed(ctx, project) { + return + } if err := project_model.DeleteColumnByID(ctx, column.ID); err != nil { ctx.APIErrorInternal(err) @@ -583,6 +642,7 @@ func ListProjectColumnIssues(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/projects/{id}/columns/{column_id}/issues repository repoListProjectColumnIssues // --- // summary: List issues in a project column + // description: Gitea projects only contain issues — note cards and pull requests are not modeled as project items. // produces: // - application/json // parameters: @@ -622,7 +682,7 @@ func ListProjectColumnIssues(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - column := getRepoProjectColumn(ctx) + _, column := getRepoProjectColumn(ctx) if ctx.Written() { return } @@ -658,6 +718,7 @@ func AddIssueToProjectColumn(ctx *context.APIContext) { // swagger:operation POST /repos/{owner}/{repo}/projects/{id}/columns/{column_id}/issues/{issue_id} repository repoAddIssueToProjectColumn // --- // summary: Add an issue to a project column + // description: Gitea projects only contain issues — note cards and pull requests cannot be added. // consumes: // - application/json // produces: @@ -758,10 +819,13 @@ func RemoveIssueFromProjectColumn(ctx *context.APIContext) { // assignIssueToProjectColumn assigns an issue to a project column when add is true, // or removes the issue from any project assignment when add is false. func assignIssueToProjectColumn(ctx *context.APIContext, add bool) { - column := getRepoProjectColumn(ctx) + project, column := getRepoProjectColumn(ctx) if ctx.Written() { return } + if rejectIfClosed(ctx, project) { + return + } issue, err := issues_model.GetIssueByRepoID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("issue_id")) if err != nil { @@ -778,11 +842,7 @@ func assignIssueToProjectColumn(ctx *context.APIContext, add bool) { // Confirm the issue is currently in this specific column before removing, // since IssueAssignOrRemoveProject(projectID=0) clears the issue's project // assignment unconditionally. - exists, err := db.GetEngine(ctx).Exist(&project_model.ProjectIssue{ - IssueID: issue.ID, - ProjectID: column.ProjectID, - ProjectColumnID: column.ID, - }) + exists, err := project_model.IsIssueInColumn(ctx, issue.ID, column.ProjectID, column.ID) if err != nil { ctx.APIErrorInternal(err) return @@ -804,3 +864,108 @@ func assignIssueToProjectColumn(ctx *context.APIContext, add bool) { ctx.Status(http.StatusNoContent) } } + +// MoveProjectIssue moves an issue between columns of the same project (and optionally sets sorting). +func MoveProjectIssue(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/projects/{id}/issues/{issue_id}/move repository repoMoveProjectIssue + // --- + // summary: Move an issue between columns of a project + // description: Atomically moves an existing project issue into a different column, optionally setting its sorting position. + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the project + // type: integer + // format: int64 + // required: true + // - name: issue_id + // in: path + // description: id of the issue + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/MoveProjectIssueOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + project := getRepoProjectByID(ctx) + if ctx.Written() { + return + } + if rejectIfClosed(ctx, project) { + return + } + + form := web.GetForm(ctx).(*api.MoveProjectIssueOption) + + column, err := project_model.GetColumn(ctx, form.ColumnID) + if err != nil { + if project_model.IsErrProjectColumnNotExist(err) { + ctx.APIError(http.StatusUnprocessableEntity, "target column does not exist") + } else { + ctx.APIErrorInternal(err) + } + return + } + if column.ProjectID != project.ID { + ctx.APIError(http.StatusUnprocessableEntity, "target column does not belong to this project") + return + } + + issue, err := issues_model.GetIssueByRepoID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("issue_id")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return + } + + var sorting int64 + if form.Sorting != nil { + sorting = *form.Sorting + } else { + next, err := project_model.GetColumnIssueNextSorting(ctx, project.ID, column.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + sorting = next + } + + if err := project_service.MoveIssuesOnProjectColumn(ctx, ctx.Doer, column, map[int64]int64{sorting: issue.ID}); err != nil { + if errors.Is(err, project_service.ErrIssueNotInProject) { + ctx.APIErrorNotFound() + return + } + ctx.APIErrorInternal(err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 0fd6e36747..1598cc039b 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -243,4 +243,7 @@ type swaggerParameterBodies struct { CreateProjectColumnOption api.CreateProjectColumnOption // in:body EditProjectColumnOption api.EditProjectColumnOption + + // in:body + MoveProjectIssueOption api.MoveProjectIssueOption } diff --git a/services/convert/project.go b/services/convert/project.go index b9c81ccd35..c67c2c7157 100644 --- a/services/convert/project.go +++ b/services/convert/project.go @@ -5,76 +5,189 @@ package convert import ( "context" + "fmt" project_model "code.gitea.io/gitea/models/project" + user_model "code.gitea.io/gitea/models/user" api "code.gitea.io/gitea/modules/structs" ) -// ToProject converts a project_model.Project to api.Project -func ToProject(ctx context.Context, p *project_model.Project) *api.Project { +func ProjectTemplateTypeToString(t project_model.TemplateType) string { + switch t { + case project_model.TemplateTypeBasicKanban: + return "basic_kanban" + case project_model.TemplateTypeBugTriage: + return "bug_triage" + default: + return "none" + } +} + +func ProjectTemplateTypeFromString(s string) (project_model.TemplateType, error) { + switch s { + case "", "none": + return project_model.TemplateTypeNone, nil + case "basic_kanban": + return project_model.TemplateTypeBasicKanban, nil + case "bug_triage": + return project_model.TemplateTypeBugTriage, nil + default: + return 0, fmt.Errorf("invalid template_type %q (expected none, basic_kanban, bug_triage)", s) + } +} + +func ProjectCardTypeToString(t project_model.CardType) string { + switch t { + case project_model.CardTypeImagesAndText: + return "images_and_text" + default: + return "text_only" + } +} + +func ProjectCardTypeFromString(s string) (project_model.CardType, error) { + switch s { + case "", "text_only": + return project_model.CardTypeTextOnly, nil + case "images_and_text": + return project_model.CardTypeImagesAndText, nil + default: + return 0, fmt.Errorf("invalid card_type %q (expected text_only, images_and_text)", s) + } +} + +func ProjectTypeToString(t project_model.Type) string { + switch t { + case project_model.TypeIndividual: + return "individual" + case project_model.TypeRepository: + return "repository" + case project_model.TypeOrganization: + return "organization" + default: + return "" + } +} + +// loadProjectCreators batch-fetches creators for the given projects + columns and +// returns a map keyed by user ID. Errors are surfaced; missing users are silently +// skipped (their creator field stays nil), matching the convention of other list +// converters that tolerate deleted users. +func loadProjectCreators(ctx context.Context, projects []*project_model.Project, columns []*project_model.Column) (map[int64]*user_model.User, error) { + idSet := make(map[int64]struct{}) + for _, p := range projects { + if p.CreatorID > 0 { + idSet[p.CreatorID] = struct{}{} + } + } + for _, c := range columns { + if c.CreatorID > 0 { + idSet[c.CreatorID] = struct{}{} + } + } + if len(idSet) == 0 { + return map[int64]*user_model.User{}, nil + } + ids := make([]int64, 0, len(idSet)) + for id := range idSet { + ids = append(ids, id) + } + users, err := user_model.GetUserByIDs(ctx, ids) + if err != nil { + return nil, err + } + result := make(map[int64]*user_model.User, len(users)) + for _, u := range users { + result[u.ID] = u + } + return result, nil +} + +// ToProject converts a project_model.Project to api.Project. +// Caller is expected to preload p.Repo / p.Owner to avoid N+1 lookups. +func ToProject(ctx context.Context, p *project_model.Project, doer *user_model.User) *api.Project { + creators, _ := loadProjectCreators(ctx, []*project_model.Project{p}, nil) + return toProject(ctx, p, doer, creators) +} + +func toProject(ctx context.Context, p *project_model.Project, doer *user_model.User, creators map[int64]*user_model.User) *api.Project { + state := api.StateOpen + if p.IsClosed { + state = api.StateClosed + } + project := &api.Project{ ID: p.ID, Title: p.Title, Description: p.Description, OwnerID: p.OwnerID, RepoID: p.RepoID, - CreatorID: p.CreatorID, - IsClosed: p.IsClosed, - TemplateType: int(p.TemplateType), - CardType: int(p.CardType), - Type: int(p.Type), + State: state, + TemplateType: ProjectTemplateTypeToString(p.TemplateType), + CardType: ProjectCardTypeToString(p.CardType), + Type: ProjectTypeToString(p.Type), NumOpenIssues: p.NumOpenIssues, NumClosedIssues: p.NumClosedIssues, NumIssues: p.NumIssues, - Created: p.CreatedUnix.AsTime(), - Updated: p.UpdatedUnix.AsTime(), + CreatedAt: p.CreatedUnix.AsTime(), + UpdatedAt: p.UpdatedUnix.AsTime(), } if p.ClosedDateUnix > 0 { t := p.ClosedDateUnix.AsTime() - project.ClosedDate = &t + project.ClosedAt = &t + } + + if creator, ok := creators[p.CreatorID]; ok { + project.Creator = ToUser(ctx, creator, doer) } - // Repo/Owner are expected to be preloaded by the caller to avoid N+1 lookups. if p.Type == project_model.TypeRepository && p.Repo != nil { - project.URL = project_model.ProjectLinkForRepo(p.Repo, p.ID) + project.HTMLURL = p.Repo.HTMLURL() + fmt.Sprintf("/projects/%d", p.ID) } else if p.Owner != nil { - project.URL = project_model.ProjectLinkForOrg(p.Owner, p.ID) + project.HTMLURL = p.Owner.HTMLURL(ctx) + fmt.Sprintf("/-/projects/%d", p.ID) } return project } -// ToProjectColumn converts a project_model.Column to api.ProjectColumn -func ToProjectColumn(ctx context.Context, column *project_model.Column) *api.ProjectColumn { - return &api.ProjectColumn{ +func ToProjectColumn(ctx context.Context, column *project_model.Column, doer *user_model.User) *api.ProjectColumn { + creators, _ := loadProjectCreators(ctx, nil, []*project_model.Column{column}) + return toProjectColumn(ctx, column, doer, creators) +} + +func toProjectColumn(ctx context.Context, column *project_model.Column, doer *user_model.User, creators map[int64]*user_model.User) *api.ProjectColumn { + apiColumn := &api.ProjectColumn{ ID: column.ID, Title: column.Title, Default: column.Default, Sorting: column.Sorting, Color: column.Color, ProjectID: column.ProjectID, - CreatorID: column.CreatorID, NumIssues: column.NumIssues, - Created: column.CreatedUnix.AsTime(), - Updated: column.UpdatedUnix.AsTime(), + CreatedAt: column.CreatedUnix.AsTime(), + UpdatedAt: column.UpdatedUnix.AsTime(), } + if creator, ok := creators[column.CreatorID]; ok { + apiColumn.Creator = ToUser(ctx, creator, doer) + } + return apiColumn } -// 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 { +func ToProjectList(ctx context.Context, projects []*project_model.Project, doer *user_model.User) []*api.Project { + creators, _ := loadProjectCreators(ctx, projects, nil) result := make([]*api.Project, len(projects)) for i, p := range projects { - result[i] = ToProject(ctx, p) + result[i] = toProject(ctx, p, doer, creators) } 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 { +func ToProjectColumnList(ctx context.Context, columns []*project_model.Column, doer *user_model.User) []*api.ProjectColumn { + creators, _ := loadProjectCreators(ctx, nil, columns) result := make([]*api.ProjectColumn, len(columns)) for i, column := range columns { - result[i] = ToProjectColumn(ctx, column) + result[i] = toProjectColumn(ctx, column, doer, creators) } return result } diff --git a/services/projects/issue.go b/services/projects/issue.go index 6b4db40b82..b8e4390fef 100644 --- a/services/projects/issue.go +++ b/services/projects/issue.go @@ -17,6 +17,10 @@ import ( "code.gitea.io/gitea/modules/optional" ) +// ErrIssueNotInProject is returned when MoveIssuesOnProjectColumn is asked to move +// issues that aren't yet attached to the column's project. +var ErrIssueNotInProject = errors.New("all issues have to be added to a project first") + // MoveIssuesOnProjectColumn moves or keeps issues in a column and sorts them inside that column func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, column *project_model.Column, sortedIssueIDs map[int64]int64) error { return db.WithTx(ctx, func(ctx context.Context) error { @@ -32,7 +36,7 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum return err } if int(count) != len(sortedIssueIDs) { - return errors.New("all issues have to be added to a project first") + return ErrIssueNotInProject } issues, err := issues_model.GetIssuesByIDs(ctx, issueIDs) diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 6c0cd24f00..bec1272649 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -13527,6 +13527,7 @@ }, "/repos/{owner}/{repo}/projects": { "get": { + "description": "Gitea projects only contain issues — note cards and pull requests are not modeled as project items.", "produces": [ "application/json" ], @@ -13558,7 +13559,7 @@ ], "type": "string", "default": "open", - "description": "State of the project (open, closed)", + "description": "State of the project (open, closed, all)", "name": "state", "in": "query" }, @@ -13634,6 +13635,7 @@ }, "/repos/{owner}/{repo}/projects/{id}": { "get": { + "description": "Gitea projects only contain issues — note cards and pull requests are not modeled as project items.", "produces": [ "application/json" ], @@ -13872,6 +13874,9 @@ "201": { "$ref": "#/responses/ProjectColumn" }, + "403": { + "$ref": "#/responses/forbidden" + }, "404": { "$ref": "#/responses/notFound" }, @@ -13924,6 +13929,9 @@ "204": { "$ref": "#/responses/empty" }, + "403": { + "$ref": "#/responses/forbidden" + }, "404": { "$ref": "#/responses/notFound" } @@ -13984,6 +13992,9 @@ "200": { "$ref": "#/responses/ProjectColumn" }, + "403": { + "$ref": "#/responses/forbidden" + }, "404": { "$ref": "#/responses/notFound" }, @@ -13995,6 +14006,7 @@ }, "/repos/{owner}/{repo}/projects/{id}/columns/{column_id}/issues": { "get": { + "description": "Gitea projects only contain issues — note cards and pull requests are not modeled as project items.", "produces": [ "application/json" ], @@ -14059,6 +14071,7 @@ }, "/repos/{owner}/{repo}/projects/{id}/columns/{column_id}/issues/{issue_id}": { "post": { + "description": "Gitea projects only contain issues — note cards and pull requests cannot be added.", "consumes": [ "application/json" ], @@ -14193,6 +14206,75 @@ } } }, + "/repos/{owner}/{repo}/projects/{id}/issues/{issue_id}/move": { + "post": { + "description": "Atomically moves an existing project issue into a different column, optionally setting its sorting position.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Move an issue between columns of a project", + "operationId": "repoMoveProjectIssue", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the issue", + "name": "issue_id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/MoveProjectIssueOption" + } + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, "/repos/{owner}/{repo}/pulls": { "get": { "produces": [ @@ -24160,7 +24242,7 @@ ], "properties": { "color": { - "description": "Column color (hex format, e.g. #FF0000)", + "description": "Column color in 6-digit hex format, e.g. #FF0000", "type": "string", "x-go-name": "Color" }, @@ -24179,9 +24261,8 @@ ], "properties": { "card_type": { - "description": "Card type: 0=text_only, 1=images_and_text", - "type": "integer", - "format": "int64", + "description": "Card type: \"text_only\" or \"images_and_text\"", + "type": "string", "x-go-name": "CardType" }, "description": { @@ -24189,9 +24270,8 @@ "x-go-name": "Description" }, "template_type": { - "description": "Template type: 0=none, 1=basic_kanban, 2=bug_triage", - "type": "integer", - "format": "int64", + "description": "Template type: \"none\", \"basic_kanban\" or \"bug_triage\"", + "type": "string", "x-go-name": "TemplateType" }, "title": { @@ -25326,7 +25406,7 @@ "type": "object", "properties": { "color": { - "description": "Column color (hex format)", + "description": "Column color in 6-digit hex format, e.g. #FF0000", "type": "string", "x-go-name": "Color" }, @@ -25347,9 +25427,8 @@ "type": "object", "properties": { "card_type": { - "description": "Card type: 0=text_only, 1=images_and_text", - "type": "integer", - "format": "int64", + "description": "Card type: \"text_only\" or \"images_and_text\"", + "type": "string", "x-go-name": "CardType" }, "description": { @@ -25357,8 +25436,12 @@ "x-go-name": "Description" }, "state": { - "description": "State of the project (open or 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" }, "title": { @@ -27403,6 +27486,28 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "MoveProjectIssueOption": { + "description": "MoveProjectIssueOption represents options for moving an issue between columns", + "type": "object", + "required": [ + "column_id" + ], + "properties": { + "column_id": { + "description": "Target column to move the issue into", + "type": "integer", + "format": "int64", + "x-go-name": "ColumnID" + }, + "sorting": { + "description": "Optional sorting position within the target column", + "type": "integer", + "format": "int64", + "x-go-name": "Sorting" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "NewIssuePinsAllowed": { "description": "NewIssuePinsAllowed represents an API response that says if new Issue Pins are allowed", "type": "object", @@ -28066,43 +28171,41 @@ "x-go-package": "code.gitea.io/gitea/modules/structs" }, "Project": { - "description": "Project represents a project", + "description": "Gitea projects can only contain issues — note cards and pull requests are\nnot modeled as project items.", "type": "object", + "title": "Project represents a project.", "properties": { "card_type": { - "description": "Card type: 0=text_only, 1=images_and_text", - "type": "integer", - "format": "int64", + "description": "Card type: \"text_only\" or \"images_and_text\"", + "type": "string", "x-go-name": "CardType" }, - "closed_date": { + "closed_at": { "type": "string", "format": "date-time", - "x-go-name": "ClosedDate" + "x-go-name": "ClosedAt" }, - "created": { + "created_at": { "type": "string", "format": "date-time", - "x-go-name": "Created" + "x-go-name": "CreatedAt" }, - "creator_id": { - "type": "integer", - "format": "int64", - "x-go-name": "CreatorID" + "creator": { + "$ref": "#/definitions/User" }, "description": { "type": "string", "x-go-name": "Description" }, + "html_url": { + "type": "string", + "x-go-name": "HTMLURL" + }, "id": { "type": "integer", "format": "int64", "x-go-name": "ID" }, - "is_closed": { - "type": "boolean", - "x-go-name": "IsClosed" - }, "num_closed_issues": { "type": "integer", "format": "int64", @@ -28128,10 +28231,18 @@ "format": "int64", "x-go-name": "RepoID" }, + "state": { + "type": "string", + "enum": [ + "open", + "closed" + ], + "x-go-enum-desc": "open StateOpen StateOpen pr is opened\nclosed StateClosed StateClosed pr is closed", + "x-go-name": "State" + }, "template_type": { - "description": "Template type: 0=none, 1=basic_kanban, 2=bug_triage", - "type": "integer", - "format": "int64", + "description": "Template type: \"none\", \"basic_kanban\" or \"bug_triage\"", + "type": "string", "x-go-name": "TemplateType" }, "title": { @@ -28139,19 +28250,14 @@ "x-go-name": "Title" }, "type": { - "description": "Project type: 1=individual, 2=repository, 3=organization", - "type": "integer", - "format": "int64", + "description": "Project type: \"individual\", \"repository\" or \"organization\"", + "type": "string", "x-go-name": "Type" }, - "updated": { + "updated_at": { "type": "string", "format": "date-time", - "x-go-name": "Updated" - }, - "url": { - "type": "string", - "x-go-name": "URL" + "x-go-name": "UpdatedAt" } }, "x-go-package": "code.gitea.io/gitea/modules/structs" @@ -28164,15 +28270,13 @@ "type": "string", "x-go-name": "Color" }, - "created": { + "created_at": { "type": "string", "format": "date-time", - "x-go-name": "Created" + "x-go-name": "CreatedAt" }, - "creator_id": { - "type": "integer", - "format": "int64", - "x-go-name": "CreatorID" + "creator": { + "$ref": "#/definitions/User" }, "default": { "type": "boolean", @@ -28202,10 +28306,10 @@ "type": "string", "x-go-name": "Title" }, - "updated": { + "updated_at": { "type": "string", "format": "date-time", - "x-go-name": "Updated" + "x-go-name": "UpdatedAt" } }, "x-go-package": "code.gitea.io/gitea/modules/structs" @@ -31499,7 +31603,7 @@ "parameterBodies": { "description": "parameterBodies", "schema": { - "$ref": "#/definitions/EditProjectColumnOption" + "$ref": "#/definitions/MoveProjectIssueOption" } }, "redirect": { diff --git a/tests/integration/api_repo_project_test.go b/tests/integration/api_repo_project_test.go index f2740556e9..c3be285125 100644 --- a/tests/integration/api_repo_project_test.go +++ b/tests/integration/api_repo_project_test.go @@ -58,7 +58,7 @@ func testAPIListProjects(t *testing.T) { resp = MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &projects) for _, project := range projects { - assert.False(t, project.IsClosed, "Project should be open") + assert.Equal(t, api.StateOpen, project.State, "Project should be open") } // Test state filter - all @@ -99,7 +99,7 @@ func testAPIGetProject(t *testing.T) { 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) + assert.NotEmpty(t, apiProject.HTMLURL) // Test getting non-existent project req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/99999", owner.Name, repo.Name). @@ -117,8 +117,8 @@ func testAPICreateProject(t *testing.T) { 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 + TemplateType: "basic_kanban", + CardType: "images_and_text", }).AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusCreated) @@ -126,9 +126,9 @@ func testAPICreateProject(t *testing.T) { 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) + assert.Equal(t, "basic_kanban", project.TemplateType) + assert.Equal(t, "images_and_text", project.CardType) + assert.Equal(t, api.StateOpen, project.State) // Test creating with minimal data req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects", owner.Name, repo.Name), &api.CreateProjectOption{ @@ -209,8 +209,7 @@ func testAPIChangeProjectStatus(t *testing.T) { token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) - // Close via PATCH with state=closed - closed := "closed" + closed := api.StateClosed req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{ State: &closed, }).AddTokenAuth(token) @@ -218,21 +217,19 @@ func testAPIChangeProjectStatus(t *testing.T) { var updatedProject api.Project DecodeJSON(t, resp, &updatedProject) - assert.True(t, updatedProject.IsClosed) - assert.NotNil(t, updatedProject.ClosedDate) + assert.Equal(t, api.StateClosed, updatedProject.State) + assert.NotNil(t, updatedProject.ClosedAt) - // Reopen via PATCH with state=open - open := "open" + open := api.StateOpen req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{ State: &open, }).AddTokenAuth(token) resp = MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &updatedProject) - assert.False(t, updatedProject.IsClosed) + assert.Equal(t, api.StateOpen, updatedProject.State) - // Invalid state value must be rejected - bogus := "reopen" + bogus := api.StateType("reopen") req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{ State: &bogus, }).AddTokenAuth(token) From 608b271efc067ec7b6d2d99afafb167ac7f51b5c Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Mon, 27 Apr 2026 11:47:16 -0700 Subject: [PATCH 28/37] make code simple --- services/convert/project.go | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/services/convert/project.go b/services/convert/project.go index c67c2c7157..5b012925d1 100644 --- a/services/convert/project.go +++ b/services/convert/project.go @@ -9,6 +9,7 @@ import ( project_model "code.gitea.io/gitea/models/project" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/container" api "code.gitea.io/gitea/modules/structs" ) @@ -74,33 +75,21 @@ func ProjectTypeToString(t project_model.Type) string { // skipped (their creator field stays nil), matching the convention of other list // converters that tolerate deleted users. func loadProjectCreators(ctx context.Context, projects []*project_model.Project, columns []*project_model.Column) (map[int64]*user_model.User, error) { - idSet := make(map[int64]struct{}) + idSet := container.Set[int64]{} for _, p := range projects { if p.CreatorID > 0 { - idSet[p.CreatorID] = struct{}{} + idSet.Add(p.CreatorID) } } for _, c := range columns { if c.CreatorID > 0 { - idSet[c.CreatorID] = struct{}{} + idSet.Add(c.CreatorID) } } if len(idSet) == 0 { return map[int64]*user_model.User{}, nil } - ids := make([]int64, 0, len(idSet)) - for id := range idSet { - ids = append(ids, id) - } - users, err := user_model.GetUserByIDs(ctx, ids) - if err != nil { - return nil, err - } - result := make(map[int64]*user_model.User, len(users)) - for _, u := range users { - result[u.ID] = u - } - return result, nil + return user_model.GetUsersMapByIDs(ctx, idSet.Values()) } // ToProject converts a project_model.Project to api.Project. From 63b6e7155193124e27865225feb1a59878fc31fa Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 28 Apr 2026 00:42:34 +0200 Subject: [PATCH 29/37] Drop v332 migration, keep project_board.sorting as int8 Per review feedback, the 127-column cap is intentional (maxProjectColumns is 20), so the DB schema is left as-is and no migration is needed. Reverts the Column.Sorting widening to match. Co-Authored-By: Claude (Opus 4.7) --- models/migrations/migrations.go | 1 - models/migrations/v1_27/v332.go | 42 ------------------------------- models/project/column.go | 7 +++--- models/project/column_test.go | 6 ++--- routers/api/v1/repo/project.go | 2 +- services/convert/project.go | 2 +- services/forms/repo_form.go | 2 +- tests/integration/project_test.go | 6 ++--- 8 files changed, 13 insertions(+), 55 deletions(-) delete mode 100644 models/migrations/v1_27/v332.go diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index d3522772d2..c3a8f08b5d 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -409,7 +409,6 @@ func prepareMigrationTasks() []*migration { // Gitea 1.26.0 ends at migration ID number 330 (database version 331) newMigration(331, "Add ActionRunAttempt model and related action fields", v1_27.AddActionRunAttemptModel), - newMigration(332, "Widen project_board.sorting from int8 to int", v1_27.WidenProjectBoardSorting), } return preparedMigrations } diff --git a/models/migrations/v1_27/v332.go b/models/migrations/v1_27/v332.go deleted file mode 100644 index fa10341241..0000000000 --- a/models/migrations/v1_27/v332.go +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package v1_27 - -import ( - "code.gitea.io/gitea/models/migrations/base" - "code.gitea.io/gitea/modules/setting" - - "xorm.io/xorm" - "xorm.io/xorm/schemas" -) - -// WidenProjectBoardSorting changes project_board.sorting from int8 to int so the -// API can stop truncating sort values and the column count is no longer capped at -// 127. DefaultIsEmpty: true is required because MSSQL's ALTER COLUMN rejects an -// inline DEFAULT, and MySQL's MODIFY COLUMN drops any DEFAULT not restated, so the -// default is reapplied for MySQL afterwards. Postgres and MSSQL keep the existing -// DEFAULT constraint independently of the type change. -func WidenProjectBoardSorting(x *xorm.Engine) error { - // SQLite uses type affinity rather than strict types: a column declared TINYINT - // already stores any 64-bit int, so the widening is a no-op. Updating the - // declared type would require recreating the table (no ALTER COLUMN in SQLite) - // for no behavioral gain. - if setting.Database.Type.IsSQLite3() { - return nil - } - if err := base.ModifyColumn(x, "project_board", &schemas.Column{ - Name: "sorting", - SQLType: schemas.SQLType{Name: "INT"}, - Nullable: false, - DefaultIsEmpty: true, - }); err != nil { - return err - } - if setting.Database.Type.IsMySQL() { - if _, err := x.Exec("ALTER TABLE `project_board` ALTER `sorting` SET DEFAULT 0"); err != nil { - return err - } - } - return nil -} diff --git a/models/project/column.go b/models/project/column.go index d55baf84d5..9c9abb4599 100644 --- a/models/project/column.go +++ b/models/project/column.go @@ -42,7 +42,7 @@ type Column struct { ID int64 `xorm:"pk autoincr"` Title string Default bool `xorm:"NOT NULL DEFAULT false"` // issues not assigned to a specific column will be assigned to this column - Sorting int `xorm:"NOT NULL DEFAULT 0"` + Sorting int8 `xorm:"NOT NULL DEFAULT 0"` Color string `xorm:"VARCHAR(7)"` ProjectID int64 `xorm:"INDEX NOT NULL"` @@ -128,7 +128,8 @@ func createDefaultColumnsForProject(ctx context.Context, project *Project) error }) } -// maxProjectColumns is the maximum number of columns allowed in a project. +// maxProjectColumns max columns allowed in a project, this should not bigger than 127 +// because sorting is int8 in database const maxProjectColumns = 20 // NewColumn adds a new project column to a given project @@ -148,7 +149,7 @@ func NewColumn(ctx context.Context, column *Column) error { if res.ColumnCount >= maxProjectColumns { return errors.New("NewBoard: maximum number of columns reached") } - column.Sorting = int(util.Iif(res.ColumnCount > 0, res.MaxSorting+1, 0)) + column.Sorting = int8(util.Iif(res.ColumnCount > 0, res.MaxSorting+1, 0)) _, err := db.GetEngine(ctx).Insert(column) return err } diff --git a/models/project/column_test.go b/models/project/column_test.go index b32d2a335a..d619698965 100644 --- a/models/project/column_test.go +++ b/models/project/column_test.go @@ -83,9 +83,9 @@ func Test_MoveColumnsOnProject(t *testing.T) { columns, err := GetProjectColumns(t.Context(), project1.ID, db.ListOptionsAll) assert.NoError(t, err) assert.Len(t, columns, 3) - assert.Equal(t, 0, columns[0].Sorting) // even if there is no default sorting, the code should also work - assert.Equal(t, 0, columns[1].Sorting) - assert.Equal(t, 0, columns[2].Sorting) + assert.EqualValues(t, 0, columns[0].Sorting) // even if there is no default sorting, the code should also work + assert.EqualValues(t, 0, columns[1].Sorting) + assert.EqualValues(t, 0, columns[2].Sorting) err = MoveColumnsOnProject(t.Context(), project1, map[int64]int64{ 0: columns[1].ID, diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index ac323fefb8..1ba67b1ff3 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -574,7 +574,7 @@ func EditProjectColumn(ctx *context.APIContext) { column.Color = *form.Color } if form.Sorting != nil { - column.Sorting = *form.Sorting + column.Sorting = int8(*form.Sorting) } if err := project_model.UpdateColumn(ctx, column); err != nil { diff --git a/services/convert/project.go b/services/convert/project.go index 5b012925d1..cd6109fd19 100644 --- a/services/convert/project.go +++ b/services/convert/project.go @@ -150,7 +150,7 @@ func toProjectColumn(ctx context.Context, column *project_model.Column, doer *us ID: column.ID, Title: column.Title, Default: column.Default, - Sorting: column.Sorting, + Sorting: int(column.Sorting), Color: column.Color, ProjectID: column.ProjectID, NumIssues: column.NumIssues, diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index aa76e82d4e..d8e019f860 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -469,7 +469,7 @@ type CreateProjectForm struct { // EditProjectColumnForm is a form for editing a project column type EditProjectColumnForm struct { Title string `binding:"Required;MaxSize(100)"` - Sorting int + Sorting int8 Color string `binding:"MaxSize(7)"` } diff --git a/tests/integration/project_test.go b/tests/integration/project_test.go index d823b85648..00764bf883 100644 --- a/tests/integration/project_test.go +++ b/tests/integration/project_test.go @@ -64,9 +64,9 @@ func TestMoveRepoProjectColumns(t *testing.T) { columns, err := project_model.GetProjectColumns(t.Context(), project1.ID, db.ListOptionsAll) assert.NoError(t, err) assert.Len(t, columns, 3) - assert.Equal(t, 0, columns[0].Sorting) - assert.Equal(t, 1, columns[1].Sorting) - assert.Equal(t, 2, columns[2].Sorting) + assert.EqualValues(t, 0, columns[0].Sorting) + assert.EqualValues(t, 1, columns[1].Sorting) + assert.EqualValues(t, 2, columns[2].Sorting) sess := loginUser(t, "user1") req := NewRequest(t, "GET", fmt.Sprintf("/%s/projects/%d", repo2.FullName(), project1.ID)) From 2d37f77938b832c5c9d84cd8124faac429e3bf98 Mon Sep 17 00:00:00 2001 From: beardev-in Date: Sun, 3 May 2026 14:37:08 +0530 Subject: [PATCH 30/37] fix: adapt to upstream refactor changes --- models/project/column.go | 14 ----- routers/api/v1/repo/project.go | 11 ++-- routers/web/repo/issue_page_meta.go | 97 ++++++++++++++++++++++------- services/convert/issue.go | 2 +- services/projects/issue.go | 7 ++- 5 files changed, 83 insertions(+), 48 deletions(-) diff --git a/models/project/column.go b/models/project/column.go index 9c9abb4599..6f4452984e 100644 --- a/models/project/column.go +++ b/models/project/column.go @@ -337,20 +337,6 @@ func SetDefaultColumn(ctx context.Context, projectID, columnID int64) error { }) } -func GetColumnsByIDs(ctx context.Context, projectID int64, columnsIDs []int64) (ColumnList, error) { - columns := make([]*Column, 0, 5) - if len(columnsIDs) == 0 { - return columns, nil - } - if err := db.GetEngine(ctx). - Where("project_id =?", projectID). - In("id", columnsIDs). - OrderBy("sorting").Find(&columns); err != nil { - return nil, err - } - return columns, nil -} - // MoveColumnsOnProject sorts columns in a project func MoveColumnsOnProject(ctx context.Context, project *Project, sortedColumnIDs map[int64]int64) error { return db.WithTx(ctx, func(ctx context.Context) error { diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index 1ba67b1ff3..acd514deab 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -689,11 +689,10 @@ func ListProjectColumnIssues(ctx *context.APIContext) { listOptions := utils.GetListOptions(ctx) issuesOpts := &issues_model.IssuesOptions{ - Paginator: &listOptions, - RepoIDs: []int64{ctx.Repo.Repository.ID}, - ProjectID: column.ProjectID, - ProjectColumnID: column.ID, - SortType: issues_model.SortTypeProjectColumnSorting, + Paginator: &listOptions, + RepoIDs: []int64{ctx.Repo.Repository.ID}, + ProjectIDs: []int64{column.ProjectID}, + SortType: issues_model.SortTypeProjectColumnSorting, } count, err := issues_model.CountIssues(ctx, issuesOpts) @@ -853,7 +852,7 @@ func assignIssueToProjectColumn(ctx *context.APIContext, add bool) { } projectID = 0 } - if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectID, column.ID); err != nil { + if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, []int64{projectID}); err != nil { ctx.APIErrorInternal(err) return } diff --git a/routers/web/repo/issue_page_meta.go b/routers/web/repo/issue_page_meta.go index 635ae8ef9c..428171dd0e 100644 --- a/routers/web/repo/issue_page_meta.go +++ b/routers/web/repo/issue_page_meta.go @@ -33,12 +33,15 @@ type issueSidebarAssigneesData struct { CandidateAssignees []*user_model.User } -type issueSidebarProjectsData struct { - SelectedProjectIDs []int64 // TODO: support multiple projects in the future +type issueSidebarProjectCardData struct { + Project *project_model.Project + Columns []*project_model.Column + SelectedColumn *project_model.Column +} - // the "selected" fields are only valid when len(SelectedProjectIDs)==1 - SelectedProjectColumns []*project_model.Column - SelectedProjectColumn *project_model.Column +type issueSidebarProjectsData struct { + SelectedProjectIDs []int64 + ProjectCards []*issueSidebarProjectCardData OpenProjects []*project_model.Project ClosedProjects []*project_model.Project @@ -107,7 +110,7 @@ func retrieveRepoIssueMetaData(ctx *context.Context, repo *repo_model.Repository // A reader(creator) could update some meta (eg: target branch), but can't change assignees anymore. // For non-creator users, only writers could update some meta (eg: assignees, milestone, project) // Need to clarify the logic and add some tests in the future - data.CanModifyIssueOrPull = ctx.Repo.CanWriteIssuesOrPulls(isPull) && !ctx.Repo.Repository.IsArchived + data.CanModifyIssueOrPull = ctx.Repo.Permission.CanWriteIssuesOrPulls(isPull) && !ctx.Repo.Repository.IsArchived if !data.CanModifyIssueOrPull { return data } @@ -168,34 +171,80 @@ func (d *IssuePageMetaData) retrieveAssigneesData(ctx *context.Context) { ctx.Data["Assignees"] = d.AssigneesData.CandidateAssignees } -func (d *IssuePageMetaData) retrieveProjectData(ctx *context.Context) { - if d.Issue == nil || d.Issue.Project == nil { +func (d *IssuePageMetaData) retrieveProjectCardsForExistingIssue(ctx *context.Context) { + if err := d.Issue.LoadProjects(ctx); err != nil { + ctx.ServerError("LoadProjects", err) return } - d.ProjectsData.SelectedProjectIDs = []int64{d.Issue.Project.ID} - columns, err := project_model.GetProjectColumns(ctx, d.Issue.Project.ID, db.ListOptionsAll) + + // Load column mappings for all projects + projectColumnMap, err := d.Issue.ProjectColumnMap(ctx) if err != nil { - ctx.ServerError("GetProjectColumns", err) + ctx.ServerError("ProjectColumnMap", err) return } - d.ProjectsData.SelectedProjectColumns = columns - columnID, err := d.Issue.ProjectColumnID(ctx) - if err != nil { - ctx.ServerError("ProjectColumnID", err) - return - } - for _, col := range columns { - if col.ID == columnID { - d.ProjectsData.SelectedProjectColumn = col - break + + // Build project cards for each project + d.ProjectsData.ProjectCards = make([]*issueSidebarProjectCardData, 0, len(d.Issue.Projects)) + for _, project := range d.Issue.Projects { + columns, err := project.GetColumns(ctx) + if err != nil { + ctx.ServerError("GetProjectColumns", err) + return } + + var selectedColumn *project_model.Column + columnID := projectColumnMap[project.ID] + for _, col := range columns { + if col.ID == columnID { + selectedColumn = col + break + } + } + + if selectedColumn == nil { + selectedColumn, err = project.MustDefaultColumn(ctx) + if err != nil { + ctx.ServerError("MustDefaultColumn", err) + return + } + } + d.ProjectsData.ProjectCards = append(d.ProjectsData.ProjectCards, &issueSidebarProjectCardData{ + Project: project, + Columns: columns, + SelectedColumn: selectedColumn, + }) + } + d.ProjectsData.SelectedProjectIDs = make([]int64, 0, len(d.ProjectsData.ProjectCards)) + for _, card := range d.ProjectsData.ProjectCards { + d.ProjectsData.SelectedProjectIDs = append(d.ProjectsData.SelectedProjectIDs, card.Project.ID) } } -func (d *IssuePageMetaData) retrieveProjectsDataForIssueWriter(ctx *context.Context) { - if d.Issue != nil && d.Issue.Project != nil { - d.ProjectsData.SelectedProjectIDs = []int64{d.Issue.Project.ID} +func (d *IssuePageMetaData) retrieveProjectData(ctx *context.Context) { + if d.Issue == nil { + return } + d.retrieveProjectCardsForExistingIssue(ctx) +} + +func (d *IssuePageMetaData) SetSelectedProjectIDs(ids []int64) { + allProjects := map[int64]*project_model.Project{} + for _, p := range d.ProjectsData.OpenProjects { + allProjects[p.ID] = p + } + for _, p := range d.ProjectsData.ClosedProjects { + allProjects[p.ID] = p + } + for _, id := range ids { + if project, ok := allProjects[id]; ok { + d.ProjectsData.ProjectCards = append(d.ProjectsData.ProjectCards, &issueSidebarProjectCardData{Project: project}) + } + } + d.ProjectsData.SelectedProjectIDs = ids +} + +func (d *IssuePageMetaData) retrieveProjectsDataForIssueWriter(ctx *context.Context) { d.ProjectsData.OpenProjects, d.ProjectsData.ClosedProjects = retrieveProjectsInternal(ctx, ctx.Repo.Repository) } diff --git a/services/convert/issue.go b/services/convert/issue.go index 8e3adaa82d..08cd9d71c8 100644 --- a/services/convert/issue.go +++ b/services/convert/issue.go @@ -99,7 +99,7 @@ func toIssue(ctx context.Context, doer *user_model.User, issue *issues_model.Iss return &api.Issue{} } if len(issue.Projects) > 0 { - apiIssue.Projects = ToAPIProjectList(issue.Projects) + apiIssue.Projects = ToProjectList(ctx, issue.Projects, doer) } if err := issue.LoadAssignees(ctx); err != nil { diff --git a/services/projects/issue.go b/services/projects/issue.go index b8e4390fef..01d067508f 100644 --- a/services/projects/issue.go +++ b/services/projects/issue.go @@ -63,10 +63,11 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum continue } - projectColumnID, err := curIssue.ProjectColumnID(ctx) + projectColumnMap, err := curIssue.ProjectColumnMap(ctx) if err != nil { return err } + projectColumnID := projectColumnMap[column.ProjectID] if projectColumnID != column.ID { // add timeline to issue @@ -121,7 +122,7 @@ func LoadIssuesAssigneesForProject(ctx context.Context, issuesMap map[int64]issu // LoadIssuesFromProject load issues assigned to each project column inside the given project func LoadIssuesFromProject(ctx context.Context, project *project_model.Project, opts *issues_model.IssuesOptions) (results map[int64]issues_model.IssueList, _ error) { issueList, err := issues_model.Issues(ctx, opts.Copy(func(o *issues_model.IssuesOptions) { - o.ProjectID = project.ID + o.ProjectIDs = []int64{project.ID} o.SortType = issues_model.SortTypeProjectColumnSorting })) if err != nil { @@ -215,7 +216,7 @@ func LoadIssueNumbersForProject(ctx context.Context, project *project_model.Proj // for user or org projects, we need to check access permissions opts := issues_model.IssuesOptions{ - ProjectID: project.ID, + ProjectIDs: []int64{project.ID}, Doer: doer, AllPublic: doer == nil, Owner: project.Owner, From 060d44e37988e1ddb381fc93e5ad5e5caa7a85c1 Mon Sep 17 00:00:00 2001 From: beardev-in Date: Sun, 3 May 2026 15:36:04 +0530 Subject: [PATCH 31/37] fix: adapt to upstream refactor, fix IssueAssignOrRemoveProject signature and column move logic --- models/project/issue.go | 17 ++++++++ routers/api/v1/repo/project.go | 49 ++++++++++++++++++---- tests/integration/api_repo_project_test.go | 6 +-- tests/integration/project_test.go | 5 +-- 4 files changed, 62 insertions(+), 15 deletions(-) diff --git a/models/project/issue.go b/models/project/issue.go index 85456b515c..eaf79d2cfd 100644 --- a/models/project/issue.go +++ b/models/project/issue.go @@ -95,3 +95,20 @@ func DeleteAllProjectIssueByIssueIDsAndProjectIDs(ctx context.Context, issueIDs, _, err := db.GetEngine(ctx).In("project_id", projectIDs).In("issue_id", issueIDs).Delete(&ProjectIssue{}) return err } + + +// MoveIssueToColumn moves a single issue to a specific column within a project. +func MoveIssueToColumn(ctx context.Context, issueID, projectID, columnID int64) error { + nextSorting, err := GetColumnIssueNextSorting(ctx, projectID, columnID) + if err != nil { + return err + } + _, err = db.GetEngine(ctx). + Where("issue_id=? AND project_id=?", issueID, projectID). + Cols("project_board_id", "sorting"). + Update(&ProjectIssue{ + ProjectColumnID: columnID, + Sorting: nextSorting, + }) + return err +} \ No newline at end of file diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index acd514deab..048992d6e8 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -836,11 +836,15 @@ func assignIssueToProjectColumn(ctx *context.APIContext, add bool) { return } - projectID := column.ProjectID + if err := issue.LoadProjects(ctx); err != nil { + ctx.APIErrorInternal(err) + return + } + currentProjectIDs := make([]int64, 0, len(issue.Projects)) + for _, p := range issue.Projects { + currentProjectIDs = append(currentProjectIDs, p.ID) + } if !add { - // Confirm the issue is currently in this specific column before removing, - // since IssueAssignOrRemoveProject(projectID=0) clears the issue's project - // assignment unconditionally. exists, err := project_model.IsIssueInColumn(ctx, issue.ID, column.ProjectID, column.ID) if err != nil { ctx.APIErrorInternal(err) @@ -850,11 +854,38 @@ func assignIssueToProjectColumn(ctx *context.APIContext, add bool) { ctx.APIErrorNotFound() return } - projectID = 0 - } - if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, []int64{projectID}); err != nil { - ctx.APIErrorInternal(err) - return + newProjectIDs := make([]int64, 0, len(currentProjectIDs)) + for _, id := range currentProjectIDs { + if id != column.ProjectID { + newProjectIDs = append(newProjectIDs, id) + } + } + if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, newProjectIDs); err != nil { + ctx.APIErrorInternal(err) + return + } + } else { + // Check if issue is already in this project + alreadyInProject := false + for _, id := range currentProjectIDs { + if id == column.ProjectID { + alreadyInProject = true + break + } + } + if !alreadyInProject { + // Add to project first (lands in default column) + newProjectIDs := append(currentProjectIDs, column.ProjectID) + if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, newProjectIDs); err != nil { + ctx.APIErrorInternal(err) + return + } + } + // Move to target column + if err := project_model.MoveIssueToColumn(ctx, issue.ID, column.ProjectID, column.ID); err != nil { + ctx.APIErrorInternal(err) + return + } } if add { diff --git a/tests/integration/api_repo_project_test.go b/tests/integration/api_repo_project_test.go index c3be285125..ec62d4e962 100644 --- a/tests/integration/api_repo_project_test.go +++ b/tests/integration/api_repo_project_test.go @@ -563,9 +563,9 @@ func testAPIListProjectColumnIssues(t *testing.T) { err = project_model.NewColumn(t.Context(), column) assert.NoError(t, err) - err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue, owner, project.ID, column.ID) + err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue, owner, []int64{project.ID}) assert.NoError(t, err) - err = issues_model.IssueAssignOrRemoveProject(t.Context(), pull, owner, project.ID, column.ID) + err = issues_model.IssueAssignOrRemoveProject(t.Context(), pull, owner, []int64{project.ID}) assert.NoError(t, err) token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue) @@ -617,7 +617,7 @@ func testAPIRemoveIssueFromProjectColumn(t *testing.T) { err = project_model.NewColumn(t.Context(), otherColumn) assert.NoError(t, err) - err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue, owner, project.ID, column.ID) + err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue, owner, []int64{project.ID}) assert.NoError(t, err) token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) diff --git a/tests/integration/project_test.go b/tests/integration/project_test.go index 00764bf883..adef22b8e0 100644 --- a/tests/integration/project_test.go +++ b/tests/integration/project_test.go @@ -298,11 +298,10 @@ func TestOrgProjectFilterByMilestone(t *testing.T) { columns, err := project_model.GetProjectColumns(t.Context(), project.ID, db.ListOptionsAll) require.NoError(t, err) require.NotEmpty(t, columns) - defaultColumnID := columns[0].ID // Add issues to the project - require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue16, user1, project.ID, defaultColumnID)) - require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue17, user1, project.ID, defaultColumnID)) + require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue16, user1, []int64{project.ID})) + require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue17, user1, []int64{project.ID})) sess := loginUser(t, "user1") projectURL := fmt.Sprintf("/org3/-/projects/%d", project.ID) From ac8466f3303115b66c1196f752530accc4efa391 Mon Sep 17 00:00:00 2001 From: beardev-in Date: Sun, 3 May 2026 16:04:23 +0530 Subject: [PATCH 32/37] fix: add sorting range validation, reject out-of-range int8 values with 400 --- routers/api/v1/repo/project.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index 048992d6e8..464270c968 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -574,6 +574,10 @@ func EditProjectColumn(ctx *context.APIContext) { column.Color = *form.Color } if form.Sorting != nil { + if *form.Sorting < -128 || *form.Sorting > 127 { + ctx.APIError(http.StatusBadRequest, "sorting value out of range, must be between -128 and 127") + return + } column.Sorting = int8(*form.Sorting) } From abeda1132509b49f5739f86258fc52f07daf09e5 Mon Sep 17 00:00:00 2001 From: beardev-in Date: Sun, 3 May 2026 16:09:42 +0530 Subject: [PATCH 33/37] chore: regenerate swagger spec --- templates/swagger/v1_json.tmpl | 922 +++++++++++++++++-- templates/swagger/v1_openapi3_json.tmpl | 1090 ++++++++++++++++++++++- 2 files changed, 1907 insertions(+), 105 deletions(-) diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index bec1272649..86d201a984 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -1,11 +1,9 @@ { "consumes": [ - "application/json", - "text/plain" + "application/json" ], "produces": [ - "application/json", - "text/html" + "application/json" ], "schemes": [ "https", @@ -76,9 +74,17 @@ ], "summary": "Get all runners", "operationId": "getAdminRunners", + "parameters": [ + { + "type": "boolean", + "description": "filter by disabled status (true or false)", + "name": "disabled", + "in": "query" + } + ], "responses": { "200": { - "$ref": "#/definitions/ActionRunnersResponse" + "$ref": "#/responses/RunnerList" }, "400": { "$ref": "#/responses/error" @@ -127,7 +133,7 @@ ], "responses": { "200": { - "$ref": "#/definitions/ActionRunner" + "$ref": "#/responses/Runner" }, "400": { "$ref": "#/responses/error" @@ -166,6 +172,49 @@ "$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": { @@ -1947,11 +1996,17 @@ "name": "org", "in": "path", "required": true + }, + { + "type": "boolean", + "description": "filter by disabled status (true or false)", + "name": "disabled", + "in": "query" } ], "responses": { "200": { - "$ref": "#/definitions/ActionRunnersResponse" + "$ref": "#/responses/RunnerList" }, "400": { "$ref": "#/responses/error" @@ -2016,7 +2071,7 @@ ], "responses": { "200": { - "$ref": "#/definitions/ActionRunner" + "$ref": "#/responses/Runner" }, "400": { "$ref": "#/responses/error" @@ -2062,6 +2117,56 @@ "$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": { @@ -3528,6 +3633,39 @@ "$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": { @@ -3730,6 +3868,7 @@ "rpm", "rubygems", "swift", + "terraform", "vagrant" ], "type": "string", @@ -3807,6 +3946,44 @@ "$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": { @@ -3992,8 +4169,8 @@ "tags": [ "package" ], - "summary": "Delete a package", - "operationId": "deletePackage", + "summary": "Delete a package version", + "operationId": "deletePackageVersion", "parameters": [ { "type": "string", @@ -4872,11 +5049,17 @@ "name": "repo", "in": "path", "required": true + }, + { + "type": "boolean", + "description": "filter by disabled status (true or false)", + "name": "disabled", + "in": "query" } ], "responses": { "200": { - "$ref": "#/definitions/ActionRunnersResponse" + "$ref": "#/responses/RunnerList" }, "400": { "$ref": "#/responses/error" @@ -4928,7 +5111,7 @@ "tags": [ "repository" ], - "summary": "Get an repo-level runner", + "summary": "Get a repo-level runner", "operationId": "getRepoRunner", "parameters": [ { @@ -4955,7 +5138,7 @@ ], "responses": { "200": { - "$ref": "#/definitions/ActionRunner" + "$ref": "#/responses/Runner" }, "400": { "$ref": "#/responses/error" @@ -4972,7 +5155,7 @@ "tags": [ "repository" ], - "summary": "Delete an repo-level runner", + "summary": "Delete a repo-level runner", "operationId": "deleteRepoRunner", "parameters": [ { @@ -5008,6 +5191,63 @@ "$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": { @@ -5117,7 +5357,7 @@ "required": true }, { - "type": "string", + "type": "integer", "description": "id of the run", "name": "run", "in": "path", @@ -5233,6 +5473,130 @@ } } }, + "/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": [ @@ -5350,6 +5714,9 @@ "404": { "$ref": "#/responses/notFound" }, + "409": { + "$ref": "#/responses/error" + }, "422": { "$ref": "#/responses/validationError" } @@ -5402,6 +5769,61 @@ "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" } @@ -10011,7 +10433,7 @@ } ], "responses": { - "200": { + "204": { "$ref": "#/responses/empty" }, "403": { @@ -10145,6 +10567,7 @@ } }, "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" ], @@ -11750,7 +12173,7 @@ } ], "responses": { - "200": { + "204": { "$ref": "#/responses/empty" }, "403": { @@ -14797,6 +15220,75 @@ } } }, + "/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": [ @@ -15026,6 +15518,9 @@ "200": { "$ref": "#/responses/empty" }, + "403": { + "$ref": "#/responses/forbidden" + }, "404": { "$ref": "#/responses/notFound" }, @@ -15262,7 +15757,7 @@ "tags": [ "repository" ], - "summary": "Create a review to an pull request", + "summary": "Create a review to a pull request", "operationId": "repoCreatePullReview", "parameters": [ { @@ -15367,7 +15862,7 @@ "tags": [ "repository" ], - "summary": "Submit a pending review to an pull request", + "summary": "Submit a pending review to a pull request", "operationId": "repoSubmitPullReview", "parameters": [ { @@ -16032,7 +16527,7 @@ }, { "type": "boolean", - "description": "filter (exclude / include) drafts, if you dont have repo write access none will show", + "description": "filter (exclude / include) drafts, if you don't have repo write access none will show", "name": "draft", "in": "query" }, @@ -19191,9 +19686,17 @@ ], "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": "#/definitions/ActionRunnersResponse" + "$ref": "#/responses/RunnerList" }, "400": { "$ref": "#/responses/error" @@ -19212,7 +19715,7 @@ "tags": [ "user" ], - "summary": "Get an user's actions runner registration token", + "summary": "Get a user's actions runner registration token", "operationId": "userCreateRunnerRegistrationToken", "responses": { "200": { @@ -19229,7 +19732,7 @@ "tags": [ "user" ], - "summary": "Get an user-level runner", + "summary": "Get a user-level runner", "operationId": "getUserRunner", "parameters": [ { @@ -19242,7 +19745,7 @@ ], "responses": { "200": { - "$ref": "#/definitions/ActionRunner" + "$ref": "#/responses/Runner" }, "400": { "$ref": "#/responses/error" @@ -19259,7 +19762,7 @@ "tags": [ "user" ], - "summary": "Delete an user-level runner", + "summary": "Delete a user-level runner", "operationId": "deleteUserRunner", "parameters": [ { @@ -19281,6 +19784,49 @@ "$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": { @@ -19760,6 +20306,9 @@ "200": { "$ref": "#/responses/OAuth2Application" }, + "400": { + "$ref": "#/responses/error" + }, "404": { "$ref": "#/responses/notFound" } @@ -21865,6 +22414,10 @@ "type": "boolean", "x-go-name": "Busy" }, + "disabled": { + "type": "boolean", + "x-go-name": "Disabled" + }, "ephemeral": { "type": "boolean", "x-go-name": "Ephemeral" @@ -22295,6 +22848,11 @@ "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" }, @@ -22304,6 +22862,7 @@ "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" @@ -22490,12 +23049,14 @@ "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" } }, @@ -23937,6 +24498,11 @@ }, "x-go-name": "Events" }, + "name": { + "description": "Optional human-readable name for the webhook", + "type": "string", + "x-go-name": "Name" + }, "type": { "type": "string", "enum": [ @@ -24025,6 +24591,15 @@ "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" @@ -24217,13 +24792,14 @@ "x-go-name": "UserName" }, "visibility": { - "description": "possible values are `public` (default), `limited` or `private`", + "description": "possible values are `public` (default), `limited` or `private`\npublic UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "type": "string", "enum": [ "public", "limited", "private" ], + "x-go-enum-desc": "public UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "x-go-name": "Visibility" }, "website": { @@ -24390,6 +24966,17 @@ }, "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", @@ -24410,7 +24997,16 @@ "x-go-name": "CommitID" }, "event": { - "$ref": "#/definitions/ReviewStateType" + "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" } }, "x-go-package": "code.gitea.io/gitea/modules/structs" @@ -24535,12 +25131,13 @@ "x-go-name": "Name" }, "object_format_name": { - "description": "ObjectFormatName of the underlying git repository, empty string for default (sha1)", + "description": "ObjectFormatName of the underlying git repository, empty string for default (sha1)\nsha1 ObjectFormatSHA1\nsha256 ObjectFormatSHA256", "type": "string", "enum": [ "sha1", "sha256" ], + "x-go-enum-desc": "sha1 ObjectFormatSHA1\nsha256 ObjectFormatSHA256", "x-go-name": "ObjectFormatName" }, "private": { @@ -24693,6 +25290,7 @@ "write", "admin" ], + "x-go-enum-desc": "read RepoWritePermissionRead\nwrite RepoWritePermissionWrite\nadmin RepoWritePermissionAdmin", "x-go-name": "Permission" }, "units": { @@ -24787,8 +25385,14 @@ "x-go-name": "Username" }, "visibility": { - "description": "User visibility level: public, limited, or private", + "description": "User visibility level: public, limited, or private\npublic UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "type": "string", + "enum": [ + "public", + "limited", + "private" + ], + "x-go-enum-desc": "public UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "x-go-name": "Visibility" } }, @@ -25002,6 +25606,20 @@ }, "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", @@ -25227,6 +25845,11 @@ "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" @@ -25265,6 +25888,12 @@ "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", @@ -25275,6 +25904,15 @@ "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" @@ -25344,6 +25982,10 @@ "state": { "description": "State indicates the updated state of the milestone", "type": "string", + "enum": [ + "open", + "closed" + ], "x-go-name": "State" }, "title": { @@ -25364,7 +26006,7 @@ "x-go-name": "Description" }, "email": { - "description": "The email address of the organization", + "description": "The email address of the organization; use empty string to clear", "type": "string", "x-go-name": "Email" }, @@ -25384,13 +26026,14 @@ "x-go-name": "RepoAdminChangeTeamAccess" }, "visibility": { - "description": "possible values are `public`, `limited` or `private`", + "description": "possible values are `public`, `limited` or `private`\npublic UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "type": "string", "enum": [ "public", "limited", "private" ], + "x-go-enum-desc": "public UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "x-go-name": "Visibility" }, "website": { @@ -25483,6 +26126,12 @@ "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", @@ -25813,6 +26462,7 @@ "write", "admin" ], + "x-go-enum-desc": "read RepoWritePermissionRead\nwrite RepoWritePermissionWrite\nadmin RepoWritePermissionAdmin", "x-go-name": "Permission" }, "units": { @@ -25943,8 +26593,14 @@ "x-go-name": "SourceID" }, "visibility": { - "description": "User visibility level: public, limited, or private", + "description": "User visibility level: public, limited, or private\npublic UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "type": "string", + "enum": [ + "public", + "limited", + "private" + ], + "x-go-enum-desc": "public UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "x-go-name": "Visibility" }, "website": { @@ -26696,6 +27352,11 @@ "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", @@ -26783,6 +27444,12 @@ "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", @@ -26835,6 +27502,13 @@ "format": "int64", "x-go-name": "PinOrder" }, + "projects": { + "type": "array", + "items": { + "$ref": "#/definitions/Project" + }, + "x-go-name": "Projects" + }, "pull_request": { "$ref": "#/definitions/PullRequestMeta" }, @@ -26846,7 +27520,13 @@ "$ref": "#/definitions/RepositoryMeta" }, "state": { - "$ref": "#/definitions/StateType" + "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" }, "time_estimate": { "type": "integer", @@ -26947,7 +27627,16 @@ "x-go-name": "ID" }, "type": { - "$ref": "#/definitions/IssueFormFieldType" + "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" }, "validations": { "type": "object", @@ -26957,23 +27646,18 @@ "visible": { "type": "array", "items": { - "$ref": "#/definitions/IssueFormFieldVisible" + "type": "string", + "enum": [ + "form", + "content" + ], + "x-go-enum-desc": "form IssueFormFieldVisibleForm\ncontent IssueFormFieldVisibleContent" }, "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", @@ -27248,10 +27932,14 @@ "description": "MergePullRequestForm form for merging Pull Request", "type": "object", "required": [ - "Do" + "do" ], "properties": { - "Do": { + "delete_branch_after_merge": { + "type": "boolean", + "x-go-name": "DeleteBranchAfterMerge" + }, + "do": { "type": "string", "enum": [ "merge", @@ -27260,20 +27948,8 @@ "squash", "fast-forward-only", "manually-merged" - ] - }, - "MergeCommitID": { - "type": "string" - }, - "MergeMessageField": { - "type": "string" - }, - "MergeTitleField": { - "type": "string" - }, - "delete_branch_after_merge": { - "type": "boolean", - "x-go-name": "DeleteBranchAfterMerge" + ], + "x-go-name": "Do" }, "force_merge": { "type": "boolean", @@ -27283,6 +27959,18 @@ "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" @@ -27471,7 +28159,14 @@ "x-go-name": "OpenIssues" }, "state": { - "$ref": "#/definitions/StateType" + "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" }, "title": { "description": "Title is the title of the milestone", @@ -27707,7 +28402,15 @@ "x-go-name": "LatestCommentURL" }, "state": { - "$ref": "#/definitions/StateType" + "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" }, "title": { "description": "Title is the title of the notification subject", @@ -27715,7 +28418,16 @@ "x-go-name": "Title" }, "type": { - "$ref": "#/definitions/NotifySubjectType" + "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" }, "url": { "description": "URL is the API URL for the notification subject", @@ -27765,11 +28477,6 @@ }, "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.", @@ -27873,8 +28580,14 @@ "x-go-name": "UserName" }, "visibility": { - "description": "The visibility level of the organization (public, limited, private)", + "description": "The visibility level of the organization (public, limited, private)\npublic UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "type": "string", + "enum": [ + "public", + "limited", + "private" + ], + "x-go-enum-desc": "public UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "x-go-name": "Visibility" }, "website": { @@ -28422,6 +29135,12 @@ "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", @@ -28546,7 +29265,14 @@ "x-go-name": "ReviewComments" }, "state": { - "$ref": "#/definitions/StateType" + "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" }, "title": { "description": "The title of the pull request", @@ -28638,7 +29364,16 @@ "x-go-name": "Stale" }, "state": { - "$ref": "#/definitions/ReviewStateType" + "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" }, "submitted_at": { "type": "string", @@ -28980,8 +29715,16 @@ "type": "object", "properties": { "permission": { - "description": "Permission level of the collaborator", + "description": "Permission level of the collaborator\nnone AccessLevelNameNone\nread AccessLevelNameRead\nwrite AccessLevelNameWrite\nadmin AccessLevelNameAdmin\nowner AccessLevelNameOwner", "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": { @@ -29258,12 +30001,13 @@ "x-go-name": "Name" }, "object_format_name": { - "description": "ObjectFormatName of the underlying git repository", + "description": "ObjectFormatName of the underlying git repository\nsha1 ObjectFormatSHA1\nsha256 ObjectFormatSHA256", "type": "string", "enum": [ "sha1", "sha256" ], + "x-go-enum-desc": "sha1 ObjectFormatSHA1\nsha256 ObjectFormatSHA256", "x-go-name": "ObjectFormatName" }, "open_issues_count": { @@ -29375,11 +30119,6 @@ }, "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", @@ -29454,11 +30193,6 @@ }, "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", @@ -29512,7 +30246,16 @@ "x-go-name": "Body" }, "event": { - "$ref": "#/definitions/ReviewStateType" + "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" } }, "x-go-package": "code.gitea.io/gitea/modules/structs" @@ -29638,6 +30381,7 @@ "admin", "owner" ], + "x-go-enum-desc": "none AccessLevelNameNone\nread AccessLevelNameRead\nwrite AccessLevelNameWrite\nadmin AccessLevelNameAdmin\nowner AccessLevelNameOwner", "x-go-name": "Permission" }, "units": { @@ -30184,8 +30928,14 @@ "x-go-name": "StarredRepos" }, "visibility": { - "description": "User visibility level option: public, limited, private", + "description": "User visibility level option: public, limited, private\npublic UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "type": "string", + "enum": [ + "public", + "limited", + "private" + ], + "x-go-enum-desc": "public UserVisibilityPublic\nlimited UserVisibilityLimited\nprivate UserVisibilityPrivate", "x-go-name": "Visibility" }, "website": { diff --git a/templates/swagger/v1_openapi3_json.tmpl b/templates/swagger/v1_openapi3_json.tmpl index 33adff75e0..687e08b832 100644 --- a/templates/swagger/v1_openapi3_json.tmpl +++ b/templates/swagger/v1_openapi3_json.tmpl @@ -980,6 +980,52 @@ }, "description": "PackageList" }, + "Project": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + }, + "description": "Project" + }, + "ProjectColumn": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectColumn" + } + } + }, + "description": "ProjectColumn" + }, + "ProjectColumnList": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ProjectColumn" + }, + "type": "array" + } + } + }, + "description": "ProjectColumnList" + }, + "ProjectList": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/Project" + }, + "type": "array" + } + } + }, + "description": "ProjectList" + }, "PublicKey": { "content": { "application/json": { @@ -1690,7 +1736,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LockIssueOption" + "$ref": "#/components/schemas/MoveProjectIssueOption" } } }, @@ -4317,6 +4363,53 @@ "type": "object", "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "CreateProjectColumnOption": { + "description": "CreateProjectColumnOption represents options for creating a project column", + "properties": { + "color": { + "description": "Column color in 6-digit hex format, e.g. #FF0000", + "type": "string", + "x-go-name": "Color" + }, + "title": { + "type": "string", + "x-go-name": "Title" + } + }, + "required": [ + "title" + ], + "type": "object", + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "CreateProjectOption": { + "description": "CreateProjectOption represents options for creating a project", + "properties": { + "card_type": { + "description": "Card type: \"text_only\" or \"images_and_text\"", + "type": "string", + "x-go-name": "CardType" + }, + "description": { + "type": "string", + "x-go-name": "Description" + }, + "template_type": { + "description": "Template type: \"none\", \"basic_kanban\" or \"bug_triage\"", + "type": "string", + "x-go-name": "TemplateType" + }, + "title": { + "type": "string", + "x-go-name": "Title" + } + }, + "required": [ + "title" + ], + "type": "object", + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "CreatePullRequestOption": { "description": "CreatePullRequestOption options when creating a pull request", "properties": { @@ -5476,6 +5569,50 @@ "type": "object", "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "EditProjectColumnOption": { + "description": "EditProjectColumnOption represents options for editing a project column", + "properties": { + "color": { + "description": "Column color in 6-digit hex format, e.g. #FF0000", + "type": "string", + "x-go-name": "Color" + }, + "sorting": { + "format": "int64", + "type": "integer", + "x-go-name": "Sorting" + }, + "title": { + "type": "string", + "x-go-name": "Title" + } + }, + "type": "object", + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "EditProjectOption": { + "description": "EditProjectOption represents options for editing a project", + "properties": { + "card_type": { + "description": "Card type: \"text_only\" or \"images_and_text\"", + "type": "string", + "x-go-name": "CardType" + }, + "description": { + "type": "string", + "x-go-name": "Description" + }, + "state": { + "$ref": "#/components/schemas/StateType" + }, + "title": { + "type": "string", + "x-go-name": "Title" + } + }, + "type": "object", + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "EditPullRequestOption": { "description": "EditPullRequestOption options when modify pull request", "properties": { @@ -7562,6 +7699,28 @@ "type": "object", "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "MoveProjectIssueOption": { + "description": "MoveProjectIssueOption represents options for moving an issue between columns", + "properties": { + "column_id": { + "description": "Target column to move the issue into", + "format": "int64", + "type": "integer", + "x-go-name": "ColumnID" + }, + "sorting": { + "description": "Optional sorting position within the target column", + "format": "int64", + "type": "integer", + "x-go-name": "Sorting" + } + }, + "required": [ + "column_id" + ], + "type": "object", + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "NewIssuePinsAllowed": { "description": "NewIssuePinsAllowed represents an API response that says if new Issue Pins are allowed", "properties": { @@ -8256,61 +8415,139 @@ "x-go-package": "code.gitea.io/gitea/modules/structs" }, "Project": { - "description": "Project represents a project", + "description": "Gitea projects can only contain issues — note cards and pull requests are\nnot modeled as project items.", "properties": { + "card_type": { + "description": "Card type: \"text_only\" or \"images_and_text\"", + "type": "string", + "x-go-name": "CardType" + }, "closed_at": { "format": "date-time", "type": "string", - "x-go-name": "Closed" + "x-go-name": "ClosedAt" }, "created_at": { "format": "date-time", "type": "string", - "x-go-name": "Created" + "x-go-name": "CreatedAt" }, - "creator_id": { - "description": "CreatorID is the user who created the project", - "format": "int64", - "type": "integer", - "x-go-name": "CreatorID" + "creator": { + "$ref": "#/components/schemas/User" }, "description": { - "description": "Description provides details about the project", "type": "string", "x-go-name": "Description" }, + "html_url": { + "format": "uri", + "type": "string", + "x-go-name": "HTMLURL" + }, "id": { - "description": "ID is the unique identifier for the project", "format": "int64", "type": "integer", "x-go-name": "ID" }, - "is_closed": { - "description": "IsClosed indicates if the project is closed", - "type": "boolean", - "x-go-name": "IsClosed" + "num_closed_issues": { + "format": "int64", + "type": "integer", + "x-go-name": "NumClosedIssues" + }, + "num_issues": { + "format": "int64", + "type": "integer", + "x-go-name": "NumIssues" + }, + "num_open_issues": { + "format": "int64", + "type": "integer", + "x-go-name": "NumOpenIssues" }, "owner_id": { - "description": "OwnerID is the owner of the project (for org-level projects)", "format": "int64", "type": "integer", "x-go-name": "OwnerID" }, "repo_id": { - "description": "RepoID is the repository this project belongs to (for repo-level projects)", "format": "int64", "type": "integer", "x-go-name": "RepoID" }, + "state": { + "$ref": "#/components/schemas/StateType" + }, + "template_type": { + "description": "Template type: \"none\", \"basic_kanban\" or \"bug_triage\"", + "type": "string", + "x-go-name": "TemplateType" + }, + "title": { + "type": "string", + "x-go-name": "Title" + }, + "type": { + "description": "Project type: \"individual\", \"repository\" or \"organization\"", + "type": "string", + "x-go-name": "Type" + }, + "updated_at": { + "format": "date-time", + "type": "string", + "x-go-name": "UpdatedAt" + } + }, + "title": "Project represents a project.", + "type": "object", + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "ProjectColumn": { + "description": "ProjectColumn represents a project column (board)", + "properties": { + "color": { + "type": "string", + "x-go-name": "Color" + }, + "created_at": { + "format": "date-time", + "type": "string", + "x-go-name": "CreatedAt" + }, + "creator": { + "$ref": "#/components/schemas/User" + }, + "default": { + "type": "boolean", + "x-go-name": "Default" + }, + "id": { + "format": "int64", + "type": "integer", + "x-go-name": "ID" + }, + "num_issues": { + "format": "int64", + "type": "integer", + "x-go-name": "NumIssues" + }, + "project_id": { + "format": "int64", + "type": "integer", + "x-go-name": "ProjectID" + }, + "sorting": { + "format": "int64", + "type": "integer", + "x-go-name": "Sorting" + }, "title": { - "description": "Title is the title of the project", "type": "string", "x-go-name": "Title" }, "updated_at": { "format": "date-time", "type": "string", - "x-go-name": "Updated" + "x-go-name": "UpdatedAt" } }, "type": "object", @@ -25509,6 +25746,821 @@ ] } }, + "/repos/{owner}/{repo}/projects": { + "get": { + "description": "Gitea projects only contain issues — note cards and pull requests are not modeled as project items.", + "operationId": "repoListProjects", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "State of the project (open, closed, all)", + "in": "query", + "name": "state", + "schema": { + "default": "open", + "enum": [ + "open", + "closed", + "all" + ], + "type": "string" + } + }, + { + "description": "page number of results", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "page size of results", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ProjectList" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "List projects in a repository", + "tags": [ + "repository" + ] + }, + "post": { + "operationId": "repoCreateProject", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProjectOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "201": { + "$ref": "#/components/responses/Project" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Create a new project", + "tags": [ + "repository" + ] + } + }, + "/repos/{owner}/{repo}/projects/{id}": { + "delete": { + "operationId": "repoDeleteProject", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Delete a project", + "tags": [ + "repository" + ] + }, + "get": { + "description": "Gitea projects only contain issues — note cards and pull requests are not modeled as project items.", + "operationId": "repoGetProject", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Project" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Get a single project", + "tags": [ + "repository" + ] + }, + "patch": { + "operationId": "repoEditProject", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditProjectOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "200": { + "$ref": "#/components/responses/Project" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Edit a project", + "tags": [ + "repository" + ] + } + }, + "/repos/{owner}/{repo}/projects/{id}/columns": { + "get": { + "operationId": "repoListProjectColumns", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "page number of results to return (1-based)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "page size of results", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ProjectColumnList" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "List columns in a project", + "tags": [ + "repository" + ] + }, + "post": { + "operationId": "repoCreateProjectColumn", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProjectColumnOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "201": { + "$ref": "#/components/responses/ProjectColumn" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Create a new column in a project", + "tags": [ + "repository" + ] + } + }, + "/repos/{owner}/{repo}/projects/{id}/columns/{column_id}": { + "delete": { + "operationId": "repoDeleteProjectColumn", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Delete a project column", + "tags": [ + "repository" + ] + }, + "patch": { + "operationId": "repoEditProjectColumn", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditProjectColumnOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "200": { + "$ref": "#/components/responses/ProjectColumn" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Edit a project column", + "tags": [ + "repository" + ] + } + }, + "/repos/{owner}/{repo}/projects/{id}/columns/{column_id}/issues": { + "get": { + "description": "Gitea projects only contain issues — note cards and pull requests are not modeled as project items.", + "operationId": "repoListProjectColumnIssues", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "page number of results to return (1-based)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "page size of results", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/IssueList" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "List issues in a project column", + "tags": [ + "repository" + ] + } + }, + "/repos/{owner}/{repo}/projects/{id}/columns/{column_id}/issues/{issue_id}": { + "delete": { + "operationId": "repoRemoveIssueFromProjectColumn", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the issue", + "in": "path", + "name": "issue_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Remove an issue from a project column", + "tags": [ + "repository" + ] + }, + "post": { + "description": "Gitea projects only contain issues — note cards and pull requests cannot be added.", + "operationId": "repoAddIssueToProjectColumn", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the issue", + "in": "path", + "name": "issue_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "201": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Add an issue to a project column", + "tags": [ + "repository" + ] + } + }, + "/repos/{owner}/{repo}/projects/{id}/issues/{issue_id}/move": { + "post": { + "description": "Atomically moves an existing project issue into a different column, optionally setting its sorting position.", + "operationId": "repoMoveProjectIssue", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repo", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the issue", + "in": "path", + "name": "issue_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MoveProjectIssueOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Move an issue between columns of a project", + "tags": [ + "repository" + ] + } + }, "/repos/{owner}/{repo}/pulls": { "get": { "operationId": "repoListPullRequests", From 542eb39d15ddcbe907667d9078a79a56552baa43 Mon Sep 17 00:00:00 2001 From: beardev-in Date: Sun, 3 May 2026 17:26:03 +0530 Subject: [PATCH 34/37] fix: address lint errors - import grouping, slices.Contains, append style --- models/project/issue.go | 4 +--- routers/api/v1/repo/project.go | 12 ++++-------- services/projects/issue.go | 6 +++--- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/models/project/issue.go b/models/project/issue.go index eaf79d2cfd..5f152b4189 100644 --- a/models/project/issue.go +++ b/models/project/issue.go @@ -95,8 +95,6 @@ func DeleteAllProjectIssueByIssueIDsAndProjectIDs(ctx context.Context, issueIDs, _, err := db.GetEngine(ctx).In("project_id", projectIDs).In("issue_id", issueIDs).Delete(&ProjectIssue{}) return err } - - // MoveIssueToColumn moves a single issue to a specific column within a project. func MoveIssueToColumn(ctx context.Context, issueID, projectID, columnID int64) error { nextSorting, err := GetColumnIssueNextSorting(ctx, projectID, columnID) @@ -111,4 +109,4 @@ func MoveIssueToColumn(ctx context.Context, issueID, projectID, columnID int64) Sorting: nextSorting, }) return err -} \ No newline at end of file +} diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index 464270c968..74f19c8017 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -870,16 +870,12 @@ func assignIssueToProjectColumn(ctx *context.APIContext, add bool) { } } else { // Check if issue is already in this project - alreadyInProject := false - for _, id := range currentProjectIDs { - if id == column.ProjectID { - alreadyInProject = true - break - } - } + alreadyInProject := slices.Contains(currentProjectIDs, column.ProjectID) if !alreadyInProject { // Add to project first (lands in default column) - newProjectIDs := append(currentProjectIDs, column.ProjectID) + newProjectIDs := make([]int64, len(currentProjectIDs)+1) + copy(newProjectIDs, currentProjectIDs) + newProjectIDs[len(currentProjectIDs)] = column.ProjectID if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, newProjectIDs); err != nil { ctx.APIErrorInternal(err) return diff --git a/services/projects/issue.go b/services/projects/issue.go index 01d067508f..3655a000ce 100644 --- a/services/projects/issue.go +++ b/services/projects/issue.go @@ -217,9 +217,9 @@ func LoadIssueNumbersForProject(ctx context.Context, project *project_model.Proj // for user or org projects, we need to check access permissions opts := issues_model.IssuesOptions{ ProjectIDs: []int64{project.ID}, - Doer: doer, - AllPublic: doer == nil, - Owner: project.Owner, + Doer: doer, + AllPublic: doer == nil, + Owner: project.Owner, } var err error From 555bec4778bfa5afbe36cdc9eb9691c30e08cc31 Mon Sep 17 00:00:00 2001 From: beardev-in Date: Sun, 3 May 2026 17:42:34 +0530 Subject: [PATCH 35/37] fix: fix import grouping and use slices.Contains --- routers/api/v1/repo/project.go | 1 + 1 file changed, 1 insertion(+) diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index 74f19c8017..1cbea1c400 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -6,6 +6,7 @@ package repo import ( "errors" "net/http" + "slices" "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" From 56461206f5197ea61e916e9d02c7cd2013a66cfa Mon Sep 17 00:00:00 2001 From: beardev-in Date: Sun, 3 May 2026 17:49:04 +0530 Subject: [PATCH 36/37] fix: backend-check lint errors --- models/project/issue.go | 1 + 1 file changed, 1 insertion(+) diff --git a/models/project/issue.go b/models/project/issue.go index 5f152b4189..9af3254aa0 100644 --- a/models/project/issue.go +++ b/models/project/issue.go @@ -95,6 +95,7 @@ func DeleteAllProjectIssueByIssueIDsAndProjectIDs(ctx context.Context, issueIDs, _, err := db.GetEngine(ctx).In("project_id", projectIDs).In("issue_id", issueIDs).Delete(&ProjectIssue{}) return err } + // MoveIssueToColumn moves a single issue to a specific column within a project. func MoveIssueToColumn(ctx context.Context, issueID, projectID, columnID int64) error { nextSorting, err := GetColumnIssueNextSorting(ctx, projectID, columnID) From ae9fff3d3fbb1eac70e2fa0f14f6caf27b784eb5 Mon Sep 17 00:00:00 2001 From: beardev-in Date: Sun, 3 May 2026 18:55:46 +0530 Subject: [PATCH 37/37] fix: handle id=0 as remove-from-all-projects in UpdateIssueProject --- routers/web/repo/projects.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index e623bff752..a517498fbc 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -293,7 +293,7 @@ func ViewProject(ctx *context.Context) { return } - columns, err := project_model.GetProjectColumns(ctx, project.ID, db.ListOptionsAll) + columns, err := project.GetColumns(ctx) if err != nil { ctx.ServerError("GetProjectColumns", err) return @@ -449,6 +449,14 @@ func UpdateIssueProject(ctx *context.Context) { } projectIDs := ctx.FormStringInt64s("id") + // Remove zero values - id=0 means "remove from all projects" + filteredIDs := make([]int64, 0, len(projectIDs)) + for _, id := range projectIDs { + if id != 0 { + filteredIDs = append(filteredIDs, id) + } + } + projectIDs = filteredIDs var failedIssues []int64 for _, issue := range issues { if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectIDs); err != nil {