Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
272 changes: 272 additions & 0 deletions .github/scripts/check_issue_readiness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
"""Determine whether an issue meets the `ready-for-dev` readiness criteria.

The criteria are type-specific and tailored to extension contributions
(skills, plugins, integrations, and automations):

- Bug reports (labeled `bug`): the Actual Behavior section must describe a
reproducible run and include a supported validation command (`uv`,
`pytest`, `python`, `pip`, `npm`, or `node`), plus a non-empty Acceptance
Criteria section with at least one checklist item.

- Enhancements (labeled `enhancement`): the body must contain non-empty
Desired Behavior and Acceptance Criteria sections, the latter with at
least one checklist item.

GitHub issue forms render each field as an `### <Label>` (h3) heading followed
by the field text, with empty optional fields rendered as `_No response_`. This
parser splits the body on those headings so each criterion is checked against
the right field rather than the whole body.

Local usage:

python .github/scripts/check_issue_readiness.py --body-file /tmp/issue.md \
--labels bug
python .github/scripts/check_issue_readiness.py --event-path "$GITHUB_EVENT_PATH"
"""

from __future__ import annotations

import argparse
import json
import os
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path


BUG_LABEL = "bug"
ENHANCEMENT_LABEL = "enhancement"

# Issue-form fields render as `### Label` h3 headings. Match case-insensitively
# and tolerate trailing whitespace/colons. `^###\s+` is specific enough because
# h1/h2 are not produced by issue forms.
HEADING_RE = re.compile(r"(?m)^###\s+(.+?)\s*$")

# `_No response_` is what GitHub writes for an empty optional form field.
NO_RESPONSE = "_No response_"

# A reproducible command must appear in the Actual Behavior section. These are
# the commands a contributor can run in this repository: the Python test
# tooling (`uv run pytest`, `pytest`, `python`, `pip`) and the catalog build /
# validation tooling (`npm run build:*`, `node`).
RUN_METHOD_PATTERNS: tuple[re.Pattern[str], ...] = (
re.compile(r"\bpython\b", re.IGNORECASE),
re.compile(r"\bpytest\b", re.IGNORECASE),
re.compile(r"\buv\b", re.IGNORECASE),
re.compile(r"\bpip\b", re.IGNORECASE),
re.compile(r"\bnpm\b", re.IGNORECASE),
re.compile(r"\bnode\b", re.IGNORECASE),
)

# An Acceptance Criteria item is a markdown checklist bullet (`- [ ]` or
# `- [x]`). We require at least one so the section is verifiable.
CHECKLIST_ITEM_RE = re.compile(r"(?m)^\s*[-*]\s*\[[ xX]\]")


@dataclass
class ReadinessResult:
"""Outcome of a readiness check."""

ready: bool
reasons: list[str] = field(default_factory=list)

def add(self, reason: str) -> None:
self.reasons.append(reason)
self.ready = False


def visible_text(text: str) -> str:
"""Return field text with HTML comments stripped and emptiness normalized."""
cleaned = re.sub(r"<!--[\s\S]*?-->", "", text).strip()
if cleaned == NO_RESPONSE:
return ""
return cleaned


def extract_sections(body: str) -> dict[str, str]:
"""Split the body into a {heading: text} map using `### <heading>` boundaries.

Issue forms render every field this way. Free-form issues (not created via a
form) may still use `###` headings; if they don't, the map is empty and the
caller falls back to whole-body checks.
"""
matches = list(HEADING_RE.finditer(body))
sections: dict[str, str] = {}
for index, match in enumerate(matches):
start = match.end()
end = matches[index + 1].start() if index + 1 < len(matches) else len(body)
sections[match.group(1).strip().lower()] = body[start:end]
return sections


def find_section(sections: dict[str, str], *labels: str) -> str:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: The docstring says "by case-insensitive label", but extract_sections lowercases keys while the callers here happen to pass already-lowercased labels. Either make find_section actually case-insensitive (compare candidates against lowered keys) so callers can pass the canonical heading text, or strip the "case-insensitive" claim from the docstring. Right now the docstring is technically untrue.

"""Return the first matching section text by case-insensitive label."""
for label in labels:
if label in sections:
return sections[label]
return ""


def references_run_method(text: str) -> bool:
return any(pattern.search(text) for pattern in RUN_METHOD_PATTERNS)


def has_checklist_item(text: str) -> bool:
return bool(CHECKLIST_ITEM_RE.search(text))


def check_bug(sections: dict[str, str]) -> ReadinessResult:
result = ReadinessResult(ready=True)

actual = visible_text(find_section(sections, "actual behavior", "actual"))
if not actual:
result.add(
"Fill in the `### Actual Behavior` section with reproducible steps "
"and the observed result."
)
elif not references_run_method(actual):
result.add(
"The Actual Behavior section must include a reproducible command "
"such as `uv run pytest`, `pytest`, `python`, `pip`, `npm run`, "
"or `node`."
)

acceptance = visible_text(
find_section(sections, "acceptance criteria", "acceptance")
)
if not acceptance:
result.add(
"Add an `### Acceptance Criteria` section with testable checklist items."
)
elif not has_checklist_item(acceptance):
result.add(
"The Acceptance Criteria section must contain at least one checklist item "
"(`- [ ] ...`)."
)

return result


def check_enhancement(sections: dict[str, str]) -> ReadinessResult:
result = ReadinessResult(ready=True)

desired = visible_text(find_section(sections, "desired behavior", "desired"))
if not desired:
result.add(
"Add a `### Desired Behavior` section describing the behavior you want."
)

acceptance = visible_text(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: The acceptance-criteria block (lines 160-171) is a near-exact duplicate of the one in check_bug (135-146) — only the leading verb ("Add" vs. "Fill in") differs in two of the four messages. Pull it into a small helper (_validate_acceptance(sections, result) or similar) and call it from both check_bug and check_enhancement. Removes ~12 lines of copy-paste and makes any future change to the checklist rule a one-line edit.

find_section(sections, "acceptance criteria", "acceptance")
)
if not acceptance:
result.add(
"Add an `### Acceptance Criteria` section with testable checklist items."
)
elif not has_checklist_item(acceptance):
result.add(
"The Acceptance Criteria section must contain at least one checklist item "
"(`- [ ] ...`)."
)

return result


def evaluate_readiness(body: str, labels: list[str]) -> ReadinessResult:
"""Return the readiness result for an issue body + label set.

An issue is only a candidate when it carries the `bug` or `enhancement`
label. If it has neither, it is treated as not-ready-for-dev (the gate does
not apply a label it cannot validate).
"""
label_set = {label.lower() for label in labels}
sections = extract_sections(body or "")

if BUG_LABEL in label_set:
return check_bug(sections)
if ENHANCEMENT_LABEL in label_set:
return check_enhancement(sections)

return ReadinessResult(
ready=False,
reasons=[
"The issue has neither the `bug` nor `enhancement` label, so its "
"readiness criteria cannot be evaluated. Add the appropriate label."
],
)


def body_and_labels_from_event(event_path: Path) -> tuple[str, list[str]]:
payload = json.loads(event_path.read_text())
issue = payload.get("issue") or payload.get("pull_request")
if not isinstance(issue, dict):
raise ValueError("GitHub event payload does not contain an issue object")
body = issue.get("body")
body = body if isinstance(body, str) else ""
labels = [
label["name"] for label in issue.get("labels", []) if isinstance(label, dict)
]
return body, labels


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Evaluate whether an issue meets the ready-for-dev criteria."
)
parser.add_argument(
"--body-file", type=Path, help="Read the issue body from a file."
)
parser.add_argument(
"--labels",
help="Comma-separated issue labels (e.g. 'bug,skills').",
default="",
)
parser.add_argument(
"--event-path",
type=Path,
default=Path(os.environ["GITHUB_EVENT_PATH"])
if "GITHUB_EVENT_PATH" in os.environ
else None,
help="Read body and labels from a GitHub event payload.",
)
parser.add_argument(
"--json",
action="store_true",
help="Emit a JSON result instead of human-readable text.",
)
return parser.parse_args()


def main() -> int:
args = parse_args()

if args.body_file is not None:
body = args.body_file.read_text()
labels = [label.strip() for label in args.labels.split(",") if label.strip()]
elif args.event_path is not None:
body, labels = body_and_labels_from_event(args.event_path)
else:
raise SystemExit("Pass --body-file or set GITHUB_EVENT_PATH.")

result = evaluate_readiness(body, labels)

if args.json:
print(json.dumps({"ready": result.ready, "reasons": result.reasons}))
# In --json mode the exit code is not meaningful: the result is consumed
# via the printed JSON, and the workflow must run to completion for both
# ready and not-ready issues (label add/remove, feedback comment).
return 0

if result.ready:
print("Issue meets ready-for-dev criteria.")
else:
print("Issue does not meet ready-for-dev criteria:")
for reason in result.reasons:
print(f" - {reason}")

return 0 if result.ready else 1


if __name__ == "__main__":
sys.exit(main())
Loading
Loading