Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 92 additions & 15 deletions scripts/assign_reviewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import os
import subprocess
import sys
import time
from datetime import datetime, timezone

TEAM_SLUG = 'binaryen-reviewers'
Expand All @@ -27,6 +28,16 @@
SECONDS_PER_DAY = 86400.0
DECAY_RATE = math.log(2.0) / (HALF_LIFE_DAYS * SECONDS_PER_DAY)
ADDITIONS_OFFSET = 10.0
RECENT_PR_PAGE_SIZE = 50
RECENT_PR_PAGES = 2
MAX_API_ATTEMPTS = 3
RETRYABLE_ERRORS = (
'HTTP 500',
'HTTP 502',
'HTTP 503',
'HTTP 504',
'Something went wrong while executing your query',
)
Comment on lines +34 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To make the script more resilient against transient network issues in CI environments, consider expanding RETRYABLE_ERRORS to include common network-level error messages (such as DNS lookup failures, connection timeouts, or connection resets).

Suggested change
RETRYABLE_ERRORS = (
'HTTP 500',
'HTTP 502',
'HTTP 503',
'HTTP 504',
'Something went wrong while executing your query',
)
RETRYABLE_ERRORS = (
'HTTP 500',
'HTTP 502',
'HTTP 503',
'HTTP 504',
'Something went wrong while executing your query',
'dial tcp',
'i/o timeout',
'connection refused',
'TLS handshake',
)


# Current UTC timestamp used as the reference point for exponential time decay.
now = datetime.now(timezone.utc).timestamp()
Expand All @@ -40,7 +51,9 @@
# Most recent review-request timestamp per reviewer, used for tie-breaking.
last_assigned: dict[str, float] = {}

QUERY = """
# Fetches the reviewer team roster and target PR metadata so closed, draft, or
# already-assigned PRs can exit early without querying PR history.
SETUP_QUERY = """
query($owner: String!, $name: String!, $pr: Int!, $team: String!) {
organization(login: $owner) {
team(slug: $team) {
Expand All @@ -56,36 +69,53 @@
state
isDraft
author { login }
reviewRequests(first: 100) {
reviewRequests(first: 20) {
nodes {
requestedReviewer {
... on User { login }
... on Team { slug }
}
}
}
latestReviews(first: 100) {
latestReviews(first: 20) {
nodes {
author { login }
state
}
}
}
recentPRs: pullRequests(first: 100, orderBy: {field: UPDATED_AT, direction: DESC}) {
}
}
"""

# Fetches a page of recently updated PRs with their review and timeline state
# to compute exponentially decayed reviewer and author loads.
RECENT_PRS_QUERY = """
query($owner: String!, $name: String!, $pageSize: Int!, $cursor: String) {
repository(owner: $owner, name: $name) {
recentPRs: pullRequests(
first: $pageSize
after: $cursor
orderBy: {field: UPDATED_AT, direction: DESC}
) {
pageInfo {
hasNextPage
endCursor
}
nodes {
number
state
isDraft
createdAt
additions
author { login }
latestReviews(first: 100) {
latestReviews(first: 20) {
nodes {
author { login }
state
}
}
reviewRequests(first: 100) {
reviewRequests(first: 20) {
nodes {
requestedReviewer {
... on User { login }
Expand Down Expand Up @@ -122,18 +152,63 @@ def pr_weight(additions: int) -> float:


def gh_api(args: list[str]) -> str:
try:
for attempt in range(1, MAX_API_ATTEMPTS + 1):
res = subprocess.run(
['gh', 'api', *args],
capture_output=True,
text=True,
check=True,
check=False,
)
if res.returncode == 0:
return res.stdout
stderr = res.stderr.strip() if res.stderr else ''
if attempt == MAX_API_ATTEMPTS or not any(
err in stderr for err in RETRYABLE_ERRORS
):
break
delay = 2**attempt
print(
f'Transient GitHub API error ({stderr}); retrying in {delay}s...',
file=sys.stderr,
)
return res.stdout
except subprocess.CalledProcessError as e:
if e.stderr:
print(e.stderr.strip(), file=sys.stderr)
raise
time.sleep(delay)
if stderr:
print(stderr, file=sys.stderr)
raise subprocess.CalledProcessError(
res.returncode,
res.args,
output=res.stdout,
stderr=res.stderr,
)


def fetch_recent_prs(owner: str, name: str) -> list[dict]:
prs: list[dict] = []
seen: set[int] = set()
cursor: str | None = None
for _ in range(RECENT_PR_PAGES):
args = [
'graphql',
'-f',
f'query={RECENT_PRS_QUERY}',
'-f',
f'owner={owner}',
'-f',
f'name={name}',
'-F',
f'pageSize={RECENT_PR_PAGE_SIZE}',
]
if cursor is not None:
args.extend(['-f', f'cursor={cursor}'])
page = json.loads(gh_api(args))['data']['repository']['recentPRs']
for pr in page['nodes']:
if pr['number'] not in seen:
seen.add(pr['number'])
prs.append(pr)
Comment on lines +204 to +207

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To prevent potential TypeError exceptions if the GraphQL response contains a null value for the nodes field (which can happen under certain partial failure modes in GraphQL), use defensive programming with .get() and a fallback empty list.

Suggested change
for pr in page['nodes']:
if pr['number'] not in seen:
seen.add(pr['number'])
prs.append(pr)
for pr in page.get('nodes') or []:
if pr['number'] not in seen:
seen.add(pr['number'])
prs.append(pr)

if not page['pageInfo']['hasNextPage']:
break
cursor = page['pageInfo']['endCursor']
return prs


def accumulate_pr_activity(pr: dict, reviewer_set: set[str]) -> None:
Expand Down Expand Up @@ -222,7 +297,7 @@ def main() -> None:
gh_api([
'graphql',
'-f',
f'query={QUERY}',
f'query={SETUP_QUERY}',
'-f',
f'owner={owner}',
'-f',
Expand Down Expand Up @@ -274,10 +349,12 @@ def main() -> None:
print(f'No eligible reviewers for PR #{pr_number} (author={pr_author}).')
return

recent_prs = fetch_recent_prs(owner, name)

review_load.update(dict.fromkeys(reviewers, 0.0))
authored_load.update(dict.fromkeys(reviewers, 0.0))
last_assigned.update(dict.fromkeys(reviewers, 0.0))
for pr in repo_data['recentPRs']['nodes']:
for pr in recent_prs:
if not pr['isDraft'] and pr['number'] != pr_number:
accumulate_pr_activity(pr, reviewer_set)

Expand Down
Loading