feat(api): user-scope and org-scope project board endpoints #2
@@ -1007,6 +1007,30 @@ func Routes() *web.Router {
|
||||
}, context.UserAssignmentAPI(), checkTokenPublicOnly())
|
||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken())
|
||||
|
||||
// Users (requires issue scope) — user-scope project boards
|
||||
m.Group("/users/{username}", func() {
|
||||
m.Group("/projects", func() {
|
||||
m.Combo("").Get(user.ListUserProjects).
|
||||
Post(reqToken(), bind(api.CreateProjectOption{}), user.CreateUserProject)
|
||||
m.Group("/{id}", func() {
|
||||
m.Combo("").Get(user.GetUserProject).
|
||||
Patch(reqToken(), bind(api.EditProjectOption{}), user.EditUserProject).
|
||||
Delete(reqToken(), user.DeleteUserProject)
|
||||
m.Combo("/columns").Get(user.ListUserProjectColumns).
|
||||
Post(reqToken(), bind(api.CreateProjectColumnOption{}), user.CreateUserProjectColumn)
|
||||
m.Group("/columns/{column_id}", func() {
|
||||
m.Combo("").
|
||||
Patch(reqToken(), bind(api.EditProjectColumnOption{}), user.EditUserProjectColumn).
|
||||
Delete(reqToken(), user.DeleteUserProjectColumn)
|
||||
m.Get("/issues", user.ListUserProjectColumnIssues)
|
||||
m.Post("/issues/{issue_id}", reqToken(), user.AddIssueToUserProjectColumn)
|
||||
m.Delete("/issues/{issue_id}", reqToken(), user.RemoveIssueFromUserProjectColumn)
|
||||
})
|
||||
m.Post("/issues/{issue_id}/move", reqToken(), bind(api.MoveProjectIssueOption{}), user.MoveUserProjectIssue)
|
||||
})
|
||||
})
|
||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue), context.UserAssignmentAPI(), checkTokenPublicOnly())
|
||||
|
||||
// Users (requires user scope)
|
||||
m.Group("/user", func() {
|
||||
m.Get("", user.GetAuthenticatedUser)
|
||||
@@ -1687,6 +1711,31 @@ func Routes() *web.Router {
|
||||
})
|
||||
}, reqToken(), reqOrgOwnership())
|
||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true), checkTokenPublicOnly())
|
||||
|
||||
// Orgs (requires issue scope) — org-scope project boards
|
||||
m.Group("/orgs/{org}", func() {
|
||||
m.Group("/projects", func() {
|
||||
m.Combo("").Get(org.ListOrgProjects).
|
||||
Post(reqToken(), reqOrgMembership(), bind(api.CreateProjectOption{}), org.CreateOrgProject)
|
||||
m.Group("/{id}", func() {
|
||||
m.Combo("").Get(org.GetOrgProject).
|
||||
Patch(reqToken(), reqOrgMembership(), bind(api.EditProjectOption{}), org.EditOrgProject).
|
||||
Delete(reqToken(), reqOrgMembership(), org.DeleteOrgProject)
|
||||
m.Combo("/columns").Get(org.ListOrgProjectColumns).
|
||||
Post(reqToken(), reqOrgMembership(), bind(api.CreateProjectColumnOption{}), org.CreateOrgProjectColumn)
|
||||
m.Group("/columns/{column_id}", func() {
|
||||
m.Combo("").
|
||||
Patch(reqToken(), reqOrgMembership(), bind(api.EditProjectColumnOption{}), org.EditOrgProjectColumn).
|
||||
Delete(reqToken(), reqOrgMembership(), org.DeleteOrgProjectColumn)
|
||||
m.Get("/issues", org.ListOrgProjectColumnIssues)
|
||||
m.Post("/issues/{issue_id}", reqToken(), reqOrgMembership(), org.AddIssueToOrgProjectColumn)
|
||||
m.Delete("/issues/{issue_id}", reqToken(), reqOrgMembership(), org.RemoveIssueFromOrgProjectColumn)
|
||||
})
|
||||
m.Post("/issues/{issue_id}/move", reqToken(), reqOrgMembership(), bind(api.MoveProjectIssueOption{}), org.MoveOrgProjectIssue)
|
||||
})
|
||||
})
|
||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue), orgAssignment(true), checkTokenPublicOnly())
|
||||
|
||||
m.Group("/teams/{teamid}", func() {
|
||||
m.Combo("").Get(reqToken(), org.GetTeam).
|
||||
Patch(reqToken(), reqOrgOwnership(), bind(api.EditTeamOption{}), org.EditTeam).
|
||||
|
||||
@@ -0,0 +1,930 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
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: 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,
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,970 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// reqUserProjectWriter returns false and writes a 403 if the doer is neither
|
||||
// the context user nor a site admin. Call at the top of every write handler.
|
||||
func reqUserProjectWriter(ctx *context.APIContext) bool {
|
||||
if ctx.Doer.ID != ctx.ContextUser.ID && !ctx.Doer.IsAdmin {
|
||||
ctx.APIError(http.StatusForbidden, "only the owner or a site admin may modify user projects")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func getUserProjectByID(ctx *context.APIContext) *project_model.Project {
|
||||
project, err := project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
|
||||
if err != nil {
|
||||
if project_model.IsErrProjectNotExist(err) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return project
|
||||
}
|
||||
|
||||
func getUserProjectColumn(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.ContextUser.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 rejectIfUserProjectClosed(ctx *context.APIContext, project *project_model.Project) bool {
|
||||
if project.IsClosed {
|
||||
ctx.APIError(http.StatusForbidden, "project is closed")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validateUserColumnColor(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
|
||||
}
|
||||
|
||||
// ListUserProjects lists all projects owned by a user
|
||||
func ListUserProjects(ctx *context.APIContext) {
|
||||
// swagger:operation GET /users/{username}/projects user userListProjects
|
||||
// ---
|
||||
// summary: List projects owned by a user
|
||||
// description: Gitea projects only contain issues — note cards and pull requests are not modeled as project items.
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: owner of the projects
|
||||
// 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.ContextUser.ID,
|
||||
IsClosed: isClosed,
|
||||
Type: project_model.TypeIndividual,
|
||||
})
|
||||
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))
|
||||
}
|
||||
|
||||
// GetUserProject gets a single user-scope project
|
||||
func GetUserProject(ctx *context.APIContext) {
|
||||
// swagger:operation GET /users/{username}/projects/{id} user userGetProject
|
||||
// ---
|
||||
// summary: Get a user-scope project
|
||||
// description: Gitea projects only contain issues — note cards and pull requests are not modeled as project items.
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: owner of the project
|
||||
// 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 := getUserProjectByID(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))
|
||||
}
|
||||
|
||||
// CreateUserProject creates a new user-scope project
|
||||
func CreateUserProject(ctx *context.APIContext) {
|
||||
// swagger:operation POST /users/{username}/projects user userCreateProject
|
||||
// ---
|
||||
// summary: Create a user-scope project
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: owner of the project
|
||||
// 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"
|
||||
|
||||
if !reqUserProjectWriter(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
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.ContextUser.ID,
|
||||
Title: form.Title,
|
||||
Description: form.Description,
|
||||
CreatorID: ctx.Doer.ID,
|
||||
TemplateType: templateType,
|
||||
CardType: cardType,
|
||||
Type: project_model.TypeIndividual,
|
||||
}
|
||||
|
||||
if err := project_model.NewProject(ctx, p); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusCreated, convert.ToProject(ctx, p, ctx.Doer))
|
||||
}
|
||||
|
||||
// EditUserProject updates a user-scope project
|
||||
func EditUserProject(ctx *context.APIContext) {
|
||||
// swagger:operation PATCH /users/{username}/projects/{id} user userEditProject
|
||||
// ---
|
||||
// summary: Edit a user-scope project
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: owner of the project
|
||||
// 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"
|
||||
|
||||
if !reqUserProjectWriter(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
project := getUserProjectByID(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))
|
||||
}
|
||||
|
||||
// DeleteUserProject deletes a user-scope project
|
||||
func DeleteUserProject(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /users/{username}/projects/{id} user userDeleteProject
|
||||
// ---
|
||||
// summary: Delete a user-scope project
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: owner of the project
|
||||
// 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"
|
||||
|
||||
if !reqUserProjectWriter(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
project := getUserProjectByID(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
if err := project_model.DeleteProjectByID(ctx, project.ID); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ListUserProjectColumns lists all columns in a user-scope project
|
||||
func ListUserProjectColumns(ctx *context.APIContext) {
|
||||
// swagger:operation GET /users/{username}/projects/{id}/columns user userListProjectColumns
|
||||
// ---
|
||||
// summary: List columns in a user-scope project
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: owner of the project
|
||||
// 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 := getUserProjectByID(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
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(total, listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(total)
|
||||
ctx.JSON(http.StatusOK, convert.ToProjectColumnList(ctx, columns, ctx.Doer))
|
||||
}
|
||||
|
||||
// CreateUserProjectColumn creates a new column in a user-scope project
|
||||
func CreateUserProjectColumn(ctx *context.APIContext) {
|
||||
// swagger:operation POST /users/{username}/projects/{id}/columns user userCreateProjectColumn
|
||||
// ---
|
||||
// summary: Create a new column in a user-scope project
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: owner of the project
|
||||
// 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"
|
||||
|
||||
if !reqUserProjectWriter(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
project := getUserProjectByID(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
if rejectIfUserProjectClosed(ctx, project) {
|
||||
return
|
||||
}
|
||||
|
||||
form := web.GetForm(ctx).(*api.CreateProjectColumnOption)
|
||||
if !validateUserColumnColor(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))
|
||||
}
|
||||
|
||||
// EditUserProjectColumn updates a column in a user-scope project
|
||||
func EditUserProjectColumn(ctx *context.APIContext) {
|
||||
// swagger:operation PATCH /users/{username}/projects/{id}/columns/{column_id} user userEditProjectColumn
|
||||
// ---
|
||||
// summary: Edit a column in a user-scope project
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: owner of the project
|
||||
// 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"
|
||||
|
||||
if !reqUserProjectWriter(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
project, column := getUserProjectColumn(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
if rejectIfUserProjectClosed(ctx, project) {
|
||||
return
|
||||
}
|
||||
|
||||
form := web.GetForm(ctx).(*api.EditProjectColumnOption)
|
||||
|
||||
if form.Color != nil && !validateUserColumnColor(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))
|
||||
}
|
||||
|
||||
// DeleteUserProjectColumn deletes a column in a user-scope project
|
||||
func DeleteUserProjectColumn(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /users/{username}/projects/{id}/columns/{column_id} user userDeleteProjectColumn
|
||||
// ---
|
||||
// summary: Delete a column in a user-scope project
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: owner of the project
|
||||
// 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"
|
||||
|
||||
if !reqUserProjectWriter(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
project, column := getUserProjectColumn(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
if rejectIfUserProjectClosed(ctx, project) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := project_model.DeleteColumnByID(ctx, column.ID); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ListUserProjectColumnIssues lists all issues in a user-scope project column
|
||||
func ListUserProjectColumnIssues(ctx *context.APIContext) {
|
||||
// swagger:operation GET /users/{username}/projects/{id}/columns/{column_id}/issues user userListProjectColumnIssues
|
||||
// ---
|
||||
// summary: List issues in a user-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: username
|
||||
// in: path
|
||||
// description: owner of the project
|
||||
// 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: 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 := getUserProjectColumn(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
// project_issue join already constrains to issues attached to this column;
|
||||
// no Owner/Doer filter — same approach as the org-scope handler.
|
||||
issuesOpts := &issues_model.IssuesOptions{
|
||||
Paginator: &listOptions,
|
||||
ProjectIDs: []int64{column.ProjectID},
|
||||
ProjectColumnID: column.ID,
|
||||
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))
|
||||
}
|
||||
|
||||
// AddIssueToUserProjectColumn adds an issue to a user-scope project column
|
||||
func AddIssueToUserProjectColumn(ctx *context.APIContext) {
|
||||
// swagger:operation POST /users/{username}/projects/{id}/columns/{column_id}/issues/{issue_id} user userAddIssueToProjectColumn
|
||||
// ---
|
||||
// summary: Add an issue to a user-scope project column
|
||||
// description: Gitea projects only contain issues — note cards and pull requests cannot be added.
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: owner of the project
|
||||
// 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"
|
||||
|
||||
assignIssueToUserProjectColumn(ctx, true)
|
||||
}
|
||||
|
||||
// RemoveIssueFromUserProjectColumn removes an issue from a user-scope project column.
|
||||
// This fully detaches the issue from the project, consistent with the repo-scope DELETE behavior.
|
||||
func RemoveIssueFromUserProjectColumn(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /users/{username}/projects/{id}/columns/{column_id}/issues/{issue_id} user userRemoveIssueFromProjectColumn
|
||||
// ---
|
||||
// summary: Remove an issue from a user-scope project column
|
||||
// description: Fully detaches the issue from the project, consistent with the repo-scope DELETE behavior.
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: owner of the project
|
||||
// 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"
|
||||
|
||||
assignIssueToUserProjectColumn(ctx, false)
|
||||
}
|
||||
|
||||
func assignIssueToUserProjectColumn(ctx *context.APIContext, add bool) {
|
||||
if !reqUserProjectWriter(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
project, column := getUserProjectColumn(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
if rejectIfUserProjectClosed(ctx, project) {
|
||||
return
|
||||
}
|
||||
|
||||
// User-scope projects can contain issues from any repo; 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)
|
||||
}
|
||||
}
|
||||
|
||||
// MoveUserProjectIssue moves an issue between columns of a user-scope project
|
||||
func MoveUserProjectIssue(ctx *context.APIContext) {
|
||||
// swagger:operation POST /users/{username}/projects/{id}/issues/{issue_id}/move user userMoveProjectIssue
|
||||
// ---
|
||||
// summary: Move an issue between columns of a user-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: username
|
||||
// in: path
|
||||
// description: owner of the project
|
||||
// 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"
|
||||
|
||||
if !reqUserProjectWriter(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
project := getUserProjectByID(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
if rejectIfUserProjectClosed(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)
|
||||
}
|
||||
Generated
+1314
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,527 @@
|
||||
// Copyright 2026 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"
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
project_model "code.gitea.io/gitea/models/project"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
projects_service "code.gitea.io/gitea/services/projects"
|
||||
"code.gitea.io/gitea/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAPIOrgProjects(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
t.Run("ListProjects", testAPIOrgListProjects)
|
||||
t.Run("GetProject", testAPIOrgGetProject)
|
||||
t.Run("CreateProject", testAPIOrgCreateProject)
|
||||
t.Run("UpdateProject", testAPIOrgUpdateProject)
|
||||
t.Run("ChangeProjectStatus", testAPIOrgChangeProjectStatus)
|
||||
t.Run("DeleteProject", testAPIOrgDeleteProject)
|
||||
t.Run("ListProjectColumns", testAPIOrgListProjectColumns)
|
||||
t.Run("CreateProjectColumn", testAPIOrgCreateProjectColumn)
|
||||
t.Run("UpdateProjectColumn", testAPIOrgUpdateProjectColumn)
|
||||
t.Run("DeleteProjectColumn", testAPIOrgDeleteProjectColumn)
|
||||
t.Run("AddIssueToProjectColumn", testAPIOrgAddIssueToProjectColumn)
|
||||
t.Run("RemoveIssueFromProjectColumn", testAPIOrgRemoveIssueFromProjectColumn)
|
||||
t.Run("ListProjectColumnIssues", testAPIOrgListProjectColumnIssues)
|
||||
t.Run("MoveProjectIssue", testAPIOrgMoveProjectIssue)
|
||||
t.Run("Permissions", testAPIOrgProjectPermissions)
|
||||
}
|
||||
|
||||
// makeOrgProject creates a TypeOrganization project owned by the named org.
|
||||
// org3 (id=3) is used throughout these tests. Per fixtures/org_user.yml,
|
||||
// user2 (id=2) and user4 (id=4) are members; user5 is not.
|
||||
func makeOrgProject(t *testing.T, orgName string, creatorID int64) *project_model.Project {
|
||||
t.Helper()
|
||||
org, err := organization.GetOrgByName(t.Context(), orgName)
|
||||
assert.NoError(t, err)
|
||||
p := &project_model.Project{
|
||||
OwnerID: org.ID,
|
||||
Title: "Test Org Project",
|
||||
Description: "created by test helper",
|
||||
CreatorID: creatorID,
|
||||
Type: project_model.TypeOrganization,
|
||||
TemplateType: project_model.TemplateTypeNone,
|
||||
}
|
||||
assert.NoError(t, project_model.NewProject(t.Context(), p))
|
||||
return p
|
||||
}
|
||||
|
||||
func testAPIOrgListProjects(t *testing.T) {
|
||||
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeReadIssue)
|
||||
|
||||
req := NewRequest(t, "GET", "/api/v1/orgs/org3/projects").AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
var projects []*api.Project
|
||||
DecodeJSON(t, resp, &projects)
|
||||
|
||||
req = NewRequest(t, "GET", "/api/v1/orgs/org3/projects?state=open").AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
DecodeJSON(t, resp, &projects)
|
||||
for _, p := range projects {
|
||||
assert.Equal(t, api.StateOpen, p.State)
|
||||
}
|
||||
|
||||
req = NewRequest(t, "GET", "/api/v1/orgs/org3/projects?state=all").AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
}
|
||||
|
||||
func testAPIOrgGetProject(t *testing.T) {
|
||||
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
p := makeOrgProject(t, "org3", member.ID)
|
||||
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeReadIssue)
|
||||
|
||||
req := NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d", p.ID).AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
var got api.Project
|
||||
DecodeJSON(t, resp, &got)
|
||||
assert.Equal(t, p.ID, got.ID)
|
||||
assert.Equal(t, p.Title, got.Title)
|
||||
assert.Equal(t, "organization", got.Type)
|
||||
|
||||
req = NewRequest(t, "GET", "/api/v1/orgs/org3/projects/99999").AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func testAPIOrgCreateProject(t *testing.T) {
|
||||
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
req := NewRequestWithJSON(t, "POST", "/api/v1/orgs/org3/projects", &api.CreateProjectOption{
|
||||
Title: "Org API Project",
|
||||
Description: "desc",
|
||||
}).AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusCreated)
|
||||
var got api.Project
|
||||
DecodeJSON(t, resp, &got)
|
||||
assert.Equal(t, "Org API Project", got.Title)
|
||||
assert.Equal(t, "organization", got.Type)
|
||||
|
||||
// unauthenticated
|
||||
req = NewRequestWithJSON(t, "POST", "/api/v1/orgs/org3/projects", &api.CreateProjectOption{Title: "x"})
|
||||
MakeRequest(t, req, http.StatusUnauthorized)
|
||||
|
||||
// empty title
|
||||
req = NewRequestWithJSON(t, "POST", "/api/v1/orgs/org3/projects", &api.CreateProjectOption{Title: ""}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||
|
||||
// non-member must be forbidden (user5 is not in org3)
|
||||
nonMember := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user5"})
|
||||
nmToken := getUserToken(t, nonMember.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
req = NewRequestWithJSON(t, "POST", "/api/v1/orgs/org3/projects", &api.CreateProjectOption{Title: "x"}).AddTokenAuth(nmToken)
|
||||
MakeRequest(t, req, http.StatusForbidden)
|
||||
}
|
||||
|
||||
func testAPIOrgUpdateProject(t *testing.T) {
|
||||
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
p := makeOrgProject(t, "org3", member.ID)
|
||||
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
newTitle := "Updated Org Project"
|
||||
newDesc := "Updated desc"
|
||||
req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/orgs/org3/projects/%d", p.ID), &api.EditProjectOption{
|
||||
Title: &newTitle,
|
||||
Description: &newDesc,
|
||||
}).AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
var got api.Project
|
||||
DecodeJSON(t, resp, &got)
|
||||
assert.Equal(t, newTitle, got.Title)
|
||||
assert.Equal(t, newDesc, got.Description)
|
||||
|
||||
// non-existent project
|
||||
req = NewRequestWithJSON(t, "PATCH", "/api/v1/orgs/org3/projects/99999", &api.EditProjectOption{
|
||||
Title: &newTitle,
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func testAPIOrgChangeProjectStatus(t *testing.T) {
|
||||
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
p := makeOrgProject(t, "org3", member.ID)
|
||||
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
closed := api.StateClosed
|
||||
req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/orgs/org3/projects/%d", p.ID), &api.EditProjectOption{
|
||||
State: &closed,
|
||||
}).AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
var got api.Project
|
||||
DecodeJSON(t, resp, &got)
|
||||
assert.Equal(t, api.StateClosed, got.State)
|
||||
assert.NotNil(t, got.ClosedAt)
|
||||
|
||||
open := api.StateOpen
|
||||
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/orgs/org3/projects/%d", p.ID), &api.EditProjectOption{
|
||||
State: &open,
|
||||
}).AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
DecodeJSON(t, resp, &got)
|
||||
assert.Equal(t, api.StateOpen, got.State)
|
||||
|
||||
bogus := api.StateType("reopen")
|
||||
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/orgs/org3/projects/%d", p.ID), &api.EditProjectOption{
|
||||
State: &bogus,
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||
}
|
||||
|
||||
func testAPIOrgDeleteProject(t *testing.T) {
|
||||
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
p := makeOrgProject(t, "org3", member.ID)
|
||||
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
req := NewRequestf(t, "DELETE", "/api/v1/orgs/org3/projects/%d", p.ID).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNoContent)
|
||||
|
||||
req = NewRequestf(t, "DELETE", "/api/v1/orgs/org3/projects/%d", p.ID).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func testAPIOrgListProjectColumns(t *testing.T) {
|
||||
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
p := makeOrgProject(t, "org3", member.ID)
|
||||
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeReadIssue)
|
||||
|
||||
for i := 1; i <= 3; i++ {
|
||||
col := &project_model.Column{
|
||||
Title: fmt.Sprintf("OrgCol%d", i),
|
||||
ProjectID: p.ID,
|
||||
CreatorID: member.ID,
|
||||
}
|
||||
assert.NoError(t, project_model.NewColumn(t.Context(), col))
|
||||
}
|
||||
|
||||
req := NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d/columns", p.ID).AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
var cols []*api.ProjectColumn
|
||||
DecodeJSON(t, resp, &cols)
|
||||
assert.Len(t, cols, 3)
|
||||
assert.Equal(t, "3", resp.Header().Get("X-Total-Count"))
|
||||
|
||||
req = NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d/columns?page=1&limit=2", p.ID).AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
DecodeJSON(t, resp, &cols)
|
||||
assert.Len(t, cols, 2)
|
||||
assert.Equal(t, "3", resp.Header().Get("X-Total-Count"))
|
||||
|
||||
req = NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d/columns?page=2&limit=2", p.ID).AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
DecodeJSON(t, resp, &cols)
|
||||
assert.Len(t, cols, 1)
|
||||
assert.Equal(t, "3", resp.Header().Get("X-Total-Count"))
|
||||
|
||||
// non-existent project
|
||||
req = NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/99999/columns").AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func testAPIOrgCreateProjectColumn(t *testing.T) {
|
||||
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
p := makeOrgProject(t, "org3", member.ID)
|
||||
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns", p.ID), &api.CreateProjectColumnOption{
|
||||
Title: "OrgCol",
|
||||
Color: "#123456",
|
||||
}).AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusCreated)
|
||||
var col api.ProjectColumn
|
||||
DecodeJSON(t, resp, &col)
|
||||
assert.Equal(t, "OrgCol", col.Title)
|
||||
assert.Equal(t, "#123456", col.Color)
|
||||
assert.Equal(t, p.ID, col.ProjectID)
|
||||
|
||||
// no color
|
||||
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns", p.ID), &api.CreateProjectColumnOption{
|
||||
Title: "Plain",
|
||||
}).AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusCreated)
|
||||
DecodeJSON(t, resp, &col)
|
||||
assert.Equal(t, "Plain", col.Title)
|
||||
|
||||
// empty title
|
||||
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns", p.ID), &api.CreateProjectColumnOption{
|
||||
Title: "",
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||
|
||||
// non-existent project
|
||||
req = NewRequestWithJSON(t, "POST", "/api/v1/orgs/org3/projects/99999/columns", &api.CreateProjectColumnOption{
|
||||
Title: "Orphan",
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func testAPIOrgUpdateProjectColumn(t *testing.T) {
|
||||
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
p := makeOrgProject(t, "org3", member.ID)
|
||||
col := &project_model.Column{
|
||||
Title: "Orig",
|
||||
ProjectID: p.ID,
|
||||
CreatorID: member.ID,
|
||||
Color: "#000000",
|
||||
}
|
||||
assert.NoError(t, project_model.NewColumn(t.Context(), col))
|
||||
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
newTitle := "Changed"
|
||||
req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns/%d", p.ID, col.ID), &api.EditProjectColumnOption{
|
||||
Title: &newTitle,
|
||||
}).AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
var got api.ProjectColumn
|
||||
DecodeJSON(t, resp, &got)
|
||||
assert.Equal(t, newTitle, got.Title)
|
||||
|
||||
newColor := "#FF0000"
|
||||
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns/%d", p.ID, col.ID), &api.EditProjectColumnOption{
|
||||
Color: &newColor,
|
||||
}).AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
DecodeJSON(t, resp, &got)
|
||||
assert.Equal(t, newColor, got.Color)
|
||||
|
||||
// non-existent column
|
||||
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns/99999", p.ID), &api.EditProjectColumnOption{
|
||||
Title: &newTitle,
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func testAPIOrgDeleteProjectColumn(t *testing.T) {
|
||||
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
p := makeOrgProject(t, "org3", member.ID)
|
||||
col := &project_model.Column{
|
||||
Title: "ToDelete",
|
||||
ProjectID: p.ID,
|
||||
CreatorID: member.ID,
|
||||
}
|
||||
assert.NoError(t, project_model.NewColumn(t.Context(), col))
|
||||
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
req := NewRequestf(t, "DELETE", "/api/v1/orgs/org3/projects/%d/columns/%d", p.ID, col.ID).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNoContent)
|
||||
|
||||
req = NewRequestf(t, "DELETE", "/api/v1/orgs/org3/projects/%d/columns/%d", p.ID, col.ID).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func testAPIOrgAddIssueToProjectColumn(t *testing.T) {
|
||||
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 6})
|
||||
p := makeOrgProject(t, "org3", member.ID)
|
||||
|
||||
col1 := &project_model.Column{Title: "Column 1", ProjectID: p.ID, CreatorID: member.ID}
|
||||
assert.NoError(t, project_model.NewColumn(t.Context(), col1))
|
||||
col2 := &project_model.Column{Title: "Column 2", ProjectID: p.ID, CreatorID: member.ID}
|
||||
assert.NoError(t, project_model.NewColumn(t.Context(), col2))
|
||||
|
||||
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
// add to col1
|
||||
req := NewRequestWithJSON(t, "POST",
|
||||
fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns/%d/issues/%d", p.ID, col1.ID, issue.ID), nil,
|
||||
).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
pi := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID})
|
||||
assert.Equal(t, col1.ID, pi.ProjectColumnID)
|
||||
|
||||
// move to col2 via POST
|
||||
req = NewRequestWithJSON(t, "POST",
|
||||
fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns/%d/issues/%d", p.ID, col2.ID, issue.ID), nil,
|
||||
).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
pi = unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID})
|
||||
assert.Equal(t, col2.ID, pi.ProjectColumnID)
|
||||
|
||||
// idempotent: add to same column again
|
||||
req = NewRequestWithJSON(t, "POST",
|
||||
fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns/%d/issues/%d", p.ID, col2.ID, issue.ID), nil,
|
||||
).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
// non-existent issue
|
||||
req = NewRequestWithJSON(t, "POST",
|
||||
fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns/%d/issues/99999", p.ID, col1.ID), nil,
|
||||
).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
|
||||
// non-existent column
|
||||
req = NewRequestWithJSON(t, "POST",
|
||||
fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns/99999/issues/%d", p.ID, issue.ID), nil,
|
||||
).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func testAPIOrgRemoveIssueFromProjectColumn(t *testing.T) {
|
||||
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 6})
|
||||
p := makeOrgProject(t, "org3", member.ID)
|
||||
|
||||
col := &project_model.Column{Title: "Col", ProjectID: p.ID, CreatorID: member.ID}
|
||||
assert.NoError(t, project_model.NewColumn(t.Context(), col))
|
||||
otherCol := &project_model.Column{Title: "Other", ProjectID: p.ID, CreatorID: member.ID}
|
||||
assert.NoError(t, project_model.NewColumn(t.Context(), otherCol))
|
||||
|
||||
assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue, member, []int64{p.ID}))
|
||||
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), member, col, map[int64]int64{0: issue.ID}))
|
||||
|
||||
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
// removing via the wrong column must 404 and not detach the issue
|
||||
req := NewRequestWithJSON(t, "DELETE",
|
||||
fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns/%d/issues/%d", p.ID, otherCol.ID, issue.ID), nil,
|
||||
).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID})
|
||||
|
||||
// correct column fully detaches the issue from the project
|
||||
req = NewRequestWithJSON(t, "DELETE",
|
||||
fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns/%d/issues/%d", p.ID, col.ID, issue.ID), nil,
|
||||
).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNoContent)
|
||||
unittest.AssertNotExistsBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID})
|
||||
}
|
||||
|
||||
func testAPIOrgListProjectColumnIssues(t *testing.T) {
|
||||
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
issueA := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 6})
|
||||
issueB := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 12})
|
||||
p := makeOrgProject(t, "org3", member.ID)
|
||||
|
||||
colA := &project_model.Column{Title: "ColA", ProjectID: p.ID, CreatorID: member.ID}
|
||||
assert.NoError(t, project_model.NewColumn(t.Context(), colA))
|
||||
colB := &project_model.Column{Title: "ColB", ProjectID: p.ID, CreatorID: member.ID}
|
||||
assert.NoError(t, project_model.NewColumn(t.Context(), colB))
|
||||
|
||||
assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issueA, member, []int64{p.ID}))
|
||||
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), member, colA, map[int64]int64{0: issueA.ID}))
|
||||
assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issueB, member, []int64{p.ID}))
|
||||
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), member, colB, map[int64]int64{0: issueB.ID}))
|
||||
|
||||
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeReadIssue)
|
||||
|
||||
// colA contains only issueA
|
||||
req := NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d/columns/%d/issues", p.ID, colA.ID).AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
var gotA []api.Issue
|
||||
DecodeJSON(t, resp, &gotA)
|
||||
assert.Len(t, gotA, 1)
|
||||
assert.Equal(t, issueA.ID, gotA[0].ID)
|
||||
|
||||
// colB contains only issueB
|
||||
req = NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d/columns/%d/issues", p.ID, colB.ID).AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
var gotB []api.Issue
|
||||
DecodeJSON(t, resp, &gotB)
|
||||
assert.Len(t, gotB, 1)
|
||||
assert.Equal(t, issueB.ID, gotB[0].ID)
|
||||
}
|
||||
|
||||
func testAPIOrgMoveProjectIssue(t *testing.T) {
|
||||
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 6})
|
||||
p := makeOrgProject(t, "org3", member.ID)
|
||||
|
||||
colA := &project_model.Column{Title: "A", ProjectID: p.ID, CreatorID: member.ID}
|
||||
assert.NoError(t, project_model.NewColumn(t.Context(), colA))
|
||||
colB := &project_model.Column{Title: "B", ProjectID: p.ID, CreatorID: member.ID}
|
||||
assert.NoError(t, project_model.NewColumn(t.Context(), colB))
|
||||
|
||||
assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue, member, []int64{p.ID}))
|
||||
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), member, colA, map[int64]int64{0: issue.ID}))
|
||||
|
||||
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
// move to colB
|
||||
req := NewRequestWithJSON(t, "POST",
|
||||
fmt.Sprintf("/api/v1/orgs/org3/projects/%d/issues/%d/move", p.ID, issue.ID),
|
||||
&api.MoveProjectIssueOption{ColumnID: colB.ID},
|
||||
).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNoContent)
|
||||
|
||||
pi := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID})
|
||||
assert.Equal(t, colB.ID, pi.ProjectColumnID)
|
||||
|
||||
// non-existent target column -> 422
|
||||
req = NewRequestWithJSON(t, "POST",
|
||||
fmt.Sprintf("/api/v1/orgs/org3/projects/%d/issues/%d/move", p.ID, issue.ID),
|
||||
&api.MoveProjectIssueOption{ColumnID: 99999},
|
||||
).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||
|
||||
// issue not in project -> 404
|
||||
otherIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 15})
|
||||
req = NewRequestWithJSON(t, "POST",
|
||||
fmt.Sprintf("/api/v1/orgs/org3/projects/%d/issues/%d/move", p.ID, otherIssue.ID),
|
||||
&api.MoveProjectIssueOption{ColumnID: colA.ID},
|
||||
).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func testAPIOrgProjectPermissions(t *testing.T) {
|
||||
// org3 members per fixtures: user2 (id=2), user4 (id=4). Non-member: user5.
|
||||
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
nonMember := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user5"})
|
||||
admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
|
||||
p := makeOrgProject(t, "org3", member.ID)
|
||||
memberToken := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
nmToken := getUserToken(t, nonMember.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
adminToken := getUserToken(t, admin.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
// anon can list
|
||||
req := NewRequest(t, "GET", "/api/v1/orgs/org3/projects")
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
// member can read
|
||||
req = NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d", p.ID).AddTokenAuth(memberToken)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
// non-member cannot write
|
||||
newTitle := "By NonMember"
|
||||
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/orgs/org3/projects/%d", p.ID), &api.EditProjectOption{
|
||||
Title: &newTitle,
|
||||
}).AddTokenAuth(nmToken)
|
||||
MakeRequest(t, req, http.StatusForbidden)
|
||||
|
||||
// non-member cannot delete
|
||||
req = NewRequestf(t, "DELETE", "/api/v1/orgs/org3/projects/%d", p.ID).AddTokenAuth(nmToken)
|
||||
MakeRequest(t, req, http.StatusForbidden)
|
||||
|
||||
// non-member cannot create column
|
||||
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns", p.ID), &api.CreateProjectColumnOption{
|
||||
Title: "By NonMember",
|
||||
}).AddTokenAuth(nmToken)
|
||||
MakeRequest(t, req, http.StatusForbidden)
|
||||
|
||||
// member can write
|
||||
newTitle = "By Member"
|
||||
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/orgs/org3/projects/%d", p.ID), &api.EditProjectOption{
|
||||
Title: &newTitle,
|
||||
}).AddTokenAuth(memberToken)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
// admin can write
|
||||
newTitle = "By Admin"
|
||||
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/orgs/org3/projects/%d", p.ID), &api.EditProjectOption{
|
||||
Title: &newTitle,
|
||||
}).AddTokenAuth(adminToken)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
}
|
||||
@@ -36,9 +36,62 @@ func TestAPIProjects(t *testing.T) {
|
||||
t.Run("AddIssueToProjectColumn", testAPIAddIssueToProjectColumn)
|
||||
t.Run("RemoveIssueFromProjectColumn", testAPIRemoveIssueFromProjectColumn)
|
||||
t.Run("ListProjectColumnIssues", testAPIListProjectColumnIssues)
|
||||
t.Run("MoveProjectIssue", testAPIMoveProjectIssue)
|
||||
t.Run("Permissions", testAPIProjectPermissions)
|
||||
}
|
||||
|
||||
func testAPIMoveProjectIssue(t *testing.T) {
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
issueA := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
|
||||
|
||||
project := &project_model.Project{
|
||||
Title: "Project for MoveIssue",
|
||||
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)
|
||||
|
||||
colA := &project_model.Column{Title: "A", ProjectID: project.ID, CreatorID: owner.ID}
|
||||
err = project_model.NewColumn(t.Context(), colA)
|
||||
assert.NoError(t, err)
|
||||
colB := &project_model.Column{Title: "B", ProjectID: project.ID, CreatorID: owner.ID}
|
||||
err = project_model.NewColumn(t.Context(), colB)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issueA, owner, []int64{project.ID})
|
||||
assert.NoError(t, err)
|
||||
err = projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, colA, map[int64]int64{0: issueA.ID})
|
||||
assert.NoError(t, err)
|
||||
|
||||
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
req := NewRequestWithJSON(t, "POST",
|
||||
fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/issues/%d/move", owner.Name, repo.Name, project.ID, issueA.ID),
|
||||
&api.MoveProjectIssueOption{ColumnID: colB.ID},
|
||||
).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNoContent)
|
||||
|
||||
pi := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: project.ID, IssueID: issueA.ID})
|
||||
assert.Equal(t, colB.ID, pi.ProjectColumnID)
|
||||
|
||||
req = NewRequestWithJSON(t, "POST",
|
||||
fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/issues/%d/move", owner.Name, repo.Name, project.ID, issueA.ID),
|
||||
&api.MoveProjectIssueOption{ColumnID: 99999},
|
||||
).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||
|
||||
issueB := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 4})
|
||||
req = NewRequestWithJSON(t, "POST",
|
||||
fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/issues/%d/move", owner.Name, repo.Name, project.ID, issueB.ID),
|
||||
&api.MoveProjectIssueOption{ColumnID: colA.ID},
|
||||
).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func testAPIListProjects(t *testing.T) {
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
// Copyright 2026 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"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
projects_service "code.gitea.io/gitea/services/projects"
|
||||
"code.gitea.io/gitea/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAPIUserProjects(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
t.Run("ListProjects", testAPIUserListProjects)
|
||||
t.Run("GetProject", testAPIUserGetProject)
|
||||
t.Run("CreateProject", testAPIUserCreateProject)
|
||||
t.Run("UpdateProject", testAPIUserUpdateProject)
|
||||
t.Run("ChangeProjectStatus", testAPIUserChangeProjectStatus)
|
||||
t.Run("DeleteProject", testAPIUserDeleteProject)
|
||||
t.Run("ListProjectColumns", testAPIUserListProjectColumns)
|
||||
t.Run("CreateProjectColumn", testAPIUserCreateProjectColumn)
|
||||
t.Run("UpdateProjectColumn", testAPIUserUpdateProjectColumn)
|
||||
t.Run("DeleteProjectColumn", testAPIUserDeleteProjectColumn)
|
||||
t.Run("AddIssueToProjectColumn", testAPIUserAddIssueToProjectColumn)
|
||||
t.Run("RemoveIssueFromProjectColumn", testAPIUserRemoveIssueFromProjectColumn)
|
||||
t.Run("ListProjectColumnIssues", testAPIUserListProjectColumnIssues)
|
||||
t.Run("MoveProjectIssue", testAPIUserMoveProjectIssue)
|
||||
t.Run("Permissions", testAPIUserProjectPermissions)
|
||||
}
|
||||
|
||||
func makeUserProject(t *testing.T, owner *user_model.User) *project_model.Project {
|
||||
t.Helper()
|
||||
p := &project_model.Project{
|
||||
OwnerID: owner.ID,
|
||||
Title: "Test User Project",
|
||||
Description: "created by test helper",
|
||||
CreatorID: owner.ID,
|
||||
Type: project_model.TypeIndividual,
|
||||
TemplateType: project_model.TemplateTypeNone,
|
||||
}
|
||||
err := project_model.NewProject(t.Context(), p)
|
||||
assert.NoError(t, err)
|
||||
return p
|
||||
}
|
||||
|
||||
func testAPIUserListProjects(t *testing.T) {
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue)
|
||||
|
||||
req := NewRequestf(t, "GET", "/api/v1/users/%s/projects", owner.Name).AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
var projects []*api.Project
|
||||
DecodeJSON(t, resp, &projects)
|
||||
|
||||
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects?state=open", owner.Name).AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
DecodeJSON(t, resp, &projects)
|
||||
for _, p := range projects {
|
||||
assert.Equal(t, api.StateOpen, p.State)
|
||||
}
|
||||
|
||||
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects?state=all", owner.Name).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
}
|
||||
|
||||
func testAPIUserGetProject(t *testing.T) {
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
p := makeUserProject(t, owner)
|
||||
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue)
|
||||
|
||||
req := NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d", owner.Name, p.ID).AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
var got api.Project
|
||||
DecodeJSON(t, resp, &got)
|
||||
assert.Equal(t, p.ID, got.ID)
|
||||
assert.Equal(t, p.Title, got.Title)
|
||||
assert.Equal(t, "individual", got.Type)
|
||||
|
||||
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/99999", owner.Name).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func testAPIUserCreateProject(t *testing.T) {
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects", owner.Name), &api.CreateProjectOption{
|
||||
Title: "Created via API",
|
||||
Description: "desc",
|
||||
}).AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
var got api.Project
|
||||
DecodeJSON(t, resp, &got)
|
||||
assert.Equal(t, "Created via API", got.Title)
|
||||
assert.Equal(t, "individual", got.Type)
|
||||
|
||||
// unauthenticated
|
||||
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects", owner.Name), &api.CreateProjectOption{Title: "x"})
|
||||
MakeRequest(t, req, http.StatusUnauthorized)
|
||||
|
||||
// empty title
|
||||
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects", owner.Name), &api.CreateProjectOption{Title: ""}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||
}
|
||||
|
||||
func testAPIUserUpdateProject(t *testing.T) {
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
p := makeUserProject(t, owner)
|
||||
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
newTitle := "Updated Title"
|
||||
newDesc := "Updated desc"
|
||||
req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{
|
||||
Title: &newTitle,
|
||||
Description: &newDesc,
|
||||
}).AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
var got api.Project
|
||||
DecodeJSON(t, resp, &got)
|
||||
assert.Equal(t, newTitle, got.Title)
|
||||
assert.Equal(t, newDesc, got.Description)
|
||||
}
|
||||
|
||||
func testAPIUserChangeProjectStatus(t *testing.T) {
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
p := makeUserProject(t, owner)
|
||||
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
closed := api.StateClosed
|
||||
req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{
|
||||
State: &closed,
|
||||
}).AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
var got api.Project
|
||||
DecodeJSON(t, resp, &got)
|
||||
assert.Equal(t, api.StateClosed, got.State)
|
||||
assert.NotNil(t, got.ClosedAt)
|
||||
|
||||
open := api.StateOpen
|
||||
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{
|
||||
State: &open,
|
||||
}).AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
DecodeJSON(t, resp, &got)
|
||||
assert.Equal(t, api.StateOpen, got.State)
|
||||
|
||||
bogus := api.StateType("reopen")
|
||||
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{
|
||||
State: &bogus,
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||
}
|
||||
|
||||
func testAPIUserDeleteProject(t *testing.T) {
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
p := makeUserProject(t, owner)
|
||||
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
req := NewRequestf(t, "DELETE", "/api/v1/users/%s/projects/%d", owner.Name, p.ID).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNoContent)
|
||||
|
||||
req = NewRequestf(t, "DELETE", "/api/v1/users/%s/projects/%d", owner.Name, p.ID).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func testAPIUserListProjectColumns(t *testing.T) {
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
p := makeUserProject(t, owner)
|
||||
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue)
|
||||
|
||||
for i := 1; i <= 3; i++ {
|
||||
col := &project_model.Column{
|
||||
Title: fmt.Sprintf("Col%d", i),
|
||||
ProjectID: p.ID,
|
||||
CreatorID: owner.ID,
|
||||
}
|
||||
assert.NoError(t, project_model.NewColumn(t.Context(), col))
|
||||
}
|
||||
|
||||
req := NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns", owner.Name, p.ID).AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
var cols []*api.ProjectColumn
|
||||
DecodeJSON(t, resp, &cols)
|
||||
assert.Len(t, cols, 3)
|
||||
assert.Equal(t, "3", resp.Header().Get("X-Total-Count"))
|
||||
|
||||
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns?page=1&limit=2", owner.Name, p.ID).AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
DecodeJSON(t, resp, &cols)
|
||||
assert.Len(t, cols, 2)
|
||||
assert.Equal(t, "3", resp.Header().Get("X-Total-Count"))
|
||||
|
||||
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns?page=2&limit=2", owner.Name, p.ID).AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
DecodeJSON(t, resp, &cols)
|
||||
assert.Len(t, cols, 1)
|
||||
assert.Equal(t, "3", resp.Header().Get("X-Total-Count"))
|
||||
}
|
||||
|
||||
func testAPIUserCreateProjectColumn(t *testing.T) {
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
p := makeUserProject(t, owner)
|
||||
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns", owner.Name, p.ID), &api.CreateProjectColumnOption{
|
||||
Title: "New Column",
|
||||
Color: "#FF5733",
|
||||
}).AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusCreated)
|
||||
var col api.ProjectColumn
|
||||
DecodeJSON(t, resp, &col)
|
||||
assert.Equal(t, "New Column", col.Title)
|
||||
assert.Equal(t, "#FF5733", col.Color)
|
||||
assert.Equal(t, p.ID, col.ProjectID)
|
||||
|
||||
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns", owner.Name, p.ID), &api.CreateProjectColumnOption{
|
||||
Title: "Simple Column",
|
||||
}).AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusCreated)
|
||||
DecodeJSON(t, resp, &col)
|
||||
assert.Equal(t, "Simple Column", col.Title)
|
||||
|
||||
// empty title
|
||||
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns", owner.Name, p.ID), &api.CreateProjectColumnOption{
|
||||
Title: "",
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||
|
||||
// non-existent project
|
||||
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects/99999/columns", owner.Name), &api.CreateProjectColumnOption{
|
||||
Title: "Orphan",
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func testAPIUserUpdateProjectColumn(t *testing.T) {
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
p := makeUserProject(t, owner)
|
||||
col := &project_model.Column{
|
||||
Title: "Original",
|
||||
ProjectID: p.ID,
|
||||
CreatorID: owner.ID,
|
||||
Color: "#000000",
|
||||
}
|
||||
assert.NoError(t, project_model.NewColumn(t.Context(), col))
|
||||
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
newTitle := "Updated Column"
|
||||
req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d", owner.Name, p.ID, col.ID), &api.EditProjectColumnOption{
|
||||
Title: &newTitle,
|
||||
}).AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
var got api.ProjectColumn
|
||||
DecodeJSON(t, resp, &got)
|
||||
assert.Equal(t, newTitle, got.Title)
|
||||
|
||||
newColor := "#FF0000"
|
||||
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d", owner.Name, p.ID, col.ID), &api.EditProjectColumnOption{
|
||||
Color: &newColor,
|
||||
}).AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
DecodeJSON(t, resp, &got)
|
||||
assert.Equal(t, newColor, got.Color)
|
||||
|
||||
// non-existent column
|
||||
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/99999", owner.Name, p.ID), &api.EditProjectColumnOption{
|
||||
Title: &newTitle,
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func testAPIUserDeleteProjectColumn(t *testing.T) {
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
p := makeUserProject(t, owner)
|
||||
col := &project_model.Column{
|
||||
Title: "ToDelete",
|
||||
ProjectID: p.ID,
|
||||
CreatorID: owner.ID,
|
||||
}
|
||||
assert.NoError(t, project_model.NewColumn(t.Context(), col))
|
||||
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
req := NewRequestf(t, "DELETE", "/api/v1/users/%s/projects/%d/columns/%d", owner.Name, p.ID, col.ID).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNoContent)
|
||||
|
||||
req = NewRequestf(t, "DELETE", "/api/v1/users/%s/projects/%d/columns/%d", owner.Name, p.ID, col.ID).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func testAPIUserAddIssueToProjectColumn(t *testing.T) {
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
|
||||
p := makeUserProject(t, owner)
|
||||
|
||||
col1 := &project_model.Column{Title: "Column 1", ProjectID: p.ID, CreatorID: owner.ID}
|
||||
assert.NoError(t, project_model.NewColumn(t.Context(), col1))
|
||||
col2 := &project_model.Column{Title: "Column 2", ProjectID: p.ID, CreatorID: owner.ID}
|
||||
assert.NoError(t, project_model.NewColumn(t.Context(), col2))
|
||||
|
||||
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
// add to col1
|
||||
req := NewRequestWithJSON(t, "POST",
|
||||
fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d/issues/%d", owner.Name, p.ID, col1.ID, issue.ID), nil,
|
||||
).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
pi := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID})
|
||||
assert.Equal(t, col1.ID, pi.ProjectColumnID)
|
||||
|
||||
// move to col2 via POST
|
||||
req = NewRequestWithJSON(t, "POST",
|
||||
fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d/issues/%d", owner.Name, p.ID, col2.ID, issue.ID), nil,
|
||||
).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
pi = unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID})
|
||||
assert.Equal(t, col2.ID, pi.ProjectColumnID)
|
||||
|
||||
// idempotent: add to same column again
|
||||
req = NewRequestWithJSON(t, "POST",
|
||||
fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d/issues/%d", owner.Name, p.ID, col2.ID, issue.ID), nil,
|
||||
).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
// non-existent issue
|
||||
req = NewRequestWithJSON(t, "POST",
|
||||
fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d/issues/99999", owner.Name, p.ID, col1.ID), nil,
|
||||
).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
|
||||
// non-existent column
|
||||
req = NewRequestWithJSON(t, "POST",
|
||||
fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/99999/issues/%d", owner.Name, p.ID, issue.ID), nil,
|
||||
).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func testAPIUserRemoveIssueFromProjectColumn(t *testing.T) {
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
|
||||
p := makeUserProject(t, owner)
|
||||
|
||||
col := &project_model.Column{Title: "Col", ProjectID: p.ID, CreatorID: owner.ID}
|
||||
assert.NoError(t, project_model.NewColumn(t.Context(), col))
|
||||
otherCol := &project_model.Column{Title: "Other", ProjectID: p.ID, CreatorID: owner.ID}
|
||||
assert.NoError(t, project_model.NewColumn(t.Context(), otherCol))
|
||||
|
||||
assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue, owner, []int64{p.ID}))
|
||||
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, col, map[int64]int64{0: issue.ID}))
|
||||
|
||||
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
// removing via the wrong column must 404 and not detach the issue
|
||||
req := NewRequestWithJSON(t, "DELETE",
|
||||
fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d/issues/%d", owner.Name, p.ID, otherCol.ID, issue.ID), nil,
|
||||
).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID})
|
||||
|
||||
// correct column fully detaches the issue from the project
|
||||
req = NewRequestWithJSON(t, "DELETE",
|
||||
fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d/issues/%d", owner.Name, p.ID, col.ID, issue.ID), nil,
|
||||
).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNoContent)
|
||||
unittest.AssertNotExistsBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID})
|
||||
}
|
||||
|
||||
func testAPIUserListProjectColumnIssues(t *testing.T) {
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
issueA := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
|
||||
issueB := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
|
||||
p := makeUserProject(t, owner)
|
||||
|
||||
colA := &project_model.Column{Title: "ColA", ProjectID: p.ID, CreatorID: owner.ID}
|
||||
assert.NoError(t, project_model.NewColumn(t.Context(), colA))
|
||||
colB := &project_model.Column{Title: "ColB", ProjectID: p.ID, CreatorID: owner.ID}
|
||||
assert.NoError(t, project_model.NewColumn(t.Context(), colB))
|
||||
|
||||
assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issueA, owner, []int64{p.ID}))
|
||||
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, colA, map[int64]int64{0: issueA.ID}))
|
||||
assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issueB, owner, []int64{p.ID}))
|
||||
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, colB, map[int64]int64{0: issueB.ID}))
|
||||
|
||||
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue)
|
||||
|
||||
// colA contains only issueA
|
||||
req := NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns/%d/issues", owner.Name, p.ID, colA.ID).AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
var gotA []api.Issue
|
||||
DecodeJSON(t, resp, &gotA)
|
||||
assert.Len(t, gotA, 1)
|
||||
assert.Equal(t, issueA.ID, gotA[0].ID)
|
||||
|
||||
// colB contains only issueB
|
||||
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns/%d/issues", owner.Name, p.ID, colB.ID).AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
var gotB []api.Issue
|
||||
DecodeJSON(t, resp, &gotB)
|
||||
assert.Len(t, gotB, 1)
|
||||
assert.Equal(t, issueB.ID, gotB[0].ID)
|
||||
}
|
||||
|
||||
func testAPIUserMoveProjectIssue(t *testing.T) {
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
|
||||
p := makeUserProject(t, owner)
|
||||
|
||||
colA := &project_model.Column{Title: "A", ProjectID: p.ID, CreatorID: owner.ID}
|
||||
assert.NoError(t, project_model.NewColumn(t.Context(), colA))
|
||||
colB := &project_model.Column{Title: "B", ProjectID: p.ID, CreatorID: owner.ID}
|
||||
assert.NoError(t, project_model.NewColumn(t.Context(), colB))
|
||||
|
||||
assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue, owner, []int64{p.ID}))
|
||||
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, colA, map[int64]int64{0: issue.ID}))
|
||||
|
||||
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
// move to colB
|
||||
req := NewRequestWithJSON(t, "POST",
|
||||
fmt.Sprintf("/api/v1/users/%s/projects/%d/issues/%d/move", owner.Name, p.ID, issue.ID),
|
||||
&api.MoveProjectIssueOption{ColumnID: colB.ID},
|
||||
).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNoContent)
|
||||
|
||||
pi := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID})
|
||||
assert.Equal(t, colB.ID, pi.ProjectColumnID)
|
||||
|
||||
// non-existent target column -> 422
|
||||
req = NewRequestWithJSON(t, "POST",
|
||||
fmt.Sprintf("/api/v1/users/%s/projects/%d/issues/%d/move", owner.Name, p.ID, issue.ID),
|
||||
&api.MoveProjectIssueOption{ColumnID: 99999},
|
||||
).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||
|
||||
// issue not in project -> 404
|
||||
otherIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 4})
|
||||
req = NewRequestWithJSON(t, "POST",
|
||||
fmt.Sprintf("/api/v1/users/%s/projects/%d/issues/%d/move", owner.Name, p.ID, otherIssue.ID),
|
||||
&api.MoveProjectIssueOption{ColumnID: colA.ID},
|
||||
).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func testAPIUserProjectPermissions(t *testing.T) {
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
other := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user5"})
|
||||
admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
|
||||
p := makeUserProject(t, owner)
|
||||
ownerToken := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
otherToken := getUserToken(t, other.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
adminToken := getUserToken(t, admin.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
// anon can read
|
||||
req := NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d", owner.Name, p.ID)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
// owner can write
|
||||
newTitle := "By Owner"
|
||||
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{
|
||||
Title: &newTitle,
|
||||
}).AddTokenAuth(ownerToken)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
// other user cannot write
|
||||
newTitle = "By Other"
|
||||
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{
|
||||
Title: &newTitle,
|
||||
}).AddTokenAuth(otherToken)
|
||||
MakeRequest(t, req, http.StatusForbidden)
|
||||
|
||||
// other user cannot delete
|
||||
req = NewRequestf(t, "DELETE", "/api/v1/users/%s/projects/%d", owner.Name, p.ID).AddTokenAuth(otherToken)
|
||||
MakeRequest(t, req, http.StatusForbidden)
|
||||
|
||||
// admin can write
|
||||
newTitle = "By Admin"
|
||||
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{
|
||||
Title: &newTitle,
|
||||
}).AddTokenAuth(adminToken)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
}
|
||||
Reference in New Issue
Block a user