@openhands/extensions 0.28.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/custom-codereview-guide.md +44 -1
- package/.claude-plugin/marketplace.json +7 -5
- package/.codex-plugin/marketplace.json +7 -5
- package/.github/workflows/build-vscode-app.yml +3 -3
- package/.github/workflows/check-extensions.yml +1 -1
- package/.github/workflows/tests.yml +1 -1
- package/.plugin/marketplace.json +7 -5
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +7 -2
- package/README.md +3 -3
- package/apps/vscode/README.md +8 -6
- package/apps/vscode/canvas-extension.json +11 -3
- package/apps/vscode/extension.js +60 -12
- package/apps/vscode/scripts/package.mjs +20 -74
- package/automations/bundle-index.js +9 -7
- package/automations/catalog/github-issue-to-pr/manifest.json +1 -1
- package/automations/catalog/github-issue-triage/manifest.json +4 -3
- package/automations/catalog/github-pr-reviewer/manifest.json +5 -4
- package/automations/catalog/gitlab-issue-to-mr/manifest.json +3 -3
- package/automations/catalog/jira-issue-to-bitbucket-pr/manifest.json +4 -4
- package/automations/catalog/jira-issue-to-gitlab-mr/manifest.json +5 -5
- package/automations/catalog/jira-issue-to-pr/manifest.json +1 -1
- package/integrations/catalog/atlassian-rovo.json +4 -3
- package/integrations/catalog/bitbucket.json +16 -1
- package/integrations/catalog/github.json +1 -0
- package/integrations/catalog/gitlab.json +1 -1
- package/marketplaces/openhands-extensions.json +7 -5
- package/package.json +1 -1
- package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +76 -8
- package/plugins/pr-review/README.md +1 -0
- package/plugins/pr-review/scripts/prompt.py +44 -11
- package/plugins/runtime-api-configs/SKILL.md +1 -1
- package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +224 -8
- package/pyproject.toml +1 -1
- package/python/openhands_extensions/_version.py +1 -1
- package/skills/canvas-extension-api/SKILL.md +4 -1
- package/skills/canvas-extension-api/references/backend-safety.md +3 -3
- package/skills/canvas-extension-api/references/native-design.md +250 -0
- package/skills/canvas-extension-api/references/v1-contract.md +7 -4
- package/skills/canvas-extension-api/scripts/validate-extension.mjs +17 -1
- package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +49 -0
- package/skills/github/scripts/agent_conversation.py +224 -8
- package/skills/github-issue-to-pr/scripts/agent_conversation.py +224 -8
- package/skills/github-issue-triage/README.md +73 -14
- package/skills/github-issue-triage/SKILL.md +26 -46
- package/skills/github-issue-triage/scripts/agent_conversation.py +224 -8
- package/skills/github-issue-triage/scripts/triage_publication.py +293 -0
- package/skills/github-issue-triage/scripts/worker.py +153 -103
- package/skills/github-pr-reviewer/README.md +103 -1
- package/skills/github-pr-reviewer/SKILL.md +35 -8
- package/skills/github-pr-reviewer/references/state-schema.md +8 -0
- package/skills/github-pr-reviewer/scripts/agent_conversation.py +224 -8
- package/skills/github-pr-reviewer/scripts/main.py +163 -39
- package/skills/github-pr-reviewer/scripts/publication.py +193 -0
- package/skills/github-pr-reviewer/scripts/worker.py +297 -8
- package/skills/github-pr-reviewer/tests/test_main.py +73 -5
- package/skills/gitlab-issue-to-mr/README.md +10 -3
- package/skills/gitlab-issue-to-mr/SKILL.md +43 -9
- package/skills/gitlab-issue-to-mr/references/state-schema.md +4 -0
- package/skills/gitlab-issue-to-mr/scripts/main.py +359 -25
- package/skills/index.js +10 -10
- package/skills/jira-issue-to-pr/.claude-plugin/plugin.json +4 -1
- package/skills/jira-issue-to-pr/.codex-plugin/plugin.json +4 -1
- package/skills/jira-issue-to-pr/.plugin/plugin.json +4 -1
- package/skills/jira-issue-to-pr/README.md +18 -11
- package/skills/jira-issue-to-pr/SKILL.md +72 -23
- package/skills/jira-issue-to-pr/references/setup.md +43 -12
- package/skills/jira-issue-to-pr/scripts/main.py +136 -26
- package/skills/openhands-automation/references/custom-automation.md +76 -8
- package/skills/slack-channel-monitor/README.md +6 -1
- package/skills/slack-channel-monitor/SKILL.md +6 -3
- package/skills/slack-channel-monitor/scripts/main.py +36 -4
- package/skills/ticket-to-code-change/.claude-plugin/plugin.json +1 -2
- package/skills/ticket-to-code-change/.codex-plugin/plugin.json +1 -2
- package/skills/ticket-to-code-change/.plugin/plugin.json +1 -2
- package/skills/ticket-to-code-change/SKILL.md +5 -3
- package/skills/ticket-to-code-change/commands/ticket-to-code-change-setup.md +1 -1
- package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
- package/tests/test_agent_conversation_dispatch.py +173 -6
- package/tests/test_automation_llm_provenance.py +1209 -0
- package/tests/test_github_review_publication.py +422 -0
- package/tests/test_github_reviewer_delivery.py +174 -21
- package/tests/test_github_triage_delivery.py +411 -238
- package/tests/test_gitlab_issue_to_mr.py +284 -0
- package/tests/test_jira_issue_to_pr.py +200 -6
- package/tests/test_pr_review_prompt_injection.py +353 -0
- package/tests/test_slack_channel_monitor.py +32 -1
- package/tests/test_vscode_app.py +8 -2
- package/tests/vscode-app.test.mjs +8 -2
package/skills/index.js
CHANGED
|
@@ -103,9 +103,9 @@ export const SKILLS_CATALOG = [
|
|
|
103
103
|
},
|
|
104
104
|
{
|
|
105
105
|
"name": "canvas-extension-api",
|
|
106
|
-
"description": "This skill should be used when the user asks to \"create an OpenHands App\", \"scaffold a Canvas App\", \"build an app with the Canvas Extensions API\", \"build an Agent Canvas App\", \"bundle a single-file Canvas App\", \"build a Sidecar-backed Canvas App\", \"add onboarding for an App service\", \"add a custom interface to Agent Canvas\", \"validate an OpenHands App\", or mentions Canvas Apps, Agent Canvas extensions, Blob-importable app bundles, registerPage, app pages, Sidecars, or canvas extension packages.",
|
|
106
|
+
"description": "This skill should be used when the user asks to \"create an OpenHands App\", \"scaffold a Canvas App\", \"build an app with the Canvas Extensions API\", \"build an Agent Canvas App\", \"bundle a single-file Canvas App\", \"build a Sidecar-backed Canvas App\", \"add onboarding for an App service\", \"add a custom interface to Agent Canvas\", \"validate an OpenHands App\", \"make a Canvas App look native\", \"match Agent Canvas styles\", \"style a canvas extension\", \"reuse OpenHands design tokens\", \"add native Tailwind classes to an app\", or mentions Canvas Apps, Agent Canvas extensions, Blob-importable app bundles, registerPage, app pages, Sidecars, or canvas extension packages.",
|
|
107
107
|
"triggers": [],
|
|
108
|
-
"content": "# Canvas Extensions API\n\nBuild and validate OpenHands Apps with the Canvas Extensions API, targeting the currently implemented manifest schema 1 and host API 1 routed-page ABI.\n\nUse the naming layers consistently:\n\n- **OpenHands Apps** - customer-facing category for interfaces people discover, install, enable, and open.\n- **Apps for Agent Canvas** - product phrasing that clarifies where apps run.\n- **Canvas Extensions API** - developer platform used to build apps.\n- **Canvas extension package** - technical artifact containing `canvas-extension.json` and its entrypoint.\n- **OpenHands Extensions** - ecosystem and repository umbrella for apps, skills, plugins, automations, and integrations.\n\nUse current product labels in user-facing instructions: **Apps for Agent Canvas**, **Add app**, **Installed apps**, **App source**, **Enable trusted app**, and **Build an app**. Keep technical identifiers such as `canvas-extension.json`, `/api/canvas-extensions/*`, `host.extension`, and `/extensions/*` unchanged unless the target implementation changes them.\n\nTreat apps as trusted, same-realm browser code owned by the active Agent Server. Keep the categories distinct: apps extend what people can do in Agent Canvas, plugins extend agent runtime capabilities, and skills provide agent instructions and knowledge.\n\n\n## Prefer the proven App authoring loop\n\nTreat an App as one authenticated browser dependency graph that Canvas imports from a Blob URL. Do not build a hosted SPA: the production result must be exactly one self-contained browser ESM entrypoint that exports `activate(host)`. Always ship this UI package even when it uses an optional package-owned managed backend or a manual/deployment-owned Sidecar. Make the App page the guided UI for service setup after the App is opened. Never treat a service as another browser chunk or an automatic extension install hook.\n\nFor a new App, create an independent package in the target repository and implement its own UI, tests, and build tooling. Follow the Vite library-build reference in `references/packaging-recipes.md`; do not copy a shared starter or introduce a repository-wide runtime/workspace unless the target repository explicitly requires it.\n\nKeep all source, scripts, dependencies, tests, and checked-in `extension.js` inside the App package. Implement one declared page with explicit root, nested, and unknown-route behavior. Inject scoped CSS per mount and clean it up with framework roots, requests, listeners, timers, Workers, and object URLs. Build validation must verify `dist/extension.js` before synchronizing it to the App root. Do not synchronize an unverified artifact. Use `CHROME_PATH` when the Chrome executable is elsewhere.\n\nFor an existing App, run the reusable static gate before and after its own checks:\n\n```sh\nnode /path/to/canvas-extension-api/scripts/validate-extension.mjs /path/to/app\nnode /path/to/canvas-extension-api/scripts/validate-extension.mjs /path/to/app --dist --marker <required-feature-marker>\n```\n\nTreat the static validator as a gate, not a replacement for the browser Blob smoke test. Read `references/packaging-recipes.md` before adding CSS, raw assets, dynamic modules, Workers, or WASM. Read `references/backend-safety.md` before any Agent Server integration or persistence. Read `references/sidecar-pattern.md` before designing a separately installed service or its onboarding. Read `references/acceptance-checklist.md` before reporting local Canvas compatibility.\n\n## Establish the target\n\nStart by locating the target directory instead of assuming the current workspace. Inspect repository instructions, existing package management, build tooling, tests, and git status before editing.\n\nMake an early architecture decision: browser-only App, Agent Server-integrated App, package-owned managed-backend App, manual/deployment-owned Sidecar App, or explicitly deployment-specific App. Keep the portable host API 1 surface separate from optional Agent Server/host capabilities and deployment-owned behavior.\n\nUse the package-owned managed-backend path only when the target stack includes the lifecycle contract from [software-agent-sdk PR #5270](https://github.com/OpenHands/software-agent-sdk/pull/5270) at `ac7b322ddf7d2e0e90a3643c5e26d848236af9a3`, the bridge contract from [PR #5272](https://github.com/OpenHands/software-agent-sdk/pull/5272) at `6d9c82973e765cce4843d6a2fb52f634efa64293`, and the host version named in `references/v1-contract.md`. Until a release containing both SDK PRs lands, those exact revisions are the minimum Agent Server implementation. Feature-detect `canvas_app_backend_bridge_v1` and `app_backend_ingress_url` through `GET /server_info`; absence is an actionable unsupported state. Do not convert manual Sidecars into managed backends unless the App owns checksum-pinned artifacts for every supported platform.\n\nClarify only choices that materially affect implementation:\n\n- app purpose and page behavior;\n- target repository and subdirectory;\n- dependency-free JavaScript versus a bundled TypeScript/framework project;\n- Agent Server endpoints or host metadata required;\n- whether to build only, install locally, or prepare publication instructions;\n- for a Sidecar: execution location, owner, connection route, secrets and data, lifecycle actions, and portability across Agent Canvas deployments.\n\nDefault to one app with one routed page when requirements are otherwise clear. Keep the first implementation small and dependency-free unless the requested UI clearly benefits from a framework or the repository already has a bundler.\n\nWhen creating multiple Apps in one repository, give every App an independent package root containing its own `canvas-extension.json`, entrypoint, version, tests, README, and build tooling. Do not introduce a monorepo, shared runtime, or common build foundation unless the target repository explicitly requires one. Keep app manifest names globally distinct within the Agent Server installation.\n\nTreat installation as one app per request. The current Customize -> Apps flow accepts one `source`, optional `ref`, and optional `repo_path`; it does not recursively discover or bulk-install every manifest in a repository. For a remote repository, add each app separately using the same source/ref and its own `repo_path`. For a backend-local source, select each app package directory as the source path.\n\nRead `references/v1-contract.md` before implementing unfamiliar Canvas Extensions API behavior. Read `references/connections.md` before connecting to Agent Server, the Automation service, or WebSocket endpoints. Read `references/sidecar-pattern.md` before proposing a Sidecar bridge, onboarding, or operator action. Read `references/testing-and-installation.md` before installing or testing inside Agent Canvas, especially for a multi-app repository.\n\n## Inspect current upstream behavior\n\nTreat the v1 contract as young and subject to change. Before substantial work, compare the installed or target OpenHands version with the current upstream sources when access is available:\n\n- `specs/canvas-extensions.md`\n- `src/types/canvas-extension.ts`\n- `src/components/features/canvas-extensions/canvas-extensions-runtime.tsx`\n- `src/routes/canvas-extension-page.tsx`\n- `docs/CANVAS_EXTENSIONS_TESTING.md`\n\nDo not invent planned surfaces such as conversation tabs, slots, themes, or visualizer replacement. Implement only contributions supported by the target version. Routed pages are the only implemented contribution in host API 1.\n\n## Create the app package\n\nPlace `canvas-extension.json` at the app package root. Point `entrypoint` to one self-contained browser ESM file inside that root.\n\nUse this minimal manifest shape:\n\n```json\n{\n \"schema_version\": 1,\n \"name\": \"example-dashboard\",\n \"display_name\": \"Example dashboard\",\n \"version\": \"0.1.0\",\n \"description\": \"A backend-specific project dashboard.\",\n \"entrypoint\": \"extension.js\",\n \"contributes\": {\n \"pages\": [\n {\n \"id\": \"dashboard\",\n \"title\": \"Dashboard\",\n \"path\": \"/dashboard\",\n \"nav_label\": \"Dashboard\"\n }\n ]\n }\n}\n```\n\nFor an optional package-owned managed backend, add this top-level sibling of `contributes`:\n\n```json\n\"backend\": {\n \"schema_version\": 1,\n \"artifacts\": {\n \"linux-amd64\": {\n \"path\": \"backend/linux-amd64.tar.gz\",\n \"sha256\": \"<64 lowercase hex characters>\"\n },\n \"linux-arm64\": {\n \"path\": \"backend/linux-arm64.tar.gz\",\n \"sha256\": \"<64 lowercase hex characters>\"\n }\n },\n \"argv\": [\n \"{artifact_dir}/bin/server\",\n \"--host=127.0.0.1\",\n \"--port={port}\",\n \"--data-dir={data_dir}\"\n ],\n \"health\": { \"path\": \"/health\" }\n}\n```\n\nThis declaration is optional. Omit it for browser-only Apps and manual/deployment-owned Sidecars. It never authorizes automatic preparation or startup. The server supports Linux amd64 and arm64 initially, verifies package-contained `.tar.gz` artifacts by SHA-256, and executes the structured argv without a shell. `argv[0]` must remain inside `{artifact_dir}`; allowed placeholders are `{artifact_dir}`, `{data_dir}`, and `{port}`.\n\nFollow these invariants:\n\n- Use lowercase letters, digits, and hyphens for app names and page IDs.\n- Use absolute kebab-case page paths with a leading slash.\n- Keep every manifest path within the app package root.\n- Give each page ID and path a unique value.\n- Register only page IDs declared in the manifest.\n- Keep the entrypoint free of unresolved bare imports and external chunks.\n- Bundle dependencies, CSS, and small assets into the entrypoint when using build tooling.\n\nAdd a concise `README.md` when the app is intended for reuse or publication. Document purpose, build command if any, output entrypoint, installation coordinate, and verification steps. Avoid adding explanatory change-log documents.\n\n## Implement activation and pages\n\nExport an `activate` function from the ESM entrypoint:\n\n```js\nexport function activate(host) {\n if (host.apiVersion !== \"1\") {\n throw new Error(`This app requires host API 1.`);\n }\n\n return host.registerPage(\"dashboard\", ({ container, path, navigate }) => {\n const root = document.createElement(\"section\");\n root.textContent = path ? `Nested route: ${path}` : \"Dashboard\";\n container.append(root);\n\n return () => root.remove();\n });\n}\n```\n\nUse the host contract deliberately:\n\n- Read immutable metadata from `host.extension` and `host.backend`.\n- Call `host.registerPage(id, mount)` during activation.\n- Use the mount callback's `path` as the route remainder below the declared page path.\n- Treat every nested-route navigation as a fresh mount: Canvas disposes the previous mount, clears the container, and invokes the mount callback with the new remainder. DOM and framework state do not persist across route changes; keep intentional cross-route state in the URL, backend, or activation-scoped state and clean it up on deactivation.\n- Use `navigate(absoluteCanvasPath)` for Canvas-aware routing.\n- Use `host.agentServer.request({ path, method, body, headers })` for authenticated calls to the owning Agent Server.\n- Pass only root-relative request paths beginning with one `/`; never pass absolute URLs or `//` paths.\n\nPrefer [`@openhands/typescript-client`](https://github.com/OpenHands/software-agent-sdk/tree/main/clients/typescript) for bundled TypeScript integrations whenever it covers the required Agent Server API and the necessary connection inputs are available. Prefer its typed clients, models, compatibility checks, and WebSocket lifecycle over hand-written transport code. Bundle the client into the self-contained app entrypoint; never leave a bare package import or external runtime chunk.\n\nRespect the host API 1 connection boundary:\n\n- Treat `host.agentServer.request` as the portable Agent Server HTTP path; Canvas selects the active backend and supplies authentication.\n- For a managed-backend view, require the feature-detected host helper documented in `references/v1-contract.md`. The helper owns session creation, validated iframe isolation, retry/new-tab behavior, and revocation; the App never reads the app-scoped cookie or constructs the ingress URL. The ingress must be a separate browser origin from Canvas, and the host must preserve the server-returned sandbox including `allow-same-origin`.\n- Installing, enabling, activating, or mounting an App never prepares or starts a managed backend. Show the resolved revision and declaration, obtain explicit lifecycle consent, and call prepare then start with that exact revision. An update invalidates approval.\n- Do not derive an Agent Server URL from `window.location` or extract session keys from Canvas internals.\n- Do not send Automation service requests through `host.agentServer.request` unless the backend explicitly documents an Agent Server proxy for them. Automation is a separate service with deployment-provided base URL and authentication.\n- Do not open a direct Agent Server WebSocket from a portable v1 app. The host currently exposes neither the owning server origin nor a WebSocket/auth capability. Poll through `host.agentServer.request`, add a backend-owned bridge, or feature-detect a future host subscription API.\n- Use the TypeScript client's Agent Server WebSocket support in trusted standalone or future host-enabled contexts, and stop it during disposal. Do not treat the Agent Server client as an Automation service client.\n\nReturn cleanup from every layer that creates effects:\n\n- Return the unregister function or an activation disposer from `activate`.\n- Return a mount disposer for DOM nodes, timers, listeners, observers, subscriptions, and outstanding state.\n- Make async work disposal-aware so late responses cannot mutate an unmounted page.\n- Remove injected styles when the page unmounts.\n\nAssume activation, mounting, and disposal may happen repeatedly during hot enable/disable, updates, backend switches, reconnects, and route changes.\n\n## Build a production-quality page\n\nRender within the supplied `container`; do not replace unrelated Canvas DOM. Scope CSS under an app-specific root class. Prefer Canvas CSS variables with sensible fallbacks rather than copying host implementation classes.\n\nProvide:\n\n- semantic structure and headings inside the supplied container. Canvas already owns the outer `<main aria-label={page title}>`, so do not add a nested `main` or duplicate page landmark label;\n- keyboard-operable controls and visible focus;\n- responsive behavior for narrow layouts;\n- loading, empty, stale, and error states;\n- reduced-motion handling for animation;\n- text-safe rendering through DOM APIs or escaping rather than untrusted `innerHTML`;\n- clear handling for malformed or partial Agent Server responses.\n\nAvoid global event handlers, prototype changes, global CSS selectors, and ambient state unless unavoidable. Same-realm execution means these effects have full Canvas authority and cleanup is only best-effort.\n\n## Add tests\n\nTest real app code with a DOM environment and a small Canvas Extensions API host test double. Test at minimum:\n\n1. `activate` registers every declared page exactly once.\n2. The mount renders its initial state.\n3. Agent Server requests use the expected root-relative path and method.\n4. Nested navigation disposes the prior mount and remounts with the correct route remainder; test any intentionally preserved state explicitly.\n5. The mount disposer removes DOM and stops effects.\n6. The activation disposer unregisters contributions.\n7. Unsupported host API versions fail clearly when compatibility is checked.\n8. Network and malformed-response errors render safely.\n\nUse the repository's existing test infrastructure. Do not introduce a new framework when adequate tests already exist. For dependency-free fixtures, Vitest with a DOM environment matches the upstream example, but it is not part of the Canvas Extensions API runtime contract.\n\n## Validate the package\n\nRun the bundled validator before reporting completion:\n\n```sh\nnode /path/to/canvas-extension-api/scripts/validate-extension.mjs /path/to/app-package\n```\n\nThen run the target repository's formatter, linter, tests, and build. Inspect the final entrypoint rather than assuming the bundler configuration worked:\n\n- confirm the file exists at the manifest path;\n- confirm it exports `activate`;\n- reject bare imports and dynamic external chunks;\n- confirm CSS and required small assets are bundled or embedded;\n- confirm the output runs as browser ESM without Node globals.\n\nTreat validator warnings as prompts for inspection, not proof of invalidity. The helper uses conservative static checks and cannot replace loading the app bundle in Canvas.\n\n## Install and verify safely\n\nInstall only when requested. Installation and enablement are separate product actions: installation must leave the app disabled, and enabling executes trusted same-realm code. Managed-backend preparation and startup are additional explicit actions tied to the resolved revision; installation, enablement, activation, and ordinary page mounts never execute backend code.\n\nFor a backend-local app, install the path as interpreted on the Agent Server machine. For a Git-hosted app, provide `source`, optional `ref`, and optional `repo_path`. Never assume a frontend-local path exists inside a remote or containerized backend.\n\nFor multiple apps in one repository, produce an install matrix listing app name, manifest directory, source, ref, and `repo_path`. Submit one Add app operation per row. For remote repositories, omit `repo_path` only when the selected app lives at the repository root. For backend-local sources, make `source` the selected app package directory. Do not claim that selecting a repository root installs nested apps.\n\nValidate and test every app package independently, then run shared repository checks once. Report partial failures by app name rather than treating one passing app package as validation of the entire repository.\n\nVerify the lifecycle in Canvas:\n\n1. Install and confirm the inventory entry is disabled.\n2. Review source, revision, manifest metadata, and contributions.\n3. Enable and accept the trusted-code disclosure: “This app runs trusted JavaScript inside Agent Canvas and can make authenticated requests to the active Agent Server. Review its source and revision before enabling it.”\n4. Open the navigation item and exercise root and nested routes.\n5. Disable and confirm navigation and mounted UI disappear.\n6. Re-enable without restarting Canvas.\n7. Switch backend or reconnect when relevant and confirm isolation.\n8. Uninstall only when requested.\n\nDo not enable, uninstall, publish, push, or open a pull request without the user's authorization.\n\n## Report completion\n\nSummarize:\n\n- app location and contributed pages;\n- manifest schema and host API targeted;\n- build output and validation results;\n- tests run and their results;\n- installation status, explicitly stating whether it remains disabled;\n- remaining limitations tied to the current v1 ABI.\n\n## Resources\n\n- `references/v1-contract.md` - exact manifest, host API, lifecycle, routing, trust, and current limitations.\n- `references/connections.md` - Agent Server HTTP, Automation service, WebSocket, and TypeScript client connection guidance.\n- `references/testing-and-installation.md` - test strategy, manual Canvas workflow, and installation coordinates.\n- `references/packaging-recipes.md` - one-file Vite, CSS, assets, Workers, and WASM rules.\n- `references/backend-safety.md` - authenticated requests, command safety, persistence, and prerequisite onboarding.\n- `references/acceptance-checklist.md` - automated checks and the local install/enable/reload lifecycle.\n- `scripts/validate-extension.mjs` - dependency-free static artifact validator for an App package directory.",
|
|
108
|
+
"content": "# Canvas Extensions API\n\nBuild and validate OpenHands Apps with the Canvas Extensions API, targeting the currently implemented manifest schema 1 and host API 1 routed-page ABI.\n\nUse the naming layers consistently:\n\n- **OpenHands Apps** - customer-facing category for interfaces people discover, install, enable, and open.\n- **Apps for Agent Canvas** - product phrasing that clarifies where apps run.\n- **Canvas Extensions API** - developer platform used to build apps.\n- **Canvas extension package** - technical artifact containing `canvas-extension.json` and its entrypoint.\n- **OpenHands Extensions** - ecosystem and repository umbrella for apps, skills, plugins, automations, and integrations.\n\nUse current product labels in user-facing instructions: **Apps for Agent Canvas**, **Add app**, **Installed apps**, **App source**, **Enable trusted app**, and **Build an app**. Keep technical identifiers such as `canvas-extension.json`, `/api/canvas-extensions/*`, `host.extension`, and `/extensions/*` unchanged unless the target implementation changes them.\n\nTreat apps as trusted, same-realm browser code owned by the active Agent Server. Keep the categories distinct: apps extend what people can do in Agent Canvas, plugins extend agent runtime capabilities, and skills provide agent instructions and knowledge.\n\n\n## Prefer the proven App authoring loop\n\nTreat an App as one authenticated browser dependency graph that Canvas imports from a Blob URL. Do not build a hosted SPA: the production result must be exactly one self-contained browser ESM entrypoint that exports `activate(host)`. Always ship this UI package even when it uses an optional package-owned managed backend or a manual/deployment-owned Sidecar. Make the App page the guided UI for service setup after the App is opened. Never treat a service as another browser chunk or an automatic extension install hook.\n\nFor a new App, create an independent package in the target repository and implement its own UI, tests, and build tooling. Follow the Vite library-build reference in `references/packaging-recipes.md`; do not copy a shared starter or introduce a repository-wide runtime/workspace unless the target repository explicitly requires it.\n\nKeep all source, scripts, dependencies, tests, and checked-in `extension.js` inside the App package. Implement one declared page with explicit root, nested, and unknown-route behavior. Inject scoped CSS per mount and clean it up with framework roots, requests, listeners, timers, Workers, and object URLs. Build validation must verify `dist/extension.js` before synchronizing it to the App root. Do not synchronize an unverified artifact. Use `CHROME_PATH` when the Chrome executable is elsewhere.\n\nFor an existing App, run the reusable static gate before and after its own checks:\n\n```sh\nnode /path/to/canvas-extension-api/scripts/validate-extension.mjs /path/to/app\nnode /path/to/canvas-extension-api/scripts/validate-extension.mjs /path/to/app --dist --marker <required-feature-marker>\n```\n\nTreat the static validator as a gate, not a replacement for the browser Blob smoke test. Read `references/packaging-recipes.md` before adding CSS, raw assets, dynamic modules, Workers, or WASM. Read `references/backend-safety.md` before any Agent Server integration or persistence. Read `references/sidecar-pattern.md` before designing a separately installed service or its onboarding. Read `references/acceptance-checklist.md` before reporting local Canvas compatibility.\n\n## Establish the target\n\nStart by locating the target directory instead of assuming the current workspace. Inspect repository instructions, existing package management, build tooling, tests, and git status before editing.\n\nMake an early architecture decision: browser-only App, Agent Server-integrated App, package-owned managed-backend App, manual/deployment-owned Sidecar App, or explicitly deployment-specific App. Keep the portable host API 1 surface separate from optional Agent Server/host capabilities and deployment-owned behavior.\n\nUse the package-owned managed-backend path only when the target stack includes the lifecycle contract from [software-agent-sdk PR #5270](https://github.com/OpenHands/software-agent-sdk/pull/5270) at `ac7b322ddf7d2e0e90a3643c5e26d848236af9a3`, the bridge contract from [PR #5272](https://github.com/OpenHands/software-agent-sdk/pull/5272) at `6d9c82973e765cce4843d6a2fb52f634efa64293`, and the host version named in `references/v1-contract.md`. Until a release containing both SDK PRs lands, those exact revisions are the minimum Agent Server implementation. Feature-detect `canvas_app_backend_bridge_v1` and `app_backend_ingress_url` through `GET /server_info`; absence is an actionable unsupported state. Do not convert manual Sidecars into managed backends unless the App owns checksum-pinned artifacts for every supported platform.\n\nClarify only choices that materially affect implementation:\n\n- app purpose and page behavior;\n- target repository and subdirectory;\n- dependency-free JavaScript versus a bundled TypeScript/framework project;\n- Agent Server endpoints or host metadata required;\n- whether to build only, install locally, or prepare publication instructions;\n- for a Sidecar: execution location, owner, connection route, secrets and data, lifecycle actions, and portability across Agent Canvas deployments.\n\nDefault to one app with one routed page when requirements are otherwise clear. Keep the first implementation small and dependency-free unless the requested UI clearly benefits from a framework or the repository already has a bundler.\n\nWhen creating multiple Apps in one repository, give every App an independent package root containing its own `canvas-extension.json`, entrypoint, version, tests, README, and build tooling. Do not introduce a monorepo, shared runtime, or common build foundation unless the target repository explicitly requires one. Keep app manifest names globally distinct within the Agent Server installation.\n\nTreat installation as one app per request. The current Customize -> Apps flow accepts one `source`, optional `ref`, and optional `repo_path`; it does not recursively discover or bulk-install every manifest in a repository. For a remote repository, add each app separately using the same source/ref and its own `repo_path`. For a backend-local source, select each app package directory as the source path.\n\nRead `references/v1-contract.md` before implementing unfamiliar Canvas Extensions API behavior. Read `references/connections.md` before connecting to Agent Server, the Automation service, or WebSocket endpoints. Read `references/sidecar-pattern.md` before proposing a Sidecar bridge, onboarding, or operator action. Read `references/testing-and-installation.md` before installing or testing inside Agent Canvas, especially for a multi-app repository.\n\n## Inspect current upstream behavior\n\nTreat the v1 contract as young and subject to change. Before substantial work, compare the installed or target OpenHands version with the current upstream sources when access is available:\n\n- `specs/canvas-extensions.md`\n- `src/types/canvas-extension.ts`\n- `src/components/features/canvas-extensions/canvas-extensions-runtime.tsx`\n- `src/routes/canvas-extension-page.tsx`\n- `docs/CANVAS_EXTENSIONS_TESTING.md`\n\nDo not invent planned surfaces such as conversation tabs, slots, themes, or visualizer replacement. Implement only contributions supported by the target version. Routed pages are the only implemented contribution in host API 1.\n\n## Create the app package\n\nPlace `canvas-extension.json` at the app package root. Point `entrypoint` to one self-contained browser ESM file inside that root.\n\nUse this minimal manifest shape:\n\n```json\n{\n \"schema_version\": 1,\n \"name\": \"example-dashboard\",\n \"display_name\": \"Example dashboard\",\n \"version\": \"0.1.0\",\n \"description\": \"A backend-specific project dashboard.\",\n \"entrypoint\": \"extension.js\",\n \"contributes\": {\n \"pages\": [\n {\n \"id\": \"dashboard\",\n \"title\": \"Dashboard\",\n \"path\": \"/dashboard\",\n \"nav_label\": \"Dashboard\"\n }\n ]\n }\n}\n```\n\nFor an optional package-owned managed backend, add this top-level sibling of `contributes`:\n\n```json\n\"backend\": {\n \"schema_version\": 1,\n \"artifacts\": {\n \"linux-amd64\": {\n \"path\": \"backend/linux-amd64.tar.gz\",\n \"sha256\": \"<64 lowercase hex characters>\"\n },\n \"linux-arm64\": {\n \"path\": \"backend/linux-arm64.tar.gz\",\n \"sha256\": \"<64 lowercase hex characters>\"\n }\n },\n \"argv\": [\n \"{artifact_dir}/bin/server\",\n \"--host=127.0.0.1\",\n \"--port={port}\",\n \"--data-dir={data_dir}\"\n ],\n \"health\": { \"path\": \"/health\" }\n}\n```\n\nThis declaration is optional. Omit it for browser-only Apps and manual/deployment-owned Sidecars. It never authorizes automatic preparation or startup. The server supports Linux amd64 and arm64 initially, verifies package-contained `.tar.gz` artifacts by SHA-256, and executes the structured argv without a shell. `argv[0]` must remain inside `{artifact_dir}`; allowed placeholders are `{artifact_dir}`, `{data_dir}`, and `{port}`.\n\nFollow these invariants:\n\n- Use lowercase letters, digits, and hyphens for app names and page IDs.\n- Use absolute kebab-case page paths with a leading slash.\n- Keep every manifest path within the app package root.\n- Give each page ID and path a unique value.\n- Register only page IDs declared in the manifest.\n- Keep the entrypoint free of unresolved bare imports and external chunks.\n- Bundle dependencies, CSS, and small assets into the entrypoint when using build tooling.\n\nAdd a concise `README.md` when the app is intended for reuse or publication. Document purpose, build command if any, output entrypoint, installation coordinate, and verification steps. Avoid adding explanatory change-log documents.\n\n## Implement activation and pages\n\nExport an `activate` function from the ESM entrypoint:\n\n```js\nexport function activate(host) {\n if (host.apiVersion !== \"1\") {\n throw new Error(`This app requires host API 1.`);\n }\n\n return host.registerPage(\"dashboard\", ({ container, path, navigate }) => {\n const root = document.createElement(\"section\");\n root.textContent = path ? `Nested route: ${path}` : \"Dashboard\";\n container.append(root);\n\n return () => root.remove();\n });\n}\n```\n\nUse the host contract deliberately:\n\n- Read immutable metadata from `host.extension` and `host.backend`.\n- Call `host.registerPage(id, mount)` during activation.\n- Use the mount callback's `path` as the route remainder below the declared page path.\n- Treat every nested-route navigation as a fresh mount: Canvas disposes the previous mount, clears the container, and invokes the mount callback with the new remainder. DOM and framework state do not persist across route changes; keep intentional cross-route state in the URL, backend, or activation-scoped state and clean it up on deactivation.\n- Use `navigate(absoluteCanvasPath)` for Canvas-aware routing.\n- Use `host.agentServer.request({ path, method, body, headers })` for authenticated calls to the owning Agent Server.\n- Pass only root-relative request paths beginning with one `/`; never pass absolute URLs or `//` paths.\n\nPrefer [`@openhands/typescript-client`](https://github.com/OpenHands/software-agent-sdk/tree/main/clients/typescript) for bundled TypeScript integrations whenever it covers the required Agent Server API and the necessary connection inputs are available. Prefer its typed clients, models, compatibility checks, and WebSocket lifecycle over hand-written transport code. Bundle the client into the self-contained app entrypoint; never leave a bare package import or external runtime chunk.\n\nRespect the host API 1 connection boundary:\n\n- Treat `host.agentServer.request` as the portable Agent Server HTTP path; Canvas selects the active backend and supplies authentication.\n- For a managed-backend view, require the feature-detected host helper documented in `references/v1-contract.md`. The helper owns session creation, validated iframe isolation, retry/new-tab behavior, and revocation; the App never reads the app-scoped cookie or constructs the ingress URL. The ingress must be a separate browser origin from Canvas, and the host must preserve the server-returned sandbox including `allow-same-origin`.\n- Installing, enabling, activating, or mounting an App never prepares or starts a managed backend. Show the resolved revision and declaration, obtain explicit lifecycle consent, and call prepare then start with that exact revision. An update invalidates approval.\n- Do not derive an Agent Server URL from `window.location` or extract session keys from Canvas internals.\n- Do not send Automation service requests through `host.agentServer.request` unless the backend explicitly documents an Agent Server proxy for them. Automation is a separate service with deployment-provided base URL and authentication.\n- Do not open a direct Agent Server WebSocket from a portable v1 app. The host currently exposes neither the owning server origin nor a WebSocket/auth capability. Poll through `host.agentServer.request`, add a backend-owned bridge, or feature-detect a future host subscription API.\n- Use the TypeScript client's Agent Server WebSocket support in trusted standalone or future host-enabled contexts, and stop it during disposal. Do not treat the Agent Server client as an Automation service client.\n\nReturn cleanup from every layer that creates effects:\n\n- Return the unregister function or an activation disposer from `activate`.\n- Return a mount disposer for DOM nodes, timers, listeners, observers, subscriptions, and outstanding state.\n- Make async work disposal-aware so late responses cannot mutate an unmounted page.\n- Remove injected styles when the page unmounts.\n\nAssume activation, mounting, and disposal may happen repeatedly during hot enable/disable, updates, backend switches, reconnects, and route changes.\n\n## Build a production-quality page\n\nRender within the supplied `container`; do not replace unrelated Canvas DOM. Scope CSS under an app-specific root class. Prefer Canvas CSS variables with sensible fallbacks rather than copying host implementation classes.\n\nRead `references/native-design.md` before styling an App page. It covers the core rule (reuse tokens, not host internals), the three implementation paths (dependency-free, Tailwind-bundled, existing CSS), the Tailwind v4 theme mapping to host variables, layout and density conventions, copy-ready component recipes, the raw scoped CSS alternative, and a mount-safe style helper. Treat it as the authoritative design reference for Apps.\n\nProvide:\n\n- semantic structure and headings inside the supplied container. Canvas already owns the outer `<main aria-label={page title}>`, so do not add a nested `main` or duplicate page landmark label;\n- keyboard-operable controls and visible focus;\n- responsive behavior for narrow layouts;\n- loading, empty, stale, and error states;\n- reduced-motion handling for animation;\n- text-safe rendering through DOM APIs or escaping rather than untrusted `innerHTML`;\n- clear handling for malformed or partial Agent Server responses.\n\nAvoid global event handlers, prototype changes, global CSS selectors, and ambient state unless unavoidable. Same-realm execution means these effects have full Canvas authority and cleanup is only best-effort.\n\n## Add tests\n\nTest real app code with a DOM environment and a small Canvas Extensions API host test double. Test at minimum:\n\n1. `activate` registers every declared page exactly once.\n2. The mount renders its initial state.\n3. Agent Server requests use the expected root-relative path and method.\n4. Nested navigation disposes the prior mount and remounts with the correct route remainder; test any intentionally preserved state explicitly.\n5. The mount disposer removes DOM and stops effects.\n6. The activation disposer unregisters contributions.\n7. Unsupported host API versions fail clearly when compatibility is checked.\n8. Network and malformed-response errors render safely.\n\nUse the repository's existing test infrastructure. Do not introduce a new framework when adequate tests already exist. For dependency-free fixtures, Vitest with a DOM environment matches the upstream example, but it is not part of the Canvas Extensions API runtime contract.\n\n## Validate the package\n\nRun the bundled validator before reporting completion:\n\n```sh\nnode /path/to/canvas-extension-api/scripts/validate-extension.mjs /path/to/app-package\n```\n\nThen run the target repository's formatter, linter, tests, and build. Inspect the final entrypoint rather than assuming the bundler configuration worked:\n\n- confirm the file exists at the manifest path;\n- confirm it exports `activate`;\n- reject bare imports and dynamic external chunks;\n- confirm CSS and required small assets are bundled or embedded;\n- confirm the output runs as browser ESM without Node globals.\n\nTreat validator warnings as prompts for inspection, not proof of invalidity. The helper uses conservative static checks and cannot replace loading the app bundle in Canvas.\n\n## Install and verify safely\n\nInstall only when requested. Installation and enablement are separate product actions: installation must leave the app disabled, and enabling executes trusted same-realm code. Managed-backend preparation and startup are additional explicit actions tied to the resolved revision; installation, enablement, activation, and ordinary page mounts never execute backend code.\n\nFor a backend-local app, install the path as interpreted on the Agent Server machine. For a Git-hosted app, provide `source`, optional `ref`, and optional `repo_path`. Never assume a frontend-local path exists inside a remote or containerized backend.\n\nFor multiple apps in one repository, produce an install matrix listing app name, manifest directory, source, ref, and `repo_path`. Submit one Add app operation per row. For remote repositories, omit `repo_path` only when the selected app lives at the repository root. For backend-local sources, make `source` the selected app package directory. Do not claim that selecting a repository root installs nested apps.\n\nValidate and test every app package independently, then run shared repository checks once. Report partial failures by app name rather than treating one passing app package as validation of the entire repository.\n\nVerify the lifecycle in Canvas:\n\n1. Install and confirm the inventory entry is disabled.\n2. Review source, revision, manifest metadata, and contributions.\n3. Enable and accept the trusted-code disclosure: “This app runs trusted JavaScript inside Agent Canvas and can make authenticated requests to the active Agent Server. Review its source and revision before enabling it.”\n4. Open the navigation item and exercise root and nested routes.\n5. Disable and confirm navigation and mounted UI disappear.\n6. Re-enable without restarting Canvas.\n7. Switch backend or reconnect when relevant and confirm isolation.\n8. Uninstall only when requested.\n\nDo not enable, uninstall, publish, push, or open a pull request without the user's authorization.\n\n## Report completion\n\nSummarize:\n\n- app location and contributed pages;\n- manifest schema and host API targeted;\n- build output and validation results;\n- tests run and their results;\n- installation status, explicitly stating whether it remains disabled;\n- remaining limitations tied to the current v1 ABI.\n\n## Resources\n\n- `references/v1-contract.md` - exact manifest, host API, lifecycle, routing, trust, and current limitations.\n- `references/connections.md` - Agent Server HTTP, Automation service, WebSocket, and TypeScript client connection guidance.\n- `references/testing-and-installation.md` - test strategy, manual Canvas workflow, and installation coordinates.\n- `references/packaging-recipes.md` - one-file Vite, CSS, assets, Workers, and WASM rules.\n- `references/backend-safety.md` - authenticated requests, command safety, persistence, and prerequisite onboarding.\n- `references/native-design.md` - native Agent Canvas styling: token reuse, Tailwind mapping, layout, component recipes, scoped CSS, and style injection.\n- `references/acceptance-checklist.md` - automated checks and the local install/enable/reload lifecycle.\n- `scripts/validate-extension.mjs` - dependency-free static artifact validator for an App package directory.",
|
|
109
109
|
"category": "agent-authoring",
|
|
110
110
|
"defaultEnabled": true
|
|
111
111
|
},
|
|
@@ -265,7 +265,7 @@ export const SKILLS_CATALOG = [
|
|
|
265
265
|
"triggers": [
|
|
266
266
|
"/github-issue-triage"
|
|
267
267
|
],
|
|
268
|
-
"content": "# GitHub issue triage\n\nPrioritize open issues and establish acceptance criteria before marking them ready for development.\n\
|
|
268
|
+
"content": "# GitHub issue triage\n\nPrioritize open issues and establish acceptance criteria before marking them ready for development.\n\n## Setup\n\nKeep triage separate from implementation and review. Follow [README.md](README.md)\nfor packaging, credentials, publisher setup, and limits. Never put token values\nin the automation definition or prompt. Legacy agent publication is the default.\n\n## Triage instructions\n\n- Honor explicit dependencies. Read applicable root/local `AGENTS.md`,\n `.agents/skills/custom-codereview-guide.md` if present, human discussion, and\n adjacent code. Identify affected versions and the owning component (Canvas,\n SDK, automation, or extensions).\n- Follow current human decisions over stale generated criteria. Do not repeat\n answered questions. Keep scope bounded, name non-goals, and ask only unresolved\n material product/design questions.\n- Define observable completion, not entry requirements. Do not require tests for\n unwritten code, PR artifacts, or images/videos for every issue. Default priority\n to low; medium/high need recorded real user pain, not speculation or synthetic\n tests.\n- Return structured recommendations. If a publisher is configured, use it; never\n bypass it or its changed-input checks. Legacy direct mode follows the same rules\n without enforcement.\n- Preserve human text and unrelated labels. Never run repository checkers with\n credentials. Use current repository policy audited by the deployment owner, not\n historical checker comments, to decide readiness.\n- Do not implement code or accept pull requests.",
|
|
269
269
|
"category": "automations"
|
|
270
270
|
},
|
|
271
271
|
{
|
|
@@ -283,7 +283,7 @@ export const SKILLS_CATALOG = [
|
|
|
283
283
|
"triggers": [
|
|
284
284
|
"/pr-reviewer:setup"
|
|
285
285
|
],
|
|
286
|
-
"content": "# GitHub PR Reviewer Automation\n\n## Agent Canvas catalog\n\nFor new Agent Canvas installations, use the **GitHub code review** catalog\nentry. Its deterministic `worker.py` delegates each requested exact head to a\nstable conversation using the selected agent profile. It supports GitHub\nreviewer-request events and scheduled label scans. The manual upload flow below\nremains for existing deployments and is deprecated for new installations.\n\nCreate a cron automation that watches one or more GitHub repositories for pull\nrequests with a review trigger label, starts an OpenHands review conversation\nonce per label event, and publishes the AI review to GitHub.\nWindows PowerShell equivalents for the setup, packaging, upload, and API-check shell snippets are in `references/windows.md`.\n\nThe automation script is deterministic: PR discovery, label-event tracking,\nhead eligibility, state persistence, stale-result suppression, the repository\ncheckout, and its removal are all handled in Python. The LLM is invoked only for\nthe review itself.\n\nBefore any review conversation is created, a scheduled scan evaluates the\ncurrent head's GitHub-required checks, read through the GraphQL `isRequired`\nsignal for the pull request. That signal is the merge policy's own source of\ntruth and is pull-request-scoped, so it stays correct for a stacked PR whose\nsymbolic base branch carries no branch rules of its own:\n\n- Only checks GitHub reports as required decide the scheduled gate. An optional\n workflow that fails - including one that fails before creating any check run -\n does not block a head whose required checks pass.\n- A completed required run on the exact head whose conclusion is `failure`,\n `cancelled`, or `timed_out` blocks the review. Any other unrecognized\n conclusion fails closed as a block rather than silently approving.\n- A completed required run whose conclusion is `success`, `neutral`, or\n `skipped` does not block. A required `commit status` context is classified\n alongside required check runs.\n- A `queued` or `in_progress` required run on the exact head, and a required\n context that has not reported a current-head check run at all, make the run\n exit with a **waiting on checks** outcome. Unfulfilled is never approval. The\n worker does not hold a slot polling; the next scheduled scan or explicit\n review request retries.\n- When the required-check signal cannot be read or is empty, the gate falls back\n to every current-head check run and workflow run, so a red head still blocks.\n In that fallback, workflow runs are read as well as check runs, because a\n workflow can fail before creating any check run - a workflow-level error, or a\n `pull_request` run whose jobs never start. Such a run leaves a failed check\n suite with no check runs under it, so the commit's check-run rollup and\n `gh pr checks` both report success and only the workflow run reveals the red\n CI. A workflow run whose check suite already reported check runs is left to\n those runs, so a workflow is never counted twice.\n- A scheduled scan considers the trigger label **and** every open PR, including drafts,\n that still holds an outstanding `all-hands-bot` review request. That is what\n resumes a request made while CI was running: the request is keyed by its own\n `review_requested` event, so repeated scans reuse one conversation and one\n review instead of creating duplicates, and a PR whose request was answered or\n withdrawn simply drops out of `requested_reviewers`.\n- The scheduled scan also reviews open, non-draft PRs that **nobody requested**\n and that carry no trigger label, once their current head has no completed\n review by this account. That is what keeps reviewing PRs after the outstanding\n requests are exhausted. The delivery key is the stable\n `scan:{repository}:{number}:{head}`, so repeated scans reuse one conversation\n and one native review, and a changed head becomes eligible again under its new\n SHA.\n- Each scheduled scan classifies the **whole open backlog**, including\n unrequested PRs. Blocked, pending, draft, or already-reviewed heads therefore\n cannot hide an eligible head behind an inspection window. The separate\n `max_new_per_run` quota limits only newly created review conversations, not the\n number of PRs inspected; explicit requests and trigger labels retain priority\n when eligible candidates are drained.\n- A gate stop on an **unrequested** head posts no managed comment. A PR nobody\n asked about that is merely red or pending is simply skipped without starting an\n agent, which is what keeps a scan over a large backlog from posting a gate\n comment on every PR. The managed comment is the answer to an explicit request,\n so a requested or labeled head that is red or pending still gets its\n explanation.\n- Workflow runs are read as well as check runs, because a workflow can fail\n before creating any check run - a workflow-level error, or a `pull_request`\n run whose jobs never start. Such a run leaves a failed check suite with no\n check runs under it, so the commit's check-run rollup and `gh pr checks` both\n report success and only the workflow run reveals the red CI. A workflow run\n whose check suite already reported check runs is left to those runs, so a\n workflow is never counted twice.\n- Runs attributed to any other (obsolete) head SHA are ignored, so a stale\n failure cannot block the push that fixed it. This applies to workflow runs\n too.\n- Only the latest run of each logical check or workflow counts. A logical check\n is its name plus the reporting app identity, and a logical workflow is its\n name plus workflow ID. The latest is chosen by the run ID (the reliable\n creation sequence) with the start time as a tie-break, so a re-run that fixed\n a check supersedes its earlier failure on the same SHA while a newer queued or\n in-progress re-run supersedes an earlier success and makes the head wait.\n Ordering by the run ID keeps a run whose start time is still absent in its\n true creation position in both directions.\n\nAn explicit `all-hands-bot` review request is the intake-policy exception: the\ncaller asked for that head by name, so the request dispatches even when required\nCI is red or pending, and the gate leaves no explanatory comment. Draft, scope,\nexact-head, and delivery-deduplication safeguards still apply. A blocked or\nwaiting scheduled stop consumes no trigger: once the head's required checks are\nnon-blocking, the scheduled scan, or a new `all-hands-bot` review request,\nstarts the normal review.\n\nThe gate needs no configured list of check names. When it stops a review it\nleaves one concise explanation on the PR, identified by a hidden marker carrying\nthe head SHA and gate category, so a later run for a different head updates that\ncomment instead of posting another. Only a marker this reviewer account authored\ncounts as its own comment; every other PR comment is untrusted and can neither\nsuppress the explanation nor be edited. If the repository's own workflow already\nposted a deterministic remediation comment that names the same current-head\nchecks, the gate adds nothing; a disclosure about some other check does not\nsuppress it. The gate applies to scheduled label scans; an explicit\n`all-hands-bot` review request bypasses it by design.\n\nEach explicit review request - a re-applied trigger label, or a new reviewer\nrequest in event mode - is a fresh review. The conversation for a PR is reused,\nbut its earlier turns must not be trusted as current: before deciding a verdict\nthe reviewer re-fetches the mutable GitHub state (the exact head, the PR body,\nreview comments and threads, review requests, the linked issues' bodies and\nlabels, and the current-head Actions results) and ignores any earlier finding,\nverdict, or label/priority claim that state no longer supports. Repository\nanalysis the conversation already did, such as reading `AGENTS.md`, stays\nuseful and is not repeated.\n\nThe waiting and blocked explanations name the retry the deployment actually has.\nA scheduled run proves a scan is configured, so it says the next scan will\nretry. An event-only run does not, so it tells the reader to remove the\noutstanding `all-hands-bot` request and request `all-hands-bot` again instead of\npromising a scan that does not exist: GitHub will not accept a second request\nwhile the first is still outstanding.\n\nThe gate leaves only one explanation per head. When the same head keeps the same\nhidden marker but the retry wording changes -- an event-only comment on a head\nwhose automation is later switched to a cron scan -- the scheduled run rewrites\nthat managed comment in place, so the comment always names the retry that is\nactually deployed, and an unchanged body is left untouched.\n\n## Bounded intake per scheduled scan\n\nA scheduled scan drains outstanding reviewer requests fairly, but starting an\nagent for every eligible pull request at once would overload the deployment, so\nthe scan starts at most `MAX_NEW_PER_RUN` new review conversations, counted\n**across every configured repository** rather than per repository. The default\nis `2`, and the rendered `config.json` overrides it through the\n`max_new_per_run` key that `github-issue-to-pr` and `gitlab-issue-to-mr` already\nuse.\n\n- Eligible candidates are ordered deterministically: an explicit `all-hands-bot`\n request (or a trigger label) is ordered before an unrequested PR, then by the\n oldest `all-hands-bot` `review_requested` event for a requested PR or the PR's\n own creation time (oldest first) for an unrequested one, then by repository,\n then by pull-request number, so the oldest work drains first and a later scan\n reaches the remainder.\n- The bound counts the conversations a scan **starts**. A delivery that only\n deduplicates or reports an already-running conversation reuses a runtime and\n consumes no slot, so repeated scans make progress on the backlog instead of\n re-spending the bound on work already in flight.\n- Reaching the bound never cuts the scan short. The scan still evaluates the\n exact-head checks of the remaining candidates, reconciles completed reviews,\n and runs the maintainer handoff. A candidate whose checks are pending or\n failing starts no conversation and consumes no slot, so it cannot block a later\n green candidate. Only an explicit request or a trigger label gets a waiting or\n blocked gate comment; an unrequested head that is merely red or pending is\n skipped silently.\n- A dispatch that raises is reported and does not consume a slot or abort the\n scan, so the candidates behind it are still considered.\n- The explicit `review_requested` event path and the trigger-label scan are\n unchanged: an explicit request still starts its conversation immediately, and\n only the scheduled scan's new conversations are bounded.\n\nThe review prompt starts with a scope gate: using the repository's own guidance\n(its scope categories and ownership boundaries, not a list of individual PR\nnumbers), the reviewer decides whether the change belongs in this repository\nand has the product/architecture direction it needs. When it does not, the review\nstops with a single `event: COMMENT` review that says whether the change should\nmove repositories, close, or receive a maintainer decision, and ends with the\n`🛑 MAINTAINER DECISION REQUIRED` verdict. That outcome is **neither an approval\nnor a change request**: it does not approve or merge the PR. The completion\nhandler recognizes the verdict and requests one configured maintainer through the\nsame handoff used after an approval. An in-scope change continues the existing\nreview unchanged.\n\nBefore approving a change to user-visible UI behavior, the prompt requires live\nevidence from a real running application when the repository's guidance demands\nit: a screenshot, screen recording, or equivalent capture of the running app\nthat exercises the production-facing path. Unit tests, CSS-token or contract\nassertions, generated mockups, and reconstructed captures may support the review\nbut cannot substitute for that evidence. When the required evidence is missing,\nthe reviewer publishes one `event: COMMENT` review naming exactly what is\nmissing and ends with `🔄 CHANGES REQUESTED`, so approval is withheld and the\ndeterministic maintainer handoff does not fire. Non-UI changes keep the existing\nrequirement of the real command and its observed output, with tests insufficient\nas sole proof.\n\nThe script prepares each review's workspace before the agent starts: the pull\nrequest's head commit is downloaded as a tarball and extracted to a directory of\nits own, which becomes the conversation's working directory. The agent is told\nnot to clone, fetch, check out, or delete anything, and the script removes the\ncheckout once the conversation has stopped. Nothing accumulates between runs.\n\n---\n\nThe script imports shared GitHub transport from\n`scripts/github_client.py`, installed with this skill. Include it beside\n`main.py` when packaging manually, as shown below; catalog bundles include it\nautomatically.\n\n## Prerequisites\n\n### Required secret\n\nVerify that the following secret is set in **OpenHands Settings -> Secrets**:\n\n| Secret name | Token type | Minimum permissions |\n|---|---|---|\n| `GITHUB_PERSONAL_ACCESS_TOKEN` | Classic PAT | `repo` for private repos or `public_repo` for public repos |\n| `GITHUB_PERSONAL_ACCESS_TOKEN` | Fine-grained PAT | Contents: Read, Metadata: Read, Pull requests: **Read and Write**, Issues: Read and Write |\n\nPull-request **write** access is required because the agent publishes a pull\nrequest review, not just an issue comment. The Agent Canvas catalog worker may\nalso request a configured human reviewer after approval. A token with only Pull\nrequests: Read will poll happily and then fail at the point of publishing or\nrequesting the handoff.\n\nWhen several repositories are monitored, the token must cover all of them.\n\nCheck with:\n```bash\ncurl -s https://api.github.com/user \\\n -H \"Authorization: Bearer $GITHUB_PERSONAL_ACCESS_TOKEN\" \\\n | python3 -c \"import json,sys; d=json.load(sys.stdin); print(d.get('login') or d.get('message'))\"\n```\n\nIf the token is missing or invalid, inform the user and stop.\n\n---\n\n## Setup Workflow\n\nFollow these steps in order.\n\n### Step 1 - Verify `GITHUB_PERSONAL_ACCESS_TOKEN`\n\nRun the `curl` check above.\n\n- If absent: *\"GITHUB_PERSONAL_ACCESS_TOKEN is not set. Please add it in\n OpenHands Settings -> Secrets.\"* Stop.\n- If the API returns `{\"message\": \"Bad credentials\"}`: tell the user the\n token is invalid and ask them to update it. Stop.\n\n### Step 2 - Collect repositories\n\nAsk: *\"Which GitHub repositories should be monitored?\n(Format: `owner/repo`, e.g. `myorg/backend`. List several separated by commas to\nreview them all from one automation.)\"*\n\nValidate access to **each** repository:\n```bash\ncurl -s \"https://api.github.com/repos/{owner}/{repo}\" \\\n -H \"Authorization: Bearer $GITHUB_PERSONAL_ACCESS_TOKEN\" \\\n | python3 -c \"\nimport json, sys\nd = json.load(sys.stdin)\nif 'message' in d:\n print('ERROR:', d['message'])\nelse:\n print(f\\\"Accessible. Private: {d.get('private')}. Permissions: {d.get('permissions')}\\\")\n\"\n```\n\nRecord every accepted repository into `REPOS = [\"{owner}/{repo}\", ...]`. If one\nrepository fails the check, say which and ask whether to continue without it.\n\nEach repository is polled independently and keeps its own state, so pull-request\nnumbers never collide between them. The trigger label, tone, and schedule are\nshared by all of them; a repository needing different settings wants its own\nautomation.\n\n### Step 3 - Collect trigger label\n\nAsk: *\"Which PR label should trigger a review?\n(Press Enter for the default: `openhands-review`.)\"*\n\nRecord the answer as `TRIGGER_LABEL`. If the label does not exist yet, tell the\nuser that GitHub will still record the event once the label is created and\napplied to a PR.\n\nThe automation reviews a PR when it sees the latest matching `labeled` event for\nthat label. To request another review later, remove and re-apply the label.\n\n### Step 4 - Collect review tone\n\nAsk: *\"What review tone should the reviewer use?\n 1. Thorough (default) - comprehensive coverage of correctness, security, tests, style\n 2. Concise - high-signal only, skips minor style feedback\n 3. Friendly - constructive and encouraging\n(Press Enter for Thorough, or type your choice or any custom style description)\"*\n\nMap the choice to `REVIEW_TONE`:\n\n| Answer | `REVIEW_TONE` | `REVIEW_STYLE_INSTRUCTIONS` |\n|---|---|---|\n| 1 / Enter | `\"thorough\"` | `\"\"` |\n| 2 | `\"concise\"` | `\"\"` |\n| 3 | `\"friendly\"` | `\"\"` |\n| Custom text, e.g. `strict but kind` | `\"thorough\"` | the custom text verbatim |\n\n### Step 5 - Collect cron schedule\n\nAsk: *\"How often should the automation poll for labeled PRs?\n(Press Enter for the default: every 5 minutes.\nUse a cron expression for a different interval, e.g. `0 * * * *` = hourly)\"*\n\nDefault: `*/5 * * * *`.\n\nRecord as `CRON_SCHEDULE`.\n\n### Step 6 - Generate the automation script\n\nRead `scripts/main.py` from this skill's directory. Apply exactly seven constant\nsubstitutions near the top of the file:\n\n> The script also reads a `config.json` shipped beside it, if there is one, over\n> these constants. That is how the catalog entry\n> (`automations/catalog/github-pr-reviewer/`) configures an unmodified copy,\n> since a declarative host cannot rewrite Python. This setup path substitutes the\n> constants and ships no `config.json`, so the two never collide.\n\n| Placeholder | Replace with |\n|---|---|\n| `REPOS = [\"owner/repo\"]` | `REPOS = [\"{owner_repo}\", ...]` - one entry per repository collected in Step 2 |\n| `TRIGGER_LABEL = \"openhands-review\"` | `TRIGGER_LABEL = \"{trigger_label}\"` |\n| `REVIEW_TONE = \"thorough\"` | `REVIEW_TONE = \"{review_tone}\"` |\n| `REVIEW_STYLE_INSTRUCTIONS = \"\"` | `REVIEW_STYLE_INSTRUCTIONS = \"{style_instructions}\"` |\n| `REPO_REVIEW_GUIDE_PATH = \".agents/skills/custom-codereview-guide.md\"` | leave unchanged to auto-load a repo review guide at this path, or set to `\"\"` to disable |\n| `MAX_NEW_PER_RUN = 2` | leave unchanged to bound a scheduled scan to two new review conversations across all repositories, or raise it if the deployment can hold more agents at once |\n| `DEFAULT_OPENHANDS_URL = \"http://localhost:8000\"` | leave unchanged unless the user has a preference |\n\nUse a safe string writer such as `json.dumps(value)` when inserting user-provided\nrepository names, labels, or style instructions into Python string literals.\n`json.dumps(list_of_repos)` produces the whole `REPOS` list safely in one step.\n\nRun these commands from this skill's directory and write the customized script\nto a temporary build directory:\n```bash\nmkdir -p /tmp/pr-reviewer-build\ncp -L scripts/github_client.py /tmp/pr-reviewer-build/github_client.py\n# write the customized main.py to /tmp/pr-reviewer-build/main.py\n```\n\nValidate syntax before packaging:\n```bash\npython3 -m py_compile /tmp/pr-reviewer-build/main.py && echo \"Syntax OK\"\n```\n\nFix any syntax errors before proceeding.\n\n### Step 7 - Package and upload\n\nDetermine the Automation backend URL and auth from the `<RUNTIME_SERVICES>`\nblock in your system context:\n- **OPENHANDS_HOST**: the Automation backend `url_from_agent`\n- **Auth**: `X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY`\n\n```bash\ntar -czf /tmp/pr-reviewer.tar.gz -C /tmp/pr-reviewer-build .\n\nTARBALL_PATH=$(curl -s -X POST \\\n \"${OPENHANDS_HOST}/api/automation/v1/uploads?name=github-pr-reviewer\" \\\n -H \"X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY\" \\\n -H \"Content-Type: application/gzip\" \\\n --data-binary @/tmp/pr-reviewer.tar.gz \\\n | python3 -c \"import json,sys; print(json.load(sys.stdin)['tarball_path'])\")\n\necho \"Uploaded: $TARBALL_PATH\"\n```\n\n### Step 8 - Register the automation\n\n```bash\ncurl -s -X POST \"${OPENHANDS_HOST}/api/automation/v1\" \\\n -H \"X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d \"{\n \\\"name\\\": \\\"GitHub PR Reviewer: {repo_summary} label {trigger_label}\\\",\n \\\"trigger\\\": {\\\"type\\\": \\\"cron\\\", \\\"schedule\\\": \\\"{cron_schedule}\\\"},\n \\\"tarball_path\\\": \\\"$TARBALL_PATH\\\",\n \\\"entrypoint\\\": \\\"python3 main.py\\\",\n \\\"timeout\\\": 600\n }\" | python3 -m json.tool\n```\n\nUse the single repository as `{repo_summary}` when there is one, and something\nlike `3 repos` when there are several. A poll now downloads a tarball per queued\nreview, so the timeout allows for that; a run never waits for a review to\nfinish, only for it to be started.\n\nRecord the returned `id`.\n\n### Step 9 - Confirm\n\nTell the user:\n\n> ✅ **GitHub PR Reviewer** is running!\n>\n> - Automation ID: `{id}`\n> - Repositories: `{owner}/{repo}`, ... (one line each)\n> - Trigger label: `{trigger_label}`\n> - Review tone: `{tone}`\n> - Polling schedule: `{cron_schedule}`\n> - State file per repository:\n> `~/.openhands/workspaces/automation-state/github_pr_reviewer_label_event_{id}_{owner}__{repo}.json`\n>\n> Apply the `{trigger_label}` label to a pull request to queue a review. Each\n> label event is processed once. To request another review, remove and re-apply\n> the label.\n>\n> The review is published as a pull request review on the head commit, with\n> inline comments where a finding maps to a changed line.\n\n---\n\n## Runtime Behaviour (per poll)\n\nEach cron run executes `main.py`, which resolves and validates\n`GITHUB_PERSONAL_ACCESS_TOKEN` once, then processes every repository in `REPOS`\nindependently. One repository failing does not stop the others; the run fails\nonly if every repository fails.\n\nFor each repository:\n\n1. Loads that repository's state (see `references/state-schema.md`).\n2. Verifies repository access.\n3. Lists open PRs, newest-updated first.\n4. For each open PR carrying `TRIGGER_LABEL`:\n - Refetches current PR metadata to avoid acting on stale list data.\n - Finds the latest matching GitHub `labeled` issue event.\n - Skips the event if it has already been tracked.\n - Downloads the PR's head commit as a tarball and extracts it to\n `{WORKSPACE_BASE}/repositories/{owner}__{repo}/pr-{number}-{sha12}`. The\n archive is checked as it is unpacked: a single root, no absolute or `..`\n paths, and symlinks skipped rather than materialised.\n - Starts an OpenHands conversation **whose working directory is that\n checkout**, with a review prompt carrying PR metadata, the exact head SHA,\n label event details, and the requirement to re-fetch the current mutable\n GitHub state before deciding a verdict.\n - Posts an acknowledgement comment with the label event, head SHA, and\n conversation link.\n - Records the review in state with `status: \"active\"` and the checkout path.\n - If the checkout or the conversation cannot be created, the checkout is\n removed and nothing is recorded, so the next poll retries the label event.\n5. For each active review conversation:\n - Marks it closed without posting if the PR has closed or merged.\n - Suppresses stale results if the PR head SHA changed after the review was\n queued.\n - When the conversation reaches `idle`, `finished`, `error`, or `stuck`,\n asks GitHub whether a review by the token's own user exists for that head\n SHA. If it does, the review is complete. If it does not, the agent's final\n response is posted as a comment so the work is not lost.\n - Abandons a conversation that has not reached a terminal status within two\n hours, so its checkout can be reclaimed.\n6. Removes the checkout of every finished review, but only after confirming the\n conversation has stopped - deleting it under a running agent would remove its\n working directory. When that cannot be confirmed the directory is left alone\n and the next poll tries again.\n7. Saves that repository's state atomically.\n\nThe completion callback fires once for the whole run.\n\n---\n\n## Additional Resources\n\n- **`references/state-schema.md`** - State JSON schema, field definitions, and\n review lifecycle diagram.\n- **`scripts/main.py`** - The complete automation script. Customize the five\n constants at the top before packaging.\n- **`tests/test_main.py`** - Unit tests for the checkout, its removal, and state\n handling. Run them from the skill root with `python -m pytest tests/` after\n editing the script.\n\n---\n\n## Troubleshooting\n\n| Symptom | Likely cause | Fix |\n|---|---|---|\n| Bot never queues reviews | Trigger label not present or no matching `labeled` event | Apply the configured label to the PR |\n| \"Bad credentials\" in run logs | Token expired | Rotate and update `GITHUB_PERSONAL_ACCESS_TOKEN` |\n| 404 on repo access | Repo name wrong or no access | Re-check the entry in `REPOS` and the token's permissions |\n| One repository is skipped, others work | That repository failed its access check | Read the `=== owner/repo ===` block in the run log |\n| Same PR not reviewed after new commits | Label event was already processed | Remove and re-apply the trigger label |\n| Review paused with a failing-check comment | A current-head required check reported `failure`, `cancelled`, or `timed_out` | Fix the named checks and push; the review starts on the new head, or request `all-hands-bot` to review immediately |\n| Review reported waiting on checks | A current-head required check is `queued` or `in_progress`, or has not reported yet | No action; a later scan or a new review request retries |\n| Optional workflow failed but no review was paused | The failed workflow is not required, so the required-only scheduled gate ignored it | No action; only GitHub-required checks gate scheduled discovery |\n| Only a few reviews start on a large backlog | The per-scan `max_new_per_run` bound (default 2) reached | No action; later scans drain the remaining oldest eligible PRs, or raise `max_new_per_run` if the deployment can hold more agents |\n| Review result never posts | Conversation still running or stuck | Open the conversation link from the acknowledgement comment |\n| Stale review suppressed | PR head SHA changed while the agent was reviewing | Re-apply the trigger label after the latest commit |\n| Review arrives as a plain comment, not a review | Publishing failed, so the script posted the text as a fallback | Check that the token has Pull requests: Read and Write |\n| Agent reports it cannot clone the repo | Prompt asked it not to; the workspace is already the checkout | No action - the code is at the head SHA in its working directory |\n| Checkouts remain under `repositories/` | Their conversations had not stopped yet | They are removed by a later poll once the conversation is terminal |",
|
|
286
|
+
"content": "# GitHub PR Reviewer Automation\n\n## Agent Canvas catalog\n\nFor new Agent Canvas installations, use the **GitHub code review** catalog\nentry. Its deterministic `worker.py` delegates each requested exact head to a\nstable conversation using the selected agent profile. It supports GitHub\nreviewer-request events and scheduled label scans. The manual upload flow below\nremains for existing deployments and is deprecated for new installations.\n\nCreate a cron automation that watches one or more GitHub repositories for pull\nrequests with a review trigger label, starts an OpenHands review conversation\nonce per label event, and publishes the AI review to GitHub.\nWindows PowerShell equivalents for the setup, packaging, upload, and API-check shell snippets are in `references/windows.md`.\n\nThe automation script is deterministic: PR discovery, label-event tracking,\nhead eligibility, state persistence, stale-result suppression, the repository\ncheckout, and its removal are all handled in Python. The LLM is invoked only for\nthe review itself.\n\nBefore any review conversation is created, a scheduled scan evaluates the\ncurrent head's GitHub-required checks, read through the GraphQL `isRequired`\nsignal for the pull request. That signal is the merge policy's own source of\ntruth and is pull-request-scoped, so it stays correct for a stacked PR whose\nsymbolic base branch carries no branch rules of its own:\n\n- Only checks GitHub reports as required decide the scheduled gate. An optional\n workflow that fails - including one that fails before creating any check run -\n does not block a head whose required checks pass.\n- A completed required run on the exact head whose conclusion is `failure`,\n `cancelled`, or `timed_out` blocks the review. Any other unrecognized\n conclusion fails closed as a block rather than silently approving.\n- A completed required run whose conclusion is `success`, `neutral`, or\n `skipped` does not block. A required `commit status` context is classified\n alongside required check runs.\n- A `queued` or `in_progress` required run on the exact head, and a required\n context that has not reported a current-head check run at all, make the run\n exit with a **waiting on checks** outcome. Unfulfilled is never approval. The\n worker does not hold a slot polling; the next scheduled scan or explicit\n review request retries.\n- When the required-check signal cannot be read or is empty, the gate falls back\n to every current-head check run and workflow run, so a red head still blocks.\n In that fallback, workflow runs are read as well as check runs, because a\n workflow can fail before creating any check run - a workflow-level error, or a\n `pull_request` run whose jobs never start. Such a run leaves a failed check\n suite with no check runs under it, so the commit's check-run rollup and\n `gh pr checks` both report success and only the workflow run reveals the red\n CI. A workflow run whose check suite already reported check runs is left to\n those runs, so a workflow is never counted twice.\n- A scheduled scan considers the trigger label **and** every open PR, including drafts,\n that still holds an outstanding `all-hands-bot` review request. That is what\n resumes a request made while CI was running: the request is keyed by its own\n `review_requested` event, so repeated scans reuse one conversation and one\n review instead of creating duplicates, and a PR whose request was answered or\n withdrawn simply drops out of `requested_reviewers`.\n- The scheduled scan also reviews open, non-draft PRs that **nobody requested**\n and that carry no trigger label, once their current head has no completed\n review by this account. That is what keeps reviewing PRs after the outstanding\n requests are exhausted. The delivery key is the stable\n `scan:{repository}:{number}:{head}`, so repeated scans reuse one conversation\n and one native review, and a changed head becomes eligible again under its new\n SHA.\n- Each scheduled scan classifies the **whole open backlog**, including\n unrequested PRs. Blocked, pending, draft, or already-reviewed heads therefore\n cannot hide an eligible head behind an inspection window. The separate\n `max_new_per_run` quota limits only newly created review conversations, not the\n number of PRs inspected; explicit requests and trigger labels retain priority\n when eligible candidates are drained.\n- A gate stop on an **unrequested** head posts no managed comment. A PR nobody\n asked about that is merely red or pending is simply skipped without starting an\n agent, which is what keeps a scan over a large backlog from posting a gate\n comment on every PR. The managed comment is the answer to an explicit request,\n so a requested or labeled head that is red or pending still gets its\n explanation.\n- Workflow runs are read as well as check runs, because a workflow can fail\n before creating any check run - a workflow-level error, or a `pull_request`\n run whose jobs never start. Such a run leaves a failed check suite with no\n check runs under it, so the commit's check-run rollup and `gh pr checks` both\n report success and only the workflow run reveals the red CI. A workflow run\n whose check suite already reported check runs is left to those runs, so a\n workflow is never counted twice.\n- Runs attributed to any other (obsolete) head SHA are ignored, so a stale\n failure cannot block the push that fixed it. This applies to workflow runs\n too.\n- Only the latest run of each logical check or workflow counts. A logical check\n is its name plus the reporting app identity, and a logical workflow is its\n name plus workflow ID. The latest is chosen by the run ID (the reliable\n creation sequence) with the start time as a tie-break, so a re-run that fixed\n a check supersedes its earlier failure on the same SHA while a newer queued or\n in-progress re-run supersedes an earlier success and makes the head wait.\n Ordering by the run ID keeps a run whose start time is still absent in its\n true creation position in both directions.\n\nAn explicit `all-hands-bot` review request is the intake-policy exception: the\ncaller asked for that head by name, so the request dispatches even when required\nCI is red or pending, and the gate leaves no explanatory comment. Draft, scope,\nexact-head, and delivery-deduplication safeguards still apply. A blocked or\nwaiting scheduled stop consumes no trigger: once the head's required checks are\nnon-blocking, the scheduled scan, or a new `all-hands-bot` review request,\nstarts the normal review.\n\nThe gate needs no configured list of check names. When it stops a review it\nleaves one concise explanation on the PR, identified by a hidden marker carrying\nthe head SHA and gate category, so a later run for a different head updates that\ncomment instead of posting another. Only a marker this reviewer account authored\ncounts as its own comment; every other PR comment is untrusted and can neither\nsuppress the explanation nor be edited. If the repository's own workflow already\nposted a deterministic remediation comment that names the same current-head\nchecks, the gate adds nothing; a disclosure about some other check does not\nsuppress it. The gate applies to scheduled label scans; an explicit\n`all-hands-bot` review request bypasses it by design.\n\nEach explicit review request - a re-applied trigger label, or a new reviewer\nrequest in event mode - is a fresh review. The conversation for a PR is reused,\nbut its earlier turns must not be trusted as current: before deciding a verdict\nthe reviewer re-fetches the mutable GitHub state (the exact head, the PR body,\nreview comments and threads, review requests, the linked issues' bodies and\nlabels, and the current-head Actions results) and ignores any earlier finding,\nverdict, or label/priority claim that state no longer supports. Repository\nanalysis the conversation already did, such as reading `AGENTS.md`, stays\nuseful and is not repeated.\n\nThe waiting and blocked explanations name the retry the deployment actually has.\nA scheduled run proves a scan is configured, so it says the next scan will\nretry. An event-only run does not, so it tells the reader to remove the\noutstanding `all-hands-bot` request and request `all-hands-bot` again instead of\npromising a scan that does not exist: GitHub will not accept a second request\nwhile the first is still outstanding.\n\nThe gate leaves only one explanation per head. When the same head keeps the same\nhidden marker but the retry wording changes -- an event-only comment on a head\nwhose automation is later switched to a cron scan -- the scheduled run rewrites\nthat managed comment in place, so the comment always names the retry that is\nactually deployed, and an unchanged body is left untouched.\n\n## Bounded intake per scheduled scan\n\nA scheduled scan drains outstanding reviewer requests fairly, but starting an\nagent for every eligible pull request at once would overload the deployment, so\nthe scan starts at most `MAX_NEW_PER_RUN` new review conversations, counted\n**across every configured repository** rather than per repository. The default\nis `2`, and the rendered `config.json` overrides it through the\n`max_new_per_run` key that `github-issue-to-pr` and `gitlab-issue-to-mr` already\nuse.\n\n- Eligible candidates are ordered deterministically: an explicit `all-hands-bot`\n request (or a trigger label) is ordered before an unrequested PR, then by the\n oldest `all-hands-bot` `review_requested` event for a requested PR or the PR's\n own creation time (oldest first) for an unrequested one, then by repository,\n then by pull-request number, so the oldest work drains first and a later scan\n reaches the remainder.\n- The bound counts the conversations a scan **starts**. A delivery that only\n deduplicates or reports an already-running conversation reuses a runtime and\n consumes no slot, so repeated scans make progress on the backlog instead of\n re-spending the bound on work already in flight.\n- Reaching the bound never cuts the scan short. The scan still evaluates the\n exact-head checks of the remaining candidates, reconciles completed reviews,\n and runs the maintainer handoff. A candidate whose checks are pending or\n failing starts no conversation and consumes no slot, so it cannot block a later\n green candidate. Only an explicit request or a trigger label gets a waiting or\n blocked gate comment; an unrequested head that is merely red or pending is\n skipped silently.\n- A dispatch that raises is reported and does not consume a slot or abort the\n scan, so the candidates behind it are still considered.\n- The explicit `review_requested` event path and the trigger-label scan are\n unchanged: an explicit request still starts its conversation immediately, and\n only the scheduled scan's new conversations are bounded.\n\nThe review prompt starts with a scope gate: using the repository's own guidance\n(its scope categories and ownership boundaries, not a list of individual PR\nnumbers), the reviewer decides whether the change belongs in this repository\nand has the product/architecture direction it needs. When it does not, the review\nstops with a single `event: COMMENT` review that says whether the change should\nmove repositories, close, or receive a maintainer decision, and ends with the\n`🛑 MAINTAINER DECISION REQUIRED` verdict. That outcome is **neither an approval\nnor a change request**: it does not approve or merge the PR. The completion\nhandler recognizes the verdict and requests one configured maintainer through the\nsame handoff used after an approval. Both paths require a successfully fetched\nsame-repository closing issue labeled `priority:medium` or `priority:high`; a\nmissing link or priority label, low/normal priority, unresolved reference, or PR\nreference does not authorize handoff. One qualifying issue is sufficient even\nwith other low-priority or missing references. Existing manual review requests\nare unchanged. An in-scope change continues the existing review unchanged.\n\nBefore approving a change to user-visible UI behavior, the prompt requires live\nevidence from a real running application when the repository's guidance demands\nit: a screenshot, screen recording, or equivalent capture of the running app\nthat exercises the production-facing path. Unit tests, CSS-token or contract\nassertions, generated mockups, and reconstructed captures may support the review\nbut cannot substitute for that evidence. When the required evidence is missing,\nthe reviewer publishes one `event: COMMENT` review naming exactly what is\nmissing and ends with `🔄 CHANGES REQUESTED`, so approval is withheld and the\ndeterministic maintainer handoff does not fire. Non-UI changes keep the existing\nrequirement of the real command and its observed output, with tests insufficient\nas sole proof.\n\nEvery review ends with the LLM profile and model of the conversation that wrote\nit, after the verdict: `LLM profile: <profile> · Model: <model>`. The agent does\nnot write this footer. The prompt gives it a hidden marker naming its own\nconversation, `<!-- openhands-review-run: <conversation-id> -->`, to copy under\nthe disclosure, and the deterministic script appends the footer to the reviews\nthat carry exactly that marker and no other. Login, head, and submission time\ncannot tell two automations that share a bot account apart, so a review without\nthe marker, with another run's marker, or with several markers is never edited.\nThe catalog worker reads the values back from the marked conversation: the\nmodel it runs, and the LLM profile its agent profile referenced at launch\n(`unknown` once that agent profile has been edited). It never substitutes the\nscanner's own settings, and a reused conversation keeps reporting the LLM it\nwas launched with. A review is stamped once, so a later agent profile edit\ncannot rewrite its footer, and a provenance failure is logged without holding\nup the verdict or the maintainer handoff. The completion handler reads the\nverdict above the footer.\n\nThe script prepares each review's workspace before the agent starts: the pull\nrequest's head commit is downloaded as a tarball and extracted to a directory of\nits own, which becomes the conversation's working directory. The agent is told\nnot to clone, fetch, check out, or delete anything, and the script removes the\ncheckout once the conversation has stopped. Nothing accumulates between runs.\n\n---\n\nThe script imports shared GitHub transport from\n`scripts/github_client.py`, installed with this skill. Include it beside\n`main.py` when packaging manually, as shown below; catalog bundles include it\nautomatically.\n\n## Prerequisites\n\n### Required secret\n\nVerify that the following secret is set in **OpenHands Settings -> Secrets**:\n\n| Secret name | Token type | Minimum permissions |\n|---|---|---|\n| `GITHUB_PERSONAL_ACCESS_TOKEN` | Classic PAT | `repo` for private repos or `public_repo` for public repos |\n| `GITHUB_PERSONAL_ACCESS_TOKEN` | Fine-grained PAT | Contents: Read, Metadata: Read, Pull requests: **Read and Write**, Issues: Read and Write |\n\nPull-request **write** access is required because the agent publishes a pull\nrequest review, not just an issue comment. The Agent Canvas catalog worker may\nalso request a configured human reviewer after approval. A token with only Pull\nrequests: Read will poll happily and then fail at the point of publishing or\nrequesting the handoff.\n\nWhen several repositories are monitored, the token must cover all of them.\n\nCheck with:\n```bash\ncurl -s https://api.github.com/user \\\n -H \"Authorization: Bearer $GITHUB_PERSONAL_ACCESS_TOKEN\" \\\n | python3 -c \"import json,sys; d=json.load(sys.stdin); print(d.get('login') or d.get('message'))\"\n```\n\nIf the token is missing or invalid, inform the user and stop.\n\n---\n\n## Setup Workflow\n\nFollow these steps in order.\n\n### Step 1 - Verify `GITHUB_PERSONAL_ACCESS_TOKEN`\n\nRun the `curl` check above.\n\n- If absent: *\"GITHUB_PERSONAL_ACCESS_TOKEN is not set. Please add it in\n OpenHands Settings -> Secrets.\"* Stop.\n- If the API returns `{\"message\": \"Bad credentials\"}`: tell the user the\n token is invalid and ask them to update it. Stop.\n\n### Step 2 - Collect repositories\n\nAsk: *\"Which GitHub repositories should be monitored?\n(Format: `owner/repo`, e.g. `myorg/backend`. List several separated by commas to\nreview them all from one automation.)\"*\n\nValidate access to **each** repository:\n```bash\ncurl -s \"https://api.github.com/repos/{owner}/{repo}\" \\\n -H \"Authorization: Bearer $GITHUB_PERSONAL_ACCESS_TOKEN\" \\\n | python3 -c \"\nimport json, sys\nd = json.load(sys.stdin)\nif 'message' in d:\n print('ERROR:', d['message'])\nelse:\n print(f\\\"Accessible. Private: {d.get('private')}. Permissions: {d.get('permissions')}\\\")\n\"\n```\n\nRecord every accepted repository into `REPOS = [\"{owner}/{repo}\", ...]`. If one\nrepository fails the check, say which and ask whether to continue without it.\n\nEach repository is polled independently and keeps its own state, so pull-request\nnumbers never collide between them. The trigger label, tone, and schedule are\nshared by all of them; a repository needing different settings wants its own\nautomation.\n\n### Step 3 - Collect trigger label\n\nAsk: *\"Which PR label should trigger a review?\n(Press Enter for the default: `openhands-review`.)\"*\n\nRecord the answer as `TRIGGER_LABEL`. If the label does not exist yet, tell the\nuser that GitHub will still record the event once the label is created and\napplied to a PR.\n\nThe automation reviews a PR when it sees the latest matching `labeled` event for\nthat label. To request another review later, remove and re-apply the label.\n\n### Step 4 - Collect review tone\n\nAsk: *\"What review tone should the reviewer use?\n 1. Thorough (default) - comprehensive coverage of correctness, security, tests, style\n 2. Concise - high-signal only, skips minor style feedback\n 3. Friendly - constructive and encouraging\n(Press Enter for Thorough, or type your choice or any custom style description)\"*\n\nMap the choice to `REVIEW_TONE`:\n\n| Answer | `REVIEW_TONE` | `REVIEW_STYLE_INSTRUCTIONS` |\n|---|---|---|\n| 1 / Enter | `\"thorough\"` | `\"\"` |\n| 2 | `\"concise\"` | `\"\"` |\n| 3 | `\"friendly\"` | `\"\"` |\n| Custom text, e.g. `strict but kind` | `\"thorough\"` | the custom text verbatim |\n\n### Step 5 - Collect cron schedule\n\nAsk: *\"How often should the automation poll for labeled PRs?\n(Press Enter for the default: every 5 minutes.\nUse a cron expression for a different interval, e.g. `0 * * * *` = hourly)\"*\n\nDefault: `*/5 * * * *`.\n\nRecord as `CRON_SCHEDULE`.\n\n### Step 6 - Generate the automation script\n\nRead `scripts/main.py` from this skill's directory. Apply exactly seven constant\nsubstitutions near the top of the file:\n\n> The script also reads a `config.json` shipped beside it, if there is one, over\n> these constants. That is how the catalog entry\n> (`automations/catalog/github-pr-reviewer/`) configures an unmodified copy,\n> since a declarative host cannot rewrite Python. This setup path substitutes the\n> constants and ships no `config.json`, so the two never collide.\n\n| Placeholder | Replace with |\n|---|---|\n| `REPOS = [\"owner/repo\"]` | `REPOS = [\"{owner_repo}\", ...]` - one entry per repository collected in Step 2 |\n| `TRIGGER_LABEL = \"openhands-review\"` | `TRIGGER_LABEL = \"{trigger_label}\"` |\n| `REVIEW_TONE = \"thorough\"` | `REVIEW_TONE = \"{review_tone}\"` |\n| `REVIEW_STYLE_INSTRUCTIONS = \"\"` | `REVIEW_STYLE_INSTRUCTIONS = \"{style_instructions}\"` |\n| `REPO_REVIEW_GUIDE_PATH = \".agents/skills/custom-codereview-guide.md\"` | leave unchanged to auto-load a repo review guide at this path, or set to `\"\"` to disable |\n| `MAX_NEW_PER_RUN = 2` | leave unchanged to bound a scheduled scan to two new review conversations across all repositories, or raise it if the deployment can hold more agents at once |\n| `DEFAULT_OPENHANDS_URL = \"http://localhost:8000\"` | leave unchanged unless the user has a preference |\n\nUse a safe string writer such as `json.dumps(value)` when inserting user-provided\nrepository names, labels, or style instructions into Python string literals.\n`json.dumps(list_of_repos)` produces the whole `REPOS` list safely in one step.\n\nRun these commands from this skill's directory and write the customized script\nto a temporary build directory:\n```bash\nmkdir -p /tmp/pr-reviewer-build\ncp -L scripts/github_client.py /tmp/pr-reviewer-build/github_client.py\n# write the customized main.py to /tmp/pr-reviewer-build/main.py\n```\n\nValidate syntax before packaging:\n```bash\npython3 -m py_compile /tmp/pr-reviewer-build/main.py && echo \"Syntax OK\"\n```\n\nFix any syntax errors before proceeding.\n\n### Step 7 - Package and upload\n\nDetermine the Automation backend URL and auth from the `<RUNTIME_SERVICES>`\nblock in your system context:\n- **OPENHANDS_HOST**: the Automation backend `url_from_agent`\n- **Auth**: `X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY`\n\n```bash\ntar -czf /tmp/pr-reviewer.tar.gz -C /tmp/pr-reviewer-build .\n\nTARBALL_PATH=$(curl -s -X POST \\\n \"${OPENHANDS_HOST}/api/automation/v1/uploads?name=github-pr-reviewer\" \\\n -H \"X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY\" \\\n -H \"Content-Type: application/gzip\" \\\n --data-binary @/tmp/pr-reviewer.tar.gz \\\n | python3 -c \"import json,sys; print(json.load(sys.stdin)['tarball_path'])\")\n\necho \"Uploaded: $TARBALL_PATH\"\n```\n\n### Step 8 - Register the automation\n\n```bash\ncurl -s -X POST \"${OPENHANDS_HOST}/api/automation/v1\" \\\n -H \"X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d \"{\n \\\"name\\\": \\\"GitHub PR Reviewer: {repo_summary} label {trigger_label}\\\",\n \\\"trigger\\\": {\\\"type\\\": \\\"cron\\\", \\\"schedule\\\": \\\"{cron_schedule}\\\"},\n \\\"tarball_path\\\": \\\"$TARBALL_PATH\\\",\n \\\"entrypoint\\\": \\\"python3 main.py\\\",\n \\\"timeout\\\": 600\n }\" | python3 -m json.tool\n```\n\nUse the single repository as `{repo_summary}` when there is one, and something\nlike `3 repos` when there are several. A poll now downloads a tarball per queued\nreview, so the timeout allows for that; a run never waits for a review to\nfinish, only for it to be started.\n\nRecord the returned `id`.\n\n### Step 9 - Confirm\n\nTell the user:\n\n> ✅ **GitHub PR Reviewer** is running!\n>\n> - Automation ID: `{id}`\n> - Repositories: `{owner}/{repo}`, ... (one line each)\n> - Trigger label: `{trigger_label}`\n> - Review tone: `{tone}`\n> - Polling schedule: `{cron_schedule}`\n> - State file per repository:\n> `~/.openhands/workspaces/automation-state/github_pr_reviewer_label_event_{id}_{owner}__{repo}.json`\n>\n> Apply the `{trigger_label}` label to a pull request to queue a review. Each\n> label event is processed once. To request another review, remove and re-apply\n> the label.\n>\n> The review is published as a pull request review on the head commit, with\n> inline comments where a finding maps to a changed line.\n\n---\n\n## Runtime Behaviour (per poll)\n\nEach cron run executes `main.py`, which resolves and validates\n`GITHUB_PERSONAL_ACCESS_TOKEN` once, then processes every repository in `REPOS`\nindependently. One repository failing does not stop the others; the run fails\nonly if every repository fails.\n\nFor each repository:\n\n1. Loads that repository's state (see `references/state-schema.md`).\n2. Verifies repository access.\n3. Lists open PRs, newest-updated first.\n4. For each open PR carrying `TRIGGER_LABEL`:\n - Refetches current PR metadata to avoid acting on stale list data.\n - Finds the latest matching GitHub `labeled` issue event.\n - Skips the event if it has already been tracked.\n - Downloads the PR's head commit as a tarball and extracts it to\n `{WORKSPACE_BASE}/repositories/{owner}__{repo}/pr-{number}-{sha12}`. The\n archive is checked as it is unpacked: a single root, no absolute or `..`\n paths, and symlinks skipped rather than materialised.\n - Records `default` and the concrete model from the server settings already\n used to start the conversation, for the review footer.\n - Starts an OpenHands conversation **whose working directory is that\n checkout**, under an id chosen before it starts, with a review prompt\n carrying PR metadata, the exact head SHA, label event details, the\n requirement to re-fetch the current mutable GitHub state before deciding\n a verdict, and that conversation's review marker.\n - Posts an acknowledgement comment with the label event, head SHA, and\n conversation link.\n - Records the review in state with `status: \"active\"` and the checkout path.\n - If the checkout or the conversation cannot be created, the checkout is\n removed and nothing is recorded, so the next poll retries the label event.\n5. For each active review conversation:\n - Marks it closed without posting if the PR has closed or merged.\n - Suppresses stale results if the PR head SHA changed after the review was\n queued.\n - When the conversation reaches `idle`, `finished`, `error`, or `stuck`,\n verifies a submitted review by the token's own user at that head SHA and\n appends the recorded LLM profile and model to each review carrying this\n conversation's marker before marking the review complete. Reviews without\n that marker are confirmed but never edited. If no review exists, the\n agent's final response is posted as a comment with provenance. Failed\n verification or publication is retried on the next poll.\n - Abandons a conversation that has not reached a terminal status within two\n hours, so its checkout can be reclaimed.\n6. Removes the checkout of every finished review, but only after confirming the\n conversation has stopped - deleting it under a running agent would remove its\n working directory. When that cannot be confirmed the directory is left alone\n and the next poll tries again.\n7. Saves that repository's state atomically.\n\nThe completion callback fires once for the whole run.\n\n---\n\n## Additional Resources\n\n- **`references/state-schema.md`** - State JSON schema, field definitions, and\n review lifecycle diagram.\n- **`scripts/main.py`** - The complete automation script. Customize the five\n constants at the top before packaging.\n- **`tests/test_main.py`** - Unit tests for the checkout, its removal, and state\n handling. Run them from the skill root with `python -m pytest tests/` after\n editing the script.\n\n---\n\n## Troubleshooting\n\n| Symptom | Likely cause | Fix |\n|---|---|---|\n| Bot never queues reviews | Trigger label not present or no matching `labeled` event | Apply the configured label to the PR |\n| \"Bad credentials\" in run logs | Token expired | Rotate and update `GITHUB_PERSONAL_ACCESS_TOKEN` |\n| 404 on repo access | Repo name wrong or no access | Re-check the entry in `REPOS` and the token's permissions |\n| One repository is skipped, others work | That repository failed its access check | Read the `=== owner/repo ===` block in the run log |\n| Same PR not reviewed after new commits | Label event was already processed | Remove and re-apply the trigger label |\n| Review paused with a failing-check comment | A current-head required check reported `failure`, `cancelled`, or `timed_out` | Fix the named checks and push; the review starts on the new head, or request `all-hands-bot` to review immediately |\n| Review reported waiting on checks | A current-head required check is `queued` or `in_progress`, or has not reported yet | No action; a later scan or a new review request retries |\n| Optional workflow failed but no review was paused | The failed workflow is not required, so the required-only scheduled gate ignored it | No action; only GitHub-required checks gate scheduled discovery |\n| Only a few reviews start on a large backlog | The per-scan `max_new_per_run` bound (default 2) reached | No action; later scans drain the remaining oldest eligible PRs, or raise `max_new_per_run` if the deployment can hold more agents |\n| Review result never posts | Conversation still running or stuck | Open the conversation link from the acknowledgement comment |\n| Stale review suppressed | PR head SHA changed while the agent was reviewing | Re-apply the trigger label after the latest commit |\n| Review arrives as a plain comment, not a review | Publishing failed, so the script posted the text as a fallback | Check that the token has Pull requests: Read and Write |\n| Agent reports it cannot clone the repo | Prompt asked it not to; the workspace is already the checkout | No action - the code is at the head SHA in its working directory |\n| Checkouts remain under `repositories/` | Their conversations had not stopped yet | They are removed by a later poll once the conversation is terminal |",
|
|
287
287
|
"category": "automations"
|
|
288
288
|
},
|
|
289
289
|
{
|
|
@@ -319,7 +319,7 @@ export const SKILLS_CATALOG = [
|
|
|
319
319
|
"triggers": [
|
|
320
320
|
"/issue-to-mr:setup"
|
|
321
321
|
],
|
|
322
|
-
"content": "# GitLab Issue to MR Automation\n\nCreate a cron automation that watches one or more GitLab projects for issues\nwith a trigger label, starts an OpenHands conversation once per label event with\nthe project's default branch already checked out, and opens a merge request with\nwhatever the agent produced.\n\nThe automation script is deterministic: issue discovery, label-event tracking,\nstate persistence, the clone, the branch, the commit, the push, the merge\nrequest, the issue comments, and the clone's removal are all handled in Python.\nThe LLM is invoked only to write the code.\n\nThe agent is told **which** issue to implement, not what it says. It fetches the\ndescription, the discussion, and whatever they link to itself, so nothing in the\nprompt goes stale between dispatch and the moment the agent reads it.\n\nThat needs read access, so the conversation is handed one secret, `GITLAB_TOKEN`.\n`AGENT_SECRET_NAMES` stays an allow-list: the rest of the deployment's secret\nstore is not reachable from a conversation whose instructions came from an issue.\n\nThe deployment's MCP servers are forwarded whole, matching `github-pr-reviewer`,\nso a connected GitLab server gives the agent typed tools rather than curl. What\nthose servers reach is reachable from an issue-authored prompt, so connect only\nservers that may be driven by untrusted text.\n\nThe agent also finishes the job: it commits, pushes its branch, and opens the\nmerge request, so the merge request appears when the agent stops rather than on\nthe next poll. The script does not trust that it happened - when the conversation\nends it asks GitLab whether the merge request exists, and opens it itself when it\ndoes not. `origin` still carries no credential, so every GitLab command the agent\nruns has to name `GITLAB_TOKEN`; the SDK only puts a secret in the environment of\na command that mentions it, and masks it in the output.\n\n---\n\n## Prerequisites\n\n### Required secret\n\nVerify that the following secret is set in **OpenHands Settings -> Secrets**:\n\n| Secret name | Token type | Minimum requirements |\n|---|---|---|\n| `GITLAB_TOKEN` | Personal access token | `api` scope, and at least the **Developer** role on every watched project |\n| `GITLAB_TOKEN` | Project or group access token | `api` scope, role **Developer** or above |\n\nThe `api` scope is what GitLab grants read and write on issues, notes, branches,\nand merge requests through one scope; `read_api` polls happily and then fails at\nthe point of pushing. Developer is the lowest role that can push a branch and\nopen a merge request.\n\nTwo things the role does not cover, and which fail the push rather than the poll:\n\n- **Protected branches.** The default branch is usually protected, but the\n automation never pushes to it. Protect the branch prefix as well and the push\n is rejected; leave `openhands/issue-*` unprotected.\n- **CI/CD files.** An issue asking for a pipeline change makes the agent touch\n `.gitlab-ci.yml`. That needs no extra scope, but a project with a protected\n CI/CD configuration path rejects the push.\n\nWhen several projects are monitored, the token must cover all of them.\n\nCheck with:\n```bash\ncurl -s \"https://gitlab.com/api/v4/user\" \\\n -H \"PRIVATE-TOKEN: $GITLAB_TOKEN\" \\\n | python3 -c \"import json,sys; d=json.load(sys.stdin); print(d.get('username') or d.get('message'))\"\n```\n\nIf the token is missing or invalid, inform the user and stop.\n\n---\n\n## Setup Workflow\n\nFollow these steps in order.\n\n### Step 1 - Verify `GITLAB_TOKEN`\n\nRun the `curl` check above, against the user's instance if it is not\n`gitlab.com`.\n\n- If absent: *\"GITLAB_TOKEN is not set. Please add it in OpenHands Settings ->\n Secrets.\"* Stop.\n- If the API returns `{\"message\": \"401 Unauthorized\"}`: tell the user the token\n is invalid and ask them to update it. Stop.\n\n### Step 2 - Collect the GitLab API URL\n\nAsk: *\"Which GitLab instance? (Press Enter for gitlab.com. For a self-managed\ninstance give its API root, e.g. `https://gitlab.example.com/api/v4`.)\"*\n\nRecord as `GITLAB_API_URL`. Default: `https://gitlab.com/api/v4`. Use this URL in\nevery check below.\n\n### Step 3 - Collect projects\n\nAsk: *\"Which GitLab projects should be watched?\n(Format: `group/project`, e.g. `myorg/backend`. Subgroups are fine -\n`myorg/team/service`. List several separated by commas to serve them all from one\nautomation.)\"*\n\nValidate access to **each** project, and confirm the token's role:\n```bash\nPROJECT_ID=$(python3 -c \"import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=''))\" \"{group}/{project}\")\ncurl -s \"${GITLAB_API_URL}/projects/${PROJECT_ID}\" \\\n -H \"PRIVATE-TOKEN: $GITLAB_TOKEN\" \\\n | python3 -c \"\nimport json, sys\nd = json.load(sys.stdin)\nif 'message' in d or 'error' in d:\n print('ERROR:', d.get('message') or d.get('error'))\nelse:\n perms = d.get('permissions') or {}\n levels = [(perms.get(k) or {}).get('access_level') for k in ('project_access', 'group_access')]\n levels = [n for n in levels if isinstance(n, int)]\n role = max(levels) if levels else 'unknown'\n print(f\\\"Accessible. Default branch: {d.get('default_branch')}. Access level: {role}\\\")\n\"\n```\n\nRecord every accepted project into `PROJECTS = [\"{group}/{project}\", ...]`. If\none project fails the check, say which and ask whether to continue without it.\nAn access level below `30` (Developer) means the automation cannot open merge\nrequests there; ask for a token with a higher role.\n\nEach project is polled independently and keeps its own state, so issue numbers\nnever collide between them. The trigger label, branch prefix, and schedule are\nshared; a project needing different settings wants its own automation.\n\n### Step 4 - Collect trigger label\n\nAsk: *\"Which issue label should trigger an implementation?\n(Press Enter for the default: `openhands`.)\"*\n\nRecord the answer as `TRIGGER_LABEL`. If the label does not exist yet, tell the\nuser that GitLab will still record the event once the label is created and\napplied to an issue.\n\nThe automation works an issue when it sees the latest matching label event for\nthat label. To ask for another attempt later, remove and re-apply the label -\nthat opens a second branch and a second merge request rather than overwriting the\nfirst.\n\n### Step 5 - Collect the merge request mode\n\nAsk: *\"Should the merge requests be opened as drafts?\n 1. Draft (default) - title prefixed `Draft:`, ready for a human to mark ready\n 2. Ready for review - opened as a normal merge request\n(Press Enter for Draft)\"*\n\nMap the choice to `DRAFT_MERGE_REQUEST` (`True` or `False`). GitLab has no draft\nflag on the merge request API; a draft is a title carrying the `Draft: ` prefix,\nwhich the script adds.\n\n### Step 6 - Collect the branch prefix\n\nAsk: *\"What branch prefix should the automation use?\n(Press Enter for the default: `openhands/issue`, which produces\n`openhands/issue-42`.)\"*\n\nRecord as `BRANCH_PREFIX`. Keep it free of spaces and of characters git rejects\nin a ref name, and make sure the prefix is not covered by a protected-branch\nrule.\n\n### Step 7 - Collect cron schedule\n\nAsk: *\"How often should the automation poll for labelled issues?\n(Press Enter for the default: every 5 minutes.\nUse a cron expression for a different interval, e.g. `0 * * * *` = hourly)\"*\n\nDefault: `*/5 * * * *`.\n\nRecord as `CRON_SCHEDULE`.\n\n### Step 8 - Confirm the secret scope\n\nThe agent is handed `GITLAB_TOKEN`, because it reads the issue and its discussion\nitself. Ask: *\"Beyond the GitLab token, does the project's build need a secret of\nits own - a package registry token, for example? (Press Enter for none.)\"*\n\nRecord the answers appended to the default, as\n`AGENT_SECRET_NAMES = [\"GITLAB_TOKEN\", \"NAME\", ...]`.\n\nKeep it an allow-list. Forwarding the whole secret store would put every\ncredential in the deployment behind a prompt written by whoever opened the issue.\nIf the projects are public and you would rather the conversation held no\ncredential at all, set the list to `[]` - the agent can still read a public issue\nunauthenticated, and private projects then stop working.\n\nThe deployment's MCP servers are a separate matter: they are forwarded whole, so\nthe conversation can reach everything they expose. Say so, and check the user is\nwilling to have those servers driven by text written by whoever opened an issue.\nRemoving a server from the deployment's MCP settings is the only way to keep it\nout of these conversations.\n\n### Step 9 - Generate the automation script\n\nRead `scripts/main.py` from this skill's directory. Apply exactly six constant\nsubstitutions near the top of the file:\n\n> The script also reads a `config.json` shipped beside it, if there is one, over\n> these constants. That is how the catalog entry\n> (`automations/catalog/gitlab-issue-to-mr/`) configures an unmodified copy,\n> since a declarative host cannot rewrite Python. This setup path substitutes the\n> constants and ships no `config.json`, so the two never collide.\n\n| Placeholder | Replace with |\n|---|---|\n| `PROJECTS = [\"group/project\"]` | `PROJECTS = [\"{group_project}\", ...]` - one entry per project collected in Step 3 |\n| `TRIGGER_LABEL = \"openhands\"` | `TRIGGER_LABEL = \"{trigger_label}\"` |\n| `BRANCH_PREFIX = \"openhands/issue\"` | `BRANCH_PREFIX = \"{branch_prefix}\"` |\n| `DRAFT_MERGE_REQUEST = True` | `DRAFT_MERGE_REQUEST = {True or False}` |\n| `GITLAB_API_URL = \"https://gitlab.com/api/v4\"` | `GITLAB_API_URL = \"{gitlab_api_url}\"` |\n| `AGENT_SECRET_NAMES: list[str] = [\"GITLAB_TOKEN\"]` | `AGENT_SECRET_NAMES: list[str] = [\"{name}\", ...]` |\n\nLeave `MAX_NEW_PER_RUN` and `DEFAULT_OPENHANDS_URL` alone unless the user asks\nfor a different cap or a non-default OpenHands URL.\n\nA project may be given as `group/project`, as a clone URL, or as an SSH remote;\nthe script normalizes each one at startup and names the value it could not read\nrather than blaming the token. Subgroups are preserved.\n\nUse a safe string writer such as `json.dumps(value)` when inserting user-provided\nproject paths, labels, or prefixes into Python string literals.\n`json.dumps(list_of_projects)` produces the whole `PROJECTS` list safely in one\nstep.\n\nWrite the customized script to a temporary build directory:\n```bash\nmkdir -p /tmp/issue-to-mr-build\n# write the customized main.py to /tmp/issue-to-mr-build/main.py\n```\n\nValidate syntax before packaging:\n```bash\npython3 -m py_compile /tmp/issue-to-mr-build/main.py && echo \"Syntax OK\"\n```\n\nFix any syntax errors before proceeding.\n\n### Step 10 - Package and upload\n\nSet `OPENHANDS_HOST` and `AUTH_HEADER` for the commands below:\n- **OpenHands Cloud or Enterprise** - a `<HOST>` value is in your system prompt:\n use it as `OPENHANDS_HOST`, with `AUTH_HEADER=\"Authorization: Bearer $OPENHANDS_API_KEY\"`\n- **Local Agent Canvas** - an **Automation backend** is listed in the\n `<RUNTIME_SERVICES>` block of your system context: use its `url_from_agent` as\n `OPENHANDS_HOST`, with `AUTH_HEADER=\"X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY\"`\n\nOn OpenHands Cloud or Enterprise each run starts in a fresh sandbox, so the\nautomation keeps its state in the KV store: check that `kvStore` is listed under\n`features` in `GET ${OPENHANDS_HOST}/api/automation/v1/capabilities` before\ndeploying.\n\n```bash\ntar -czf /tmp/issue-to-mr.tar.gz -C /tmp/issue-to-mr-build .\n\nTARBALL_PATH=$(curl -s -X POST \\\n \"${OPENHANDS_HOST}/api/automation/v1/uploads?name=gitlab-issue-to-mr\" \\\n -H \"$AUTH_HEADER\" \\\n -H \"Content-Type: application/gzip\" \\\n --data-binary @/tmp/issue-to-mr.tar.gz \\\n | python3 -c \"import json,sys; print(json.load(sys.stdin)['tarball_path'])\")\n\necho \"Uploaded: $TARBALL_PATH\"\n```\n\n### Step 11 - Register the automation\n\n```bash\ncurl -s -X POST \"${OPENHANDS_HOST}/api/automation/v1\" \\\n -H \"$AUTH_HEADER\" \\\n -H \"Content-Type: application/json\" \\\n -d \"{\n \\\"name\\\": \\\"GitLab Issue to MR: {project_summary} label {trigger_label}\\\",\n \\\"trigger\\\": {\\\"type\\\": \\\"cron\\\", \\\"schedule\\\": \\\"{cron_schedule}\\\"},\n \\\"tarball_path\\\": \\\"$TARBALL_PATH\\\",\n \\\"entrypoint\\\": \\\"python3 main.py\\\",\n \\\"timeout\\\": 900\n }\" | python3 -m json.tool\n```\n\nUse the single project as `{project_summary}` when there is one, and something\nlike `3 projects` when there are several. A poll clones a project per queued\nissue and pushes finished branches, so the timeout allows for that; a run never\nwaits for an agent to finish, only for it to be started.\n\nRecord the returned `id`.\n\n### Step 12 - Confirm\n\nTell the user:\n\n> ✅ **GitLab Issue to MR** is running!\n>\n> - Automation ID: `{id}`\n> - Projects: `{group}/{project}`, ... (one line each)\n> - GitLab API: `{gitlab_api_url}`\n> - Trigger label: `{trigger_label}`\n> - Branch prefix: `{branch_prefix}`\n> - Merge requests: `{draft or ready for review}`\n> - Polling schedule: `{cron_schedule}`\n> - State file per project:\n> `~/.openhands/workspaces/automation-state/gitlab_issue_to_mr_{id}_{group}__{project}.json`\n>\n> Apply the `{trigger_label}` label to an issue to queue an implementation. Each\n> label event is processed once. To ask for another attempt, remove and re-apply\n> the label - that opens a second branch and merge request.\n>\n> The agent runs without a checkout credential; the automation pushes the branch\n> and opens the merge request once the agent has stopped.\n\n---\n\n## Runtime Behaviour (per poll)\n\nEach cron run executes `main.py`, which loads `config.json` if the catalog\nshipped one, checks that `git` is available, resolves and validates `GITLAB_TOKEN`\nonce, then processes every project in `PROJECTS` independently. One project\nfailing does not stop the others; the run fails only if every project fails.\n\nFor each project:\n\n1. Loads that project's state (see `references/state-schema.md`) and reads its\n default branch and clone URL.\n2. Lists open issues carrying `TRIGGER_LABEL`, newest-updated first. GitLab keeps\n merge requests on their own endpoint, so labelling a merge request never\n queues an implementation.\n3. For each labelled issue, up to `MAX_NEW_PER_RUN` new ones per run:\n - Refetches the issue so a label removed since the listing does not start work.\n - Finds the latest matching resource label event with `action: \"add\"`, and\n skips it if that event has already been tracked.\n - Picks the first free branch name, `{BRANCH_PREFIX}-{iid}` or a numbered\n variant of it.\n - Clones the default branch, shallow and single-branch, into\n `{WORKSPACE_BASE}/issue-to-mr/{group}__{project}/issue-{iid}-{event_id}`,\n sets the commit identity, and creates the branch. `origin` keeps its plain\n HTTPS URL, so the workspace holds no credential.\n - Starts an OpenHands conversation **whose working directory is that clone**,\n told which issue to read, with the secrets named in `AGENT_SECRET_NAMES`\n and the deployment's MCP servers attached.\n - Comments on the issue with the branch, the label event, and the conversation\n link.\n - Records the task with `status: \"active\"`.\n - If the clone or the conversation cannot be created, the clone is removed and\n nothing is recorded, so the next poll retries the label event.\n4. For each active task:\n - Abandons a conversation that has not reached a terminal status within two\n hours, comments on the issue, and reclaims its clone.\n - When the conversation reaches `idle`, `finished`, `error`, or `stuck`:\n - Adopts the merge request the agent opened, if GitLab says one exists for\n the branch, and comments its link on the issue. Everything below is the\n path taken when it does not.\n - Skips the merge request if the issue was closed meanwhile.\n - Reports the problem on the issue if the conversation ended in `error` or\n `stuck`.\n - Commits whatever the agent left uncommitted, on top of any commits it made\n itself.\n - Posts the agent's answer on the issue, and opens no merge request, when\n there are no commits at all - that is how an agent reports an issue too\n ambiguous to implement.\n - Otherwise pushes the branch, opens the merge request (draft by default,\n titled `Draft: [#42] <issue title>`, with the agent's summary and\n `Closes #42` in the description), and comments the link on the issue.\n - A push or merge request that fails is retried on the next two polls before\n the task is reported as failed, so a transient GitLab error does not throw\n the work away.\n5. Removes the clone of every finished task, but only after confirming the\n conversation has stopped - deleting it under a running agent would remove its\n working directory. When that cannot be confirmed the directory is left alone\n and the next poll tries again.\n6. Saves that project's state atomically.\n\nThe completion callback fires once for the whole run.\n\n---\n\n## Additional Resources\n\n- **`references/state-schema.md`** - State JSON schema, field definitions, and the\n task lifecycle.\n- **`scripts/main.py`** - The complete automation script. Customize the six\n constants at the top before packaging.\n\n---\n\n## Troubleshooting\n\n| Symptom | Likely cause | Fix |\n|---|---|---|\n| Nothing is ever queued | Trigger label not present, or applied to a merge request rather than an issue | Apply the configured label to an issue |\n| \"401 Unauthorized\" in run logs | Token expired | Rotate and update `GITLAB_TOKEN` |\n| \"The token's role on ... is below Developer\" | The token has Reporter or Guest on that project | Grant Developer or above, or drop the project from `PROJECTS` |\n| Push rejected: \"You are not allowed to push code to protected branches\" | The branch prefix is covered by a protected-branch rule | Leave `{BRANCH_PREFIX}-*` unprotected |\n| Push rejected on `.gitlab-ci.yml` | The project protects its CI/CD configuration path | Allow the token's role to update it, or exclude such issues |\n| 404 on project access | Project path wrong, or no access | Re-check the entry in `PROJECTS` and the token's role. Subgroups must be included in full |\n| `git is not available in the automation runtime` | The runtime image has no git | Use a runtime image that ships git; the script clones, commits, and pushes with it |\n| Issue commented \"did not change any code\" | The agent judged the issue too ambiguous, or made no edits | Read its answer in the comment, add the missing detail to the issue, then re-apply the label |\n| Same issue not picked up again after new comments | Its label event was already processed | Remove and re-apply the trigger label |\n| Agent reports it cannot push or open an MR | By design - it has no push credentials in `origin` | No action; the automation pushes and opens the merge request after the agent stops |\n| `Warning: could not fetch MCP config` in run logs | The settings endpoint was unreachable | Non-fatal; the agent falls back to the REST calls in the prompt |\n| A backlog of labelled issues starts slowly | `MAX_NEW_PER_RUN` caps how many conversations one poll starts | Wait for the next polls, or raise the cap in the script |\n| Clones remain under `issue-to-mr/` | Their conversations had not stopped yet | They are removed by a later poll once the conversation is terminal |",
|
|
322
|
+
"content": "# GitLab Issue to MR Automation\n\nCreate a cron automation that watches one or more GitLab projects for issues\nwith a trigger label, starts an OpenHands conversation once per label event with\nthe project's default branch already checked out, and opens a merge request with\nwhatever the agent produced.\n\nThe automation script is deterministic: issue discovery, label-event tracking,\nstate persistence, the clone, the branch, the commit, the push, the merge\nrequest, the issue comments, and the clone's removal are all handled in Python.\nThe LLM is invoked only to write the code.\n\nThe agent is told **which** issue to implement, not what it says. It fetches the\ndescription, the discussion, and whatever they link to itself, so nothing in the\nprompt goes stale between dispatch and the moment the agent reads it.\n\nThat needs read access, so the conversation is handed one secret, `GITLAB_TOKEN`.\n`AGENT_SECRET_NAMES` stays an allow-list: the rest of the deployment's secret\nstore is not reachable from a conversation whose instructions came from an issue.\nOn OpenHands Cloud and Enterprise the conversation runs with the user's own\nsettings instead: the connected GitLab integration's token, or the tools of the\nconnected GitLab MCP server.\n\nThe deployment's MCP servers are forwarded whole, matching `github-pr-reviewer`,\nso a connected GitLab server gives the agent typed tools rather than curl. What\nthose servers reach is reachable from an issue-authored prompt, so connect only\nservers that may be driven by untrusted text.\n\nThe agent also finishes the job: it commits, pushes its branch, and opens the\nmerge request, so the merge request appears when the agent stops rather than on\nthe next poll. The script does not trust that it happened - when the conversation\nends it asks GitLab whether the merge request exists, and opens it itself when it\ndoes not. `origin` still carries no credential, so every GitLab command the agent\nruns has to name `GITLAB_TOKEN`; the SDK only puts a secret in the environment of\na command that mentions it, and masks it in the output.\n\n---\n\n## Prerequisites\n\n### GitLab access\n\nOn OpenHands Cloud and Enterprise, no secret is needed. A connected GitLab MCP\nserver (Settings -> MCP) is used directly, by the poller and by the conversations\nit starts; a connected GitLab integration (Settings -> Integrations, connected by\nsigning in with GitLab) serves both with its token. With only the MCP server, the conversation commits through its\ntools and reads a private project through them rather than cloning it.\n\nCalling GitLab MCP directly needs GitLab 19.4 or later, where the branch,\ncommit, repository and work item tools this uses first appear. A token works with\nany GitLab, so whenever there is one - the token of the user's GitLab sign-in or\nconnected integration, or a `GITLAB_TOKEN` secret - the poller and its\nconversations use it rather than the MCP server. On an older GitLab, sign in with\nGitLab or connect it under Settings -> Integrations; a `GITLAB_TOKEN` secret is\nonly needed when neither is possible.\n\nOn a local Agent Canvas, or when neither is connected, verify that the following\nsecret is set in **OpenHands Settings -> Secrets**:\n\n| Secret name | Token type | Minimum requirements |\n|---|---|---|\n| `GITLAB_TOKEN` | Personal access token | `api` scope, and at least the **Developer** role on every watched project |\n| `GITLAB_TOKEN` | Project or group access token | `api` scope, role **Developer** or above |\n\nThe `api` scope is what GitLab grants read and write on issues, notes, branches,\nand merge requests through one scope; `read_api` polls happily and then fails at\nthe point of pushing. Developer is the lowest role that can push a branch and\nopen a merge request.\n\nTwo things the role does not cover, and which fail the push rather than the poll:\n\n- **Protected branches.** The default branch is usually protected, but the\n automation never pushes to it. Protect the branch prefix as well and the push\n is rejected; leave `openhands/issue-*` unprotected.\n- **CI/CD files.** An issue asking for a pipeline change makes the agent touch\n `.gitlab-ci.yml`. That needs no extra scope, but a project with a protected\n CI/CD configuration path rejects the push.\n\nWhen several projects are monitored, the token must cover all of them.\n\nCheck with:\n```bash\ncurl -s \"https://gitlab.com/api/v4/user\" \\\n -H \"PRIVATE-TOKEN: $GITLAB_TOKEN\" \\\n | python3 -c \"import json,sys; d=json.load(sys.stdin); print(d.get('username') or d.get('message'))\"\n```\n\nIf the token is missing or invalid, inform the user and stop.\n\n---\n\n## Setup Workflow\n\nFollow these steps in order.\n\n### Step 1 - Verify GitLab access\n\nOn OpenHands Cloud and Enterprise, read `GET ${OPENHANDS_HOST}/api/v1/settings`\nfirst. `gitlab` in `provider_tokens_set` means the user signed in with GitLab or\nconnected the integration, and its token is used, with any GitLab version: skip the\ntoken check and go to Step 2. Otherwise, an entry in `agent_settings.mcp_config`\nwhose `url` is the instance's `/api/v4/mcp` means the poller uses that server: put\nits key in `gitlab_mcp_server`, or leave it out and the run finds the server itself.\nCall its `get_mcp_server_version` tool, and on 19.4 or later skip the token check\nand go to Step 2. Calling GitLab MCP directly needs 19.4 or later, so on an older\nGitLab tell the user that and ask them to sign in with GitLab or connect it under\nSettings -> Integrations. Only if they can do neither, ask for a `GITLAB_TOKEN`\nsecret and run the token check below.\n\nOtherwise run the `curl` check above, against the user's instance if it is not\n`gitlab.com`.\n\n- If absent: *\"GITLAB_TOKEN is not set. Please add it in OpenHands Settings ->\n Secrets.\"* Stop.\n- If the API returns `{\"message\": \"401 Unauthorized\"}`: tell the user the token\n is invalid and ask them to update it. Stop.\n\n### Step 2 - Collect the GitLab API URL\n\nAsk: *\"Which GitLab instance? (Press Enter for gitlab.com. For a self-managed\ninstance give its API root, e.g. `https://gitlab.example.com/api/v4`.)\"*\n\nRecord as `GITLAB_API_URL`. Default: `https://gitlab.com/api/v4`. Use this URL in\nevery check below.\n\n### Step 3 - Collect projects\n\nAsk: *\"Which GitLab projects should be watched?\n(Format: `group/project`, e.g. `myorg/backend`. Subgroups are fine -\n`myorg/team/service`. List several separated by commas to serve them all from one\nautomation.)\"*\n\nValidate access to **each** project, and confirm the token's role:\n```bash\nPROJECT_ID=$(python3 -c \"import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=''))\" \"{group}/{project}\")\ncurl -s \"${GITLAB_API_URL}/projects/${PROJECT_ID}\" \\\n -H \"PRIVATE-TOKEN: $GITLAB_TOKEN\" \\\n | python3 -c \"\nimport json, sys\nd = json.load(sys.stdin)\nif 'message' in d or 'error' in d:\n print('ERROR:', d.get('message') or d.get('error'))\nelse:\n perms = d.get('permissions') or {}\n levels = [(perms.get(k) or {}).get('access_level') for k in ('project_access', 'group_access')]\n levels = [n for n in levels if isinstance(n, int)]\n role = max(levels) if levels else 'unknown'\n print(f\\\"Accessible. Default branch: {d.get('default_branch')}. Access level: {role}\\\")\n\"\n```\n\nRecord every accepted project into `PROJECTS = [\"{group}/{project}\", ...]`. If\none project fails the check, say which and ask whether to continue without it.\nAn access level below `30` (Developer) means the automation cannot open merge\nrequests there; ask for a token with a higher role.\n\nEach project is polled independently and keeps its own state, so issue numbers\nnever collide between them. The trigger label, branch prefix, and schedule are\nshared; a project needing different settings wants its own automation.\n\n### Step 4 - Collect trigger label\n\nAsk: *\"Which issue label should trigger an implementation?\n(Press Enter for the default: `openhands`.)\"*\n\nRecord the answer as `TRIGGER_LABEL`. If the label does not exist yet, tell the\nuser that GitLab will still record the event once the label is created and\napplied to an issue.\n\nThe automation works an issue when it sees the latest matching label event for\nthat label. To ask for another attempt later, remove and re-apply the label -\nthat opens a second branch and a second merge request rather than overwriting the\nfirst.\n\n### Step 5 - Collect the merge request mode\n\nAsk: *\"Should the merge requests be opened as drafts?\n 1. Draft (default) - title prefixed `Draft:`, ready for a human to mark ready\n 2. Ready for review - opened as a normal merge request\n(Press Enter for Draft)\"*\n\nMap the choice to `DRAFT_MERGE_REQUEST` (`True` or `False`). GitLab has no draft\nflag on the merge request API; a draft is a title carrying the `Draft: ` prefix,\nwhich the script adds.\n\n### Step 6 - Collect the branch prefix\n\nAsk: *\"What branch prefix should the automation use?\n(Press Enter for the default: `openhands/issue`, which produces\n`openhands/issue-42`.)\"*\n\nRecord as `BRANCH_PREFIX`. Keep it free of spaces and of characters git rejects\nin a ref name, and make sure the prefix is not covered by a protected-branch\nrule.\n\n### Step 7 - Collect cron schedule\n\nAsk: *\"How often should the automation poll for labelled issues?\n(Press Enter for the default: every 5 minutes.\nUse a cron expression for a different interval, e.g. `0 * * * *` = hourly)\"*\n\nDefault: `*/5 * * * *`.\n\nRecord as `CRON_SCHEDULE`.\n\n### Step 8 - Confirm the secret scope\n\nThe agent is handed `GITLAB_TOKEN`, because it reads the issue and its discussion\nitself. Ask: *\"Beyond the GitLab token, does the project's build need a secret of\nits own - a package registry token, for example? (Press Enter for none.)\"*\n\nRecord the answers appended to the default, as\n`AGENT_SECRET_NAMES = [\"GITLAB_TOKEN\", \"NAME\", ...]`.\n\nKeep it an allow-list. Forwarding the whole secret store would put every\ncredential in the deployment behind a prompt written by whoever opened the issue.\nIf the projects are public and you would rather the conversation held no\ncredential at all, set the list to `[]` - the agent can still read a public issue\nunauthenticated, and private projects then stop working.\n\nThe deployment's MCP servers are a separate matter: they are forwarded whole, so\nthe conversation can reach everything they expose. Say so, and check the user is\nwilling to have those servers driven by text written by whoever opened an issue.\nRemoving a server from the deployment's MCP settings is the only way to keep it\nout of these conversations.\n\n### Step 9 - Generate the automation script\n\nRead `scripts/main.py` from this skill's directory. Apply the constant\nsubstitutions below near the top of the file:\n\n> The script also reads a `config.json` shipped beside it, if there is one, over\n> these constants. That is how the catalog entry\n> (`automations/catalog/gitlab-issue-to-mr/`) configures an unmodified copy,\n> since a declarative host cannot rewrite Python. This setup path substitutes the\n> constants and ships no `config.json`, so the two never collide.\n\n| Placeholder | Replace with |\n|---|---|\n| `PROJECTS = [\"group/project\"]` | `PROJECTS = [\"{group_project}\", ...]` - one entry per project collected in Step 3 |\n| `TRIGGER_LABEL = \"openhands\"` | `TRIGGER_LABEL = \"{trigger_label}\"` |\n| `BRANCH_PREFIX = \"openhands/issue\"` | `BRANCH_PREFIX = \"{branch_prefix}\"` |\n| `DRAFT_MERGE_REQUEST = True` | `DRAFT_MERGE_REQUEST = {True or False}` |\n| `GITLAB_API_URL = \"https://gitlab.com/api/v4\"` | `GITLAB_API_URL = \"{gitlab_api_url}\"` |\n| `GITLAB_MCP_SERVER_NAME = \"\"` | `GITLAB_MCP_SERVER_NAME = \"{settings key}\"` - only on OpenHands Cloud and Enterprise, and only when the user named a connected GitLab MCP server in Step 1; left empty, a cloud run finds the server itself |\n| `AGENT_SECRET_NAMES: list[str] = [\"GITLAB_TOKEN\"]` | `AGENT_SECRET_NAMES: list[str] = [\"{name}\", ...]` |\n\nLeave `MAX_NEW_PER_RUN` and `DEFAULT_OPENHANDS_URL` alone unless the user asks\nfor a different cap or a non-default OpenHands URL.\n\nA project may be given as `group/project`, as a clone URL, or as an SSH remote;\nthe script normalizes each one at startup and names the value it could not read\nrather than blaming the token. Subgroups are preserved.\n\nUse a safe string writer such as `json.dumps(value)` when inserting user-provided\nproject paths, labels, or prefixes into Python string literals.\n`json.dumps(list_of_projects)` produces the whole `PROJECTS` list safely in one\nstep.\n\nWrite the customized script to a temporary build directory:\n```bash\nmkdir -p /tmp/issue-to-mr-build\n# write the customized main.py to /tmp/issue-to-mr-build/main.py\n```\n\nValidate syntax before packaging:\n```bash\npython3 -m py_compile /tmp/issue-to-mr-build/main.py && echo \"Syntax OK\"\n```\n\nFix any syntax errors before proceeding.\n\n### Step 10 - Package and upload\n\nSet `OPENHANDS_HOST` and `AUTH_HEADER` for the commands below:\n- **OpenHands Cloud or Enterprise** - a `<HOST>` value is in your system prompt:\n use it as `OPENHANDS_HOST`, with `AUTH_HEADER=\"Authorization: Bearer $OPENHANDS_API_KEY\"`\n- **Local Agent Canvas** - an **Automation backend** is listed in the\n `<RUNTIME_SERVICES>` block of your system context: use its `url_from_agent` as\n `OPENHANDS_HOST`, with `AUTH_HEADER=\"X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY\"`\n\nOn OpenHands Cloud or Enterprise each run starts in a fresh sandbox, so the\nautomation keeps its state in the KV store: check that `kvStore` is listed under\n`features` in `GET ${OPENHANDS_HOST}/api/automation/v1/capabilities` before\ndeploying.\n\n```bash\ntar -czf /tmp/issue-to-mr.tar.gz -C /tmp/issue-to-mr-build .\n\nTARBALL_PATH=$(curl -s -X POST \\\n \"${OPENHANDS_HOST}/api/automation/v1/uploads?name=gitlab-issue-to-mr\" \\\n -H \"$AUTH_HEADER\" \\\n -H \"Content-Type: application/gzip\" \\\n --data-binary @/tmp/issue-to-mr.tar.gz \\\n | python3 -c \"import json,sys; print(json.load(sys.stdin)['tarball_path'])\")\n\necho \"Uploaded: $TARBALL_PATH\"\n```\n\n### Step 11 - Register the automation\n\n```bash\ncurl -s -X POST \"${OPENHANDS_HOST}/api/automation/v1\" \\\n -H \"$AUTH_HEADER\" \\\n -H \"Content-Type: application/json\" \\\n -d \"{\n \\\"name\\\": \\\"GitLab Issue to MR: {project_summary} label {trigger_label}\\\",\n \\\"trigger\\\": {\\\"type\\\": \\\"cron\\\", \\\"schedule\\\": \\\"{cron_schedule}\\\"},\n \\\"tarball_path\\\": \\\"$TARBALL_PATH\\\",\n \\\"entrypoint\\\": \\\"python3 main.py\\\",\n \\\"timeout\\\": 900\n }\" | python3 -m json.tool\n```\n\nUse the single project as `{project_summary}` when there is one, and something\nlike `3 projects` when there are several. A poll clones a project per queued\nissue and pushes finished branches, so the timeout allows for that; a run never\nwaits for an agent to finish, only for it to be started.\n\nRecord the returned `id`.\n\n### Step 12 - Confirm\n\nTell the user:\n\n> ✅ **GitLab Issue to MR** is running!\n>\n> - Automation ID: `{id}`\n> - Projects: `{group}/{project}`, ... (one line each)\n> - GitLab API: `{gitlab_api_url}`\n> - Trigger label: `{trigger_label}`\n> - Branch prefix: `{branch_prefix}`\n> - Merge requests: `{draft or ready for review}`\n> - Polling schedule: `{cron_schedule}`\n> - State file per project:\n> `~/.openhands/workspaces/automation-state/gitlab_issue_to_mr_{id}_{group}__{project}.json`\n>\n> Apply the `{trigger_label}` label to an issue to queue an implementation. Each\n> label event is processed once. To ask for another attempt, remove and re-apply\n> the label - that opens a second branch and merge request.\n>\n> The agent runs without a checkout credential; the automation pushes the branch\n> and opens the merge request once the agent has stopped. On OpenHands Cloud and\n> Enterprise the agent pushes and opens the merge request itself, and the\n> automation reports on the issue when none appears.\n\n---\n\n## Runtime Behaviour (per poll)\n\nEach cron run executes `main.py`, which loads `config.json` if the catalog\nshipped one, checks that `git` is available, resolves and validates `GITLAB_TOKEN`\nonce, then processes every project in `PROJECTS` independently. One project\nfailing does not stop the others; the run fails only if every project fails.\n\nFor each project:\n\n1. Loads that project's state (see `references/state-schema.md`) and reads its\n default branch and clone URL.\n2. Lists open issues carrying `TRIGGER_LABEL`, newest-updated first. GitLab keeps\n merge requests on their own endpoint, so labelling a merge request never\n queues an implementation.\n3. For each labelled issue, up to `MAX_NEW_PER_RUN` new ones per run:\n - Refetches the issue so a label removed since the listing does not start work.\n - Finds the latest matching resource label event with `action: \"add\"`, and\n skips it if that event has already been tracked.\n - Picks the first free branch name, `{BRANCH_PREFIX}-{iid}` or a numbered\n variant of it.\n - Clones the default branch, shallow and single-branch, into\n `{WORKSPACE_BASE}/issue-to-mr/{group}__{project}/issue-{iid}-{event_id}`,\n sets the commit identity, and creates the branch. `origin` keeps its plain\n HTTPS URL, so the workspace holds no credential.\n - Starts an OpenHands conversation **whose working directory is that clone**,\n told which issue to read, with the secrets named in `AGENT_SECRET_NAMES`\n and the deployment's MCP servers attached.\n - Comments on the issue with the branch, the label event, and the conversation\n link.\n - Records the task with `status: \"active\"`.\n - If the clone or the conversation cannot be created, the clone is removed and\n nothing is recorded, so the next poll retries the label event.\n4. For each active task:\n - Abandons a conversation that has not reached a terminal status within two\n hours, comments on the issue, and reclaims its clone.\n - When the conversation reaches `idle`, `finished`, `error`, or `stuck`:\n - Adopts the merge request the agent opened, if GitLab says one exists for\n the branch, and comments its link on the issue. Everything below is the\n path taken when it does not.\n - Skips the merge request if the issue was closed meanwhile.\n - Reports the problem on the issue if the conversation ended in `error` or\n `stuck`.\n - Commits whatever the agent left uncommitted, on top of any commits it made\n itself.\n - Posts the agent's answer on the issue, and opens no merge request, when\n there are no commits at all - that is how an agent reports an issue too\n ambiguous to implement.\n - Otherwise pushes the branch, opens the merge request (draft by default,\n titled `Draft: [#42] <issue title>`, with the agent's summary and\n `Closes #42` in the description), and comments the link on the issue.\n - A push or merge request that fails is retried on the next two polls before\n the task is reported as failed, so a transient GitLab error does not throw\n the work away.\n5. Removes the clone of every finished task, but only after confirming the\n conversation has stopped - deleting it under a running agent would remove its\n working directory. When that cannot be confirmed the directory is left alone\n and the next poll tries again.\n6. Saves that project's state atomically.\n\nThe completion callback fires once for the whole run.\n\n---\n\n## Additional Resources\n\n- **`references/state-schema.md`** - State JSON schema, field definitions, and the\n task lifecycle.\n- **`scripts/main.py`** - The complete automation script. Customize the six\n constants at the top before packaging.\n\n---\n\n## Troubleshooting\n\n| Symptom | Likely cause | Fix |\n|---|---|---|\n| Nothing is ever queued | Trigger label not present, or applied to a merge request rather than an issue | Apply the configured label to an issue |\n| \"401 Unauthorized\" in run logs | Token expired | Rotate and update `GITLAB_TOKEN` |\n| \"The token's role on ... is below Developer\" | The token has Reporter or Guest on that project | Grant Developer or above, or drop the project from `PROJECTS` |\n| Push rejected: \"You are not allowed to push code to protected branches\" | The branch prefix is covered by a protected-branch rule | Leave `{BRANCH_PREFIX}-*` unprotected |\n| Push rejected on `.gitlab-ci.yml` | The project protects its CI/CD configuration path | Allow the token's role to update it, or exclude such issues |\n| 404 on project access | Project path wrong, or no access | Re-check the entry in `PROJECTS` and the token's role. Subgroups must be included in full |\n| `git is not available in the automation runtime` | The runtime image has no git | Use a runtime image that ships git; the script clones, commits, and pushes with it |\n| Issue commented \"did not change any code\" | The agent judged the issue too ambiguous, or made no edits | Read its answer in the comment, add the missing detail to the issue, then re-apply the label |\n| Same issue not picked up again after new comments | Its label event was already processed | Remove and re-apply the trigger label |\n| Agent reports it cannot push or open an MR | By design on a local run - it has no push credentials in `origin`. On Cloud it pushes with the connected integration, or commits through the GitLab MCP tools | No action locally; the automation pushes and opens the merge request after the agent stops |\n| `Warning: could not fetch MCP config` in run logs | The settings endpoint was unreachable | Non-fatal; the agent falls back to the REST calls in the prompt |\n| A GitLab MCP tool call fails in the run log, for example on `get_project` | The run found no GitLab token, and the connected GitLab is older than 19.4, which lacks the tools the poller calls | Sign in with GitLab or connect it under Settings -> Integrations, or add a `GITLAB_TOKEN` secret; any GitLab token is used instead of the MCP server. Or upgrade GitLab |\n| A backlog of labelled issues starts slowly | `MAX_NEW_PER_RUN` caps how many conversations one poll starts | Wait for the next polls, or raise the cap in the script |\n| Clones remain under `issue-to-mr/` | Their conversations had not stopped yet | They are removed by a later poll once the conversation is terminal |",
|
|
323
323
|
"category": "automations"
|
|
324
324
|
},
|
|
325
325
|
{
|
|
@@ -344,9 +344,9 @@ export const SKILLS_CATALOG = [
|
|
|
344
344
|
},
|
|
345
345
|
{
|
|
346
346
|
"name": "jira-issue-to-pr",
|
|
347
|
-
"description": "This skill should be used when the user asks to \"set up a Jira automation to create pull requests\", \"poll Jira for create-pr issues\", \"automatically create GitHub PRs from Jira tickets\", \"deploy a Jira issue-to-PR automation\", \"create a Jira to GitHub PR workflow\", or mentions automating
|
|
347
|
+
"description": "This skill should be used when the user asks to \"set up a Jira automation to create pull requests\", \"poll Jira for create-pr issues\", \"automatically create GitHub PRs from Jira tickets\", \"deploy a Jira issue-to-PR automation\", \"create a Jira to GitHub PR workflow\", \"set up the Jira issue to GitLab MR automation\", \"set up the Jira issue to Bitbucket PR automation\", \"open GitLab merge requests from Jira tickets\", \"open Bitbucket pull requests from Jira tickets\", or mentions automating pull request or merge request creation from a Jira label. Deploys a cron-based OpenHands automation that watches a Jira Cloud project for issues labeled with a configurable label (default: \"create-pr\") and spawns an agent conversation that opens a pull request on GitHub (the default), a merge request on GitLab, or a pull request on Bitbucket for each new issue found, chosen with git_provider. The target repository is read from the body of the Jira ticket - no repo parameter is required at deploy time.",
|
|
348
348
|
"triggers": [],
|
|
349
|
-
"content": "# Jira → GitHub PR Automation\n\nDeploys a cron automation that polls a Jira Cloud instance for open issues carrying a\nconfigurable label and, for each new issue, starts an OpenHands agent conversation that\nclones the GitHub repository specified in the ticket body, creates a branch, implements\nor placeholders the requested change, and opens a pull request. Once the conversation\nstarts, it also posts a comment on the Jira ticket: \"I'm on it: <conversation URL>\".\n\n## How It Works\n\n1. **Poll** - every N minutes, `POST /rest/api/3/search/jql` on the Jira Cloud instance\n to find open issues with the configured label.\n2. **Deduplicate** - on the very first run the script records a `first_run_at` baseline\n timestamp in the KV store; any issue whose `updated` timestamp predates that baseline\n is skipped (no backfill blast on first deploy). Using `updated` rather than `created`\n means an old issue that has its label added after the automation is deployed will still\n be picked up. Subsequent runs filter by both `first_run_at` and a KV-backed set of\n already-processed issue keys. A `max_new_per_run` cap (default 5) limits conversations\n started per cron firing as additional defense-in-depth.\n3. **Dispatch** - for each new issue, start an independent agent conversation with a\n PR-creation prompt: `POST /api/conversations` on the agent server when running locally,\n or `POST /api/v1/app-conversations` on OpenHands Cloud. The prompt instructs the agent\n to extract the target GitHub repository (`owner/repo`) from the ticket body.\n4. **Comment** - immediately after the conversation is created, post a Jira comment on the\n issue: `I'm on it: <conversation URL>`.\n5. **Persist** - record the processed issue key so re-runs never duplicate work.\n\nThe polling run is lightweight (stdlib only, no SDK install); LLM costs are incurred only\nwhen new issues are actually found.\n\n## Prerequisites\n\nBefore deploying, ensure the following are in place:\n\n| Requirement | Details |\n|---|---|\n| **Jira API token** | Stored as an OpenHands secret (see [Jira API token setup](#jira-api-token)) |\n| **GitHub access** | Local: a GitHub token stored as an OpenHands secret with `repo` + `workflow` scope so the spawned conversation can push branches and open PRs. OpenHands Cloud: the spawned conversation uses the user's connected GitHub integration (native integration recommended, or the GitHub MCP server) |\n| **KV store** (OpenHands Cloud only) | The automation service must have its KV store enabled (`kvStore` in `GET /api/automation/v1/capabilities`); each cloud run starts in a fresh sandbox, so processed issues are remembered there |\n| **Jira label** | The label to watch for (default: `create-pr`) must exist in the Jira project |\n| **GitHub repo** | The target repository must exist and the GitHub token must have write access |\n\n## Deploying the Automation\n\n### Determine the API host and auth\n\nSet `OPENHANDS_HOST` and `AUTH_HEADER` for the curl commands below:\n\n- **OpenHands Cloud** - a `<HOST>` value is present in the system prompt:\n ```bash\n OPENHANDS_HOST=\"<HOST value>\"\n AUTH_HEADER=\"Authorization: Bearer $OPENHANDS_API_KEY\"\n ```\n- **Local Agent Canvas** - no `<HOST>` value:\n ```bash\n OPENHANDS_HOST=\"http://localhost:8000\"\n AUTH_HEADER=\"X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY\"\n ```\n\n### Step 1 - Collect parameters\n\nGather the following from the user before proceeding:\n\n| Parameter | Example | Notes |\n|---|---|---|\n| `jira_base_url` | `https://acme.atlassian.net` | No trailing slash |\n| `jira_email` | `alice@acme.com` | Atlassian account email for Basic auth |\n| `jira_token_secret` | `JIRA_CLOUD_KEY` | Name of the OpenHands secret holding the API token |\n| `jira_label` | `create-pr` | Label to watch for (optional, defaults to `create-pr`) |\n| `max_new_per_run` | `5` | Max conversations dispatched per cron firing (optional, defaults to `5`) |\n| `cron_schedule` | `*/5 * * * *` | Polling frequency in cron syntax |\n\n> **Note**: The GitHub repository is not configured here. Each Jira ticket body must include\n> a reference to the target GitHub repo in `owner/repo` format (e.g. `acme-org/backend`).\n> The spawned agent extracts it from the ticket text.\n\n### Step 2 - Create config.json\n\nCreate `config.json` next to `scripts/main.py` when packaging:\n\n```json\n{\n \"jira_base_url\": \"https://acme.atlassian.net\",\n \"jira_email\": \"alice@acme.com\",\n \"jira_token_secret\": \"JIRA_CLOUD_KEY\",\n \"jira_label\": \"create-pr\",\n \"max_new_per_run\": 5\n}\n```\n\n### Step 3 - Package the tarball\n\nCopy `scripts/main.py` from this skill and package it with the `config.json`:\n\n```bash\nWORK=$(mktemp -d)\ncp <skill-dir>/scripts/main.py \"$WORK/main.py\"\n# write config.json into $WORK/config.json (see Step 2)\ntar -czf /tmp/jira-issue-to-pr.tar.gz -C \"$WORK\" .\npython3 -m py_compile \"$WORK/main.py\" # validate syntax before uploading\n```\n\n### Step 4 - Upload the tarball\n\n```bash\nTARBALL_PATH=$(curl -s -X POST \\\n \"${OPENHANDS_HOST}/api/automation/v1/uploads?name=jira-issue-to-pr\" \\\n -H \"$AUTH_HEADER\" \\\n -H \"Content-Type: application/gzip\" \\\n --data-binary @/tmp/jira-issue-to-pr.tar.gz \\\n | python3 -c \"import sys,json; print(json.load(sys.stdin)['tarball_path'])\")\n```\n\n### Step 5 - Create the automation\n\n```bash\ncurl -s -X POST \"${OPENHANDS_HOST}/api/automation/v1\" \\\n -H \"$AUTH_HEADER\" \\\n -H \"Content-Type: application/json\" \\\n -d \"{\n \\\"name\\\": \\\"Jira issue-to-PR Poller\\\",\n \\\"trigger\\\": {\n \\\"type\\\": \\\"cron\\\",\n \\\"schedule\\\": \\\"*/5 * * * *\\\",\n \\\"timezone\\\": \\\"UTC\\\"\n },\n \\\"tarball_path\\\": \\\"$TARBALL_PATH\\\",\n \\\"entrypoint\\\": \\\"python3 main.py\\\",\n \\\"timeout\\\": 540\n }\" | python3 -m json.tool\n```\n\nSave the returned `id` - use it for updates and monitoring.\n\n### Step 6 - Verify with a test dispatch\n\n```bash\ncurl -s -X POST \\\n \"${OPENHANDS_HOST}/api/automation/v1/<AUTOMATION_ID>/dispatch\" \\\n -H \"$AUTH_HEADER\" | python3 -m json.tool\n\n# After ~30 seconds, check the run status:\ncurl -s \"${OPENHANDS_HOST}/api/automation/v1/<AUTOMATION_ID>/runs?limit=1\" \\\n -H \"$AUTH_HEADER\" \\\n | python3 -c \"import sys,json; r=json.load(sys.stdin)['runs'][0]; print(r['status'], r.get('error_detail'))\"\n```\n\n## Event-Based Alternative\n\nWhere Jira can reach the deployment over the internet (OpenHands Cloud and Enterprise),\nJira can push label events instead of being polled. That is a different automation from\nthe poller above: a prompt automation triggered by a Jira webhook, created with the\n`openhands-automation` skill (see its \"Custom Webhook Example: Jira Cloud\").\n`scripts/main.py` is not used, so its KV-backed deduplication and its \"I'm on it\"\ncomment do not apply; the prompt has to ask for whatever of that is wanted.\n\n### Set up the webhook\n\nThe webhook is a pair: a custom webhook source in OpenHands and an admin webhook in\nJira, sharing a signing secret. `scripts/setup_webhook.py` creates both, so the user\ndoes not have to open Jira or copy a URL and secret by hand. It needs the API token of\nan account with the **Administer Jira** permission, stored as an OpenHands secret.\n\n1. **Collect** the Jira site URL, the account email, the name of the secret holding that\n account's API token, and the label. A token used only for this step (for example\n `JIRA_ADMIN_TOKEN`) can be deleted from the user's secrets afterwards.\n2. **Dry run, then ask.** Registering a webhook changes the user's Jira site, so show\n what will be created and wait for the user to confirm:\n\n ```bash\n OPENHANDS_API_KEY=\"$OPENHANDS_API_KEY\" python3 <skill-dir>/scripts/setup_webhook.py apply --dry-run \\\n --jira-base-url \"https://acme.atlassian.net\" --jira-email \"alice@acme.com\" \\\n --jira-token-env JIRA_ADMIN_TOKEN --openhands-host \"$OPENHANDS_HOST\" --label create-pr\n ```\n\n The command has to name the secret (`--jira-token-env JIRA_ADMIN_TOKEN`): naming it is\n what makes its value available to the command.\n3. **Apply** - the same command without `--dry-run`. It registers the OpenHands webhook\n (`source` `jira`, `webhookEvent`, `X-Hub-Signature`), creates the Jira webhook for\n *Issue created* and *Issue updated* limited to `labels = \"create-pr\"`, and sends one\n signed test request. `\"signed_delivery_check\": \"ok\"` in its output means the pair works.\n Running it again updates the Jira webhook in place and keeps the secret, so a\n failure half way cannot break a working pair. Add `--rotate-secret` when the two\n sides have drifted apart, for instance after the deployment was reinstalled.\n\n The secret is generated inside the script and is never printed. Do not ask the user\n for it, and do not try to read or echo it.\n4. **If it exits with code 3**, the account is not a Jira administrator. Fall back to\n [Manual webhook setup](#manual-webhook-setup).\n\nTo stop Jira sending events, for example when the automation is deleted, run the same\ncommand with `delete` in place of `apply`.\n\n### Create the event-triggered automation\n\nCreate the automation with this trigger:\n\n```json\n{\n \"type\": \"event\",\n \"source\": \"jira\",\n \"on\": [\"jira:issue_created\", \"jira:issue_updated\"],\n \"filter\": \"contains(issue.fields.labels, 'create-pr') && (webhookEvent == 'jira:issue_created' || length(changelog.items[?field == 'labels'] || `[]`) > `0`)\"\n}\n```\n\nJira sends labels as plain strings, so the filter reads `issue.fields.labels`, not\n`issue.fields.labels[].name`. The `changelog` check limits updates to those that\nchange the labels; without it every later edit of a labelled issue starts another run.\nIt still fires when another label is added to or removed from an issue that\ncarries the label, so have the prompt skip an issue that already has a pull request.\n\nTo verify, add the label to an issue and check the automation's runs.\n\n### Manual webhook setup\n\nUse this when the Jira account is not an administrator: the user, or their Jira\nadministrator, does both steps by hand.\n\n1. **Register the webhook** with `\"source\": \"jira\"`, `\"event_key_expr\": \"webhookEvent\"`\n and `\"signature_header\": \"X-Hub-Signature\"` (see the `openhands-automation` skill for\n the request). The defaults (`type`, `X-Signature-256`) do not fit Jira: deliveries are\n refused with 401, or accepted and never matched.\n2. **Configure Jira** - a Jira admin creates the webhook under Jira settings → System →\n WebHooks with the returned `webhook_url` and secret, the *Issue created* and\n *Issue updated* events, and a JQL filter such as `labels = create-pr`. A Jira\n Automation rule cannot be used instead: its \"Send web request\" action is not signed.\n\nTo test a manually configured webhook without Jira, sign a request the way Jira does:\n\n```bash\nBODY='{\"webhookEvent\":\"jira:issue_created\",\"issue\":{\"key\":\"TEST-1\",\"fields\":{\"labels\":[\"create-pr\"]}}}'\nSIG=$(printf '%s' \"$BODY\" | openssl dgst -sha256 -hmac \"$WEBHOOK_SECRET\" | awk '{print $NF}')\ncurl -s -X POST \"<webhook_url>\" -H \"Content-Type: application/json\" \\\n -H \"X-Hub-Signature: sha256=$SIG\" -d \"$BODY\"\n```\n\n`\"matched\": 1` in the response means the trigger fired and a run was started.\n\n## Updating an Existing Deployment\n\nTo change configuration or update the script:\n\n1. Edit `config.json` with new values.\n2. Repackage and upload a new tarball (Steps 3-4 above).\n3. PATCH the existing automation with the new `tarball_path`:\n\n```bash\ncurl -s -X PATCH \\\n \"${OPENHANDS_HOST}/api/automation/v1/<AUTOMATION_ID>\" \\\n -H \"$AUTH_HEADER\" \\\n -H \"Content-Type: application/json\" \\\n -d \"{\\\"tarball_path\\\": \\\"<NEW_TARBALL_PATH>\\\"}\"\n```\n\n## Resetting Processed State\n\nTo reprocess issues that were already handled (e.g., after testing), clear the KV store:\n\n```bash\ncurl -s -X DELETE \\\n \"${OPENHANDS_HOST}/api/automation/v1/<KV_BASE>/v1/kv/state\" \\\n -H \"Authorization: Bearer $AUTOMATION_KV_TOKEN\"\n```\n\nOr delete and recreate the automation to start with a clean state.\n\n## Script Reference\n\nThe automation script lives at `scripts/main.py`. Key behaviors:\n\n- **No SDK dependencies** - pure Python stdlib; no `setup.sh` or `uv` install needed.\n- **Config file** - reads all parameters from `config.json` co-located with the script.\n- **First-run baseline** - on the very first execution the script writes `first_run_at` (UTC timestamp) into the KV store and exits without dispatching; issues whose `updated` timestamp predates that baseline are skipped on all subsequent runs. Using `updated` (not `created`) means an old issue that has its label applied after deployment is correctly treated as new.\n- **Per-run cap** - `max_new_per_run` (default 5) limits how many conversations are started per cron firing; any remaining new issues are dispatched on the next run.\n- **KV store** - persists `{\"processed_keys\": [...], \"first_run_at\": \"...\"}` between runs; falls back to a local file in local dev environments where `AUTOMATION_KV_TOKEN` is absent (on OpenHands Cloud the run fails instead, since its sandbox does not persist).\n- **Jira API** - uses `POST /rest/api/3/search/jql` (the current non-deprecated endpoint).\n- **Conversation dispatch** - locally, calls `POST /api/conversations` on the agent server with the current user's LLM/agent settings forwarded to the new conversation; on OpenHands Cloud, calls `POST /api/v1/app-conversations`, which runs each conversation in its own sandbox with the user's settings, secrets and connected git provider.\n- **Start confirmation** (OpenHands Cloud only) - the API answers a start request before the conversation exists, so the next run checks each one. A start that failed is made again, up to three times, and after that the failure is posted on the Jira issue.\n- **Error transparency** - captures Jira HTTP response bodies in error messages for fast diagnosis.\n\n`scripts/setup_webhook.py` belongs to the [event-based alternative](#event-based-alternative) and is not part of the poller's tarball. It is stdlib-only, reads `OPENHANDS_API_KEY` and the Jira API token from the environment, and prints a JSON summary that never contains the signing secret.\n\n## Known Limitations\n\n### Pre-existing issues updated after deployment\n\nThe deduplication filter compares each issue's `fields.updated` timestamp against\n`first_run_at`. `updated` is Jira's last-modified timestamp for the issue as a whole —\nit advances whenever **any** field changes (comments, priority, description, status, etc.),\nnot only when the `create-pr` label is applied.\n\nThis means a pre-existing issue that already carried the label at deployment time can\nslip through the filter if it is later updated for an unrelated reason (e.g. someone adds\na comment), because its `updated` timestamp will have advanced past `first_run_at` while\nits key is not yet in `processed_keys`.\n\n**Workaround:** The only fully reliable way to detect exactly when a label was applied\nis the Jira changelog API (`GET /rest/api/3/issue/{key}/changelog`), which requires an\nextra HTTP call per issue. To avoid that overhead, keep the automation's scope narrow:\nuse a label that is exclusively added as a PR-creation signal and is not already present\non issues at the time of deployment.\n\nOnce an issue is successfully dispatched its key is written to `processed_keys` in the\nKV store and is **permanently skipped on every future run** — regardless of subsequent\nlabel changes, comments, or any other updates to the issue. The only way to re-trigger a\npreviously processed issue is to manually clear the KV store or delete and recreate the\nautomation. This means the risk window described above is finite: as soon as the\nautomation processes a pre-existing issue (even accidentally), it will never dispatch\nthat issue again.\n\n## Additional Resources\n\n- **`references/setup.md`** - Jira API token creation, GitHub token scopes, cron schedule reference, and troubleshooting guide.",
|
|
349
|
+
"content": "# Jira → Pull Request Automation (GitHub, GitLab, Bitbucket)\n\nDeploys a cron automation that polls a Jira Cloud instance for open issues carrying a\nconfigurable label and, for each new issue, starts an OpenHands agent conversation that\nclones the repository specified in the ticket body on the configured git provider (GitHub\nby default, GitLab or Bitbucket with `git_provider`), creates a branch, implements or\nplaceholders the requested change, and opens a pull request (a merge request on GitLab).\nOnce the conversation\nstarts, it also posts a comment on the Jira ticket: \"I'm on it: <conversation URL>\".\n\n## How It Works\n\n1. **Poll** - every N minutes, search the Jira Cloud instance for open issues with the\n configured label: through the user's connected Atlassian Rovo MCP server on OpenHands\n Cloud and Enterprise, or with `POST /rest/api/3/search/jql` and an API token.\n2. **Deduplicate** - on the very first run the script records a `first_run_at` baseline\n timestamp in the KV store; any issue whose `updated` timestamp predates that baseline\n is skipped (no backfill blast on first deploy). Using `updated` rather than `created`\n means an old issue that has its label added after the automation is deployed will still\n be picked up. Subsequent runs filter by both `first_run_at` and a KV-backed set of\n already-processed issue keys. A `max_new_per_run` cap (default 5) limits conversations\n started per cron firing as additional defense-in-depth.\n3. **Dispatch** - for each new issue, start an independent agent conversation with a\n PR-creation prompt: `POST /api/conversations` on the agent server when running locally,\n or `POST /api/v1/app-conversations` on OpenHands Cloud. The prompt instructs the agent\n to extract the target repository (`owner/repo` on GitHub; `group/project` or a URL on GitLab;\n `workspace/repo` on Bitbucket) from the ticket body.\n4. **Comment** - immediately after the conversation is created, post a Jira comment on the\n issue: `I'm on it: <conversation URL>`.\n5. **Persist** - record the processed issue key so re-runs never duplicate work.\n\nThe polling run is lightweight (stdlib only, no SDK install); LLM costs are incurred only\nwhen new issues are actually found.\n\n## Prerequisites\n\nBefore deploying, ensure the following are in place:\n\n| Requirement | Details |\n|---|---|\n| **Jira access** | OpenHands Cloud and Enterprise: the user's connected Atlassian Rovo MCP server, with no API token. Local, or without that connection: a Jira API token stored as an OpenHands secret (see [Jira API token setup](#jira-api-token)) |\n| **Git provider access** | OpenHands Cloud and Enterprise: the user's connected GitHub, GitLab or Bitbucket integration, whichever `git_provider` names. A connected MCP server also works - GitHub's (with the saved access token), GitLab's, or Atlassian Rovo for Bitbucket - with a private repository read through the tools rather than cloned. Local: a token for that provider stored as an OpenHands secret (`GITHUB_TOKEN` with `repo` + `workflow`, `GITLAB_TOKEN` with `api`, `BITBUCKET_TOKEN`) so the spawned conversation can push branches and open the request |\n| **KV store** (OpenHands Cloud only) | The automation service must have its KV store enabled (`kvStore` in `GET /api/automation/v1/capabilities`); each cloud run starts in a fresh sandbox, so processed issues are remembered there |\n| **Jira label** | The label to watch for (default: `create-pr`) must exist in the Jira project |\n| **Target repository** | Must exist on the configured provider; the connected integration or token must have write access |\n\n## Deploying the Automation\n\n### Determine the API host and auth\n\nSet `OPENHANDS_HOST` and `AUTH_HEADER` for the curl commands below:\n\n- **OpenHands Cloud** - a `<HOST>` value is present in the system prompt:\n ```bash\n OPENHANDS_HOST=\"<HOST value>\"\n AUTH_HEADER=\"Authorization: Bearer $OPENHANDS_API_KEY\"\n ```\n- **Local Agent Canvas** - no `<HOST>` value:\n ```bash\n OPENHANDS_HOST=\"http://localhost:8000\"\n AUTH_HEADER=\"X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY\"\n ```\n\n### Step 1 - Collect parameters\n\nFirst decide how the poller reaches Jira:\n\n- **Connected Atlassian Rovo MCP server** - on OpenHands Cloud and Enterprise, when the\n user has connected it. OpenHands calls Jira for the poller through that connection, so\n do not ask for a Jira email or an API token and do not create a secret.\n- **Jira API token** - on a local Agent Canvas, or when the Rovo server is not connected.\n\nThen pick `git_provider` from the automation the user asked for: `github` for *Jira issue to\nGitHub PR* (the default, so it can be left out), `gitlab` for *Jira issue to GitLab MR*,\n`bitbucket` for *Jira issue to Bitbucket PR*. It changes only what the conversation is told;\nthe conversation pushes with the user's connected integration for that provider, or delivers\nthrough the connected MCP server's tools when it has no token.\n\nThen gather the parameters for that route, plus the shared ones, before proceeding:\n\n| Parameter | Example | Notes |\n|---|---|---|\n| `jira_mcp_server` | `atlassian-rovo` | Rovo connection only. The name the server is stored under: the key in `agent_settings.mcp_config` of `GET ${OPENHANDS_HOST}/api/v1/settings` whose `url` is on `mcp.atlassian.com`. Look it up; do not ask the user |\n| `jira_cloud_id` | `1324a887-45db-1bf4-1e99-ef0ff456d421` | Rovo connection only. The `id` of the Jira site, from the Rovo tool `getAccessibleAtlassianResources`. Ask which site only when it lists several |\n| `jira_base_url` | `https://acme.atlassian.net` | API token only. No trailing slash |\n| `jira_email` | `alice@acme.com` | API token only. Atlassian account email for Basic auth |\n| `jira_token_secret` | `JIRA_CLOUD_KEY` | API token only. Name of the OpenHands secret holding the API token |\n| `jira_label` | `create-pr` | Label to watch for (optional, defaults to `create-pr`) |\n| `git_provider` | `gitlab` | `github` (default), `gitlab` or `bitbucket` - the provider the ticket names its repository on (optional) |\n| `max_new_per_run` | `5` | Max conversations dispatched per cron firing (optional, defaults to `5`) |\n| `cron_schedule` | `*/5 * * * *` | Polling frequency in cron syntax |\n\n> **Note**: The repository is not configured here. Each Jira ticket body must name it:\n> `owner/repo` on GitHub, `group/subgroup/project` or a full URL on GitLab (self-managed\n> hosts included), `workspace/repo` on bitbucket.org. The spawned agent extracts it from\n> the ticket text.\n\n### Step 2 - Create config.json\n\nCreate `config.json` next to `scripts/main.py` when packaging. With the connected\nAtlassian Rovo MCP server:\n\n```json\n{\n \"jira_mcp_server\": \"atlassian-rovo\",\n \"jira_cloud_id\": \"1324a887-45db-1bf4-1e99-ef0ff456d421\",\n \"jira_label\": \"create-pr\",\n \"max_new_per_run\": 5\n}\n```\n\nWith a Jira API token:\n\n```json\n{\n \"jira_base_url\": \"https://acme.atlassian.net\",\n \"jira_email\": \"alice@acme.com\",\n \"jira_token_secret\": \"JIRA_CLOUD_KEY\",\n \"jira_label\": \"create-pr\",\n \"max_new_per_run\": 5\n}\n```\n\nFor the GitLab and Bitbucket templates add `\"git_provider\": \"gitlab\"` or\n`\"git_provider\": \"bitbucket\"` to either example; it is `github` when omitted.\n\n### Step 3 - Package the tarball\n\nCopy `scripts/main.py` from this skill and package it with the `config.json`:\n\n```bash\nWORK=$(mktemp -d)\ncp <skill-dir>/scripts/main.py \"$WORK/main.py\"\n# write config.json into $WORK/config.json (see Step 2)\ntar -czf /tmp/jira-issue-to-pr.tar.gz -C \"$WORK\" .\npython3 -m py_compile \"$WORK/main.py\" # validate syntax before uploading\n```\n\n### Step 4 - Upload the tarball\n\n```bash\nTARBALL_PATH=$(curl -s -X POST \\\n \"${OPENHANDS_HOST}/api/automation/v1/uploads?name=jira-issue-to-pr\" \\\n -H \"$AUTH_HEADER\" \\\n -H \"Content-Type: application/gzip\" \\\n --data-binary @/tmp/jira-issue-to-pr.tar.gz \\\n | python3 -c \"import sys,json; print(json.load(sys.stdin)['tarball_path'])\")\n```\n\n### Step 5 - Create the automation\n\n```bash\ncurl -s -X POST \"${OPENHANDS_HOST}/api/automation/v1\" \\\n -H \"$AUTH_HEADER\" \\\n -H \"Content-Type: application/json\" \\\n -d \"{\n \\\"name\\\": \\\"Jira issue-to-PR Poller\\\",\n \\\"trigger\\\": {\n \\\"type\\\": \\\"cron\\\",\n \\\"schedule\\\": \\\"*/5 * * * *\\\",\n \\\"timezone\\\": \\\"UTC\\\"\n },\n \\\"tarball_path\\\": \\\"$TARBALL_PATH\\\",\n \\\"entrypoint\\\": \\\"python3 main.py\\\",\n \\\"timeout\\\": 540\n }\" | python3 -m json.tool\n```\n\nSave the returned `id` - use it for updates and monitoring. Name the automation after the\ntemplate the user picked, for example `Jira issue-to-MR Poller` for GitLab.\n\n### Step 6 - Verify with a test dispatch\n\n```bash\ncurl -s -X POST \\\n \"${OPENHANDS_HOST}/api/automation/v1/<AUTOMATION_ID>/dispatch\" \\\n -H \"$AUTH_HEADER\" | python3 -m json.tool\n\n# After ~30 seconds, check the run status:\ncurl -s \"${OPENHANDS_HOST}/api/automation/v1/<AUTOMATION_ID>/runs?limit=1\" \\\n -H \"$AUTH_HEADER\" \\\n | python3 -c \"import sys,json; r=json.load(sys.stdin)['runs'][0]; print(r['status'], r.get('error_detail'))\"\n```\n\nWith the Rovo connection, a run that fails on `/api/v1/mcp/servers/<name>/call-tool` says why:\n\n- `404` with `No connected MCP server is named ...` - `jira_mcp_server` is not the name the\n server is stored under, or the user switched the server off.\n- `404` with `Not Found` - this deployment cannot call a connected MCP server for a run yet.\n Set the automation up with a Jira API token instead.\n- `502` - the server could not be reached, or the user has to reconnect it in the settings.\n\n## Event-Based Alternative\n\nWhere Jira can reach the deployment over the internet (OpenHands Cloud and Enterprise),\nJira can push label events instead of being polled. That is a different automation from\nthe poller above: a prompt automation triggered by a Jira webhook, created with the\n`openhands-automation` skill (see its \"Custom Webhook Example: Jira Cloud\").\n`scripts/main.py` is not used, so its KV-backed deduplication and its \"I'm on it\"\ncomment do not apply; the prompt has to ask for whatever of that is wanted.\n\n### Set up the webhook\n\nThe webhook is a pair: a custom webhook source in OpenHands and an admin webhook in\nJira, sharing a signing secret. `scripts/setup_webhook.py` creates both, so the user\ndoes not have to open Jira or copy a URL and secret by hand. It needs the API token of\nan account with the **Administer Jira** permission, stored as an OpenHands secret.\n\n1. **Collect** the Jira site URL, the account email, the name of the secret holding that\n account's API token, and the label. A token used only for this step (for example\n `JIRA_ADMIN_TOKEN`) can be deleted from the user's secrets afterwards.\n2. **Dry run, then ask.** Registering a webhook changes the user's Jira site, so show\n what will be created and wait for the user to confirm:\n\n ```bash\n OPENHANDS_API_KEY=\"$OPENHANDS_API_KEY\" python3 <skill-dir>/scripts/setup_webhook.py apply --dry-run \\\n --jira-base-url \"https://acme.atlassian.net\" --jira-email \"alice@acme.com\" \\\n --jira-token-env JIRA_ADMIN_TOKEN --openhands-host \"$OPENHANDS_HOST\" --label create-pr\n ```\n\n The command has to name the secret (`--jira-token-env JIRA_ADMIN_TOKEN`): naming it is\n what makes its value available to the command.\n3. **Apply** - the same command without `--dry-run`. It registers the OpenHands webhook\n (`source` `jira`, `webhookEvent`, `X-Hub-Signature`), creates the Jira webhook for\n *Issue created* and *Issue updated* limited to `labels = \"create-pr\"`, and sends one\n signed test request. `\"signed_delivery_check\": \"ok\"` in its output means the pair works.\n Running it again updates the Jira webhook in place and keeps the secret, so a\n failure half way cannot break a working pair. Add `--rotate-secret` when the two\n sides have drifted apart, for instance after the deployment was reinstalled.\n\n The secret is generated inside the script and is never printed. Do not ask the user\n for it, and do not try to read or echo it.\n4. **If it exits with code 3**, the account is not a Jira administrator. Fall back to\n [Manual webhook setup](#manual-webhook-setup).\n\nTo stop Jira sending events, for example when the automation is deleted, run the same\ncommand with `delete` in place of `apply`.\n\n### Create the event-triggered automation\n\nCreate the automation with this trigger:\n\n```json\n{\n \"type\": \"event\",\n \"source\": \"jira\",\n \"on\": [\"jira:issue_created\", \"jira:issue_updated\"],\n \"filter\": \"contains(issue.fields.labels, 'create-pr') && (webhookEvent == 'jira:issue_created' || length(changelog.items[?field == 'labels'] || `[]`) > `0`)\"\n}\n```\n\nJira sends labels as plain strings, so the filter reads `issue.fields.labels`, not\n`issue.fields.labels[].name`. The `changelog` check limits updates to those that\nchange the labels; without it every later edit of a labelled issue starts another run.\nIt still fires when another label is added to or removed from an issue that\ncarries the label, so have the prompt skip an issue that already has a pull request.\n\nTo verify, add the label to an issue and check the automation's runs.\n\n### Manual webhook setup\n\nUse this when the Jira account is not an administrator: the user, or their Jira\nadministrator, does both steps by hand.\n\n1. **Register the webhook** with `\"source\": \"jira\"`, `\"event_key_expr\": \"webhookEvent\"`\n and `\"signature_header\": \"X-Hub-Signature\"` (see the `openhands-automation` skill for\n the request). The defaults (`type`, `X-Signature-256`) do not fit Jira: deliveries are\n refused with 401, or accepted and never matched.\n2. **Configure Jira** - a Jira admin creates the webhook under Jira settings → System →\n WebHooks with the returned `webhook_url` and secret, the *Issue created* and\n *Issue updated* events, and a JQL filter such as `labels = create-pr`. A Jira\n Automation rule cannot be used instead: its \"Send web request\" action is not signed.\n\nTo test a manually configured webhook without Jira, sign a request the way Jira does:\n\n```bash\nBODY='{\"webhookEvent\":\"jira:issue_created\",\"issue\":{\"key\":\"TEST-1\",\"fields\":{\"labels\":[\"create-pr\"]}}}'\nSIG=$(printf '%s' \"$BODY\" | openssl dgst -sha256 -hmac \"$WEBHOOK_SECRET\" | awk '{print $NF}')\ncurl -s -X POST \"<webhook_url>\" -H \"Content-Type: application/json\" \\\n -H \"X-Hub-Signature: sha256=$SIG\" -d \"$BODY\"\n```\n\n`\"matched\": 1` in the response means the trigger fired and a run was started.\n\n## Updating an Existing Deployment\n\nTo change configuration or update the script:\n\n1. Edit `config.json` with new values.\n2. Repackage and upload a new tarball (Steps 3-4 above).\n3. PATCH the existing automation with the new `tarball_path`:\n\n```bash\ncurl -s -X PATCH \\\n \"${OPENHANDS_HOST}/api/automation/v1/<AUTOMATION_ID>\" \\\n -H \"$AUTH_HEADER\" \\\n -H \"Content-Type: application/json\" \\\n -d \"{\\\"tarball_path\\\": \\\"<NEW_TARBALL_PATH>\\\"}\"\n```\n\n## Resetting Processed State\n\nTo reprocess issues that were already handled (e.g., after testing), clear the KV store:\n\n```bash\ncurl -s -X DELETE \\\n \"${OPENHANDS_HOST}/api/automation/v1/<KV_BASE>/v1/kv/state\" \\\n -H \"Authorization: Bearer $AUTOMATION_KV_TOKEN\"\n```\n\nOr delete and recreate the automation to start with a clean state.\n\n## Script Reference\n\nThe automation script lives at `scripts/main.py`. Key behaviors:\n\n- **No SDK dependencies** - pure Python stdlib; no `setup.sh` or `uv` install needed.\n- **Config file** - reads all parameters from `config.json` co-located with the script.\n- **First-run baseline** - on the very first execution the script writes `first_run_at` (UTC timestamp) into the KV store and exits without dispatching; issues whose `updated` timestamp predates that baseline are skipped on all subsequent runs. Using `updated` (not `created`) means an old issue that has its label applied after deployment is correctly treated as new.\n- **Per-run cap** - `max_new_per_run` (default 5) limits how many conversations are started per cron firing; any remaining new issues are dispatched on the next run.\n- **KV store** - persists `{\"processed_keys\": [...], \"first_run_at\": \"...\"}` between runs; falls back to a local file in local dev environments where `AUTOMATION_KV_TOKEN` is absent (on OpenHands Cloud the run fails instead, since its sandbox does not persist).\n- **Jira access** - with `jira_mcp_server` set, searches and comments through the user's connected Atlassian Rovo MCP server (`POST /api/v1/mcp/servers/<name>/call-tool` on OpenHands Cloud, tools `searchJiraIssuesUsingJql` and `addOrEditJiraIssueComment`, or `addCommentToJiraIssue` on a server that still offers the v1 tools), so the run reads no Jira credential. Otherwise uses the Jira REST API with the token secret: `POST /rest/api/3/search/jql` (the current non-deprecated endpoint).\n- **Git provider** - `git_provider` selects the prompt's wording (GitHub pull request, GitLab merge request, Bitbucket pull request), the repository formats the agent looks for, and how it delivers without a token (GitLab's MCP tools, Rovo's Bitbucket tools); nothing else differs between the three templates.\n- **Conversation dispatch** - locally, calls `POST /api/conversations` on the agent server with the current user's LLM/agent settings forwarded to the new conversation; on OpenHands Cloud, calls `POST /api/v1/app-conversations`, which runs each conversation in its own sandbox with the user's settings, secrets and connected git provider.\n- **Start confirmation** (OpenHands Cloud only) - the API answers a start request before the conversation exists, so the next run checks each one. A start that failed is made again, up to three times, and after that the failure is posted on the Jira issue.\n- **Error transparency** - captures Jira HTTP response bodies in error messages for fast diagnosis.\n\n`scripts/setup_webhook.py` belongs to the [event-based alternative](#event-based-alternative) and is not part of the poller's tarball. It is stdlib-only, reads `OPENHANDS_API_KEY` and the Jira API token from the environment, and prints a JSON summary that never contains the signing secret.\n\n## Known Limitations\n\n### Pre-existing issues updated after deployment\n\nThe deduplication filter compares each issue's `fields.updated` timestamp against\n`first_run_at`. `updated` is Jira's last-modified timestamp for the issue as a whole —\nit advances whenever **any** field changes (comments, priority, description, status, etc.),\nnot only when the `create-pr` label is applied.\n\nThis means a pre-existing issue that already carried the label at deployment time can\nslip through the filter if it is later updated for an unrelated reason (e.g. someone adds\na comment), because its `updated` timestamp will have advanced past `first_run_at` while\nits key is not yet in `processed_keys`.\n\n**Workaround:** The only fully reliable way to detect exactly when a label was applied\nis the Jira changelog API (`GET /rest/api/3/issue/{key}/changelog`), which requires an\nextra HTTP call per issue. To avoid that overhead, keep the automation's scope narrow:\nuse a label that is exclusively added as a PR-creation signal and is not already present\non issues at the time of deployment.\n\nOnce an issue is successfully dispatched its key is written to `processed_keys` in the\nKV store and is **permanently skipped on every future run** — regardless of subsequent\nlabel changes, comments, or any other updates to the issue. The only way to re-trigger a\npreviously processed issue is to manually clear the KV store or delete and recreate the\nautomation. This means the risk window described above is finite: as soon as the\nautomation processes a pre-existing issue (even accidentally), it will never dispatch\nthat issue again.\n\n## Additional Resources\n\n- **`references/setup.md`** - Jira API token creation, git provider tokens, cron schedule reference, and troubleshooting guide.",
|
|
350
350
|
"category": "automations"
|
|
351
351
|
},
|
|
352
352
|
{
|
|
@@ -596,7 +596,7 @@ export const SKILLS_CATALOG = [
|
|
|
596
596
|
"triggers": [
|
|
597
597
|
"/slack-monitor:poll"
|
|
598
598
|
],
|
|
599
|
-
"content": "# Slack Channel Monitor\n\nCreate a cron automation that polls up to 10 Slack channels every minute.\nWindows PowerShell equivalents for the setup, packaging, upload, and API-check shell snippets are in `references/windows.md`.\nWhen a message containing the **trigger phrase** (default: `@openhands`) is\ndetected it:\n\n1. Adds a 👀 reaction to the triggering message.\n2. Opens an OpenHands conversation with the message and recent channel context.\n3. Posts a reply in the Slack thread with a link to the conversation.\n\nOn every subsequent run:\n- New Slack thread replies are forwarded only when they contain the trigger\n phrase, so unrelated conversation in the thread is ignored.\n- When the conversation finishes (or errors), the agent's final response is\n posted back to the Slack thread.\n- Completed conversations stay in a short follow-up watch window, allowing\n triggered Slack replies to continue the same OpenHands conversation.\n\n> **Local mode only.** This automation targets the local OpenHands setup\n> (`dev:automation` stack). A cloud/webhook-based variant is out of scope here.\n\n---\n\n## Prerequisites\n\n### Required secrets\n\nVerify that at least one of the following secrets is set in\n**OpenHands Settings → Secrets** before proceeding:\n\n| Secret name | Token type | Minimum scopes |\n|---|---|---|\n| `SLACK_BOT_TOKEN` | Bot (`xoxb-…`) | `channels:history`, `channels:read`, `reactions:write`, `chat:write` |\n| `SLACK_USER_TOKEN` | User (`xoxp-…`) | Same as bot, plus `search:read` for multi-channel efficiency |\n\nCheck with:\n```bash\n# For bot token:\ncurl -s https://slack.com/api/auth.test -H \"Authorization: Bearer $SLACK_BOT_TOKEN\" \\\n | python3 -c \"import json,sys; d=json.load(sys.stdin); print('ok' if d.get('ok') else d.get('error'))\"\n\n# For user token:\ncurl -s https://slack.com/api/auth.test -H \"Authorization: Bearer $SLACK_USER_TOKEN\" \\\n | python3 -c \"import json,sys; d=json.load(sys.stdin); print('ok' if d.get('ok') else d.get('error'))\"\n```\n\nIf neither token is present, inform the user and stop - the automation cannot\nfunction without Slack credentials.\n\n### Optional secret\n\n| Secret name | Default | Purpose |\n|---|---|---|\n| `OPENHANDS_URL` | `http://localhost:8000` | Base URL used to build conversation links posted in Slack |\n\n---\n\n## Setup Workflow\n\nFollow these steps in order.\n\n### Step 1 - Collect channels\n\nAsk the user: *\"Which Slack channels should be monitored? You can provide\nchannel names (e.g. `#general`) or IDs (e.g. `C0123456789`).\"*\n\n**If the user provides channel names**, resolve them to IDs:\n\n```bash\nSLACK_TOKEN=\"${SLACK_BOT_TOKEN:-$SLACK_USER_TOKEN}\"\ncurl -s \"https://slack.com/api/conversations.list?types=public_channel,private_channel&limit=200&exclude_archived=true\" \\\n -H \"Authorization: Bearer $SLACK_TOKEN\" \\\n | python3 -c \"\nimport json, sys\ndata = json.load(sys.stdin)\nif not data.get('ok'):\n print('ERROR:', data.get('error'))\n exit(1)\nnames = set(n.lstrip('#') for n in ['CHANNEL_NAMES_HERE'.split(',')])\nfor ch in data.get('channels', []):\n if ch['name'] in names:\n print(f\\\"{ch['name']} → {ch['id']}\\\")\n\"\n```\n\nReplace `CHANNEL_NAMES_HERE` with the comma-separated names the user provided.\n\n**If `conversations.list` returns `missing_scope` or `not_authed`:**\nInform the user: *\"The token doesn't have permission to list channels. Please\nprovide the channel IDs directly (right-click a channel in Slack → Copy link - \nthe last path segment starting with `C` is the ID).\"*\n\n**If the bot token lacks `channels:read`** for private channels, the user can\neither invite the bot first (`/invite @botname`) or switch to a user token.\n\nCollect up to 10 channel IDs. Record them as a Python list literal, e.g.:\n```python\n[\"C0123456789\", \"C9876543210\"]\n```\n\n### Step 2 - Collect trigger phrase\n\nAsk the user: *\"What trigger phrase should OpenHands respond to?\n(Press Enter to use the default: `@openhands`)\"*\n\nAccepted values: any non-empty string unlikely to appear accidentally, e.g.\n`@openhands`, `jazz hands`, `take-me-to-funky-town`.\n\n### Step 3 - Generate the automation script\n\nRead `scripts/main.py` from this skill's directory and **copy it verbatim**.\nApply exactly three constant substitutions near the top of the file:\n\n> **Do not reimplement, simplify, or hand-write a replacement script.**\n> The template already contains the correct secret-loading, state-path,\n> conversation-creation, and context-forwarding logic. Only the three\n> configuration constants below should change unless syntax validation fails.\n\n| Placeholder | Replace with |\n|---|---|\n| `TRIGGER_PHRASE = \"@openhands\"` | `TRIGGER_PHRASE = \"{user_phrase}\"` |\n| `CHANNEL_IDS: list[str] = []` | `CHANNEL_IDS: list[str] = {channel_id_list}` |\n| `DEFAULT_OPENHANDS_URL = \"http://localhost:8000\"` | `DEFAULT_OPENHANDS_URL = \"{url}\"` (keep default if user has no preference) |\n\nWrite the customised script to a temporary directory:\n```bash\nmkdir -p /tmp/slack-monitor-build\n# copy scripts/main.py to /tmp/slack-monitor-build/main.py\n# then replace only the three constants above\n```\n\nValidate syntax before packaging:\n```bash\npython3 -m py_compile /tmp/slack-monitor-build/main.py && echo \"Syntax OK\"\n```\n\nThen run a quick integrity check to confirm the template structure is still\npresent and only the configuration block was customised:\n```bash\ngrep -n 'TRIGGER_PHRASE = \"' /tmp/slack-monitor-build/main.py\ngrep -n 'CHANNEL_IDS: list\\[str\\] =' /tmp/slack-monitor-build/main.py\ngrep -n 'DEFAULT_OPENHANDS_URL = \"' /tmp/slack-monitor-build/main.py\ngrep -n 'def get_secret' /tmp/slack-monitor-build/main.py\ngrep -n 'def _state_file_path' /tmp/slack-monitor-build/main.py\ngrep -n 'def create_conversation' /tmp/slack-monitor-build/main.py\n```\n\nIf any of those checks fail, stop and re-copy the template instead of trying to\nrepair a hand-written variant.\n\n### Step 4 - Package and upload\n\nDetermine the Automation backend URL and auth from the `<RUNTIME_SERVICES>`\nblock in your system context:\n- Use the **Automation backend** `url_from_agent` as `OPENHANDS_HOST`\n- Auth: `X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY`\n\nIf no Automation backend is listed in `<RUNTIME_SERVICES>`, stop and tell\nthe user to start the full automation stack.\n\n```bash\ntar -czf /tmp/slack-monitor.tar.gz -C /tmp/slack-monitor-build .\n\n# OPENHANDS_HOST: read from <RUNTIME_SERVICES> Automation backend url_from_agent\nOPENHANDS_HOST=\"<automation-url-from-runtime-services>\"\n\nTARBALL_PATH=$(curl -s -X POST \\\n \"${OPENHANDS_HOST}/api/automation/v1/uploads?name=slack-channel-monitor\" \\\n -H \"X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY\" \\\n -H \"Content-Type: application/gzip\" \\\n --data-binary @/tmp/slack-monitor.tar.gz \\\n | python3 -c \"import json,sys; print(json.load(sys.stdin)['tarball_path'])\")\n\necho \"Uploaded: $TARBALL_PATH\"\n```\n\nIf the upload fails with a size error, the tarball must be under 1 MB.\n`main.py` is under 15 KB so this should never trigger.\n\n### Step 5 - Create the automation\n\n```bash\ncurl -s -X POST \"${OPENHANDS_HOST}/api/automation/v1\" \\\n -H \"X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d \"{\n \\\"name\\\": \\\"Slack Channel Monitor\\\",\n \\\"trigger\\\": {\\\"type\\\": \\\"cron\\\", \\\"schedule\\\": \\\"* * * * *\\\"},\n \\\"tarball_path\\\": \\\"$TARBALL_PATH\\\",\n \\\"entrypoint\\\": \\\"python3 main.py\\\",\n \\\"timeout\\\": 55\n }\" | python3 -m json.tool\n```\n\nA 55-second timeout keeps runs well within the 60-second cron window.\n\nRecord the returned `id` - share it with the user as confirmation.\n\n### Step 6 - Confirm\n\nTell the user:\n\n> ✅ **Slack Channel Monitor** is running!\n>\n> - Automation ID: `{id}`\n> - Channels: `{channel list}`\n> - Trigger phrase: `{phrase}`\n> - Polling every minute via cron `* * * * *`\n> - State file: `~/.openhands/workspaces/automation-state/slack_poller_{id}.json`\n>\n> Send a message containing `{phrase}` in any monitored channel to test it.\n> The bot will react with 👀 and reply with a link to the new conversation.\n\n---\n\n## Runtime Behaviour (per poll)\n\nEach cron run executes `main.py`, which runs **10 polling iterations** (every\n5 seconds) within the 55-second timeout window. Each iteration:\n\n1. **Loads state** from the JSON file (see `references/state-schema.md`).\n2. **Resolves the Slack token** - checks `SLACK_USER_TOKEN` then `SLACK_BOT_TOKEN`.\n3. **Fetches new messages:**\n - User token + `search:read` + > 1 channel → single `search.messages` call\n (searches for the trigger phrase across all channels).\n - Otherwise → one `conversations.history` call per channel.\n4. **Fetches due thread replies** - polls at most one tracked thread per\n iteration using per-thread exponential backoff to stay within Slack rate\n limits.\n5. **Processes messages** in chronological order:\n - Skips messages already in `processed_ts` (dedup across the overlap window).\n - Skips bot messages and any `ts` in `bot_message_ts`.\n - Reply in a tracked thread whose text contains the trigger phrase → forwards\n a follow-up request to the existing conversation and resets the follow-up\n watch window. Replies without the trigger phrase are marked processed and\n ignored.\n - Contains trigger phrase outside a tracked conversation → 👀 reaction, create\n a new conversation, post link.\n - Thread replies: agent receives full thread history for context.\n - Root messages: agent receives the trigger text only.\n6. **Checks conversation statuses** - for each active conversation where\n `time.time() - last_activity > 15 s`:\n - If status is `idle`, `finished`, `error`, or `stuck` → fetch the agent's\n final response via `/api/conversations/{id}/agent_final_response` and post\n it to the Slack thread using Slack's `markdown_text` field so Markdown\n formatting renders correctly. Mark the record `watching` for five minutes\n so triggered follow-up replies can continue the same conversation.\n7. **Advances `last_poll`** to `now - 10 s` (overlap window prevents boundary\n races). If a conversation creation failed, pins `last_poll` further back to\n retry on the next iteration.\n8. **Saves state** (including `processed_ts`) and continues to the next iteration.\n9. After all iterations, fires the completion callback.\n\nDebug output is written to both stdout and a persistent log at:\n```\n{WORKSPACE_BASE_ROOT}/automation-state/slack_poller_debug.log\n```\n\n---\n\n## Additional Resources\n\n### Reference Files\n\n- **`references/slack-api.md`** - Slack token types, required scopes, API\n endpoint reference, rate limits, and common error codes.\n- **`references/state-schema.md`** - State JSON schema, field definitions,\n example file, and conversation lifecycle diagram.\n\n### Script Template\n\n- **`scripts/main.py`** - The complete automation script. Customise the three\n constants at the top (`TRIGGER_PHRASE`, `CHANNEL_IDS`, `DEFAULT_OPENHANDS_URL`)\n before packaging.\n\n---\n\n## Troubleshooting\n\n| Symptom | Likely cause | Fix |\n|---|---|---|\n| Bot doesn't react to messages | Token missing or bot not in channel | Verify token with `auth.test`; `/invite @botname` |\n| `not_in_channel` error in run logs | Bot token used but bot not a member | Invite bot or switch to user token |\n| `missing_scope` error | Token lacks required scopes | Re-install Slack app with correct scopes (see `references/slack-api.md`) |\n| No messages detected | `last_poll` timestamp is in the future | Delete the state file to reset; it will be recreated on next run |\n| Conversation link 404 | `OPENHANDS_URL` points to wrong host | Set the `OPENHANDS_URL` secret to the correct base URL |\n| Summary never posted | Conversation stuck in `running` state | Check conversation in the OpenHands UI; the agent may need intervention |\n| Duplicate conversations created | `processed_ts` state missing or corrupted | Delete the state file to reset; dedup will rebuild on next run |\n| Trigger message processed on each cron run | State file deleted between runs | Ensure `automation-state/` directory is persistent across runs |\n| Debug info needed | Need detailed per-message trace | Check `{WORKSPACE_BASE_ROOT}/automation-state/slack_poller_debug.log` |",
|
|
599
|
+
"content": "# Slack Channel Monitor\n\nCreate a cron automation that polls up to 10 Slack channels every minute.\nWindows PowerShell equivalents for the setup, packaging, upload, and API-check shell snippets are in `references/windows.md`.\nWhen a message containing the **trigger phrase** (default: `@openhands`) is\ndetected it:\n\n1. Adds a 👀 reaction to the triggering message.\n2. Opens an OpenHands conversation with the message and recent channel context.\n3. Posts a reply in the Slack thread with a link to the conversation.\n\nOn every subsequent run:\n- New Slack thread replies are forwarded only when they contain the trigger\n phrase, so unrelated conversation in the thread is ignored.\n- When the conversation finishes (or errors), the agent's final response is\n posted back to the Slack thread.\n- Completed conversations stay in a short follow-up watch window, allowing\n triggered Slack replies to continue the same OpenHands conversation.\n\n> **Local mode only.** This automation targets the local OpenHands setup\n> (`dev:automation` stack). A cloud/webhook-based variant is out of scope here.\n\n---\n\n## Prerequisites\n\n### Required secrets\n\nVerify that at least one of the following secrets is set in\n**OpenHands Settings → Secrets** before proceeding:\n\n| Secret name | Token type | Minimum scopes |\n|---|---|---|\n| `SLACK_BOT_TOKEN` | Bot (`xoxb-…`) | `channels:history`, `channels:read`, `reactions:write`, `chat:write` |\n| `SLACK_USER_TOKEN` | User (`xoxp-…`) | Same as bot, plus `search:read` for multi-channel efficiency |\n\nCheck with:\n```bash\n# For bot token:\ncurl -s https://slack.com/api/auth.test -H \"Authorization: Bearer $SLACK_BOT_TOKEN\" \\\n | python3 -c \"import json,sys; d=json.load(sys.stdin); print('ok' if d.get('ok') else d.get('error'))\"\n\n# For user token:\ncurl -s https://slack.com/api/auth.test -H \"Authorization: Bearer $SLACK_USER_TOKEN\" \\\n | python3 -c \"import json,sys; d=json.load(sys.stdin); print('ok' if d.get('ok') else d.get('error'))\"\n```\n\nIf neither token is present, inform the user and stop - the automation cannot\nfunction without Slack credentials.\n\n### Optional secret\n\n| Secret name | Default | Purpose |\n|---|---|---|\n| `OPENHANDS_URL` | `http://localhost:8000` | Base URL used to build conversation links posted in Slack |\n\n---\n\n## Setup Workflow\n\nFollow these steps in order.\n\n### Step 1 - Collect channels\n\nAsk the user: *\"Which Slack channels should be monitored? You can provide\nchannel names (e.g. `#general`) or IDs (e.g. `C0123456789`).\"*\n\n**If the user provides channel names**, resolve them to IDs:\n\n```bash\nSLACK_TOKEN=\"${SLACK_BOT_TOKEN:-$SLACK_USER_TOKEN}\"\ncurl -s \"https://slack.com/api/conversations.list?types=public_channel,private_channel&limit=200&exclude_archived=true\" \\\n -H \"Authorization: Bearer $SLACK_TOKEN\" \\\n | python3 -c \"\nimport json, sys\ndata = json.load(sys.stdin)\nif not data.get('ok'):\n print('ERROR:', data.get('error'))\n exit(1)\nnames = set(n.lstrip('#') for n in ['CHANNEL_NAMES_HERE'.split(',')])\nfor ch in data.get('channels', []):\n if ch['name'] in names:\n print(f\\\"{ch['name']} → {ch['id']}\\\")\n\"\n```\n\nReplace `CHANNEL_NAMES_HERE` with the comma-separated names the user provided.\n\n**If `conversations.list` returns `missing_scope` or `not_authed`:**\nInform the user: *\"The token doesn't have permission to list channels. Please\nprovide the channel IDs directly (right-click a channel in Slack → Copy link - \nthe last path segment starting with `C` is the ID).\"*\n\n**If the bot token lacks `channels:read`** for private channels, the user can\neither invite the bot first (`/invite @botname`) or switch to a user token.\n\nCollect up to 10 channel IDs. Record them as a Python list literal, e.g.:\n```python\n[\"C0123456789\", \"C9876543210\"]\n```\n\n### Step 2 - Collect trigger phrase\n\nAsk the user: *\"What trigger phrase should OpenHands respond to?\n(Press Enter to use the default: `@openhands`)\"*\n\nAccepted values: any non-empty string unlikely to appear accidentally, e.g.\n`@openhands`, `jazz hands`, `take-me-to-funky-town`.\n\n### Step 3 - Generate the automation script\n\nRead `scripts/main.py` from this skill's directory and **copy it verbatim**.\nApply exactly three constant substitutions near the top of the file:\n\n> **Do not reimplement, simplify, or hand-write a replacement script.**\n> The template already contains the correct secret-loading, state-path,\n> conversation-creation, and context-forwarding logic. Only the three\n> configuration constants below should change unless syntax validation fails.\n\n| Placeholder | Replace with |\n|---|---|\n| `TRIGGER_PHRASE = \"@openhands\"` | `TRIGGER_PHRASE = \"{user_phrase}\"` |\n| `CHANNEL_IDS: list[str] = []` | `CHANNEL_IDS: list[str] = {channel_id_list}` |\n| `DEFAULT_OPENHANDS_URL = \"http://localhost:8000\"` | `DEFAULT_OPENHANDS_URL = \"{url}\"` (keep default if user has no preference) |\n\nWrite the customised script to a temporary directory:\n```bash\nmkdir -p /tmp/slack-monitor-build\n# copy scripts/main.py to /tmp/slack-monitor-build/main.py\n# then replace only the three constants above\n```\n\nValidate syntax before packaging:\n```bash\npython3 -m py_compile /tmp/slack-monitor-build/main.py && echo \"Syntax OK\"\n```\n\nThen run a quick integrity check to confirm the template structure is still\npresent and only the configuration block was customised:\n```bash\ngrep -n 'TRIGGER_PHRASE = \"' /tmp/slack-monitor-build/main.py\ngrep -n 'CHANNEL_IDS: list\\[str\\] =' /tmp/slack-monitor-build/main.py\ngrep -n 'DEFAULT_OPENHANDS_URL = \"' /tmp/slack-monitor-build/main.py\ngrep -n 'def get_secret' /tmp/slack-monitor-build/main.py\ngrep -n 'def _state_file_path' /tmp/slack-monitor-build/main.py\ngrep -n 'def create_conversation' /tmp/slack-monitor-build/main.py\n```\n\nIf any of those checks fail, stop and re-copy the template instead of trying to\nrepair a hand-written variant.\n\n### Step 4 - Package and upload\n\nDetermine the Automation backend URL and auth from the `<RUNTIME_SERVICES>`\nblock in your system context:\n- Use the **Automation backend** `url_from_agent` as `OPENHANDS_HOST`\n- Auth: `X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY`\n\nIf no Automation backend is listed in `<RUNTIME_SERVICES>`, stop and tell\nthe user to start the full automation stack.\n\n```bash\ntar -czf /tmp/slack-monitor.tar.gz -C /tmp/slack-monitor-build .\n\n# OPENHANDS_HOST: read from <RUNTIME_SERVICES> Automation backend url_from_agent\nOPENHANDS_HOST=\"<automation-url-from-runtime-services>\"\n\nTARBALL_PATH=$(curl -s -X POST \\\n \"${OPENHANDS_HOST}/api/automation/v1/uploads?name=slack-channel-monitor\" \\\n -H \"X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY\" \\\n -H \"Content-Type: application/gzip\" \\\n --data-binary @/tmp/slack-monitor.tar.gz \\\n | python3 -c \"import json,sys; print(json.load(sys.stdin)['tarball_path'])\")\n\necho \"Uploaded: $TARBALL_PATH\"\n```\n\nIf the upload fails with a size error, the tarball must be under 1 MB.\n`main.py` is under 15 KB so this should never trigger.\n\n### Step 5 - Create the automation\n\n```bash\ncurl -s -X POST \"${OPENHANDS_HOST}/api/automation/v1\" \\\n -H \"X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d \"{\n \\\"name\\\": \\\"Slack Channel Monitor\\\",\n \\\"trigger\\\": {\\\"type\\\": \\\"cron\\\", \\\"schedule\\\": \\\"* * * * *\\\"},\n \\\"tarball_path\\\": \\\"$TARBALL_PATH\\\",\n \\\"entrypoint\\\": \\\"python3 main.py\\\",\n \\\"timeout\\\": 55\n }\" | python3 -m json.tool\n```\n\nA 55-second timeout keeps runs well within the 60-second cron window.\n\nRecord the returned `id` - share it with the user as confirmation.\n\n### Step 6 - Confirm\n\nTell the user:\n\n> ✅ **Slack Channel Monitor** is running!\n>\n> - Automation ID: `{id}`\n> - Channels: `{channel list}`\n> - Trigger phrase: `{phrase}`\n> - Polling every minute via cron `* * * * *`\n> - State file: `~/.openhands/workspaces/automation-state/slack_poller_{id}.json`\n>\n> Send a message containing `{phrase}` in any monitored channel to test it.\n> The bot will react with 👀 and reply with a link to the new conversation.\n\n---\n\n## Runtime Behaviour (per poll)\n\nEach cron run executes `main.py`, which runs **10 polling iterations** (every\n5 seconds) within the 55-second timeout window. Each iteration:\n\n1. **Loads state** from the JSON file (see `references/state-schema.md`).\n2. **Resolves the Slack token** - checks `SLACK_USER_TOKEN` then `SLACK_BOT_TOKEN`.\n3. **Fetches new messages:**\n - User token + `search:read` + > 1 channel → single `search.messages` call\n (searches for the trigger phrase across all channels).\n - Otherwise → one `conversations.history` call per channel.\n4. **Fetches due thread replies** - polls at most one tracked thread per\n iteration using per-thread exponential backoff to stay within Slack rate\n limits.\n5. **Processes messages** in chronological order:\n - Skips messages already in `processed_ts` (dedup across the overlap window).\n - Skips bot messages and any `ts` in `bot_message_ts`.\n - Reply in a tracked thread whose text contains the trigger phrase → forwards\n a follow-up request to the existing conversation and resets the follow-up\n watch window. Replies without the trigger phrase are marked processed and\n ignored.\n - Contains trigger phrase outside a tracked conversation → 👀 reaction, create\n a new conversation, post link.\n - Records `default` and the concrete model from the server settings already\n used to start the conversation, for the response footer.\n - Thread replies: agent receives full thread history for context.\n - Root messages: agent receives the trigger text only.\n6. **Checks conversation statuses** - for each active conversation where\n `time.time() - last_activity > 15 s`:\n - If status is `idle`, `finished`, `error`, or `stuck` → fetch the agent's\n final response via `/api/conversations/{id}/agent_final_response`, append\n the LLM profile and model, and post it to the Slack thread using Slack's\n `markdown_text` field so Markdown formatting renders correctly. Mark the\n record `watching` for five minutes\n so triggered follow-up replies can continue the same conversation.\n7. **Advances `last_poll`** to `now - 10 s` (overlap window prevents boundary\n races). If a conversation creation failed, pins `last_poll` further back to\n retry on the next iteration.\n8. **Saves state** (including `processed_ts`) and continues to the next iteration.\n9. After all iterations, fires the completion callback.\n\nDebug output is written to both stdout and a persistent log at:\n```\n{WORKSPACE_BASE_ROOT}/automation-state/slack_poller_debug.log\n```\n\n---\n\n## Additional Resources\n\n### Reference Files\n\n- **`references/slack-api.md`** - Slack token types, required scopes, API\n endpoint reference, rate limits, and common error codes.\n- **`references/state-schema.md`** - State JSON schema, field definitions,\n example file, and conversation lifecycle diagram.\n\n### Script Template\n\n- **`scripts/main.py`** - The complete automation script. Customise the three\n constants at the top (`TRIGGER_PHRASE`, `CHANNEL_IDS`, `DEFAULT_OPENHANDS_URL`)\n before packaging.\n\n---\n\n## Troubleshooting\n\n| Symptom | Likely cause | Fix |\n|---|---|---|\n| Bot doesn't react to messages | Token missing or bot not in channel | Verify token with `auth.test`; `/invite @botname` |\n| `not_in_channel` error in run logs | Bot token used but bot not a member | Invite bot or switch to user token |\n| `missing_scope` error | Token lacks required scopes | Re-install Slack app with correct scopes (see `references/slack-api.md`) |\n| No messages detected | `last_poll` timestamp is in the future | Delete the state file to reset; it will be recreated on next run |\n| Conversation link 404 | `OPENHANDS_URL` points to wrong host | Set the `OPENHANDS_URL` secret to the correct base URL |\n| Summary never posted | Conversation stuck in `running` state | Check conversation in the OpenHands UI; the agent may need intervention |\n| Duplicate conversations created | `processed_ts` state missing or corrupted | Delete the state file to reset; dedup will rebuild on next run |\n| Trigger message processed on each cron run | State file deleted between runs | Ensure `automation-state/` directory is persistent across runs |\n| Debug info needed | Need detailed per-message trace | Check `{WORKSPACE_BASE_ROOT}/automation-state/slack_poller_debug.log` |",
|
|
600
600
|
"category": "automations"
|
|
601
601
|
},
|
|
602
602
|
{
|
|
@@ -668,11 +668,11 @@ export const SKILLS_CATALOG = [
|
|
|
668
668
|
},
|
|
669
669
|
{
|
|
670
670
|
"name": "ticket-to-code-change",
|
|
671
|
-
"description": "Set up a ticket-to-code-change automation using
|
|
671
|
+
"description": "Set up a ticket-to-code-change automation using Linear as the issue tracker and GitHub, GitLab, or Bitbucket as the source-control provider. The Jira templates are served by the jira-issue-to-pr skill instead. Watches for implementation-ready tickets, starts an OpenHands conversation to implement and test the request, opens a pull or merge request, and links the result back to the ticket.",
|
|
672
672
|
"triggers": [
|
|
673
673
|
"/ticket-to-code-change:setup"
|
|
674
674
|
],
|
|
675
|
-
"content": "# Ticket to code change\n\nCreate an automation that turns implementation-ready tickets into tested pull\nrequests or merge requests.\n\n## Information to collect\n\nAsk the user for:\n\n1. The issue tracker: Jira
|
|
675
|
+
"content": "# Ticket to code change\n\nCreate an automation that turns implementation-ready tickets into tested pull\nrequests or merge requests.\n\n## Information to collect\n\nAsk the user for:\n\n1. The issue tracker: Linear. Jira automations are set up with the\n `jira-issue-to-pr` skill instead, which ships a poller.\n2. The source-control provider: GitHub, GitLab, or Bitbucket Cloud.\n3. The project or team to watch and the label or workflow state that means\n \"implementation ready\".\n4. How a ticket identifies its target repository and base branch.\n5. The polling schedule or issue event to use.\n6. The ticket state to set when work starts and when the change request opens.\n7. Whether linked dependencies must be completed before dispatch.\n\n## Setup workflow\n\n1. Verify that both required integrations are connected and can read the\n selected project and repository.\n2. Prefer an issue event trigger when the deployment can receive events;\n otherwise use cron polling with durable issue-ID deduplication.\n3. Limit each run to a small configurable number of new tickets. On initial\n deployment, establish a baseline instead of dispatching the entire backlog.\n4. Build a prompt that includes the full ticket, acceptance criteria,\n dependency status, repository, base branch, and provider-specific request\n terminology. Require the agent to run the repository's tests before opening\n the pull request or merge request.\n5. Create the automation through the Automation backend's prompt-preset\n endpoint. On OpenHands Cloud or Enterprise the backend is the `<HOST>` value\n in your system prompt, authenticated with\n `Authorization: Bearer $OPENHANDS_API_KEY`. On a local Agent Canvas it is the\n Automation backend described in `<RUNTIME_SERVICES>`, authenticated with the\n runtime-provided automation API key.\n6. Configure the automation to post the OpenHands conversation URL immediately\n and the resulting pull-request or merge-request URL back to the ticket.\n\nDo not dispatch tickets with unresolved dependencies or without an unambiguous\ntarget repository; comment on the ticket with the missing information instead.",
|
|
676
676
|
"category": "automations"
|
|
677
677
|
},
|
|
678
678
|
{
|