@openhands/extensions 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/.claude-plugin/marketplace.json +30 -0
  2. package/.codex-plugin/marketplace.json +30 -0
  3. package/.github/pull_request_template.md +17 -2
  4. package/.github/scripts/check_pr_description.py +64 -5
  5. package/.github/scripts/markdown_sections.py +95 -0
  6. package/.github/workflows/build-vscode-app.yml +30 -0
  7. package/.github/workflows/check-extensions.yml +3 -0
  8. package/.github/workflows/tests.yml +26 -0
  9. package/.plugin/marketplace.json +30 -0
  10. package/.release-please-manifest.json +1 -1
  11. package/AGENTS.md +3 -2
  12. package/README.md +10 -3
  13. package/apps/README.md +20 -0
  14. package/apps/catalog.json +10 -0
  15. package/apps/index.d.ts +11 -0
  16. package/apps/index.js +5 -0
  17. package/apps/vscode/README.md +13 -0
  18. package/apps/vscode/canvas-extension.json +19 -0
  19. package/apps/vscode/extension.js +114 -0
  20. package/apps/vscode/scripts/package.mjs +84 -0
  21. package/automations/bundle-index.js +18 -14
  22. package/automations/catalog/github-agents-md-maintainer/manifest.json +1 -1
  23. package/automations/catalog/github-pr-reviewer/manifest.json +19 -8
  24. package/automations/catalog/github-stale-ci-pr-closer/manifest.json +71 -0
  25. package/automations/catalog/gitlab-issue-to-mr/manifest.json +1 -1
  26. package/automations/catalog/news-digest/manifest.json +2 -2
  27. package/automations/catalog-index.js +2 -0
  28. package/index.d.ts +2 -0
  29. package/index.js +1 -0
  30. package/marketplaces/openhands-extensions.json +30 -0
  31. package/package.json +9 -1
  32. package/plugins/openhands/skills/openhands-api/README.md +1 -1
  33. package/plugins/openhands/skills/openhands-api/SKILL.md +3 -3
  34. package/plugins/openhands/skills/openhands-api/scripts/openhands_api.py +13 -0
  35. package/plugins/openhands/skills/openhands-automation/SKILL.md +66 -4
  36. package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +21 -7
  37. package/plugins/openhands/skills/openhands-sdk/SKILL.md +1 -0
  38. package/plugins/pr-review/skills/github-pr-review/SKILL.md +12 -2
  39. package/plugins/qa-changes/skills/github-pr-review/SKILL.md +12 -2
  40. package/plugins/runtime-api-configs/.claude-plugin/plugin.json +20 -0
  41. package/plugins/runtime-api-configs/.codex-plugin/plugin.json +20 -0
  42. package/plugins/runtime-api-configs/.plugin/plugin.json +20 -0
  43. package/plugins/runtime-api-configs/README.md +59 -0
  44. package/plugins/runtime-api-configs/SKILL.md +240 -0
  45. package/plugins/runtime-api-configs/scripts/warm_runtime_configs.py +331 -0
  46. package/plugins/runtime-api-configs/tests/test_warm_runtime_configs.py +338 -0
  47. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +223 -43
  48. package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +183 -15
  49. package/pyproject.toml +1 -1
  50. package/python/openhands_extensions/_version.py +1 -1
  51. package/scripts/check_plugin_resources.py +116 -0
  52. package/scripts/sync_extensions.py +32 -5
  53. package/skills/canvas-extension-api/SKILL.md +34 -3
  54. package/skills/canvas-extension-api/references/backend-safety.md +14 -0
  55. package/skills/canvas-extension-api/references/connections.md +24 -0
  56. package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -10
  57. package/skills/canvas-extension-api/references/v1-contract.md +53 -3
  58. package/skills/canvas-extension-api/scripts/validate-extension.mjs +52 -0
  59. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +192 -1
  60. package/skills/github/scripts/agent_conversation.py +223 -43
  61. package/skills/github/scripts/github_client.py +183 -15
  62. package/skills/github-agents-md-maintainer/README.md +7 -0
  63. package/skills/github-agents-md-maintainer/SKILL.md +13 -6
  64. package/skills/github-agents-md-maintainer/scripts/github_client.py +183 -15
  65. package/skills/github-agents-md-maintainer/scripts/main.py +199 -29
  66. package/skills/github-delivery-watchdog/scripts/github_client.py +183 -15
  67. package/skills/github-issue-to-pr/scripts/agent_conversation.py +223 -43
  68. package/skills/github-issue-to-pr/scripts/github_client.py +183 -15
  69. package/skills/github-issue-to-pr/scripts/worker.py +10 -1
  70. package/skills/github-issue-triage/scripts/agent_conversation.py +223 -43
  71. package/skills/github-issue-triage/scripts/github_client.py +183 -15
  72. package/skills/github-pr-review/SKILL.md +12 -2
  73. package/skills/github-pr-reviewer/README.md +36 -0
  74. package/skills/github-pr-reviewer/SKILL.md +147 -4
  75. package/skills/github-pr-reviewer/scripts/agent_conversation.py +223 -43
  76. package/skills/github-pr-reviewer/scripts/github_client.py +183 -15
  77. package/skills/github-pr-reviewer/scripts/main.py +17 -1
  78. package/skills/github-pr-reviewer/scripts/worker.py +842 -57
  79. package/skills/github-repo-monitor/scripts/github_client.py +183 -15
  80. package/skills/github-repo-monitor/scripts/main.py +32 -1
  81. package/skills/github-stale-ci-pr-closer/.claude-plugin/plugin.json +9 -0
  82. package/skills/github-stale-ci-pr-closer/.codex-plugin/plugin.json +9 -0
  83. package/skills/github-stale-ci-pr-closer/.plugin/plugin.json +9 -0
  84. package/skills/github-stale-ci-pr-closer/README.md +9 -0
  85. package/skills/github-stale-ci-pr-closer/SKILL.md +24 -0
  86. package/skills/github-stale-ci-pr-closer/commands/github-stale-ci-pr-closer.md +8 -0
  87. package/skills/github-stale-ci-pr-closer/scripts/worker.py +508 -0
  88. package/skills/github-stale-ci-pr-closer/tests/test_worker.py +353 -0
  89. package/skills/gitlab-issue-to-mr/README.md +12 -0
  90. package/skills/gitlab-issue-to-mr/SKILL.md +13 -6
  91. package/skills/gitlab-issue-to-mr/scripts/main.py +217 -42
  92. package/skills/incident-retrospective/SKILL.md +5 -5
  93. package/skills/index.js +34 -16
  94. package/skills/jira-issue-to-pr/README.md +12 -4
  95. package/skills/jira-issue-to-pr/SKILL.md +129 -18
  96. package/skills/jira-issue-to-pr/references/setup.md +8 -0
  97. package/skills/jira-issue-to-pr/scripts/main.py +181 -54
  98. package/skills/jira-issue-to-pr/scripts/setup_webhook.py +290 -0
  99. package/skills/linear-triage/SKILL.md +5 -5
  100. package/skills/news-digest/README.md +6 -0
  101. package/skills/news-digest/SKILL.md +19 -9
  102. package/skills/news-digest/commands/news-digest-setup.md +1 -1
  103. package/skills/news-digest/scripts/main.py +133 -15
  104. package/skills/openhands-api/README.md +1 -1
  105. package/skills/openhands-api/SKILL.md +3 -3
  106. package/skills/openhands-api/scripts/openhands_api.py +13 -0
  107. package/skills/openhands-automation/SKILL.md +66 -4
  108. package/skills/openhands-automation/references/custom-automation.md +21 -7
  109. package/skills/openhands-sdk/SKILL.md +1 -0
  110. package/skills/research-brief/SKILL.md +5 -5
  111. package/skills/slack-standup-digest/SKILL.md +5 -5
  112. package/skills/ticket-to-code-change/SKILL.md +6 -3
  113. package/skills/use-jev/.claude-plugin/plugin.json +8 -0
  114. package/skills/use-jev/.codex-plugin/plugin.json +8 -0
  115. package/skills/use-jev/.plugin/plugin.json +8 -0
  116. package/skills/use-jev/README.md +66 -0
  117. package/skills/use-jev/SKILL.md +66 -0
  118. package/tests/fixtures/automations/github-pr-reviewer.json +39 -24
  119. package/tests/test_agent_conversation_dispatch.py +212 -0
  120. package/tests/test_apps_catalog.py +26 -0
  121. package/tests/test_automation_setup.py +44 -0
  122. package/tests/test_check_pr_description.py +89 -6
  123. package/tests/test_github_automation_foundation.py +158 -0
  124. package/tests/test_github_repo_monitor.py +107 -0
  125. package/tests/test_github_reviewer_delivery.py +2247 -6
  126. package/tests/test_github_stale_ci_pr_closer.py +56 -0
  127. package/tests/test_jira_issue_to_pr.py +312 -0
  128. package/tests/test_jira_setup_webhook.py +309 -0
  129. package/tests/test_openhands_api_python.py +37 -0
  130. package/tests/test_skill_plugin_loading.py +25 -15
  131. package/tests/test_sync_extensions.py +102 -0
  132. package/tests/test_vscode_app.py +33 -0
  133. package/tests/vscode-app.test.mjs +43 -0
@@ -28,10 +28,13 @@ This skill is activated by keywords:
28
28
  Subsequent runs filter by both `first_run_at` and a KV-backed set of
29
29
  already-processed issue keys. A `max_new_per_run` cap (default 5) limits
30
30
  conversations started per cron firing.
31
- 3. **Dispatch** - for each new issue, the script calls
32
- `POST /api/conversations` on the agent server to start an independent agent
33
- conversation with a PR-creation prompt. The prompt instructs the agent to
34
- extract the target GitHub repository (`owner/repo`) from the ticket body.
31
+ 3. **Dispatch** - for each new issue, the script starts an independent agent
32
+ conversation with a PR-creation prompt: `POST /api/conversations` on the
33
+ agent server when running locally, `POST /api/v1/app-conversations` on
34
+ OpenHands Cloud and Enterprise. The prompt instructs the agent to extract the
35
+ target GitHub repository (`owner/repo`) from the ticket body. On Cloud the
36
+ next poll confirms the conversation came up and starts it again when it did
37
+ not.
35
38
  4. **Comment** - immediately after the conversation is created, the script
36
39
  posts a Jira comment on the issue: `I'm on it: <conversation URL>`.
37
40
  5. **Persist** - the processed issue key is recorded so re-runs never
@@ -40,6 +43,11 @@ This skill is activated by keywords:
40
43
  The polling run is lightweight (Python stdlib only, no SDK install); LLM costs
41
44
  are incurred only when new issues are actually found.
42
45
 
46
+ Where Jira can reach the deployment (OpenHands Cloud and Enterprise), the skill
47
+ can set up an event-based automation instead of this poller: Jira pushes label
48
+ events through a webhook, which `scripts/setup_webhook.py` registers in both
49
+ OpenHands and Jira. See "Event-Based Alternative" in `SKILL.md`.
50
+
43
51
  ## Prerequisites
44
52
 
45
53
  | Requirement | Details |
@@ -30,10 +30,10 @@ starts, it also posts a comment on the Jira ticket: "I'm on it: &lt;conversation
30
30
  be picked up. Subsequent runs filter by both `first_run_at` and a KV-backed set of
31
31
  already-processed issue keys. A `max_new_per_run` cap (default 5) limits conversations
32
32
  started per cron firing as additional defense-in-depth.
33
- 3. **Dispatch** - for each new issue, call `POST /api/conversations` on the agent server
34
- to start an independent agent conversation with a PR-creation prompt. The prompt
35
- instructs the agent to extract the target GitHub repository (`owner/repo`) from the
36
- ticket body.
33
+ 3. **Dispatch** - for each new issue, start an independent agent conversation with a
34
+ PR-creation prompt: `POST /api/conversations` on the agent server when running locally,
35
+ or `POST /api/v1/app-conversations` on OpenHands Cloud. The prompt instructs the agent
36
+ to extract the target GitHub repository (`owner/repo`) from the ticket body.
37
37
  4. **Comment** - immediately after the conversation is created, post a Jira comment on the
38
38
  issue: `I'm on it: <conversation URL>`.
39
39
  5. **Persist** - record the processed issue key so re-runs never duplicate work.
@@ -48,12 +48,28 @@ Before deploying, ensure the following are in place:
48
48
  | Requirement | Details |
49
49
  |---|---|
50
50
  | **Jira API token** | Stored as an OpenHands secret (see [Jira API token setup](#jira-api-token)) |
51
- | **GitHub token** | Must be stored as an OpenHands secret with `repo` + `workflow` scope so the spawned conversation can push branches and open PRs |
51
+ | **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) |
52
+ | **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 |
52
53
  | **Jira label** | The label to watch for (default: `create-pr`) must exist in the Jira project |
53
54
  | **GitHub repo** | The target repository must exist and the GitHub token must have write access |
54
55
 
55
56
  ## Deploying the Automation
56
57
 
58
+ ### Determine the API host and auth
59
+
60
+ Set `OPENHANDS_HOST` and `AUTH_HEADER` for the curl commands below:
61
+
62
+ - **OpenHands Cloud** - a `<HOST>` value is present in the system prompt:
63
+ ```bash
64
+ OPENHANDS_HOST="<HOST value>"
65
+ AUTH_HEADER="Authorization: Bearer $OPENHANDS_API_KEY"
66
+ ```
67
+ - **Local Agent Canvas** - no `<HOST>` value:
68
+ ```bash
69
+ OPENHANDS_HOST="http://localhost:8000"
70
+ AUTH_HEADER="X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY"
71
+ ```
72
+
57
73
  ### Step 1 - Collect parameters
58
74
 
59
75
  Gather the following from the user before proceeding:
@@ -101,8 +117,8 @@ python3 -m py_compile "$WORK/main.py" # validate syntax before uploading
101
117
 
102
118
  ```bash
103
119
  TARBALL_PATH=$(curl -s -X POST \
104
- "http://localhost:8000/api/automation/v1/uploads?name=jira-issue-to-pr" \
105
- -H "X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY" \
120
+ "${OPENHANDS_HOST}/api/automation/v1/uploads?name=jira-issue-to-pr" \
121
+ -H "$AUTH_HEADER" \
106
122
  -H "Content-Type: application/gzip" \
107
123
  --data-binary @/tmp/jira-issue-to-pr.tar.gz \
108
124
  | python3 -c "import sys,json; print(json.load(sys.stdin)['tarball_path'])")
@@ -111,8 +127,8 @@ TARBALL_PATH=$(curl -s -X POST \
111
127
  ### Step 5 - Create the automation
112
128
 
113
129
  ```bash
114
- curl -s -X POST "http://localhost:8000/api/automation/v1" \
115
- -H "X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY" \
130
+ curl -s -X POST "${OPENHANDS_HOST}/api/automation/v1" \
131
+ -H "$AUTH_HEADER" \
116
132
  -H "Content-Type: application/json" \
117
133
  -d "{
118
134
  \"name\": \"Jira issue-to-PR Poller\",
@@ -133,15 +149,107 @@ Save the returned `id` - use it for updates and monitoring.
133
149
 
134
150
  ```bash
135
151
  curl -s -X POST \
136
- "http://localhost:8000/api/automation/v1/<AUTOMATION_ID>/dispatch" \
137
- -H "X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY" | python3 -m json.tool
152
+ "${OPENHANDS_HOST}/api/automation/v1/<AUTOMATION_ID>/dispatch" \
153
+ -H "$AUTH_HEADER" | python3 -m json.tool
138
154
 
139
155
  # After ~30 seconds, check the run status:
140
- curl -s "http://localhost:8000/api/automation/v1/<AUTOMATION_ID>/runs?limit=1" \
141
- -H "X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY" \
156
+ curl -s "${OPENHANDS_HOST}/api/automation/v1/<AUTOMATION_ID>/runs?limit=1" \
157
+ -H "$AUTH_HEADER" \
142
158
  | python3 -c "import sys,json; r=json.load(sys.stdin)['runs'][0]; print(r['status'], r.get('error_detail'))"
143
159
  ```
144
160
 
161
+ ## Event-Based Alternative
162
+
163
+ Where Jira can reach the deployment over the internet (OpenHands Cloud and Enterprise),
164
+ Jira can push label events instead of being polled. That is a different automation from
165
+ the poller above: a prompt automation triggered by a Jira webhook, created with the
166
+ `openhands-automation` skill (see its "Custom Webhook Example: Jira Cloud").
167
+ `scripts/main.py` is not used, so its KV-backed deduplication and its "I'm on it"
168
+ comment do not apply; the prompt has to ask for whatever of that is wanted.
169
+
170
+ ### Set up the webhook
171
+
172
+ The webhook is a pair: a custom webhook source in OpenHands and an admin webhook in
173
+ Jira, sharing a signing secret. `scripts/setup_webhook.py` creates both, so the user
174
+ does not have to open Jira or copy a URL and secret by hand. It needs the API token of
175
+ an account with the **Administer Jira** permission, stored as an OpenHands secret.
176
+
177
+ 1. **Collect** the Jira site URL, the account email, the name of the secret holding that
178
+ account's API token, and the label. A token used only for this step (for example
179
+ `JIRA_ADMIN_TOKEN`) can be deleted from the user's secrets afterwards.
180
+ 2. **Dry run, then ask.** Registering a webhook changes the user's Jira site, so show
181
+ what will be created and wait for the user to confirm:
182
+
183
+ ```bash
184
+ OPENHANDS_API_KEY="$OPENHANDS_API_KEY" python3 <skill-dir>/scripts/setup_webhook.py apply --dry-run \
185
+ --jira-base-url "https://acme.atlassian.net" --jira-email "alice@acme.com" \
186
+ --jira-token-env JIRA_ADMIN_TOKEN --openhands-host "$OPENHANDS_HOST" --label create-pr
187
+ ```
188
+
189
+ The command has to name the secret (`--jira-token-env JIRA_ADMIN_TOKEN`): naming it is
190
+ what makes its value available to the command.
191
+ 3. **Apply** - the same command without `--dry-run`. It registers the OpenHands webhook
192
+ (`source` `jira`, `webhookEvent`, `X-Hub-Signature`), creates the Jira webhook for
193
+ *Issue created* and *Issue updated* limited to `labels = "create-pr"`, and sends one
194
+ signed test request. `"signed_delivery_check": "ok"` in its output means the pair works.
195
+ Running it again updates the Jira webhook in place and keeps the secret, so a
196
+ failure half way cannot break a working pair. Add `--rotate-secret` when the two
197
+ sides have drifted apart, for instance after the deployment was reinstalled.
198
+
199
+ The secret is generated inside the script and is never printed. Do not ask the user
200
+ for it, and do not try to read or echo it.
201
+ 4. **If it exits with code 3**, the account is not a Jira administrator. Fall back to
202
+ [Manual webhook setup](#manual-webhook-setup).
203
+
204
+ To stop Jira sending events, for example when the automation is deleted, run the same
205
+ command with `delete` in place of `apply`.
206
+
207
+ ### Create the event-triggered automation
208
+
209
+ Create the automation with this trigger:
210
+
211
+ ```json
212
+ {
213
+ "type": "event",
214
+ "source": "jira",
215
+ "on": ["jira:issue_created", "jira:issue_updated"],
216
+ "filter": "contains(issue.fields.labels, 'create-pr') && (webhookEvent == 'jira:issue_created' || length(changelog.items[?field == 'labels'] || `[]`) > `0`)"
217
+ }
218
+ ```
219
+
220
+ Jira sends labels as plain strings, so the filter reads `issue.fields.labels`, not
221
+ `issue.fields.labels[].name`. The `changelog` check limits updates to those that
222
+ change the labels; without it every later edit of a labelled issue starts another run.
223
+ It still fires when another label is added to or removed from an issue that
224
+ carries the label, so have the prompt skip an issue that already has a pull request.
225
+
226
+ To verify, add the label to an issue and check the automation's runs.
227
+
228
+ ### Manual webhook setup
229
+
230
+ Use this when the Jira account is not an administrator: the user, or their Jira
231
+ administrator, does both steps by hand.
232
+
233
+ 1. **Register the webhook** with `"source": "jira"`, `"event_key_expr": "webhookEvent"`
234
+ and `"signature_header": "X-Hub-Signature"` (see the `openhands-automation` skill for
235
+ the request). The defaults (`type`, `X-Signature-256`) do not fit Jira: deliveries are
236
+ refused with 401, or accepted and never matched.
237
+ 2. **Configure Jira** - a Jira admin creates the webhook under Jira settings → System →
238
+ WebHooks with the returned `webhook_url` and secret, the *Issue created* and
239
+ *Issue updated* events, and a JQL filter such as `labels = create-pr`. A Jira
240
+ Automation rule cannot be used instead: its "Send web request" action is not signed.
241
+
242
+ To test a manually configured webhook without Jira, sign a request the way Jira does:
243
+
244
+ ```bash
245
+ BODY='{"webhookEvent":"jira:issue_created","issue":{"key":"TEST-1","fields":{"labels":["create-pr"]}}}'
246
+ SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $NF}')
247
+ curl -s -X POST "<webhook_url>" -H "Content-Type: application/json" \
248
+ -H "X-Hub-Signature: sha256=$SIG" -d "$BODY"
249
+ ```
250
+
251
+ `"matched": 1` in the response means the trigger fired and a run was started.
252
+
145
253
  ## Updating an Existing Deployment
146
254
 
147
255
  To change configuration or update the script:
@@ -152,8 +260,8 @@ To change configuration or update the script:
152
260
 
153
261
  ```bash
154
262
  curl -s -X PATCH \
155
- "http://localhost:8000/api/automation/v1/<AUTOMATION_ID>" \
156
- -H "X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY" \
263
+ "${OPENHANDS_HOST}/api/automation/v1/<AUTOMATION_ID>" \
264
+ -H "$AUTH_HEADER" \
157
265
  -H "Content-Type: application/json" \
158
266
  -d "{\"tarball_path\": \"<NEW_TARBALL_PATH>\"}"
159
267
  ```
@@ -164,7 +272,7 @@ To reprocess issues that were already handled (e.g., after testing), clear the K
164
272
 
165
273
  ```bash
166
274
  curl -s -X DELETE \
167
- "http://localhost:8000/api/automation/v1/<KV_BASE>/v1/kv/state" \
275
+ "${OPENHANDS_HOST}/api/automation/v1/<KV_BASE>/v1/kv/state" \
168
276
  -H "Authorization: Bearer $AUTOMATION_KV_TOKEN"
169
277
  ```
170
278
 
@@ -178,11 +286,14 @@ The automation script lives at `scripts/main.py`. Key behaviors:
178
286
  - **Config file** - reads all parameters from `config.json` co-located with the script.
179
287
  - **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.
180
288
  - **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.
181
- - **KV store** - persists `{"processed_keys": [...], "first_run_at": "..."}` between runs; falls back to a local file in dev environments where `AUTOMATION_KV_TOKEN` is absent.
289
+ - **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).
182
290
  - **Jira API** - uses `POST /rest/api/3/search/jql` (the current non-deprecated endpoint).
183
- - **Conversation dispatch** - calls `POST /api/conversations` on the agent server with the current user's LLM/agent settings forwarded to the new conversation.
291
+ - **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.
292
+ - **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.
184
293
  - **Error transparency** - captures Jira HTTP response bodies in error messages for fast diagnosis.
185
294
 
295
+ `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.
296
+
186
297
  ## Known Limitations
187
298
 
188
299
  ### Pre-existing issues updated after deployment
@@ -9,6 +9,14 @@
9
9
  3. Give it a label (e.g., `openhands-create-pr`) and click **Create**.
10
10
  4. Copy the token value - it is shown only once.
11
11
 
12
+ ### Token for the event-based webhook setup
13
+
14
+ `scripts/setup_webhook.py` registers a webhook in Jira, which Jira allows only for an
15
+ account with the **Administer Jira** global permission. Create the token as above while
16
+ logged in as such an account - a classic API token, not one restricted to scopes - and
17
+ store it under its own name, for example `JIRA_ADMIN_TOKEN`. It is needed only while the
18
+ webhook is set up, changed or deleted, and can be removed from the secrets afterwards.
19
+
12
20
  ### Store the token as an OpenHands secret
13
21
 
14
22
  Navigate to **Settings → Secrets** in the OpenHands UI and add a new secret:
@@ -10,8 +10,11 @@ config.json fields:
10
10
 
11
11
  The target GitHub repository is NOT configured here. Each Jira ticket body must include
12
12
  the repo in "owner/repo" format; the spawned agent extracts it from the ticket text.
13
+
14
+ Runs against a local agent server (AGENT_SERVER_URL is injected) or on OpenHands Cloud
15
+ (OPENHANDS_CLOUD_API_URL / OPENHANDS_API_KEY / SANDBOX_ID are injected instead).
13
16
  """
14
- import base64, json, os, re, sys, tempfile, urllib.error, urllib.request
17
+ import base64, json, os, re, sys, tempfile, urllib.error, urllib.request, uuid
15
18
  from datetime import datetime, timezone
16
19
  from pathlib import Path
17
20
 
@@ -25,6 +28,11 @@ JIRA_EMAIL = _cfg["jira_email"]
25
28
  JIRA_TOKEN_SECRET = _cfg.get("jira_token_secret", "JIRA_CLOUD_KEY")
26
29
  JIRA_LABEL = _cfg.get("jira_label", "create-pr")
27
30
  MAX_NEW_PER_RUN = int(_cfg.get("max_new_per_run", 5))
31
+ # How often a conversation that OpenHands Cloud failed to start is started again.
32
+ MAX_START_ATTEMPTS = 3
33
+
34
+ # Only a local run is handed the agent server's URL.
35
+ IS_LOCAL = bool(os.environ.get("AGENT_SERVER_URL"))
28
36
 
29
37
  # ── KV store helpers ──────────────────────────────────────────────────────────
30
38
  _KV_TOKEN = os.environ.get("AUTOMATION_KV_TOKEN", "")
@@ -105,10 +113,16 @@ def save_state(state):
105
113
 
106
114
  # ── Required stdlib helpers ───────────────────────────────────────────────────
107
115
  def get_secret(name):
108
- url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
109
116
  key = os.environ.get("SESSION_API_KEY") or os.environ.get("OH_SESSION_API_KEYS_0", "")
117
+ if IS_LOCAL:
118
+ url = f"{os.environ['AGENT_SERVER_URL'].rstrip('/')}/api/settings/secrets/{name}"
119
+ else:
120
+ # Cloud: the OpenHands Cloud API serves the secrets of this run's sandbox.
121
+ cloud_url = os.environ.get("OPENHANDS_CLOUD_API_URL", "").rstrip("/")
122
+ sandbox_id = os.environ.get("SANDBOX_ID", "")
123
+ url = f"{cloud_url}/api/v1/sandboxes/{sandbox_id}/settings/secrets/{name}"
110
124
  with urllib.request.urlopen(urllib.request.Request(
111
- f"{url}/api/settings/secrets/{name}", headers={"X-Session-API-Key": key}
125
+ url, headers={"X-Session-API-Key": key}
112
126
  )) as r:
113
127
  return r.read().decode().strip()
114
128
 
@@ -120,12 +134,15 @@ def fire_callback(status="COMPLETED", error=None):
120
134
  body = {"status": status, "run_id": os.environ.get("AUTOMATION_RUN_ID", "")}
121
135
  if error:
122
136
  body["error"] = error
137
+ # Cloud runs are not given a callback key; they authenticate with their API key.
138
+ api_key = (os.environ.get("AUTOMATION_CALLBACK_API_KEY")
139
+ or os.environ.get("OPENHANDS_API_KEY", ""))
123
140
  try:
124
141
  urllib.request.urlopen(urllib.request.Request(
125
142
  url, data=json.dumps(body).encode(),
126
143
  headers={
127
144
  "Content-Type": "application/json",
128
- "Authorization": f"Bearer {os.environ.get('AUTOMATION_CALLBACK_API_KEY', '')}",
145
+ "Authorization": f"Bearer {api_key}",
129
146
  }
130
147
  ))
131
148
  except Exception as e:
@@ -199,6 +216,96 @@ def post_jira_comment(issue_key, auth_header, text):
199
216
  print(f"Warning: failed to post Jira comment on {issue_key} ({exc.code}): {body_text[:200]}")
200
217
 
201
218
 
219
+ def _cloud_request(method, path, body=None):
220
+ """Call the OpenHands Cloud API with the API key this run was given."""
221
+ cloud_url = os.environ.get("OPENHANDS_CLOUD_API_URL", "").rstrip("/")
222
+ req = urllib.request.Request(
223
+ f"{cloud_url}{path}",
224
+ data=json.dumps(body).encode() if body is not None else None,
225
+ headers={
226
+ "Authorization": f"Bearer {os.environ.get('OPENHANDS_API_KEY', '')}",
227
+ "Content-Type": "application/json",
228
+ },
229
+ method=method,
230
+ )
231
+ try:
232
+ with urllib.request.urlopen(req) as r:
233
+ raw = r.read()
234
+ except urllib.error.HTTPError as exc:
235
+ body_text = exc.read().decode(errors="replace")
236
+ raise RuntimeError(f"OpenHands API {method} {path} failed {exc.code}: {body_text[:500]}") from exc
237
+ return json.loads(raw) if raw.strip() else {}
238
+
239
+
240
+ def start_cloud_conversation(title, prompt):
241
+ """Ask the OpenHands Cloud API to start a conversation.
242
+
243
+ OpenHands Cloud runs it in its own sandbox with the user's LLM, MCP servers,
244
+ secrets and connected git provider (e.g. the native GitHub integration).
245
+
246
+ The API answers with the task that starts the conversation, not with a
247
+ started conversation, so this returns the conversation's URL and that task's
248
+ id for a later run to confirm (settle_cloud_starts).
249
+ """
250
+ cloud_url = os.environ.get("OPENHANDS_CLOUD_API_URL", "").rstrip("/")
251
+ conversation_id = uuid.uuid4().hex
252
+ task = _cloud_request("POST", "/api/v1/app-conversations", {
253
+ "conversation_id": conversation_id,
254
+ "title": title,
255
+ "initial_message": {"content": [{"type": "text", "text": prompt}]},
256
+ })
257
+ if task.get("status") == "ERROR":
258
+ raise RuntimeError(f"Starting a conversation failed: {task.get('detail') or 'no detail'}")
259
+ return f"{cloud_url}/canvas/conversations/{conversation_id}", task.get("id")
260
+
261
+
262
+ def settle_cloud_starts(state, processed_keys, auth_header):
263
+ """Confirm the conversations earlier runs asked OpenHands Cloud to start.
264
+
265
+ A start can still fail after the API accepted it, for instance when its
266
+ sandbox does not come up. Such an issue is released so the next poll starts
267
+ it again, MAX_START_ATTEMPTS times in all; after that the failure is posted
268
+ on the issue instead, so it is neither lost silently nor retried forever.
269
+ """
270
+ pending = state.get("pending_starts") or {}
271
+ if not pending:
272
+ return
273
+ query = "&".join(f"ids={entry['task_id']}" for entry in pending.values())
274
+ try:
275
+ tasks = _cloud_request("GET", f"/api/v1/app-conversations/start-tasks?{query}")
276
+ except RuntimeError as exc:
277
+ # Confirming earlier starts must not keep this run from polling Jira.
278
+ print(f"Warning: could not confirm earlier conversation starts: {exc}")
279
+ return
280
+ if not isinstance(tasks, list):
281
+ return # not the answer expected; leave them for the next run
282
+ by_id = {task["id"]: task for task in tasks if task}
283
+
284
+ for key, entry in list(pending.items()):
285
+ task = by_id.get(entry["task_id"])
286
+ # A task OpenHands no longer has says nothing about a failure, so it is
287
+ # settled like a started one rather than risk a second conversation.
288
+ status = task["status"] if task else "READY"
289
+ if status not in ("READY", "ERROR"):
290
+ continue # still starting
291
+ del pending[key]
292
+ if status == "READY":
293
+ continue
294
+ detail = task.get("detail") or "no detail given"
295
+ if entry.get("attempts", 1) < MAX_START_ATTEMPTS:
296
+ print(f"Conversation for {key} did not start ({detail}); it will be started again")
297
+ processed_keys.discard(key)
298
+ state.setdefault("start_attempts", {})[key] = entry.get("attempts", 1)
299
+ else:
300
+ print(f"Conversation for {key} did not start ({detail}); giving up")
301
+ post_jira_comment(
302
+ key, auth_header,
303
+ f"OpenHands could not start a conversation for this issue: {detail}",
304
+ )
305
+ state["pending_starts"] = pending
306
+ state["processed_keys"] = list(processed_keys)
307
+
308
+
202
309
  # ── Timestamp helpers ─────────────────────────────────────────────────────────
203
310
  def _parse_ts(ts):
204
311
  """Parse an ISO-8601 timestamp, normalising +HHMM → +HH:MM for Python < 3.11."""
@@ -208,6 +315,12 @@ def _parse_ts(ts):
208
315
 
209
316
  # ── Main ──────────────────────────────────────────────────────────────────────
210
317
  try:
318
+ # A cloud run starts in a fresh sandbox, so without the KV store the processed
319
+ # issues (and the first-run baseline) would be forgotten after every run.
320
+ if not IS_LOCAL and not kv_available():
321
+ raise RuntimeError("The automation KV store is required on OpenHands Cloud, "
322
+ "but AUTOMATION_KV_TOKEN is not set for this run.")
323
+
211
324
  jira_token = get_secret(JIRA_TOKEN_SECRET)
212
325
  auth_header = "Basic " + base64.b64encode(
213
326
  f"{JIRA_EMAIL}:{jira_token}".encode()
@@ -227,6 +340,9 @@ try:
227
340
 
228
341
  first_run_at = _parse_ts(state["first_run_at"])
229
342
 
343
+ if not IS_LOCAL:
344
+ settle_cloud_starts(state, processed_keys, auth_header)
345
+
230
346
  print(f"Polling {JIRA_BASE_URL} for issues labeled '{JIRA_LABEL}'…")
231
347
  issues = fetch_labeled_issues(auth_header)
232
348
  print(f"Total matching issues : {len(issues)}")
@@ -248,26 +364,28 @@ try:
248
364
  fire_callback("COMPLETED")
249
365
  sys.exit(0)
250
366
 
251
- # Start one agent conversation per new issue via the agent server HTTP API.
252
- agent_url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
253
- session_key = os.environ.get("SESSION_API_KEY") or os.environ.get("OH_SESSION_API_KEYS_0", "")
254
-
255
- # Fetch settings with encrypted secrets so llm.api_key is a Fernet token
256
- # (starts with gAAAAA) rather than the masked "**********" placeholder.
257
- # The conversation payload must include secrets_encrypted: True so the
258
- # agent-server decrypts it server-side; we never handle the plaintext key.
259
- with urllib.request.urlopen(urllib.request.Request(
260
- f"{agent_url}/api/settings",
261
- headers={"X-Session-API-Key": session_key, "X-Expose-Secrets": "encrypted"},
262
- )) as r:
263
- settings = json.loads(r.read())
264
-
265
- agent_settings = settings.get("agent_settings", {})
266
- agent_settings.pop("schema_version", None)
267
- mcp_config = agent_settings.pop("mcp_config", None)
268
- ctx = agent_settings.setdefault("agent_context", {})
269
- ctx.update({"load_public_skills": True, "load_user_skills": True, "load_project_skills": True})
270
- max_iterations = (settings.get("conversation_settings") or {}).get("max_iterations") or 1000
367
+ # Start one agent conversation per new issue: via the agent server HTTP API on a
368
+ # local run, via the OpenHands Cloud API (start_cloud_conversation) on a cloud run.
369
+ if IS_LOCAL:
370
+ agent_url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
371
+ session_key = os.environ.get("SESSION_API_KEY") or os.environ.get("OH_SESSION_API_KEYS_0", "")
372
+
373
+ # Fetch settings with encrypted secrets so llm.api_key is a Fernet token
374
+ # (starts with gAAAAA) rather than the masked "**********" placeholder.
375
+ # The conversation payload must include secrets_encrypted: True so the
376
+ # agent-server decrypts it server-side; we never handle the plaintext key.
377
+ with urllib.request.urlopen(urllib.request.Request(
378
+ f"{agent_url}/api/settings",
379
+ headers={"X-Session-API-Key": session_key, "X-Expose-Secrets": "encrypted"},
380
+ )) as r:
381
+ settings = json.loads(r.read())
382
+
383
+ agent_settings = settings.get("agent_settings", {})
384
+ agent_settings.pop("schema_version", None)
385
+ mcp_config = agent_settings.pop("mcp_config", None)
386
+ ctx = agent_settings.setdefault("agent_context", {})
387
+ ctx.update({"load_public_skills": True, "load_user_skills": True, "load_project_skills": True})
388
+ max_iterations = (settings.get("conversation_settings") or {}).get("max_iterations") or 1000
271
389
 
272
390
  for issue in new_issues:
273
391
  key = issue["key"]
@@ -297,36 +415,45 @@ Steps:
297
415
  - Body : Reference the Jira issue key and describe the changes made.
298
416
  6. Print the PR URL when done.
299
417
  """
300
- workdir = tempfile.mkdtemp(prefix=f"jira-{key.lower()}-")
301
- payload = {
302
- "secrets_encrypted": True,
303
- "agent_settings": agent_settings,
304
- "workspace": {"kind": "LocalWorkspace", "working_dir": workdir},
305
- "confirmation_policy": {"kind": "NeverConfirm"},
306
- "max_iterations": max_iterations,
307
- "stuck_detection": True,
308
- "autotitle": True,
309
- "worktree": False,
310
- "initial_message": {
311
- "role": "user",
312
- "content": [{"type": "text", "text": prompt}],
313
- "run": True,
314
- },
315
- }
316
- if mcp_config:
317
- payload["mcp_config"] = mcp_config
318
-
319
- conv_req = urllib.request.Request(
320
- f"{agent_url}/api/conversations",
321
- data=json.dumps(payload).encode(),
322
- headers={"Content-Type": "application/json", "X-Session-API-Key": session_key},
323
- )
324
- with urllib.request.urlopen(conv_req) as r:
325
- conv = json.loads(r.read())
326
-
327
- conv_id = conv.get("id")
328
- conv_url = f"{agent_url}/conversations/{conv_id}"
329
- print(f"✓ Conversation started for {key}: id={conv_id}")
418
+ if IS_LOCAL:
419
+ workdir = tempfile.mkdtemp(prefix=f"jira-{key.lower()}-")
420
+ payload = {
421
+ "secrets_encrypted": True,
422
+ "agent_settings": agent_settings,
423
+ "workspace": {"kind": "LocalWorkspace", "working_dir": workdir},
424
+ "confirmation_policy": {"kind": "NeverConfirm"},
425
+ "max_iterations": max_iterations,
426
+ "stuck_detection": True,
427
+ "autotitle": True,
428
+ "worktree": False,
429
+ "initial_message": {
430
+ "role": "user",
431
+ "content": [{"type": "text", "text": prompt}],
432
+ "run": True,
433
+ },
434
+ }
435
+ if mcp_config:
436
+ payload["mcp_config"] = mcp_config
437
+
438
+ conv_req = urllib.request.Request(
439
+ f"{agent_url}/api/conversations",
440
+ data=json.dumps(payload).encode(),
441
+ headers={"Content-Type": "application/json", "X-Session-API-Key": session_key},
442
+ )
443
+ with urllib.request.urlopen(conv_req) as r:
444
+ conv = json.loads(r.read())
445
+
446
+ conv_id = conv.get("id")
447
+ conv_url = f"{agent_url}/conversations/{conv_id}"
448
+ print(f"✓ Conversation started for {key}: id={conv_id}")
449
+ else:
450
+ conv_url, start_task_id = start_cloud_conversation(f"[{key}] {summary}", prompt)
451
+ if start_task_id:
452
+ attempts = state.setdefault("start_attempts", {}).pop(key, 0) + 1
453
+ state.setdefault("pending_starts", {})[key] = {
454
+ "task_id": start_task_id, "attempts": attempts,
455
+ }
456
+ print(f"✓ Conversation requested for {key}: {conv_url}")
330
457
 
331
458
  post_jira_comment(key, auth_header, f"I'm on it: {conv_url}")
332
459