@openhands/extensions 0.30.0 → 0.31.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 (32) hide show
  1. package/.agents/skills/custom-codereview-guide.md +3 -4
  2. package/.claude-plugin/marketplace.json +13 -0
  3. package/.codex-plugin/marketplace.json +13 -0
  4. package/.github/workflows/pr-artifacts.yml +4 -83
  5. package/.github/workflows/tests.yml +13 -0
  6. package/.plugin/marketplace.json +13 -0
  7. package/.release-please-manifest.json +1 -1
  8. package/AGENTS.md +4 -3
  9. package/README.md +3 -2
  10. package/automations/bundle-index.js +1 -1
  11. package/automations/catalog/github-pr-reviewer/manifest.json +2 -2
  12. package/marketplaces/openhands-extensions.json +13 -0
  13. package/package.json +1 -1
  14. package/pyproject.toml +1 -1
  15. package/python/openhands_extensions/_version.py +1 -1
  16. package/skills/github-pr-reviewer/README.md +6 -0
  17. package/skills/github-pr-reviewer/SKILL.md +7 -0
  18. package/skills/github-stale-ci-pr-closer/scripts/worker.py +8 -2
  19. package/skills/index.js +12 -2
  20. package/skills/slack-channel-monitor/README.md +6 -2
  21. package/skills/slack-channel-monitor/SKILL.md +29 -17
  22. package/skills/slack-channel-monitor/references/windows.md +6 -2
  23. package/skills/slack-channel-monitor/scripts/main.py +156 -11
  24. package/skills/software-factory/.claude-plugin/plugin.json +19 -0
  25. package/skills/software-factory/.codex-plugin/plugin.json +19 -0
  26. package/skills/software-factory/.plugin/plugin.json +19 -0
  27. package/skills/software-factory/README.md +64 -0
  28. package/skills/software-factory/SKILL.md +229 -0
  29. package/skills/software-factory/commands/software-factory.md +8 -0
  30. package/skills/software-factory/scripts/factory.py +630 -0
  31. package/tests/test_github_stale_ci_pr_closer.py +52 -1
  32. package/tests/test_software_factory_script.py +231 -0
@@ -0,0 +1,231 @@
1
+ """The software-factory helper builds the same create body as Agent Canvas."""
2
+
3
+ import importlib.util
4
+ import io
5
+ import json
6
+ import tarfile
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+ from test_automation_setup import _render_payload
11
+
12
+ ROOT = Path(__file__).resolve().parents[1]
13
+ BUNDLE_STATIONS = [
14
+ "github-issue-to-pr",
15
+ "github-pr-reviewer",
16
+ "github-agents-md-maintainer",
17
+ "github-issue-triage",
18
+ "github-delivery-watchdog",
19
+ "github-stale-ci-pr-closer",
20
+ ]
21
+
22
+
23
+ @pytest.fixture(scope="module")
24
+ def factory():
25
+ path = ROOT / "skills" / "software-factory" / "scripts" / "factory.py"
26
+ spec = importlib.util.spec_from_file_location("factory", path)
27
+ assert spec and spec.loader
28
+ module = importlib.util.module_from_spec(spec)
29
+ spec.loader.exec_module(module)
30
+ return module
31
+
32
+
33
+ def _entry(station: str) -> dict:
34
+ return json.loads((ROOT / "automations" / "catalog" / station / "manifest.json").read_text())
35
+
36
+
37
+ @pytest.mark.parametrize("station", BUNDLE_STATIONS)
38
+ @pytest.mark.parametrize("repos", [["acme/api"], ["acme/api", "acme/web"]])
39
+ def test_create_body_matches_reference_renderer(factory, station, repos):
40
+ entry = _entry(station)
41
+ values = factory._form_values(entry, repos, {})
42
+ expected = _render_payload(entry, values, "", selected_trigger="cron")
43
+ assert factory._create_body(entry, values, repos) == expected
44
+
45
+
46
+ @pytest.mark.parametrize("station", BUNDLE_STATIONS)
47
+ def test_every_token_secret_is_the_factory_token(factory, station):
48
+ values = factory._form_values(_entry(station), ["acme/api"], {})
49
+ if "githubTokenSecret" in values:
50
+ assert values["githubTokenSecret"] == "GITHUB_PERSONAL_ACCESS_TOKEN"
51
+
52
+
53
+ def test_overrides_keep_json_types(factory):
54
+ entry = _entry("github-pr-reviewer")
55
+ values = factory._form_values(
56
+ entry, ["acme/api"], {"maxNewPerRun": factory._parse_value("4"), "reviewTone": "thorough"}
57
+ )
58
+ config = factory._create_body(entry, values, ["acme/api"])["template"]["config"]
59
+ assert config["max_new_per_run"] == 4
60
+ assert config["review_tone"] == "thorough"
61
+ assert config["repos"] == ["acme/api"]
62
+
63
+
64
+ def test_unknown_field_is_rejected(factory):
65
+ with pytest.raises(factory.FactoryError):
66
+ factory._form_values(_entry("github-issue-to-pr"), ["acme/api"], {"nope": 1})
67
+
68
+
69
+ @pytest.mark.parametrize("station", BUNDLE_STATIONS)
70
+ def test_tarball_holds_bundle_files_and_config(factory, station):
71
+ bundle = _entry(station)["setup"]["bundle"]
72
+ data = factory._tarball(ROOT, bundle["files"], {"repos": ["acme/api"]})
73
+ with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
74
+ names = set(tar.getnames())
75
+ config = json.load(tar.extractfile("config.json"))
76
+ assert names == set(bundle["files"]) | {"config.json"}
77
+ assert config == {"repos": ["acme/api"]}
78
+
79
+
80
+ def test_prs_keeps_only_branches_with_the_prefix(factory, monkeypatch):
81
+ pulls = [
82
+ {"number": 2, "title": "docs: add AGENTS.md", "html_url": "u2", "draft": True,
83
+ "created_at": "2026-10-16T21:32:40Z", "head": {"ref": "openhands/agents-md-2026-W42"}},
84
+ {"number": 3, "title": "feature", "html_url": "u3", "draft": False,
85
+ "head": {"ref": "feature/x"}},
86
+ ]
87
+ monkeypatch.setattr(factory, "_github", lambda path: pulls)
88
+ args = type("Args", (), {"repos": "acme/api", "branch_prefix": "openhands/agents-md"})
89
+ assert factory.prs(args) == {
90
+ "acme/api": [
91
+ {"number": 2, "title": "docs: add AGENTS.md", "url": "u2", "draft": True,
92
+ "created_at": "2026-10-16T21:32:40Z"}
93
+ ]
94
+ }
95
+
96
+
97
+ def test_automation_url_points_at_the_canvas_page(factory, monkeypatch):
98
+ monkeypatch.setenv("OPENHANDS_HOST", "http://localhost:8000/")
99
+ assert factory._automation_url("abc") == "http://localhost:8000/automations/abc"
100
+
101
+
102
+ def test_extensions_prefers_the_installed_checkout(factory, monkeypatch):
103
+ """Bundles must come from the release the user has, not a moving main."""
104
+ monkeypatch.delenv("FACTORY_EXTENSIONS", raising=False)
105
+ monkeypatch.setattr(factory, "subprocess", None) # a clone here would fail
106
+ assert factory._extensions() == ROOT
107
+
108
+
109
+ def test_extensions_honours_the_override(factory, monkeypatch, tmp_path):
110
+ monkeypatch.setenv("FACTORY_EXTENSIONS", str(tmp_path))
111
+ assert factory._extensions() == tmp_path
112
+
113
+
114
+ def test_release_ref_is_none_without_installed_metadata(factory, monkeypatch):
115
+ import importlib.metadata as md
116
+
117
+ def missing(name):
118
+ raise md.PackageNotFoundError(name)
119
+
120
+ monkeypatch.setattr(md, "version", missing)
121
+ assert factory._release_ref() is None
122
+
123
+
124
+ def test_automation_backend_host_gets_a_clear_error(factory, monkeypatch):
125
+ def not_found(*args, **kwargs):
126
+ raise factory.FactoryError("GET http://localhost:18001/api/agent-profiles -> 404: {}")
127
+
128
+ monkeypatch.setenv("OPENHANDS_HOST", "http://localhost:18001")
129
+ monkeypatch.setenv("OPENHANDS_AUTOMATION_API_KEY", "key")
130
+ monkeypatch.setattr(factory, "_request", not_found)
131
+ with pytest.raises(factory.FactoryError, match="Ingress URL"):
132
+ factory._profile(None)
133
+
134
+
135
+ def _create_args(ids: str, repos: str = "acme/api", sets=None):
136
+ return type("Args", (), {"ids": ids, "repos": repos, "set": sets, "profile": None})
137
+
138
+
139
+ def _stub_create(factory, monkeypatch, responses):
140
+ """Stub upload/manifest/profile and record the POST bodies and PATCHes."""
141
+ monkeypatch.setenv("OPENHANDS_HOST", "http://localhost:8000")
142
+ monkeypatch.setattr(factory, "_upload", lambda name, data: f"oh-internal://uploads/{name}")
143
+ monkeypatch.setattr(factory, "_extensions", lambda: ROOT)
144
+ monkeypatch.setattr(factory, "_profile", lambda name: {"id": "p1", "name": "default"})
145
+ posts = []
146
+ patches = []
147
+
148
+ def api(method, path, body=None, **kwargs):
149
+ if method == "POST" and path == "":
150
+ posts.append(body)
151
+ return responses[len(posts) - 1]
152
+ if method == "PATCH":
153
+ patches.append((path, body))
154
+ return {"id": path.strip("/")}
155
+ raise AssertionError(f"unexpected {method} {path}")
156
+
157
+ monkeypatch.setattr(factory, "_api", api)
158
+ return posts, patches
159
+
160
+
161
+ def test_create_reports_existing_station_instead_of_claiming_it(factory, monkeypatch):
162
+ """A 200 from the service means the template already exists, not a create."""
163
+ responses = [
164
+ (201, {"id": "a1", "enabled": False, "state": "INACTIVE"}),
165
+ (200, {"id": "a2", "enabled": True, "state": "ACTIVE"}),
166
+ ]
167
+ posts, patches = _stub_create(factory, monkeypatch, responses)
168
+ result = factory.create(_create_args("github-pr-reviewer,github-issue-to-pr"))
169
+ assert [c["id"] for c in result["created"]] == ["a1"]
170
+ assert [e["id"] for e in result["existing"]] == ["a2"]
171
+ # The pre-existing automation is left untouched: no disable PATCH for it.
172
+ assert patches == []
173
+
174
+
175
+ def test_create_disables_a_station_the_service_returned_enabled(factory, monkeypatch):
176
+ posts, patches = _stub_create(factory, monkeypatch, [(201, {"id": "a1", "enabled": True})])
177
+ factory.create(_create_args("github-pr-reviewer"))
178
+ assert patches == [("/a1", {"enabled": False})]
179
+
180
+
181
+ def test_tune_patches_in_place_without_stamping_template(factory, monkeypatch):
182
+ """Tune must not go through create's template-id dedup path."""
183
+ monkeypatch.setenv("OPENHANDS_HOST", "http://localhost:8000")
184
+ monkeypatch.setattr(factory, "_upload", lambda name, data: f"oh-internal://uploads/{name}")
185
+ monkeypatch.setattr(factory, "_extensions", lambda: ROOT)
186
+ monkeypatch.setattr(factory, "_profile", lambda name: {"id": "p1", "name": "default"})
187
+ monkeypatch.setattr(factory, "_find_existing", lambda station: {"id": "a1"})
188
+ calls = []
189
+
190
+ def api(method, path, body=None, **kwargs):
191
+ calls.append((method, path, body))
192
+ return {"id": "a1"}
193
+
194
+ monkeypatch.setattr(factory, "_api", api)
195
+ result = factory.tune(
196
+ _create_args("github-pr-reviewer", sets=["reviewTone=thorough"])
197
+ )
198
+ assert [t["id"] for t in result["tuned"]] == ["a1"]
199
+ method, path, body = calls[-1]
200
+ assert (method, path) == ("PATCH", "/a1")
201
+ assert "template" not in body
202
+ assert body["tarball_path"].startswith("oh-internal://uploads/")
203
+ assert body["agent_profile_id"] == "p1"
204
+
205
+
206
+ def test_tune_rejects_a_station_that_is_not_set_up(factory, monkeypatch):
207
+ monkeypatch.setattr(factory, "_extensions", lambda: ROOT)
208
+ monkeypatch.setattr(factory, "_find_existing", lambda station: None)
209
+ with pytest.raises(factory.FactoryError, match="not set up yet"):
210
+ factory.tune(_create_args("github-pr-reviewer"))
211
+
212
+
213
+ def test_find_existing_matches_on_template_id(factory, monkeypatch):
214
+ page = {
215
+ "automations": [
216
+ {"id": "a1", "preset_metadata": {"template": {"id": "other"}}},
217
+ {"id": "a2", "preset_metadata": {"template": {"id": "github-pr-reviewer"}}},
218
+ {"id": "a3", "preset_metadata": None},
219
+ ]
220
+ }
221
+ monkeypatch.setattr(factory, "_api", lambda method, path, **kwargs: page)
222
+ assert factory._find_existing("github-pr-reviewer")["id"] == "a2"
223
+ assert factory._find_existing("github-issue-to-pr") is None
224
+
225
+
226
+ def test_event_only_station_fields_do_not_raise(factory):
227
+ """qa-changes declares only an event trigger; --set must report, not KeyError."""
228
+ entry = _entry("qa-changes")
229
+ assert factory._fields(entry) == entry["setup"]["form"]["args"]
230
+ with pytest.raises(factory.FactoryError, match="no listed station has field"):
231
+ factory._overrides({"qa-changes": entry}, ["reviewTone=thorough"], ["qa-changes"])