Skip to content

Commit f2d7c33

Browse files
author
Sevastyan Zhukov
authored
Merge pull request #4325 from mapbox/NAVSDK-828
Auto-assigning reviewers
2 parents 8d1fad4 + c1b5216 commit f2d7c33

File tree

7 files changed

+521
-0
lines changed

7 files changed

+521
-0
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: Set reviewers
2+
on:
3+
pull_request:
4+
types: [ opened, reopened, ready_for_review ]
5+
jobs:
6+
process:
7+
permissions:
8+
pull-requests: write
9+
contents: read
10+
runs-on: ubuntu-20.04
11+
env:
12+
PR_NUMBER: ${{ github.event.pull_request.number }}
13+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
14+
steps:
15+
- uses: actions/checkout@v3
16+
with:
17+
ref: ${{github.head_ref}}
18+
19+
- name: setup python
20+
uses: actions/setup-python@v4
21+
with:
22+
python-version: '3.7.7'
23+
24+
- name: install python packages
25+
run: |
26+
python3 -m pip install requests PyBambooHR retry
27+
28+
- name: execute py script
29+
run: |
30+
python3 scripts/reviewers/set_reviewers.py

scripts/reviewers/README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Auto-assigning reviewers
2+
3+
The auto-assigning reviewers script assign reviewers to your PR when you:
4+
5+
- open PR
6+
- reopen PR
7+
- make PR ready for review
8+
9+
The script will assign 2 reviewers.
10+
If you assign 1 reviewer by yourself, the script will assign 1 more reviewer.
11+
If you assign 2 reviewers by yourself, the script will assign nobody.
12+
13+
## Implementation
14+
15+
The script is powered by Python and located in `scripts/set_reviewers.py`.
16+
17+
The script runs by GitHub actions. The GitHub action config is located in `.github/workflows/set_reviewers.yml`.
18+
19+
### Algorithm
20+
21+
1. Get pull request info
22+
2. Check `draft` field. Exit if it is true
23+
3. Get reviews and reviewers info for this pull request
24+
4. Exit if this pull request has 2 assigned reviewers or 2 finished reviews
25+
5. Get potential reviewers from the teams config `scripts/teams.json`
26+
6. Get information about current reviews for every potential reviewers from opened pull requests
27+
7. Get information about finished reviews for every potential reviewers from closed pull requests for the last working
28+
week
29+
8. Sort potential reviewers list by current and finished reviews
30+
9. Get information about changed files in the pull request
31+
10. Define affected modules by changed files
32+
11. Define owners of affected modules by the owners config `scripts/owners.json`
33+
12. Choose the first reviewer from the sorted potential reviewers list which is owner of changed modules
34+
13. Choose the second reviewer from the sorted potential reviewers list from any team if no assigned reviewers and no
35+
finished reviews

scripts/reviewers/owners.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
[
2+
{
3+
"teams": [
4+
"core"
5+
],
6+
"modules": [
7+
"Sources/MapboxCoreNavigation"
8+
]
9+
},
10+
{
11+
"teams": [
12+
"ui"
13+
],
14+
"modules": [
15+
"Sources/MapboxNavigation"
16+
]
17+
},
18+
{
19+
"teams": [
20+
"devops"
21+
],
22+
"modules": [
23+
".circleci",
24+
".github",
25+
".tx",
26+
"scripts"
27+
]
28+
}
29+
]

scripts/reviewers/set_reviewers.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import datetime
2+
import json
3+
import os
4+
from pprint import pprint
5+
6+
import requests
7+
8+
from set_reviewers_defs import get_owners_of_changes, get_changed_modules, get_reviewers, sort_users, get_current_reviews, \
9+
get_fresh_pulls, get_done_reviews, parse_users
10+
11+
pr_number = os.environ['PR_NUMBER']
12+
token = os.environ['GITHUB_TOKEN']
13+
14+
prs_url = "https://api.github.com/repos/mapbox/mapbox-navigation-ios/pulls"
15+
pr_url = prs_url + "/" + pr_number
16+
17+
headers = {"Authorization": "Bearer " + token}
18+
pr = requests.get(pr_url, headers=headers).json()
19+
20+
if pr['draft']:
21+
print("It is draft pr")
22+
exit()
23+
24+
author = pr['user']['login']
25+
current_reviewers = list(map(lambda reviewer: reviewer['login'], pr['requested_reviewers']))
26+
27+
# check existing approvals on pr
28+
29+
reviews_url = pr_url + "/reviews"
30+
reviews = requests.get(reviews_url, headers=headers).json()
31+
for review in reviews:
32+
if review['state'] == 'APPROVED':
33+
current_reviewers.append(review['user']['login'])
34+
35+
if len(current_reviewers) >= 2:
36+
print("2 or more reviewers already assigned")
37+
exit()
38+
39+
# parse users from config
40+
41+
with open('scripts/reviewers/teams.json') as json_file:
42+
teams = json.load(json_file)
43+
users = parse_users(teams, author)
44+
45+
# get users reviews
46+
47+
pulls = requests.get(prs_url, headers=headers).json()
48+
49+
users = get_current_reviews(users, pulls)
50+
51+
# get users done reviews
52+
53+
closed_pulls_url = prs_url + "?state=closed&per_page=100"
54+
closed_pulls = requests.get(closed_pulls_url, headers=headers).json()
55+
56+
today = datetime.date.today()
57+
fresh_pulls = get_fresh_pulls(list(closed_pulls + pulls), today)
58+
59+
users = get_done_reviews(prs_url, headers, users, fresh_pulls)
60+
61+
# sort by reviews
62+
63+
users = sort_users(users)
64+
65+
print("Available reviewers")
66+
pprint(users)
67+
68+
# get changes
69+
70+
pr_files_url = pr_url + '/files'
71+
pr_files = requests.get(pr_files_url, headers=headers).json()
72+
changed_modules = get_changed_modules(pr_files)
73+
74+
# find owners
75+
76+
with open('scripts/reviewers/owners.json') as json_file:
77+
owners = json.load(json_file)
78+
found_owners = get_owners_of_changes(owners, changed_modules)
79+
80+
print("Owners of changes")
81+
print(found_owners)
82+
83+
# find reviewers
84+
85+
found_reviewers = get_reviewers(users, found_owners, current_reviewers)
86+
87+
print("Reviewers to assign")
88+
print(found_reviewers)
89+
90+
# assign reviewers
91+
92+
pr_url = prs_url + '/%s/requested_reviewers'
93+
requests.post(pr_url % pr_number, json={'reviewers': found_reviewers}, headers=headers)
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import datetime
2+
3+
import requests
4+
5+
6+
def parse_users(teams, author):
7+
users = []
8+
for team in teams:
9+
team_name = team['name']
10+
for user in team['users']:
11+
if user == author:
12+
continue
13+
users.append({
14+
'login': user,
15+
'team': team_name,
16+
'reviews': 0,
17+
'done_reviews': 0
18+
})
19+
return users
20+
21+
22+
def get_current_reviews(users, pulls):
23+
for pull in pulls:
24+
reviewers = pull['requested_reviewers']
25+
for reviewer in reviewers:
26+
for user in users:
27+
if user['login'] == reviewer['login']:
28+
user['reviews'] += 1
29+
return users
30+
31+
32+
def get_fresh_pulls(all_pulls, today):
33+
fresh_pulls = []
34+
for pull in all_pulls:
35+
if pull['closed_at'] is None:
36+
created_date = datetime.date.fromisoformat(pull['created_at'].partition('T')[0])
37+
if created_date + datetime.timedelta(days=7) < today:
38+
continue
39+
else:
40+
closed_date = datetime.date.fromisoformat(pull['closed_at'].partition('T')[0])
41+
if closed_date + datetime.timedelta(days=7) < today:
42+
continue
43+
fresh_pulls.append(pull)
44+
return fresh_pulls
45+
46+
47+
def get_done_reviews(prs_url, headers, users, fresh_pulls):
48+
for pull in fresh_pulls:
49+
pull_number = pull['number']
50+
reviews_url = prs_url + "/" + str(pull_number) + "/reviews"
51+
reviews = requests.get(reviews_url, headers=headers).json()
52+
for review in reviews:
53+
if review['state'] == 'APPROVED':
54+
for user in users:
55+
if user['login'] == review['user']['login']:
56+
user['done_reviews'] += 1
57+
return users
58+
59+
60+
def sort_users(users):
61+
return sorted(users, key=lambda x: (x['reviews'], x['done_reviews']))
62+
63+
64+
def get_changed_modules(pr_files):
65+
return set(map(lambda file: get_module(file['filename']), pr_files))
66+
67+
68+
def get_module(filename):
69+
if filename.startswith('Sources/MapboxCoreNavigation'):
70+
return 'Sources/MapboxCoreNavigation'
71+
elif filename.startswith('Sources/MapboxNavigation'):
72+
return 'Sources/MapboxNavigation'
73+
else:
74+
return filename.split('/')[0]
75+
76+
77+
def get_owners_of_changes(owners, changed_modules):
78+
found_owners = set()
79+
for changed_module in changed_modules:
80+
for owner in owners:
81+
if changed_module in owner['modules']:
82+
for team in owner['teams']:
83+
found_owners.add(team)
84+
return found_owners
85+
86+
87+
def get_reviewers(users, found_owners, current_reviewers):
88+
found_reviewers = []
89+
90+
for user in users:
91+
if user['team'] in found_owners:
92+
found_reviewers.append(user['login'])
93+
break
94+
95+
if len(current_reviewers) + len(found_reviewers) < 2:
96+
for user in users:
97+
if user['login'] not in found_reviewers or user['team'] == "any":
98+
found_reviewers.append(user['login'])
99+
if len(current_reviewers) + len(found_reviewers) == 2:
100+
break
101+
102+
return found_reviewers

0 commit comments

Comments
 (0)