scripts/gh_pr_watch.py
scripts/gh_pr_watch.pyBrowse 6 files
6,967 tokens
30,134 bytes
Token encoding: o200k_base
Snapshot 18d7ace
← Back to SKILL.md
1#!/usr/bin/env python32"""Watch GitHub PR CI and review activity for Codex PR babysitting workflows."""3 4import argparse5import json6import os7import re8import subprocess9import sys10import tempfile11import time12from pathlib import Path13from urllib.parse import urlparse14 15FAILED_RUN_CONCLUSIONS = {16 "failure",17 "timed_out",18 "cancelled",19 "action_required",20 "startup_failure",21 "stale",22}23PENDING_CHECK_STATES = {24 "QUEUED",25 "IN_PROGRESS",26 "PENDING",27 "WAITING",28 "REQUESTED",29}30REVIEW_BOT_LOGIN_KEYWORDS = {31 "codex",32}33TRUSTED_AUTHOR_ASSOCIATIONS = {34 "OWNER",35 "MEMBER",36 "COLLABORATOR",37}38MERGE_BLOCKING_REVIEW_DECISIONS = {39 "REVIEW_REQUIRED",40 "CHANGES_REQUESTED",41}42MERGE_CONFLICT_OR_BLOCKING_STATES = {43 "BLOCKED",44 "DIRTY",45 "DRAFT",46 "UNKNOWN",47}48 49 50class GhCommandError(RuntimeError):51 pass52 53 54def parse_args():55 parser = argparse.ArgumentParser(56 description=(57 "Normalize PR/CI/review state for Codex PR babysitting and optionally "58 "trigger flaky reruns."59 )60 )61 parser.add_argument("--pr", default="auto", help="auto, PR number, or PR URL")62 parser.add_argument("--repo", help="Optional OWNER/REPO override")63 parser.add_argument(64 "--poll-seconds", type=int, default=30, help="Watch poll interval"65 )66 parser.add_argument(67 "--max-flaky-retries",68 type=int,69 default=3,70 help="Max rerun cycles per head SHA before stop recommendation",71 )72 parser.add_argument("--state-file", help="Path to state JSON file")73 parser.add_argument(74 "--once", action="store_true", help="Emit one snapshot and exit"75 )76 parser.add_argument(77 "--watch", action="store_true", help="Continuously emit JSONL snapshots"78 )79 parser.add_argument(80 "--retry-failed-now",81 action="store_true",82 help="Rerun failed jobs for current failed workflow runs when policy allows",83 )84 parser.add_argument(85 "--json",86 action="store_true",87 help="Emit machine-readable output (default behavior for --once and --retry-failed-now)",88 )89 args = parser.parse_args()90 91 if args.poll_seconds <= 0:92 parser.error("--poll-seconds must be > 0")93 if args.max_flaky_retries < 0:94 parser.error("--max-flaky-retries must be >= 0")95 if args.watch and args.retry_failed_now:96 parser.error("--watch cannot be combined with --retry-failed-now")97 if not args.once and not args.watch and not args.retry_failed_now:98 args.once = True99 return args100 101 102def _format_gh_error(cmd, err):103 stdout = (err.stdout or "").strip()104 stderr = (err.stderr or "").strip()105 parts = [f"GitHub CLI command failed: {' '.join(cmd)}"]106 if stdout:107 parts.append(f"stdout: {stdout}")108 if stderr:109 parts.append(f"stderr: {stderr}")110 return "\n".join(parts)111 112 113def gh_text(args, repo=None):114 cmd = ["gh"]115 # `gh api` does not accept `-R/--repo` on all gh versions. The watcher's116 # API calls use explicit endpoints (e.g. repos/{owner}/{repo}/...), so the117 # repo flag is unnecessary there.118 if repo and (not args or args[0] != "api"):119 cmd.extend(["-R", repo])120 cmd.extend(args)121 try:122 proc = subprocess.run(cmd, check=True, capture_output=True, text=True)123 except FileNotFoundError as err:124 raise GhCommandError("`gh` command not found") from err125 except subprocess.CalledProcessError as err:126 raise GhCommandError(_format_gh_error(cmd, err)) from err127 return proc.stdout128 129 130def gh_json(args, repo=None):131 raw = gh_text(args, repo=repo).strip()132 if not raw:133 return None134 try:135 return json.loads(raw)136 except json.JSONDecodeError as err:137 raise GhCommandError(138 f"Failed to parse JSON from gh output for {' '.join(args)}"139 ) from err140 141 142def parse_pr_spec(pr_spec):143 if pr_spec == "auto":144 return {"mode": "auto", "value": None}145 if re.fullmatch(r"\d+", pr_spec):146 return {"mode": "number", "value": pr_spec}147 parsed = urlparse(pr_spec)148 if parsed.scheme and parsed.netloc and "/pull/" in parsed.path:149 return {"mode": "url", "value": pr_spec}150 raise ValueError("--pr must be 'auto', a PR number, or a PR URL")151 152 153def pr_view_fields():154 return (155 "number,url,state,mergedAt,closedAt,headRefName,headRefOid,"156 "headRepository,headRepositoryOwner,mergeable,mergeStateStatus,reviewDecision"157 )158 159 160def checks_fields():161 return "name,state,bucket,link,workflow,event,startedAt,completedAt"162 163 164def resolve_pr(pr_spec, repo_override=None):165 parsed = parse_pr_spec(pr_spec)166 cmd = ["pr", "view"]167 if parsed["value"] is not None:168 cmd.append(parsed["value"])169 cmd.extend(["--json", pr_view_fields()])170 data = gh_json(cmd, repo=repo_override)171 if not isinstance(data, dict):172 raise GhCommandError("Unexpected PR payload from `gh pr view`")173 174 pr_url = str(data.get("url") or "")175 repo = (176 repo_override177 or extract_repo_from_pr_url(pr_url)178 or extract_repo_from_pr_view(data)179 )180 if not repo:181 raise GhCommandError("Unable to determine OWNER/REPO for the PR")182 183 state = str(data.get("state") or "")184 merged = bool(data.get("mergedAt"))185 closed = bool(data.get("closedAt")) or state.upper() == "CLOSED"186 187 return {188 "number": int(data["number"]),189 "url": pr_url,190 "repo": repo,191 "head_sha": str(data.get("headRefOid") or ""),192 "head_branch": str(data.get("headRefName") or ""),193 "state": state,194 "merged": merged,195 "closed": closed,196 "mergeable": str(data.get("mergeable") or ""),197 "merge_state_status": str(data.get("mergeStateStatus") or ""),198 "review_decision": str(data.get("reviewDecision") or ""),199 }200 201 202def extract_repo_from_pr_view(data):203 head_repo = data.get("headRepository")204 head_owner = data.get("headRepositoryOwner")205 owner = None206 name = None207 if isinstance(head_owner, dict):208 owner = head_owner.get("login") or head_owner.get("name")209 elif isinstance(head_owner, str):210 owner = head_owner211 if isinstance(head_repo, dict):212 name = head_repo.get("name")213 repo_owner = head_repo.get("owner")214 if not owner and isinstance(repo_owner, dict):215 owner = repo_owner.get("login") or repo_owner.get("name")216 elif isinstance(head_repo, str):217 name = head_repo218 if owner and name:219 return f"{owner}/{name}"220 return None221 222 223def extract_repo_from_pr_url(pr_url):224 parsed = urlparse(pr_url)225 parts = [p for p in parsed.path.split("/") if p]226 if len(parts) >= 4 and parts[2] == "pull":227 return f"{parts[0]}/{parts[1]}"228 return None229 230 231def load_state(path):232 if path.exists():233 try:234 data = json.loads(path.read_text())235 except json.JSONDecodeError as err:236 raise RuntimeError(f"State file is not valid JSON: {path}") from err237 if not isinstance(data, dict):238 raise RuntimeError(f"State file must contain an object: {path}")239 return data, False240 return {241 "pr": {},242 "started_at": None,243 "last_seen_head_sha": None,244 "retries_by_sha": {},245 "seen_issue_comment_ids": [],246 "seen_review_comment_ids": [],247 "seen_review_ids": [],248 "last_snapshot_at": None,249 }, True250 251 252def save_state(path, state):253 path.parent.mkdir(parents=True, exist_ok=True)254 payload = json.dumps(state, indent=2, sort_keys=True) + "\n"255 fd, tmp_name = tempfile.mkstemp(256 prefix=f"{path.name}.", suffix=".tmp", dir=path.parent257 )258 tmp_path = Path(tmp_name)259 try:260 with os.fdopen(fd, "w", encoding="utf-8") as tmp_file:261 tmp_file.write(payload)262 os.replace(tmp_path, path)263 except Exception:264 try:265 tmp_path.unlink(missing_ok=True)266 except OSError:267 pass268 raise269 270 271def default_state_file_for(pr):272 repo_slug = pr["repo"].replace("/", "-")273 return Path(f"/tmp/codex-babysit-pr-{repo_slug}-pr{pr['number']}.json")274 275 276def get_pr_checks(pr_spec, repo):277 parsed = parse_pr_spec(pr_spec)278 cmd = ["pr", "checks"]279 if parsed["value"] is not None:280 cmd.append(parsed["value"])281 cmd.extend(["--json", checks_fields()])282 data = gh_json(cmd, repo=repo)283 if data is None:284 return []285 if not isinstance(data, list):286 raise GhCommandError("Unexpected payload from `gh pr checks`")287 return data288 289 290def is_pending_check(check):291 bucket = str(check.get("bucket") or "").lower()292 state = str(check.get("state") or "").upper()293 return bucket == "pending" or state in PENDING_CHECK_STATES294 295 296def summarize_checks(checks):297 pending_count = 0298 failed_count = 0299 passed_count = 0300 for check in checks:301 bucket = str(check.get("bucket") or "").lower()302 if is_pending_check(check):303 pending_count += 1304 if bucket == "fail":305 failed_count += 1306 if bucket == "pass":307 passed_count += 1308 return {309 "pending_count": pending_count,310 "failed_count": failed_count,311 "passed_count": passed_count,312 "all_terminal": pending_count == 0,313 }314 315 316def get_workflow_runs_for_sha(repo, head_sha):317 endpoint = f"repos/{repo}/actions/runs"318 data = gh_json(319 [320 "api",321 endpoint,322 "-X",323 "GET",324 "-f",325 f"head_sha={head_sha}",326 "-f",327 "per_page=100",328 ],329 repo=repo,330 )331 if not isinstance(data, dict):332 raise GhCommandError("Unexpected payload from actions runs API")333 runs = data.get("workflow_runs") or []334 if not isinstance(runs, list):335 raise GhCommandError("Expected `workflow_runs` to be a list")336 return runs337 338 339def failed_runs_from_workflow_runs(runs, head_sha):340 failed_runs = []341 for run in runs:342 if not isinstance(run, dict):343 continue344 if str(run.get("head_sha") or "") != head_sha:345 continue346 conclusion = str(run.get("conclusion") or "")347 if conclusion not in FAILED_RUN_CONCLUSIONS:348 continue349 failed_runs.append(350 {351 "run_id": run.get("id"),352 "workflow_name": run.get("name") or run.get("display_title") or "",353 "status": str(run.get("status") or ""),354 "conclusion": conclusion,355 "html_url": str(run.get("html_url") or ""),356 }357 )358 failed_runs.sort(359 key=lambda item: (360 str(item.get("workflow_name") or ""),361 str(item.get("run_id") or ""),362 )363 )364 return failed_runs365 366 367def get_jobs_for_run(repo, run_id):368 endpoint = f"repos/{repo}/actions/runs/{run_id}/jobs"369 data = gh_json(["api", endpoint, "-X", "GET", "-f", "per_page=100"], repo=repo)370 if not isinstance(data, dict):371 raise GhCommandError("Unexpected payload from actions run jobs API")372 jobs = data.get("jobs") or []373 if not isinstance(jobs, list):374 raise GhCommandError("Expected `jobs` to be a list")375 return jobs376 377 378def failed_jobs_from_workflow_runs(repo, runs, head_sha):379 failed_jobs = []380 for run in runs:381 if not isinstance(run, dict):382 continue383 if str(run.get("head_sha") or "") != head_sha:384 continue385 run_id = run.get("id")386 if run_id in (None, ""):387 continue388 run_status = str(run.get("status") or "")389 run_conclusion = str(run.get("conclusion") or "")390 if (391 run_status.lower() == "completed"392 and run_conclusion not in FAILED_RUN_CONCLUSIONS393 ):394 continue395 jobs = get_jobs_for_run(repo, run_id)396 for job in jobs:397 if not isinstance(job, dict):398 continue399 conclusion = str(job.get("conclusion") or "")400 if conclusion not in FAILED_RUN_CONCLUSIONS:401 continue402 job_id = job.get("id")403 logs_endpoint = None404 if job_id not in (None, ""):405 logs_endpoint = f"repos/{repo}/actions/jobs/{job_id}/logs"406 failed_jobs.append(407 {408 "run_id": run_id,409 "workflow_name": run.get("name") or run.get("display_title") or "",410 "run_status": run_status,411 "run_conclusion": run_conclusion,412 "job_id": job_id,413 "job_name": str(job.get("name") or ""),414 "status": str(job.get("status") or ""),415 "conclusion": conclusion,416 "html_url": str(job.get("html_url") or ""),417 "logs_endpoint": logs_endpoint,418 }419 )420 failed_jobs.sort(421 key=lambda item: (422 str(item.get("workflow_name") or ""),423 str(item.get("job_name") or ""),424 str(item.get("job_id") or ""),425 )426 )427 return failed_jobs428 429 430def get_authenticated_login():431 data = gh_json(["api", "user"])432 if not isinstance(data, dict) or not data.get("login"):433 raise GhCommandError(434 "Unable to determine authenticated GitHub login from `gh api user`"435 )436 return str(data["login"])437 438 439def comment_endpoints(repo, pr_number):440 return {441 "issue_comment": f"repos/{repo}/issues/{pr_number}/comments",442 "review_comment": f"repos/{repo}/pulls/{pr_number}/comments",443 "review": f"repos/{repo}/pulls/{pr_number}/reviews",444 }445 446 447def gh_api_list_paginated(endpoint, repo=None, per_page=100):448 items = []449 page = 1450 while True:451 sep = "&" if "?" in endpoint else "?"452 page_endpoint = f"{endpoint}{sep}per_page={per_page}&page={page}"453 payload = gh_json(["api", page_endpoint], repo=repo)454 if payload is None:455 break456 if not isinstance(payload, list):457 raise GhCommandError(f"Unexpected paginated payload from gh api {endpoint}")458 items.extend(payload)459 if len(payload) < per_page:460 break461 page += 1462 return items463 464 465def normalize_issue_comments(items):466 out = []467 for item in items:468 if not isinstance(item, dict):469 continue470 out.append(471 {472 "kind": "issue_comment",473 "id": str(item.get("id") or ""),474 "author": extract_login(item.get("user")),475 "author_association": str(item.get("author_association") or ""),476 "created_at": str(item.get("created_at") or ""),477 "body": str(item.get("body") or ""),478 "path": None,479 "line": None,480 "url": str(item.get("html_url") or ""),481 }482 )483 return out484 485 486def normalize_review_comments(items, review_states):487 out = []488 for item in items:489 if not isinstance(item, dict):490 continue491 review_id = str(item.get("pull_request_review_id") or "")492 if review_states.get(review_id) == "PENDING":493 continue494 line = item.get("line")495 if line is None:496 line = item.get("original_line")497 out.append(498 {499 "kind": "review_comment",500 "id": str(item.get("id") or ""),501 "author": extract_login(item.get("user")),502 "author_association": str(item.get("author_association") or ""),503 "created_at": str(item.get("created_at") or ""),504 "body": str(item.get("body") or ""),505 "path": item.get("path"),506 "line": line,507 "url": str(item.get("html_url") or ""),508 }509 )510 return out511 512 513def normalize_reviews(items):514 out = []515 for item in items:516 if not isinstance(item, dict):517 continue518 if str(item.get("state") or "").upper() == "PENDING":519 continue520 out.append(521 {522 "kind": "review",523 "id": str(item.get("id") or ""),524 "author": extract_login(item.get("user")),525 "author_association": str(item.get("author_association") or ""),526 "created_at": str(527 item.get("submitted_at") or item.get("created_at") or ""528 ),529 "body": str(item.get("body") or ""),530 "path": None,531 "line": None,532 "url": str(item.get("html_url") or ""),533 }534 )535 return out536 537 538def extract_login(user_obj):539 if isinstance(user_obj, dict):540 return str(user_obj.get("login") or "")541 return ""542 543 544def is_bot_login(login):545 return bool(login) and login.endswith("[bot]")546 547 548def is_actionable_review_bot_login(login):549 if not is_bot_login(login):550 return False551 lower_login = login.lower()552 return any(keyword in lower_login for keyword in REVIEW_BOT_LOGIN_KEYWORDS)553 554 555def is_trusted_human_review_author(item, authenticated_login):556 author = str(item.get("author") or "")557 if not author:558 return False559 if authenticated_login and author == authenticated_login:560 return True561 association = str(item.get("author_association") or "").upper()562 return association in TRUSTED_AUTHOR_ASSOCIATIONS563 564 565def fetch_new_review_items(pr, state, fresh_state, authenticated_login=None):566 repo = pr["repo"]567 pr_number = pr["number"]568 endpoints = comment_endpoints(repo, pr_number)569 570 issue_payload = gh_api_list_paginated(endpoints["issue_comment"], repo=repo)571 review_comment_payload = gh_api_list_paginated(572 endpoints["review_comment"], repo=repo573 )574 review_payload = gh_api_list_paginated(endpoints["review"], repo=repo)575 576 issue_items = normalize_issue_comments(issue_payload)577 review_states = {578 str(item.get("id")): str(item.get("state") or "").upper()579 for item in review_payload580 if isinstance(item, dict) and item.get("id") not in (None, "")581 }582 pending_review_ids = {583 review_id584 for review_id, review_state in review_states.items()585 if review_state == "PENDING"586 }587 pending_review_comment_ids = {588 str(item.get("id"))589 for item in review_comment_payload590 if isinstance(item, dict)591 and item.get("id") not in (None, "")592 and str(item.get("pull_request_review_id") or "") in pending_review_ids593 }594 review_comment_items = normalize_review_comments(595 review_comment_payload, review_states596 )597 review_items = normalize_reviews(review_payload)598 all_items = issue_items + review_comment_items + review_items599 600 seen_issue = {str(x) for x in state.get("seen_issue_comment_ids") or []}601 seen_review_comment = {str(x) for x in state.get("seen_review_comment_ids") or []}602 seen_review = {str(x) for x in state.get("seen_review_ids") or []}603 seen_review_comment.difference_update(pending_review_comment_ids)604 seen_review.difference_update(pending_review_ids)605 606 # On a brand-new state file, surface existing review activity instead of607 # silently treating it as seen. This avoids missing already-published review608 # feedback when monitoring starts after comments were posted.609 610 new_items = []611 for item in all_items:612 item_id = item.get("id")613 if not item_id:614 continue615 author = item.get("author") or ""616 if not author:617 continue618 if is_bot_login(author):619 if not is_actionable_review_bot_login(author):620 continue621 elif not is_trusted_human_review_author(item, authenticated_login):622 continue623 624 kind = item["kind"]625 if kind == "issue_comment" and item_id in seen_issue:626 continue627 if kind == "review_comment" and item_id in seen_review_comment:628 continue629 if kind == "review" and item_id in seen_review:630 continue631 632 new_items.append(item)633 if kind == "issue_comment":634 seen_issue.add(item_id)635 elif kind == "review_comment":636 seen_review_comment.add(item_id)637 elif kind == "review":638 seen_review.add(item_id)639 640 new_items.sort(641 key=lambda item: (642 item.get("created_at") or "",643 item.get("kind") or "",644 item.get("id") or "",645 )646 )647 state["seen_issue_comment_ids"] = sorted(seen_issue)648 state["seen_review_comment_ids"] = sorted(seen_review_comment)649 state["seen_review_ids"] = sorted(seen_review)650 return new_items651 652 653def current_retry_count(state, head_sha):654 retries = state.get("retries_by_sha") or {}655 value = retries.get(head_sha, 0)656 try:657 return int(value)658 except (TypeError, ValueError):659 return 0660 661 662def set_retry_count(state, head_sha, count):663 retries = state.get("retries_by_sha")664 if not isinstance(retries, dict):665 retries = {}666 retries[head_sha] = int(count)667 state["retries_by_sha"] = retries668 669 670def unique_actions(actions):671 out = []672 seen = set()673 for action in actions:674 if action not in seen:675 out.append(action)676 seen.add(action)677 return out678 679 680def is_pr_ready_to_merge(pr, checks_summary, new_review_items):681 if pr["closed"] or pr["merged"]:682 return False683 if not checks_summary["all_terminal"]:684 return False685 if checks_summary["failed_count"] > 0 or checks_summary["pending_count"] > 0:686 return False687 if new_review_items:688 return False689 if str(pr.get("mergeable") or "") != "MERGEABLE":690 return False691 if str(pr.get("merge_state_status") or "") in MERGE_CONFLICT_OR_BLOCKING_STATES:692 return False693 if str(pr.get("review_decision") or "") in MERGE_BLOCKING_REVIEW_DECISIONS:694 return False695 return True696 697 698def recommend_actions(699 pr,700 checks_summary,701 failed_runs,702 failed_jobs,703 new_review_items,704 retries_used,705 max_retries,706):707 actions = []708 if pr["closed"] or pr["merged"]:709 if new_review_items:710 actions.append("process_review_comment")711 actions.append("stop_pr_closed")712 return unique_actions(actions)713 714 if is_pr_ready_to_merge(pr, checks_summary, new_review_items):715 actions.append("ready_to_merge")716 return unique_actions(actions)717 718 if new_review_items:719 actions.append("process_review_comment")720 721 has_failed_pr_checks = checks_summary["failed_count"] > 0 or bool(failed_jobs)722 if has_failed_pr_checks:723 if checks_summary["all_terminal"] and retries_used >= max_retries:724 actions.append("stop_exhausted_retries")725 else:726 actions.append("diagnose_ci_failure")727 if (728 checks_summary["all_terminal"]729 and failed_runs730 and retries_used < max_retries731 ):732 actions.append("retry_failed_checks")733 734 if not actions:735 actions.append("idle")736 return unique_actions(actions)737 738 739def collect_snapshot(args):740 pr = resolve_pr(args.pr, repo_override=args.repo)741 state_path = (742 Path(args.state_file) if args.state_file else default_state_file_for(pr)743 )744 state, fresh_state = load_state(state_path)745 746 if not state.get("started_at"):747 state["started_at"] = int(time.time())748 749 authenticated_login = get_authenticated_login()750 new_review_items = fetch_new_review_items(751 pr,752 state,753 fresh_state=fresh_state,754 authenticated_login=authenticated_login,755 )756 # Surface review feedback before drilling into CI and mergeability details.757 # That keeps the babysitter responsive to new comments even when other758 # actions are also available.759 # `gh pr checks -R <repo>` requires an explicit PR/branch/url argument.760 # After resolving `--pr auto`, reuse the concrete PR number.761 checks = get_pr_checks(str(pr["number"]), repo=pr["repo"])762 checks_summary = summarize_checks(checks)763 workflow_runs = get_workflow_runs_for_sha(pr["repo"], pr["head_sha"])764 failed_runs = failed_runs_from_workflow_runs(workflow_runs, pr["head_sha"])765 failed_jobs = failed_jobs_from_workflow_runs(766 pr["repo"], workflow_runs, pr["head_sha"]767 )768 769 retries_used = current_retry_count(state, pr["head_sha"])770 actions = recommend_actions(771 pr,772 checks_summary,773 failed_runs,774 failed_jobs,775 new_review_items,776 retries_used,777 args.max_flaky_retries,778 )779 780 state["pr"] = {"repo": pr["repo"], "number": pr["number"]}781 state["last_seen_head_sha"] = pr["head_sha"]782 state["last_snapshot_at"] = int(time.time())783 save_state(state_path, state)784 785 snapshot = {786 "pr": pr,787 "checks": checks_summary,788 "failed_runs": failed_runs,789 "failed_jobs": failed_jobs,790 "new_review_items": new_review_items,791 "actions": actions,792 "retry_state": {793 "current_sha_retries_used": retries_used,794 "max_flaky_retries": args.max_flaky_retries,795 },796 }797 return snapshot, state_path798 799 800def retry_failed_now(args):801 snapshot, state_path = collect_snapshot(args)802 pr = snapshot["pr"]803 checks_summary = snapshot["checks"]804 failed_runs = snapshot["failed_runs"]805 retries_used = snapshot["retry_state"]["current_sha_retries_used"]806 max_retries = snapshot["retry_state"]["max_flaky_retries"]807 808 result = {809 "snapshot": snapshot,810 "state_file": str(state_path),811 "rerun_attempted": False,812 "rerun_count": 0,813 "rerun_run_ids": [],814 "reason": None,815 }816 817 if pr["closed"] or pr["merged"]:818 result["reason"] = "pr_closed"819 return result820 if checks_summary["failed_count"] <= 0:821 result["reason"] = "no_failed_pr_checks"822 return result823 if not failed_runs:824 result["reason"] = "no_failed_runs"825 return result826 if not checks_summary["all_terminal"]:827 result["reason"] = "checks_still_pending"828 return result829 if retries_used >= max_retries:830 result["reason"] = "retry_budget_exhausted"831 return result832 833 for run in failed_runs:834 run_id = run.get("run_id")835 if run_id in (None, ""):836 continue837 gh_text(["run", "rerun", str(run_id), "--failed"], repo=pr["repo"])838 result["rerun_run_ids"].append(run_id)839 840 if result["rerun_run_ids"]:841 state, _ = load_state(state_path)842 new_count = current_retry_count(state, pr["head_sha"]) + 1843 set_retry_count(state, pr["head_sha"], new_count)844 state["last_snapshot_at"] = int(time.time())845 save_state(state_path, state)846 result["rerun_attempted"] = True847 result["rerun_count"] = len(result["rerun_run_ids"])848 result["reason"] = "rerun_triggered"849 else:850 result["reason"] = "failed_runs_missing_ids"851 852 return result853 854 855def print_json(obj):856 sys.stdout.write(json.dumps(obj, sort_keys=True) + "\n")857 sys.stdout.flush()858 859 860def print_event(event, payload):861 print_json({"event": event, "payload": payload})862 863 864def is_ci_green(snapshot):865 checks = snapshot.get("checks") or {}866 return (867 bool(checks.get("all_terminal"))868 and int(checks.get("failed_count") or 0) == 0869 and int(checks.get("pending_count") or 0) == 0870 )871 872 873def snapshot_change_key(snapshot):874 pr = snapshot.get("pr") or {}875 checks = snapshot.get("checks") or {}876 review_items = snapshot.get("new_review_items") or []877 return (878 str(pr.get("head_sha") or ""),879 str(pr.get("state") or ""),880 str(pr.get("mergeable") or ""),881 str(pr.get("merge_state_status") or ""),882 str(pr.get("review_decision") or ""),883 int(checks.get("passed_count") or 0),884 int(checks.get("failed_count") or 0),885 int(checks.get("pending_count") or 0),886 tuple(887 (str(item.get("kind") or ""), str(item.get("id") or ""))888 for item in review_items889 if isinstance(item, dict)890 ),891 tuple(snapshot.get("actions") or []),892 )893 894 895def run_watch(args):896 poll_seconds = args.poll_seconds897 last_change_key = None898 while True:899 snapshot, state_path = collect_snapshot(args)900 print_event(901 "snapshot",902 {903 "snapshot": snapshot,904 "state_file": str(state_path),905 "next_poll_seconds": poll_seconds,906 },907 )908 actions = set(snapshot.get("actions") or [])909 if "stop_pr_closed" in actions or "stop_exhausted_retries" in actions:910 print_event(911 "stop", {"actions": snapshot.get("actions"), "pr": snapshot.get("pr")}912 )913 return 0914 915 current_change_key = snapshot_change_key(snapshot)916 changed = current_change_key != last_change_key917 green = is_ci_green(snapshot)918 pr = snapshot.get("pr") or {}919 pr_open = not bool(pr.get("closed")) and not bool(pr.get("merged"))920 921 if not green or pr_open:922 poll_seconds = args.poll_seconds923 elif changed or last_change_key is None:924 poll_seconds = args.poll_seconds925 926 last_change_key = current_change_key927 time.sleep(poll_seconds)928 929 930def main():931 args = parse_args()932 try:933 if args.retry_failed_now:934 print_json(retry_failed_now(args))935 return 0936 if args.watch:937 return run_watch(args)938 snapshot, state_path = collect_snapshot(args)939 snapshot["state_file"] = str(state_path)940 print_json(snapshot)941 return 0942 except (GhCommandError, RuntimeError, ValueError) as err:943 sys.stderr.write(f"gh_pr_watch.py error: {err}\n")944 return 1945 except KeyboardInterrupt:946 sys.stderr.write("gh_pr_watch.py interrupted\n")947 return 130948 949 950if __name__ == "__main__":951 raise SystemExit(main())952 