From fe6792dff3d167e87b0c4476f7e7a7ce15742855 Mon Sep 17 00:00:00 2001 From: Denys Konovalov Date: Mon, 4 Mar 2024 03:56:52 +0100 Subject: [PATCH] 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 --- models/fixtures/repo_unit.yml | 7 +-- models/repo/repo.go | 5 ++ models/repo/repo_unit.go | 56 ++++++++++++++++- modules/repository/create.go | 6 ++ modules/structs/repo.go | 3 + options/locale/locale_en-US.ini | 6 +- routers/api/v1/repo/repo.go | 26 +++++++- routers/web/repo/issue.go | 93 ++++++++++++++++------------ routers/web/repo/projects.go | 15 ++--- routers/web/repo/setting/setting.go | 3 + routers/web/web.go | 2 +- services/convert/repository.go | 6 +- services/forms/repo_form.go | 1 + templates/repo/header.tmpl | 3 +- templates/repo/settings/options.tmpl | 34 +++++++++- templates/swagger/v1_json.tmpl | 9 +++ 16 files changed, 212 insertions(+), 63 deletions(-) diff --git a/models/fixtures/repo_unit.yml b/models/fixtures/repo_unit.yml index 4b26674990..6714294e2b 100644 --- a/models/fixtures/repo_unit.yml +++ b/models/fixtures/repo_unit.yml @@ -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 diff --git a/models/repo/repo.go b/models/repo/repo.go index 5ce3ecb58a..ad2e21b66b 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -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{ diff --git a/models/repo/repo_unit.go b/models/repo/repo_unit.go index 31a2a2e248..6b9dde7faf 100644 --- a/models/repo/repo_unit.go +++ b/models/repo/repo_unit.go @@ -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 { diff --git a/modules/repository/create.go b/modules/repository/create.go index ca2150b972..f009c0880d 100644 --- a/modules/repository/create.go +++ b/modules/repository/create.go @@ -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, diff --git a/modules/structs/repo.go b/modules/structs/repo.go index 56d6158bd8..bc8eb0b756 100644 --- a/modules/structs/repo.go +++ b/modules/structs/repo.go @@ -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. diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 8c4dae753b..c8c8f2dfeb 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -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) diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index 6fde73a4e8..5f1af92041 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -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) } } diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index 1abd5e2ba5..b8c7f70aa6 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -587,52 +587,63 @@ func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) { if repo.Owner.IsOrganization() { repoOwnerType = project_model.TypeOrganization } + + projectsUnit := repo.MustGetUnit(ctx, unit.TypeProjects) + + var openProjects []*project_model.Project + var closedProjects []*project_model.Project 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 + + 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 + } } - ctx.Data["OpenProjects"] = append(projects, projects2...) - - 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 - } - 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 diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index 4c171defbd..86909b5fd0 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -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 } } diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index 0f649acba3..3af0ddb578 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -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) diff --git a/routers/web/web.go b/routers/web/web.go index 9de652fba5..14d31b3a90 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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) diff --git a/services/convert/repository.go b/services/convert/repository.go index 9184bc05c7..39efd304a9 100644 --- a/services/convert/repository.go +++ b/services/convert/repository.go @@ -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, diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index e40bcf4eea..8c3e458d2f 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -142,6 +142,7 @@ type RepoSettingForm struct { ExternalTrackerRegexpPattern string EnableCloseIssuesViaCommitInAnyBranch bool EnableProjects bool + ProjectsMode string EnableReleases bool EnablePackages bool EnablePulls bool diff --git a/templates/repo/header.tmpl b/templates/repo/header.tmpl index ee46af4236..b692c851ee 100644 --- a/templates/repo/header.tmpl +++ b/templates/repo/header.tmpl @@ -174,7 +174,8 @@ {{end}} - {{if and (not .UnitProjectsGlobalDisabled) (.Permission.CanRead $.UnitTypeProjects)}} + {{$projectsUnit := .Repository.MustGetUnit $.Context $.UnitTypeProjects}} + {{if and (not .UnitProjectsGlobalDisabled) (.Permission.CanRead $.UnitTypeProjects) ($projectsUnit.ProjectsConfig.IsProjectsAllowed "repo")}} {{svg "octicon-project"}} {{ctx.Locale.Tr "repo.project_board"}} {{if .Repository.NumOpenProjects}} diff --git a/templates/repo/settings/options.tmpl b/templates/repo/settings/options.tmpl index 6d01b227ff..5a85192a43 100644 --- a/templates/repo/settings/options.tmpl +++ b/templates/repo/settings/options.tmpl @@ -446,13 +446,45 @@ {{$isProjectsEnabled := .Repository.UnitEnabled $.Context $.UnitTypeProjects}} {{$isProjectsGlobalDisabled := .UnitTypeProjects.UnitGlobalDisabled}} + {{$projectsUnit := .Repository.MustGetUnit $.Context $.UnitTypeProjects}}
- +
+
+

+ {{ctx.Locale.Tr "repo.settings.projects_mode_desc"}} +

+ +
+ +
{{$isReleasesEnabled := .Repository.UnitEnabled $.Context $.UnitTypeReleases}} {{$isReleasesGlobalDisabled := .UnitTypeReleases.UnitGlobalDisabled}} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index fa7cd60eb3..9aba84a023 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -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",