From 1739e84ac02c0384c04576a00abab9348293f9c7 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Thu, 14 Sep 2017 16:16:22 +0800 Subject: [PATCH] improve protected branch to add whitelist support (#2451) * improve protected branch to add whitelist support * fix lint * fix style check * fix tests * fix description on UI and import * fix test * bug fixed * fix tests and languages * move isSliceInt64Eq to util pkg; improve function names & typo --- cmd/hook.go | 48 ++--- integrations/editor_test.go | 25 ++- integrations/integration_test.go | 2 +- integrations/internal_test.go | 2 +- models/branches.go | 192 ++++++++++-------- models/migrations/migrations.go | 2 + models/migrations/v40.go | 55 +++++ models/org.go | 5 + models/org_team.go | 20 ++ models/repo.go | 36 ++++ modules/auth/repo_form.go | 20 ++ modules/base/tool.go | 10 + modules/context/repo.go | 4 +- modules/private/branch.go | 26 +++ modules/util/compare.go | 29 +++ options/locale/locale_en-US.ini | 14 +- public/css/index.css | 24 +++ public/js/index.js | 45 +--- public/less/_repository.less | 33 +++ routers/private/branch.go | 24 ++- routers/private/internal.go | 1 + routers/repo/editor.go | 2 +- routers/repo/issue.go | 2 +- routers/repo/pull.go | 2 +- routers/repo/setting.go | 138 +------------ routers/repo/setting_protected_branch.go | 186 +++++++++++++++++ routers/routes/routes.go | 4 +- templates/repo/settings/branches.tmpl | 14 +- templates/repo/settings/protected_branch.tmpl | 74 +++++++ 29 files changed, 736 insertions(+), 303 deletions(-) create mode 100644 models/migrations/v40.go create mode 100644 modules/util/compare.go create mode 100644 routers/repo/setting_protected_branch.go create mode 100644 templates/repo/settings/protected_branch.tmpl diff --git a/cmd/hook.go b/cmd/hook.go index 06250181d..0ddbb36f9 100644 --- a/cmd/hook.go +++ b/cmd/hook.go @@ -84,9 +84,10 @@ func runHookPreReceive(c *cli.Context) error { // the environment setted on serv command repoID, _ := strconv.ParseInt(os.Getenv(models.ProtectedBranchRepoID), 10, 64) isWiki := (os.Getenv(models.EnvRepoIsWiki) == "true") - //username := os.Getenv(models.EnvRepoUsername) - //reponame := os.Getenv(models.EnvRepoName) - //repoPath := models.RepoPath(username, reponame) + username := os.Getenv(models.EnvRepoUsername) + reponame := os.Getenv(models.EnvRepoName) + userIDStr := os.Getenv(models.EnvPusherID) + repoPath := models.RepoPath(username, reponame) buf := bytes.NewBuffer(nil) scanner := bufio.NewScanner(os.Stdin) @@ -104,36 +105,37 @@ func runHookPreReceive(c *cli.Context) error { continue } - //oldCommitID := string(fields[0]) + oldCommitID := string(fields[0]) newCommitID := string(fields[1]) refFullName := string(fields[2]) - // FIXME: when we add feature to protected branch to deny force push, then uncomment below - /*var isForce bool - // detect force push - if git.EmptySHA != oldCommitID { - output, err := git.NewCommand("rev-list", oldCommitID, "^"+newCommitID).RunInDir(repoPath) - if err != nil { - fail("Internal error", "Fail to detect force push: %v", err) - } else if len(output) > 0 { - isForce = true - } - }*/ - branchName := strings.TrimPrefix(refFullName, git.BranchPrefix) protectBranch, err := private.GetProtectedBranchBy(repoID, branchName) if err != nil { log.GitLogger.Fatal(2, "retrieve protected branches information failed") } - if protectBranch != nil { - if !protectBranch.CanPush { - // check and deletion - if newCommitID == git.EmptySHA { - fail(fmt.Sprintf("branch %s is protected from deletion", branchName), "") - } else { + if protectBranch != nil && protectBranch.IsProtected() { + // detect force push + if git.EmptySHA != oldCommitID { + output, err := git.NewCommand("rev-list", oldCommitID, "^"+newCommitID).RunInDir(repoPath) + if err != nil { + fail("Internal error", "Fail to detect force push: %v", err) + } else if len(output) > 0 { + fail(fmt.Sprintf("branch %s is protected from force push", branchName), "") + } + } + + // check and deletion + if newCommitID == git.EmptySHA { + fail(fmt.Sprintf("branch %s is protected from deletion", branchName), "") + } else { + userID, _ := strconv.ParseInt(userIDStr, 10, 64) + canPush, err := private.CanUserPush(protectBranch.ID, userID) + if err != nil { + fail("Internal error", "Fail to detect user can push: %v", err) + } else if !canPush { fail(fmt.Sprintf("protected branch %s can not be pushed to", branchName), "") - //fail(fmt.Sprintf("branch %s is protected from force push", branchName), "") } } } diff --git a/integrations/editor_test.go b/integrations/editor_test.go index 79b6bb790..cc94edfd3 100644 --- a/integrations/editor_test.go +++ b/integrations/editor_test.go @@ -43,16 +43,15 @@ func TestCreateFileOnProtectedBranch(t *testing.T) { csrf := GetCSRF(t, session, "/user2/repo1/settings/branches") // Change master branch to protected - req := NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches?action=protected_branch", map[string]string{ - "_csrf": csrf, - "branchName": "master", - "canPush": "true", + req := NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/master", map[string]string{ + "_csrf": csrf, + "protected": "on", }) - resp := session.MakeRequest(t, req, http.StatusOK) + resp := session.MakeRequest(t, req, http.StatusFound) // Check if master branch has been locked successfully flashCookie := session.GetCookie("macaron_flash") assert.NotNil(t, flashCookie) - assert.EqualValues(t, flashCookie.Value, "success%3Dmaster%2BLocked%2Bsuccessfully") + assert.EqualValues(t, "success%3DBranch%2Bmaster%2Bprotect%2Boptions%2Bchanged%2Bsuccessfully.", flashCookie.Value) // Request editor page req = NewRequest(t, "GET", "/user2/repo1/_new/master/") @@ -74,6 +73,20 @@ func TestCreateFileOnProtectedBranch(t *testing.T) { resp = session.MakeRequest(t, req, http.StatusOK) // Check body for error message assert.Contains(t, string(resp.Body), "Can not commit to protected branch 'master'.") + + // remove the protected branch + csrf = GetCSRF(t, session, "/user2/repo1/settings/branches") + // Change master branch to protected + req = NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/master", map[string]string{ + "_csrf": csrf, + "protected": "off", + }) + resp = session.MakeRequest(t, req, http.StatusFound) + // Check if master branch has been locked successfully + flashCookie = session.GetCookie("macaron_flash") + assert.NotNil(t, flashCookie) + assert.EqualValues(t, "success%3DBranch%2Bmaster%2Bprotect%2Boptions%2Bremoved%2Bsuccessfully", flashCookie.Value) + } func testEditFile(t *testing.T, session *TestSession, user, repo, branch, filePath string) *TestResponse { diff --git a/integrations/integration_test.go b/integrations/integration_test.go index d43f3977b..0b5d8a764 100644 --- a/integrations/integration_test.go +++ b/integrations/integration_test.go @@ -269,7 +269,7 @@ func MakeRequest(t testing.TB, req *http.Request, expectedStatus int) *TestRespo mac.ServeHTTP(respWriter, req) if expectedStatus != NoExpectedStatus { assert.EqualValues(t, expectedStatus, respWriter.HeaderCode, - "Request URL: %s", req.URL.String()) + "Request URL: %s %s", req.URL.String(), buffer.String()) } return &TestResponse{ HeaderCode: respWriter.HeaderCode, diff --git a/integrations/internal_test.go b/integrations/internal_test.go index c22e951bc..d58b8b0b4 100644 --- a/integrations/internal_test.go +++ b/integrations/internal_test.go @@ -31,7 +31,7 @@ func assertProtectedBranch(t *testing.T, repoID int64, branchName string, isErr, var branch models.ProtectedBranch t.Log(string(resp.Body)) assert.NoError(t, json.Unmarshal(resp.Body, &branch)) - assert.Equal(t, canPush, branch.CanPush) + assert.Equal(t, canPush, !branch.IsProtected()) } } diff --git a/models/branches.go b/models/branches.go index 4461da006..1c3c0d17b 100644 --- a/models/branches.go +++ b/models/branches.go @@ -8,6 +8,12 @@ import ( "fmt" "strings" "time" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" + + "github.com/Unknwon/com" ) const ( @@ -17,14 +23,43 @@ const ( // ProtectedBranch struct type ProtectedBranch struct { - ID int64 `xorm:"pk autoincr"` - RepoID int64 `xorm:"UNIQUE(s)"` - BranchName string `xorm:"UNIQUE(s)"` - CanPush bool - Created time.Time `xorm:"-"` - CreatedUnix int64 `xorm:"created"` - Updated time.Time `xorm:"-"` - UpdatedUnix int64 `xorm:"updated"` + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"UNIQUE(s)"` + BranchName string `xorm:"UNIQUE(s)"` + EnableWhitelist bool + WhitelistUserIDs []int64 `xorm:"JSON TEXT"` + WhitelistTeamIDs []int64 `xorm:"JSON TEXT"` + Created time.Time `xorm:"-"` + CreatedUnix int64 `xorm:"created"` + Updated time.Time `xorm:"-"` + UpdatedUnix int64 `xorm:"updated"` +} + +// IsProtected returns if the branch is protected +func (protectBranch *ProtectedBranch) IsProtected() bool { + return protectBranch.ID > 0 +} + +// CanUserPush returns if some user could push to this protected branch +func (protectBranch *ProtectedBranch) CanUserPush(userID int64) bool { + if !protectBranch.EnableWhitelist { + return false + } + + if base.Int64sContains(protectBranch.WhitelistUserIDs, userID) { + return true + } + + if len(protectBranch.WhitelistTeamIDs) == 0 { + return false + } + + in, err := IsUserInTeams(userID, protectBranch.WhitelistTeamIDs) + if err != nil { + log.Error(1, "IsUserInTeams:", err) + return false + } + return in } // GetProtectedBranchByRepoID getting protected branch by repo ID @@ -46,6 +81,73 @@ func GetProtectedBranchBy(repoID int64, BranchName string) (*ProtectedBranch, er return rel, nil } +// GetProtectedBranchByID getting protected branch by ID +func GetProtectedBranchByID(id int64) (*ProtectedBranch, error) { + rel := &ProtectedBranch{ID: id} + has, err := x.Get(rel) + if err != nil { + return nil, err + } + if !has { + return nil, nil + } + return rel, nil +} + +// UpdateProtectBranch saves branch protection options of repository. +// If ID is 0, it creates a new record. Otherwise, updates existing record. +// This function also performs check if whitelist user and team's IDs have been changed +// to avoid unnecessary whitelist delete and regenerate. +func UpdateProtectBranch(repo *Repository, protectBranch *ProtectedBranch, whitelistUserIDs, whitelistTeamIDs []int64) (err error) { + if err = repo.GetOwner(); err != nil { + return fmt.Errorf("GetOwner: %v", err) + } + + hasUsersChanged := !util.IsSliceInt64Eq(protectBranch.WhitelistUserIDs, whitelistUserIDs) + if hasUsersChanged { + protectBranch.WhitelistUserIDs = make([]int64, 0, len(whitelistUserIDs)) + for _, userID := range whitelistUserIDs { + has, err := hasAccess(x, userID, repo, AccessModeWrite) + if err != nil { + return fmt.Errorf("HasAccess [user_id: %d, repo_id: %d]: %v", userID, protectBranch.RepoID, err) + } else if !has { + continue // Drop invalid user ID + } + + protectBranch.WhitelistUserIDs = append(protectBranch.WhitelistUserIDs, userID) + } + } + + // if the repo is in an orgniziation + hasTeamsChanged := !util.IsSliceInt64Eq(protectBranch.WhitelistTeamIDs, whitelistTeamIDs) + if hasTeamsChanged { + teams, err := GetTeamsWithAccessToRepo(repo.OwnerID, repo.ID, AccessModeWrite) + if err != nil { + return fmt.Errorf("GetTeamsWithAccessToRepo [org_id: %d, repo_id: %d]: %v", repo.OwnerID, repo.ID, err) + } + protectBranch.WhitelistTeamIDs = make([]int64, 0, len(teams)) + for i := range teams { + if teams[i].HasWriteAccess() && com.IsSliceContainsInt64(whitelistTeamIDs, teams[i].ID) { + protectBranch.WhitelistTeamIDs = append(protectBranch.WhitelistTeamIDs, teams[i].ID) + } + } + } + + // Make sure protectBranch.ID is not 0 for whitelists + if protectBranch.ID == 0 { + if _, err = x.Insert(protectBranch); err != nil { + return fmt.Errorf("Insert: %v", err) + } + return nil + } + + if _, err = x.Id(protectBranch.ID).AllCols().Update(protectBranch); err != nil { + return fmt.Errorf("Update: %v", err) + } + + return nil +} + // GetProtectedBranches get all protected branches func (repo *Repository) GetProtectedBranches() ([]*ProtectedBranch, error) { protectedBranches := make([]*ProtectedBranch, 0) @@ -53,7 +155,7 @@ func (repo *Repository) GetProtectedBranches() ([]*ProtectedBranch, error) { } // IsProtectedBranch checks if branch is protected -func (repo *Repository) IsProtectedBranch(branchName string) (bool, error) { +func (repo *Repository) IsProtectedBranch(branchName string, doer *User) (bool, error) { protectedBranch := &ProtectedBranch{ RepoID: repo.ID, BranchName: branchName, @@ -63,70 +165,12 @@ func (repo *Repository) IsProtectedBranch(branchName string) (bool, error) { if err != nil { return true, err } else if has { - return true, nil + return !protectedBranch.CanUserPush(doer.ID), nil } return false, nil } -// AddProtectedBranch add protection to branch -func (repo *Repository) AddProtectedBranch(branchName string, canPush bool) error { - protectedBranch := &ProtectedBranch{ - RepoID: repo.ID, - BranchName: branchName, - } - - has, err := x.Get(protectedBranch) - if err != nil { - return err - } else if has { - return nil - } - - sess := x.NewSession() - defer sess.Close() - if err = sess.Begin(); err != nil { - return err - } - protectedBranch.CanPush = canPush - if _, err = sess.InsertOne(protectedBranch); err != nil { - return err - } - - return sess.Commit() -} - -// ChangeProtectedBranch access mode sets new access mode for the ProtectedBranch. -func (repo *Repository) ChangeProtectedBranch(id int64, canPush bool) error { - ProtectedBranch := &ProtectedBranch{ - RepoID: repo.ID, - ID: id, - } - has, err := x.Get(ProtectedBranch) - if err != nil { - return fmt.Errorf("get ProtectedBranch: %v", err) - } else if !has { - return nil - } - - if ProtectedBranch.CanPush == canPush { - return nil - } - ProtectedBranch.CanPush = canPush - - sess := x.NewSession() - defer sess.Close() - if err = sess.Begin(); err != nil { - return err - } - - if _, err = sess.Id(ProtectedBranch.ID).AllCols().Update(ProtectedBranch); err != nil { - return fmt.Errorf("update ProtectedBranch: %v", err) - } - - return sess.Commit() -} - // DeleteProtectedBranch removes ProtectedBranch relation between the user and repository. func (repo *Repository) DeleteProtectedBranch(id int64) (err error) { protectedBranch := &ProtectedBranch{ @@ -148,15 +192,3 @@ func (repo *Repository) DeleteProtectedBranch(id int64) (err error) { return sess.Commit() } - -// newProtectedBranch insert one queue -func newProtectedBranch(protectedBranch *ProtectedBranch) error { - _, err := x.InsertOne(protectedBranch) - return err -} - -// UpdateProtectedBranch update queue -func UpdateProtectedBranch(protectedBranch *ProtectedBranch) error { - _, err := x.Update(protectedBranch) - return err -} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index a796c6d6a..e7542954d 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -128,6 +128,8 @@ var migrations = []Migration{ NewMigration("remove commits and settings unit types", removeCommitsUnitType), // v39 -> v40 NewMigration("adds time tracking and stopwatches", addTimetracking), + // v40 -> v41 + NewMigration("migrate protected branch struct", migrateProtectedBranchStruct), } // Migrate database to current version diff --git a/models/migrations/v40.go b/models/migrations/v40.go new file mode 100644 index 000000000..324521e0b --- /dev/null +++ b/models/migrations/v40.go @@ -0,0 +1,55 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package migrations + +import ( + "fmt" + "time" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + + "github.com/go-xorm/xorm" +) + +func migrateProtectedBranchStruct(x *xorm.Engine) error { + type ProtectedBranch struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"UNIQUE(s)"` + BranchName string `xorm:"UNIQUE(s)"` + CanPush bool + Created time.Time `xorm:"-"` + CreatedUnix int64 + Updated time.Time `xorm:"-"` + UpdatedUnix int64 + } + + var pbs []ProtectedBranch + err := x.Find(&pbs) + if err != nil { + return err + } + + for _, pb := range pbs { + if pb.CanPush { + if _, err = x.ID(pb.ID).Delete(new(ProtectedBranch)); err != nil { + return err + } + } + } + + switch { + case setting.UseSQLite3: + log.Warn("Unable to drop columns in SQLite") + case setting.UseMySQL, setting.UsePostgreSQL, setting.UseMSSQL, setting.UseTiDB: + if _, err := x.Exec("ALTER TABLE protected_branch DROP COLUMN can_push"); err != nil { + return fmt.Errorf("DROP COLUMN can_push: %v", err) + } + default: + log.Fatal(4, "Unrecognized DB") + } + + return nil +} diff --git a/models/org.go b/models/org.go index d43f15f9a..fd8175393 100644 --- a/models/org.go +++ b/models/org.go @@ -577,6 +577,11 @@ func (org *User) getUserTeamIDs(e Engine, userID int64) ([]int64, error) { Find(&teamIDs) } +// TeamsWithAccessToRepo returns all teamsthat have given access level to the repository. +func (org *User) TeamsWithAccessToRepo(repoID int64, mode AccessMode) ([]*Team, error) { + return GetTeamsWithAccessToRepo(org.ID, repoID, mode) +} + // GetUserTeamIDs returns of all team IDs of the organization that user is member of. func (org *User) GetUserTeamIDs(userID int64) ([]int64, error) { return org.getUserTeamIDs(x, userID) diff --git a/models/org_team.go b/models/org_team.go index bc0e12b50..acddc70b5 100644 --- a/models/org_team.go +++ b/models/org_team.go @@ -35,6 +35,11 @@ func (t *Team) GetUnitTypes() []UnitType { return t.UnitTypes } +// HasWriteAccess returns true if team has at least write level access mode. +func (t *Team) HasWriteAccess() bool { + return t.Authorize >= AccessModeWrite +} + // IsOwnerTeam returns true if team is owner team. func (t *Team) IsOwnerTeam() bool { return t.Name == ownerTeamName @@ -594,6 +599,11 @@ func RemoveTeamMember(team *Team, userID int64) error { return sess.Commit() } +// IsUserInTeams returns if a user in some teams +func IsUserInTeams(userID int64, teamIDs []int64) (bool, error) { + return x.Where("uid=?", userID).In("team_id", teamIDs).Exist(new(TeamUser)) +} + // ___________ __________ // \__ ___/___ _____ _____\______ \ ____ ______ ____ // | |_/ __ \\__ \ / \| _// __ \\____ \ / _ \ @@ -639,3 +649,13 @@ func removeTeamRepo(e Engine, teamID, repoID int64) error { }) return err } + +// GetTeamsWithAccessToRepo returns all teams in an organization that have given access level to the repository. +func GetTeamsWithAccessToRepo(orgID, repoID int64, mode AccessMode) ([]*Team, error) { + teams := make([]*Team, 0, 5) + return teams, x.Where("team.authorize >= ?", mode). + Join("INNER", "team_repo", "team_repo.team_id = team.id"). + And("team_repo.org_id = ?", orgID). + And("team_repo.repo_id = ?", repoID). + Find(&teams) +} diff --git a/models/repo.go b/models/repo.go index a2e63e2af..8d5b3b87c 100644 --- a/models/repo.go +++ b/models/repo.go @@ -656,6 +656,42 @@ func (repo *Repository) CanEnableEditor() bool { return !repo.IsMirror } +// GetWriters returns all users that have write access to the repository. +func (repo *Repository) GetWriters() (_ []*User, err error) { + return repo.getUsersWithAccessMode(x, AccessModeWrite) +} + +// getUsersWithAccessMode returns users that have at least given access mode to the repository. +func (repo *Repository) getUsersWithAccessMode(e Engine, mode AccessMode) (_ []*User, err error) { + if err = repo.getOwner(e); err != nil { + return nil, err + } + + accesses := make([]*Access, 0, 10) + if err = e.Where("repo_id = ? AND mode >= ?", repo.ID, mode).Find(&accesses); err != nil { + return nil, err + } + + // Leave a seat for owner itself to append later, but if owner is an organization + // and just waste 1 unit is cheaper than re-allocate memory once. + users := make([]*User, 0, len(accesses)+1) + if len(accesses) > 0 { + userIDs := make([]int64, len(accesses)) + for i := 0; i < len(accesses); i++ { + userIDs[i] = accesses[i].UserID + } + + if err = e.In("id", userIDs).Find(&users); err != nil { + return nil, err + } + } + if !repo.Owner.IsOrganization() { + users = append(users, repo.Owner) + } + + return users, nil +} + // NextIssueIndex returns the next issue index // FIXME: should have a mutex to prevent producing same index for two issues that are created // closely enough. diff --git a/modules/auth/repo_form.go b/modules/auth/repo_form.go index 4e9d2bff6..a6454655f 100644 --- a/modules/auth/repo_form.go +++ b/modules/auth/repo_form.go @@ -113,6 +113,26 @@ func (f *RepoSettingForm) Validate(ctx *macaron.Context, errs binding.Errors) bi return validate(errs, ctx.Data, f, ctx.Locale) } +// __________ .__ +// \______ \____________ ____ ____ | |__ +// | | _/\_ __ \__ \ / \_/ ___\| | \ +// | | \ | | \// __ \| | \ \___| Y \ +// |______ / |__| (____ /___| /\___ >___| / +// \/ \/ \/ \/ \/ + +// ProtectBranchForm form for changing protected branch settings +type ProtectBranchForm struct { + Protected bool + EnableWhitelist bool + WhitelistUsers string + WhitelistTeams string +} + +// Validate validates the fields +func (f *ProtectBranchForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors { + return validate(errs, ctx.Data, f, ctx.Locale) +} + // __ __ ___. .__ .__ __ // / \ / \ ____\_ |__ | |__ | |__ ____ | | __ // \ \/\/ // __ \| __ \| | \| | \ / _ \| |/ / diff --git a/modules/base/tool.go b/modules/base/tool.go index 543775e0d..26ced075d 100644 --- a/modules/base/tool.go +++ b/modules/base/tool.go @@ -497,6 +497,16 @@ func Int64sToMap(ints []int64) map[int64]bool { return m } +// Int64sContains returns if a int64 in a slice of int64 +func Int64sContains(intsSlice []int64, a int64) bool { + for _, c := range intsSlice { + if c == a { + return true + } + } + return false +} + // IsLetter reports whether the rune is a letter (category L). // https://github.com/golang/go/blob/master/src/go/scanner/scanner.go#L257 func IsLetter(ch rune) bool { diff --git a/modules/context/repo.go b/modules/context/repo.go index e335eafde..a82535a6d 100644 --- a/modules/context/repo.go +++ b/modules/context/repo.go @@ -78,8 +78,8 @@ func (r *Repository) CanEnableEditor() bool { // CanCommitToBranch returns true if repository is editable and user has proper access level // and branch is not protected -func (r *Repository) CanCommitToBranch() (bool, error) { - protectedBranch, err := r.Repository.IsProtectedBranch(r.BranchName) +func (r *Repository) CanCommitToBranch(doer *models.User) (bool, error) { + protectedBranch, err := r.Repository.IsProtectedBranch(r.BranchName, doer) if err != nil { return false, err } diff --git a/modules/private/branch.go b/modules/private/branch.go index 6b3b9170b..fed66d29f 100644 --- a/modules/private/branch.go +++ b/modules/private/branch.go @@ -38,3 +38,29 @@ func GetProtectedBranchBy(repoID int64, branchName string) (*models.ProtectedBra return &branch, nil } + +// CanUserPush returns if user can push +func CanUserPush(protectedBranchID, userID int64) (bool, error) { + // Ask for running deliver hook and test pull request tasks. + reqURL := setting.LocalURL + fmt.Sprintf("api/internal/protectedbranch/%d/%d", protectedBranchID, userID) + log.GitLogger.Trace("CanUserPush: %s", reqURL) + + resp, err := newInternalRequest(reqURL, "GET").Response() + if err != nil { + return false, err + } + + var canPush = make(map[string]interface{}) + if err := json.NewDecoder(resp.Body).Decode(&canPush); err != nil { + return false, err + } + + defer resp.Body.Close() + + // All 2XX status codes are accepted and others will return an error + if resp.StatusCode/100 != 2 { + return false, fmt.Errorf("Failed to retrieve push user: %s", decodeJSONError(resp).Err) + } + + return canPush["can_push"].(bool), nil +} diff --git a/modules/util/compare.go b/modules/util/compare.go new file mode 100644 index 000000000..c03a823d8 --- /dev/null +++ b/modules/util/compare.go @@ -0,0 +1,29 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package util + +import "sort" + +// Int64Slice attaches the methods of Interface to []int64, sorting in increasing order. +type Int64Slice []int64 + +func (p Int64Slice) Len() int { return len(p) } +func (p Int64Slice) Less(i, j int) bool { return p[i] < p[j] } +func (p Int64Slice) Swap(i, j int) { p[i], p[j] = p[j], p[i] } + +// IsSliceInt64Eq returns if the two slice has the same elements but different sequences. +func IsSliceInt64Eq(a, b []int64) bool { + if len(a) != len(b) { + return false + } + sort.Sort(Int64Slice(a)) + sort.Sort(Int64Slice(b)) + for i := 0; i < len(a); i++ { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index f43badaab..949f560a3 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -945,11 +945,19 @@ settings.protected_branch=Branch Protection settings.protected_branch_can_push=Allow push? settings.protected_branch_can_push_yes=You can push settings.protected_branch_can_push_no=You can not push +settings.branch_protection = Branch Protection for %s +settings.protect_this_branch = Protect this branch +settings.protect_this_branch_desc = Disable force pushes and prevent deletion. +settings.protect_whitelist_committers = Whitelist who can push to this branch +settings.protect_whitelist_committers_desc = Add users or teams to this branch's whitelist. Whitelisted users bypass the typical push restrictions. +settings.protect_whitelist_users = Users who can push to this branch +settings.protect_whitelist_search_users = Search users +settings.protect_whitelist_teams = Teams whose members can push to this branch. +settings.protect_whitelist_search_teams = Search teams settings.add_protected_branch=Enable protection settings.delete_protected_branch=Disable protection -settings.add_protected_branch_success=%s Locked successfully -settings.add_protected_branch_failed= %s Locked failed -settings.remove_protected_branch_success=%s Unlocked successfully +settings.update_protect_branch_success = Branch %s protect options changed successfully. +settings.remove_protected_branch_success= Branch %s protect options removed successfully settings.protected_branch_deletion=To delete a protected branch settings.protected_branch_deletion_desc=Anyone with write permissions will be able to push directly to this branch. Are you sure? settings.default_branch_desc = The default branch is considered the "base" branch in your repository against which all pull requests and code commits are automatically made, unless you specify a different branch. diff --git a/public/css/index.css b/public/css/index.css index ffc7fe1a1..d09490adb 100644 --- a/public/css/index.css +++ b/public/css/index.css @@ -2344,6 +2344,30 @@ footer .ui.language .menu { margin-left: 5px; margin-top: -3px; } +.repository.settings.branches .protected-branches .selection.dropdown { + width: 300px; +} +.repository.settings.branches .protected-branches .item { + border: 1px solid #eaeaea; + padding: 10px 15px; +} +.repository.settings.branches .protected-branches .item:not(:last-child) { + border-bottom: 0; +} +.repository.settings.branches .branch-protection .help { + margin-left: 26px; + padding-top: 0; +} +.repository.settings.branches .branch-protection .fields { + margin-left: 20px; + display: block; +} +.repository.settings.branches .branch-protection .whitelist { + margin-left: 26px; +} +.repository.settings.branches .branch-protection .whitelist .dropdown img { + display: inline-block; +} .repository.settings.webhook .events .column { padding-bottom: 0; } diff --git a/public/js/index.js b/public/js/index.js index dc2a09c9e..cded5e2a1 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -639,42 +639,18 @@ function initRepository() { if ($('.repository.compare.pull').length > 0) { initFilterSearchDropdown('.choose.branch .dropdown'); } -} - -function initProtectedBranch() { - $('#protectedBranch').change(function () { - var $this = $(this); - $.post($this.data('url'), { - "_csrf": csrf, - "canPush": true, - "branchName": $this.val(), - }, - function (data) { - if (data.redirect) { - window.location.href = data.redirect; - } else { - location.reload(); - } - } - ); - }); - $('.rm').click(function () { - var $this = $(this); - $.post($this.data('url'), { - "_csrf": csrf, - "canPush": false, - "branchName": $this.data('val'), - }, - function (data) { - if (data.redirect) { - window.location.href = data.redirect; - } else { - location.reload(); - } + // Branches + if ($('.repository.settings.branches').length > 0) { + initFilterSearchDropdown('.protected-branches .dropdown'); + $('.enable-protection, .enable-whitelist').change(function () { + if (this.checked) { + $($(this).data('target')).removeClass('disabled'); + } else { + $($(this).data('target')).addClass('disabled'); } - ); - }); + }); + } } function initRepositoryCollaboration() { @@ -1598,7 +1574,6 @@ $(document).ready(function () { initEditForm(); initEditor(); initOrganization(); - initProtectedBranch(); initWebhook(); initAdmin(); initCodeView(); diff --git a/public/less/_repository.less b/public/less/_repository.less index 34fdc2609..d161cc65e 100644 --- a/public/less/_repository.less +++ b/public/less/_repository.less @@ -1251,6 +1251,39 @@ } } + &.branches { + .protected-branches { + .selection.dropdown { + width: 300px; + } + .item { + border: 1px solid #eaeaea; + padding: 10px 15px; + + &:not(:last-child) { + border-bottom: 0; + } + } + } + .branch-protection { + .help { + margin-left: 26px; + padding-top: 0; + } + .fields { + margin-left: 20px; + display: block; + } + .whitelist { + margin-left: 26px; + + .dropdown img { + display: inline-block; + } + } + } + } + &.webhook { .events { .column { diff --git a/routers/private/branch.go b/routers/private/branch.go index 8e42f7303..448c61f1d 100644 --- a/routers/private/branch.go +++ b/routers/private/branch.go @@ -24,7 +24,29 @@ func GetProtectedBranchBy(ctx *macaron.Context) { ctx.JSON(200, protectBranch) } else { ctx.JSON(200, &models.ProtectedBranch{ - CanPush: true, + ID: 0, + }) + } +} + +// CanUserPush returns if user push +func CanUserPush(ctx *macaron.Context) { + pbID := ctx.ParamsInt64(":pbid") + userID := ctx.ParamsInt64(":userid") + + protectBranch, err := models.GetProtectedBranchByID(pbID) + if err != nil { + ctx.JSON(500, map[string]interface{}{ + "err": err.Error(), + }) + return + } else if protectBranch != nil { + ctx.JSON(200, map[string]interface{}{ + "can_push": protectBranch.CanUserPush(userID), + }) + } else { + ctx.JSON(200, map[string]interface{}{ + "can_push": false, }) } } diff --git a/routers/private/internal.go b/routers/private/internal.go index 3e7233226..b69411dd0 100644 --- a/routers/private/internal.go +++ b/routers/private/internal.go @@ -42,6 +42,7 @@ func RegisterRoutes(m *macaron.Macaron) { m.Group("/", func() { m.Post("/ssh/:id/update", UpdatePublicKey) m.Post("/push/update", PushUpdate) + m.Get("/protectedbranch/:pbid/:userid", CanUserPush) m.Get("/branch/:id/*", GetProtectedBranchBy) }, CheckInternalToken) } diff --git a/routers/repo/editor.go b/routers/repo/editor.go index bb0475338..42ede9a28 100644 --- a/routers/repo/editor.go +++ b/routers/repo/editor.go @@ -32,7 +32,7 @@ const ( ) func renderCommitRights(ctx *context.Context) bool { - canCommit, err := ctx.Repo.CanCommitToBranch() + canCommit, err := ctx.Repo.CanCommitToBranch(ctx.User) if err != nil { log.Error(4, "CanCommitToBranch: %v", err) } diff --git a/routers/repo/issue.go b/routers/repo/issue.go index 0cd4edabb..d5d1af7e4 100644 --- a/routers/repo/issue.go +++ b/routers/repo/issue.go @@ -694,7 +694,7 @@ func ViewIssue(ctx *context.Context) { log.Error(4, "GetHeadRepo: %v", err) } else if pull.HeadRepo != nil && pull.HeadBranch != pull.HeadRepo.DefaultBranch && ctx.User.IsWriterOfRepo(pull.HeadRepo) { // Check if branch is not protected - if protected, err := pull.HeadRepo.IsProtectedBranch(pull.HeadBranch); err != nil { + if protected, err := pull.HeadRepo.IsProtectedBranch(pull.HeadBranch, ctx.User); err != nil { log.Error(4, "IsProtectedBranch: %v", err) } else if !protected { canDelete = true diff --git a/routers/repo/pull.go b/routers/repo/pull.go index 47fcff312..87d3bdc26 100644 --- a/routers/repo/pull.go +++ b/routers/repo/pull.go @@ -841,7 +841,7 @@ func CleanUpPullRequest(ctx *context.Context) { } // Check if branch is not protected - if protected, err := pr.HeadRepo.IsProtectedBranch(pr.HeadBranch); err != nil || protected { + if protected, err := pr.HeadRepo.IsProtectedBranch(pr.HeadBranch, ctx.User); err != nil || protected { if err != nil { log.Error(4, "HeadRepo.IsProtectedBranch: %v", err) } diff --git a/routers/repo/setting.go b/routers/repo/setting.go index 6e12c7ad6..eb4136b07 100644 --- a/routers/repo/setting.go +++ b/routers/repo/setting.go @@ -25,6 +25,7 @@ const ( tplGithooks base.TplName = "repo/settings/githooks" tplGithookEdit base.TplName = "repo/settings/githook_edit" tplDeployKeys base.TplName = "repo/settings/deploy_keys" + tplProtectedBranch base.TplName = "repo/settings/protected_branch" ) // Settings show a repository's settings page @@ -437,143 +438,6 @@ func DeleteCollaboration(ctx *context.Context) { }) } -// ProtectedBranch render the page to protect the repository -func ProtectedBranch(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("repo.settings") - ctx.Data["PageIsSettingsBranches"] = true - - protectedBranches, err := ctx.Repo.Repository.GetProtectedBranches() - if err != nil { - ctx.Handle(500, "GetProtectedBranches", err) - return - } - ctx.Data["ProtectedBranches"] = protectedBranches - - branches := ctx.Data["Branches"].([]string) - leftBranches := make([]string, 0, len(branches)-len(protectedBranches)) - for _, b := range branches { - var protected bool - for _, pb := range protectedBranches { - if b == pb.BranchName { - protected = true - break - } - } - if !protected { - leftBranches = append(leftBranches, b) - } - } - - ctx.Data["LeftBranches"] = leftBranches - - ctx.HTML(200, tplBranches) -} - -// ProtectedBranchPost response for protect for a branch of a repository -func ProtectedBranchPost(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("repo.settings") - ctx.Data["PageIsSettingsBranches"] = true - - repo := ctx.Repo.Repository - - switch ctx.Query("action") { - case "default_branch": - if ctx.HasError() { - ctx.HTML(200, tplBranches) - return - } - - branch := ctx.Query("branch") - if !ctx.Repo.GitRepo.IsBranchExist(branch) { - ctx.Status(404) - return - } else if repo.DefaultBranch != branch { - repo.DefaultBranch = branch - if err := ctx.Repo.GitRepo.SetDefaultBranch(branch); err != nil { - if !git.IsErrUnsupportedVersion(err) { - ctx.Handle(500, "SetDefaultBranch", err) - return - } - } - if err := repo.UpdateDefaultBranch(); err != nil { - ctx.Handle(500, "SetDefaultBranch", err) - return - } - } - - log.Trace("Repository basic settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name) - - ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) - ctx.Redirect(setting.AppSubURL + ctx.Req.URL.Path) - case "protected_branch": - if ctx.HasError() { - ctx.JSON(200, map[string]string{ - "redirect": setting.AppSubURL + ctx.Req.URL.Path, - }) - return - } - - branchName := strings.ToLower(ctx.Query("branchName")) - if len(branchName) == 0 || !ctx.Repo.GitRepo.IsBranchExist(branchName) { - ctx.JSON(200, map[string]string{ - "redirect": setting.AppSubURL + ctx.Req.URL.Path, - }) - return - } - - canPush := ctx.QueryBool("canPush") - - if canPush { - if err := ctx.Repo.Repository.AddProtectedBranch(branchName, canPush); err != nil { - ctx.Flash.Error(ctx.Tr("repo.settings.add_protected_branch_failed", branchName)) - ctx.JSON(200, map[string]string{ - "status": "ok", - }) - return - } - - ctx.Flash.Success(ctx.Tr("repo.settings.add_protected_branch_success", branchName)) - ctx.JSON(200, map[string]string{ - "redirect": setting.AppSubURL + ctx.Req.URL.Path, - }) - } else { - if err := ctx.Repo.Repository.DeleteProtectedBranch(ctx.QueryInt64("id")); err != nil { - ctx.Flash.Error("DeleteProtectedBranch: " + err.Error()) - } else { - ctx.Flash.Success(ctx.Tr("repo.settings.remove_protected_branch_success", branchName)) - } - - ctx.JSON(200, map[string]interface{}{ - "status": "ok", - }) - } - default: - ctx.Handle(404, "", nil) - } -} - -// ChangeProtectedBranch response for changing access of a protect branch -func ChangeProtectedBranch(ctx *context.Context) { - if err := ctx.Repo.Repository.ChangeProtectedBranch( - ctx.QueryInt64("id"), - ctx.QueryBool("canPush")); err != nil { - log.Error(4, "ChangeProtectedBranch: %v", err) - } -} - -// DeleteProtectedBranch delete a protection for a branch of a repository -func DeleteProtectedBranch(ctx *context.Context) { - if err := ctx.Repo.Repository.DeleteProtectedBranch(ctx.QueryInt64("id")); err != nil { - ctx.Flash.Error("DeleteProtectedBranch: " + err.Error()) - } else { - ctx.Flash.Success(ctx.Tr("repo.settings.remove_protected_branch_success")) - } - - ctx.JSON(200, map[string]interface{}{ - "redirect": ctx.Repo.RepoLink + "/settings/branches", - }) -} - // parseOwnerAndRepo get repos by owner func parseOwnerAndRepo(ctx *context.Context) (*models.User, *models.Repository) { owner, err := models.GetUserByName(ctx.Params(":username")) diff --git a/routers/repo/setting_protected_branch.go b/routers/repo/setting_protected_branch.go new file mode 100644 index 000000000..7ab8ca218 --- /dev/null +++ b/routers/repo/setting_protected_branch.go @@ -0,0 +1,186 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package repo + +import ( + "fmt" + "strings" + + "code.gitea.io/git" + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/auth" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" +) + +// ProtectedBranch render the page to protect the repository +func ProtectedBranch(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings") + ctx.Data["PageIsSettingsBranches"] = true + + protectedBranches, err := ctx.Repo.Repository.GetProtectedBranches() + if err != nil { + ctx.Handle(500, "GetProtectedBranches", err) + return + } + ctx.Data["ProtectedBranches"] = protectedBranches + + branches := ctx.Data["Branches"].([]string) + leftBranches := make([]string, 0, len(branches)-len(protectedBranches)) + for _, b := range branches { + var protected bool + for _, pb := range protectedBranches { + if b == pb.BranchName { + protected = true + break + } + } + if !protected { + leftBranches = append(leftBranches, b) + } + } + + ctx.Data["LeftBranches"] = leftBranches + + ctx.HTML(200, tplBranches) +} + +// ProtectedBranchPost response for protect for a branch of a repository +func ProtectedBranchPost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings") + ctx.Data["PageIsSettingsBranches"] = true + + repo := ctx.Repo.Repository + + switch ctx.Query("action") { + case "default_branch": + if ctx.HasError() { + ctx.HTML(200, tplBranches) + return + } + + branch := ctx.Query("branch") + if !ctx.Repo.GitRepo.IsBranchExist(branch) { + ctx.Status(404) + return + } else if repo.DefaultBranch != branch { + repo.DefaultBranch = branch + if err := ctx.Repo.GitRepo.SetDefaultBranch(branch); err != nil { + if !git.IsErrUnsupportedVersion(err) { + ctx.Handle(500, "SetDefaultBranch", err) + return + } + } + if err := repo.UpdateDefaultBranch(); err != nil { + ctx.Handle(500, "SetDefaultBranch", err) + return + } + } + + log.Trace("Repository basic settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name) + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(setting.AppSubURL + ctx.Req.URL.Path) + default: + ctx.Handle(404, "", nil) + } +} + +// SettingsProtectedBranch renders the protected branch setting page +func SettingsProtectedBranch(c *context.Context) { + branch := c.Params("*") + if !c.Repo.GitRepo.IsBranchExist(branch) { + c.NotFound() + return + } + + c.Data["Title"] = c.Tr("repo.settings.protected_branches") + " - " + branch + c.Data["PageIsSettingsBranches"] = true + + protectBranch, err := models.GetProtectedBranchBy(c.Repo.Repository.ID, branch) + if err != nil { + if !models.IsErrBranchNotExist(err) { + c.Handle(500, "GetProtectBranchOfRepoByName", err) + return + } + } + + if protectBranch == nil { + // No options found, create defaults. + protectBranch = &models.ProtectedBranch{ + BranchName: branch, + } + } + + users, err := c.Repo.Repository.GetWriters() + if err != nil { + c.Handle(500, "Repo.Repository.GetWriters", err) + return + } + c.Data["Users"] = users + c.Data["whitelist_users"] = strings.Join(base.Int64sToStrings(protectBranch.WhitelistUserIDs), ",") + + if c.Repo.Owner.IsOrganization() { + teams, err := c.Repo.Owner.TeamsWithAccessToRepo(c.Repo.Repository.ID, models.AccessModeWrite) + if err != nil { + c.Handle(500, "Repo.Owner.TeamsWithAccessToRepo", err) + return + } + c.Data["Teams"] = teams + c.Data["whitelist_teams"] = strings.Join(base.Int64sToStrings(protectBranch.WhitelistTeamIDs), ",") + } + + c.Data["Branch"] = protectBranch + c.HTML(200, tplProtectedBranch) +} + +// SettingsProtectedBranchPost updates the protected branch settings +func SettingsProtectedBranchPost(ctx *context.Context, f auth.ProtectBranchForm) { + branch := ctx.Params("*") + if !ctx.Repo.GitRepo.IsBranchExist(branch) { + ctx.NotFound() + return + } + + protectBranch, err := models.GetProtectedBranchBy(ctx.Repo.Repository.ID, branch) + if err != nil { + if !models.IsErrBranchNotExist(err) { + ctx.Handle(500, "GetProtectBranchOfRepoByName", err) + return + } + } + + if f.Protected { + if protectBranch == nil { + // No options found, create defaults. + protectBranch = &models.ProtectedBranch{ + RepoID: ctx.Repo.Repository.ID, + BranchName: branch, + } + } + + protectBranch.EnableWhitelist = f.EnableWhitelist + whitelistUsers, _ := base.StringsToInt64s(strings.Split(f.WhitelistUsers, ",")) + whitelistTeams, _ := base.StringsToInt64s(strings.Split(f.WhitelistTeams, ",")) + err = models.UpdateProtectBranch(ctx.Repo.Repository, protectBranch, whitelistUsers, whitelistTeams) + if err != nil { + ctx.Handle(500, "UpdateProtectBranch", err) + return + } + ctx.Flash.Success(ctx.Tr("repo.settings.update_protect_branch_success", branch)) + ctx.Redirect(fmt.Sprintf("%s/settings/branches/%s", ctx.Repo.RepoLink, branch)) + } else { + if protectBranch != nil { + if err := ctx.Repo.Repository.DeleteProtectedBranch(protectBranch.ID); err != nil { + ctx.Handle(500, "DeleteProtectedBranch", err) + return + } + } + ctx.Flash.Success(ctx.Tr("repo.settings.remove_protected_branch_success", branch)) + ctx.Redirect(fmt.Sprintf("%s/settings/branches", ctx.Repo.RepoLink)) + } +} diff --git a/routers/routes/routes.go b/routers/routes/routes.go index 938dec1dc..067bf6a7f 100644 --- a/routers/routes/routes.go +++ b/routers/routes/routes.go @@ -433,8 +433,8 @@ func RegisterRoutes(m *macaron.Macaron) { }) m.Group("/branches", func() { m.Combo("").Get(repo.ProtectedBranch).Post(repo.ProtectedBranchPost) - m.Post("/can_push", repo.ChangeProtectedBranch) - m.Post("/delete", repo.DeleteProtectedBranch) + m.Combo("/*").Get(repo.SettingsProtectedBranch). + Post(bindIgnErr(auth.ProtectBranchForm{}), repo.SettingsProtectedBranchPost) }, repo.MustBeNotBare) m.Group("/hooks", func() { diff --git a/templates/repo/settings/branches.tmpl b/templates/repo/settings/branches.tmpl index 7008e2e53..b685217c8 100644 --- a/templates/repo/settings/branches.tmpl +++ b/templates/repo/settings/branches.tmpl @@ -39,20 +39,16 @@

{{.i18n.Tr "repo.settings.protected_branch"}}

+
@@ -65,8 +61,8 @@ {{range .ProtectedBranches}} -
{{.BranchName}}
- +
{{.BranchName}}
+ Edit {{else}} {{.i18n.Tr "repo.settings.no_protected_branch"}} diff --git a/templates/repo/settings/protected_branch.tmpl b/templates/repo/settings/protected_branch.tmpl new file mode 100644 index 000000000..a3a153eb3 --- /dev/null +++ b/templates/repo/settings/protected_branch.tmpl @@ -0,0 +1,74 @@ +{{template "base/head" .}} +
+ {{template "repo/header" .}} + {{template "repo/settings/navbar" .}} +
+ {{template "base/alert" .}} +

+ {{.i18n.Tr "repo.settings.branch_protection" .Branch.BranchName | Str2html}} +

+
+
+ {{.CsrfTokenHtml}} +
+
+ + +

{{.i18n.Tr "repo.settings.protect_this_branch_desc"}}

+
+
+
+
+
+ + +

{{.i18n.Tr "repo.settings.protect_whitelist_committers_desc"}}

+
+
+
+
+ + +
+ {{if .Owner.IsOrganization}} +
+
+ + +
+ {{end}} +
+
+ +
+ +
+ +
+
+
+
+
+{{template "base/footer" .}} \ No newline at end of file