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
+73
View File
@@ -88,6 +88,7 @@ import (
"gitea.dev/routers/api/v1/packages"
"gitea.dev/routers/api/v1/repo"
"gitea.dev/routers/api/v1/settings"
"gitea.dev/routers/api/v1/shared"
"gitea.dev/routers/api/v1/token"
"gitea.dev/routers/api/v1/user"
"gitea.dev/routers/common"
@@ -799,6 +800,67 @@ func mustEnableWiki(ctx *context.APIContext) {
}
}
// reqProjectsUnitAccess mirrors the web's reqUnitAccess for the Projects unit. Org
// visibility is too permissive for reads, org ownership too strict for writes.
func reqProjectsUnitAccess(accessMode perm.AccessMode) func(ctx *context.APIContext) {
return func(ctx *context.APIContext) {
// "/users/{username}/projects" also accepts an organization, where checkTokenPublicOnly
// does nothing because IsTokenAccessAllowed is false for orgs. Enforce it here, before
// the admin bypass, so both spellings of the route answer alike.
if ctx.PublicOnly && ctx.ContextUser.IsOrganization() && !ctx.ContextUser.Visibility.IsPublic() {
ctx.APIError(http.StatusForbidden, "token scope is limited to public orgs")
return
}
if ctx.IsUserSiteAdmin() {
return
}
// individual visibility is handled by individualPermsChecker
if ctx.ContextUser.IsOrganization() &&
organization.OrgFromUser(ctx.ContextUser).UnitPermission(ctx, ctx.Doer, unit.TypeProjects) < accessMode {
ctx.APIErrorNotFound()
}
}
}
// addProjectRoutes registers a scope's project tree, "writeChecks" guard every mutation.
func addProjectRoutes(m *web.Router, writeChecks ...any) {
m.Get("", shared.ListProjects)
m.Group("/{id}", func() {
m.Get("", shared.GetProject)
m.Get("/columns", shared.ListProjectColumns)
m.Group("/columns/{column_id}", func() {
m.Get("", shared.GetProjectColumn)
m.Get("/issues", shared.ListProjectColumnIssues)
})
})
m.Group("", func() {
m.Post("", bind(api.CreateProjectOption{}), shared.CreateProject)
m.Group("/{id}", func() {
m.Patch("", bind(api.EditProjectOption{}), shared.EditProject)
m.Delete("", shared.DeleteProject)
m.Post("/columns", bind(api.CreateProjectColumnOption{}), shared.CreateProjectColumn)
m.Post("/columns/move", bind(api.MoveProjectColumnsOption{}), shared.MoveProjectColumns)
m.Group("/columns/{column_id}", func() {
m.Patch("", bind(api.EditProjectColumnOption{}), shared.EditProjectColumn)
m.Delete("", shared.DeleteProjectColumn)
m.Post("/default", shared.SetDefaultProjectColumn)
m.Post("/issues/{issue_id}", shared.AddIssueToProjectColumn)
m.Delete("/issues/{issue_id}", shared.RemoveIssueFromProjectColumn)
})
m.Post("/issues/{issue_id}/move", bind(api.MoveProjectIssueOption{}), shared.MoveProjectIssue)
})
}, writeChecks...)
}
// mustEnableRepoProjects mirrors repo.MustEnableRepoProjects: the Projects unit can be
// readable while repo-level boards are disallowed, and the web UI then hides them entirely.
func mustEnableRepoProjects(ctx *context.APIContext) {
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 {
@@ -1077,6 +1139,8 @@ func Routes() *web.Router {
}
m.Get("/repos", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), reqExploreSignIn(), user.ListUserRepos)
m.Get("/projects", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue), reqExploreSignIn(),
reqProjectsUnitAccess(perm.AccessModeRead), shared.ListProjects)
m.Group("/tokens", func() {
m.Combo("").Get(user.ListAccessTokens).
Post(bind(api.CreateAccessTokenOption{}), reqToken(), user.CreateAccessToken)
@@ -1112,6 +1176,9 @@ func Routes() *web.Router {
m.Get("", user.GetUserSettings)
m.Patch("", bind(api.UserSettingsOptions{}), user.UpdateUserSettings)
}, rejectPublicOnly())
m.Group("/projects", func() {
addProjectRoutes(m, reqToken())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue))
// Email addresses are always private account data.
m.Combo("/emails", rejectPublicOnly()).
Get(user.ListEmails).
@@ -1690,6 +1757,9 @@ func Routes() *web.Router {
Patch(reqToken(), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), bind(api.EditMilestoneOption{}), repo.EditMilestone).
Delete(reqToken(), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), repo.DeleteMilestone)
})
m.Group("/projects", func() {
addProjectRoutes(m, reqToken(), reqRepoWriter(unit.TypeProjects), mustNotBeArchived)
}, reqRepoReader(unit.TypeProjects), mustEnableRepoProjects)
}, repoAssignment(), checkTokenPublicOnly())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue))
@@ -1753,6 +1823,9 @@ func Routes() *web.Router {
m.Post("", reqOrgOwnership(), bind(api.CreateTeamOption{}), org.CreateTeam)
m.Get("/search", org.SearchTeam)
}, reqToken(), reqOrgMembership())
m.Group("/projects", func() {
addProjectRoutes(m, reqToken(), reqProjectsUnitAccess(perm.AccessModeWrite))
}, reqProjectsUnitAccess(perm.AccessModeRead), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue))
m.Group("/labels", func() {
m.Get("", org.ListLabels)
m.Post("", reqToken(), reqOrgOwnership(), bind(api.CreateLabelOption{}), org.CreateLabel)
File diff suppressed because it is too large Load Diff
+16
View File
@@ -237,6 +237,22 @@ type swaggerParameterBodies struct {
// in:body
LockIssueOption api.LockIssueOption
// in:body
CreateProjectOption api.CreateProjectOption
// in:body
EditProjectOption api.EditProjectOption
// in:body
CreateProjectColumnOption api.CreateProjectColumnOption
// in:body
EditProjectColumnOption api.EditProjectColumnOption
// in:body
MoveProjectColumnsOption api.MoveProjectColumnsOption
// in:body
MoveProjectIssueOption api.MoveProjectIssueOption
// in:body
MergeUpstreamRequest api.MergeUpstreamRequest
}
+36
View File
@@ -0,0 +1,36 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package swagger
import (
api "gitea.dev/modules/structs"
)
// Project
// swagger:response Project
type swaggerResponseProject struct {
// in:body
Body api.Project `json:"body"`
}
// ProjectList
// swagger:response ProjectList
type swaggerResponseProjectList struct {
// in:body
Body []api.Project `json:"body"`
}
// ProjectColumn
// swagger:response ProjectColumn
type swaggerResponseProjectColumn struct {
// in:body
Body api.ProjectColumn `json:"body"`
}
// ProjectColumnList
// swagger:response ProjectColumnList
type swaggerResponseProjectColumnList struct {
// in:body
Body []api.ProjectColumn `json:"body"`
}