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.
|
- `scripts/tea_comment.sh` accepts stdin with `-` as body source.
|
||||||
- The helper fails fast when body looks like escaped-newline text only.
|
- 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
|
#### ❌ TTY Error - Interactive Confirmation Fails
|
||||||
```bash
|
```bash
|
||||||
~/bin/tea issues create --repo X --title "Y" --description "Z"
|
~/bin/tea issues create --repo X --title "Y" --description "Z"
|
||||||
|
|||||||
@@ -128,6 +128,16 @@ tea pr create \
|
|||||||
--description "$PR_BODY"
|
--description "$PR_BODY"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
PR 생성 직후 본문 무결성 검증(필수):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/validate_pr_body.py --pr <PR_NUMBER>
|
||||||
|
```
|
||||||
|
|
||||||
|
강제 규칙:
|
||||||
|
- 검증 실패(`\n` 리터럴, 코드펜스 불균형, 헤더/리스트 누락) 상태에서는 리뷰/머지 금지
|
||||||
|
- 본문 수정 후 같은 명령으로 재검증 통과 필요
|
||||||
|
|
||||||
금지 패턴:
|
금지 패턴:
|
||||||
|
|
||||||
- `-d "line1\nline2"` (웹 UI에 `\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:
|
def main() -> int:
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
|
|
||||||
@@ -117,6 +136,7 @@ def main() -> int:
|
|||||||
validate_summary_docs_reference_core_docs(errors)
|
validate_summary_docs_reference_core_docs(errors)
|
||||||
validate_commands_endpoint_duplicates(errors)
|
validate_commands_endpoint_duplicates(errors)
|
||||||
validate_testing_doc_has_dynamic_count_guidance(errors)
|
validate_testing_doc_has_dynamic_count_guidance(errors)
|
||||||
|
validate_pr_body_postcheck_guidance(errors)
|
||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
print("[FAIL] docs sync validation failed")
|
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] summary docs link to core docs and links resolve")
|
||||||
print("[OK] commands endpoint rows have no duplicates")
|
print("[OK] commands endpoint rows have no duplicates")
|
||||||
print("[OK] testing doc includes dynamic count guidance")
|
print("[OK] testing doc includes dynamic count guidance")
|
||||||
|
print("[OK] PR body post-check guidance exists in commands/workflow docs")
|
||||||
return 0
|
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)
|
monkeypatch.setattr(module, "_read", fake_read)
|
||||||
module.validate_testing_doc_has_dynamic_count_guidance(errors)
|
module.validate_testing_doc_has_dynamic_count_guidance(errors)
|
||||||
assert 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