Skip to content

Commit aaa94eb

Browse files
committed
fix(reviewer-bot): harden GitHub API failure handling
1 parent 84aa729 commit aaa94eb

15 files changed

Lines changed: 1649 additions & 219 deletions

File tree

.github/reviewer-bot-tests/test_reviewer_bot.py

Lines changed: 756 additions & 20 deletions
Large diffs are not rendered by default.

scripts/reviewer_bot.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,8 @@ def github_api_request(
213213
data: dict | None = None,
214214
extra_headers: dict[str, str] | None = None,
215215
*,
216+
retry_policy: str = "none",
217+
timeout_seconds: float | None = None,
216218
suppress_error_log: bool = False,
217219
) -> GitHubApiResult:
218220
return github_api_module.github_api_request(
@@ -221,6 +223,8 @@ def github_api_request(
221223
endpoint,
222224
data,
223225
extra_headers,
226+
retry_policy=retry_policy,
227+
timeout_seconds=timeout_seconds,
224228
suppress_error_log=suppress_error_log,
225229
)
226230

@@ -234,13 +238,17 @@ def github_graphql_request(
234238
variables: dict | None = None,
235239
*,
236240
token: str | None = None,
241+
retry_policy: str = "none",
242+
timeout_seconds: float | None = None,
237243
suppress_error_log: bool = False,
238244
) -> GitHubApiResult:
239245
return github_api_module.github_graphql_request(
240246
_runtime_bot(),
241247
query,
242248
variables,
243249
token=token,
250+
retry_policy=retry_policy,
251+
timeout_seconds=timeout_seconds,
244252
suppress_error_log=suppress_error_log,
245253
)
246254

@@ -346,7 +354,7 @@ def get_assignment_failure_comment(reviewer: str, attempt: AssignmentAttempt) ->
346354
return github_api_module.get_assignment_failure_comment(_runtime_bot(), reviewer, attempt)
347355

348356

349-
def get_issue_assignees(issue_number: int) -> list[str]:
357+
def get_issue_assignees(issue_number: int) -> list[str] | None:
350358
return github_api_module.get_issue_assignees(_runtime_bot(), issue_number)
351359

352360

@@ -366,7 +374,11 @@ def unassign_reviewer(issue_number: int, username: str) -> bool:
366374
return github_api_module.unassign_reviewer(_runtime_bot(), issue_number, username)
367375

368376

369-
def check_user_permission(username: str, required_permission: str = "triage") -> bool:
377+
def get_user_permission_status(username: str, required_permission: str = "triage") -> str:
378+
return github_api_module.get_user_permission_status(_runtime_bot(), username, required_permission)
379+
380+
381+
def check_user_permission(username: str, required_permission: str = "triage") -> bool | None:
370382
return github_api_module.check_user_permission(_runtime_bot(), username, required_permission)
371383

372384

@@ -629,6 +641,10 @@ def find_open_pr_for_branch(branch: str) -> dict | None:
629641
return automation_module.find_open_pr_for_branch(_runtime_bot(), branch)
630642

631643

644+
def find_open_pr_for_branch_status(branch: str) -> tuple[str, dict | None]:
645+
return automation_module.find_open_pr_for_branch_status(_runtime_bot(), branch)
646+
647+
632648
def resolve_workflow_run_pr_number() -> int:
633649
return commands_module.resolve_workflow_run_pr_number(_runtime_bot())
634650

scripts/reviewer_bot_lib/automation.py

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,23 +46,41 @@ def get_default_branch(bot) -> str:
4646
return "main"
4747

4848

49-
def find_open_pr_for_branch(bot, branch: str) -> dict | None:
49+
def find_open_pr_for_branch_status(bot, branch: str) -> tuple[str, dict | None]:
5050
owner = os.environ.get("REPO_OWNER", "").strip()
5151
branch = branch.strip()
5252
if not owner or not branch:
53-
return None
54-
response = bot.github_api("GET", f"pulls?state=open&head={owner}:{branch}")
55-
if isinstance(response, list) and response:
56-
first = response[0]
53+
return "not_found", None
54+
response = bot.github_api_request(
55+
"GET",
56+
f"pulls?state=open&head={owner}:{branch}",
57+
retry_policy="idempotent_read",
58+
)
59+
if not response.ok:
60+
return "unavailable", None
61+
payload = response.payload
62+
if not isinstance(payload, list):
63+
return "unavailable", None
64+
if payload:
65+
first = payload[0]
5766
if isinstance(first, dict):
58-
return first
59-
return None
67+
return "found", first
68+
return "not_found", None
69+
70+
71+
def find_open_pr_for_branch(bot, branch: str) -> dict | None:
72+
status, pr = find_open_pr_for_branch_status(bot, branch)
73+
if status != "found":
74+
return None
75+
return pr
6076

6177

6278
def create_pull_request(bot, branch: str, base: str, issue_number: int) -> dict | None:
63-
existing = bot.find_open_pr_for_branch(branch)
64-
if existing:
79+
lookup_status, existing = bot.find_open_pr_for_branch_status(branch)
80+
if lookup_status == "found":
6581
return existing
82+
if lookup_status == "unavailable":
83+
raise RuntimeError(f"Unable to determine whether branch '{branch}' already has an open PR")
6684
title = "chore: update spec.lock (no guideline impact)"
6785
body = (
6886
"Updates `src/spec.lock` after confirming the audit reported no affected guidelines.\n\n"
@@ -84,7 +102,10 @@ def handle_accept_no_fls_changes_command(bot, issue_number: int, comment_author:
84102
labels = bot.parse_issue_labels()
85103
if bot.FLS_AUDIT_LABEL not in labels:
86104
return "❌ This command is only available on issues labeled `fls-audit`.", False
87-
if not bot.check_user_permission(comment_author, "triage"):
105+
permission_status = bot.get_user_permission_status(comment_author, "triage")
106+
if permission_status == "unavailable":
107+
return "❌ Unable to verify triage permissions right now; refusing to run this command.", False
108+
if permission_status != "granted":
88109
return "❌ You must have triage permissions to run this command.", False
89110

90111
repo_root = get_target_repo_root()

scripts/reviewer_bot_lib/commands.py

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
from datetime import datetime, timezone
88
from pathlib import Path
99

10+
from .automation import (
11+
find_open_pr_for_branch_status as automation_find_open_pr_for_branch_status,
12+
)
1013
from .config import AssignmentAttempt
1114
from .guidance import get_issue_guidance, get_pr_guidance
1215

@@ -110,13 +113,22 @@ def _apply_assignment_side_effects(
110113
return assignment_attempt, failure_comment
111114

112115

116+
def _current_assignees_or_error(bot, issue_number: int) -> tuple[list[str] | None, str | None]:
117+
current_assignees = bot.get_issue_assignees(issue_number)
118+
if current_assignees is None:
119+
return None, "❌ Unable to determine current assignees/reviewers from GitHub; refusing to continue."
120+
return current_assignees, None
121+
122+
113123
def handle_pass_command(bot, state: dict, issue_number: int, comment_author: str, reason: str | None) -> tuple[str, bool]:
114124
issue_data = bot.ensure_review_entry(state, issue_number, create=True)
115125
if issue_data is None:
116126
return "❌ Unable to load review state.", False
117127
passed_reviewer = issue_data.get("current_reviewer")
118128
if not passed_reviewer:
119-
current_assignees = bot.get_issue_assignees(issue_number)
129+
current_assignees, assignee_error = _current_assignees_or_error(bot, issue_number)
130+
if assignee_error:
131+
return assignee_error, False
120132
passed_reviewer = current_assignees[0] if current_assignees else None
121133
if not passed_reviewer:
122134
return "❌ No reviewer is currently assigned to pass.", False
@@ -195,7 +207,9 @@ def handle_pass_until_command(bot, state: dict, issue_number: int, comment_autho
195207
issue_data = state["active_reviews"][issue_key]
196208
if isinstance(issue_data, dict):
197209
tracked_reviewer = issue_data.get("current_reviewer")
198-
current_assignees = bot.get_issue_assignees(issue_number)
210+
current_assignees, assignee_error = _current_assignees_or_error(bot, issue_number)
211+
if assignee_error:
212+
return assignee_error, False
199213
is_current_reviewer = ((tracked_reviewer and tracked_reviewer.lower() == comment_author.lower()) or comment_author.lower() in [a.lower() for a in current_assignees])
200214
reassigned_msg = ""
201215
if is_current_reviewer:
@@ -321,16 +335,10 @@ def get_default_branch(bot) -> str:
321335

322336

323337
def find_open_pr_for_branch(bot, branch: str) -> dict | None:
324-
owner = os.environ.get("REPO_OWNER", "").strip()
325-
branch = branch.strip()
326-
if not owner or not branch:
338+
status, pr = automation_find_open_pr_for_branch_status(bot, branch)
339+
if status != "found":
327340
return None
328-
response = bot.github_api("GET", f"pulls?state=open&head={owner}:{branch}")
329-
if isinstance(response, list) and response:
330-
first = response[0]
331-
if isinstance(first, dict):
332-
return first
333-
return None
341+
return pr
334342

335343

336344
def resolve_workflow_run_pr_number(bot) -> int:
@@ -351,9 +359,10 @@ def resolve_workflow_run_pr_number(bot) -> int:
351359
raise RuntimeError("Missing WORKFLOW_RUN_HEAD_SHA for workflow_run reconcile")
352360
if reconcile_head_sha != workflow_run_head_sha:
353361
raise RuntimeError("Workflow_run reconcile context SHA mismatch between artifact and workflow payload")
354-
pull_request = bot.github_api("GET", f"pulls/{pr_number}")
355-
if not isinstance(pull_request, dict):
362+
response = bot.github_api_request("GET", f"pulls/{pr_number}", retry_policy="idempotent_read")
363+
if not response.ok or not isinstance(response.payload, dict):
356364
raise RuntimeError(f"Failed to fetch pull request #{pr_number} during workflow_run reconcile")
365+
pull_request = response.payload
357366
head = pull_request.get("head")
358367
pull_request_head_sha = ""
359368
if isinstance(head, dict):
@@ -369,9 +378,11 @@ def resolve_workflow_run_pr_number(bot) -> int:
369378

370379

371380
def create_pull_request(bot, branch: str, base: str, issue_number: int) -> dict | None:
372-
existing = bot.find_open_pr_for_branch(branch)
373-
if existing:
381+
lookup_status, existing = automation_find_open_pr_for_branch_status(bot, branch)
382+
if lookup_status == "found":
374383
return existing
384+
if lookup_status == "unavailable":
385+
raise RuntimeError(f"Unable to determine whether branch '{branch}' already has an open PR")
375386
title = "chore: update spec.lock (no guideline impact)"
376387
body = "Updates `src/spec.lock` after confirming the audit reported no affected guidelines.\n\n" f"Closes #{issue_number}"
377388
response = bot.github_api("POST", "pulls", {"title": title, "head": branch, "base": base, "body": body})
@@ -386,7 +397,10 @@ def handle_accept_no_fls_changes_command(bot, issue_number: int, comment_author:
386397
labels = bot.parse_issue_labels()
387398
if bot.FLS_AUDIT_LABEL not in labels:
388399
return "❌ This command is only available on issues labeled `fls-audit`.", False
389-
if not bot.check_user_permission(comment_author, "triage"):
400+
permission_status = bot.get_user_permission_status(comment_author, "triage")
401+
if permission_status == "unavailable":
402+
return "❌ Unable to verify triage permissions right now; refusing to run this command.", False
403+
if permission_status != "granted":
390404
return "❌ You must have triage permissions to run this command.", False
391405
repo_root = get_target_repo_root()
392406
if bot.list_changed_files(repo_root):
@@ -474,7 +488,9 @@ def handle_claim_command(bot, state: dict, issue_number: int, comment_author: st
474488
return (f"❌ @{comment_author} is not in the reviewer queue. Only Producers can claim reviews."), False
475489
if is_away:
476490
return (f"❌ @{comment_author} is currently marked as away. Please use `{bot.BOT_MENTION} /away YYYY-MM-DD` to update your return date first, or wait until your scheduled return."), False
477-
current_assignees = bot.get_issue_assignees(issue_number)
491+
current_assignees, assignee_error = _current_assignees_or_error(bot, issue_number)
492+
if assignee_error:
493+
return assignee_error, False
478494
for assignee in current_assignees:
479495
bot.unassign_reviewer(issue_number, assignee)
480496
issue_author = os.environ.get("ISSUE_AUTHOR", "")
@@ -503,7 +519,10 @@ def handle_release_command(bot, state: dict, issue_number: int, comment_author:
503519
target_username = args[0].lstrip("@")
504520
reason = " ".join(args[1:]) if len(args) > 1 else None
505521
releasing_other = target_username.lower() != comment_author.lower()
506-
if releasing_other and not bot.check_user_permission(comment_author, "triage"):
522+
permission_status = bot.get_user_permission_status(comment_author, "triage")
523+
if permission_status == "unavailable":
524+
return "❌ Unable to verify triage permissions right now; refusing to continue.", False
525+
if releasing_other and permission_status != "granted":
507526
return (f"❌ @{comment_author} does not have permission to release other reviewers. Triage access or higher is required."), False
508527
else:
509528
target_username = comment_author
@@ -516,7 +535,9 @@ def handle_release_command(bot, state: dict, issue_number: int, comment_author:
516535
if isinstance(issue_data, dict):
517536
tracked_reviewer = issue_data.get("current_reviewer")
518537
assignment_method = issue_data.get("assignment_method")
519-
current_assignees = bot.get_issue_assignees(issue_number)
538+
current_assignees, assignee_error = _current_assignees_or_error(bot, issue_number)
539+
if assignee_error:
540+
return assignee_error, False
520541
is_tracked = tracked_reviewer and tracked_reviewer.lower() == target_username.lower()
521542
is_assigned = target_username.lower() in [assignee.lower() for assignee in current_assignees]
522543
if not is_tracked and not is_assigned:
@@ -555,7 +576,9 @@ def handle_assign_command(bot, state: dict, issue_number: int, username: str) ->
555576
if entry["github"].lower() == username.lower():
556577
return_date = entry.get("return_date", "unknown")
557578
return (f"⚠️ @{username} is currently marked as away until {return_date}. Consider assigning someone else or waiting."), False
558-
current_assignees = bot.get_issue_assignees(issue_number)
579+
current_assignees, assignee_error = _current_assignees_or_error(bot, issue_number)
580+
if assignee_error:
581+
return assignee_error, False
559582
for assignee in current_assignees:
560583
bot.unassign_reviewer(issue_number, assignee)
561584
issue_author = os.environ.get("ISSUE_AUTHOR", "")
@@ -577,7 +600,9 @@ def handle_assign_command(bot, state: dict, issue_number: int, username: str) ->
577600

578601

579602
def handle_assign_from_queue_command(bot, state: dict, issue_number: int) -> tuple[str, bool]:
580-
current_assignees = bot.get_issue_assignees(issue_number)
603+
current_assignees, assignee_error = _current_assignees_or_error(bot, issue_number)
604+
if assignee_error:
605+
return assignee_error, False
581606
for assignee in current_assignees:
582607
bot.unassign_reviewer(issue_number, assignee)
583608
issue_author = os.environ.get("ISSUE_AUTHOR", "")

scripts/reviewer_bot_lib/config.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,11 +197,14 @@ def get_commands_help() -> str:
197197

198198
@dataclass
199199
class GitHubApiResult:
200-
status_code: int
200+
status_code: int | None
201201
payload: Any
202202
headers: dict[str, str]
203203
text: str
204204
ok: bool
205+
failure_kind: str | None = None
206+
retry_attempts: int = 0
207+
transport_error: str | None = None
205208

206209

207210
@dataclass

scripts/reviewer_bot_lib/context.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ def github_api_request(
2828
data: dict | None = None,
2929
extra_headers: dict[str, str] | None = None,
3030
*,
31+
retry_policy: str = "none",
32+
timeout_seconds: float | None = None,
3133
suppress_error_log: bool = False,
3234
) -> GitHubApiResult: ...
3335
def github_api(self, method: str, endpoint: str, data: dict | None = None) -> Any | None: ...
@@ -37,6 +39,8 @@ def github_graphql_request(
3739
variables: dict | None = None,
3840
*,
3941
token: str | None = None,
42+
retry_policy: str = "none",
43+
timeout_seconds: float | None = None,
4044
suppress_error_log: bool = False,
4145
) -> GitHubApiResult: ...
4246
def github_graphql(
@@ -47,6 +51,7 @@ def github_graphql(
4751
token: str | None = None,
4852
) -> Any | None: ...
4953
def request_reviewer_assignment(self, issue_number: int, username: str) -> AssignmentAttempt: ...
54+
def get_user_permission_status(self, username: str, required_permission: str = "triage") -> str: ...
5055
def remove_assignee(self, issue_number: int, username: str) -> bool: ...
5156
def remove_pr_reviewer(self, issue_number: int, username: str) -> bool: ...
5257

@@ -71,6 +76,8 @@ def github_api_request(
7176
data: dict | None = None,
7277
extra_headers: dict[str, str] | None = None,
7378
*,
79+
retry_policy: str = "none",
80+
timeout_seconds: float | None = None,
7481
suppress_error_log: bool = False,
7582
) -> GitHubApiResult: ...
7683
def get_state_issue(self) -> dict | None: ...
@@ -119,6 +126,8 @@ def github_api_request(
119126
data: dict | None = None,
120127
extra_headers: dict[str, str] | None = None,
121128
*,
129+
retry_policy: str = "none",
130+
timeout_seconds: float | None = None,
122131
suppress_error_log: bool = False,
123132
) -> GitHubApiResult: ...
124133
def get_lock_ref_display(self) -> str: ...

0 commit comments

Comments
 (0)