Skip to content
Open
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
51 changes: 51 additions & 0 deletions .github/aicodingflow-tests/test_validate_pr_body.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,57 @@ def test_html_comment_inside_fenced_code_is_literal_content(self) -> None:
body = "## Summary\nChange.\n## Testing\n```html\n<!-- test fixture -->\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()
19 changes: 15 additions & 4 deletions .github/scripts/validate_pr_body.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading