Browse Source

Enable/disable owner and repo projects independently (#28805)

Part of #23318 

Add menu in repo settings to allow for repo admin to decide not just if
projects are enabled or disabled per repo, but also which kind of
projects (repo-level/owner-level) are enabled. If repo projects
disabled, don't show the projects tab.


![grafik](https://github.com/go-gitea/gitea/assets/47871822/b9b43fb4-824b-47f9-b8e2-12004313647c)

---------

Co-authored-by: delvh <dev.lh@web.de>
tags/v1.22.0-rc0
Denys Konovalov 3 months ago
parent
commit
fe6792dff3
No account linked to committer's email address

+ 1
- 6
models/fixtures/repo_unit.yml View File

@@ -520,6 +520,7 @@
id: 75
repo_id: 1
type: 8
config: "{\"ProjectsMode\":\"all\"}"
created_unix: 946684810

-
@@ -650,12 +651,6 @@
type: 2
created_unix: 946684810

-
id: 98
repo_id: 1
type: 8
created_unix: 946684810

-
id: 99
repo_id: 1

+ 5
- 0
models/repo/repo.go View File

@@ -411,6 +411,11 @@ func (repo *Repository) MustGetUnit(ctx context.Context, tp unit.Type) *RepoUnit
Type: tp,
Config: new(ActionsConfig),
}
} else if tp == unit.TypeProjects {
return &RepoUnit{
Type: tp,
Config: new(ProjectsConfig),
}
}

return &RepoUnit{

+ 55
- 1
models/repo/repo_unit.go View File

@@ -202,6 +202,53 @@ func (cfg *ActionsConfig) ToDB() ([]byte, error) {
return json.Marshal(cfg)
}

// ProjectsMode represents the projects enabled for a repository
type ProjectsMode string

const (
// ProjectsModeRepo allows only repo-level projects
ProjectsModeRepo ProjectsMode = "repo"
// ProjectsModeOwner allows only owner-level projects
ProjectsModeOwner ProjectsMode = "owner"
// ProjectsModeAll allows both kinds of projects
ProjectsModeAll ProjectsMode = "all"
// ProjectsModeNone doesn't allow projects
ProjectsModeNone ProjectsMode = "none"
)

// ProjectsConfig describes projects config
type ProjectsConfig struct {
ProjectsMode ProjectsMode
}

// FromDB fills up a ProjectsConfig from serialized format.
func (cfg *ProjectsConfig) FromDB(bs []byte) error {
return json.UnmarshalHandleDoubleEncode(bs, &cfg)
}

// ToDB exports a ProjectsConfig to a serialized format.
func (cfg *ProjectsConfig) ToDB() ([]byte, error) {
return json.Marshal(cfg)
}

func (cfg *ProjectsConfig) GetProjectsMode() ProjectsMode {
if cfg.ProjectsMode != "" {
return cfg.ProjectsMode
}

return ProjectsModeNone
}

func (cfg *ProjectsConfig) IsProjectsAllowed(m ProjectsMode) bool {
projectsMode := cfg.GetProjectsMode()

if m == ProjectsModeNone {
return true
}

return projectsMode == m || projectsMode == ProjectsModeAll
}

// BeforeSet is invoked from XORM before setting the value of a field of this object.
func (r *RepoUnit) BeforeSet(colName string, val xorm.Cell) {
switch colName {
@@ -217,7 +264,9 @@ func (r *RepoUnit) BeforeSet(colName string, val xorm.Cell) {
r.Config = new(IssuesConfig)
case unit.TypeActions:
r.Config = new(ActionsConfig)
case unit.TypeCode, unit.TypeReleases, unit.TypeWiki, unit.TypeProjects, unit.TypePackages:
case unit.TypeProjects:
r.Config = new(ProjectsConfig)
case unit.TypeCode, unit.TypeReleases, unit.TypeWiki, unit.TypePackages:
fallthrough
default:
r.Config = new(UnitConfig)
@@ -265,6 +314,11 @@ func (r *RepoUnit) ActionsConfig() *ActionsConfig {
return r.Config.(*ActionsConfig)
}

// ProjectsConfig returns config for unit.ProjectsConfig
func (r *RepoUnit) ProjectsConfig() *ProjectsConfig {
return r.Config.(*ProjectsConfig)
}

func getUnitsByRepoID(ctx context.Context, repoID int64) (units []*RepoUnit, err error) {
var tmpUnits []*RepoUnit
if err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Find(&tmpUnits); err != nil {

+ 6
- 0
modules/repository/create.go View File

@@ -93,6 +93,12 @@ func CreateRepositoryByExample(ctx context.Context, doer, u *user_model.User, re
AllowRebaseUpdate: true,
},
})
} else if tp == unit.TypeProjects {
units = append(units, repo_model.RepoUnit{
RepoID: repo.ID,
Type: tp,
Config: &repo_model.ProjectsConfig{ProjectsMode: repo_model.ProjectsModeAll},
})
} else {
units = append(units, repo_model.RepoUnit{
RepoID: repo.ID,

+ 3
- 0
modules/structs/repo.go View File

@@ -90,6 +90,7 @@ type Repository struct {
ExternalWiki *ExternalWiki `json:"external_wiki,omitempty"`
HasPullRequests bool `json:"has_pull_requests"`
HasProjects bool `json:"has_projects"`
ProjectsMode string `json:"projects_mode"`
HasReleases bool `json:"has_releases"`
HasPackages bool `json:"has_packages"`
HasActions bool `json:"has_actions"`
@@ -180,6 +181,8 @@ type EditRepoOption struct {
HasPullRequests *bool `json:"has_pull_requests,omitempty"`
// either `true` to enable project unit, or `false` to disable them.
HasProjects *bool `json:"has_projects,omitempty"`
// `repo` to only allow repo-level projects, `owner` to only allow owner projects, `all` to allow both.
ProjectsMode *string `json:"projects_mode,omitempty" binding:"In(repo,owner,all)"`
// either `true` to enable releases unit, or `false` to disable them.
HasReleases *bool `json:"has_releases,omitempty"`
// either `true` to enable packages unit, or `false` to disable them.

+ 5
- 1
options/locale/locale_en-US.ini View File

@@ -2090,7 +2090,11 @@ settings.pulls.default_delete_branch_after_merge = Delete pull request branch af
settings.pulls.default_allow_edits_from_maintainers = Allow edits from maintainers by default
settings.releases_desc = Enable Repository Releases
settings.packages_desc = Enable Repository Packages Registry
settings.projects_desc = Enable Repository Projects
settings.projects_desc = Enable Projects
settings.projects_mode_desc = Projects Mode (which kinds of projects to show)
settings.projects_mode_repo = Repo projects only
settings.projects_mode_owner = Only user or org projects
settings.projects_mode_all = All projects
settings.actions_desc = Enable Repository Actions
settings.admin_settings = Administrator Settings
settings.admin_enable_health_check = Enable Repository Health Checks (git fsck)

+ 23
- 3
routers/api/v1/repo/repo.go View File

@@ -944,13 +944,33 @@ func updateRepoUnits(ctx *context.APIContext, opts api.EditRepoOption) error {
}
}

if opts.HasProjects != nil && !unit_model.TypeProjects.UnitGlobalDisabled() {
if *opts.HasProjects {
currHasProjects := repo.UnitEnabled(ctx, unit_model.TypeProjects)
newHasProjects := currHasProjects
if opts.HasProjects != nil {
newHasProjects = *opts.HasProjects
}
if currHasProjects || newHasProjects {
if newHasProjects && !unit_model.TypeProjects.UnitGlobalDisabled() {
unit, err := repo.GetUnit(ctx, unit_model.TypeProjects)
var config *repo_model.ProjectsConfig
if err != nil {
config = &repo_model.ProjectsConfig{
ProjectsMode: repo_model.ProjectsModeAll,
}
} else {
config = unit.ProjectsConfig()
}

if opts.ProjectsMode != nil {
config.ProjectsMode = repo_model.ProjectsMode(*opts.ProjectsMode)
}

units = append(units, repo_model.RepoUnit{
RepoID: repo.ID,
Type: unit_model.TypeProjects,
Config: config,
})
} else {
} else if !newHasProjects && !unit_model.TypeProjects.UnitGlobalDisabled() {
deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeProjects)
}
}

+ 52
- 41
routers/web/repo/issue.go View File

@@ -587,52 +587,63 @@ func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) {
if repo.Owner.IsOrganization() {
repoOwnerType = project_model.TypeOrganization
}
var err error
projects, err := db.Find[project_model.Project](ctx, project_model.SearchOptions{
ListOptions: db.ListOptionsAll,
RepoID: repo.ID,
IsClosed: optional.Some(false),
Type: project_model.TypeRepository,
})
if err != nil {
ctx.ServerError("GetProjects", err)
return
}
projects2, err := db.Find[project_model.Project](ctx, project_model.SearchOptions{
ListOptions: db.ListOptionsAll,
OwnerID: repo.OwnerID,
IsClosed: optional.Some(false),
Type: repoOwnerType,
})
if err != nil {
ctx.ServerError("GetProjects", err)
return
}

ctx.Data["OpenProjects"] = append(projects, projects2...)
projectsUnit := repo.MustGetUnit(ctx, unit.TypeProjects)

projects, err = db.Find[project_model.Project](ctx, project_model.SearchOptions{
ListOptions: db.ListOptionsAll,
RepoID: repo.ID,
IsClosed: optional.Some(true),
Type: project_model.TypeRepository,
})
if err != nil {
ctx.ServerError("GetProjects", err)
return
var openProjects []*project_model.Project
var closedProjects []*project_model.Project
var err error

if projectsUnit.ProjectsConfig().IsProjectsAllowed(repo_model.ProjectsModeRepo) {
openProjects, err = db.Find[project_model.Project](ctx, project_model.SearchOptions{
ListOptions: db.ListOptionsAll,
RepoID: repo.ID,
IsClosed: optional.Some(false),
Type: project_model.TypeRepository,
})
if err != nil {
ctx.ServerError("GetProjects", err)
return
}
closedProjects, err = db.Find[project_model.Project](ctx, project_model.SearchOptions{
ListOptions: db.ListOptionsAll,
RepoID: repo.ID,
IsClosed: optional.Some(true),
Type: project_model.TypeRepository,
})
if err != nil {
ctx.ServerError("GetProjects", err)
return
}
}
projects2, err = db.Find[project_model.Project](ctx, project_model.SearchOptions{
ListOptions: db.ListOptionsAll,
OwnerID: repo.OwnerID,
IsClosed: optional.Some(true),
Type: repoOwnerType,
})
if err != nil {
ctx.ServerError("GetProjects", err)
return

if projectsUnit.ProjectsConfig().IsProjectsAllowed(repo_model.ProjectsModeOwner) {
openProjects2, err := db.Find[project_model.Project](ctx, project_model.SearchOptions{
ListOptions: db.ListOptionsAll,
OwnerID: repo.OwnerID,
IsClosed: optional.Some(false),
Type: repoOwnerType,
})
if err != nil {
ctx.ServerError("GetProjects", err)
return
}
openProjects = append(openProjects, openProjects2...)
closedProjects2, err := db.Find[project_model.Project](ctx, project_model.SearchOptions{
ListOptions: db.ListOptionsAll,
OwnerID: repo.OwnerID,
IsClosed: optional.Some(true),
Type: repoOwnerType,
})
if err != nil {
ctx.ServerError("GetProjects", err)
return
}
closedProjects = append(closedProjects, closedProjects2...)
}

ctx.Data["ClosedProjects"] = append(projects, projects2...)
ctx.Data["OpenProjects"] = openProjects
ctx.Data["ClosedProjects"] = closedProjects
}

// repoReviewerSelection items to bee shown

+ 8
- 7
routers/web/repo/projects.go View File

@@ -14,7 +14,7 @@ import (
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/perm"
project_model "code.gitea.io/gitea/models/project"
attachment_model "code.gitea.io/gitea/models/repo"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/json"
@@ -33,16 +33,17 @@ const (
tplProjectsView base.TplName = "repo/projects/view"
)

// MustEnableProjects check if projects are enabled in settings
func MustEnableProjects(ctx *context.Context) {
// MustEnableRepoProjects check if repo projects are enabled in settings
func MustEnableRepoProjects(ctx *context.Context) {
if unit.TypeProjects.UnitGlobalDisabled() {
ctx.NotFound("EnableKanbanBoard", nil)
return
}

if ctx.Repo.Repository != nil {
if !ctx.Repo.CanRead(unit.TypeProjects) {
ctx.NotFound("MustEnableProjects", nil)
projectsUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeProjects)
if !ctx.Repo.CanRead(unit.TypeProjects) || !projectsUnit.ProjectsConfig().IsProjectsAllowed(repo_model.ProjectsModeRepo) {
ctx.NotFound("MustEnableRepoProjects", nil)
return
}
}
@@ -325,10 +326,10 @@ func ViewProject(ctx *context.Context) {
}

if project.CardType != project_model.CardTypeTextOnly {
issuesAttachmentMap := make(map[int64][]*attachment_model.Attachment)
issuesAttachmentMap := make(map[int64][]*repo_model.Attachment)
for _, issuesList := range issuesMap {
for _, issue := range issuesList {
if issueAttachment, err := attachment_model.GetAttachmentsByIssueIDImagesLatest(ctx, issue.ID); err == nil {
if issueAttachment, err := repo_model.GetAttachmentsByIssueIDImagesLatest(ctx, issue.ID); err == nil {
issuesAttachmentMap[issue.ID] = issueAttachment
}
}

+ 3
- 0
routers/web/repo/setting/setting.go View File

@@ -533,6 +533,9 @@ func SettingsPost(ctx *context.Context) {
units = append(units, repo_model.RepoUnit{
RepoID: repo.ID,
Type: unit_model.TypeProjects,
Config: &repo_model.ProjectsConfig{
ProjectsMode: repo_model.ProjectsMode(form.ProjectsMode),
},
})
} else if !unit_model.TypeProjects.UnitGlobalDisabled() {
deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeProjects)

+ 1
- 1
routers/web/web.go View File

@@ -1344,7 +1344,7 @@ func registerRoutes(m *web.Route) {
})
})
}, reqRepoProjectsWriter, context.RepoMustNotBeArchived())
}, reqRepoProjectsReader, repo.MustEnableProjects)
}, reqRepoProjectsReader, repo.MustEnableRepoProjects)

m.Group("/actions", func() {
m.Get("", actions.List)

+ 5
- 1
services/convert/repository.go View File

@@ -113,8 +113,11 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR
defaultAllowMaintainerEdit = config.DefaultAllowMaintainerEdit
}
hasProjects := false
if _, err := repo.GetUnit(ctx, unit_model.TypeProjects); err == nil {
projectsMode := repo_model.ProjectsModeAll
if unit, err := repo.GetUnit(ctx, unit_model.TypeProjects); err == nil {
hasProjects = true
config := unit.ProjectsConfig()
projectsMode = config.ProjectsMode
}

hasReleases := false
@@ -211,6 +214,7 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR
InternalTracker: internalTracker,
HasWiki: hasWiki,
HasProjects: hasProjects,
ProjectsMode: string(projectsMode),
HasReleases: hasReleases,
HasPackages: hasPackages,
HasActions: hasActions,

+ 1
- 0
services/forms/repo_form.go View File

@@ -142,6 +142,7 @@ type RepoSettingForm struct {
ExternalTrackerRegexpPattern string
EnableCloseIssuesViaCommitInAnyBranch bool
EnableProjects bool
ProjectsMode string
EnableReleases bool
EnablePackages bool
EnablePulls bool

+ 2
- 1
templates/repo/header.tmpl View File

@@ -174,7 +174,8 @@
</a>
{{end}}

{{if and (not .UnitProjectsGlobalDisabled) (.Permission.CanRead $.UnitTypeProjects)}}
{{$projectsUnit := .Repository.MustGetUnit $.Context $.UnitTypeProjects}}
{{if and (not .UnitProjectsGlobalDisabled) (.Permission.CanRead $.UnitTypeProjects) ($projectsUnit.ProjectsConfig.IsProjectsAllowed "repo")}}
<a href="{{.RepoLink}}/projects" class="{{if .IsProjectsPage}}active {{end}}item">
{{svg "octicon-project"}} {{ctx.Locale.Tr "repo.project_board"}}
{{if .Repository.NumOpenProjects}}

+ 33
- 1
templates/repo/settings/options.tmpl View File

@@ -446,13 +446,45 @@

{{$isProjectsEnabled := .Repository.UnitEnabled $.Context $.UnitTypeProjects}}
{{$isProjectsGlobalDisabled := .UnitTypeProjects.UnitGlobalDisabled}}
{{$projectsUnit := .Repository.MustGetUnit $.Context $.UnitTypeProjects}}
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.project_board"}}</label>
<div class="ui checkbox{{if $isProjectsGlobalDisabled}} disabled{{end}}"{{if $isProjectsGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
<input class="enable-system" name="enable_projects" type="checkbox" {{if $isProjectsEnabled}}checked{{end}}>
<input class="enable-system" name="enable_projects" type="checkbox" data-target="#projects_box" {{if $isProjectsEnabled}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.projects_desc"}}</label>
</div>
</div>
<div class="field {{if not $isProjectsEnabled}} disabled{{end}} gt-pl-4" id="projects_box">
<p>
{{ctx.Locale.Tr "repo.settings.projects_mode_desc"}}
</p>
<div class="ui dropdown selection">
<select name="projects_mode">
<option value="repo" {{if or (not $isProjectsEnabled) (eq $projectsUnit.ProjectsConfig.ProjectsMode "repo")}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.projects_mode_repo"}}</option>
<option value="owner" {{if or (not $isProjectsEnabled) (eq $projectsUnit.ProjectsConfig.ProjectsMode "owner")}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.projects_mode_owner"}}</option>
<option value="all" {{if or (not $isProjectsEnabled) (eq $projectsUnit.ProjectsConfig.ProjectsMode "all")}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.projects_mode_all"}}</option>
</select>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="default text">
{{if (eq $projectsUnit.ProjectsConfig.ProjectsMode "repo")}}
{{ctx.Locale.Tr "repo.settings.projects_mode_repo"}}
{{end}}
{{if (eq $projectsUnit.ProjectsConfig.ProjectsMode "owner")}}
{{ctx.Locale.Tr "repo.settings.projects_mode_owner"}}
{{end}}
{{if (eq $projectsUnit.ProjectsConfig.ProjectsMode "all")}}
{{ctx.Locale.Tr "repo.settings.projects_mode_all"}}
{{end}}
</div>
<div class="menu">
<div class="item" data-value="repo">{{ctx.Locale.Tr "repo.settings.projects_mode_repo"}}</div>
<div class="item" data-value="owner">{{ctx.Locale.Tr "repo.settings.projects_mode_owner"}}</div>
<div class="item" data-value="all">{{ctx.Locale.Tr "repo.settings.projects_mode_all"}}</div>
</div>
</div>
</div>

<div class="divider"></div>

{{$isReleasesEnabled := .Repository.UnitEnabled $.Context $.UnitTypeReleases}}
{{$isReleasesGlobalDisabled := .UnitTypeReleases.UnitGlobalDisabled}}

+ 9
- 0
templates/swagger/v1_json.tmpl View File

@@ -19570,6 +19570,11 @@
"type": "boolean",
"x-go-name": "Private"
},
"projects_mode": {
"description": "`repo` to only allow repo-level projects, `owner` to only allow owner projects, `all` to allow both.",
"type": "string",
"x-go-name": "ProjectsMode"
},
"template": {
"description": "either `true` to make this repository a template or `false` to make it a normal repository",
"type": "boolean",
@@ -22491,6 +22496,10 @@
"type": "boolean",
"x-go-name": "Private"
},
"projects_mode": {
"type": "string",
"x-go-name": "ProjectsMode"
},
"release_counter": {
"type": "integer",
"format": "int64",

Loading…
Cancel
Save