From 7d24f19cc46a2757fc985fb8a1de1bbe0ee9f953 Mon Sep 17 00:00:00 2001 From: agentson Date: Mon, 2 Mar 2026 18:19:42 +0900 Subject: [PATCH 1/3] process: add mandatory PR body post-check step (#392) --- docs/commands.md | 12 ++++ docs/workflow.md | 10 ++++ scripts/validate_docs_sync.py | 21 +++++++ scripts/validate_pr_body.py | 94 ++++++++++++++++++++++++++++++++ tests/test_validate_docs_sync.py | 41 ++++++++++++++ tests/test_validate_pr_body.py | 84 ++++++++++++++++++++++++++++ 6 files changed, 262 insertions(+) create mode 100644 scripts/validate_pr_body.py create mode 100644 tests/test_validate_pr_body.py diff --git a/docs/commands.md b/docs/commands.md index 5e85f94..84c2aac 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -59,6 +59,18 @@ scripts/tea_comment.sh 374 /tmp/comment.md - `scripts/tea_comment.sh` accepts stdin with `-` as body source. - The helper fails fast when body looks like escaped-newline text only. +#### PR Body Post-Check (Mandatory) + +PR 생성 직후 본문이 `\n` 문자열로 깨지지 않았는지 반드시 확인한다. + +```bash +python3 scripts/validate_pr_body.py --pr +``` + +검증 실패 시: +- PR 본문을 API patch 또는 파일 기반 본문으로 즉시 수정 +- 같은 명령으로 재검증 통과 후에만 리뷰/머지 진행 + #### ❌ TTY Error - Interactive Confirmation Fails ```bash ~/bin/tea issues create --repo X --title "Y" --description "Z" diff --git a/docs/workflow.md b/docs/workflow.md index ecbcdcd..5549b20 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -128,6 +128,16 @@ tea pr create \ --description "$PR_BODY" ``` +PR 생성 직후 본문 무결성 검증(필수): + +```bash +python3 scripts/validate_pr_body.py --pr +``` + +강제 규칙: +- 검증 실패(`\n` 리터럴, 코드펜스 불균형, 헤더/리스트 누락) 상태에서는 리뷰/머지 금지 +- 본문 수정 후 같은 명령으로 재검증 통과 필요 + 금지 패턴: - `-d "line1\nline2"` (웹 UI에 `\n` 문자 그대로 노출될 수 있음) diff --git a/scripts/validate_docs_sync.py b/scripts/validate_docs_sync.py index 0dc83c2..11893fc 100644 --- a/scripts/validate_docs_sync.py +++ b/scripts/validate_docs_sync.py @@ -92,6 +92,25 @@ def validate_testing_doc_has_dynamic_count_guidance(errors: list[str]) -> None: ) +def validate_pr_body_postcheck_guidance(errors: list[str]) -> None: + required_tokens = { + "commands": ( + "PR Body Post-Check (Mandatory)", + "python3 scripts/validate_pr_body.py --pr ", + ), + "workflow": ( + "PR 생성 직후 본문 무결성 검증(필수)", + "python3 scripts/validate_pr_body.py --pr ", + ), + } + for key, tokens in required_tokens.items(): + path = REQUIRED_FILES[key] + text = _read(path) + for token in tokens: + if token not in text: + errors.append(f"{path}: missing PR body post-check guidance token -> {token}") + + def main() -> int: errors: list[str] = [] @@ -117,6 +136,7 @@ def main() -> int: validate_summary_docs_reference_core_docs(errors) validate_commands_endpoint_duplicates(errors) validate_testing_doc_has_dynamic_count_guidance(errors) + validate_pr_body_postcheck_guidance(errors) if errors: print("[FAIL] docs sync validation failed") @@ -128,6 +148,7 @@ def main() -> int: print("[OK] summary docs link to core docs and links resolve") print("[OK] commands endpoint rows have no duplicates") print("[OK] testing doc includes dynamic count guidance") + print("[OK] PR body post-check guidance exists in commands/workflow docs") return 0 diff --git a/scripts/validate_pr_body.py b/scripts/validate_pr_body.py new file mode 100644 index 0000000..a95344a --- /dev/null +++ b/scripts/validate_pr_body.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Validate PR body formatting to prevent escaped-newline artifacts.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +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) + + +def validate_pr_body_text(text: str) -> list[str]: + errors: list[str] = [] + if "\\n" in text and "\n" not in text: + errors.append("body contains escaped newline sequence (\\n)") + if text.count("```") % 2 != 0: + errors.append("body has unbalanced fenced code blocks (``` count is odd)") + if not HEADER_PATTERN.search(text): + errors.append("body is missing markdown section headers (e.g. '## Summary')") + if not LIST_ITEM_PATTERN.search(text): + errors.append("body is missing markdown list items") + return errors + + +def fetch_pr_body(pr_number: int) -> str: + try: + completed = subprocess.run( + [ + "tea", + "api", + "-R", + "origin", + f"repos/{{owner}}/{{repo}}/pulls/{pr_number}", + ], + check=True, + capture_output=True, + text=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError) as exc: + raise RuntimeError(f"failed to fetch PR #{pr_number}: {exc}") from exc + + try: + payload = json.loads(completed.stdout) + except json.JSONDecodeError as exc: + raise RuntimeError(f"failed to parse PR payload for #{pr_number}: {exc}") from exc + + body = payload.get("body", "") + if not isinstance(body, str): + raise RuntimeError(f"unexpected PR body type for #{pr_number}: {type(body).__name__}") + return body + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Validate PR body markdown formatting and escaped-newline artifacts." + ) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--pr", type=int, help="PR number to fetch via `tea api`") + group.add_argument("--body-file", type=Path, help="Path to markdown body file") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if args.body_file is not None: + if not args.body_file.exists(): + print(f"[FAIL] body file not found: {args.body_file}") + return 1 + body = args.body_file.read_text(encoding="utf-8") + source = f"file:{args.body_file}" + else: + body = fetch_pr_body(args.pr) + source = f"pr:{args.pr}" + + errors = validate_pr_body_text(body) + if errors: + print("[FAIL] PR body validation failed") + print(f"- source: {source}") + for err in errors: + print(f"- {err}") + return 1 + + print("[OK] PR body validation passed") + print(f"- source: {source}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_validate_docs_sync.py b/tests/test_validate_docs_sync.py index 5c8309f..2daf78d 100644 --- a/tests/test_validate_docs_sync.py +++ b/tests/test_validate_docs_sync.py @@ -121,3 +121,44 @@ def test_validate_testing_doc_has_dynamic_count_guidance(monkeypatch) -> None: monkeypatch.setattr(module, "_read", fake_read) module.validate_testing_doc_has_dynamic_count_guidance(errors) assert errors == [] + + +def test_validate_pr_body_postcheck_guidance_passes(monkeypatch) -> None: + module = _load_module() + errors: list[str] = [] + fake_docs = { + str(module.REQUIRED_FILES["commands"]): ( + "PR Body Post-Check (Mandatory)\n" + "python3 scripts/validate_pr_body.py --pr \n" + ), + str(module.REQUIRED_FILES["workflow"]): ( + "PR 생성 직후 본문 무결성 검증(필수)\n" + "python3 scripts/validate_pr_body.py --pr \n" + ), + } + + def fake_read(path: Path) -> str: + return fake_docs[str(path)] + + monkeypatch.setattr(module, "_read", fake_read) + module.validate_pr_body_postcheck_guidance(errors) + assert errors == [] + + +def test_validate_pr_body_postcheck_guidance_reports_missing_tokens( + monkeypatch, +) -> None: + module = _load_module() + errors: list[str] = [] + fake_docs = { + str(module.REQUIRED_FILES["commands"]): "PR Body Post-Check (Mandatory)\n", + str(module.REQUIRED_FILES["workflow"]): "PR Body Post-Check\n", + } + + def fake_read(path: Path) -> str: + return fake_docs[str(path)] + + monkeypatch.setattr(module, "_read", fake_read) + module.validate_pr_body_postcheck_guidance(errors) + assert any("commands.md" in err for err in errors) + assert any("workflow.md" in err for err in errors) diff --git a/tests/test_validate_pr_body.py b/tests/test_validate_pr_body.py new file mode 100644 index 0000000..ad930a4 --- /dev/null +++ b/tests/test_validate_pr_body.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +def _load_module(): + script_path = Path(__file__).resolve().parents[1] / "scripts" / "validate_pr_body.py" + spec = importlib.util.spec_from_file_location("validate_pr_body", script_path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_validate_pr_body_text_detects_escaped_newline() -> None: + module = _load_module() + errors = module.validate_pr_body_text("## Summary\\n- item") + assert any("escaped newline" in err for err in errors) + + +def test_validate_pr_body_text_allows_literal_sequence_when_multiline() -> None: + module = _load_module() + text = "## Summary\n- escaped sequence example: \\\\n" + assert module.validate_pr_body_text(text) == [] + + +def test_validate_pr_body_text_detects_unbalanced_code_fence() -> None: + module = _load_module() + errors = module.validate_pr_body_text("## Summary\n- item\n```bash\necho hi\n") + assert any("unbalanced fenced code blocks" in err for err in errors) + + +def test_validate_pr_body_text_detects_missing_structure() -> None: + module = _load_module() + errors = module.validate_pr_body_text("plain text only") + assert any("missing markdown section headers" in err for err in errors) + assert any("missing markdown list items" in err for err in errors) + + +def test_validate_pr_body_text_passes_with_valid_markdown() -> None: + module = _load_module() + text = "\n".join( + [ + "## Summary", + "- item", + "", + "## Validation", + "```bash", + "pytest -q", + "```", + ] + ) + assert module.validate_pr_body_text(text) == [] + + +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 check is True + assert capture_output is True + assert text is True + return SimpleNamespace(stdout=json.dumps({"body": "## Summary\n- item"})) + + monkeypatch.setattr(module.subprocess, "run", fake_run) + assert module.fetch_pr_body(391) == "## Summary\n- item" + + +def test_fetch_pr_body_rejects_non_string_body(monkeypatch) -> None: + module = _load_module() + + def fake_run(cmd, check, capture_output, text): # noqa: ANN001 + return SimpleNamespace(stdout=json.dumps({"body": 123})) + + monkeypatch.setattr(module.subprocess, "run", fake_run) + with pytest.raises(RuntimeError): + module.fetch_pr_body(391) From f4f88273534269df6023c1f242782dec46c4c792 Mon Sep 17 00:00:00 2001 From: agentson Date: Mon, 2 Mar 2026 18:27:59 +0900 Subject: [PATCH 2/3] 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) From bd9286a39fb25a2b88d4a54f553a21f84024600e Mon Sep 17 00:00:00 2001 From: agentson Date: Mon, 2 Mar 2026 18:32:07 +0900 Subject: [PATCH 3/3] fix: require executable tea fallback binary (#392) --- scripts/validate_pr_body.py | 5 +++-- tests/test_validate_pr_body.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/scripts/validate_pr_body.py b/scripts/validate_pr_body.py index 8800517..2d6ae4d 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 os import shutil import re import subprocess @@ -28,7 +29,7 @@ def resolve_tea_binary() -> str: return tea_from_path tea_home = Path.home() / "bin" / "tea" - if tea_home.exists(): + if tea_home.exists() and tea_home.is_file() and os.access(tea_home, os.X_OK): return str(tea_home) raise RuntimeError("tea binary not found (checked PATH and ~/bin/tea)") @@ -63,7 +64,7 @@ def fetch_pr_body(pr_number: int) -> str: capture_output=True, text=True, ) - except (subprocess.CalledProcessError, FileNotFoundError) as exc: + except (subprocess.CalledProcessError, FileNotFoundError, PermissionError) as exc: raise RuntimeError(f"failed to fetch PR #{pr_number}: {exc}") from exc try: diff --git a/tests/test_validate_pr_body.py b/tests/test_validate_pr_body.py index 1d4f2ca..f6c7b21 100644 --- a/tests/test_validate_pr_body.py +++ b/tests/test_validate_pr_body.py @@ -106,7 +106,21 @@ def test_resolve_tea_binary_falls_back_to_home_bin(monkeypatch, tmp_path) -> Non tea_home = tmp_path / "bin" / "tea" tea_home.parent.mkdir(parents=True) tea_home.write_text("#!/usr/bin/env bash\n", encoding="utf-8") + tea_home.chmod(0o755) monkeypatch.setattr(module.shutil, "which", lambda _: None) monkeypatch.setattr(module.Path, "home", lambda: tmp_path) assert module.resolve_tea_binary() == str(tea_home) + + +def test_resolve_tea_binary_rejects_non_executable_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("not executable\n", encoding="utf-8") + tea_home.chmod(0o644) + + monkeypatch.setattr(module.shutil, "which", lambda _: None) + monkeypatch.setattr(module.Path, "home", lambda: tmp_path) + with pytest.raises(RuntimeError): + module.resolve_tea_binary()