@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,352 @@
1
+ """Unit tests for the github-agents-md-maintainer automation script.
2
+
3
+ The focus is what the script decides on its own: which week it is working, when
4
+ it declines to work at all, where it clones to, and what it hands the agent.
5
+ """
6
+
7
+ import importlib.util
8
+ import json
9
+ import subprocess
10
+ import time
11
+ import urllib.error
12
+ from pathlib import Path
13
+
14
+ import pytest
15
+
16
+ SCRIPT_PATH = (
17
+ Path(__file__).parent.parent
18
+ / "skills"
19
+ / "github-agents-md-maintainer"
20
+ / "scripts"
21
+ / "main.py"
22
+ )
23
+
24
+
25
+ @pytest.fixture
26
+ def main(monkeypatch, tmp_path):
27
+ monkeypatch.setenv("WORKSPACE_BASE", str(tmp_path / "workspace"))
28
+ monkeypatch.delenv("AUTOMATION_KV_TOKEN", raising=False)
29
+ monkeypatch.delenv("AUTOMATION_API_URL", raising=False)
30
+ spec = importlib.util.spec_from_file_location("agents_md_main", SCRIPT_PATH)
31
+ module = importlib.util.module_from_spec(spec)
32
+ spec.loader.exec_module(module)
33
+ return module
34
+
35
+
36
+ def _http_error(code: int) -> urllib.error.HTTPError:
37
+ return urllib.error.HTTPError("https://api.github.com", code, "err", {}, None)
38
+
39
+
40
+ # ── One unit of work is one ISO week ──────────────────────────────────────────
41
+
42
+
43
+ def test_the_period_is_the_iso_week(main):
44
+ """A cron that fires more often, a retried run, or a restarted service all
45
+ resolve to the same key, so the work happens once."""
46
+ assert main._current_period() == time.strftime("%G-W%V", time.gmtime())
47
+ assert main._task_key("2026-W34") == "agents-md:2026-W34"
48
+
49
+
50
+ def test_the_branch_carries_the_period(main, monkeypatch):
51
+ monkeypatch.setattr(main, "_github_request", lambda *a, **k: (_ for _ in ()).throw(_http_error(404)))
52
+
53
+ assert main._branch_name("tok", "acme/widget", "2026-W34") == "openhands/agents-md-2026-W34"
54
+
55
+
56
+ def test_a_taken_branch_name_is_skipped(main, monkeypatch):
57
+ taken = {"openhands/agents-md-2026-W34"}
58
+
59
+ def fake(token, method, path, params=None, body=None):
60
+ branch = path.split("/git/ref/heads/", 1)[1]
61
+ if branch in taken:
62
+ return {"ref": branch}, {}
63
+ raise _http_error(404)
64
+
65
+ monkeypatch.setattr(main, "_github_request", fake)
66
+
67
+ assert main._branch_name("tok", "acme/widget", "2026-W34") == "openhands/agents-md-2026-W34-2"
68
+
69
+
70
+ # ── Declining to work ─────────────────────────────────────────────────────────
71
+
72
+
73
+ def test_only_this_automations_pull_requests_count_as_in_flight(main, monkeypatch):
74
+ """Someone else's open pull request must not silence the automation, and its
75
+ own must."""
76
+ monkeypatch.setattr(
77
+ main,
78
+ "_github_paginate",
79
+ lambda token, path, params=None: [
80
+ {"number": 1, "head": {"ref": "feature/something"}},
81
+ {"number": 2, "head": {"ref": "openhands/agents-md-2026-W33"}},
82
+ {"number": 3, "head": {"ref": "openhands/issue-42"}},
83
+ ],
84
+ )
85
+
86
+ in_flight = main._open_pull_requests_from_this_automation("tok", "acme/widget")
87
+
88
+ assert [pr["number"] for pr in in_flight] == [2]
89
+
90
+
91
+ def test_a_listing_failure_does_not_claim_a_pull_request_is_open(main, monkeypatch):
92
+ """Failing closed here would mean an API blip silently skips the week."""
93
+
94
+ def boom(*args, **kwargs):
95
+ raise RuntimeError("GitHub unreachable")
96
+
97
+ monkeypatch.setattr(main, "_github_paginate", boom)
98
+
99
+ assert main._open_pull_requests_from_this_automation("tok", "acme/widget") == []
100
+
101
+
102
+ # ── Create or update ──────────────────────────────────────────────────────────
103
+
104
+
105
+ def test_a_missing_agents_file_is_detected(main, monkeypatch):
106
+ monkeypatch.setattr(main, "_github_request", lambda *a, **k: (_ for _ in ()).throw(_http_error(404)))
107
+
108
+ assert main._agents_file_state("tok", "acme/widget", "main") == "missing"
109
+ assert main._pull_request_title("missing") == "docs: add AGENTS.md"
110
+
111
+
112
+ def test_an_unreadable_answer_is_treated_as_present(main, monkeypatch):
113
+ """Proposing to "add" a file that already exists reads worse than the
114
+ reverse, so an API failure resolves to update."""
115
+ monkeypatch.setattr(main, "_github_request", lambda *a, **k: (_ for _ in ()).throw(_http_error(500)))
116
+
117
+ assert main._agents_file_state("tok", "acme/widget", "main") == "present"
118
+ assert main._pull_request_title("present") == "docs: update AGENTS.md"
119
+
120
+
121
+ # ── The prompt ────────────────────────────────────────────────────────────────
122
+
123
+
124
+ def _prompt(main, state="present"):
125
+ return main._build_maintenance_prompt(
126
+ "acme/widget", state, "openhands/agents-md-2026-W34", "main", "abc123", "2026-W34"
127
+ )
128
+
129
+
130
+ def test_the_prompt_asks_for_an_edit_not_a_rewrite(main):
131
+ prompt = _prompt(main)
132
+
133
+ assert "update it so it matches" in prompt
134
+ assert "This is an edit, not a rewrite" in prompt
135
+ assert "someone else's writing" in prompt
136
+
137
+
138
+ def test_the_prompt_states_what_belongs_in_the_file(main):
139
+ """The rules are the ones the agent-memory skill sets out."""
140
+ prompt = _prompt(main)
141
+
142
+ assert "task-specific" in prompt
143
+ assert "have not verified" in prompt
144
+ assert "No secrets" in prompt
145
+
146
+
147
+ def test_the_prompt_allows_doing_nothing(main):
148
+ """Most weeks the file is fine, and an edit made to look busy is worse."""
149
+ prompt = _prompt(main)
150
+
151
+ assert "change nothing, open nothing" in prompt
152
+
153
+
154
+ def test_the_prompt_tells_the_agent_to_open_the_pull_request(main):
155
+ prompt = _prompt(main)
156
+
157
+ assert "x-access-token:$GITHUB_PERSONAL_ACCESS_TOKEN" in prompt
158
+ assert "gh pr create --repo acme/widget --base main --head openhands/agents-md-2026-W34" in prompt
159
+ assert "--draft" in prompt
160
+ assert "GITHUB_PR_OPENED" in prompt
161
+
162
+
163
+ def test_a_ready_for_review_configuration_drops_the_draft_flag(main, monkeypatch):
164
+ monkeypatch.setattr(main, "DRAFT_PULL_REQUEST", False)
165
+ prompt = _prompt(main)
166
+
167
+ assert "--draft" not in prompt
168
+ assert "ready for review" in prompt
169
+
170
+
171
+ def test_the_prompt_keeps_the_untrusted_input_boundary(main):
172
+ prompt = _prompt(main)
173
+
174
+ assert "untrusted input" in prompt
175
+ assert "repositories other than acme/widget" in prompt
176
+
177
+
178
+ # ── Configuration ─────────────────────────────────────────────────────────────
179
+
180
+
181
+ def test_config_json_overrides_the_constants(main, tmp_path):
182
+ (tmp_path / "config.json").write_text(
183
+ json.dumps(
184
+ {
185
+ "repos": ["acme/one"],
186
+ "branch_prefix": "bot/agents",
187
+ "pull_request_mode": "ready",
188
+ "max_new_per_run": 2,
189
+ "agent_secret_names": ["GITHUB_PERSONAL_ACCESS_TOKEN"],
190
+ }
191
+ )
192
+ )
193
+
194
+ config = main.load_config(tmp_path)
195
+
196
+ assert config["branch_prefix"] == "bot/agents"
197
+ assert config["pull_request_mode"] == "ready"
198
+
199
+
200
+ def test_the_github_token_is_forwarded_by_default(main, monkeypatch):
201
+ monkeypatch.setattr(
202
+ main,
203
+ "_list_secret_names",
204
+ lambda a, k: [{"name": "GITHUB_PERSONAL_ACCESS_TOKEN"}, {"name": "AWS_SECRET_ACCESS_KEY"}],
205
+ )
206
+
207
+ assert main.AGENT_SECRET_NAMES == ["GITHUB_PERSONAL_ACCESS_TOKEN"]
208
+ assert list(main._build_secrets_payload("http://agent", "key")) == [
209
+ "GITHUB_PERSONAL_ACCESS_TOKEN"
210
+ ]
211
+
212
+
213
+ # ── Files on disk ─────────────────────────────────────────────────────────────
214
+
215
+
216
+ def test_the_clone_lives_under_its_own_root(main):
217
+ path = main._checkout_path("acme/widget", "2026-W34")
218
+
219
+ assert path.parent.parent == main._checkouts_root()
220
+ assert str(path).endswith("agents-md/acme__widget/2026-W34")
221
+
222
+
223
+ def test_a_path_outside_the_checkout_root_is_never_removed(main, monkeypatch, tmp_path):
224
+ outside = tmp_path / "precious"
225
+ outside.mkdir()
226
+ rec = {"workspace_dir": str(outside), "conversation_id": "conv-1"}
227
+ monkeypatch.setattr(main, "conversation_status", lambda *a: "finished")
228
+
229
+ assert main._release_checkout(rec, "http://agent", "key") is True
230
+ assert outside.exists()
231
+
232
+
233
+ def test_a_running_conversation_keeps_its_clone(main, monkeypatch):
234
+ checkout = main._checkout_path("acme/widget", "2026-W34")
235
+ checkout.mkdir(parents=True)
236
+ rec = {"workspace_dir": str(checkout), "conversation_id": "conv-1"}
237
+ monkeypatch.setattr(main, "conversation_status", lambda *a: "running")
238
+
239
+ assert main._release_checkout(rec, "http://agent", "key") is False
240
+ assert checkout.exists()
241
+
242
+
243
+ def _init_repo(path: Path) -> str:
244
+ path.mkdir(parents=True, exist_ok=True)
245
+ run = lambda *a: subprocess.run(["git", *a], cwd=path, check=True, capture_output=True)
246
+ run("init", "-q", "-b", "main")
247
+ run("config", "user.name", "Test")
248
+ run("config", "user.email", "test@example.com")
249
+ (path / "README.md").write_text("base\n")
250
+ run("add", "-A")
251
+ run("commit", "-q", "-m", "base")
252
+ return subprocess.run(
253
+ ["git", "rev-parse", "HEAD"], cwd=path, check=True, capture_output=True, text=True
254
+ ).stdout.strip()
255
+
256
+
257
+ def test_an_edited_agents_file_is_committed_and_counted(main, tmp_path):
258
+ checkout = tmp_path / "clone"
259
+ base = _init_repo(checkout)
260
+ (checkout / "AGENTS.md").write_text("# AGENTS\n\nRun `pytest`.\n")
261
+
262
+ assert main._commit_agent_work(checkout, base) == 1
263
+
264
+
265
+ def test_an_untouched_repository_produces_no_commits(main, tmp_path):
266
+ """No commits is the expected weekly outcome, not a failure."""
267
+ checkout = tmp_path / "clone"
268
+ base = _init_repo(checkout)
269
+
270
+ assert main._commit_agent_work(checkout, base) == 0
271
+
272
+
273
+ # ── Pull request body ─────────────────────────────────────────────────────────
274
+
275
+
276
+ def test_the_pull_request_body_dates_the_run_and_discloses_the_agent(main):
277
+ body = main._pull_request_body("acme/widget", "Refreshed the test command.", "http://oh/c/1", "2026-W34")
278
+
279
+ assert "Refreshed the test command." in body
280
+ assert "2026-W34" in body
281
+ assert "_This pull request was opened by an AI agent (OpenHands)._" in body
282
+
283
+
284
+ def test_a_capped_run_still_finalizes_but_starts_nothing(main, monkeypatch, tmp_path):
285
+ """The per-run cap limits new conversations. A repository beyond it still
286
+ has an in-flight task to finish - skipping the repository outright would
287
+ leave its pull request unopened and its clone on disk."""
288
+ started = []
289
+ finalized = []
290
+
291
+ monkeypatch.setattr(
292
+ main, "_get_repo", lambda token, repo: {"default_branch": "main", "permissions": {"push": True}}
293
+ )
294
+ monkeypatch.setattr(main, "_open_pull_requests_from_this_automation", lambda t, r: [])
295
+ monkeypatch.setattr(main, "_agents_file_state", lambda t, r, b: "present")
296
+ monkeypatch.setattr(main, "_start_task", lambda *a, **k: started.append(1))
297
+ monkeypatch.setattr(main, "_finalize_task", lambda *a, **k: finalized.append(1))
298
+
299
+ period = main._current_period()
300
+ main.save_state(
301
+ "acme/widget",
302
+ {
303
+ "version": 1,
304
+ "repo": "acme/widget",
305
+ "tasks": {
306
+ "agents-md:2026-W01": {
307
+ "period": "2026-W01",
308
+ "status": "active",
309
+ "conversation_id": "c1",
310
+ "last_activity": 0,
311
+ }
312
+ },
313
+ },
314
+ )
315
+
316
+ main._process_repo("acme/widget", "tok", "http://agent", "key", "http://oh", may_start=False)
317
+
318
+ assert finalized == [1], "a capped run must still finish work already in flight"
319
+ assert started == [], "a capped run must not start new work"
320
+ assert main._task_key(period) not in main.load_state("acme/widget")["tasks"]
321
+
322
+
323
+ def test_the_entry_declares_the_icon_its_card_should_show():
324
+ """A card whose identity is the work, not the service it talks to, names its
325
+ own glyph; the host derives one from the integrations otherwise."""
326
+ entry = json.loads(
327
+ (
328
+ Path(__file__).parent.parent
329
+ / "automations"
330
+ / "catalog"
331
+ / "github-agents-md-maintainer"
332
+ / "manifest.json"
333
+ ).read_text()
334
+ )
335
+ schema = json.loads(
336
+ (Path(__file__).parent.parent / "automations" / "catalog.schema.json").read_text()
337
+ )
338
+
339
+ assert entry["icon"] == "bot"
340
+ assert entry["icon"] in schema["properties"]["icon"]["enum"]
341
+
342
+
343
+ def test_the_icon_vocabulary_matches_the_interface_manifest():
344
+ """Two closed sets for the same artwork would drift; the card icons are the
345
+ navigation's icons."""
346
+ root = Path(__file__).parent.parent
347
+ catalog = json.loads((root / "automations" / "catalog.schema.json").read_text())
348
+ interface = json.loads((root / "automations" / "interface.schema.json").read_text())
349
+
350
+ nav_icons = json.dumps(interface)
351
+ for slug in catalog["properties"]["icon"]["enum"]:
352
+ assert f'"{slug}"' in nav_icons, f"{slug} is not an icon the interface manifest knows"