Compare commits
2 Commits
fix/400
...
efa43e2c97
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efa43e2c97 | ||
|
|
b708e8b4ed |
@@ -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_NUMBER>
|
||||
```
|
||||
|
||||
검증 실패 시:
|
||||
- PR 본문을 API patch 또는 파일 기반 본문으로 즉시 수정
|
||||
- 같은 명령으로 재검증 통과 후에만 리뷰/머지 진행
|
||||
|
||||
#### ❌ TTY Error - Interactive Confirmation Fails
|
||||
```bash
|
||||
~/bin/tea issues create --repo X --title "Y" --description "Z"
|
||||
|
||||
@@ -128,6 +128,16 @@ tea pr create \
|
||||
--description "$PR_BODY"
|
||||
```
|
||||
|
||||
PR 생성 직후 본문 무결성 검증(필수):
|
||||
|
||||
```bash
|
||||
python3 scripts/validate_pr_body.py --pr <PR_NUMBER>
|
||||
```
|
||||
|
||||
강제 규칙:
|
||||
- 검증 실패(`\n` 리터럴, 코드펜스 불균형, 헤더/리스트 누락) 상태에서는 리뷰/머지 금지
|
||||
- 본문 수정 후 같은 명령으로 재검증 통과 필요
|
||||
|
||||
금지 패턴:
|
||||
|
||||
- `-d "line1\nline2"` (웹 UI에 `\n` 문자 그대로 노출될 수 있음)
|
||||
|
||||
@@ -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 <PR_NUMBER>",
|
||||
),
|
||||
"workflow": (
|
||||
"PR 생성 직후 본문 무결성 검증(필수)",
|
||||
"python3 scripts/validate_pr_body.py --pr <PR_NUMBER>",
|
||||
),
|
||||
}
|
||||
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
|
||||
|
||||
|
||||
|
||||
94
scripts/validate_pr_body.py
Normal file
94
scripts/validate_pr_body.py
Normal file
@@ -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())
|
||||
@@ -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 <PR_NUMBER>\n"
|
||||
),
|
||||
str(module.REQUIRED_FILES["workflow"]): (
|
||||
"PR 생성 직후 본문 무결성 검증(필수)\n"
|
||||
"python3 scripts/validate_pr_body.py --pr <PR_NUMBER>\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)
|
||||
|
||||
84
tests/test_validate_pr_body.py
Normal file
84
tests/test_validate_pr_body.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user