feat(api): add user-scope and org-scope project board endpoints

Adds the missing REST surface that mirrors the existing repo-scope project
API onto user and organization namespaces, so projects at /{owner}/-/projects/
become programmatically manageable (linking issues, listing column membership,
moving cards between columns) without browser-session auth.

Routes registered under /api/v1/users/{username}/projects/... and
/api/v1/orgs/{org}/projects/..., gated by AccessTokenScopeCategoryIssue.
User-scope writes require owner==doer or site admin; org-scope writes
require org membership. Handlers copy the repo-scope shape rather than
refactoring the existing repo handlers, keeping shipping code untouched.

Integration tests cover CRUD, columns, issue add/remove/move, listing per
column, and the permission matrix (owner/non-member/admin) for both scopes.
This commit is contained in:
Oleks
2026-05-15 15:47:29 +03:00
parent 086dd1858e
commit 8ae6245c19
7 changed files with 5691 additions and 0 deletions
+49
View File
@@ -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).
+930
View File
@@ -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)
}
+970
View File
@@ -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)
}
+1314
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+527
View File
@@ -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)
}
+497
View File
@@ -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)
}