1cd81ff925
Adds two improvements to the user/org/repo project-board REST API: * state filter on column-issues endpoints (issue #4) GET /api/v1/{users,orgs}/{name}/-/projects/{id}/columns/{col}/issues GET /api/v1/repos/{owner}/{repo}/projects/{id}/columns/{col}/issues Now accept ?state=open|closed|all (default open), matching the convention on the project-list endpoint and on /repos/.../issues. Applied at the IssuesOptions layer so all three scopes inherit the filter. * populated num_issues / num_open_issues / num_closed_issues on column-list (issue #5) ColumnList.LoadIssueCounts runs two grouped queries against project_issue joined with issue (one open, one closed). All three List*Columns handlers call it before converting, so num_issues stops being null and consumers can render a kanban summary in a single round trip instead of N+1. Tests: * unit: empty-input fast path on ColumnList.LoadIssueCounts. * integration: extended testAPIListProjectColumnIssues / -User / -Org to close an issue, then verify default=open hides it, state=closed and state=all return it, and the column-list response carries the correct open/closed/total split. Closes #4, closes #5
941 lines
25 KiB
Go
941 lines
25 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package org
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"slices"
|
|
|
|
"code.gitea.io/gitea/models/db"
|
|
issues_model "code.gitea.io/gitea/models/issues"
|
|
project_model "code.gitea.io/gitea/models/project"
|
|
"code.gitea.io/gitea/modules/optional"
|
|
api "code.gitea.io/gitea/modules/structs"
|
|
"code.gitea.io/gitea/modules/web"
|
|
"code.gitea.io/gitea/routers/api/v1/utils"
|
|
"code.gitea.io/gitea/routers/common"
|
|
"code.gitea.io/gitea/services/context"
|
|
"code.gitea.io/gitea/services/convert"
|
|
project_service "code.gitea.io/gitea/services/projects"
|
|
)
|
|
|
|
func getOrgProjectByID(ctx *context.APIContext) *project_model.Project {
|
|
project, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.Org.Organization.ID)
|
|
if err != nil {
|
|
if project_model.IsErrProjectNotExist(err) {
|
|
ctx.APIErrorNotFound()
|
|
} else {
|
|
ctx.APIErrorInternal(err)
|
|
}
|
|
return nil
|
|
}
|
|
return project
|
|
}
|
|
|
|
func getOrgProjectColumn(ctx *context.APIContext) (*project_model.Project, *project_model.Column) {
|
|
column, err := project_model.GetColumn(ctx, ctx.PathParamInt64("column_id"))
|
|
if err != nil {
|
|
if project_model.IsErrProjectColumnNotExist(err) {
|
|
ctx.APIErrorNotFound()
|
|
} else {
|
|
ctx.APIErrorInternal(err)
|
|
}
|
|
return nil, nil
|
|
}
|
|
project, err := project_model.GetProjectByIDAndOwner(ctx, column.ProjectID, ctx.Org.Organization.ID)
|
|
if err != nil {
|
|
if project_model.IsErrProjectNotExist(err) {
|
|
ctx.APIErrorNotFound()
|
|
} else {
|
|
ctx.APIErrorInternal(err)
|
|
}
|
|
return nil, nil
|
|
}
|
|
if project.ID != ctx.PathParamInt64("id") {
|
|
ctx.APIErrorNotFound()
|
|
return nil, nil
|
|
}
|
|
return project, column
|
|
}
|
|
|
|
func rejectIfOrgProjectClosed(ctx *context.APIContext, project *project_model.Project) bool {
|
|
if project.IsClosed {
|
|
ctx.APIError(http.StatusForbidden, "project is closed")
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func validateOrgColumnColor(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
|
|
}
|
|
|
|
// ListOrgProjects lists all projects owned by an organization
|
|
func ListOrgProjects(ctx *context.APIContext) {
|
|
// swagger:operation GET /orgs/{org}/projects organization orgListProjects
|
|
// ---
|
|
// summary: List projects owned by an organization
|
|
// description: Gitea projects only contain issues — note cards and pull requests are not modeled as project items.
|
|
// produces:
|
|
// - application/json
|
|
// parameters:
|
|
// - name: org
|
|
// in: path
|
|
// description: name of the organization
|
|
// type: string
|
|
// required: true
|
|
// - name: state
|
|
// in: query
|
|
// description: State of the project (open, closed, all)
|
|
// type: string
|
|
// enum: [open, closed, all]
|
|
// default: open
|
|
// - name: page
|
|
// in: query
|
|
// description: page number of results
|
|
// type: integer
|
|
// - name: limit
|
|
// in: query
|
|
// description: page size of results
|
|
// type: integer
|
|
// responses:
|
|
// "200":
|
|
// "$ref": "#/responses/ProjectList"
|
|
// "404":
|
|
// "$ref": "#/responses/notFound"
|
|
|
|
isClosed := common.ParseIssueFilterStateIsClosed(ctx.FormTrim("state"))
|
|
listOptions := utils.GetListOptions(ctx)
|
|
|
|
projects, count, err := db.FindAndCount[project_model.Project](ctx, project_model.SearchOptions{
|
|
ListOptions: listOptions,
|
|
OwnerID: ctx.Org.Organization.ID,
|
|
IsClosed: isClosed,
|
|
Type: project_model.TypeOrganization,
|
|
})
|
|
if err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
|
|
if err := project_service.LoadIssueNumbersForProjects(ctx, projects, ctx.Doer); err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
|
|
ctx.SetLinkHeader(count, listOptions.PageSize)
|
|
ctx.SetTotalCountHeader(count)
|
|
ctx.JSON(http.StatusOK, convert.ToProjectList(ctx, projects, ctx.Doer))
|
|
}
|
|
|
|
// GetOrgProject gets a single org-scope project
|
|
func GetOrgProject(ctx *context.APIContext) {
|
|
// swagger:operation GET /orgs/{org}/projects/{id} organization orgGetProject
|
|
// ---
|
|
// summary: Get an organization-scope project
|
|
// description: Gitea projects only contain issues — note cards and pull requests are not modeled as project items.
|
|
// produces:
|
|
// - application/json
|
|
// parameters:
|
|
// - name: org
|
|
// in: path
|
|
// description: name of the organization
|
|
// 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 := getOrgProjectByID(ctx)
|
|
if ctx.Written() {
|
|
return
|
|
}
|
|
|
|
if err := project_service.LoadIssueNumbersForProject(ctx, project, ctx.Doer); err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
|
|
ctx.JSON(http.StatusOK, convert.ToProject(ctx, project, ctx.Doer))
|
|
}
|
|
|
|
// CreateOrgProject creates a new org-scope project
|
|
func CreateOrgProject(ctx *context.APIContext) {
|
|
// swagger:operation POST /orgs/{org}/projects organization orgCreateProject
|
|
// ---
|
|
// summary: Create an organization-scope project
|
|
// consumes:
|
|
// - application/json
|
|
// produces:
|
|
// - application/json
|
|
// parameters:
|
|
// - name: org
|
|
// in: path
|
|
// description: name of the organization
|
|
// type: string
|
|
// required: true
|
|
// - name: body
|
|
// in: body
|
|
// schema:
|
|
// "$ref": "#/definitions/CreateProjectOption"
|
|
// responses:
|
|
// "201":
|
|
// "$ref": "#/responses/Project"
|
|
// "403":
|
|
// "$ref": "#/responses/forbidden"
|
|
// "404":
|
|
// "$ref": "#/responses/notFound"
|
|
// "422":
|
|
// "$ref": "#/responses/validationError"
|
|
|
|
form := web.GetForm(ctx).(*api.CreateProjectOption)
|
|
|
|
templateType, err := convert.ProjectTemplateTypeFromString(form.TemplateType)
|
|
if err != nil {
|
|
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
|
|
return
|
|
}
|
|
cardType, err := convert.ProjectCardTypeFromString(form.CardType)
|
|
if err != nil {
|
|
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
|
|
return
|
|
}
|
|
|
|
p := &project_model.Project{
|
|
OwnerID: ctx.Org.Organization.ID,
|
|
Title: form.Title,
|
|
Description: form.Description,
|
|
CreatorID: ctx.Doer.ID,
|
|
TemplateType: templateType,
|
|
CardType: cardType,
|
|
Type: project_model.TypeOrganization,
|
|
}
|
|
|
|
if err := project_model.NewProject(ctx, p); err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
|
|
ctx.JSON(http.StatusCreated, convert.ToProject(ctx, p, ctx.Doer))
|
|
}
|
|
|
|
// EditOrgProject updates an org-scope project
|
|
func EditOrgProject(ctx *context.APIContext) {
|
|
// swagger:operation PATCH /orgs/{org}/projects/{id} organization orgEditProject
|
|
// ---
|
|
// summary: Edit an organization-scope project
|
|
// consumes:
|
|
// - application/json
|
|
// produces:
|
|
// - application/json
|
|
// parameters:
|
|
// - name: org
|
|
// in: path
|
|
// description: name of the organization
|
|
// 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"
|
|
// "403":
|
|
// "$ref": "#/responses/forbidden"
|
|
// "404":
|
|
// "$ref": "#/responses/notFound"
|
|
// "422":
|
|
// "$ref": "#/responses/validationError"
|
|
|
|
project := getOrgProjectByID(ctx)
|
|
if ctx.Written() {
|
|
return
|
|
}
|
|
|
|
form := web.GetForm(ctx).(*api.EditProjectOption)
|
|
|
|
opts := project_service.UpdateProjectOptions{
|
|
Title: optional.FromPtr(form.Title),
|
|
Description: optional.FromPtr(form.Description),
|
|
}
|
|
if form.CardType != nil {
|
|
cardType, err := convert.ProjectCardTypeFromString(*form.CardType)
|
|
if err != nil {
|
|
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
|
|
return
|
|
}
|
|
opts.CardType = optional.Some(cardType)
|
|
}
|
|
if form.State != nil {
|
|
switch *form.State {
|
|
case api.StateOpen:
|
|
opts.IsClosed = optional.Some(false)
|
|
case api.StateClosed:
|
|
opts.IsClosed = optional.Some(true)
|
|
default:
|
|
ctx.APIError(http.StatusUnprocessableEntity, "state must be 'open' or 'closed'")
|
|
return
|
|
}
|
|
}
|
|
if err := project_service.UpdateProject(ctx, project, opts); err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
|
|
if err := project_service.LoadIssueNumbersForProject(ctx, project, ctx.Doer); err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
|
|
ctx.JSON(http.StatusOK, convert.ToProject(ctx, project, ctx.Doer))
|
|
}
|
|
|
|
// DeleteOrgProject deletes an org-scope project
|
|
func DeleteOrgProject(ctx *context.APIContext) {
|
|
// swagger:operation DELETE /orgs/{org}/projects/{id} organization orgDeleteProject
|
|
// ---
|
|
// summary: Delete an organization-scope project
|
|
// parameters:
|
|
// - name: org
|
|
// in: path
|
|
// description: name of the organization
|
|
// type: string
|
|
// required: true
|
|
// - name: id
|
|
// in: path
|
|
// description: id of the project
|
|
// type: integer
|
|
// format: int64
|
|
// required: true
|
|
// responses:
|
|
// "204":
|
|
// "$ref": "#/responses/empty"
|
|
// "403":
|
|
// "$ref": "#/responses/forbidden"
|
|
// "404":
|
|
// "$ref": "#/responses/notFound"
|
|
|
|
project := getOrgProjectByID(ctx)
|
|
if ctx.Written() {
|
|
return
|
|
}
|
|
|
|
if err := project_model.DeleteProjectByID(ctx, project.ID); err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
|
|
ctx.Status(http.StatusNoContent)
|
|
}
|
|
|
|
// ListOrgProjectColumns lists all columns in an org-scope project
|
|
func ListOrgProjectColumns(ctx *context.APIContext) {
|
|
// swagger:operation GET /orgs/{org}/projects/{id}/columns organization orgListProjectColumns
|
|
// ---
|
|
// summary: List columns in an organization-scope project
|
|
// produces:
|
|
// - application/json
|
|
// parameters:
|
|
// - name: org
|
|
// in: path
|
|
// description: name of the organization
|
|
// 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 := getOrgProjectByID(ctx)
|
|
if ctx.Written() {
|
|
return
|
|
}
|
|
|
|
total, err := project_model.CountProjectColumns(ctx, project.ID)
|
|
if err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
|
|
listOptions := utils.GetListOptions(ctx)
|
|
columns, err := project_model.GetProjectColumns(ctx, project.ID, listOptions)
|
|
if err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
|
|
if err := columns.LoadIssueCounts(ctx); err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
|
|
ctx.SetLinkHeader(total, listOptions.PageSize)
|
|
ctx.SetTotalCountHeader(total)
|
|
ctx.JSON(http.StatusOK, convert.ToProjectColumnList(ctx, columns, ctx.Doer))
|
|
}
|
|
|
|
// CreateOrgProjectColumn creates a new column in an org-scope project
|
|
func CreateOrgProjectColumn(ctx *context.APIContext) {
|
|
// swagger:operation POST /orgs/{org}/projects/{id}/columns organization orgCreateProjectColumn
|
|
// ---
|
|
// summary: Create a new column in an organization-scope project
|
|
// consumes:
|
|
// - application/json
|
|
// produces:
|
|
// - application/json
|
|
// parameters:
|
|
// - name: org
|
|
// in: path
|
|
// description: name of the organization
|
|
// type: string
|
|
// required: true
|
|
// - name: id
|
|
// in: path
|
|
// description: id of the project
|
|
// type: integer
|
|
// format: int64
|
|
// required: true
|
|
// - name: body
|
|
// in: body
|
|
// schema:
|
|
// "$ref": "#/definitions/CreateProjectColumnOption"
|
|
// responses:
|
|
// "201":
|
|
// "$ref": "#/responses/ProjectColumn"
|
|
// "403":
|
|
// "$ref": "#/responses/forbidden"
|
|
// "404":
|
|
// "$ref": "#/responses/notFound"
|
|
// "422":
|
|
// "$ref": "#/responses/validationError"
|
|
|
|
project := getOrgProjectByID(ctx)
|
|
if ctx.Written() {
|
|
return
|
|
}
|
|
if rejectIfOrgProjectClosed(ctx, project) {
|
|
return
|
|
}
|
|
|
|
form := web.GetForm(ctx).(*api.CreateProjectColumnOption)
|
|
if !validateOrgColumnColor(ctx, form.Color) {
|
|
return
|
|
}
|
|
|
|
column := &project_model.Column{
|
|
Title: form.Title,
|
|
Color: form.Color,
|
|
ProjectID: project.ID,
|
|
CreatorID: ctx.Doer.ID,
|
|
}
|
|
|
|
if err := project_model.NewColumn(ctx, column); err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
|
|
ctx.JSON(http.StatusCreated, convert.ToProjectColumn(ctx, column, ctx.Doer))
|
|
}
|
|
|
|
// EditOrgProjectColumn updates a column in an org-scope project
|
|
func EditOrgProjectColumn(ctx *context.APIContext) {
|
|
// swagger:operation PATCH /orgs/{org}/projects/{id}/columns/{column_id} organization orgEditProjectColumn
|
|
// ---
|
|
// summary: Edit a column in an organization-scope project
|
|
// consumes:
|
|
// - application/json
|
|
// produces:
|
|
// - application/json
|
|
// parameters:
|
|
// - name: org
|
|
// in: path
|
|
// description: name of the organization
|
|
// type: string
|
|
// required: true
|
|
// - name: id
|
|
// in: path
|
|
// description: id of the project
|
|
// type: integer
|
|
// format: int64
|
|
// required: true
|
|
// - name: column_id
|
|
// in: path
|
|
// description: id of the column
|
|
// type: integer
|
|
// format: int64
|
|
// required: true
|
|
// - name: body
|
|
// in: body
|
|
// schema:
|
|
// "$ref": "#/definitions/EditProjectColumnOption"
|
|
// responses:
|
|
// "200":
|
|
// "$ref": "#/responses/ProjectColumn"
|
|
// "403":
|
|
// "$ref": "#/responses/forbidden"
|
|
// "404":
|
|
// "$ref": "#/responses/notFound"
|
|
// "422":
|
|
// "$ref": "#/responses/validationError"
|
|
|
|
project, column := getOrgProjectColumn(ctx)
|
|
if ctx.Written() {
|
|
return
|
|
}
|
|
if rejectIfOrgProjectClosed(ctx, project) {
|
|
return
|
|
}
|
|
|
|
form := web.GetForm(ctx).(*api.EditProjectColumnOption)
|
|
|
|
if form.Color != nil && !validateOrgColumnColor(ctx, *form.Color) {
|
|
return
|
|
}
|
|
|
|
if form.Title != nil {
|
|
column.Title = *form.Title
|
|
}
|
|
if form.Color != nil {
|
|
column.Color = *form.Color
|
|
}
|
|
if form.Sorting != nil {
|
|
if *form.Sorting < -128 || *form.Sorting > 127 {
|
|
ctx.APIError(http.StatusBadRequest, "sorting value out of range, must be between -128 and 127")
|
|
return
|
|
}
|
|
column.Sorting = int8(*form.Sorting)
|
|
}
|
|
|
|
if err := project_model.UpdateColumn(ctx, column); err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
|
|
ctx.JSON(http.StatusOK, convert.ToProjectColumn(ctx, column, ctx.Doer))
|
|
}
|
|
|
|
// DeleteOrgProjectColumn deletes a column in an org-scope project
|
|
func DeleteOrgProjectColumn(ctx *context.APIContext) {
|
|
// swagger:operation DELETE /orgs/{org}/projects/{id}/columns/{column_id} organization orgDeleteProjectColumn
|
|
// ---
|
|
// summary: Delete a column in an organization-scope project
|
|
// parameters:
|
|
// - name: org
|
|
// in: path
|
|
// description: name of the organization
|
|
// type: string
|
|
// required: true
|
|
// - name: id
|
|
// in: path
|
|
// description: id of the project
|
|
// type: integer
|
|
// format: int64
|
|
// required: true
|
|
// - name: column_id
|
|
// in: path
|
|
// description: id of the column
|
|
// type: integer
|
|
// format: int64
|
|
// required: true
|
|
// responses:
|
|
// "204":
|
|
// "$ref": "#/responses/empty"
|
|
// "403":
|
|
// "$ref": "#/responses/forbidden"
|
|
// "404":
|
|
// "$ref": "#/responses/notFound"
|
|
|
|
project, column := getOrgProjectColumn(ctx)
|
|
if ctx.Written() {
|
|
return
|
|
}
|
|
if rejectIfOrgProjectClosed(ctx, project) {
|
|
return
|
|
}
|
|
|
|
if err := project_model.DeleteColumnByID(ctx, column.ID); err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
|
|
ctx.Status(http.StatusNoContent)
|
|
}
|
|
|
|
// ListOrgProjectColumnIssues lists all issues in an org-scope project column
|
|
func ListOrgProjectColumnIssues(ctx *context.APIContext) {
|
|
// swagger:operation GET /orgs/{org}/projects/{id}/columns/{column_id}/issues organization orgListProjectColumnIssues
|
|
// ---
|
|
// summary: List issues in an organization-scope project column
|
|
// description: Gitea projects only contain issues — note cards and pull requests are not modeled as project items.
|
|
// produces:
|
|
// - application/json
|
|
// parameters:
|
|
// - name: org
|
|
// in: path
|
|
// description: name of the organization
|
|
// type: string
|
|
// required: true
|
|
// - name: id
|
|
// in: path
|
|
// description: id of the project
|
|
// type: integer
|
|
// format: int64
|
|
// required: true
|
|
// - name: column_id
|
|
// in: path
|
|
// description: id of the column
|
|
// type: integer
|
|
// format: int64
|
|
// required: true
|
|
// - name: state
|
|
// in: query
|
|
// description: filter issues by state. "open" (default), "closed", or "all".
|
|
// type: string
|
|
// - 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 := getOrgProjectColumn(ctx)
|
|
if ctx.Written() {
|
|
return
|
|
}
|
|
|
|
listOptions := utils.GetListOptions(ctx)
|
|
// project_issue join already constrains to issues attached to this column;
|
|
// no Owner/Doer filter so issues from any repo (including private ones the
|
|
// caller may not directly access) are listed if they're on this org board.
|
|
// This matches how the org project web UI loads its kanban board.
|
|
issuesOpts := &issues_model.IssuesOptions{
|
|
Paginator: &listOptions,
|
|
ProjectIDs: []int64{column.ProjectID},
|
|
ProjectColumnID: column.ID,
|
|
IsClosed: common.ParseIssueFilterStateIsClosed(ctx.FormTrim("state")),
|
|
SortType: issues_model.SortTypeProjectColumnSorting,
|
|
}
|
|
|
|
count, err := issues_model.CountIssues(ctx, issuesOpts)
|
|
if err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
|
|
issues, err := issues_model.Issues(ctx, issuesOpts)
|
|
if err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
|
|
ctx.SetLinkHeader(count, listOptions.PageSize)
|
|
ctx.SetTotalCountHeader(count)
|
|
ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, ctx.Doer, issues))
|
|
}
|
|
|
|
// AddIssueToOrgProjectColumn adds an issue to an org-scope project column
|
|
func AddIssueToOrgProjectColumn(ctx *context.APIContext) {
|
|
// swagger:operation POST /orgs/{org}/projects/{id}/columns/{column_id}/issues/{issue_id} organization orgAddIssueToProjectColumn
|
|
// ---
|
|
// summary: Add an issue to an organization-scope project column
|
|
// description: Gitea projects only contain issues — note cards and pull requests cannot be added.
|
|
// produces:
|
|
// - application/json
|
|
// parameters:
|
|
// - name: org
|
|
// in: path
|
|
// description: name of the organization
|
|
// type: string
|
|
// required: true
|
|
// - name: id
|
|
// in: path
|
|
// description: id of the project
|
|
// type: integer
|
|
// format: int64
|
|
// required: true
|
|
// - name: column_id
|
|
// in: path
|
|
// description: id of the column
|
|
// type: integer
|
|
// format: int64
|
|
// required: true
|
|
// - name: issue_id
|
|
// in: path
|
|
// description: id of the issue
|
|
// type: integer
|
|
// format: int64
|
|
// required: true
|
|
// responses:
|
|
// "201":
|
|
// "$ref": "#/responses/empty"
|
|
// "403":
|
|
// "$ref": "#/responses/forbidden"
|
|
// "404":
|
|
// "$ref": "#/responses/notFound"
|
|
|
|
assignIssueToOrgProjectColumn(ctx, true)
|
|
}
|
|
|
|
// RemoveIssueFromOrgProjectColumn removes an issue from an org-scope project column.
|
|
// This fully detaches the issue from the project, consistent with the repo-scope DELETE behavior.
|
|
func RemoveIssueFromOrgProjectColumn(ctx *context.APIContext) {
|
|
// swagger:operation DELETE /orgs/{org}/projects/{id}/columns/{column_id}/issues/{issue_id} organization orgRemoveIssueFromProjectColumn
|
|
// ---
|
|
// summary: Remove an issue from an organization-scope project column
|
|
// description: Fully detaches the issue from the project, consistent with the repo-scope DELETE behavior.
|
|
// produces:
|
|
// - application/json
|
|
// parameters:
|
|
// - name: org
|
|
// in: path
|
|
// description: name of the organization
|
|
// type: string
|
|
// required: true
|
|
// - name: id
|
|
// in: path
|
|
// description: id of the project
|
|
// type: integer
|
|
// format: int64
|
|
// required: true
|
|
// - name: column_id
|
|
// in: path
|
|
// description: id of the column
|
|
// type: integer
|
|
// format: int64
|
|
// required: true
|
|
// - name: issue_id
|
|
// in: path
|
|
// description: id of the issue
|
|
// type: integer
|
|
// format: int64
|
|
// required: true
|
|
// responses:
|
|
// "204":
|
|
// "$ref": "#/responses/empty"
|
|
// "403":
|
|
// "$ref": "#/responses/forbidden"
|
|
// "404":
|
|
// "$ref": "#/responses/notFound"
|
|
|
|
assignIssueToOrgProjectColumn(ctx, false)
|
|
}
|
|
|
|
func assignIssueToOrgProjectColumn(ctx *context.APIContext, add bool) {
|
|
project, column := getOrgProjectColumn(ctx)
|
|
if ctx.Written() {
|
|
return
|
|
}
|
|
if rejectIfOrgProjectClosed(ctx, project) {
|
|
return
|
|
}
|
|
|
|
// Org-scope projects can contain issues from any repo in the org; no repo-ID constraint.
|
|
issue, err := issues_model.GetIssueByID(ctx, ctx.PathParamInt64("issue_id"))
|
|
if err != nil {
|
|
if issues_model.IsErrIssueNotExist(err) {
|
|
ctx.APIErrorNotFound()
|
|
} else {
|
|
ctx.APIErrorInternal(err)
|
|
}
|
|
return
|
|
}
|
|
|
|
if err := issue.LoadProjects(ctx); err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
currentProjectIDs := make([]int64, 0, len(issue.Projects))
|
|
for _, p := range issue.Projects {
|
|
currentProjectIDs = append(currentProjectIDs, p.ID)
|
|
}
|
|
|
|
if !add {
|
|
exists, err := project_model.IsIssueInColumn(ctx, issue.ID, column.ProjectID, column.ID)
|
|
if err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
if !exists {
|
|
ctx.APIErrorNotFound()
|
|
return
|
|
}
|
|
newProjectIDs := make([]int64, 0, len(currentProjectIDs))
|
|
for _, id := range currentProjectIDs {
|
|
if id != column.ProjectID {
|
|
newProjectIDs = append(newProjectIDs, id)
|
|
}
|
|
}
|
|
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, newProjectIDs); err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
} else {
|
|
alreadyInProject := slices.Contains(currentProjectIDs, column.ProjectID)
|
|
if !alreadyInProject {
|
|
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
|
|
}
|
|
}
|
|
if err := project_model.MoveIssueToColumn(ctx, issue.ID, column.ProjectID, column.ID); err != nil {
|
|
ctx.APIErrorInternal(err)
|
|
return
|
|
}
|
|
}
|
|
|
|
if add {
|
|
ctx.Status(http.StatusCreated)
|
|
} else {
|
|
ctx.Status(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
// MoveOrgProjectIssue moves an issue between columns of an org-scope project
|
|
func MoveOrgProjectIssue(ctx *context.APIContext) {
|
|
// swagger:operation POST /orgs/{org}/projects/{id}/issues/{issue_id}/move organization orgMoveProjectIssue
|
|
// ---
|
|
// summary: Move an issue between columns of an organization-scope 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: org
|
|
// in: path
|
|
// description: name of the organization
|
|
// 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 := getOrgProjectByID(ctx)
|
|
if ctx.Written() {
|
|
return
|
|
}
|
|
if rejectIfOrgProjectClosed(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.GetIssueByID(ctx, 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)
|
|
}
|