Download scripts/hai_gate.py from smlflg/hai-github-gate: direct link, hf CLI and curl.
- Browser
- Download file 11.2 kB
-
https://huggingface.co/smlflg/hai-github-gate/resolve/main/scripts/hai_gate.py
- Command line
-
hf download hf://smlflg/hai-github-gate/scripts/hai_gate.py
-
curl -L -o hai_gate.py https://huggingface.co/smlflg/hai-github-gate/resolve/main/scripts/hai_gate.py
11.2 kB
| #!/usr/bin/env python3 | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import os | |
| import re | |
| import subprocess | |
| import sys | |
| from dataclasses import dataclass, field | |
| from enum import Enum | |
| from pathlib import Path | |
| class Status(str, Enum): | |
| PASS = "PASS" | |
| PARK = "PARK" | |
| OWNER_DECISION = "OWNER_DECISION" | |
| EVIDENCE_REQUIRED = "EVIDENCE_REQUIRED" | |
| BLOCK = "BLOCK" | |
| BLOCKING_STATUSES = {Status.BLOCK, Status.EVIDENCE_REQUIRED, Status.OWNER_DECISION} | |
| RISKY_PATH_PATTERNS = ( | |
| re.compile(r"(^|/)\.env($|[./])"), | |
| re.compile(r"(^|/)\.env\.[^/]+$"), | |
| re.compile(r"(^|/)(secret|secrets|credentials|private|token|tokens)(/|$)", re.I), | |
| re.compile(r"(^|/)infra(/|$)", re.I), | |
| re.compile(r"(^|/)migrations?(/|$)", re.I), | |
| re.compile(r"(^|/)\.github/workflows(/|$)", re.I), | |
| re.compile(r"(^|/)wrangler\.jsonc?$", re.I), | |
| re.compile(r"(^|/)terraform(/|$)", re.I), | |
| ) | |
| CODE_PATH_SUFFIXES = ( | |
| ".py", | |
| ".js", | |
| ".jsx", | |
| ".ts", | |
| ".tsx", | |
| ".go", | |
| ".rs", | |
| ".java", | |
| ".kt", | |
| ".c", | |
| ".cc", | |
| ".cpp", | |
| ".h", | |
| ".hpp", | |
| ".cs", | |
| ".php", | |
| ".rb", | |
| ".swift", | |
| ".scala", | |
| ".sh", | |
| ) | |
| TEST_EVIDENCE_PATTERNS = ( | |
| re.compile(r"\b(test|tests|pytest|unittest|vitest|jest|npm test|pnpm test|uv run pytest|cargo test|go test|ruff|lint|typecheck|tsc|mypy)\b", re.I), | |
| re.compile(r"\b(passed|pass|green|success|successful|ok|0 failures?|0 failed)\b", re.I), | |
| ) | |
| OWNER_APPROVAL_PATTERNS = ( | |
| re.compile(r"owner (approval|approved|freigabe)", re.I), | |
| re.compile(r"samuel (approved|freigegeben|hat freigegeben)", re.I), | |
| re.compile(r"\bapproved-by:\s*\S+", re.I), | |
| ) | |
| PARK_PATTERNS = ( | |
| re.compile(r"\b(follow[- ]?up|nice[- ]?to[- ]?have|future work|later|park|parking lot|wishlist)\b", re.I), | |
| re.compile(r"\b(spaeter|später|irgendwann|nicht jetzt|wishlist|idee)\b", re.I), | |
| ) | |
| class GateFinding: | |
| status: Status | |
| reason: str | |
| evidence: str = "" | |
| class GateResult: | |
| status: Status | |
| findings: list[GateFinding] = field(default_factory=list) | |
| changed_files: list[str] = field(default_factory=list) | |
| pr_title: str = "" | |
| pr_body: str = "" | |
| def should_block(self) -> bool: | |
| return self.status in BLOCKING_STATUSES | |
| def main(argv: list[str] | None = None) -> int: | |
| parser = argparse.ArgumentParser(description="Run the deterministic HAI PR gate.") | |
| parser.add_argument("--base", default=None, help="Base ref/commit for git diff, e.g. origin/main.") | |
| parser.add_argument("--event", default=os.environ.get("GITHUB_EVENT_PATH"), help="GitHub event JSON path.") | |
| parser.add_argument("--summary", default=os.environ.get("GITHUB_STEP_SUMMARY"), help="Markdown summary path.") | |
| parser.add_argument("--files", default=None, help="Optional newline-delimited changed files fixture.") | |
| args = parser.parse_args(argv) | |
| changed_files = load_changed_files(args.files, args.base) | |
| title, body = load_pr_text(args.event) | |
| result = evaluate(changed_files=changed_files, pr_title=title, pr_body=body) | |
| summary = render_owner_packet(result) | |
| write_summary(summary, args.summary) | |
| print(summary) | |
| return 1 if result.should_block else 0 | |
| def evaluate(*, changed_files: list[str], pr_title: str = "", pr_body: str = "") -> GateResult: | |
| body = pr_body.strip() | |
| findings: list[GateFinding] = [] | |
| risky_files = [path for path in changed_files if is_risky_path(path)] | |
| code_files = [path for path in changed_files if is_code_path(path)] | |
| parked_items = find_parked_items(body) | |
| has_owner_approval = contains_owner_approval(body) | |
| has_test_evidence = contains_test_evidence(body) | |
| if risky_files and not has_owner_approval: | |
| findings.append( | |
| GateFinding( | |
| Status.BLOCK, | |
| "Risky files changed without explicit owner approval.", | |
| ", ".join(risky_files[:8]), | |
| ) | |
| ) | |
| if len(changed_files) > 12 and not has_owner_approval: | |
| findings.append( | |
| GateFinding( | |
| Status.OWNER_DECISION, | |
| "Large PR scope needs an explicit owner decision.", | |
| f"{len(changed_files)} changed files", | |
| ) | |
| ) | |
| if code_files and not has_test_evidence: | |
| findings.append( | |
| GateFinding( | |
| Status.EVIDENCE_REQUIRED, | |
| "Code changed but PR body does not show concrete test/check evidence.", | |
| ", ".join(code_files[:8]), | |
| ) | |
| ) | |
| if parked_items: | |
| findings.append( | |
| GateFinding( | |
| Status.PARK, | |
| "Follow-up or future-work content should stay parked, not expand this PR.", | |
| parked_items[0], | |
| ) | |
| ) | |
| if not body: | |
| findings.append( | |
| GateFinding( | |
| Status.EVIDENCE_REQUIRED, | |
| "PR body is empty; HAI requires goal, verification, risk, and owner-decision state.", | |
| "empty PR body", | |
| ) | |
| ) | |
| status = strongest_status(findings) | |
| return GateResult( | |
| status=status, | |
| findings=findings, | |
| changed_files=changed_files, | |
| pr_title=pr_title, | |
| pr_body=pr_body, | |
| ) | |
| def strongest_status(findings: list[GateFinding]) -> Status: | |
| priority = { | |
| Status.BLOCK: 0, | |
| Status.EVIDENCE_REQUIRED: 1, | |
| Status.OWNER_DECISION: 2, | |
| Status.PARK: 3, | |
| Status.PASS: 4, | |
| } | |
| if not findings: | |
| return Status.PASS | |
| return sorted((finding.status for finding in findings), key=lambda status: priority[status])[0] | |
| def is_risky_path(path: str) -> bool: | |
| normalized = path.strip().replace("\\", "/") | |
| return any(pattern.search(normalized) for pattern in RISKY_PATH_PATTERNS) | |
| def is_code_path(path: str) -> bool: | |
| normalized = path.strip().lower() | |
| return normalized.endswith(CODE_PATH_SUFFIXES) | |
| def contains_test_evidence(text: str) -> bool: | |
| if not text.strip(): | |
| return False | |
| return all(pattern.search(text) for pattern in TEST_EVIDENCE_PATTERNS) | |
| def contains_owner_approval(text: str) -> bool: | |
| return any(pattern.search(text) for pattern in OWNER_APPROVAL_PATTERNS) | |
| def find_parked_items(text: str) -> list[str]: | |
| lines = [] | |
| for raw_line in text.splitlines(): | |
| stripped = raw_line.strip() | |
| if stripped.startswith("#"): | |
| continue | |
| lines.append(stripped.strip("-* >\t ")) | |
| return [line for line in lines if line and any(pattern.search(line) for pattern in PARK_PATTERNS)] | |
| def render_owner_packet(result: GateResult) -> str: | |
| primary = primary_finding(result) | |
| status = result.status.value | |
| recommendation = recommendation_for(result.status) | |
| changed = "\n".join(f"- `{path}`" for path in result.changed_files[:20]) or "- No changed files detected." | |
| extra_count = max(0, len(result.changed_files) - 20) | |
| if extra_count: | |
| changed += f"\n- ...and {extra_count} more" | |
| findings = "\n".join( | |
| f"- **{finding.status.value}**: {finding.reason}" | |
| + (f" Evidence: `{finding.evidence}`" if finding.evidence else "") | |
| for finding in result.findings | |
| ) | |
| if not findings: | |
| findings = "- **PASS**: No blocking HAI risk detected." | |
| decision = owner_decision_for(result.status, primary) | |
| return "\n".join( | |
| [ | |
| "## HAI Owner Packet", | |
| "", | |
| f"Status: **{status}**", | |
| "", | |
| "### One Decision", | |
| decision, | |
| "", | |
| "### Recommendation", | |
| recommendation, | |
| "", | |
| "### Findings", | |
| findings, | |
| "", | |
| "### Changed Files", | |
| changed, | |
| "", | |
| "### Gate Result", | |
| "Merge is blocked until this gate passes." if result.should_block else "Gate passed; merge is not blocked by HAI.", | |
| "", | |
| ] | |
| ) | |
| def primary_finding(result: GateResult) -> GateFinding | None: | |
| if not result.findings: | |
| return None | |
| priority = { | |
| Status.BLOCK: 0, | |
| Status.EVIDENCE_REQUIRED: 1, | |
| Status.OWNER_DECISION: 2, | |
| Status.PARK: 3, | |
| Status.PASS: 4, | |
| } | |
| return sorted(result.findings, key=lambda finding: priority[finding.status])[0] | |
| def owner_decision_for(status: Status, primary: GateFinding | None) -> str: | |
| if status == Status.PASS: | |
| return "No owner decision required by this gate." | |
| if status == Status.PARK: | |
| return "Keep parked items out of this PR unless Samuel explicitly reopens scope." | |
| if status == Status.BLOCK: | |
| return "Do not merge. Samuel must approve or remove the risky change." | |
| if status == Status.EVIDENCE_REQUIRED: | |
| return "Do not merge. Agent must add concrete test/check evidence." | |
| if status == Status.OWNER_DECISION: | |
| return "Do not merge. Samuel must choose whether this scope is allowed." | |
| if primary: | |
| return primary.reason | |
| return "Do not merge until HAI status is clear." | |
| def recommendation_for(status: Status) -> str: | |
| return { | |
| Status.PASS: "Proceed with normal review.", | |
| Status.PARK: "Leave follow-up ideas parked and keep the PR scoped.", | |
| Status.BLOCK: "Stop and reduce risk before review continues.", | |
| Status.EVIDENCE_REQUIRED: "Request tests, command output, or reproducible validation before review continues.", | |
| Status.OWNER_DECISION: "Ask Samuel for a single explicit scope decision before further agent work.", | |
| }[status] | |
| def load_pr_text(event_path: str | None) -> tuple[str, str]: | |
| if not event_path: | |
| return "", "" | |
| path = Path(event_path) | |
| if not path.exists(): | |
| return "", "" | |
| raw = json.loads(path.read_text(encoding="utf-8")) | |
| pr = raw.get("pull_request") or {} | |
| return str(pr.get("title") or ""), str(pr.get("body") or "") | |
| def load_changed_files(files_path: str | None, base: str | None) -> list[str]: | |
| if files_path: | |
| return [ | |
| line.strip() | |
| for line in Path(files_path).read_text(encoding="utf-8").splitlines() | |
| if line.strip() | |
| ] | |
| if not base: | |
| return [] | |
| try: | |
| proc = subprocess.run( | |
| ["git", "diff", "--name-only", f"{base}...HEAD"], | |
| check=True, | |
| text=True, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| ) | |
| except subprocess.CalledProcessError as exc: | |
| fallback = subprocess.run( | |
| ["git", "diff", "--name-only", base], | |
| check=False, | |
| text=True, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| ) | |
| if fallback.returncode != 0: | |
| raise SystemExit( | |
| f"Could not compute changed files against {base}: {exc.stderr or fallback.stderr}" | |
| ) from exc | |
| proc = fallback | |
| return [line.strip() for line in proc.stdout.splitlines() if line.strip()] | |
| def write_summary(summary: str, summary_path: str | None) -> None: | |
| if not summary_path: | |
| return | |
| Path(summary_path).write_text(summary, encoding="utf-8") | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |