11name : PR self-approval
22
33on :
4+ # We need pull_request_target so we can use ${{ secrets.* }}.
45 pull_request_target :
56 types :
67 - auto_merge_enabled
@@ -9,91 +10,102 @@ jobs:
910 approve :
1011 runs-on : ubuntu-latest
1112 # Only run if the PR author enabled auto-merge, not someone else.
12- if : github.event.sender.login == github.event.pull_request.user.login
13+ # Also run if a new approval was created, as this affects whether we can
14+ # auto-approve. There is a risk of infinite loops here, though -- when we
15+ # submit our review, that'll trigger this workflow again, so only run if
16+ # someone other than us (i.e. alibuild) reviewed.
17+ if : >-
18+ (github.event.action == 'auto_merge_enabled' &&
19+ github.event.sender.login == github.event.pull_request.user.login)
1320
1421 steps :
15- # Check out in order to get CODEOWNERS.
16- - uses : actions/checkout@v2
17- with :
18- # Always use the latest CODEOWNERS file, *not* the one from the PR!
19- ref : ${{ github.event.repository.default_branch }}
20- persist-credentials : false
21-
2222 - name : Install dependencies
23- run : pip install PyGithub
24-
25- # First, parse filename patterns in CODEOWNERS using git-check-ignore(1)
26- # to get the applicable code owners for this PR.
27- - name : Parse CODEOWNERS
28- id : owners
29- shell : bash -exo pipefail {0}
30- run : |
31- git config --global init.defaultBranch master # avoid annoying warning
32- git init ../tmp # temporary repo for .gitignore parsing
33- awk '!/^[[:space:]]*(#|$)/ {print $1}' CODEOWNERS > ../tmp/.gitignore
34- # Find changed files in PR and which CODEOWNERS entries match them.
35- curl -fsSL "${{ github.event.pull_request.diff_url }}" |
36- sed -rn 's,^diff --git a/(.*) b/(.*)$,\1\n\2,p' | uniq |
37- git -C ../tmp check-ignore -v --no-index --stdin |
38- cut -d: -f2 |
39- sed 's/$/p/' > extract-lines.sed
40- # Extract user names applicable to this PR. Same format as CODEOWNERS,
41- # but without the leading pattern, so we just have usernames separated
42- # by spaces and newlines.
43- echo "::set-output name=owners::$(
44- grep -vE '^[[:space:]]*(#|$)' CODEOWNERS |
45- sed -nf extract-lines.sed |
46- sed 's/^[^[:space:]]*[[:space:]]*//' |
47- tr '\n' ';'
48- )"
23+ run : pip install codeowners PyGithub
4924
50- # Finally, approve , if the author is only editing files owned by themselves.
51- - name : Check author is allowed to self-approve
25+ # Approve the PR , if the author is only editing files owned by themselves.
26+ - name : Auto-approve PR if permitted
5227 shell : python
5328 env :
54- submitter : ${{ github.event.sender .login }}
29+ submitter : ${{ github.event.pull_request.user .login }}
5530 pr : ${{ github.event.pull_request.number }}
5631 repo : ${{ github.event.repository.full_name }}
57- owners : ${{ steps.owners.outputs.owners }}
5832 github_token : ${{ secrets.ALIBUILD_GITHUB_TOKEN }}
5933 run : |
60- import functools, github, os
34+ import os
35+ import sys
36+ from functools import lru_cache
37+ from codeowners import CodeOwners
38+ from github import Github, UnknownObjectException
39+
40+ # Cache results of this function so we don't have to look up team
41+ # membership multiple times.
42+ @lru_cache(maxsize=None)
43+ def matches_owner(owner):
44+ '''Return whether the PR's submitter matches the given owner.
6145
62- gh = github.Github(os.environ['github_token'])
63- submitter = os.environ['submitter']
46+ owner is a (type, name) tuple, as returned by the CodeOwners.of
47+ function. EMAIL owners cannot be handled currently, and will raise
48+ a ValueError.
49+ '''
50+ owner_type, owner_name = owner
51+ if owner_type == 'USERNAME':
52+ return owner_name.lstrip('@') == os.environ['submitter']
53+ elif owner_type == 'TEAM':
54+ org, _, team_name = owner_name.lstrip('@').partition('/')
55+ try:
56+ gh.get_organization(org) \
57+ .get_team_by_slug(team_name) \
58+ .get_team_membership(os.environ['submitter'])
59+ except UnknownObjectException:
60+ return False # submitter is not a member of this team
61+ return True
62+ elif owner_type == 'EMAIL':
63+ # We can't resolve email addresses to GitHub usernames using
64+ # GitHub's API.
65+ raise ValueError('cannot handle email addresses in CODEOWNERS')
66+ else:
67+ raise ValueError(f'unknown owner type {owner_type}')
6468
65- @functools.lru_cache(maxsize=None)
66- def matches_owner(user_or_team):
67- user_or_team = user_or_team.lstrip('@')
68- org, is_team, team_name = user_or_team.partition('/')
69- if not is_team:
70- return user_or_team == submitter
71- try:
72- gh.get_organization(org) \
73- .get_team_by_slug(team_name) \
74- .get_team_membership(submitter)
75- except github.UnknownObjectException:
76- return False
77- return True
69+ gh = Github(os.environ['github_token'])
70+ repo = gh.get_repo(os.environ['repo'])
71+ pr = repo.get_pull(int(os.environ['pr']))
72+ owners = CodeOwners(repo.get_contents('CODEOWNERS')
73+ .decoded_content.decode('utf-8'))
74+ approvals_from = {review.user.login for review in pr.get_reviews()
75+ if review.state == 'APPROVED'}
7876
79- def check_lines():
80- auto_approve = True
81- # owners is a string containing semicolon-separated records of
82- # space-separated usernames. At least one username per record must
83- # match the submitter (taking teams into account), and all lines
84- # must have a matching username.
85- for line in os.environ['owners'].strip(';').split(';'):
86- line_owners = line.split()
87- assert all(o.startswith('@') for o in line_owners), \
88- 'failed to parse CODEOWNERS'
89- if not any(map(matches_owner, line_owners)):
90- print('::warning::Not auto-approving as you are not one of',
91- ', '.join(line_owners))
92- auto_approve = False
93- return auto_approve
77+ # At least one username per CODEOWNERS line must match the submitter
78+ # (taking teams into account), and all lines must have a matching
79+ # username. If the PR author is not the codeowner for a file, then if
80+ # a codeowner of that file approved this PR, we'll still auto-approve.
81+ auto_approve = True
82+ for filename in (f.filename for f in pr.get_files()):
83+ file_owners, line = owners.matching_line(filename)
84+ file_owners_names = {name.lstrip('@') for _, name in file_owners}
85+ if approvals_from & file_owners_names:
86+ print(f'{filename}: OK: you have approval from the code'
87+ ' owners of this file, specifically:',
88+ ', '.join(approvals_from & file_owners_names),
89+ f' (CODEOWNERS line {line})', file=sys.stderr)
90+ elif any(map(matches_owner, file_owners)):
91+ print(f'{filename}: OK: you are a code owner of this file'
92+ f' (CODEOWNERS line {line})', file=sys.stderr)
93+ else:
94+ print('::warning::Not auto-approving as none of',
95+ ', '.join(file_owners_names),
96+ f'have approved this PR as owners of {filename}'
97+ f' (CODEOWNERS line {line})', file=sys.stderr)
98+ # Don't break out of the loop, so that we print every
99+ # non-matching CODEOWNERS rule for information.
100+ auto_approve = False
94101
95- if check_lines():
96- gh.get_repo(os.environ['repo']) \
97- .get_pull(int(os.environ['pr'])) \
98- .create_review(event='APPROVE',
99- body=f'Auto-approving on behalf of @{submitter}.')
102+ if auto_approve:
103+ print('Approving PR', file=sys.stderr)
104+ pr.create_review(event='APPROVE', body=(
105+ f'Auto-approving on behalf of @{os.environ["submitter"]}.'
106+ ))
107+ else:
108+ print('::warning::Not approving PR. You can see whose approval'
109+ ' you need in the messages above. This check will run again'
110+ " when the PR's author disables and reenables auto-merge.",
111+ file=sys.stderr)
0 commit comments