@openhands/extensions 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.agents/skills/custom-codereview-guide.md +14 -12
  2. package/.claude-plugin/marketplace.json +14 -0
  3. package/.codex-plugin/marketplace.json +14 -0
  4. package/.github/workflows/check-extensions.yml +6 -6
  5. package/.github/workflows/deprecation-check.yml +1 -1
  6. package/.github/workflows/issue-readiness-check.yml +37 -106
  7. package/.github/workflows/npm-publish.yml +2 -2
  8. package/.github/workflows/pr-description-check.yml +1 -1
  9. package/.github/workflows/pypi-publish.yml +3 -3
  10. package/.github/workflows/tests.yml +2 -2
  11. package/.plugin/marketplace.json +14 -0
  12. package/.release-please-manifest.json +1 -1
  13. package/AGENTS.md +10 -1
  14. package/README.md +3 -2
  15. package/automations/bundle-index.js +2 -2
  16. package/automations/catalog/github-pr-reviewer/manifest.json +4 -4
  17. package/integrations/catalog/granola.json +38 -0
  18. package/integrations/catalog-index.js +16 -14
  19. package/marketplaces/openhands-extensions.json +14 -0
  20. package/package.json +1 -1
  21. package/pyproject.toml +2 -2
  22. package/python/openhands_extensions/_version.py +1 -1
  23. package/skills/add-skill/SKILL.md +5 -2
  24. package/skills/canvas-extension-api/.claude-plugin/plugin.json +19 -0
  25. package/skills/canvas-extension-api/.codex-plugin/plugin.json +19 -0
  26. package/skills/canvas-extension-api/.plugin/plugin.json +19 -0
  27. package/skills/canvas-extension-api/README.md +33 -0
  28. package/skills/canvas-extension-api/SKILL.md +254 -0
  29. package/skills/canvas-extension-api/references/acceptance-checklist.md +20 -0
  30. package/skills/canvas-extension-api/references/backend-safety.md +9 -0
  31. package/skills/canvas-extension-api/references/connections.md +126 -0
  32. package/skills/canvas-extension-api/references/packaging-recipes.md +43 -0
  33. package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -0
  34. package/skills/canvas-extension-api/references/testing-and-installation.md +286 -0
  35. package/skills/canvas-extension-api/references/v1-contract.md +250 -0
  36. package/skills/canvas-extension-api/scripts/validate-extension.mjs +92 -0
  37. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +60 -0
  38. package/skills/github-pr-reviewer/README.md +6 -1
  39. package/skills/github-pr-reviewer/SKILL.md +24 -1
  40. package/skills/github-pr-reviewer/scripts/main.py +40 -11
  41. package/skills/github-pr-reviewer/scripts/worker.py +12 -3
  42. package/skills/github-pr-reviewer/tests/test_main.py +69 -0
  43. package/skills/index.js +11 -3
  44. package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
  45. package/tests/test_github_reviewer_delivery.py +128 -1
  46. package/.github/scripts/check_issue_readiness.py +0 -272
  47. package/.github/scripts/post-readiness-comment.mjs +0 -184
  48. package/tests/test_check_issue_readiness.py +0 -135
@@ -6,9 +6,9 @@ export const AUTOMATION_BUNDLE_FILES = {
6
6
  "github-pr-reviewer": {
7
7
  "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",
8
8
  "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 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",
9
- "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\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. 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 \" Then inspect the PR discussion, existing review comments, changed files, and the diff, \"\n \"together with the surrounding code in the workspace.\\n\"\n f\" Use `gh` or GitHub REST API calls with `{github_token_secret}`; never print secret values.\\n\"\n \"3. 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\"4. 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 \"5. 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 \"6. Begin the review body with this disclosure: \"\n \"`_This review was posted by an AI agent (OpenHands)._`\\n\"\n \"7. End the review body with a verdict on its own line: either `✅ APPROVED` \"\n \"or `🔄 CHANGES REQUESTED`.\\n\"\n \"8. 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 5, 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",
9
+ "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",
10
10
  "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",
11
- "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\nclass PullRequestReviewer(GitHubRepository):\n name = \"github-pr-reviewer\"\n\n @cached_property\n def github_login(self):\n return self.api(\"GET\", \"/user\")[\"login\"]\n\n @cached_property\n def trigger_reviewer(self):\n return self.config.get(\"trigger_reviewer\", \"all-hands-bot\").lower()\n\n @staticmethod\n def _event_payload():\n \"\"\"Return the GitHub webhook payload, or None for a scheduled run.\"\"\"\n raw = os.environ.get(\"AUTOMATION_EVENT_PAYLOAD\")\n if not raw:\n return None\n outer = json.loads(raw)\n payload = (outer.get(\"event\") or {}).get(\"payload\")\n return payload if isinstance(payload, dict) else None\n\n def _latest_reviewer_request(self, number):\n matching = [\n event\n for event in self.gh_pages(f\"/issues/{number}/events\")\n if event.get(\"event\") == \"review_requested\"\n and ((event.get(\"requested_reviewer\") or {}).get(\"login\") or \"\").lower()\n == self.trigger_reviewer\n and event.get(\"id\") is not None\n ]\n if not matching:\n return None\n return max(\n matching,\n key=lambda event: (\n event.get(\"created_at\") or \"\",\n int(event.get(\"id\") or 0),\n ),\n )\n\n def _event_candidate(self, payload):\n repository = ((payload.get(\"repository\") or {}).get(\"full_name\") or \"\")\n if repository.lower() != self.repository.lower():\n return None\n pr = payload.get(\"pull_request\")\n if not isinstance(pr, dict) or pr.get(\"number\") is None:\n return None\n action = payload.get(\"action\")\n if action == \"review_requested\":\n requested = (\n (payload.get(\"requested_reviewer\") or {}).get(\"login\") or \"\"\n ).lower()\n return pr if requested == self.trigger_reviewer else None\n if action == \"submitted\":\n author = ((payload.get(\"review\") or {}).get(\"user\") or {}).get(\"login\")\n return pr if (author or \"\").lower() == self.trigger_reviewer else None\n return None\n\n def _prompt(self, pr, trigger, label=None):\n number = pr[\"number\"]\n sha = pr[\"head\"][\"sha\"]\n token = self.token_name\n workspace = (\n \"The workspace contains an empty Git repository. Before fetching, run \"\n f\"`GH_TOKEN=${{{token}}} gh auth setup-git` so Git uses the \"\n \"profile-scoped credential without storing it in the remote URL. Set \"\n f\"the plain HTTPS origin to `https://github.com/{self.repository}.git`, \"\n f\"fetch PR #{number}, and check out exact head `{sha}` in detached-HEAD \"\n \"mode. Set `GIT_TERMINAL_PROMPT=0` on Git network commands so a missing \"\n \"permission fails immediately instead of waiting for input.\"\n )\n prompt = workflow._build_review_prompt(\n self.repository,\n pr,\n sha,\n trigger,\n workspace_instructions=workspace,\n github_token_secret=token,\n trigger_description=(\n None\n if label\n else (\n f\"latest review request for \"\n f\"`{self.trigger_reviewer}` \"\n f\"event {trigger.get('id', '?')} at \"\n f\"{trigger.get('created_at', '?')}\"\n )\n ),\n )\n moved_head_instruction = (\n f\"leave `{label}` in place so the new head is reviewed\"\n if label\n else \"publish no review; the new head requires another reviewer request\"\n )\n trigger_completion = (\n f\"Leave the `{label}` label in place after GitHub accepts the review. \"\n \"The deterministic scanner removes it\"\n if label\n else \"Do not change review requests after GitHub accepts the review. \"\n \"The deterministic event handler completes the request\"\n )\n return (\n prompt + \"\\n\\nAcceptance reporting:\\n\"\n \"- Inspect the current GitHub Actions results for the exact head and run \"\n \"the repository's appropriate focused tests in the workspace. Do not \"\n \"modify tracked files.\\n\"\n f\"- Re-read {self.repository} PR #{number} immediately before reporting. \"\n f\"If its head is no longer `{sha}`, {moved_head_instruction}.\\n\"\n f\"- {trigger_completion} after completing any configured \"\n \"human-review handoff. Never paste JSON artifacts or full command logs \"\n \"into comments.\\n\"\n \"- Once GitHub accepts the native review, stop immediately. Do not \"\n \"continue inspecting the repository, run more commands, or publish a \"\n \"second result.\"\n )\n\n def _finish_completed_review(self, pr, trigger, label=None):\n \"\"\"Complete an exact-head review, including an optional human handoff.\"\"\"\n head_sha = pr[\"head\"][\"sha\"]\n triggered_at = trigger.get(\"created_at\") or \"\"\n reviews = self.gh_pages(f\"/pulls/{pr['number']}/reviews\")\n completed = [\n review\n for review in reviews\n if review.get(\"commit_id\") == head_sha\n and ((review.get(\"user\") or {}).get(\"login\") or \"\").lower()\n == self.github_login.lower()\n and (review.get(\"submitted_at\") or \"\") > triggered_at\n ]\n if not completed:\n return False\n approved = None\n for review in sorted(\n completed, key=lambda item: item[\"submitted_at\"], reverse=True\n ):\n body = (review.get(\"body\") or \"\").rstrip()\n if body.endswith(\"✅ APPROVED\"):\n approved = True\n break\n if body.endswith(\"🔄 CHANGES REQUESTED\"):\n approved = False\n break\n if approved is None:\n return False\n if approved:\n maintainers = parse_maintainers(self.config.get(\"maintainers\"))\n if maintainers:\n try:\n selected = request_maintainer_review(self, pr, maintainers)\n except HandoffConfigurationError as exc:\n self.gh(\n \"POST\",\n f\"/issues/{pr['number']}/comments\",\n {\n \"body\": (\n \"⚠️ **Automated maintainer handoff could not be \"\n f\"completed:** {exc}. Update the automation's \"\n \"maintainer roster, then request another review.\"\n \"\\n\\n_This is an automated configuration check; \"\n \"no AI was used to generate this comment._\"\n )\n },\n )\n selected = None\n print(\n json.dumps(\n {\n \"repository\": self.repository,\n \"pr\": pr[\"number\"],\n \"human_reviewer\": selected,\n }\n ),\n flush=True,\n )\n current = self.gh(\"GET\", f\"/pulls/{pr['number']}\")\n if current[\"head\"][\"sha\"] != pr[\"head\"][\"sha\"]:\n # Treat this scan as handled so it cannot redeliver the stale head.\n # Scheduled mode leaves its label in place; event mode requires a\n # fresh review request for the new head.\n return True\n if label:\n self.gh(\n \"DELETE\", f\"/issues/{pr['number']}/labels/{quote(label, safe='')}\"\n )\n return True\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 }:\n continue\n try:\n pr = self.gh(\"GET\", f\"/pulls/{candidate['number']}\")\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 if trigger is None:\n continue\n trigger_label = None if event_mode else label\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 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"
11
+ "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\nclass PullRequestReviewer(GitHubRepository):\n name = \"github-pr-reviewer\"\n\n @cached_property\n def github_login(self):\n return self.api(\"GET\", \"/user\")[\"login\"]\n\n @cached_property\n def trigger_reviewer(self):\n return self.config.get(\"trigger_reviewer\", \"all-hands-bot\").lower()\n\n @staticmethod\n def _event_payload():\n \"\"\"Return the GitHub webhook payload, or None for a scheduled run.\"\"\"\n raw = os.environ.get(\"AUTOMATION_EVENT_PAYLOAD\")\n if not raw:\n return None\n outer = json.loads(raw)\n payload = (outer.get(\"event\") or {}).get(\"payload\")\n return payload if isinstance(payload, dict) else None\n\n def _latest_reviewer_request(self, number):\n matching = [\n event\n for event in self.gh_pages(f\"/issues/{number}/events\")\n if event.get(\"event\") == \"review_requested\"\n and ((event.get(\"requested_reviewer\") or {}).get(\"login\") or \"\").lower()\n == self.trigger_reviewer\n and event.get(\"id\") is not None\n ]\n if not matching:\n return None\n return max(\n matching,\n key=lambda event: (\n event.get(\"created_at\") or \"\",\n int(event.get(\"id\") or 0),\n ),\n )\n\n def _event_candidate(self, payload):\n repository = ((payload.get(\"repository\") or {}).get(\"full_name\") or \"\")\n if repository.lower() != self.repository.lower():\n return None\n pr = payload.get(\"pull_request\")\n if not isinstance(pr, dict) or pr.get(\"number\") is None:\n return None\n action = payload.get(\"action\")\n if action == \"review_requested\":\n requested = (\n (payload.get(\"requested_reviewer\") or {}).get(\"login\") or \"\"\n ).lower()\n return pr if requested == self.trigger_reviewer else None\n if action == \"submitted\":\n author = ((payload.get(\"review\") or {}).get(\"user\") or {}).get(\"login\")\n return pr if (author or \"\").lower() == self.trigger_reviewer else None\n return None\n\n def _prompt(self, pr, trigger, label=None):\n number = pr[\"number\"]\n sha = pr[\"head\"][\"sha\"]\n token = self.token_name\n workspace = (\n \"The workspace contains an empty Git repository. Before fetching, run \"\n f\"`GH_TOKEN=${{{token}}} gh auth setup-git` so Git uses the \"\n \"profile-scoped credential without storing it in the remote URL. Set \"\n f\"the plain HTTPS origin to `https://github.com/{self.repository}.git`, \"\n f\"fetch PR #{number}, and check out exact head `{sha}` in detached-HEAD \"\n \"mode. Set `GIT_TERMINAL_PROMPT=0` on Git network commands so a missing \"\n \"permission fails immediately instead of waiting for input.\"\n )\n prompt = workflow._build_review_prompt(\n self.repository,\n pr,\n sha,\n trigger,\n workspace_instructions=workspace,\n github_token_secret=token,\n trigger_description=(\n None\n if label\n else (\n f\"latest review request for \"\n f\"`{self.trigger_reviewer}` \"\n f\"event {trigger.get('id', '?')} at \"\n f\"{trigger.get('created_at', '?')}\"\n )\n ),\n )\n moved_head_instruction = (\n f\"leave `{label}` in place so the new head is reviewed\"\n if label\n else \"publish no review; the new head requires another reviewer request\"\n )\n trigger_completion = (\n f\"Leave the `{label}` label in place after GitHub accepts the review. \"\n \"The deterministic scanner removes it\"\n if label\n else \"Do not change review requests after GitHub accepts the review. \"\n \"The deterministic event handler completes the request\"\n )\n return (\n prompt + \"\\n\\nAcceptance reporting:\\n\"\n \"- Inspect the current GitHub Actions results for the exact head and run \"\n \"the repository's appropriate focused tests in the workspace. Do not \"\n \"modify tracked files.\\n\"\n f\"- Re-read {self.repository} PR #{number} immediately before reporting. \"\n \"Confirm its current body, labels, review threads, and the head's check \"\n \"results, and re-read every linked issue's current body and labels: an \"\n \"issue's readiness or priority may have changed since an earlier turn. \"\n f\"If the head is no longer `{sha}`, {moved_head_instruction}.\\n\"\n f\"- {trigger_completion} after completing any configured \"\n \"human-review handoff. Never paste JSON artifacts or full command logs \"\n \"into comments.\\n\"\n \"- Once GitHub accepts the native review, stop immediately. Do not \"\n \"continue inspecting the repository, run more commands, or publish a \"\n \"second result.\"\n )\n\n def _finish_completed_review(self, pr, trigger, label=None):\n \"\"\"Complete an exact-head review, including an optional human handoff.\"\"\"\n head_sha = pr[\"head\"][\"sha\"]\n triggered_at = trigger.get(\"created_at\") or \"\"\n reviews = self.gh_pages(f\"/pulls/{pr['number']}/reviews\")\n completed = [\n review\n for review in reviews\n if review.get(\"commit_id\") == head_sha\n and ((review.get(\"user\") or {}).get(\"login\") or \"\").lower()\n == self.github_login.lower()\n and (review.get(\"submitted_at\") or \"\") > triggered_at\n ]\n if not completed:\n return False\n approved = None\n maintainer_decision = False\n for review in sorted(\n completed, key=lambda item: item[\"submitted_at\"], reverse=True\n ):\n body = (review.get(\"body\") or \"\").rstrip()\n if body.endswith(\"✅ APPROVED\"):\n approved = True\n break\n if body.endswith(\"🔄 CHANGES REQUESTED\"):\n approved = False\n break\n if body.endswith(workflow.MAINTAINER_DECISION_VERDICT):\n maintainer_decision = True\n break\n if approved is None and not maintainer_decision:\n return False\n # A scope stop is not an approval: it requests the maintainer decision\n # the change is missing, through the same handoff as an approval.\n if approved or maintainer_decision:\n maintainers = parse_maintainers(self.config.get(\"maintainers\"))\n if maintainers:\n try:\n selected = request_maintainer_review(self, pr, maintainers)\n except HandoffConfigurationError as exc:\n self.gh(\n \"POST\",\n f\"/issues/{pr['number']}/comments\",\n {\n \"body\": (\n \"⚠️ **Automated maintainer handoff could not be \"\n f\"completed:** {exc}. Update the automation's \"\n \"maintainer roster, then request another review.\"\n \"\\n\\n_This is an automated configuration check; \"\n \"no AI was used to generate this comment._\"\n )\n },\n )\n selected = None\n print(\n json.dumps(\n {\n \"repository\": self.repository,\n \"pr\": pr[\"number\"],\n \"human_reviewer\": selected,\n }\n ),\n flush=True,\n )\n current = self.gh(\"GET\", f\"/pulls/{pr['number']}\")\n if current[\"head\"][\"sha\"] != pr[\"head\"][\"sha\"]:\n # Treat this scan as handled so it cannot redeliver the stale head.\n # Scheduled mode leaves its label in place; event mode requires a\n # fresh review request for the new head.\n return True\n if label:\n self.gh(\n \"DELETE\", f\"/issues/{pr['number']}/labels/{quote(label, safe='')}\"\n )\n return True\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 }:\n continue\n try:\n pr = self.gh(\"GET\", f\"/pulls/{candidate['number']}\")\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 if trigger is None:\n continue\n trigger_label = None if event_mode else label\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 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"
12
12
  },
13
13
  "github-issue-to-pr": {
14
14
  "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",
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "id": "github-pr-reviewer",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "name": "GitHub code review",
5
5
  "category": "Code review",
6
- "description": "Review requested pull-request heads, record exact-head decisions, and optionally request a code-aware human maintainer after approval.",
6
+ "description": "Review requested pull-request heads, 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.",
7
7
  "requires": {
8
8
  "integrations": {},
9
9
  "features": [
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "popularityRank": 100,
15
15
  "estimatedSetupMinutes": 4,
16
- "exampleImplementation": "A scheduled label scan or 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, reuses the established review workflow, runs appropriate tests, and publishes a readable native review. A native approval can then be handed to a code-aware human maintainer.",
16
+ "exampleImplementation": "A scheduled label scan or 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.",
17
17
  "impact": {
18
18
  "basis": "completed-runs",
19
19
  "one": "1 PR review sweep completed",
@@ -125,7 +125,7 @@
125
125
  }
126
126
  },
127
127
  "bundle": {
128
- "version": "1.3.0",
128
+ "version": "1.5.0",
129
129
  "entrypoint": "python3 worker.py",
130
130
  "timeout": 300,
131
131
  "files": {
@@ -0,0 +1,38 @@
1
+ {
2
+ "id": "granola",
3
+ "name": "Granola",
4
+ "description": "Search and query your Granola meeting notes, transcripts, folders, and action items.",
5
+ "categories": [
6
+ "Meetings",
7
+ "Knowledge base"
8
+ ],
9
+ "appUrl": "https://www.granola.ai",
10
+ "docsUrl": "https://docs.granola.ai/help-center/sharing/integrations/mcp",
11
+ "notes": "Uses Granola's hosted MCP server. OAuth is handled by the server itself via browser sign-in with Dynamic Client Registration, so no client ID or client secret is required. MCP connects as an individual user and follows the active workspace selected in the Granola app; there is no API key access for MCP.",
12
+ "connectionOptions": [
13
+ {
14
+ "id": "oauth",
15
+ "provider": "mcp",
16
+ "transport": {
17
+ "kind": "shttp",
18
+ "url": "https://mcp.granola.ai/mcp"
19
+ },
20
+ "auth": {
21
+ "strategy": "oauth2",
22
+ "oauth": {
23
+ "clientAuthentication": "none"
24
+ }
25
+ }
26
+ }
27
+ ],
28
+ "iconBg": "#D1E043",
29
+ "iconColor": "var(--oh-surface-deep)",
30
+ "keywords": [
31
+ "meeting notes",
32
+ "transcripts",
33
+ "meetings",
34
+ "notes",
35
+ "action items"
36
+ ],
37
+ "installHint": "Sign in to Granola through the browser OAuth flow when prompted; no client credentials or API key are needed. MCP follows the active workspace selected in your Granola app, so switch workspaces there before querying if you belong to more than one. Some folder, search, and transcript tools require a paid Granola plan."
38
+ }
@@ -66,20 +66,21 @@ import entry60 from "./catalog/fetch.json" with { type: "json" };
66
66
  import entry61 from "./catalog/filesystem.json" with { type: "json" };
67
67
  import entry62 from "./catalog/firecrawl.json" with { type: "json" };
68
68
  import entry63 from "./catalog/git.json" with { type: "json" };
69
- import entry64 from "./catalog/huggingface.json" with { type: "json" };
70
- import entry65 from "./catalog/kagi.json" with { type: "json" };
71
- import entry66 from "./catalog/memory.json" with { type: "json" };
72
- import entry67 from "./catalog/mongodb.json" with { type: "json" };
73
- import entry68 from "./catalog/neon.json" with { type: "json" };
74
- import entry69 from "./catalog/obsidian.json" with { type: "json" };
75
- import entry70 from "./catalog/paypal.json" with { type: "json" };
76
- import entry71 from "./catalog/playwright.json" with { type: "json" };
77
- import entry72 from "./catalog/redis.json" with { type: "json" };
78
- import entry73 from "./catalog/reportportal.json" with { type: "json" };
79
- import entry74 from "./catalog/resend.json" with { type: "json" };
80
- import entry75 from "./catalog/sequential-thinking.json" with { type: "json" };
81
- import entry76 from "./catalog/superhuman-mail.json" with { type: "json" };
82
- import entry77 from "./catalog/time.json" with { type: "json" };
69
+ import entry64 from "./catalog/granola.json" with { type: "json" };
70
+ import entry65 from "./catalog/huggingface.json" with { type: "json" };
71
+ import entry66 from "./catalog/kagi.json" with { type: "json" };
72
+ import entry67 from "./catalog/memory.json" with { type: "json" };
73
+ import entry68 from "./catalog/mongodb.json" with { type: "json" };
74
+ import entry69 from "./catalog/neon.json" with { type: "json" };
75
+ import entry70 from "./catalog/obsidian.json" with { type: "json" };
76
+ import entry71 from "./catalog/paypal.json" with { type: "json" };
77
+ import entry72 from "./catalog/playwright.json" with { type: "json" };
78
+ import entry73 from "./catalog/redis.json" with { type: "json" };
79
+ import entry74 from "./catalog/reportportal.json" with { type: "json" };
80
+ import entry75 from "./catalog/resend.json" with { type: "json" };
81
+ import entry76 from "./catalog/sequential-thinking.json" with { type: "json" };
82
+ import entry77 from "./catalog/superhuman-mail.json" with { type: "json" };
83
+ import entry78 from "./catalog/time.json" with { type: "json" };
83
84
 
84
85
  export const INTEGRATION_CATALOG_ENTRIES = [
85
86
  entry0,
@@ -160,4 +161,5 @@ export const INTEGRATION_CATALOG_ENTRIES = [
160
161
  entry75,
161
162
  entry76,
162
163
  entry77,
164
+ entry78,
163
165
  ];
@@ -106,6 +106,20 @@
106
106
  "delegation"
107
107
  ]
108
108
  },
109
+ {
110
+ "name": "canvas-extension-api",
111
+ "source": "./skills/canvas-extension-api",
112
+ "description": "Build and validate Apps for Agent Canvas using the supported Canvas Extensions API v1 routed-page contract.",
113
+ "category": "agent-authoring",
114
+ "defaultEnabled": true,
115
+ "keywords": [
116
+ "agent-canvas",
117
+ "canvas-app",
118
+ "canvas-extension",
119
+ "register-page",
120
+ "app-development"
121
+ ]
122
+ },
109
123
  {
110
124
  "name": "openhands-sdk",
111
125
  "source": "./skills/openhands-sdk",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openhands/extensions",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Public OpenHands extension catalogs for skills, plugins, integrations, and automation templates.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -1,11 +1,11 @@
1
1
  [project]
2
2
  name = "openhands-extensions"
3
- version = "0.23.0"
3
+ version = "0.24.0"
4
4
  description = "OpenHands extensions, plugins, and skills (Python bindings for the integration catalog)"
5
5
  requires-python = ">=3.12"
6
6
  dependencies = [
7
7
  "deprecation>=2.1,<3",
8
- "packaging>=24,<26",
8
+ "packaging>=24,<27",
9
9
  "pydantic>=2.10,<3",
10
10
  ]
11
11
 
@@ -18,7 +18,7 @@ from importlib.metadata import PackageNotFoundError, version
18
18
  #: Kept in lock-step with pyproject.toml/package.json by the version test.
19
19
  #: release-please only rewrites a semver found on the annotated line itself, so
20
20
  #: the annotation must stay on the assignment; on its own line it is a no-op.
21
- _FALLBACK_VERSION = "0.23.0" # x-release-please-version
21
+ _FALLBACK_VERSION = "0.24.0" # x-release-please-version
22
22
 
23
23
  try:
24
24
  __version__: str = version("openhands-extensions")
@@ -12,9 +12,10 @@ Import skills from GitHub repositories into the current workspace.
12
12
  When a user requests to add a skill from a GitHub URL:
13
13
 
14
14
  1. **Parse the URL** to extract repository owner, name, and skill path
15
- 2. **Fetch the skill** using the bundled script:
15
+ 2. **Fetch the skill** using the bundled script. Run it from this skill's
16
+ directory (the directory containing this SKILL.md file):
16
17
  ```bash
17
- python3 <this-skill-path>/scripts/fetch_skill.py "<github-url>" "<workspace-path>"
18
+ python3 scripts/fetch_skill.py "<github-url>" "<workspace-path>"
18
19
  ```
19
20
  3. **Verify** that SKILL.md exists in the destination
20
21
  4. **Inform the user** the skill is now available
@@ -44,6 +45,8 @@ Response: "✅ Added `codereview` to your workspace. The skill is now available.
44
45
 
45
46
  ## Notes
46
47
 
48
+ - Installs workspace-locally into `<workspace>/.agents/skills/<skill-name>/`,
49
+ not globally; that workspace is where the skill becomes available
47
50
  - Creates `.agents/skills/` directory if it doesn't exist
48
51
  - Uses `GITHUB_TOKEN` for authentication (required for private repos)
49
52
  - Warns before overwriting existing skills with the same name
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "canvas-extension-api",
3
+ "version": "1.0.0",
4
+ "description": "Build and validate Apps for Agent Canvas using the supported Canvas Extensions API v1 routed-page contract.",
5
+ "author": {
6
+ "name": "OpenHands",
7
+ "email": "contact@all-hands.dev"
8
+ },
9
+ "homepage": "https://github.com/OpenHands/extensions",
10
+ "repository": "https://github.com/OpenHands/extensions",
11
+ "license": "MIT",
12
+ "keywords": [
13
+ "agent-canvas",
14
+ "canvas-app",
15
+ "canvas-extension",
16
+ "register-page",
17
+ "app-development"
18
+ ]
19
+ }