@openhands/extensions 0.17.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 (76) 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 +13 -2
  12. package/automations/README.md +3 -2
  13. package/automations/bundle-index.js +10 -1
  14. package/automations/catalog/github-agents-md-maintainer/manifest.json +101 -0
  15. package/automations/catalog/github-issue-to-pr/manifest.json +112 -0
  16. package/automations/catalog/github-pr-reviewer/manifest.json +6 -0
  17. package/automations/catalog/github-repo-monitor/manifest.json +6 -0
  18. package/automations/catalog/jira-issue-to-bitbucket-pr/manifest.json +23 -0
  19. package/automations/catalog/jira-issue-to-gitlab-mr/manifest.json +23 -0
  20. package/automations/catalog/linear-issue-to-bitbucket-pr/manifest.json +23 -0
  21. package/automations/catalog/linear-issue-to-github-pr/manifest.json +23 -0
  22. package/automations/catalog/linear-issue-to-gitlab-mr/manifest.json +23 -0
  23. package/automations/catalog/news-digest/manifest.json +81 -0
  24. package/automations/catalog/qa-changes/manifest.json +72 -0
  25. package/automations/catalog/slack-github-linear-daily-organization.json +27 -0
  26. package/automations/catalog-index.js +25 -7
  27. package/automations/catalog.schema.json +35 -2
  28. package/automations/index.d.ts +34 -1
  29. package/automations/interface.json +3 -1
  30. package/integrations/catalog/datadog.json +2 -2
  31. package/integrations/catalog/gitlab.json +41 -0
  32. package/integrations/catalog/notion.json +1 -1
  33. package/integrations/catalog-index.js +71 -69
  34. package/integrations/index.d.ts +1 -1
  35. package/marketplaces/openhands-extensions.json +59 -6
  36. package/package.json +1 -1
  37. package/plugins/pr-review/scripts/prompt.py +2 -0
  38. package/pyproject.toml +1 -1
  39. package/python/openhands_extensions/_version.py +1 -1
  40. package/skills/github-agents-md-maintainer/.plugin/plugin.json +19 -0
  41. package/skills/github-agents-md-maintainer/README.md +59 -0
  42. package/skills/github-agents-md-maintainer/SKILL.md +271 -0
  43. package/skills/github-agents-md-maintainer/commands/agents-md-setup.md +8 -0
  44. package/skills/github-agents-md-maintainer/references/state-schema.md +113 -0
  45. package/skills/github-agents-md-maintainer/scripts/main.py +1187 -0
  46. package/skills/github-issue-to-pr/.plugin/plugin.json +19 -0
  47. package/skills/github-issue-to-pr/README.md +84 -0
  48. package/skills/github-issue-to-pr/SKILL.md +373 -0
  49. package/skills/github-issue-to-pr/commands/issue-to-pr-setup.md +8 -0
  50. package/skills/github-issue-to-pr/references/state-schema.md +139 -0
  51. package/skills/github-issue-to-pr/scripts/main.py +1305 -0
  52. package/skills/github-pr-reviewer/SKILL.md +2 -1
  53. package/skills/github-pr-reviewer/scripts/main.py +45 -4
  54. package/skills/github-pr-reviewer/tests/test_main.py +74 -0
  55. package/skills/index.js +38 -2
  56. package/skills/news-digest/.plugin/plugin.json +19 -0
  57. package/skills/news-digest/README.md +71 -0
  58. package/skills/news-digest/SKILL.md +284 -0
  59. package/skills/news-digest/commands/news-digest-setup.md +8 -0
  60. package/skills/news-digest/references/state-schema.md +178 -0
  61. package/skills/news-digest/scripts/main.py +1250 -0
  62. package/skills/openhands-sdk/SKILL.md +1 -0
  63. package/skills/ticket-to-code-change/.plugin/plugin.json +21 -0
  64. package/skills/ticket-to-code-change/README.md +5 -0
  65. package/skills/ticket-to-code-change/SKILL.md +50 -0
  66. package/skills/ticket-to-code-change/commands/ticket-to-code-change-setup.md +8 -0
  67. package/tests/fixtures/automations/github-repo-monitor.json +77 -0
  68. package/tests/test_automation_setup.py +56 -0
  69. package/tests/test_catalogs.py +5 -3
  70. package/tests/test_check_issue_readiness.py +135 -0
  71. package/tests/test_check_pr_description.py +228 -0
  72. package/tests/test_github_agents_md_maintainer.py +352 -0
  73. package/tests/test_github_issue_to_pr.py +539 -0
  74. package/tests/test_news_digest.py +646 -0
  75. package/tests/test_pr_review_prompt.py +10 -0
  76. 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())