diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 4ec826b8c2..e0f89656fd 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -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). diff --git a/routers/api/v1/org/project.go b/routers/api/v1/org/project.go new file mode 100644 index 0000000000..72a7597c69 --- /dev/null +++ b/routers/api/v1/org/project.go @@ -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) +} diff --git a/routers/api/v1/user/project.go b/routers/api/v1/user/project.go new file mode 100644 index 0000000000..a47cc893bc --- /dev/null +++ b/routers/api/v1/user/project.go @@ -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) +} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index f3c6b59b8d..a69f7c9916 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -3363,6 +3363,663 @@ } } }, + "/orgs/{org}/projects": { + "get": { + "description": "Gitea projects only contain issues — note cards and pull requests are not modeled as project items.", + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "List projects owned by an organization", + "operationId": "orgListProjects", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "enum": [ + "open", + "closed", + "all" + ], + "type": "string", + "default": "open", + "description": "State of the project (open, closed, all)", + "name": "state", + "in": "query" + }, + { + "type": "integer", + "description": "page number of results", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectList" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Create an organization-scope project", + "operationId": "orgCreateProject", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "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" + } + } + } + }, + "/orgs/{org}/projects/{id}": { + "get": { + "description": "Gitea projects only contain issues — note cards and pull requests are not modeled as project items.", + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Get an organization-scope project", + "operationId": "orgGetProject", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/Project" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "delete": { + "tags": [ + "organization" + ], + "summary": "Delete an organization-scope project", + "operationId": "orgDeleteProject", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Edit an organization-scope project", + "operationId": "orgEditProject", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/EditProjectOption" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/Project" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, + "/orgs/{org}/projects/{id}/columns": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "List columns in an organization-scope project", + "operationId": "orgListProjectColumns", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectColumnList" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Create a new column in an organization-scope project", + "operationId": "orgCreateProjectColumn", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/CreateProjectColumnOption" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/ProjectColumn" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, + "/orgs/{org}/projects/{id}/columns/{column_id}": { + "delete": { + "tags": [ + "organization" + ], + "summary": "Delete a column in an organization-scope project", + "operationId": "orgDeleteProjectColumn", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Edit a column in an organization-scope project", + "operationId": "orgEditProjectColumn", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/EditProjectColumnOption" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectColumn" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, + "/orgs/{org}/projects/{id}/columns/{column_id}/issues": { + "get": { + "description": "Gitea projects only contain issues — note cards and pull requests are not modeled as project items.", + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "List issues in an organization-scope project column", + "operationId": "orgListProjectColumnIssues", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/IssueList" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/orgs/{org}/projects/{id}/columns/{column_id}/issues/{issue_id}": { + "post": { + "description": "Gitea projects only contain issues — note cards and pull requests cannot be added.", + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Add an issue to an organization-scope project column", + "operationId": "orgAddIssueToProjectColumn", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the issue", + "name": "issue_id", + "in": "path", + "required": true + } + ], + "responses": { + "201": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "delete": { + "description": "Fully detaches the issue from the project, consistent with the repo-scope DELETE behavior.", + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Remove an issue from an organization-scope project column", + "operationId": "orgRemoveIssueFromProjectColumn", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the issue", + "name": "issue_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/orgs/{org}/projects/{id}/issues/{issue_id}/move": { + "post": { + "description": "Atomically moves an existing project issue into a different column, optionally setting its sorting position.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Move an issue between columns of an organization-scope project", + "operationId": "orgMoveProjectIssue", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the issue", + "name": "issue_id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/MoveProjectIssueOption" + } + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, "/orgs/{org}/public_members": { "get": { "produces": [ @@ -22003,6 +22660,663 @@ } } }, + "/users/{username}/projects": { + "get": { + "description": "Gitea projects only contain issues — note cards and pull requests are not modeled as project items.", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "List projects owned by a user", + "operationId": "userListProjects", + "parameters": [ + { + "type": "string", + "description": "owner of the projects", + "name": "username", + "in": "path", + "required": true + }, + { + "enum": [ + "open", + "closed", + "all" + ], + "type": "string", + "default": "open", + "description": "State of the project (open, closed, all)", + "name": "state", + "in": "query" + }, + { + "type": "integer", + "description": "page number of results", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectList" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Create a user-scope project", + "operationId": "userCreateProject", + "parameters": [ + { + "type": "string", + "description": "owner of the project", + "name": "username", + "in": "path", + "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" + } + } + } + }, + "/users/{username}/projects/{id}": { + "get": { + "description": "Gitea projects only contain issues — note cards and pull requests are not modeled as project items.", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Get a user-scope project", + "operationId": "userGetProject", + "parameters": [ + { + "type": "string", + "description": "owner of the project", + "name": "username", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/Project" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "delete": { + "tags": [ + "user" + ], + "summary": "Delete a user-scope project", + "operationId": "userDeleteProject", + "parameters": [ + { + "type": "string", + "description": "owner of the project", + "name": "username", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Edit a user-scope project", + "operationId": "userEditProject", + "parameters": [ + { + "type": "string", + "description": "owner of the project", + "name": "username", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/EditProjectOption" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/Project" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, + "/users/{username}/projects/{id}/columns": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "List columns in a user-scope project", + "operationId": "userListProjectColumns", + "parameters": [ + { + "type": "string", + "description": "owner of the project", + "name": "username", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectColumnList" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Create a new column in a user-scope project", + "operationId": "userCreateProjectColumn", + "parameters": [ + { + "type": "string", + "description": "owner of the project", + "name": "username", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/CreateProjectColumnOption" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/ProjectColumn" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, + "/users/{username}/projects/{id}/columns/{column_id}": { + "delete": { + "tags": [ + "user" + ], + "summary": "Delete a column in a user-scope project", + "operationId": "userDeleteProjectColumn", + "parameters": [ + { + "type": "string", + "description": "owner of the project", + "name": "username", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Edit a column in a user-scope project", + "operationId": "userEditProjectColumn", + "parameters": [ + { + "type": "string", + "description": "owner of the project", + "name": "username", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/EditProjectColumnOption" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/ProjectColumn" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, + "/users/{username}/projects/{id}/columns/{column_id}/issues": { + "get": { + "description": "Gitea projects only contain issues — note cards and pull requests are not modeled as project items.", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "List issues in a user-scope project column", + "operationId": "userListProjectColumnIssues", + "parameters": [ + { + "type": "string", + "description": "owner of the project", + "name": "username", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/IssueList" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/users/{username}/projects/{id}/columns/{column_id}/issues/{issue_id}": { + "post": { + "description": "Gitea projects only contain issues — note cards and pull requests cannot be added.", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Add an issue to a user-scope project column", + "operationId": "userAddIssueToProjectColumn", + "parameters": [ + { + "type": "string", + "description": "owner of the project", + "name": "username", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the issue", + "name": "issue_id", + "in": "path", + "required": true + } + ], + "responses": { + "201": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "delete": { + "description": "Fully detaches the issue from the project, consistent with the repo-scope DELETE behavior.", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Remove an issue from a user-scope project column", + "operationId": "userRemoveIssueFromProjectColumn", + "parameters": [ + { + "type": "string", + "description": "owner of the project", + "name": "username", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the column", + "name": "column_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the issue", + "name": "issue_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/users/{username}/projects/{id}/issues/{issue_id}/move": { + "post": { + "description": "Atomically moves an existing project issue into a different column, optionally setting its sorting position.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Move an issue between columns of a user-scope project", + "operationId": "userMoveProjectIssue", + "parameters": [ + { + "type": "string", + "description": "owner of the project", + "name": "username", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the project", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the issue", + "name": "issue_id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/MoveProjectIssueOption" + } + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, "/users/{username}/repos": { "get": { "produces": [ diff --git a/templates/swagger/v1_openapi3_json.tmpl b/templates/swagger/v1_openapi3_json.tmpl index 70fa3882b5..28b737f170 100644 --- a/templates/swagger/v1_openapi3_json.tmpl +++ b/templates/swagger/v1_openapi3_json.tmpl @@ -14252,6 +14252,708 @@ ] } }, + "/orgs/{org}/projects": { + "get": { + "description": "Gitea projects only contain issues — note cards and pull requests are not modeled as project items.", + "operationId": "orgListProjects", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "State of the project (open, closed, all)", + "in": "query", + "name": "state", + "schema": { + "default": "open", + "enum": [ + "open", + "closed", + "all" + ], + "type": "string" + } + }, + { + "description": "page number of results", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "page size of results", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ProjectList" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "List projects owned by an organization", + "tags": [ + "organization" + ] + }, + "post": { + "operationId": "orgCreateProject", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProjectOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "201": { + "$ref": "#/components/responses/Project" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Create an organization-scope project", + "tags": [ + "organization" + ] + } + }, + "/orgs/{org}/projects/{id}": { + "delete": { + "operationId": "orgDeleteProject", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Delete an organization-scope project", + "tags": [ + "organization" + ] + }, + "get": { + "description": "Gitea projects only contain issues — note cards and pull requests are not modeled as project items.", + "operationId": "orgGetProject", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Project" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Get an organization-scope project", + "tags": [ + "organization" + ] + }, + "patch": { + "operationId": "orgEditProject", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditProjectOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "200": { + "$ref": "#/components/responses/Project" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Edit an organization-scope project", + "tags": [ + "organization" + ] + } + }, + "/orgs/{org}/projects/{id}/columns": { + "get": { + "operationId": "orgListProjectColumns", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "page number of results to return (1-based)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "page size of results", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ProjectColumnList" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "List columns in an organization-scope project", + "tags": [ + "organization" + ] + }, + "post": { + "operationId": "orgCreateProjectColumn", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProjectColumnOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "201": { + "$ref": "#/components/responses/ProjectColumn" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Create a new column in an organization-scope project", + "tags": [ + "organization" + ] + } + }, + "/orgs/{org}/projects/{id}/columns/{column_id}": { + "delete": { + "operationId": "orgDeleteProjectColumn", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Delete a column in an organization-scope project", + "tags": [ + "organization" + ] + }, + "patch": { + "operationId": "orgEditProjectColumn", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditProjectColumnOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "200": { + "$ref": "#/components/responses/ProjectColumn" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Edit a column in an organization-scope project", + "tags": [ + "organization" + ] + } + }, + "/orgs/{org}/projects/{id}/columns/{column_id}/issues": { + "get": { + "description": "Gitea projects only contain issues — note cards and pull requests are not modeled as project items.", + "operationId": "orgListProjectColumnIssues", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "page number of results to return (1-based)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "page size of results", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/IssueList" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "List issues in an organization-scope project column", + "tags": [ + "organization" + ] + } + }, + "/orgs/{org}/projects/{id}/columns/{column_id}/issues/{issue_id}": { + "delete": { + "description": "Fully detaches the issue from the project, consistent with the repo-scope DELETE behavior.", + "operationId": "orgRemoveIssueFromProjectColumn", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the issue", + "in": "path", + "name": "issue_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Remove an issue from an organization-scope project column", + "tags": [ + "organization" + ] + }, + "post": { + "description": "Gitea projects only contain issues — note cards and pull requests cannot be added.", + "operationId": "orgAddIssueToProjectColumn", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the issue", + "in": "path", + "name": "issue_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "201": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Add an issue to an organization-scope project column", + "tags": [ + "organization" + ] + } + }, + "/orgs/{org}/projects/{id}/issues/{issue_id}/move": { + "post": { + "description": "Atomically moves an existing project issue into a different column, optionally setting its sorting position.", + "operationId": "orgMoveProjectIssue", + "parameters": [ + { + "description": "name of the organization", + "in": "path", + "name": "org", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the issue", + "in": "path", + "name": "issue_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MoveProjectIssueOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Move an issue between columns of an organization-scope project", + "tags": [ + "organization" + ] + } + }, "/orgs/{org}/public_members": { "get": { "operationId": "orgListPublicMembers", @@ -34302,6 +35004,708 @@ ] } }, + "/users/{username}/projects": { + "get": { + "description": "Gitea projects only contain issues — note cards and pull requests are not modeled as project items.", + "operationId": "userListProjects", + "parameters": [ + { + "description": "owner of the projects", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "State of the project (open, closed, all)", + "in": "query", + "name": "state", + "schema": { + "default": "open", + "enum": [ + "open", + "closed", + "all" + ], + "type": "string" + } + }, + { + "description": "page number of results", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "page size of results", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ProjectList" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "List projects owned by a user", + "tags": [ + "user" + ] + }, + "post": { + "operationId": "userCreateProject", + "parameters": [ + { + "description": "owner of the project", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProjectOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "201": { + "$ref": "#/components/responses/Project" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Create a user-scope project", + "tags": [ + "user" + ] + } + }, + "/users/{username}/projects/{id}": { + "delete": { + "operationId": "userDeleteProject", + "parameters": [ + { + "description": "owner of the project", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Delete a user-scope project", + "tags": [ + "user" + ] + }, + "get": { + "description": "Gitea projects only contain issues — note cards and pull requests are not modeled as project items.", + "operationId": "userGetProject", + "parameters": [ + { + "description": "owner of the project", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Project" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Get a user-scope project", + "tags": [ + "user" + ] + }, + "patch": { + "operationId": "userEditProject", + "parameters": [ + { + "description": "owner of the project", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditProjectOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "200": { + "$ref": "#/components/responses/Project" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Edit a user-scope project", + "tags": [ + "user" + ] + } + }, + "/users/{username}/projects/{id}/columns": { + "get": { + "operationId": "userListProjectColumns", + "parameters": [ + { + "description": "owner of the project", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "page number of results to return (1-based)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "page size of results", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ProjectColumnList" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "List columns in a user-scope project", + "tags": [ + "user" + ] + }, + "post": { + "operationId": "userCreateProjectColumn", + "parameters": [ + { + "description": "owner of the project", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProjectColumnOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "201": { + "$ref": "#/components/responses/ProjectColumn" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Create a new column in a user-scope project", + "tags": [ + "user" + ] + } + }, + "/users/{username}/projects/{id}/columns/{column_id}": { + "delete": { + "operationId": "userDeleteProjectColumn", + "parameters": [ + { + "description": "owner of the project", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Delete a column in a user-scope project", + "tags": [ + "user" + ] + }, + "patch": { + "operationId": "userEditProjectColumn", + "parameters": [ + { + "description": "owner of the project", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditProjectColumnOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "200": { + "$ref": "#/components/responses/ProjectColumn" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Edit a column in a user-scope project", + "tags": [ + "user" + ] + } + }, + "/users/{username}/projects/{id}/columns/{column_id}/issues": { + "get": { + "description": "Gitea projects only contain issues — note cards and pull requests are not modeled as project items.", + "operationId": "userListProjectColumnIssues", + "parameters": [ + { + "description": "owner of the project", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "page number of results to return (1-based)", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "page size of results", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/IssueList" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "List issues in a user-scope project column", + "tags": [ + "user" + ] + } + }, + "/users/{username}/projects/{id}/columns/{column_id}/issues/{issue_id}": { + "delete": { + "description": "Fully detaches the issue from the project, consistent with the repo-scope DELETE behavior.", + "operationId": "userRemoveIssueFromProjectColumn", + "parameters": [ + { + "description": "owner of the project", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the issue", + "in": "path", + "name": "issue_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Remove an issue from a user-scope project column", + "tags": [ + "user" + ] + }, + "post": { + "description": "Gitea projects only contain issues — note cards and pull requests cannot be added.", + "operationId": "userAddIssueToProjectColumn", + "parameters": [ + { + "description": "owner of the project", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the column", + "in": "path", + "name": "column_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the issue", + "in": "path", + "name": "issue_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "201": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Add an issue to a user-scope project column", + "tags": [ + "user" + ] + } + }, + "/users/{username}/projects/{id}/issues/{issue_id}/move": { + "post": { + "description": "Atomically moves an existing project issue into a different column, optionally setting its sorting position.", + "operationId": "userMoveProjectIssue", + "parameters": [ + { + "description": "owner of the project", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "id of the project", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "description": "id of the issue", + "in": "path", + "name": "issue_id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MoveProjectIssueOption" + } + } + }, + "x-originalParamName": "body" + }, + "responses": { + "204": { + "$ref": "#/components/responses/empty" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" + } + }, + "summary": "Move an issue between columns of a user-scope project", + "tags": [ + "user" + ] + } + }, "/users/{username}/repos": { "get": { "operationId": "userListRepos", diff --git a/tests/integration/api_org_project_test.go b/tests/integration/api_org_project_test.go new file mode 100644 index 0000000000..51b79d1d3b --- /dev/null +++ b/tests/integration/api_org_project_test.go @@ -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) +} diff --git a/tests/integration/api_user_project_test.go b/tests/integration/api_user_project_test.go new file mode 100644 index 0000000000..07c6332c21 --- /dev/null +++ b/tests/integration/api_user_project_test.go @@ -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) +}