diff --git a/.github/workflows/pr-github-hygiene.yml b/.github/workflows/pr-github-hygiene.yml new file mode 100644 index 0000000..8909867 --- /dev/null +++ b/.github/workflows/pr-github-hygiene.yml @@ -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[\w.-]+)/(?P[\w.-]+)#(?P\d+)" + r"|#(?P\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