Skip to content

Commit 9681c83

Browse files
jonasfranzlunny
authored andcommitted
Approvals at Branch Protection (#5350)
* Add branch protection for approvals Signed-off-by: Jonas Franz <[email protected]> * Add required approvals Signed-off-by: Jonas Franz <[email protected]> * Add missing comments and fmt Signed-off-by: Jonas Franz <[email protected]> * Add type = approval and group by reviewer_id to review * Prevent users from adding negative review limits * Add migration for approval whitelists Signed-off-by: Jonas Franz <[email protected]>
1 parent 64680b7 commit 9681c83

File tree

13 files changed

+251
-41
lines changed

13 files changed

+251
-41
lines changed

models/branches.go

Lines changed: 83 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,21 @@ const (
2323

2424
// ProtectedBranch struct
2525
type ProtectedBranch struct {
26-
ID int64 `xorm:"pk autoincr"`
27-
RepoID int64 `xorm:"UNIQUE(s)"`
28-
BranchName string `xorm:"UNIQUE(s)"`
29-
CanPush bool `xorm:"NOT NULL DEFAULT false"`
30-
EnableWhitelist bool
31-
WhitelistUserIDs []int64 `xorm:"JSON TEXT"`
32-
WhitelistTeamIDs []int64 `xorm:"JSON TEXT"`
33-
EnableMergeWhitelist bool `xorm:"NOT NULL DEFAULT false"`
34-
MergeWhitelistUserIDs []int64 `xorm:"JSON TEXT"`
35-
MergeWhitelistTeamIDs []int64 `xorm:"JSON TEXT"`
36-
CreatedUnix util.TimeStamp `xorm:"created"`
37-
UpdatedUnix util.TimeStamp `xorm:"updated"`
26+
ID int64 `xorm:"pk autoincr"`
27+
RepoID int64 `xorm:"UNIQUE(s)"`
28+
BranchName string `xorm:"UNIQUE(s)"`
29+
CanPush bool `xorm:"NOT NULL DEFAULT false"`
30+
EnableWhitelist bool
31+
WhitelistUserIDs []int64 `xorm:"JSON TEXT"`
32+
WhitelistTeamIDs []int64 `xorm:"JSON TEXT"`
33+
EnableMergeWhitelist bool `xorm:"NOT NULL DEFAULT false"`
34+
MergeWhitelistUserIDs []int64 `xorm:"JSON TEXT"`
35+
MergeWhitelistTeamIDs []int64 `xorm:"JSON TEXT"`
36+
ApprovalsWhitelistUserIDs []int64 `xorm:"JSON TEXT"`
37+
ApprovalsWhitelistTeamIDs []int64 `xorm:"JSON TEXT"`
38+
RequiredApprovals int64 `xorm:"NOT NULL DEFAULT 0"`
39+
CreatedUnix util.TimeStamp `xorm:"created"`
40+
UpdatedUnix util.TimeStamp `xorm:"updated"`
3841
}
3942

4043
// IsProtected returns if the branch is protected
@@ -86,6 +89,41 @@ func (protectBranch *ProtectedBranch) CanUserMerge(userID int64) bool {
8689
return in
8790
}
8891

92+
// HasEnoughApprovals returns true if pr has enough granted approvals.
93+
func (protectBranch *ProtectedBranch) HasEnoughApprovals(pr *PullRequest) bool {
94+
if protectBranch.RequiredApprovals == 0 {
95+
return true
96+
}
97+
return protectBranch.GetGrantedApprovalsCount(pr) >= protectBranch.RequiredApprovals
98+
}
99+
100+
// GetGrantedApprovalsCount returns the number of granted approvals for pr. A granted approval must be authored by a user in an approval whitelist.
101+
func (protectBranch *ProtectedBranch) GetGrantedApprovalsCount(pr *PullRequest) int64 {
102+
reviews, err := GetReviewersByPullID(pr.ID)
103+
if err != nil {
104+
log.Error(1, "GetUniqueApprovalsByPullRequestID:", err)
105+
return 0
106+
}
107+
approvals := int64(0)
108+
userIDs := make([]int64, 0)
109+
for _, review := range reviews {
110+
if review.Type != ReviewTypeApprove {
111+
continue
112+
}
113+
if base.Int64sContains(protectBranch.ApprovalsWhitelistUserIDs, review.ID) {
114+
approvals++
115+
continue
116+
}
117+
userIDs = append(userIDs, review.ID)
118+
}
119+
approvalTeamCount, err := UsersInTeamsCount(userIDs, protectBranch.ApprovalsWhitelistTeamIDs)
120+
if err != nil {
121+
log.Error(1, "UsersInTeamsCount:", err)
122+
return 0
123+
}
124+
return approvalTeamCount + approvals
125+
}
126+
89127
// GetProtectedBranchByRepoID getting protected branch by repo ID
90128
func GetProtectedBranchByRepoID(RepoID int64) ([]*ProtectedBranch, error) {
91129
protectedBranches := make([]*ProtectedBranch, 0)
@@ -118,40 +156,64 @@ func GetProtectedBranchByID(id int64) (*ProtectedBranch, error) {
118156
return rel, nil
119157
}
120158

159+
// WhitelistOptions represent all sorts of whitelists used for protected branches
160+
type WhitelistOptions struct {
161+
UserIDs []int64
162+
TeamIDs []int64
163+
164+
MergeUserIDs []int64
165+
MergeTeamIDs []int64
166+
167+
ApprovalsUserIDs []int64
168+
ApprovalsTeamIDs []int64
169+
}
170+
121171
// UpdateProtectBranch saves branch protection options of repository.
122172
// If ID is 0, it creates a new record. Otherwise, updates existing record.
123173
// This function also performs check if whitelist user and team's IDs have been changed
124174
// to avoid unnecessary whitelist delete and regenerate.
125-
func UpdateProtectBranch(repo *Repository, protectBranch *ProtectedBranch, whitelistUserIDs, whitelistTeamIDs, mergeWhitelistUserIDs, mergeWhitelistTeamIDs []int64) (err error) {
175+
func UpdateProtectBranch(repo *Repository, protectBranch *ProtectedBranch, opts WhitelistOptions) (err error) {
126176
if err = repo.GetOwner(); err != nil {
127177
return fmt.Errorf("GetOwner: %v", err)
128178
}
129179

130-
whitelist, err := updateUserWhitelist(repo, protectBranch.WhitelistUserIDs, whitelistUserIDs)
180+
whitelist, err := updateUserWhitelist(repo, protectBranch.WhitelistUserIDs, opts.UserIDs)
131181
if err != nil {
132182
return err
133183
}
134184
protectBranch.WhitelistUserIDs = whitelist
135185

136-
whitelist, err = updateUserWhitelist(repo, protectBranch.MergeWhitelistUserIDs, mergeWhitelistUserIDs)
186+
whitelist, err = updateUserWhitelist(repo, protectBranch.MergeWhitelistUserIDs, opts.MergeUserIDs)
137187
if err != nil {
138188
return err
139189
}
140190
protectBranch.MergeWhitelistUserIDs = whitelist
141191

192+
whitelist, err = updateUserWhitelist(repo, protectBranch.ApprovalsWhitelistUserIDs, opts.ApprovalsUserIDs)
193+
if err != nil {
194+
return err
195+
}
196+
protectBranch.ApprovalsWhitelistUserIDs = whitelist
197+
142198
// if the repo is in an organization
143-
whitelist, err = updateTeamWhitelist(repo, protectBranch.WhitelistTeamIDs, whitelistTeamIDs)
199+
whitelist, err = updateTeamWhitelist(repo, protectBranch.WhitelistTeamIDs, opts.TeamIDs)
144200
if err != nil {
145201
return err
146202
}
147203
protectBranch.WhitelistTeamIDs = whitelist
148204

149-
whitelist, err = updateTeamWhitelist(repo, protectBranch.MergeWhitelistTeamIDs, mergeWhitelistTeamIDs)
205+
whitelist, err = updateTeamWhitelist(repo, protectBranch.MergeWhitelistTeamIDs, opts.MergeTeamIDs)
150206
if err != nil {
151207
return err
152208
}
153209
protectBranch.MergeWhitelistTeamIDs = whitelist
154210

211+
whitelist, err = updateTeamWhitelist(repo, protectBranch.ApprovalsWhitelistTeamIDs, opts.ApprovalsTeamIDs)
212+
if err != nil {
213+
return err
214+
}
215+
protectBranch.ApprovalsWhitelistTeamIDs = whitelist
216+
155217
// Make sure protectBranch.ID is not 0 for whitelists
156218
if protectBranch.ID == 0 {
157219
if _, err = x.Insert(protectBranch); err != nil {
@@ -213,7 +275,7 @@ func (repo *Repository) IsProtectedBranchForPush(branchName string, doer *User)
213275
}
214276

215277
// IsProtectedBranchForMerging checks if branch is protected for merging
216-
func (repo *Repository) IsProtectedBranchForMerging(branchName string, doer *User) (bool, error) {
278+
func (repo *Repository) IsProtectedBranchForMerging(pr *PullRequest, branchName string, doer *User) (bool, error) {
217279
if doer == nil {
218280
return true, nil
219281
}
@@ -227,7 +289,7 @@ func (repo *Repository) IsProtectedBranchForMerging(branchName string, doer *Use
227289
if err != nil {
228290
return true, err
229291
} else if has {
230-
return !protectedBranch.CanUserMerge(doer.ID), nil
292+
return !protectedBranch.CanUserMerge(doer.ID) || !protectedBranch.HasEnoughApprovals(pr), nil
231293
}
232294

233295
return false, nil
@@ -270,14 +332,14 @@ func updateTeamWhitelist(repo *Repository, currentWhitelist, newWhitelist []int6
270332
return currentWhitelist, nil
271333
}
272334

273-
teams, err := GetTeamsWithAccessToRepo(repo.OwnerID, repo.ID, AccessModeWrite)
335+
teams, err := GetTeamsWithAccessToRepo(repo.OwnerID, repo.ID, AccessModeRead)
274336
if err != nil {
275337
return nil, fmt.Errorf("GetTeamsWithAccessToRepo [org_id: %d, repo_id: %d]: %v", repo.OwnerID, repo.ID, err)
276338
}
277339

278340
whitelist = make([]int64, 0, len(teams))
279341
for i := range teams {
280-
if teams[i].HasWriteAccess() && com.IsSliceContainsInt64(newWhitelist, teams[i].ID) {
342+
if com.IsSliceContainsInt64(newWhitelist, teams[i].ID) {
281343
whitelist = append(whitelist, teams[i].ID)
282344
}
283345
}

models/migrations/migrations.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,8 @@ var migrations = []Migration{
200200
NewMigration("add review", addReview),
201201
// v73 -> v74
202202
NewMigration("add must_change_password column for users table", addMustChangePassword),
203+
// v74 -> v75
204+
NewMigration("add approval whitelists to protected branches", addApprovalWhitelistsToProtectedBranches),
203205
}
204206

205207
// Migrate database to current version

models/migrations/v74.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Copyright 2018 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package migrations
6+
7+
import "github.com/go-xorm/xorm"
8+
9+
func addApprovalWhitelistsToProtectedBranches(x *xorm.Engine) error {
10+
type ProtectedBranch struct {
11+
ApprovalsWhitelistUserIDs []int64 `xorm:"JSON TEXT"`
12+
ApprovalsWhitelistTeamIDs []int64 `xorm:"JSON TEXT"`
13+
RequiredApprovals int64 `xorm:"NOT NULL DEFAULT 0"`
14+
}
15+
return x.Sync2(new(ProtectedBranch))
16+
}

models/org_team.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -700,6 +700,14 @@ func IsUserInTeams(userID int64, teamIDs []int64) (bool, error) {
700700
return x.Where("uid=?", userID).In("team_id", teamIDs).Exist(new(TeamUser))
701701
}
702702

703+
// UsersInTeamsCount counts the number of users which are in userIDs and teamIDs
704+
func UsersInTeamsCount(userIDs []int64, teamIDs []int64) (count int64, err error) {
705+
if count, err = x.In("uid", userIDs).In("team_id", teamIDs).Count(new(TeamUser)); err != nil {
706+
return 0, err
707+
}
708+
return
709+
}
710+
703711
// ___________ __________
704712
// \__ ___/___ _____ _____\______ \ ____ ______ ____
705713
// | |_/ __ \\__ \ / \| _// __ \\____ \ / _ \

models/org_team_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,3 +346,17 @@ func TestHasTeamRepo(t *testing.T) {
346346
test(2, 3, true)
347347
test(2, 5, false)
348348
}
349+
350+
func TestUsersInTeamsCount(t *testing.T) {
351+
assert.NoError(t, PrepareTestDatabase())
352+
353+
test := func(teamIDs []int64, userIDs []int64, expected int64) {
354+
count, err := UsersInTeamsCount(teamIDs, userIDs)
355+
assert.NoError(t, err)
356+
assert.Equal(t, expected, count)
357+
}
358+
359+
test([]int64{2}, []int64{1, 2, 3, 4}, 2)
360+
test([]int64{1, 2, 3, 4, 5}, []int64{2, 5}, 2)
361+
test([]int64{1, 2, 3, 4, 5}, []int64{2, 3, 5}, 3)
362+
}

models/pull.go

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,15 @@ type PullRequest struct {
6060
Issue *Issue `xorm:"-"`
6161
Index int64
6262

63-
HeadRepoID int64 `xorm:"INDEX"`
64-
HeadRepo *Repository `xorm:"-"`
65-
BaseRepoID int64 `xorm:"INDEX"`
66-
BaseRepo *Repository `xorm:"-"`
67-
HeadUserName string
68-
HeadBranch string
69-
BaseBranch string
70-
MergeBase string `xorm:"VARCHAR(40)"`
63+
HeadRepoID int64 `xorm:"INDEX"`
64+
HeadRepo *Repository `xorm:"-"`
65+
BaseRepoID int64 `xorm:"INDEX"`
66+
BaseRepo *Repository `xorm:"-"`
67+
HeadUserName string
68+
HeadBranch string
69+
BaseBranch string
70+
ProtectedBranch *ProtectedBranch `xorm:"-"`
71+
MergeBase string `xorm:"VARCHAR(40)"`
7172

7273
HasMerged bool `xorm:"INDEX"`
7374
MergedCommitID string `xorm:"VARCHAR(40)"`
@@ -110,6 +111,12 @@ func (pr *PullRequest) loadIssue(e Engine) (err error) {
110111
return err
111112
}
112113

114+
// LoadProtectedBranch loads the protected branch of the base branch
115+
func (pr *PullRequest) LoadProtectedBranch() (err error) {
116+
pr.ProtectedBranch, err = GetProtectedBranchBy(pr.BaseRepo.ID, pr.BaseBranch)
117+
return
118+
}
119+
113120
// GetDefaultMergeMessage returns default message used when merging pull request
114121
func (pr *PullRequest) GetDefaultMergeMessage() string {
115122
if pr.HeadRepo == nil {
@@ -288,7 +295,7 @@ func (pr *PullRequest) CheckUserAllowedToMerge(doer *User) (err error) {
288295
}
289296
}
290297

291-
if protected, err := pr.BaseRepo.IsProtectedBranchForMerging(pr.BaseBranch, doer); err != nil {
298+
if protected, err := pr.BaseRepo.IsProtectedBranchForMerging(pr, pr.BaseBranch, doer); err != nil {
292299
return fmt.Errorf("IsProtectedBranch: %v", err)
293300
} else if protected {
294301
return ErrNotAllowedToMerge{

models/review.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,23 @@ func GetReviewByID(id int64) (*Review, error) {
161161
return getReviewByID(x, id)
162162
}
163163

164+
func getUniqueApprovalsByPullRequestID(e Engine, prID int64) (reviews []*Review, err error) {
165+
reviews = make([]*Review, 0)
166+
if err := e.
167+
Where("issue_id = ? AND type = ?", prID, ReviewTypeApprove).
168+
OrderBy("updated_unix").
169+
GroupBy("reviewer_id").
170+
Find(&reviews); err != nil {
171+
return nil, err
172+
}
173+
return
174+
}
175+
176+
// GetUniqueApprovalsByPullRequestID returns all reviews submitted for a specific pull request
177+
func GetUniqueApprovalsByPullRequestID(prID int64) ([]*Review, error) {
178+
return getUniqueApprovalsByPullRequestID(x, prID)
179+
}
180+
164181
// FindReviewOptions represent possible filters to find reviews
165182
type FindReviewOptions struct {
166183
Type ReviewType

modules/auth/repo_form.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -135,13 +135,16 @@ func (f *RepoSettingForm) Validate(ctx *macaron.Context, errs binding.Errors) bi
135135

136136
// ProtectBranchForm form for changing protected branch settings
137137
type ProtectBranchForm struct {
138-
Protected bool
139-
EnableWhitelist bool
140-
WhitelistUsers string
141-
WhitelistTeams string
142-
EnableMergeWhitelist bool
143-
MergeWhitelistUsers string
144-
MergeWhitelistTeams string
138+
Protected bool
139+
EnableWhitelist bool
140+
WhitelistUsers string
141+
WhitelistTeams string
142+
EnableMergeWhitelist bool
143+
MergeWhitelistUsers string
144+
MergeWhitelistTeams string
145+
RequiredApprovals int64
146+
ApprovalsWhitelistUsers string
147+
ApprovalsWhitelistTeams string
145148
}
146149

147150
// Validate validates the fields

options/locale/locale_en-US.ini

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -859,6 +859,7 @@ pulls.title_wip_desc = `<a href="#">Start the title with <strong>%s</strong></a>
859859
pulls.cannot_merge_work_in_progress = This pull request is marked as a work in progress. Remove the <strong>%s</strong> prefix from the title when it's ready
860860
pulls.data_broken = This pull request is broken due to missing fork information.
861861
pulls.is_checking = "Merge conflict checking is in progress. Try again in few moments."
862+
pulls.blocked_by_approvals = "This Pull Request hasn't enough approvals yet. %d of %d approvals granted."
862863
pulls.can_auto_merge_desc = This pull request can be merged automatically.
863864
pulls.cannot_auto_merge_desc = This pull request cannot be merged automatically due to conflicts.
864865
pulls.cannot_auto_merge_helper = Merge manually to resolve the conflicts.
@@ -1149,6 +1150,10 @@ settings.protect_merge_whitelist_committers = Enable Merge Whitelist
11491150
settings.protect_merge_whitelist_committers_desc = Allow only whitelisted users or teams to merge pull requests into this branch.
11501151
settings.protect_merge_whitelist_users = Whitelisted users for merging:
11511152
settings.protect_merge_whitelist_teams = Whitelisted teams for merging:
1153+
settings.protect_required_approvals = Required approvals:
1154+
settings.protect_required_approvals_desc = Allow only to merge pull request with enough positive reviews of whitelisted users or teams.
1155+
settings.protect_approvals_whitelist_users = Whitelisted reviewers:
1156+
settings.protect_approvals_whitelist_teams = Whitelisted teams for reviews:
11521157
settings.add_protected_branch = Enable protection
11531158
settings.delete_protected_branch = Disable protection
11541159
settings.update_protect_branch_success = Branch protection for branch '%s' has been updated.
@@ -1159,6 +1164,7 @@ settings.default_branch_desc = Select a default repository branch for pull reque
11591164
settings.choose_branch = Choose a branch…
11601165
settings.no_protected_branch = There are no protected branches.
11611166
settings.edit_protected_branch = Edit
1167+
settings.protected_branch_required_approvals_min = Required approvals cannot be negative.
11621168
11631169
diff.browse_source = Browse Source
11641170
diff.parent = parent

routers/repo/issue.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -828,6 +828,14 @@ func ViewIssue(ctx *context.Context) {
828828
ctx.Data["MergeStyle"] = ""
829829
}
830830
}
831+
if err = pull.LoadProtectedBranch(); err != nil {
832+
ctx.ServerError("LoadProtectedBranch", err)
833+
return
834+
}
835+
if pull.ProtectedBranch != nil {
836+
ctx.Data["IsBlockedByApprovals"] = !pull.ProtectedBranch.HasEnoughApprovals(pull)
837+
ctx.Data["GrantedApprovals"] = pull.ProtectedBranch.GetGrantedApprovalsCount(pull)
838+
}
831839
ctx.Data["IsPullBranchDeletable"] = canDelete && pull.HeadRepo != nil && git.IsBranchExist(pull.HeadRepo.RepoPath(), pull.HeadBranch)
832840

833841
ctx.Data["PullReviewersWithType"], err = models.GetReviewersByPullID(issue.ID)

0 commit comments

Comments
 (0)