Skip to content

Commit 50ec001

Browse files
authored
ci: automatically assign new reviewers (#620)
* ci: add new workflow * feat: add assign reviewers script * feat: add first version of codeowners * fix: align with linting * fix: apply feedback
1 parent 67f9940 commit 50ec001

3 files changed

Lines changed: 183 additions & 0 deletions

File tree

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# Copyright 2025 - Pruna AI GmbH. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import json
16+
import os
17+
import re
18+
from collections import Counter
19+
from pathlib import Path
20+
21+
import github
22+
from github import Github
23+
24+
25+
def pattern_to_regex(pattern):
26+
"""Turn a CODEOWNERS glob ``pattern`` into a regex for matching file paths."""
27+
if pattern.startswith("/"):
28+
start_anchor = True
29+
pattern = re.escape(pattern[1:])
30+
else:
31+
start_anchor = False
32+
pattern = re.escape(pattern)
33+
pattern = pattern.replace(r"\*", "[^/]*")
34+
if start_anchor:
35+
pattern = r"^\/?" + pattern # Allow an optional leading slash after the start of the string
36+
return pattern
37+
38+
39+
def get_file_owners(file_path, codeowners_lines):
40+
"""Return owner logins for ``file_path`` using CODEOWNERS rules (last match wins)."""
41+
for line in reversed(codeowners_lines):
42+
line = line.split('#')[0].strip()
43+
if not line:
44+
continue
45+
46+
parts = line.split()
47+
pattern = parts[0]
48+
# Can be empty, e.g. for dummy files with explicitly no owner
49+
owners = [owner.removeprefix("@") for owner in parts[1:]]
50+
51+
file_regex = pattern_to_regex(pattern)
52+
if re.search(file_regex, file_path) is not None:
53+
return owners # It can be empty
54+
return []
55+
56+
57+
def get_dispatch_owners(codeowners_lines):
58+
"""Return fallback owners from the catch-all ``*`` CODEOWNERS rule."""
59+
for line in codeowners_lines:
60+
line = line.split("#", 1)[0].strip()
61+
if not line:
62+
continue
63+
64+
parts = line.split()
65+
if parts[0] == "*":
66+
return [owner.removeprefix("@") for owner in parts[1:]]
67+
68+
return []
69+
70+
71+
def main():
72+
"""Load the PR event, skip if reviews exist or reviewers are already requested, then request recent owners."""
73+
script_dir = Path(__file__).parent.absolute()
74+
with open(script_dir / "codeowners_assignment") as f:
75+
codeowners_lines = f.readlines()
76+
77+
g = Github(os.environ['GITHUB_TOKEN'])
78+
repo = g.get_repo("PrunaAI/pruna")
79+
with open(os.environ['GITHUB_EVENT_PATH']) as f:
80+
event = json.load(f)
81+
82+
pr_number = event['pull_request']['number']
83+
pr = repo.get_pull(pr_number)
84+
pr_author = pr.user.login
85+
86+
# Skipping exceptions
87+
existing_reviews = list(pr.get_reviews())
88+
if existing_reviews:
89+
return
90+
91+
users_requested, teams_requested = pr.get_review_requests()
92+
users_requested = list(users_requested)
93+
if users_requested:
94+
return
95+
96+
# Counting recent owner matches
97+
latest_owner_matches = Counter()
98+
for file in pr.get_files():
99+
owners = set(get_file_owners(file.filename, codeowners_lines))
100+
owners.discard(pr_author)
101+
if not owners:
102+
continue
103+
104+
commits = repo.get_commits(path=file.filename)
105+
for commit in commits:
106+
if commit.author is None:
107+
continue
108+
109+
login = commit.author.login
110+
if login in owners:
111+
latest_owner_matches[login] += file.changes
112+
break
113+
114+
top_owners = [owner for owner, _ in latest_owner_matches.most_common(2)]
115+
116+
if not top_owners:
117+
top_owners = [
118+
owner for owner in get_dispatch_owners(codeowners_lines)
119+
if owner != pr_author
120+
]
121+
try:
122+
pr.create_review_request(top_owners)
123+
except github.GithubException as e:
124+
raise e
125+
126+
127+
if __name__ == "__main__":
128+
main()
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# For more information on how to use this file, see:
2+
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners#about-code-owners
3+
4+
# If no one is pinged, in charge of dispatching reviewers
5+
* @sdiazlor @minettekaum
6+
7+
# CI and GitHub config
8+
/.github/workflows/ @SaboniAmine @johannaSommer @gsprochette
9+
/.github/scripts/ @sdiazlor
10+
/.github/ISSUE_TEMPLATE/ @sdiazlor @minettekaum
11+
/.github/PULL_REQUEST_TEMPLATE/ @minettekaum @sdiazlor
12+
13+
# Dependencies and packaging
14+
/pyproject.toml @gsprochette @begumcig
15+
/.pre-commit-config.yaml @gsprochette @begumcig
16+
17+
# Docs
18+
/docs/ @sdiazlor @minettekaum
19+
20+
# Source
21+
/src/pruna/algorithms/ @johannaSommer @begumcig @gsprochette @simlang @llcnt
22+
/src/pruna/config/ @gsprochette @johannaSommer
23+
/src/pruna/data/ @begumcig
24+
/src/pruna/engine/ @gsprochette @johannaSommer
25+
/src/pruna/evaluation/ @begumcig @davidberenstein1957 @johannaSommer
26+
/src/pruna/logging/ @johannaSommer
27+
28+
# Tests
29+
/tests/ @johannaSommer @begumcig @gsprochette @simlang @llcnt
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: Assign PR Reviewers
2+
on:
3+
pull_request_target:
4+
branches:
5+
- main
6+
types: [ready_for_review]
7+
8+
jobs:
9+
assign_reviewers:
10+
permissions:
11+
pull-requests: write
12+
runs-on: ubuntu-22.04
13+
steps:
14+
- uses: actions/checkout@v4
15+
- name: Set up Python
16+
uses: actions/setup-python@v6
17+
with:
18+
python-version: "3.11"
19+
- name: Install dependencies
20+
run: |
21+
python -m pip install --upgrade pip
22+
pip install PyGithub
23+
- name: Run assignment script
24+
env:
25+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
26+
run: python .github/scripts/assign_reviewers.py

0 commit comments

Comments
 (0)