feat: kanban tui
runner nix smoke / nix label and flake smoke (push) Failing after 1m12s

This commit is contained in:
2026-09-27 15:42:51 +00:00
parent 4c9611a2d2
commit 335a74010d
28 changed files with 4636 additions and 100 deletions
@@ -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
}
+135 -63
View File
@@ -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
View File
@@ -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)
})
}