Skip to content

Commit d6b10e8

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 d6b10e8

File tree

3 files changed

+223
-0
lines changed

3 files changed

+223
-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: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
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
15+
16+
17+
def load_config(config_path: str) -> dict:
18+
"""Load the reviewer configuration from YAML file."""
19+
try:
20+
with open(config_path, 'r') as f:
21+
return yaml.safe_load(f)
22+
except FileNotFoundError:
23+
print(f"Error: Configuration file {config_path} not found")
24+
sys.exit(1)
25+
except yaml.YAMLError as e:
26+
print(f"Error parsing YAML configuration: {e}")
27+
sys.exit(1)
28+
29+
30+
def extract_reviewers_from_maintainers() -> List[str]:
31+
"""Extract GitHub usernames from MAINTAINERS.md file."""
32+
maintainers_path = 'MAINTAINERS.md'
33+
reviewers = []
34+
35+
try:
36+
with open(maintainers_path, 'r') as f:
37+
content = f.read()
38+
39+
# Look for GitHub usernames in the format [username](https://github.com/...)
40+
# This regex matches the GitHub ID column in the maintainers table
41+
pattern = r'\[([^\]]+)\]\(https://github\.com/[^)]+\)'
42+
matches = re.findall(pattern, content)
43+
44+
if matches:
45+
reviewers = matches
46+
print(f"Found {len(reviewers)} reviewers from MAINTAINERS.md: {', '.join(reviewers)}")
47+
else:
48+
print("Warning: No GitHub usernames found in MAINTAINERS.md")
49+
50+
except FileNotFoundError:
51+
print(f"Error: MAINTAINERS.md file not found")
52+
sys.exit(1)
53+
except Exception as e:
54+
print(f"Error reading MAINTAINERS.md: {e}")
55+
sys.exit(1)
56+
57+
return reviewers
58+
59+
60+
def get_current_sprint_info(config: dict) -> dict:
61+
"""Calculate current sprint information."""
62+
start_date_str = config.get('start_date')
63+
rotation_cycle_weeks = config.get('rotation_cycle_weeks', 3)
64+
65+
if not start_date_str:
66+
return {"sprint_number": 1, "week_in_sprint": 1}
67+
68+
try:
69+
start_date = datetime.strptime(start_date_str, "%Y-%m-%d")
70+
current_date = datetime.now()
71+
weeks_since_start = (current_date - start_date).days / 7
72+
73+
sprint_number = int(weeks_since_start // rotation_cycle_weeks) + 1
74+
week_in_sprint = int(weeks_since_start % rotation_cycle_weeks) + 1
75+
76+
return {
77+
"sprint_number": sprint_number,
78+
"week_in_sprint": week_in_sprint,
79+
"total_weeks": int(weeks_since_start)
80+
}
81+
except ValueError:
82+
return {"sprint_number": 1, "week_in_sprint": 1}
83+
84+
85+
def calculate_current_reviewer(config: dict, reviewers: List[str]) -> Optional[str]:
86+
"""Calculate the current reviewer based on the rotation schedule."""
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 or not reviewers:
91+
print("Error: Missing start_date or no reviewers found")
92+
return None
93+
94+
try:
95+
start_date = datetime.strptime(start_date_str, "%Y-%m-%d")
96+
except ValueError:
97+
print(f"Error: Invalid start_date format: {start_date_str}")
98+
return None
99+
100+
current_date = datetime.now()
101+
weeks_since_start = (current_date - start_date).days / 7
102+
103+
# Calculate which week in the rotation cycle we're in
104+
week_in_cycle = int(weeks_since_start) % rotation_cycle_weeks
105+
106+
# Map week to reviewer (assuming one reviewer per week)
107+
reviewer_index = week_in_cycle % len(reviewers)
108+
109+
return reviewers[reviewer_index]
110+
111+
112+
def get_existing_reviewers(pr_number: str) -> List[str]:
113+
"""Get list of reviewers already assigned to the PR."""
114+
try:
115+
result = subprocess.run(
116+
['gh', 'pr', 'view', pr_number, '--json', 'reviewRequests', '--jq', '.reviewRequests[].login'],
117+
capture_output=True, text=True, check=True
118+
)
119+
return result.stdout.strip().split('\n') if result.stdout.strip() else []
120+
except subprocess.CalledProcessError:
121+
print(f"Warning: Could not fetch existing reviewers for PR {pr_number}")
122+
return []
123+
124+
125+
def assign_reviewer(pr_number: str, reviewer: str) -> bool:
126+
"""Assign a reviewer to the PR using GitHub CLI."""
127+
try:
128+
subprocess.run(
129+
['gh', 'pr', 'edit', pr_number, '--add-reviewer', reviewer],
130+
check=True, capture_output=True
131+
)
132+
print(f"Successfully assigned reviewer {reviewer} to PR {pr_number}")
133+
return True
134+
except subprocess.CalledProcessError as e:
135+
print(f"Error assigning reviewer {reviewer} to PR {pr_number}: {e}")
136+
return False
137+
138+
139+
def main():
140+
"""Main function to handle reviewer assignment."""
141+
# Get PR number from environment variable
142+
pr_number = os.environ.get('PR_NUMBER')
143+
if not pr_number:
144+
print("Error: PR_NUMBER environment variable not set")
145+
sys.exit(1)
146+
147+
# Load configuration (for start_date and rotation_cycle_weeks)
148+
config_path = '.github/auto-review-config.yml'
149+
config = load_config(config_path)
150+
151+
# Extract reviewers from MAINTAINERS.md
152+
reviewers = extract_reviewers_from_maintainers()
153+
if not reviewers:
154+
print("Error: No reviewers found in MAINTAINERS.md")
155+
sys.exit(1)
156+
157+
# Get sprint information
158+
sprint_info = get_current_sprint_info(config)
159+
print(f"Current sprint: {sprint_info['sprint_number']}, week: {sprint_info['week_in_sprint']}")
160+
161+
# Calculate current reviewer
162+
current_reviewer = calculate_current_reviewer(config, reviewers)
163+
if not current_reviewer:
164+
print("Error: Could not calculate current reviewer")
165+
sys.exit(1)
166+
167+
print(f"Assigned reviewer for this week: {current_reviewer}")
168+
169+
# Get existing reviewers
170+
existing_reviewers = get_existing_reviewers(pr_number)
171+
172+
# Check if current reviewer is already assigned
173+
if current_reviewer in existing_reviewers:
174+
print(f"Reviewer {current_reviewer} is already assigned to PR {pr_number}")
175+
return
176+
177+
# Assign the current reviewer
178+
success = assign_reviewer(pr_number, current_reviewer)
179+
if not success:
180+
sys.exit(1)
181+
182+
183+
if __name__ == "__main__":
184+
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)