[FEAT] API support for repository flags

Expose the repository flags feature over the API, so the flags can be
managed by a site administrator without using the web API.

Signed-off-by: Gergely Nagy <forgejo@gergo.csillger.hu>
(cherry picked from commit bac9f0225d47e159afa90e5bbea9562cbc860dae)
(cherry picked from commit e7f5c1ba141ac7f8c7834b5048d0ffd3ce50900b)
(cherry picked from commit 95d9fe19cf3ed5787855ac2a442d29104498aa36)
(cherry picked from commit 7fc51991e405ea8d44fd6b4b4de13ad65da63ae7)
This commit is contained in:
Gergely Nagy 2024-01-05 13:45:10 +01:00 committed by Earl Warren
parent 36f7c162e2
commit 639b428cf4
No known key found for this signature in database
GPG key ID: 0579CB2928A78A00
6 changed files with 686 additions and 0 deletions

View file

@ -0,0 +1,9 @@
// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
// SPDX-License-Identifier: MIT
package structs
// ReplaceFlagsOption options when replacing the flags of a repository
type ReplaceFlagsOption struct {
Flags []string `json:"flags"`
}

View file

@ -1096,6 +1096,18 @@ func Routes() *web.Route {
m.Get("/permission", repo.GetRepoPermissions) m.Get("/permission", repo.GetRepoPermissions)
}) })
}, reqToken()) }, reqToken())
if setting.Repository.EnableFlags {
m.Group("/flags", func() {
m.Combo("").Get(repo.ListFlags).
Put(bind(api.ReplaceFlagsOption{}), repo.ReplaceAllFlags).
Delete(repo.DeleteAllFlags)
m.Group("/{flag}", func() {
m.Combo("").Get(repo.HasFlag).
Put(repo.AddFlag).
Delete(repo.DeleteFlag)
})
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryAdmin), reqToken(), reqSiteAdmin())
}
m.Get("/assignees", reqToken(), reqAnyRepoReader(), repo.GetAssignees) m.Get("/assignees", reqToken(), reqAnyRepoReader(), repo.GetAssignees)
m.Get("/reviewers", reqToken(), reqAnyRepoReader(), repo.GetReviewers) m.Get("/reviewers", reqToken(), reqAnyRepoReader(), repo.GetReviewers)
m.Group("/teams", func() { m.Group("/teams", func() {

View file

@ -0,0 +1,245 @@
// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"net/http"
"code.gitea.io/gitea/modules/context"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/web"
)
func ListFlags(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/flags repository repoListFlags
// ---
// summary: List a repository's flags
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/StringSlice"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
repoFlags, err := ctx.Repo.Repository.ListFlags(ctx)
if err != nil {
ctx.InternalServerError(err)
return
}
flags := make([]string, len(repoFlags))
for i := range repoFlags {
flags[i] = repoFlags[i].Name
}
ctx.SetTotalCountHeader(int64(len(repoFlags)))
ctx.JSON(http.StatusOK, flags)
}
func ReplaceAllFlags(ctx *context.APIContext) {
// swagger:operation PUT /repos/{owner}/{repo}/flags repository repoReplaceAllFlags
// ---
// summary: Replace all flags of a repository
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/ReplaceFlagsOption"
// responses:
// "204":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
flagsForm := web.GetForm(ctx).(*api.ReplaceFlagsOption)
if err := ctx.Repo.Repository.ReplaceAllFlags(ctx, flagsForm.Flags); err != nil {
ctx.InternalServerError(err)
return
}
ctx.Status(http.StatusNoContent)
}
func DeleteAllFlags(ctx *context.APIContext) {
// swagger:operation DELETE /repos/{owner}/{repo}/flags repository repoDeleteAllFlags
// ---
// summary: Remove all flags from a repository
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
if err := ctx.Repo.Repository.ReplaceAllFlags(ctx, nil); err != nil {
ctx.InternalServerError(err)
return
}
ctx.Status(http.StatusNoContent)
}
func HasFlag(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/flags/{flag} repository repoCheckFlag
// ---
// summary: Check if a repository has a given flag
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// - name: flag
// in: path
// description: name of the flag
// type: string
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
hasFlag := ctx.Repo.Repository.HasFlag(ctx, ctx.Params(":flag"))
if hasFlag {
ctx.Status(http.StatusNoContent)
} else {
ctx.NotFound()
}
}
func AddFlag(ctx *context.APIContext) {
// swagger:operation PUT /repos/{owner}/{repo}/flags/{flag} repository repoAddFlag
// ---
// summary: Add a flag to a repository
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// - name: flag
// in: path
// description: name of the flag
// type: string
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
flag := ctx.Params(":flag")
if ctx.Repo.Repository.HasFlag(ctx, flag) {
ctx.Status(http.StatusNoContent)
return
}
if err := ctx.Repo.Repository.AddFlag(ctx, flag); err != nil {
ctx.InternalServerError(err)
return
}
ctx.Status(http.StatusNoContent)
}
func DeleteFlag(ctx *context.APIContext) {
// swagger:operation DELETE /repos/{owner}/{repo}/flags/{flag} repository repoDeleteFlag
// ---
// summary: Remove a flag from a repository
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// - name: flag
// in: path
// description: name of the flag
// type: string
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
flag := ctx.Params(":flag")
if _, err := ctx.Repo.Repository.DeleteFlag(ctx, flag); err != nil {
ctx.InternalServerError(err)
return
}
ctx.Status(http.StatusNoContent)
}

View file

@ -17,6 +17,9 @@ type swaggerParameterBodies struct {
// in:body // in:body
AddCollaboratorOption api.AddCollaboratorOption AddCollaboratorOption api.AddCollaboratorOption
// in:body
ReplaceFlagsOption api.ReplaceFlagsOption
// in:body // in:body
CreateEmailOption api.CreateEmailOption CreateEmailOption api.CreateEmailOption
// in:body // in:body

View file

@ -4992,6 +4992,260 @@
} }
} }
}, },
"/repos/{owner}/{repo}/flags": {
"get": {
"produces": [
"application/json"
],
"tags": [
"repository"
],
"summary": "List a repository's flags",
"operationId": "repoListFlags",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repo",
"name": "repo",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/StringSlice"
},
"403": {
"$ref": "#/responses/forbidden"
},
"404": {
"$ref": "#/responses/notFound"
}
}
},
"put": {
"produces": [
"application/json"
],
"tags": [
"repository"
],
"summary": "Replace all flags of a repository",
"operationId": "repoReplaceAllFlags",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repo",
"name": "repo",
"in": "path",
"required": true
},
{
"name": "body",
"in": "body",
"schema": {
"$ref": "#/definitions/ReplaceFlagsOption"
}
}
],
"responses": {
"204": {
"$ref": "#/responses/empty"
},
"403": {
"$ref": "#/responses/forbidden"
},
"404": {
"$ref": "#/responses/notFound"
}
}
},
"delete": {
"produces": [
"application/json"
],
"tags": [
"repository"
],
"summary": "Remove all flags from a repository",
"operationId": "repoDeleteAllFlags",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repo",
"name": "repo",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"$ref": "#/responses/empty"
},
"403": {
"$ref": "#/responses/forbidden"
},
"404": {
"$ref": "#/responses/notFound"
}
}
}
},
"/repos/{owner}/{repo}/flags/{flag}": {
"get": {
"produces": [
"application/json"
],
"tags": [
"repository"
],
"summary": "Check if a repository has a given flag",
"operationId": "repoCheckFlag",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repo",
"name": "repo",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the flag",
"name": "flag",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"$ref": "#/responses/empty"
},
"403": {
"$ref": "#/responses/forbidden"
},
"404": {
"$ref": "#/responses/notFound"
}
}
},
"put": {
"produces": [
"application/json"
],
"tags": [
"repository"
],
"summary": "Add a flag to a repository",
"operationId": "repoAddFlag",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repo",
"name": "repo",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the flag",
"name": "flag",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"$ref": "#/responses/empty"
},
"403": {
"$ref": "#/responses/forbidden"
},
"404": {
"$ref": "#/responses/notFound"
}
}
},
"delete": {
"produces": [
"application/json"
],
"tags": [
"repository"
],
"summary": "Remove a flag from a repository",
"operationId": "repoDeleteFlag",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repo",
"name": "repo",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the flag",
"name": "flag",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"$ref": "#/responses/empty"
},
"403": {
"$ref": "#/responses/forbidden"
},
"404": {
"$ref": "#/responses/notFound"
}
}
}
},
"/repos/{owner}/{repo}/forks": { "/repos/{owner}/{repo}/forks": {
"get": { "get": {
"produces": [ "produces": [
@ -22012,6 +22266,20 @@
}, },
"x-go-package": "code.gitea.io/gitea/modules/structs" "x-go-package": "code.gitea.io/gitea/modules/structs"
}, },
"ReplaceFlagsOption": {
"description": "ReplaceFlagsOption options when replacing the flags of a repository",
"type": "object",
"properties": {
"flags": {
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "Flags"
}
},
"x-go-package": "code.gitea.io/gitea/modules/structs"
},
"RepoCollaboratorPermission": { "RepoCollaboratorPermission": {
"description": "RepoCollaboratorPermission to get repository permission for a collaborator", "description": "RepoCollaboratorPermission to get repository permission for a collaborator",
"type": "object", "type": "object",

View file

@ -7,13 +7,16 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"slices"
"testing" "testing"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/routers" "code.gitea.io/gitea/routers"
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
@ -42,6 +45,152 @@ func TestRepositoryFlagsUIDisabled(t *testing.T) {
assert.Equal(t, 0, flagsLinkCount) assert.Equal(t, 0, flagsLinkCount)
} }
func TestRepositoryFlagsAPI(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.Repository.EnableFlags, true)()
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
// *************
// ** Helpers **
// *************
adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}).Name
normalUserBean := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
assert.False(t, normalUserBean.IsAdmin)
normalUser := normalUserBean.Name
assertAccess := func(t *testing.T, user, method, uri string, expectedStatus int) {
session := loginUser(t, user)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeReadAdmin)
req := NewRequestf(t, method, "/api/v1/repos/user2/repo1/flags%s", uri).AddTokenAuth(token)
MakeRequest(t, req, expectedStatus)
}
// ***********
// ** Tests **
// ***********
t.Run("API access", func(t *testing.T) {
t.Run("as admin", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
assertAccess(t, adminUser, "GET", "", http.StatusOK)
})
t.Run("as normal user", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
assertAccess(t, normalUser, "GET", "", http.StatusForbidden)
})
})
t.Run("token scopes", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// Trying to access the API with a token that lacks permissions, will
// fail, even if the token owner is an instance admin.
session := loginUser(t, adminUser)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/flags").AddTokenAuth(token)
MakeRequest(t, req, http.StatusForbidden)
})
t.Run("setting.Repository.EnableFlags is respected", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer test.MockVariableValue(&setting.Repository.EnableFlags, false)()
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
t.Run("as admin", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
assertAccess(t, adminUser, "GET", "", http.StatusNotFound)
})
t.Run("as normal user", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
assertAccess(t, normalUser, "GET", "", http.StatusNotFound)
})
})
t.Run("API functionality", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
defer func() {
repo.ReplaceAllFlags(db.DefaultContext, []string{})
}()
baseURLFmtStr := "/api/v1/repos/user5/repo4/flags%s"
session := loginUser(t, adminUser)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteAdmin)
// Listing flags
req := NewRequestf(t, "GET", baseURLFmtStr, "").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var flags []string
DecodeJSON(t, resp, &flags)
assert.Empty(t, flags)
// Replacing all tags works, twice in a row
for i := 0; i < 2; i++ {
req = NewRequestWithJSON(t, "PUT", fmt.Sprintf(baseURLFmtStr, ""), &api.ReplaceFlagsOption{
Flags: []string{"flag-1", "flag-2", "flag-3"},
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
}
// The list now includes all three flags
req = NewRequestf(t, "GET", baseURLFmtStr, "").AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &flags)
assert.Len(t, flags, 3)
for _, flag := range []string{"flag-1", "flag-2", "flag-3"} {
assert.True(t, slices.Contains(flags, flag))
}
// Check a flag that is on the repo
req = NewRequestf(t, "GET", baseURLFmtStr, "/flag-1").AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
// Check a flag that isn't on the repo
req = NewRequestf(t, "GET", baseURLFmtStr, "/no-such-flag").AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
// We can add the same flag twice
for i := 0; i < 2; i++ {
req = NewRequestf(t, "PUT", baseURLFmtStr, "/brand-new-flag").AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
}
// The new flag is there
req = NewRequestf(t, "GET", baseURLFmtStr, "/brand-new-flag").AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
// We can delete a flag, twice
for i := 0; i < 2; i++ {
req = NewRequestf(t, "DELETE", baseURLFmtStr, "/flag-3").AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
}
// We can delete a flag that wasn't there
req = NewRequestf(t, "DELETE", baseURLFmtStr, "/no-such-flag").AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
// We can delete all of the flags in one go, too
req = NewRequestf(t, "DELETE", baseURLFmtStr, "").AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
// ..once all flags are deleted, none are listed, either
req = NewRequestf(t, "GET", baseURLFmtStr, "").AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &flags)
assert.Empty(t, flags)
})
}
func TestRepositoryFlagsUI(t *testing.T) { func TestRepositoryFlagsUI(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.Repository.EnableFlags, true)() defer test.MockVariableValue(&setting.Repository.EnableFlags, true)()