@openhands/extensions 0.16.0 → 0.18.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 (70) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/AGENTS.md +1 -0
  3. package/README.md +5 -2
  4. package/automations/README.md +78 -11
  5. package/automations/bundle-index.js +18 -0
  6. package/automations/catalog/github-agents-md-maintainer/manifest.json +96 -0
  7. package/automations/catalog/github-issue-to-pr/manifest.json +107 -0
  8. package/automations/catalog/github-pr-reviewer/manifest.json +24 -7
  9. package/automations/catalog/github-repo-monitor/manifest.json +29 -34
  10. package/automations/catalog/jira-issue-to-pr/manifest.json +3 -3
  11. package/automations/catalog/news-digest/manifest.json +76 -0
  12. package/automations/catalog-index.js +13 -7
  13. package/automations/catalog.schema.json +112 -3
  14. package/automations/index.d.ts +66 -1
  15. package/automations/index.js +14 -0
  16. package/automations/interface.json +6 -2
  17. package/automations/interface.schema.json +6 -2
  18. package/index.d.ts +1 -1
  19. package/index.js +1 -1
  20. package/integrations/catalog/posthog.json +41 -3
  21. package/integrations/catalog/sonarqube.json +78 -0
  22. package/integrations/catalog-index.js +65 -63
  23. package/marketplaces/openhands-extensions.json +49 -0
  24. package/package.json +1 -1
  25. package/plugins/qa-changes/action.yml +1 -1
  26. package/pyproject.toml +1 -1
  27. package/python/openhands_extensions/_version.py +1 -1
  28. package/scripts/build-automation-catalog.mjs +30 -0
  29. package/scripts/build-skills-catalog.mjs +24 -7
  30. package/skills/add-skill/SKILL.md +1 -1
  31. package/skills/github-agents-md-maintainer/.plugin/plugin.json +19 -0
  32. package/skills/github-agents-md-maintainer/README.md +59 -0
  33. package/skills/github-agents-md-maintainer/SKILL.md +271 -0
  34. package/skills/github-agents-md-maintainer/commands/agents-md-setup.md +8 -0
  35. package/skills/github-agents-md-maintainer/references/state-schema.md +113 -0
  36. package/skills/github-agents-md-maintainer/scripts/main.py +1187 -0
  37. package/skills/github-issue-to-pr/.plugin/plugin.json +19 -0
  38. package/skills/github-issue-to-pr/README.md +84 -0
  39. package/skills/github-issue-to-pr/SKILL.md +373 -0
  40. package/skills/github-issue-to-pr/commands/issue-to-pr-setup.md +8 -0
  41. package/skills/github-issue-to-pr/references/state-schema.md +139 -0
  42. package/skills/github-issue-to-pr/scripts/main.py +1305 -0
  43. package/skills/github-pr-reviewer/README.md +12 -6
  44. package/skills/github-pr-reviewer/SKILL.md +93 -31
  45. package/skills/github-pr-reviewer/commands/pr-reviewer-setup.md +1 -1
  46. package/skills/github-pr-reviewer/references/state-schema.md +68 -18
  47. package/skills/github-pr-reviewer/scripts/main.py +540 -103
  48. package/skills/github-pr-reviewer/tests/test_main.py +503 -0
  49. package/skills/index.d.ts +6 -0
  50. package/skills/index.js +56 -17
  51. package/skills/jira-issue-to-pr/scripts/main.py +4 -2
  52. package/skills/news-digest/.plugin/plugin.json +19 -0
  53. package/skills/news-digest/README.md +71 -0
  54. package/skills/news-digest/SKILL.md +284 -0
  55. package/skills/news-digest/commands/news-digest-setup.md +8 -0
  56. package/skills/news-digest/references/state-schema.md +178 -0
  57. package/skills/news-digest/scripts/main.py +1250 -0
  58. package/skills/openhands-sdk/SKILL.md +2 -0
  59. package/skills/skill-creator/SKILL.md +1 -1
  60. package/skills/slack-channel-monitor/scripts/main.py +3 -2
  61. package/tests/fixtures/automations/capabilities.json +30 -0
  62. package/tests/fixtures/automations/github-pr-reviewer.json +319 -55
  63. package/tests/fixtures/automations/github-repo-monitor.json +230 -62
  64. package/tests/test_automation_setup.py +260 -31
  65. package/tests/test_catalogs.py +46 -1
  66. package/tests/test_github_agents_md_maintainer.py +352 -0
  67. package/tests/test_github_issue_to_pr.py +539 -0
  68. package/tests/test_news_digest.py +646 -0
  69. package/tests/test_skills_catalog.py +182 -36
  70. package/tests/test_slack_channel_monitor.py +1 -1
@@ -0,0 +1,539 @@
1
+ """Unit tests for the github-issue-to-pr automation script.
2
+
3
+ The focus is what the script owns rather than the agent: the credential never
4
+ reaching the workspace, the secrets the conversation is handed, the branch it
5
+ picks, the commits it counts, and the clone it removes.
6
+ """
7
+
8
+ import importlib.util
9
+ import json
10
+ import subprocess
11
+ import urllib.error
12
+ from pathlib import Path
13
+
14
+ import pytest
15
+
16
+ SCRIPT_PATH = (
17
+ Path(__file__).parent.parent / "skills" / "github-issue-to-pr" / "scripts" / "main.py"
18
+ )
19
+
20
+
21
+ def _load_module(monkeypatch, workspace_base: Path):
22
+ """Import main.py under its own module name, with a scratch workspace."""
23
+ monkeypatch.setenv("WORKSPACE_BASE", str(workspace_base))
24
+ monkeypatch.delenv("AUTOMATION_KV_TOKEN", raising=False)
25
+ monkeypatch.delenv("AUTOMATION_API_URL", raising=False)
26
+ spec = importlib.util.spec_from_file_location("github_issue_to_pr_main", SCRIPT_PATH)
27
+ module = importlib.util.module_from_spec(spec)
28
+ spec.loader.exec_module(module)
29
+ return module
30
+
31
+
32
+ @pytest.fixture
33
+ def main(monkeypatch, tmp_path):
34
+ return _load_module(monkeypatch, tmp_path / "workspace")
35
+
36
+
37
+ def _http_error(code: int, body: bytes = b"{}") -> urllib.error.HTTPError:
38
+ return urllib.error.HTTPError("https://api.github.com", code, "err", {}, None)
39
+
40
+
41
+ # ── Configuration ─────────────────────────────────────────────────────────────
42
+
43
+
44
+ def test_config_json_overrides_the_constants(main, tmp_path):
45
+ """The catalog path ships an unmodified script plus a rendered config.json,
46
+ because a declarative host cannot rewrite Python."""
47
+ (tmp_path / "config.json").write_text(
48
+ json.dumps(
49
+ {
50
+ "repos": ["acme/one", "acme/two"],
51
+ "trigger_label": "ship-it",
52
+ "branch_prefix": "bot/issue",
53
+ "pull_request_mode": "ready",
54
+ "max_new_per_run": 5,
55
+ "agent_secret_names": ["NPM_TOKEN"],
56
+ "openhands_url": "https://app.example.com",
57
+ "unknown_key": "ignored",
58
+ }
59
+ )
60
+ )
61
+
62
+ config = main.load_config(tmp_path)
63
+
64
+ assert config == {
65
+ "repos": ["acme/one", "acme/two"],
66
+ "trigger_label": "ship-it",
67
+ "branch_prefix": "bot/issue",
68
+ "pull_request_mode": "ready",
69
+ "max_new_per_run": 5,
70
+ "agent_secret_names": ["NPM_TOKEN"],
71
+ "openhands_url": "https://app.example.com",
72
+ }
73
+
74
+
75
+ def test_a_missing_config_leaves_the_constants_alone(main, tmp_path):
76
+ assert main.load_config(tmp_path) == {}
77
+
78
+
79
+ @pytest.mark.parametrize(
80
+ "config",
81
+ [
82
+ {"repos": "acme/one"},
83
+ {"repos": []},
84
+ {"repos": ["acme/one", ""]},
85
+ {"trigger_label": ["openhands"]},
86
+ {"pull_request_mode": "maybe"},
87
+ {"max_new_per_run": 0},
88
+ {"max_new_per_run": True},
89
+ {"agent_secret_names": [1]},
90
+ ],
91
+ )
92
+ def test_a_config_that_would_misbehave_fails_the_run(main, tmp_path, config):
93
+ """Polling the string "owner/repo" one character at a time is worse than
94
+ stopping, so a wrong type is a hard error rather than a coercion."""
95
+ (tmp_path / "config.json").write_text(json.dumps(config))
96
+
97
+ with pytest.raises(SystemExit):
98
+ main.load_config(tmp_path)
99
+
100
+
101
+ def test_a_config_that_is_not_json_fails_the_run(main, tmp_path):
102
+ (tmp_path / "config.json").write_text("{not json")
103
+
104
+ with pytest.raises(SystemExit):
105
+ main.load_config(tmp_path)
106
+
107
+
108
+ @pytest.mark.parametrize(
109
+ ("written", "expected"),
110
+ [
111
+ ("owner/repo", "owner/repo"),
112
+ (" owner/repo ", "owner/repo"),
113
+ ("https://github.com/owner/repo", "owner/repo"),
114
+ ("https://github.com/owner/repo.git", "owner/repo"),
115
+ ("git@github.com:owner/repo.git", "owner/repo"),
116
+ ],
117
+ )
118
+ def test_repositories_are_normalized_to_owner_repo(main, written, expected):
119
+ """A clone URL is what a repository page offers to copy, so it is what ends
120
+ up pasted into the form. Left alone it becomes a 404 blamed on the token."""
121
+ assert main.normalize_repo(written) == expected
122
+
123
+
124
+ @pytest.mark.parametrize("written", ["", "owner", "https://github.com/owner", "owner/repo/extra"])
125
+ def test_a_value_that_is_not_a_repository_is_named(main, written):
126
+ with pytest.raises(ValueError):
127
+ main.normalize_repo(written)
128
+
129
+
130
+ # ── The credential never reaches the workspace ────────────────────────────────
131
+
132
+
133
+ def test_git_passes_the_token_as_a_header_in_the_environment(main, monkeypatch):
134
+ """A token on the command line shows up in `ps`; one in the clone's config
135
+ is readable by the agent. It travels in the environment instead."""
136
+ captured = {}
137
+
138
+ def fake_run(argv, **kwargs):
139
+ captured["argv"] = argv
140
+ captured["env"] = kwargs["env"]
141
+ return subprocess.CompletedProcess(argv, 0, "", "")
142
+
143
+ monkeypatch.setattr(main.subprocess, "run", fake_run)
144
+ main._git(["clone", "https://github.com/owner/repo.git", "/tmp/x"], token="ghp_secret")
145
+
146
+ assert "ghp_secret" not in " ".join(captured["argv"])
147
+ assert captured["env"]["GIT_CONFIG_KEY_0"] == "http.extraHeader"
148
+ assert captured["env"]["GIT_CONFIG_VALUE_0"].startswith("Authorization: Basic ")
149
+ assert "ghp_secret" not in captured["env"]["GIT_CONFIG_VALUE_0"]
150
+ assert captured["env"]["GIT_TERMINAL_PROMPT"] == "0"
151
+
152
+
153
+ def test_git_failures_do_not_echo_the_token(main, monkeypatch):
154
+ def fake_run(argv, **kwargs):
155
+ return subprocess.CompletedProcess(argv, 128, "", "fatal: bad credentials ghp_secret")
156
+
157
+ monkeypatch.setattr(main.subprocess, "run", fake_run)
158
+
159
+ with pytest.raises(RuntimeError) as excinfo:
160
+ main._git(["push", "origin", "HEAD"], token="ghp_secret")
161
+
162
+ assert "ghp_secret" not in str(excinfo.value)
163
+ assert "***" in str(excinfo.value)
164
+
165
+
166
+ # ── Secrets handed to the conversation ────────────────────────────────────────
167
+
168
+
169
+ def test_only_the_github_token_is_forwarded_by_default(main, monkeypatch):
170
+ """The agent reads the issue itself, so it needs the GitHub token - and
171
+ nothing else in the deployment's secret store."""
172
+ monkeypatch.setattr(
173
+ main,
174
+ "_list_secret_names",
175
+ lambda agent_url, api_key: [
176
+ {"name": "GITHUB_PERSONAL_ACCESS_TOKEN"},
177
+ {"name": "NPM_TOKEN"},
178
+ {"name": "AWS_SECRET_ACCESS_KEY"},
179
+ ],
180
+ )
181
+
182
+ assert main.AGENT_SECRET_NAMES == ["GITHUB_PERSONAL_ACCESS_TOKEN"]
183
+ assert list(main._build_secrets_payload("http://agent", "key")) == [
184
+ "GITHUB_PERSONAL_ACCESS_TOKEN"
185
+ ]
186
+
187
+
188
+ def test_an_empty_allow_list_forwards_nothing(main, monkeypatch):
189
+ monkeypatch.setattr(main, "AGENT_SECRET_NAMES", [])
190
+ called = False
191
+
192
+ def fake_list(agent_url, api_key):
193
+ nonlocal called
194
+ called = True
195
+ return [{"name": "GITHUB_PERSONAL_ACCESS_TOKEN"}]
196
+
197
+ monkeypatch.setattr(main, "_list_secret_names", fake_list)
198
+
199
+ assert main._build_secrets_payload("http://agent", "key") == {}
200
+ assert called is False
201
+
202
+
203
+ def test_only_declared_secrets_are_forwarded(main, monkeypatch):
204
+ monkeypatch.setattr(main, "AGENT_SECRET_NAMES", ["NPM_TOKEN", "ABSENT_TOKEN"])
205
+ monkeypatch.setattr(
206
+ main,
207
+ "_list_secret_names",
208
+ lambda agent_url, api_key: [
209
+ {"name": "GITHUB_PERSONAL_ACCESS_TOKEN"},
210
+ {"name": "NPM_TOKEN"},
211
+ ],
212
+ )
213
+
214
+ payload = main._build_secrets_payload("http://agent", "key")
215
+
216
+ assert list(payload) == ["NPM_TOKEN"]
217
+ assert payload["NPM_TOKEN"]["url"] == "/api/settings/secrets/NPM_TOKEN"
218
+ assert payload["NPM_TOKEN"]["headers"] == {"X-Session-API-Key": "key"}
219
+
220
+
221
+ def test_the_conversation_payload_carries_no_secrets_block_when_there_are_none(
222
+ main, monkeypatch, tmp_path
223
+ ):
224
+ sent = {}
225
+
226
+ monkeypatch.setattr(main, "_get_agent_dict", lambda url, key: {"kind": "Agent"})
227
+ monkeypatch.setattr(main, "_build_secrets_payload", lambda url, key: {})
228
+
229
+ def fake_request(agent_url, api_key, method, path, body=None):
230
+ sent["body"] = body
231
+ return {"id": "conv-1"}
232
+
233
+ monkeypatch.setattr(main, "_oh_request", fake_request)
234
+
235
+ conv_id = main.create_conversation("http://agent", "key", "do the thing", tmp_path)
236
+
237
+ assert conv_id == "conv-1"
238
+ assert "secrets" not in sent["body"]
239
+ assert sent["body"]["workspace"] == {"working_dir": str(tmp_path)}
240
+
241
+
242
+ # ── Issue discovery ───────────────────────────────────────────────────────────
243
+
244
+
245
+ def test_pull_requests_are_not_treated_as_issues(main, monkeypatch):
246
+ """The issues endpoint returns pull requests too; labelling a PR must not
247
+ queue an implementation run."""
248
+ monkeypatch.setattr(
249
+ main,
250
+ "_github_paginate",
251
+ lambda token, path, params=None: [
252
+ {"number": 1, "title": "an issue"},
253
+ {"number": 2, "title": "a pull request", "pull_request": {"url": "..."}},
254
+ ],
255
+ )
256
+
257
+ issues = main._list_labeled_issues("token", "owner/repo")
258
+
259
+ assert [issue["number"] for issue in issues] == [1]
260
+
261
+
262
+ def test_latest_matching_label_event_wins(main, monkeypatch):
263
+ events = [
264
+ {"event": "labeled", "id": 1, "created_at": "2026-01-01T00:00:00Z", "label": {"name": "openhands"}},
265
+ {"event": "labeled", "id": 2, "created_at": "2026-01-03T00:00:00Z", "label": {"name": "other"}},
266
+ {"event": "unlabeled", "id": 3, "created_at": "2026-01-04T00:00:00Z", "label": {"name": "openhands"}},
267
+ {"event": "labeled", "id": 4, "created_at": "2026-01-02T00:00:00Z", "label": {"name": "OpenHands"}},
268
+ ]
269
+ monkeypatch.setattr(main, "_github_paginate", lambda token, path, params=None: events)
270
+
271
+ event = main._latest_trigger_label_event("token", "owner/repo", 42)
272
+
273
+ assert event["id"] == 4
274
+
275
+
276
+ def test_no_matching_label_event_returns_none(main, monkeypatch):
277
+ monkeypatch.setattr(
278
+ main,
279
+ "_github_paginate",
280
+ lambda token, path, params=None: [{"event": "closed", "id": 9, "created_at": "2026-01-01T00:00:00Z"}],
281
+ )
282
+
283
+ assert main._latest_trigger_label_event("token", "owner/repo", 42) is None
284
+
285
+
286
+ # ── Branch naming ─────────────────────────────────────────────────────────────
287
+
288
+
289
+ def test_branch_name_is_the_first_free_one(main, monkeypatch):
290
+ """Re-applying the label must not force-push over the previous attempt."""
291
+ taken = {"openhands/issue-42", "openhands/issue-42-2"}
292
+
293
+ def fake_request(token, method, path, params=None, body=None):
294
+ branch = path.split("/git/ref/heads/", 1)[1]
295
+ if branch in taken:
296
+ return {"ref": f"refs/heads/{branch}"}, {}
297
+ raise _http_error(404)
298
+
299
+ monkeypatch.setattr(main, "_github_request", fake_request)
300
+
301
+ assert main._branch_name("token", "owner/repo", 42) == "openhands/issue-42-3"
302
+
303
+
304
+ def test_branch_lookup_does_not_swallow_other_errors(main, monkeypatch):
305
+ def fake_request(token, method, path, params=None, body=None):
306
+ raise _http_error(500)
307
+
308
+ monkeypatch.setattr(main, "_github_request", fake_request)
309
+
310
+ with pytest.raises(urllib.error.HTTPError):
311
+ main._branch_name("token", "owner/repo", 42)
312
+
313
+
314
+ # ── Commits the agent left behind ─────────────────────────────────────────────
315
+
316
+
317
+ def _init_repo(path: Path) -> str:
318
+ path.mkdir(parents=True, exist_ok=True)
319
+ run = lambda *args: subprocess.run(["git", *args], cwd=path, check=True, capture_output=True)
320
+ run("init", "-q", "-b", "main")
321
+ run("config", "user.name", "Test")
322
+ run("config", "user.email", "test@example.com")
323
+ (path / "README.md").write_text("base\n")
324
+ run("add", "-A")
325
+ run("commit", "-q", "-m", "base")
326
+ return subprocess.run(
327
+ ["git", "rev-parse", "HEAD"], cwd=path, check=True, capture_output=True, text=True
328
+ ).stdout.strip()
329
+
330
+
331
+ def test_uncommitted_work_is_committed_and_counted(main, tmp_path):
332
+ checkout = tmp_path / "clone"
333
+ base_sha = _init_repo(checkout)
334
+ (checkout / "fix.py").write_text("print('fixed')\n")
335
+
336
+ commits = main._commit_agent_work(checkout, 42, "Retry uploads on a 502", base_sha)
337
+
338
+ assert commits == 1
339
+ assert main._git(["status", "--porcelain"], cwd=checkout).stdout.strip() == ""
340
+
341
+
342
+ def test_commits_the_agent_made_itself_are_kept(main, tmp_path):
343
+ checkout = tmp_path / "clone"
344
+ base_sha = _init_repo(checkout)
345
+ (checkout / "fix.py").write_text("print('fixed')\n")
346
+ subprocess.run(["git", "add", "-A"], cwd=checkout, check=True, capture_output=True)
347
+ subprocess.run(["git", "commit", "-q", "-m", "agent commit"], cwd=checkout, check=True, capture_output=True)
348
+ (checkout / "extra.py").write_text("print('extra')\n")
349
+
350
+ commits = main._commit_agent_work(checkout, 42, "title", base_sha)
351
+
352
+ assert commits == 2
353
+
354
+
355
+ def test_an_untouched_clone_produces_no_commits(main, tmp_path):
356
+ """No commits is how an agent reports an issue it could not implement, so it
357
+ must not become an empty pull request."""
358
+ checkout = tmp_path / "clone"
359
+ base_sha = _init_repo(checkout)
360
+
361
+ assert main._commit_agent_work(checkout, 42, "title", base_sha) == 0
362
+
363
+
364
+ # ── Releasing the clone ───────────────────────────────────────────────────────
365
+
366
+
367
+ def test_a_running_conversation_keeps_its_clone(main, monkeypatch, tmp_path):
368
+ checkout = main._checkouts_root() / "owner__repo" / "issue-42-1"
369
+ checkout.mkdir(parents=True)
370
+ rec = {"workspace_dir": str(checkout), "conversation_id": "conv-1"}
371
+ monkeypatch.setattr(main, "conversation_status", lambda *a: "running")
372
+
373
+ assert main._release_checkout(rec, "http://agent", "key") is False
374
+ assert checkout.exists()
375
+ assert rec["workspace_dir"] == str(checkout)
376
+
377
+
378
+ def test_a_stopped_conversation_releases_its_clone(main, monkeypatch, tmp_path):
379
+ checkout = main._checkouts_root() / "owner__repo" / "issue-42-1"
380
+ checkout.mkdir(parents=True)
381
+ rec = {"workspace_dir": str(checkout), "conversation_id": "conv-1"}
382
+ monkeypatch.setattr(main, "conversation_status", lambda *a: "finished")
383
+
384
+ assert main._release_checkout(rec, "http://agent", "key") is True
385
+ assert not checkout.exists()
386
+ assert "workspace_dir" not in rec
387
+
388
+
389
+ def test_a_path_outside_the_checkout_root_is_never_removed(main, monkeypatch, tmp_path):
390
+ outside = tmp_path / "precious"
391
+ outside.mkdir()
392
+ rec = {"workspace_dir": str(outside), "conversation_id": "conv-1"}
393
+ monkeypatch.setattr(main, "conversation_status", lambda *a: "finished")
394
+
395
+ assert main._release_checkout(rec, "http://agent", "key") is True
396
+ assert outside.exists()
397
+ assert "workspace_dir" not in rec
398
+
399
+
400
+ def test_an_unconfirmable_conversation_keeps_its_clone(main, monkeypatch):
401
+ checkout = main._checkouts_root() / "owner__repo" / "issue-42-1"
402
+ checkout.mkdir(parents=True)
403
+ rec = {"workspace_dir": str(checkout), "conversation_id": "conv-1"}
404
+
405
+ def boom(*args):
406
+ raise RuntimeError("agent server unreachable")
407
+
408
+ monkeypatch.setattr(main, "conversation_status", boom)
409
+
410
+ assert main._release_checkout(rec, "http://agent", "key") is False
411
+ assert checkout.exists()
412
+
413
+
414
+ # ── Pull request body ─────────────────────────────────────────────────────────
415
+
416
+
417
+ def test_pull_request_body_links_the_issue_and_discloses_the_agent(main):
418
+ body = main._pull_request_body(42, "Adds a retry.", "http://oh/conversations/c1")
419
+
420
+ assert "Adds a retry." in body
421
+ assert "Closes #42" in body
422
+ assert "http://oh/conversations/c1" in body
423
+ assert "_This pull request was opened by an AI agent (OpenHands)._" in body
424
+
425
+
426
+ def test_pull_request_body_is_truncated(main):
427
+ body = main._pull_request_body(42, "x" * (main.MAX_PR_BODY_CHARS + 100), "url")
428
+
429
+ assert len(body) < main.MAX_PR_BODY_CHARS + 400
430
+ assert "(summary truncated)" in body
431
+
432
+
433
+ # ── Prompt ────────────────────────────────────────────────────────────────────
434
+
435
+
436
+ def _prompt(main):
437
+ return main._build_implementation_prompt(
438
+ "owner/repo",
439
+ {
440
+ "number": 42,
441
+ "title": "Retry uploads",
442
+ "body": "It 502s, see the log in the linked run.",
443
+ "user": {"login": "alice"},
444
+ "labels": [{"name": "openhands"}],
445
+ "html_url": "https://github.com/owner/repo/issues/42",
446
+ },
447
+ {"id": 1, "created_at": "2026-01-02T00:00:00Z"},
448
+ "openhands/issue-42",
449
+ "main",
450
+ "abc123",
451
+ )
452
+
453
+
454
+ def test_the_prompt_names_the_issue_and_sends_the_agent_to_read_it(main):
455
+ """A copy of the description pasted at dispatch is stale as soon as someone
456
+ comments, and it stops where the issue's own text stops."""
457
+ prompt = _prompt(main)
458
+
459
+ assert "#42" in prompt
460
+ assert "https://github.com/owner/repo/issues/42" in prompt
461
+ assert "gh issue view 42 --repo owner/repo --comments" in prompt
462
+ assert "/repos/owner/repo/issues/42/comments" in prompt
463
+ assert "GITHUB_PERSONAL_ACCESS_TOKEN" in prompt
464
+
465
+
466
+ def test_the_prompt_does_not_embed_the_issue_text(main):
467
+ prompt = _prompt(main)
468
+
469
+ assert "It 502s" not in prompt
470
+
471
+
472
+ def test_the_prompt_tells_the_agent_to_push_and_open_the_pull_request(main):
473
+ """The pull request should appear when the agent stops, not on the next poll."""
474
+ prompt = _prompt(main)
475
+
476
+ assert "git push" in prompt and "x-access-token:$GITHUB_PERSONAL_ACCESS_TOKEN" in prompt
477
+ assert "HEAD:refs/heads/openhands/issue-42" in prompt
478
+ assert "gh pr create --repo owner/repo --base main --head openhands/issue-42" in prompt
479
+ assert "--draft" in prompt
480
+ assert '--title "[#42] Retry uploads"' in prompt
481
+ assert "Closes #42" in prompt
482
+ assert "GITHUB_PR_OPENED" in prompt
483
+
484
+
485
+ def test_the_prompt_says_a_command_must_name_the_secret(main):
486
+ """The SDK only puts a secret in the environment of a command that mentions
487
+ it, so an instruction that omits the name would simply fail to authenticate."""
488
+ prompt = _prompt(main)
489
+
490
+ assert "only put in the environment of a command that mentions it" in prompt
491
+ assert "Never echo it" in prompt
492
+
493
+
494
+ def test_the_prompt_keeps_the_untrusted_input_boundary(main):
495
+ prompt = _prompt(main)
496
+
497
+ assert "untrusted input" in prompt
498
+ assert "repositories other than owner/repo" in prompt
499
+
500
+
501
+ def test_a_ready_for_review_configuration_drops_the_draft_flag(main, monkeypatch):
502
+ monkeypatch.setattr(main, "DRAFT_PULL_REQUEST", False)
503
+ prompt = _prompt(main)
504
+
505
+ assert "--draft" not in prompt
506
+ assert "ready for review" in prompt
507
+
508
+
509
+ # ── State ─────────────────────────────────────────────────────────────────────
510
+
511
+
512
+ def test_state_is_kept_per_repository(main, tmp_path):
513
+ main.save_state("owner/one", {"version": 1, "repo": "owner/one", "tasks": {"1:label:1": {}}})
514
+ main.save_state("owner/two", {"version": 1, "repo": "owner/two", "tasks": {}})
515
+
516
+ assert list(main.load_state("owner/one")["tasks"]) == ["1:label:1"]
517
+ assert main.load_state("owner/two")["tasks"] == {}
518
+ assert main.load_state("owner/three") == {
519
+ "version": 1,
520
+ "repo": "owner/three",
521
+ "trigger_label": main.TRIGGER_LABEL,
522
+ "tasks": {},
523
+ }
524
+
525
+
526
+ def test_unreadable_state_starts_fresh_rather_than_failing_the_run(main):
527
+ path = Path(main._state_file_path("owner/repo"))
528
+ path.write_text("{not json")
529
+
530
+ assert main.load_state("owner/repo")["tasks"] == {}
531
+
532
+
533
+ def test_state_is_written_atomically(main):
534
+ state = {"version": 1, "repo": "owner/repo", "tasks": {"1:label:1": {"status": "active"}}}
535
+ main.save_state("owner/repo", state)
536
+
537
+ path = Path(main._state_file_path("owner/repo"))
538
+ assert json.loads(path.read_text()) == state
539
+ assert not Path(f"{path}.tmp").exists()