feat(api): add project APIs (#38691)

Adds REST APIs for project boards for repo, org and user scopes, using
as much shared code as possible for all 3 scopes.

Fixes: https://github.com/go-gitea/gitea/issues/14299
Fixes: https://github.com/go-gitea/gitea/issues/31769
Fixes: https://github.com/go-gitea/gitea/issues/35921
Replaces: https://github.com/go-gitea/gitea/pull/37518
Replaces: https://github.com/go-gitea/gitea/pull/36008
Replaces: https://github.com/go-gitea/gitea/pull/28111
Replaces: https://github.com/go-gitea/gitea/pull/31768
Signed-off-by: silverwind <me@silverwind.io>
Co-authored-by: Supen.Huang <supen.huang@qq.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Ember <ember@mubergacres.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: beardev-in <abhinav.edulakanti@gmail.com>
This commit is contained in:
silverwind
2026-08-08 15:53:28 +02:00
committed by GitHub
parent 8163139ec0
commit 7fae3d5db3
29 changed files with 9107 additions and 732 deletions
-48
View File
@@ -1,48 +0,0 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
project_model "gitea.dev/models/project"
"gitea.dev/modules/json"
"gitea.dev/services/context"
)
// MoveColumns moves or keeps columns in a project and sorts them inside that project
func MoveColumns(ctx *context.Context) {
project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64("id"))
if err != nil {
ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err)
return
}
if !project.CanBeAccessedByOwnerRepo(ctx.ContextUser.ID, ctx.Repo.Repository) {
ctx.NotFound(nil)
return
}
type movedColumnsForm struct {
Columns []struct {
ColumnID int64 `json:"columnID"`
Sorting int64 `json:"sorting"`
} `json:"columns"`
}
form := &movedColumnsForm{}
if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
ctx.ServerError("DecodeMovedColumnsForm", err)
return
}
sortedColumnIDs := make(map[int64]int64)
for _, column := range form.Columns {
sortedColumnIDs[column.Sorting] = column.ColumnID
}
if err = project_model.MoveColumnsOnProject(ctx, project, sortedColumnIDs); err != nil {
ctx.ServerError("MoveColumnsOnProject", err)
return
}
ctx.JSONOK()
}
+205
View File
@@ -0,0 +1,205 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
"errors"
issues_model "gitea.dev/models/issues"
project_model "gitea.dev/models/project"
"gitea.dev/modules/json"
"gitea.dev/modules/web"
"gitea.dev/services/context"
"gitea.dev/services/forms"
project_service "gitea.dev/services/projects"
)
// findProject loads the "id" path param, scoped to whichever owner the route assigned:
// anyone else's ID reads as not found. Write permission is enforced by the route.
func findProject(ctx *context.Context) *project_model.Project {
var project *project_model.Project
var err error
if ctx.Repo != nil && ctx.Repo.Repository != nil {
project, err = project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id"))
} else {
project, err = project_model.GetProjectByIDAndOwner(ctx, ctx.PathParamInt64("id"), ctx.ContextUser.ID)
}
if err != nil {
ctx.NotFoundOrServerError("GetProject", project_model.IsErrProjectNotExist, err)
return nil
}
return project
}
func findColumn(ctx *context.Context) (*project_model.Project, *project_model.Column) {
project := findProject(ctx)
if ctx.Written() {
return nil, nil
}
column, err := project_model.GetColumnByIDAndProjectID(ctx, ctx.PathParamInt64("columnID"), project.ID)
if err != nil {
ctx.NotFoundOrServerError("GetColumnByIDAndProjectID", project_model.IsErrProjectColumnNotExist, err)
return nil, nil
}
return project, column
}
func MoveColumns(ctx *context.Context) {
project := findProject(ctx)
if ctx.Written() {
return
}
type movedColumnsForm struct {
Columns []struct {
ColumnID int64 `json:"columnID"`
Sorting int64 `json:"sorting"`
} `json:"columns"`
}
form := &movedColumnsForm{}
if err := json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
ctx.ServerError("DecodeMovedColumnsForm", err)
return
}
sortedColumnIDs := make(map[int64]int64)
for _, column := range form.Columns {
sortedColumnIDs[column.Sorting] = column.ColumnID
}
if err := project_model.MoveColumnsOnProject(ctx, project, sortedColumnIDs); err != nil {
ctx.ServerError("MoveColumnsOnProject", err)
return
}
ctx.JSONOK()
}
func AddColumnToProjectPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.EditProjectColumnForm)
project := findProject(ctx)
if ctx.Written() {
return
}
if err := project_model.NewColumn(ctx, &project_model.Column{
ProjectID: project.ID,
Title: form.Title,
Color: form.Color,
CreatorID: ctx.Doer.ID,
}); err != nil {
ctx.ServerError("NewProjectColumn", err)
return
}
ctx.JSONOK()
}
func EditProjectColumn(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.EditProjectColumnForm)
_, column := findColumn(ctx)
if ctx.Written() {
return
}
if form.Title != "" {
column.Title = form.Title
}
column.Color = form.Color
if form.Sorting != 0 {
column.Sorting = form.Sorting
}
if err := project_model.UpdateColumn(ctx, column); err != nil {
ctx.ServerError("UpdateProjectColumn", err)
return
}
ctx.JSONOK()
}
func DeleteProjectColumn(ctx *context.Context) {
_, column := findColumn(ctx)
if ctx.Written() {
return
}
if err := project_model.DeleteColumnByID(ctx, column.ID); err != nil {
ctx.ServerError("DeleteProjectColumnByID", err)
return
}
ctx.JSONOK()
}
func SetDefaultProjectColumn(ctx *context.Context) {
project, column := findColumn(ctx)
if ctx.Written() {
return
}
if err := project_model.SetDefaultColumn(ctx, project.ID, column.ID); err != nil {
ctx.ServerError("SetDefaultColumn", err)
return
}
ctx.JSONOK()
}
func MoveIssues(ctx *context.Context) {
project, column := findColumn(ctx)
if ctx.Written() {
return
}
type movedIssuesForm struct {
Issues []struct {
IssueID int64 `json:"issueID"`
Sorting int64 `json:"sorting"`
} `json:"issues"`
}
form := &movedIssuesForm{}
if err := json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
ctx.ServerError("DecodeMovedIssuesForm", err)
return
}
issueIDs := make([]int64, 0, len(form.Issues))
sortedIssueIDs := make(map[int64]int64)
for _, issue := range form.Issues {
issueIDs = append(issueIDs, issue.IssueID)
sortedIssueIDs[issue.Sorting] = issue.IssueID
}
movedIssues, err := issues_model.GetIssuesByIDs(ctx, issueIDs)
if err != nil {
ctx.NotFoundOrServerError("GetIssueByID", issues_model.IsErrIssueNotExist, err)
return
}
if len(movedIssues) != len(form.Issues) {
ctx.ServerError("some issues do not exist", errors.New("some issues do not exist"))
return
}
if _, err = movedIssues.LoadRepositories(ctx); err != nil {
ctx.ServerError("LoadRepositories", err)
return
}
for _, issue := range movedIssues {
if !project.CanBeAccessedByOwnerRepo(issue.Repo.OwnerID, issue.Repo) {
ctx.ServerError("Some issue's repoID is not equal to project's repoID", errors.New("Some issue's repoID is not equal to project's repoID"))
return
}
}
if err = project_service.MoveIssuesOnProjectColumn(ctx, ctx.Doer, column, sortedIssueIDs); err != nil {
ctx.ServerError("MoveIssuesOnProjectColumn", err)
return
}
ctx.JSONOK()
}
@@ -0,0 +1,60 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
"net/http"
"testing"
"gitea.dev/models/unittest"
"gitea.dev/services/contexttest"
"github.com/stretchr/testify/assert"
)
func TestMain(m *testing.M) {
unittest.MainTest(m)
}
// TestFindColumn covers the scoping rule every board handler depends on: a request only
// resolves projects owned by the scope its route assigned, so an ID belonging to anyone
// else reads as not found rather than leaking across owners.
func TestFindColumn(t *testing.T) {
for _, tc := range []struct {
name string
projectID string
columnID string
doerID int64
repoScoped bool
resolves bool
}{
{"repository project", "1", "2", 2, true, true},
{"owner project", "4", "4", 2, false, true},
{"repository board cannot reach an owner project", "4", "1", 2, true, false},
{"owner board cannot reach another owner's project", "4", "4", 1, false, false},
} {
t.Run(tc.name, func(t *testing.T) {
unittest.PrepareTestEnv(t)
ctx, _ := contexttest.MockContext(t, "user2/-/projects")
contexttest.LoadUser(t, ctx, tc.doerID)
if tc.repoScoped {
contexttest.LoadRepo(t, ctx, 1)
} else {
ctx.ContextUser = ctx.Doer
}
ctx.SetPathParam("id", tc.projectID)
ctx.SetPathParam("columnID", tc.columnID)
project, column := findColumn(ctx)
assert.Equal(t, tc.resolves, project != nil)
assert.Equal(t, tc.resolves, column != nil)
if tc.resolves {
assert.False(t, ctx.Written())
} else {
// a foreign ID must read as not found, never as a 500
assert.Equal(t, http.StatusNotFound, ctx.Resp.WrittenStatus())
}
})
}
}