chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
"""Shared Markdown-section parsing for the PR-template tooling.
|
||||
|
||||
`validate.py` (the merge gate) and the release-time changelog harvester
|
||||
(`.github/scripts/changelog/generate.py`) both need to pull a named `##`
|
||||
section out of a PR body. Keeping that logic in one place means the gate and
|
||||
the harvester can never drift on what counts as the "## Changelog" section.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
_HEADING_RE = re.compile(r"(?im)^\s*##\s+(.+?)\s*$")
|
||||
_HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
|
||||
_CHECKBOX_RE = re.compile(r"(?im)^\s*-\s*\[(?P<mark>[ xX])\]\s*(?P<label>.+?)\s*$")
|
||||
|
||||
|
||||
def strip_html_comments(text: str) -> str:
|
||||
"""Drop ``<!-- ... -->`` comments (template guidance lives in these)."""
|
||||
return _HTML_COMMENT_RE.sub("", text)
|
||||
|
||||
|
||||
def heading_spans(body: str) -> dict[str, tuple[int, int]]:
|
||||
"""Map each lowercased ``## heading`` to the (start, end) span of its body.
|
||||
|
||||
The span runs from just after the heading line to the start of the next
|
||||
``##`` heading (or end of document). Later duplicate headings win, matching
|
||||
the existing validator behaviour.
|
||||
"""
|
||||
matches = list(_HEADING_RE.finditer(body))
|
||||
spans: dict[str, tuple[int, int]] = {}
|
||||
for idx, match in enumerate(matches):
|
||||
title = match.group(1).strip().lower()
|
||||
start = match.end()
|
||||
end = matches[idx + 1].start() if idx + 1 < len(matches) else len(body)
|
||||
spans[title] = (start, end)
|
||||
return spans
|
||||
|
||||
|
||||
def section(body: str, spans: dict[str, tuple[int, int]], heading: str) -> str:
|
||||
"""Return the raw text under *heading*, or ``""`` if it is absent."""
|
||||
span = spans.get(heading.lower())
|
||||
if span is None:
|
||||
return ""
|
||||
return body[span[0] : span[1]]
|
||||
|
||||
|
||||
def section_text(body: str, heading: str) -> str:
|
||||
"""Convenience: raw text under *heading* parsed straight from *body*."""
|
||||
return section(body, heading_spans(body), heading)
|
||||
|
||||
|
||||
# --- checkbox parsing (shared by the gate and the harvester) ----------------
|
||||
|
||||
|
||||
def checked_labels(section_raw: str, expected_labels: tuple[str, ...]) -> set[str]:
|
||||
"""Return the canonical labels whose checkbox is ticked in *section_raw*."""
|
||||
expected_by_lower = {label.lower(): label for label in expected_labels}
|
||||
checked: set[str] = set()
|
||||
for match in _CHECKBOX_RE.finditer(section_raw):
|
||||
label = match.group("label").strip()
|
||||
canonical = expected_by_lower.get(label.lower())
|
||||
if canonical and match.group("mark").lower() == "x":
|
||||
checked.add(canonical)
|
||||
return checked
|
||||
|
||||
|
||||
# --- "## Changelog" section format ------------------------------------------
|
||||
#
|
||||
# The section holds a free-text, user-voice one-liner describing the change (the
|
||||
# author may hard-wrap it — we take the first line). The category/tag is NOT
|
||||
# written here; it is derived from the "Type of change" checkboxes via TYPE_TAGS.
|
||||
# The section is optional: an author deletes it (or leaves the `<…>` placeholder)
|
||||
# when the change isn't noteworthy, and the PR is then omitted from the changelog.
|
||||
# The same parser backs the PR gate (validate.py) and the harvester (generate.py).
|
||||
|
||||
# "Type of change" checkbox label -> bracket tag rendered in CHANGELOG.md.
|
||||
TYPE_TAGS: dict[str, str] = {
|
||||
"UI / frontend change": "UI",
|
||||
"Bug fix": "Bug fix",
|
||||
"Feature": "Feature",
|
||||
"Docs": "Docs",
|
||||
"Refactor / chore": "Chore",
|
||||
"Test / CI": "Test/CI",
|
||||
"Breaking change": "Breaking",
|
||||
}
|
||||
|
||||
_PLACEHOLDER_RE = re.compile(r"^\s*<.*>\s*$")
|
||||
# Markers meaning "nothing to announce" — the section is optional and deletable,
|
||||
# but authors (and the old template's `skip` sentinel) still write these; treat
|
||||
# them as an absent section rather than leaking them in as literal entries.
|
||||
_OMIT_MARKERS = frozenset({"skip", "n/a", "na", "none", "-"})
|
||||
|
||||
|
||||
def is_placeholder(line: str) -> bool:
|
||||
"""True when *line* is the untouched ``<…>`` template placeholder."""
|
||||
return bool(_PLACEHOLDER_RE.match(line))
|
||||
|
||||
|
||||
def changelog_description(section_raw: str) -> str:
|
||||
"""First meaningful line of a "## Changelog" section.
|
||||
|
||||
Strips HTML comments, then returns the first non-blank line — unless that
|
||||
line is the ``<…>`` placeholder or an omit marker (``skip``/``n/a``/…), in
|
||||
which case the section counts as absent and this returns ``""``. Multi-line /
|
||||
wrapped bodies collapse to their first line.
|
||||
"""
|
||||
for raw in strip_html_comments(section_raw).splitlines():
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
if is_placeholder(line) or line.lower() in _OMIT_MARKERS:
|
||||
return ""
|
||||
return line
|
||||
return ""
|
||||
|
||||
|
||||
def type_tag(labels: set[str]) -> str:
|
||||
"""Render the bracket tag for the checked Type-of-change *labels*.
|
||||
|
||||
Joined with ` / ` in TYPE_TAGS declaration order (e.g. ``[UI / Bug fix]``).
|
||||
Returns ``""`` when no known type is checked.
|
||||
"""
|
||||
tags = [tag for label, tag in TYPE_TAGS.items() if label in labels]
|
||||
return f"[{' / '.join(tags)}]" if tags else ""
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Add PR-template scaffolding without deleting the author's text."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from validate import TEST_LABELS, TYPE_LABELS
|
||||
|
||||
_HEADING_RE = re.compile(r"(?im)^\s*##\s+(.+?)\s*$")
|
||||
|
||||
|
||||
def _has_heading(body: str, heading: str) -> bool:
|
||||
wanted = heading.strip().lower()
|
||||
return any(match.group(1).strip().lower() == wanted for match in _HEADING_RE.finditer(body))
|
||||
|
||||
|
||||
def _append_section(body: str, heading: str, content: str) -> str:
|
||||
if _has_heading(body, heading):
|
||||
return body
|
||||
return body.rstrip() + f"\n\n## {heading}\n\n{content.rstrip()}\n"
|
||||
|
||||
|
||||
def _checkbox_block(labels: tuple[str, ...]) -> str:
|
||||
return "\n".join(f"- [ ] {label}" for label in labels) + "\n"
|
||||
|
||||
|
||||
def format_body(body: str) -> str:
|
||||
"""Return *body* with missing PR-template sections appended.
|
||||
|
||||
Existing prose is preserved verbatim. When the body has no Summary
|
||||
heading, the existing text is placed under Summary so it remains the
|
||||
main description instead of being pushed below the template.
|
||||
"""
|
||||
body = body.strip()
|
||||
if not body:
|
||||
body = "## Summary\n\n"
|
||||
elif not _has_heading(body, "Summary"):
|
||||
body = f"## Summary\n\n{body}"
|
||||
|
||||
body = _append_section(
|
||||
body,
|
||||
"Test Plan",
|
||||
"How was this change tested? Describe the steps, commands, or scenarios "
|
||||
"used to verify it (autoformat added this section — please replace it).",
|
||||
)
|
||||
body = _append_section(
|
||||
body,
|
||||
"Demo",
|
||||
"<!-- Video or images demonstrating the change. Mandatory for UI / "
|
||||
"frontend changes; use 'N/A' otherwise. -->",
|
||||
)
|
||||
body = _append_section(
|
||||
body,
|
||||
"ELI5",
|
||||
"<!-- Optional: explain the change in plain language. -->",
|
||||
)
|
||||
body = _append_section(
|
||||
body,
|
||||
"Diagram",
|
||||
"```mermaid\nflowchart LR\n A[Before] --> B[Change]\n B --> C[After]\n```",
|
||||
)
|
||||
body = _append_section(body, "Type of change", _checkbox_block(TYPE_LABELS))
|
||||
body = _append_section(body, "Test coverage", _checkbox_block(TEST_LABELS))
|
||||
body = _append_section(
|
||||
body,
|
||||
"Coverage notes",
|
||||
"<!-- Optional; required if you checked 'Manual verification completed' "
|
||||
"or 'Not applicable' above. -->",
|
||||
)
|
||||
body = _append_section(
|
||||
body,
|
||||
"Changelog",
|
||||
"<!-- One line, in the user's voice, describing the user-facing change; "
|
||||
"the category comes from the 'Type of change' boxes above. DELETE this "
|
||||
"section if the change isn't noteworthy (a Breaking change must keep it). "
|
||||
"-->\n\n<Add a line to describe the change, else delete this section>",
|
||||
)
|
||||
return body.rstrip() + "\n"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 3:
|
||||
print("usage: format_body.py INPUT OUTPUT", file=sys.stderr)
|
||||
return 2
|
||||
source = Path(sys.argv[1])
|
||||
dest = Path(sys.argv[2])
|
||||
dest.write_text(format_body(source.read_text()))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+182
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate that a PR description follows the repository template.
|
||||
|
||||
The GitHub workflow passes the PR body in PR_BODY. The script is also
|
||||
unit-tested directly so changes to the template gate are reviewed like
|
||||
normal code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Share the Markdown-section + changelog parsing with the release-time harvester
|
||||
# (.github/scripts/changelog/generate.py) so the gate and the harvester can
|
||||
# never disagree on what the "## Changelog" section means.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from _md import changelog_description
|
||||
from _md import checked_labels as _checked_labels
|
||||
from _md import heading_spans as _heading_spans
|
||||
from _md import section as _section
|
||||
from _md import strip_html_comments as _strip_html_comments
|
||||
|
||||
REQUIRED_HEADINGS = (
|
||||
"Summary",
|
||||
"Test Plan",
|
||||
"Type of change",
|
||||
"Test coverage",
|
||||
)
|
||||
|
||||
TYPE_LABELS = (
|
||||
"Bug fix",
|
||||
"Feature",
|
||||
"UI / frontend change",
|
||||
"Refactor / chore",
|
||||
"Docs",
|
||||
"Test / CI",
|
||||
"Breaking change",
|
||||
)
|
||||
|
||||
TEST_LABELS = (
|
||||
"Unit tests added / updated",
|
||||
"Integration tests added / updated",
|
||||
"E2E tests added / updated",
|
||||
"Manual verification completed",
|
||||
"Existing tests cover this change",
|
||||
"Not applicable",
|
||||
)
|
||||
|
||||
PLACEHOLDER_FRAGMENTS = (
|
||||
"what changed and why",
|
||||
"check all that apply",
|
||||
"describe below",
|
||||
"how was this change tested",
|
||||
)
|
||||
|
||||
|
||||
class ValidationResult:
|
||||
def __init__(self, ok: bool, errors: list[str]) -> None:
|
||||
self.ok = ok
|
||||
self.errors = errors
|
||||
|
||||
|
||||
_CHECKBOX_RE = re.compile(r"(?im)^\s*-\s*\[(?P<mark>[ xX])\]\s*(?P<label>.+?)\s*$")
|
||||
|
||||
|
||||
def _missing_labels(section: str, expected_labels: tuple[str, ...]) -> list[str]:
|
||||
present = {match.group("label").strip().lower() for match in _CHECKBOX_RE.finditer(section)}
|
||||
return [label for label in expected_labels if label.lower() not in present]
|
||||
|
||||
|
||||
def _meaningful_text(section: str) -> str:
|
||||
text = _strip_html_comments(section)
|
||||
text = re.sub(r"(?im)^\s*-\s*\[[ xX]\].*$", "", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _contains_placeholder(text: str) -> bool:
|
||||
lowered = text.lower()
|
||||
return any(fragment in lowered for fragment in PLACEHOLDER_FRAGMENTS)
|
||||
|
||||
|
||||
def validate_pr_body(body: str) -> ValidationResult:
|
||||
body = body.lstrip("\ufeff")
|
||||
errors: list[str] = []
|
||||
|
||||
spans = _heading_spans(body)
|
||||
for heading in REQUIRED_HEADINGS:
|
||||
if heading.lower() not in spans:
|
||||
errors.append(f"Missing required section: ## {heading}")
|
||||
|
||||
summary = _meaningful_text(_section(body, spans, "Summary"))
|
||||
if not summary:
|
||||
errors.append("Summary must describe what changed and why.")
|
||||
elif _contains_placeholder(summary):
|
||||
errors.append("Summary still contains template placeholder text.")
|
||||
|
||||
test_plan = _meaningful_text(_section(body, spans, "Test Plan"))
|
||||
if not test_plan:
|
||||
errors.append("Test Plan must describe how the change was tested.")
|
||||
elif _contains_placeholder(test_plan):
|
||||
errors.append("Test Plan still contains template placeholder text.")
|
||||
|
||||
type_section = _section(body, spans, "Type of change")
|
||||
missing_type_labels = _missing_labels(type_section, TYPE_LABELS)
|
||||
if missing_type_labels:
|
||||
errors.append(
|
||||
"Type of change is missing template checkbox(es): " + ", ".join(missing_type_labels)
|
||||
)
|
||||
checked_types = _checked_labels(type_section, TYPE_LABELS)
|
||||
if not checked_types:
|
||||
errors.append("Check at least one Type of change checkbox.")
|
||||
|
||||
# The Demo section is mandatory for UI / frontend changes — reviewers need
|
||||
# a screenshot or recording of the new behaviour. It stays optional for
|
||||
# everything else.
|
||||
if "UI / frontend change" in checked_types:
|
||||
demo = _meaningful_text(_section(body, spans, "Demo"))
|
||||
if not demo:
|
||||
errors.append(
|
||||
"Demo is required for UI / frontend changes — attach a screenshot "
|
||||
"or screen recording demonstrating the new behaviour."
|
||||
)
|
||||
elif _contains_placeholder(demo):
|
||||
errors.append("Demo still contains template placeholder text.")
|
||||
|
||||
test_section = _section(body, spans, "Test coverage")
|
||||
missing_test_labels = _missing_labels(test_section, TEST_LABELS)
|
||||
if missing_test_labels:
|
||||
errors.append(
|
||||
"Test coverage is missing template checkbox(es): " + ", ".join(missing_test_labels)
|
||||
)
|
||||
checked_tests = _checked_labels(test_section, TEST_LABELS)
|
||||
if not checked_tests:
|
||||
errors.append("Check at least one Test coverage checkbox.")
|
||||
|
||||
# Coverage notes are optional in general, but required whenever "Manual
|
||||
# verification completed" or "Not applicable" is checked — those choices
|
||||
# need a written justification.
|
||||
if checked_tests & {"Manual verification completed", "Not applicable"}:
|
||||
coverage_notes = _meaningful_text(_section(body, spans, "Coverage notes"))
|
||||
if not coverage_notes:
|
||||
errors.append(
|
||||
"Coverage notes are required when 'Manual verification completed' or "
|
||||
"'Not applicable' is selected — describe what you verified or why "
|
||||
"automated coverage is not needed."
|
||||
)
|
||||
elif _contains_placeholder(coverage_notes):
|
||||
errors.append("Coverage notes still contains template placeholder text.")
|
||||
|
||||
# The Changelog section is optional — an author deletes it (or leaves the
|
||||
# `<…>` placeholder) when the change isn't noteworthy, and the PR is simply
|
||||
# omitted from the changelog. The one exception: a Breaking change is always
|
||||
# noteworthy, so it must carry a real description line.
|
||||
if "Breaking change" in checked_types:
|
||||
changelog_section = _section(body, spans, "Changelog") if "changelog" in spans else ""
|
||||
if not changelog_description(changelog_section):
|
||||
errors.append(
|
||||
"A Breaking change must describe the change in the Changelog section "
|
||||
"(otherwise it would be omitted from the changelog)."
|
||||
)
|
||||
|
||||
return ValidationResult(ok=not errors, errors=errors)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
body = os.environ["PR_BODY"]
|
||||
result = validate_pr_body(body)
|
||||
if result.ok:
|
||||
print("PR template validation passed.")
|
||||
return 0
|
||||
|
||||
print("PR template validation failed:")
|
||||
for error in result.errors:
|
||||
print(f"- {error}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user