From 7be6908e3e8c263adf39863d7794e366603ef725 Mon Sep 17 00:00:00 2001 From: Zhan Rongrui <46243324+zrr1999@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:16:32 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20feat(body):=20support=20optiona?= =?UTF-8?q?l=20PR=20template=20sections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/zendev/body.py | 113 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 97 insertions(+), 16 deletions(-) diff --git a/src/zendev/body.py b/src/zendev/body.py index dfdde08..e08ed89 100644 --- a/src/zendev/body.py +++ b/src/zendev/body.py @@ -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 @@ -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"^$") + + +@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]: @@ -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 ``.", 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) @@ -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.") From 3a3806488dc31612989f01263399c6f44ec6630b Mon Sep 17 00:00:00 2001 From: Zhan Rongrui <46243324+zrr1999@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:16:56 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=85=20test(body):=20cover=20optional?= =?UTF-8?q?=20PR=20sections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_body_cli.py | 110 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 109 insertions(+), 1 deletion(-) diff --git a/tests/test_body_cli.py b/tests/test_body_cli.py index da46c1c..0de7d61 100644 --- a/tests/test_body_cli.py +++ b/tests/test_body_cli.py @@ -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 @@ -64,6 +70,26 @@ None. """ +OPTIONAL_TEMPLATE = """\ +## Why + +Explain the problem. + +## What changed + +Describe the change. + + +## Notes + +Compatibility or risk notes. + + +## Next + +Immediate follow-up work. +""" + def test_extract_h2_headings_normal(): assert _extract_h2_headings(VALID_BODY) == ["Summary", "Validation", "Notes"] @@ -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 = """\ + +## Why + +```md + +## Hidden +``` + + +## 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 = "\n\n## Why\n" + dangling = "## Why\n\n\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 @@ -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\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") From 6b9f7aee5ef6489d0713541baf062218fa9c381e Mon Sep 17 00:00:00 2001 From: Zhan Rongrui <46243324+zrr1999@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:17:14 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=93=9D=20docs(body):=20describe=20opt?= =?UTF-8?q?ional=20section=20markers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- actions/validate-body/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/validate-body/action.yml b/actions/validate-body/action.yml index e3ad7e8..3525ad2 100644 --- a/actions/validate-body/action.yml +++ b/actions/validate-body/action.yml @@ -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: From 7590d264d9f2df22a4af7666be3766b13fc7e8ac Mon Sep 17 00:00:00 2001 From: Zhan Rongrui <46243324+zrr1999@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:17:49 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=93=9D=20docs(body):=20document=20sec?= =?UTF-8?q?tion=20requirement=20markers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 73bc21d..8ac704f 100644 --- a/README.md +++ b/README.md @@ -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 +``; `` 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.