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
@@ -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 |
@@ -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{ |
@@ -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 { |
@@ -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, |
@@ -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. |
@@ -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) |
@@ -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) | |||
} | |||
} |
@@ -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 |
@@ -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 | |||
} | |||
} |
@@ -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) |
@@ -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) |
@@ -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, |
@@ -142,6 +142,7 @@ type RepoSettingForm struct { | |||
ExternalTrackerRegexpPattern string | |||
EnableCloseIssuesViaCommitInAnyBranch bool | |||
EnableProjects bool | |||
ProjectsMode string | |||
EnableReleases bool | |||
EnablePackages bool | |||
EnablePulls bool |
@@ -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}} |
@@ -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}} |
@@ -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", |