-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add report-only governed PR check #20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
|
||
|
|
||
| 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()) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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] | ||
|
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" | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.