@openhands/extensions 0.18.0 → 0.20.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/.pr/custom-automation-event-trigger.png +0 -0
- package/.pr/custom-automation-plugin-fields.png +0 -0
- package/.pr/custom-automation-prompt-review.png +0 -0
- package/.pr/custom-automation-prompt-scheduled.png +0 -0
- package/.pr/custom-automation-upload-fields.png +0 -0
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +17 -1
- package/README.md +10 -2
- package/automations/README.md +52 -36
- package/automations/bundle-index.js +1 -1
- package/automations/catalog/custom-automation/manifest.json +229 -0
- package/automations/catalog/github-agents-md-maintainer/manifest.json +5 -0
- package/automations/catalog/github-issue-to-pr/manifest.json +6 -1
- package/automations/catalog/github-pr-reviewer/manifest.json +6 -1
- 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 +25 -11
- package/automations/catalog.schema.json +106 -7
- package/automations/index.d.ts +68 -2
- package/automations/interface.json +2 -1
- package/integrations/catalog/datadog.json +2 -2
- package/integrations/catalog/gitlab.json +63 -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/fixtures/automations/custom-automation.json +398 -0
- package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
- package/tests/test_automation_setup.py +362 -92
- 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
|
@@ -7,6 +7,13 @@ triggers:
|
|
|
7
7
|
|
|
8
8
|
# Extensions Repo — Code Review Guidelines
|
|
9
9
|
|
|
10
|
+
## Repository Boundaries
|
|
11
|
+
|
|
12
|
+
Review whether a PR belongs in this public extensions registry. Skills, plugins, automations, and integrations belong here; Agent Server behavior and API endpoints belong in [`OpenHands/software-agent-sdk`](https://github.com/OpenHands/software-agent-sdk), typed browser API access belongs in [`OpenHands/typescript-client`](https://github.com/OpenHands/typescript-client), UI belongs in [`OpenHands/OpenHands`](https://github.com/OpenHands/OpenHands), and scheduling/dispatch lifecycle behavior belongs in [`OpenHands/automation`](https://github.com/OpenHands/automation).
|
|
13
|
+
|
|
14
|
+
If a PR is opened in the wrong repository, explicitly recommend that it may need to be closed and moved to the repository that owns the change rather than merged here. Apply the repository's contribution and review guidance to every PR.
|
|
15
|
+
|
|
16
|
+
|
|
10
17
|
## SDK Documentation Placement
|
|
11
18
|
|
|
12
19
|
If a PR adds or modifies OpenHands SDK-specific documentation (API guides, SDK usage examples, SDK feature descriptions), flag it:
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Keep dependency update scheduling centralized in this repository configuration.
|
|
2
|
+
version: 2
|
|
3
|
+
updates:
|
|
4
|
+
- package-ecosystem: github-actions
|
|
5
|
+
directory: /
|
|
6
|
+
schedule:
|
|
7
|
+
interval: weekly
|
|
8
|
+
commit-message:
|
|
9
|
+
prefix: ci(deps)
|
|
10
|
+
|
|
11
|
+
- package-ecosystem: npm
|
|
12
|
+
directory: /
|
|
13
|
+
schedule:
|
|
14
|
+
interval: weekly
|
|
15
|
+
commit-message:
|
|
16
|
+
prefix: chore(deps)
|
|
17
|
+
include: scope
|
|
18
|
+
|
|
19
|
+
- package-ecosystem: pip
|
|
20
|
+
directory: /
|
|
21
|
+
schedule:
|
|
22
|
+
interval: weekly
|
|
23
|
+
commit-message:
|
|
24
|
+
prefix: chore(deps)
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Determine whether an issue meets the `ready-for-dev` readiness criteria.
|
|
2
|
+
|
|
3
|
+
The criteria are type-specific and tailored to extension contributions
|
|
4
|
+
(skills, plugins, integrations, and automations):
|
|
5
|
+
|
|
6
|
+
- Bug reports (labeled `bug`): the Actual Behavior section must describe a
|
|
7
|
+
reproducible run and include a supported validation command (`uv`,
|
|
8
|
+
`pytest`, `python`, `pip`, `npm`, or `node`), plus a non-empty Acceptance
|
|
9
|
+
Criteria section with at least one checklist item.
|
|
10
|
+
|
|
11
|
+
- Enhancements (labeled `enhancement`): the body must contain non-empty
|
|
12
|
+
Desired Behavior and Acceptance Criteria sections, the latter with at
|
|
13
|
+
least one checklist item.
|
|
14
|
+
|
|
15
|
+
GitHub issue forms render each field as an `### <Label>` (h3) heading followed
|
|
16
|
+
by the field text, with empty optional fields rendered as `_No response_`. This
|
|
17
|
+
parser splits the body on those headings so each criterion is checked against
|
|
18
|
+
the right field rather than the whole body.
|
|
19
|
+
|
|
20
|
+
Local usage:
|
|
21
|
+
|
|
22
|
+
python .github/scripts/check_issue_readiness.py --body-file /tmp/issue.md \
|
|
23
|
+
--labels bug
|
|
24
|
+
python .github/scripts/check_issue_readiness.py --event-path "$GITHUB_EVENT_PATH"
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import sys
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
BUG_LABEL = "bug"
|
|
39
|
+
ENHANCEMENT_LABEL = "enhancement"
|
|
40
|
+
|
|
41
|
+
# Issue-form fields render as `### Label` h3 headings. Match case-insensitively
|
|
42
|
+
# and tolerate trailing whitespace/colons. `^###\s+` is specific enough because
|
|
43
|
+
# h1/h2 are not produced by issue forms.
|
|
44
|
+
HEADING_RE = re.compile(r"(?m)^###\s+(.+?)\s*$")
|
|
45
|
+
|
|
46
|
+
# `_No response_` is what GitHub writes for an empty optional form field.
|
|
47
|
+
NO_RESPONSE = "_No response_"
|
|
48
|
+
|
|
49
|
+
# A reproducible command must appear in the Actual Behavior section. These are
|
|
50
|
+
# the commands a contributor can run in this repository: the Python test
|
|
51
|
+
# tooling (`uv run pytest`, `pytest`, `python`, `pip`) and the catalog build /
|
|
52
|
+
# validation tooling (`npm run build:*`, `node`).
|
|
53
|
+
RUN_METHOD_PATTERNS: tuple[re.Pattern[str], ...] = (
|
|
54
|
+
re.compile(r"\bpython\b", re.IGNORECASE),
|
|
55
|
+
re.compile(r"\bpytest\b", re.IGNORECASE),
|
|
56
|
+
re.compile(r"\buv\b", re.IGNORECASE),
|
|
57
|
+
re.compile(r"\bpip\b", re.IGNORECASE),
|
|
58
|
+
re.compile(r"\bnpm\b", re.IGNORECASE),
|
|
59
|
+
re.compile(r"\bnode\b", re.IGNORECASE),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# An Acceptance Criteria item is a markdown checklist bullet (`- [ ]` or
|
|
63
|
+
# `- [x]`). We require at least one so the section is verifiable.
|
|
64
|
+
CHECKLIST_ITEM_RE = re.compile(r"(?m)^\s*[-*]\s*\[[ xX]\]")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class ReadinessResult:
|
|
69
|
+
"""Outcome of a readiness check."""
|
|
70
|
+
|
|
71
|
+
ready: bool
|
|
72
|
+
reasons: list[str] = field(default_factory=list)
|
|
73
|
+
|
|
74
|
+
def add(self, reason: str) -> None:
|
|
75
|
+
self.reasons.append(reason)
|
|
76
|
+
self.ready = False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def visible_text(text: str) -> str:
|
|
80
|
+
"""Return field text with HTML comments stripped and emptiness normalized."""
|
|
81
|
+
cleaned = re.sub(r"<!--[\s\S]*?-->", "", text).strip()
|
|
82
|
+
if cleaned == NO_RESPONSE:
|
|
83
|
+
return ""
|
|
84
|
+
return cleaned
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def extract_sections(body: str) -> dict[str, str]:
|
|
88
|
+
"""Split the body into a {heading: text} map using `### <heading>` boundaries.
|
|
89
|
+
|
|
90
|
+
Issue forms render every field this way. Free-form issues (not created via a
|
|
91
|
+
form) may still use `###` headings; if they don't, the map is empty and the
|
|
92
|
+
caller falls back to whole-body checks.
|
|
93
|
+
"""
|
|
94
|
+
matches = list(HEADING_RE.finditer(body))
|
|
95
|
+
sections: dict[str, str] = {}
|
|
96
|
+
for index, match in enumerate(matches):
|
|
97
|
+
start = match.end()
|
|
98
|
+
end = matches[index + 1].start() if index + 1 < len(matches) else len(body)
|
|
99
|
+
sections[match.group(1).strip().lower()] = body[start:end]
|
|
100
|
+
return sections
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def find_section(sections: dict[str, str], *labels: str) -> str:
|
|
104
|
+
"""Return the first matching section text by case-insensitive label."""
|
|
105
|
+
for label in labels:
|
|
106
|
+
if label in sections:
|
|
107
|
+
return sections[label]
|
|
108
|
+
return ""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def references_run_method(text: str) -> bool:
|
|
112
|
+
return any(pattern.search(text) for pattern in RUN_METHOD_PATTERNS)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def has_checklist_item(text: str) -> bool:
|
|
116
|
+
return bool(CHECKLIST_ITEM_RE.search(text))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def check_bug(sections: dict[str, str]) -> ReadinessResult:
|
|
120
|
+
result = ReadinessResult(ready=True)
|
|
121
|
+
|
|
122
|
+
actual = visible_text(find_section(sections, "actual behavior", "actual"))
|
|
123
|
+
if not actual:
|
|
124
|
+
result.add(
|
|
125
|
+
"Fill in the `### Actual Behavior` section with reproducible steps "
|
|
126
|
+
"and the observed result."
|
|
127
|
+
)
|
|
128
|
+
elif not references_run_method(actual):
|
|
129
|
+
result.add(
|
|
130
|
+
"The Actual Behavior section must include a reproducible command "
|
|
131
|
+
"such as `uv run pytest`, `pytest`, `python`, `pip`, `npm run`, "
|
|
132
|
+
"or `node`."
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
acceptance = visible_text(
|
|
136
|
+
find_section(sections, "acceptance criteria", "acceptance")
|
|
137
|
+
)
|
|
138
|
+
if not acceptance:
|
|
139
|
+
result.add(
|
|
140
|
+
"Add an `### Acceptance Criteria` section with testable checklist items."
|
|
141
|
+
)
|
|
142
|
+
elif not has_checklist_item(acceptance):
|
|
143
|
+
result.add(
|
|
144
|
+
"The Acceptance Criteria section must contain at least one checklist item "
|
|
145
|
+
"(`- [ ] ...`)."
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return result
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def check_enhancement(sections: dict[str, str]) -> ReadinessResult:
|
|
152
|
+
result = ReadinessResult(ready=True)
|
|
153
|
+
|
|
154
|
+
desired = visible_text(find_section(sections, "desired behavior", "desired"))
|
|
155
|
+
if not desired:
|
|
156
|
+
result.add(
|
|
157
|
+
"Add a `### Desired Behavior` section describing the behavior you want."
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
acceptance = visible_text(
|
|
161
|
+
find_section(sections, "acceptance criteria", "acceptance")
|
|
162
|
+
)
|
|
163
|
+
if not acceptance:
|
|
164
|
+
result.add(
|
|
165
|
+
"Add an `### Acceptance Criteria` section with testable checklist items."
|
|
166
|
+
)
|
|
167
|
+
elif not has_checklist_item(acceptance):
|
|
168
|
+
result.add(
|
|
169
|
+
"The Acceptance Criteria section must contain at least one checklist item "
|
|
170
|
+
"(`- [ ] ...`)."
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
return result
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def evaluate_readiness(body: str, labels: list[str]) -> ReadinessResult:
|
|
177
|
+
"""Return the readiness result for an issue body + label set.
|
|
178
|
+
|
|
179
|
+
An issue is only a candidate when it carries the `bug` or `enhancement`
|
|
180
|
+
label. If it has neither, it is treated as not-ready-for-dev (the gate does
|
|
181
|
+
not apply a label it cannot validate).
|
|
182
|
+
"""
|
|
183
|
+
label_set = {label.lower() for label in labels}
|
|
184
|
+
sections = extract_sections(body or "")
|
|
185
|
+
|
|
186
|
+
if BUG_LABEL in label_set:
|
|
187
|
+
return check_bug(sections)
|
|
188
|
+
if ENHANCEMENT_LABEL in label_set:
|
|
189
|
+
return check_enhancement(sections)
|
|
190
|
+
|
|
191
|
+
return ReadinessResult(
|
|
192
|
+
ready=False,
|
|
193
|
+
reasons=[
|
|
194
|
+
"The issue has neither the `bug` nor `enhancement` label, so its "
|
|
195
|
+
"readiness criteria cannot be evaluated. Add the appropriate label."
|
|
196
|
+
],
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def body_and_labels_from_event(event_path: Path) -> tuple[str, list[str]]:
|
|
201
|
+
payload = json.loads(event_path.read_text())
|
|
202
|
+
issue = payload.get("issue") or payload.get("pull_request")
|
|
203
|
+
if not isinstance(issue, dict):
|
|
204
|
+
raise ValueError("GitHub event payload does not contain an issue object")
|
|
205
|
+
body = issue.get("body")
|
|
206
|
+
body = body if isinstance(body, str) else ""
|
|
207
|
+
labels = [
|
|
208
|
+
label["name"] for label in issue.get("labels", []) if isinstance(label, dict)
|
|
209
|
+
]
|
|
210
|
+
return body, labels
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def parse_args() -> argparse.Namespace:
|
|
214
|
+
parser = argparse.ArgumentParser(
|
|
215
|
+
description="Evaluate whether an issue meets the ready-for-dev criteria."
|
|
216
|
+
)
|
|
217
|
+
parser.add_argument(
|
|
218
|
+
"--body-file", type=Path, help="Read the issue body from a file."
|
|
219
|
+
)
|
|
220
|
+
parser.add_argument(
|
|
221
|
+
"--labels",
|
|
222
|
+
help="Comma-separated issue labels (e.g. 'bug,skills').",
|
|
223
|
+
default="",
|
|
224
|
+
)
|
|
225
|
+
parser.add_argument(
|
|
226
|
+
"--event-path",
|
|
227
|
+
type=Path,
|
|
228
|
+
default=Path(os.environ["GITHUB_EVENT_PATH"])
|
|
229
|
+
if "GITHUB_EVENT_PATH" in os.environ
|
|
230
|
+
else None,
|
|
231
|
+
help="Read body and labels from a GitHub event payload.",
|
|
232
|
+
)
|
|
233
|
+
parser.add_argument(
|
|
234
|
+
"--json",
|
|
235
|
+
action="store_true",
|
|
236
|
+
help="Emit a JSON result instead of human-readable text.",
|
|
237
|
+
)
|
|
238
|
+
return parser.parse_args()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def main() -> int:
|
|
242
|
+
args = parse_args()
|
|
243
|
+
|
|
244
|
+
if args.body_file is not None:
|
|
245
|
+
body = args.body_file.read_text()
|
|
246
|
+
labels = [label.strip() for label in args.labels.split(",") if label.strip()]
|
|
247
|
+
elif args.event_path is not None:
|
|
248
|
+
body, labels = body_and_labels_from_event(args.event_path)
|
|
249
|
+
else:
|
|
250
|
+
raise SystemExit("Pass --body-file or set GITHUB_EVENT_PATH.")
|
|
251
|
+
|
|
252
|
+
result = evaluate_readiness(body, labels)
|
|
253
|
+
|
|
254
|
+
if args.json:
|
|
255
|
+
print(json.dumps({"ready": result.ready, "reasons": result.reasons}))
|
|
256
|
+
# In --json mode the exit code is not meaningful: the result is consumed
|
|
257
|
+
# via the printed JSON, and the workflow must run to completion for both
|
|
258
|
+
# ready and not-ready issues (label add/remove, feedback comment).
|
|
259
|
+
return 0
|
|
260
|
+
|
|
261
|
+
if result.ready:
|
|
262
|
+
print("Issue meets ready-for-dev criteria.")
|
|
263
|
+
else:
|
|
264
|
+
print("Issue does not meet ready-for-dev criteria:")
|
|
265
|
+
for reason in result.reasons:
|
|
266
|
+
print(f" - {reason}")
|
|
267
|
+
|
|
268
|
+
return 0 if result.ready else 1
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
if __name__ == "__main__":
|
|
272
|
+
sys.exit(main())
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Validate a pull request description against the repository PR template.
|
|
2
|
+
|
|
3
|
+
Two gates:
|
|
4
|
+
|
|
5
|
+
1. Template sections: the `## Why`, `## Summary`, and `## How to Test`
|
|
6
|
+
sections from `.github/pull_request_template.md` must be present and
|
|
7
|
+
filled in.
|
|
8
|
+
|
|
9
|
+
2. Linked-issue readiness: every issue linked via a closing keyword
|
|
10
|
+
(`Fixes #123`, `Closes #123`, `Resolves #123`) or referenced in the
|
|
11
|
+
`## Issue Number` section must exist and, unless it predates the
|
|
12
|
+
`ready-for-dev` rollout, carry the `ready-for-dev` label. This blocks
|
|
13
|
+
PRs until their linked issue meets the readiness criteria.
|
|
14
|
+
|
|
15
|
+
Local usage:
|
|
16
|
+
|
|
17
|
+
python .github/scripts/check_pr_description.py --body-file /tmp/pr.md
|
|
18
|
+
GITHUB_TOKEN=... python .github/scripts/check_pr_description.py \
|
|
19
|
+
--event-path "$GITHUB_EVENT_PATH"
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
import urllib.error
|
|
30
|
+
import urllib.request
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# These are the only PR-template sections that must remain and contain content.
|
|
35
|
+
REQUIRED_TEMPLATE_FIELDS: tuple[str, ...] = ("Why", "Summary", "How to Test")
|
|
36
|
+
|
|
37
|
+
HTML_COMMENT_RE = re.compile(r"<!--[\s\S]*?-->")
|
|
38
|
+
HEADING_RE = re.compile(r"(?m)^##\s+(.+?)\s*$")
|
|
39
|
+
ISSUE_REF_RE = re.compile(r"(?i)\b(?:fix|clos|resolv)(?:e?(?:s|d)?|ing)?\s+#(\d+)")
|
|
40
|
+
BARE_ISSUE_REF_RE = re.compile(r"(?<!\w)#(\d+)")
|
|
41
|
+
READY_FOR_DEV_LABEL = "ready-for-dev"
|
|
42
|
+
# Issues created before the `ready-for-dev` rollout are grandfathered: the
|
|
43
|
+
# issue-readiness workflow only labels issues on `issues` events, so long-open
|
|
44
|
+
# issues were never evaluated. Requiring the label retroactively would block
|
|
45
|
+
# existing PRs linked to those issues. The cutoff is the UTC day AFTER the
|
|
46
|
+
# rollout/deployment day (2026-08-24), so every issue predating deployment -
|
|
47
|
+
# including ones opened earlier that same day, before the workflow existed -
|
|
48
|
+
# is exempt. Issues created on or after 2026-08-25 must carry the label.
|
|
49
|
+
READY_FOR_DEV_ROLLOUT_ISO = "2026-08-25"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def visible_text(text: str) -> str:
|
|
53
|
+
"""Return PR body content that should count as author-provided text."""
|
|
54
|
+
lines = []
|
|
55
|
+
for line in HTML_COMMENT_RE.sub("", text).splitlines():
|
|
56
|
+
stripped = line.strip()
|
|
57
|
+
if stripped and stripped != "-":
|
|
58
|
+
lines.append(stripped)
|
|
59
|
+
return "\n".join(lines).strip()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def extract_sections(body: str) -> dict[str, str]:
|
|
63
|
+
matches = list(HEADING_RE.finditer(body))
|
|
64
|
+
sections: dict[str, str] = {}
|
|
65
|
+
for index, match in enumerate(matches):
|
|
66
|
+
start = match.end()
|
|
67
|
+
end = matches[index + 1].start() if index + 1 < len(matches) else len(body)
|
|
68
|
+
sections[match.group(1).strip()] = body[start:end]
|
|
69
|
+
return sections
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def extract_linked_issue_numbers(body: str) -> list[int]:
|
|
73
|
+
numbers: list[int] = []
|
|
74
|
+
seen: set[int] = set()
|
|
75
|
+
for match in ISSUE_REF_RE.finditer(body):
|
|
76
|
+
number = int(match.group(1))
|
|
77
|
+
if number not in seen:
|
|
78
|
+
numbers.append(number)
|
|
79
|
+
seen.add(number)
|
|
80
|
+
|
|
81
|
+
sections = extract_sections(body)
|
|
82
|
+
issue_section = sections.get("Issue Number", "")
|
|
83
|
+
for match in BARE_ISSUE_REF_RE.finditer(issue_section):
|
|
84
|
+
number = int(match.group(1))
|
|
85
|
+
if number not in seen:
|
|
86
|
+
numbers.append(number)
|
|
87
|
+
seen.add(number)
|
|
88
|
+
return numbers
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def fetch_issue_details(
|
|
92
|
+
repo: str, issue_number: int, token: str
|
|
93
|
+
) -> tuple[list[str], str]:
|
|
94
|
+
"""Return an issue's (labels, created_at) from the GitHub API."""
|
|
95
|
+
request = urllib.request.Request(
|
|
96
|
+
f"https://api.github.com/repos/{repo}/issues/{issue_number}",
|
|
97
|
+
headers={
|
|
98
|
+
"Authorization": f"Bearer {token}",
|
|
99
|
+
"Accept": "application/vnd.github+json",
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
with urllib.request.urlopen(request, timeout=10) as response: # noqa: S310 - trusted HTTPS API
|
|
103
|
+
issue = json.loads(response.read().decode())
|
|
104
|
+
labels = [
|
|
105
|
+
label["name"] for label in issue.get("labels", []) if isinstance(label, dict)
|
|
106
|
+
]
|
|
107
|
+
created_at = issue.get("created_at", "")
|
|
108
|
+
return labels, created_at
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def validate_linked_issue_ready(
|
|
112
|
+
body: str, repo: str | None = None, token: str | None = None
|
|
113
|
+
) -> list[str]:
|
|
114
|
+
numbers = extract_linked_issue_numbers(body)
|
|
115
|
+
if not numbers:
|
|
116
|
+
return [
|
|
117
|
+
"Link an issue in the `## Issue Number` section (e.g. `Fixes #123`). "
|
|
118
|
+
"Newly opened issues must carry the `ready-for-dev` label."
|
|
119
|
+
]
|
|
120
|
+
if not repo or not token:
|
|
121
|
+
return []
|
|
122
|
+
|
|
123
|
+
checked: list[int] = []
|
|
124
|
+
not_ready_new: list[int] = []
|
|
125
|
+
for number in numbers:
|
|
126
|
+
try:
|
|
127
|
+
labels, created_at = fetch_issue_details(repo, number, token)
|
|
128
|
+
except urllib.error.HTTPError as exc:
|
|
129
|
+
if exc.code == 404:
|
|
130
|
+
continue
|
|
131
|
+
raise
|
|
132
|
+
checked.append(number)
|
|
133
|
+
if READY_FOR_DEV_LABEL in (label.lower() for label in labels):
|
|
134
|
+
continue
|
|
135
|
+
if created_at[:10] < READY_FOR_DEV_ROLLOUT_ISO:
|
|
136
|
+
# Predates the rollout; grandfathered to avoid retroactive blocking.
|
|
137
|
+
continue
|
|
138
|
+
not_ready_new.append(number)
|
|
139
|
+
|
|
140
|
+
if not checked:
|
|
141
|
+
refs = ", ".join(f"#{number}" for number in numbers)
|
|
142
|
+
return [f"Referenced issue(s) {refs} could not be found in this repository."]
|
|
143
|
+
if not_ready_new:
|
|
144
|
+
refs = ", ".join(f"#{number}" for number in not_ready_new)
|
|
145
|
+
return [
|
|
146
|
+
f"Linked issue(s) ({refs}) carry neither `ready-for-dev` nor a "
|
|
147
|
+
"pre-rollout creation date. Newly referenced issues must meet the "
|
|
148
|
+
"readiness criteria before a PR can be opened."
|
|
149
|
+
]
|
|
150
|
+
return []
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def validate_pr_body(body: str) -> list[str]:
|
|
154
|
+
errors: list[str] = []
|
|
155
|
+
|
|
156
|
+
sections = extract_sections(body)
|
|
157
|
+
for section in REQUIRED_TEMPLATE_FIELDS:
|
|
158
|
+
if section not in sections:
|
|
159
|
+
errors.append(f"Keep the `## {section}` section from the PR template.")
|
|
160
|
+
elif not visible_text(sections[section]):
|
|
161
|
+
errors.append(f"Fill in the `## {section}` section of the PR template.")
|
|
162
|
+
|
|
163
|
+
return errors
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def body_from_event(event_path: Path) -> tuple[str, str | None]:
|
|
167
|
+
"""Return the (pull request body, repository full name) from an event payload."""
|
|
168
|
+
payload = json.loads(event_path.read_text())
|
|
169
|
+
pull_request = payload.get("pull_request")
|
|
170
|
+
if not isinstance(pull_request, dict):
|
|
171
|
+
raise ValueError("GitHub event payload does not contain a pull_request object")
|
|
172
|
+
body = pull_request.get("body")
|
|
173
|
+
body = body if isinstance(body, str) else ""
|
|
174
|
+
repo = payload.get("repository", {}).get("full_name")
|
|
175
|
+
return body, repo if isinstance(repo, str) else None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def parse_args() -> argparse.Namespace:
|
|
179
|
+
parser = argparse.ArgumentParser(
|
|
180
|
+
description=(
|
|
181
|
+
"Validate pull request description readiness from --body-file "
|
|
182
|
+
"or a GitHub event payload."
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
parser.add_argument(
|
|
186
|
+
"--body-file", type=Path, help="Read a PR description body from a file."
|
|
187
|
+
)
|
|
188
|
+
parser.add_argument(
|
|
189
|
+
"--event-path",
|
|
190
|
+
type=Path,
|
|
191
|
+
default=Path(os.environ["GITHUB_EVENT_PATH"])
|
|
192
|
+
if "GITHUB_EVENT_PATH" in os.environ
|
|
193
|
+
else None,
|
|
194
|
+
help="Read the PR description body from a GitHub event payload.",
|
|
195
|
+
)
|
|
196
|
+
return parser.parse_args()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def main() -> int:
|
|
200
|
+
args = parse_args()
|
|
201
|
+
if args.body_file is not None:
|
|
202
|
+
body = args.body_file.read_text()
|
|
203
|
+
repo = None
|
|
204
|
+
elif args.event_path is not None:
|
|
205
|
+
body, repo = body_from_event(args.event_path)
|
|
206
|
+
else:
|
|
207
|
+
raise SystemExit("Pass --body-file or set GITHUB_EVENT_PATH.")
|
|
208
|
+
|
|
209
|
+
errors = validate_pr_body(body)
|
|
210
|
+
|
|
211
|
+
token = os.environ.get("GITHUB_TOKEN")
|
|
212
|
+
errors.extend(validate_linked_issue_ready(body, repo, token))
|
|
213
|
+
|
|
214
|
+
for error in errors:
|
|
215
|
+
print(f"::error::{error}")
|
|
216
|
+
|
|
217
|
+
if errors:
|
|
218
|
+
print(f"PR description validation failed with {len(errors)} error(s).")
|
|
219
|
+
return 1
|
|
220
|
+
|
|
221
|
+
print("PR description validation passed.")
|
|
222
|
+
return 0
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
if __name__ == "__main__":
|
|
226
|
+
sys.exit(main())
|