@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.
- package/.claude-plugin/marketplace.json +30 -0
- package/.codex-plugin/marketplace.json +30 -0
- package/.github/pull_request_template.md +17 -2
- package/.github/scripts/check_pr_description.py +64 -5
- package/.github/scripts/markdown_sections.py +95 -0
- package/.github/workflows/build-vscode-app.yml +30 -0
- package/.github/workflows/check-extensions.yml +3 -0
- package/.github/workflows/tests.yml +26 -0
- package/.plugin/marketplace.json +30 -0
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +3 -2
- package/README.md +10 -3
- package/apps/README.md +20 -0
- package/apps/catalog.json +10 -0
- package/apps/index.d.ts +11 -0
- package/apps/index.js +5 -0
- package/apps/vscode/README.md +13 -0
- package/apps/vscode/canvas-extension.json +19 -0
- package/apps/vscode/extension.js +114 -0
- package/apps/vscode/scripts/package.mjs +84 -0
- package/automations/bundle-index.js +18 -14
- package/automations/catalog/github-agents-md-maintainer/manifest.json +1 -1
- package/automations/catalog/github-pr-reviewer/manifest.json +19 -8
- package/automations/catalog/github-stale-ci-pr-closer/manifest.json +71 -0
- package/automations/catalog/gitlab-issue-to-mr/manifest.json +1 -1
- package/automations/catalog/news-digest/manifest.json +2 -2
- package/automations/catalog-index.js +2 -0
- package/index.d.ts +2 -0
- package/index.js +1 -0
- package/marketplaces/openhands-extensions.json +30 -0
- package/package.json +9 -1
- package/plugins/openhands/skills/openhands-api/README.md +1 -1
- package/plugins/openhands/skills/openhands-api/SKILL.md +3 -3
- package/plugins/openhands/skills/openhands-api/scripts/openhands_api.py +13 -0
- package/plugins/openhands/skills/openhands-automation/SKILL.md +66 -4
- package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +21 -7
- package/plugins/openhands/skills/openhands-sdk/SKILL.md +1 -0
- package/plugins/pr-review/skills/github-pr-review/SKILL.md +12 -2
- package/plugins/qa-changes/skills/github-pr-review/SKILL.md +12 -2
- package/plugins/runtime-api-configs/.claude-plugin/plugin.json +20 -0
- package/plugins/runtime-api-configs/.codex-plugin/plugin.json +20 -0
- package/plugins/runtime-api-configs/.plugin/plugin.json +20 -0
- package/plugins/runtime-api-configs/README.md +59 -0
- package/plugins/runtime-api-configs/SKILL.md +240 -0
- package/plugins/runtime-api-configs/scripts/warm_runtime_configs.py +331 -0
- package/plugins/runtime-api-configs/tests/test_warm_runtime_configs.py +338 -0
- package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +223 -43
- package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +183 -15
- package/pyproject.toml +1 -1
- package/python/openhands_extensions/_version.py +1 -1
- package/scripts/check_plugin_resources.py +116 -0
- package/scripts/sync_extensions.py +32 -5
- package/skills/canvas-extension-api/SKILL.md +34 -3
- package/skills/canvas-extension-api/references/backend-safety.md +14 -0
- package/skills/canvas-extension-api/references/connections.md +24 -0
- package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -10
- package/skills/canvas-extension-api/references/v1-contract.md +53 -3
- package/skills/canvas-extension-api/scripts/validate-extension.mjs +52 -0
- package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +192 -1
- package/skills/github/scripts/agent_conversation.py +223 -43
- package/skills/github/scripts/github_client.py +183 -15
- package/skills/github-agents-md-maintainer/README.md +7 -0
- package/skills/github-agents-md-maintainer/SKILL.md +13 -6
- package/skills/github-agents-md-maintainer/scripts/github_client.py +183 -15
- package/skills/github-agents-md-maintainer/scripts/main.py +199 -29
- package/skills/github-delivery-watchdog/scripts/github_client.py +183 -15
- package/skills/github-issue-to-pr/scripts/agent_conversation.py +223 -43
- package/skills/github-issue-to-pr/scripts/github_client.py +183 -15
- package/skills/github-issue-to-pr/scripts/worker.py +10 -1
- package/skills/github-issue-triage/scripts/agent_conversation.py +223 -43
- package/skills/github-issue-triage/scripts/github_client.py +183 -15
- package/skills/github-pr-review/SKILL.md +12 -2
- package/skills/github-pr-reviewer/README.md +36 -0
- package/skills/github-pr-reviewer/SKILL.md +147 -4
- package/skills/github-pr-reviewer/scripts/agent_conversation.py +223 -43
- package/skills/github-pr-reviewer/scripts/github_client.py +183 -15
- package/skills/github-pr-reviewer/scripts/main.py +17 -1
- package/skills/github-pr-reviewer/scripts/worker.py +842 -57
- package/skills/github-repo-monitor/scripts/github_client.py +183 -15
- package/skills/github-repo-monitor/scripts/main.py +32 -1
- package/skills/github-stale-ci-pr-closer/.claude-plugin/plugin.json +9 -0
- package/skills/github-stale-ci-pr-closer/.codex-plugin/plugin.json +9 -0
- package/skills/github-stale-ci-pr-closer/.plugin/plugin.json +9 -0
- package/skills/github-stale-ci-pr-closer/README.md +9 -0
- package/skills/github-stale-ci-pr-closer/SKILL.md +24 -0
- package/skills/github-stale-ci-pr-closer/commands/github-stale-ci-pr-closer.md +8 -0
- package/skills/github-stale-ci-pr-closer/scripts/worker.py +508 -0
- package/skills/github-stale-ci-pr-closer/tests/test_worker.py +353 -0
- package/skills/gitlab-issue-to-mr/README.md +12 -0
- package/skills/gitlab-issue-to-mr/SKILL.md +13 -6
- package/skills/gitlab-issue-to-mr/scripts/main.py +217 -42
- package/skills/incident-retrospective/SKILL.md +5 -5
- package/skills/index.js +34 -16
- package/skills/jira-issue-to-pr/README.md +12 -4
- package/skills/jira-issue-to-pr/SKILL.md +129 -18
- package/skills/jira-issue-to-pr/references/setup.md +8 -0
- package/skills/jira-issue-to-pr/scripts/main.py +181 -54
- package/skills/jira-issue-to-pr/scripts/setup_webhook.py +290 -0
- package/skills/linear-triage/SKILL.md +5 -5
- package/skills/news-digest/README.md +6 -0
- package/skills/news-digest/SKILL.md +19 -9
- package/skills/news-digest/commands/news-digest-setup.md +1 -1
- package/skills/news-digest/scripts/main.py +133 -15
- package/skills/openhands-api/README.md +1 -1
- package/skills/openhands-api/SKILL.md +3 -3
- package/skills/openhands-api/scripts/openhands_api.py +13 -0
- package/skills/openhands-automation/SKILL.md +66 -4
- package/skills/openhands-automation/references/custom-automation.md +21 -7
- package/skills/openhands-sdk/SKILL.md +1 -0
- package/skills/research-brief/SKILL.md +5 -5
- package/skills/slack-standup-digest/SKILL.md +5 -5
- package/skills/ticket-to-code-change/SKILL.md +6 -3
- package/skills/use-jev/.claude-plugin/plugin.json +8 -0
- package/skills/use-jev/.codex-plugin/plugin.json +8 -0
- package/skills/use-jev/.plugin/plugin.json +8 -0
- package/skills/use-jev/README.md +66 -0
- package/skills/use-jev/SKILL.md +66 -0
- package/tests/fixtures/automations/github-pr-reviewer.json +39 -24
- package/tests/test_agent_conversation_dispatch.py +212 -0
- package/tests/test_apps_catalog.py +26 -0
- package/tests/test_automation_setup.py +44 -0
- package/tests/test_check_pr_description.py +89 -6
- package/tests/test_github_automation_foundation.py +158 -0
- package/tests/test_github_repo_monitor.py +107 -0
- package/tests/test_github_reviewer_delivery.py +2247 -6
- package/tests/test_github_stale_ci_pr_closer.py +56 -0
- package/tests/test_jira_issue_to_pr.py +312 -0
- package/tests/test_jira_setup_webhook.py +309 -0
- package/tests/test_openhands_api_python.py +37 -0
- package/tests/test_skill_plugin_loading.py +25 -15
- package/tests/test_sync_extensions.py +102 -0
- package/tests/test_vscode_app.py +33 -0
- 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
|
|
32
|
-
`POST /api/conversations` on the
|
|
33
|
-
|
|
34
|
-
|
|
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: <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,
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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
|
-
"
|
|
105
|
-
-H "
|
|
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 "
|
|
115
|
-
-H "
|
|
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
|
-
"
|
|
137
|
-
-H "
|
|
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 "
|
|
141
|
-
-H "
|
|
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
|
-
"
|
|
156
|
-
-H "
|
|
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
|
-
"
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
"
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
|