From ff78713dbf35591e45b8e7cd1c3fd04dd4a892df Mon Sep 17 00:00:00 2001 From: all-hands-bot Date: Wed, 23 Sep 2026 02:42:13 +0000 Subject: [PATCH 1/3] fix(review): bound scheduled PR review intake across repositories A scheduled scan resumed every outstanding all-hands-bot request it found and started one conversation for each, so a first scan over the OSS backlog could start an agent for hundreds of eligible pull requests at once and exhaust the deployment. The scan now starts at most `max_new_per_run` new review conversations, defaulting to 2, counted across every configured repository rather than reset per repository. Delivery reuses the existing machinery: eligible candidates are collected with the request event and head SHA #654 already keys deliveries on, ordered by the oldest outstanding request and then by repository and pull-request number, and started through the same `dispatcher.deliver`. The bound counts conversations a scan starts, so a deduplicated or already-running delivery reuses its runtime and consumes no slot, and a candidate whose exact-head checks are pending or failing posts its gate disposition and consumes none either. Reaching the bound still evaluates the remaining candidates and their gate comments, and a dispatch that raises is reported without aborting the scan. The explicit reviewer-request event path and the trigger-label scan are unchanged. Wires the bound through the worker's rendered config with the `max_new_per_run` key the other automations already use, documents it in SKILL.md and README.md, bumps the catalog entry and bundle 1.6.0 -> 1.7.0, and regenerates the catalogs. Part of #656. Stacked on #654. Co-authored-by: openhands (cherry picked from commit cb1b625e135a8cbe58bb6dfbf64ec7d5dfde4607) --- automations/bundle-index.js | 4 +- .../catalog/github-pr-reviewer/manifest.json | 21 +- skills/github-pr-reviewer/README.md | 3 + skills/github-pr-reviewer/SKILL.md | 32 +- skills/github-pr-reviewer/scripts/main.py | 18 +- skills/github-pr-reviewer/scripts/worker.py | 179 ++++++++- skills/index.js | 2 +- .../automations/github-pr-reviewer.json | 63 +-- tests/test_github_reviewer_delivery.py | 373 ++++++++++++++++++ 9 files changed, 644 insertions(+), 51 deletions(-) diff --git a/automations/bundle-index.js b/automations/bundle-index.js index 0dae9fa0..f6eacc4c 100644 --- a/automations/bundle-index.js +++ b/automations/bundle-index.js @@ -6,9 +6,9 @@ export const AUTOMATION_BUNDLE_FILES = { "github-pr-reviewer": { "agent_conversation.py": "\"\"\"Idempotently deliver automation work to profile-backed conversations.\"\"\"\n\nimport json\nimport os\nfrom urllib.error import HTTPError\nfrom urllib.request import Request, urlopen\nfrom uuid import NAMESPACE_URL, UUID, uuid5\n\nimport httpx\nfrom openhands.sdk import RemoteConversation\nfrom openhands.sdk.conversation.request import (\n SendMessageRequest,\n StartConversationRequest,\n)\nfrom openhands.sdk.conversation.state import ConversationExecutionStatus\nfrom openhands.sdk.llm.message import TextContent\nfrom openhands.sdk.workspace import LocalWorkspace, RemoteWorkspace\n\n_CONVERSATION_KEY_PREFIX = \"agent-conversation-\"\n\n\ndef _register_tools() -> None:\n \"\"\"Register tool models needed to deserialize an attached agent.\"\"\"\n from openhands.tools import register_default_tools\n\n register_default_tools()\n\n\ndef _kv_request(key: str, method: str, value: dict | None = None) -> dict | None:\n base_url = os.environ.get(\"AUTOMATION_API_URL\", \"\").rstrip(\"/\")\n token = os.environ.get(\"AUTOMATION_KV_TOKEN\", \"\")\n if not base_url or not token:\n raise RuntimeError(\"Automation KV is required for agent conversation dispatch\")\n request = Request(\n f\"{base_url}/v1/kv/{key}\",\n data=json.dumps(value).encode() if value is not None else None,\n headers={\n \"Authorization\": f\"Bearer {token}\",\n \"Content-Type\": \"application/json\",\n },\n method=method,\n )\n try:\n with urlopen(request, timeout=90) as response:\n body = json.load(response)\n except HTTPError as exc:\n if method == \"GET\" and exc.code == 404:\n return None\n raise\n return body.get(\"value\") if method == \"GET\" else body\n\n\nclass AgentConversationDispatcher:\n \"\"\"Deliver one revision at a time to a stable conversation for each subject.\"\"\"\n\n def __init__(self) -> None:\n self.agent_url = os.environ[\"AGENT_SERVER_URL\"]\n self.api_key = os.environ[\"SESSION_API_KEY\"]\n self.profile_id = UUID(os.environ[\"AUTOMATION_AGENT_PROFILE_ID\"])\n payload = json.loads(os.environ[\"AUTOMATION_EVENT_PAYLOAD\"])\n self.automation_id = str(payload[\"automation_id\"])\n self._workspace: RemoteWorkspace | None = None\n self._secrets = {}\n\n def __enter__(self):\n _register_tools()\n self._workspace = RemoteWorkspace(\n host=self.agent_url,\n api_key=self.api_key,\n working_dir=os.environ.get(\"WORKSPACE_BASE\", \"/workspace\"),\n )\n self._workspace.__enter__()\n self._secrets = self._workspace.get_secrets(\n agent_profile_id=str(self.profile_id)\n )\n return self\n\n def __exit__(self, *args):\n assert self._workspace is not None\n return self._workspace.__exit__(*args)\n\n def deliver(self, subject: str, delivery: str, prompt: str) -> dict[str, str]:\n conversation_id = uuid5(NAMESPACE_URL, f\"{self.automation_id}:{subject}\")\n state_key = f\"{_CONVERSATION_KEY_PREFIX}{conversation_id}\"\n record = _kv_request(state_key, \"GET\") or {}\n if self._workspace is None:\n raise RuntimeError(\"AgentConversationDispatcher must be used as a context\")\n\n same_delivery = record.get(\"delivery\") == delivery\n\n try:\n conversation = RemoteConversation.attach(\n self._workspace, conversation_id, visualizer=None\n )\n disposition = \"resumed\"\n except httpx.HTTPStatusError as exc:\n if exc.response.status_code != 404:\n raise\n conversation = RemoteConversation.create(\n self._workspace,\n StartConversationRequest(\n workspace=LocalWorkspace(working_dir=\"/workspace\"),\n conversation_id=conversation_id,\n agent_profile_id=self.profile_id,\n secrets=self._secrets,\n initial_message=SendMessageRequest(\n content=[TextContent(text=prompt)], run=True\n ),\n ),\n visualizer=None,\n )\n disposition = \"created\"\n try:\n if disposition == \"resumed\":\n if same_delivery:\n if (\n conversation.state.execution_status\n == ConversationExecutionStatus.RUNNING\n ):\n conversation.update_secrets(self._secrets)\n disposition = \"in_progress\"\n elif conversation.state.execution_status in (\n ConversationExecutionStatus.IDLE,\n ConversationExecutionStatus.PAUSED,\n ):\n conversation.update_secrets(self._secrets)\n conversation.run(blocking=False)\n else:\n disposition = \"deduplicated\"\n else:\n conversation.update_secrets(self._secrets)\n conversation.send_message(prompt)\n conversation.run(blocking=False)\n finally:\n conversation.close()\n\n if disposition in (\"deduplicated\", \"in_progress\"):\n return {\n \"disposition\": disposition,\n \"conversation_id\": str(conversation_id),\n }\n\n _kv_request(\n state_key,\n \"PUT\",\n {\n \"subject\": subject,\n \"conversation_id\": str(conversation_id),\n \"delivery\": delivery,\n },\n )\n return {\n \"disposition\": disposition,\n \"conversation_id\": str(conversation_id),\n }\n", "github_client.py": "\"\"\"Shared GitHub transport and repository operations for GitHub automations.\"\"\"\n\nimport argparse\nimport json\nimport os\nimport re\nimport subprocess\nfrom functools import cached_property\nfrom pathlib import Path\nfrom urllib.error import HTTPError\nfrom urllib.parse import parse_qsl, urlencode, urlsplit\nfrom urllib.request import Request, urlopen\n\n\ndef _load_secret(name: str) -> str:\n \"\"\"Read one named secret from the environment or configured Agent Server.\"\"\"\n value = os.environ.get(name)\n if value:\n return value\n\n from openhands.sdk.workspace import RemoteWorkspace\n\n workspace = RemoteWorkspace(\n host=os.environ[\"AGENT_SERVER_URL\"],\n api_key=os.environ[\"SESSION_API_KEY\"],\n working_dir=os.environ.get(\"WORKSPACE_BASE\", \"/workspace\"),\n )\n try:\n secret = workspace.get_secrets([name]).get(name)\n value = secret.get_value() if secret else None\n finally:\n workspace.reset_client()\n if not value:\n raise ValueError(f\"The GitHub credential {name} is unavailable\")\n return value\n\n\ndef github_request(\n token: str,\n method: str,\n path: str,\n params: dict | None = None,\n body: dict | None = None,\n accept: str = \"application/vnd.github+json\",\n) -> tuple:\n url = f\"https://api.github.com{path}\"\n if params:\n url = f\"{url}?{urlencode(params)}\"\n headers = {\n \"Authorization\": f\"Bearer {token}\",\n \"Accept\": accept,\n \"X-GitHub-Api-Version\": \"2022-11-28\",\n \"Content-Type\": \"application/json\",\n }\n data = json.dumps(body).encode() if body is not None else None\n req = Request(url, data=data, headers=headers, method=method)\n with urlopen(req, timeout=90) as r:\n raw = r.read()\n return (json.loads(raw) if raw.strip() else {}), dict(r.headers)\n\n\ndef github_paginate(token: str, path: str, params: dict | None = None) -> list:\n results = []\n base_params = dict(params or {})\n base_params.setdefault(\"per_page\", 100)\n for page in range(1, 101):\n base_params[\"page\"] = page\n data, _ = github_request(token, \"GET\", path, params=base_params)\n if not isinstance(data, list):\n raise TypeError(\"Expected a paginated GitHub list\")\n results.extend(data)\n if len(data) < int(base_params[\"per_page\"]):\n return results\n raise RuntimeError(\"GitHub pagination exceeded limit\")\n\n\nclass GitHubRepository:\n name = \"GitHub automation\"\n\n def __init__(\n self,\n config_path=Path(\"config.json\"),\n *,\n github_token_secret,\n repository=None,\n conversation=None,\n dispatcher=None,\n ):\n self.config = json.loads(Path(config_path).read_text())\n self.repository = repository or self.config[\"repository\"]\n if not re.fullmatch(r\"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+\", self.repository):\n raise ValueError(\"repository must be owner/repo\")\n if not re.fullmatch(r\"[A-Z_][A-Z0-9_]*\", github_token_secret):\n raise ValueError(\n \"Expected the environment variable containing the GitHub token\"\n )\n self.token_name = github_token_secret\n self.token = _load_secret(github_token_secret)\n self.conversation = conversation\n self.conversation_id = str(conversation.id) if conversation else None\n self.dispatcher = dispatcher\n self.workspace = Path(os.environ[\"WORKSPACE_BASE\"])\n self.project = self.workspace\n self.evidence = self.workspace / \"evidence\"\n self.evidence.mkdir(exist_ok=True)\n self._completed_dependencies = {}\n\n @cached_property\n def base_branch(self):\n return self.config.get(\"base_branch\") or self.gh(\"GET\", \"\")[\"default_branch\"]\n\n @property\n def github_instructions(self):\n return (\n f\"Use `GH_TOKEN=${self.token_name} gh api` for GitHub requests. \"\n \"Never print the credential value. \"\n f\"Only {self.repository} is in scope. Work in {self.project}. \"\n \"Do not modify the automation bundle or its configuration.\"\n )\n\n def gh(self, method, path, body=None):\n return github_request(\n self.token, method, f\"/repos/{self.repository}\" + path, body=body\n )[0]\n\n def api(self, method, path, params=None, body=None):\n \"\"\"Call a GitHub endpoint that is not scoped to one repository.\"\"\"\n return github_request(self.token, method, path, params=params, body=body)[0]\n\n def shell(self, args, cwd=None, timeout=300):\n result = subprocess.run(\n args,\n cwd=cwd or self.project,\n text=True,\n stdout=subprocess.PIPE,\n stderr=subprocess.STDOUT,\n timeout=timeout,\n check=False,\n )\n if result.returncode:\n raise RuntimeError(\n f\"{args[0]} failed: {result.stdout[-4000:].replace(self.token, '[REDACTED]')}\"\n )\n return result.stdout.strip()\n\n def comment(self, number, text):\n return self.gh(\n \"POST\",\n f\"/issues/{number}/comments\",\n {\n \"body\": text\n + f\"\\n\\nFactory role: `{self.name}`; conversation: `{self.conversation_id}`.\"\n + \"\\n\\n_This comment was posted by an AI agent (OpenHands)._\"\n },\n )\n\n def open_issues(self):\n return [\n i for i in self.gh_pages(\"/issues?state=open\") if \"pull_request\" not in i\n ]\n\n def statuses(self, sha):\n result = {}\n for item in self.gh_pages(f\"/commits/{sha}/statuses\"):\n result.setdefault(item[\"context\"], item[\"state\"])\n return result\n\n def check_runs(self, sha):\n \"\"\"Return every check run GitHub reported for one commit SHA.\n\n Reading check runs needs no branch-protection or ruleset access, so this\n is the head-eligibility signal the automation's own token can always\n see. The endpoint answers with an object rather than a list, so it\n paginates through ``gh`` instead of ``gh_pages``.\n \"\"\"\n runs = []\n for page in range(1, 101):\n data = self.gh(\n \"GET\", f\"/commits/{sha}/check-runs?per_page=100&page={page}\"\n )\n batch = data.get(\"check_runs\") or []\n runs.extend(batch)\n if len(runs) >= int(data.get(\"total_count\") or 0) or not batch:\n return runs\n raise RuntimeError(\"GitHub check-run pagination exceeded limit\")\n\n def required_check_contexts(self, number):\n \"\"\"Return the status contexts GitHub marks required on one pull request.\n\n `isRequired` is the merge-policy source of truth and is PR-scoped, so it\n is correct for a stacked PR whose symbolic base branch carries no branch\n rules of its own. It is a field on each context in the head's status\n check rollup and takes the pull request number, so a single GraphQL query\n answers with the required check runs and any required commit statuses,\n each already attributed to the exact head. Only contexts GitHub itself\n reports as required are returned, so an optional workflow that fails\n before creating any check run is not present.\n \"\"\"\n owner, name = self.repository.split(\"/\", 1)\n query = (\n \"query($owner:String!,$name:String!,$number:Int!){\"\n \"repository(owner:$owner,name:$name){\"\n \"pullRequest(number:$number){\"\n \"commits(last:1){\"\n \"nodes{\"\n \"commit{\"\n \"statusCheckRollup{\"\n \"contexts(first:100){\"\n \"pageInfo{hasNextPage}\"\n \"nodes{\"\n \"__typename\"\n \" ... on CheckRun{name isRequired(pullRequestNumber:$number)}\"\n \" ... on StatusContext{context isRequired(pullRequestNumber:$number)}\"\n \"}\"\n \"}\"\n \"}\"\n \"}\"\n \"}\"\n \"}\"\n \"}\"\n \"}\"\n \"}\"\n )\n data = self.api(\n \"POST\",\n \"/graphql\",\n body={\n \"query\": query,\n \"variables\": {\"owner\": owner, \"name\": name, \"number\": number},\n },\n )\n if data.get(\"errors\"):\n raise RuntimeError(\n f\"GitHub required-check query failed: {data['errors'][0].get('message')}\"\n )\n nodes = (\n (((data.get(\"data\") or {}).get(\"repository\") or {}).get(\"pullRequest\") or {})\n .get(\"commits\", {})\n .get(\"nodes\")\n ) or []\n contexts = (\n ((nodes[0].get(\"commit\") or {}).get(\"statusCheckRollup\") or {}).get(\n \"contexts\", {}\n )\n if nodes\n else {}\n )\n if (contexts.get(\"pageInfo\") or {}).get(\"hasNextPage\"):\n raise RuntimeError(\n \"GitHub required-check rollup exceeds one page; the required \"\n \"set may be incomplete\"\n )\n return [\n {\n \"name\": node.get(\"name\") or node.get(\"context\"),\n \"kind\": node.get(\"__typename\"),\n }\n for node in (contexts.get(\"nodes\") or [])\n if node.get(\"isRequired\") and (node.get(\"name\") or node.get(\"context\"))\n ]\n\n def workflow_runs(self, sha):\n \"\"\"Return every Actions workflow run GitHub reported for one commit SHA.\n\n A workflow that fails before any job starts - a workflow-level error, or\n a `pull_request` run whose jobs never materialize - still records a\n failed check suite, but it contributes no check runs, so the commit's\n check-run rollup and `gh pr checks` both report success. Reading the\n workflow runs directly is the only way the gate can see that failure.\n Like the check-run endpoint this answers with an object, so it paginates\n manually.\n \"\"\"\n runs = []\n for page in range(1, 101):\n data = self.gh(\n \"GET\", f\"/actions/runs?head_sha={sha}&per_page=100&page={page}\"\n )\n batch = data.get(\"workflow_runs\") or []\n runs.extend(batch)\n if len(runs) >= int(data.get(\"total_count\") or 0) or not batch:\n return runs\n raise RuntimeError(\"GitHub workflow-run pagination exceeded limit\")\n\n def completed_dependency(self, number):\n if number in self._completed_dependencies:\n return self._completed_dependencies[number]\n try:\n dependency = self.gh(\"GET\", f\"/issues/{number}\")\n except HTTPError as exc:\n if exc.code == 404:\n return False\n raise\n completed = (\n dependency[\"state\"] == \"closed\"\n and dependency.get(\"state_reason\") == \"completed\"\n )\n self._completed_dependencies[number] = completed\n return completed\n\n def dependencies_complete(self, issue):\n \"\"\"Honor explicit Depends on lines; unknown/incomplete issues remain blocked.\"\"\"\n for line in re.findall(\n \"^Depends on:\\\\s*(.+)$\",\n issue.get(\"body\") or \"\",\n re.MULTILINE | re.IGNORECASE,\n ):\n for number in re.findall(\"#(\\\\d+)\", line):\n if not self.completed_dependency(number):\n return False\n return True\n\n def gh_pages(self, endpoint):\n split = urlsplit(endpoint)\n return github_paginate(\n self.token,\n f\"/repos/{self.repository}\" + split.path,\n params=dict(parse_qsl(split.query)),\n )\n\n\ndef run_repositories(automation_type, conversation=None, dispatcher=None):\n parser = argparse.ArgumentParser(description=automation_type.__doc__)\n parser.add_argument(\"--github-token-secret\")\n args = parser.parse_args()\n config = json.loads(Path(\"config.json\").read_text())\n token_name = args.github_token_secret or config.get(\n \"github_token_secret\", \"GITHUB_PERSONAL_ACCESS_TOKEN\"\n )\n repositories = config.get(\"repos\") or [config[\"repository\"]]\n failures = []\n for repository in repositories:\n options = dict(\n github_token_secret=token_name,\n repository=repository,\n conversation=conversation,\n )\n if dispatcher is not None:\n options[\"dispatcher\"] = dispatcher\n automation = automation_type(**options)\n try:\n automation.run()\n except Exception as exc: # noqa: BLE001 - one repository must not block others\n failures.append(repository)\n print(\n json.dumps({\"repository\": repository, \"error\": type(exc).__name__}),\n flush=True,\n )\n if failures:\n raise RuntimeError(\"Automation failed for: \" + \", \".join(failures))\n return str(conversation.id) if conversation else None\n", - "main.py": "\"\"\"\nGitHub PR Reviewer - OpenHands Automation Script\n\nCron-polls one or more GitHub repositories for open pull requests carrying the\nconfigured trigger label. A review is queued only when the latest matching\nGitHub `labeled` event has not already been processed by this automation.\n\nEach repository is polled independently and keeps its own state document, so\npull-request numbers never collide across repositories.\n\nThis standalone script owns the repository checkout: it downloads the pull\nrequest's head commit as a tarball, hands the agent that directory as its\nworkspace, and removes it once the review has finished. Catalog workers may\ninstead reuse its prompt builder with their own workspace instructions.\n\"\"\"\n\nimport io\nimport json\nimport os\nimport re\nimport shutil\nimport sys\nimport tarfile\nimport time\nimport urllib.error\nimport urllib.request\nfrom collections.abc import Callable\nfrom pathlib import Path, PurePosixPath\n\nfrom github_client import github_request as _github_request\nfrom github_client import github_paginate as _github_paginate\n\n# Configuration. Two setup paths write it, and both end up here:\n#\n# - the agent-driven path (SKILL.md) substitutes these constants directly\n# into a copy of this file before packaging it;\n# - the catalog path packs an unmodified copy and ships a rendered\n# config.json beside it, which is loaded over these defaults below.\n#\n# A declarative host cannot rewrite Python - the catalog schema admits data,\n# not code - so the constants stay as the defaults and config.json is the\n# override, rather than one path being expressed in terms of the other.\nREPOS = [\"owner/repo\"]\nTRIGGER_LABEL = \"openhands-review\"\nREVIEW_TONE = \"thorough\"\nREVIEW_STYLE_INSTRUCTIONS = \"\"\n# Path within the checked-out repository to a repo-specific review guide\n# (e.g. the repo's own code-review skill). When the file exists at this path\n# relative to the repo root, its contents are read and injected verbatim into\n# the review prompt so the guide is always applied deterministically, rather\n# than relying on the spawned agent's skill activation. Set to \"\" to disable.\nREPO_REVIEW_GUIDE_PATH = \".agents/skills/custom-codereview-guide.md\"\nDEFAULT_OPENHANDS_URL = \"http://localhost:8000\"\n\n# A review that ends with this marker is a scope stop: the reviewer found the\n# change out of scope, or needing a product/architecture decision, before the\n# technical review. The completion handler hands it to a maintainer without\n# approving or merging the PR.\nMAINTAINER_DECISION_VERDICT = \"🛑 MAINTAINER DECISION REQUIRED\"\n\nCONFIG_FILENAME = \"config.json\"\n\n# Config keys, paired with the type each must have. A wrong type is a hard\n# error at import: the alternative is polling the string \"owner/repo\" one\n# character at a time, or matching a label that is silently a list.\n_CONFIG_TYPES: dict[str, type] = {\n \"repos\": list,\n \"trigger_label\": str,\n \"review_tone\": str,\n \"review_style_instructions\": str,\n \"repo_review_guide_path\": str,\n \"openhands_url\": str,\n}\n\n\ndef load_config(directory: Path | None = None) -> dict:\n \"\"\"Return the rendered config shipped beside this script, or {} if absent.\n\n Only the keys above are read; anything else in the file is ignored, so a\n host may ship provenance there without this script caring.\n \"\"\"\n path = (directory or Path(__file__).resolve().parent) / CONFIG_FILENAME\n if not path.is_file():\n return {}\n\n try:\n raw = json.loads(path.read_text())\n except json.JSONDecodeError as e:\n raise SystemExit(f\"{CONFIG_FILENAME} is not valid JSON: {e}\") from e\n if not isinstance(raw, dict):\n raise SystemExit(f\"{CONFIG_FILENAME} must contain a JSON object\")\n\n config = {}\n for key, expected in _CONFIG_TYPES.items():\n if key not in raw:\n continue\n value = raw[key]\n if not isinstance(value, expected):\n raise SystemExit(\n f\"{CONFIG_FILENAME}: {key} must be {expected.__name__}, \"\n f\"got {type(value).__name__}\"\n )\n if key == \"repos\" and not (\n value and all(isinstance(item, str) and item for item in value)\n ):\n raise SystemExit(\n f'{CONFIG_FILENAME}: repos must be a non-empty list of \"owner/repo\" strings'\n )\n config[key] = value\n return config\n\n\n# owner/repo, which is what every GitHub API path in this script is built from.\n_REPO_NAME_RE = re.compile(r\"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$\")\n\n\ndef normalize_repo(value: str) -> str:\n \"\"\"Return ``owner/repo`` for the ways a repository gets written down.\n\n A clone URL is what a repository page offers to copy, so it is what ends up\n pasted into a setup form. Left alone it becomes\n ``/repos/https://github.com/owner/repo``, which GitHub answers with a 404 -\n indistinguishable, from here, from a repository the token cannot see.\n\n Raises ValueError for anything that is not a repository name, so the run\n says which value it could not read instead of blaming the token.\n \"\"\"\n repo = value.strip()\n if repo.startswith(\"git@\"):\n # git@github.com:owner/repo.git\n repo = repo.partition(\":\")[2]\n elif \"://\" in repo:\n # https://github.com/owner/repo, and anything else with a host\n repo = repo.split(\"://\", 1)[1].partition(\"/\")[2]\n repo = repo.strip(\"/\")\n if repo.endswith(\".git\"):\n repo = repo[: -len(\".git\")]\n\n if not _REPO_NAME_RE.match(repo):\n raise ValueError(\n f\"{value!r} is not a repository. Use owner/repo, for example \"\n \"OpenHands/automation.\"\n )\n return repo\n\n\n_CONFIG = load_config()\nREPOS = _CONFIG.get(\"repos\", REPOS)\nTRIGGER_LABEL = _CONFIG.get(\"trigger_label\", TRIGGER_LABEL)\nREVIEW_TONE = _CONFIG.get(\"review_tone\", REVIEW_TONE)\nREVIEW_STYLE_INSTRUCTIONS = _CONFIG.get(\"review_style_instructions\", REVIEW_STYLE_INSTRUCTIONS)\nREPO_REVIEW_GUIDE_PATH = _CONFIG.get(\"repo_review_guide_path\", REPO_REVIEW_GUIDE_PATH)\nDEFAULT_OPENHANDS_URL = _CONFIG.get(\"openhands_url\", DEFAULT_OPENHANDS_URL)\n\nDONE_DEBOUNCE = 15\nTERMINAL_STATUSES = {\"idle\", \"finished\", \"error\", \"stuck\"}\n# A conversation that never reaches a terminal status would hold its checkout\n# forever. After this long the review is abandoned so the disk can be reclaimed.\nMAX_ACTIVE_AGE = 2 * 60 * 60\n# A label event is claimed in the state document before its review starts, so an\n# overlapping poll skips it. If the claiming poll dies before the conversation\n# exists, the claim is released after this long - comfortably longer than\n# fetching an archive and opening a conversation, short enough that a crash does\n# not park the review until someone notices.\nSTALLED_CLAIM_SECONDS = 15 * 60\n\n# Login of the token owner, filled in by _verify_token. Reviews are matched\n# against it to answer \"did we already publish a review for this commit\", which\n# is checked on GitHub rather than trusted from the agent.\n_AUTH_LOGIN = \"\"\n\n\ndef _get_env_key() -> str:\n return os.environ.get(\"SESSION_API_KEY\") or os.environ.get(\"OH_SESSION_API_KEYS_0\") or \"\"\n\n\ndef get_secret(name: str) -> str:\n url = os.environ.get(\"AGENT_SERVER_URL\", \"\").rstrip(\"/\")\n key = _get_env_key()\n req = urllib.request.Request(\n f\"{url}/api/settings/secrets/{name}\",\n headers={\"X-Session-API-Key\": key},\n )\n with urllib.request.urlopen(req) as r:\n return r.read().decode().strip()\n\n\ndef fire_callback(\n status: str = \"COMPLETED\",\n error: str | None = None,\n conversation_id: str | None = None,\n) -> None:\n url = os.environ.get(\"AUTOMATION_CALLBACK_URL\", \"\")\n if not url:\n return\n body: dict = {\"status\": status, \"run_id\": os.environ.get(\"AUTOMATION_RUN_ID\", \"\")}\n if error:\n body[\"error\"] = error\n if conversation_id:\n body[\"conversation_id\"] = conversation_id\n req = urllib.request.Request(\n url,\n data=json.dumps(body).encode(),\n headers={\n \"Content-Type\": \"application/json\",\n \"Authorization\": f\"Bearer {os.environ.get('AUTOMATION_CALLBACK_API_KEY', '')}\",\n },\n )\n try:\n urllib.request.urlopen(req)\n except Exception as exc:\n print(f\"Callback error (non-fatal): {exc}\")\n\n\n# ── State persistence (KV store with local-file fallback) ─────────────────────\n\n_KV_TOKEN = os.environ.get(\"AUTOMATION_KV_TOKEN\", \"\")\n_KV_BASE = os.environ.get(\"AUTOMATION_API_URL\", \"\").rstrip(\"/\")\n# Single-repository deployments of this script kept their state under a bare\n# \"state\" key. It is adopted once, on first poll after an upgrade, so the\n# switch to per-repository keys does not re-review every open labelled PR.\n_LEGACY_STATE_KEY = \"state\"\n\n\ndef _repo_slug(repo: str) -> str:\n return repo.replace(\"/\", \"__\")\n\n\ndef _state_key(repo: str) -> str:\n return f\"state:{_repo_slug(repo)}\"\n\n\ndef _kv_available() -> bool:\n return bool(_KV_TOKEN and _KV_BASE)\n\n\ndef _kv_get(key: str) -> dict | None:\n req = urllib.request.Request(\n f\"{_KV_BASE}/v1/kv/{key}\",\n headers={\"Authorization\": f\"Bearer {_KV_TOKEN}\"},\n )\n try:\n with urllib.request.urlopen(req) as r:\n return json.loads(r.read())[\"value\"]\n except urllib.error.HTTPError as exc:\n if exc.code == 404:\n return None\n raise\n\n\ndef _kv_set(key: str, value: dict) -> None:\n req = urllib.request.Request(\n f\"{_KV_BASE}/v1/kv/{key}\",\n data=json.dumps(value).encode(),\n headers={\n \"Authorization\": f\"Bearer {_KV_TOKEN}\",\n \"Content-Type\": \"application/json\",\n },\n method=\"PUT\",\n )\n with urllib.request.urlopen(req) as r:\n r.read()\n\n\ndef _state_dir() -> Path:\n workspace_base = os.environ.get(\"WORKSPACE_BASE\", \"\")\n if workspace_base:\n root = Path(workspace_base).resolve().parent.parent\n else:\n root = Path.home() / \".openhands\" / \"workspaces\"\n state_dir = root / \"automation-state\"\n state_dir.mkdir(parents=True, exist_ok=True)\n return state_dir\n\n\ndef _automation_id() -> str:\n event_payload = json.loads(os.environ.get(\"AUTOMATION_EVENT_PAYLOAD\", \"{}\"))\n return event_payload.get(\"automation_id\", \"default\")\n\n\ndef _state_file_path(repo: str) -> str:\n name = f\"github_pr_reviewer_label_event_{_automation_id()}_{_repo_slug(repo)}.json\"\n return str(_state_dir() / name)\n\n\ndef _legacy_state_file_path() -> str:\n return str(_state_dir() / f\"github_pr_reviewer_label_event_{_automation_id()}.json\")\n\n\ndef _read_state_file(path: str) -> dict | None:\n if not os.path.exists(path):\n return None\n try:\n with open(path) as f:\n return json.load(f)\n except (json.JSONDecodeError, OSError) as exc:\n print(f\" Warning: state file {path} unreadable ({exc}); starting fresh\")\n return None\n\n\ndef _default_state(repo: str) -> dict:\n return {\n \"version\": 3,\n \"repo\": repo,\n \"trigger_label\": TRIGGER_LABEL,\n \"reviews\": {},\n \"prs\": {},\n }\n\n\ndef load_state(repo: str) -> dict:\n \"\"\"Load this repository's state, adopting a pre-multi-repo document once.\"\"\"\n if _kv_available():\n data = _kv_get(_state_key(repo))\n if data is not None:\n print(f\" State loaded from KV store ({_state_key(repo)})\")\n return data\n legacy = _kv_get(_LEGACY_STATE_KEY)\n if legacy is not None and legacy.get(\"repo\") == repo:\n print(f\" Adopted legacy KV state for {repo}\")\n return legacy\n return _default_state(repo)\n\n data = _read_state_file(_state_file_path(repo))\n if data is not None:\n return data\n legacy = _read_state_file(_legacy_state_file_path())\n if legacy is not None and legacy.get(\"repo\") == repo:\n print(f\" Adopted legacy state file for {repo}\")\n return legacy\n return _default_state(repo)\n\n\ndef save_state(repo: str, state: dict) -> None:\n if _kv_available():\n _kv_set(_state_key(repo), state)\n print(f\" State saved to KV store ({_state_key(repo)})\")\n return\n path = _state_file_path(repo)\n tmp_path = f\"{path}.tmp\"\n with open(tmp_path, \"w\") as f:\n json.dump(state, f, indent=2, sort_keys=True)\n os.replace(tmp_path, path)\n print(f\" State saved to {path}\")\n\n\n\ndef _resolve_github_token() -> str:\n try:\n token = get_secret(\"GITHUB_PERSONAL_ACCESS_TOKEN\")\n if token:\n return token\n except Exception:\n pass\n raise RuntimeError(\n \"GITHUB_PERSONAL_ACCESS_TOKEN secret is not set. \"\n \"Go to OpenHands Settings → Secrets and add your GitHub Personal Access Token.\"\n )\n\n\ndef _verify_token(token: str) -> None:\n \"\"\"Check the token once per run and remember who it belongs to.\"\"\"\n global _AUTH_LOGIN\n try:\n user_data, _ = _github_request(token, \"GET\", \"/user\")\n except urllib.error.HTTPError as exc:\n if exc.code == 401:\n raise RuntimeError(\"GITHUB_PERSONAL_ACCESS_TOKEN is invalid or expired.\") from exc\n raise RuntimeError(f\"GitHub /user check failed: {exc.code}\") from exc\n\n _AUTH_LOGIN = user_data.get(\"login\", \"\")\n print(f\"Authenticated as GitHub user: {_AUTH_LOGIN or '?'}\")\n\n\ndef _verify_repo(token: str, repo: str) -> None:\n try:\n _github_request(token, \"GET\", f\"/repos/{repo}\")\n except urllib.error.HTTPError as exc:\n if exc.code == 404:\n raise RuntimeError(f\"Repository '{repo}' is not accessible with the current token.\") from exc\n raise RuntimeError(f\"GitHub /repos/{repo} check failed: {exc.code}\") from exc\n\n\ndef _list_open_prs(token: str, repo: str) -> list[dict]:\n return _github_paginate(\n token,\n f\"/repos/{repo}/pulls\",\n {\"state\": \"open\", \"sort\": \"updated\", \"direction\": \"desc\"},\n )\n\n\ndef _get_pr(token: str, repo: str, pr_number: int) -> dict:\n pr, _ = _github_request(token, \"GET\", f\"/repos/{repo}/pulls/{pr_number}\")\n return pr\n\n\ndef _get_issue_events(token: str, repo: str, pr_number: int) -> list[dict]:\n return _github_paginate(token, f\"/repos/{repo}/issues/{pr_number}/events\")\n\n\ndef _latest_trigger_label_event(token: str, repo: str, pr_number: int) -> dict | None:\n events = _get_issue_events(token, repo, pr_number)\n matching = [\n event for event in events\n if event.get(\"event\") == \"labeled\"\n and (event.get(\"label\") or {}).get(\"name\", \"\").lower() == TRIGGER_LABEL.lower()\n and event.get(\"id\") is not None\n ]\n if not matching:\n return None\n return max(matching, key=lambda event: (event.get(\"created_at\") or \"\", int(event.get(\"id\") or 0)))\n\n\ndef _post_github_comment(token: str, repo: str, pr_number: int, body: str) -> None:\n try:\n _github_request(\n token,\n \"POST\",\n f\"/repos/{repo}/issues/{pr_number}/comments\",\n body={\"body\": body},\n )\n except Exception as exc:\n print(f\" Warning: failed to post comment on PR #{pr_number}: {exc}\")\n\n\ndef _matching_review_exists(token: str, repo: str, pr_number: int, head_sha: str) -> bool:\n \"\"\"Has this token's user already published a review for this exact commit?\n\n The agent is asked to report success, but a report is not evidence: reviews\n have been reported as posted when none existed. GitHub is the source of\n truth for whether the review landed.\n \"\"\"\n if not head_sha or not _AUTH_LOGIN:\n return False\n try:\n reviews = _github_paginate(token, f\"/repos/{repo}/pulls/{pr_number}/reviews\")\n except Exception as exc:\n print(f\" Warning: could not list reviews for PR #{pr_number}: {exc}\")\n return False\n for review in reviews:\n if (review.get(\"user\") or {}).get(\"login\", \"\").lower() != _AUTH_LOGIN.lower():\n continue\n if review.get(\"commit_id\") == head_sha:\n return True\n return False\n\n\n# ── Repository checkout ───────────────────────────────────────────────────────\n\n\ndef _checkouts_root() -> Path:\n return Path(os.environ.get(\"WORKSPACE_BASE\", \"/workspace\")).resolve() / \"repositories\"\n\n\ndef _checkout_path(repo: str, pr_number: int, head_sha: str) -> Path:\n return _checkouts_root() / _repo_slug(repo) / f\"pr-{pr_number}-{head_sha[:12]}\"\n\n\ndef _prepare_repository(token: str, repo: str, pr_number: int, head_sha: str) -> Path:\n \"\"\"Materialise the pull request's head commit as the agent's workspace.\n\n The commit is fetched as a tarball rather than cloned, so the directory\n holds exactly the reviewed tree with no history and no git remote for the\n agent to push to.\n \"\"\"\n checkout = _checkout_path(repo, pr_number, head_sha)\n if checkout.exists():\n shutil.rmtree(checkout)\n checkout.mkdir(parents=True)\n\n req = urllib.request.Request(\n f\"https://api.github.com/repos/{repo}/tarball/{head_sha}\",\n headers={\n \"Authorization\": f\"Bearer {token}\",\n \"Accept\": \"application/vnd.github+json\",\n \"X-GitHub-Api-Version\": \"2022-11-28\",\n },\n )\n skipped_links = 0\n try:\n with urllib.request.urlopen(req) as response:\n archive = tarfile.open(fileobj=io.BytesIO(response.read()), mode=\"r:gz\")\n with archive:\n members = archive.getmembers()\n roots = {\n PurePosixPath(member.name).parts[0]\n for member in members\n if PurePosixPath(member.name).parts\n }\n if len(roots) != 1:\n raise RuntimeError(\"Repository archive has an unexpected layout\")\n root = next(iter(roots))\n for member in members:\n path = PurePosixPath(member.name)\n if not path.parts or path.parts[0] != root:\n raise RuntimeError(\"Repository archive contains an invalid path\")\n relative = PurePosixPath(*path.parts[1:])\n if not relative.parts:\n continue\n if relative.is_absolute() or \"..\" in relative.parts:\n raise RuntimeError(\"Repository archive contains path traversal\")\n if member.issym() or member.islnk() or member.isdev():\n # Repositories legitimately contain symlinks. Reviewing does\n # not need them, and materialising them risks escaping the\n # checkout, so skip rather than reject the whole archive.\n skipped_links += 1\n continue\n destination = checkout.joinpath(*relative.parts)\n if member.isdir():\n destination.mkdir(parents=True, exist_ok=True)\n continue\n if not member.isfile():\n continue\n destination.parent.mkdir(parents=True, exist_ok=True)\n source = archive.extractfile(member)\n if source is None:\n raise RuntimeError(f\"Could not read archive member {member.name}\")\n with source, destination.open(\"wb\") as target:\n shutil.copyfileobj(source, target)\n destination.chmod(member.mode & 0o777)\n except Exception:\n shutil.rmtree(checkout, ignore_errors=True)\n raise\n\n if skipped_links:\n print(f\" Skipped {skipped_links} link/device entries while extracting\")\n return checkout\n\n\ndef _release_checkout(rec: dict, agent_url: str, api_key: str) -> bool:\n \"\"\"Remove a finished review's checkout. Returns True when nothing is left.\n\n The checkout is the conversation's working directory, so it is only removed\n once the conversation has stopped - deleting it under a running agent would\n pull the ground out from under it. When the status cannot be confirmed the\n directory is left alone and the next poll tries again.\n \"\"\"\n workspace_dir = rec.get(\"workspace_dir\")\n if not workspace_dir:\n return True\n\n conversation_id = rec.get(\"conversation_id\")\n if conversation_id:\n try:\n status = conversation_status(agent_url, api_key, conversation_id)\n except urllib.error.HTTPError as exc:\n status = \"finished\" if exc.code == 404 else None\n except Exception:\n status = None\n if status is None:\n print(f\" Could not confirm conversation {conversation_id} has stopped; keeping {workspace_dir}\")\n return False\n if status not in TERMINAL_STATUSES:\n print(f\" Conversation {conversation_id} is still '{status}'; keeping its checkout\")\n return False\n\n path = Path(workspace_dir)\n root = _checkouts_root()\n try:\n resolved = path.resolve()\n except OSError:\n resolved = path\n if resolved == root or not resolved.is_relative_to(root):\n # Never delete anything the script did not create under the checkout\n # root, whatever ended up recorded in state.\n print(f\" Refusing to remove {resolved}: outside {root}\")\n rec.pop(\"workspace_dir\", None)\n return True\n\n shutil.rmtree(resolved, ignore_errors=True)\n rec.pop(\"workspace_dir\", None)\n print(f\" Removed checkout {resolved}\")\n return True\n\n\ndef _oh_request(agent_url: str, api_key: str, method: str, path: str, body: dict | None = None) -> dict:\n url = f\"{agent_url}{path}\"\n headers = {\"X-Session-API-Key\": api_key, \"Content-Type\": \"application/json\"}\n data = json.dumps(body).encode() if body is not None else None\n req = urllib.request.Request(url, data=data, headers=headers, method=method)\n try:\n with urllib.request.urlopen(req) as r:\n raw = r.read()\n return json.loads(raw) if raw.strip() else {}\n except urllib.error.HTTPError as exc:\n body_text = exc.read().decode()\n raise RuntimeError(f\"Agent API {method} {path} → {exc.code}: {body_text}\") from exc\n\n\ndef _fetch_settings(agent_url: str, api_key: str) -> dict:\n req = urllib.request.Request(\n f\"{agent_url}/api/settings\",\n headers={\"X-Session-API-Key\": api_key, \"X-Expose-Secrets\": \"plaintext\"},\n )\n with urllib.request.urlopen(req) as r:\n return json.loads(r.read())\n\n\ndef _get_agent_dict(agent_url: str, api_key: str) -> dict:\n data = _fetch_settings(agent_url, api_key)\n llm = data.get(\"agent_settings\", {}).get(\"llm\", {})\n return {\n \"kind\": \"Agent\",\n \"llm\": llm,\n \"tools\": [{\"name\": \"terminal\"}, {\"name\": \"file_editor\"}],\n }\n\n\ndef _get_mcp_config(agent_url: str, api_key: str) -> dict | None:\n try:\n data = _fetch_settings(agent_url, api_key)\n mcp_config = data.get(\"agent_settings\", {}).get(\"mcp_config\")\n if isinstance(mcp_config, dict) and mcp_config.get(\"mcpServers\"):\n return mcp_config\n except Exception as exc:\n print(f\"Warning: could not fetch MCP config: {exc}\")\n return None\n\n\ndef _list_secret_names(agent_url: str, api_key: str) -> list[dict]:\n try:\n result = _oh_request(agent_url, api_key, \"GET\", \"/api/settings/secrets\")\n return result.get(\"secrets\", [])\n except Exception as exc:\n print(f\"Warning: could not list secrets: {exc}\")\n return []\n\n\ndef _build_secrets_payload(agent_url: str, api_key: str) -> dict:\n secrets = {}\n for secret in _list_secret_names(agent_url, api_key):\n name = secret.get(\"name\", \"\")\n if not name:\n continue\n lookup: dict = {\n \"kind\": \"LookupSecret\",\n \"url\": f\"/api/settings/secrets/{name}\",\n }\n if api_key:\n lookup[\"headers\"] = {\"X-Session-API-Key\": api_key}\n desc = secret.get(\"description\")\n if desc:\n lookup[\"description\"] = desc\n secrets[name] = lookup\n return secrets\n\n\ndef create_conversation(\n agent_url: str,\n api_key: str,\n initial_message: str,\n workspace_dir: Path,\n) -> str:\n payload: dict = {\n \"workspace\": {\"working_dir\": str(workspace_dir)},\n \"agent\": _get_agent_dict(agent_url, api_key),\n \"initial_message\": {\"content\": [{\"text\": initial_message}]},\n }\n secrets = _build_secrets_payload(agent_url, api_key)\n if secrets:\n payload[\"secrets\"] = secrets\n mcp_config = _get_mcp_config(agent_url, api_key)\n if mcp_config:\n payload[\"mcp_config\"] = mcp_config\n result = _oh_request(agent_url, api_key, \"POST\", \"/api/conversations\", payload)\n return result[\"id\"]\n\n\ndef conversation_status(agent_url: str, api_key: str, conv_id: str) -> str:\n result = _oh_request(agent_url, api_key, \"GET\", f\"/api/conversations/{conv_id}\")\n return result.get(\"execution_status\", \"unknown\")\n\n\ndef conversation_final_response(agent_url: str, api_key: str, conv_id: str) -> str:\n result = _oh_request(agent_url, api_key, \"GET\", f\"/api/conversations/{conv_id}/agent_final_response\")\n return result.get(\"response\", \"\")\n\n\n_TONE_INSTRUCTIONS = {\n \"thorough\": (\n \"Provide a comprehensive review. Cover correctness, security vulnerabilities, \"\n \"missing or inadequate tests, code style, maintainability, and potential edge cases. \"\n \"Reference specific files and line numbers where relevant.\"\n ),\n \"concise\": (\n \"Provide a brief, high-signal review. Focus only on important bugs, security problems, \"\n \"or significant design flaws. Omit minor style feedback.\"\n ),\n \"friendly\": (\n \"Provide a constructive, encouraging review. Acknowledge what is done well before \"\n \"raising concerns while still noting real issues.\"\n ),\n}\n\n\ndef _labels(pr: dict) -> list[str]:\n return [label.get(\"name\", \"\") for label in pr.get(\"labels\", [])]\n\n\ndef _has_trigger_label(pr: dict) -> bool:\n return any(label.lower() == TRIGGER_LABEL.lower() for label in _labels(pr))\n\n\ndef _head_sha(pr: dict) -> str:\n return ((pr.get(\"head\") or {}).get(\"sha\") or \"\").strip()\n\n\ndef _review_key(pr_number: int, label_event_id: int | str) -> str:\n return f\"{pr_number}:label:{label_event_id}\"\n\n\ndef _with_ai_disclosure(body: str) -> str:\n disclosure = \"_This comment was posted by an AI agent (OpenHands)._\"\n body = (body or \"\").strip()\n if disclosure.lower() in body.lower():\n return body\n return f\"{body}\\n\\n{disclosure}\" if body else disclosure\n\n\ndef _load_repo_review_guide(workspace_dir: Path) -> str | None:\n \"\"\"Read the repo-specific review guide from the checked-out repository.\n\n The path is taken from ``REPO_REVIEW_GUIDE_PATH``. An empty path disables\n the feature. Returns the file contents, or None if the file is absent or\n unreadable — a missing guide is never fatal, the review simply proceeds\n without it.\n \"\"\"\n if not REPO_REVIEW_GUIDE_PATH:\n return None\n candidate = workspace_dir / REPO_REVIEW_GUIDE_PATH\n try:\n if candidate.is_file():\n text = candidate.read_text(encoding=\"utf-8\", errors=\"replace\").strip()\n if text:\n return text\n except Exception as exc:\n print(f\" Warning: could not read repo review guide {candidate}: {exc}\")\n return None\n\n\ndef _build_review_prompt(\n repo: str,\n pr: dict,\n head_sha: str,\n label_event: dict,\n repo_review_guide: str | None = None,\n *,\n workspace_instructions: str | None = None,\n github_token_secret: str = \"GITHUB_PERSONAL_ACCESS_TOKEN\",\n trigger_description: str | None = None,\n) -> str:\n number = pr.get(\"number\", \"?\")\n title = pr.get(\"title\", \"(no title)\")\n body = (pr.get(\"body\") or \"\").strip() or \"(no description)\"\n html_url = pr.get(\"html_url\", \"\")\n author = (pr.get(\"user\") or {}).get(\"login\", \"?\")\n base_branch = (pr.get(\"base\") or {}).get(\"ref\", \"?\")\n head_branch = (pr.get(\"head\") or {}).get(\"ref\", \"?\")\n label_str = \", \".join(_labels(pr)) or \"(none)\"\n label_event_id = label_event.get(\"id\", \"?\")\n label_event_created_at = label_event.get(\"created_at\", \"?\")\n trigger = trigger_description or (\n f\"latest `{TRIGGER_LABEL}` labeled event {label_event_id} \"\n f\"at {label_event_created_at}\"\n )\n changed_files = pr.get(\"changed_files\", \"?\")\n additions = pr.get(\"additions\", \"?\")\n deletions = pr.get(\"deletions\", \"?\")\n tone = _TONE_INSTRUCTIONS.get(REVIEW_TONE, _TONE_INSTRUCTIONS[\"thorough\"])\n extra = f\"\\n\\nAdditional style instructions:\\n{REVIEW_STYLE_INSTRUCTIONS}\" if REVIEW_STYLE_INSTRUCTIONS.strip() else \"\"\n guide_section = (\n f\"\\n\\nRepo-specific review guide (from {REPO_REVIEW_GUIDE_PATH}):\\n---\\n{repo_review_guide}\\n---\\n\"\n if repo_review_guide else \"\"\n )\n workspace = workspace_instructions or (\n \"The workspace is already the repository root at the exact Head SHA above. \"\n \"Do not clone, fetch, check out, or delete the repository.\"\n )\n\n return (\n \"You are an AI code reviewer. Review the GitHub pull request below and publish \"\n \"the review directly to GitHub. Do not modify files, push commits, or merge \"\n \"the pull request.\\n\\n\"\n f\"Repository : {repo}\\n\"\n f\"PR #{number}: \\\"{title}\\\"\\n\"\n f\"Author : @{author}\\n\"\n f\"Base → Head: {base_branch} ← {head_branch}\\n\"\n f\"Head SHA : {head_sha}\\n\"\n f\"Trigger : {trigger}\\n\"\n f\"Labels : {label_str}\\n\"\n f\"Changes : +{additions} -{deletions} across {changed_files} file(s)\\n\"\n f\"URL : {html_url}\\n\"\n f\"\\nPR Description:\\n---\\n{body}\\n---\\n\\n\"\n \"Required workflow:\\n\"\n f\"1. {workspace}\\n\"\n \"2. CURRENT STATE - treat this request as a fresh review, never a continuation of \"\n \"earlier observations. Before the scope gate and before deciding any verdict, re-fetch \"\n \"the current mutable GitHub state for this pull request and act only on what you read \"\n \"now: whether the exact head still matches the Head SHA above, the current PR title and \"\n \"body, the current review comments and threads, the current review requests, the current \"\n \"GitHub Actions check results for that head, and the current body and labels of every \"\n \"linked issue the PR references - a linked issue may have gained or lost a readiness \"\n \"label (for example `ready-for-dev`) or changed priority since an earlier turn. If an \"\n \"earlier turn in this conversation reviewed this PR or its linked issues, that analysis \"\n \"and the repository guidance you read remain useful background, but every mutable fact \"\n \"above must be re-established now; never repeat an earlier finding, verdict, or \"\n \"label/priority claim that the state you just read does not support.\\n\"\n f\" Use `gh` or GitHub REST API calls with `{github_token_secret}`; never print secret values.\\n\"\n \"3. Before reviewing, you MUST read the repository's own guidance to understand the repo first.\\n\"\n \" Read `AGENTS.md` at the repository root (and any nested `AGENTS.md` covering the \"\n \"changed files), plus other relevant docs when present - e.g. `CONTRIBUTING.md`, \"\n \"`CLAUDE.md`, `.cursorrules`, and any review or coding-guideline docs. Apply that \"\n \"guidance to your review.\\n\"\n \"4. SCOPE GATE - before inspecting changed files, reading the diff, or running any test, \"\n \"use the repository guidance above (its scope categories and ownership boundaries) to decide \"\n \"whether this change belongs in this repository and has the product/architecture direction \"\n \"it needs. If it does, continue the technical review unchanged. If it does not, or needs a \"\n \"product/architecture decision, stop here and publish exactly one review with \"\n f\"`POST /repos/{repo}/pulls/{number}/reviews`, using `commit_id` equal to the Head SHA above \"\n \"and `event: COMMENT`. Briefly say whether the change should move repositories, close, or \"\n \"receive a maintainer decision; when it belongs elsewhere, name the likely owning repository \"\n \"only if the evidence supports it. Do not run tests or report implementation findings. This \"\n \"outcome is not an approval, and its body ends with the verdict on its own line: \"\n f\"`{MAINTAINER_DECISION_VERDICT}`.\\n\"\n \"5. Otherwise continue the technical review. Inspect the PR discussion, existing review \"\n \"comments, changed files, and the diff, together with the surrounding code in the workspace.\\n\"\n \"6. Ground every finding in the workspace code. Before using an inline location, verify that \"\n \"the path and line are part of this pull request's diff. Compare every changed branch with \"\n \"the base behavior, including side effects outside the reported bug; a revision, event, or \"\n \"delivery identifier proves only the inputs it actually includes, not that unrelated profile, \"\n \"credential, configuration, or external state stayed unchanged. Do not add speculative or \"\n \"out-of-scope notes: every blocking or non-blocking observation must identify demonstrated \"\n \"behavior on the current head and explain why it matters to the merge decision.\\n\"\n f\"7. Publish one review with `POST /repos/{repo}/pulls/{number}/reviews`, using \"\n \"`commit_id` equal to the Head SHA above. Use `event: APPROVE` when there are \"\n \"no material findings; otherwise use `event: COMMENT`. Never use \"\n \"`REQUEST_CHANGES`.\\n\"\n \" The native GitHub review is the only result channel. Do not create commit \"\n \"statuses or Checks, post a separate issue comment, change labels, request \"\n \"reviewers, or merge. The deterministic automation owns trigger completion \"\n \"and any human-review handoff.\\n\"\n \" Put the overall assessment in `body`, and each line-specific finding in the `comments` \"\n \"array with `path`, `line`, `side: RIGHT`, and `body`.\\n\"\n \" Only create inline comments for actionable findings; do not open praise or nitpick threads.\\n\"\n \"8. If a finding cannot be attached to a changed line, put it in the review body instead. \"\n \"If the API rejects the inline positions, retry with every finding in the body and no `comments` array. \"\n \"If GitHub forbids the configured bot from approving its own PR, retry the clean review with \"\n \"`event: COMMENT` and keep the approved verdict.\\n\"\n \"9. Begin the review body with this disclosure: \"\n \"`_This review was posted by an AI agent (OpenHands)._`\\n\"\n \"10. End the review body with a verdict on its own line: either `✅ APPROVED` \"\n \"or `🔄 CHANGES REQUESTED`.\\n\"\n \"11. If there are no material issues, still publish a review saying so, with the \"\n \"disclosure and the verdict.\\n\"\n f\"\\nReview instructions:\\n{tone}{extra}{guide_section}\\n\\n\"\n \"After GitHub accepts the review, output exactly `GITHUB_REVIEW_POSTED`. \"\n \"If publishing still fails after the fallback in step 8, output the complete review text \"\n \"so it can be posted as a comment instead.\"\n )\n\n\ndef _process_review_request(\n github_token: str,\n agent_url: str,\n api_key: str,\n openhands_url: str,\n repo: str,\n pr: dict,\n label_event: dict,\n reviews: dict,\n persist: Callable[[], None],\n) -> str | None:\n number = pr[\"number\"]\n head_sha = _head_sha(pr)\n label_event_id = label_event[\"id\"]\n key = _review_key(number, label_event_id)\n title = pr.get(\"title\", \"(no title)\")\n html_url = pr.get(\"html_url\", \"\")\n\n print(f\" Queuing review for PR #{number} from `{TRIGGER_LABEL}` event {label_event_id} at {head_sha[:12]}: {title}\")\n\n # Claim the label event and persist it *before* the slow work below. State\n # is otherwise only written when the repository finishes polling, so a poll\n # starting while this one downloads an archive or spins up a conversation\n # would read no record for this event and review the same commit a second\n # time - two conversations, two \"reviewing\" comments, two reviews.\n reviews[key] = {\n \"pr_number\": number,\n \"head_sha\": head_sha,\n \"trigger_label_event_id\": label_event_id,\n \"trigger_label_event_created_at\": label_event.get(\"created_at\"),\n \"html_url\": html_url,\n \"status\": \"starting\",\n \"conversation_id\": None,\n \"workspace_dir\": None,\n \"last_activity\": time.time(),\n }\n persist()\n\n workspace_dir = None\n try:\n workspace_dir = _prepare_repository(github_token, repo, number, head_sha)\n repo_review_guide = _load_repo_review_guide(workspace_dir)\n if repo_review_guide:\n print(f\" Injected repo review guide for PR #{number}\")\n prompt = _build_review_prompt(repo, pr, head_sha, label_event, repo_review_guide)\n conv_id = create_conversation(agent_url, api_key, prompt, workspace_dir)\n except Exception as exc:\n # The claim is dropped so the next poll retries this label event. The\n # checkout goes with it rather than being left behind.\n if workspace_dir:\n shutil.rmtree(workspace_dir, ignore_errors=True)\n reviews.pop(key, None)\n persist()\n print(f\" Error starting review for PR #{number}: {exc}\")\n return None\n\n reviews[key].update(\n {\n \"status\": \"active\",\n \"conversation_id\": conv_id,\n \"workspace_dir\": str(workspace_dir),\n \"last_activity\": time.time(),\n }\n )\n persist()\n print(f\" Created review conversation {conv_id}\")\n\n conv_url = f\"{openhands_url}/conversations/{conv_id}\"\n _post_github_comment(\n github_token,\n repo,\n number,\n _with_ai_disclosure(\n \"🤖 **OpenHands is reviewing this PR.**\\n\\n\"\n f\"Trigger label: `{TRIGGER_LABEL}`\\n\"\n f\"Label event: `{label_event_id}` at `{label_event.get('created_at', '?')}`\\n\"\n f\"Head commit: `{head_sha}`\\n\"\n f\"View the conversation: {conv_url}\"\n ),\n )\n return conv_id\n\n\ndef _check_conversation_completion(\n rec: dict,\n latest_open_prs: dict[int, dict],\n github_token: str,\n agent_url: str,\n api_key: str,\n repo: str,\n) -> None:\n age = time.time() - rec.get(\"last_activity\", 0.0)\n if age < DONE_DEBOUNCE:\n return\n\n conv_id = rec[\"conversation_id\"]\n pr_number = rec[\"pr_number\"]\n reviewed_sha = rec.get(\"head_sha\", \"\")\n current_pr = latest_open_prs.get(pr_number)\n\n if not current_pr:\n rec[\"status\"] = \"closed\"\n print(f\" PR #{pr_number} closed/merged — skipping result post\")\n _release_checkout(rec, agent_url, api_key)\n return\n\n current_sha = _head_sha(current_pr)\n if current_sha and reviewed_sha and current_sha != reviewed_sha:\n rec[\"status\"] = \"stale\"\n rec[\"stale_reason\"] = f\"head changed from {reviewed_sha} to {current_sha}\"\n print(f\" PR #{pr_number} advanced to {current_sha[:12]} — suppressing stale review {conv_id}\")\n _release_checkout(rec, agent_url, api_key)\n return\n\n try:\n status = conversation_status(agent_url, api_key, conv_id)\n except Exception as exc:\n print(f\" Warning: could not get status for {conv_id}: {exc}\")\n return\n\n print(f\" PR #{pr_number} conversation {conv_id} → status={status}\")\n if status not in TERMINAL_STATUSES:\n if age > MAX_ACTIVE_AGE:\n rec[\"status\"] = \"expired\"\n rec[\"expired_after\"] = age\n print(f\" Review for PR #{pr_number} still '{status}' after {int(age)}s; abandoning it\")\n _release_checkout(rec, agent_url, api_key)\n return\n\n try:\n final = conversation_final_response(agent_url, api_key, conv_id)\n except Exception:\n final = \"\"\n\n if status in {\"error\", \"stuck\"}:\n _post_github_comment(\n github_token,\n repo,\n pr_number,\n _with_ai_disclosure(\n f\"⚠️ **OpenHands PR Reviewer encountered a problem** at commit `{reviewed_sha[:12]}` \"\n f\"(status: `{status}`).\\n\\n{final}\".strip()\n ),\n )\n elif _matching_review_exists(github_token, repo, pr_number, reviewed_sha):\n print(f\" PR #{pr_number}: review confirmed on GitHub at {reviewed_sha[:12]}\")\n else:\n # The agent was asked to publish the review itself; it did not, so the\n # work is not lost - post whatever it produced as a comment.\n _post_github_comment(\n github_token,\n repo,\n pr_number,\n _with_ai_disclosure(\n final\n or f\"✅ **OpenHands completed the review for commit `{reviewed_sha[:12]}`.** No review text was produced.\"\n ),\n )\n print(f\" PR #{pr_number}: no review found on GitHub; posted the result as a comment\")\n\n rec[\"status\"] = \"closed\"\n rec[\"completed_at\"] = time.time()\n _release_checkout(rec, agent_url, api_key)\n\n\ndef _process_repo(\n repo: str,\n github_token: str,\n agent_url: str,\n api_key: str,\n openhands_url: str,\n) -> str | None:\n \"\"\"Poll one repository end to end. Its state is loaded and saved here, so a\n failure in another repository cannot discard this one's progress.\"\"\"\n print(f\"\\n=== {repo} ===\")\n _verify_repo(github_token, repo)\n\n state = load_state(repo)\n reviews: dict = state.setdefault(\"reviews\", {})\n prs_state: dict = state.setdefault(\"prs\", {})\n\n def persist() -> None:\n state[\"version\"] = 3\n state[\"repo\"] = repo\n state[\"trigger_label\"] = TRIGGER_LABEL\n state[\"updated_at\"] = time.time()\n save_state(repo, state)\n\n open_prs = _list_open_prs(github_token, repo)\n latest_open_prs = {pr[\"number\"]: pr for pr in open_prs}\n print(f\" Found {len(open_prs)} open PR(s)\")\n\n last_conversation_id = None\n\n for pr in open_prs:\n number = pr[\"number\"]\n head_sha = _head_sha(pr)\n label_present = _has_trigger_label(pr)\n prs_state[str(number)] = {\n \"head_sha\": head_sha,\n \"label_present\": label_present,\n \"labels\": _labels(pr),\n \"last_seen\": time.time(),\n }\n\n if not label_present:\n continue\n if not head_sha:\n print(f\" PR #{number} has no head SHA; skipping\")\n continue\n\n fresh_pr = _get_pr(github_token, repo, number)\n fresh_head_sha = _head_sha(fresh_pr)\n if fresh_head_sha != head_sha:\n print(f\" PR #{number} head changed during poll ({head_sha[:12]} → {fresh_head_sha[:12]}); using latest PR metadata\")\n if not _has_trigger_label(fresh_pr):\n print(f\" PR #{number} lost `{TRIGGER_LABEL}` during poll; skipping\")\n continue\n\n label_event = _latest_trigger_label_event(github_token, repo, number)\n if not label_event:\n print(f\" PR #{number} has `{TRIGGER_LABEL}` but no matching labeled event; skipping\")\n continue\n\n key = _review_key(number, label_event[\"id\"])\n if key in reviews:\n print(f\" PR #{number} label event {label_event['id']} already tracked ({reviews[key].get('status')})\")\n continue\n\n conv_id = _process_review_request(\n github_token, agent_url, api_key, openhands_url, repo, fresh_pr, label_event, reviews, persist\n )\n if conv_id:\n last_conversation_id = conv_id\n\n for rev_key, rec in list(reviews.items()):\n if rec.get(\"status\") == \"starting\":\n # A claim this poll made has already moved to \"active\" or been\n # dropped, so one still sitting here belongs to a poll that died\n # between claiming and creating its conversation. Release it once it\n # is old enough that no live poll could still be working on it,\n # otherwise the label event would never be reviewed.\n age = time.time() - float(rec.get(\"last_activity\") or 0)\n if age > STALLED_CLAIM_SECONDS:\n print(f\" Releasing a claim stalled for {int(age)}s: {rev_key}\")\n reviews.pop(rev_key, None)\n continue\n if rec.get(\"status\") == \"active\":\n _check_conversation_completion(rec, latest_open_prs, github_token, agent_url, api_key, repo)\n elif rec.get(\"workspace_dir\"):\n # A checkout whose removal could not be confirmed on an earlier\n # poll, e.g. the agent was still running when its PR was closed.\n _release_checkout(rec, agent_url, api_key)\n\n persist()\n return last_conversation_id\n\n\ndef main() -> str | None:\n agent_url = os.environ.get(\"AGENT_SERVER_URL\", \"\").rstrip(\"/\")\n api_key = _get_env_key()\n\n github_token = _resolve_github_token()\n _verify_token(github_token)\n\n try:\n openhands_url = get_secret(\"OPENHANDS_URL\").rstrip(\"/\") or DEFAULT_OPENHANDS_URL\n except Exception:\n openhands_url = DEFAULT_OPENHANDS_URL\n\n last_conversation_id = None\n failures = []\n for configured in REPOS:\n # One repository failing must not stop the others from being polled.\n try:\n repo = normalize_repo(configured)\n conv_id = _process_repo(repo, github_token, agent_url, api_key, openhands_url)\n if conv_id:\n last_conversation_id = conv_id\n except Exception as exc:\n print(f\"Error processing {configured}: {exc}\")\n failures.append(f\"{configured}: {exc}\")\n\n if failures and len(failures) == len(REPOS):\n # Every repository failed, so the run achieved nothing - report it as a\n # failed run rather than a successful no-op.\n raise RuntimeError(\"; \".join(failures))\n return last_conversation_id\n\n\nif __name__ == \"__main__\":\n try:\n conversation_id = main()\n fire_callback(\"COMPLETED\", conversation_id=conversation_id)\n except Exception as exc:\n import traceback\n\n traceback.print_exc()\n fire_callback(\"FAILED\", str(exc))\n sys.exit(1)\n", + "main.py": "\"\"\"\nGitHub PR Reviewer - OpenHands Automation Script\n\nCron-polls one or more GitHub repositories for open pull requests carrying the\nconfigured trigger label. A review is queued only when the latest matching\nGitHub `labeled` event has not already been processed by this automation.\n\nEach repository is polled independently and keeps its own state document, so\npull-request numbers never collide across repositories.\n\nThis standalone script owns the repository checkout: it downloads the pull\nrequest's head commit as a tarball, hands the agent that directory as its\nworkspace, and removes it once the review has finished. Catalog workers may\ninstead reuse its prompt builder with their own workspace instructions.\n\"\"\"\n\nimport io\nimport json\nimport os\nimport re\nimport shutil\nimport sys\nimport tarfile\nimport time\nimport urllib.error\nimport urllib.request\nfrom collections.abc import Callable\nfrom pathlib import Path, PurePosixPath\n\nfrom github_client import github_request as _github_request\nfrom github_client import github_paginate as _github_paginate\n\n# Configuration. Two setup paths write it, and both end up here:\n#\n# - the agent-driven path (SKILL.md) substitutes these constants directly\n# into a copy of this file before packaging it;\n# - the catalog path packs an unmodified copy and ships a rendered\n# config.json beside it, which is loaded over these defaults below.\n#\n# A declarative host cannot rewrite Python - the catalog schema admits data,\n# not code - so the constants stay as the defaults and config.json is the\n# override, rather than one path being expressed in terms of the other.\nREPOS = [\"owner/repo\"]\nTRIGGER_LABEL = \"openhands-review\"\nREVIEW_TONE = \"thorough\"\nREVIEW_STYLE_INSTRUCTIONS = \"\"\n# Path within the checked-out repository to a repo-specific review guide\n# (e.g. the repo's own code-review skill). When the file exists at this path\n# relative to the repo root, its contents are read and injected verbatim into\n# the review prompt so the guide is always applied deterministically, rather\n# than relying on the spawned agent's skill activation. Set to \"\" to disable.\nREPO_REVIEW_GUIDE_PATH = \".agents/skills/custom-codereview-guide.md\"\nDEFAULT_OPENHANDS_URL = \"http://localhost:8000\"\n# The most new review conversations one scheduled scan may start, counted across\n# every configured repository rather than per repository. The scheduled scan\n# drains outstanding reviewer requests in oldest-request order, so a small bound\n# keeps a first scan over a large backlog from starting an agent for every\n# eligible pull request at once.\nMAX_NEW_PER_RUN = 2\n\n# A review that ends with this marker is a scope stop: the reviewer found the\n# change out of scope, or needing a product/architecture decision, before the\n# technical review. The completion handler hands it to a maintainer without\n# approving or merging the PR.\nMAINTAINER_DECISION_VERDICT = \"🛑 MAINTAINER DECISION REQUIRED\"\n\nCONFIG_FILENAME = \"config.json\"\n\n# Config keys, paired with the type each must have. A wrong type is a hard\n# error at import: the alternative is polling the string \"owner/repo\" one\n# character at a time, or matching a label that is silently a list.\n_CONFIG_TYPES: dict[str, type] = {\n \"repos\": list,\n \"trigger_label\": str,\n \"review_tone\": str,\n \"review_style_instructions\": str,\n \"repo_review_guide_path\": str,\n \"max_new_per_run\": int,\n \"openhands_url\": str,\n}\n\n\ndef load_config(directory: Path | None = None) -> dict:\n \"\"\"Return the rendered config shipped beside this script, or {} if absent.\n\n Only the keys above are read; anything else in the file is ignored, so a\n host may ship provenance there without this script caring.\n \"\"\"\n path = (directory or Path(__file__).resolve().parent) / CONFIG_FILENAME\n if not path.is_file():\n return {}\n\n try:\n raw = json.loads(path.read_text())\n except json.JSONDecodeError as e:\n raise SystemExit(f\"{CONFIG_FILENAME} is not valid JSON: {e}\") from e\n if not isinstance(raw, dict):\n raise SystemExit(f\"{CONFIG_FILENAME} must contain a JSON object\")\n\n config = {}\n for key, expected in _CONFIG_TYPES.items():\n if key not in raw:\n continue\n value = raw[key]\n # bool is an int in Python, so an unguarded int check would accept\n # `\"max_new_per_run\": true` and then start `True` conversations.\n if not isinstance(value, expected) or (\n expected is int and isinstance(value, bool)\n ):\n raise SystemExit(\n f\"{CONFIG_FILENAME}: {key} must be {expected.__name__}, \"\n f\"got {type(value).__name__}\"\n )\n if key == \"repos\" and not (\n value and all(isinstance(item, str) and item for item in value)\n ):\n raise SystemExit(\n f'{CONFIG_FILENAME}: repos must be a non-empty list of \"owner/repo\" strings'\n )\n if key == \"max_new_per_run\" and value < 1:\n raise SystemExit(\n f\"{CONFIG_FILENAME}: max_new_per_run must be at least 1\"\n )\n config[key] = value\n return config\n\n\n# owner/repo, which is what every GitHub API path in this script is built from.\n_REPO_NAME_RE = re.compile(r\"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$\")\n\n\ndef normalize_repo(value: str) -> str:\n \"\"\"Return ``owner/repo`` for the ways a repository gets written down.\n\n A clone URL is what a repository page offers to copy, so it is what ends up\n pasted into a setup form. Left alone it becomes\n ``/repos/https://github.com/owner/repo``, which GitHub answers with a 404 -\n indistinguishable, from here, from a repository the token cannot see.\n\n Raises ValueError for anything that is not a repository name, so the run\n says which value it could not read instead of blaming the token.\n \"\"\"\n repo = value.strip()\n if repo.startswith(\"git@\"):\n # git@github.com:owner/repo.git\n repo = repo.partition(\":\")[2]\n elif \"://\" in repo:\n # https://github.com/owner/repo, and anything else with a host\n repo = repo.split(\"://\", 1)[1].partition(\"/\")[2]\n repo = repo.strip(\"/\")\n if repo.endswith(\".git\"):\n repo = repo[: -len(\".git\")]\n\n if not _REPO_NAME_RE.match(repo):\n raise ValueError(\n f\"{value!r} is not a repository. Use owner/repo, for example \"\n \"OpenHands/automation.\"\n )\n return repo\n\n\n_CONFIG = load_config()\nREPOS = _CONFIG.get(\"repos\", REPOS)\nTRIGGER_LABEL = _CONFIG.get(\"trigger_label\", TRIGGER_LABEL)\nREVIEW_TONE = _CONFIG.get(\"review_tone\", REVIEW_TONE)\nREVIEW_STYLE_INSTRUCTIONS = _CONFIG.get(\"review_style_instructions\", REVIEW_STYLE_INSTRUCTIONS)\nREPO_REVIEW_GUIDE_PATH = _CONFIG.get(\"repo_review_guide_path\", REPO_REVIEW_GUIDE_PATH)\nMAX_NEW_PER_RUN = _CONFIG.get(\"max_new_per_run\", MAX_NEW_PER_RUN)\nDEFAULT_OPENHANDS_URL = _CONFIG.get(\"openhands_url\", DEFAULT_OPENHANDS_URL)\n\nDONE_DEBOUNCE = 15\nTERMINAL_STATUSES = {\"idle\", \"finished\", \"error\", \"stuck\"}\n# A conversation that never reaches a terminal status would hold its checkout\n# forever. After this long the review is abandoned so the disk can be reclaimed.\nMAX_ACTIVE_AGE = 2 * 60 * 60\n# A label event is claimed in the state document before its review starts, so an\n# overlapping poll skips it. If the claiming poll dies before the conversation\n# exists, the claim is released after this long - comfortably longer than\n# fetching an archive and opening a conversation, short enough that a crash does\n# not park the review until someone notices.\nSTALLED_CLAIM_SECONDS = 15 * 60\n\n# Login of the token owner, filled in by _verify_token. Reviews are matched\n# against it to answer \"did we already publish a review for this commit\", which\n# is checked on GitHub rather than trusted from the agent.\n_AUTH_LOGIN = \"\"\n\n\ndef _get_env_key() -> str:\n return os.environ.get(\"SESSION_API_KEY\") or os.environ.get(\"OH_SESSION_API_KEYS_0\") or \"\"\n\n\ndef get_secret(name: str) -> str:\n url = os.environ.get(\"AGENT_SERVER_URL\", \"\").rstrip(\"/\")\n key = _get_env_key()\n req = urllib.request.Request(\n f\"{url}/api/settings/secrets/{name}\",\n headers={\"X-Session-API-Key\": key},\n )\n with urllib.request.urlopen(req) as r:\n return r.read().decode().strip()\n\n\ndef fire_callback(\n status: str = \"COMPLETED\",\n error: str | None = None,\n conversation_id: str | None = None,\n) -> None:\n url = os.environ.get(\"AUTOMATION_CALLBACK_URL\", \"\")\n if not url:\n return\n body: dict = {\"status\": status, \"run_id\": os.environ.get(\"AUTOMATION_RUN_ID\", \"\")}\n if error:\n body[\"error\"] = error\n if conversation_id:\n body[\"conversation_id\"] = conversation_id\n req = urllib.request.Request(\n url,\n data=json.dumps(body).encode(),\n headers={\n \"Content-Type\": \"application/json\",\n \"Authorization\": f\"Bearer {os.environ.get('AUTOMATION_CALLBACK_API_KEY', '')}\",\n },\n )\n try:\n urllib.request.urlopen(req)\n except Exception as exc:\n print(f\"Callback error (non-fatal): {exc}\")\n\n\n# ── State persistence (KV store with local-file fallback) ─────────────────────\n\n_KV_TOKEN = os.environ.get(\"AUTOMATION_KV_TOKEN\", \"\")\n_KV_BASE = os.environ.get(\"AUTOMATION_API_URL\", \"\").rstrip(\"/\")\n# Single-repository deployments of this script kept their state under a bare\n# \"state\" key. It is adopted once, on first poll after an upgrade, so the\n# switch to per-repository keys does not re-review every open labelled PR.\n_LEGACY_STATE_KEY = \"state\"\n\n\ndef _repo_slug(repo: str) -> str:\n return repo.replace(\"/\", \"__\")\n\n\ndef _state_key(repo: str) -> str:\n return f\"state:{_repo_slug(repo)}\"\n\n\ndef _kv_available() -> bool:\n return bool(_KV_TOKEN and _KV_BASE)\n\n\ndef _kv_get(key: str) -> dict | None:\n req = urllib.request.Request(\n f\"{_KV_BASE}/v1/kv/{key}\",\n headers={\"Authorization\": f\"Bearer {_KV_TOKEN}\"},\n )\n try:\n with urllib.request.urlopen(req) as r:\n return json.loads(r.read())[\"value\"]\n except urllib.error.HTTPError as exc:\n if exc.code == 404:\n return None\n raise\n\n\ndef _kv_set(key: str, value: dict) -> None:\n req = urllib.request.Request(\n f\"{_KV_BASE}/v1/kv/{key}\",\n data=json.dumps(value).encode(),\n headers={\n \"Authorization\": f\"Bearer {_KV_TOKEN}\",\n \"Content-Type\": \"application/json\",\n },\n method=\"PUT\",\n )\n with urllib.request.urlopen(req) as r:\n r.read()\n\n\ndef _state_dir() -> Path:\n workspace_base = os.environ.get(\"WORKSPACE_BASE\", \"\")\n if workspace_base:\n root = Path(workspace_base).resolve().parent.parent\n else:\n root = Path.home() / \".openhands\" / \"workspaces\"\n state_dir = root / \"automation-state\"\n state_dir.mkdir(parents=True, exist_ok=True)\n return state_dir\n\n\ndef _automation_id() -> str:\n event_payload = json.loads(os.environ.get(\"AUTOMATION_EVENT_PAYLOAD\", \"{}\"))\n return event_payload.get(\"automation_id\", \"default\")\n\n\ndef _state_file_path(repo: str) -> str:\n name = f\"github_pr_reviewer_label_event_{_automation_id()}_{_repo_slug(repo)}.json\"\n return str(_state_dir() / name)\n\n\ndef _legacy_state_file_path() -> str:\n return str(_state_dir() / f\"github_pr_reviewer_label_event_{_automation_id()}.json\")\n\n\ndef _read_state_file(path: str) -> dict | None:\n if not os.path.exists(path):\n return None\n try:\n with open(path) as f:\n return json.load(f)\n except (json.JSONDecodeError, OSError) as exc:\n print(f\" Warning: state file {path} unreadable ({exc}); starting fresh\")\n return None\n\n\ndef _default_state(repo: str) -> dict:\n return {\n \"version\": 3,\n \"repo\": repo,\n \"trigger_label\": TRIGGER_LABEL,\n \"reviews\": {},\n \"prs\": {},\n }\n\n\ndef load_state(repo: str) -> dict:\n \"\"\"Load this repository's state, adopting a pre-multi-repo document once.\"\"\"\n if _kv_available():\n data = _kv_get(_state_key(repo))\n if data is not None:\n print(f\" State loaded from KV store ({_state_key(repo)})\")\n return data\n legacy = _kv_get(_LEGACY_STATE_KEY)\n if legacy is not None and legacy.get(\"repo\") == repo:\n print(f\" Adopted legacy KV state for {repo}\")\n return legacy\n return _default_state(repo)\n\n data = _read_state_file(_state_file_path(repo))\n if data is not None:\n return data\n legacy = _read_state_file(_legacy_state_file_path())\n if legacy is not None and legacy.get(\"repo\") == repo:\n print(f\" Adopted legacy state file for {repo}\")\n return legacy\n return _default_state(repo)\n\n\ndef save_state(repo: str, state: dict) -> None:\n if _kv_available():\n _kv_set(_state_key(repo), state)\n print(f\" State saved to KV store ({_state_key(repo)})\")\n return\n path = _state_file_path(repo)\n tmp_path = f\"{path}.tmp\"\n with open(tmp_path, \"w\") as f:\n json.dump(state, f, indent=2, sort_keys=True)\n os.replace(tmp_path, path)\n print(f\" State saved to {path}\")\n\n\n\ndef _resolve_github_token() -> str:\n try:\n token = get_secret(\"GITHUB_PERSONAL_ACCESS_TOKEN\")\n if token:\n return token\n except Exception:\n pass\n raise RuntimeError(\n \"GITHUB_PERSONAL_ACCESS_TOKEN secret is not set. \"\n \"Go to OpenHands Settings → Secrets and add your GitHub Personal Access Token.\"\n )\n\n\ndef _verify_token(token: str) -> None:\n \"\"\"Check the token once per run and remember who it belongs to.\"\"\"\n global _AUTH_LOGIN\n try:\n user_data, _ = _github_request(token, \"GET\", \"/user\")\n except urllib.error.HTTPError as exc:\n if exc.code == 401:\n raise RuntimeError(\"GITHUB_PERSONAL_ACCESS_TOKEN is invalid or expired.\") from exc\n raise RuntimeError(f\"GitHub /user check failed: {exc.code}\") from exc\n\n _AUTH_LOGIN = user_data.get(\"login\", \"\")\n print(f\"Authenticated as GitHub user: {_AUTH_LOGIN or '?'}\")\n\n\ndef _verify_repo(token: str, repo: str) -> None:\n try:\n _github_request(token, \"GET\", f\"/repos/{repo}\")\n except urllib.error.HTTPError as exc:\n if exc.code == 404:\n raise RuntimeError(f\"Repository '{repo}' is not accessible with the current token.\") from exc\n raise RuntimeError(f\"GitHub /repos/{repo} check failed: {exc.code}\") from exc\n\n\ndef _list_open_prs(token: str, repo: str) -> list[dict]:\n return _github_paginate(\n token,\n f\"/repos/{repo}/pulls\",\n {\"state\": \"open\", \"sort\": \"updated\", \"direction\": \"desc\"},\n )\n\n\ndef _get_pr(token: str, repo: str, pr_number: int) -> dict:\n pr, _ = _github_request(token, \"GET\", f\"/repos/{repo}/pulls/{pr_number}\")\n return pr\n\n\ndef _get_issue_events(token: str, repo: str, pr_number: int) -> list[dict]:\n return _github_paginate(token, f\"/repos/{repo}/issues/{pr_number}/events\")\n\n\ndef _latest_trigger_label_event(token: str, repo: str, pr_number: int) -> dict | None:\n events = _get_issue_events(token, repo, pr_number)\n matching = [\n event for event in events\n if event.get(\"event\") == \"labeled\"\n and (event.get(\"label\") or {}).get(\"name\", \"\").lower() == TRIGGER_LABEL.lower()\n and event.get(\"id\") is not None\n ]\n if not matching:\n return None\n return max(matching, key=lambda event: (event.get(\"created_at\") or \"\", int(event.get(\"id\") or 0)))\n\n\ndef _post_github_comment(token: str, repo: str, pr_number: int, body: str) -> None:\n try:\n _github_request(\n token,\n \"POST\",\n f\"/repos/{repo}/issues/{pr_number}/comments\",\n body={\"body\": body},\n )\n except Exception as exc:\n print(f\" Warning: failed to post comment on PR #{pr_number}: {exc}\")\n\n\ndef _matching_review_exists(token: str, repo: str, pr_number: int, head_sha: str) -> bool:\n \"\"\"Has this token's user already published a review for this exact commit?\n\n The agent is asked to report success, but a report is not evidence: reviews\n have been reported as posted when none existed. GitHub is the source of\n truth for whether the review landed.\n \"\"\"\n if not head_sha or not _AUTH_LOGIN:\n return False\n try:\n reviews = _github_paginate(token, f\"/repos/{repo}/pulls/{pr_number}/reviews\")\n except Exception as exc:\n print(f\" Warning: could not list reviews for PR #{pr_number}: {exc}\")\n return False\n for review in reviews:\n if (review.get(\"user\") or {}).get(\"login\", \"\").lower() != _AUTH_LOGIN.lower():\n continue\n if review.get(\"commit_id\") == head_sha:\n return True\n return False\n\n\n# ── Repository checkout ───────────────────────────────────────────────────────\n\n\ndef _checkouts_root() -> Path:\n return Path(os.environ.get(\"WORKSPACE_BASE\", \"/workspace\")).resolve() / \"repositories\"\n\n\ndef _checkout_path(repo: str, pr_number: int, head_sha: str) -> Path:\n return _checkouts_root() / _repo_slug(repo) / f\"pr-{pr_number}-{head_sha[:12]}\"\n\n\ndef _prepare_repository(token: str, repo: str, pr_number: int, head_sha: str) -> Path:\n \"\"\"Materialise the pull request's head commit as the agent's workspace.\n\n The commit is fetched as a tarball rather than cloned, so the directory\n holds exactly the reviewed tree with no history and no git remote for the\n agent to push to.\n \"\"\"\n checkout = _checkout_path(repo, pr_number, head_sha)\n if checkout.exists():\n shutil.rmtree(checkout)\n checkout.mkdir(parents=True)\n\n req = urllib.request.Request(\n f\"https://api.github.com/repos/{repo}/tarball/{head_sha}\",\n headers={\n \"Authorization\": f\"Bearer {token}\",\n \"Accept\": \"application/vnd.github+json\",\n \"X-GitHub-Api-Version\": \"2022-11-28\",\n },\n )\n skipped_links = 0\n try:\n with urllib.request.urlopen(req) as response:\n archive = tarfile.open(fileobj=io.BytesIO(response.read()), mode=\"r:gz\")\n with archive:\n members = archive.getmembers()\n roots = {\n PurePosixPath(member.name).parts[0]\n for member in members\n if PurePosixPath(member.name).parts\n }\n if len(roots) != 1:\n raise RuntimeError(\"Repository archive has an unexpected layout\")\n root = next(iter(roots))\n for member in members:\n path = PurePosixPath(member.name)\n if not path.parts or path.parts[0] != root:\n raise RuntimeError(\"Repository archive contains an invalid path\")\n relative = PurePosixPath(*path.parts[1:])\n if not relative.parts:\n continue\n if relative.is_absolute() or \"..\" in relative.parts:\n raise RuntimeError(\"Repository archive contains path traversal\")\n if member.issym() or member.islnk() or member.isdev():\n # Repositories legitimately contain symlinks. Reviewing does\n # not need them, and materialising them risks escaping the\n # checkout, so skip rather than reject the whole archive.\n skipped_links += 1\n continue\n destination = checkout.joinpath(*relative.parts)\n if member.isdir():\n destination.mkdir(parents=True, exist_ok=True)\n continue\n if not member.isfile():\n continue\n destination.parent.mkdir(parents=True, exist_ok=True)\n source = archive.extractfile(member)\n if source is None:\n raise RuntimeError(f\"Could not read archive member {member.name}\")\n with source, destination.open(\"wb\") as target:\n shutil.copyfileobj(source, target)\n destination.chmod(member.mode & 0o777)\n except Exception:\n shutil.rmtree(checkout, ignore_errors=True)\n raise\n\n if skipped_links:\n print(f\" Skipped {skipped_links} link/device entries while extracting\")\n return checkout\n\n\ndef _release_checkout(rec: dict, agent_url: str, api_key: str) -> bool:\n \"\"\"Remove a finished review's checkout. Returns True when nothing is left.\n\n The checkout is the conversation's working directory, so it is only removed\n once the conversation has stopped - deleting it under a running agent would\n pull the ground out from under it. When the status cannot be confirmed the\n directory is left alone and the next poll tries again.\n \"\"\"\n workspace_dir = rec.get(\"workspace_dir\")\n if not workspace_dir:\n return True\n\n conversation_id = rec.get(\"conversation_id\")\n if conversation_id:\n try:\n status = conversation_status(agent_url, api_key, conversation_id)\n except urllib.error.HTTPError as exc:\n status = \"finished\" if exc.code == 404 else None\n except Exception:\n status = None\n if status is None:\n print(f\" Could not confirm conversation {conversation_id} has stopped; keeping {workspace_dir}\")\n return False\n if status not in TERMINAL_STATUSES:\n print(f\" Conversation {conversation_id} is still '{status}'; keeping its checkout\")\n return False\n\n path = Path(workspace_dir)\n root = _checkouts_root()\n try:\n resolved = path.resolve()\n except OSError:\n resolved = path\n if resolved == root or not resolved.is_relative_to(root):\n # Never delete anything the script did not create under the checkout\n # root, whatever ended up recorded in state.\n print(f\" Refusing to remove {resolved}: outside {root}\")\n rec.pop(\"workspace_dir\", None)\n return True\n\n shutil.rmtree(resolved, ignore_errors=True)\n rec.pop(\"workspace_dir\", None)\n print(f\" Removed checkout {resolved}\")\n return True\n\n\ndef _oh_request(agent_url: str, api_key: str, method: str, path: str, body: dict | None = None) -> dict:\n url = f\"{agent_url}{path}\"\n headers = {\"X-Session-API-Key\": api_key, \"Content-Type\": \"application/json\"}\n data = json.dumps(body).encode() if body is not None else None\n req = urllib.request.Request(url, data=data, headers=headers, method=method)\n try:\n with urllib.request.urlopen(req) as r:\n raw = r.read()\n return json.loads(raw) if raw.strip() else {}\n except urllib.error.HTTPError as exc:\n body_text = exc.read().decode()\n raise RuntimeError(f\"Agent API {method} {path} → {exc.code}: {body_text}\") from exc\n\n\ndef _fetch_settings(agent_url: str, api_key: str) -> dict:\n req = urllib.request.Request(\n f\"{agent_url}/api/settings\",\n headers={\"X-Session-API-Key\": api_key, \"X-Expose-Secrets\": \"plaintext\"},\n )\n with urllib.request.urlopen(req) as r:\n return json.loads(r.read())\n\n\ndef _get_agent_dict(agent_url: str, api_key: str) -> dict:\n data = _fetch_settings(agent_url, api_key)\n llm = data.get(\"agent_settings\", {}).get(\"llm\", {})\n return {\n \"kind\": \"Agent\",\n \"llm\": llm,\n \"tools\": [{\"name\": \"terminal\"}, {\"name\": \"file_editor\"}],\n }\n\n\ndef _get_mcp_config(agent_url: str, api_key: str) -> dict | None:\n try:\n data = _fetch_settings(agent_url, api_key)\n mcp_config = data.get(\"agent_settings\", {}).get(\"mcp_config\")\n if isinstance(mcp_config, dict) and mcp_config.get(\"mcpServers\"):\n return mcp_config\n except Exception as exc:\n print(f\"Warning: could not fetch MCP config: {exc}\")\n return None\n\n\ndef _list_secret_names(agent_url: str, api_key: str) -> list[dict]:\n try:\n result = _oh_request(agent_url, api_key, \"GET\", \"/api/settings/secrets\")\n return result.get(\"secrets\", [])\n except Exception as exc:\n print(f\"Warning: could not list secrets: {exc}\")\n return []\n\n\ndef _build_secrets_payload(agent_url: str, api_key: str) -> dict:\n secrets = {}\n for secret in _list_secret_names(agent_url, api_key):\n name = secret.get(\"name\", \"\")\n if not name:\n continue\n lookup: dict = {\n \"kind\": \"LookupSecret\",\n \"url\": f\"/api/settings/secrets/{name}\",\n }\n if api_key:\n lookup[\"headers\"] = {\"X-Session-API-Key\": api_key}\n desc = secret.get(\"description\")\n if desc:\n lookup[\"description\"] = desc\n secrets[name] = lookup\n return secrets\n\n\ndef create_conversation(\n agent_url: str,\n api_key: str,\n initial_message: str,\n workspace_dir: Path,\n) -> str:\n payload: dict = {\n \"workspace\": {\"working_dir\": str(workspace_dir)},\n \"agent\": _get_agent_dict(agent_url, api_key),\n \"initial_message\": {\"content\": [{\"text\": initial_message}]},\n }\n secrets = _build_secrets_payload(agent_url, api_key)\n if secrets:\n payload[\"secrets\"] = secrets\n mcp_config = _get_mcp_config(agent_url, api_key)\n if mcp_config:\n payload[\"mcp_config\"] = mcp_config\n result = _oh_request(agent_url, api_key, \"POST\", \"/api/conversations\", payload)\n return result[\"id\"]\n\n\ndef conversation_status(agent_url: str, api_key: str, conv_id: str) -> str:\n result = _oh_request(agent_url, api_key, \"GET\", f\"/api/conversations/{conv_id}\")\n return result.get(\"execution_status\", \"unknown\")\n\n\ndef conversation_final_response(agent_url: str, api_key: str, conv_id: str) -> str:\n result = _oh_request(agent_url, api_key, \"GET\", f\"/api/conversations/{conv_id}/agent_final_response\")\n return result.get(\"response\", \"\")\n\n\n_TONE_INSTRUCTIONS = {\n \"thorough\": (\n \"Provide a comprehensive review. Cover correctness, security vulnerabilities, \"\n \"missing or inadequate tests, code style, maintainability, and potential edge cases. \"\n \"Reference specific files and line numbers where relevant.\"\n ),\n \"concise\": (\n \"Provide a brief, high-signal review. Focus only on important bugs, security problems, \"\n \"or significant design flaws. Omit minor style feedback.\"\n ),\n \"friendly\": (\n \"Provide a constructive, encouraging review. Acknowledge what is done well before \"\n \"raising concerns while still noting real issues.\"\n ),\n}\n\n\ndef _labels(pr: dict) -> list[str]:\n return [label.get(\"name\", \"\") for label in pr.get(\"labels\", [])]\n\n\ndef _has_trigger_label(pr: dict) -> bool:\n return any(label.lower() == TRIGGER_LABEL.lower() for label in _labels(pr))\n\n\ndef _head_sha(pr: dict) -> str:\n return ((pr.get(\"head\") or {}).get(\"sha\") or \"\").strip()\n\n\ndef _review_key(pr_number: int, label_event_id: int | str) -> str:\n return f\"{pr_number}:label:{label_event_id}\"\n\n\ndef _with_ai_disclosure(body: str) -> str:\n disclosure = \"_This comment was posted by an AI agent (OpenHands)._\"\n body = (body or \"\").strip()\n if disclosure.lower() in body.lower():\n return body\n return f\"{body}\\n\\n{disclosure}\" if body else disclosure\n\n\ndef _load_repo_review_guide(workspace_dir: Path) -> str | None:\n \"\"\"Read the repo-specific review guide from the checked-out repository.\n\n The path is taken from ``REPO_REVIEW_GUIDE_PATH``. An empty path disables\n the feature. Returns the file contents, or None if the file is absent or\n unreadable — a missing guide is never fatal, the review simply proceeds\n without it.\n \"\"\"\n if not REPO_REVIEW_GUIDE_PATH:\n return None\n candidate = workspace_dir / REPO_REVIEW_GUIDE_PATH\n try:\n if candidate.is_file():\n text = candidate.read_text(encoding=\"utf-8\", errors=\"replace\").strip()\n if text:\n return text\n except Exception as exc:\n print(f\" Warning: could not read repo review guide {candidate}: {exc}\")\n return None\n\n\ndef _build_review_prompt(\n repo: str,\n pr: dict,\n head_sha: str,\n label_event: dict,\n repo_review_guide: str | None = None,\n *,\n workspace_instructions: str | None = None,\n github_token_secret: str = \"GITHUB_PERSONAL_ACCESS_TOKEN\",\n trigger_description: str | None = None,\n) -> str:\n number = pr.get(\"number\", \"?\")\n title = pr.get(\"title\", \"(no title)\")\n body = (pr.get(\"body\") or \"\").strip() or \"(no description)\"\n html_url = pr.get(\"html_url\", \"\")\n author = (pr.get(\"user\") or {}).get(\"login\", \"?\")\n base_branch = (pr.get(\"base\") or {}).get(\"ref\", \"?\")\n head_branch = (pr.get(\"head\") or {}).get(\"ref\", \"?\")\n label_str = \", \".join(_labels(pr)) or \"(none)\"\n label_event_id = label_event.get(\"id\", \"?\")\n label_event_created_at = label_event.get(\"created_at\", \"?\")\n trigger = trigger_description or (\n f\"latest `{TRIGGER_LABEL}` labeled event {label_event_id} \"\n f\"at {label_event_created_at}\"\n )\n changed_files = pr.get(\"changed_files\", \"?\")\n additions = pr.get(\"additions\", \"?\")\n deletions = pr.get(\"deletions\", \"?\")\n tone = _TONE_INSTRUCTIONS.get(REVIEW_TONE, _TONE_INSTRUCTIONS[\"thorough\"])\n extra = f\"\\n\\nAdditional style instructions:\\n{REVIEW_STYLE_INSTRUCTIONS}\" if REVIEW_STYLE_INSTRUCTIONS.strip() else \"\"\n guide_section = (\n f\"\\n\\nRepo-specific review guide (from {REPO_REVIEW_GUIDE_PATH}):\\n---\\n{repo_review_guide}\\n---\\n\"\n if repo_review_guide else \"\"\n )\n workspace = workspace_instructions or (\n \"The workspace is already the repository root at the exact Head SHA above. \"\n \"Do not clone, fetch, check out, or delete the repository.\"\n )\n\n return (\n \"You are an AI code reviewer. Review the GitHub pull request below and publish \"\n \"the review directly to GitHub. Do not modify files, push commits, or merge \"\n \"the pull request.\\n\\n\"\n f\"Repository : {repo}\\n\"\n f\"PR #{number}: \\\"{title}\\\"\\n\"\n f\"Author : @{author}\\n\"\n f\"Base → Head: {base_branch} ← {head_branch}\\n\"\n f\"Head SHA : {head_sha}\\n\"\n f\"Trigger : {trigger}\\n\"\n f\"Labels : {label_str}\\n\"\n f\"Changes : +{additions} -{deletions} across {changed_files} file(s)\\n\"\n f\"URL : {html_url}\\n\"\n f\"\\nPR Description:\\n---\\n{body}\\n---\\n\\n\"\n \"Required workflow:\\n\"\n f\"1. {workspace}\\n\"\n \"2. CURRENT STATE - treat this request as a fresh review, never a continuation of \"\n \"earlier observations. Before the scope gate and before deciding any verdict, re-fetch \"\n \"the current mutable GitHub state for this pull request and act only on what you read \"\n \"now: whether the exact head still matches the Head SHA above, the current PR title and \"\n \"body, the current review comments and threads, the current review requests, the current \"\n \"GitHub Actions check results for that head, and the current body and labels of every \"\n \"linked issue the PR references - a linked issue may have gained or lost a readiness \"\n \"label (for example `ready-for-dev`) or changed priority since an earlier turn. If an \"\n \"earlier turn in this conversation reviewed this PR or its linked issues, that analysis \"\n \"and the repository guidance you read remain useful background, but every mutable fact \"\n \"above must be re-established now; never repeat an earlier finding, verdict, or \"\n \"label/priority claim that the state you just read does not support.\\n\"\n f\" Use `gh` or GitHub REST API calls with `{github_token_secret}`; never print secret values.\\n\"\n \"3. Before reviewing, you MUST read the repository's own guidance to understand the repo first.\\n\"\n \" Read `AGENTS.md` at the repository root (and any nested `AGENTS.md` covering the \"\n \"changed files), plus other relevant docs when present - e.g. `CONTRIBUTING.md`, \"\n \"`CLAUDE.md`, `.cursorrules`, and any review or coding-guideline docs. Apply that \"\n \"guidance to your review.\\n\"\n \"4. SCOPE GATE - before inspecting changed files, reading the diff, or running any test, \"\n \"use the repository guidance above (its scope categories and ownership boundaries) to decide \"\n \"whether this change belongs in this repository and has the product/architecture direction \"\n \"it needs. If it does, continue the technical review unchanged. If it does not, or needs a \"\n \"product/architecture decision, stop here and publish exactly one review with \"\n f\"`POST /repos/{repo}/pulls/{number}/reviews`, using `commit_id` equal to the Head SHA above \"\n \"and `event: COMMENT`. Briefly say whether the change should move repositories, close, or \"\n \"receive a maintainer decision; when it belongs elsewhere, name the likely owning repository \"\n \"only if the evidence supports it. Do not run tests or report implementation findings. This \"\n \"outcome is not an approval, and its body ends with the verdict on its own line: \"\n f\"`{MAINTAINER_DECISION_VERDICT}`.\\n\"\n \"5. Otherwise continue the technical review. Inspect the PR discussion, existing review \"\n \"comments, changed files, and the diff, together with the surrounding code in the workspace.\\n\"\n \"6. Ground every finding in the workspace code. Before using an inline location, verify that \"\n \"the path and line are part of this pull request's diff. Compare every changed branch with \"\n \"the base behavior, including side effects outside the reported bug; a revision, event, or \"\n \"delivery identifier proves only the inputs it actually includes, not that unrelated profile, \"\n \"credential, configuration, or external state stayed unchanged. Do not add speculative or \"\n \"out-of-scope notes: every blocking or non-blocking observation must identify demonstrated \"\n \"behavior on the current head and explain why it matters to the merge decision.\\n\"\n f\"7. Publish one review with `POST /repos/{repo}/pulls/{number}/reviews`, using \"\n \"`commit_id` equal to the Head SHA above. Use `event: APPROVE` when there are \"\n \"no material findings; otherwise use `event: COMMENT`. Never use \"\n \"`REQUEST_CHANGES`.\\n\"\n \" The native GitHub review is the only result channel. Do not create commit \"\n \"statuses or Checks, post a separate issue comment, change labels, request \"\n \"reviewers, or merge. The deterministic automation owns trigger completion \"\n \"and any human-review handoff.\\n\"\n \" Put the overall assessment in `body`, and each line-specific finding in the `comments` \"\n \"array with `path`, `line`, `side: RIGHT`, and `body`.\\n\"\n \" Only create inline comments for actionable findings; do not open praise or nitpick threads.\\n\"\n \"8. If a finding cannot be attached to a changed line, put it in the review body instead. \"\n \"If the API rejects the inline positions, retry with every finding in the body and no `comments` array. \"\n \"If GitHub forbids the configured bot from approving its own PR, retry the clean review with \"\n \"`event: COMMENT` and keep the approved verdict.\\n\"\n \"9. Begin the review body with this disclosure: \"\n \"`_This review was posted by an AI agent (OpenHands)._`\\n\"\n \"10. End the review body with a verdict on its own line: either `✅ APPROVED` \"\n \"or `🔄 CHANGES REQUESTED`.\\n\"\n \"11. If there are no material issues, still publish a review saying so, with the \"\n \"disclosure and the verdict.\\n\"\n f\"\\nReview instructions:\\n{tone}{extra}{guide_section}\\n\\n\"\n \"After GitHub accepts the review, output exactly `GITHUB_REVIEW_POSTED`. \"\n \"If publishing still fails after the fallback in step 8, output the complete review text \"\n \"so it can be posted as a comment instead.\"\n )\n\n\ndef _process_review_request(\n github_token: str,\n agent_url: str,\n api_key: str,\n openhands_url: str,\n repo: str,\n pr: dict,\n label_event: dict,\n reviews: dict,\n persist: Callable[[], None],\n) -> str | None:\n number = pr[\"number\"]\n head_sha = _head_sha(pr)\n label_event_id = label_event[\"id\"]\n key = _review_key(number, label_event_id)\n title = pr.get(\"title\", \"(no title)\")\n html_url = pr.get(\"html_url\", \"\")\n\n print(f\" Queuing review for PR #{number} from `{TRIGGER_LABEL}` event {label_event_id} at {head_sha[:12]}: {title}\")\n\n # Claim the label event and persist it *before* the slow work below. State\n # is otherwise only written when the repository finishes polling, so a poll\n # starting while this one downloads an archive or spins up a conversation\n # would read no record for this event and review the same commit a second\n # time - two conversations, two \"reviewing\" comments, two reviews.\n reviews[key] = {\n \"pr_number\": number,\n \"head_sha\": head_sha,\n \"trigger_label_event_id\": label_event_id,\n \"trigger_label_event_created_at\": label_event.get(\"created_at\"),\n \"html_url\": html_url,\n \"status\": \"starting\",\n \"conversation_id\": None,\n \"workspace_dir\": None,\n \"last_activity\": time.time(),\n }\n persist()\n\n workspace_dir = None\n try:\n workspace_dir = _prepare_repository(github_token, repo, number, head_sha)\n repo_review_guide = _load_repo_review_guide(workspace_dir)\n if repo_review_guide:\n print(f\" Injected repo review guide for PR #{number}\")\n prompt = _build_review_prompt(repo, pr, head_sha, label_event, repo_review_guide)\n conv_id = create_conversation(agent_url, api_key, prompt, workspace_dir)\n except Exception as exc:\n # The claim is dropped so the next poll retries this label event. The\n # checkout goes with it rather than being left behind.\n if workspace_dir:\n shutil.rmtree(workspace_dir, ignore_errors=True)\n reviews.pop(key, None)\n persist()\n print(f\" Error starting review for PR #{number}: {exc}\")\n return None\n\n reviews[key].update(\n {\n \"status\": \"active\",\n \"conversation_id\": conv_id,\n \"workspace_dir\": str(workspace_dir),\n \"last_activity\": time.time(),\n }\n )\n persist()\n print(f\" Created review conversation {conv_id}\")\n\n conv_url = f\"{openhands_url}/conversations/{conv_id}\"\n _post_github_comment(\n github_token,\n repo,\n number,\n _with_ai_disclosure(\n \"🤖 **OpenHands is reviewing this PR.**\\n\\n\"\n f\"Trigger label: `{TRIGGER_LABEL}`\\n\"\n f\"Label event: `{label_event_id}` at `{label_event.get('created_at', '?')}`\\n\"\n f\"Head commit: `{head_sha}`\\n\"\n f\"View the conversation: {conv_url}\"\n ),\n )\n return conv_id\n\n\ndef _check_conversation_completion(\n rec: dict,\n latest_open_prs: dict[int, dict],\n github_token: str,\n agent_url: str,\n api_key: str,\n repo: str,\n) -> None:\n age = time.time() - rec.get(\"last_activity\", 0.0)\n if age < DONE_DEBOUNCE:\n return\n\n conv_id = rec[\"conversation_id\"]\n pr_number = rec[\"pr_number\"]\n reviewed_sha = rec.get(\"head_sha\", \"\")\n current_pr = latest_open_prs.get(pr_number)\n\n if not current_pr:\n rec[\"status\"] = \"closed\"\n print(f\" PR #{pr_number} closed/merged — skipping result post\")\n _release_checkout(rec, agent_url, api_key)\n return\n\n current_sha = _head_sha(current_pr)\n if current_sha and reviewed_sha and current_sha != reviewed_sha:\n rec[\"status\"] = \"stale\"\n rec[\"stale_reason\"] = f\"head changed from {reviewed_sha} to {current_sha}\"\n print(f\" PR #{pr_number} advanced to {current_sha[:12]} — suppressing stale review {conv_id}\")\n _release_checkout(rec, agent_url, api_key)\n return\n\n try:\n status = conversation_status(agent_url, api_key, conv_id)\n except Exception as exc:\n print(f\" Warning: could not get status for {conv_id}: {exc}\")\n return\n\n print(f\" PR #{pr_number} conversation {conv_id} → status={status}\")\n if status not in TERMINAL_STATUSES:\n if age > MAX_ACTIVE_AGE:\n rec[\"status\"] = \"expired\"\n rec[\"expired_after\"] = age\n print(f\" Review for PR #{pr_number} still '{status}' after {int(age)}s; abandoning it\")\n _release_checkout(rec, agent_url, api_key)\n return\n\n try:\n final = conversation_final_response(agent_url, api_key, conv_id)\n except Exception:\n final = \"\"\n\n if status in {\"error\", \"stuck\"}:\n _post_github_comment(\n github_token,\n repo,\n pr_number,\n _with_ai_disclosure(\n f\"⚠️ **OpenHands PR Reviewer encountered a problem** at commit `{reviewed_sha[:12]}` \"\n f\"(status: `{status}`).\\n\\n{final}\".strip()\n ),\n )\n elif _matching_review_exists(github_token, repo, pr_number, reviewed_sha):\n print(f\" PR #{pr_number}: review confirmed on GitHub at {reviewed_sha[:12]}\")\n else:\n # The agent was asked to publish the review itself; it did not, so the\n # work is not lost - post whatever it produced as a comment.\n _post_github_comment(\n github_token,\n repo,\n pr_number,\n _with_ai_disclosure(\n final\n or f\"✅ **OpenHands completed the review for commit `{reviewed_sha[:12]}`.** No review text was produced.\"\n ),\n )\n print(f\" PR #{pr_number}: no review found on GitHub; posted the result as a comment\")\n\n rec[\"status\"] = \"closed\"\n rec[\"completed_at\"] = time.time()\n _release_checkout(rec, agent_url, api_key)\n\n\ndef _process_repo(\n repo: str,\n github_token: str,\n agent_url: str,\n api_key: str,\n openhands_url: str,\n) -> str | None:\n \"\"\"Poll one repository end to end. Its state is loaded and saved here, so a\n failure in another repository cannot discard this one's progress.\"\"\"\n print(f\"\\n=== {repo} ===\")\n _verify_repo(github_token, repo)\n\n state = load_state(repo)\n reviews: dict = state.setdefault(\"reviews\", {})\n prs_state: dict = state.setdefault(\"prs\", {})\n\n def persist() -> None:\n state[\"version\"] = 3\n state[\"repo\"] = repo\n state[\"trigger_label\"] = TRIGGER_LABEL\n state[\"updated_at\"] = time.time()\n save_state(repo, state)\n\n open_prs = _list_open_prs(github_token, repo)\n latest_open_prs = {pr[\"number\"]: pr for pr in open_prs}\n print(f\" Found {len(open_prs)} open PR(s)\")\n\n last_conversation_id = None\n\n for pr in open_prs:\n number = pr[\"number\"]\n head_sha = _head_sha(pr)\n label_present = _has_trigger_label(pr)\n prs_state[str(number)] = {\n \"head_sha\": head_sha,\n \"label_present\": label_present,\n \"labels\": _labels(pr),\n \"last_seen\": time.time(),\n }\n\n if not label_present:\n continue\n if not head_sha:\n print(f\" PR #{number} has no head SHA; skipping\")\n continue\n\n fresh_pr = _get_pr(github_token, repo, number)\n fresh_head_sha = _head_sha(fresh_pr)\n if fresh_head_sha != head_sha:\n print(f\" PR #{number} head changed during poll ({head_sha[:12]} → {fresh_head_sha[:12]}); using latest PR metadata\")\n if not _has_trigger_label(fresh_pr):\n print(f\" PR #{number} lost `{TRIGGER_LABEL}` during poll; skipping\")\n continue\n\n label_event = _latest_trigger_label_event(github_token, repo, number)\n if not label_event:\n print(f\" PR #{number} has `{TRIGGER_LABEL}` but no matching labeled event; skipping\")\n continue\n\n key = _review_key(number, label_event[\"id\"])\n if key in reviews:\n print(f\" PR #{number} label event {label_event['id']} already tracked ({reviews[key].get('status')})\")\n continue\n\n conv_id = _process_review_request(\n github_token, agent_url, api_key, openhands_url, repo, fresh_pr, label_event, reviews, persist\n )\n if conv_id:\n last_conversation_id = conv_id\n\n for rev_key, rec in list(reviews.items()):\n if rec.get(\"status\") == \"starting\":\n # A claim this poll made has already moved to \"active\" or been\n # dropped, so one still sitting here belongs to a poll that died\n # between claiming and creating its conversation. Release it once it\n # is old enough that no live poll could still be working on it,\n # otherwise the label event would never be reviewed.\n age = time.time() - float(rec.get(\"last_activity\") or 0)\n if age > STALLED_CLAIM_SECONDS:\n print(f\" Releasing a claim stalled for {int(age)}s: {rev_key}\")\n reviews.pop(rev_key, None)\n continue\n if rec.get(\"status\") == \"active\":\n _check_conversation_completion(rec, latest_open_prs, github_token, agent_url, api_key, repo)\n elif rec.get(\"workspace_dir\"):\n # A checkout whose removal could not be confirmed on an earlier\n # poll, e.g. the agent was still running when its PR was closed.\n _release_checkout(rec, agent_url, api_key)\n\n persist()\n return last_conversation_id\n\n\ndef main() -> str | None:\n agent_url = os.environ.get(\"AGENT_SERVER_URL\", \"\").rstrip(\"/\")\n api_key = _get_env_key()\n\n github_token = _resolve_github_token()\n _verify_token(github_token)\n\n try:\n openhands_url = get_secret(\"OPENHANDS_URL\").rstrip(\"/\") or DEFAULT_OPENHANDS_URL\n except Exception:\n openhands_url = DEFAULT_OPENHANDS_URL\n\n last_conversation_id = None\n failures = []\n for configured in REPOS:\n # One repository failing must not stop the others from being polled.\n try:\n repo = normalize_repo(configured)\n conv_id = _process_repo(repo, github_token, agent_url, api_key, openhands_url)\n if conv_id:\n last_conversation_id = conv_id\n except Exception as exc:\n print(f\"Error processing {configured}: {exc}\")\n failures.append(f\"{configured}: {exc}\")\n\n if failures and len(failures) == len(REPOS):\n # Every repository failed, so the run achieved nothing - report it as a\n # failed run rather than a successful no-op.\n raise RuntimeError(\"; \".join(failures))\n return last_conversation_id\n\n\nif __name__ == \"__main__\":\n try:\n conversation_id = main()\n fire_callback(\"COMPLETED\", conversation_id=conversation_id)\n except Exception as exc:\n import traceback\n\n traceback.print_exc()\n fire_callback(\"FAILED\", str(exc))\n sys.exit(1)\n", "maintainer_handoff.py": "\"\"\"Choose one code-aware, currently available maintainer for a reviewed PR.\"\"\"\n\nfrom urllib.error import HTTPError\nfrom urllib.parse import urlencode\n\n_DECISIVE_REVIEW_STATES = {\"APPROVED\", \"CHANGES_REQUESTED\"}\n_MAX_PATHS = 8\n_COMMITS_PER_PATH = 10\n\n\nclass HandoffConfigurationError(ValueError):\n \"\"\"The configured roster cannot produce a GitHub review request.\"\"\"\n\n\ndef parse_maintainers(value):\n \"\"\"Normalize a comma-separated catalog value or a JSON-style list.\"\"\"\n if isinstance(value, str):\n value = value.split(\",\")\n result = []\n seen = set()\n for item in value or []:\n login = str(item).strip()\n key = login.lower()\n if login and key not in seen:\n seen.add(key)\n result.append(login)\n return result\n\n\ndef _reviewer_states(reviews, head_sha=None):\n standing = {}\n for review in reviews:\n if head_sha is not None and review.get(\"commit_id\") != head_sha:\n continue\n state = (review.get(\"state\") or \"\").upper()\n login = (review.get(\"user\") or {}).get(\"login\", \"\").lower()\n if not login:\n continue\n if state == \"DISMISSED\":\n standing.pop(login, None)\n elif state in _DECISIVE_REVIEW_STATES:\n standing[login] = state\n return standing\n\n\ndef _path_scores(repository, pr_number, base_ref, candidates):\n scores = dict.fromkeys(candidates, 0)\n files = repository.gh_pages(f\"/pulls/{pr_number}/files\")[:_MAX_PATHS]\n for changed_file in files:\n path = changed_file.get(\"filename\")\n if not path:\n continue\n query = urlencode(\n {\"path\": path, \"sha\": base_ref, \"per_page\": _COMMITS_PER_PATH}\n )\n commits = repository.gh(\"GET\", f\"/commits?{query}\")\n for rank, commit in enumerate(commits[:_COMMITS_PER_PATH]):\n login = ((commit.get(\"author\") or {}).get(\"login\") or \"\").lower()\n if login in scores:\n scores[login] += _COMMITS_PER_PATH - rank\n return scores\n\n\ndef _open_review_load(repository, owner, owner_type, login):\n owner_qualifier = \"org\" if owner_type.lower() == \"organization\" else \"user\"\n response = repository.api(\n \"GET\",\n \"/search/issues\",\n params={\n \"q\": (\n f\"is:pr is:open {owner_qualifier}:{owner} \"\n f\"review-requested:{login}\"\n ),\n \"per_page\": 1,\n },\n )\n return int(response.get(\"total_count\", 0))\n\n\ndef request_maintainer_review(repository, pr, maintainers):\n \"\"\"Ensure one configured maintainer is reviewing *pr*.\n\n API failures propagate so the scanner can retry without clearing its trigger\n label. The return value is the existing or newly requested login.\n \"\"\"\n roster = parse_maintainers(maintainers)\n if not roster:\n return None\n\n by_key = {login.lower(): login for login in roster}\n requested = {\n (item.get(\"login\") or \"\").lower() for item in pr.get(\"requested_reviewers\", [])\n }\n for login in roster:\n if login.lower() in requested:\n return login\n\n number = pr[\"number\"]\n head_sha = pr[\"head\"][\"sha\"]\n reviews = repository.gh_pages(f\"/pulls/{number}/reviews\")\n # A human approval remains useful after a later push unless GitHub dismisses\n # it or the reviewer subsequently requests changes. Do not ask that person\n # to review the same PR again merely because the automated review targets a\n # newer commit.\n review_states = _reviewer_states(reviews)\n for login in roster:\n if review_states.get(login.lower()) == \"APPROVED\":\n return login\n\n standing = _reviewer_states(reviews, head_sha)\n for login in roster:\n if login.lower() in standing:\n return login\n\n author = ((pr.get(\"user\") or {}).get(\"login\") or \"\").lower()\n candidates = [key for key in by_key if key != author]\n if not candidates:\n raise HandoffConfigurationError(\n \"No eligible maintainer remains after excluding the PR author\"\n )\n\n base_ref = (pr.get(\"base\") or {}).get(\"ref\") or \"main\"\n scores = _path_scores(repository, number, base_ref, candidates)\n owner = repository.repository.split(\"/\", 1)[0]\n owner_type = (\n (((pr.get(\"base\") or {}).get(\"repo\") or {}).get(\"owner\") or {}).get(\"type\")\n or \"Organization\"\n )\n loads = {\n login: _open_review_load(repository, owner, owner_type, by_key[login])\n for login in candidates\n }\n order = {login.lower(): index for index, login in enumerate(roster)}\n selected_key = min(\n candidates,\n key=lambda login: (-scores[login], loads[login], order[login]),\n )\n selected = by_key[selected_key]\n try:\n repository.gh(\n \"POST\",\n f\"/pulls/{number}/requested_reviewers\",\n {\"reviewers\": [selected]},\n )\n except HTTPError as exc:\n if exc.code == 422:\n raise HandoffConfigurationError(\n f\"GitHub cannot assign configured maintainer @{selected}\"\n ) from exc\n raise\n return selected\n", - "worker.py": "\"\"\"Select requested PR heads and delegate each review to a sandboxed agent.\"\"\"\n\nimport json\nimport os\nimport sys\nfrom functools import cached_property\nfrom urllib.parse import quote\n\nimport main as workflow\nfrom agent_conversation import AgentConversationDispatcher\nfrom github_client import GitHubRepository, run_repositories\nfrom maintainer_handoff import (\n HandoffConfigurationError,\n parse_maintainers,\n request_maintainer_review,\n)\n\n# The head-eligibility gate. Scheduled discovery classifies only the checks\n# GitHub reports as required for the pull request, read through the GraphQL\n# `isRequired` signal, so an optional workflow that fails before creating any\n# check run cannot block a head whose required checks pass. An explicit\n# `all-hands-bot` review request is the intake-policy exception and bypasses the\n# gate entirely. A completed required run blocks unless its conclusion is\n# explicitly non-blocking, so an unrecognized conclusion fails closed rather than\n# approving silently, and a required run that has not completed means waiting,\n# never approval. When the required set cannot be read, the gate falls back to\n# every current-head check and workflow run, so a red head still blocks.\nCHECK_GATE_MARKER = \"\\n\\n\"\n f\"_This is an automated check - {WORKFLOW_DISCLOSURE}._\"\n )\n\n def _gate_head(self, pr, requested=False, scheduled=False):\n \"\"\"Return the head's eligibility, explaining any stop on the PR.\n\n An explicit `all-hands-bot` review request is the intake-policy\n exception: the caller asked for this head by name, so the CI gate does\n not apply and no gate comment is left. Scheduled discovery still gates on\n the required checks. When it stops a scheduled run, the explanation names\n the retry that deployment actually has, which is why `scheduled` is\n threaded into the body.\n \"\"\"\n sha = pr[\"head\"][\"sha\"]\n if requested:\n return \"green\", sha\n state, names = self._classify_check_runs(pr)\n if state in (\"blocked\", \"waiting\"):\n marker = f\"{CHECK_GATE_MARKER}{state}:{sha} -->\"\n self._gate_comment(\n pr[\"number\"],\n marker,\n self._gate_body(sha, state, names, scheduled),\n names,\n )\n return state, sha\n\n def _outstanding_review_request(self, pr):\n \"\"\"Whether an open, non-draft PR still holds a request for the reviewer.\n\n The list endpoint already answers this: `requested_reviewers` is the live\n set, so a review that was submitted, or a request that was withdrawn, is\n simply absent. Drafts are excluded because a draft is not reviewable.\n \"\"\"\n if pr.get(\"draft\"):\n return False\n return any(\n (item.get(\"login\") or \"\").lower() == self.trigger_reviewer\n for item in pr.get(\"requested_reviewers\") or []\n )\n\n def run(self):\n repository_id = self.gh(\"GET\", \"\")[\"id\"]\n label = self.config.get(\"trigger_label\", workflow.TRIGGER_LABEL)\n payload = self._event_payload()\n if payload is None:\n prs = self.gh_pages(\"/pulls?state=open&sort=updated&direction=asc\")\n else:\n candidate = self._event_candidate(payload)\n if candidate and self.github_login.lower() != self.trigger_reviewer:\n raise RuntimeError(\n \"The configured GitHub credential must authenticate as \"\n f\"{self.trigger_reviewer} for reviewer-request mode\"\n )\n prs = [candidate] if candidate else []\n failures = []\n for candidate in prs:\n event_mode = payload is not None\n if not event_mode and label not in {\n item[\"name\"] for item in candidate.get(\"labels\", [])\n } and not self._outstanding_review_request(candidate):\n # A scheduled scan covers the trigger label and any PR that still\n # holds a review request the CI gate deferred. Everything else is\n # not ours to review.\n continue\n try:\n pr = self.gh(\"GET\", f\"/pulls/{candidate['number']}\")\n has_label = label in {\n item[\"name\"] for item in pr.get(\"labels\", [])\n }\n if event_mode or has_label:\n trigger = (\n self._latest_reviewer_request(pr[\"number\"])\n if event_mode\n else workflow._latest_trigger_label_event(\n self.token, self.repository, pr[\"number\"]\n )\n )\n else:\n # The outstanding request is the trigger, so its own event\n # keys the delivery and dedupes repeated scans.\n trigger = self._latest_reviewer_request(pr[\"number\"])\n if trigger is None:\n continue\n trigger_label = label if (not event_mode and has_label) else None\n if self._finish_completed_review(pr, trigger, trigger_label):\n continue\n if event_mode and payload.get(\"action\") == \"submitted\":\n # A submitted review is a completion signal, never a fresh\n # trigger. A non-decisive review, or one superseded by a new\n # head, must wait for another reviewer request instead of\n # dispatching a review the caller never asked for.\n continue\n sha = pr[\"head\"][\"sha\"]\n gate_state, sha = self._gate_head(\n pr, requested=event_mode, scheduled=not event_mode\n )\n if gate_state != \"green\":\n # A deterministic blocker stops the run without spending a\n # worker slot on an agent. The trigger is not consumed: the\n # next scheduled scan or explicit request re-evaluates the\n # head once its checks are non-blocking.\n print(\n json.dumps(\n {\n \"repository\": self.repository,\n \"pr\": pr[\"number\"],\n \"head_sha\": sha,\n \"disposition\": f\"review-{gate_state}\",\n }\n ),\n flush=True,\n )\n continue\n result = self.dispatcher.deliver(\n subject=f\"{repository_id}:pr:{pr['number']}\",\n delivery=f\"{trigger['id']}:{sha}\",\n prompt=self._prompt(pr, trigger, trigger_label),\n )\n print(\n json.dumps(\n {\n \"repository\": self.repository,\n \"pr\": pr[\"number\"],\n \"head_sha\": sha,\n \"disposition\": result[\"disposition\"],\n \"conversation_id\": result[\"conversation_id\"],\n }\n ),\n flush=True,\n )\n except Exception as exc: # noqa: BLE001 - one PR must not block the scan\n failures.append(candidate.get(\"number\", \"?\"))\n print(\n f\"Failed to submit {self.repository} PR \"\n f\"#{candidate.get('number', '?')}: \"\n f\"{type(exc).__name__}: {exc}\",\n file=sys.stderr,\n flush=True,\n )\n if failures:\n raise RuntimeError(\n \"Reviewer scan failed for PRs: \"\n + \", \".join(f\"#{number}\" for number in failures)\n )\n\n\nif __name__ == \"__main__\":\n with AgentConversationDispatcher() as dispatcher:\n run_repositories(PullRequestReviewer, dispatcher=dispatcher)\n" + "worker.py": "\"\"\"Select requested PR heads and delegate each review to a sandboxed agent.\"\"\"\n\nimport json\nimport os\nimport sys\nfrom functools import cached_property\nfrom urllib.parse import quote\n\nimport main as workflow\nfrom agent_conversation import AgentConversationDispatcher\nfrom github_client import GitHubRepository, run_repositories\nfrom maintainer_handoff import (\n HandoffConfigurationError,\n parse_maintainers,\n request_maintainer_review,\n)\n\n# The head-eligibility gate. Scheduled discovery classifies only the checks\n# GitHub reports as required for the pull request, read through the GraphQL\n# `isRequired` signal, so an optional workflow that fails before creating any\n# check run cannot block a head whose required checks pass. An explicit\n# `all-hands-bot` review request is the intake-policy exception and bypasses the\n# gate entirely. A completed required run blocks unless its conclusion is\n# explicitly non-blocking, so an unrecognized conclusion fails closed rather than\n# approving silently, and a required run that has not completed means waiting,\n# never approval. When the required set cannot be read, the gate falls back to\n# every current-head check and workflow run, so a red head still blocks.\nCHECK_GATE_MARKER = \"\\n\\n\"\n f\"_This is an automated check - {WORKFLOW_DISCLOSURE}._\"\n )\n\n def _gate_head(self, pr, requested=False, scheduled=False):\n \"\"\"Return the head's eligibility, explaining any stop on the PR.\n\n An explicit `all-hands-bot` review request is the intake-policy\n exception: the caller asked for this head by name, so the CI gate does\n not apply and no gate comment is left. Scheduled discovery still gates on\n the required checks. When it stops a scheduled run, the explanation names\n the retry that deployment actually has, which is why `scheduled` is\n threaded into the body.\n \"\"\"\n sha = pr[\"head\"][\"sha\"]\n if requested:\n return \"green\", sha\n state, names = self._classify_check_runs(pr)\n if state in (\"blocked\", \"waiting\"):\n marker = f\"{CHECK_GATE_MARKER}{state}:{sha} -->\"\n self._gate_comment(\n pr[\"number\"],\n marker,\n self._gate_body(sha, state, names, scheduled),\n names,\n )\n return state, sha\n\n def _outstanding_review_request(self, pr):\n \"\"\"Whether an open, non-draft PR still holds a request for the reviewer.\n\n The list endpoint already answers this: `requested_reviewers` is the live\n set, so a review that was submitted, or a request that was withdrawn, is\n simply absent. Drafts are excluded because a draft is not reviewable.\n \"\"\"\n if pr.get(\"draft\"):\n return False\n return any(\n (item.get(\"login\") or \"\").lower() == self.trigger_reviewer\n for item in pr.get(\"requested_reviewers\") or []\n )\n\n def run(self):\n repository_id = self.gh(\"GET\", \"\")[\"id\"]\n label = self.config.get(\"trigger_label\", workflow.TRIGGER_LABEL)\n payload = self._event_payload()\n if payload is None:\n prs = self.gh_pages(\"/pulls?state=open&sort=updated&direction=asc\")\n else:\n candidate = self._event_candidate(payload)\n if candidate and self.github_login.lower() != self.trigger_reviewer:\n raise RuntimeError(\n \"The configured GitHub credential must authenticate as \"\n f\"{self.trigger_reviewer} for reviewer-request mode\"\n )\n prs = [candidate] if candidate else []\n failures = []\n for candidate in prs:\n event_mode = payload is not None\n if not event_mode and label not in {\n item[\"name\"] for item in candidate.get(\"labels\", [])\n } and not self._outstanding_review_request(candidate):\n # A scheduled scan covers the trigger label and any PR that still\n # holds a review request the CI gate deferred. Everything else is\n # not ours to review.\n continue\n try:\n pr = self.gh(\"GET\", f\"/pulls/{candidate['number']}\")\n has_label = label in {\n item[\"name\"] for item in pr.get(\"labels\", [])\n }\n if event_mode or has_label:\n trigger = (\n self._latest_reviewer_request(pr[\"number\"])\n if event_mode\n else workflow._latest_trigger_label_event(\n self.token, self.repository, pr[\"number\"]\n )\n )\n else:\n # The outstanding request is the trigger, so its own event\n # keys the delivery and dedupes repeated scans.\n trigger = self._latest_reviewer_request(pr[\"number\"])\n if trigger is None:\n continue\n trigger_label = label if (not event_mode and has_label) else None\n if self._finish_completed_review(pr, trigger, trigger_label):\n continue\n if event_mode and payload.get(\"action\") == \"submitted\":\n # A submitted review is a completion signal, never a fresh\n # trigger. A non-decisive review, or one superseded by a new\n # head, must wait for another reviewer request instead of\n # dispatching a review the caller never asked for.\n continue\n sha = pr[\"head\"][\"sha\"]\n gate_state, sha = self._gate_head(\n pr, requested=event_mode, scheduled=not event_mode\n )\n if gate_state != \"green\":\n # A deterministic blocker stops the run without spending a\n # worker slot on an agent. The trigger is not consumed: the\n # next scheduled scan or explicit request re-evaluates the\n # head once its checks are non-blocking.\n print(\n json.dumps(\n {\n \"repository\": self.repository,\n \"pr\": pr[\"number\"],\n \"head_sha\": sha,\n \"disposition\": f\"review-{gate_state}\",\n }\n ),\n flush=True,\n )\n continue\n record = {\n \"repository\": self.repository,\n \"number\": pr[\"number\"],\n # The oldest outstanding request drains first; repository and\n # number break a tie so the order is deterministic.\n \"created_at\": trigger.get(\"created_at\") or \"\",\n \"config\": self.config,\n \"start\": lambda pr=pr, trigger=trigger, sha=sha,\n trigger_label=trigger_label: self._start_review(\n repository_id, pr, trigger, sha, trigger_label\n ),\n }\n if event_mode:\n # An explicit request is a caller's decision to spend a\n # conversation now, not a backlog item, so the event path\n # dispatches immediately and is never bounded by the\n # scheduled scan's per-run maximum.\n self._start_review(\n repository_id, pr, trigger, sha, trigger_label\n )\n else:\n self.intake.register(record)\n except Exception as exc: # noqa: BLE001 - one PR must not block the scan\n failures.append(candidate.get(\"number\", \"?\"))\n print(\n f\"Failed to submit {self.repository} PR \"\n f\"#{candidate.get('number', '?')}: \"\n f\"{type(exc).__name__}: {exc}\",\n file=sys.stderr,\n flush=True,\n )\n if self.scan_intake is None:\n # A run() with no shared scan intake owns its whole scan, so it\n # drains the candidates it collected, bounded by the per-run maximum.\n self.intake.drain()\n if failures:\n raise RuntimeError(\n \"Reviewer scan failed for PRs: \"\n + \", \".join(f\"#{number}\" for number in failures)\n )\n\n def _start_review(self, repository_id, pr, trigger, sha, trigger_label):\n result = self.dispatcher.deliver(\n subject=f\"{repository_id}:pr:{pr['number']}\",\n delivery=f\"{trigger['id']}:{sha}\",\n prompt=self._prompt(pr, trigger, trigger_label),\n )\n print(\n json.dumps(\n {\n \"repository\": self.repository,\n \"pr\": pr[\"number\"],\n \"head_sha\": sha,\n \"disposition\": result[\"disposition\"],\n \"conversation_id\": result[\"conversation_id\"],\n }\n ),\n flush=True,\n )\n return result\n\n\ndef run_scan(dispatcher):\n \"\"\"Run one scheduled scan over every configured repository, then drain.\n\n One shared intake spans the whole scan, so the per-run maximum is global\n rather than reset per repository, and the drain happens after every\n repository has been scanned so the oldest outstanding request across all of\n them starts first. One repository failing must not discard another\n repository's drained candidates, so the drain still runs and the first\n failure is what the scan reports.\n \"\"\"\n PullRequestReviewer.scan_intake = ReviewIntake()\n failure = None\n try:\n run_repositories(PullRequestReviewer, dispatcher=dispatcher)\n except Exception as exc: # noqa: BLE001 - reported after the drain\n failure = exc\n try:\n PullRequestReviewer.scan_intake.drain()\n except Exception as exc: # noqa: BLE001 - reported after the scan failure\n failure = failure or exc\n if failure is not None:\n raise failure\n\n\nif __name__ == \"__main__\":\n with AgentConversationDispatcher() as dispatcher:\n run_scan(dispatcher)\n" }, "github-issue-to-pr": { "agent_conversation.py": "\"\"\"Idempotently deliver automation work to profile-backed conversations.\"\"\"\n\nimport json\nimport os\nfrom urllib.error import HTTPError\nfrom urllib.request import Request, urlopen\nfrom uuid import NAMESPACE_URL, UUID, uuid5\n\nimport httpx\nfrom openhands.sdk import RemoteConversation\nfrom openhands.sdk.conversation.request import (\n SendMessageRequest,\n StartConversationRequest,\n)\nfrom openhands.sdk.conversation.state import ConversationExecutionStatus\nfrom openhands.sdk.llm.message import TextContent\nfrom openhands.sdk.workspace import LocalWorkspace, RemoteWorkspace\n\n_CONVERSATION_KEY_PREFIX = \"agent-conversation-\"\n\n\ndef _register_tools() -> None:\n \"\"\"Register tool models needed to deserialize an attached agent.\"\"\"\n from openhands.tools import register_default_tools\n\n register_default_tools()\n\n\ndef _kv_request(key: str, method: str, value: dict | None = None) -> dict | None:\n base_url = os.environ.get(\"AUTOMATION_API_URL\", \"\").rstrip(\"/\")\n token = os.environ.get(\"AUTOMATION_KV_TOKEN\", \"\")\n if not base_url or not token:\n raise RuntimeError(\"Automation KV is required for agent conversation dispatch\")\n request = Request(\n f\"{base_url}/v1/kv/{key}\",\n data=json.dumps(value).encode() if value is not None else None,\n headers={\n \"Authorization\": f\"Bearer {token}\",\n \"Content-Type\": \"application/json\",\n },\n method=method,\n )\n try:\n with urlopen(request, timeout=90) as response:\n body = json.load(response)\n except HTTPError as exc:\n if method == \"GET\" and exc.code == 404:\n return None\n raise\n return body.get(\"value\") if method == \"GET\" else body\n\n\nclass AgentConversationDispatcher:\n \"\"\"Deliver one revision at a time to a stable conversation for each subject.\"\"\"\n\n def __init__(self) -> None:\n self.agent_url = os.environ[\"AGENT_SERVER_URL\"]\n self.api_key = os.environ[\"SESSION_API_KEY\"]\n self.profile_id = UUID(os.environ[\"AUTOMATION_AGENT_PROFILE_ID\"])\n payload = json.loads(os.environ[\"AUTOMATION_EVENT_PAYLOAD\"])\n self.automation_id = str(payload[\"automation_id\"])\n self._workspace: RemoteWorkspace | None = None\n self._secrets = {}\n\n def __enter__(self):\n _register_tools()\n self._workspace = RemoteWorkspace(\n host=self.agent_url,\n api_key=self.api_key,\n working_dir=os.environ.get(\"WORKSPACE_BASE\", \"/workspace\"),\n )\n self._workspace.__enter__()\n self._secrets = self._workspace.get_secrets(\n agent_profile_id=str(self.profile_id)\n )\n return self\n\n def __exit__(self, *args):\n assert self._workspace is not None\n return self._workspace.__exit__(*args)\n\n def deliver(self, subject: str, delivery: str, prompt: str) -> dict[str, str]:\n conversation_id = uuid5(NAMESPACE_URL, f\"{self.automation_id}:{subject}\")\n state_key = f\"{_CONVERSATION_KEY_PREFIX}{conversation_id}\"\n record = _kv_request(state_key, \"GET\") or {}\n if self._workspace is None:\n raise RuntimeError(\"AgentConversationDispatcher must be used as a context\")\n\n same_delivery = record.get(\"delivery\") == delivery\n\n try:\n conversation = RemoteConversation.attach(\n self._workspace, conversation_id, visualizer=None\n )\n disposition = \"resumed\"\n except httpx.HTTPStatusError as exc:\n if exc.response.status_code != 404:\n raise\n conversation = RemoteConversation.create(\n self._workspace,\n StartConversationRequest(\n workspace=LocalWorkspace(working_dir=\"/workspace\"),\n conversation_id=conversation_id,\n agent_profile_id=self.profile_id,\n secrets=self._secrets,\n initial_message=SendMessageRequest(\n content=[TextContent(text=prompt)], run=True\n ),\n ),\n visualizer=None,\n )\n disposition = \"created\"\n try:\n if disposition == \"resumed\":\n if same_delivery:\n if (\n conversation.state.execution_status\n == ConversationExecutionStatus.RUNNING\n ):\n conversation.update_secrets(self._secrets)\n disposition = \"in_progress\"\n elif conversation.state.execution_status in (\n ConversationExecutionStatus.IDLE,\n ConversationExecutionStatus.PAUSED,\n ):\n conversation.update_secrets(self._secrets)\n conversation.run(blocking=False)\n else:\n disposition = \"deduplicated\"\n else:\n conversation.update_secrets(self._secrets)\n conversation.send_message(prompt)\n conversation.run(blocking=False)\n finally:\n conversation.close()\n\n if disposition in (\"deduplicated\", \"in_progress\"):\n return {\n \"disposition\": disposition,\n \"conversation_id\": str(conversation_id),\n }\n\n _kv_request(\n state_key,\n \"PUT\",\n {\n \"subject\": subject,\n \"conversation_id\": str(conversation_id),\n \"delivery\": delivery,\n },\n )\n return {\n \"disposition\": disposition,\n \"conversation_id\": str(conversation_id),\n }\n", diff --git a/automations/catalog/github-pr-reviewer/manifest.json b/automations/catalog/github-pr-reviewer/manifest.json index cda71bb8..16483270 100644 --- a/automations/catalog/github-pr-reviewer/manifest.json +++ b/automations/catalog/github-pr-reviewer/manifest.json @@ -1,9 +1,9 @@ { "id": "github-pr-reviewer", - "version": "1.6.0", + "version": "1.7.0", "name": "GitHub code review", "category": "Code review", - "description": "Review requested pull-request heads, resuming an outstanding reviewer request on a schedule once its checks are green, record exact-head decisions, stop early on out-of-scope changes, and optionally request a code-aware human maintainer after approval or a maintainer-decision scope stop.", + "description": "Review requested pull-request heads, resuming an outstanding reviewer request on a schedule once its checks are green, draining the backlog oldest-request first under a small per-scan new-conversation bound, record exact-head decisions, stop early on out-of-scope changes, and optionally request a code-aware human maintainer after approval or a maintainer-decision scope stop.", "requires": { "integrations": {}, "features": [ @@ -13,7 +13,7 @@ }, "popularityRank": 100, "estimatedSetupMinutes": 4, - "exampleImplementation": "A scheduled scan of the trigger label and of open non-draft PRs holding an outstanding reviewer request, or a GitHub reviewer-request event, selects a PR head and submits an idempotent subject turn. The profile-backed agent clones that exact head in its runtime, applies an early repository-ownership and product/architecture scope gate, then reuses the established review workflow, runs appropriate tests, and publishes a readable native review. A native approval, or a maintainer-decision scope stop, can then be handed to a code-aware human maintainer.", + "exampleImplementation": "A scheduled scan of the trigger label and of open non-draft PRs holding an outstanding reviewer request, or a GitHub reviewer-request event, selects a PR head and submits an idempotent subject turn. The scheduled scan orders eligible heads by the oldest outstanding request and starts at most a small configurable number of new conversations per scan across every repository. The profile-backed agent clones that exact head in its runtime, applies an early repository-ownership and product/architecture scope gate, then reuses the established review workflow, runs appropriate tests, and publishes a readable native review. A native approval, or a maintainer-decision scope stop, can then be handed to a code-aware human maintainer.", "impact": { "basis": "completed-runs", "one": "1 PR review sweep completed", @@ -87,6 +87,16 @@ "maxLength": 39 } }, + "maxNewPerRun": { + "type": "number", + "label": "New reviews per scan", + "help": "Most new review conversations one scheduled scan may start, counted across every repository. Keeps a first scan over a large backlog from starting an agent for every eligible pull request at once. Reviews that are already running are resumed without counting against it.", + "default": 2, + "required": true, + "constraints": { + "min": 1 + } + }, "reviewTone": { "type": "select", "label": "Review tone", @@ -125,7 +135,7 @@ } }, "bundle": { - "version": "1.6.0", + "version": "1.7.0", "entrypoint": "python3 worker.py", "timeout": 300, "files": { @@ -139,12 +149,13 @@ "repos": "{{form.repositories}}", "trigger_label": "{{form.triggerLabel}}", "trigger_reviewer": "{{form.triggerReviewer}}", + "max_new_per_run": "{{form.maxNewPerRun}}", "review_tone": "{{form.reviewTone}}", "maintainers": "{{form.maintainers}}", "github_token_secret": "{{form.githubTokenSecret}}" } }, "filter": "contains({{form.repositories}}, repository.full_name) && contains(keys(@), 'pull_request') && ((action == 'review_requested' && requested_reviewer.login == '{{form.triggerReviewer}}') || (action == 'submitted' && contains(keys(@), 'review') && review.user.login == '{{form.triggerReviewer}}'))", - "message": "Configure the repositories, trigger, review tone, optional maintainer roster, credential name, and agent profile." + "message": "Configure the repositories, trigger, review tone, optional maintainer roster, credential name, per-scan new-review bound, and agent profile." } } diff --git a/skills/github-pr-reviewer/README.md b/skills/github-pr-reviewer/README.md index fed97855..a0b0b96a 100644 --- a/skills/github-pr-reviewer/README.md +++ b/skills/github-pr-reviewer/README.md @@ -34,6 +34,9 @@ This skill is activated by: so switching a deployment from the event trigger to a cron scan updates the outstanding-request explanation instead of leaving the old instruction - Watches several repositories from a single automation, each with its own state +- Bounds a scheduled scan to a small, configurable number of new review + conversations across all repositories (default 2), draining the oldest + outstanding reviewer requests first and reaching the rest on later scans - Processes each review request or label application idempotently - Supports re-review by requesting the bot again or re-applying the label. Each explicit request refreshes mutable GitHub state (head, PR body, reviews, diff --git a/skills/github-pr-reviewer/SKILL.md b/skills/github-pr-reviewer/SKILL.md index d68c95c9..2bf93a24 100644 --- a/skills/github-pr-reviewer/SKILL.md +++ b/skills/github-pr-reviewer/SKILL.md @@ -118,6 +118,34 @@ whose automation is later switched to a cron scan -- the scheduled run rewrites that managed comment in place, so the comment always names the retry that is actually deployed, and an unchanged body is left untouched. +## Bounded intake per scheduled scan + +A scheduled scan drains outstanding reviewer requests fairly, but starting an +agent for every eligible pull request at once would overload the deployment, so +the scan starts at most `MAX_NEW_PER_RUN` new review conversations, counted +**across every configured repository** rather than per repository. The default +is `2`, and the rendered `config.json` overrides it through the +`max_new_per_run` key that `github-issue-to-pr` and `gitlab-issue-to-mr` already +use. + +- Eligible candidates are ordered by the oldest outstanding `all-hands-bot` + `review_requested` event, then by repository, then by pull-request number, so + the oldest requests drain first and a later scan reaches the remainder. +- The bound counts the conversations a scan **starts**. A delivery that only + deduplicates or reports an already-running conversation reuses a runtime and + consumes no slot, so repeated scans make progress on the backlog instead of + re-spending the bound on work already in flight. +- Reaching the bound never cuts the scan short. The scan still evaluates the + exact-head checks of the remaining candidates, posts its waiting or blocked + gate comments, reconciles completed reviews, and runs the maintainer handoff. + A candidate whose checks are pending or failing starts no conversation and + consumes no slot, so it cannot block a later green candidate. +- A dispatch that raises is reported and does not consume a slot or abort the + scan, so the candidates behind it are still considered. +- The explicit `review_requested` event path and the trigger-label scan are + unchanged: an explicit request still starts its conversation immediately, and + only the scheduled scan's new conversations are bounded. + The review prompt starts with a scope gate: using the repository's own guidance (its scope categories and ownership boundaries, not a list of individual PR numbers), the reviewer decides whether the change belongs in this repository @@ -255,7 +283,7 @@ Record as `CRON_SCHEDULE`. ### Step 6 - Generate the automation script -Read `scripts/main.py` from this skill's directory. Apply exactly six constant +Read `scripts/main.py` from this skill's directory. Apply exactly seven constant substitutions near the top of the file: > The script also reads a `config.json` shipped beside it, if there is one, over @@ -271,6 +299,7 @@ substitutions near the top of the file: | `REVIEW_TONE = "thorough"` | `REVIEW_TONE = "{review_tone}"` | | `REVIEW_STYLE_INSTRUCTIONS = ""` | `REVIEW_STYLE_INSTRUCTIONS = "{style_instructions}"` | | `REPO_REVIEW_GUIDE_PATH = ".agents/skills/custom-codereview-guide.md"` | leave unchanged to auto-load a repo review guide at this path, or set to `""` to disable | +| `MAX_NEW_PER_RUN = 2` | leave unchanged to bound a scheduled scan to two new review conversations across all repositories, or raise it if the deployment can hold more agents at once | | `DEFAULT_OPENHANDS_URL = "http://localhost:8000"` | leave unchanged unless the user has a preference | Use a safe string writer such as `json.dumps(value)` when inserting user-provided @@ -430,6 +459,7 @@ The completion callback fires once for the whole run. | Review paused with a failing-check comment | A current-head required check reported `failure`, `cancelled`, or `timed_out` | Fix the named checks and push; the review starts on the new head, or request `all-hands-bot` to review immediately | | Review reported waiting on checks | A current-head required check is `queued` or `in_progress`, or has not reported yet | No action; a later scan or a new review request retries | | Optional workflow failed but no review was paused | The failed workflow is not required, so the required-only scheduled gate ignored it | No action; only GitHub-required checks gate scheduled discovery | +| Only a few reviews start on a large backlog | The per-scan `max_new_per_run` bound (default 2) reached | No action; later scans drain the remaining oldest requests, or raise `max_new_per_run` if the deployment can hold more agents | | Review result never posts | Conversation still running or stuck | Open the conversation link from the acknowledgement comment | | Stale review suppressed | PR head SHA changed while the agent was reviewing | Re-apply the trigger label after the latest commit | | Review arrives as a plain comment, not a review | Publishing failed, so the script posted the text as a fallback | Check that the token has Pull requests: Read and Write | diff --git a/skills/github-pr-reviewer/scripts/main.py b/skills/github-pr-reviewer/scripts/main.py index daa9ca31..40235d7a 100644 --- a/skills/github-pr-reviewer/scripts/main.py +++ b/skills/github-pr-reviewer/scripts/main.py @@ -51,6 +51,12 @@ # than relying on the spawned agent's skill activation. Set to "" to disable. REPO_REVIEW_GUIDE_PATH = ".agents/skills/custom-codereview-guide.md" DEFAULT_OPENHANDS_URL = "http://localhost:8000" +# The most new review conversations one scheduled scan may start, counted across +# every configured repository rather than per repository. The scheduled scan +# drains outstanding reviewer requests in oldest-request order, so a small bound +# keeps a first scan over a large backlog from starting an agent for every +# eligible pull request at once. +MAX_NEW_PER_RUN = 2 # A review that ends with this marker is a scope stop: the reviewer found the # change out of scope, or needing a product/architecture decision, before the @@ -69,6 +75,7 @@ "review_tone": str, "review_style_instructions": str, "repo_review_guide_path": str, + "max_new_per_run": int, "openhands_url": str, } @@ -95,7 +102,11 @@ def load_config(directory: Path | None = None) -> dict: if key not in raw: continue value = raw[key] - if not isinstance(value, expected): + # bool is an int in Python, so an unguarded int check would accept + # `"max_new_per_run": true` and then start `True` conversations. + if not isinstance(value, expected) or ( + expected is int and isinstance(value, bool) + ): raise SystemExit( f"{CONFIG_FILENAME}: {key} must be {expected.__name__}, " f"got {type(value).__name__}" @@ -106,6 +117,10 @@ def load_config(directory: Path | None = None) -> dict: raise SystemExit( f'{CONFIG_FILENAME}: repos must be a non-empty list of "owner/repo" strings' ) + if key == "max_new_per_run" and value < 1: + raise SystemExit( + f"{CONFIG_FILENAME}: max_new_per_run must be at least 1" + ) config[key] = value return config @@ -150,6 +165,7 @@ def normalize_repo(value: str) -> str: REVIEW_TONE = _CONFIG.get("review_tone", REVIEW_TONE) REVIEW_STYLE_INSTRUCTIONS = _CONFIG.get("review_style_instructions", REVIEW_STYLE_INSTRUCTIONS) REPO_REVIEW_GUIDE_PATH = _CONFIG.get("repo_review_guide_path", REPO_REVIEW_GUIDE_PATH) +MAX_NEW_PER_RUN = _CONFIG.get("max_new_per_run", MAX_NEW_PER_RUN) DEFAULT_OPENHANDS_URL = _CONFIG.get("openhands_url", DEFAULT_OPENHANDS_URL) DONE_DEBOUNCE = 15 diff --git a/skills/github-pr-reviewer/scripts/worker.py b/skills/github-pr-reviewer/scripts/worker.py index a030a69b..dd0b8f36 100644 --- a/skills/github-pr-reviewer/scripts/worker.py +++ b/skills/github-pr-reviewer/scripts/worker.py @@ -31,8 +31,100 @@ WORKFLOW_DISCLOSURE = "no AI was used to generate this comment" +class ReviewIntake: + """The per-scan bound on new review conversations, shared across repositories. + + A scheduled scan drains every eligible pull request, but starting an agent + for each one at once exhausted the OSS Agent Canvas VM, so a small maximum + caps what a single scan may start. The maximum is per scan and shared by + every configured repository rather than reset per repository, and it bounds + the conversations a scan *starts*: a delivery that only deduplicates, or + reports an already-running conversation, reuses a runtime and consumes no + slot. Candidates are drained oldest reviewer-request first, then by + repository and pull-request number, so a scan over more candidates than the + maximum allows starts the oldest and a later scan reaches the remainder. + """ + + def __init__(self): + # The budget lives on the intake so a scan that drains repository by + # repository still counts its conversations against one shared maximum. + self._pending = [] + self._maximum = None + self._started = 0 + + def register(self, record): + """Queue one eligible candidate for this scan's bounded drain.""" + self._pending.append(record) + + def drain(self): + """Start the oldest pending conversations, up to the per-scan maximum. + + The budget is held on the intake, so a scan that drains repository by + repository still counts its conversations against one shared maximum. A + candidate whose dispatch raises is reported and skipped without + consuming a slot, so the candidates behind it are still considered. + """ + pending, self._pending = self._pending, [] + if not pending: + return + if self._maximum is None: + self._maximum = pending[0]["config"].get( + "max_new_per_run", workflow.MAX_NEW_PER_RUN + ) + failures = [] + for record in sorted( + pending, + key=lambda item: ( + item["created_at"], + item["repository"], + item["number"], + ), + ): + if self._started >= self._maximum: + break + try: + result = record["start"]() + except Exception as exc: # noqa: BLE001 - one PR must not block the scan + failures.append(record["number"]) + print( + f"Failed to submit {record['repository']} PR " + f"#{record['number']}: {type(exc).__name__}: {exc}", + file=sys.stderr, + flush=True, + ) + continue + if result["disposition"] == "created": + self._started += 1 + if failures: + raise RuntimeError( + "Reviewer scan failed for PRs: " + + ", ".join(f"#{number}" for number in failures) + ) + + class PullRequestReviewer(GitHubRepository): name = "github-pr-reviewer" + # The shared intake the shipped entrypoint creates once per scheduled scan, + # so the per-run maximum spans every configured repository. It is None for a + # run() invoked on its own (tests, one-off scans), which then bounds its own + # conversations with a private intake instead. + scan_intake = None + + @property + def intake(self): + """The intake this run registers eligible candidates with. + + The shipped entrypoint sets `scan_intake` once, so every repository in a + scan shares one budget and the drain happens after all repositories have + been scanned. A run() with no shared intake uses its own, created lazily + so it exists whether or not __init__ ran. + """ + if self.scan_intake is not None: + return self.scan_intake + intake = self.__dict__.get("_intake") + if intake is None: + intake = self.__dict__["_intake"] = ReviewIntake() + return intake @cached_property def github_login(self): @@ -654,23 +746,28 @@ def run(self): flush=True, ) continue - result = self.dispatcher.deliver( - subject=f"{repository_id}:pr:{pr['number']}", - delivery=f"{trigger['id']}:{sha}", - prompt=self._prompt(pr, trigger, trigger_label), - ) - print( - json.dumps( - { - "repository": self.repository, - "pr": pr["number"], - "head_sha": sha, - "disposition": result["disposition"], - "conversation_id": result["conversation_id"], - } + record = { + "repository": self.repository, + "number": pr["number"], + # The oldest outstanding request drains first; repository and + # number break a tie so the order is deterministic. + "created_at": trigger.get("created_at") or "", + "config": self.config, + "start": lambda pr=pr, trigger=trigger, sha=sha, + trigger_label=trigger_label: self._start_review( + repository_id, pr, trigger, sha, trigger_label ), - flush=True, - ) + } + if event_mode: + # An explicit request is a caller's decision to spend a + # conversation now, not a backlog item, so the event path + # dispatches immediately and is never bounded by the + # scheduled scan's per-run maximum. + self._start_review( + repository_id, pr, trigger, sha, trigger_label + ) + else: + self.intake.register(record) except Exception as exc: # noqa: BLE001 - one PR must not block the scan failures.append(candidate.get("number", "?")) print( @@ -680,13 +777,61 @@ def run(self): file=sys.stderr, flush=True, ) + if self.scan_intake is None: + # A run() with no shared scan intake owns its whole scan, so it + # drains the candidates it collected, bounded by the per-run maximum. + self.intake.drain() if failures: raise RuntimeError( "Reviewer scan failed for PRs: " + ", ".join(f"#{number}" for number in failures) ) + def _start_review(self, repository_id, pr, trigger, sha, trigger_label): + result = self.dispatcher.deliver( + subject=f"{repository_id}:pr:{pr['number']}", + delivery=f"{trigger['id']}:{sha}", + prompt=self._prompt(pr, trigger, trigger_label), + ) + print( + json.dumps( + { + "repository": self.repository, + "pr": pr["number"], + "head_sha": sha, + "disposition": result["disposition"], + "conversation_id": result["conversation_id"], + } + ), + flush=True, + ) + return result + + +def run_scan(dispatcher): + """Run one scheduled scan over every configured repository, then drain. + + One shared intake spans the whole scan, so the per-run maximum is global + rather than reset per repository, and the drain happens after every + repository has been scanned so the oldest outstanding request across all of + them starts first. One repository failing must not discard another + repository's drained candidates, so the drain still runs and the first + failure is what the scan reports. + """ + PullRequestReviewer.scan_intake = ReviewIntake() + failure = None + try: + run_repositories(PullRequestReviewer, dispatcher=dispatcher) + except Exception as exc: # noqa: BLE001 - reported after the drain + failure = exc + try: + PullRequestReviewer.scan_intake.drain() + except Exception as exc: # noqa: BLE001 - reported after the scan failure + failure = failure or exc + if failure is not None: + raise failure + if __name__ == "__main__": with AgentConversationDispatcher() as dispatcher: - run_repositories(PullRequestReviewer, dispatcher=dispatcher) + run_scan(dispatcher) diff --git a/skills/index.js b/skills/index.js index e9c23b96..4a3c3d84 100644 --- a/skills/index.js +++ b/skills/index.js @@ -283,7 +283,7 @@ export const SKILLS_CATALOG = [ "triggers": [ "/pr-reviewer:setup" ], - "content": "# GitHub PR Reviewer Automation\n\n## Agent Canvas catalog\n\nFor new Agent Canvas installations, use the **GitHub code review** catalog\nentry. Its deterministic `worker.py` delegates each requested exact head to a\nstable conversation using the selected agent profile. It supports GitHub\nreviewer-request events and scheduled label scans. The manual upload flow below\nremains for existing deployments and is deprecated for new installations.\n\nCreate a cron automation that watches one or more GitHub repositories for pull\nrequests with a review trigger label, starts an OpenHands review conversation\nonce per label event, and publishes the AI review to GitHub.\nWindows PowerShell equivalents for the setup, packaging, upload, and API-check shell snippets are in `references/windows.md`.\n\nThe automation script is deterministic: PR discovery, label-event tracking,\nhead eligibility, state persistence, stale-result suppression, the repository\ncheckout, and its removal are all handled in Python. The LLM is invoked only for\nthe review itself.\n\nBefore any review conversation is created, a scheduled scan evaluates the\ncurrent head's GitHub-required checks, read through the GraphQL `isRequired`\nsignal for the pull request. That signal is the merge policy's own source of\ntruth and is pull-request-scoped, so it stays correct for a stacked PR whose\nsymbolic base branch carries no branch rules of its own:\n\n- Only checks GitHub reports as required decide the scheduled gate. An optional\n workflow that fails - including one that fails before creating any check run -\n does not block a head whose required checks pass.\n- A completed required run on the exact head whose conclusion is `failure`,\n `cancelled`, or `timed_out` blocks the review. Any other unrecognized\n conclusion fails closed as a block rather than silently approving.\n- A completed required run whose conclusion is `success`, `neutral`, or\n `skipped` does not block. A required `commit status` context is classified\n alongside required check runs.\n- A `queued` or `in_progress` required run on the exact head, and a required\n context that has not reported a current-head check run at all, make the run\n exit with a **waiting on checks** outcome. Unfulfilled is never approval. The\n worker does not hold a slot polling; the next scheduled scan or explicit\n review request retries.\n- When the required-check signal cannot be read or is empty, the gate falls back\n to every current-head check run and workflow run, so a red head still blocks.\n In that fallback, workflow runs are read as well as check runs, because a\n workflow can fail before creating any check run - a workflow-level error, or a\n `pull_request` run whose jobs never start. Such a run leaves a failed check\n suite with no check runs under it, so the commit's check-run rollup and\n `gh pr checks` both report success and only the workflow run reveals the red\n CI. A workflow run whose check suite already reported check runs is left to\n those runs, so a workflow is never counted twice.\n- A scheduled scan considers the trigger label **and** every open, non-draft PR\n that still holds an outstanding `all-hands-bot` review request. That is what\n resumes a request made while CI was running: the request is keyed by its own\n `review_requested` event, so repeated scans reuse one conversation and one\n review instead of creating duplicates, and a PR whose request was answered or\n withdrawn simply drops out of `requested_reviewers`.\n- Runs attributed to any other (obsolete) head SHA are ignored, so a stale\n failure cannot block the push that fixed it. This applies to workflow runs\n too.\n- Only the latest run of each logical check or workflow counts. A logical check\n is its name plus the reporting app identity, and a logical workflow is its\n name plus workflow ID. The latest is chosen by the run ID (the reliable\n creation sequence) with the start time as a tie-break, so a re-run that fixed\n a check supersedes its earlier failure on the same SHA while a newer queued or\n in-progress re-run supersedes an earlier success and makes the head wait.\n Ordering by the run ID keeps a run whose start time is still absent in its\n true creation position in both directions.\n\nAn explicit `all-hands-bot` review request is the intake-policy exception: the\ncaller asked for that head by name, so the request dispatches even when required\nCI is red or pending, and the gate leaves no explanatory comment. Draft, scope,\nexact-head, and delivery-deduplication safeguards still apply. A blocked or\nwaiting scheduled stop consumes no trigger: once the head's required checks are\nnon-blocking, the scheduled scan, or a new `all-hands-bot` review request,\nstarts the normal review.\n\nThe gate needs no configured list of check names. When it stops a review it\nleaves one concise explanation on the PR, identified by a hidden marker carrying\nthe head SHA and gate category, so a later run for a different head updates that\ncomment instead of posting another. Only a marker this reviewer account authored\ncounts as its own comment; every other PR comment is untrusted and can neither\nsuppress the explanation nor be edited. If the repository's own workflow already\nposted a deterministic remediation comment that names the same current-head\nchecks, the gate adds nothing; a disclosure about some other check does not\nsuppress it. The gate applies to scheduled label scans; an explicit\n`all-hands-bot` review request bypasses it by design.\n\nEach explicit review request - a re-applied trigger label, or a new reviewer\nrequest in event mode - is a fresh review. The conversation for a PR is reused,\nbut its earlier turns must not be trusted as current: before deciding a verdict\nthe reviewer re-fetches the mutable GitHub state (the exact head, the PR body,\nreview comments and threads, review requests, the linked issues' bodies and\nlabels, and the current-head Actions results) and ignores any earlier finding,\nverdict, or label/priority claim that state no longer supports. Repository\nanalysis the conversation already did, such as reading `AGENTS.md`, stays\nuseful and is not repeated.\n\nThe waiting and blocked explanations name the retry the deployment actually has.\nA scheduled run proves a scan is configured, so it says the next scan will\nretry. An event-only run does not, so it tells the reader to remove the\noutstanding `all-hands-bot` request and request `all-hands-bot` again instead of\npromising a scan that does not exist: GitHub will not accept a second request\nwhile the first is still outstanding.\n\nThe gate leaves only one explanation per head. When the same head keeps the same\nhidden marker but the retry wording changes -- an event-only comment on a head\nwhose automation is later switched to a cron scan -- the scheduled run rewrites\nthat managed comment in place, so the comment always names the retry that is\nactually deployed, and an unchanged body is left untouched.\n\nThe review prompt starts with a scope gate: using the repository's own guidance\n(its scope categories and ownership boundaries, not a list of individual PR\nnumbers), the reviewer decides whether the change belongs in this repository\nand has the product/architecture direction it needs. When it does not, the review\nstops with a single `event: COMMENT` review that says whether the change should\nmove repositories, close, or receive a maintainer decision, and ends with the\n`🛑 MAINTAINER DECISION REQUIRED` verdict. That outcome is **neither an approval\nnor a change request**: it does not approve or merge the PR. The completion\nhandler recognizes the verdict and requests one configured maintainer through the\nsame handoff used after an approval. An in-scope change continues the existing\nreview unchanged.\n\nThe script prepares each review's workspace before the agent starts: the pull\nrequest's head commit is downloaded as a tarball and extracted to a directory of\nits own, which becomes the conversation's working directory. The agent is told\nnot to clone, fetch, check out, or delete anything, and the script removes the\ncheckout once the conversation has stopped. Nothing accumulates between runs.\n\n---\n\nThe script imports shared GitHub transport from\n`scripts/github_client.py`, installed with this skill. Include it beside\n`main.py` when packaging manually, as shown below; catalog bundles include it\nautomatically.\n\n## Prerequisites\n\n### Required secret\n\nVerify that the following secret is set in **OpenHands Settings -> Secrets**:\n\n| Secret name | Token type | Minimum permissions |\n|---|---|---|\n| `GITHUB_PERSONAL_ACCESS_TOKEN` | Classic PAT | `repo` for private repos or `public_repo` for public repos |\n| `GITHUB_PERSONAL_ACCESS_TOKEN` | Fine-grained PAT | Contents: Read, Metadata: Read, Pull requests: **Read and Write**, Issues: Read and Write |\n\nPull-request **write** access is required because the agent publishes a pull\nrequest review, not just an issue comment. The Agent Canvas catalog worker may\nalso request a configured human reviewer after approval. A token with only Pull\nrequests: Read will poll happily and then fail at the point of publishing or\nrequesting the handoff.\n\nWhen several repositories are monitored, the token must cover all of them.\n\nCheck with:\n```bash\ncurl -s https://api.github.com/user \\\n -H \"Authorization: Bearer $GITHUB_PERSONAL_ACCESS_TOKEN\" \\\n | python3 -c \"import json,sys; d=json.load(sys.stdin); print(d.get('login') or d.get('message'))\"\n```\n\nIf the token is missing or invalid, inform the user and stop.\n\n---\n\n## Setup Workflow\n\nFollow these steps in order.\n\n### Step 1 - Verify `GITHUB_PERSONAL_ACCESS_TOKEN`\n\nRun the `curl` check above.\n\n- If absent: *\"GITHUB_PERSONAL_ACCESS_TOKEN is not set. Please add it in\n OpenHands Settings -> Secrets.\"* Stop.\n- If the API returns `{\"message\": \"Bad credentials\"}`: tell the user the\n token is invalid and ask them to update it. Stop.\n\n### Step 2 - Collect repositories\n\nAsk: *\"Which GitHub repositories should be monitored?\n(Format: `owner/repo`, e.g. `myorg/backend`. List several separated by commas to\nreview them all from one automation.)\"*\n\nValidate access to **each** repository:\n```bash\ncurl -s \"https://api.github.com/repos/{owner}/{repo}\" \\\n -H \"Authorization: Bearer $GITHUB_PERSONAL_ACCESS_TOKEN\" \\\n | python3 -c \"\nimport json, sys\nd = json.load(sys.stdin)\nif 'message' in d:\n print('ERROR:', d['message'])\nelse:\n print(f\\\"Accessible. Private: {d.get('private')}. Permissions: {d.get('permissions')}\\\")\n\"\n```\n\nRecord every accepted repository into `REPOS = [\"{owner}/{repo}\", ...]`. If one\nrepository fails the check, say which and ask whether to continue without it.\n\nEach repository is polled independently and keeps its own state, so pull-request\nnumbers never collide between them. The trigger label, tone, and schedule are\nshared by all of them; a repository needing different settings wants its own\nautomation.\n\n### Step 3 - Collect trigger label\n\nAsk: *\"Which PR label should trigger a review?\n(Press Enter for the default: `openhands-review`.)\"*\n\nRecord the answer as `TRIGGER_LABEL`. If the label does not exist yet, tell the\nuser that GitHub will still record the event once the label is created and\napplied to a PR.\n\nThe automation reviews a PR when it sees the latest matching `labeled` event for\nthat label. To request another review later, remove and re-apply the label.\n\n### Step 4 - Collect review tone\n\nAsk: *\"What review tone should the reviewer use?\n 1. Thorough (default) - comprehensive coverage of correctness, security, tests, style\n 2. Concise - high-signal only, skips minor style feedback\n 3. Friendly - constructive and encouraging\n(Press Enter for Thorough, or type your choice or any custom style description)\"*\n\nMap the choice to `REVIEW_TONE`:\n\n| Answer | `REVIEW_TONE` | `REVIEW_STYLE_INSTRUCTIONS` |\n|---|---|---|\n| 1 / Enter | `\"thorough\"` | `\"\"` |\n| 2 | `\"concise\"` | `\"\"` |\n| 3 | `\"friendly\"` | `\"\"` |\n| Custom text, e.g. `strict but kind` | `\"thorough\"` | the custom text verbatim |\n\n### Step 5 - Collect cron schedule\n\nAsk: *\"How often should the automation poll for labeled PRs?\n(Press Enter for the default: every 5 minutes.\nUse a cron expression for a different interval, e.g. `0 * * * *` = hourly)\"*\n\nDefault: `*/5 * * * *`.\n\nRecord as `CRON_SCHEDULE`.\n\n### Step 6 - Generate the automation script\n\nRead `scripts/main.py` from this skill's directory. Apply exactly six constant\nsubstitutions near the top of the file:\n\n> The script also reads a `config.json` shipped beside it, if there is one, over\n> these constants. That is how the catalog entry\n> (`automations/catalog/github-pr-reviewer/`) configures an unmodified copy,\n> since a declarative host cannot rewrite Python. This setup path substitutes the\n> constants and ships no `config.json`, so the two never collide.\n\n| Placeholder | Replace with |\n|---|---|\n| `REPOS = [\"owner/repo\"]` | `REPOS = [\"{owner_repo}\", ...]` - one entry per repository collected in Step 2 |\n| `TRIGGER_LABEL = \"openhands-review\"` | `TRIGGER_LABEL = \"{trigger_label}\"` |\n| `REVIEW_TONE = \"thorough\"` | `REVIEW_TONE = \"{review_tone}\"` |\n| `REVIEW_STYLE_INSTRUCTIONS = \"\"` | `REVIEW_STYLE_INSTRUCTIONS = \"{style_instructions}\"` |\n| `REPO_REVIEW_GUIDE_PATH = \".agents/skills/custom-codereview-guide.md\"` | leave unchanged to auto-load a repo review guide at this path, or set to `\"\"` to disable |\n| `DEFAULT_OPENHANDS_URL = \"http://localhost:8000\"` | leave unchanged unless the user has a preference |\n\nUse a safe string writer such as `json.dumps(value)` when inserting user-provided\nrepository names, labels, or style instructions into Python string literals.\n`json.dumps(list_of_repos)` produces the whole `REPOS` list safely in one step.\n\nRun these commands from this skill's directory and write the customized script\nto a temporary build directory:\n```bash\nmkdir -p /tmp/pr-reviewer-build\ncp -L scripts/github_client.py /tmp/pr-reviewer-build/github_client.py\n# write the customized main.py to /tmp/pr-reviewer-build/main.py\n```\n\nValidate syntax before packaging:\n```bash\npython3 -m py_compile /tmp/pr-reviewer-build/main.py && echo \"Syntax OK\"\n```\n\nFix any syntax errors before proceeding.\n\n### Step 7 - Package and upload\n\nDetermine the Automation backend URL and auth from the ``\nblock in your system context:\n- **OPENHANDS_HOST**: the Automation backend `url_from_agent`\n- **Auth**: `X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY`\n\n```bash\ntar -czf /tmp/pr-reviewer.tar.gz -C /tmp/pr-reviewer-build .\n\nTARBALL_PATH=$(curl -s -X POST \\\n \"${OPENHANDS_HOST}/api/automation/v1/uploads?name=github-pr-reviewer\" \\\n -H \"X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY\" \\\n -H \"Content-Type: application/gzip\" \\\n --data-binary @/tmp/pr-reviewer.tar.gz \\\n | python3 -c \"import json,sys; print(json.load(sys.stdin)['tarball_path'])\")\n\necho \"Uploaded: $TARBALL_PATH\"\n```\n\n### Step 8 - Register the automation\n\n```bash\ncurl -s -X POST \"${OPENHANDS_HOST}/api/automation/v1\" \\\n -H \"X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d \"{\n \\\"name\\\": \\\"GitHub PR Reviewer: {repo_summary} label {trigger_label}\\\",\n \\\"trigger\\\": {\\\"type\\\": \\\"cron\\\", \\\"schedule\\\": \\\"{cron_schedule}\\\"},\n \\\"tarball_path\\\": \\\"$TARBALL_PATH\\\",\n \\\"entrypoint\\\": \\\"python3 main.py\\\",\n \\\"timeout\\\": 600\n }\" | python3 -m json.tool\n```\n\nUse the single repository as `{repo_summary}` when there is one, and something\nlike `3 repos` when there are several. A poll now downloads a tarball per queued\nreview, so the timeout allows for that; a run never waits for a review to\nfinish, only for it to be started.\n\nRecord the returned `id`.\n\n### Step 9 - Confirm\n\nTell the user:\n\n> ✅ **GitHub PR Reviewer** is running!\n>\n> - Automation ID: `{id}`\n> - Repositories: `{owner}/{repo}`, ... (one line each)\n> - Trigger label: `{trigger_label}`\n> - Review tone: `{tone}`\n> - Polling schedule: `{cron_schedule}`\n> - State file per repository:\n> `~/.openhands/workspaces/automation-state/github_pr_reviewer_label_event_{id}_{owner}__{repo}.json`\n>\n> Apply the `{trigger_label}` label to a pull request to queue a review. Each\n> label event is processed once. To request another review, remove and re-apply\n> the label.\n>\n> The review is published as a pull request review on the head commit, with\n> inline comments where a finding maps to a changed line.\n\n---\n\n## Runtime Behaviour (per poll)\n\nEach cron run executes `main.py`, which resolves and validates\n`GITHUB_PERSONAL_ACCESS_TOKEN` once, then processes every repository in `REPOS`\nindependently. One repository failing does not stop the others; the run fails\nonly if every repository fails.\n\nFor each repository:\n\n1. Loads that repository's state (see `references/state-schema.md`).\n2. Verifies repository access.\n3. Lists open PRs, newest-updated first.\n4. For each open PR carrying `TRIGGER_LABEL`:\n - Refetches current PR metadata to avoid acting on stale list data.\n - Finds the latest matching GitHub `labeled` issue event.\n - Skips the event if it has already been tracked.\n - Downloads the PR's head commit as a tarball and extracts it to\n `{WORKSPACE_BASE}/repositories/{owner}__{repo}/pr-{number}-{sha12}`. The\n archive is checked as it is unpacked: a single root, no absolute or `..`\n paths, and symlinks skipped rather than materialised.\n - Starts an OpenHands conversation **whose working directory is that\n checkout**, with a review prompt carrying PR metadata, the exact head SHA,\n label event details, and the requirement to re-fetch the current mutable\n GitHub state before deciding a verdict.\n - Posts an acknowledgement comment with the label event, head SHA, and\n conversation link.\n - Records the review in state with `status: \"active\"` and the checkout path.\n - If the checkout or the conversation cannot be created, the checkout is\n removed and nothing is recorded, so the next poll retries the label event.\n5. For each active review conversation:\n - Marks it closed without posting if the PR has closed or merged.\n - Suppresses stale results if the PR head SHA changed after the review was\n queued.\n - When the conversation reaches `idle`, `finished`, `error`, or `stuck`,\n asks GitHub whether a review by the token's own user exists for that head\n SHA. If it does, the review is complete. If it does not, the agent's final\n response is posted as a comment so the work is not lost.\n - Abandons a conversation that has not reached a terminal status within two\n hours, so its checkout can be reclaimed.\n6. Removes the checkout of every finished review, but only after confirming the\n conversation has stopped - deleting it under a running agent would remove its\n working directory. When that cannot be confirmed the directory is left alone\n and the next poll tries again.\n7. Saves that repository's state atomically.\n\nThe completion callback fires once for the whole run.\n\n---\n\n## Additional Resources\n\n- **`references/state-schema.md`** - State JSON schema, field definitions, and\n review lifecycle diagram.\n- **`scripts/main.py`** - The complete automation script. Customize the five\n constants at the top before packaging.\n- **`tests/test_main.py`** - Unit tests for the checkout, its removal, and state\n handling. Run them from the skill root with `python -m pytest tests/` after\n editing the script.\n\n---\n\n## Troubleshooting\n\n| Symptom | Likely cause | Fix |\n|---|---|---|\n| Bot never queues reviews | Trigger label not present or no matching `labeled` event | Apply the configured label to the PR |\n| \"Bad credentials\" in run logs | Token expired | Rotate and update `GITHUB_PERSONAL_ACCESS_TOKEN` |\n| 404 on repo access | Repo name wrong or no access | Re-check the entry in `REPOS` and the token's permissions |\n| One repository is skipped, others work | That repository failed its access check | Read the `=== owner/repo ===` block in the run log |\n| Same PR not reviewed after new commits | Label event was already processed | Remove and re-apply the trigger label |\n| Review paused with a failing-check comment | A current-head required check reported `failure`, `cancelled`, or `timed_out` | Fix the named checks and push; the review starts on the new head, or request `all-hands-bot` to review immediately |\n| Review reported waiting on checks | A current-head required check is `queued` or `in_progress`, or has not reported yet | No action; a later scan or a new review request retries |\n| Optional workflow failed but no review was paused | The failed workflow is not required, so the required-only scheduled gate ignored it | No action; only GitHub-required checks gate scheduled discovery |\n| Review result never posts | Conversation still running or stuck | Open the conversation link from the acknowledgement comment |\n| Stale review suppressed | PR head SHA changed while the agent was reviewing | Re-apply the trigger label after the latest commit |\n| Review arrives as a plain comment, not a review | Publishing failed, so the script posted the text as a fallback | Check that the token has Pull requests: Read and Write |\n| Agent reports it cannot clone the repo | Prompt asked it not to; the workspace is already the checkout | No action - the code is at the head SHA in its working directory |\n| Checkouts remain under `repositories/` | Their conversations had not stopped yet | They are removed by a later poll once the conversation is terminal |", + "content": "# GitHub PR Reviewer Automation\n\n## Agent Canvas catalog\n\nFor new Agent Canvas installations, use the **GitHub code review** catalog\nentry. Its deterministic `worker.py` delegates each requested exact head to a\nstable conversation using the selected agent profile. It supports GitHub\nreviewer-request events and scheduled label scans. The manual upload flow below\nremains for existing deployments and is deprecated for new installations.\n\nCreate a cron automation that watches one or more GitHub repositories for pull\nrequests with a review trigger label, starts an OpenHands review conversation\nonce per label event, and publishes the AI review to GitHub.\nWindows PowerShell equivalents for the setup, packaging, upload, and API-check shell snippets are in `references/windows.md`.\n\nThe automation script is deterministic: PR discovery, label-event tracking,\nhead eligibility, state persistence, stale-result suppression, the repository\ncheckout, and its removal are all handled in Python. The LLM is invoked only for\nthe review itself.\n\nBefore any review conversation is created, a scheduled scan evaluates the\ncurrent head's GitHub-required checks, read through the GraphQL `isRequired`\nsignal for the pull request. That signal is the merge policy's own source of\ntruth and is pull-request-scoped, so it stays correct for a stacked PR whose\nsymbolic base branch carries no branch rules of its own:\n\n- Only checks GitHub reports as required decide the scheduled gate. An optional\n workflow that fails - including one that fails before creating any check run -\n does not block a head whose required checks pass.\n- A completed required run on the exact head whose conclusion is `failure`,\n `cancelled`, or `timed_out` blocks the review. Any other unrecognized\n conclusion fails closed as a block rather than silently approving.\n- A completed required run whose conclusion is `success`, `neutral`, or\n `skipped` does not block. A required `commit status` context is classified\n alongside required check runs.\n- A `queued` or `in_progress` required run on the exact head, and a required\n context that has not reported a current-head check run at all, make the run\n exit with a **waiting on checks** outcome. Unfulfilled is never approval. The\n worker does not hold a slot polling; the next scheduled scan or explicit\n review request retries.\n- When the required-check signal cannot be read or is empty, the gate falls back\n to every current-head check run and workflow run, so a red head still blocks.\n In that fallback, workflow runs are read as well as check runs, because a\n workflow can fail before creating any check run - a workflow-level error, or a\n `pull_request` run whose jobs never start. Such a run leaves a failed check\n suite with no check runs under it, so the commit's check-run rollup and\n `gh pr checks` both report success and only the workflow run reveals the red\n CI. A workflow run whose check suite already reported check runs is left to\n those runs, so a workflow is never counted twice.\n- A scheduled scan considers the trigger label **and** every open, non-draft PR\n that still holds an outstanding `all-hands-bot` review request. That is what\n resumes a request made while CI was running: the request is keyed by its own\n `review_requested` event, so repeated scans reuse one conversation and one\n review instead of creating duplicates, and a PR whose request was answered or\n withdrawn simply drops out of `requested_reviewers`.\n- Runs attributed to any other (obsolete) head SHA are ignored, so a stale\n failure cannot block the push that fixed it. This applies to workflow runs\n too.\n- Only the latest run of each logical check or workflow counts. A logical check\n is its name plus the reporting app identity, and a logical workflow is its\n name plus workflow ID. The latest is chosen by the run ID (the reliable\n creation sequence) with the start time as a tie-break, so a re-run that fixed\n a check supersedes its earlier failure on the same SHA while a newer queued or\n in-progress re-run supersedes an earlier success and makes the head wait.\n Ordering by the run ID keeps a run whose start time is still absent in its\n true creation position in both directions.\n\nAn explicit `all-hands-bot` review request is the intake-policy exception: the\ncaller asked for that head by name, so the request dispatches even when required\nCI is red or pending, and the gate leaves no explanatory comment. Draft, scope,\nexact-head, and delivery-deduplication safeguards still apply. A blocked or\nwaiting scheduled stop consumes no trigger: once the head's required checks are\nnon-blocking, the scheduled scan, or a new `all-hands-bot` review request,\nstarts the normal review.\n\nThe gate needs no configured list of check names. When it stops a review it\nleaves one concise explanation on the PR, identified by a hidden marker carrying\nthe head SHA and gate category, so a later run for a different head updates that\ncomment instead of posting another. Only a marker this reviewer account authored\ncounts as its own comment; every other PR comment is untrusted and can neither\nsuppress the explanation nor be edited. If the repository's own workflow already\nposted a deterministic remediation comment that names the same current-head\nchecks, the gate adds nothing; a disclosure about some other check does not\nsuppress it. The gate applies to scheduled label scans; an explicit\n`all-hands-bot` review request bypasses it by design.\n\nEach explicit review request - a re-applied trigger label, or a new reviewer\nrequest in event mode - is a fresh review. The conversation for a PR is reused,\nbut its earlier turns must not be trusted as current: before deciding a verdict\nthe reviewer re-fetches the mutable GitHub state (the exact head, the PR body,\nreview comments and threads, review requests, the linked issues' bodies and\nlabels, and the current-head Actions results) and ignores any earlier finding,\nverdict, or label/priority claim that state no longer supports. Repository\nanalysis the conversation already did, such as reading `AGENTS.md`, stays\nuseful and is not repeated.\n\nThe waiting and blocked explanations name the retry the deployment actually has.\nA scheduled run proves a scan is configured, so it says the next scan will\nretry. An event-only run does not, so it tells the reader to remove the\noutstanding `all-hands-bot` request and request `all-hands-bot` again instead of\npromising a scan that does not exist: GitHub will not accept a second request\nwhile the first is still outstanding.\n\nThe gate leaves only one explanation per head. When the same head keeps the same\nhidden marker but the retry wording changes -- an event-only comment on a head\nwhose automation is later switched to a cron scan -- the scheduled run rewrites\nthat managed comment in place, so the comment always names the retry that is\nactually deployed, and an unchanged body is left untouched.\n\n## Bounded intake per scheduled scan\n\nA scheduled scan drains outstanding reviewer requests fairly, but starting an\nagent for every eligible pull request at once would overload the deployment, so\nthe scan starts at most `MAX_NEW_PER_RUN` new review conversations, counted\n**across every configured repository** rather than per repository. The default\nis `2`, and the rendered `config.json` overrides it through the\n`max_new_per_run` key that `github-issue-to-pr` and `gitlab-issue-to-mr` already\nuse.\n\n- Eligible candidates are ordered by the oldest outstanding `all-hands-bot`\n `review_requested` event, then by repository, then by pull-request number, so\n the oldest requests drain first and a later scan reaches the remainder.\n- The bound counts the conversations a scan **starts**. A delivery that only\n deduplicates or reports an already-running conversation reuses a runtime and\n consumes no slot, so repeated scans make progress on the backlog instead of\n re-spending the bound on work already in flight.\n- Reaching the bound never cuts the scan short. The scan still evaluates the\n exact-head checks of the remaining candidates, posts its waiting or blocked\n gate comments, reconciles completed reviews, and runs the maintainer handoff.\n A candidate whose checks are pending or failing starts no conversation and\n consumes no slot, so it cannot block a later green candidate.\n- A dispatch that raises is reported and does not consume a slot or abort the\n scan, so the candidates behind it are still considered.\n- The explicit `review_requested` event path and the trigger-label scan are\n unchanged: an explicit request still starts its conversation immediately, and\n only the scheduled scan's new conversations are bounded.\n\nThe review prompt starts with a scope gate: using the repository's own guidance\n(its scope categories and ownership boundaries, not a list of individual PR\nnumbers), the reviewer decides whether the change belongs in this repository\nand has the product/architecture direction it needs. When it does not, the review\nstops with a single `event: COMMENT` review that says whether the change should\nmove repositories, close, or receive a maintainer decision, and ends with the\n`🛑 MAINTAINER DECISION REQUIRED` verdict. That outcome is **neither an approval\nnor a change request**: it does not approve or merge the PR. The completion\nhandler recognizes the verdict and requests one configured maintainer through the\nsame handoff used after an approval. An in-scope change continues the existing\nreview unchanged.\n\nThe script prepares each review's workspace before the agent starts: the pull\nrequest's head commit is downloaded as a tarball and extracted to a directory of\nits own, which becomes the conversation's working directory. The agent is told\nnot to clone, fetch, check out, or delete anything, and the script removes the\ncheckout once the conversation has stopped. Nothing accumulates between runs.\n\n---\n\nThe script imports shared GitHub transport from\n`scripts/github_client.py`, installed with this skill. Include it beside\n`main.py` when packaging manually, as shown below; catalog bundles include it\nautomatically.\n\n## Prerequisites\n\n### Required secret\n\nVerify that the following secret is set in **OpenHands Settings -> Secrets**:\n\n| Secret name | Token type | Minimum permissions |\n|---|---|---|\n| `GITHUB_PERSONAL_ACCESS_TOKEN` | Classic PAT | `repo` for private repos or `public_repo` for public repos |\n| `GITHUB_PERSONAL_ACCESS_TOKEN` | Fine-grained PAT | Contents: Read, Metadata: Read, Pull requests: **Read and Write**, Issues: Read and Write |\n\nPull-request **write** access is required because the agent publishes a pull\nrequest review, not just an issue comment. The Agent Canvas catalog worker may\nalso request a configured human reviewer after approval. A token with only Pull\nrequests: Read will poll happily and then fail at the point of publishing or\nrequesting the handoff.\n\nWhen several repositories are monitored, the token must cover all of them.\n\nCheck with:\n```bash\ncurl -s https://api.github.com/user \\\n -H \"Authorization: Bearer $GITHUB_PERSONAL_ACCESS_TOKEN\" \\\n | python3 -c \"import json,sys; d=json.load(sys.stdin); print(d.get('login') or d.get('message'))\"\n```\n\nIf the token is missing or invalid, inform the user and stop.\n\n---\n\n## Setup Workflow\n\nFollow these steps in order.\n\n### Step 1 - Verify `GITHUB_PERSONAL_ACCESS_TOKEN`\n\nRun the `curl` check above.\n\n- If absent: *\"GITHUB_PERSONAL_ACCESS_TOKEN is not set. Please add it in\n OpenHands Settings -> Secrets.\"* Stop.\n- If the API returns `{\"message\": \"Bad credentials\"}`: tell the user the\n token is invalid and ask them to update it. Stop.\n\n### Step 2 - Collect repositories\n\nAsk: *\"Which GitHub repositories should be monitored?\n(Format: `owner/repo`, e.g. `myorg/backend`. List several separated by commas to\nreview them all from one automation.)\"*\n\nValidate access to **each** repository:\n```bash\ncurl -s \"https://api.github.com/repos/{owner}/{repo}\" \\\n -H \"Authorization: Bearer $GITHUB_PERSONAL_ACCESS_TOKEN\" \\\n | python3 -c \"\nimport json, sys\nd = json.load(sys.stdin)\nif 'message' in d:\n print('ERROR:', d['message'])\nelse:\n print(f\\\"Accessible. Private: {d.get('private')}. Permissions: {d.get('permissions')}\\\")\n\"\n```\n\nRecord every accepted repository into `REPOS = [\"{owner}/{repo}\", ...]`. If one\nrepository fails the check, say which and ask whether to continue without it.\n\nEach repository is polled independently and keeps its own state, so pull-request\nnumbers never collide between them. The trigger label, tone, and schedule are\nshared by all of them; a repository needing different settings wants its own\nautomation.\n\n### Step 3 - Collect trigger label\n\nAsk: *\"Which PR label should trigger a review?\n(Press Enter for the default: `openhands-review`.)\"*\n\nRecord the answer as `TRIGGER_LABEL`. If the label does not exist yet, tell the\nuser that GitHub will still record the event once the label is created and\napplied to a PR.\n\nThe automation reviews a PR when it sees the latest matching `labeled` event for\nthat label. To request another review later, remove and re-apply the label.\n\n### Step 4 - Collect review tone\n\nAsk: *\"What review tone should the reviewer use?\n 1. Thorough (default) - comprehensive coverage of correctness, security, tests, style\n 2. Concise - high-signal only, skips minor style feedback\n 3. Friendly - constructive and encouraging\n(Press Enter for Thorough, or type your choice or any custom style description)\"*\n\nMap the choice to `REVIEW_TONE`:\n\n| Answer | `REVIEW_TONE` | `REVIEW_STYLE_INSTRUCTIONS` |\n|---|---|---|\n| 1 / Enter | `\"thorough\"` | `\"\"` |\n| 2 | `\"concise\"` | `\"\"` |\n| 3 | `\"friendly\"` | `\"\"` |\n| Custom text, e.g. `strict but kind` | `\"thorough\"` | the custom text verbatim |\n\n### Step 5 - Collect cron schedule\n\nAsk: *\"How often should the automation poll for labeled PRs?\n(Press Enter for the default: every 5 minutes.\nUse a cron expression for a different interval, e.g. `0 * * * *` = hourly)\"*\n\nDefault: `*/5 * * * *`.\n\nRecord as `CRON_SCHEDULE`.\n\n### Step 6 - Generate the automation script\n\nRead `scripts/main.py` from this skill's directory. Apply exactly seven constant\nsubstitutions near the top of the file:\n\n> The script also reads a `config.json` shipped beside it, if there is one, over\n> these constants. That is how the catalog entry\n> (`automations/catalog/github-pr-reviewer/`) configures an unmodified copy,\n> since a declarative host cannot rewrite Python. This setup path substitutes the\n> constants and ships no `config.json`, so the two never collide.\n\n| Placeholder | Replace with |\n|---|---|\n| `REPOS = [\"owner/repo\"]` | `REPOS = [\"{owner_repo}\", ...]` - one entry per repository collected in Step 2 |\n| `TRIGGER_LABEL = \"openhands-review\"` | `TRIGGER_LABEL = \"{trigger_label}\"` |\n| `REVIEW_TONE = \"thorough\"` | `REVIEW_TONE = \"{review_tone}\"` |\n| `REVIEW_STYLE_INSTRUCTIONS = \"\"` | `REVIEW_STYLE_INSTRUCTIONS = \"{style_instructions}\"` |\n| `REPO_REVIEW_GUIDE_PATH = \".agents/skills/custom-codereview-guide.md\"` | leave unchanged to auto-load a repo review guide at this path, or set to `\"\"` to disable |\n| `MAX_NEW_PER_RUN = 2` | leave unchanged to bound a scheduled scan to two new review conversations across all repositories, or raise it if the deployment can hold more agents at once |\n| `DEFAULT_OPENHANDS_URL = \"http://localhost:8000\"` | leave unchanged unless the user has a preference |\n\nUse a safe string writer such as `json.dumps(value)` when inserting user-provided\nrepository names, labels, or style instructions into Python string literals.\n`json.dumps(list_of_repos)` produces the whole `REPOS` list safely in one step.\n\nRun these commands from this skill's directory and write the customized script\nto a temporary build directory:\n```bash\nmkdir -p /tmp/pr-reviewer-build\ncp -L scripts/github_client.py /tmp/pr-reviewer-build/github_client.py\n# write the customized main.py to /tmp/pr-reviewer-build/main.py\n```\n\nValidate syntax before packaging:\n```bash\npython3 -m py_compile /tmp/pr-reviewer-build/main.py && echo \"Syntax OK\"\n```\n\nFix any syntax errors before proceeding.\n\n### Step 7 - Package and upload\n\nDetermine the Automation backend URL and auth from the ``\nblock in your system context:\n- **OPENHANDS_HOST**: the Automation backend `url_from_agent`\n- **Auth**: `X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY`\n\n```bash\ntar -czf /tmp/pr-reviewer.tar.gz -C /tmp/pr-reviewer-build .\n\nTARBALL_PATH=$(curl -s -X POST \\\n \"${OPENHANDS_HOST}/api/automation/v1/uploads?name=github-pr-reviewer\" \\\n -H \"X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY\" \\\n -H \"Content-Type: application/gzip\" \\\n --data-binary @/tmp/pr-reviewer.tar.gz \\\n | python3 -c \"import json,sys; print(json.load(sys.stdin)['tarball_path'])\")\n\necho \"Uploaded: $TARBALL_PATH\"\n```\n\n### Step 8 - Register the automation\n\n```bash\ncurl -s -X POST \"${OPENHANDS_HOST}/api/automation/v1\" \\\n -H \"X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d \"{\n \\\"name\\\": \\\"GitHub PR Reviewer: {repo_summary} label {trigger_label}\\\",\n \\\"trigger\\\": {\\\"type\\\": \\\"cron\\\", \\\"schedule\\\": \\\"{cron_schedule}\\\"},\n \\\"tarball_path\\\": \\\"$TARBALL_PATH\\\",\n \\\"entrypoint\\\": \\\"python3 main.py\\\",\n \\\"timeout\\\": 600\n }\" | python3 -m json.tool\n```\n\nUse the single repository as `{repo_summary}` when there is one, and something\nlike `3 repos` when there are several. A poll now downloads a tarball per queued\nreview, so the timeout allows for that; a run never waits for a review to\nfinish, only for it to be started.\n\nRecord the returned `id`.\n\n### Step 9 - Confirm\n\nTell the user:\n\n> ✅ **GitHub PR Reviewer** is running!\n>\n> - Automation ID: `{id}`\n> - Repositories: `{owner}/{repo}`, ... (one line each)\n> - Trigger label: `{trigger_label}`\n> - Review tone: `{tone}`\n> - Polling schedule: `{cron_schedule}`\n> - State file per repository:\n> `~/.openhands/workspaces/automation-state/github_pr_reviewer_label_event_{id}_{owner}__{repo}.json`\n>\n> Apply the `{trigger_label}` label to a pull request to queue a review. Each\n> label event is processed once. To request another review, remove and re-apply\n> the label.\n>\n> The review is published as a pull request review on the head commit, with\n> inline comments where a finding maps to a changed line.\n\n---\n\n## Runtime Behaviour (per poll)\n\nEach cron run executes `main.py`, which resolves and validates\n`GITHUB_PERSONAL_ACCESS_TOKEN` once, then processes every repository in `REPOS`\nindependently. One repository failing does not stop the others; the run fails\nonly if every repository fails.\n\nFor each repository:\n\n1. Loads that repository's state (see `references/state-schema.md`).\n2. Verifies repository access.\n3. Lists open PRs, newest-updated first.\n4. For each open PR carrying `TRIGGER_LABEL`:\n - Refetches current PR metadata to avoid acting on stale list data.\n - Finds the latest matching GitHub `labeled` issue event.\n - Skips the event if it has already been tracked.\n - Downloads the PR's head commit as a tarball and extracts it to\n `{WORKSPACE_BASE}/repositories/{owner}__{repo}/pr-{number}-{sha12}`. The\n archive is checked as it is unpacked: a single root, no absolute or `..`\n paths, and symlinks skipped rather than materialised.\n - Starts an OpenHands conversation **whose working directory is that\n checkout**, with a review prompt carrying PR metadata, the exact head SHA,\n label event details, and the requirement to re-fetch the current mutable\n GitHub state before deciding a verdict.\n - Posts an acknowledgement comment with the label event, head SHA, and\n conversation link.\n - Records the review in state with `status: \"active\"` and the checkout path.\n - If the checkout or the conversation cannot be created, the checkout is\n removed and nothing is recorded, so the next poll retries the label event.\n5. For each active review conversation:\n - Marks it closed without posting if the PR has closed or merged.\n - Suppresses stale results if the PR head SHA changed after the review was\n queued.\n - When the conversation reaches `idle`, `finished`, `error`, or `stuck`,\n asks GitHub whether a review by the token's own user exists for that head\n SHA. If it does, the review is complete. If it does not, the agent's final\n response is posted as a comment so the work is not lost.\n - Abandons a conversation that has not reached a terminal status within two\n hours, so its checkout can be reclaimed.\n6. Removes the checkout of every finished review, but only after confirming the\n conversation has stopped - deleting it under a running agent would remove its\n working directory. When that cannot be confirmed the directory is left alone\n and the next poll tries again.\n7. Saves that repository's state atomically.\n\nThe completion callback fires once for the whole run.\n\n---\n\n## Additional Resources\n\n- **`references/state-schema.md`** - State JSON schema, field definitions, and\n review lifecycle diagram.\n- **`scripts/main.py`** - The complete automation script. Customize the five\n constants at the top before packaging.\n- **`tests/test_main.py`** - Unit tests for the checkout, its removal, and state\n handling. Run them from the skill root with `python -m pytest tests/` after\n editing the script.\n\n---\n\n## Troubleshooting\n\n| Symptom | Likely cause | Fix |\n|---|---|---|\n| Bot never queues reviews | Trigger label not present or no matching `labeled` event | Apply the configured label to the PR |\n| \"Bad credentials\" in run logs | Token expired | Rotate and update `GITHUB_PERSONAL_ACCESS_TOKEN` |\n| 404 on repo access | Repo name wrong or no access | Re-check the entry in `REPOS` and the token's permissions |\n| One repository is skipped, others work | That repository failed its access check | Read the `=== owner/repo ===` block in the run log |\n| Same PR not reviewed after new commits | Label event was already processed | Remove and re-apply the trigger label |\n| Review paused with a failing-check comment | A current-head required check reported `failure`, `cancelled`, or `timed_out` | Fix the named checks and push; the review starts on the new head, or request `all-hands-bot` to review immediately |\n| Review reported waiting on checks | A current-head required check is `queued` or `in_progress`, or has not reported yet | No action; a later scan or a new review request retries |\n| Optional workflow failed but no review was paused | The failed workflow is not required, so the required-only scheduled gate ignored it | No action; only GitHub-required checks gate scheduled discovery |\n| Only a few reviews start on a large backlog | The per-scan `max_new_per_run` bound (default 2) reached | No action; later scans drain the remaining oldest requests, or raise `max_new_per_run` if the deployment can hold more agents |\n| Review result never posts | Conversation still running or stuck | Open the conversation link from the acknowledgement comment |\n| Stale review suppressed | PR head SHA changed while the agent was reviewing | Re-apply the trigger label after the latest commit |\n| Review arrives as a plain comment, not a review | Publishing failed, so the script posted the text as a fallback | Check that the token has Pull requests: Read and Write |\n| Agent reports it cannot clone the repo | Prompt asked it not to; the workspace is already the checkout | No action - the code is at the head SHA in its working directory |\n| Checkouts remain under `repositories/` | Their conversations had not stopped yet | They are removed by a later poll once the conversation is terminal |", "category": "automations" }, { diff --git a/tests/fixtures/automations/github-pr-reviewer.json b/tests/fixtures/automations/github-pr-reviewer.json index a4d9d7e9..2408083a 100644 --- a/tests/fixtures/automations/github-pr-reviewer.json +++ b/tests/fixtures/automations/github-pr-reviewer.json @@ -20,7 +20,8 @@ ], "githubTokenSecret": "GITHUB_PERSONAL_ACCESS_TOKEN", "maintainers": "", - "triggerReviewer": "all-hands-bot" + "triggerReviewer": "all-hands-bot", + "maxNewPerRun": 2 }, "upload": { "request": { @@ -67,7 +68,7 @@ "timeout": 300, "template": { "id": "github-pr-reviewer", - "version": "1.6.0", + "version": "1.7.0", "config": { "repos": [ "OpenHands/agent-server-gui" @@ -76,7 +77,8 @@ "review_tone": "thorough", "github_token_secret": "GITHUB_PERSONAL_ACCESS_TOKEN", "maintainers": "", - "trigger_reviewer": "all-hands-bot" + "trigger_reviewer": "all-hands-bot", + "max_new_per_run": 2 } } } @@ -106,7 +108,7 @@ "timeout": 300, "template": { "id": "github-pr-reviewer", - "version": "1.6.0", + "version": "1.7.0", "config": { "repos": [ "OpenHands/agent-server-gui" @@ -115,7 +117,8 @@ "review_tone": "thorough", "github_token_secret": "GITHUB_PERSONAL_ACCESS_TOKEN", "maintainers": "", - "trigger_reviewer": "all-hands-bot" + "trigger_reviewer": "all-hands-bot", + "max_new_per_run": 2 } } } @@ -162,7 +165,8 @@ ], "githubTokenSecret": "GITHUB_PERSONAL_ACCESS_TOKEN", "maintainers": "", - "triggerReviewer": "all-hands-bot" + "triggerReviewer": "all-hands-bot", + "maxNewPerRun": 2 }, "upload": { "request": { @@ -209,7 +213,7 @@ "timeout": 300, "template": { "id": "github-pr-reviewer", - "version": "1.6.0", + "version": "1.7.0", "config": { "repos": [ "OpenHands/agent-server-gui", @@ -219,7 +223,8 @@ "review_tone": "thorough", "github_token_secret": "GITHUB_PERSONAL_ACCESS_TOKEN", "maintainers": "", - "trigger_reviewer": "all-hands-bot" + "trigger_reviewer": "all-hands-bot", + "max_new_per_run": 2 } } } @@ -249,7 +254,7 @@ "timeout": 300, "template": { "id": "github-pr-reviewer", - "version": "1.6.0", + "version": "1.7.0", "config": { "repos": [ "OpenHands/agent-server-gui", @@ -259,7 +264,8 @@ "review_tone": "thorough", "github_token_secret": "GITHUB_PERSONAL_ACCESS_TOKEN", "maintainers": "", - "trigger_reviewer": "all-hands-bot" + "trigger_reviewer": "all-hands-bot", + "max_new_per_run": 2 } } } @@ -290,7 +296,7 @@ "preset_metadata": { "template": { "id": "github-pr-reviewer", - "version": "1.6.0", + "version": "1.7.0", "config": { "repos": [ "OpenHands/agent-server-gui", @@ -300,7 +306,8 @@ "review_tone": "thorough", "github_token_secret": "GITHUB_PERSONAL_ACCESS_TOKEN", "maintainers": "", - "trigger_reviewer": "all-hands-bot" + "trigger_reviewer": "all-hands-bot", + "max_new_per_run": 2 } } } @@ -322,7 +329,8 @@ ], "githubTokenSecret": "GITHUB_PERSONAL_ACCESS_TOKEN", "maintainers": "", - "triggerReviewer": "all-hands-bot" + "triggerReviewer": "all-hands-bot", + "maxNewPerRun": 2 }, "preflight": { "request": { @@ -343,7 +351,7 @@ "timeout": 300, "template": { "id": "github-pr-reviewer", - "version": "1.6.0", + "version": "1.7.0", "config": { "repos": [ "OpenHands/agent-server-gui" @@ -352,7 +360,8 @@ "review_tone": "concise", "github_token_secret": "GITHUB_PERSONAL_ACCESS_TOKEN", "maintainers": "", - "trigger_reviewer": "all-hands-bot" + "trigger_reviewer": "all-hands-bot", + "max_new_per_run": 2 } } } @@ -390,7 +399,8 @@ ], "githubTokenSecret": "GITHUB_PERSONAL_ACCESS_TOKEN", "maintainers": "", - "triggerReviewer": "all-hands-bot" + "triggerReviewer": "all-hands-bot", + "maxNewPerRun": 2 }, "upload": { "request": { @@ -434,7 +444,7 @@ "timeout": 300, "template": { "id": "github-pr-reviewer", - "version": "1.6.0", + "version": "1.7.0", "config": { "repos": [ "OpenHands/agent-server-gui" @@ -443,7 +453,8 @@ "review_tone": "concise", "github_token_secret": "GITHUB_PERSONAL_ACCESS_TOKEN", "maintainers": "", - "trigger_reviewer": "all-hands-bot" + "trigger_reviewer": "all-hands-bot", + "max_new_per_run": 2 } } } @@ -484,7 +495,8 @@ ], "githubTokenSecret": "GITHUB_PERSONAL_ACCESS_TOKEN", "maintainers": "", - "triggerReviewer": "all-hands-bot" + "triggerReviewer": "all-hands-bot", + "maxNewPerRun": 2 }, "upload": { "request": { @@ -528,7 +540,7 @@ "timeout": 300, "template": { "id": "github-pr-reviewer", - "version": "1.6.0", + "version": "1.7.0", "config": { "repos": [ "OpenHands/agent-server-gui" @@ -537,7 +549,8 @@ "review_tone": "concise", "github_token_secret": "GITHUB_PERSONAL_ACCESS_TOKEN", "maintainers": "", - "trigger_reviewer": "all-hands-bot" + "trigger_reviewer": "all-hands-bot", + "max_new_per_run": 2 } }, "repos": [ @@ -580,7 +593,8 @@ "OpenHands/automation" ], "githubTokenSecret": "GITHUB_TOKEN_REVIEW", - "maintainers": "neubig,malhotra5" + "maintainers": "neubig,malhotra5", + "maxNewPerRun": 2 }, "preflight": { "request": { @@ -602,7 +616,7 @@ "timeout": 300, "template": { "id": "github-pr-reviewer", - "version": "1.6.0", + "version": "1.7.0", "config": { "repos": [ "OpenHands/automation" @@ -611,7 +625,8 @@ "trigger_reviewer": "all-hands-bot", "review_tone": "concise", "maintainers": "neubig,malhotra5", - "github_token_secret": "GITHUB_TOKEN_REVIEW" + "github_token_secret": "GITHUB_TOKEN_REVIEW", + "max_new_per_run": 2 } } } diff --git a/tests/test_github_reviewer_delivery.py b/tests/test_github_reviewer_delivery.py index ab95c529..91cf91ac 100644 --- a/tests/test_github_reviewer_delivery.py +++ b/tests/test_github_reviewer_delivery.py @@ -1737,3 +1737,376 @@ def test_reviewer_scheduled_scan_blocked_comment_names_the_scan( body = posted[0].args[2]["body"] assert "The scheduled scan then starts the review" in body + + +# --------------------------------------------------------------------------- # +# Bounded intake: one scheduled scan starts a small, configurable number of new +# review conversations across every configured repository, oldest request first. +# --------------------------------------------------------------------------- # + + +class _DedupeDispatcher: + """An in-memory stand-in for the shipped dispatcher's dedupe contract. + + The real AgentConversationDispatcher needs an agent server and SDK, so this + reproduces only the contract the worker relies on: a delivery keyed by + (subject, delivery) starts one conversation and reports `created` once, then + reports `deduplicated` for the same key. That is what lets a repeat scan + reuse a conversation without spending another intake slot. + """ + + def __init__(self): + self.seen = {} + self.calls = [] + + def deliver(self, *, subject, delivery, prompt): + self.calls.append((subject, delivery)) + if self.seen.get(subject) == delivery: + return {"disposition": "deduplicated", "conversation_id": subject} + self.seen[subject] = delivery + return {"disposition": "created", "conversation_id": subject} + + +def _scan_reviewers(tmp_path, monkeypatch, repositories, *, max_new=None): + """One module and one reviewer per repository, as the shipped scan builds them.""" + module = worker("github-pr-reviewer", tmp_path, monkeypatch) + monkeypatch.delenv("AUTOMATION_EVENT_PAYLOAD", raising=False) + monkeypatch.setattr( + module.workflow, + "_latest_trigger_label_event", + lambda *args: {"id": 7, "created_at": "2026-01-01T00:00:00Z"}, + ) + reviewers = [] + for repository in repositories: + run = object.__new__(module.PullRequestReviewer) + run.config = {"trigger_label": "openhands-review"} + if max_new is not None: + run.config["max_new_per_run"] = max_new + run.repository = repository + run.token = "token" + run.token_name = "FACTORY_GITHUB_REVIEWER_TOKEN" + run.github_login = "all-hands-bot" + run.dispatcher = _DedupeDispatcher() + run.check_runs = lambda sha: [] + run.workflow_runs = lambda sha: [] + reviewers.append(run) + return module, reviewers + + +def _run_scan(module, reviewers): + """Drive the shipped scheduled-scan control flow over the given reviewers. + + `module.run_scan` is the shipped entrypoint function, so the shared-intake + and drain wiring under test is the one that runs in production. Only the + repository objects it drives are supplied here: the GitHub transport is + already stubbed on each reviewer, so `run_repositories` is replaced by a + loop over them in the order a scan would visit the configured repositories. + """ + dispatcher = reviewers[0].dispatcher if reviewers else None + by_repository = {run.repository: run for run in reviewers} + + def fake_run_repositories(automation_type, conversation=None, dispatcher=None): + for source in by_repository.values(): + run = object.__new__(automation_type) + run.__dict__.update(source.__dict__) + run.run() + + original = module.run_repositories + module.run_repositories = fake_run_repositories + try: + module.run_scan(dispatcher) + finally: + module.run_repositories = original + module.PullRequestReviewer.scan_intake = None + + +def _eligible_pr(number, sha, request_id, created_at, *, checks=()): + """An open non-draft PR holding an outstanding all-hands-bot request.""" + return { + "number": number, + "head": {"sha": sha}, + "labels": [], + "draft": False, + "requested_reviewers": [{"login": "all-hands-bot"}], + "_request": { + "id": request_id, + "event": "review_requested", + "created_at": created_at, + "requested_reviewer": {"login": "all-hands-bot"}, + }, + "_checks": list(checks), + } + + +def _wire_scan(run, prs, repository_id): + """Point one reviewer at its PRs, request events, and check runs.""" + by_number = {pr["number"]: pr for pr in prs} + + def gh(method, path, body=None): + if path == "": + return {"id": repository_id} + if method == "GET" and path.startswith("/pulls/"): + return by_number[int(path.split("/")[2])] + if method == "POST" and path.endswith("/comments"): + return {"id": 1234} + return {} + + def gh_pages(path): + if path.startswith("/pulls?"): + return list(prs) + if path.endswith("/events"): + return [by_number[int(path.split("/")[2])]["_request"]] + return [] + + def check_runs(sha): + for pr in by_number.values(): + if pr["head"]["sha"] == sha: + return [ + { + "name": name, + "status": status, + "conclusion": conclusion, + "head_sha": sha, + } + for name, status, conclusion in pr["_checks"] + ] + return [] + + run.gh = gh + run.gh_pages = gh_pages + run.check_runs = check_runs + + +def test_one_scan_starts_at_most_the_maximum_and_a_later_scan_reaches_the_rest( + tmp_path, monkeypatch +): + """Three eligible PRs across two repositories, default maximum of two.""" + module, (one, two) = _scan_reviewers( + tmp_path, monkeypatch, ["owner/one", "owner/two"] + ) + # Oldest request first: #5 (Jan 1), then #2 (Jan 2), then #9 (Jan 3). + _wire_scan( + one, + [ + _eligible_pr(5, "head-5", 500, "2026-01-01T00:00:00Z"), + _eligible_pr(9, "head-9", 900, "2026-01-03T00:00:00Z"), + ], + 101, + ) + _wire_scan(two, [_eligible_pr(2, "head-2", 200, "2026-01-02T00:00:00Z")], 102) + + _run_scan(module, [one, two]) + + started = set(one.dispatcher.calls) | set(two.dispatcher.calls) + assert started == {("101:pr:5", "500:head-5"), ("102:pr:2", "200:head-2")} + assert one.dispatcher.seen == {"101:pr:5": "500:head-5"} + assert two.dispatcher.seen == {"102:pr:2": "200:head-2"} + + # A later scan sees the first two as already-running and reaches the rest. + _run_scan(module, [one, two]) + + assert one.dispatcher.seen == { + "101:pr:5": "500:head-5", + "101:pr:9": "900:head-9", + } + assert two.dispatcher.seen == {"102:pr:2": "200:head-2"} + + +def test_a_pending_check_candidate_consumes_no_slot_and_does_not_block_the_queue( + tmp_path, monkeypatch +): + """The oldest candidate is waiting on CI, so the next two drain instead.""" + module, (one, two) = _scan_reviewers( + tmp_path, monkeypatch, ["owner/one", "owner/two"] + ) + _wire_scan( + one, + [ + # Oldest request, but its exact head still has a queued check. + _eligible_pr( + 5, + "head-5", + 500, + "2026-01-01T00:00:00Z", + checks=[("slow-e2e", "queued", None)], + ), + _eligible_pr(9, "head-9", 900, "2026-01-03T00:00:00Z"), + ], + 101, + ) + _wire_scan(two, [_eligible_pr(2, "head-2", 200, "2026-01-02T00:00:00Z")], 102) + + _run_scan(module, [one, two]) + + assert one.dispatcher.seen == {"101:pr:9": "900:head-9"} + assert two.dispatcher.seen == {"102:pr:2": "200:head-2"} + + +def test_non_dispatching_work_still_runs_after_the_maximum_is_reached( + tmp_path, monkeypatch +): + """A blocked candidate past the maximum still gets its gate explanation.""" + module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"]) + _wire_scan( + one, + [ + _eligible_pr(5, "head-5", 500, "2026-01-01T00:00:00Z"), + _eligible_pr(6, "head-6", 600, "2026-01-02T00:00:00Z"), + _eligible_pr( + 7, + "head-7", + 700, + "2026-01-04T00:00:00Z", + checks=[("ci", "completed", "failure")], + ), + ], + 101, + ) + one.gh = Mock(side_effect=one.gh) + + _run_scan(module, [one]) + + # Only the two oldest start; the blocked third consumes no slot. + assert set(one.dispatcher.seen) == {"101:pr:5", "101:pr:6"} + posted = [call for call in one.gh.call_args_list if call.args[0] == "POST"] + assert len(posted) == 1 + assert "" in posted[0].args[2]["body"] + + +def test_a_failing_dispatch_is_reported_without_consuming_a_slot_or_aborting( + tmp_path, monkeypatch +): + """The oldest dispatch raises; the next candidates still get their turn.""" + module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"]) + _wire_scan( + one, + [ + _eligible_pr(5, "head-5", 500, "2026-01-01T00:00:00Z"), + _eligible_pr(6, "head-6", 600, "2026-01-02T00:00:00Z"), + _eligible_pr(7, "head-7", 700, "2026-01-03T00:00:00Z"), + ], + 101, + ) + + def deliver(*, subject, delivery, prompt): + if subject == "101:pr:5": + raise RuntimeError("agent server unavailable") + one.dispatcher.seen[subject] = delivery + return {"disposition": "created", "conversation_id": subject} + + one.dispatcher.deliver = deliver + + with pytest.raises(RuntimeError, match="#5"): + _run_scan(module, [one]) + + assert set(one.dispatcher.seen) == {"101:pr:6", "101:pr:7"} + + +def test_the_maximum_is_configurable_through_the_rendered_config(tmp_path, monkeypatch): + module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"], max_new=1) + _wire_scan( + one, + [ + _eligible_pr(5, "head-5", 500, "2026-01-01T00:00:00Z"), + _eligible_pr(6, "head-6", 600, "2026-01-02T00:00:00Z"), + ], + 101, + ) + + _run_scan(module, [one]) + + assert set(one.dispatcher.seen) == {"101:pr:5"} + + +def test_the_rendered_config_defaults_the_maximum_to_two(tmp_path, monkeypatch): + """An unset max_new_per_run still bounds a scheduled scan to two.""" + module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"]) + _wire_scan( + one, + [ + _eligible_pr(5, "head-5", 500, "2026-01-01T00:00:00Z"), + _eligible_pr(6, "head-6", 600, "2026-01-02T00:00:00Z"), + _eligible_pr(7, "head-7", 700, "2026-01-03T00:00:00Z"), + ], + 101, + ) + + _run_scan(module, [one]) + + assert module.workflow.MAX_NEW_PER_RUN == 2 + assert set(one.dispatcher.seen) == {"101:pr:5", "101:pr:6"} + + +def test_the_explicit_request_event_path_is_not_bounded(tmp_path, monkeypatch): + """Only the scheduled scan's new conversations are bounded.""" + module, (one,) = _scan_reviewers(tmp_path, monkeypatch, ["owner/one"]) + monkeypatch.setenv( + "AUTOMATION_EVENT_PAYLOAD", + json.dumps( + { + "automation_id": "automation", + "event": { + "payload": { + "action": "review_requested", + "repository": {"full_name": "owner/one"}, + "pull_request": {"number": 5}, + "requested_reviewer": {"login": "all-hands-bot"}, + } + }, + } + ), + ) + pr = {"number": 5, "head": {"sha": "head-5"}, "labels": []} + one.gh = Mock(side_effect=[{"id": 101}, pr]) + one.gh_pages = lambda path: ( + [ + { + "id": 500, + "event": "review_requested", + "created_at": "2026-01-01T00:00:00Z", + "requested_reviewer": {"login": "all-hands-bot"}, + } + ] + if path.endswith("/events") + else [] + ) + + one.run() + + assert one.dispatcher.seen == {"101:pr:5": "500:head-5"} + + +def test_the_rendered_config_reads_a_positive_max_new_per_run(tmp_path, monkeypatch): + """The rendered config overrides the default, following the key the other + automations already use.""" + module = worker("github-pr-reviewer", tmp_path, monkeypatch) + (tmp_path / "github-pr-reviewer" / "config.json").write_text( + json.dumps( + { + "repos": ["owner/one"], + "max_new_per_run": 3, + "github_token_secret": "GITHUB_TOKEN", + } + ) + ) + + config = module.workflow.load_config(tmp_path / "github-pr-reviewer") + + assert config["max_new_per_run"] == 3 + + +@pytest.mark.parametrize("bad", [0, -1, True, "2"]) +def test_the_rendered_config_rejects_a_misbehaving_max_new_per_run( + tmp_path, monkeypatch, bad +): + """A non-positive or non-integer bound is a hard error, not a coercion: the + alternative is a scan that either never starts or starts `True` conversations.""" + module = worker("github-pr-reviewer", tmp_path, monkeypatch) + (tmp_path / "github-pr-reviewer" / "config.json").write_text( + json.dumps({"repos": ["owner/one"], "max_new_per_run": bad}) + ) + + with pytest.raises(SystemExit): + module.workflow.load_config(tmp_path / "github-pr-reviewer") + + From 68d45c9d2d08ecad16fc51ae6414c58b7ce4d69d Mon Sep 17 00:00:00 2001 From: all-hands-bot Date: Wed, 23 Sep 2026 02:47:36 +0000 Subject: [PATCH 2/3] chore(review): add read-only bounded-intake canary for #656 Loads the shipped catalog bundle and drives the real scheduled scan and shared-intake drain against the live GitHub API. It starts no agent and posts no comment: the dispatcher and the gate comment upsert record what would have happened, so the canary is read-only. Development-only, kept in .pr/. Co-authored-by: openhands (cherry picked from commit 9396c243208cff33e444baf5180c789bea54e885) --- .pr/canary_bounded_intake.py | 197 +++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 .pr/canary_bounded_intake.py diff --git a/.pr/canary_bounded_intake.py b/.pr/canary_bounded_intake.py new file mode 100644 index 00000000..673717a7 --- /dev/null +++ b/.pr/canary_bounded_intake.py @@ -0,0 +1,197 @@ +"""Read-only live canary for bounded scheduled intake across OSS repositories. + +Loads the *shipped* bundle from the catalog manifest and drives the real +`PullRequestReviewer.run()` scheduled path and the real shared-intake drain +against the live GitHub API. It never starts an agent or posts a comment: the +dispatcher records what would have been delivered, and the gate comment upsert +is recorded instead of written. The maintainer handoff is skipped so the run +stays read-only. + +Run: + GITHUB_TOKEN_REVIEW=... WORKSPACE_BASE=$(mktemp -d) \ + python3 .pr/canary_bounded_intake.py +""" + +import json +import os +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tests")) +from github_automation_helpers import worker # noqa: E402 + +REPOSITORIES = [ + "OpenHands/extensions", + "OpenHands/OpenHands", + "OpenHands/software-agent-sdk", + "OpenHands/automation", +] + + +def _eligible_live_candidates(module, reviewers, bound): + """Live outstanding-request PRs, forced eligible, to exercise the bound. + + The live scan is honest about the gate: a blocked head starts nothing. That + left only one green candidate at canary time, so the bound was not the + limiting factor. This pass takes the same live PRs, request events, head + SHAs, and repositories, and treats each outstanding request as eligible, so + the real drain is shown stopping at the bound on real data. Only the gate + flag is overridden; ordering, delivery keys, and the drain are the shipped + code. + """ + delivered = [] + registered = [] + module.PullRequestReviewer.scan_intake = module.ReviewIntake() + for run in reviewers: + prs = run.gh_pages("/pulls?state=open&sort=updated&direction=asc") + for candidate in prs: + if candidate.get("draft") or not run._outstanding_review_request( + candidate + ): + continue + pr = run.gh("GET", f"/pulls/{candidate['number']}") + trigger = run._latest_reviewer_request(pr["number"]) + if trigger is None: + continue + record = { + "repository": run.repository, + "number": pr["number"], + "created_at": trigger.get("created_at") or "", + "config": {**run.config, "max_new_per_run": bound}, + "start": ( + lambda run=run, pr=pr, trigger=trigger: ( + delivered.append( + { + "repository": run.repository, + "pr": pr["number"], + "delivery": ( + f"{trigger['id']}:{pr['head']['sha']}" + ), + } + ) + or {"disposition": "created", "conversation_id": ""} + ) + ), + } + registered.append(record) + run.intake.register(record) + module.PullRequestReviewer.scan_intake.drain() + return { + "max_new_per_run": bound, + "live_eligible_candidates": sorted( + ( + { + "repository": item["repository"], + "pr": item["number"], + "requested_at": item["created_at"], + } + for item in registered + ), + key=lambda i: (i["requested_at"], i["repository"], i["pr"]), + ), + "conversations_started": delivered, + "conversations_started_count": len(delivered), + } + + +def main(): + workspace = Path(os.environ["WORKSPACE_BASE"]) + sys.argv = ["worker.py"] + module = worker("github-pr-reviewer", workspace, _Ignore()) + + bound = int(os.environ.get("CANARY_MAX_NEW_PER_RUN", "2")) + delivered = [] + gate = [] + reviewers = [] + inspected = {} + + for repository in REPOSITORIES: + run = object.__new__(module.PullRequestReviewer) + run.config = { + "repos": REPOSITORIES, + "trigger_label": "openhands-review-canary", + "trigger_reviewer": "all-hands-bot", + "maintainers": "", + "github_token_secret": "GITHUB_TOKEN_REVIEW", + "max_new_per_run": bound, + } + run.repository = repository + run.token_name = "GITHUB_TOKEN_REVIEW" + run.token = os.environ["GITHUB_TOKEN_REVIEW"] + run.github_login = "all-hands-bot" + run.workspace = workspace + run.project = workspace + run.evidence = workspace / "evidence" + run.evidence.mkdir(exist_ok=True) + run._completed_dependencies = {} + + class Dispatcher: + def deliver(self, *, subject, delivery, prompt, repo=repository): + delivered.append( + { + "repository": repo, + "subject": subject, + "delivery": delivery, + } + ) + return {"disposition": "created", "conversation_id": subject} + + run.dispatcher = Dispatcher() + run._gate_comment = ( + lambda number, marker, body, names, repo=repository: gate.append( + {"repository": repo, "pr": number, "marker": marker} + ) + ) + run._finish_completed_review = lambda *a, **k: False + reviewers.append(run) + + # The shipped entrypoint wiring: one shared intake, every repository's + # scheduled run, then one bounded drain. + module.PullRequestReviewer.scan_intake = module.ReviewIntake() + for run in reviewers: + run.run() + inspected[run.repository] = len(run.gh_pages("/pulls?state=open")) + pending = [ + {"repository": item["repository"], "pr": item["number"], + "requested_at": item["created_at"]} + for item in module.PullRequestReviewer.scan_intake._pending + ] + module.PullRequestReviewer.scan_intake.drain() + + evidence = { + "configured_repositories": REPOSITORIES, + "max_new_per_run": bound, + "open_pull_requests_inspected": inspected, + "registered_green_candidates": len(pending), + "drain_order": sorted( + pending, + key=lambda i: ( + i["requested_at"], + i["repository"], + i["pr"], + ), + ), + "conversations_started": delivered, + "conversations_started_count": len(delivered), + "gate_dispositions": gate, + } + for bound_case in (2, 1, 3): + evidence[f"bound_demo_max_{bound_case}"] = _eligible_live_candidates( + module, reviewers, bound_case + ) + print(json.dumps(evidence, indent=2)) + + +class _Ignore: + """Minimal stand-in for pytest's monkeypatch, used only by the bundle loader.""" + + def delitem(self, mapping, key, raising=True): + mapping.pop(key, None) + + def syspath_prepend(self, path): + sys.path.insert(0, path) + + +if __name__ == "__main__": + main() From 50fb986ff5a4b5d26520508e80f1077ff1f871f0 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 23 Sep 2026 05:06:18 +0000 Subject: [PATCH 3/3] chore(review): drop the one-off bounded-intake canary harness The 197-line read-only canary was evidence for #656, not part of the shipped automation, so it does not belong in this focused per-scan bound PR. The live result stays recorded in the PR description; the harness itself is preserved on factory/reviewer-continuous-canary. Co-authored-by: openhands --- .pr/canary_bounded_intake.py | 197 ----------------------------------- 1 file changed, 197 deletions(-) delete mode 100644 .pr/canary_bounded_intake.py diff --git a/.pr/canary_bounded_intake.py b/.pr/canary_bounded_intake.py deleted file mode 100644 index 673717a7..00000000 --- a/.pr/canary_bounded_intake.py +++ /dev/null @@ -1,197 +0,0 @@ -"""Read-only live canary for bounded scheduled intake across OSS repositories. - -Loads the *shipped* bundle from the catalog manifest and drives the real -`PullRequestReviewer.run()` scheduled path and the real shared-intake drain -against the live GitHub API. It never starts an agent or posts a comment: the -dispatcher records what would have been delivered, and the gate comment upsert -is recorded instead of written. The maintainer handoff is skipped so the run -stays read-only. - -Run: - GITHUB_TOKEN_REVIEW=... WORKSPACE_BASE=$(mktemp -d) \ - python3 .pr/canary_bounded_intake.py -""" - -import json -import os -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(ROOT / "tests")) -from github_automation_helpers import worker # noqa: E402 - -REPOSITORIES = [ - "OpenHands/extensions", - "OpenHands/OpenHands", - "OpenHands/software-agent-sdk", - "OpenHands/automation", -] - - -def _eligible_live_candidates(module, reviewers, bound): - """Live outstanding-request PRs, forced eligible, to exercise the bound. - - The live scan is honest about the gate: a blocked head starts nothing. That - left only one green candidate at canary time, so the bound was not the - limiting factor. This pass takes the same live PRs, request events, head - SHAs, and repositories, and treats each outstanding request as eligible, so - the real drain is shown stopping at the bound on real data. Only the gate - flag is overridden; ordering, delivery keys, and the drain are the shipped - code. - """ - delivered = [] - registered = [] - module.PullRequestReviewer.scan_intake = module.ReviewIntake() - for run in reviewers: - prs = run.gh_pages("/pulls?state=open&sort=updated&direction=asc") - for candidate in prs: - if candidate.get("draft") or not run._outstanding_review_request( - candidate - ): - continue - pr = run.gh("GET", f"/pulls/{candidate['number']}") - trigger = run._latest_reviewer_request(pr["number"]) - if trigger is None: - continue - record = { - "repository": run.repository, - "number": pr["number"], - "created_at": trigger.get("created_at") or "", - "config": {**run.config, "max_new_per_run": bound}, - "start": ( - lambda run=run, pr=pr, trigger=trigger: ( - delivered.append( - { - "repository": run.repository, - "pr": pr["number"], - "delivery": ( - f"{trigger['id']}:{pr['head']['sha']}" - ), - } - ) - or {"disposition": "created", "conversation_id": ""} - ) - ), - } - registered.append(record) - run.intake.register(record) - module.PullRequestReviewer.scan_intake.drain() - return { - "max_new_per_run": bound, - "live_eligible_candidates": sorted( - ( - { - "repository": item["repository"], - "pr": item["number"], - "requested_at": item["created_at"], - } - for item in registered - ), - key=lambda i: (i["requested_at"], i["repository"], i["pr"]), - ), - "conversations_started": delivered, - "conversations_started_count": len(delivered), - } - - -def main(): - workspace = Path(os.environ["WORKSPACE_BASE"]) - sys.argv = ["worker.py"] - module = worker("github-pr-reviewer", workspace, _Ignore()) - - bound = int(os.environ.get("CANARY_MAX_NEW_PER_RUN", "2")) - delivered = [] - gate = [] - reviewers = [] - inspected = {} - - for repository in REPOSITORIES: - run = object.__new__(module.PullRequestReviewer) - run.config = { - "repos": REPOSITORIES, - "trigger_label": "openhands-review-canary", - "trigger_reviewer": "all-hands-bot", - "maintainers": "", - "github_token_secret": "GITHUB_TOKEN_REVIEW", - "max_new_per_run": bound, - } - run.repository = repository - run.token_name = "GITHUB_TOKEN_REVIEW" - run.token = os.environ["GITHUB_TOKEN_REVIEW"] - run.github_login = "all-hands-bot" - run.workspace = workspace - run.project = workspace - run.evidence = workspace / "evidence" - run.evidence.mkdir(exist_ok=True) - run._completed_dependencies = {} - - class Dispatcher: - def deliver(self, *, subject, delivery, prompt, repo=repository): - delivered.append( - { - "repository": repo, - "subject": subject, - "delivery": delivery, - } - ) - return {"disposition": "created", "conversation_id": subject} - - run.dispatcher = Dispatcher() - run._gate_comment = ( - lambda number, marker, body, names, repo=repository: gate.append( - {"repository": repo, "pr": number, "marker": marker} - ) - ) - run._finish_completed_review = lambda *a, **k: False - reviewers.append(run) - - # The shipped entrypoint wiring: one shared intake, every repository's - # scheduled run, then one bounded drain. - module.PullRequestReviewer.scan_intake = module.ReviewIntake() - for run in reviewers: - run.run() - inspected[run.repository] = len(run.gh_pages("/pulls?state=open")) - pending = [ - {"repository": item["repository"], "pr": item["number"], - "requested_at": item["created_at"]} - for item in module.PullRequestReviewer.scan_intake._pending - ] - module.PullRequestReviewer.scan_intake.drain() - - evidence = { - "configured_repositories": REPOSITORIES, - "max_new_per_run": bound, - "open_pull_requests_inspected": inspected, - "registered_green_candidates": len(pending), - "drain_order": sorted( - pending, - key=lambda i: ( - i["requested_at"], - i["repository"], - i["pr"], - ), - ), - "conversations_started": delivered, - "conversations_started_count": len(delivered), - "gate_dispositions": gate, - } - for bound_case in (2, 1, 3): - evidence[f"bound_demo_max_{bound_case}"] = _eligible_live_candidates( - module, reviewers, bound_case - ) - print(json.dumps(evidence, indent=2)) - - -class _Ignore: - """Minimal stand-in for pytest's monkeypatch, used only by the bundle loader.""" - - def delitem(self, mapping, key, raising=True): - mapping.pop(key, None) - - def syspath_prepend(self, path): - sys.path.insert(0, path) - - -if __name__ == "__main__": - main()