← Files DeveloopARCHIVED FILE
skills/gh-autoreview-resolve/scripts/inspect_review_state.py
68.6 KB · Oct 3, 2026 · 06:30 UTC
#!/usr/bin/env python3
"""Inspect or watch one GitHub PR's automated-review state through gh CLI."""
from __future__ import annotations
import argparse
import json
import math
import os
import random
import re
import shutil
import stat
import subprocess
import sys
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
try:
import fcntl
except ImportError: # pragma: no cover - exercised on Windows hosts
fcntl = None
try:
import msvcrt
except ImportError: # pragma: no cover - exercised on POSIX hosts
msvcrt = None
PR_URL_RE = re.compile(r"^https://github\.com/([^/]+/[^/]+)/pull/(\d+)(?:/.*)?$")
REPO_RE = re.compile(r"^[^/\s]+/[^/\s]+$")
CONNECTOR_MARKERS = ("chatgpt-codex-connector",)
CLEAN_RESPONSE_MARKERS = (
"didn't find any major issues",
"did not find any major issues",
"no major issues found",
)
REVIEWED_COMMIT_RE = re.compile(r"reviewed commit:\s*`?([0-9a-f]{7,40})", re.IGNORECASE)
REVIEW_HEAD_RE = re.compile(r"review head:\s*`?([0-9a-f]{7,40})", re.IGNORECASE)
RETRY_AFTER_RE = re.compile(r"retry[- ]after[:= ]+(\d+)", re.IGNORECASE)
RATE_LIMIT_MARKERS = (
"rate limit",
"rate_limit",
"api rate limit exceeded",
)
SECONDARY_RATE_LIMIT_MARKERS = (
"secondary rate limit",
"abuse detection",
)
TERMINAL_OUTCOMES = {
"passed",
"review_feedback",
"review_response",
"pagination_incomplete",
"rate_limited",
"budget_exhausted",
"preflight_unavailable",
"observer_unavailable",
}
REQUIRED_CONNECTIONS = (
"comments",
"reviews",
"review_threads",
"pull_request_reactions",
"request_reactions",
"check_contexts",
)
class GhError(RuntimeError):
pass
class GhTimeout(GhError):
pass
@dataclass
class InspectionStop(RuntimeError):
outcome: str
reason: str
connection: str | None = None
cursor: str | None = None
retry_after: int | None = None
def __str__(self) -> str:
return self.reason
@dataclass
class InspectionConfig:
reserve: int = 200
query_cost_buffer: int = 5
max_requests: int = 40
max_pages: int = 20
max_seconds: float = 90.0
page_size: int = 100
@dataclass
class GraphQLSession:
config: InspectionConfig
runner: Callable[[list[str], float | None], str]
monotonic: Callable[[], float] = time.monotonic
deadline: float | None = None
started_at: float = field(init=False)
preflight_done: bool = False
preflight: dict[str, Any] = field(default_factory=dict)
request_count: int = 0
total_cost: int = 0
last_rate_limit: dict[str, Any] = field(default_factory=dict)
operations: list[dict[str, Any]] = field(default_factory=list)
page_counts: dict[str, int] = field(default_factory=dict)
status: str = "ok"
stop_reason: str | None = None
retry_after: int | None = None
query_seconds: float = 0.0
def __post_init__(self) -> None:
self.started_at = self.monotonic()
def run_preflight(self) -> None:
if self.preflight_done:
return
self.preflight_done = True
timeout = self._remaining_timeout()
if timeout <= 0:
self._stop(
"budget_exhausted",
"inspection deadline reached before REST rate-limit preflight",
)
try:
raw = self.runner(["api", "rate_limit", "--include"], timeout)
_, body = parse_included_response(raw)
payload = parse_json_output(body)
graphql = ((payload.get("resources") or {}).get("graphql") or {})
reset_at = graphql.get("reset")
self.preflight = {
"limit": graphql.get("limit"),
"cost": None,
"remaining": graphql.get("remaining"),
"used": graphql.get("used"),
"reset_at": epoch_to_iso(reset_at),
"source": "rest_rate_limit",
}
if graphql.get("remaining") is None:
self.preflight["error"] = "REST rate-limit response omitted GraphQL remaining quota"
self._stop(
"preflight_unavailable",
"REST GraphQL-quota preflight omitted the remaining value",
)
elif int(graphql["remaining"]) < (
self.config.reserve + self.config.query_cost_buffer
):
self._stop(
"rate_limited",
"GraphQL remaining quota "
f"({graphql['remaining']}) cannot preserve reserve ({self.config.reserve}) "
f"plus query-cost buffer ({self.config.query_cost_buffer})",
)
except InspectionStop:
raise
except GhTimeout as error:
self._stop("budget_exhausted", str(error))
except (GhError, TypeError, ValueError) as error:
detail = str(error)
if isinstance(error, GhError) and is_rate_limit_error(detail):
kind = "secondary" if is_secondary_rate_limit(detail) else "primary"
self._stop(
"rate_limited",
f"GitHub {kind} rate limit stopped REST preflight: {detail}",
retry_after=parse_retry_after(detail),
)
self.preflight = {
"limit": None,
"cost": None,
"remaining": None,
"used": None,
"reset_at": None,
"source": "rest_rate_limit",
"error": detail,
}
self._stop(
"preflight_unavailable",
f"REST GraphQL-quota preflight failed closed: {detail}",
)
def _stop(
self,
outcome: str,
reason: str,
*,
connection: str | None = None,
cursor: str | None = None,
retry_after: int | None = None,
) -> None:
self.status = outcome
self.stop_reason = reason
self.retry_after = retry_after
raise InspectionStop(
outcome,
reason,
connection=connection,
cursor=cursor,
retry_after=retry_after,
)
def _guard(self, connection: str | None, cursor: str | None) -> None:
if self.status != "ok":
self._stop(
self.status,
self.stop_reason or "inspection session has already stopped",
connection=connection,
cursor=cursor,
retry_after=self.retry_after,
)
if self.request_count >= self.config.max_requests:
self._stop(
"budget_exhausted",
f"GraphQL request ceiling ({self.config.max_requests}) reached",
connection=connection,
cursor=cursor,
)
if self._remaining_timeout() <= 0:
self._stop(
"budget_exhausted",
f"inspection execution/deadline ceiling ({self.config.max_seconds:g}s) reached",
connection=connection,
cursor=cursor,
)
remaining = self.last_rate_limit.get("remaining")
if remaining is None:
remaining = self.preflight.get("remaining")
if remaining is not None and int(remaining) < (
self.config.reserve + self.config.query_cost_buffer
):
self._stop(
"rate_limited",
"GraphQL remaining quota "
f"({remaining}) cannot preserve reserve ({self.config.reserve}) "
f"plus query-cost buffer ({self.config.query_cost_buffer})",
connection=connection,
cursor=cursor,
)
def _remaining_timeout(self) -> float:
remaining = self.config.max_seconds - self.query_seconds
if self.deadline is not None:
remaining = min(remaining, self.deadline - self.monotonic())
return max(0.0, remaining)
def graphql(
self,
operation: str,
query: str,
variables: dict[str, Any],
*,
connection: str | None = None,
cursor: str | None = None,
) -> dict[str, Any]:
self.run_preflight()
self._guard(connection, cursor)
arguments = ["api", "graphql", "--include", "-f", f"query={query}"]
for key, value in variables.items():
if value is None:
continue
flag = "-F" if isinstance(value, (bool, int)) else "-f"
rendered = str(value).lower() if isinstance(value, bool) else str(value)
arguments.extend([flag, f"{key}={rendered}"])
self.request_count += 1
query_started = self.monotonic()
try:
raw = self.runner(arguments, self._remaining_timeout())
except GhTimeout as error:
self.query_seconds += self.monotonic() - query_started
self.operations.append({
"operation": operation,
"cost": None,
"remaining": None,
"used": None,
"reset_at": None,
"error": str(error),
})
self._stop(
"budget_exhausted",
str(error),
connection=connection,
cursor=cursor,
)
except GhError as error:
self.query_seconds += self.monotonic() - query_started
detail = str(error)
if is_rate_limit_error(detail):
self.operations.append({
"operation": operation,
"cost": None,
"remaining": None,
"used": None,
"reset_at": None,
"error": detail,
})
retry_after = parse_retry_after(detail)
kind = "secondary" if is_secondary_rate_limit(detail) else "primary"
self._stop(
"rate_limited",
f"GitHub {kind} rate limit stopped {operation}: {detail}",
connection=connection,
cursor=cursor,
retry_after=retry_after,
)
raise
self.query_seconds += self.monotonic() - query_started
_, body = parse_included_response(raw)
payload = parse_json_output(body)
errors = payload.get("errors") or []
if errors:
detail = "; ".join(str(item.get("message") or item) for item in errors)
if is_rate_limit_error(detail):
kind = "secondary" if is_secondary_rate_limit(detail) else "primary"
self._stop(
"rate_limited",
f"GitHub {kind} rate limit stopped {operation}: {detail}",
connection=connection,
cursor=cursor,
retry_after=parse_retry_after(detail),
)
raise GhError(f"GitHub GraphQL {operation} failed: {detail}")
data = payload.get("data") or {}
rate_limit = data.get("rateLimit") or {}
normalized_rate = {
"cost": rate_limit.get("cost"),
"remaining": rate_limit.get("remaining"),
"used": rate_limit.get("used"),
"reset_at": rate_limit.get("resetAt"),
}
if rate_limit:
self.last_rate_limit = normalized_rate
if rate_limit.get("cost") is not None:
self.total_cost += int(rate_limit["cost"])
else:
normalized_rate["error"] = "GraphQL response omitted rateLimit telemetry"
self.operations.append({"operation": operation, **normalized_rate})
remaining = normalized_rate.get("remaining")
if remaining is not None and int(remaining) < self.config.reserve:
self._stop(
"rate_limited",
f"GraphQL query {operation} crossed reserve: {remaining} remaining, "
f"reserve {self.config.reserve}",
connection=connection,
cursor=cursor,
)
if self.query_seconds > self.config.max_seconds:
self._stop(
"budget_exhausted",
f"GraphQL query {operation} crossed execution-time ceiling "
f"({self.query_seconds:.3f}s > {self.config.max_seconds:g}s)",
connection=connection,
cursor=cursor,
)
if self.deadline is not None and self.monotonic() > self.deadline:
self._stop(
"budget_exhausted",
f"GraphQL query {operation} crossed the observer deadline",
connection=connection,
cursor=cursor,
)
return data
def record_page(self, connection: str) -> None:
self.page_counts[connection] = self.page_counts.get(connection, 0) + 1
def telemetry(self) -> dict[str, Any]:
last = self.last_rate_limit
preflight = self.preflight
telemetry_error = None
if not last:
telemetry_error = (
"No GraphQL query completed; query cost is unavailable. "
"Quota values come from the REST preflight."
)
return {
"status": self.status,
"reason": self.stop_reason,
"retry_after_seconds": self.retry_after,
"reserve": self.config.reserve,
"query_cost_buffer": self.config.query_cost_buffer,
"preflight": self.preflight,
"graphql": {
"cost": last.get("cost"),
"remaining": last.get("remaining", preflight.get("remaining")),
"used": last.get("used", preflight.get("used")),
"reset_at": last.get("reset_at", preflight.get("reset_at")),
"total_cost": self.total_cost,
"requests": self.request_count,
"query_seconds": self.query_seconds,
"error": telemetry_error,
"operations": self.operations,
},
}
def run_gh(arguments: list[str], timeout: float | None = None) -> str:
if shutil.which("gh") is None:
raise GhError("gh CLI is not installed or not on PATH")
try:
result = subprocess.run(
["gh", *arguments],
check=False,
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired as error:
raise GhTimeout(f"gh request timed out after {timeout:g}s") from error
if result.returncode:
detail = "\n".join(
part for part in (result.stderr.strip(), result.stdout.strip()) if part
) or "unknown gh error"
raise GhError(detail)
return result.stdout
def parse_json_output(output: str) -> Any:
try:
return json.loads(output)
except json.JSONDecodeError as error:
raise GhError(f"gh returned invalid JSON: {error}") from error
def parse_included_response(output: str) -> tuple[dict[str, str], str]:
normalized = output.replace("\r\n", "\n")
if not normalized.startswith("HTTP/"):
return {}, output
header_text, separator, body = normalized.partition("\n\n")
if not separator:
raise GhError("gh --include response omitted the HTTP body separator")
headers: dict[str, str] = {}
for line in header_text.splitlines()[1:]:
name, colon, value = line.partition(":")
if colon:
headers[name.strip().casefold()] = value.strip()
return headers, body
def resolve_target(target: str | None, repository: str | None) -> tuple[str, int]:
if target:
match = PR_URL_RE.match(target)
if match:
url_repository, number = match.groups()
if repository and repository != url_repository:
raise GhError("--repo does not match the pull request URL")
return url_repository, int(number)
if not target.isdigit() or int(target) < 1:
raise GhError("PR must be a positive number or a github.com pull request URL")
number = int(target)
else:
view = parse_json_output(run_gh(["pr", "view", "--json", "number,url"]))
match = PR_URL_RE.match(view.get("url", ""))
if not match:
raise GhError("could not resolve the current branch pull request")
url_repository, _ = match.groups()
return repository or url_repository, int(view["number"])
if repository is None:
repository = run_gh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]).strip()
if not REPO_RE.match(repository):
raise GhError("repository must use OWNER/REPO format")
return repository, number
def parse_time(value: str | None) -> datetime | None:
if value is None:
return None
normalized = value.strip()
if normalized.endswith("Z"):
normalized = normalized[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(normalized)
except ValueError as error:
raise GhError("--after must be an ISO-8601 timestamp") from error
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def epoch_to_iso(value: Any) -> str | None:
if value is None:
return None
return datetime.fromtimestamp(int(value), tz=timezone.utc).isoformat().replace("+00:00", "Z")
def at_or_after(value: str | None, minimum: datetime | None) -> bool:
if minimum is None:
return True
parsed = parse_time(value)
return parsed is not None and parsed >= minimum
def is_connector(author: str | None) -> bool:
login = (author or "").casefold()
return any(marker in login for marker in CONNECTOR_MARKERS)
def is_rate_limit_error(detail: str) -> bool:
folded = detail.casefold()
return any(marker in folded for marker in RATE_LIMIT_MARKERS)
def is_secondary_rate_limit(detail: str) -> bool:
folded = detail.casefold()
return any(marker in folded for marker in SECONDARY_RATE_LIMIT_MARKERS)
def parse_retry_after(detail: str) -> int | None:
match = RETRY_AFTER_RE.search(detail)
return int(match.group(1)) if match else None
def reaction_counts(
reactions: list[dict[str, Any]] | None,
minimum: datetime | None,
) -> dict[str, int]:
counts = {"eyes": 0, "thumbs_up": 0}
for reaction in reactions or []:
if not is_connector((reaction.get("user") or {}).get("login")):
continue
if not at_or_after(reaction.get("createdAt"), minimum):
continue
content = str(reaction.get("content", "")).upper()
if content == "EYES":
counts["eyes"] += 1
elif content == "THUMBS_UP":
counts["thumbs_up"] += 1
return counts
def merge_reaction_counts(*counts: dict[str, int]) -> dict[str, int]:
return {
key: sum(item.get(key, 0) for item in counts)
for key in ("eyes", "thumbs_up")
}
def body_reviews_head(
item: dict[str, Any],
head_oid: str | None,
marker: re.Pattern[str],
) -> bool:
if not head_oid:
return False
match = marker.search(str(item.get("body") or ""))
return bool(match and head_oid.casefold().startswith(match.group(1).casefold()))
def response_reviews_head(item: dict[str, Any], head_oid: str | None) -> bool:
if not head_oid:
return False
commit_oid = str((item.get("commit") or {}).get("oid") or "")
if commit_oid:
return commit_oid.casefold() == head_oid.casefold()
return body_reviews_head(item, head_oid, REVIEWED_COMMIT_RE)
def request_reviews_head(item: dict[str, Any], head_oid: str | None) -> bool:
return body_reviews_head(item, head_oid, REVIEW_HEAD_RE)
def clip(value: str | None, limit: int) -> str:
text = value or ""
if len(text) <= limit:
return text
return text[: max(0, limit - 1)] + "…"
def classify_review_state(
*,
unresolved_count: int,
eyes: int,
thumbs_up: int,
clean_response: bool,
connector_response: bool,
pagination_incomplete: bool = False,
forced_outcome: str | None = None,
) -> str:
if forced_outcome:
return forced_outcome
if unresolved_count:
return "review_feedback"
if pagination_incomplete:
return "pagination_incomplete"
if thumbs_up or clean_response:
return "passed"
if connector_response:
return "review_response"
if eyes:
return "in_progress"
return "not_started_or_pending"
def collect_pages(
session: GraphQLSession,
connection: str,
fetch_page: Callable[[str | None], dict[str, Any]],
*,
initial_cursor: str | None = None,
) -> tuple[list[dict[str, Any]], int | None]:
nodes: list[dict[str, Any]] = []
cursor = initial_cursor
requested_cursors: set[str | None] = set()
total_count: int | None = None
while True:
page_number = session.page_counts.get(connection, 0)
if page_number >= session.config.max_pages:
session._stop(
"budget_exhausted",
f"page ceiling ({session.config.max_pages}) reached for {connection}",
connection=connection,
cursor=cursor,
)
if cursor in requested_cursors:
session._stop(
"pagination_incomplete",
f"cursor made no progress for {connection}: {cursor!r} was already requested",
connection=connection,
cursor=cursor,
)
requested_cursors.add(cursor)
page = fetch_page(cursor)
session.record_page(connection)
nodes.extend(page.get("nodes") or [])
if page.get("totalCount") is not None:
total_count = int(page["totalCount"])
page_info = page.get("pageInfo") or {}
if not page_info.get("hasNextPage"):
return nodes, total_count
next_cursor = page_info.get("endCursor")
if not next_cursor or next_cursor == cursor or next_cursor in requested_cursors:
session._stop(
"pagination_incomplete",
f"cursor made no progress for {connection}: next cursor {next_cursor!r}",
connection=connection,
cursor=next_cursor,
)
cursor = str(next_cursor)
RATE_LIMIT_FRAGMENT = "rateLimit { cost remaining used resetAt }"
BASE_QUERY = rf"""
query Base($owner:String!, $name:String!, $number:Int!) {{
repository(owner:$owner, name:$name) {{
pullRequest(number:$number) {{
id number url state isDraft headRefOid mergeStateStatus updatedAt
commits(last:1) {{
nodes {{
commit {{
oid
statusCheckRollup {{ state }}
}}
}}
}}
}}
}}
{RATE_LIMIT_FRAGMENT}
}}
"""
COMMENTS_QUERY = rf"""
query Comments($owner:String!, $name:String!, $number:Int!, $pageSize:Int!, $cursor:String) {{
repository(owner:$owner, name:$name) {{
pullRequest(number:$number) {{
comments(first:$pageSize, after:$cursor) {{
totalCount
pageInfo {{ hasNextPage endCursor }}
nodes {{ id databaseId url body createdAt author {{ login }} }}
}}
}}
}}
{RATE_LIMIT_FRAGMENT}
}}
"""
REVIEWS_QUERY = rf"""
query Reviews($owner:String!, $name:String!, $number:Int!, $pageSize:Int!, $cursor:String) {{
repository(owner:$owner, name:$name) {{
pullRequest(number:$number) {{
reviews(first:$pageSize, after:$cursor) {{
totalCount
pageInfo {{ hasNextPage endCursor }}
nodes {{
databaseId url body state submittedAt author {{ login }} commit {{ oid }}
}}
}}
}}
}}
{RATE_LIMIT_FRAGMENT}
}}
"""
THREADS_QUERY = rf"""
query Threads($owner:String!, $name:String!, $number:Int!, $pageSize:Int!, $cursor:String) {{
repository(owner:$owner, name:$name) {{
pullRequest(number:$number) {{
reviewThreads(first:$pageSize, after:$cursor) {{
totalCount
pageInfo {{ hasNextPage endCursor }}
nodes {{
id isResolved
comments(first:$pageSize) {{
totalCount
pageInfo {{ hasNextPage endCursor }}
nodes {{
databaseId url body path line originalLine createdAt author {{ login }}
}}
}}
}}
}}
}}
}}
{RATE_LIMIT_FRAGMENT}
}}
"""
THREAD_COMMENTS_QUERY = rf"""
query ThreadComments($threadId:ID!, $pageSize:Int!, $cursor:String) {{
node(id:$threadId) {{
... on PullRequestReviewThread {{
comments(first:$pageSize, after:$cursor) {{
totalCount
pageInfo {{ hasNextPage endCursor }}
nodes {{
databaseId url body path line originalLine createdAt author {{ login }}
}}
}}
}}
}}
{RATE_LIMIT_FRAGMENT}
}}
"""
PR_REACTIONS_QUERY = rf"""
query PullRequestReactions($owner:String!, $name:String!, $number:Int!, $pageSize:Int!, $cursor:String) {{
repository(owner:$owner, name:$name) {{
pullRequest(number:$number) {{
reactions(first:$pageSize, after:$cursor) {{
totalCount
pageInfo {{ hasNextPage endCursor }}
nodes {{ content createdAt user {{ login }} }}
}}
}}
}}
{RATE_LIMIT_FRAGMENT}
}}
"""
REQUEST_REACTIONS_QUERY = rf"""
query RequestReactions($requestId:ID!, $pageSize:Int!, $cursor:String) {{
node(id:$requestId) {{
... on IssueComment {{
reactions(first:$pageSize, after:$cursor) {{
totalCount
pageInfo {{ hasNextPage endCursor }}
nodes {{ content createdAt user {{ login }} }}
}}
}}
}}
{RATE_LIMIT_FRAGMENT}
}}
"""
CHECKS_QUERY = rf"""
query Checks($owner:String!, $name:String!, $number:Int!, $pageSize:Int!, $cursor:String) {{
repository(owner:$owner, name:$name) {{
pullRequest(number:$number) {{
headRefOid
commits(last:1) {{
nodes {{
commit {{
oid
statusCheckRollup {{
state
contexts(first:$pageSize, after:$cursor) {{
totalCount
pageInfo {{ hasNextPage endCursor }}
nodes {{
__typename
... on CheckRun {{ name status conclusion detailsUrl }}
... on StatusContext {{ context state targetUrl }}
}}
}}
}}
}}
}}
}}
}}
}}
{RATE_LIMIT_FRAGMENT}
}}
"""
TRANSITION_QUERY = rf"""
query Transition($owner:String!, $name:String!, $number:Int!, $requestId:ID!, $hasRequest:Boolean!) {{
repository(owner:$owner, name:$name) {{
pullRequest(number:$number) {{
headRefOid updatedAt
comments(last:1) {{ totalCount nodes {{ id createdAt }} }}
reviews(last:1) {{ totalCount nodes {{ databaseId submittedAt }} }}
reviewThreads(last:20) {{
totalCount
nodes {{ id isResolved comments(last:1) {{ totalCount }} }}
}}
reactions(last:20) {{ totalCount nodes {{ content createdAt user {{ login }} }} }}
commits(last:1) {{
nodes {{
commit {{
statusCheckRollup {{
state
contexts(last:20) {{
totalCount
nodes {{
__typename
... on CheckRun {{ name status conclusion detailsUrl }}
... on StatusContext {{ context state targetUrl }}
}}
}}
}}
}}
}}
}}
}}
}}
request: node(id:$requestId) @include(if:$hasRequest) {{
... on IssueComment {{
reactions(last:20) {{ totalCount nodes {{ content createdAt user {{ login }} }} }}
}}
}}
{RATE_LIMIT_FRAGMENT}
}}
"""
def pr_connection(data: dict[str, Any], name: str) -> dict[str, Any]:
pr = (((data.get("repository") or {}).get("pullRequest")) or {})
return pr.get(name) or {}
def base_variables(repository: str, number: int) -> dict[str, Any]:
owner, name = repository.split("/", 1)
return {"owner": owner, "name": name, "number": number}
def page_variables(
repository: str,
number: int,
page_size: int,
cursor: str | None,
) -> dict[str, Any]:
return {**base_variables(repository, number), "pageSize": page_size, "cursor": cursor}
def collect_check_snapshot(
session: GraphQLSession,
repository: str,
number: int,
expected_head_oid: str | None,
connection: str,
) -> tuple[list[dict[str, Any]], int | None, str | None, str | None]:
metadata: dict[str, str | None] = {"head_oid": None, "rollup_state": None}
def fetch_page(cursor: str | None) -> dict[str, Any]:
check_data = session.graphql(
"Checks",
CHECKS_QUERY,
page_variables(repository, number, session.config.page_size, cursor),
connection=connection,
cursor=cursor,
)
check_pr = ((check_data.get("repository") or {}).get("pullRequest") or {})
if check_pr.get("headRefOid") != expected_head_oid:
session._stop(
"pagination_incomplete",
"pull request head changed while check contexts were being collected",
connection=connection,
cursor=cursor,
)
commits = (check_pr.get("commits") or {}).get("nodes") or [{}]
commit = commits[-1].get("commit") or {}
rollup = commit.get("statusCheckRollup") or {}
metadata["head_oid"] = commit.get("oid")
metadata["rollup_state"] = rollup.get("state")
return rollup.get("contexts") or {}
contexts, total = collect_pages(session, connection, fetch_page)
return contexts, total, metadata["head_oid"], metadata["rollup_state"]
def latest_marker(items: list[dict[str, Any]], *fields: str) -> dict[str, Any] | None:
if not items:
return None
latest = items[-1]
return {field: latest.get(field) for field in fields}
def recent_thread_markers(threads: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [
{
"id": thread.get("id"),
"isResolved": thread.get("isResolved"),
"comments": {
"totalCount": (thread.get("comments") or {}).get("totalCount")
},
}
for thread in threads[-20:]
]
def fingerprint_json(payload: dict[str, Any]) -> str:
return json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def incomplete_result(
repository: str,
number: int,
session: GraphQLSession,
stop: InspectionStop,
) -> dict[str, Any]:
return {
"repository": repository,
"inspection": {
"outcome": stop.outcome,
"complete": False,
"reason": stop.reason,
},
"pr": {
"number": number,
"url": f"https://github.com/{repository}/pull/{number}",
"state": None,
"is_draft": None,
"head_oid": None,
"merge_state": None,
"updated_at": None,
},
"trigger": {
"surface": None,
"request_comment": None,
"after": None,
"reactions": {"eyes": 0, "thumbs_up": 0},
"observed_reactions": {"eyes": 0, "thumbs_up": 0},
"reaction_head_anchored": False,
"ignored_thumbs_up": 0,
},
"review": {
"outcome": stop.outcome,
"clean_response": False,
"stale_clean_response": False,
"connector_response": False,
"unresolved_count": None,
"unresolved_threads": [],
"connector_issue_comments": [],
"connector_reviews": [],
},
"checks": {"head_oid": None, "rollup_state": None, "contexts": []},
"rate_limit": session.telemetry(),
"pagination": {
"complete": False,
"pages": session.page_counts,
"unfinished": [{
"connection": stop.connection or "inspection",
"cursor": stop.cursor,
"reason": stop.reason,
}],
},
"pagination_incomplete": True,
}
def inspect(
repository: str,
number: int,
after: datetime | None,
body_limit: int,
*,
config: InspectionConfig | None = None,
runner: Callable[[list[str], float | None], str] = run_gh,
session: GraphQLSession | None = None,
) -> dict[str, Any]:
session = session or GraphQLSession(config or InspectionConfig(), runner)
variables = base_variables(repository, number)
try:
data = session.graphql("Base", BASE_QUERY, variables, connection="base")
except InspectionStop as stop:
return incomplete_result(repository, number, session, stop)
pr = (((data.get("repository") or {}).get("pullRequest")) or None)
if pr is None:
raise GhError(f"pull request {repository}#{number} was not found or is not accessible")
incomplete: InspectionStop | None = None
comments: list[dict[str, Any]] = []
reviews: list[dict[str, Any]] = []
threads: list[dict[str, Any]] = []
pr_reactions: list[dict[str, Any]] = []
request_reactions: list[dict[str, Any]] = []
contexts: list[dict[str, Any]] = []
totals: dict[str, int | None] = {}
base_commit = (((pr.get("commits") or {}).get("nodes") or [{}])[-1].get("commit") or {})
checks_head_oid = base_commit.get("oid")
checks_rollup_state = (base_commit.get("statusCheckRollup") or {}).get("state")
try:
comments, totals["comments"] = collect_pages(
session,
"comments",
lambda cursor: pr_connection(
session.graphql(
"Comments",
COMMENTS_QUERY,
page_variables(repository, number, session.config.page_size, cursor),
connection="comments",
cursor=cursor,
),
"comments",
),
)
reviews, totals["reviews"] = collect_pages(
session,
"reviews",
lambda cursor: pr_connection(
session.graphql(
"Reviews",
REVIEWS_QUERY,
page_variables(repository, number, session.config.page_size, cursor),
connection="reviews",
cursor=cursor,
),
"reviews",
),
)
threads, totals["review_threads"] = collect_pages(
session,
"review_threads",
lambda cursor: pr_connection(
session.graphql(
"Threads",
THREADS_QUERY,
page_variables(repository, number, session.config.page_size, cursor),
connection="review_threads",
cursor=cursor,
),
"reviewThreads",
),
)
pr_reactions, totals["pull_request_reactions"] = collect_pages(
session,
"pull_request_reactions",
lambda cursor: pr_connection(
session.graphql(
"PullRequestReactions",
PR_REACTIONS_QUERY,
page_variables(repository, number, session.config.page_size, cursor),
connection="pull_request_reactions",
cursor=cursor,
),
"reactions",
),
)
eligible_requests = [
comment for comment in comments
if str(comment.get("body") or "").lstrip().startswith("@codex review")
and at_or_after(comment.get("createdAt"), after)
]
active_request = max(
eligible_requests,
key=lambda item: item.get("createdAt") or "",
default=None,
)
if active_request:
request_reactions, totals["request_reactions"] = collect_pages(
session,
"request_reactions",
lambda cursor: (
session.graphql(
"RequestReactions",
REQUEST_REACTIONS_QUERY,
{
"requestId": active_request["id"],
"pageSize": session.config.page_size,
"cursor": cursor,
},
connection="request_reactions",
cursor=cursor,
).get("node") or {}
).get("reactions") or {},
)
else:
totals["request_reactions"] = 0
session.page_counts["request_reactions"] = 0
(
contexts,
totals["check_contexts"],
checks_head_oid,
checks_rollup_state,
) = collect_check_snapshot(
session,
repository,
number,
pr.get("headRefOid"),
"check_contexts",
)
for thread in threads:
initial = thread.get("comments") or {}
thread["_all_comments"] = list(initial.get("nodes") or [])
page_info = initial.get("pageInfo") or {}
if not page_info.get("hasNextPage"):
continue
connection = f"review_thread_comments:{thread.get('id')}"
cursor = page_info.get("endCursor")
if not cursor:
session._stop(
"pagination_incomplete",
f"nested review-thread comments omitted an end cursor for {thread.get('id')}",
connection=connection,
)
more_comments, _ = collect_pages(
session,
connection,
lambda nested_cursor, thread_id=thread["id"]: (
session.graphql(
"ThreadComments",
THREAD_COMMENTS_QUERY,
{
"threadId": thread_id,
"pageSize": session.config.page_size,
"cursor": nested_cursor,
},
connection=connection,
cursor=nested_cursor,
).get("node") or {}
).get("comments") or {},
initial_cursor=str(cursor),
)
thread["_all_comments"].extend(more_comments)
(
verified_contexts,
verified_total,
verified_head_oid,
verified_rollup_state,
) = collect_check_snapshot(
session,
repository,
number,
pr.get("headRefOid"),
"check_contexts_verify",
)
if (
verified_contexts != contexts
or verified_total != totals.get("check_contexts")
or verified_head_oid != checks_head_oid
or verified_rollup_state != checks_rollup_state
):
session._stop(
"pagination_incomplete",
"check contexts changed during the paginated snapshot; rerun after they stabilize",
connection="check_contexts_verify",
)
expected_fingerprint = fingerprint_json({
"head_oid": pr.get("headRefOid"),
"updated_at": pr.get("updatedAt"),
"comments": totals.get("comments"),
"reviews": totals.get("reviews"),
"review_threads": totals.get("review_threads"),
"pull_request_reactions": totals.get("pull_request_reactions"),
"request_reactions": totals.get("request_reactions"),
"check_contexts": totals.get("check_contexts"),
"rollup_state": checks_rollup_state,
"latest_comment": latest_marker(comments, "id", "createdAt"),
"latest_review": latest_marker(reviews, "databaseId", "submittedAt"),
"recent_review_threads": recent_thread_markers(threads),
"recent_pull_request_reactions": pr_reactions[-20:],
"recent_request_reactions": request_reactions[-20:],
"recent_check_contexts": contexts[-20:],
})
final_fingerprint = transition_probe(
repository,
number,
active_request.get("id") if active_request else None,
pr["id"],
session,
)
if final_fingerprint != expected_fingerprint:
session._stop(
"pagination_incomplete",
"pull request state changed during the paginated snapshot; rerun after it stabilizes",
connection="snapshot_consistency",
)
except InspectionStop as stop:
incomplete = stop
eligible_requests = [
comment for comment in comments
if str(comment.get("body") or "").lstrip().startswith("@codex review")
and at_or_after(comment.get("createdAt"), after)
]
active_request = max(
eligible_requests,
key=lambda item: item.get("createdAt") or "",
default=None,
)
trigger_time = parse_time(active_request.get("createdAt")) if active_request else after
current_head_oid = pr.get("headRefOid")
reaction_head_anchored = bool(
active_request and request_reviews_head(active_request, current_head_oid)
)
observed_reactions = merge_reaction_counts(
reaction_counts(pr_reactions, trigger_time),
reaction_counts(request_reactions, trigger_time),
)
trigger_reactions = dict(observed_reactions)
ignored_thumbs_up = 0
if not reaction_head_anchored:
ignored_thumbs_up = trigger_reactions["thumbs_up"]
trigger_reactions["thumbs_up"] = 0
connector_issue_comments = [
item for item in comments
if is_connector((item.get("author") or {}).get("login"))
and at_or_after(item.get("createdAt"), trigger_time)
]
connector_reviews = [
item for item in reviews
if is_connector((item.get("author") or {}).get("login"))
and at_or_after(item.get("submittedAt"), trigger_time)
]
unresolved_threads = []
connector_thread_response = False
for thread in threads:
thread_comments = thread.get("_all_comments")
if thread_comments is None:
thread_comments = (thread.get("comments") or {}).get("nodes") or []
connector_thread_response = connector_thread_response or any(
is_connector((item.get("author") or {}).get("login"))
and at_or_after(item.get("createdAt"), trigger_time)
for item in thread_comments
)
if thread.get("isResolved"):
continue
unresolved_threads.append({
"id": thread.get("id"),
"comments": [
{
"database_id": item.get("databaseId"),
"author": (item.get("author") or {}).get("login"),
"created_at": item.get("createdAt"),
"path": item.get("path"),
"line": item.get("line"),
"original_line": item.get("originalLine"),
"url": item.get("url"),
"body": clip(item.get("body"), body_limit),
}
for item in thread_comments
],
})
connector_responses = [*connector_issue_comments, *connector_reviews]
clean_response_candidates = [
item for item in connector_responses
if any(
marker in str(item.get("body") or "").casefold()
for marker in CLEAN_RESPONSE_MARKERS
)
]
clean_response = any(
response_reviews_head(item, current_head_oid)
for item in clean_response_candidates
)
stale_clean_response = any(
not response_reviews_head(item, current_head_oid)
for item in clean_response_candidates
)
connector_response = bool(
connector_issue_comments or connector_reviews or connector_thread_response
)
pagination_complete = incomplete is None
forced_outcome = (
incomplete.outcome
if incomplete and incomplete.outcome in {"rate_limited", "budget_exhausted"}
else None
)
outcome = classify_review_state(
unresolved_count=len(unresolved_threads),
eyes=trigger_reactions["eyes"],
thumbs_up=trigger_reactions["thumbs_up"],
clean_response=clean_response,
connector_response=connector_response,
pagination_incomplete=not pagination_complete,
forced_outcome=forced_outcome,
)
unfinished = []
if incomplete:
unfinished.append({
"connection": incomplete.connection or "inspection",
"cursor": incomplete.cursor,
"reason": incomplete.reason,
})
return {
"repository": repository,
"inspection": {
"outcome": "complete" if pagination_complete else incomplete.outcome,
"complete": pagination_complete,
"reason": None if pagination_complete else incomplete.reason,
},
"pr": {
"number": pr.get("number"),
"url": pr.get("url"),
"state": pr.get("state"),
"is_draft": pr.get("isDraft"),
"head_oid": pr.get("headRefOid"),
"merge_state": pr.get("mergeStateStatus"),
"updated_at": pr.get("updatedAt"),
"node_id": pr.get("id"),
},
"trigger": {
"surface": "comment" if active_request else "pull_request",
"request_comment": None if active_request is None else {
"node_id": active_request.get("id"),
"database_id": active_request.get("databaseId"),
"url": active_request.get("url"),
"body": clip(active_request.get("body"), body_limit),
"created_at": active_request.get("createdAt"),
},
"after": after.isoformat().replace("+00:00", "Z") if after else None,
"reactions": trigger_reactions,
"observed_reactions": observed_reactions,
"reaction_head_anchored": reaction_head_anchored,
"ignored_thumbs_up": ignored_thumbs_up,
},
"review": {
"outcome": outcome,
"clean_response": clean_response,
"stale_clean_response": stale_clean_response,
"connector_response": connector_response,
"unresolved_count": len(unresolved_threads) if pagination_complete else None,
"unresolved_threads": unresolved_threads,
"connector_issue_comments": [
{
"database_id": item.get("databaseId"),
"created_at": item.get("createdAt"),
"url": item.get("url"),
"body": clip(item.get("body"), body_limit),
}
for item in connector_issue_comments
],
"connector_reviews": [
{
"database_id": item.get("databaseId"),
"submitted_at": item.get("submittedAt"),
"state": item.get("state"),
"commit_oid": (item.get("commit") or {}).get("oid"),
"url": item.get("url"),
"body": clip(item.get("body"), body_limit),
}
for item in connector_reviews
],
},
"checks": {
"head_oid": checks_head_oid,
"rollup_state": checks_rollup_state,
"contexts": contexts,
},
"activity": {
"comments": totals.get("comments"),
"reviews": totals.get("reviews"),
"review_threads": totals.get("review_threads"),
"pull_request_reactions": totals.get("pull_request_reactions"),
"request_reactions": totals.get("request_reactions"),
"check_contexts": totals.get("check_contexts"),
"latest_comment": latest_marker(comments, "id", "createdAt"),
"latest_review": latest_marker(reviews, "databaseId", "submittedAt"),
"recent_review_threads": recent_thread_markers(threads),
"recent_pull_request_reactions": pr_reactions[-20:],
"recent_request_reactions": request_reactions[-20:],
"recent_check_contexts": contexts[-20:],
},
"rate_limit": session.telemetry(),
"pagination": {
"complete": pagination_complete,
"pages": session.page_counts,
"unfinished": unfinished,
},
"pagination_incomplete": not pagination_complete,
}
def transition_fingerprint_from_result(result: dict[str, Any]) -> str:
pr = result.get("pr") or {}
activity = result.get("activity") or {}
checks = result.get("checks") or {}
payload = {
"head_oid": pr.get("head_oid"),
"updated_at": pr.get("updated_at"),
"comments": activity.get("comments"),
"reviews": activity.get("reviews"),
"review_threads": activity.get("review_threads"),
"pull_request_reactions": activity.get("pull_request_reactions"),
"request_reactions": activity.get("request_reactions"),
"check_contexts": activity.get("check_contexts"),
"rollup_state": checks.get("rollup_state"),
"latest_comment": activity.get("latest_comment"),
"latest_review": activity.get("latest_review"),
"recent_review_threads": activity.get("recent_review_threads") or [],
"recent_pull_request_reactions": activity.get("recent_pull_request_reactions") or [],
"recent_request_reactions": activity.get("recent_request_reactions") or [],
"recent_check_contexts": activity.get("recent_check_contexts") or [],
}
return fingerprint_json(payload)
def transition_probe(
repository: str,
number: int,
request_id: str | None,
pr_id: str,
session: GraphQLSession,
) -> str:
data = session.graphql(
"Transition",
TRANSITION_QUERY,
{
**base_variables(repository, number),
"requestId": request_id or pr_id,
"hasRequest": bool(request_id),
},
connection="transition_probe",
)
pr = (((data.get("repository") or {}).get("pullRequest")) or {})
request = data.get("request") or {}
commit = (((pr.get("commits") or {}).get("nodes") or [{}])[-1].get("commit") or {})
rollup = commit.get("statusCheckRollup") or {}
pr_reactions = pr.get("reactions") or {}
request_reactions = request.get("reactions") or {}
comments = pr.get("comments") or {}
reviews = pr.get("reviews") or {}
review_threads = pr.get("reviewThreads") or {}
contexts = rollup.get("contexts") or {}
payload = {
"head_oid": pr.get("headRefOid"),
"updated_at": pr.get("updatedAt"),
"comments": comments.get("totalCount"),
"reviews": reviews.get("totalCount"),
"review_threads": review_threads.get("totalCount"),
"pull_request_reactions": pr_reactions.get("totalCount"),
"request_reactions": request_reactions.get("totalCount") if request_id else 0,
"check_contexts": contexts.get("totalCount"),
"rollup_state": rollup.get("state"),
"latest_comment": latest_marker(comments.get("nodes") or [], "id", "createdAt"),
"latest_review": latest_marker(reviews.get("nodes") or [], "databaseId", "submittedAt"),
"recent_review_threads": recent_thread_markers(review_threads.get("nodes") or []),
"recent_pull_request_reactions": pr_reactions.get("nodes") or [],
"recent_request_reactions": request_reactions.get("nodes") or [],
"recent_check_contexts": contexts.get("nodes") or [],
}
return fingerprint_json(payload)
def backoff_seconds(
unchanged_polls: int,
initial: float,
maximum: float,
jitter_ratio: float,
random_value: float,
) -> float:
base = min(maximum, initial * (2 ** max(0, unchanged_polls)))
factor = 1 + ((random_value * 2) - 1) * jitter_ratio
return max(1.0, min(maximum, base * factor))
def secure_observer_lock_directory(directory: Path | None = None) -> Path:
if directory is None:
runtime_directory = os.environ.get("XDG_RUNTIME_DIR")
root = (
Path(runtime_directory) / "develoop-observer-locks"
if runtime_directory
else Path.home() / ".develoop-observer-locks"
)
else:
root = directory
try:
root.mkdir(mode=0o700, parents=True, exist_ok=True)
metadata = root.lstat()
except OSError as error:
raise GhError(f"could not prepare observer lock directory {root}: {error}") from error
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
raise GhError(f"observer lock directory must be a real directory: {root}")
if hasattr(os, "getuid") and metadata.st_uid != os.getuid():
raise GhError(f"observer lock directory is not owned by the current user: {root}")
try:
root.chmod(0o700)
except OSError as error:
raise GhError(f"could not secure observer lock directory {root}: {error}") from error
return root
class ObserverLock:
def __init__(self, repository: str, number: int, directory: Path | None = None) -> None:
safe_repo = re.sub(r"[^A-Za-z0-9_.-]", "_", repository)
self.path = secure_observer_lock_directory(directory) / f"develoop-{safe_repo}-{number}.lock"
self.owned = False
self.descriptor: int | None = None
def acquire(self) -> None:
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(self.path, flags, 0o600)
except OSError as error:
raise InspectionStop(
"observer_unavailable",
f"observer lock path is unsafe or unavailable: {self.path}: {error}",
) from error
metadata = os.fstat(descriptor)
if (
not stat.S_ISREG(metadata.st_mode)
or metadata.st_nlink != 1
or (hasattr(os, "getuid") and metadata.st_uid != os.getuid())
):
os.close(descriptor)
raise InspectionStop(
"observer_unavailable",
f"observer lock must be a single-link regular file owned by the current user: {self.path}",
)
if hasattr(os, "fchmod"):
os.fchmod(descriptor, 0o600)
try:
if fcntl is not None:
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
elif msvcrt is not None: # pragma: no cover - Windows-only path
if os.fstat(descriptor).st_size == 0:
os.write(descriptor, b"\0")
os.lseek(descriptor, 0, os.SEEK_SET)
msvcrt.locking(descriptor, msvcrt.LK_NBLCK, 1)
else: # pragma: no cover - Python always exposes one on supported hosts
os.close(descriptor)
raise InspectionStop(
"observer_unavailable",
"this host does not provide an advisory file-lock implementation",
)
except (BlockingIOError, OSError):
try:
os.lseek(descriptor, 0, os.SEEK_SET)
owner = os.read(descriptor, 4_096).decode("utf-8", errors="replace").strip("\0\n ")
finally:
os.close(descriptor)
raise InspectionStop(
"observer_active",
f"another observer owns {self.path}: {owner or 'owner unknown'}",
)
payload = json.dumps({"pid": os.getpid(), "started_at": utc_now()}).encode("utf-8")
os.ftruncate(descriptor, 0)
os.lseek(descriptor, 0, os.SEEK_SET)
os.write(descriptor, payload)
os.fsync(descriptor)
self.descriptor = descriptor
self.owned = True
def release(self) -> None:
if not self.owned or self.descriptor is None:
return
if fcntl is not None:
fcntl.flock(self.descriptor, fcntl.LOCK_UN)
elif msvcrt is not None: # pragma: no cover - Windows-only path
os.lseek(self.descriptor, 0, os.SEEK_SET)
msvcrt.locking(self.descriptor, msvcrt.LK_UNLCK, 1)
os.close(self.descriptor)
self.descriptor = None
self.owned = False
def __enter__(self) -> ObserverLock:
self.acquire()
return self
def __exit__(self, *_: Any) -> None:
self.release()
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def watch(
repository: str,
number: int,
after: datetime | None,
body_limit: int,
*,
config: InspectionConfig,
poll_initial: float,
poll_max: float,
max_wait: float,
full_refresh_interval: float,
jitter: float,
runner: Callable[[list[str], float | None], str] = run_gh,
sleep: Callable[[float], None] = time.sleep,
monotonic: Callable[[], float] = time.monotonic,
) -> dict[str, Any]:
lock = ObserverLock(repository, number)
try:
lock.acquire()
except InspectionStop as stop:
session = GraphQLSession(config, runner, monotonic=monotonic)
result = incomplete_result(repository, number, session, stop)
result["review"]["outcome"] = stop.outcome
result["observer"] = {"outcome": stop.outcome, "reason": stop.reason}
return result
started = monotonic()
deadline = started + max_wait
probes = 0
unchanged_polls = 0
last_full_snapshot = started
session = GraphQLSession(
config,
runner,
monotonic=monotonic,
deadline=deadline,
)
try:
result = inspect(
repository,
number,
after,
body_limit,
session=session,
)
while result["review"]["outcome"] not in TERMINAL_OUTCOMES:
elapsed = monotonic() - started
if elapsed >= max_wait:
result["observer"] = {
"outcome": "watch_timeout",
"elapsed_seconds": elapsed,
"probes": probes,
"unchanged_polls": unchanged_polls,
}
result["rate_limit"] = session.telemetry()
return result
delay = backoff_seconds(
unchanged_polls,
poll_initial,
poll_max,
jitter,
random.random(),
)
sleep(min(delay, max_wait - elapsed))
if monotonic() - started >= max_wait:
result["observer"] = {
"outcome": "watch_timeout",
"elapsed_seconds": monotonic() - started,
"probes": probes,
"unchanged_polls": unchanged_polls,
}
result["rate_limit"] = session.telemetry()
return result
baseline = transition_fingerprint_from_result(result)
request_comment = (result.get("trigger") or {}).get("request_comment") or {}
try:
fingerprint = transition_probe(
repository,
number,
request_comment.get("node_id"),
result["pr"]["node_id"],
session,
)
except InspectionStop as stop:
result["review"]["outcome"] = stop.outcome
result["inspection"] = {
"outcome": stop.outcome,
"complete": False,
"reason": stop.reason,
}
result["pagination_incomplete"] = True
result["pagination"]["complete"] = False
result["pagination"]["unfinished"].append({
"connection": stop.connection or "transition_probe",
"cursor": stop.cursor,
"reason": stop.reason,
})
result["rate_limit"] = session.telemetry()
result["observer"] = {
"outcome": stop.outcome,
"elapsed_seconds": monotonic() - started,
"probes": probes,
"unchanged_polls": unchanged_polls,
}
return result
probes += 1
if fingerprint == baseline:
unchanged_polls += 1
if monotonic() - last_full_snapshot >= full_refresh_interval:
result = inspect(
repository,
number,
after,
body_limit,
session=session,
)
last_full_snapshot = monotonic()
continue
unchanged_polls = 0
result = inspect(
repository,
number,
after,
body_limit,
session=session,
)
last_full_snapshot = monotonic()
result["observer"] = {
"outcome": result["review"]["outcome"],
"elapsed_seconds": monotonic() - started,
"probes": probes,
"unchanged_polls": unchanged_polls,
}
result["rate_limit"] = session.telemetry()
return result
finally:
lock.release()
def self_test() -> None:
cases = [
({"unresolved_count": 0, "eyes": 1, "thumbs_up": 0, "clean_response": False, "connector_response": False}, "in_progress"),
({"unresolved_count": 0, "eyes": 0, "thumbs_up": 1, "clean_response": False, "connector_response": False}, "passed"),
({"unresolved_count": 0, "eyes": 0, "thumbs_up": 0, "clean_response": True, "connector_response": True}, "passed"),
({"unresolved_count": 1, "eyes": 0, "thumbs_up": 1, "clean_response": True, "connector_response": True}, "review_feedback"),
({"unresolved_count": 0, "eyes": 0, "thumbs_up": 0, "clean_response": False, "connector_response": True}, "review_response"),
({"unresolved_count": 0, "eyes": 0, "thumbs_up": 0, "clean_response": False, "connector_response": False}, "not_started_or_pending"),
({"unresolved_count": 0, "eyes": 0, "thumbs_up": 1, "clean_response": True, "connector_response": True, "pagination_incomplete": True}, "pagination_incomplete"),
({"unresolved_count": 1, "eyes": 0, "thumbs_up": 0, "clean_response": False, "connector_response": False, "pagination_incomplete": True}, "review_feedback"),
({"unresolved_count": 0, "eyes": 0, "thumbs_up": 1, "clean_response": True, "connector_response": True, "forced_outcome": "budget_exhausted"}, "budget_exhausted"),
]
for inputs, expected in cases:
actual = classify_review_state(**inputs)
if actual != expected:
raise AssertionError(f"expected {expected}, got {actual} for {inputs}")
minimum = parse_time("2026-07-24T00:00:00Z")
combined = merge_reaction_counts(
reaction_counts([
{
"content": "EYES",
"createdAt": "2026-07-24T00:00:01Z",
"user": {"login": "chatgpt-codex-connector"},
},
{
"content": "THUMBS_UP",
"createdAt": "2026-07-23T23:59:59Z",
"user": {"login": "chatgpt-codex-connector"},
},
], minimum),
reaction_counts([
{
"content": "THUMBS_UP",
"createdAt": "2026-07-24T00:00:02Z",
"user": {"login": "chatgpt-codex-connector"},
},
], minimum),
)
if combined != {"eyes": 1, "thumbs_up": 1}:
raise AssertionError(f"expected combined reactions, got {combined}")
head_oid = "abcdef0123456789abcdef0123456789abcdef01"
if not request_reviews_head(
{"body": "@codex review\n\nReview head: `abcdef0`"},
head_oid,
):
raise AssertionError("expected a matching review request marker to anchor reactions")
if request_reviews_head(
{"body": "@codex review\n\nReview head: `1234567`"},
head_oid,
):
raise AssertionError("expected a stale review request marker to fail closed")
if request_reviews_head({"body": "@codex review"}, head_oid):
raise AssertionError("expected an unanchored review request to fail closed")
if not response_reviews_head({"commit": {"oid": head_oid}}, head_oid):
raise AssertionError("expected matching review commit to apply to the current head")
if not response_reviews_head({"body": "Reviewed commit: `abcdef0`"}, head_oid):
raise AssertionError("expected reviewed commit marker to apply to the current head")
if response_reviews_head({"body": "Didn't find any major issues."}, head_oid):
raise AssertionError("expected an unanchored response to fail closed")
if response_reviews_head({"commit": {"oid": "1234567"}}, head_oid):
raise AssertionError("expected a stale review commit to fail closed")
print("inspect_review_state self-test passed")
def positive_int(value: str) -> int:
parsed = int(value)
if parsed < 1:
raise argparse.ArgumentTypeError("must be at least 1")
return parsed
def non_negative_int(value: str) -> int:
parsed = int(value)
if parsed < 0:
raise argparse.ArgumentTypeError("must be at least 0")
return parsed
def positive_float(value: str) -> float:
parsed = float(value)
if not math.isfinite(parsed) or parsed <= 0:
raise argparse.ArgumentTypeError("must be finite and greater than 0")
return parsed
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pr", nargs="?", help="PR number or github.com pull request URL")
parser.add_argument("--repo", help="Repository in OWNER/REPO format")
parser.add_argument("--after", help="Ignore older review requests and connector events")
parser.add_argument("--body-limit", type=positive_int, default=4_000, help="Maximum body characters per item")
parser.add_argument("--compact", action="store_true", help="Emit compact JSON")
parser.add_argument("--self-test", action="store_true", help="Run state-classifier tests without GitHub")
parser.add_argument("--watch", action="store_true", help="Own the bounded adaptive polling loop")
parser.add_argument("--reserve", type=non_negative_int, default=200, help="GraphQL points to keep in reserve")
parser.add_argument("--query-cost-buffer", type=positive_int, default=5, help="Points budgeted before each GraphQL query")
parser.add_argument("--max-requests", type=positive_int, default=40, help="Maximum GraphQL requests per invocation")
parser.add_argument("--max-pages", type=positive_int, default=20, help="Maximum pages per connection")
parser.add_argument("--max-seconds", type=positive_float, default=90.0, help="Maximum GraphQL inspection time")
parser.add_argument("--page-size", type=positive_int, default=100, help="GraphQL connection page size (1-100)")
parser.add_argument("--poll-initial", type=positive_float, default=60.0, help="Initial watch delay in seconds")
parser.add_argument("--poll-max", type=positive_float, default=300.0, help="Maximum watch delay in seconds")
parser.add_argument("--max-wait", type=positive_float, default=1_200.0, help="Maximum total watch time in seconds")
parser.add_argument("--full-refresh-interval", type=positive_float, default=600.0, help="Maximum seconds between authoritative watch snapshots")
parser.add_argument("--jitter", type=float, default=0.15, help="Watch jitter ratio from 0 through 0.5")
args = parser.parse_args()
if args.self_test:
self_test()
return 0
if args.body_limit < 100:
parser.error("--body-limit must be at least 100")
if args.page_size > 100:
parser.error("--page-size must be at most 100")
if not 0 <= args.jitter <= 0.5:
parser.error("--jitter must be between 0 and 0.5")
if args.poll_max < args.poll_initial:
parser.error("--poll-max must be at least --poll-initial")
config = InspectionConfig(
reserve=args.reserve,
query_cost_buffer=args.query_cost_buffer,
max_requests=args.max_requests,
max_pages=args.max_pages,
max_seconds=args.max_seconds,
page_size=args.page_size,
)
try:
repository, number = resolve_target(args.pr, args.repo)
if args.watch:
result = watch(
repository,
number,
parse_time(args.after),
args.body_limit,
config=config,
poll_initial=args.poll_initial,
poll_max=args.poll_max,
max_wait=args.max_wait,
full_refresh_interval=args.full_refresh_interval,
jitter=args.jitter,
)
else:
result = inspect(
repository,
number,
parse_time(args.after),
args.body_limit,
config=config,
)
except GhError as error:
print(f"error: {error}", file=sys.stderr)
return 2
if args.compact:
print(json.dumps(result, ensure_ascii=False, separators=(",", ":")))
else:
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
SHA-256: 9e4f6710dc6a2402cd0fe1b577ce2c4397bc1a6fab0d1665506007c0126ca4c3