[FEAT] Repository flags
This implements "repository flags", a way for instance administrators to assign custom flags to repositories. The idea is that custom templates can look at these flags, and display banners based on them, Forgejo does not provide anything built on top of it, just the foundation. The feature is optional, and disabled by default. To enable it, set `[repository].ENABLE_FLAGS = true`. On the UI side, instance administrators will see a new "Manage flags" tab on repositories, and a list of enabled tags (if any) on the repository home page. The "Manage flags" page allows them to remove existing flags, or add any new ones that are listed in `[repository].SETTABLE_FLAGS`. The model does not enforce that only the `SETTABLE_FLAGS` are present. If the setting is changed, old flags may remain present in the database, and anything that uses them, will still work. The repository flag management page will allow an instance administrator to remove them, but not set them, once removed. Signed-off-by: Gergely Nagy <forgejo@gergo.csillger.hu>
This commit is contained in:
parent
b1ae3c2e3b
commit
ba735ce222
15 changed files with 610 additions and 0 deletions
|
@ -46,6 +46,8 @@ var migrations = []*Migration{
|
||||||
NewMigration("create the forgejo_auth_token table", forgejo_v1_20.CreateAuthorizationTokenTable),
|
NewMigration("create the forgejo_auth_token table", forgejo_v1_20.CreateAuthorizationTokenTable),
|
||||||
// v3 -> v4
|
// v3 -> v4
|
||||||
NewMigration("Add default_permissions to repo_unit", forgejo_v1_22.AddDefaultPermissionsToRepoUnit),
|
NewMigration("Add default_permissions to repo_unit", forgejo_v1_22.AddDefaultPermissionsToRepoUnit),
|
||||||
|
// v4 -> v5
|
||||||
|
NewMigration("create the forgejo_repo_flag table", forgejo_v1_22.CreateRepoFlagTable),
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCurrentDBVersion returns the current Forgejo database version.
|
// GetCurrentDBVersion returns the current Forgejo database version.
|
||||||
|
|
22
models/forgejo_migrations/v1_22/v5.go
Normal file
22
models/forgejo_migrations/v1_22/v5.go
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package v1_22 //nolint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"xorm.io/xorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RepoFlag struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
RepoID int64 `xorm:"UNIQUE(s) INDEX"`
|
||||||
|
Name string `xorm:"UNIQUE(s) INDEX"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RepoFlag) TableName() string {
|
||||||
|
return "forgejo_repo_flag"
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateRepoFlagTable(x *xorm.Engine) error {
|
||||||
|
return x.Sync(new(RepoFlag))
|
||||||
|
}
|
102
models/repo/repo_flags.go
Normal file
102
models/repo/repo_flags.go
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/models/db"
|
||||||
|
|
||||||
|
"xorm.io/builder"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RepoFlag represents a single flag against a repository
|
||||||
|
type RepoFlag struct { //revive:disable-line:exported
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
RepoID int64 `xorm:"UNIQUE(s) INDEX"`
|
||||||
|
Name string `xorm:"UNIQUE(s) INDEX"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
db.RegisterModel(new(RepoFlag))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName provides the real table name
|
||||||
|
func (RepoFlag) TableName() string {
|
||||||
|
return "forgejo_repo_flag"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFlags returns the array of flags on the repo.
|
||||||
|
func (repo *Repository) ListFlags(ctx context.Context) ([]RepoFlag, error) {
|
||||||
|
var flags []RepoFlag
|
||||||
|
err := db.GetEngine(ctx).Table(&RepoFlag{}).Where("repo_id = ?", repo.ID).Find(&flags)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return flags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsFlagged returns whether a repo has any flags or not
|
||||||
|
func (repo *Repository) IsFlagged(ctx context.Context) bool {
|
||||||
|
has, _ := db.Exist[RepoFlag](ctx, builder.Eq{"repo_id": repo.ID})
|
||||||
|
return has
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFlag returns a single RepoFlag based on its name
|
||||||
|
func (repo *Repository) GetFlag(ctx context.Context, flagName string) (bool, *RepoFlag, error) {
|
||||||
|
flag, has, err := db.Get[RepoFlag](ctx, builder.Eq{"repo_id": repo.ID, "name": flagName})
|
||||||
|
if err != nil {
|
||||||
|
return false, nil, err
|
||||||
|
}
|
||||||
|
return has, flag, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasFlag returns true if a repo has a given flag, false otherwise
|
||||||
|
func (repo *Repository) HasFlag(ctx context.Context, flagName string) bool {
|
||||||
|
has, _ := db.Exist[RepoFlag](ctx, builder.Eq{"repo_id": repo.ID, "name": flagName})
|
||||||
|
return has
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddFlag adds a new flag to the repo
|
||||||
|
func (repo *Repository) AddFlag(ctx context.Context, flagName string) error {
|
||||||
|
return db.Insert(ctx, RepoFlag{
|
||||||
|
RepoID: repo.ID,
|
||||||
|
Name: flagName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFlag removes a flag from the repo
|
||||||
|
func (repo *Repository) DeleteFlag(ctx context.Context, flagName string) (int64, error) {
|
||||||
|
return db.DeleteByBean(ctx, &RepoFlag{RepoID: repo.ID, Name: flagName})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceAllFlags replaces all flags of a repo with a new set
|
||||||
|
func (repo *Repository) ReplaceAllFlags(ctx context.Context, flagNames []string) error {
|
||||||
|
ctx, committer, err := db.TxContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer committer.Close()
|
||||||
|
|
||||||
|
if err := db.DeleteBeans(ctx, &RepoFlag{RepoID: repo.ID}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(flagNames) == 0 {
|
||||||
|
return committer.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
var flags []RepoFlag
|
||||||
|
for _, name := range flagNames {
|
||||||
|
flags = append(flags, RepoFlag{
|
||||||
|
RepoID: repo.ID,
|
||||||
|
Name: name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := db.Insert(ctx, &flags); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return committer.Commit()
|
||||||
|
}
|
114
models/repo/repo_flags_test.go
Normal file
114
models/repo/repo_flags_test.go
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package repo_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/models/db"
|
||||||
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
|
"code.gitea.io/gitea/models/unittest"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRepositoryFlags(t *testing.T) {
|
||||||
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
|
||||||
|
|
||||||
|
// ********************
|
||||||
|
// ** NEGATIVE TESTS **
|
||||||
|
// ********************
|
||||||
|
|
||||||
|
// Unless we add flags, the repo has none
|
||||||
|
flags, err := repo.ListFlags(db.DefaultContext)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Empty(t, flags)
|
||||||
|
|
||||||
|
// If the repo has no flags, it is not flagged
|
||||||
|
flagged := repo.IsFlagged(db.DefaultContext)
|
||||||
|
assert.False(t, flagged)
|
||||||
|
|
||||||
|
// Trying to find a flag when there is none
|
||||||
|
has := repo.HasFlag(db.DefaultContext, "foo")
|
||||||
|
assert.False(t, has)
|
||||||
|
|
||||||
|
// Trying to retrieve a non-existent flag indicates not found
|
||||||
|
has, _, err = repo.GetFlag(db.DefaultContext, "foo")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, has)
|
||||||
|
|
||||||
|
// Deleting a non-existent flag fails
|
||||||
|
deleted, err := repo.DeleteFlag(db.DefaultContext, "no-such-flag")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(0), deleted)
|
||||||
|
|
||||||
|
// ********************
|
||||||
|
// ** POSITIVE TESTS **
|
||||||
|
// ********************
|
||||||
|
|
||||||
|
// Adding a flag works
|
||||||
|
err = repo.AddFlag(db.DefaultContext, "foo")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Adding it again fails
|
||||||
|
err = repo.AddFlag(db.DefaultContext, "foo")
|
||||||
|
assert.Error(t, err)
|
||||||
|
|
||||||
|
// Listing flags includes the one we added
|
||||||
|
flags, err = repo.ListFlags(db.DefaultContext)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, flags, 1)
|
||||||
|
assert.Equal(t, "foo", flags[0].Name)
|
||||||
|
|
||||||
|
// With a flag added, the repo is flagged
|
||||||
|
flagged = repo.IsFlagged(db.DefaultContext)
|
||||||
|
assert.True(t, flagged)
|
||||||
|
|
||||||
|
// The flag can be found
|
||||||
|
has = repo.HasFlag(db.DefaultContext, "foo")
|
||||||
|
assert.True(t, has)
|
||||||
|
|
||||||
|
// Added flag can be retrieved
|
||||||
|
_, flag, err := repo.GetFlag(db.DefaultContext, "foo")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "foo", flag.Name)
|
||||||
|
|
||||||
|
// Deleting a flag works
|
||||||
|
deleted, err = repo.DeleteFlag(db.DefaultContext, "foo")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(1), deleted)
|
||||||
|
|
||||||
|
// The list is now empty
|
||||||
|
flags, err = repo.ListFlags(db.DefaultContext)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Empty(t, flags)
|
||||||
|
|
||||||
|
// Replacing an empty list works
|
||||||
|
err = repo.ReplaceAllFlags(db.DefaultContext, []string{"bar"})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// The repo is now flagged with "bar"
|
||||||
|
has = repo.HasFlag(db.DefaultContext, "bar")
|
||||||
|
assert.True(t, has)
|
||||||
|
|
||||||
|
// Replacing a tag set with another works
|
||||||
|
err = repo.ReplaceAllFlags(db.DefaultContext, []string{"baz", "quux"})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// The repo now has two tags
|
||||||
|
flags, err = repo.ListFlags(db.DefaultContext)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, flags, 2)
|
||||||
|
assert.Equal(t, "baz", flags[0].Name)
|
||||||
|
assert.Equal(t, "quux", flags[1].Name)
|
||||||
|
|
||||||
|
// Replacing flags with an empty set deletes all flags
|
||||||
|
err = repo.ReplaceAllFlags(db.DefaultContext, []string{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// The repo is now unflagged
|
||||||
|
flagged = repo.IsFlagged(db.DefaultContext)
|
||||||
|
assert.False(t, flagged)
|
||||||
|
}
|
|
@ -112,6 +112,9 @@ var (
|
||||||
Wiki []string
|
Wiki []string
|
||||||
DefaultTrustModel string
|
DefaultTrustModel string
|
||||||
} `ini:"repository.signing"`
|
} `ini:"repository.signing"`
|
||||||
|
|
||||||
|
SettableFlags []string
|
||||||
|
EnableFlags bool
|
||||||
}{
|
}{
|
||||||
DetectedCharsetsOrder: []string{
|
DetectedCharsetsOrder: []string{
|
||||||
"UTF-8",
|
"UTF-8",
|
||||||
|
@ -267,6 +270,8 @@ var (
|
||||||
Wiki: []string{"never"},
|
Wiki: []string{"never"},
|
||||||
DefaultTrustModel: "collaborator",
|
DefaultTrustModel: "collaborator",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
EnableFlags: false,
|
||||||
}
|
}
|
||||||
RepoRootPath string
|
RepoRootPath string
|
||||||
ScriptType = "bash"
|
ScriptType = "bash"
|
||||||
|
@ -369,4 +374,6 @@ func loadRepositoryFrom(rootCfg ConfigProvider) {
|
||||||
log.Error("Unrecognised repository download or clone method: %s", method)
|
log.Error("Unrecognised repository download or clone method: %s", method)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Repository.EnableFlags = sec.Key("ENABLE_FLAGS").MustBool()
|
||||||
}
|
}
|
||||||
|
|
|
@ -96,6 +96,9 @@ func NewFuncMap() template.FuncMap {
|
||||||
"AppDomain": func() string { // documented in mail-templates.md
|
"AppDomain": func() string { // documented in mail-templates.md
|
||||||
return setting.Domain
|
return setting.Domain
|
||||||
},
|
},
|
||||||
|
"RepoFlagsEnabled": func() bool {
|
||||||
|
return setting.Repository.EnableFlags
|
||||||
|
},
|
||||||
"AssetVersion": func() string {
|
"AssetVersion": func() string {
|
||||||
return setting.AssetVersion
|
return setting.AssetVersion
|
||||||
},
|
},
|
||||||
|
|
|
@ -937,6 +937,12 @@ visibility.private_tooltip = Visible only to members of organizations you have j
|
||||||
[repo]
|
[repo]
|
||||||
rss.must_be_on_branch = You must be on a branch to have an RSS feed.
|
rss.must_be_on_branch = You must be on a branch to have an RSS feed.
|
||||||
|
|
||||||
|
admin.manage_flags = Manage flags
|
||||||
|
admin.enabled_flags = Flags enabled for the repository:
|
||||||
|
admin.update_flags = Update flags
|
||||||
|
admin.failed_to_replace_flags = Failed to replace repository flags
|
||||||
|
admin.flags_replaced = Repository flags replaced
|
||||||
|
|
||||||
new_repo_helper = A repository contains all project files, including revision history. Already hosting one elsewhere? <a href="%s">Migrate repository.</a>
|
new_repo_helper = A repository contains all project files, including revision history. Already hosting one elsewhere? <a href="%s">Migrate repository.</a>
|
||||||
owner = Owner
|
owner = Owner
|
||||||
owner_helper = Some organizations may not show up in the dropdown due to a maximum repository count limit.
|
owner_helper = Some organizations may not show up in the dropdown due to a maximum repository count limit.
|
||||||
|
|
49
routers/web/repo/flags/manage.go
Normal file
49
routers/web/repo/flags/manage.go
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package flags
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/base"
|
||||||
|
"code.gitea.io/gitea/modules/context"
|
||||||
|
"code.gitea.io/gitea/modules/log"
|
||||||
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
tplRepoFlags base.TplName = "repo/flags"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Manage(ctx *context.Context) {
|
||||||
|
ctx.Data["IsRepoFlagsPage"] = true
|
||||||
|
ctx.Data["Title"] = ctx.Tr("repo.admin.manage_flags")
|
||||||
|
|
||||||
|
flags := map[string]bool{}
|
||||||
|
for _, f := range setting.Repository.SettableFlags {
|
||||||
|
flags[f] = false
|
||||||
|
}
|
||||||
|
repoFlags, _ := ctx.Repo.Repository.ListFlags(ctx)
|
||||||
|
for _, f := range repoFlags {
|
||||||
|
flags[f.Name] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Data["Flags"] = flags
|
||||||
|
|
||||||
|
ctx.HTML(http.StatusOK, tplRepoFlags)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ManagePost(ctx *context.Context) {
|
||||||
|
newFlags := ctx.FormStrings("flags")
|
||||||
|
|
||||||
|
err := ctx.Repo.Repository.ReplaceAllFlags(ctx, newFlags)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Flash.Error(ctx.Tr("repo.admin.failed_to_replace_flags"))
|
||||||
|
log.Error("Error replacing repository flags for repo %d: %v", ctx.Repo.Repository.ID, err)
|
||||||
|
} else {
|
||||||
|
ctx.Flash.Success(ctx.Tr("repo.admin.flags_replaced"))
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Redirect(ctx.Repo.Repository.HTMLURL() + "/flags")
|
||||||
|
}
|
|
@ -38,6 +38,7 @@ import (
|
||||||
"code.gitea.io/gitea/routers/web/repo"
|
"code.gitea.io/gitea/routers/web/repo"
|
||||||
"code.gitea.io/gitea/routers/web/repo/actions"
|
"code.gitea.io/gitea/routers/web/repo/actions"
|
||||||
"code.gitea.io/gitea/routers/web/repo/badges"
|
"code.gitea.io/gitea/routers/web/repo/badges"
|
||||||
|
repo_flags "code.gitea.io/gitea/routers/web/repo/flags"
|
||||||
repo_setting "code.gitea.io/gitea/routers/web/repo/setting"
|
repo_setting "code.gitea.io/gitea/routers/web/repo/setting"
|
||||||
"code.gitea.io/gitea/routers/web/user"
|
"code.gitea.io/gitea/routers/web/user"
|
||||||
user_setting "code.gitea.io/gitea/routers/web/user/setting"
|
user_setting "code.gitea.io/gitea/routers/web/user/setting"
|
||||||
|
@ -1572,6 +1573,13 @@ func registerRoutes(m *web.Route) {
|
||||||
gitHTTPRouters(m)
|
gitHTTPRouters(m)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if setting.Repository.EnableFlags {
|
||||||
|
m.Group("/{username}/{reponame}/flags", func() {
|
||||||
|
m.Get("", repo_flags.Manage)
|
||||||
|
m.Post("", repo_flags.ManagePost)
|
||||||
|
}, adminReq, context.RepoAssignment, context.UnitTypes())
|
||||||
|
}
|
||||||
// ***** END: Repository *****
|
// ***** END: Repository *****
|
||||||
|
|
||||||
m.Group("/notifications", func() {
|
m.Group("/notifications", func() {
|
||||||
|
|
0
templates/custom/repo_flag_banners.tmpl
Normal file
0
templates/custom/repo_flag_banners.tmpl
Normal file
8
templates/repo/admin_flags.tmpl
Normal file
8
templates/repo/admin_flags.tmpl
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
{{if .Repository.IsFlagged $.Context}}
|
||||||
|
<div class="ui info message" style="text-align: left">
|
||||||
|
<strong>{{ctx.Locale.Tr "repo.admin.enabled_flags"}}</strong>
|
||||||
|
{{range .Repository.ListFlags $.Context}}
|
||||||
|
<span class="ui label">{{.Name}}</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
33
templates/repo/flags.tmpl
Normal file
33
templates/repo/flags.tmpl
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
{{template "base/head" .}}
|
||||||
|
<div role="main" aria-label="{{.Title}}" class="page-content repository">
|
||||||
|
{{template "repo/header" .}}
|
||||||
|
<div class="ui container">
|
||||||
|
{{template "base/alert" .}}
|
||||||
|
<div class="user-main-content twelve wide column">
|
||||||
|
<h4 class="ui top attached header">
|
||||||
|
{{ctx.Locale.Tr "repo.admin.manage_flags"}}
|
||||||
|
</h4>
|
||||||
|
<div class="ui attached segment">
|
||||||
|
<form class="ui form" action="{{.Link}}" method="post">
|
||||||
|
{{.CsrfTokenHtml}}
|
||||||
|
<strong>{{ctx.Locale.Tr "repo.admin.enabled_flags"}}</strong>
|
||||||
|
<div class="ui segment gt-pl-4">
|
||||||
|
{{range $flag, $checked := .Flags}}
|
||||||
|
<div class="field">
|
||||||
|
<div class="ui checkbox{{if $checked}} checked{{end}}">
|
||||||
|
<input name="flags" type="checkbox" value="{{$flag}}" {{if $checked}}checked{{end}}>
|
||||||
|
<label>{{$flag}}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<button class="ui primary button">{{ctx.Locale.Tr "repo.admin.update_flags"}}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{template "base/footer" .}}
|
|
@ -227,6 +227,12 @@
|
||||||
|
|
||||||
{{template "custom/extra_tabs" .}}
|
{{template "custom/extra_tabs" .}}
|
||||||
|
|
||||||
|
{{if and RepoFlagsEnabled .SignedUser.IsAdmin}}
|
||||||
|
<a class="{{if .IsRepoFlagsPage}}active {{end}}item" href="{{.RepoLink}}/flags">
|
||||||
|
{{svg "octicon-milestone"}} {{ctx.Locale.Tr "repo.admin.manage_flags"}}
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{if .Permission.IsAdmin}}
|
{{if .Permission.IsAdmin}}
|
||||||
<a class="{{if .PageIsRepoSettings}}active {{end}}right item" href="{{.RepoLink}}/settings">
|
<a class="{{if .PageIsRepoSettings}}active {{end}}right item" href="{{.RepoLink}}/settings">
|
||||||
{{svg "octicon-tools"}} {{ctx.Locale.Tr "repo.settings"}}
|
{{svg "octicon-tools"}} {{ctx.Locale.Tr "repo.settings"}}
|
||||||
|
|
|
@ -52,6 +52,14 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{if RepoFlagsEnabled}}
|
||||||
|
{{template "custom/repo_flag_banners" .}}
|
||||||
|
{{if .SignedUser.IsAdmin}}
|
||||||
|
{{template "repo/admin_flags" .}}
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{if .Repository.IsArchived}}
|
{{if .Repository.IsArchived}}
|
||||||
<div class="ui warning message gt-text-center">
|
<div class="ui warning message gt-text-center">
|
||||||
{{if .Repository.ArchivedUnix.IsZero}}
|
{{if .Repository.ArchivedUnix.IsZero}}
|
||||||
|
|
242
tests/integration/repo_flags_test.go
Normal file
242
tests/integration/repo_flags_test.go
Normal file
|
@ -0,0 +1,242 @@
|
||||||
|
// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/models/db"
|
||||||
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
|
"code.gitea.io/gitea/models/unittest"
|
||||||
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
"code.gitea.io/gitea/modules/test"
|
||||||
|
"code.gitea.io/gitea/routers"
|
||||||
|
"code.gitea.io/gitea/tests"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRepositoryFlagsUIDisabled(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
defer test.MockVariableValue(&setting.Repository.EnableFlags, false)()
|
||||||
|
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
|
||||||
|
|
||||||
|
admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true})
|
||||||
|
session := loginUser(t, admin.Name)
|
||||||
|
|
||||||
|
// With the repo flags feature disabled, the /flags route is 404
|
||||||
|
req := NewRequest(t, "GET", "/user2/repo1/flags")
|
||||||
|
session.MakeRequest(t, req, http.StatusNotFound)
|
||||||
|
|
||||||
|
// With the repo flags feature disabled, the "Modify flags" tab does not
|
||||||
|
// appear for instance admins
|
||||||
|
req = NewRequest(t, "GET", "/user2/repo1")
|
||||||
|
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||||
|
doc := NewHTMLParser(t, resp.Body)
|
||||||
|
flagsLinkCount := doc.Find(fmt.Sprintf(`a[href="%s/flags"]`, "/user2/repo1")).Length()
|
||||||
|
assert.Equal(t, 0, flagsLinkCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepositoryFlagsUI(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
defer test.MockVariableValue(&setting.Repository.EnableFlags, true)()
|
||||||
|
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
|
||||||
|
|
||||||
|
// *******************
|
||||||
|
// ** Preparations **
|
||||||
|
// *******************
|
||||||
|
flaggedRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||||
|
unflaggedRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||||
|
|
||||||
|
// **************
|
||||||
|
// ** Helpers **
|
||||||
|
// **************
|
||||||
|
|
||||||
|
adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}).Name
|
||||||
|
flaggedOwner := "user2"
|
||||||
|
flaggedRepoURLStr := "/user2/repo1"
|
||||||
|
unflaggedOwner := "user5"
|
||||||
|
unflaggedRepoURLStr := "/user5/repo4"
|
||||||
|
otherUser := "user4"
|
||||||
|
|
||||||
|
ensureFlags := func(repo *repo_model.Repository, flags []string) func() {
|
||||||
|
repo.ReplaceAllFlags(db.DefaultContext, flags)
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
repo.ReplaceAllFlags(db.DefaultContext, flags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests:
|
||||||
|
// - Presence of the link
|
||||||
|
// - Number of flags listed in the admin-only message box
|
||||||
|
// - Whether there's a link to /user/repo/flags
|
||||||
|
// - Whether /user/repo/flags is OK or Forbidden
|
||||||
|
assertFlagAccessAndCount := func(t *testing.T, user, repoURL string, hasAccess bool, expectedFlagCount int) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var expectedLinkCount int
|
||||||
|
var expectedStatus int
|
||||||
|
if hasAccess {
|
||||||
|
expectedLinkCount = 1
|
||||||
|
expectedStatus = http.StatusOK
|
||||||
|
} else {
|
||||||
|
expectedLinkCount = 0
|
||||||
|
if user != "" {
|
||||||
|
expectedStatus = http.StatusForbidden
|
||||||
|
} else {
|
||||||
|
expectedStatus = http.StatusSeeOther
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp *httptest.ResponseRecorder
|
||||||
|
var session *TestSession
|
||||||
|
req := NewRequest(t, "GET", repoURL)
|
||||||
|
if user != "" {
|
||||||
|
session = loginUser(t, user)
|
||||||
|
resp = session.MakeRequest(t, req, http.StatusOK)
|
||||||
|
} else {
|
||||||
|
resp = MakeRequest(t, req, http.StatusOK)
|
||||||
|
}
|
||||||
|
doc := NewHTMLParser(t, resp.Body)
|
||||||
|
|
||||||
|
flagsLinkCount := doc.Find(fmt.Sprintf(`a[href="%s/flags"]`, repoURL)).Length()
|
||||||
|
assert.Equal(t, expectedLinkCount, flagsLinkCount)
|
||||||
|
|
||||||
|
flagCount := doc.Find(".ui.info.message .ui.label").Length()
|
||||||
|
assert.Equal(t, expectedFlagCount, flagCount)
|
||||||
|
|
||||||
|
req = NewRequest(t, "GET", fmt.Sprintf("%s/flags", repoURL))
|
||||||
|
if user != "" {
|
||||||
|
session.MakeRequest(t, req, expectedStatus)
|
||||||
|
} else {
|
||||||
|
MakeRequest(t, req, expectedStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensures that given a repo owner and a repo:
|
||||||
|
// - An instance admin has access to flags, and sees the list on the repo home
|
||||||
|
// - A repo admin does not have access to either, and does not see the list
|
||||||
|
// - A passer by has no access to either, and does not see the list
|
||||||
|
runTests := func(t *testing.T, ownerUser, repoURL string, expectedFlagCount int) {
|
||||||
|
t.Run("as instance admin", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
assertFlagAccessAndCount(t, adminUser, repoURL, true, expectedFlagCount)
|
||||||
|
})
|
||||||
|
t.Run("as owner", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
assertFlagAccessAndCount(t, ownerUser, repoURL, false, 0)
|
||||||
|
})
|
||||||
|
t.Run("as other user", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
assertFlagAccessAndCount(t, otherUser, repoURL, false, 0)
|
||||||
|
})
|
||||||
|
t.Run("as non-logged in user", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
assertFlagAccessAndCount(t, "", repoURL, false, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// **************************
|
||||||
|
// ** The tests themselves **
|
||||||
|
// **************************
|
||||||
|
t.Run("unflagged repo", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
defer ensureFlags(unflaggedRepo, []string{})()
|
||||||
|
|
||||||
|
runTests(t, unflaggedOwner, unflaggedRepoURLStr, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("flagged repo", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
defer ensureFlags(flaggedRepo, []string{"test-flag"})()
|
||||||
|
|
||||||
|
runTests(t, flaggedOwner, flaggedRepoURLStr, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("modifying flags", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
session := loginUser(t, adminUser)
|
||||||
|
flaggedRepoManageURL := fmt.Sprintf("%s/flags", flaggedRepoURLStr)
|
||||||
|
unflaggedRepoManageURL := fmt.Sprintf("%s/flags", unflaggedRepoURLStr)
|
||||||
|
|
||||||
|
assertUIFlagStates := func(t *testing.T, url string, flagStates map[string]bool) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req := NewRequest(t, "GET", url)
|
||||||
|
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
doc := NewHTMLParser(t, resp.Body)
|
||||||
|
flagBoxes := doc.Find(`input[name="flags"]`)
|
||||||
|
assert.Equal(t, len(flagStates), flagBoxes.Length())
|
||||||
|
|
||||||
|
for name, state := range flagStates {
|
||||||
|
_, checked := doc.Find(fmt.Sprintf(`input[value="%s"]`, name)).Attr("checked")
|
||||||
|
assert.Equal(t, state, checked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("flag presence on the UI", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
defer ensureFlags(flaggedRepo, []string{"test-flag"})()
|
||||||
|
|
||||||
|
assertUIFlagStates(t, flaggedRepoManageURL, map[string]bool{"test-flag": true})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("setting.Repository.SettableFlags is respected", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
defer test.MockVariableValue(&setting.Repository.SettableFlags, []string{"featured", "no-license"})()
|
||||||
|
defer ensureFlags(flaggedRepo, []string{"test-flag"})()
|
||||||
|
|
||||||
|
assertUIFlagStates(t, flaggedRepoManageURL, map[string]bool{
|
||||||
|
"test-flag": true,
|
||||||
|
"featured": false,
|
||||||
|
"no-license": false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("removing flags", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
defer ensureFlags(flaggedRepo, []string{"test-flag"})()
|
||||||
|
|
||||||
|
flagged := flaggedRepo.IsFlagged(db.DefaultContext)
|
||||||
|
assert.True(t, flagged)
|
||||||
|
|
||||||
|
req := NewRequestWithValues(t, "POST", flaggedRepoManageURL, map[string]string{
|
||||||
|
"_csrf": GetCSRF(t, session, flaggedRepoManageURL),
|
||||||
|
})
|
||||||
|
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||||
|
|
||||||
|
flagged = flaggedRepo.IsFlagged(db.DefaultContext)
|
||||||
|
assert.False(t, flagged)
|
||||||
|
|
||||||
|
assertUIFlagStates(t, flaggedRepoManageURL, map[string]bool{})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("adding flags", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
defer ensureFlags(unflaggedRepo, []string{})()
|
||||||
|
|
||||||
|
flagged := unflaggedRepo.IsFlagged(db.DefaultContext)
|
||||||
|
assert.False(t, flagged)
|
||||||
|
|
||||||
|
req := NewRequestWithValues(t, "POST", unflaggedRepoManageURL, map[string]string{
|
||||||
|
"_csrf": GetCSRF(t, session, unflaggedRepoManageURL),
|
||||||
|
"flags": "test-flag",
|
||||||
|
})
|
||||||
|
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||||
|
|
||||||
|
assertUIFlagStates(t, unflaggedRepoManageURL, map[string]bool{"test-flag": true})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
Loading…
Reference in a new issue