From 746e0ca2b0c6258cfc0410ccc8b1021f81208526 Mon Sep 17 00:00:00 2001 From: echuan-agent Date: Wed, 4 Nov 2026 05:06:40 +0000 Subject: [PATCH] =?UTF-8?q?fix(pr-body):=20=E8=AF=86=E5=88=AB=20CommonMark?= =?UTF-8?q?=20=E5=90=88=E6=B3=95=20H2=20=E5=86=99=E6=B3=95=20(#334)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原因:HEADING 正则不接受 0–3 前导空格、也不剥离 ATX 闭合井号, `## Summary ##` 被解析成 `summary ##`、` ## Summary` 完全不匹配, 四章齐全的正文被判缺章,create-pr 无法创建/更新 PR。 改动: - HEADING 改为 `^ {0,3}##[ \t]+(.+?)[ \t]*$` - 新增 normalize_heading:仅在「空白 + 行尾 #」时剥离闭合井号,`## C#` 仍是 `c#` - fence / HTML 注释屏蔽逻辑未改 - 测试补 3 正 3 负,另加 1 条闭合井号不误剥 验证:python3 -m unittest discover -s .github/aicodingflow-tests → 533 tests OK --- .../test_validate_pr_body.py | 45 +++++++++++++++++++ .github/scripts/validate_pr_body.py | 17 +++++-- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/.github/aicodingflow-tests/test_validate_pr_body.py b/.github/aicodingflow-tests/test_validate_pr_body.py index abe209e..c474d73 100644 --- a/.github/aicodingflow-tests/test_validate_pr_body.py +++ b/.github/aicodingflow-tests/test_validate_pr_body.py @@ -49,6 +49,51 @@ 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("")) + if __name__ == "__main__": unittest.main() diff --git a/.github/scripts/validate_pr_body.py b/.github/scripts/validate_pr_body.py index 3558d0c..f6744a0 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 @@ -56,8 +66,9 @@ def parse_sections(body: str) -> tuple[dict[str, str], dict[str, int]]: fence_length = len(opening.group(1)) continue heading = HEADING.fullmatch(line) - if heading: - current_section = heading.group(1).strip().lower() + name = normalize_heading(heading.group(1)) if heading else None + if name: + current_section = name sections[current_section] = [] headings[current_section] = line_number elif current_section is not None: