process: add PR body post-check gate and tooling (#392) #393
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import shutil
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -12,11 +13,31 @@ from pathlib import Path
|
|||||||
|
|
||||||
HEADER_PATTERN = re.compile(r"^##\s+\S+", re.MULTILINE)
|
HEADER_PATTERN = re.compile(r"^##\s+\S+", re.MULTILINE)
|
||||||
LIST_ITEM_PATTERN = re.compile(r"^\s*(?:-|\*|\d+\.)\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]:
|
def validate_pr_body_text(text: str) -> list[str]:
|
||||||
errors: 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)")
|
errors.append("body contains escaped newline sequence (\\n)")
|
||||||
if text.count("```") % 2 != 0:
|
if text.count("```") % 2 != 0:
|
||||||
errors.append("body has unbalanced fenced code blocks (``` count is odd)")
|
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:
|
def fetch_pr_body(pr_number: int) -> str:
|
||||||
|
tea_binary = resolve_tea_binary()
|
||||||
try:
|
try:
|
||||||
completed = subprocess.run(
|
completed = subprocess.run(
|
||||||
[
|
[
|
||||||
"tea",
|
tea_binary,
|
||||||
"api",
|
"api",
|
||||||
"-R",
|
"-R",
|
||||||
"origin",
|
"origin",
|
||||||
|
|||||||
@@ -24,9 +24,24 @@ def test_validate_pr_body_text_detects_escaped_newline() -> None:
|
|||||||
assert any("escaped newline" in err for err in errors)
|
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()
|
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) == []
|
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()
|
module = _load_module()
|
||||||
|
|
||||||
def fake_run(cmd, check, capture_output, text): # noqa: ANN001
|
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 check is True
|
||||||
assert capture_output is True
|
assert capture_output is True
|
||||||
assert text is True
|
assert text is True
|
||||||
return SimpleNamespace(stdout=json.dumps({"body": "## Summary\n- item"}))
|
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)
|
monkeypatch.setattr(module.subprocess, "run", fake_run)
|
||||||
assert module.fetch_pr_body(391) == "## Summary\n- item"
|
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
|
def fake_run(cmd, check, capture_output, text): # noqa: ANN001
|
||||||
return SimpleNamespace(stdout=json.dumps({"body": 123}))
|
return SimpleNamespace(stdout=json.dumps({"body": 123}))
|
||||||
|
|
||||||
|
monkeypatch.setattr(module, "resolve_tea_binary", lambda: "/tmp/tea-bin")
|
||||||
monkeypatch.setattr(module.subprocess, "run", fake_run)
|
monkeypatch.setattr(module.subprocess, "run", fake_run)
|
||||||
with pytest.raises(RuntimeError):
|
with pytest.raises(RuntimeError):
|
||||||
module.fetch_pr_body(391)
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user