This commit is contained in:
@@ -25,28 +25,30 @@ const ScopeSortPrefix = "scope-"
|
||||
|
||||
// IssuesOptions represents options of an issue.
|
||||
type IssuesOptions struct { //nolint:revive // export stutter
|
||||
Paginator *db.ListOptions
|
||||
RepoIDs []int64 // overwrites RepoCond if the length is not 0
|
||||
AllPublic bool // include also all public repositories
|
||||
RepoCond builder.Cond
|
||||
AssigneeID string // "(none)" or "(any)" or a user ID
|
||||
PosterID string // "(none)" or "(any)" or a user ID
|
||||
MentionedID int64
|
||||
ReviewRequestedID int64
|
||||
ReviewedID int64
|
||||
SubscriberID int64
|
||||
MilestoneIDs []int64
|
||||
ProjectIDs []int64
|
||||
IsClosed optional.Option[bool]
|
||||
IsPull optional.Option[bool]
|
||||
LabelIDs []int64
|
||||
IncludedLabelNames []string
|
||||
ExcludedLabelNames []string
|
||||
IncludeMilestones []string
|
||||
SortType string
|
||||
IssueIDs []int64
|
||||
UpdatedAfterUnix int64
|
||||
UpdatedBeforeUnix int64
|
||||
Paginator *db.ListOptions
|
||||
RepoIDs []int64 // overwrites RepoCond if the length is not 0
|
||||
AllPublic bool // include also all public repositories
|
||||
RepoCond builder.Cond
|
||||
AssigneeID string // "(none)" or "(any)" or a user ID
|
||||
PosterID string // "(none)" or "(any)" or a user ID
|
||||
MentionedID int64
|
||||
ReviewRequestedID int64
|
||||
ReviewedID int64
|
||||
SubscriberID int64
|
||||
MilestoneIDs []int64
|
||||
ProjectIDs []int64
|
||||
ProjectColumnID int64
|
||||
ProjectColumnOrUnassigned bool
|
||||
IsClosed optional.Option[bool]
|
||||
IsPull optional.Option[bool]
|
||||
LabelIDs []int64
|
||||
IncludedLabelNames []string
|
||||
ExcludedLabelNames []string
|
||||
IncludeMilestones []string
|
||||
SortType string
|
||||
IssueIDs []int64
|
||||
UpdatedAfterUnix int64
|
||||
UpdatedBeforeUnix int64
|
||||
// prioritize issues from this repo
|
||||
PriorityRepoID int64
|
||||
IsArchived optional.Option[bool]
|
||||
@@ -201,7 +203,17 @@ func applyProjectCondition(sess db.Session, opts *IssuesOptions) {
|
||||
if len(projectIDs) == 1 && projectIDs[0] == db.NoConditionID { // show those that are in no project
|
||||
sess.And(builder.NotIn("issue.id", builder.Select("issue_id").From("project_issue")))
|
||||
} else if len(projectIDs) == 1 && projectIDs[0] > 0 { // single specific project
|
||||
sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id AND project_issue.project_id = ?", projectIDs[0])
|
||||
joinCondition := "issue.id = project_issue.issue_id AND project_issue.project_id = ?"
|
||||
joinArgs := []any{projectIDs[0]}
|
||||
if opts.ProjectColumnID > 0 {
|
||||
if opts.ProjectColumnOrUnassigned {
|
||||
joinCondition += " AND project_issue.project_board_id IN (?, 0)"
|
||||
} else {
|
||||
joinCondition += " AND project_issue.project_board_id = ?"
|
||||
}
|
||||
joinArgs = append(joinArgs, opts.ProjectColumnID)
|
||||
}
|
||||
sess.Join("INNER", "project_issue", joinCondition, joinArgs...)
|
||||
} else if len(projectIDs) > 1 { // multiple projects
|
||||
// FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: this logic is not right, it should use "AND" but not "OR"
|
||||
sess.And(builder.In("issue.id", builder.Select("issue_id").From("project_issue").Where(builder.In("project_id", projectIDs))))
|
||||
|
||||
@@ -6,6 +6,7 @@ package project
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/util"
|
||||
@@ -46,6 +47,9 @@ func GetColumnIssueNextSorting(ctx context.Context, projectID, columnID int64) (
|
||||
Get(&res); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if res.IssueCount > 0 && res.MaxSorting == math.MaxInt64 {
|
||||
return 0, util.NewInvalidArgumentErrorf("project column sorting is exhausted")
|
||||
}
|
||||
return util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -31,3 +31,26 @@ type Project struct {
|
||||
// swagger:strfmt date-time
|
||||
Closed *time.Time `json:"closed_at,omitempty"`
|
||||
}
|
||||
|
||||
// ProjectColumn represents a column in a project
|
||||
// swagger:model
|
||||
type ProjectColumn struct {
|
||||
// ID is the unique identifier for the column
|
||||
ID int64 `json:"id"`
|
||||
// Title is the title of the column
|
||||
Title string `json:"title"`
|
||||
// Color is the optional column color
|
||||
Color string `json:"color"`
|
||||
// Sorting is the column position
|
||||
Sorting int8 `json:"sorting"`
|
||||
}
|
||||
|
||||
// MoveProjectIssueOption options for moving an issue inside a project
|
||||
// swagger:model
|
||||
type MoveProjectIssueOption struct {
|
||||
// ColumnID is the destination project column
|
||||
// required: true
|
||||
ColumnID int64 `json:"column_id" binding:"Required"`
|
||||
// Sorting is the optional position inside the destination column
|
||||
Sorting *int64 `json:"sorting,omitempty"`
|
||||
}
|
||||
|
||||
@@ -799,6 +799,17 @@ func mustEnableWiki(ctx *context.APIContext) {
|
||||
}
|
||||
}
|
||||
|
||||
func mustEnableRepoProjects(ctx *context.APIContext) {
|
||||
if unit.TypeProjects.UnitGlobalDisabled() || !ctx.Repo.Permission.CanRead(unit.TypeProjects) {
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
projectsUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeProjects)
|
||||
if !projectsUnit.ProjectsConfig().IsProjectsAllowed(repo_model.ProjectsModeRepo) {
|
||||
ctx.APIErrorNotFound()
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: for consistency, maybe most mustNotBeArchived checks should be replaced with mustEnableEditor
|
||||
func mustNotBeArchived(ctx *context.APIContext) {
|
||||
if ctx.Repo.Repository.IsArchived {
|
||||
@@ -1554,6 +1565,12 @@ func Routes() *web.Router {
|
||||
m.Get("/issues/search", repo.SearchIssues)
|
||||
|
||||
m.Group("/{username}/{reponame}", func() {
|
||||
m.Group("/projects", func() {
|
||||
m.Get("", repo.ListProjects)
|
||||
m.Get("/{id}/columns", repo.ListProjectColumns)
|
||||
m.Get("/{id}/columns/{column_id}/issues", repo.ListProjectColumnIssues)
|
||||
m.Post("/{id}/issues/{issue_id}/move", reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived, bind(api.MoveProjectIssueOption{}), repo.MoveProjectIssue)
|
||||
}, reqRepoReader(unit.TypeProjects), mustEnableRepoProjects)
|
||||
m.Group("/issues", func() {
|
||||
m.Combo("").Get(repo.ListIssues).
|
||||
Post(reqToken(), mustNotBeArchived, bind(api.CreateIssueOption{}), reqRepoReader(unit.TypeIssues), repo.CreateIssue)
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
project_model "gitea.dev/models/project"
|
||||
"gitea.dev/modules/optional"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/routers/api/v1/utils"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/convert"
|
||||
project_service "gitea.dev/services/projects"
|
||||
)
|
||||
|
||||
func getRepoProject(ctx *context.APIContext) *project_model.Project {
|
||||
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 nil
|
||||
}
|
||||
return project
|
||||
}
|
||||
|
||||
func getProjectColumn(ctx *context.APIContext, projectID int64) *project_model.Column {
|
||||
column, err := project_model.GetColumnByIDAndProjectID(ctx, ctx.PathParamInt64("column_id"), projectID)
|
||||
if err != nil {
|
||||
if project_model.IsErrProjectColumnNotExist(err) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return column
|
||||
}
|
||||
|
||||
// ListProjects list a repository's projects
|
||||
func ListProjects(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/projects project repoListProjects
|
||||
// ---
|
||||
// summary: List a repository's projects
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: repo
|
||||
// in: path
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: state
|
||||
// in: query
|
||||
// type: string
|
||||
// enum: [open, closed, all]
|
||||
// default: open
|
||||
// - name: page
|
||||
// in: query
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/ProjectList"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "500":
|
||||
// "$ref": "#/responses/internalServerError"
|
||||
|
||||
state := ctx.FormString("state")
|
||||
isClosed := optional.Some(false)
|
||||
switch state {
|
||||
case "", "open":
|
||||
case "closed":
|
||||
isClosed = optional.Some(true)
|
||||
case "all":
|
||||
isClosed = optional.None[bool]()
|
||||
default:
|
||||
ctx.APIError(http.StatusUnprocessableEntity, "state must be one of open, closed, or all")
|
||||
return
|
||||
}
|
||||
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
projects, count, err := db.FindAndCount[project_model.Project](ctx, project_model.SearchOptions{
|
||||
ListOptions: listOptions,
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
IsClosed: isClosed,
|
||||
OrderBy: db.SearchOrderByNewest,
|
||||
Type: project_model.TypeRepository,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.SetLinkHeader(count, listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(count)
|
||||
ctx.JSON(http.StatusOK, convert.ToAPIProjectList(projects))
|
||||
}
|
||||
|
||||
// ListProjectColumns list a repository project's columns
|
||||
func ListProjectColumns(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/projects/{id}/columns project repoListProjectColumns
|
||||
// ---
|
||||
// summary: List a repository project's columns
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: repo
|
||||
// in: path
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: id
|
||||
// in: path
|
||||
// required: true
|
||||
// type: integer
|
||||
// format: int64
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/ProjectColumnList"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "500":
|
||||
// "$ref": "#/responses/internalServerError"
|
||||
|
||||
project := getRepoProject(ctx)
|
||||
if project == nil {
|
||||
return
|
||||
}
|
||||
columns, err := project.GetColumns(ctx)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.SetTotalCountHeader(int64(len(columns)))
|
||||
ctx.JSON(http.StatusOK, convert.ToAPIProjectColumnList(columns))
|
||||
}
|
||||
|
||||
// ListProjectColumnIssues list issues in a repository project column
|
||||
func ListProjectColumnIssues(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/projects/{id}/columns/{column_id}/issues project repoListProjectColumnIssues
|
||||
// ---
|
||||
// summary: List issues in a repository project column
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: repo
|
||||
// in: path
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: id
|
||||
// in: path
|
||||
// required: true
|
||||
// type: integer
|
||||
// format: int64
|
||||
// - name: column_id
|
||||
// in: path
|
||||
// required: true
|
||||
// type: integer
|
||||
// format: int64
|
||||
// - name: page
|
||||
// in: query
|
||||
// type: integer
|
||||
// - name: limit
|
||||
// in: query
|
||||
// type: integer
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/IssueList"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "500":
|
||||
// "$ref": "#/responses/internalServerError"
|
||||
|
||||
project := getRepoProject(ctx)
|
||||
if project == nil {
|
||||
return
|
||||
}
|
||||
column := getProjectColumn(ctx, project.ID)
|
||||
if column == nil {
|
||||
return
|
||||
}
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
issueOptions := &issues_model.IssuesOptions{
|
||||
Paginator: &listOptions,
|
||||
RepoIDs: []int64{ctx.Repo.Repository.ID},
|
||||
ProjectIDs: []int64{project.ID},
|
||||
ProjectColumnID: column.ID,
|
||||
ProjectColumnOrUnassigned: column.Default,
|
||||
SortType: "project-column-sorting",
|
||||
Doer: ctx.Doer,
|
||||
}
|
||||
total, err := issues_model.CountIssues(ctx, issueOptions)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
issues, err := issues_model.Issues(ctx, issueOptions)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.SetLinkHeader(total, listOptions.PageSize)
|
||||
ctx.SetTotalCountHeader(total)
|
||||
ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, ctx.Doer, issues))
|
||||
}
|
||||
|
||||
// MoveProjectIssue moves an issue to another repository project column
|
||||
func MoveProjectIssue(ctx *context.APIContext) {
|
||||
// swagger:operation POST /repos/{owner}/{repo}/projects/{id}/issues/{issue_id}/move project repoMoveProjectIssue
|
||||
// ---
|
||||
// summary: Move an issue to another repository project column
|
||||
// consumes:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: repo
|
||||
// in: path
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: id
|
||||
// in: path
|
||||
// required: true
|
||||
// type: integer
|
||||
// format: int64
|
||||
// - name: issue_id
|
||||
// in: path
|
||||
// required: true
|
||||
// type: integer
|
||||
// format: int64
|
||||
// - name: body
|
||||
// in: body
|
||||
// required: true
|
||||
// schema:
|
||||
// "$ref": "#/definitions/MoveProjectIssueOption"
|
||||
// responses:
|
||||
// "204":
|
||||
// description: issue moved
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "423":
|
||||
// "$ref": "#/responses/locked"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
// "500":
|
||||
// "$ref": "#/responses/internalServerError"
|
||||
|
||||
project := getRepoProject(ctx)
|
||||
if project == nil {
|
||||
return
|
||||
}
|
||||
if project.IsClosed {
|
||||
ctx.APIError(http.StatusForbidden, "project is closed")
|
||||
return
|
||||
}
|
||||
form := web.GetForm(ctx).(*api.MoveProjectIssueOption)
|
||||
column, err := project_model.GetColumnByIDAndProjectID(ctx, form.ColumnID, project.ID)
|
||||
if err != nil {
|
||||
if project_model.IsErrProjectColumnNotExist(err) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
issueID := ctx.PathParamInt64("issue_id")
|
||||
if _, err := issues_model.GetIssueByRepoID(ctx, ctx.Repo.Repository.ID, issueID); err != nil {
|
||||
if issues_model.IsErrIssueNotExist(err) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := project_service.MoveIssueOnProjectColumn(ctx, ctx.Doer, column, issueID, form.Sorting); err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else if errors.Is(err, util.ErrInvalidArgument) {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -48,6 +48,8 @@ type swaggerParameterBodies struct {
|
||||
|
||||
// in:body
|
||||
IssueLabelsOption api.IssueLabelsOption
|
||||
// in:body
|
||||
MoveProjectIssueOption api.MoveProjectIssueOption
|
||||
|
||||
// in:body
|
||||
CreateKeyOption api.CreateKeyOption
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package swagger
|
||||
|
||||
import api "gitea.dev/modules/structs"
|
||||
|
||||
// ProjectList
|
||||
// swagger:response ProjectList
|
||||
type swaggerResponseProjectList struct {
|
||||
// in:body
|
||||
Body []api.Project `json:"body"`
|
||||
}
|
||||
|
||||
// ProjectColumnList
|
||||
// swagger:response ProjectColumnList
|
||||
type swaggerResponseProjectColumnList struct {
|
||||
// in:body
|
||||
Body []api.ProjectColumn `json:"body"`
|
||||
}
|
||||
@@ -35,3 +35,22 @@ func ToAPIProjectList(projects []*project_model.Project) []*api.Project {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ToAPIProjectColumn converts a project column to API format
|
||||
func ToAPIProjectColumn(column *project_model.Column) *api.ProjectColumn {
|
||||
return &api.ProjectColumn{
|
||||
ID: column.ID,
|
||||
Title: column.Title,
|
||||
Color: column.Color,
|
||||
Sorting: column.Sorting,
|
||||
}
|
||||
}
|
||||
|
||||
// ToAPIProjectColumnList converts project columns to API format
|
||||
func ToAPIProjectColumnList(columns project_model.ColumnList) []*api.ProjectColumn {
|
||||
result := make([]*api.ProjectColumn, len(columns))
|
||||
for i := range columns {
|
||||
result[i] = ToAPIProjectColumn(columns[i])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -5,102 +5,174 @@ package project
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
project_model "gitea.dev/models/project"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
var projectIssueMoveMu sync.Mutex
|
||||
|
||||
// MoveIssuesOnProjectColumn moves or keeps issues in a column and sorts them inside that column
|
||||
func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, column *project_model.Column, sortedIssueIDs map[int64]int64) error {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
issueIDs := make([]int64, 0, len(sortedIssueIDs))
|
||||
for _, issueID := range sortedIssueIDs {
|
||||
issueIDs = append(issueIDs, issueID)
|
||||
}
|
||||
count, err := db.GetEngine(ctx).
|
||||
Where("project_id=?", column.ProjectID).
|
||||
In("issue_id", issueIDs).
|
||||
Count(new(project_model.ProjectIssue))
|
||||
return moveIssuesOnProjectColumn(ctx, doer, column, sortedIssueIDs)
|
||||
})
|
||||
}
|
||||
|
||||
// MoveIssueOnProjectColumn appends or positions one issue atomically.
|
||||
func MoveIssueOnProjectColumn(ctx context.Context, doer *user_model.User, column *project_model.Column, issueID int64, sorting *int64) error {
|
||||
// Serialize reorder calculations so concurrent requests cannot allocate the same sorting value.
|
||||
projectIssueMoveMu.Lock()
|
||||
defer projectIssueMoveMu.Unlock()
|
||||
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
nextSorting, err := project_model.GetColumnIssueNextSorting(ctx, column.ProjectID, column.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if int(count) != len(sortedIssueIDs) {
|
||||
return errors.New("all issues have to be added to a project first")
|
||||
position := nextSorting
|
||||
if sorting != nil {
|
||||
position = *sorting
|
||||
}
|
||||
|
||||
issues, err := issues_model.GetIssuesByIDs(ctx, issueIDs)
|
||||
if position < 0 || position == math.MaxInt64 {
|
||||
return util.NewInvalidArgumentErrorf("sorting must be between 0 and %d", math.MaxInt64-1)
|
||||
}
|
||||
current := &project_model.ProjectIssue{}
|
||||
exists, err := db.GetEngine(ctx).
|
||||
Where("project_id=? AND issue_id=?", column.ProjectID, issueID).
|
||||
Get(current)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := issues.LoadRepositories(ctx); err != nil {
|
||||
return err
|
||||
if !exists {
|
||||
return util.NewInvalidArgumentErrorf("issue has to be added to project first")
|
||||
}
|
||||
|
||||
project, err := project_model.GetProjectByID(ctx, column.ProjectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
issuesMap := make(map[int64]*issues_model.Issue, len(issues))
|
||||
for _, issue := range issues {
|
||||
issuesMap[issue.ID] = issue
|
||||
}
|
||||
|
||||
for sorting, issueID := range sortedIssueIDs {
|
||||
curIssue := issuesMap[issueID]
|
||||
if curIssue == nil {
|
||||
continue
|
||||
if current.ProjectColumnID == column.ID {
|
||||
if position >= nextSorting {
|
||||
position = nextSorting - 1
|
||||
}
|
||||
|
||||
projectColumnMap, err := curIssue.ProjectColumnMap(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
projectColumnID := projectColumnMap[column.ProjectID]
|
||||
|
||||
if projectColumnID != column.ID {
|
||||
// add timeline to issue
|
||||
if _, err := issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{
|
||||
Type: issues_model.CommentTypeProjectColumn,
|
||||
Doer: doer,
|
||||
Repo: curIssue.Repo,
|
||||
Issue: curIssue,
|
||||
ProjectID: column.ProjectID,
|
||||
ProjectTitle: project.Title,
|
||||
ProjectColumnID: column.ID,
|
||||
ProjectColumnTitle: column.Title,
|
||||
}); err != nil {
|
||||
if current.Sorting < position {
|
||||
if _, err := db.GetEngine(ctx).
|
||||
Where("project_id=? AND project_board_id=? AND sorting>? AND sorting<=?", column.ProjectID, column.ID, current.Sorting, position).
|
||||
SetExpr("sorting", "sorting - 1").Update(new(project_model.ProjectIssue)); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if current.Sorting > position {
|
||||
if _, err := db.GetEngine(ctx).
|
||||
Where("project_id=? AND project_board_id=? AND sorting>=? AND sorting<?", column.ProjectID, column.ID, position, current.Sorting).
|
||||
SetExpr("sorting", "sorting + 1").Update(new(project_model.ProjectIssue)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Update the column and sorting for this specific issue in this specific project.
|
||||
// IMPORTANT: The WHERE clause must include both issue_id AND project_id to ensure
|
||||
// that moving an issue's column in one project doesn't affect its column in other
|
||||
// projects when the issue is assigned to multiple projects.
|
||||
_, err = db.GetEngine(ctx).Table("project_issue").
|
||||
Where("issue_id = ? AND project_id = ?", issueID, column.ProjectID).
|
||||
Update(map[string]any{
|
||||
"project_board_id": column.ID,
|
||||
"sorting": sorting,
|
||||
})
|
||||
if err != nil {
|
||||
} else if position > nextSorting {
|
||||
position = nextSorting
|
||||
}
|
||||
if current.ProjectColumnID != column.ID {
|
||||
if _, err := db.GetEngine(ctx).
|
||||
Where("project_id=? AND project_board_id=? AND sorting>=?", column.ProjectID, column.ID, position).
|
||||
SetExpr("sorting", "sorting + 1").Update(new(project_model.ProjectIssue)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return moveIssuesOnProjectColumn(ctx, doer, column, map[int64]int64{position: issueID})
|
||||
})
|
||||
}
|
||||
|
||||
func moveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, column *project_model.Column, sortedIssueIDs map[int64]int64) error {
|
||||
for sorting := range sortedIssueIDs {
|
||||
if sorting < 0 || sorting == math.MaxInt64 {
|
||||
return util.NewInvalidArgumentErrorf("sorting must be between 0 and %d", math.MaxInt64-1)
|
||||
}
|
||||
}
|
||||
issueIDs := make([]int64, 0, len(sortedIssueIDs))
|
||||
for _, issueID := range sortedIssueIDs {
|
||||
issueIDs = append(issueIDs, issueID)
|
||||
}
|
||||
count, err := db.GetEngine(ctx).
|
||||
Where("project_id=?", column.ProjectID).
|
||||
In("issue_id", issueIDs).
|
||||
Count(new(project_model.ProjectIssue))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if int(count) != len(sortedIssueIDs) {
|
||||
return util.NewInvalidArgumentErrorf("all issues have to be added to a project first")
|
||||
}
|
||||
|
||||
issues, err := issues_model.GetIssuesByIDs(ctx, issueIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := issues.LoadRepositories(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
project, err := project_model.GetProjectByID(ctx, column.ProjectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
issuesMap := make(map[int64]*issues_model.Issue, len(issues))
|
||||
for _, issue := range issues {
|
||||
issuesMap[issue.ID] = issue
|
||||
}
|
||||
|
||||
for sorting, issueID := range sortedIssueIDs {
|
||||
curIssue := issuesMap[issueID]
|
||||
if curIssue == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
projectColumnMap, err := curIssue.ProjectColumnMap(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
projectColumnID := projectColumnMap[column.ProjectID]
|
||||
|
||||
if projectColumnID != column.ID {
|
||||
// add timeline to issue
|
||||
if _, err := issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{
|
||||
Type: issues_model.CommentTypeProjectColumn,
|
||||
Doer: doer,
|
||||
Repo: curIssue.Repo,
|
||||
Issue: curIssue,
|
||||
ProjectID: column.ProjectID,
|
||||
ProjectTitle: project.Title,
|
||||
ProjectColumnID: column.ID,
|
||||
ProjectColumnTitle: column.Title,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Update the column and sorting for this specific issue in this specific project.
|
||||
// IMPORTANT: The WHERE clause must include both issue_id AND project_id to ensure
|
||||
// that moving an issue's column in one project doesn't affect its column in other
|
||||
// projects when the issue is assigned to multiple projects.
|
||||
_, err = db.GetEngine(ctx).Table("project_issue").
|
||||
Where("issue_id = ? AND project_id = ?", issueID, column.ProjectID).
|
||||
Update(map[string]any{
|
||||
"project_board_id": column.ID,
|
||||
"sorting": sorting,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func LoadIssuesAssigneesForProject(ctx context.Context, projectID int64) (users []*user_model.User, _ error) {
|
||||
sub := builder.Select("distinct issue_assignees.assignee_id").
|
||||
From("project_issue").Join("INNER", "issue_assignees", "project_issue.issue_id=issue_assignees.issue_id").
|
||||
|
||||
+287
-2
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"version": "{{.SwaggerAppVer}}"
|
||||
},
|
||||
"basePath": "{{.SwaggerAppSubUrl}}/api/v1",
|
||||
"basePath": "/{{.SwaggerAppSubUrl}}/api/v1",
|
||||
"paths": {
|
||||
"/admin/actions/jobs": {
|
||||
"get": {
|
||||
@@ -14354,6 +14354,222 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/repos/{owner}/{repo}/projects": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"project"
|
||||
],
|
||||
"summary": "List a repository's projects",
|
||||
"operationId": "repoListProjects",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"name": "owner",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"name": "repo",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"open",
|
||||
"closed",
|
||||
"all"
|
||||
],
|
||||
"type": "string",
|
||||
"default": "open",
|
||||
"name": "state",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"name": "page",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/responses/ProjectList"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/responses/notFound"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/repos/{owner}/{repo}/projects/{id}/columns": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"project"
|
||||
],
|
||||
"summary": "List a repository project's columns",
|
||||
"operationId": "repoListProjectColumns",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"name": "owner",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"name": "repo",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/responses/ProjectColumnList"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/responses/notFound"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/repos/{owner}/{repo}/projects/{id}/columns/{column_id}/issues": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"project"
|
||||
],
|
||||
"summary": "List issues in a repository project column",
|
||||
"operationId": "repoListProjectColumnIssues",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"name": "owner",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"name": "repo",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"name": "column_id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"name": "page",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/responses/IssueList"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/responses/notFound"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/repos/{owner}/{repo}/projects/{id}/issues/{issue_id}/move": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"project"
|
||||
],
|
||||
"summary": "Move an issue to another repository project column",
|
||||
"operationId": "repoMoveProjectIssue",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"name": "owner",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"name": "repo",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"name": "issue_id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/MoveProjectIssueOption"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "issue moved"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/responses/forbidden"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/responses/notFound"
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#/responses/validationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/repos/{owner}/{repo}/pulls": {
|
||||
"get": {
|
||||
"produces": [
|
||||
@@ -27941,6 +28157,28 @@
|
||||
},
|
||||
"x-go-package": "gitea.dev/modules/structs"
|
||||
},
|
||||
"MoveProjectIssueOption": {
|
||||
"description": "MoveProjectIssueOption options for moving an issue inside a project",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"column_id"
|
||||
],
|
||||
"properties": {
|
||||
"column_id": {
|
||||
"description": "ColumnID is the destination project column",
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"x-go-name": "ColumnID"
|
||||
},
|
||||
"sorting": {
|
||||
"description": "Sorting is the optional position inside the destination column",
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"x-go-name": "Sorting"
|
||||
}
|
||||
},
|
||||
"x-go-package": "gitea.dev/modules/structs"
|
||||
},
|
||||
"NewIssuePinsAllowed": {
|
||||
"description": "NewIssuePinsAllowed represents an API response that says if new Issue Pins are allowed",
|
||||
"type": "object",
|
||||
@@ -28682,6 +28920,35 @@
|
||||
},
|
||||
"x-go-package": "gitea.dev/modules/structs"
|
||||
},
|
||||
"ProjectColumn": {
|
||||
"description": "ProjectColumn represents a column in a project",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "Color is the optional column color",
|
||||
"type": "string",
|
||||
"x-go-name": "Color"
|
||||
},
|
||||
"id": {
|
||||
"description": "ID is the unique identifier for the column",
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"x-go-name": "ID"
|
||||
},
|
||||
"sorting": {
|
||||
"description": "Sorting is the column position",
|
||||
"type": "integer",
|
||||
"format": "int8",
|
||||
"x-go-name": "Sorting"
|
||||
},
|
||||
"title": {
|
||||
"description": "Title is the title of the column",
|
||||
"type": "string",
|
||||
"x-go-name": "Title"
|
||||
}
|
||||
},
|
||||
"x-go-package": "gitea.dev/modules/structs"
|
||||
},
|
||||
"PublicKey": {
|
||||
"description": "PublicKey publickey is a user key to push code to repository",
|
||||
"type": "object",
|
||||
@@ -31645,6 +31912,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProjectColumnList": {
|
||||
"description": "ProjectColumnList",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/ProjectColumn"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProjectList": {
|
||||
"description": "ProjectList",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/Project"
|
||||
}
|
||||
}
|
||||
},
|
||||
"PublicKey": {
|
||||
"description": "PublicKey",
|
||||
"schema": {
|
||||
@@ -32205,4 +32490,4 @@
|
||||
"TOTPHeader": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -990,6 +990,32 @@
|
||||
},
|
||||
"description": "PackageList"
|
||||
},
|
||||
"ProjectColumnList": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ProjectColumn"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "ProjectColumnList"
|
||||
},
|
||||
"ProjectList": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Project"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "ProjectList"
|
||||
},
|
||||
"PublicKey": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
@@ -7705,6 +7731,28 @@
|
||||
"type": "object",
|
||||
"x-go-package": "gitea.dev/modules/structs"
|
||||
},
|
||||
"MoveProjectIssueOption": {
|
||||
"description": "MoveProjectIssueOption options for moving an issue inside a project",
|
||||
"properties": {
|
||||
"column_id": {
|
||||
"description": "ColumnID is the destination project column",
|
||||
"format": "int64",
|
||||
"type": "integer",
|
||||
"x-go-name": "ColumnID"
|
||||
},
|
||||
"sorting": {
|
||||
"description": "Sorting is the optional position inside the destination column",
|
||||
"format": "int64",
|
||||
"type": "integer",
|
||||
"x-go-name": "Sorting"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"column_id"
|
||||
],
|
||||
"type": "object",
|
||||
"x-go-package": "gitea.dev/modules/structs"
|
||||
},
|
||||
"NewIssuePinsAllowed": {
|
||||
"description": "NewIssuePinsAllowed represents an API response that says if new Issue Pins are allowed",
|
||||
"properties": {
|
||||
@@ -8459,6 +8507,35 @@
|
||||
"type": "object",
|
||||
"x-go-package": "gitea.dev/modules/structs"
|
||||
},
|
||||
"ProjectColumn": {
|
||||
"description": "ProjectColumn represents a column in a project",
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "Color is the optional column color",
|
||||
"type": "string",
|
||||
"x-go-name": "Color"
|
||||
},
|
||||
"id": {
|
||||
"description": "ID is the unique identifier for the column",
|
||||
"format": "int64",
|
||||
"type": "integer",
|
||||
"x-go-name": "ID"
|
||||
},
|
||||
"sorting": {
|
||||
"description": "Sorting is the column position",
|
||||
"format": "int8",
|
||||
"type": "integer",
|
||||
"x-go-name": "Sorting"
|
||||
},
|
||||
"title": {
|
||||
"description": "Title is the title of the column",
|
||||
"type": "string",
|
||||
"x-go-name": "Title"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"x-go-package": "gitea.dev/modules/structs"
|
||||
},
|
||||
"PublicKey": {
|
||||
"description": "PublicKey publickey is a user key to push code to repository",
|
||||
"properties": {
|
||||
@@ -10583,6 +10660,10 @@
|
||||
"type": "boolean",
|
||||
"x-go-name": "HideEmail"
|
||||
},
|
||||
"include_private_contributions": {
|
||||
"type": "boolean",
|
||||
"x-go-name": "IncludePrivateContributions"
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"x-go-name": "Language"
|
||||
@@ -10627,6 +10708,10 @@
|
||||
"type": "boolean",
|
||||
"x-go-name": "HideEmail"
|
||||
},
|
||||
"include_private_contributions": {
|
||||
"type": "boolean",
|
||||
"x-go-name": "IncludePrivateContributions"
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"x-go-name": "Language"
|
||||
@@ -26239,6 +26324,249 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/repos/{owner}/{repo}/projects": {
|
||||
"get": {
|
||||
"operationId": "repoListProjects",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "owner",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "path",
|
||||
"name": "repo",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "state",
|
||||
"schema": {
|
||||
"default": "open",
|
||||
"enum": [
|
||||
"open",
|
||||
"closed",
|
||||
"all"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "page",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "limit",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/ProjectList"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/notFound"
|
||||
}
|
||||
},
|
||||
"summary": "List a repository's projects",
|
||||
"tags": [
|
||||
"project"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/repos/{owner}/{repo}/projects/{id}/columns": {
|
||||
"get": {
|
||||
"operationId": "repoListProjectColumns",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "owner",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "path",
|
||||
"name": "repo",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "path",
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/ProjectColumnList"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/notFound"
|
||||
}
|
||||
},
|
||||
"summary": "List a repository project's columns",
|
||||
"tags": [
|
||||
"project"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/repos/{owner}/{repo}/projects/{id}/columns/{column_id}/issues": {
|
||||
"get": {
|
||||
"operationId": "repoListProjectColumnIssues",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "owner",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "path",
|
||||
"name": "repo",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "path",
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "path",
|
||||
"name": "column_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "page",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "limit",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/IssueList"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/notFound"
|
||||
}
|
||||
},
|
||||
"summary": "List issues in a repository project column",
|
||||
"tags": [
|
||||
"project"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/repos/{owner}/{repo}/projects/{id}/issues/{issue_id}/move": {
|
||||
"post": {
|
||||
"operationId": "repoMoveProjectIssue",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "owner",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "path",
|
||||
"name": "repo",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "path",
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "path",
|
||||
"name": "issue_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MoveProjectIssueOption"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true,
|
||||
"x-originalParamName": "body"
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "issue moved"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/forbidden"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/notFound"
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#/components/responses/validationError"
|
||||
}
|
||||
},
|
||||
"summary": "Move an issue to another repository project column",
|
||||
"tags": [
|
||||
"project"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/repos/{owner}/{repo}/pulls": {
|
||||
"get": {
|
||||
"operationId": "repoListPullRequests",
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
project_model "gitea.dev/models/project"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAPIRepositoryProjects(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
ownerSession := loginUser(t, repo.OwnerName)
|
||||
readToken := getTokenForLoggedInUser(t, ownerSession, auth_model.AccessTokenScopeReadIssue)
|
||||
writeToken := getTokenForLoggedInUser(t, ownerSession, auth_model.AccessTokenScopeWriteIssue)
|
||||
baseURL := fmt.Sprintf("/api/v1/repos/%s/projects", repo.FullName())
|
||||
|
||||
t.Run("List", func(t *testing.T) {
|
||||
req := NewRequest(t, "GET", baseURL).AddTokenAuth(readToken)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
projects := DecodeJSON(t, resp, &[]api.Project{})
|
||||
require.Len(t, *projects, 1)
|
||||
assert.Equal(t, int64(1), (*projects)[0].ID)
|
||||
assert.Equal(t, "First project", (*projects)[0].Title)
|
||||
})
|
||||
|
||||
t.Run("Columns", func(t *testing.T) {
|
||||
req := NewRequest(t, "GET", baseURL+"/1/columns").AddTokenAuth(readToken)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
columns := DecodeJSON(t, resp, &[]api.ProjectColumn{})
|
||||
require.Len(t, *columns, 3)
|
||||
assert.Equal(t, int64(1), (*columns)[0].ID)
|
||||
assert.Equal(t, "To Do", (*columns)[0].Title)
|
||||
})
|
||||
|
||||
t.Run("ColumnIssues", func(t *testing.T) {
|
||||
req := NewRequest(t, "GET", baseURL+"/1/columns/2/issues").AddTokenAuth(readToken)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
issues := DecodeJSON(t, resp, &[]api.Issue{})
|
||||
require.Len(t, *issues, 1)
|
||||
assert.Equal(t, int64(3), (*issues)[0].ID)
|
||||
})
|
||||
|
||||
t.Run("ColumnIssuesPaginationAndDefault", func(t *testing.T) {
|
||||
req := NewRequest(t, "GET", baseURL+"/1/columns/1/issues?limit=1&page=1").AddTokenAuth(readToken)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
issues := DecodeJSON(t, resp, &[]api.Issue{})
|
||||
require.Len(t, *issues, 1)
|
||||
assert.Equal(t, "2", resp.Header().Get("X-Total-Count"))
|
||||
|
||||
req = NewRequest(t, "GET", baseURL+"/1/columns/1/issues?limit=1&page=2").AddTokenAuth(readToken)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
issues = DecodeJSON(t, resp, &[]api.Issue{})
|
||||
require.Len(t, *issues, 1)
|
||||
assert.NotEqual(t, (*issues)[0].ID, int64(0))
|
||||
})
|
||||
|
||||
t.Run("CrossRepositoryProject", func(t *testing.T) {
|
||||
req := NewRequest(t, "GET", baseURL+"/2/columns").AddTokenAuth(readToken)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
})
|
||||
|
||||
t.Run("CrossProjectColumn", func(t *testing.T) {
|
||||
req := NewRequest(t, "GET", baseURL+"/1/columns/5/issues").AddTokenAuth(readToken)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
})
|
||||
|
||||
t.Run("RejectsSortingOverflow", func(t *testing.T) {
|
||||
projectIssue := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: 1, IssueID: 3})
|
||||
sorting := int64(math.MaxInt64)
|
||||
req := NewRequestWithJSON(t, "POST", baseURL+"/1/issues/3/move", api.MoveProjectIssueOption{
|
||||
ColumnID: 3,
|
||||
Sorting: &sorting,
|
||||
}).AddTokenAuth(writeToken)
|
||||
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||
unchanged := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: 1, IssueID: 3})
|
||||
assert.Equal(t, projectIssue.ProjectColumnID, unchanged.ProjectColumnID)
|
||||
assert.Equal(t, projectIssue.Sorting, unchanged.Sorting)
|
||||
})
|
||||
|
||||
t.Run("ReordersWithinColumn", func(t *testing.T) {
|
||||
sorting := int64(0)
|
||||
req := NewRequestWithJSON(t, "POST", baseURL+"/1/issues/3/move", api.MoveProjectIssueOption{
|
||||
ColumnID: 1,
|
||||
Sorting: &sorting,
|
||||
}).AddTokenAuth(writeToken)
|
||||
MakeRequest(t, req, http.StatusNoContent)
|
||||
|
||||
sorting = 2
|
||||
req = NewRequestWithJSON(t, "POST", baseURL+"/1/issues/3/move", api.MoveProjectIssueOption{
|
||||
ColumnID: 1,
|
||||
Sorting: &sorting,
|
||||
}).AddTokenAuth(writeToken)
|
||||
MakeRequest(t, req, http.StatusNoContent)
|
||||
|
||||
var projectIssues []project_model.ProjectIssue
|
||||
err := db.GetEngine(t.Context()).
|
||||
Where("project_id=? AND project_board_id=?", 1, 1).
|
||||
OrderBy("sorting").Find(&projectIssues)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, projectIssues, 2)
|
||||
assert.Equal(t, int64(3), projectIssues[1].IssueID)
|
||||
assert.Equal(t, []int64{0, 1}, []int64{projectIssues[0].Sorting, projectIssues[1].Sorting})
|
||||
})
|
||||
|
||||
t.Run("Move", func(t *testing.T) {
|
||||
req := NewRequestWithJSON(t, "POST", baseURL+"/1/issues/3/move", api.MoveProjectIssueOption{
|
||||
ColumnID: 3,
|
||||
}).AddTokenAuth(writeToken)
|
||||
MakeRequest(t, req, http.StatusNoContent)
|
||||
projectIssue := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: 1, IssueID: 3})
|
||||
assert.Equal(t, int64(3), projectIssue.ProjectColumnID)
|
||||
})
|
||||
|
||||
t.Run("UnassignedIssue", func(t *testing.T) {
|
||||
req := NewRequestWithJSON(t, "POST", baseURL+"/1/issues/11/move", api.MoveProjectIssueOption{
|
||||
ColumnID: 3,
|
||||
}).AddTokenAuth(writeToken)
|
||||
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
||||
})
|
||||
|
||||
t.Run("Permission", func(t *testing.T) {
|
||||
outsiderSession := loginUser(t, "user5")
|
||||
outsiderToken := getTokenForLoggedInUser(t, outsiderSession, auth_model.AccessTokenScopeWriteIssue)
|
||||
req := NewRequestWithJSON(t, "POST", baseURL+"/1/issues/3/move", api.MoveProjectIssueOption{
|
||||
ColumnID: 2,
|
||||
}).AddTokenAuth(outsiderToken)
|
||||
MakeRequest(t, req, http.StatusForbidden)
|
||||
})
|
||||
|
||||
t.Run("ClosedProject", func(t *testing.T) {
|
||||
project := unittest.AssertExistsAndLoadBean(t, &project_model.Project{ID: 1})
|
||||
require.NoError(t, project_model.ChangeProjectStatus(t.Context(), project, true))
|
||||
defer func() {
|
||||
require.NoError(t, project_model.ChangeProjectStatus(t.Context(), project, false))
|
||||
}()
|
||||
req := NewRequestWithJSON(t, "POST", baseURL+"/1/issues/3/move", api.MoveProjectIssueOption{
|
||||
ColumnID: 2,
|
||||
}).AddTokenAuth(writeToken)
|
||||
MakeRequest(t, req, http.StatusForbidden)
|
||||
})
|
||||
|
||||
t.Run("ArchivedRepository", func(t *testing.T) {
|
||||
_, err := db.GetEngine(t.Context()).ID(repo.ID).Cols("is_archived").Update(&repo_model.Repository{IsArchived: true})
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
_, err := db.GetEngine(t.Context()).ID(repo.ID).Cols("is_archived").Update(&repo_model.Repository{IsArchived: false})
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
req := NewRequestWithJSON(t, "POST", baseURL+"/1/issues/3/move", api.MoveProjectIssueOption{
|
||||
ColumnID: 2,
|
||||
}).AddTokenAuth(writeToken)
|
||||
MakeRequest(t, req, http.StatusLocked)
|
||||
})
|
||||
|
||||
t.Run("DisabledProjects", func(t *testing.T) {
|
||||
disabledSession := loginUser(t, "user5")
|
||||
disabledToken := getTokenForLoggedInUser(t, disabledSession, auth_model.AccessTokenScopeReadIssue)
|
||||
req := NewRequest(t, "GET", "/api/v1/repos/user5/repo4/projects").AddTokenAuth(disabledToken)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user