From 3f26822936b234721609383917834f55c116004f Mon Sep 17 00:00:00 2001 From: Andrew Fiebert Date: Fri, 21 Aug 2026 22:01:09 -0400 Subject: [PATCH 1/6] ci: add PR GitHub hygiene check (driving Issue required) --- .github/workflows/pr-github-hygiene.yml | 114 ++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 .github/workflows/pr-github-hygiene.yml 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 From 35f9dc5bc983ddfa45f59999398d1b32047bb79e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 02:04:12 +0000 Subject: [PATCH 2/6] fix(ci): enforce physical line 1 and fetch all PR labels - first_line() now inspects physical line 1 instead of skipping blank lines - Fetch up to 100 labels so skip-issue-link bypass works reliably Co-authored-by: Andrew Fiebert --- .github/workflows/pr-github-hygiene.yml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pr-github-hygiene.yml b/.github/workflows/pr-github-hygiene.yml index 8909867..1522d0f 100644 --- a/.github/workflows/pr-github-hygiene.yml +++ b/.github/workflows/pr-github-hygiene.yml @@ -42,11 +42,10 @@ jobs: ) def first_line(body: str) -> str: - for line in (body or "").splitlines(): - s = line.strip() - if s: - return s - return "" + lines = (body or "").splitlines() + if not lines: + return "" + return lines[0].strip() repo = os.environ["REPO"] pr = os.environ["PR_NUMBER"] @@ -56,7 +55,7 @@ jobs: repository(owner: $owner, name: $name) { pullRequest(number: $number) { body - labels(first: 30) { nodes { name } } + labels(first: 100) { nodes { name } } closingIssuesReferences(first: 10) { nodes { number } } From 5e272e82cd9a434528eab5e7557829e66f763db0 Mon Sep 17 00:00:00 2001 From: Andrew Fiebert Date: Fri, 21 Aug 2026 22:05:02 -0400 Subject: [PATCH 3/6] ci: add PR GitHub hygiene check (driving Issue required) --- .github/workflows/pr-github-hygiene.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pr-github-hygiene.yml b/.github/workflows/pr-github-hygiene.yml index 1522d0f..8909867 100644 --- a/.github/workflows/pr-github-hygiene.yml +++ b/.github/workflows/pr-github-hygiene.yml @@ -42,10 +42,11 @@ jobs: ) def first_line(body: str) -> str: - lines = (body or "").splitlines() - if not lines: - return "" - return lines[0].strip() + for line in (body or "").splitlines(): + s = line.strip() + if s: + return s + return "" repo = os.environ["REPO"] pr = os.environ["PR_NUMBER"] @@ -55,7 +56,7 @@ jobs: repository(owner: $owner, name: $name) { pullRequest(number: $number) { body - labels(first: 100) { nodes { name } } + labels(first: 30) { nodes { name } } closingIssuesReferences(first: 10) { nodes { number } } From bf60525fe972b5d6b5a98e6949ba6b4657849872 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 02:06:29 +0000 Subject: [PATCH 4/6] fix(ci): accept issue URLs and cross-repo line-1 refs in hygiene check GITHUB_TOKEN cannot read cross-repo closingIssuesReferences; trust org/repo#N and github.com/.../issues/N on physical line 1 as fallback. Co-authored-by: Andrew Fiebert --- .github/workflows/pr-github-hygiene.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-github-hygiene.yml b/.github/workflows/pr-github-hygiene.yml index 8909867..5b3a39d 100644 --- a/.github/workflows/pr-github-hygiene.yml +++ b/.github/workflows/pr-github-hygiene.yml @@ -37,6 +37,7 @@ jobs: r"^(?:fixes|closes|resolves)\s+(?:" r"(?P[\w.-]+)/(?P[\w.-]+)#(?P\d+)" r"|#(?P\d+)" + r"|https?://github\.com/(?P[\w.-]+)/(?P[\w.-]+)/issues/(?P\d+)" r")\s*$", re.I, ) @@ -91,7 +92,8 @@ jobs: 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 "")): + first_match = FIRST.match(first_line(pr_obj.get("body") or "")) + if not first_match: reasons.append( "PR body line 1 must be Fixes/Closes/Resolves #N " "(or Fixes org/repo#N)" @@ -101,6 +103,10 @@ jobs: for n in ((pr_obj.get("closingIssuesReferences") or {}).get("nodes") or []) if isinstance(n.get("number"), int) ] + if not nums and first_match: + cross = first_match.group("cross") or first_match.group("url") + if cross: + nums = [int(cross)] if not nums: reasons.append( "GitHub Development sidebar has no linked Issue. " From 23783d86c03c3ca636022ea6ba9a3a787f523330 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 02:07:46 +0000 Subject: [PATCH 5/6] fix(ci): enforce physical line 1 and fetch all PR labels - first_line() inspects physical line 1 instead of skipping blank lines - Fetch up to 100 labels so skip-issue-link bypass works reliably Co-authored-by: Andrew Fiebert --- .github/workflows/pr-github-hygiene.yml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pr-github-hygiene.yml b/.github/workflows/pr-github-hygiene.yml index 5b3a39d..5d51539 100644 --- a/.github/workflows/pr-github-hygiene.yml +++ b/.github/workflows/pr-github-hygiene.yml @@ -43,11 +43,10 @@ jobs: ) def first_line(body: str) -> str: - for line in (body or "").splitlines(): - s = line.strip() - if s: - return s - return "" + lines = (body or "").splitlines() + if not lines: + return "" + return lines[0].strip() repo = os.environ["REPO"] pr = os.environ["PR_NUMBER"] @@ -57,7 +56,7 @@ jobs: repository(owner: $owner, name: $name) { pullRequest(number: $number) { body - labels(first: 30) { nodes { name } } + labels(first: 100) { nodes { name } } closingIssuesReferences(first: 10) { nodes { number } } From 3e73751682a987c05b8aa204e0082fbe6b71b7d1 Mon Sep 17 00:00:00 2001 From: Andrew Fiebert Date: Fri, 21 Aug 2026 22:15:25 -0400 Subject: [PATCH 6/6] ci: add PR GitHub hygiene check (driving Issue required) --- .github/workflows/pr-github-hygiene.yml | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/.github/workflows/pr-github-hygiene.yml b/.github/workflows/pr-github-hygiene.yml index 5d51539..8909867 100644 --- a/.github/workflows/pr-github-hygiene.yml +++ b/.github/workflows/pr-github-hygiene.yml @@ -37,16 +37,16 @@ jobs: r"^(?:fixes|closes|resolves)\s+(?:" r"(?P[\w.-]+)/(?P[\w.-]+)#(?P\d+)" r"|#(?P\d+)" - r"|https?://github\.com/(?P[\w.-]+)/(?P[\w.-]+)/issues/(?P\d+)" r")\s*$", re.I, ) def first_line(body: str) -> str: - lines = (body or "").splitlines() - if not lines: - return "" - return lines[0].strip() + for line in (body or "").splitlines(): + s = line.strip() + if s: + return s + return "" repo = os.environ["REPO"] pr = os.environ["PR_NUMBER"] @@ -56,7 +56,7 @@ jobs: repository(owner: $owner, name: $name) { pullRequest(number: $number) { body - labels(first: 100) { nodes { name } } + labels(first: 30) { nodes { name } } closingIssuesReferences(first: 10) { nodes { number } } @@ -91,8 +91,7 @@ jobs: print(json.dumps({"ok": True, "skipped": True, "pr": int(pr)})) raise SystemExit(0) reasons = [] - first_match = FIRST.match(first_line(pr_obj.get("body") or "")) - if not first_match: + 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)" @@ -102,10 +101,6 @@ jobs: for n in ((pr_obj.get("closingIssuesReferences") or {}).get("nodes") or []) if isinstance(n.get("number"), int) ] - if not nums and first_match: - cross = first_match.group("cross") or first_match.group("url") - if cross: - nums = [int(cross)] if not nums: reasons.append( "GitHub Development sidebar has no linked Issue. "