From f4f88273534269df6023c1f242782dec46c4c792 Mon Sep 17 00:00:00 2001 From: agentson Date: Mon, 2 Mar 2026 18:27:59 +0900 Subject: [PATCH] fix: harden PR body validator for mixed escaped-newline and tea path (#392) --- scripts/validate_pr_body.py | 26 ++++++++++++++++++++++++-- tests/test_validate_pr_body.py | 34 +++++++++++++++++++++++++++++++--- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/scripts/validate_pr_body.py b/scripts/validate_pr_body.py index a95344a..8800517 100644 --- a/scripts/validate_pr_body.py +++ b/scripts/validate_pr_body.py @@ -5,6 +5,7 @@ from __future__ import annotations import argparse import json +import shutil import re import subprocess import sys @@ -12,11 +13,31 @@ from pathlib import Path HEADER_PATTERN = re.compile(r"^##\s+\S+", re.MULTILINE) LIST_ITEM_PATTERN = re.compile(r"^\s*(?:-|\*|\d+\.)\s+\S+", re.MULTILINE) +FENCED_CODE_PATTERN = re.compile(r"```.*?```", re.DOTALL) +INLINE_CODE_PATTERN = re.compile(r"`[^`]*`") + + +def _strip_code_segments(text: str) -> str: + without_fences = FENCED_CODE_PATTERN.sub("", text) + return INLINE_CODE_PATTERN.sub("", without_fences) + + +def resolve_tea_binary() -> str: + tea_from_path = shutil.which("tea") + if tea_from_path: + return tea_from_path + + tea_home = Path.home() / "bin" / "tea" + if tea_home.exists(): + return str(tea_home) + + raise RuntimeError("tea binary not found (checked PATH and ~/bin/tea)") def validate_pr_body_text(text: str) -> list[str]: errors: list[str] = [] - if "\\n" in text and "\n" not in text: + searchable = _strip_code_segments(text) + if "\\n" in searchable: errors.append("body contains escaped newline sequence (\\n)") if text.count("```") % 2 != 0: errors.append("body has unbalanced fenced code blocks (``` count is odd)") @@ -28,10 +49,11 @@ def validate_pr_body_text(text: str) -> list[str]: def fetch_pr_body(pr_number: int) -> str: + tea_binary = resolve_tea_binary() try: completed = subprocess.run( [ - "tea", + tea_binary, "api", "-R", "origin", diff --git a/tests/test_validate_pr_body.py b/tests/test_validate_pr_body.py index ad930a4..1d4f2ca 100644 --- a/tests/test_validate_pr_body.py +++ b/tests/test_validate_pr_body.py @@ -24,9 +24,24 @@ def test_validate_pr_body_text_detects_escaped_newline() -> None: assert any("escaped newline" in err for err in errors) -def test_validate_pr_body_text_allows_literal_sequence_when_multiline() -> None: +def test_validate_pr_body_text_detects_escaped_newline_in_multiline_body() -> None: module = _load_module() - text = "## Summary\n- escaped sequence example: \\\\n" + text = "## Summary\n- first line\n- broken line with \\n literal" + errors = module.validate_pr_body_text(text) + assert any("escaped newline" in err for err in errors) + + +def test_validate_pr_body_text_allows_escaped_newline_in_code_blocks() -> None: + module = _load_module() + text = "\n".join( + [ + "## Summary", + "- example uses `\\n` for explanation", + "```bash", + "printf 'line1\\nline2\\n'", + "```", + ] + ) assert module.validate_pr_body_text(text) == [] @@ -63,12 +78,13 @@ def test_fetch_pr_body_reads_body_from_tea_api(monkeypatch) -> None: module = _load_module() def fake_run(cmd, check, capture_output, text): # noqa: ANN001 - assert "tea" in cmd[0] + assert cmd[0] == "/tmp/tea-bin" assert check is True assert capture_output is True assert text is True return SimpleNamespace(stdout=json.dumps({"body": "## Summary\n- item"})) + monkeypatch.setattr(module, "resolve_tea_binary", lambda: "/tmp/tea-bin") monkeypatch.setattr(module.subprocess, "run", fake_run) assert module.fetch_pr_body(391) == "## Summary\n- item" @@ -79,6 +95,18 @@ def test_fetch_pr_body_rejects_non_string_body(monkeypatch) -> None: def fake_run(cmd, check, capture_output, text): # noqa: ANN001 return SimpleNamespace(stdout=json.dumps({"body": 123})) + monkeypatch.setattr(module, "resolve_tea_binary", lambda: "/tmp/tea-bin") monkeypatch.setattr(module.subprocess, "run", fake_run) with pytest.raises(RuntimeError): module.fetch_pr_body(391) + + +def test_resolve_tea_binary_falls_back_to_home_bin(monkeypatch, tmp_path) -> None: + module = _load_module() + tea_home = tmp_path / "bin" / "tea" + tea_home.parent.mkdir(parents=True) + tea_home.write_text("#!/usr/bin/env bash\n", encoding="utf-8") + + monkeypatch.setattr(module.shutil, "which", lambda _: None) + monkeypatch.setattr(module.Path, "home", lambda: tmp_path) + assert module.resolve_tea_binary() == str(tea_home)