Files
wehub-resource-sync f3d80b4628
Auto Release / auto-release (push) Failing after 1s
CI / lint (push) Failing after 0s
CI / screenshot-quality (push) Failing after 3s
CI / pr-policy (push) Has been skipped
CI / test (3.11) (push) Failing after 0s
CI / test (3.12) (push) Failing after 0s
CI / test (3.13) (push) Failing after 3s
CI / coverage (push) Failing after 1s
Legibility / legibility (push) Failing after 3s
chore: import upstream snapshot with attribution
2026-07-13 12:31:48 +08:00

263 lines
8.7 KiB
Python

#!/usr/bin/env python3
"""Validate pull request body and changed-file policy gates."""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import unquote, urlparse
IMAGE_EXT_RE = re.compile(r"\.(?:png|jpe?g|gif|svg|webp)(?:[?#][^\s)]*)?$", re.IGNORECASE)
MARKDOWN_IMAGE_RE = re.compile(r"!\[[^\]]*]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)", re.IGNORECASE)
HTML_IMAGE_RE = re.compile(r"<img\b[^>]*\bsrc=[\"']([^\"']+)[\"']", re.IGNORECASE)
PLAIN_IMAGE_URL_RE = re.compile(
r"https?://[^\s<>()\"']+\.(?:png|jpe?g|gif|svg|webp)(?:[?#][^\s<>()\"']*)?", re.IGNORECASE
)
SUMMARY_HEADINGS = {
"summary",
"problem",
"goal",
"refactor summary",
"fix summary",
}
VALIDATION_HEADINGS = {
"validation",
"test plan",
"results",
}
EVIDENCE_REQUIRED_PATHS = (
"claude_tap/",
"docs/support-matrix.md",
)
EVIDENCE_SCREENSHOT_ROOT = ".agents/evidence/pr/"
EVIDENCE_SCREENSHOT_KEYWORDS = (
"browser",
"dashboard",
"e2e",
"session",
"trace",
"viewer",
)
REAL_EVIDENCE_MARKERS = (
".traces/",
"--run-real-e2e",
"claude-tap --tap-client",
"dashboard",
"jsonl",
"real e2e",
"real trace",
"run_real_e2e",
"trace viewer",
"uv run claude-tap",
"viewer html",
)
SECRET_PATTERNS = (
re.compile(r"\bsk-[A-Za-z0-9_-]{20,}"),
re.compile(r"\bBearer\s+eyJ[A-Za-z0-9_.-]+"),
re.compile(r"\b(?:ANTHROPIC|OPENAI|OPENROUTER|GITHUB)_[A-Z0-9_]*KEY\s*="),
)
@dataclass(frozen=True)
class PolicyResult:
ok: bool
failures: tuple[str, ...]
warnings: tuple[str, ...]
def _heading_names(body: str) -> set[str]:
headings: set[str] = set()
for line in body.splitlines():
match = re.match(r"^\s{0,3}#{1,6}\s+(.+?)\s*$", line)
if match:
headings.add(match.group(1).strip().lower())
return headings
def _image_urls(body: str) -> list[str]:
urls = [match.group(1).strip() for match in MARKDOWN_IMAGE_RE.finditer(body)]
urls.extend(match.group(1).strip() for match in HTML_IMAGE_RE.finditer(body))
urls.extend(match.group(0).strip() for match in PLAIN_IMAGE_URL_RE.finditer(body))
unique: list[str] = []
seen: set[str] = set()
for url in urls:
if url not in seen:
seen.add(url)
unique.append(url)
return unique
def _raw_image_urls(urls: list[str]) -> list[str]:
return [url for url in urls if "://raw.githubusercontent.com/" in url and IMAGE_EXT_RE.search(url)]
def _raw_image_paths(urls: list[str]) -> list[str]:
paths: list[str] = []
for url in urls:
parsed = urlparse(url)
if parsed.netloc != "raw.githubusercontent.com":
continue
parts = [unquote(part) for part in parsed.path.split("/") if part]
if ".agents" in parts:
paths.append("/".join(parts[parts.index(".agents") :]))
elif len(parts) >= 4:
paths.append("/".join(parts[3:]))
return paths
def _body_without_image_references(body: str) -> str:
stripped = MARKDOWN_IMAGE_RE.sub("", body)
stripped = HTML_IMAGE_RE.sub("", stripped)
return PLAIN_IMAGE_URL_RE.sub("", stripped)
def _has_real_evidence_source(body: str) -> bool:
lower = _body_without_image_references(body).lower()
return any(marker in lower for marker in REAL_EVIDENCE_MARKERS)
def _evidence_required_paths(changed_files: list[str]) -> list[str]:
required: list[str] = []
for path in changed_files:
normalized = path.strip()
if not normalized:
continue
if normalized.startswith(EVIDENCE_REQUIRED_PATHS):
required.append(normalized)
return required
def _dangerous_files(changed_files: list[str]) -> list[str]:
dangerous: list[str] = []
for path in changed_files:
normalized = path.strip()
if not normalized:
continue
name = Path(normalized).name.lower()
suffix = Path(normalized).suffix.lower()
if normalized.startswith(".traces/") and suffix in {".jsonl", ".html", ".log"}:
dangerous.append(normalized)
elif name.startswith("trace_") and suffix in {".jsonl", ".html", ".log"}:
dangerous.append(normalized)
elif name in {".env", "id_rsa", "id_ed25519"}:
dangerous.append(normalized)
return dangerous
def validate_policy(body: str, changed_files: list[str]) -> PolicyResult:
failures: list[str] = []
warnings: list[str] = []
body = body.strip()
if not body:
failures.append("PR body is empty")
return PolicyResult(ok=False, failures=tuple(failures), warnings=())
headings = _heading_names(body)
if not headings.intersection(SUMMARY_HEADINGS):
failures.append("PR body is missing a Summary, Problem, Goal, or equivalent section")
if not headings.intersection(VALIDATION_HEADINGS):
failures.append("PR body is missing a Validation, Test plan, or Results section")
for pattern in SECRET_PATTERNS:
if pattern.search(body):
failures.append("PR body appears to contain a secret or bearer token")
break
image_urls = _image_urls(body)
raw_urls = _raw_image_urls(image_urls)
non_raw_urls = [url for url in image_urls if url not in raw_urls]
if non_raw_urls:
failures.append("PR image evidence must use raw.githubusercontent.com URLs")
evidence_paths = _evidence_required_paths(changed_files)
if evidence_paths and not raw_urls:
failures.append(
"PR changes runtime/viewer/client behavior but has no raw.githubusercontent.com screenshot evidence"
)
warnings.append("Evidence-triggering files: " + ", ".join(evidence_paths[:8]))
elif evidence_paths:
raw_paths = _raw_image_paths(raw_urls)
evidence_image_paths = [path for path in raw_paths if path.startswith(EVIDENCE_SCREENSHOT_ROOT)]
if not evidence_image_paths:
failures.append("PR runtime screenshot evidence must link to committed .agents/evidence/pr/ images")
elif not any(
any(keyword in Path(path).name.lower() for keyword in EVIDENCE_SCREENSHOT_KEYWORDS)
for path in evidence_image_paths
):
failures.append(
"PR runtime screenshot evidence must be trace/viewer/dashboard evidence, not test-output art"
)
if not _has_real_evidence_source(body):
failures.append("PR runtime evidence must document the real trace/viewer source used for screenshots")
dangerous = _dangerous_files(changed_files)
if dangerous:
failures.append("PR includes raw trace, generated viewer, log, or secret-like files")
warnings.append("Blocked files: " + ", ".join(dangerous[:8]))
return PolicyResult(ok=not failures, failures=tuple(failures), warnings=tuple(warnings))
def _load_body(args: argparse.Namespace) -> str:
if args.body is not None:
return args.body
if args.body_file:
return Path(args.body_file).read_text(encoding="utf-8")
event_path = args.event_path or os.environ.get("GITHUB_EVENT_PATH")
if event_path:
data = json.loads(Path(event_path).read_text(encoding="utf-8"))
body = data.get("pull_request", {}).get("body")
if isinstance(body, str):
return body
raise SystemExit("error: provide --body, --body-file, or --event-path with pull_request.body")
def _load_changed_files(args: argparse.Namespace) -> list[str]:
if args.changed_file:
return args.changed_file
if args.changed_files_file:
return [
line.strip()
for line in Path(args.changed_files_file).read_text(encoding="utf-8").splitlines()
if line.strip()
]
return []
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--body", help="PR body text")
parser.add_argument("--body-file", help="File containing the PR body")
parser.add_argument("--event-path", help="GitHub event JSON path")
parser.add_argument("--changed-files-file", help="File containing changed paths, one per line")
parser.add_argument("--changed-file", action="append", default=[], help="Changed path; repeatable")
args = parser.parse_args(argv)
result = validate_policy(_load_body(args), _load_changed_files(args))
if result.ok:
print("PR Policy: PASS")
for warning in result.warnings:
print(f" WARN {warning}")
return 0
print("PR Policy: FAIL")
for failure in result.failures:
print(f" FAIL {failure}")
for warning in result.warnings:
print(f" INFO {warning}")
return 2
if __name__ == "__main__":
sys.exit(main())