diff --git a/.github/aicodingflow-tests/test_validate_pr_body.py b/.github/aicodingflow-tests/test_validate_pr_body.py index abe209e..7235607 100644 --- a/.github/aicodingflow-tests/test_validate_pr_body.py +++ b/.github/aicodingflow-tests/test_validate_pr_body.py @@ -49,6 +49,57 @@ def test_html_comment_inside_fenced_code_is_literal_content(self) -> None: body = "## Summary\nChange.\n## Testing\n```html\n\n```\n## Risk\nLow.\n## How to revert\nRevert." validator.validate_body(body) + def test_accepts_plain_atx_h2(self) -> None: + body = "## Summary\nChange.\n## Testing\nPassed.\n## Risk\nLow.\n## How to revert\nRevert." + validator.validate_body(body) + self.assertEqual( + sorted(validator.section_contents(body)), + ["how to revert", "risk", "summary", "testing"], + ) + + def test_accepts_closing_hashes(self) -> None: + body = "## Summary ##\nChange.\n## Testing ##\nPassed.\n## Risk ##\nLow.\n## How to revert ##\nRevert." + validator.validate_body(body) + self.assertNotIn("summary ##", validator.section_contents(body)) + + def test_accepts_up_to_three_leading_spaces(self) -> None: + for indent in ("", " ", " ", " "): + with self.subTest(indent=len(indent)): + body = ( + f"{indent}## Summary\nChange.\n" + f"{indent}## Testing\nPassed.\n" + f"{indent}## Risk\nLow.\n" + f"{indent}## How to revert\nRevert." + ) + validator.validate_body(body) + + def test_rejects_four_space_indented_heading(self) -> None: + body = " ## Summary\nChange.\n## Testing\nPassed.\n## Risk\nLow.\n## How to revert\nRevert." + with self.assertRaisesRegex(SystemExit, "Summary"): + validator.validate_body(body) + + def test_rejects_third_level_heading(self) -> None: + body = "### Summary\nChange.\n## Testing\nPassed.\n## Risk\nLow.\n## How to revert\nRevert." + with self.assertRaisesRegex(SystemExit, "Summary"): + validator.validate_body(body) + + def test_rejects_heading_without_space(self) -> None: + body = "##Summary\nChange.\n## Testing\nPassed.\n## Risk\nLow.\n## How to revert\nRevert." + with self.assertRaisesRegex(SystemExit, "Summary"): + validator.validate_body(body) + + def test_hash_inside_heading_is_not_a_closing_sequence(self) -> None: + self.assertEqual(validator.normalize_heading("C#"), "c#") + self.assertEqual(validator.normalize_heading("Summary ##"), "summary") + self.assertEqual(validator.normalize_heading("Summary ## extra"), "summary ## extra") + self.assertIsNone(validator.normalize_heading("")) + + def test_empty_h2_does_not_fill_previous_section(self) -> None: + body = "## Summary\n## \n## Testing\nPassed.\n## Risk\nLow.\n## How to revert\nRevert." + self.assertEqual(validator.section_contents(body)["summary"], "") + with self.assertRaisesRegex(SystemExit, "Summary"): + validator.validate_body(body) + if __name__ == "__main__": unittest.main() diff --git a/.github/scripts/validate_pr_body.py b/.github/scripts/validate_pr_body.py index 3558d0c..ad1a6af 100644 --- a/.github/scripts/validate_pr_body.py +++ b/.github/scripts/validate_pr_body.py @@ -9,10 +9,20 @@ REQUIRED_SECTIONS = ("Summary", "Testing", "Risk", "How to revert") -HEADING = re.compile(r"^##[ \t]+(.+?)[ \t]*$", re.MULTILINE) +# CommonMark ATX heading: up to three leading spaces, then `##` plus space/tab. +HEADING = re.compile(r"^ {0,3}##[ \t]+(.+?)[ \t]*$") +# A closing sequence is only stripped when whitespace-separated and at line end, +# so `## C#` keeps its hash while `## Summary ##` normalises to `summary`. +CLOSING_HASHES = re.compile(r"[ \t]+#+$") FENCE_OPEN = re.compile(r"^ {0,3}(`{3,}|~{3,})([^\r\n]*)$") +def normalize_heading(text: str) -> str | None: + """Return the comparable section name for an ATX H2 heading text.""" + name = CLOSING_HASHES.sub("", text.strip()).strip().lower() + return name or None + + def strip_html_comments(line: str, in_comment: bool) -> tuple[str, bool]: visible = [] offset = 0 @@ -57,9 +67,10 @@ def parse_sections(body: str) -> tuple[dict[str, str], dict[str, int]]: continue heading = HEADING.fullmatch(line) if heading: - current_section = heading.group(1).strip().lower() - sections[current_section] = [] - headings[current_section] = line_number + current_section = normalize_heading(heading.group(1)) + if current_section is not None: + sections[current_section] = [] + headings[current_section] = line_number elif current_section is not None: sections[current_section].append(line) return {name: "\n".join(lines).strip() for name, lines in sections.items()}, headings