Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 228 additions & 0 deletions .github/scripts/governed_pr_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
#!/usr/bin/env python3
"""Report-only validator for HUMMBL governed pull request metadata."""

from __future__ import annotations

import argparse
import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable


BOUNDARY_OPTIONS = (
"Public",
"Private",
"Internal-only",
"Sensitive / requires review",
)
SOURCE_STATUS_OPTIONS = (
"No source/canon impact",
"Source candidate only",
"Prior art",
"Canon-bearing change",
"Deprecated/removal",
)
CHANGE_CLASS_OPTIONS = (
"Docs-only",
"CI/CD",
"Source packet",
"Code/runtime",
"Governance",
"Public surface",
)

CANON_TERMS = ("canon", "canonical", "verified", "official")
PRIVATE_MARKERS = (
"private repo",
"private-only",
"internal-only",
"confidential",
"secret",
)
PLACEHOLDER_PATTERNS = (
r"^\s*$",
r"tests/checks run:\s*$",
r"receipt:\s*$",
r"todo\b",
r"tbd\b",
r"n/a\s*$",
)
GOVERNANCE_SENSITIVE_PREFIXES = (
".github/",
"CODEOWNERS",
"CONSTITUTION.md",
"GOVERNANCE.md",
"KRINEIA.md",
"hummbl.repo.yaml",
)


@dataclass(frozen=True)
class Finding:
level: str
code: str
message: str


def checked_options(body: str, options: Iterable[str]) -> list[str]:
selected: list[str] = []
for option in options:
pattern = re.compile(
rf"^\s*-\s*\[[xX]\]\s*{re.escape(option)}\s*$",
re.MULTILINE,
)
if pattern.search(body):
selected.append(option)
return selected


def section_text(body: str, heading: str) -> str:
pattern = re.compile(
rf"^##\s+{re.escape(heading)}\s*$\n(?P<body>.*?)(?=^##\s+|\Z)",
re.MULTILINE | re.DOTALL,
)
match = pattern.search(body)
if not match:
return ""
return match.group("body").strip()


def has_linked_issue(body: str) -> bool:
patterns = (
r"\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#\d+\b",
r"\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+https://github\.com/[^/\s]+/[^/\s]+/issues/\d+\b",
r"\blinked issue\s*[:#]\s*\d+\b",
)
return any(re.search(pattern, body, re.IGNORECASE) for pattern in patterns)


def is_placeholder(text: str) -> bool:
normalized = text.strip().lower()
if not normalized:
return True
return any(re.search(pattern, normalized, re.IGNORECASE) for pattern in PLACEHOLDER_PATTERNS)


def body_without_unchecked_options(body: str) -> str:
lines = []
for line in body.splitlines():
if re.match(r"^\s*-\s*\[\s*\]\s+", line):
continue
lines.append(line)
return "\n".join(lines)


def changed_files_from_json(path: Path | None) -> list[str]:
if path is None:
return []
payload = json.loads(path.read_text(encoding="utf-8"))
if isinstance(payload, list):
files = payload
elif isinstance(payload, dict):
files = payload.get("files", [])
else:
return []
result: list[str] = []
for item in files:
if isinstance(item, dict) and isinstance(item.get("filename"), str):
filename = item["filename"]
result.append(filename)
return result
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def governance_sensitive_files(files: Iterable[str]) -> list[str]:
sensitive: list[str] = []
for filename in files:
if filename in GOVERNANCE_SENSITIVE_PREFIXES:
sensitive.append(filename)
continue
if any(filename.startswith(prefix) for prefix in GOVERNANCE_SENSITIVE_PREFIXES):
sensitive.append(filename)
return sensitive


def validate(body: str, changed_files: Iterable[str]) -> list[Finding]:
findings: list[Finding] = []

if not has_linked_issue(body):
findings.append(Finding("warning", "linked-issue-missing", "No linked issue closure/reference was found."))

for code, label, options in (
("boundary", "boundary", BOUNDARY_OPTIONS),
("source-status", "source status", SOURCE_STATUS_OPTIONS),
("change-class", "change class", CHANGE_CLASS_OPTIONS),
):
selected = checked_options(body, options)
if not selected:
findings.append(Finding("warning", f"{code}-missing", f"No {label} option is selected."))
elif len(selected) > 1:
joined = ", ".join(selected)
findings.append(Finding("warning", f"{code}-multiple", f"Multiple {label} options are selected: {joined}."))

evidence = section_text(body, "Evidence")
if is_placeholder(evidence):
findings.append(Finding("warning", "evidence-placeholder", "Evidence is blank or placeholder-only."))

lower_body = body.lower()
lower_asserted_body = body_without_unchecked_options(body).lower()
has_gate_marker = "canon-bearing change" in lower_body or "governance gate" in lower_body
if any(re.search(rf"\b{re.escape(term)}\b", lower_body) for term in CANON_TERMS) and not has_gate_marker:
findings.append(Finding("warning", "canon-claim-without-gate", "Canon/official claim appears without a gate marker."))

boundary = checked_options(body, BOUNDARY_OPTIONS)
if boundary == ["Public"] and any(marker in lower_asserted_body for marker in PRIVATE_MARKERS):
findings.append(Finding("warning", "public-boundary-private-marker", "Public-boundary PR includes private/internal markers."))

classes = checked_options(body, CHANGE_CLASS_OPTIONS)
if "Code/runtime" in classes:
if "docs-only" in lower_body or "no runtime change" in lower_body:
pass
elif is_placeholder(evidence) or not re.search(r"\b(test|pytest|lint|ci|check)\b", evidence, re.IGNORECASE):
findings.append(Finding("warning", "runtime-without-test-evidence", "Code/runtime change lacks test evidence or no-runtime declaration."))

sensitive = governance_sensitive_files(changed_files)
if sensitive and "owner review" not in lower_body and "codeowners" not in lower_body:
preview = ", ".join(sensitive[:5])
if len(sensitive) > 5:
preview += ", ..."
findings.append(Finding("warning", "governance-files-without-owner-path", f"Governance-sensitive files changed without owner review path: {preview}."))

return findings


def format_markdown(findings: list[Finding]) -> str:
if not findings:
return "### Governed PR Check\n\nNo report-only findings."
lines = ["### Governed PR Check", "", "Report-only findings:"]
for finding in findings:
lines.append(f"- `{finding.code}`: {finding.message}")
return "\n".join(lines)


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--body-file", required=True, type=Path)
parser.add_argument("--files-json", type=Path)
parser.add_argument("--markdown-output", type=Path)
parser.add_argument("--fail-on-findings", action="store_true")
args = parser.parse_args()

body = args.body_file.read_text(encoding="utf-8")
changed_files = changed_files_from_json(args.files_json)
findings = validate(body, changed_files)
markdown = format_markdown(findings)

if args.markdown_output:
args.markdown_output.write_text(markdown + "\n", encoding="utf-8")
print(markdown)

if findings and args.fail_on_findings:
return 1
return 0


if __name__ == "__main__":
sys.exit(main())
55 changes: 55 additions & 0 deletions .github/workflows/governed-pr-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: Governed PR Check

on:
workflow_call:
inputs:
fail-on-findings:
description: "Fail the workflow when report-only findings are present."
required: false
default: false
type: boolean
pull_request:
types: [opened, edited, synchronize, reopened, ready_for_review]
Comment thread
coderabbitai[bot] marked this conversation as resolved.

concurrency:
group: governed-pr-check-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

permissions:
contents: read
pull-requests: read

jobs:
governed-pr-check:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
persist-credentials: false

- name: Write PR body
env:
PR_BODY: ${{ github.event.pull_request.body }}
run: |
printf '%s' "$PR_BODY" > pr-body.md

- name: Fetch changed files
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPOSITORY: ${{ github.repository }}
run: |
gh api "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100" > pr-files.json

- name: Run governed PR check
run: |
python3 .github/scripts/governed_pr_check.py \
--body-file pr-body.md \
--files-json pr-files.json \
--markdown-output governed-pr-check.md \
${{ inputs.fail-on-findings && '--fail-on-findings' || '' }}

- name: Add summary
run: |
cat governed-pr-check.md >> "$GITHUB_STEP_SUMMARY"
54 changes: 54 additions & 0 deletions docs/GOVERNED_PR_CHECK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Governed PR Check

`governed-pr-check` is the first executable validation layer for the Governed
Throughput defaults. It is intentionally report-only by default.

## What It Checks

- linked issue presence,
- exactly one boundary selection,
- exactly one source-status selection,
- exactly one change-class selection,
- non-placeholder evidence,
- canon/official claims without a gate marker,
- public-boundary PRs that mention private/internal markers,
- runtime changes without test evidence or a no-runtime declaration,
- governance-sensitive file changes without an owner-review path.

The check reports warnings. It should not become a required blocking check until
pilot repositories show acceptable false-positive rates.

## Reusable Workflow

Pilot repositories can call the default workflow:

```yaml
name: Governed PR Check

on:
pull_request:
types: [opened, edited, synchronize, reopened, ready_for_review]

permissions:
contents: read
pull-requests: read

jobs:
governed-pr-check:
uses: hummbl-dev/.github/.github/workflows/governed-pr-check.yml@main
with:
fail-on-findings: false
```

Keep `fail-on-findings` set to `false` during the pilot phase.

## Local Validation

```bash
python3 .github/scripts/governed_pr_check.py \
--body-file pr-body.md \
--files-json pr-files.json
```

`pr-files.json` should be the GitHub pull-request files API payload. If omitted,
body-only checks still run.
Loading