Skip to content

Commit 09890a4

Browse files
authored
Merge pull request #1491 from gursewak1997/feature/auto-reviewer-rotation
Add automated PR reviewer rotation system
2 parents 7ca0caf + efe6031 commit 09890a4

File tree

3 files changed

+355
-0
lines changed

3 files changed

+355
-0
lines changed

.github/auto-review-config.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Auto-reviewer configuration
2+
# Start date for the rotation cycle (YYYY-MM-DD format)
3+
start_date: "2025-08-04"
4+
5+
# Rotation cycle in weeks
6+
rotation_cycle_weeks: 3

.github/scripts/assign_reviewer.py

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Auto-reviewer assignment script for GitHub PRs.
4+
Rotates through a list of reviewers based on a weekly cycle.
5+
Automatically reads reviewers from MAINTAINERS.md
6+
"""
7+
8+
import os
9+
import sys
10+
import yaml
11+
import subprocess
12+
import re
13+
from datetime import datetime, timezone, timedelta
14+
from typing import List, Optional, TypedDict
15+
16+
17+
class RotationInfo(TypedDict):
18+
start_date: Optional[datetime]
19+
rotation_cycle_weeks: int
20+
weeks_since_start: float
21+
before_start: bool
22+
error: Optional[str]
23+
24+
25+
class SprintInfo(TypedDict):
26+
sprint_number: int
27+
week_in_sprint: int
28+
total_weeks: int
29+
before_start: Optional[bool]
30+
31+
32+
def load_config(config_path: str) -> dict:
33+
"""Load the reviewer configuration from YAML file."""
34+
try:
35+
with open(config_path, 'r') as f:
36+
return yaml.safe_load(f)
37+
except FileNotFoundError:
38+
print(f"Error: Configuration file {config_path} not found", file=sys.stderr)
39+
sys.exit(1)
40+
except yaml.YAMLError as e:
41+
print(f"Error parsing YAML configuration: {e}", file=sys.stderr)
42+
sys.exit(1)
43+
44+
45+
def extract_reviewers_from_maintainers() -> List[str]:
46+
"""Extract GitHub usernames from MAINTAINERS.md file by parsing the table structure."""
47+
maintainers_path = os.environ.get("MAINTAINERS_PATH", 'MAINTAINERS.md')
48+
reviewers = []
49+
50+
try:
51+
with open(maintainers_path, 'r') as f:
52+
content = f.read()
53+
54+
lines = content.splitlines()
55+
github_id_column_index = None
56+
in_table = False
57+
58+
for line in lines:
59+
line = line.strip()
60+
61+
# Skip empty lines
62+
if not line:
63+
continue
64+
65+
# Look for table header to find GitHub ID column
66+
if line.startswith('|') and 'GitHub ID' in line:
67+
in_table = True
68+
columns = [col.strip() for col in line.split('|')[1:-1]] # Remove empty first/last elements
69+
try:
70+
github_id_column_index = columns.index('GitHub ID')
71+
except ValueError:
72+
print("Error: Could not find 'GitHub ID' column in MAINTAINERS.md table")
73+
sys.exit(1)
74+
continue
75+
76+
# Skip separator line (|---|---|...)
77+
if in_table and line.startswith('|') and '---' in line:
78+
continue
79+
80+
# Process table data rows
81+
if line.startswith('|') and github_id_column_index is not None:
82+
columns = [col.strip() for col in line.split('|')[1:-1]]
83+
if len(columns) > github_id_column_index:
84+
github_id_cell = columns[github_id_column_index]
85+
match = re.search(r'\[([a-zA-Z0-9-]+)\]\(https://github\.com/[^)]+\)', github_id_cell)
86+
if match:
87+
username = match.group(1)
88+
if username and username not in reviewers:
89+
reviewers.append(username)
90+
91+
# Stop parsing when we hit the end of the table
92+
if in_table and not line.startswith('|'):
93+
break
94+
95+
if reviewers:
96+
print(f"Found {len(reviewers)} reviewers from MAINTAINERS.md: {', '.join(reviewers)}")
97+
else:
98+
print("Warning: No GitHub usernames found in MAINTAINERS.md")
99+
100+
except FileNotFoundError:
101+
print(f"Error: MAINTAINERS.md file not found")
102+
sys.exit(1)
103+
except IOError as e:
104+
print(f"Error reading MAINTAINERS.md: {e}")
105+
sys.exit(1)
106+
107+
return reviewers
108+
109+
110+
def calculate_rotation_info(config: dict) -> RotationInfo:
111+
"""Calculate rotation information from config (helper function)."""
112+
start_date_str = config.get('start_date')
113+
rotation_cycle_weeks = config.get('rotation_cycle_weeks', 3)
114+
115+
if not start_date_str:
116+
return {
117+
"start_date": None,
118+
"rotation_cycle_weeks": rotation_cycle_weeks,
119+
"weeks_since_start": 0,
120+
"before_start": False,
121+
"error": "No start_date configured"
122+
}
123+
124+
try:
125+
start_date = datetime.fromisoformat(start_date_str).replace(tzinfo=timezone.utc)
126+
weeks_since_start = (datetime.now(timezone.utc) - start_date) / timedelta(weeks=1)
127+
128+
return {
129+
"start_date": start_date,
130+
"rotation_cycle_weeks": rotation_cycle_weeks,
131+
"weeks_since_start": weeks_since_start,
132+
"before_start": weeks_since_start < 0,
133+
"error": None
134+
}
135+
except ValueError:
136+
return {
137+
"start_date": None,
138+
"rotation_cycle_weeks": rotation_cycle_weeks,
139+
"weeks_since_start": 0,
140+
"before_start": False,
141+
"error": f"Invalid start_date format: {start_date_str}"
142+
}
143+
144+
145+
def get_current_sprint_info(rotation_info: RotationInfo) -> SprintInfo:
146+
"""Calculate current sprint information."""
147+
148+
if rotation_info["before_start"]:
149+
return {
150+
"sprint_number": 1,
151+
"week_in_sprint": 1,
152+
"total_weeks": 0
153+
}
154+
155+
weeks_since_start = rotation_info["weeks_since_start"]
156+
rotation_cycle_weeks = rotation_info["rotation_cycle_weeks"]
157+
158+
sprint_number = int(weeks_since_start // rotation_cycle_weeks) + 1
159+
week_in_sprint = int(weeks_since_start % rotation_cycle_weeks) + 1
160+
161+
return {
162+
"sprint_number": sprint_number,
163+
"week_in_sprint": week_in_sprint,
164+
"total_weeks": int(weeks_since_start)
165+
}
166+
167+
168+
def get_pr_author(pr_number: str) -> str:
169+
"""Get the author of the PR."""
170+
repo = os.environ.get('GITHUB_REPOSITORY', 'bootc-dev/bootc')
171+
result = run_gh_command(
172+
['api', f'repos/{repo}/pulls/{pr_number}', '--jq', '.user.login'],
173+
f"Could not fetch PR author for PR {pr_number}"
174+
)
175+
return result.stdout.strip()
176+
177+
178+
def calculate_current_reviewer(reviewers: List[str], rotation_info: RotationInfo, exclude_user: Optional[str] = None) -> Optional[str]:
179+
"""Calculate the current reviewer based on the rotation schedule, excluding specified user."""
180+
if not reviewers:
181+
print("Error: No reviewers found")
182+
return None
183+
184+
if rotation_info["before_start"]:
185+
print(f"Warning: Current date is before start date. Using first reviewer.")
186+
# Find first reviewer that's not the excluded user
187+
for reviewer in reviewers:
188+
if reviewer != exclude_user:
189+
return reviewer
190+
return reviewers[0] if reviewers else None
191+
192+
# Calculate total weeks since start and map to reviewer
193+
# Each reviewer gets rotation_cycle_weeks weeks, then we cycle to the next
194+
total_weeks = int(rotation_info["weeks_since_start"])
195+
rotation_cycle_weeks = rotation_info["rotation_cycle_weeks"]
196+
reviewer_index = (total_weeks // rotation_cycle_weeks) % len(reviewers)
197+
198+
# If the calculated reviewer is the excluded user, find the next one
199+
if exclude_user and reviewers[reviewer_index] == exclude_user:
200+
# Try next reviewer in the list
201+
next_reviewer_index = (reviewer_index + 1) % len(reviewers)
202+
attempts = 0
203+
while reviewers[next_reviewer_index] == exclude_user and attempts < len(reviewers):
204+
next_reviewer_index = (next_reviewer_index + 1) % len(reviewers)
205+
attempts += 1
206+
207+
# If all reviewers are excluded, return None
208+
if reviewers[next_reviewer_index] == exclude_user:
209+
print(f"Warning: All reviewers are excluded ({exclude_user}), skipping assignment")
210+
return None
211+
212+
return reviewers[next_reviewer_index]
213+
214+
return reviewers[reviewer_index]
215+
216+
217+
def run_gh_command(args: List[str], error_message: str) -> subprocess.CompletedProcess:
218+
"""Run a GitHub CLI command with consistent error handling."""
219+
try:
220+
return subprocess.run(
221+
['gh'] + args,
222+
capture_output=True, text=True, check=True
223+
)
224+
except FileNotFoundError:
225+
print("Error: 'gh' command not found. Is the GitHub CLI installed and in the PATH?")
226+
sys.exit(1)
227+
except subprocess.CalledProcessError as e:
228+
print(f"{error_message}: {e.stderr}", file=sys.stderr)
229+
sys.exit(1)
230+
231+
232+
def get_existing_reviewers(pr_number: str) -> List[str]:
233+
"""Get list of reviewers already assigned to the PR."""
234+
repo = os.environ.get('GITHUB_REPOSITORY', 'bootc-dev/bootc')
235+
result = run_gh_command(
236+
['api', f'repos/{repo}/pulls/{pr_number}', '--jq', '.requested_reviewers[].login'],
237+
f"Could not fetch existing reviewers for PR {pr_number}"
238+
)
239+
return result.stdout.strip().split('\n') if result.stdout.strip() else []
240+
241+
242+
def assign_reviewer(pr_number: str, reviewer: str) -> None:
243+
"""Assign a reviewer to the PR using GitHub API directly."""
244+
print(f"Attempting to assign reviewer {reviewer} to PR {pr_number}")
245+
246+
# Use GitHub API directly to avoid organization team issues
247+
# Get the repository from environment or default to bootc-dev/bootc
248+
repo = os.environ.get('GITHUB_REPOSITORY', 'bootc-dev/bootc')
249+
250+
run_gh_command(
251+
['api', f'repos/{repo}/pulls/{pr_number}/requested_reviewers',
252+
'-f', f'reviewers=["{reviewer}"]',
253+
'-X', 'POST'],
254+
f"Error assigning reviewer {reviewer} to PR {pr_number}"
255+
)
256+
print(f"Successfully assigned reviewer {reviewer} to PR {pr_number}")
257+
258+
259+
def main():
260+
"""Main function to handle reviewer assignment."""
261+
# Get PR number from environment variable
262+
pr_number = os.environ.get('PR_NUMBER')
263+
if not pr_number:
264+
print("Error: PR_NUMBER environment variable not set")
265+
sys.exit(1)
266+
267+
# Load configuration (for start_date and rotation_cycle_weeks)
268+
config_path = os.environ.get('AUTO_REVIEW_CONFIG_PATH', '.github/auto-review-config.yml')
269+
config = load_config(config_path)
270+
271+
# Extract reviewers from MAINTAINERS.md
272+
reviewers = extract_reviewers_from_maintainers()
273+
if not reviewers:
274+
print("Error: No reviewers found in MAINTAINERS.md")
275+
sys.exit(1)
276+
277+
# Calculate rotation information once
278+
rotation_info = calculate_rotation_info(config)
279+
if rotation_info['error']:
280+
print(f"Error in configuration: {rotation_info['error']}", file=sys.stderr)
281+
sys.exit(1)
282+
283+
# Get sprint information
284+
sprint_info = get_current_sprint_info(rotation_info)
285+
print(f"Current sprint: {sprint_info['sprint_number']}, week: {sprint_info['week_in_sprint']}")
286+
287+
# Get PR author to exclude them from being assigned
288+
pr_author = get_pr_author(pr_number)
289+
print(f"PR author: {pr_author}")
290+
291+
# Calculate current reviewer, excluding the PR author
292+
current_reviewer = calculate_current_reviewer(reviewers, rotation_info, exclude_user=pr_author)
293+
if not current_reviewer:
294+
print("Error: Could not calculate current reviewer")
295+
sys.exit(1)
296+
297+
print(f"Assigned reviewer for this week: {current_reviewer}")
298+
299+
# Get existing reviewers
300+
existing_reviewers = get_existing_reviewers(pr_number)
301+
302+
# Check if current reviewer is already assigned
303+
if current_reviewer in existing_reviewers:
304+
print(f"Reviewer {current_reviewer} is already assigned to PR {pr_number}")
305+
return
306+
307+
# Assign the current reviewer
308+
assign_reviewer(pr_number, current_reviewer)
309+
310+
if __name__ == "__main__":
311+
main()

.github/workflows/auto-review.yml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: Auto Assign Reviewer
2+
3+
on:
4+
pull_request_target:
5+
types: [opened, ready_for_review]
6+
7+
permissions:
8+
pull-requests: write
9+
contents: read
10+
11+
jobs:
12+
assign-reviewer:
13+
runs-on: ubuntu-latest
14+
15+
steps:
16+
- name: Checkout code
17+
uses: actions/checkout@v4
18+
19+
- name: Setup Python
20+
uses: actions/setup-python@v4
21+
with:
22+
python-version: '3.11'
23+
24+
- name: Install dependencies
25+
run: |
26+
python -m pip install --upgrade pip
27+
pip install pyyaml
28+
29+
- name: Authenticate with GitHub
30+
run: echo "${{ secrets.GH_PAT }}" | gh auth login --with-token
31+
env:
32+
GH_TOKEN: ${{ secrets.GH_PAT }}
33+
34+
- name: Assign reviewer
35+
env:
36+
PR_NUMBER: ${{ github.event.pull_request.number }}
37+
run: |
38+
python .github/scripts/assign_reviewer.py

0 commit comments

Comments
 (0)