mirror of https://github.com/go-gitea/gitea.git
Merge 675b69caf0 into a440116a16
commit
97e3cf2189
@ -0,0 +1,139 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package structs
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Project represents a project
|
||||
// swagger:model
|
||||
type Project struct {
|
||||
// Unique identifier of the project
|
||||
ID int64 `json:"id"`
|
||||
// Project title
|
||||
Title string `json:"title"`
|
||||
// Project description
|
||||
Description string `json:"description"`
|
||||
// Owner ID (for organization or user projects)
|
||||
OwnerID int64 `json:"owner_id,omitempty"`
|
||||
// Repository ID (for repository projects)
|
||||
RepoID int64 `json:"repo_id,omitempty"`
|
||||
// Creator ID
|
||||
CreatorID int64 `json:"creator_id"`
|
||||
// Whether the project is closed
|
||||
IsClosed bool `json:"is_closed"`
|
||||
// Template type: 0=none, 1=basic_kanban, 2=bug_triage
|
||||
TemplateType int `json:"template_type"`
|
||||
// Card type: 0=text_only, 1=images_and_text
|
||||
CardType int `json:"card_type"`
|
||||
// Project type: 1=individual, 2=repository, 3=organization
|
||||
Type int `json:"type"`
|
||||
// Number of open issues
|
||||
NumOpenIssues int64 `json:"num_open_issues,omitempty"`
|
||||
// Number of closed issues
|
||||
NumClosedIssues int64 `json:"num_closed_issues,omitempty"`
|
||||
// Total number of issues
|
||||
NumIssues int64 `json:"num_issues,omitempty"`
|
||||
// Created time
|
||||
// swagger:strfmt date-time
|
||||
Created time.Time `json:"created"`
|
||||
// Updated time
|
||||
// swagger:strfmt date-time
|
||||
Updated time.Time `json:"updated"`
|
||||
// Closed time
|
||||
// swagger:strfmt date-time
|
||||
ClosedDate *time.Time `json:"closed_date,omitempty"`
|
||||
// Project URL
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// CreateProjectOption represents options for creating a project
|
||||
// swagger:model
|
||||
type CreateProjectOption struct {
|
||||
// required: true
|
||||
Title string `json:"title" binding:"Required"`
|
||||
// Project description
|
||||
Description string `json:"description"`
|
||||
// Template type: 0=none, 1=basic_kanban, 2=bug_triage
|
||||
TemplateType int `json:"template_type"`
|
||||
// Card type: 0=text_only, 1=images_and_text
|
||||
CardType int `json:"card_type"`
|
||||
}
|
||||
|
||||
// EditProjectOption represents options for editing a project
|
||||
// swagger:model
|
||||
type EditProjectOption struct {
|
||||
// Project title
|
||||
Title *string `json:"title,omitempty"`
|
||||
// Project description
|
||||
Description *string `json:"description,omitempty"`
|
||||
// Card type: 0=text_only, 1=images_and_text
|
||||
CardType *int `json:"card_type,omitempty"`
|
||||
// Whether the project is closed
|
||||
IsClosed *bool `json:"is_closed,omitempty"`
|
||||
}
|
||||
|
||||
// ProjectColumn represents a project column (board)
|
||||
// swagger:model
|
||||
type ProjectColumn struct {
|
||||
// Unique identifier of the column
|
||||
ID int64 `json:"id"`
|
||||
// Column title
|
||||
Title string `json:"title"`
|
||||
// Whether this is the default column
|
||||
Default bool `json:"default"`
|
||||
// Sorting order
|
||||
Sorting int `json:"sorting"`
|
||||
// Column color (hex format)
|
||||
Color string `json:"color,omitempty"`
|
||||
// Project ID
|
||||
ProjectID int64 `json:"project_id"`
|
||||
// Creator ID
|
||||
CreatorID int64 `json:"creator_id"`
|
||||
// Number of issues in this column
|
||||
NumIssues int64 `json:"num_issues,omitempty"`
|
||||
// Created time
|
||||
// swagger:strfmt date-time
|
||||
Created time.Time `json:"created"`
|
||||
// Updated time
|
||||
// swagger:strfmt date-time
|
||||
Updated time.Time `json:"updated"`
|
||||
}
|
||||
|
||||
// CreateProjectColumnOption represents options for creating a project column
|
||||
// swagger:model
|
||||
type CreateProjectColumnOption struct {
|
||||
// required: true
|
||||
Title string `json:"title" binding:"Required"`
|
||||
// Column color (hex format, e.g., #FF0000)
|
||||
Color string `json:"color,omitempty"`
|
||||
}
|
||||
|
||||
// EditProjectColumnOption represents options for editing a project column
|
||||
// swagger:model
|
||||
type EditProjectColumnOption struct {
|
||||
// Column title
|
||||
Title *string `json:"title,omitempty"`
|
||||
// Column color (hex format)
|
||||
Color *string `json:"color,omitempty"`
|
||||
// Sorting order
|
||||
Sorting *int `json:"sorting,omitempty"`
|
||||
}
|
||||
|
||||
// MoveProjectColumnOption represents options for moving a project column
|
||||
// swagger:model
|
||||
type MoveProjectColumnOption struct {
|
||||
// Position to move the column to (0-based index)
|
||||
// required: true
|
||||
Position int `json:"position" binding:"Required"`
|
||||
}
|
||||
|
||||
// AddIssueToProjectColumnOption represents options for adding an issue to a project column
|
||||
// swagger:model
|
||||
type AddIssueToProjectColumnOption struct {
|
||||
// Issue ID to add to the column
|
||||
// required: true
|
||||
IssueID int64 `json:"issue_id" binding:"Required"`
|
||||
}
|
||||
@ -0,0 +1,772 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
project_model "code.gitea.io/gitea/models/project"
|
||||
"code.gitea.io/gitea/models/unit"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/convert"
|
||||
project_service "code.gitea.io/gitea/services/projects"
|
||||
)
|
||||
|
||||
// ListProjects lists all projects in a repository
|
||||
func ListProjects(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/projects repository repoListProjects
|
||||
// ---
|
||||
// summary: List projects in a repository
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: state
|
||||
// in: query
|
||||
// description: State of the project (open, closed)
|
||||
// type: string
|
||||
// enum: [open, closed, all]
|
||||
// default: open
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/ProjectList"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
if !ctx.Repo.CanRead(unit.TypeProjects) {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
state := ctx.FormTrim("state")
|
||||
var isClosed optional.Option[bool]
|
||||
switch state {
|
||||
case "closed":
|
||||
isClosed = optional.Some(true)
|
||||
case "open":
|
||||
isClosed = optional.Some(false)
|
||||
case "all":
|
||||
isClosed = optional.None[bool]()
|
||||
default:
|
||||
isClosed = optional.Some(false)
|
||||
}
|
||||
|
||||
page := ctx.FormInt("page")
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
limit := ctx.FormInt("limit")
|
||||
if limit <= 0 {
|
||||
limit = setting.UI.IssuePagingNum
|
||||
}
|
||||
|
||||
projects, count, err := db.FindAndCount[project_model.Project](ctx, project_model.SearchOptions{
|
||||
ListOptions: db.ListOptions{
|
||||
Page: page,
|
||||
PageSize: limit,
|
||||
},
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
IsClosed: isClosed,
|
||||
Type: project_model.TypeRepository,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := project_service.LoadIssueNumbersForProjects(ctx, projects, ctx.Doer); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
apiProjects := convert.ToProjectList(ctx, projects)
|
||||
|
||||
ctx.SetLinkHeader(int(count), limit)
|
||||
ctx.SetTotalCountHeader(count)
|
||||
ctx.JSON(http.StatusOK, apiProjects)
|
||||
}
|
||||
|
||||
// GetProject gets a single project
|
||||
func GetProject(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/projects/{id} repository repoGetProject
|
||||
// ---
|
||||
// summary: Get a single project
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the project
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/Project"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
if !ctx.Repo.CanRead(unit.TypeProjects) {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
project, err := project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
if project_model.IsErrProjectNotExist(err) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := project_service.LoadIssueNumbersForProjects(ctx, []*project_model.Project{project}, ctx.Doer); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, convert.ToProject(ctx, project))
|
||||
}
|
||||
|
||||
// CreateProject creates a new project
|
||||
func CreateProject(ctx *context.APIContext) {
|
||||
// swagger:operation POST /repos/{owner}/{repo}/projects repository repoCreateProject
|
||||
// ---
|
||||
// summary: Create a new project
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/CreateProjectOption"
|
||||
// responses:
|
||||
// "201":
|
||||
// "$ref": "#/responses/Project"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
if !ctx.Repo.CanWrite(unit.TypeProjects) {
|
||||
ctx.APIError(http.StatusForbidden, "no permission")
|
||||
return
|
||||
}
|
||||
|
||||
form := web.GetForm(ctx).(*api.CreateProjectOption)
|
||||
|
||||
p := &project_model.Project{
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
Title: form.Title,
|
||||
Description: form.Description,
|
||||
CreatorID: ctx.Doer.ID,
|
||||
TemplateType: project_model.TemplateType(form.TemplateType),
|
||||
CardType: project_model.CardType(form.CardType),
|
||||
Type: project_model.TypeRepository,
|
||||
}
|
||||
|
||||
if err := project_model.NewProject(ctx, p); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := project_service.LoadIssueNumbersForProjects(ctx, []*project_model.Project{p}, ctx.Doer); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusCreated, convert.ToProject(ctx, p))
|
||||
}
|
||||
|
||||
// EditProject updates a project
|
||||
func EditProject(ctx *context.APIContext) {
|
||||
// swagger:operation PATCH /repos/{owner}/{repo}/projects/{id} repository repoEditProject
|
||||
// ---
|
||||
// summary: Edit a project
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the project
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/EditProjectOption"
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/Project"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
if !ctx.Repo.CanWrite(unit.TypeProjects) {
|
||||
ctx.APIError(http.StatusForbidden, "no permission")
|
||||
return
|
||||
}
|
||||
|
||||
project, err := project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
if project_model.IsErrProjectNotExist(err) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
form := web.GetForm(ctx).(*api.EditProjectOption)
|
||||
|
||||
if form.Title != nil {
|
||||
project.Title = *form.Title
|
||||
}
|
||||
if form.Description != nil {
|
||||
project.Description = *form.Description
|
||||
}
|
||||
if form.CardType != nil {
|
||||
project.CardType = project_model.CardType(*form.CardType)
|
||||
}
|
||||
if form.IsClosed != nil {
|
||||
if err := project_model.ChangeProjectStatus(ctx, project, *form.IsClosed); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := project_model.UpdateProject(ctx, project); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := project_service.LoadIssueNumbersForProjects(ctx, []*project_model.Project{project}, ctx.Doer); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, convert.ToProject(ctx, project))
|
||||
}
|
||||
|
||||
// DeleteProject deletes a project
|
||||
func DeleteProject(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /repos/{owner}/{repo}/projects/{id} repository repoDeleteProject
|
||||
// ---
|
||||
// summary: Delete a project
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the project
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
if !ctx.Repo.CanWrite(unit.TypeProjects) {
|
||||
ctx.APIError(http.StatusForbidden, "no permission")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify project exists and belongs to this repository
|
||||
project, err := project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
if project_model.IsErrProjectNotExist(err) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := project_model.DeleteProjectByID(ctx, project.ID); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ListProjectColumns lists all columns in a project
|
||||
func ListProjectColumns(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/projects/{id}/columns repository repoListProjectColumns
|
||||
// ---
|
||||
// summary: List columns in a project
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the project
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// - name: page
|
||||
// in: query
|
||||
// description: page number of results
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// description: page size of results
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/ProjectColumnList"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
if !ctx.Repo.CanRead(unit.TypeProjects) {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
project, err := project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
if project_model.IsErrProjectNotExist(err) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Get all columns
|
||||
allColumns, err := project.GetColumns(ctx)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
totalCount := int64(len(allColumns))
|
||||
|
||||
// Parse pagination parameters
|
||||
page := ctx.FormInt("page")
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
limit := ctx.FormInt("limit")
|
||||
if limit <= 0 {
|
||||
limit = setting.UI.IssuePagingNum
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
start := (page - 1) * limit
|
||||
end := start + limit
|
||||
|
||||
var columns project_model.ColumnList
|
||||
if start < len(allColumns) {
|
||||
if end > len(allColumns) {
|
||||
end = len(allColumns)
|
||||
}
|
||||
columns = allColumns[start:end]
|
||||
} else {
|
||||
columns = make([]*project_model.Column, 0)
|
||||
}
|
||||
|
||||
ctx.SetLinkHeader(int(totalCount), limit)
|
||||
ctx.SetTotalCountHeader(totalCount)
|
||||
ctx.JSON(http.StatusOK, convert.ToProjectColumnList(ctx, columns))
|
||||
}
|
||||
|
||||
// CreateProjectColumn creates a new column in a project
|
||||
func CreateProjectColumn(ctx *context.APIContext) {
|
||||
// swagger:operation POST /repos/{owner}/{repo}/projects/{id}/columns repository repoCreateProjectColumn
|
||||
// ---
|
||||
// summary: Create a new column in a project
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the project
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/CreateProjectColumnOption"
|
||||
// responses:
|
||||
// "201":
|
||||
// "$ref": "#/responses/ProjectColumn"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
if !ctx.Repo.CanWrite(unit.TypeProjects) {
|
||||
ctx.APIError(http.StatusForbidden, "no permission")
|
||||
return
|
||||
}
|
||||
|
||||
project, err := project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
if project_model.IsErrProjectNotExist(err) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
form := web.GetForm(ctx).(*api.CreateProjectColumnOption)
|
||||
|
||||
column := &project_model.Column{
|
||||
Title: form.Title,
|
||||
Color: form.Color,
|
||||
ProjectID: project.ID,
|
||||
CreatorID: ctx.Doer.ID,
|
||||
}
|
||||
|
||||
if err := project_model.NewColumn(ctx, column); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusCreated, convert.ToProjectColumn(ctx, column))
|
||||
}
|
||||
|
||||
// EditProjectColumn updates a column
|
||||
func EditProjectColumn(ctx *context.APIContext) {
|
||||
// swagger:operation PATCH /repos/{owner}/{repo}/projects/columns/{id} repository repoEditProjectColumn
|
||||
// ---
|
||||
// summary: Edit a project column
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the column
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/EditProjectColumnOption"
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/ProjectColumn"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
if !ctx.Repo.CanWrite(unit.TypeProjects) {
|
||||
ctx.APIError(http.StatusForbidden, "no permission")
|
||||
return
|
||||
}
|
||||
|
||||
column, err := project_model.GetColumn(ctx, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
if project_model.IsErrProjectColumnNotExist(err) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Verify column belongs to this repo's project
|
||||
_, err = project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, column.ProjectID)
|
||||
if err != nil {
|
||||
if project_model.IsErrProjectNotExist(err) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
form := web.GetForm(ctx).(*api.EditProjectColumnOption)
|
||||
|
||||
if form.Title != nil {
|
||||
column.Title = *form.Title
|
||||
}
|
||||
if form.Color != nil {
|
||||
column.Color = *form.Color
|
||||
}
|
||||
if form.Sorting != nil {
|
||||
column.Sorting = int8(*form.Sorting)
|
||||
}
|
||||
|
||||
if err := project_model.UpdateColumn(ctx, column); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, convert.ToProjectColumn(ctx, column))
|
||||
}
|
||||
|
||||
// DeleteProjectColumn deletes a column
|
||||
func DeleteProjectColumn(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /repos/{owner}/{repo}/projects/columns/{id} repository repoDeleteProjectColumn
|
||||
// ---
|
||||
// summary: Delete a project column
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the column
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
if !ctx.Repo.CanWrite(unit.TypeProjects) {
|
||||
ctx.APIError(http.StatusForbidden, "no permission")
|
||||
return
|
||||
}
|
||||
|
||||
column, err := project_model.GetColumn(ctx, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
if project_model.IsErrProjectColumnNotExist(err) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Verify column belongs to this repo's project
|
||||
_, err = project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, column.ProjectID)
|
||||
if err != nil {
|
||||
if project_model.IsErrProjectNotExist(err) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := project_model.DeleteColumnByID(ctx, column.ID); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// AddIssueToProjectColumn adds an issue to a project column
|
||||
func AddIssueToProjectColumn(ctx *context.APIContext) {
|
||||
// swagger:operation POST /repos/{owner}/{repo}/projects/columns/{id}/issues repository repoAddIssueToProjectColumn
|
||||
// ---
|
||||
// summary: Add an issue to a project column
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: id
|
||||
// in: path
|
||||
// description: id of the column
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// type: object
|
||||
// required:
|
||||
// - issue_id
|
||||
// properties:
|
||||
// issue_id:
|
||||
// type: integer
|
||||
// format: int64
|
||||
// description: ID of the issue to add
|
||||
// responses:
|
||||
// "201":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
|
||||
if !ctx.Repo.CanWrite(unit.TypeProjects) {
|
||||
ctx.APIError(http.StatusForbidden, "no permission")
|
||||
return
|
||||
}
|
||||
|
||||
column, err := project_model.GetColumn(ctx, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
if project_model.IsErrProjectColumnNotExist(err) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Verify column belongs to this repo's project
|
||||
_, err = project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, column.ProjectID)
|
||||
if err != nil {
|
||||
if project_model.IsErrProjectNotExist(err) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
form := web.GetForm(ctx).(*api.AddIssueToProjectColumnOption)
|
||||
|
||||
// Verify issue exists and belongs to this repository
|
||||
issue, err := issues_model.GetIssueByID(ctx, form.IssueID)
|
||||
if err != nil {
|
||||
if issues_model.IsErrIssueNotExist(err) {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, "issue not found")
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if issue.RepoID != ctx.Repo.Repository.ID {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, "issue does not belong to this repository")
|
||||
return
|
||||
}
|
||||
|
||||
// Add or update issue in column
|
||||
if err := project_model.AddOrUpdateIssueToColumn(ctx, form.IssueID, column); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusCreated)
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package swagger
|
||||
|
||||
import (
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
)
|
||||
|
||||
// Project
|
||||
// swagger:response Project
|
||||
type swaggerResponseProject struct {
|
||||
// in:body
|
||||
Body api.Project `json:"body"`
|
||||
}
|
||||
|
||||
// ProjectList
|
||||
// swagger:response ProjectList
|
||||
type swaggerResponseProjectList struct {
|
||||
// in:body
|
||||
Body []api.Project `json:"body"`
|
||||
}
|
||||
|
||||
// ProjectColumn
|
||||
// swagger:response ProjectColumn
|
||||
type swaggerResponseProjectColumn struct {
|
||||
// in:body
|
||||
Body api.ProjectColumn `json:"body"`
|
||||
}
|
||||
|
||||
// ProjectColumnList
|
||||
// swagger:response ProjectColumnList
|
||||
type swaggerResponseProjectColumnList struct {
|
||||
// in:body
|
||||
Body []api.ProjectColumn `json:"body"`
|
||||
}
|
||||
@ -0,0 +1,92 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package convert
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
project_model "code.gitea.io/gitea/models/project"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
)
|
||||
|
||||
// ToProject converts a project_model.Project to api.Project
|
||||
func ToProject(ctx context.Context, p *project_model.Project) *api.Project {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
project := &api.Project{
|
||||
ID: p.ID,
|
||||
Title: p.Title,
|
||||
Description: p.Description,
|
||||
OwnerID: p.OwnerID,
|
||||
RepoID: p.RepoID,
|
||||
CreatorID: p.CreatorID,
|
||||
IsClosed: p.IsClosed,
|
||||
TemplateType: int(p.TemplateType),
|
||||
CardType: int(p.CardType),
|
||||
Type: int(p.Type),
|
||||
NumOpenIssues: p.NumOpenIssues,
|
||||
NumClosedIssues: p.NumClosedIssues,
|
||||
NumIssues: p.NumIssues,
|
||||
Created: p.CreatedUnix.AsTime(),
|
||||
Updated: p.UpdatedUnix.AsTime(),
|
||||
}
|
||||
|
||||
if p.ClosedDateUnix > 0 {
|
||||
t := p.ClosedDateUnix.AsTime()
|
||||
project.ClosedDate = &t
|
||||
}
|
||||
|
||||
// Generate project URL
|
||||
if p.Type == project_model.TypeRepository && p.RepoID > 0 {
|
||||
if err := p.LoadRepo(ctx); err == nil && p.Repo != nil {
|
||||
project.URL = project_model.ProjectLinkForRepo(p.Repo, p.ID)
|
||||
}
|
||||
} else if p.OwnerID > 0 {
|
||||
if err := p.LoadOwner(ctx); err == nil && p.Owner != nil {
|
||||
project.URL = project_model.ProjectLinkForOrg(p.Owner, p.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return project
|
||||
}
|
||||
|
||||
// ToProjectColumn converts a project_model.Column to api.ProjectColumn
|
||||
func ToProjectColumn(ctx context.Context, column *project_model.Column) *api.ProjectColumn {
|
||||
if column == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &api.ProjectColumn{
|
||||
ID: column.ID,
|
||||
Title: column.Title,
|
||||
Default: column.Default,
|
||||
Sorting: int(column.Sorting),
|
||||
Color: column.Color,
|
||||
ProjectID: column.ProjectID,
|
||||
CreatorID: column.CreatorID,
|
||||
NumIssues: column.NumIssues,
|
||||
Created: column.CreatedUnix.AsTime(),
|
||||
Updated: column.UpdatedUnix.AsTime(),
|
||||
}
|
||||
}
|
||||
|
||||
// ToProjectList converts a list of project_model.Project to a list of api.Project
|
||||
func ToProjectList(ctx context.Context, projects []*project_model.Project) []*api.Project {
|
||||
result := make([]*api.Project, len(projects))
|
||||
for i, p := range projects {
|
||||
result[i] = ToProject(ctx, p)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ToProjectColumnList converts a list of project_model.Column to a list of api.ProjectColumn
|
||||
func ToProjectColumnList(ctx context.Context, columns []*project_model.Column) []*api.ProjectColumn {
|
||||
result := make([]*api.ProjectColumn, len(columns))
|
||||
for i, column := range columns {
|
||||
result[i] = ToProjectColumn(ctx, column)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@ -0,0 +1,595 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
project_model "code.gitea.io/gitea/models/project"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAPIListProjects(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
|
||||
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue)
|
||||
|
||||
// Test listing all projects
|
||||
req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects", owner.Name, repo.Name).
|
||||
AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
var projects []*api.Project
|
||||
DecodeJSON(t, resp, &projects)
|
||||
|
||||
// Test state filter - open
|
||||
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects?state=open", owner.Name, repo.Name).
|
||||
AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
DecodeJSON(t, resp, &projects)
|
||||
for _, project := range projects {
|
||||
assert.False(t, project.IsClosed, "Project should be open")
|
||||
}
|
||||
|
||||
// Test state filter - all
|
||||
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects?state=all", owner.Name, repo.Name).
|
||||
AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
DecodeJSON(t, resp, &projects)
|
||||
|
||||
// Test pagination
|
||||
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects?page=1&limit=5", owner.Name, repo.Name).
|
||||
AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
}
|
||||
|
||||
func TestAPIGetProject(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
|
||||
// Create a test project
|
||||
project := &project_model.Project{
|
||||
Title: "Test Project for API",
|
||||
RepoID: repo.ID,
|
||||
Type: project_model.TypeRepository,
|
||||
CreatorID: owner.ID,
|
||||
TemplateType: project_model.TemplateTypeNone,
|
||||
}
|
||||
err := project_model.NewProject(t.Context(), project)
|
||||
assert.NoError(t, err)
|
||||
defer func() {
|
||||
_ = project_model.DeleteProjectByID(t.Context(), project.ID)
|
||||
}()
|
||||
|
||||
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue)
|
||||
|
||||
// Test getting the project
|
||||
req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID).
|
||||
AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
var apiProject api.Project
|
||||
DecodeJSON(t, resp, &apiProject)
|
||||
assert.Equal(t, project.Title, apiProject.Title)
|
||||
assert.Equal(t, project.ID, apiProject.ID)
|
||||
assert.Equal(t, repo.ID, apiProject.RepoID)
|
||||
assert.NotEmpty(t, apiProject.URL)
|
||||
|
||||
// Test getting non-existent project
|
||||
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/99999", owner.Name, repo.Name).
|
||||
AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestAPICreateProject(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
|
||||
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
// Test creating a project
|
||||
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects", owner.Name, repo.Name), &api.CreateProjectOption{
|
||||
Title: "API Created Project",
|
||||
Description: "This is a test project created via API",
|
||||
TemplateType: 1, // basic_kanban
|
||||
CardType: 1, // images_and_text
|
||||
}).AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
var project api.Project
|
||||
DecodeJSON(t, resp, &project)
|
||||
assert.Equal(t, "API Created Project", project.Title)
|
||||
assert.Equal(t, "This is a test project created via API", project.Description)
|
||||
assert.Equal(t, 1, project.TemplateType)
|
||||
assert.Equal(t, 1, project.CardType)
|
||||
assert.False(t, project.IsClosed)
|
||||
defer func() {
|
||||
_ = project_model.DeleteProjectByID(t.Context(), project.ID)
|
||||
}()
|
||||
|
||||
// Test creating with minimal data
|
||||
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects", owner.Name, repo.Name), &api.CreateProjectOption{
|
||||
Title: "Minimal Project",
|
||||
}).AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
var minimalProject api.Project
|
||||
DecodeJSON(t, resp, &minimalProject)
|
||||
assert.Equal(t, "Minimal Project", minimalProject.Title)
|
||||
defer func() {
|
||||
_ = project_model.DeleteProjectByID(t.Context(), minimalProject.ID)
|
||||
}()
|
||||
|
||||
// Test creating without authentication
|
||||
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects", owner.Name, repo.Name), &api.CreateProjectOption{
|
||||
Title: "Unauthorized Project",
|
||||
})
|
||||
MakeRequest(t, req, http.StatusUnauthorized)
|
||||
|
||||
// Test creating with invalid data (empty title)
|
||||
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects", owner.Name, repo.Name), &api.CreateProjectOption{
|
||||
Title: "",
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||
}
|
||||
|
||||
func TestAPIUpdateProject(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
|
||||
// Create a test project
|
||||
project := &project_model.Project{
|
||||
Title: "Project to Update",
|
||||
Description: "Original description",
|
||||
RepoID: repo.ID,
|
||||
Type: project_model.TypeRepository,
|
||||
CreatorID: owner.ID,
|
||||
TemplateType: project_model.TemplateTypeNone,
|
||||
}
|
||||
err := project_model.NewProject(t.Context(), project)
|
||||
assert.NoError(t, err)
|
||||
defer func() {
|
||||
_ = project_model.DeleteProjectByID(t.Context(), project.ID)
|
||||
}()
|
||||
|
||||
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
// Test updating project title and description
|
||||
newTitle := "Updated Project Title"
|
||||
newDesc := "Updated description"
|
||||
req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{
|
||||
Title: &newTitle,
|
||||
Description: &newDesc,
|
||||
}).AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
var updatedProject api.Project
|
||||
DecodeJSON(t, resp, &updatedProject)
|
||||
assert.Equal(t, newTitle, updatedProject.Title)
|
||||
assert.Equal(t, newDesc, updatedProject.Description)
|
||||
|
||||
// Test closing project
|
||||
isClosed := true
|
||||
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{
|
||||
IsClosed: &isClosed,
|
||||
}).AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
DecodeJSON(t, resp, &updatedProject)
|
||||
assert.True(t, updatedProject.IsClosed)
|
||||
assert.NotNil(t, updatedProject.ClosedDate)
|
||||
|
||||
// Test reopening project
|
||||
isClosed = false
|
||||
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{
|
||||
IsClosed: &isClosed,
|
||||
}).AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
DecodeJSON(t, resp, &updatedProject)
|
||||
assert.False(t, updatedProject.IsClosed)
|
||||
|
||||
// Test updating non-existent project
|
||||
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/99999", owner.Name, repo.Name), &api.EditProjectOption{
|
||||
Title: &newTitle,
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestAPIDeleteProject(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
|
||||
// Create a test project
|
||||
project := &project_model.Project{
|
||||
Title: "Project to Delete",
|
||||
RepoID: repo.ID,
|
||||
Type: project_model.TypeRepository,
|
||||
CreatorID: owner.ID,
|
||||
TemplateType: project_model.TemplateTypeNone,
|
||||
}
|
||||
err := project_model.NewProject(t.Context(), project)
|
||||
assert.NoError(t, err)
|
||||
|
||||
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
// Test deleting the project
|
||||
req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID).
|
||||
AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNoContent)
|
||||
|
||||
// Test deleting non-existent project (including the one we just deleted)
|
||||
req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID).
|
||||
AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestAPIListProjectColumns(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
|
||||
// Create a test project
|
||||
project := &project_model.Project{
|
||||
Title: "Project for Columns Test",
|
||||
RepoID: repo.ID,
|
||||
Type: project_model.TypeRepository,
|
||||
CreatorID: owner.ID,
|
||||
TemplateType: project_model.TemplateTypeNone,
|
||||
}
|
||||
err := project_model.NewProject(t.Context(), project)
|
||||
assert.NoError(t, err)
|
||||
defer func() {
|
||||
_ = project_model.DeleteProjectByID(t.Context(), project.ID)
|
||||
}()
|
||||
|
||||
// Create test columns
|
||||
for i := 1; i <= 3; i++ {
|
||||
column := &project_model.Column{
|
||||
Title: fmt.Sprintf("Column %d", i),
|
||||
ProjectID: project.ID,
|
||||
CreatorID: owner.ID,
|
||||
}
|
||||
err = project_model.NewColumn(t.Context(), column)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue)
|
||||
|
||||
// Test listing columns
|
||||
req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns", owner.Name, repo.Name, project.ID).
|
||||
AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
var columns []*api.ProjectColumn
|
||||
DecodeJSON(t, resp, &columns)
|
||||
assert.Len(t, columns, 3)
|
||||
assert.Equal(t, "Column 1", columns[0].Title)
|
||||
assert.Equal(t, "Column 2", columns[1].Title)
|
||||
assert.Equal(t, "Column 3", columns[2].Title)
|
||||
|
||||
// Test pagination
|
||||
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns?page=1&limit=2", owner.Name, repo.Name, project.ID).
|
||||
AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
DecodeJSON(t, resp, &columns)
|
||||
assert.Len(t, columns, 2)
|
||||
|
||||
// Test listing columns for non-existent project
|
||||
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/99999/columns", owner.Name, repo.Name).
|
||||
AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestAPICreateProjectColumn(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
|
||||
// Create a test project
|
||||
project := &project_model.Project{
|
||||
Title: "Project for Column Creation",
|
||||
RepoID: repo.ID,
|
||||
Type: project_model.TypeRepository,
|
||||
CreatorID: owner.ID,
|
||||
TemplateType: project_model.TemplateTypeNone,
|
||||
}
|
||||
err := project_model.NewProject(t.Context(), project)
|
||||
assert.NoError(t, err)
|
||||
defer func() {
|
||||
_ = project_model.DeleteProjectByID(t.Context(), project.ID)
|
||||
}()
|
||||
|
||||
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
// Test creating a column with color
|
||||
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns", owner.Name, repo.Name, project.ID), &api.CreateProjectColumnOption{
|
||||
Title: "New Column",
|
||||
Color: "#FF5733",
|
||||
}).AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
var column api.ProjectColumn
|
||||
DecodeJSON(t, resp, &column)
|
||||
assert.Equal(t, "New Column", column.Title)
|
||||
assert.Equal(t, "#FF5733", column.Color)
|
||||
assert.Equal(t, project.ID, column.ProjectID)
|
||||
|
||||
// Test creating a column without color
|
||||
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns", owner.Name, repo.Name, project.ID), &api.CreateProjectColumnOption{
|
||||
Title: "Simple Column",
|
||||
}).AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
DecodeJSON(t, resp, &column)
|
||||
assert.Equal(t, "Simple Column", column.Title)
|
||||
|
||||
// Test creating with empty title
|
||||
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns", owner.Name, repo.Name, project.ID), &api.CreateProjectColumnOption{
|
||||
Title: "",
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||
|
||||
// Test creating for non-existent project
|
||||
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/99999/columns", owner.Name, repo.Name), &api.CreateProjectColumnOption{
|
||||
Title: "Orphan Column",
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestAPIUpdateProjectColumn(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
|
||||
// Create a test project and column
|
||||
project := &project_model.Project{
|
||||
Title: "Project for Column Update",
|
||||
RepoID: repo.ID,
|
||||
Type: project_model.TypeRepository,
|
||||
CreatorID: owner.ID,
|
||||
TemplateType: project_model.TemplateTypeNone,
|
||||
}
|
||||
err := project_model.NewProject(t.Context(), project)
|
||||
assert.NoError(t, err)
|
||||
defer func() {
|
||||
_ = project_model.DeleteProjectByID(t.Context(), project.ID)
|
||||
}()
|
||||
|
||||
column := &project_model.Column{
|
||||
Title: "Original Column",
|
||||
ProjectID: project.ID,
|
||||
CreatorID: owner.ID,
|
||||
Color: "#000000",
|
||||
}
|
||||
err = project_model.NewColumn(t.Context(), column)
|
||||
assert.NoError(t, err)
|
||||
|
||||
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
// Test updating column title
|
||||
newTitle := "Updated Column"
|
||||
req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/columns/%d", owner.Name, repo.Name, column.ID), &api.EditProjectColumnOption{
|
||||
Title: &newTitle,
|
||||
}).AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
var updatedColumn api.ProjectColumn
|
||||
DecodeJSON(t, resp, &updatedColumn)
|
||||
assert.Equal(t, newTitle, updatedColumn.Title)
|
||||
|
||||
// Test updating column color
|
||||
newColor := "#FF0000"
|
||||
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/columns/%d", owner.Name, repo.Name, column.ID), &api.EditProjectColumnOption{
|
||||
Color: &newColor,
|
||||
}).AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
DecodeJSON(t, resp, &updatedColumn)
|
||||
assert.Equal(t, newColor, updatedColumn.Color)
|
||||
|
||||
// Test updating non-existent column
|
||||
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/columns/99999", owner.Name, repo.Name), &api.EditProjectColumnOption{
|
||||
Title: &newTitle,
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestAPIDeleteProjectColumn(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
|
||||
// Create a test project and column
|
||||
project := &project_model.Project{
|
||||
Title: "Project for Column Deletion",
|
||||
RepoID: repo.ID,
|
||||
Type: project_model.TypeRepository,
|
||||
CreatorID: owner.ID,
|
||||
TemplateType: project_model.TemplateTypeNone,
|
||||
}
|
||||
err := project_model.NewProject(t.Context(), project)
|
||||
assert.NoError(t, err)
|
||||
defer func() {
|
||||
_ = project_model.DeleteProjectByID(t.Context(), project.ID)
|
||||
}()
|
||||
|
||||
column := &project_model.Column{
|
||||
Title: "Column to Delete",
|
||||
ProjectID: project.ID,
|
||||
CreatorID: owner.ID,
|
||||
}
|
||||
err = project_model.NewColumn(t.Context(), column)
|
||||
assert.NoError(t, err)
|
||||
|
||||
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
// Test deleting the column
|
||||
req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/projects/columns/%d", owner.Name, repo.Name, column.ID).
|
||||
AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNoContent)
|
||||
|
||||
// Test deleting non-existent column (including the one we just deleted)
|
||||
req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/projects/columns/%d", owner.Name, repo.Name, column.ID).
|
||||
AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestAPIAddIssueToProjectColumn(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID})
|
||||
|
||||
// Create a test project and column
|
||||
project := &project_model.Project{
|
||||
Title: "Project for Issue Assignment",
|
||||
RepoID: repo.ID,
|
||||
Type: project_model.TypeRepository,
|
||||
CreatorID: owner.ID,
|
||||
TemplateType: project_model.TemplateTypeNone,
|
||||
}
|
||||
err := project_model.NewProject(t.Context(), project)
|
||||
assert.NoError(t, err)
|
||||
defer func() {
|
||||
_ = project_model.DeleteProjectByID(t.Context(), project.ID)
|
||||
}()
|
||||
|
||||
column1 := &project_model.Column{
|
||||
Title: "Column 1",
|
||||
ProjectID: project.ID,
|
||||
CreatorID: owner.ID,
|
||||
}
|
||||
err = project_model.NewColumn(t.Context(), column1)
|
||||
assert.NoError(t, err)
|
||||
|
||||
column2 := &project_model.Column{
|
||||
Title: "Column 2",
|
||||
ProjectID: project.ID,
|
||||
CreatorID: owner.ID,
|
||||
}
|
||||
err = project_model.NewColumn(t.Context(), column2)
|
||||
assert.NoError(t, err)
|
||||
|
||||
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
// Test adding issue to column
|
||||
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/columns/%d/issues", owner.Name, repo.Name, column1.ID), &api.AddIssueToProjectColumnOption{
|
||||
IssueID: issue.ID,
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
// Verify issue is in the column
|
||||
projectIssue := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{
|
||||
ProjectID: project.ID,
|
||||
IssueID: issue.ID,
|
||||
})
|
||||
assert.Equal(t, column1.ID, projectIssue.ProjectColumnID)
|
||||
|
||||
// Test moving issue to another column
|
||||
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/columns/%d/issues", owner.Name, repo.Name, column2.ID), &api.AddIssueToProjectColumnOption{
|
||||
IssueID: issue.ID,
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
// Verify issue moved to new column
|
||||
projectIssue = unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{
|
||||
ProjectID: project.ID,
|
||||
IssueID: issue.ID,
|
||||
})
|
||||
assert.Equal(t, column2.ID, projectIssue.ProjectColumnID)
|
||||
|
||||
// Test adding same issue to same column (should be idempotent)
|
||||
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/columns/%d/issues", owner.Name, repo.Name, column2.ID), &api.AddIssueToProjectColumnOption{
|
||||
IssueID: issue.ID,
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
// Test adding non-existent issue
|
||||
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/columns/%d/issues", owner.Name, repo.Name, column1.ID), &api.AddIssueToProjectColumnOption{
|
||||
IssueID: 99999,
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||
|
||||
// Test adding to non-existent column
|
||||
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/columns/99999/issues", owner.Name, repo.Name), &api.AddIssueToProjectColumnOption{
|
||||
IssueID: issue.ID,
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestAPIProjectPermissions(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
nonCollaborator := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user5"})
|
||||
|
||||
// Create a test project
|
||||
project := &project_model.Project{
|
||||
Title: "Permission Test Project",
|
||||
RepoID: repo.ID,
|
||||
Type: project_model.TypeRepository,
|
||||
CreatorID: owner.ID,
|
||||
TemplateType: project_model.TemplateTypeNone,
|
||||
}
|
||||
err := project_model.NewProject(t.Context(), project)
|
||||
assert.NoError(t, err)
|
||||
defer func() {
|
||||
_ = project_model.DeleteProjectByID(t.Context(), project.ID)
|
||||
}()
|
||||
|
||||
ownerToken := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
nonCollaboratorToken := getUserToken(t, nonCollaborator.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
// Owner should be able to read
|
||||
req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID).
|
||||
AddTokenAuth(ownerToken)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
// Owner should be able to update
|
||||
newTitle := "Updated by Owner"
|
||||
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{
|
||||
Title: &newTitle,
|
||||
}).AddTokenAuth(ownerToken)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
// Non-collaborator should not be able to update
|
||||
anotherTitle := "Updated by Non-collaborator"
|
||||
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{
|
||||
Title: &anotherTitle,
|
||||
}).AddTokenAuth(nonCollaboratorToken)
|
||||
MakeRequest(t, req, http.StatusForbidden)
|
||||
|
||||
// Non-collaborator should not be able to delete
|
||||
req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID).
|
||||
AddTokenAuth(nonCollaboratorToken)
|
||||
MakeRequest(t, req, http.StatusForbidden)
|
||||
}
|
||||
Loading…
Reference in New Issue