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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,13 @@ jobs:
```

`actions/validate-body` validates the PR body's H2 sections against the repository PR template.
When `require-checklist` is true, it also parses every `- [x] …` row under the configured
Every template H2 is required by default. Prefix an optional H2 with
`<!-- pr-body:optional -->`; `<!-- pr-body:required -->` is accepted when an explicit marker is
useful. Optional sections may be omitted from the PR body, but present sections must remain in
template order and the body may not introduce undeclared or duplicate H2 headings. Directives
inside fenced code blocks are ignored, and ambiguous or dangling directives fail closed.

When `require-checklist` is true, the action also parses every `- [x] …` row under the configured
`## Checklist` section and requires those exact lines (character-for-character except trailing
newline handling) to appear in the PR body. Use `checklist-section` for a different H2 title and
`fail-on-empty-checklist` to make a missing checklist section fail closed.
Expand Down
2 changes: 1 addition & 1 deletion actions/validate-body/action.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: Validate PR body
description: Validate a PR body against required H2 section headings and optional checked checklist rows via zendev.
description: Validate required-by-default and explicitly optional PR template H2 sections plus optional checked checklist rows via zendev.

inputs:
body:
Expand Down
113 changes: 97 additions & 16 deletions src/zendev/body.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import re
import sys
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import TextIO

Expand All @@ -18,6 +19,15 @@
from zendev.markdown_scan import iter_lines_outside_fences

REQUIRED_SECTIONS: tuple[str, ...] = ("Summary", "Validation", "Notes")
_SECTION_DIRECTIVE_RE = re.compile(r"^<!--\s*pr-body:(required|optional)\s*-->$")


@dataclass(frozen=True)
class BodySection:
"""One H2 section declared by a PR template."""

heading: str
required: bool = True


def _extract_h2_headings(text: str) -> list[str]:
Expand All @@ -30,34 +40,99 @@ def _extract_h2_headings(text: str) -> list[str]:
return headings


def _load_template_headings(template_path: Path | None) -> list[str]:
"""Return required H2 headings from the PR template file, or fall back to defaults."""
def _extract_template_sections(text: str) -> list[BodySection]:
"""Parse H2 sections and optional requirement directives from a PR template."""
sections: list[BodySection] = []
pending_requirement: bool | None = None

for line in iter_lines_outside_fences(text):
stripped = line.strip()
directive = _SECTION_DIRECTIVE_RE.fullmatch(stripped)
if directive is not None:
if pending_requirement is not None:
raise ValueError("multiple pr-body directives appear before the same H2 section")
pending_requirement = directive.group(1) == "required"
continue

if not re.match(r"^##\s+\S", stripped):
continue

heading = re.sub(r"^##\s+", "", stripped)
sections.append(
BodySection(
heading=heading,
required=True if pending_requirement is None else pending_requirement,
)
)
pending_requirement = None

if pending_requirement is not None:
raise ValueError("pr-body directive is not followed by an H2 section")

headings = [section.heading for section in sections]
if len(headings) != len(set(headings)):
raise ValueError("PR template H2 headings must be unique")

return sections


def _load_template_sections(template_path: Path | None) -> list[BodySection]:
"""Return PR template H2 requirements, or fall back to the legacy required defaults."""
if template_path is not None and template_path.is_file():
return _extract_h2_headings(template_path.read_text(encoding="utf-8"))
return list(REQUIRED_SECTIONS)
return _extract_template_sections(template_path.read_text(encoding="utf-8"))
return [BodySection(heading) for heading in REQUIRED_SECTIONS]


def _coerce_sections(sections: Sequence[BodySection | str]) -> list[BodySection]:
return [section if isinstance(section, BodySection) else BodySection(section) for section in sections]


def validate_body(body: str, required_headings: list[str]) -> tuple[bool, list[str]]:
def validate_body(body: str, sections: Sequence[BodySection | str]) -> tuple[bool, list[str]]:
"""Validate PR body sections. Returns (is_valid, actual_headings)."""
expected = _coerce_sections(sections)
actual = _extract_h2_headings(body)
return actual == required_headings, actual

expected_headings = [section.heading for section in expected]
if len(expected_headings) != len(set(expected_headings)):
return False, actual
if len(actual) != len(set(actual)):
return False, actual

positions = {heading: index for index, heading in enumerate(expected_headings)}
if any(heading not in positions for heading in actual):
return False, actual

actual_positions = [positions[heading] for heading in actual]
if actual_positions != sorted(actual_positions):
return False, actual

actual_set = set(actual)
if any(section.required and section.heading not in actual_set for section in expected):
return False, actual

return True, actual


def report_invalid_body(
actual: list[str],
expected: list[str],
expected: Sequence[BodySection],
*,
file: TextIO,
) -> None:
expected_headings = [section.heading for section in expected]
required_headings = [section.heading for section in expected if section.required]
optional_headings = [section.heading for section in expected if not section.required]

print("::error::PR body headings do not match the repository template.", file=file)
print(f"\n Expected headings: {expected}", file=file)
print(f"\n Template order: {expected_headings}", file=file)
print(f" Required headings: {required_headings}", file=file)
print(f" Optional headings: {optional_headings}", file=file)
print(f" Actual headings: {actual}", file=file)
print(file=file)
print(" Each PR body should contain exactly these H2 sections:", file=file)
for section in expected:
print(f" ## {section}", file=file)
print(" Undeclared template H2 sections are required by default.", file=file)
print(" Prefix an optional template section with `<!-- pr-body:optional -->`.", file=file)
print(file=file)
print(" Commit convention reference (for the Summary section):", file=file)
print(" Commit convention reference (for the first required section):", file=file)
print(format_commit_convention_help_body(include_special_prefix_note=False), file=file)


Expand Down Expand Up @@ -124,15 +199,21 @@ def validate_body_cli(argv: Sequence[str] | None = None) -> int:
args = parser.parse_args(argv)

template_path = Path(args.template)
required = _load_template_headings(template_path)
try:
sections = _load_template_sections(template_path)
except ValueError as exc:
print(f"::error::Invalid PR template: {exc}")
return 1

print("::group::PR / body check")
print(f"Required headings: {required}")
print(f"Template headings: {[section.heading for section in sections]}")
print(f"Required headings: {[section.heading for section in sections if section.required]}")
print(f"Optional headings: {[section.heading for section in sections if not section.required]}")
print("::endgroup::")

is_valid, actual = validate_body(args.body, required)
is_valid, actual = validate_body(args.body, sections)
if not is_valid:
report_invalid_body(actual, required, file=sys.stdout)
report_invalid_body(actual, sections, file=sys.stdout)
return 1

print("PR body headings are valid.")
Expand Down
110 changes: 109 additions & 1 deletion tests/test_body_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@

from pathlib import Path

from zendev.body import _extract_h2_headings, validate_body, validate_body_cli
from zendev.body import (
BodySection,
_extract_h2_headings,
_extract_template_sections,
validate_body,
validate_body_cli,
)

VALID_BODY = """\
## Summary
Expand Down Expand Up @@ -64,6 +70,26 @@
None.
"""

OPTIONAL_TEMPLATE = """\
## Why

Explain the problem.

## What changed

Describe the change.

<!-- pr-body:optional -->
## Notes

Compatibility or risk notes.

<!-- pr-body:optional -->
## Next

Immediate follow-up work.
"""


def test_extract_h2_headings_normal():
assert _extract_h2_headings(VALID_BODY) == ["Summary", "Validation", "Notes"]
Expand All @@ -74,6 +100,48 @@ def test_extract_h2_headings_skips_fences():
assert _extract_h2_headings(body) == ["Summary", "Validation", "Notes"]


def test_extract_template_sections_defaults_unmarked_h2_to_required():
assert _extract_template_sections(OPTIONAL_TEMPLATE) == [
BodySection("Why", required=True),
BodySection("What changed", required=True),
BodySection("Notes", required=False),
BodySection("Next", required=False),
]


def test_extract_template_sections_supports_explicit_required_and_ignores_fenced_directives():
template = """\
<!-- pr-body:required -->
## Why

```md
<!-- pr-body:optional -->
## Hidden
```

<!-- pr-body:optional -->
## Notes
"""
assert _extract_template_sections(template) == [
BodySection("Why", required=True),
BodySection("Notes", required=False),
]


def test_extract_template_sections_rejects_ambiguous_or_dangling_directives():
ambiguous = "<!-- pr-body:optional -->\n<!-- pr-body:required -->\n## Why\n"
dangling = "## Why\n\n<!-- pr-body:optional -->\n"
duplicate = "## Why\n\n## Why\n"

for template in (ambiguous, dangling, duplicate):
try:
_extract_template_sections(template)
except ValueError:
pass
else:
raise AssertionError("invalid template must fail closed")


def test_validate_body_valid():
ok, actual = validate_body(VALID_BODY, REQUIRED)
assert ok
Expand All @@ -92,6 +160,46 @@ def test_validate_body_empty():
assert actual == []


def test_validate_body_allows_optional_sections_to_be_omitted_or_present_in_order():
sections = _extract_template_sections(OPTIONAL_TEMPLATE)
minimal = "## Why\n\nReason.\n\n## What changed\n\nChange.\n"
with_next = minimal + "\n## Next\n\nFollow-up.\n"
with_all = minimal + "\n## Notes\n\nNone.\n\n## Next\n\nFollow-up.\n"

assert validate_body(minimal, sections)[0]
assert validate_body(with_next, sections)[0]
assert validate_body(with_all, sections)[0]


def test_validate_body_rejects_missing_required_extra_duplicate_or_out_of_order_sections():
sections = _extract_template_sections(OPTIONAL_TEMPLATE)
invalid_bodies = [
"## Why\n\nReason.\n",
"## Why\n\nReason.\n\n## What changed\n\nChange.\n\n## Validation\n\nTests.\n",
"## Why\n\nReason.\n\n## Why\n\nAgain.\n\n## What changed\n\nChange.\n",
"## What changed\n\nChange.\n\n## Why\n\nReason.\n",
"## Why\n\nReason.\n\n## Notes\n\nNone.\n\n## What changed\n\nChange.\n",
]

for body in invalid_bodies:
assert not validate_body(body, sections)[0]


def test_validate_body_cli_supports_optional_template_sections(tmp_path: Path) -> None:
template = tmp_path / "pull_request_template.md"
template.write_text(OPTIONAL_TEMPLATE, encoding="utf-8")
body = "## Why\n\nReason.\n\n## What changed\n\nChange.\n"

assert validate_body_cli([body, "--template", str(template)]) == 0


def test_validate_body_cli_rejects_invalid_template_directive(tmp_path: Path) -> None:
template = tmp_path / "pull_request_template.md"
template.write_text("## Summary\n\n<!-- pr-body:optional -->\n", encoding="utf-8")

assert validate_body_cli(["## Summary\n", "--template", str(template)]) == 1


def test_validate_body_cli_success_with_required_checklist(tmp_path: Path) -> None:
template = tmp_path / "pull_request_template.md"
template.write_text(CHECKLIST_TEMPLATE, encoding="utf-8")
Expand Down
Loading