From 746e0ca2b0c6258cfc0410ccc8b1021f81208526 Mon Sep 17 00:00:00 2001 From: echuan-agent Date: Wed, 4 Nov 2026 05:06:40 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(pr-body):=20=E8=AF=86=E5=88=AB=20Common?= =?UTF-8?q?Mark=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: From 20988a931925df98c1a69a122fe21d372eb47b4e Mon Sep 17 00:00:00 2001 From: "Terry.Mao" Date: Thu, 5 Nov 2026 01:14:20 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(pr-body):=20=E7=A9=BA=E4=BA=8C=E7=BA=A7?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E4=B8=8D=E5=86=8D=E8=AE=A1=E5=85=A5=E4=B8=8A?= =?UTF-8?q?=E4=B8=80=E7=AB=A0=E8=8A=82=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/aicodingflow-tests/test_validate_pr_body.py | 6 ++++++ .github/scripts/validate_pr_body.py | 10 +++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/aicodingflow-tests/test_validate_pr_body.py b/.github/aicodingflow-tests/test_validate_pr_body.py index c474d73..7235607 100644 --- a/.github/aicodingflow-tests/test_validate_pr_body.py +++ b/.github/aicodingflow-tests/test_validate_pr_body.py @@ -94,6 +94,12 @@ def test_hash_inside_heading_is_not_a_closing_sequence(self) -> None: 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 f6744a0..ad1a6af 100644 --- a/.github/scripts/validate_pr_body.py +++ b/.github/scripts/validate_pr_body.py @@ -66,11 +66,11 @@ def parse_sections(body: str) -> tuple[dict[str, str], dict[str, int]]: fence_length = len(opening.group(1)) continue heading = HEADING.fullmatch(line) - name = normalize_heading(heading.group(1)) if heading else None - if name: - current_section = name - sections[current_section] = [] - headings[current_section] = line_number + if heading: + 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