| """Determine whether an issue meets the `ready-for-dev` readiness criteria. |
| |
| The criteria are type-specific, and the type is inferred from the body |
| structure rather than the `bug`/`enhancement` label: |
| |
| - Bug reports (a `##`/`### Steps to Reproduce` or `##`/`### Actual Behavior` |
| section): |
| the Steps to Reproduce section must reference at least one supported run |
| method (`agent-canvas`, `npm run`, or `app.all-hands.dev/canvas`), the |
| Actual Behavior section must embed a screenshot or video, and there must be |
| a non-empty Acceptance Criteria section with at least one checklist item. |
| |
| - Enhancements (a `##`/`### Desired Behavior` section): 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_`. The |
| parser also accepts `## <Label>` headings used by hand-edited and free-form |
| issues, and checks each criterion against the corresponding section. |
| |
| Local usage: |
| |
| python .github/scripts/check_issue_readiness.py --body-file /tmp/issue.md |
| 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 |
|
|
| from markdown_sections import find_headings |
|
|
| BUG_LABEL = "bug" |
| ENHANCEMENT_LABEL = "enhancement" |
|
|
| |
| |
| |
| |
| BUG_SECTION_LABELS = ( |
| "actual behavior", |
| "actual", |
| "steps to reproduce", |
| "reproduction", |
| ) |
| ENHANCEMENT_SECTION_LABELS = ("desired behavior", "desired") |
|
|
| |
| |
| |
| HEADING_RE = re.compile(r"(?m)^(?P<level>#{2,3})\s+(?P<title>.+?)\s*$") |
|
|
| READINESS_SECTION_LABELS = { |
| *BUG_SECTION_LABELS, |
| *ENHANCEMENT_SECTION_LABELS, |
| "acceptance criteria", |
| "acceptance", |
| } |
|
|
| |
| NO_RESPONSE = "_No response_" |
|
|
| |
| |
| |
| |
| |
| RUN_METHOD_PATTERNS: tuple[re.Pattern[str], ...] = ( |
| re.compile(r"agent-canvas", re.IGNORECASE), |
| re.compile(r"npm\s+run", re.IGNORECASE), |
| re.compile(r"app\.all-hands\.dev/canvas", re.IGNORECASE), |
| ) |
|
|
| |
| MARKDOWN_IMAGE_RE = re.compile(r"!\[[^\]]*\]\([^)]+\)") |
| |
| HTML_IMG_RE = re.compile(r"<img\b[^>]*\bsrc\s*=", re.IGNORECASE) |
| HTML_VIDEO_RE = re.compile(r"<video\b", re.IGNORECASE) |
| |
| GITHUB_ATTACHMENT_RE = re.compile( |
| r"https?://(?:www\.)?github\.com/user-attachments/assets/" |
| ) |
| |
| VIDEO_FILE_RE = re.compile( |
| r"https?://\S+\.(?:mp4|webm|mov|avi|mkv|ogv|3gp|m4v)(?:\S*)", |
| re.IGNORECASE, |
| ) |
| |
| VIDEO_HOST_RE = re.compile( |
| r"https?://(?:[a-z0-9-]+\.)?(?:youtube\.com|youtu\.be|loom\.com|vimeo\.com|asciinema\.org|streamable\.com)/", |
| re.IGNORECASE, |
| ) |
|
|
| |
| |
| 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) |
| issue_type: str | None = None |
|
|
| 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 h2 or h3 boundaries. |
| |
| Issue forms render fields as h3 headings. Free-form and hand-edited issues |
| may use h2 headings instead, including a mix of both levels. |
| """ |
| matches = find_headings(body, HEADING_RE) |
| has_h2 = any(match.group("level") == "##" for match in matches) |
| boundaries = [ |
| match |
| for match in matches |
| if match.group("level") == "##" |
| or not has_h2 |
| or match.group("title").strip().lower() in READINESS_SECTION_LABELS |
| ] |
|
|
| sections: dict[str, str] = {} |
| for index, match in enumerate(boundaries): |
| start = match.end() |
| end = ( |
| boundaries[index + 1].start() |
| if index + 1 < len(boundaries) |
| else len(body) |
| ) |
| sections[match.group("title").strip().lower()] = body[start:end] |
| return sections |
|
|
|
|
| def find_section(sections: dict[str, str], *labels: str) -> str: |
| """Return the first matching section text by case-insensitive label.""" |
| for label in labels: |
| if label in sections: |
| return sections[label] |
| return "" |
|
|
|
|
| def has_screenshot_or_video(text: str) -> bool: |
| if MARKDOWN_IMAGE_RE.search(text): |
| return True |
| if HTML_IMG_RE.search(text): |
| return True |
| if HTML_VIDEO_RE.search(text): |
| return True |
| if GITHUB_ATTACHMENT_RE.search(text): |
| return True |
| if VIDEO_FILE_RE.search(text): |
| return True |
| if VIDEO_HOST_RE.search(text): |
| return True |
| return False |
|
|
|
|
| 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, issue_type=BUG_LABEL) |
|
|
| reproduction = visible_text(find_section(sections, "steps to reproduce", "reproduction")) |
| if not reproduction: |
| result.add( |
| "Fill in the `### Steps to Reproduce` section showing how you reproduced " |
| "the bug in a live Agent Canvas session." |
| ) |
| elif not references_run_method(reproduction): |
| result.add( |
| "The Steps to Reproduce section must reference a supported run method: " |
| "`agent-canvas`, `npm run`, or `app.all-hands.dev/canvas`." |
| ) |
|
|
| actual = visible_text(find_section(sections, "actual behavior", "actual")) |
| if not actual: |
| result.add("Fill in the `### Actual Behavior` section describing the observed bug.") |
| elif not has_screenshot_or_video(actual): |
| result.add( |
| "The Actual Behavior section must include a screenshot or video of " |
| "the bug (drag a file into the field or paste a link)." |
| ) |
|
|
| 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, issue_type=ENHANCEMENT_LABEL) |
|
|
| 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(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. |
| |
| The type is inferred from the body structure, not the `bug`/`enhancement` |
| label: a body with a `##`/`### Steps to Reproduce` or `##`/`### Actual |
| Behavior` section is treated as a bug report, and a body with a |
| `##`/`### Desired Behavior` section is treated as an enhancement. `labels` |
| is accepted for backwards compatibility but is not consulted. |
| """ |
| sections = extract_sections(body or "") |
| has_bug_sections = any(label in sections for label in BUG_SECTION_LABELS) |
| has_enhancement_sections = any( |
| label in sections for label in ENHANCEMENT_SECTION_LABELS |
| ) |
|
|
| if has_bug_sections and has_enhancement_sections: |
| return ReadinessResult( |
| ready=False, |
| reasons=[ |
| "The issue contains both bug-report and feature-request sections, " |
| "so its type cannot be inferred. Use only one issue template." |
| ], |
| ) |
| if has_bug_sections: |
| return check_bug(sections) |
| if has_enhancement_sections: |
| return check_enhancement(sections) |
|
|
| return ReadinessResult( |
| ready=False, |
| reasons=[ |
| "The issue has neither a `### Steps to Reproduce`/`### Actual " |
| "Behavior` nor a `### Desired Behavior` section, so its readiness " |
| "criteria cannot be evaluated. Use the bug report or feature " |
| "request template." |
| ], |
| ) |
|
|
|
|
| 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. Accepted for backwards compatibility " |
| "but no longer consulted (the type is inferred from the body).", |
| 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, |
| "issue_type": result.issue_type, |
| } |
| ) |
| ) |
| else: |
| 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()) |
|
|