Fix CI lint failure by formatting PR governance scripts (#933)

`CI / lint (pull_request)` failed because `ruff format --check` detected
formatting drift in the new PR governance script and its tests. This PR
aligns those files with repository formatting rules so the lint job can
pass.

- **Root cause**
  - `ruff format --check .` reported two files as non-canonical:
    - `scripts/pr-governance.py`
    - `scripts/tests/test_pr_governance.py`

- **Change set**
  - Applied `ruff` formatting to only the two flagged files.
- No behavioral or logic changes; edits are line-wrap/format
normalization only.

- **Representative update**
  ```python
  parser.add_argument(
"--event", type=Path, required=True, help="Path to the GitHub event
payload JSON."
  )
  ```

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Copilot 2026-06-12 17:11:39 -05:00 committed by GitHub
parent b9e27614c6
commit 96a7d7cbbe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 781 additions and 111 deletions

View file

@ -1,8 +1,8 @@
## Description
Brief description of changes and motivation.
<!-- Briefly explain the change and why it is needed. -->
Fixes #(issue number)
Closes #
## Type of Change
@ -15,13 +15,11 @@ Fixes #(issue number)
## Changes Made
- Change 1
- Change 2
- Change 3
-
## Testing
Describe the tests you ran to verify your changes:
<!-- Check what you actually ran, then paste the real command output below. -->
- [ ] Unit tests pass (`pytest`)
- [ ] Linting passes (`ruff check .`)
@ -29,12 +27,23 @@ Describe the tests you ran to verify your changes:
- [ ] New tests added for new functionality
- [ ] Manual testing performed
## Test Output
### Test Output
```text
# Paste relevant command output or artifact links here
```
# Paste relevant test output here
pytest -v tests/test_your_feature.py
```
## Real Behavior Proof
- Environment:
- Exact command / steps:
- Observed result:
- Not tested:
## Review Readiness
- [ ] I have performed a self-review
- [ ] This PR is ready for human review
## Checklist
@ -53,4 +62,4 @@ Add screenshots to help explain your changes.
## Additional Notes
Any additional information that reviewers should know.
<!-- Mention any N/A checklist items, tradeoffs, follow-ups, or maintainer context. -->

19
.github/act/pr-governance-invalid.json vendored Normal file
View file

@ -0,0 +1,19 @@
{
"action": "opened",
"number": 42,
"pull_request": {
"number": 42,
"draft": false,
"title": "feat: add PR governance",
"body": "## Description\n\nFixes #123\n",
"user": {
"login": "octocat"
},
"base": {
"sha": "dff6a199"
}
},
"repository": {
"full_name": "JerrettDavis/headroom"
}
}

19
.github/act/pr-governance-valid.json vendored Normal file
View file

@ -0,0 +1,19 @@
{
"action": "ready_for_review",
"number": 42,
"pull_request": {
"number": 42,
"draft": false,
"title": "feat: add PR governance",
"body": "## Description\n\nAdd a required PR governance check and commit-msg enforcement.\n\nCloses #123\n\n## Type of Change\n\n- [x] New feature (non-breaking change that adds functionality)\n\n## Changes Made\n\n- Added workflow validation for PR template completeness.\n- Added a commit-msg hook that runs commitlint locally.\n\n## Testing\n\n- [x] Unit tests pass (`pytest`)\n- [x] Manual testing performed\n\n### Test Output\n\n```text\npytest scripts/tests/test_pr_governance.py -q\n```\n\n## Real Behavior Proof\n\n- Environment: Ubuntu runner, Python 3.12\n- Exact command / steps: Opened a PR with an incomplete template, then fixed the body.\n- Observed result: The governance check failed until the template and readiness boxes were complete.\n- Not tested: Repository-level automatic Copilot rulesets.\n\n## Review Readiness\n\n- [x] I have performed a self-review\n- [x] This PR is ready for human review\n",
"user": {
"login": "octocat"
},
"base": {
"sha": "dff6a199"
}
},
"repository": {
"full_name": "JerrettDavis/headroom"
}
}

7
.github/copilot-instructions.md vendored Normal file
View file

@ -0,0 +1,7 @@
When performing a pull request review in this repository:
1. Treat `.github/PULL_REQUEST_TEMPLATE.md` and `CONTRIBUTING.md` as required policy, not optional guidance.
2. Flag pull requests that do not include concrete "Real Behavior Proof" with environment, exact commands or steps, observed result, and what was not tested.
3. Be strict about contributor verification: missing tests, missing runtime evidence, or placeholder PR text should be called out.
4. For user-facing, release, dependency, workflow, or security-sensitive changes, prefer blocking feedback over optional suggestions.
5. Focus on correctness, safety, and whether the PR is actually ready for human maintainer review.

View file

@ -1,91 +1,223 @@
name: PR Health
on:
pull_request_target:
types: [opened, reopened, synchronize, ready_for_review]
schedule:
# Keep labels fresh even when base branches move or checks finish later.
- cron: '23 14 * * 1-5'
workflow_dispatch:
permissions:
contents: read
issues: write
pull-requests: write
checks: read
statuses: read
concurrency:
group: pr-health-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
label:
runs-on: ubuntu-latest
timeout-minutes: 10
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
steps:
- name: Ensure maintenance labels exist
run: |
set -euo pipefail
gh label create "status: needs rebase" \
--repo "$REPO" \
--color "fbca04" \
--description "Pull request branch is behind the base branch" \
--force
gh label create "status: has conflicts" \
--repo "$REPO" \
--color "d73a4a" \
--description "Pull request has merge conflicts with the base branch" \
--force
gh label create "status: ci failing" \
--repo "$REPO" \
--color "d73a4a" \
--description "Required or reported CI checks are failing" \
--force
- name: Label open pull requests
run: |
set -euo pipefail
if jq -e '.pull_request.number' "$GITHUB_EVENT_PATH" >/dev/null; then
pr_numbers="$(jq -r '.pull_request.number' "$GITHUB_EVENT_PATH")"
else
pr_numbers="$(gh pr list --repo "$REPO" --state open --limit 100 --json number --jq '.[].number')"
fi
for pr in $pr_numbers; do
data="$(gh pr view "$pr" --repo "$REPO" \
--json mergeStateStatus,statusCheckRollup)"
merge_state="$(jq -r '.mergeStateStatus // "UNKNOWN"' <<<"$data")"
check_state="$(jq -r '
[
.statusCheckRollup[]
| select((.conclusion // .state // "") as $s
| ["FAILURE", "TIMED_OUT", "ACTION_REQUIRED", "CANCELLED", "ERROR"] | index($s))
]
| if length > 0 then "failing" else "passing" end
' <<<"$data")"
if [[ "$merge_state" == "BEHIND" ]]; then
gh pr edit "$pr" --repo "$REPO" --add-label "status: needs rebase"
else
gh pr edit "$pr" --repo "$REPO" --remove-label "status: needs rebase" || true
fi
if [[ "$merge_state" == "DIRTY" ]]; then
gh pr edit "$pr" --repo "$REPO" --add-label "status: has conflicts"
else
gh pr edit "$pr" --repo "$REPO" --remove-label "status: has conflicts" || true
fi
if [[ "$check_state" == "failing" ]]; then
gh pr edit "$pr" --repo "$REPO" --add-label "status: ci failing"
else
gh pr edit "$pr" --repo "$REPO" --remove-label "status: ci failing" || true
fi
done
name: PR Governance
on:
pull_request_target:
types: [opened, edited, reopened, synchronize, ready_for_review, converted_to_draft]
schedule:
# Keep labels fresh even when base branches move or checks finish later.
- cron: '23 14 * * 1-5'
workflow_dispatch:
permissions:
contents: read
issues: write
pull-requests: write
checks: read
statuses: read
concurrency:
group: pr-health-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
template:
if: github.event_name == 'pull_request_target'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.base.sha }}
- name: Validate PR template
id: validate
run: python3 scripts/pr-governance.py --event "$GITHUB_EVENT_PATH" --report .pr-governance-report.json
- name: Append governance summary
run: |
python3 - <<'PY'
import json
import os
from pathlib import Path
report = json.loads(Path(".pr-governance-report.json").read_text(encoding="utf-8"))
summary = report["summary_markdown"].strip()
with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("a", encoding="utf-8") as handle:
handle.write(f"{summary}\n")
PY
- name: Ensure governance labels exist
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
gh label create "status: needs author action" \
--repo "$REPO" \
--color "d93f0b" \
--description "Pull request body or readiness checklist still needs author updates" \
--force
gh label create "status: ready for review" \
--repo "$REPO" \
--color "0e8a16" \
--description "Pull request body is complete and the author marked it ready for human review" \
--force
- name: Sync governance comment and labels
if: steps.validate.outputs.is_bot_pr != 'true'
uses: actions/github-script@v7
env:
REPORT_PATH: .pr-governance-report.json
with:
script: |
const fs = require('fs');
const report = JSON.parse(fs.readFileSync(process.env.REPORT_PATH, 'utf8'));
const owner = context.repo.owner;
const repo = context.repo.repo;
const issue_number = context.payload.pull_request.number;
const marker = report.comment_marker;
const body = `${marker}\n${report.comment_markdown}`.trim();
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number,
per_page: 100,
});
const existing = comments.find(
(comment) =>
comment.user?.type === 'Bot' && typeof comment.body === 'string' && comment.body.includes(marker),
);
if (existing) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body,
});
}
if (report.labels_to_add.length > 0) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number,
labels: report.labels_to_add,
});
}
for (const label of report.labels_to_remove) {
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number,
name: label,
});
} catch (error) {
if (error.status !== 404) {
throw error;
}
}
}
- name: Fail when the PR body is incomplete
if: steps.validate.outputs.valid != 'true'
run: |
echo "PR template validation failed. Update the PR body or move the PR back to draft."
exit 1
label:
runs-on: ubuntu-latest
timeout-minutes: 10
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
steps:
- name: Ensure maintenance labels exist
run: |
set -euo pipefail
gh label create "status: needs rebase" \
--repo "$REPO" \
--color "fbca04" \
--description "Pull request branch is behind the base branch" \
--force
gh label create "status: has conflicts" \
--repo "$REPO" \
--color "d73a4a" \
--description "Pull request has merge conflicts with the base branch" \
--force
gh label create "status: ci failing" \
--repo "$REPO" \
--color "d73a4a" \
--description "Required or reported CI checks are failing" \
--force
gh label create "status: needs author action" \
--repo "$REPO" \
--color "d93f0b" \
--description "Pull request body or readiness checklist still needs author updates" \
--force
gh label create "status: ready for review" \
--repo "$REPO" \
--color "0e8a16" \
--description "Pull request body is complete and the author marked it ready for human review" \
--force
- name: Label open pull requests
run: |
set -euo pipefail
if jq -e '.pull_request.number' "$GITHUB_EVENT_PATH" >/dev/null; then
pr_numbers="$(jq -r '.pull_request.number' "$GITHUB_EVENT_PATH")"
else
pr_numbers="$(gh pr list --repo "$REPO" --state open --limit 100 --json number --jq '.[].number')"
fi
for pr in $pr_numbers; do
data="$(gh pr view "$pr" --repo "$REPO" \
--json isDraft,labels,mergeStateStatus,statusCheckRollup)"
merge_state="$(jq -r '.mergeStateStatus // "UNKNOWN"' <<<"$data")"
check_state="$(jq -r '
[
(.statusCheckRollup // [])[]
| select((.conclusion // .state // "") as $s
| ["FAILURE", "TIMED_OUT", "ACTION_REQUIRED", "CANCELLED", "ERROR"] | index($s))
]
| if length > 0 then "failing" else "passing" end
' <<<"$data")"
is_draft="$(jq -r '.isDraft' <<<"$data")"
if [[ "$merge_state" == "BEHIND" ]]; then
gh pr edit "$pr" --repo "$REPO" --add-label "status: needs rebase"
else
gh pr edit "$pr" --repo "$REPO" --remove-label "status: needs rebase" || true
fi
if [[ "$merge_state" == "DIRTY" ]]; then
gh pr edit "$pr" --repo "$REPO" --add-label "status: has conflicts"
else
gh pr edit "$pr" --repo "$REPO" --remove-label "status: has conflicts" || true
fi
if [[ "$check_state" == "failing" ]]; then
gh pr edit "$pr" --repo "$REPO" --add-label "status: ci failing"
else
gh pr edit "$pr" --repo "$REPO" --remove-label "status: ci failing" || true
fi
if [[ "$merge_state" == "BEHIND" || "$merge_state" == "DIRTY" || "$check_state" == "failing" || "$is_draft" == "true" ]]; then
gh pr edit "$pr" --repo "$REPO" --remove-label "status: ready for review" || true
fi
done

1
.gitignore vendored
View file

@ -17,6 +17,7 @@ scripts/*
!scripts/sync-plugin-versions.py
!scripts/changelog-gen.py
!scripts/verify-versions.py
!scripts/pr-governance.py
!scripts/tests/
!scripts/README.md
!scripts/repro_codex_replay.py

View file

@ -7,6 +7,11 @@ repos:
language: system
pass_filenames: false
always_run: true
- id: commitlint
name: Commitlint
entry: bash -lc 'npx --yes --package=@commitlint/cli --package=@commitlint/config-conventional -- commitlint --edit "$1" --config .commitlintrc.json' --
language: system
stages: [commit-msg]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.4
hooks:

View file

@ -73,15 +73,17 @@ A human maintainer reviews every dep change. PRs that add or bump a package must
## PR workflow
1. Fork, branch from `main`.
2. `pip install -e ".[dev]"` then `make install-git-hooks` — installs repo pre-commit checks on every commit and ci-precheck on every push.
2. Install **Node 18+** and run `pip install -e ".[dev]"` then `make install-git-hooks` — installs repo pre-commit checks on every commit, commitlint on every commit message, and ci-precheck on every push.
3. One logical change per PR.
4. Add tests.
5. `pytest` · `ruff check .` · `ruff format .`
6. Update `CHANGELOG.md` for user-facing changes.
7. Open the PR with a clear description + `Real behavior proof` + any spec/justification required.
7. Open the PR with a clear description + `Real behavior proof` + any spec/justification required, and keep the PR in draft until the `Review Readiness` boxes are complete.
**Title format** (conventional commits): `feat:`, `fix:`, `docs:`, `test:`, `refactor:`.
**Commit message format** is enforced locally by the repo's `commit-msg` hook and again in CI.
**Review:** CI green, one maintainer review, coverage held/improved.
## Development setup
@ -90,6 +92,7 @@ A human maintainer reviews every dep change. PRs that add or bump a package must
git clone https://github.com/chopratejas/headroom.git
cd headroom
python -m venv .venv && source .venv/bin/activate
node --version # Node 18+ required for commitlint hooks
pip install -e ".[dev,relevance,proxy]"
pytest
```
@ -103,6 +106,12 @@ Two configs ship for VS Code / Codespaces:
Inside, use: `uv run ruff check .`, `uv run pytest`, etc.
## Optional automated review
This repository includes `.github/copilot-instructions.md` so maintainers can opt into GitHub Copilot code review without adding workflow billing noise to every PR.
Enable or disable automatic Copilot review in **Settings → Rules → Rulesets → Automatically request Copilot code review**. Keep it off unless maintainers explicitly want the extra review traffic.
## Coding standards
- [Ruff](https://github.com/astral-sh/ruff) for lint + format, line length 100, PEP 8.

View file

@ -27,7 +27,7 @@ help:
@echo " make ci-precheck-rust - cargo fmt --check + clippy + test"
@echo " make ci-precheck-python - smart_crusher-affected python tests"
@echo " make ci-precheck-commitlint - lint commits since origin/main"
@echo " make install-git-hooks - install a pre-push hook that runs ci-precheck"
@echo " make install-git-hooks - install pre-commit, commit-msg, and pre-push hooks"
test:
$(CARGO) test --workspace
@ -123,16 +123,15 @@ ci-precheck-python:
tests/test_toin_integration.py
# Lint commits since `origin/main`. Requires npx (Node 18+) on PATH.
# Skips silently if npx is unavailable; install nodejs to enable.
ci-precheck-commitlint:
@echo "── ci-precheck-commitlint ─────────────────────────────────────"
@if ! command -v npx >/dev/null 2>&1; then \
echo "skip: npx not on PATH (install node 18+ to enable commitlint pre-check)"; \
exit 0; \
echo "error: npx not on PATH (install Node 18+ to enable commitlint checks)"; \
exit 1; \
fi
@if ! git rev-parse --verify origin/main >/dev/null 2>&1; then \
echo "skip: origin/main not fetched (run 'git fetch origin main')"; \
exit 0; \
echo "error: origin/main not fetched (run 'git fetch origin main')"; \
exit 1; \
fi
npx --yes --package=@commitlint/cli --package=@commitlint/config-conventional -- \
commitlint --from origin/main --to HEAD --config .commitlintrc.json

View file

@ -1,7 +1,8 @@
#!/usr/bin/env bash
# Install git hooks for the Headroom repo:
# 1. pre-commit — repo pre-commit checks (ruff, mypy, sync-plugin-versions)
# 2. pre-push — full ci-precheck (cargo fmt/clippy/test + python suite)
# 2. commit-msg — conventional-commit enforcement via commitlint
# 3. pre-push — full ci-precheck (cargo fmt/clippy/test + python suite)
#
# Why pre-push was added: the 2026-04-27 push hit five CI failures that could
# all have been caught locally — cargo fmt drift, an x86_64-apple-darwin wheel
@ -24,6 +25,11 @@ if [[ ! -d .git/hooks ]]; then
exit 1
fi
if ! command -v npx &>/dev/null; then
echo "error: npx not found — install Node 18+ before installing Headroom's git hooks." >&2
exit 1
fi
HOOK_PATH=".git/hooks/pre-push"
cat > "$HOOK_PATH" <<'HOOK_EOF'
@ -89,7 +95,9 @@ fi
if [[ -n "$PRE_COMMIT_BIN" ]]; then
"$PRE_COMMIT_BIN" install
"$PRE_COMMIT_BIN" install --hook-type commit-msg
echo "✅ installed: .git/hooks/pre-commit (repo pre-commit checks via pre-commit)"
echo "✅ installed: .git/hooks/commit-msg (conventional commit enforcement via commitlint)"
else
echo "error: pre-commit not found — run 'pip install -e .[dev]' first, then re-run this script." >&2
exit 1

297
scripts/pr-governance.py Normal file
View file

@ -0,0 +1,297 @@
#!/usr/bin/env python3
"""Validate Headroom PR template compliance for GitHub Actions."""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any
COMMENT_MARKER = "<!-- headroom-pr-governance -->"
READY_LABEL = "status: ready for review"
AUTHOR_ACTION_LABEL = "status: needs author action"
REQUIRED_SECTIONS = (
"Description",
"Type of Change",
"Changes Made",
"Testing",
"Real Behavior Proof",
"Review Readiness",
)
PROOF_FIELDS = (
"Environment",
"Exact command / steps",
"Observed result",
"Not tested",
)
SECTION_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE)
CHECKBOX_RE = re.compile(r"^- \[(?P<checked>[ xX])\] (?P<label>.+)$", re.MULTILINE)
HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
CODE_BLOCK_RE = re.compile(r"```(?:[\w.+-]+)?\n(?P<content>.*?)```", re.DOTALL)
@dataclass(slots=True)
class GovernanceReport:
"""Serializable PR governance result."""
comment_marker: str
valid: bool
is_draft: bool
is_bot_pr: bool
ready_for_review: bool
needs_author_action: bool
problems: list[str] = field(default_factory=list)
labels_to_add: list[str] = field(default_factory=list)
labels_to_remove: list[str] = field(default_factory=list)
comment_markdown: str = ""
summary_markdown: str = ""
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def load_event(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def extract_sections(body: str) -> dict[str, str]:
matches = list(SECTION_RE.finditer(body))
sections: dict[str, str] = {}
for index, match in enumerate(matches):
start = match.end()
end = matches[index + 1].start() if index + 1 < len(matches) else len(body)
sections[match.group(1).strip()] = body[start:end].strip()
return sections
def strip_html_comments(text: str) -> str:
return HTML_COMMENT_RE.sub("", text).strip()
def non_empty_lines(text: str) -> list[str]:
return [line.strip() for line in strip_html_comments(text).splitlines() if line.strip()]
def checked_items(section: str) -> list[str]:
return [
match.group("label").strip()
for match in CHECKBOX_RE.finditer(section)
if match.group("checked").lower() == "x"
]
def has_descriptive_text(section: str) -> bool:
ignored_prefixes = ("closes #", "fixes #", "resolves #", "related to #")
for line in non_empty_lines(section):
lowered = line.lower()
if line.startswith("#"):
continue
if lowered.startswith(ignored_prefixes):
continue
if len(line) >= 10:
return True
return False
def has_non_placeholder_bullets(section: str) -> bool:
placeholders = {"change 1", "change 2", "change 3"}
for line in non_empty_lines(section):
if not line.startswith("- "):
continue
bullet = line[2:].strip().lower()
if bullet and bullet not in placeholders:
return True
return False
def has_test_output(section: str) -> bool:
for match in CODE_BLOCK_RE.finditer(section):
content = strip_html_comments(match.group("content")).strip()
if not content:
continue
if "paste relevant command output or artifact links here" in content.lower():
continue
return True
return False
def proof_field_values(section: str) -> dict[str, str]:
values: dict[str, str] = {}
for line in non_empty_lines(section):
if not line.startswith("- ") or ":" not in line:
continue
label, value = line[2:].split(":", 1)
values[label.strip()] = value.strip()
return values
def normalize_checkbox_map(items: list[str]) -> set[str]:
return {item.lower() for item in items}
def validate_pull_request(event: dict[str, Any]) -> GovernanceReport:
pull_request = event["pull_request"]
author = pull_request["user"]["login"]
is_draft = bool(pull_request.get("draft", False))
is_bot_pr = author.endswith("[bot]")
body = pull_request.get("body") or ""
if is_bot_pr:
summary = "### PR governance\n\nBot-authored PR detected; template enforcement is skipped."
return GovernanceReport(
comment_marker=COMMENT_MARKER,
valid=True,
is_draft=is_draft,
is_bot_pr=True,
ready_for_review=False,
needs_author_action=False,
comment_markdown=summary,
summary_markdown=summary,
)
sections = extract_sections(body)
problems: list[str] = []
for section_name in REQUIRED_SECTIONS:
if section_name not in sections:
problems.append(f"Missing required section `{section_name}`.")
description = sections.get("Description", "")
if description and not has_descriptive_text(description):
problems.append("Fill in `Description` with a real summary of the change.")
changes_made = sections.get("Changes Made", "")
if changes_made and not has_non_placeholder_bullets(changes_made):
problems.append(
"Replace the placeholder bullets in `Changes Made` with the actual changes."
)
type_of_change_checked = checked_items(sections.get("Type of Change", ""))
if sections.get("Type of Change") and not type_of_change_checked:
problems.append("Check at least one box in `Type of Change`.")
testing_section = sections.get("Testing", "")
testing_checked = checked_items(testing_section)
if testing_section and not testing_checked:
problems.append("Check at least one verification item in `Testing`.")
if testing_section and not has_test_output(testing_section):
problems.append("Paste real command output or artifact links in `Testing` → `Test Output`.")
proof_section = sections.get("Real Behavior Proof", "")
proof_values = proof_field_values(proof_section)
for field_name in PROOF_FIELDS:
if proof_section and not proof_values.get(field_name):
problems.append(f"Fill in `Real Behavior Proof` → `{field_name}`.")
readiness_checked = normalize_checkbox_map(checked_items(sections.get("Review Readiness", "")))
has_self_review = "i have performed a self-review" in readiness_checked
has_ready_checkbox = "this pr is ready for human review" in readiness_checked
if not is_draft:
if not has_self_review:
problems.append(
"Check `I have performed a self-review` before requesting human review."
)
if not has_ready_checkbox:
problems.append(
"Check `This PR is ready for human review` or convert the PR back to draft."
)
valid = not problems
ready_for_review = valid and not is_draft and has_ready_checkbox and has_self_review
needs_author_action = not valid
if valid and ready_for_review:
status_lines = [
"### PR governance",
"",
"This PR follows the template and is marked ready for human review.",
]
elif valid:
status_lines = [
"### PR governance",
"",
"This draft PR follows the template so far. Keep it in draft until it is ready for human review.",
]
else:
status_lines = [
"### PR governance",
"",
"This PR does not yet satisfy the required template fields:",
"",
*[f"- {problem}" for problem in problems],
"",
"Please update the PR body, or move the PR back to draft while it is still in progress.",
]
labels_to_add: list[str] = []
labels_to_remove: list[str] = []
if needs_author_action:
labels_to_add.append(AUTHOR_ACTION_LABEL)
labels_to_remove.append(READY_LABEL)
else:
labels_to_remove.append(AUTHOR_ACTION_LABEL)
if ready_for_review:
labels_to_add.append(READY_LABEL)
else:
labels_to_remove.append(READY_LABEL)
comment_markdown = "\n".join(status_lines)
return GovernanceReport(
comment_marker=COMMENT_MARKER,
valid=valid,
is_draft=is_draft,
is_bot_pr=False,
ready_for_review=ready_for_review,
needs_author_action=needs_author_action,
problems=problems,
labels_to_add=labels_to_add,
labels_to_remove=labels_to_remove,
comment_markdown=comment_markdown,
summary_markdown=comment_markdown,
)
def emit_outputs(report: GovernanceReport) -> None:
output_path = os.environ.get("GITHUB_OUTPUT")
lines = [
f"valid={str(report.valid).lower()}",
f"ready_for_review={str(report.ready_for_review).lower()}",
f"needs_author_action={str(report.needs_author_action).lower()}",
f"is_bot_pr={str(report.is_bot_pr).lower()}",
]
if not output_path:
for line in lines:
print(line)
return
with Path(output_path).open("a", encoding="utf-8") as output_file:
for line in lines:
output_file.write(f"{line}\n")
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--event", type=Path, required=True, help="Path to the GitHub event payload JSON."
)
parser.add_argument("--report", type=Path, required=True, help="Path to write the JSON report.")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv or sys.argv[1:])
report = validate_pull_request(load_event(args.event))
args.report.write_text(json.dumps(report.to_dict(), indent=2), encoding="utf-8")
emit_outputs(report)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,163 @@
"""Tests for pr-governance.py."""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
def _load_module():
script = Path(__file__).parent.parent / "pr-governance.py"
spec = importlib.util.spec_from_file_location("pr_governance", script)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def _event(body: str, *, draft: bool = False, login: str = "octocat") -> dict[str, object]:
return {
"pull_request": {
"number": 42,
"draft": draft,
"body": body,
"user": {"login": login},
}
}
VALID_BODY = """## Description
Add a required PR-governance gate for template validation and review readiness.
Closes #123
## Type of Change
- [x] New feature (non-breaking change that adds functionality)
- [ ] Documentation update
## Changes Made
- Added a workflow-backed PR template validator.
- Added local commit message linting in the commit-msg hook.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Manual testing performed
### Test Output
```text
pytest scripts/tests/test_pr_governance.py -q
```
## Real Behavior Proof
- Environment: Ubuntu runner, Python 3.12
- Exact command / steps: Open a PR, remove the ready checkbox, re-run the workflow.
- Observed result: The governance check fails and the PR gets a needs-author-action label.
- Not tested: Automatic Copilot review rulesets in repository settings.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Additional Notes
- Maintainers can optionally enable Copilot code review from repository rulesets.
"""
def test_validate_pull_request_marks_ready_pr_valid() -> None:
module = _load_module()
report = module.validate_pull_request(_event(VALID_BODY))
assert report.valid is True
assert report.ready_for_review is True
assert report.needs_author_action is False
assert report.problems == []
assert report.labels_to_add == [module.READY_LABEL]
assert module.AUTHOR_ACTION_LABEL in report.labels_to_remove
def test_validate_pull_request_allows_draft_without_ready_checkboxes() -> None:
module = _load_module()
body = VALID_BODY.replace(
"- [x] I have performed a self-review", "- [ ] I have performed a self-review"
)
body = body.replace(
"- [x] This PR is ready for human review",
"- [ ] This PR is ready for human review",
)
report = module.validate_pull_request(_event(body, draft=True))
assert report.valid is True
assert report.ready_for_review is False
assert report.needs_author_action is False
assert report.labels_to_add == []
assert module.READY_LABEL in report.labels_to_remove
def test_validate_pull_request_fails_on_missing_required_content() -> None:
module = _load_module()
body = """## Description
Fixes #123
## Type of Change
- [ ] New feature (non-breaking change that adds functionality)
## Changes Made
- Change 1
## Testing
### Test Output
```text
# Paste relevant command output or artifact links here
```
## Real Behavior Proof
- Environment:
- Exact command / steps:
- Observed result:
- Not tested:
## Review Readiness
- [ ] I have performed a self-review
- [ ] This PR is ready for human review
"""
report = module.validate_pull_request(_event(body))
assert report.valid is False
assert report.needs_author_action is True
assert module.AUTHOR_ACTION_LABEL in report.labels_to_add
assert any("Description" in problem for problem in report.problems)
assert any("Type of Change" in problem for problem in report.problems)
assert any("Test Output" in problem for problem in report.problems)
assert any("Real Behavior Proof" in problem for problem in report.problems)
def test_validate_pull_request_skips_bot_authored_prs() -> None:
module = _load_module()
report = module.validate_pull_request(_event("", login="dependabot[bot]"))
assert report.valid is True
assert report.is_bot_pr is True
assert report.needs_author_action is False
assert report.labels_to_add == []

View file

@ -32,4 +32,6 @@ run_act act workflow_dispatch -W .github/workflows/release.yml -e .github/act/dr
# validation step exercises the same code path CI actually fires on.
run_act act release -W .github/workflows/release.yml -e .github/act/release-published.json -n
run_act act push -W .github/workflows/release-please.yml -e .github/act/push-feat.json -n
run_act act pull_request_target -W .github/workflows/pr-health.yml -e .github/act/pr-governance-invalid.json -n
run_act act pull_request_target -W .github/workflows/pr-health.yml -e .github/act/pr-governance-valid.json -n
run_act act workflow_dispatch -W .github/workflows/docker.yml -e .github/act/docker-version.json -n