Download .github/scripts/check_pr_description.py from SaylorTwift/openhands: direct link, hf CLI and curl.
- Browser
- Download file 18.8 kB
-
https://huggingface.co/SaylorTwift/openhands/resolve/main/.github/scripts/check_pr_description.py
- Command line
-
hf download hf://SaylorTwift/openhands/.github/scripts/check_pr_description.py
-
curl -L -o check_pr_description.py https://huggingface.co/SaylorTwift/openhands/resolve/main/.github/scripts/check_pr_description.py
18.8 kB
| """Validate PR description readiness before a PR is reviewed. | |
| Required template fields: Why, Summary, and How to Test. | |
| Additional checks: | |
| - If the "A human has tested these changes" checkbox is present, it must be | |
| checked. | |
| - If frontend code was touched, the description must include a screenshot or | |
| video. | |
| - If the PR is marked as a Bug fix, the description must include a screenshot | |
| or video showing reproduction evidence — regardless of whether frontend | |
| code was touched. | |
| - The body must reference at least one issue (e.g. `Fixes #123`) and at least | |
| one referenced issue must carry the `ready-for-dev` label. The API lookup is | |
| only performed in CI (when GITHUB_EVENT_PATH and GITHUB_TOKEN are available). | |
| - The PR's Type checkbox must match the linked issue's labels: a "Bug fix" PR | |
| should link an issue with the `bug` label; a "Feature" PR should link one | |
| with the `enhancement` label. This prevents a contributor from bypassing | |
| bug-specific requirements by mislabeling the PR type. | |
| Local usage example: | |
| python .github/scripts/check_pr_description.py --body-file /tmp/pr-body.md \ | |
| --files-file /tmp/pr-files.txt | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import os | |
| import re | |
| import sys | |
| from pathlib import Path | |
| from markdown_sections import find_headings, without_fenced_code_blocks | |
| # Reject placeholders while allowing a concise human-written sentence. | |
| MIN_HUMAN_NOTE_CHARS = 20 | |
| # These are the only PR-template sections that must remain and contain content. | |
| REQUIRED_TEMPLATE_FIELDS: tuple[str, ...] = ("Why", "Summary", "How to Test") | |
| HTML_COMMENT_RE = re.compile(r"<!--[\s\S]*?-->") | |
| HEADING_RE = re.compile(r"(?m)^##\s+(.+?)\s*$") | |
| HUMAN_HEADING_RE = re.compile(r"(?im)^\s*HUMAN:\s*$") | |
| AGENT_HEADING_RE = re.compile(r"(?im)^\s*AGENT:\s*$") | |
| # A file counts as frontend code if its path is under one of these prefixes or | |
| # has one of these extensions. This mirrors the paths the E2E workflows treat as | |
| # stack-affecting (src/**, public/**) plus component/style/test extensions. | |
| FRONTEND_PATH_PREFIXES: tuple[str, ...] = ("src/", "__tests__/", "public/") | |
| FRONTEND_FILE_EXTENSIONS: tuple[str, ...] = ( | |
| ".tsx", | |
| ".jsx", | |
| ".vue", | |
| ".svelte", | |
| ".css", | |
| ".scss", | |
| ".sass", | |
| ".less", | |
| ) | |
| # Docs carry no visual state, so a screenshot can't evidence a change to them. | |
| DOCUMENTATION_FILE_EXTENSIONS: tuple[str, ...] = (".md", ".mdx") | |
| FRONTEND_CONFIG_GLOBS: tuple[str, ...] = ( | |
| "tailwind.config.*", | |
| "vite.config.*", | |
| "postcss.config.*", | |
| ) | |
| # The human-tested checkbox the HUMAN section asks contributors to tick. | |
| HUMAN_TESTED_RE = re.compile( | |
| r"(?im)^\s*[-*]\s*\[(?P<box>[ xX])\]\s*.*?human has tested these changes" | |
| ) | |
| # Issue references in the PR body: "Fixes #123", "Closes #123", "Resolves #123", | |
| # or a bare "#123" in the Issue Number section. We capture the issue number. | |
| # GitHub auto-linking keywords: close, closes, closed, fix, fixes, fixed, | |
| # resolve, resolves, resolved (case-insensitive). | |
| ISSUE_REF_RE = re.compile( | |
| r"(?i)(?:fix|clos|resolv)(?:e?(?:s|d)?|ing)?\s+#(\d+)" | |
| ) | |
| BARE_ISSUE_REF_RE = re.compile(r"(?<!\w)#(\d+)") | |
| READY_FOR_DEV_LABEL = "ready-for-dev" | |
| BUG_LABEL = "bug" | |
| ENHANCEMENT_LABEL = "enhancement" | |
| # PR Type checkboxes in the `## Type` section. We capture which type the author | |
| # checked so we can cross-check it against the linked issue's labels. | |
| PR_TYPE_BUG_RE = re.compile( | |
| r"(?im)^\s*[-*]\s*\[(?P<box>[ xX])\]\s*Bug fix" | |
| ) | |
| PR_TYPE_FEATURE_RE = re.compile( | |
| r"(?im)^\s*[-*]\s*\[(?P<box>[ xX])\]\s*Feature" | |
| ) | |
| # Markdown image:  | |
| MARKDOWN_IMAGE_RE = re.compile(r"!\[[^\]]*\]\([^)]+\)") | |
| # HTML <img ...> and <video ...> tags. | |
| HTML_IMG_RE = re.compile(r"<img\b[^>]*\bsrc\s*=", re.IGNORECASE) | |
| HTML_VIDEO_RE = re.compile(r"<video\b", re.IGNORECASE) | |
| # GitHub-uploaded assets (images and videos both use this URL shape). | |
| GITHUB_ATTACHMENT_RE = re.compile( | |
| r"https?://(?:www\.)?github\.com/user-attachments/assets/" | |
| ) | |
| # Direct links to video files. | |
| VIDEO_FILE_RE = re.compile( | |
| r"https?://\S+\.(?:mp4|webm|mov|avi|mkv|ogv|3gp|m4v)(?:\S*)", | |
| re.IGNORECASE, | |
| ) | |
| # Known video-hosting services. | |
| VIDEO_HOST_RE = re.compile( | |
| r"https?://(?:[a-z0-9-]+\.)?(?:youtube\.com|youtu\.be|loom\.com|vimeo\.com|asciinema\.org|streamable\.com)/", | |
| re.IGNORECASE, | |
| ) | |
| def visible_text(text: str) -> str: | |
| """Return PR body content that should count as author-provided text.""" | |
| lines = [] | |
| for line in HTML_COMMENT_RE.sub("", text).splitlines(): | |
| stripped = line.strip() | |
| if stripped and stripped != "-": | |
| lines.append(stripped) | |
| return "\n".join(lines).strip() | |
| def first_visible_line(text: str) -> str: | |
| for line in HTML_COMMENT_RE.sub("", text).splitlines(): | |
| stripped = line.strip() | |
| if stripped: | |
| return stripped | |
| return "" | |
| def extract_sections(body: str) -> dict[str, str]: | |
| matches = find_headings(body, HEADING_RE) | |
| 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()] = body[start:end] | |
| return sections | |
| def extract_human_note(body: str) -> str: | |
| """Return human-written text in the required location before `AGENT:`. | |
| The markers are located outside fenced code blocks so that quoting the | |
| template does not stand in for filling it out. Offsets are preserved by the | |
| masking, so the note itself is still read from the original body. | |
| """ | |
| outside_fences = without_fenced_code_blocks(body) | |
| human_match = HUMAN_HEADING_RE.search(outside_fences) | |
| if human_match is None: | |
| return "" | |
| agent_match = AGENT_HEADING_RE.search(outside_fences, human_match.end()) | |
| if agent_match is None: | |
| return "" | |
| return visible_text(body[human_match.end() : agent_match.start()]) | |
| def is_frontend_file(path: str) -> bool: | |
| """Return True if a changed file should be treated as frontend code.""" | |
| normalized = path.lstrip("./") | |
| lower = normalized.lower() | |
| if lower.endswith(DOCUMENTATION_FILE_EXTENSIONS): | |
| return False | |
| if any(normalized.startswith(prefix) for prefix in FRONTEND_PATH_PREFIXES): | |
| return True | |
| if any(lower.endswith(ext) for ext in FRONTEND_FILE_EXTENSIONS): | |
| return True | |
| name = normalized.split("/")[-1] | |
| return any( | |
| re.fullmatch(glob.replace(r".", r"\.").replace(r"*", r".*"), name) | |
| for glob in FRONTEND_CONFIG_GLOBS | |
| ) | |
| def touches_frontend(files: list[str]) -> bool: | |
| """Return True if any changed file is frontend code.""" | |
| return any(is_frontend_file(path) for path in files if path) | |
| def has_screenshot_or_video(body: str) -> bool: | |
| """Return True if the PR body embeds a screenshot or video.""" | |
| if MARKDOWN_IMAGE_RE.search(body): | |
| return True | |
| if HTML_IMG_RE.search(body): | |
| return True | |
| if HTML_VIDEO_RE.search(body): | |
| return True | |
| if GITHUB_ATTACHMENT_RE.search(body): | |
| return True | |
| if VIDEO_FILE_RE.search(body): | |
| return True | |
| if VIDEO_HOST_RE.search(body): | |
| return True | |
| return False | |
| def validate_human_tested_checkbox(body: str) -> list[str]: | |
| """Require the human-tested checkbox to be checked when it is present.""" | |
| errors: list[str] = [] | |
| matches = list(HUMAN_TESTED_RE.finditer(body)) | |
| for match in matches: | |
| if match.group("box").strip().lower() != "x": | |
| errors.append( | |
| "The `A human has tested these changes` checkbox is present but " | |
| "unchecked. Tick it (`- [x]`) or remove the line if it does not apply." | |
| ) | |
| break | |
| return errors | |
| def validate_frontend_screenshot(body: str, files: list[str]) -> list[str]: | |
| """Require a screenshot/video in the body when frontend code was touched.""" | |
| if not touches_frontend(files): | |
| return [] | |
| if has_screenshot_or_video(body): | |
| return [] | |
| return [ | |
| "This PR touches frontend code but the description has no screenshot or " | |
| "video. Add one under `## Video/Screenshots` (drag a file into the editor " | |
| "or paste a video link)." | |
| ] | |
| def validate_bug_fix_evidence(body: str) -> list[str]: | |
| """Require reproduction evidence when the PR is marked as a Bug fix. | |
| A bug-fix PR must include a screenshot or video showing the bug reproduced | |
| and then fixed. This applies regardless of whether frontend code was touched | |
| — a terminal screenshot showing the error before and the fix after is just | |
| as valid as a UI screenshot. | |
| """ | |
| pr_type = extract_pr_type(body) | |
| if pr_type != BUG_LABEL: | |
| return [] | |
| if has_screenshot_or_video(body): | |
| return [] | |
| return [ | |
| "This PR is marked as a Bug fix but the description has no screenshot or " | |
| "video. Add reproduction evidence under `## Video/Screenshots` showing the " | |
| "bug before the fix and the result after (drag a file into the editor or " | |
| "paste a video link)." | |
| ] | |
| def extract_linked_issue_numbers(body: str) -> list[int]: | |
| """Return issue numbers referenced in the PR body. | |
| Looks for GitHub auto-close keywords (`Fixes #N`, `Closes #N`, | |
| `Resolves #N`) anywhere in the body, plus bare `#N` references inside the | |
| `## Issue Number` section. | |
| """ | |
| numbers: list[int] = [] | |
| seen: set[int] = set() | |
| for match in ISSUE_REF_RE.finditer(body): | |
| number = int(match.group(1)) | |
| if number not in seen: | |
| numbers.append(number) | |
| seen.add(number) | |
| sections = extract_sections(body) | |
| issue_section = sections.get("Issue Number", "") | |
| if visible_text(issue_section): | |
| for match in BARE_ISSUE_REF_RE.finditer(issue_section): | |
| number = int(match.group(1)) | |
| if number not in seen: | |
| numbers.append(number) | |
| seen.add(number) | |
| return numbers | |
| def extract_pr_type(body: str) -> str | None: | |
| """Return the checked PR type from the `## Type` section. | |
| Returns "bug" if the "Bug fix" checkbox is checked, "enhancement" if the | |
| "Feature" checkbox is checked, or None if neither is checked or the section | |
| is absent. | |
| """ | |
| bug_match = PR_TYPE_BUG_RE.search(body) | |
| if bug_match and bug_match.group("box").strip().lower() == "x": | |
| return BUG_LABEL | |
| feature_match = PR_TYPE_FEATURE_RE.search(body) | |
| if feature_match and feature_match.group("box").strip().lower() == "x": | |
| return ENHANCEMENT_LABEL | |
| return None | |
| def fetch_issue_labels(repo: str, issue_number: int, token: str) -> list[str]: | |
| """Fetch label names for an issue via the GitHub REST API.""" | |
| import urllib.request | |
| url = f"https://api.github.com/repos/{repo}/issues/{issue_number}/labels" | |
| req = urllib.request.Request( | |
| url, | |
| headers={ | |
| "Authorization": f"Bearer {token}", | |
| "Accept": "application/vnd.github+json", | |
| }, | |
| ) | |
| with urllib.request.urlopen(req) as resp: # noqa: S310 - trusted HTTPS API | |
| data = json.loads(resp.read().decode()) | |
| return [label["name"] for label in data if isinstance(label, dict)] | |
| def validate_linked_issue_ready( | |
| body: str, repo: str | None = None, token: str | None = None | |
| ) -> list[str]: | |
| """Require a linked issue carrying the `ready-for-dev` label. | |
| Also cross-checks the PR's Type checkbox against the linked issue's labels: | |
| a "Bug fix" PR must link an issue with the `bug` label, and a "Feature" PR | |
| must link one with the `enhancement` label. | |
| When `repo` and `token` are not provided (local `--body-file` mode), only | |
| checks that the body references at least one issue — the API lookup is | |
| skipped. | |
| """ | |
| errors: list[str] = [] | |
| numbers = extract_linked_issue_numbers(body) | |
| if not numbers: | |
| errors.append( | |
| "Link an issue in the `## Issue Number` section (e.g. `Fixes #123`). " | |
| "The issue must carry the `ready-for-dev` label." | |
| ) | |
| return errors | |
| if not repo or not token: | |
| return errors | |
| import urllib.error | |
| pr_type = extract_pr_type(body) | |
| checked: list[int] = [] | |
| found_ready = False | |
| found_type_match = False | |
| for number in numbers: | |
| try: | |
| labels = fetch_issue_labels(repo, number, token) | |
| except urllib.error.HTTPError as exc: | |
| if exc.code == 404: | |
| continue | |
| raise | |
| checked.append(number) | |
| if READY_FOR_DEV_LABEL in labels: | |
| found_ready = True | |
| if pr_type and pr_type in [label.lower() for label in labels]: | |
| found_type_match = True | |
| if not found_ready: | |
| if checked: | |
| ref = ", ".join(f"#{n}" for n in checked) | |
| errors.append( | |
| f"None of the linked issues ({ref}) carry the `ready-for-dev` label. " | |
| "The issue must meet the type-specific readiness criteria before a PR " | |
| "can be opened against it." | |
| ) | |
| else: | |
| errors.append( | |
| f"Referenced issue(s) {', '.join(f'#{n}' for n in numbers)} could not " | |
| "be found in this repository. Link an issue in this repo." | |
| ) | |
| if pr_type and not found_type_match and checked: | |
| type_name = "bug" if pr_type == BUG_LABEL else "enhancement" | |
| ref = ", ".join(f"#{n}" for n in checked) | |
| errors.append( | |
| f"This PR is marked as `{type_name}` but none of the linked issues " | |
| f"({ref}) carry the `{pr_type}` label. Ensure the linked issue has the " | |
| f"correct type label (`{pr_type}`)." | |
| ) | |
| return errors | |
| def validate_pr_body(body: str, files: list[str] | None = None) -> list[str]: | |
| errors: list[str] = [] | |
| if first_visible_line(body) != "HUMAN:": | |
| errors.append("The first visible line of the PR description must be `HUMAN:`.") | |
| human_note = extract_human_note(body) | |
| if len(human_note) < MIN_HUMAN_NOTE_CHARS: | |
| errors.append("Add a short human-written note between `HUMAN:` and `AGENT:`.") | |
| if AGENT_HEADING_RE.search(without_fenced_code_blocks(body)) is None: | |
| errors.append("Keep the `AGENT:` marker from the PR template.") | |
| sections = extract_sections(body) | |
| for section in REQUIRED_TEMPLATE_FIELDS: | |
| if section not in sections: | |
| errors.append(f"Keep the `## {section}` section from the PR template.") | |
| elif not visible_text(sections[section]): | |
| errors.append(f"Fill in the `## {section}` section of the PR template.") | |
| errors.extend(validate_human_tested_checkbox(body)) | |
| errors.extend(validate_frontend_screenshot(body, files or [])) | |
| errors.extend(validate_bug_fix_evidence(body)) | |
| return errors | |
| def body_from_event(event_path: Path) -> str: | |
| payload = json.loads(event_path.read_text()) | |
| pull_request = payload.get("pull_request") | |
| if not isinstance(pull_request, dict): | |
| raise ValueError("GitHub event payload does not contain a pull_request object") | |
| body = pull_request.get("body") | |
| return body if isinstance(body, str) else "" | |
| def pr_number_from_event(event_path: Path) -> int | None: | |
| payload = json.loads(event_path.read_text()) | |
| pull_request = payload.get("pull_request") | |
| if not isinstance(pull_request, dict): | |
| return None | |
| number = pull_request.get("number") | |
| return int(number) if number is not None else None | |
| def files_from_api(event_path: Path) -> list[str]: | |
| """Fetch the changed file paths for the PR via the GitHub REST API. | |
| Used in CI when --files-file is not provided. Relies on GITHUB_TOKEN and the | |
| repository being available in the event payload. | |
| """ | |
| import urllib.request | |
| payload = json.loads(event_path.read_text()) | |
| repo = payload.get("repository", {}).get("full_name") | |
| number = pr_number_from_event(event_path) | |
| if not repo or number is None: | |
| return [] | |
| token = os.environ.get("GITHUB_TOKEN") | |
| if not token: | |
| return [] | |
| files: list[str] = [] | |
| page = 1 | |
| while True: | |
| url = f"https://api.github.com/repos/{repo}/pulls/{number}/files?per_page=100&page={page}" | |
| req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}) | |
| with urllib.request.urlopen(req) as resp: # noqa: S310 - trusted HTTPS API | |
| data = json.loads(resp.read().decode()) | |
| if not data: | |
| break | |
| files.extend(item.get("filename", "") for item in data if isinstance(item, dict)) | |
| if len(data) < 100: | |
| break | |
| page += 1 | |
| return files | |
| def parse_args() -> argparse.Namespace: | |
| parser = argparse.ArgumentParser( | |
| description=( | |
| "Validate pull request description readiness from --body-file " | |
| "or a GitHub event payload." | |
| ) | |
| ) | |
| parser.add_argument( | |
| "--body-file", type=Path, help="Read a PR description body from a file." | |
| ) | |
| 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 the PR description body from a GitHub event payload.", | |
| ) | |
| parser.add_argument( | |
| "--files-file", | |
| type=Path, | |
| help=( | |
| "Read changed file paths (one per line) from a file. If omitted in CI, " | |
| "the script fetches them from the GitHub API." | |
| ), | |
| ) | |
| return parser.parse_args() | |
| def main() -> int: | |
| args = parse_args() | |
| if args.body_file is not None: | |
| body = args.body_file.read_text() | |
| files: list[str] = [] | |
| elif args.event_path is not None: | |
| body = body_from_event(args.event_path) | |
| else: | |
| raise SystemExit("Pass --body-file or set GITHUB_EVENT_PATH.") | |
| if args.files_file is not None: | |
| files = [ | |
| line.strip() | |
| for line in args.files_file.read_text().splitlines() | |
| if line.strip() | |
| ] | |
| elif args.event_path is not None and args.body_file is None: | |
| files = files_from_api(args.event_path) | |
| else: | |
| files = [] | |
| errors = validate_pr_body(body, files) | |
| repo = None | |
| token = os.environ.get("GITHUB_TOKEN") | |
| if args.event_path is not None and args.body_file is None: | |
| payload = json.loads(args.event_path.read_text()) | |
| repo = payload.get("repository", {}).get("full_name") | |
| errors.extend(validate_linked_issue_ready(body, repo, token)) | |
| for error in errors: | |
| print(f"::error::{error}") | |
| if errors: | |
| print(f"PR description validation failed with {len(errors)} error(s).") | |
| return 1 | |
| print("PR description validation passed.") | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |