Skip to content
Merged
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
114 changes: 114 additions & 0 deletions .github/workflows/pr-github-hygiene.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
name: PR GitHub hygiene

# Lint the GitHub process, not the code. Fail unless:
# 1) PR body line 1 is Fixes/Closes/Resolves #N (or org/repo#N)
# 2) Development sidebar has a linked Issue
# or the PR is labeled skip-issue-link.
# Self-contained — copy this file only; do not import repo scripts.
# Require the check `pr-github-hygiene` on the default branch AFTER this
# file exists there (required-before-land freezes unrelated open PRs).

on:
pull_request:
types: [opened, edited, synchronize, reopened, labeled, unlabeled]

permissions:
contents: read
pull-requests: read
issues: read

jobs:
hygiene:
name: pr-github-hygiene
runs-on: ubuntu-22.04
steps:
- name: Check driving Issue link
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
python3 - <<'PY'
import json, os, re, subprocess, sys

SKIP = "skip-issue-link"
FIRST = re.compile(
r"^(?:fixes|closes|resolves)\s+(?:"
r"(?P<owner>[\w.-]+)/(?P<repo>[\w.-]+)#(?P<cross>\d+)"
r"|#(?P<same>\d+)"
r")\s*$",
re.I,
)

def first_line(body: str) -> str:
for line in (body or "").splitlines():
s = line.strip()
if s:
return s
return ""

repo = os.environ["REPO"]
pr = os.environ["PR_NUMBER"]
owner, name = repo.split("/", 1)
query = """
query($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
body
labels(first: 30) { nodes { name } }
closingIssuesReferences(first: 10) {
nodes { number }
}
}
}
}
"""
payload = {
"query": query,
"variables": {"owner": owner, "name": name, "number": int(pr)},
}
proc = subprocess.run(
["gh", "api", "graphql", "--input", "-"],
input=json.dumps(payload),
text=True,
capture_output=True,
check=False,
)
if proc.returncode != 0:
print(proc.stderr or proc.stdout, file=sys.stderr)
raise SystemExit(1)
data = json.loads(proc.stdout)
if data.get("errors"):
print(json.dumps(data["errors"]), file=sys.stderr)
raise SystemExit(1)
pr_obj = data["data"]["repository"]["pullRequest"]
labels = {
(n.get("name") or "").strip().lower()
for n in ((pr_obj.get("labels") or {}).get("nodes") or [])
}
if SKIP in labels:
print(json.dumps({"ok": True, "skipped": True, "pr": int(pr)}))
raise SystemExit(0)
reasons = []
if not FIRST.match(first_line(pr_obj.get("body") or "")):
reasons.append(
"PR body line 1 must be Fixes/Closes/Resolves #N "
"(or Fixes org/repo#N)"
)
nums = [
n.get("number")
for n in ((pr_obj.get("closingIssuesReferences") or {}).get("nodes") or [])
if isinstance(n.get("number"), int)
]
if not nums:
reasons.append(
"GitHub Development sidebar has no linked Issue. "
f"Add Fixes #N on line 1 or label the PR `{SKIP}`."
)
print(json.dumps({"ok": not reasons, "reasons": reasons, "issues": nums, "pr": int(pr)}))
if reasons:
for r in reasons:
print(f"::error::{r}", file=sys.stderr)
raise SystemExit(1)
PY
Loading