@openhands/extensions 0.18.0 → 0.19.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 (52) hide show
  1. package/.agents/skills/custom-codereview-guide.md +7 -0
  2. package/.github/dependabot.yml +24 -0
  3. package/.github/scripts/check_issue_readiness.py +272 -0
  4. package/.github/scripts/check_pr_description.py +226 -0
  5. package/.github/scripts/post-readiness-comment.mjs +184 -0
  6. package/.github/scripts/refresh_linked_pr_checks.py +171 -0
  7. package/.github/workflows/issue-readiness-check.yml +142 -0
  8. package/.github/workflows/pr-description-check.yml +37 -0
  9. package/.release-please-manifest.json +1 -1
  10. package/AGENTS.md +16 -0
  11. package/README.md +10 -2
  12. package/automations/bundle-index.js +1 -1
  13. package/automations/catalog/github-agents-md-maintainer/manifest.json +5 -0
  14. package/automations/catalog/github-issue-to-pr/manifest.json +5 -0
  15. package/automations/catalog/github-pr-reviewer/manifest.json +5 -0
  16. package/automations/catalog/github-repo-monitor/manifest.json +5 -0
  17. package/automations/catalog/jira-issue-to-bitbucket-pr/manifest.json +23 -0
  18. package/automations/catalog/jira-issue-to-gitlab-mr/manifest.json +23 -0
  19. package/automations/catalog/linear-issue-to-bitbucket-pr/manifest.json +23 -0
  20. package/automations/catalog/linear-issue-to-github-pr/manifest.json +23 -0
  21. package/automations/catalog/linear-issue-to-gitlab-mr/manifest.json +23 -0
  22. package/automations/catalog/news-digest/manifest.json +5 -0
  23. package/automations/catalog/qa-changes/manifest.json +72 -0
  24. package/automations/catalog/slack-github-linear-daily-organization.json +27 -0
  25. package/automations/catalog-index.js +18 -6
  26. package/automations/catalog.schema.json +25 -0
  27. package/automations/index.d.ts +16 -0
  28. package/integrations/catalog/datadog.json +2 -2
  29. package/integrations/catalog/gitlab.json +41 -0
  30. package/integrations/catalog/notion.json +1 -1
  31. package/integrations/catalog-index.js +71 -69
  32. package/integrations/index.d.ts +1 -1
  33. package/marketplaces/openhands-extensions.json +21 -6
  34. package/package.json +1 -1
  35. package/plugins/pr-review/scripts/prompt.py +2 -0
  36. package/pyproject.toml +1 -1
  37. package/python/openhands_extensions/_version.py +1 -1
  38. package/skills/github-pr-reviewer/SKILL.md +2 -1
  39. package/skills/github-pr-reviewer/scripts/main.py +45 -4
  40. package/skills/github-pr-reviewer/tests/test_main.py +74 -0
  41. package/skills/index.js +11 -2
  42. package/skills/openhands-sdk/SKILL.md +1 -0
  43. package/skills/ticket-to-code-change/.plugin/plugin.json +21 -0
  44. package/skills/ticket-to-code-change/README.md +5 -0
  45. package/skills/ticket-to-code-change/SKILL.md +50 -0
  46. package/skills/ticket-to-code-change/commands/ticket-to-code-change-setup.md +8 -0
  47. package/tests/test_automation_setup.py +22 -0
  48. package/tests/test_catalogs.py +2 -2
  49. package/tests/test_check_issue_readiness.py +135 -0
  50. package/tests/test_check_pr_description.py +228 -0
  51. package/tests/test_pr_review_prompt.py +10 -0
  52. package/tests/test_refresh_linked_pr_checks.py +130 -0
@@ -0,0 +1,135 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import sys
5
+ from pathlib import Path
6
+
7
+
8
+ def _load_prod_module():
9
+ repo_root = Path(__file__).resolve().parents[1]
10
+ script_path = repo_root / ".github" / "scripts" / "check_issue_readiness.py"
11
+ name = "check_issue_readiness"
12
+ spec = importlib.util.spec_from_file_location(name, script_path)
13
+ assert spec and spec.loader
14
+ mod = importlib.util.module_from_spec(spec)
15
+ sys.modules[name] = mod
16
+ spec.loader.exec_module(mod)
17
+ return mod
18
+
19
+
20
+ _prod = _load_prod_module()
21
+ evaluate_readiness = _prod.evaluate_readiness
22
+ extract_sections = _prod.extract_sections
23
+
24
+
25
+ ENHANCEMENT_READY = """### Problem or Use Case
26
+
27
+ The integration catalog lacks a Linear entry.
28
+
29
+ ### Desired Behavior
30
+
31
+ `integrations/catalog/linear.json` describes the Linear MCP server so the
32
+ catalog build picks it up.
33
+
34
+ ### Acceptance Criteria
35
+
36
+ - [ ] `npm run build:integrations` succeeds with the new entry
37
+ - [ ] `getIntegrationCatalogEntry("linear")` returns the Linear catalog model
38
+ """
39
+
40
+ BUG_READY = """### Actual Behavior
41
+
42
+ Running `uv run --group test pytest tests/` fails in
43
+ `test_skills_catalog.py` with a KeyError when a skill is missing from the
44
+ marketplace.
45
+
46
+ ### Acceptance Criteria
47
+
48
+ - [ ] `uv run --group test pytest tests/` passes with a clear error message
49
+ """
50
+
51
+
52
+ def test_extract_sections_splits_on_headings():
53
+ sections = extract_sections("### Alpha\n\ntext\n\n### Beta\n\nmore\n")
54
+ assert sections["alpha"] == "\ntext\n\n"
55
+ assert sections["beta"] == "\nmore\n"
56
+
57
+
58
+ def test_enhancement_ready_passes():
59
+ result = evaluate_readiness(ENHANCEMENT_READY, ["enhancement"])
60
+ assert result.ready is True
61
+ assert result.reasons == []
62
+
63
+
64
+ def test_enhancement_missing_acceptance_criteria_fails():
65
+ body = "### Desired Behavior\n\nSome desired change.\n"
66
+ result = evaluate_readiness(body, ["enhancement"])
67
+ assert result.ready is False
68
+ assert any("Acceptance Criteria" in r for r in result.reasons)
69
+
70
+
71
+ def test_enhancement_missing_desired_behavior_fails():
72
+ body = ENHANCEMENT_READY.replace(
73
+ "### Desired Behavior\n\n"
74
+ "`integrations/catalog/linear.json` describes the Linear MCP server so the\n"
75
+ "catalog build picks it up.\n\n",
76
+ "",
77
+ )
78
+ result = evaluate_readiness(body, ["enhancement"])
79
+ assert result.ready is False
80
+ assert any("Desired Behavior" in r for r in result.reasons)
81
+
82
+
83
+ def test_bug_ready_passes():
84
+ result = evaluate_readiness(BUG_READY, ["bug"])
85
+ assert result.ready is True
86
+ assert result.reasons == []
87
+
88
+
89
+ def test_bug_missing_run_method_fails():
90
+ body = BUG_READY.replace(
91
+ "Running `uv run --group test pytest tests/` fails",
92
+ "Running the test suite fails",
93
+ )
94
+ result = evaluate_readiness(body, ["bug"])
95
+ assert result.ready is False
96
+ assert any("reproducible command" in r for r in result.reasons)
97
+
98
+
99
+ def test_bug_npm_build_command_is_a_valid_run_method():
100
+ body = BUG_READY.replace(
101
+ "Running `uv run --group test pytest tests/` fails in\n"
102
+ "`test_skills_catalog.py` with a KeyError when a skill is missing from the\n"
103
+ "marketplace.",
104
+ "Running `npm run build:skills` fails with a schema error.",
105
+ )
106
+ result = evaluate_readiness(body, ["bug"])
107
+ assert result.ready is True
108
+ assert result.reasons == []
109
+
110
+
111
+ def test_bug_acceptance_needs_checklist_item():
112
+ body = BUG_READY.replace(
113
+ "- [ ] `uv run --group test pytest tests/` passes with a clear error message",
114
+ "Make the test error message clearer",
115
+ )
116
+ result = evaluate_readiness(body, ["bug"])
117
+ assert result.ready is False
118
+ assert any("checklist item" in r for r in result.reasons)
119
+
120
+
121
+ def test_no_response_field_counts_as_empty():
122
+ body = BUG_READY.replace(
123
+ "### Acceptance Criteria\n\n- [ ] `uv run --group test pytest tests/` "
124
+ "passes with a clear error message\n",
125
+ "### Acceptance Criteria\n\n_No response_\n",
126
+ )
127
+ result = evaluate_readiness(body, ["bug"])
128
+ assert result.ready is False
129
+ assert any("Acceptance Criteria" in r for r in result.reasons)
130
+
131
+
132
+ def test_no_bug_or_enhancement_label_not_ready():
133
+ result = evaluate_readiness(ENHANCEMENT_READY, [])
134
+ assert result.ready is False
135
+ assert any("bug" in r and "enhancement" in r for r in result.reasons)
@@ -0,0 +1,228 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+
9
+ def _load_prod_module():
10
+ repo_root = Path(__file__).resolve().parents[1]
11
+ script_path = repo_root / ".github" / "scripts" / "check_pr_description.py"
12
+ name = "check_pr_description"
13
+ spec = importlib.util.spec_from_file_location(name, script_path)
14
+ assert spec and spec.loader
15
+ mod = importlib.util.module_from_spec(spec)
16
+ sys.modules[name] = mod
17
+ spec.loader.exec_module(mod)
18
+ return mod
19
+
20
+
21
+ _prod = _load_prod_module()
22
+ validate_pr_body = _prod.validate_pr_body
23
+ body_from_event = _prod.body_from_event
24
+ extract_linked_issue_numbers = _prod.extract_linked_issue_numbers
25
+ validate_linked_issue_ready = _prod.validate_linked_issue_ready
26
+ fetch_issue_details = _prod.fetch_issue_details
27
+
28
+
29
+ VALID_BODY = """<!-- Keep this PR as draft until it is ready for review. -->
30
+
31
+ <!-- AI/LLM agents: be concise and specific. Do not check the box below. -->
32
+
33
+ - [ ] A human has tested these changes.
34
+
35
+ ---
36
+
37
+ ## Why
38
+
39
+ The repository had no readiness gate for issues or linked PRs.
40
+
41
+ ## Summary
42
+
43
+ - Add ready-for-dev issue and PR readiness gates.
44
+
45
+ ## Issue Number
46
+
47
+ Fixes #508
48
+
49
+ ## How to Test
50
+
51
+ Run `uv run --group test pytest tests/`.
52
+
53
+ ## Video/Screenshots
54
+
55
+ N/A
56
+
57
+ ## Notes
58
+
59
+ N/A
60
+ """
61
+
62
+
63
+ def test_valid_pr_body_passes():
64
+ assert validate_pr_body(VALID_BODY) == []
65
+
66
+
67
+ def test_required_template_fields_must_be_present_and_filled():
68
+ how_to_test = "## How to Test\n\nRun `uv run --group test pytest tests/`."
69
+ body = VALID_BODY.replace(how_to_test, "## How to Test\n\n<!-- TODO -->")
70
+ body = body.replace("## Summary", "## Details")
71
+
72
+ errors = validate_pr_body(body)
73
+
74
+ assert "Fill in the `## How to Test` section of the PR template." in errors
75
+ assert "Keep the `## Summary` section from the PR template." in errors
76
+
77
+
78
+ def test_summary_placeholder_bullet_counts_as_empty():
79
+ body = VALID_BODY.replace(
80
+ "- Add ready-for-dev issue and PR readiness gates.", "-"
81
+ )
82
+
83
+ errors = validate_pr_body(body)
84
+
85
+ assert "Fill in the `## Summary` section of the PR template." in errors
86
+
87
+
88
+ def test_optional_template_sections_may_be_removed():
89
+ body = VALID_BODY.replace("## Issue Number\n\nFixes #508\n\n", "")
90
+ body = body.split("## Video/Screenshots", maxsplit=1)[0]
91
+
92
+ assert validate_pr_body(body) == []
93
+
94
+
95
+ def test_body_from_event_reads_pull_request_body(tmp_path: Path):
96
+ event_path = tmp_path / "event.json"
97
+ event_path.write_text(
98
+ json.dumps(
99
+ {
100
+ "pull_request": {"body": VALID_BODY},
101
+ "repository": {"full_name": "org/repo"},
102
+ }
103
+ )
104
+ )
105
+
106
+ body, repo = body_from_event(event_path)
107
+ assert body == VALID_BODY
108
+ assert repo == "org/repo"
109
+
110
+
111
+ def test_extract_linked_issue_numbers_keyword_and_bare_ref():
112
+ body = (
113
+ "Fixes #12\n"
114
+ "Closes #12 again\n"
115
+ "resolves #34\n"
116
+ "## Issue Number\n"
117
+ "Issue: #56, see also #12\n"
118
+ )
119
+ assert extract_linked_issue_numbers(body) == [12, 34, 56]
120
+
121
+
122
+ def test_extract_linked_issue_numbers_only_bare_ref_in_issue_section():
123
+ body = "## Summary\n\nSome work.\n\n## Issue Number\n\n#7\n"
124
+ assert extract_linked_issue_numbers(body) == [7]
125
+
126
+
127
+ def test_extract_linked_issue_numbers_no_bare_ref_outside_issue_section():
128
+ # A bare `#42` in the Summary must not be treated as a linked issue.
129
+ body = "## Summary\n\nSee #42 for background.\n\n## Issue Number\n\nN/A\n"
130
+ assert extract_linked_issue_numbers(body) == []
131
+
132
+
133
+ def test_extract_linked_issue_numbers_keyword_inside_word_is_ignored():
134
+ # "fix"/"clos"/"resolv" must not match inside larger words (e.g. "crucifixes").
135
+ body = (
136
+ "## Summary\n\n"
137
+ "crucifixes #12, encloses #34, transfixes #56.\n\n"
138
+ "## Issue Number\n\nN/A\n"
139
+ )
140
+ assert extract_linked_issue_numbers(body) == []
141
+
142
+
143
+ def test_validate_linked_issue_ready_requires_a_number():
144
+ errors = validate_linked_issue_ready(
145
+ "## Issue Number\n\nN/A\n", "org/repo", "token"
146
+ )
147
+ assert errors and "Link an issue" in errors[0]
148
+
149
+
150
+ def test_validate_linked_issue_ready_no_token_skips_network(monkeypatch):
151
+ def _fail(*args, **kwargs):
152
+ raise AssertionError("should not call the network without a token")
153
+
154
+ monkeypatch.setattr(_prod, "fetch_issue_details", _fail)
155
+ assert validate_linked_issue_ready("Fixes #12\n", None, None) == []
156
+
157
+
158
+ def test_validate_linked_issue_ready_passes_with_ready_label(monkeypatch):
159
+ monkeypatch.setattr(
160
+ _prod,
161
+ "fetch_issue_details",
162
+ lambda repo, num, token: (["ready-for-dev"], "2026-08-25T00:00:00Z"),
163
+ )
164
+ assert validate_linked_issue_ready("Fixes #12\n", "org/repo", "token") == []
165
+
166
+
167
+ def test_validate_linked_issue_ready_grandfathers_pre_rollout_issue(monkeypatch):
168
+ monkeypatch.setattr(
169
+ _prod,
170
+ "fetch_issue_details",
171
+ lambda repo, num, token: (["bug"], "2026-01-15T00:00:00Z"),
172
+ )
173
+ assert validate_linked_issue_ready("Fixes #12\n", "org/repo", "token") == []
174
+
175
+
176
+ def test_validate_linked_issue_ready_grandfathers_rollout_day_before_deployment(
177
+ monkeypatch,
178
+ ):
179
+ # Opened on the rollout day (2026-08-24) before the workflow was deployed,
180
+ # so it was never labeled. It must be exempt.
181
+ monkeypatch.setattr(
182
+ _prod,
183
+ "fetch_issue_details",
184
+ lambda repo, num, token: (["bug"], "2026-08-24T06:46:00Z"),
185
+ )
186
+ assert validate_linked_issue_ready("Fixes #12\n", "org/repo", "token") == []
187
+
188
+
189
+ def test_validate_linked_issue_ready_fails_for_new_not_ready_issue(monkeypatch):
190
+ monkeypatch.setattr(
191
+ _prod,
192
+ "fetch_issue_details",
193
+ lambda repo, num, token: (["bug"], "2026-08-25T00:00:00Z"),
194
+ )
195
+ errors = validate_linked_issue_ready("Fixes #12\n", "org/repo", "token")
196
+ assert errors and "ready-for-dev" in errors[0]
197
+
198
+
199
+ def test_validate_linked_issue_ready_new_unready_not_masked_by_ready_sibling(
200
+ monkeypatch,
201
+ ):
202
+ def _issues(repo, num, token):
203
+ # #12 carries ready-for-dev; #34 is new and not ready.
204
+ if num == 34:
205
+ return ["bug"], "2026-08-25T00:00:00Z"
206
+ return ["ready-for-dev"], "2026-08-25T00:00:00Z"
207
+
208
+ monkeypatch.setattr(_prod, "fetch_issue_details", _issues)
209
+ body = "Fixes #12 and Closes #34"
210
+ errors = validate_linked_issue_ready(body, "org/repo", "token")
211
+ assert "#34" in errors[0]
212
+ assert "ready-for-dev" in errors[0]
213
+
214
+
215
+ def test_validate_linked_issue_ready_returns_error_when_all_issues_not_found(
216
+ monkeypatch,
217
+ ):
218
+ import urllib.error
219
+ from http.client import HTTPMessage
220
+
221
+ def _missing(repo, num, token):
222
+ raise urllib.error.HTTPError(
223
+ "https://api.github.com", 404, "Not Found", HTTPMessage(), None
224
+ )
225
+
226
+ monkeypatch.setattr(_prod, "fetch_issue_details", _missing)
227
+ errors = validate_linked_issue_ready("Fixes #12\n", "org/repo", "token")
228
+ assert errors and "could not be found" in errors[0]
@@ -59,6 +59,16 @@ def test_prompt_with_roasted_trigger():
59
59
  assert "/codereview-roasted" in prompt
60
60
 
61
61
 
62
+ def test_prompt_instructs_reading_repo_guidance():
63
+ """The reviewer must be told to read AGENTS.md (and other guideline docs)
64
+ to understand the repo before reviewing."""
65
+ prompt = _format_prompt(require_evidence=False)
66
+
67
+ assert "AGENTS.md" in prompt
68
+ assert "MUST read" in prompt
69
+ assert "CONTRIBUTING.md" in prompt
70
+
71
+
62
72
  def test_format_prompt_omits_evidence_requirements_by_default():
63
73
  prompt = _format_prompt(require_evidence=False)
64
74
 
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+
9
+ def _load(name: str, script_name: str):
10
+ script_path = (
11
+ Path(__file__).resolve().parents[1] / ".github" / "scripts" / script_name
12
+ )
13
+ spec = importlib.util.spec_from_file_location(name, script_path)
14
+ assert spec and spec.loader
15
+ mod = importlib.util.module_from_spec(spec)
16
+ sys.modules[name] = mod
17
+ spec.loader.exec_module(mod)
18
+ return mod
19
+
20
+
21
+ # Import check_pr_description first so refresh_linked_pr_checks can resolve its
22
+ # `from check_pr_description import ...` against the module we loaded above.
23
+ _load("check_pr_description", "check_pr_description.py")
24
+ _prod = _load("refresh_linked_pr_checks", "refresh_linked_pr_checks.py")
25
+
26
+
27
+ def _event(action="labeled", label="ready-for-dev", number=12):
28
+ return {
29
+ "action": action,
30
+ "issue": {"number": number},
31
+ "label": {"name": label},
32
+ "repository": {"full_name": "org/repo"},
33
+ }
34
+
35
+
36
+ def _write_event(monkeypatch, payload, tmp_path):
37
+ event_path = tmp_path / "event.json"
38
+ event_path.write_text(json.dumps(payload))
39
+ monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_path))
40
+
41
+
42
+ class _FakeProc:
43
+ def __init__(self, stdout: str = ""):
44
+ self.returncode = 0
45
+ self.stdout = stdout
46
+ self.stderr = ""
47
+
48
+
49
+ def _recording_run(calls):
50
+ def _call(args):
51
+ calls.append(args)
52
+ # Listing runs returns one run; rerun is a no-op success.
53
+ if any("/actions/runs" in a for a in args):
54
+ return _FakeProc("8675309 2026-08-25T00:00:00Z\n")
55
+ return _FakeProc()
56
+
57
+ return _call
58
+
59
+
60
+ def _fail_on_call(value):
61
+ def _call(*args, **kwargs):
62
+ raise AssertionError(value)
63
+
64
+ return _call
65
+
66
+
67
+ def test_noop_for_unrelated_label(monkeypatch, tmp_path):
68
+ _write_event(monkeypatch, _event(label="bug"), tmp_path)
69
+ monkeypatch.setattr(_prod, "_linked_open_prs", _fail_on_call("unexpected"))
70
+ assert _prod.main() == 0
71
+
72
+
73
+ def test_noop_for_unrelated_action(monkeypatch, tmp_path):
74
+ _write_event(monkeypatch, _event(action="edited"), tmp_path)
75
+ monkeypatch.setattr(_prod, "_linked_open_prs", _fail_on_call("unexpected"))
76
+ assert _prod.main() == 0
77
+
78
+
79
+ def test_reruns_linked_pr_check_when_readiness_label_changes(monkeypatch, tmp_path):
80
+ _write_event(monkeypatch, _event(), tmp_path)
81
+ monkeypatch.setattr(
82
+ _prod,
83
+ "_linked_open_prs",
84
+ lambda repo, num: [{"number": 7, "headRefOid": "abc123"}],
85
+ )
86
+ monkeypatch.setattr(_prod, "_run", lambda args: _FakeProc("Fixes #12"))
87
+ seen = []
88
+ monkeypatch.setattr(
89
+ _prod,
90
+ "_rerun_pr_description_check",
91
+ lambda repo, sha: (seen.append((repo, sha)) or True),
92
+ )
93
+ assert _prod.main() == 0
94
+ assert seen == [("org/repo", "abc123")]
95
+
96
+
97
+ def test_skips_cross_referenced_pr_that_does_not_link_issue(monkeypatch, tmp_path):
98
+ _write_event(monkeypatch, _event(), tmp_path)
99
+ monkeypatch.setattr(
100
+ _prod,
101
+ "_linked_open_prs",
102
+ lambda repo, num: [{"number": 7, "headRefOid": "abc123"}],
103
+ )
104
+ # Body mentions #99 (the cross-reference) but not the event's issue #12.
105
+ monkeypatch.setattr(_prod, "_run", lambda args: _FakeProc("Fixes #99"))
106
+ seen = []
107
+ monkeypatch.setattr(
108
+ _prod,
109
+ "_rerun_pr_description_check",
110
+ lambda repo, sha: (seen.append((repo, sha)) or True),
111
+ )
112
+ assert _prod.main() == 0
113
+ assert seen == []
114
+
115
+
116
+ def test_rerun_pr_description_check_lists_runs_with_get(monkeypatch):
117
+ calls: list[list[str]] = []
118
+ monkeypatch.setattr(_prod, "_run", _recording_run(calls))
119
+ assert _prod._rerun_pr_description_check("org/repo", "abc123") is True
120
+ # Listing runs must be an explicit GET: `-f` args alone switch `gh api`
121
+ # to POST, which 404s on the runs collection endpoint (seen in the wild).
122
+ list_call = next(a for a in calls if "repos/org/repo/actions/runs" in a)
123
+ assert "-X" in list_call
124
+ assert list_call[list_call.index("-X") + 1] == "GET"
125
+ # The selected run is then re-run via an explicit POST.
126
+ rerun_call = next(a for a in calls if any("/rerun" in arg for arg in a))
127
+ assert rerun_call[rerun_call.index("api") + 1 : rerun_call.index("api") + 3] == [
128
+ "-X",
129
+ "POST",
130
+ ]