Skip to content

Commit af6043c

Browse files
committed
Minimum approval config
1 parent 4b95950 commit af6043c

File tree

3 files changed

+53
-34
lines changed

3 files changed

+53
-34
lines changed

README.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -193,19 +193,24 @@ ssh-keygen -t ed25519 -C marge-bot@invalid -f marge-bot-ssh-key -P ''
193193
Add the public key (`marge-bot-ssh-key.pub`) to the user's `SSH Keys` in GitLab
194194
and keep the private one handy.
195195
196-
### Per project configuration
196+
### Merge Approvals on Gitlab CE
197197
198198
On GitLab enterprise the [merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/merge_request_approvals.html)
199199
provide the information how many approvals from whom are needed for
200-
a merge request. On GitlLab CE this is done via a configuration file
201-
called `.marge-bot.yml`. Currently Marge uses the config file from master
202-
as config for all merge request.
200+
a merge request.
203201
204-
The `.marge-bot.yml` config currently only supports `approver_count`:
205-
```yaml
202+
On GitlLab CE this is done via the merge request's award API (thumbup action) and the
203+
[CODEOWNERS](https://docs.gitlab.com/ee/user/project/code_owners.html) file.
204+
Appropriate approvers will be determined from that file and the changes in the MR and minimum approvers can be set
205+
by adding a commented line anywhere in that file that contains:
206206
207-
approver_count: 3 # number of "thumbs up" needed, defaults to 1
208207
```
208+
# MARGEBOT_MINIMUM_APPROVERS = 2
209+
```
210+
211+
Adjust for your needs. If no CODEOWNERS file exist or no matched owners the approval flow will be disabled. If no minimum
212+
approvers is set, all matched users from CODEOWNERS will be required to approve.
213+
209214
210215
### Running marge-bot in docker (what we do)
211216

marge/approvals.py

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging as log
22
import fnmatch
3+
import re
34
import shlex
45

56
from . import gitlab
@@ -26,24 +27,29 @@ def refetch_info(self):
2627
def get_approvers_ce(self):
2728
"""get approvers status using thumbs on merge request
2829
"""
29-
owner_globs = self.get_codeowners_ce()
30-
if not owner_globs:
30+
owner_file = self.get_codeowners_ce()
31+
if not owner_file['owners']:
3132
log.info("No CODEOWNERS file in master, continuing without approvers flow")
3233
return dict(self._info, approvals_left=0, approved_by=[], codeowners=[])
3334

34-
codeowners = self.determine_responsible_owners(owner_globs, self.get_changes_ce())
35+
code_owners = self.determine_responsible_owners(owner_file['owners'], self.get_changes_ce())
3536

36-
if not codeowners:
37+
if not code_owners:
3738
log.info("No matched code owners, continuing without approvers flow")
3839
return dict(self._info, approvals_left=0, approved_by=[], codeowners=[])
3940

4041
awards = self.get_awards_ce()
4142

42-
up_votes = [e for e in awards if e['name'] == 'thumbsup' and e['user']['username'] in codeowners]
43-
approver_count = len(codeowners)
44-
approvals_left = max(approver_count - len(up_votes), 0)
43+
up_votes = [e for e in awards if e['name'] == 'thumbsup' and e['user']['username'] in code_owners]
4544

46-
return dict(self._info, approvals_left=approvals_left, approved_by=up_votes, codeowners=codeowners)
45+
approvals_required = len(code_owners)
46+
47+
if owner_file['approvals_required'] > 0:
48+
approvals_required = owner_file['approvals_required']
49+
50+
approvals_left = max(approvals_required - len(up_votes), 0)
51+
52+
return dict(self._info, approvals_left=approvals_left, approved_by=up_votes, codeowners=code_owners)
4753

4854
def determine_responsible_owners(self, owners_glob, changes):
4955
owners = set([])
@@ -77,11 +83,18 @@ def get_awards_ce(self):
7783
def get_codeowners_ce(self):
7884
config_file = self._api.repo_file_get(self.project_id, "CODEOWNERS", "master")
7985
owner_globs = {}
86+
required = 0
87+
required_regex = re.compile('.*MARGEBOT_MINIMUM_APPROVERS *= *([0-9]+)')
8088

8189
if config_file is None:
8290
return owner_globs
8391

8492
for line in config_file['content'].splitlines():
93+
if 'MARGEBOT_' in line:
94+
match = required_regex.match(line)
95+
if match:
96+
required = int(match.group(1))
97+
8598
if line != "" and not line.startswith(' ') and not line.startswith('#'):
8699
elements = shlex.split(line)
87100
glob = elements.pop(0)
@@ -90,7 +103,7 @@ def get_codeowners_ce(self):
90103
for user in elements:
91104
owner_globs[glob].add(user.strip('@'))
92105

93-
return owner_globs
106+
return {"approvals_required": required, "owners": owner_globs}
94107

95108
@property
96109
def iid(self):

tests/test_approvals.py

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
CODEOWNERS = {
1313
"content": """
14+
# MARGEBOT_MINIMUM_APPROVERS = 2
1415
# This is an example code owners file, lines starting with a `#` will
1516
# be ignored.
1617
@@ -24,6 +25,7 @@
2425
# pylint: disable=anomalous-backslash-in-string
2526
CODEOWNERS_FULL = {
2627
"content": """
28+
# MARGEBOT_MINIMUM_APPROVERS=3
2729
# This is an example code owners file, lines starting with a `#` will
2830
# be ignored.
2931
@@ -49,10 +51,6 @@
4951
# owner for the LICENSE file
5052
LICENSE @legal this_does_not_match [email protected]
5153
52-
# Group names can be used to match groups and nested groups to specify
53-
# them as owners for a file
54-
README @group @group/with-nested/subgroup
55-
5654
# Ending a path in a `/` will specify the code owners for every file
5755
# nested in that directory, on any level
5856
/docs/ @all-docs
@@ -222,7 +220,7 @@ def test_fetch_from_merge_request_ce_compat(self):
222220
'id': 74,
223221
'iid': 6,
224222
'project_id': 1234,
225-
'approvals_left': 2,
223+
'approvals_left': 1,
226224
'approved_by': [AWARDS[1]],
227225
'codeowners': {'default-codeowner', 'ebert', 'test-user1'},
228226
}
@@ -280,17 +278,19 @@ def test_approvals_ce_get_codeowners_full(self):
280278
approvals = Approvals(api, {'id': 74, 'iid': 6, 'project_id': 1234})
281279

282280
assert approvals.get_codeowners_ce() == {
283-
'#file_with_pound.rb': {'owner-file-with-pound'},
284-
'*': {'default-codeowner'},
285-
'*.rb': {'ruby-owner'},
286-
'/config/': {'config-owner'},
287-
'/docs/': {'all-docs'},
288-
'/docs/*': {'root-docs'},
289-
'CODEOWNERS': {'owners', 'multiple', 'code'},
290-
'LICENSE': {'this_does_not_match', '[email protected]', 'legal'},
291-
'README': {'group', 'group/with-nested/subgroup'},
292-
'lib/': {'lib-owner'},
293-
'path with spaces/': {'space-owner'}
281+
'approvals_required': 3,
282+
'owners': {
283+
'#file_with_pound.rb': {'owner-file-with-pound'},
284+
'*': {'default-codeowner'},
285+
'*.rb': {'ruby-owner'},
286+
'/config/': {'config-owner'},
287+
'/docs/': {'all-docs'},
288+
'/docs/*': {'root-docs'},
289+
'CODEOWNERS': {'owners', 'multiple', 'code'},
290+
'LICENSE': {'this_does_not_match', '[email protected]', 'legal'},
291+
'lib/': {'lib-owner'},
292+
'path with spaces/': {'space-owner'}
293+
}
294294
}
295295

296296
def test_approvals_ce_get_codeowners_wildcard(self):
@@ -301,7 +301,8 @@ def test_approvals_ce_get_codeowners_wildcard(self):
301301
approvals = Approvals(api, {'id': 74, 'iid': 6, 'project_id': 1234})
302302

303303
assert approvals.get_codeowners_ce() == {
304-
'*': set(['default-codeowner', 'test-user1', 'ebert']), 'unmatched/*': {'test5'}
304+
'approvals_required': 2,
305+
'owners': {'*': {'default-codeowner', 'test-user1', 'ebert'}, 'unmatched/*': {'test5'}}
305306
}
306307

307308
@patch('marge.approvals.Approvals.get_awards_ce', Mock(return_value=AWARDS))
@@ -316,5 +317,5 @@ def test_approvals_ce(self):
316317

317318
result = approvals.get_approvers_ce()
318319

319-
assert result['approvals_left'] == 2
320+
assert result['approvals_left'] == 1
320321
assert len(result['approved_by']) == 1

0 commit comments

Comments
 (0)