Skip to content

Commit 4e031c0

Browse files
committed
Add automated PR reviewer rotation system
- Add GitHub Action workflow for automatic reviewer assignment - Create Python script that reads reviewers from MAINTAINERS.md - Implement 3-week sprint-based rotation cycle - Preserve existing reviewer assignments (manual/GitHub auto) - Add configuration for start date and rotation cycle
1 parent 06a643c commit 4e031c0

File tree

3 files changed

+287
-0
lines changed

3 files changed

+287
-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: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
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
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+
for line in content.splitlines():
55+
# Process only markdown table rows, skipping header separator
56+
if not line.startswith('|') or '---' in line:
57+
continue
58+
59+
columns = [col.strip() for col in line.split('|')]
60+
# GitHub ID is expected in the second data column (index 2)
61+
if len(columns) > 2:
62+
github_id_cell = columns[2]
63+
match = re.search(r'\[([^\]]+)\]\(https://github\.com/.*\)', github_id_cell)
64+
username = None
65+
if match:
66+
username = match.group(1)
67+
if username and username not in reviewers:
68+
reviewers.append(username)
69+
70+
if reviewers:
71+
print(f"Found {len(reviewers)} reviewers from MAINTAINERS.md: {', '.join(reviewers)}")
72+
else:
73+
print("Warning: No GitHub usernames found in MAINTAINERS.md")
74+
75+
except FileNotFoundError:
76+
print(f"Error: MAINTAINERS.md file not found")
77+
sys.exit(1)
78+
except IOError as e:
79+
print(f"Error reading MAINTAINERS.md: {e}")
80+
sys.exit(1)
81+
82+
return reviewers
83+
84+
85+
def calculate_rotation_info(config: dict) -> RotationInfo:
86+
"""Calculate rotation information from config (helper function)."""
87+
start_date_str = config.get('start_date')
88+
rotation_cycle_weeks = config.get('rotation_cycle_weeks', 3)
89+
90+
if not start_date_str:
91+
return {
92+
"start_date": None,
93+
"rotation_cycle_weeks": rotation_cycle_weeks,
94+
"weeks_since_start": 0,
95+
"before_start": False,
96+
"error": "No start_date configured"
97+
}
98+
99+
try:
100+
start_date = datetime.strptime(start_date_str, "%Y-%m-%d")
101+
current_date = datetime.now()
102+
weeks_since_start = (current_date - start_date).total_seconds() / (7 * 24 * 3600)
103+
104+
return {
105+
"start_date": start_date,
106+
"rotation_cycle_weeks": rotation_cycle_weeks,
107+
"weeks_since_start": weeks_since_start,
108+
"before_start": weeks_since_start < 0,
109+
"error": None
110+
}
111+
except ValueError:
112+
return {
113+
"start_date": None,
114+
"rotation_cycle_weeks": rotation_cycle_weeks,
115+
"weeks_since_start": 0,
116+
"before_start": False,
117+
"error": f"Invalid start_date format: {start_date_str}"
118+
}
119+
120+
121+
def get_current_sprint_info(rotation_info: RotationInfo) -> SprintInfo:
122+
"""Calculate current sprint information."""
123+
if rotation_info["error"]:
124+
return {"sprint_number": 1, "week_in_sprint": 1}
125+
126+
if rotation_info["before_start"]:
127+
return {
128+
"sprint_number": 1,
129+
"week_in_sprint": 1,
130+
"total_weeks": 0,
131+
"before_start": True
132+
}
133+
134+
weeks_since_start = rotation_info["weeks_since_start"]
135+
rotation_cycle_weeks = rotation_info["rotation_cycle_weeks"]
136+
137+
sprint_number = int(weeks_since_start // rotation_cycle_weeks) + 1
138+
week_in_sprint = int(weeks_since_start % rotation_cycle_weeks) + 1
139+
140+
return {
141+
"sprint_number": sprint_number,
142+
"week_in_sprint": week_in_sprint,
143+
"total_weeks": int(weeks_since_start)
144+
}
145+
146+
147+
def calculate_current_reviewer(reviewers: List[str], rotation_info: RotationInfo) -> Optional[str]:
148+
"""Calculate the current reviewer based on the rotation schedule."""
149+
if not reviewers:
150+
print("Error: No reviewers found")
151+
return None
152+
153+
if rotation_info["error"]:
154+
print(f"Error: {rotation_info['error']}")
155+
return None
156+
157+
if rotation_info["before_start"]:
158+
print(f"Warning: Current date is before start date. Using first reviewer.")
159+
return reviewers[0]
160+
161+
# Calculate total weeks since start and map to reviewer
162+
# Each reviewer gets rotation_cycle_weeks weeks, then we cycle to the next
163+
total_weeks = int(rotation_info["weeks_since_start"])
164+
rotation_cycle_weeks = rotation_info["rotation_cycle_weeks"]
165+
reviewer_index = (total_weeks // rotation_cycle_weeks) % len(reviewers)
166+
167+
return reviewers[reviewer_index]
168+
169+
170+
def get_existing_reviewers(pr_number: str) -> List[str]:
171+
"""Get list of reviewers already assigned to the PR."""
172+
try:
173+
result = subprocess.run(
174+
['gh', 'pr', 'view', pr_number, '--json', 'reviewRequests', '--jq', '.reviewRequests[].login'],
175+
capture_output=True, text=True, check=True
176+
)
177+
return result.stdout.strip().split('\n') if result.stdout.strip() else []
178+
except FileNotFoundError:
179+
print("Error: 'gh' command not found. Is the GitHub CLI installed and in the PATH?")
180+
sys.exit(1)
181+
except subprocess.CalledProcessError as e:
182+
print(f"Error: Could not fetch existing reviewers for PR {pr_number}: {e.stderr}", file=sys.stderr)
183+
sys.exit(1)
184+
185+
186+
def assign_reviewer(pr_number: str, reviewer: str) -> bool:
187+
"""Assign a reviewer to the PR using GitHub CLI."""
188+
try:
189+
subprocess.run(
190+
['gh', 'pr', 'edit', pr_number, '--add-reviewer', reviewer],
191+
check=True, capture_output=True, text=True
192+
)
193+
print(f"Successfully assigned reviewer {reviewer} to PR {pr_number}")
194+
return True
195+
except FileNotFoundError:
196+
print("Error: 'gh' command not found. Is the GitHub CLI installed and in the PATH?")
197+
sys.exit(1)
198+
except subprocess.CalledProcessError as e:
199+
print(f"Error assigning reviewer {reviewer} to PR {pr_number}: {e.stderr}", file=sys.stderr)
200+
sys.exit(1)
201+
202+
203+
def main():
204+
"""Main function to handle reviewer assignment."""
205+
# Get PR number from environment variable
206+
pr_number = os.environ.get('PR_NUMBER')
207+
if not pr_number:
208+
print("Error: PR_NUMBER environment variable not set")
209+
sys.exit(1)
210+
211+
# Load configuration (for start_date and rotation_cycle_weeks)
212+
config_path = os.environ.get('AUTO_REVIEW_CONFIG_PATH', '.github/auto-review-config.yml')
213+
config = load_config(config_path)
214+
215+
# Extract reviewers from MAINTAINERS.md
216+
reviewers = extract_reviewers_from_maintainers()
217+
if not reviewers:
218+
print("Error: No reviewers found in MAINTAINERS.md")
219+
sys.exit(1)
220+
221+
# Calculate rotation information once
222+
rotation_info = calculate_rotation_info(config)
223+
224+
# Get sprint information
225+
sprint_info = get_current_sprint_info(rotation_info)
226+
print(f"Current sprint: {sprint_info['sprint_number']}, week: {sprint_info['week_in_sprint']}")
227+
228+
# Calculate current reviewer
229+
current_reviewer = calculate_current_reviewer(reviewers, rotation_info)
230+
if not current_reviewer:
231+
print("Error: Could not calculate current reviewer")
232+
sys.exit(1)
233+
234+
print(f"Assigned reviewer for this week: {current_reviewer}")
235+
236+
# Get existing reviewers
237+
existing_reviewers = get_existing_reviewers(pr_number)
238+
239+
# Check if current reviewer is already assigned
240+
if current_reviewer in existing_reviewers:
241+
print(f"Reviewer {current_reviewer} is already assigned to PR {pr_number}")
242+
return
243+
244+
# Assign the current reviewer
245+
assign_reviewer(pr_number, current_reviewer)
246+
247+
if __name__ == "__main__":
248+
main()

.github/workflows/auto-review.yml

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

0 commit comments

Comments
 (0)