@openhands/extensions 0.15.0 → 0.17.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/.release-please-manifest.json +1 -1
- package/AGENTS.md +1 -0
- package/automations/README.md +97 -18
- package/automations/bundle-index.js +9 -0
- package/automations/catalog/github-pr-reviewer/manifest.json +23 -7
- package/automations/catalog/github-repo-monitor/manifest.json +28 -34
- package/automations/catalog/jira-issue-to-pr/manifest.json +3 -3
- package/automations/catalog.schema.json +102 -1
- package/automations/index.d.ts +174 -1
- package/automations/index.js +14 -0
- package/automations/interface.json +93 -6
- package/automations/interface.schema.json +273 -9
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/integrations/catalog/posthog.json +41 -3
- package/integrations/catalog/sonarqube.json +78 -0
- package/integrations/catalog-index.js +65 -63
- package/marketplaces/openhands-extensions.json +11 -0
- package/package.json +1 -1
- package/plugins/qa-changes/action.yml +1 -1
- package/pyproject.toml +1 -1
- package/python/openhands_extensions/_version.py +1 -1
- package/scripts/build-automation-catalog.mjs +30 -0
- package/scripts/build-skills-catalog.mjs +24 -7
- package/skills/add-skill/SKILL.md +1 -1
- package/skills/github-pr-reviewer/README.md +12 -6
- package/skills/github-pr-reviewer/SKILL.md +93 -31
- package/skills/github-pr-reviewer/commands/pr-reviewer-setup.md +1 -1
- package/skills/github-pr-reviewer/references/state-schema.md +68 -18
- package/skills/github-pr-reviewer/scripts/main.py +540 -103
- package/skills/github-pr-reviewer/tests/test_main.py +503 -0
- package/skills/index.d.ts +6 -0
- package/skills/index.js +29 -17
- package/skills/jira-issue-to-pr/scripts/main.py +4 -2
- package/skills/openhands-sdk/SKILL.md +2 -0
- package/skills/skill-creator/SKILL.md +1 -1
- package/skills/slack-channel-monitor/scripts/main.py +3 -2
- package/tests/fixtures/automations/capabilities.json +30 -0
- package/tests/fixtures/automations/github-pr-reviewer.json +319 -55
- package/tests/fixtures/automations/github-repo-monitor.json +153 -62
- package/tests/test_automation_setup.py +226 -31
- package/tests/test_catalogs.py +43 -0
- package/tests/test_interface_manifest.py +20 -0
- package/tests/test_skills_catalog.py +182 -36
- package/tests/test_slack_channel_monitor.py +1 -1
package/AGENTS.md
CHANGED
|
@@ -87,6 +87,7 @@ When editing or adding skills in this repo, follow these rules (and add new skil
|
|
|
87
87
|
## Repository conventions
|
|
88
88
|
|
|
89
89
|
- **Punctuation style**: Use plain hyphens (`-`) instead of em dashes (`—` / `\u2014`) in skill descriptions, SKILL.md content, and marketplace JSON entries.
|
|
90
|
+
- **`defaultEnabled` on marketplace skill entries**: a skill entry may carry `"defaultEnabled": true`, which means the skill is enabled for every **new** workspace. Omit the field for everything else - absence already means off, so `"defaultEnabled": false` is not written. Keep the set small and provider-agnostic; anything language-, vendor- or workflow-specific should start off and be opted into from the catalog UI. `npm run build:skills` joins the flag into `skills/index.js` and exports `DEFAULT_ENABLED_SKILL_NAMES` for hosts to seed a workspace from. Seeding is all it does: the contract hosts implement is that a workspace which already saved a selection keeps it, so adding the flag to a skill later will not retroactively enable it for existing users.
|
|
90
91
|
- Keep formatting consistent across skills.
|
|
91
92
|
- If you change a skill’s behavior or scope, update its `README.md` (if present) accordingly.
|
|
92
93
|
- If you change top-level documentation, ensure links still resolve.
|
package/automations/README.md
CHANGED
|
@@ -50,7 +50,8 @@ two records of the same fact from drifting apart, and it is why the file is as s
|
|
|
50
50
|
| `trigger` in the create request | The key under `form.triggers`, and the fields under it named after trigger properties |
|
|
51
51
|
| Which input a rejected payload path belongs to | Rebuilding that body with each field standing in for its own value |
|
|
52
52
|
| The review screen | The fields and their labels |
|
|
53
|
-
| The create endpoint | `POST /v1/preset/prompt` |
|
|
53
|
+
| The create endpoint | `POST /v1/preset/prompt`, or `POST /v1` for an entry that ships a bundle |
|
|
54
|
+
| The files a bundle packs | `setup.bundle.files`, read from this repository at build time |
|
|
54
55
|
| Where a success navigates | The created automation, or the started conversation |
|
|
55
56
|
| The analytics stages | The same stages for every automation |
|
|
56
57
|
|
|
@@ -103,8 +104,7 @@ That way a skill can rename its trigger without leaving a stale copy behind in t
|
|
|
103
104
|
`form` separates the two things a user is configuring, and both are keyed by field name:
|
|
104
105
|
|
|
105
106
|
- **`form.triggers`** decides *when* the automation runs, keyed by trigger kind (`cron` or `event`).
|
|
106
|
-
`github-pr-reviewer`
|
|
107
|
-
answer and which phrase to match.
|
|
107
|
+
`github-pr-reviewer` and `github-repo-monitor` each ask for a schedule and a timezone.
|
|
108
108
|
- **`form.args`** is everything else: the arguments to the automation itself, such as the repository to
|
|
109
109
|
clone and the tone of the review.
|
|
110
110
|
|
|
@@ -124,11 +124,71 @@ when the deployment cannot run the direct path - a deployment whose capabilities
|
|
|
124
124
|
kind or required features. The same 2000-character cap applies, and because the fallback fires before the
|
|
125
125
|
form is trustworthy, a direct `message` should not reference `{{form.*}}`.
|
|
126
126
|
|
|
127
|
+
A `repo-picker` may declare `multiple: true`, and then it collects several
|
|
128
|
+
repositories and its value is a list. A placeholder that is the *whole* value
|
|
129
|
+
resolves to that list rather than to text, which is what lets `"repos":
|
|
130
|
+
"{{form.repositories}}"` produce an array; the same placeholder inside a
|
|
131
|
+
sentence still reads as text. On the preset path the list becomes one `repos[]`
|
|
132
|
+
entry per repository. The created automation is named after the single
|
|
133
|
+
repository when there is one and after the count when there are several, since a
|
|
134
|
+
list of names does not fit a name.
|
|
135
|
+
|
|
127
136
|
A form field is named after the property it fills. `schedule` and `timezone` under `triggers.cron` become
|
|
128
137
|
`trigger.schedule` and `trigger.timezone`; `on` under `triggers.event` becomes `trigger.on`; a field named
|
|
129
138
|
`ref` becomes `repos[].ref`. Any other field under a trigger kind, such as a phrase to match, is an input
|
|
130
139
|
to `filter` rather than a trigger property.
|
|
131
140
|
|
|
141
|
+
### Entries that ship a script
|
|
142
|
+
|
|
143
|
+
`mode: "direct"` produces either a `prompt` or a `bundle`, never both. A prompt
|
|
144
|
+
is the right shape when the automation *is* the judgement: the agent reads the
|
|
145
|
+
prompt and does the work. A bundle is the right shape when most of what the
|
|
146
|
+
automation does is deterministic machinery - polling, dedupe, state, fixed API
|
|
147
|
+
calls - and the agent is needed only for the part that genuinely needs judgement.
|
|
148
|
+
`github-pr-reviewer` is the first: its script owns discovery, label-event
|
|
149
|
+
dedupe, per-repo state and the review checkout, and starts a conversation only
|
|
150
|
+
once a pull request actually needs reviewing.
|
|
151
|
+
|
|
152
|
+
```jsonc
|
|
153
|
+
"bundle": {
|
|
154
|
+
"version": "1.0.0", // provenance; bump when the files or config shape change
|
|
155
|
+
"entrypoint": "python3 main.py", // run inside the extracted tarball
|
|
156
|
+
"timeout": 600, // when the service default is not enough
|
|
157
|
+
"files": { // packed path -> where it lives in this repo
|
|
158
|
+
"main.py": "skills/github-pr-reviewer/scripts/main.py"
|
|
159
|
+
},
|
|
160
|
+
"config": { // rendered from the form, packed as config.json
|
|
161
|
+
"repos": "{{form.repositories}}", // a whole-value placeholder, so this is a list
|
|
162
|
+
"trigger_label": "{{form.triggerLabel}}",
|
|
163
|
+
"review_tone": "{{form.reviewTone}}"
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The host packs those files plus the rendered `config.json`, `POST`s the archive
|
|
169
|
+
to `uploads`, and creates from the `oh-internal://` path that comes back. What it
|
|
170
|
+
sends is otherwise the same restatement of the form as the preset path, plus the
|
|
171
|
+
`template` provenance that makes enabling an entry twice return the automation
|
|
172
|
+
that already exists rather than a duplicate (`OpenHands/automation#344`).
|
|
173
|
+
|
|
174
|
+
Two things follow from the archetype rather than being stated:
|
|
175
|
+
|
|
176
|
+
- **`files` names paths, not contents.** The reviewer script is shipped by both
|
|
177
|
+
its skill and this catalog, and a second copy would drift. `npm run
|
|
178
|
+
build:automations` inlines the contents into `automations/bundle-index.js`,
|
|
179
|
+
which is what `getAutomationBundleFiles(id)` returns - a host packing the
|
|
180
|
+
archive has the published package, not this repository.
|
|
181
|
+
- **`config` is the bundle's `prompt`.** Everything else in the create request is
|
|
182
|
+
read off the form; only the entry knows which key of its own script each field
|
|
183
|
+
fills. The script reads that file over its own defaults, so the agent-driven
|
|
184
|
+
skill path, which substitutes the same values as constants, keeps working
|
|
185
|
+
unchanged.
|
|
186
|
+
|
|
187
|
+
A bundle declares `requires.features: ["customTarball"]`: a deployment that
|
|
188
|
+
cannot run a client-supplied tarball cannot run the entry, whatever trigger kinds
|
|
189
|
+
it offers. It never declares `repos` - the raw create endpoint has no such field,
|
|
190
|
+
and a bundle fetches what it needs itself.
|
|
191
|
+
|
|
132
192
|
### Format constraints
|
|
133
193
|
|
|
134
194
|
A setup block is data that instructs another repo to render copy and build a request, so the schema is the
|
|
@@ -151,10 +211,14 @@ entered and `{{automation.*}}` for the entry itself. There is deliberately no se
|
|
|
151
211
|
|
|
152
212
|
| Entry | Archetype | Trigger | Produces |
|
|
153
213
|
| --- | --- | --- | --- |
|
|
154
|
-
| `github-pr-reviewer` | Direct scheduled | `cron` | a create payload |
|
|
155
|
-
| `github-repo-monitor` | Direct
|
|
214
|
+
| `github-pr-reviewer` | Direct scheduled, script bundle | `cron` | an upload, then a create payload |
|
|
215
|
+
| `github-repo-monitor` | Direct scheduled | `cron` | a create payload |
|
|
156
216
|
| `incident-retrospective-drafter` | Assisted conversation | decided during the conversation | a seed message |
|
|
157
217
|
|
|
218
|
+
An event archetype is still expressible - `github-repo-monitor` used one until its deployment could no
|
|
219
|
+
longer receive webhooks, and was converted to the polled `cron` form. The schema keeps supporting `event`
|
|
220
|
+
triggers for deployments that can. See the `event` key under `setup.form.triggers` in `catalog.schema.json`.
|
|
221
|
+
|
|
158
222
|
The assisted archetype has no payload and no preflight, because at the end of its flow no automation exists
|
|
159
223
|
yet. The agent creates it during the conversation, and the service validates it there. That is the defining
|
|
160
224
|
property of the archetype, not an omission.
|
|
@@ -164,10 +228,10 @@ property of the archetype, not an omission.
|
|
|
164
228
|
`skill` and `exampleImplementation` describe the **current** path: Agent Canvas launches that skill, and the
|
|
165
229
|
agent builds the automation. `setup` describes the **declarative** path that replaces it.
|
|
166
230
|
|
|
167
|
-
They can differ in more than wording. `github-repo-monitor`'s skill polls GitHub on a cron and
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
ship a `setup` block belongs to whoever promotes this to production.
|
|
231
|
+
They can differ in more than wording. `github-repo-monitor`'s skill polls GitHub on a cron, and its `setup`
|
|
232
|
+
block declares the same polled `cron` form, so the two generations agree. (It once declared an `event`
|
|
233
|
+
form; when the deployment stopped receiving webhooks it was converted to this polling one.) Retiring the
|
|
234
|
+
skill path for entries that ship a `setup` block belongs to whoever promotes this to production.
|
|
171
235
|
|
|
172
236
|
## The interface manifest (`interface.json`)
|
|
173
237
|
|
|
@@ -177,13 +241,26 @@ extension surface tomorrow) rather than between entries, so each is stated once
|
|
|
177
241
|
Agent Canvas keeps its rendering components and reads every automation-specific datum from this file,
|
|
178
242
|
falling back to its built-in defaults when the manifest is absent or fails admission:
|
|
179
243
|
|
|
180
|
-
- **`routes`** - the list, setup, and
|
|
181
|
-
declared shape, so admission verifies they match what it mounted; the manifest is the single source
|
|
182
|
-
link construction.
|
|
183
|
-
- **`navigation`** - the sidebar entry
|
|
244
|
+
- **`routes`** - the list, setup, detail, and templates routes. The host must have a registration serving
|
|
245
|
+
each declared shape, so admission verifies they match what it mounted; the manifest is the single source
|
|
246
|
+
for link construction.
|
|
247
|
+
- **`navigation`** - the sidebar entry, the command-menu entry, and `subPages`: the ordered sub-page
|
|
248
|
+
navigation rendered inside the Automation interface, each item naming a `pages` entry with a label and
|
|
249
|
+
an icon slug from the host's closed icon map.
|
|
184
250
|
- **`pages`** - page-identity copy: the list title and subtitle, the detail back label, the edit-dialog
|
|
185
|
-
title. Generic chrome - buttons, toasts, empty states, validation
|
|
186
|
-
through the host's translations.
|
|
251
|
+
title, the templates title and description. Generic chrome - buttons, toasts, empty states, validation
|
|
252
|
+
sentences - stays host copy, rendered through the host's translations.
|
|
253
|
+
- **`pages.list.overview` / `filters` / `sort` / `insights`** - the list page's dashboard composition.
|
|
254
|
+
Every value here names something the host implements from a closed set, and the manifest picks and
|
|
255
|
+
captions it: `overview.tiles[].metric` names a host-computed value (`automations`, `needs-attention`,
|
|
256
|
+
`total-runs`, `average-duration`), filter option values name host predicates (`status`: enabled /
|
|
257
|
+
latest-run-failed / disabled; `trigger`: `event` matches event-triggered automations, `schedule`
|
|
258
|
+
everything else), `sort` values name host comparators, and `insights` captions the host's run-health
|
|
259
|
+
states and per-automation stats. The health precedence, the run sampling, the value formatting, the
|
|
260
|
+
relative-time rendering, and the filtered-empty state with its reset button are the host's - a manifest
|
|
261
|
+
cannot redefine them, only relabel what appears. Tile `detail` copy is plain substitution over the
|
|
262
|
+
metric's placeholder namespace (only the `automations` metric exposes `{{active}}`); `zeroDetail`
|
|
263
|
+
replaces `detail` while the value is zero.
|
|
187
264
|
- **`docsUrl`** - the automations documentation link, prefix-pinned to docs.openhands.dev by schema.
|
|
188
265
|
- **`attributes`** - the input surface of an existing Automation: which attributes can be set after
|
|
189
266
|
creation, keyed by the runtime-model property the host sends (`name`, `prompt`, `model`, `timeout`,
|
|
@@ -247,6 +324,8 @@ Two findings came out of that check and are baked into the entries:
|
|
|
247
324
|
| Endpoint | Status |
|
|
248
325
|
| --- | --- |
|
|
249
326
|
| `POST /v1/preset/prompt` | Exists |
|
|
327
|
+
| `POST /v1/uploads` | Exists |
|
|
328
|
+
| `POST /v1` | Exists; accepts `template` provenance from OpenHands/automation#344 |
|
|
250
329
|
| `GET /v1/capabilities` | Exists (OpenHands/automation#270) |
|
|
251
330
|
| `POST /v1/validate` | Exists (OpenHands/automation#270) |
|
|
252
331
|
|
|
@@ -300,8 +379,8 @@ Recorded rather than resolved, because they need an owner outside this contract:
|
|
|
300
379
|
related fields onto one line, which reads better; if that matters, it is a host concern, not an entry's.
|
|
301
380
|
- **Assisted-setup completion.** The assisted flow ends at a conversation, so the host cannot emit a
|
|
302
381
|
completion event. Until something reports back, the ratio of direct to assisted setup is not measurable.
|
|
303
|
-
- **Delete confirmation and per-run views.** `interface.json` models the
|
|
304
|
-
and the
|
|
305
|
-
|
|
382
|
+
- **Delete confirmation and per-run views.** `interface.json` models the routes, the settable attributes,
|
|
383
|
+
and the dashboard and templates sub-pages; deletion and run logs remain host-owned surfaces with no
|
|
384
|
+
manifest data of their own yet.
|
|
306
385
|
- **Types from the schema.** `index.d.ts` is hand-written and mirrors `catalog.schema.json`. Generating it
|
|
307
386
|
would remove that second source of truth.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// This file is auto-generated by scripts/build-automation-catalog.mjs.
|
|
2
|
+
// Do not edit it manually. It inlines the files each bundle entry ships, read from
|
|
3
|
+
// the repository paths its manifest names. To update it, run: npm run build:automations
|
|
4
|
+
|
|
5
|
+
export const AUTOMATION_BUNDLE_FILES = {
|
|
6
|
+
"github-pr-reviewer": {
|
|
7
|
+
"main.py": "\"\"\"\nGitHub PR Reviewer - OpenHands Automation Script\n\nCron-polls one or more GitHub repositories for open pull requests carrying the\nconfigured trigger label. A review is queued only when the latest matching\nGitHub `labeled` event has not already been processed by this automation.\n\nEach repository is polled independently and keeps its own state document, so\npull-request numbers never collide across repositories.\n\nThe script owns the repository checkout: it downloads the pull request's head\ncommit as a tarball, hands the agent that directory as its workspace, and\nremoves it once the review has finished. The agent never clones, checks out, or\ndeletes anything.\n\"\"\"\n\nimport io\nimport json\nimport os\nimport re\nimport shutil\nimport sys\nimport tarfile\nimport time\nimport urllib.error\nimport urllib.request\nfrom collections.abc import Callable\nfrom pathlib import Path, PurePosixPath\nfrom urllib.parse import urlencode\n\n# Configuration. Two setup paths write it, and both end up here:\n#\n# - the agent-driven path (SKILL.md) substitutes these constants directly\n# into a copy of this file before packaging it;\n# - the catalog path packs an unmodified copy and ships a rendered\n# config.json beside it, which is loaded over these defaults below.\n#\n# A declarative host cannot rewrite Python - the catalog schema admits data,\n# not code - so the constants stay as the defaults and config.json is the\n# override, rather than one path being expressed in terms of the other.\nREPOS = [\"owner/repo\"]\nTRIGGER_LABEL = \"openhands-review\"\nREVIEW_TONE = \"thorough\"\nREVIEW_STYLE_INSTRUCTIONS = \"\"\nDEFAULT_OPENHANDS_URL = \"http://localhost:8000\"\n\nCONFIG_FILENAME = \"config.json\"\n\n# Config keys, paired with the type each must have. A wrong type is a hard\n# error at import: the alternative is polling the string \"owner/repo\" one\n# character at a time, or matching a label that is silently a list.\n_CONFIG_TYPES: dict[str, type] = {\n \"repos\": list,\n \"trigger_label\": str,\n \"review_tone\": str,\n \"review_style_instructions\": str,\n \"openhands_url\": str,\n}\n\n\ndef load_config(directory: Path | None = None) -> dict:\n \"\"\"Return the rendered config shipped beside this script, or {} if absent.\n\n Only the keys above are read; anything else in the file is ignored, so a\n host may ship provenance there without this script caring.\n \"\"\"\n path = (directory or Path(__file__).resolve().parent) / CONFIG_FILENAME\n if not path.is_file():\n return {}\n\n try:\n raw = json.loads(path.read_text())\n except json.JSONDecodeError as e:\n raise SystemExit(f\"{CONFIG_FILENAME} is not valid JSON: {e}\") from e\n if not isinstance(raw, dict):\n raise SystemExit(f\"{CONFIG_FILENAME} must contain a JSON object\")\n\n config = {}\n for key, expected in _CONFIG_TYPES.items():\n if key not in raw:\n continue\n value = raw[key]\n if not isinstance(value, expected):\n raise SystemExit(\n f\"{CONFIG_FILENAME}: {key} must be {expected.__name__}, \"\n f\"got {type(value).__name__}\"\n )\n if key == \"repos\" and not (\n value and all(isinstance(item, str) and item for item in value)\n ):\n raise SystemExit(\n f'{CONFIG_FILENAME}: repos must be a non-empty list of \"owner/repo\" strings'\n )\n config[key] = value\n return config\n\n\n# owner/repo, which is what every GitHub API path in this script is built from.\n_REPO_NAME_RE = re.compile(r\"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$\")\n\n\ndef normalize_repo(value: str) -> str:\n \"\"\"Return ``owner/repo`` for the ways a repository gets written down.\n\n A clone URL is what a repository page offers to copy, so it is what ends up\n pasted into a setup form. Left alone it becomes\n ``/repos/https://github.com/owner/repo``, which GitHub answers with a 404 -\n indistinguishable, from here, from a repository the token cannot see.\n\n Raises ValueError for anything that is not a repository name, so the run\n says which value it could not read instead of blaming the token.\n \"\"\"\n repo = value.strip()\n if repo.startswith(\"git@\"):\n # git@github.com:owner/repo.git\n repo = repo.partition(\":\")[2]\n elif \"://\" in repo:\n # https://github.com/owner/repo, and anything else with a host\n repo = repo.split(\"://\", 1)[1].partition(\"/\")[2]\n repo = repo.strip(\"/\")\n if repo.endswith(\".git\"):\n repo = repo[: -len(\".git\")]\n\n if not _REPO_NAME_RE.match(repo):\n raise ValueError(\n f\"{value!r} is not a repository. Use owner/repo, for example \"\n \"OpenHands/automation.\"\n )\n return repo\n\n\n_CONFIG = load_config()\nREPOS = _CONFIG.get(\"repos\", REPOS)\nTRIGGER_LABEL = _CONFIG.get(\"trigger_label\", TRIGGER_LABEL)\nREVIEW_TONE = _CONFIG.get(\"review_tone\", REVIEW_TONE)\nREVIEW_STYLE_INSTRUCTIONS = _CONFIG.get(\"review_style_instructions\", REVIEW_STYLE_INSTRUCTIONS)\nDEFAULT_OPENHANDS_URL = _CONFIG.get(\"openhands_url\", DEFAULT_OPENHANDS_URL)\n\nDONE_DEBOUNCE = 15\nTERMINAL_STATUSES = {\"idle\", \"finished\", \"error\", \"stuck\"}\n# A conversation that never reaches a terminal status would hold its checkout\n# forever. After this long the review is abandoned so the disk can be reclaimed.\nMAX_ACTIVE_AGE = 2 * 60 * 60\n# A label event is claimed in the state document before its review starts, so an\n# overlapping poll skips it. If the claiming poll dies before the conversation\n# exists, the claim is released after this long - comfortably longer than\n# fetching an archive and opening a conversation, short enough that a crash does\n# not park the review until someone notices.\nSTALLED_CLAIM_SECONDS = 15 * 60\n\n# Login of the token owner, filled in by _verify_token. Reviews are matched\n# against it to answer \"did we already publish a review for this commit\", which\n# is checked on GitHub rather than trusted from the agent.\n_AUTH_LOGIN = \"\"\n\n\ndef _get_env_key() -> str:\n return os.environ.get(\"SESSION_API_KEY\") or os.environ.get(\"OH_SESSION_API_KEYS_0\") or \"\"\n\n\ndef get_secret(name: str) -> str:\n url = os.environ.get(\"AGENT_SERVER_URL\", \"\").rstrip(\"/\")\n key = _get_env_key()\n req = urllib.request.Request(\n f\"{url}/api/settings/secrets/{name}\",\n headers={\"X-Session-API-Key\": key},\n )\n with urllib.request.urlopen(req) as r:\n return r.read().decode().strip()\n\n\ndef fire_callback(\n status: str = \"COMPLETED\",\n error: str | None = None,\n conversation_id: str | None = None,\n) -> None:\n url = os.environ.get(\"AUTOMATION_CALLBACK_URL\", \"\")\n if not url:\n return\n body: dict = {\"status\": status, \"run_id\": os.environ.get(\"AUTOMATION_RUN_ID\", \"\")}\n if error:\n body[\"error\"] = error\n if conversation_id:\n body[\"conversation_id\"] = conversation_id\n req = urllib.request.Request(\n url,\n data=json.dumps(body).encode(),\n headers={\n \"Content-Type\": \"application/json\",\n \"Authorization\": f\"Bearer {os.environ.get('AUTOMATION_CALLBACK_API_KEY', '')}\",\n },\n )\n try:\n urllib.request.urlopen(req)\n except Exception as exc:\n print(f\"Callback error (non-fatal): {exc}\")\n\n\n# ── State persistence (KV store with local-file fallback) ─────────────────────\n\n_KV_TOKEN = os.environ.get(\"AUTOMATION_KV_TOKEN\", \"\")\n_KV_BASE = os.environ.get(\"AUTOMATION_API_URL\", \"\").rstrip(\"/\")\n# Single-repository deployments of this script kept their state under a bare\n# \"state\" key. It is adopted once, on first poll after an upgrade, so the\n# switch to per-repository keys does not re-review every open labelled PR.\n_LEGACY_STATE_KEY = \"state\"\n\n\ndef _repo_slug(repo: str) -> str:\n return repo.replace(\"/\", \"__\")\n\n\ndef _state_key(repo: str) -> str:\n return f\"state:{_repo_slug(repo)}\"\n\n\ndef _kv_available() -> bool:\n return bool(_KV_TOKEN and _KV_BASE)\n\n\ndef _kv_get(key: str) -> dict | None:\n req = urllib.request.Request(\n f\"{_KV_BASE}/v1/kv/{key}\",\n headers={\"Authorization\": f\"Bearer {_KV_TOKEN}\"},\n )\n try:\n with urllib.request.urlopen(req) as r:\n return json.loads(r.read())[\"value\"]\n except urllib.error.HTTPError as exc:\n if exc.code == 404:\n return None\n raise\n\n\ndef _kv_set(key: str, value: dict) -> None:\n req = urllib.request.Request(\n f\"{_KV_BASE}/v1/kv/{key}\",\n data=json.dumps(value).encode(),\n headers={\n \"Authorization\": f\"Bearer {_KV_TOKEN}\",\n \"Content-Type\": \"application/json\",\n },\n method=\"PUT\",\n )\n with urllib.request.urlopen(req) as r:\n r.read()\n\n\ndef _state_dir() -> Path:\n workspace_base = os.environ.get(\"WORKSPACE_BASE\", \"\")\n if workspace_base:\n root = Path(workspace_base).resolve().parent.parent\n else:\n root = Path.home() / \".openhands\" / \"workspaces\"\n state_dir = root / \"automation-state\"\n state_dir.mkdir(parents=True, exist_ok=True)\n return state_dir\n\n\ndef _automation_id() -> str:\n event_payload = json.loads(os.environ.get(\"AUTOMATION_EVENT_PAYLOAD\", \"{}\"))\n return event_payload.get(\"automation_id\", \"default\")\n\n\ndef _state_file_path(repo: str) -> str:\n name = f\"github_pr_reviewer_label_event_{_automation_id()}_{_repo_slug(repo)}.json\"\n return str(_state_dir() / name)\n\n\ndef _legacy_state_file_path() -> str:\n return str(_state_dir() / f\"github_pr_reviewer_label_event_{_automation_id()}.json\")\n\n\ndef _read_state_file(path: str) -> dict | None:\n if not os.path.exists(path):\n return None\n try:\n with open(path) as f:\n return json.load(f)\n except (json.JSONDecodeError, OSError) as exc:\n print(f\" Warning: state file {path} unreadable ({exc}); starting fresh\")\n return None\n\n\ndef _default_state(repo: str) -> dict:\n return {\n \"version\": 3,\n \"repo\": repo,\n \"trigger_label\": TRIGGER_LABEL,\n \"reviews\": {},\n \"prs\": {},\n }\n\n\ndef load_state(repo: str) -> dict:\n \"\"\"Load this repository's state, adopting a pre-multi-repo document once.\"\"\"\n if _kv_available():\n data = _kv_get(_state_key(repo))\n if data is not None:\n print(f\" State loaded from KV store ({_state_key(repo)})\")\n return data\n legacy = _kv_get(_LEGACY_STATE_KEY)\n if legacy is not None and legacy.get(\"repo\") == repo:\n print(f\" Adopted legacy KV state for {repo}\")\n return legacy\n return _default_state(repo)\n\n data = _read_state_file(_state_file_path(repo))\n if data is not None:\n return data\n legacy = _read_state_file(_legacy_state_file_path())\n if legacy is not None and legacy.get(\"repo\") == repo:\n print(f\" Adopted legacy state file for {repo}\")\n return legacy\n return _default_state(repo)\n\n\ndef save_state(repo: str, state: dict) -> None:\n if _kv_available():\n _kv_set(_state_key(repo), state)\n print(f\" State saved to KV store ({_state_key(repo)})\")\n return\n path = _state_file_path(repo)\n tmp_path = f\"{path}.tmp\"\n with open(tmp_path, \"w\") as f:\n json.dump(state, f, indent=2, sort_keys=True)\n os.replace(tmp_path, path)\n print(f\" State saved to {path}\")\n\n\ndef _github_request(\n token: str,\n method: str,\n path: str,\n params: dict | None = None,\n body: dict | None = None,\n accept: str = \"application/vnd.github+json\",\n) -> tuple:\n url = f\"https://api.github.com{path}\"\n if params:\n url = f\"{url}?{urlencode(params)}\"\n headers = {\n \"Authorization\": f\"Bearer {token}\",\n \"Accept\": accept,\n \"X-GitHub-Api-Version\": \"2022-11-28\",\n \"Content-Type\": \"application/json\",\n }\n data = json.dumps(body).encode() if body is not None else None\n req = urllib.request.Request(url, data=data, headers=headers, method=method)\n with urllib.request.urlopen(req) as r:\n raw = r.read()\n return (json.loads(raw) if raw.strip() else {}), dict(r.headers)\n\n\ndef _github_paginate(token: str, path: str, params: dict | None = None) -> list:\n results = []\n page = 1\n base_params = dict(params or {})\n base_params.setdefault(\"per_page\", 100)\n while True:\n base_params[\"page\"] = page\n data, _ = _github_request(token, \"GET\", path, params=base_params)\n if not isinstance(data, list):\n break\n results.extend(data)\n if len(data) < base_params[\"per_page\"]:\n break\n page += 1\n return results\n\n\ndef _resolve_github_token() -> str:\n try:\n token = get_secret(\"GITHUB_PERSONAL_ACCESS_TOKEN\")\n if token:\n return token\n except Exception:\n pass\n raise RuntimeError(\n \"GITHUB_PERSONAL_ACCESS_TOKEN secret is not set. \"\n \"Go to OpenHands Settings → Secrets and add your GitHub Personal Access Token.\"\n )\n\n\ndef _verify_token(token: str) -> None:\n \"\"\"Check the token once per run and remember who it belongs to.\"\"\"\n global _AUTH_LOGIN\n try:\n user_data, _ = _github_request(token, \"GET\", \"/user\")\n except urllib.error.HTTPError as exc:\n if exc.code == 401:\n raise RuntimeError(\"GITHUB_PERSONAL_ACCESS_TOKEN is invalid or expired.\") from exc\n raise RuntimeError(f\"GitHub /user check failed: {exc.code}\") from exc\n\n _AUTH_LOGIN = user_data.get(\"login\", \"\")\n print(f\"Authenticated as GitHub user: {_AUTH_LOGIN or '?'}\")\n\n\ndef _verify_repo(token: str, repo: str) -> None:\n try:\n _github_request(token, \"GET\", f\"/repos/{repo}\")\n except urllib.error.HTTPError as exc:\n if exc.code == 404:\n raise RuntimeError(f\"Repository '{repo}' is not accessible with the current token.\") from exc\n raise RuntimeError(f\"GitHub /repos/{repo} check failed: {exc.code}\") from exc\n\n\ndef _list_open_prs(token: str, repo: str) -> list[dict]:\n return _github_paginate(\n token,\n f\"/repos/{repo}/pulls\",\n {\"state\": \"open\", \"sort\": \"updated\", \"direction\": \"desc\"},\n )\n\n\ndef _get_pr(token: str, repo: str, pr_number: int) -> dict:\n pr, _ = _github_request(token, \"GET\", f\"/repos/{repo}/pulls/{pr_number}\")\n return pr\n\n\ndef _get_issue_events(token: str, repo: str, pr_number: int) -> list[dict]:\n return _github_paginate(token, f\"/repos/{repo}/issues/{pr_number}/events\")\n\n\ndef _latest_trigger_label_event(token: str, repo: str, pr_number: int) -> dict | None:\n events = _get_issue_events(token, repo, pr_number)\n matching = [\n event for event in events\n if event.get(\"event\") == \"labeled\"\n and (event.get(\"label\") or {}).get(\"name\", \"\").lower() == TRIGGER_LABEL.lower()\n and event.get(\"id\") is not None\n ]\n if not matching:\n return None\n return max(matching, key=lambda event: (event.get(\"created_at\") or \"\", int(event.get(\"id\") or 0)))\n\n\ndef _post_github_comment(token: str, repo: str, pr_number: int, body: str) -> None:\n try:\n _github_request(\n token,\n \"POST\",\n f\"/repos/{repo}/issues/{pr_number}/comments\",\n body={\"body\": body},\n )\n except Exception as exc:\n print(f\" Warning: failed to post comment on PR #{pr_number}: {exc}\")\n\n\ndef _matching_review_exists(token: str, repo: str, pr_number: int, head_sha: str) -> bool:\n \"\"\"Has this token's user already published a review for this exact commit?\n\n The agent is asked to report success, but a report is not evidence: reviews\n have been reported as posted when none existed. GitHub is the source of\n truth for whether the review landed.\n \"\"\"\n if not head_sha or not _AUTH_LOGIN:\n return False\n try:\n reviews = _github_paginate(token, f\"/repos/{repo}/pulls/{pr_number}/reviews\")\n except Exception as exc:\n print(f\" Warning: could not list reviews for PR #{pr_number}: {exc}\")\n return False\n for review in reviews:\n if (review.get(\"user\") or {}).get(\"login\", \"\").lower() != _AUTH_LOGIN.lower():\n continue\n if review.get(\"commit_id\") == head_sha:\n return True\n return False\n\n\n# ── Repository checkout ───────────────────────────────────────────────────────\n\n\ndef _checkouts_root() -> Path:\n return Path(os.environ.get(\"WORKSPACE_BASE\", \"/workspace\")).resolve() / \"repositories\"\n\n\ndef _checkout_path(repo: str, pr_number: int, head_sha: str) -> Path:\n return _checkouts_root() / _repo_slug(repo) / f\"pr-{pr_number}-{head_sha[:12]}\"\n\n\ndef _prepare_repository(token: str, repo: str, pr_number: int, head_sha: str) -> Path:\n \"\"\"Materialise the pull request's head commit as the agent's workspace.\n\n The commit is fetched as a tarball rather than cloned, so the directory\n holds exactly the reviewed tree with no history and no git remote for the\n agent to push to.\n \"\"\"\n checkout = _checkout_path(repo, pr_number, head_sha)\n if checkout.exists():\n shutil.rmtree(checkout)\n checkout.mkdir(parents=True)\n\n req = urllib.request.Request(\n f\"https://api.github.com/repos/{repo}/tarball/{head_sha}\",\n headers={\n \"Authorization\": f\"Bearer {token}\",\n \"Accept\": \"application/vnd.github+json\",\n \"X-GitHub-Api-Version\": \"2022-11-28\",\n },\n )\n skipped_links = 0\n try:\n with urllib.request.urlopen(req) as response:\n archive = tarfile.open(fileobj=io.BytesIO(response.read()), mode=\"r:gz\")\n with archive:\n members = archive.getmembers()\n roots = {\n PurePosixPath(member.name).parts[0]\n for member in members\n if PurePosixPath(member.name).parts\n }\n if len(roots) != 1:\n raise RuntimeError(\"Repository archive has an unexpected layout\")\n root = next(iter(roots))\n for member in members:\n path = PurePosixPath(member.name)\n if not path.parts or path.parts[0] != root:\n raise RuntimeError(\"Repository archive contains an invalid path\")\n relative = PurePosixPath(*path.parts[1:])\n if not relative.parts:\n continue\n if relative.is_absolute() or \"..\" in relative.parts:\n raise RuntimeError(\"Repository archive contains path traversal\")\n if member.issym() or member.islnk() or member.isdev():\n # Repositories legitimately contain symlinks. Reviewing does\n # not need them, and materialising them risks escaping the\n # checkout, so skip rather than reject the whole archive.\n skipped_links += 1\n continue\n destination = checkout.joinpath(*relative.parts)\n if member.isdir():\n destination.mkdir(parents=True, exist_ok=True)\n continue\n if not member.isfile():\n continue\n destination.parent.mkdir(parents=True, exist_ok=True)\n source = archive.extractfile(member)\n if source is None:\n raise RuntimeError(f\"Could not read archive member {member.name}\")\n with source, destination.open(\"wb\") as target:\n shutil.copyfileobj(source, target)\n destination.chmod(member.mode & 0o777)\n except Exception:\n shutil.rmtree(checkout, ignore_errors=True)\n raise\n\n if skipped_links:\n print(f\" Skipped {skipped_links} link/device entries while extracting\")\n return checkout\n\n\ndef _release_checkout(rec: dict, agent_url: str, api_key: str) -> bool:\n \"\"\"Remove a finished review's checkout. Returns True when nothing is left.\n\n The checkout is the conversation's working directory, so it is only removed\n once the conversation has stopped - deleting it under a running agent would\n pull the ground out from under it. When the status cannot be confirmed the\n directory is left alone and the next poll tries again.\n \"\"\"\n workspace_dir = rec.get(\"workspace_dir\")\n if not workspace_dir:\n return True\n\n conversation_id = rec.get(\"conversation_id\")\n if conversation_id:\n try:\n status = conversation_status(agent_url, api_key, conversation_id)\n except urllib.error.HTTPError as exc:\n status = \"finished\" if exc.code == 404 else None\n except Exception:\n status = None\n if status is None:\n print(f\" Could not confirm conversation {conversation_id} has stopped; keeping {workspace_dir}\")\n return False\n if status not in TERMINAL_STATUSES:\n print(f\" Conversation {conversation_id} is still '{status}'; keeping its checkout\")\n return False\n\n path = Path(workspace_dir)\n root = _checkouts_root()\n try:\n resolved = path.resolve()\n except OSError:\n resolved = path\n if resolved == root or not resolved.is_relative_to(root):\n # Never delete anything the script did not create under the checkout\n # root, whatever ended up recorded in state.\n print(f\" Refusing to remove {resolved}: outside {root}\")\n rec.pop(\"workspace_dir\", None)\n return True\n\n shutil.rmtree(resolved, ignore_errors=True)\n rec.pop(\"workspace_dir\", None)\n print(f\" Removed checkout {resolved}\")\n return True\n\n\ndef _oh_request(agent_url: str, api_key: str, method: str, path: str, body: dict | None = None) -> dict:\n url = f\"{agent_url}{path}\"\n headers = {\"X-Session-API-Key\": api_key, \"Content-Type\": \"application/json\"}\n data = json.dumps(body).encode() if body is not None else None\n req = urllib.request.Request(url, data=data, headers=headers, method=method)\n try:\n with urllib.request.urlopen(req) as r:\n raw = r.read()\n return json.loads(raw) if raw.strip() else {}\n except urllib.error.HTTPError as exc:\n body_text = exc.read().decode()\n raise RuntimeError(f\"Agent API {method} {path} → {exc.code}: {body_text}\") from exc\n\n\ndef _fetch_settings(agent_url: str, api_key: str) -> dict:\n req = urllib.request.Request(\n f\"{agent_url}/api/settings\",\n headers={\"X-Session-API-Key\": api_key, \"X-Expose-Secrets\": \"plaintext\"},\n )\n with urllib.request.urlopen(req) as r:\n return json.loads(r.read())\n\n\ndef _get_agent_dict(agent_url: str, api_key: str) -> dict:\n data = _fetch_settings(agent_url, api_key)\n llm = data.get(\"agent_settings\", {}).get(\"llm\", {})\n return {\n \"kind\": \"Agent\",\n \"llm\": llm,\n \"tools\": [{\"name\": \"terminal\"}, {\"name\": \"file_editor\"}],\n }\n\n\ndef _get_mcp_config(agent_url: str, api_key: str) -> dict | None:\n try:\n data = _fetch_settings(agent_url, api_key)\n mcp_config = data.get(\"agent_settings\", {}).get(\"mcp_config\")\n if isinstance(mcp_config, dict) and mcp_config.get(\"mcpServers\"):\n return mcp_config\n except Exception as exc:\n print(f\"Warning: could not fetch MCP config: {exc}\")\n return None\n\n\ndef _list_secret_names(agent_url: str, api_key: str) -> list[dict]:\n try:\n result = _oh_request(agent_url, api_key, \"GET\", \"/api/settings/secrets\")\n return result.get(\"secrets\", [])\n except Exception as exc:\n print(f\"Warning: could not list secrets: {exc}\")\n return []\n\n\ndef _build_secrets_payload(agent_url: str, api_key: str) -> dict:\n secrets = {}\n for secret in _list_secret_names(agent_url, api_key):\n name = secret.get(\"name\", \"\")\n if not name:\n continue\n lookup: dict = {\n \"kind\": \"LookupSecret\",\n \"url\": f\"/api/settings/secrets/{name}\",\n }\n if api_key:\n lookup[\"headers\"] = {\"X-Session-API-Key\": api_key}\n desc = secret.get(\"description\")\n if desc:\n lookup[\"description\"] = desc\n secrets[name] = lookup\n return secrets\n\n\ndef create_conversation(\n agent_url: str,\n api_key: str,\n initial_message: str,\n workspace_dir: Path,\n) -> str:\n payload: dict = {\n \"workspace\": {\"working_dir\": str(workspace_dir)},\n \"agent\": _get_agent_dict(agent_url, api_key),\n \"initial_message\": {\"content\": [{\"text\": initial_message}]},\n }\n secrets = _build_secrets_payload(agent_url, api_key)\n if secrets:\n payload[\"secrets\"] = secrets\n mcp_config = _get_mcp_config(agent_url, api_key)\n if mcp_config:\n payload[\"mcp_config\"] = mcp_config\n result = _oh_request(agent_url, api_key, \"POST\", \"/api/conversations\", payload)\n return result[\"id\"]\n\n\ndef conversation_status(agent_url: str, api_key: str, conv_id: str) -> str:\n result = _oh_request(agent_url, api_key, \"GET\", f\"/api/conversations/{conv_id}\")\n return result.get(\"execution_status\", \"unknown\")\n\n\ndef conversation_final_response(agent_url: str, api_key: str, conv_id: str) -> str:\n result = _oh_request(agent_url, api_key, \"GET\", f\"/api/conversations/{conv_id}/agent_final_response\")\n return result.get(\"response\", \"\")\n\n\n_TONE_INSTRUCTIONS = {\n \"thorough\": (\n \"Provide a comprehensive review. Cover correctness, security vulnerabilities, \"\n \"missing or inadequate tests, code style, maintainability, and potential edge cases. \"\n \"Reference specific files and line numbers where relevant.\"\n ),\n \"concise\": (\n \"Provide a brief, high-signal review. Focus only on important bugs, security problems, \"\n \"or significant design flaws. Omit minor style feedback.\"\n ),\n \"friendly\": (\n \"Provide a constructive, encouraging review. Acknowledge what is done well before \"\n \"raising concerns while still noting real issues.\"\n ),\n}\n\n\ndef _labels(pr: dict) -> list[str]:\n return [label.get(\"name\", \"\") for label in pr.get(\"labels\", [])]\n\n\ndef _has_trigger_label(pr: dict) -> bool:\n return any(label.lower() == TRIGGER_LABEL.lower() for label in _labels(pr))\n\n\ndef _head_sha(pr: dict) -> str:\n return ((pr.get(\"head\") or {}).get(\"sha\") or \"\").strip()\n\n\ndef _review_key(pr_number: int, label_event_id: int | str) -> str:\n return f\"{pr_number}:label:{label_event_id}\"\n\n\ndef _with_ai_disclosure(body: str) -> str:\n disclosure = \"_This comment was posted by an AI agent (OpenHands)._\"\n body = (body or \"\").strip()\n if disclosure.lower() in body.lower():\n return body\n return f\"{body}\\n\\n{disclosure}\" if body else disclosure\n\n\ndef _build_review_prompt(repo: str, pr: dict, head_sha: str, label_event: dict) -> str:\n number = pr.get(\"number\", \"?\")\n title = pr.get(\"title\", \"(no title)\")\n body = (pr.get(\"body\") or \"\").strip() or \"(no description)\"\n html_url = pr.get(\"html_url\", \"\")\n author = (pr.get(\"user\") or {}).get(\"login\", \"?\")\n base_branch = (pr.get(\"base\") or {}).get(\"ref\", \"?\")\n head_branch = (pr.get(\"head\") or {}).get(\"ref\", \"?\")\n label_str = \", \".join(_labels(pr)) or \"(none)\"\n label_event_id = label_event.get(\"id\", \"?\")\n label_event_created_at = label_event.get(\"created_at\", \"?\")\n changed_files = pr.get(\"changed_files\", \"?\")\n additions = pr.get(\"additions\", \"?\")\n deletions = pr.get(\"deletions\", \"?\")\n tone = _TONE_INSTRUCTIONS.get(REVIEW_TONE, _TONE_INSTRUCTIONS[\"thorough\"])\n extra = f\"\\n\\nAdditional style instructions:\\n{REVIEW_STYLE_INSTRUCTIONS}\" if REVIEW_STYLE_INSTRUCTIONS.strip() else \"\"\n\n return (\n \"You are an AI code reviewer. Review the GitHub pull request below and publish \"\n \"the review directly to GitHub. Do not modify files, push commits, or approve \"\n \"the pull request.\\n\\n\"\n f\"Repository : {repo}\\n\"\n f\"PR #{number}: \\\"{title}\\\"\\n\"\n f\"Author : @{author}\\n\"\n f\"Base → Head: {base_branch} ← {head_branch}\\n\"\n f\"Head SHA : {head_sha}\\n\"\n f\"Trigger : latest `{TRIGGER_LABEL}` labeled event {label_event_id} at {label_event_created_at}\\n\"\n f\"Labels : {label_str}\\n\"\n f\"Changes : +{additions} -{deletions} across {changed_files} file(s)\\n\"\n f\"URL : {html_url}\\n\"\n f\"\\nPR Description:\\n---\\n{body}\\n---\\n\\n\"\n \"Required workflow:\\n\"\n \"1. The workspace is already the repository root at the exact Head SHA above. \"\n \"Do not clone, fetch, check out, or delete the repository.\\n\"\n \"2. Inspect the PR discussion, existing review comments, changed files, and the diff, \"\n \"together with the surrounding code in the workspace.\\n\"\n \" Use `gh` or GitHub REST API calls with `GITHUB_PERSONAL_ACCESS_TOKEN`; never print secret values.\\n\"\n \"3. Ground every finding in the workspace code. Before using an inline location, verify that \"\n \"the path and line are part of this pull request's diff.\\n\"\n f\"4. Publish one review with `POST /repos/{repo}/pulls/{number}/reviews`, using \"\n \"`commit_id` equal to the Head SHA above and `event: COMMENT`.\\n\"\n \" Put the overall assessment in `body`, and each line-specific finding in the `comments` \"\n \"array with `path`, `line`, `side: RIGHT`, and `body`.\\n\"\n \" Only create inline comments for actionable findings; do not open praise or nitpick threads.\\n\"\n \"5. If a finding cannot be attached to a changed line, put it in the review body instead. \"\n \"If the API rejects the inline positions, retry with every finding in the body and no `comments` array.\\n\"\n \"6. Begin the review body with this disclosure: \"\n \"`_This review was posted by an AI agent (OpenHands)._`\\n\"\n \"7. End the review body with a verdict on its own line: either `✅ APPROVED` \"\n \"or `🔄 CHANGES REQUESTED`.\\n\"\n \"8. If there are no material issues, still publish a review saying so, with the \"\n \"disclosure and the verdict.\\n\"\n f\"\\nReview instructions:\\n{tone}{extra}\\n\\n\"\n \"After GitHub accepts the review, output exactly `GITHUB_REVIEW_POSTED`. \"\n \"If publishing still fails after the fallback in step 5, output the complete review text \"\n \"so it can be posted as a comment instead.\"\n )\n\n\ndef _process_review_request(\n github_token: str,\n agent_url: str,\n api_key: str,\n openhands_url: str,\n repo: str,\n pr: dict,\n label_event: dict,\n reviews: dict,\n persist: Callable[[], None],\n) -> str | None:\n number = pr[\"number\"]\n head_sha = _head_sha(pr)\n label_event_id = label_event[\"id\"]\n key = _review_key(number, label_event_id)\n title = pr.get(\"title\", \"(no title)\")\n html_url = pr.get(\"html_url\", \"\")\n\n print(f\" Queuing review for PR #{number} from `{TRIGGER_LABEL}` event {label_event_id} at {head_sha[:12]}: {title}\")\n\n # Claim the label event and persist it *before* the slow work below. State\n # is otherwise only written when the repository finishes polling, so a poll\n # starting while this one downloads an archive or spins up a conversation\n # would read no record for this event and review the same commit a second\n # time - two conversations, two \"reviewing\" comments, two reviews.\n reviews[key] = {\n \"pr_number\": number,\n \"head_sha\": head_sha,\n \"trigger_label_event_id\": label_event_id,\n \"trigger_label_event_created_at\": label_event.get(\"created_at\"),\n \"html_url\": html_url,\n \"status\": \"starting\",\n \"conversation_id\": None,\n \"workspace_dir\": None,\n \"last_activity\": time.time(),\n }\n persist()\n\n workspace_dir = None\n try:\n workspace_dir = _prepare_repository(github_token, repo, number, head_sha)\n prompt = _build_review_prompt(repo, pr, head_sha, label_event)\n conv_id = create_conversation(agent_url, api_key, prompt, workspace_dir)\n except Exception as exc:\n # The claim is dropped so the next poll retries this label event. The\n # checkout goes with it rather than being left behind.\n if workspace_dir:\n shutil.rmtree(workspace_dir, ignore_errors=True)\n reviews.pop(key, None)\n persist()\n print(f\" Error starting review for PR #{number}: {exc}\")\n return None\n\n reviews[key].update(\n {\n \"status\": \"active\",\n \"conversation_id\": conv_id,\n \"workspace_dir\": str(workspace_dir),\n \"last_activity\": time.time(),\n }\n )\n persist()\n print(f\" Created review conversation {conv_id}\")\n\n conv_url = f\"{openhands_url}/conversations/{conv_id}\"\n _post_github_comment(\n github_token,\n repo,\n number,\n _with_ai_disclosure(\n \"🤖 **OpenHands is reviewing this PR.**\\n\\n\"\n f\"Trigger label: `{TRIGGER_LABEL}`\\n\"\n f\"Label event: `{label_event_id}` at `{label_event.get('created_at', '?')}`\\n\"\n f\"Head commit: `{head_sha}`\\n\"\n f\"View the conversation: {conv_url}\"\n ),\n )\n return conv_id\n\n\ndef _check_conversation_completion(\n rec: dict,\n latest_open_prs: dict[int, dict],\n github_token: str,\n agent_url: str,\n api_key: str,\n repo: str,\n) -> None:\n age = time.time() - rec.get(\"last_activity\", 0.0)\n if age < DONE_DEBOUNCE:\n return\n\n conv_id = rec[\"conversation_id\"]\n pr_number = rec[\"pr_number\"]\n reviewed_sha = rec.get(\"head_sha\", \"\")\n current_pr = latest_open_prs.get(pr_number)\n\n if not current_pr:\n rec[\"status\"] = \"closed\"\n print(f\" PR #{pr_number} closed/merged — skipping result post\")\n _release_checkout(rec, agent_url, api_key)\n return\n\n current_sha = _head_sha(current_pr)\n if current_sha and reviewed_sha and current_sha != reviewed_sha:\n rec[\"status\"] = \"stale\"\n rec[\"stale_reason\"] = f\"head changed from {reviewed_sha} to {current_sha}\"\n print(f\" PR #{pr_number} advanced to {current_sha[:12]} — suppressing stale review {conv_id}\")\n _release_checkout(rec, agent_url, api_key)\n return\n\n try:\n status = conversation_status(agent_url, api_key, conv_id)\n except Exception as exc:\n print(f\" Warning: could not get status for {conv_id}: {exc}\")\n return\n\n print(f\" PR #{pr_number} conversation {conv_id} → status={status}\")\n if status not in TERMINAL_STATUSES:\n if age > MAX_ACTIVE_AGE:\n rec[\"status\"] = \"expired\"\n rec[\"expired_after\"] = age\n print(f\" Review for PR #{pr_number} still '{status}' after {int(age)}s; abandoning it\")\n _release_checkout(rec, agent_url, api_key)\n return\n\n try:\n final = conversation_final_response(agent_url, api_key, conv_id)\n except Exception:\n final = \"\"\n\n if status in {\"error\", \"stuck\"}:\n _post_github_comment(\n github_token,\n repo,\n pr_number,\n _with_ai_disclosure(\n f\"⚠️ **OpenHands PR Reviewer encountered a problem** at commit `{reviewed_sha[:12]}` \"\n f\"(status: `{status}`).\\n\\n{final}\".strip()\n ),\n )\n elif _matching_review_exists(github_token, repo, pr_number, reviewed_sha):\n print(f\" PR #{pr_number}: review confirmed on GitHub at {reviewed_sha[:12]}\")\n else:\n # The agent was asked to publish the review itself; it did not, so the\n # work is not lost - post whatever it produced as a comment.\n _post_github_comment(\n github_token,\n repo,\n pr_number,\n _with_ai_disclosure(\n final\n or f\"✅ **OpenHands completed the review for commit `{reviewed_sha[:12]}`.** No review text was produced.\"\n ),\n )\n print(f\" PR #{pr_number}: no review found on GitHub; posted the result as a comment\")\n\n rec[\"status\"] = \"closed\"\n rec[\"completed_at\"] = time.time()\n _release_checkout(rec, agent_url, api_key)\n\n\ndef _process_repo(\n repo: str,\n github_token: str,\n agent_url: str,\n api_key: str,\n openhands_url: str,\n) -> str | None:\n \"\"\"Poll one repository end to end. Its state is loaded and saved here, so a\n failure in another repository cannot discard this one's progress.\"\"\"\n print(f\"\\n=== {repo} ===\")\n _verify_repo(github_token, repo)\n\n state = load_state(repo)\n reviews: dict = state.setdefault(\"reviews\", {})\n prs_state: dict = state.setdefault(\"prs\", {})\n\n def persist() -> None:\n state[\"version\"] = 3\n state[\"repo\"] = repo\n state[\"trigger_label\"] = TRIGGER_LABEL\n state[\"updated_at\"] = time.time()\n save_state(repo, state)\n\n open_prs = _list_open_prs(github_token, repo)\n latest_open_prs = {pr[\"number\"]: pr for pr in open_prs}\n print(f\" Found {len(open_prs)} open PR(s)\")\n\n last_conversation_id = None\n\n for pr in open_prs:\n number = pr[\"number\"]\n head_sha = _head_sha(pr)\n label_present = _has_trigger_label(pr)\n prs_state[str(number)] = {\n \"head_sha\": head_sha,\n \"label_present\": label_present,\n \"labels\": _labels(pr),\n \"last_seen\": time.time(),\n }\n\n if not label_present:\n continue\n if not head_sha:\n print(f\" PR #{number} has no head SHA; skipping\")\n continue\n\n fresh_pr = _get_pr(github_token, repo, number)\n fresh_head_sha = _head_sha(fresh_pr)\n if fresh_head_sha != head_sha:\n print(f\" PR #{number} head changed during poll ({head_sha[:12]} → {fresh_head_sha[:12]}); using latest PR metadata\")\n if not _has_trigger_label(fresh_pr):\n print(f\" PR #{number} lost `{TRIGGER_LABEL}` during poll; skipping\")\n continue\n\n label_event = _latest_trigger_label_event(github_token, repo, number)\n if not label_event:\n print(f\" PR #{number} has `{TRIGGER_LABEL}` but no matching labeled event; skipping\")\n continue\n\n key = _review_key(number, label_event[\"id\"])\n if key in reviews:\n print(f\" PR #{number} label event {label_event['id']} already tracked ({reviews[key].get('status')})\")\n continue\n\n conv_id = _process_review_request(\n github_token, agent_url, api_key, openhands_url, repo, fresh_pr, label_event, reviews, persist\n )\n if conv_id:\n last_conversation_id = conv_id\n\n for rev_key, rec in list(reviews.items()):\n if rec.get(\"status\") == \"starting\":\n # A claim this poll made has already moved to \"active\" or been\n # dropped, so one still sitting here belongs to a poll that died\n # between claiming and creating its conversation. Release it once it\n # is old enough that no live poll could still be working on it,\n # otherwise the label event would never be reviewed.\n age = time.time() - float(rec.get(\"last_activity\") or 0)\n if age > STALLED_CLAIM_SECONDS:\n print(f\" Releasing a claim stalled for {int(age)}s: {rev_key}\")\n reviews.pop(rev_key, None)\n continue\n if rec.get(\"status\") == \"active\":\n _check_conversation_completion(rec, latest_open_prs, github_token, agent_url, api_key, repo)\n elif rec.get(\"workspace_dir\"):\n # A checkout whose removal could not be confirmed on an earlier\n # poll, e.g. the agent was still running when its PR was closed.\n _release_checkout(rec, agent_url, api_key)\n\n persist()\n return last_conversation_id\n\n\ndef main() -> str | None:\n agent_url = os.environ.get(\"AGENT_SERVER_URL\", \"\").rstrip(\"/\")\n api_key = _get_env_key()\n\n github_token = _resolve_github_token()\n _verify_token(github_token)\n\n try:\n openhands_url = get_secret(\"OPENHANDS_URL\").rstrip(\"/\") or DEFAULT_OPENHANDS_URL\n except Exception:\n openhands_url = DEFAULT_OPENHANDS_URL\n\n last_conversation_id = None\n failures = []\n for configured in REPOS:\n # One repository failing must not stop the others from being polled.\n try:\n repo = normalize_repo(configured)\n conv_id = _process_repo(repo, github_token, agent_url, api_key, openhands_url)\n if conv_id:\n last_conversation_id = conv_id\n except Exception as exc:\n print(f\"Error processing {configured}: {exc}\")\n failures.append(f\"{configured}: {exc}\")\n\n if failures and len(failures) == len(REPOS):\n # Every repository failed, so the run achieved nothing - report it as a\n # failed run rather than a successful no-op.\n raise RuntimeError(\"; \".join(failures))\n return last_conversation_id\n\n\nif __name__ == \"__main__\":\n try:\n conversation_id = main()\n fire_callback(\"COMPLETED\", conversation_id=conversation_id)\n except Exception as exc:\n import traceback\n\n traceback.print_exc()\n fire_callback(\"FAILED\", str(exc))\n sys.exit(1)\n"
|
|
8
|
+
}
|
|
9
|
+
};
|
|
@@ -10,13 +10,12 @@
|
|
|
10
10
|
}
|
|
11
11
|
},
|
|
12
12
|
"features": [
|
|
13
|
-
"
|
|
14
|
-
"presetPrompt"
|
|
13
|
+
"customTarball"
|
|
15
14
|
]
|
|
16
15
|
},
|
|
17
16
|
"popularityRank": 100,
|
|
18
17
|
"estimatedSetupMinutes": 4,
|
|
19
|
-
"exampleImplementation": "Trigger: cron polling for open GitHub PRs with a configured label such as openhands-review\nRequired secret: GITHUB_PERSONAL_ACCESS_TOKEN\n\n1. Read
|
|
18
|
+
"exampleImplementation": "Trigger: cron polling for open GitHub PRs with a configured label such as openhands-review\nRequired secret: GITHUB_PERSONAL_ACCESS_TOKEN, with permission to write pull request reviews\n\n1. Read the repositories, trigger label, review tone, and polling schedule from setup.\n2. Poll each repository independently, with its own state, so PR numbers never collide.\n3. List open PRs and find the latest matching GitHub labeled issue event for each labeled PR.\n4. Deduplicate on the label event ID so every label application queues exactly one review.\n5. Extract the PR head commit into a directory of its own and start an OpenHands conversation with that directory as its workspace, so the agent reviews the exact commit without cloning anything.\n6. Post an acknowledgement with the conversation link, then confirm on GitHub that the review was published for that head SHA, falling back to posting the agent's text as a comment.\n7. Remove the checkout once the conversation has stopped, so nothing accumulates between runs.",
|
|
20
19
|
"setup": {
|
|
21
20
|
"version": "1.0",
|
|
22
21
|
"mode": "direct",
|
|
@@ -40,11 +39,12 @@
|
|
|
40
39
|
}
|
|
41
40
|
},
|
|
42
41
|
"args": {
|
|
43
|
-
"
|
|
42
|
+
"repositories": {
|
|
44
43
|
"type": "repo-picker",
|
|
45
|
-
"label": "
|
|
46
|
-
"help": "The
|
|
44
|
+
"label": "Repositories",
|
|
45
|
+
"help": "The repositories whose pull requests will be reviewed. Each is polled independently and keeps its own state, so pull request numbers never collide between them.",
|
|
47
46
|
"provider": "github",
|
|
47
|
+
"multiple": true,
|
|
48
48
|
"required": true
|
|
49
49
|
},
|
|
50
50
|
"triggerLabel": {
|
|
@@ -72,12 +72,28 @@
|
|
|
72
72
|
{
|
|
73
73
|
"value": "thorough",
|
|
74
74
|
"label": "Thorough"
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"value": "friendly",
|
|
78
|
+
"label": "Friendly"
|
|
75
79
|
}
|
|
76
80
|
]
|
|
77
81
|
}
|
|
78
82
|
}
|
|
79
83
|
},
|
|
80
|
-
"
|
|
84
|
+
"bundle": {
|
|
85
|
+
"version": "1.0.0",
|
|
86
|
+
"entrypoint": "python3 main.py",
|
|
87
|
+
"timeout": 600,
|
|
88
|
+
"files": {
|
|
89
|
+
"main.py": "skills/github-pr-reviewer/scripts/main.py"
|
|
90
|
+
},
|
|
91
|
+
"config": {
|
|
92
|
+
"repos": "{{form.repositories}}",
|
|
93
|
+
"trigger_label": "{{form.triggerLabel}}",
|
|
94
|
+
"review_tone": "{{form.reviewTone}}"
|
|
95
|
+
}
|
|
96
|
+
},
|
|
81
97
|
"message": "This deployment cannot run the scheduled review automation directly. Set it up in this conversation instead: confirm the repository to review, the trigger label, the review tone, and the polling schedule, then create the automation."
|
|
82
98
|
}
|
|
83
99
|
}
|
|
@@ -11,47 +11,31 @@
|
|
|
11
11
|
},
|
|
12
12
|
"features": [
|
|
13
13
|
"repoClone",
|
|
14
|
-
"presetPrompt"
|
|
15
|
-
"webhookDelivery"
|
|
14
|
+
"presetPrompt"
|
|
16
15
|
]
|
|
17
16
|
},
|
|
18
17
|
"popularityRank": 98,
|
|
19
18
|
"estimatedSetupMinutes": 5,
|
|
20
|
-
"exampleImplementation": "Trigger: cron
|
|
19
|
+
"exampleImplementation": "Trigger: cron polling (e.g. every 15 minutes, configurable)\nRequired secret: GITHUB_PERSONAL_ACCESS_TOKEN\n\n1. Poll GitHub for new issue and PR comments since the last run.\n2. Match comments containing the trigger phrase (case-insensitive, default: @OpenHands).\n3. Post an acknowledgment comment with a link to the new OpenHands conversation.\n4. Forward follow-up replies in the same thread to the running conversation.\n5. Post the agent's final response back to GitHub when the conversation completes.",
|
|
21
20
|
"setup": {
|
|
22
21
|
"version": "1.0",
|
|
23
22
|
"mode": "direct",
|
|
24
23
|
"form": {
|
|
25
24
|
"triggers": {
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"type": "
|
|
29
|
-
"label": "
|
|
30
|
-
"help": "
|
|
31
|
-
"default": "
|
|
32
|
-
"required": true
|
|
33
|
-
"options": [
|
|
34
|
-
{
|
|
35
|
-
"value": "issue_comment.created",
|
|
36
|
-
"label": "Comments on issues and pull requests"
|
|
37
|
-
},
|
|
38
|
-
{
|
|
39
|
-
"value": "pull_request_review_comment.created",
|
|
40
|
-
"label": "Pull request review comments only"
|
|
41
|
-
}
|
|
42
|
-
]
|
|
25
|
+
"cron": {
|
|
26
|
+
"schedule": {
|
|
27
|
+
"type": "cron",
|
|
28
|
+
"label": "Check frequency",
|
|
29
|
+
"help": "How often to look for new comments to respond to.",
|
|
30
|
+
"default": "*/15 * * * *",
|
|
31
|
+
"required": true
|
|
43
32
|
},
|
|
44
|
-
"
|
|
45
|
-
"type": "
|
|
46
|
-
"label": "
|
|
47
|
-
"help": "
|
|
48
|
-
"default": "
|
|
49
|
-
"required": true
|
|
50
|
-
"constraints": {
|
|
51
|
-
"minLength": 2,
|
|
52
|
-
"maxLength": 50,
|
|
53
|
-
"format": "safeExpressionLiteral"
|
|
54
|
-
}
|
|
33
|
+
"timezone": {
|
|
34
|
+
"type": "timezone",
|
|
35
|
+
"label": "Timezone",
|
|
36
|
+
"help": "Timezone the schedule is interpreted in.",
|
|
37
|
+
"default": "UTC",
|
|
38
|
+
"required": true
|
|
55
39
|
}
|
|
56
40
|
}
|
|
57
41
|
},
|
|
@@ -63,6 +47,17 @@
|
|
|
63
47
|
"provider": "github",
|
|
64
48
|
"required": true
|
|
65
49
|
},
|
|
50
|
+
"triggerPhrase": {
|
|
51
|
+
"type": "text",
|
|
52
|
+
"label": "Trigger phrase",
|
|
53
|
+
"help": "Only comments containing this phrase start a conversation. Matched case-insensitively.",
|
|
54
|
+
"default": "@openhands",
|
|
55
|
+
"required": true,
|
|
56
|
+
"constraints": {
|
|
57
|
+
"minLength": 2,
|
|
58
|
+
"maxLength": 50
|
|
59
|
+
}
|
|
60
|
+
},
|
|
66
61
|
"ref": {
|
|
67
62
|
"type": "text",
|
|
68
63
|
"label": "Base branch",
|
|
@@ -76,8 +71,7 @@
|
|
|
76
71
|
}
|
|
77
72
|
}
|
|
78
73
|
},
|
|
79
|
-
"prompt": "
|
|
80
|
-
"
|
|
81
|
-
"message": "This deployment cannot run the webhook-driven monitor directly. Set it up in this conversation instead: confirm the repository to watch, the comment event, and the trigger phrase, then create the automation. If webhook delivery is unavailable here, build the polling variant this skill supports."
|
|
74
|
+
"prompt": "Poll {{form.repository}} for any new issue or pull request comments since the last run. For every comment mentioning '{{form.triggerPhrase}}', read the surrounding issue or pull request context and post a helpful reply as a comment on the same thread.",
|
|
75
|
+
"message": "This deployment cannot run the scheduled monitor directly. Set it up in this conversation instead: confirm the repository to watch, the trigger phrase, and the polling schedule, then create the automation."
|
|
82
76
|
}
|
|
83
77
|
}
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
"description": "Watch a Jira Cloud project for issues with a configurable label and automatically open a GitHub pull request for each new issue found. The target GitHub repo is read from the ticket body - no repo parameter required at deploy time.",
|
|
6
6
|
"requires": {
|
|
7
7
|
"integrations": {
|
|
8
|
-
"
|
|
9
|
-
"message": "
|
|
8
|
+
"atlassian-rovo": {
|
|
9
|
+
"message": "Provides the Atlassian Rovo MCP connection used to access Jira data."
|
|
10
10
|
},
|
|
11
11
|
"github": {
|
|
12
12
|
"message": "Opens a pull request on the repository named in the ticket."
|
|
@@ -15,5 +15,5 @@
|
|
|
15
15
|
},
|
|
16
16
|
"popularityRank": 85,
|
|
17
17
|
"estimatedSetupMinutes": 5,
|
|
18
|
-
"exampleImplementation": "Trigger: cron polling (e.g. every 5 minutes)\nRequired
|
|
18
|
+
"exampleImplementation": "Trigger: cron polling (e.g. every 5 minutes)\nRequired integrations: Atlassian Rovo MCP for Jira access and a GitHub MCP connection. The poller also needs a Jira API token and a GitHub personal access token (repo + workflow scope) as secrets for its direct API calls.\n\n1. Connect Atlassian Rovo MCP and GitHub, then collect the Jira base URL, email, API token secret name, label to watch, and cron schedule from the user. No GitHub repo is needed at deploy time - each ticket must include the target repo (owner/repo) in its body.\n2. Poll POST /rest/api/3/search/jql on the Jira Cloud instance to find open issues carrying the configured label.\n3. Deduplicate against a KV-store-backed set of already-processed issue keys so re-runs never create duplicate PRs.\n4. For each new issue, start an independent OpenHands agent conversation that extracts the GitHub repo from the ticket body, clones it, creates a branch named after the Jira key, implements or scaffolds the requested change, and opens a pull request.\n5. Immediately after the conversation is created, post a Jira comment on the issue: 'I'm on it: <conversation URL>'.\n6. Persist the processed issue key immediately after dispatching so the next poll skips it."
|
|
19
19
|
}
|