@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.
- package/.agents/skills/custom-codereview-guide.md +7 -0
- package/.github/dependabot.yml +24 -0
- package/.github/scripts/check_issue_readiness.py +272 -0
- package/.github/scripts/check_pr_description.py +226 -0
- package/.github/scripts/post-readiness-comment.mjs +184 -0
- package/.github/scripts/refresh_linked_pr_checks.py +171 -0
- package/.github/workflows/issue-readiness-check.yml +142 -0
- package/.github/workflows/pr-description-check.yml +37 -0
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +16 -0
- package/README.md +10 -2
- package/automations/bundle-index.js +1 -1
- package/automations/catalog/github-agents-md-maintainer/manifest.json +5 -0
- package/automations/catalog/github-issue-to-pr/manifest.json +5 -0
- package/automations/catalog/github-pr-reviewer/manifest.json +5 -0
- package/automations/catalog/github-repo-monitor/manifest.json +5 -0
- package/automations/catalog/jira-issue-to-bitbucket-pr/manifest.json +23 -0
- package/automations/catalog/jira-issue-to-gitlab-mr/manifest.json +23 -0
- package/automations/catalog/linear-issue-to-bitbucket-pr/manifest.json +23 -0
- package/automations/catalog/linear-issue-to-github-pr/manifest.json +23 -0
- package/automations/catalog/linear-issue-to-gitlab-mr/manifest.json +23 -0
- package/automations/catalog/news-digest/manifest.json +5 -0
- package/automations/catalog/qa-changes/manifest.json +72 -0
- package/automations/catalog/slack-github-linear-daily-organization.json +27 -0
- package/automations/catalog-index.js +18 -6
- package/automations/catalog.schema.json +25 -0
- package/automations/index.d.ts +16 -0
- package/integrations/catalog/datadog.json +2 -2
- package/integrations/catalog/gitlab.json +41 -0
- package/integrations/catalog/notion.json +1 -1
- package/integrations/catalog-index.js +71 -69
- package/integrations/index.d.ts +1 -1
- package/marketplaces/openhands-extensions.json +21 -6
- package/package.json +1 -1
- package/plugins/pr-review/scripts/prompt.py +2 -0
- package/pyproject.toml +1 -1
- package/python/openhands_extensions/_version.py +1 -1
- package/skills/github-pr-reviewer/SKILL.md +2 -1
- package/skills/github-pr-reviewer/scripts/main.py +45 -4
- package/skills/github-pr-reviewer/tests/test_main.py +74 -0
- package/skills/index.js +11 -2
- package/skills/openhands-sdk/SKILL.md +1 -0
- package/skills/ticket-to-code-change/.plugin/plugin.json +21 -0
- package/skills/ticket-to-code-change/README.md +5 -0
- package/skills/ticket-to-code-change/SKILL.md +50 -0
- package/skills/ticket-to-code-change/commands/ticket-to-code-change-setup.md +8 -0
- package/tests/test_automation_setup.py +22 -0
- package/tests/test_catalogs.py +2 -2
- package/tests/test_check_issue_readiness.py +135 -0
- package/tests/test_check_pr_description.py +228 -0
- package/tests/test_pr_review_prompt.py +10 -0
- 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
|
+
]
|