|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Enforce the *aspirational invariants are ticketed* rule from `docs/HARNESS.md`. |
| 3 | +
|
| 4 | +`docs/HARNESS.md`: *"Do not merge an 'aspirational' invariant without a |
| 5 | +follow-up issue."* `docs/INVARIANTS.md` documents this with the |
| 6 | +`*Aspirational:*` marker line per invariant. Until #133 this rule was |
| 7 | +enforced by reviewer memory only. |
| 8 | +
|
| 9 | +Behaviour: |
| 10 | +
|
| 11 | +- Walks `docs/INVARIANTS.md`, reading line by line. |
| 12 | +- A line is treated as an *aspirational marker* when it starts with |
| 13 | + `*Aspirational` (one leading asterisk) or `**Aspirational**` (two). |
| 14 | + Mid-sentence prose like "items marked *aspirational* are…" is |
| 15 | + ignored — the marker shape is documented in |
| 16 | + `docs/INVARIANTS.md`. |
| 17 | +- For each marker line, requires at least one `#NNN` reference on the |
| 18 | + same line; fails when none is present. |
| 19 | +- When `GITHUB_TOKEN` and `GITHUB_REPOSITORY` are set, fetches each |
| 20 | + cited issue via the REST API and emits a `::warning::` (not |
| 21 | + failure) when the ticket is closed. API failures (network, rate |
| 22 | + limit, 404) downgrade to a warning — the gate's job is to catch |
| 23 | + drift in the doc, not to be a transient-CI tripwire. |
| 24 | +- When `ASPIRATIONAL_STRICT=1` is set, a closed-ticket cite is |
| 25 | + promoted from `::warning::` to a hard failure (#153). Default off. |
| 26 | + This is the toggle to flip when the project decides "an aspirational |
| 27 | + invariant whose cite is closed must be promoted to enforced or |
| 28 | + refiled in this PR" — useful as the harness matures and accumulated |
| 29 | + closed-cite drift would otherwise sit unread. API failures still |
| 30 | + downgrade to a warning even under strict mode (the gate is still |
| 31 | + not a transient-CI tripwire — only documented closed state hits). |
| 32 | +
|
| 33 | +There is **no exemption mechanism** (see `feedback_no_noqa`). If an |
| 34 | +entry should not be flagged, it is not aspirational — reword it with |
| 35 | +a different marker (e.g. `**Production note:**` for future product |
| 36 | +evolution that's not a yet-to-be-enforced rule). |
| 37 | +
|
| 38 | +Exit codes: |
| 39 | + 0 — every aspirational marker cites at least one open / unverified ticket |
| 40 | + 1 — at least one marker has no ticket cite, or (strict mode) has a |
| 41 | + closed cite |
| 42 | + 2 — script-level error (`docs/INVARIANTS.md` not found) |
| 43 | +
|
| 44 | +Usage (from repo root): |
| 45 | +
|
| 46 | + python .github/scripts/check_aspirational_tickets.py |
| 47 | +""" |
| 48 | + |
| 49 | +from __future__ import annotations |
| 50 | + |
| 51 | +import json |
| 52 | +import os |
| 53 | +import re |
| 54 | +import sys |
| 55 | +import urllib.error |
| 56 | +import urllib.request |
| 57 | +from pathlib import Path |
| 58 | + |
| 59 | +INVARIANTS_DOC = Path("docs/INVARIANTS.md") |
| 60 | + |
| 61 | +# A marker line *starts* with one or two asterisks immediately followed by |
| 62 | +# `Aspirational` and a word boundary. Avoids picking up mid-sentence prose |
| 63 | +# such as `Items marked *aspirational* are rules…` (lower-case + non-anchored). |
| 64 | +_MARKER_RE = re.compile(r"^\*{1,2}Aspirational\b") |
| 65 | +_TICKET_RE = re.compile(r"#(\d+)") |
| 66 | + |
| 67 | + |
| 68 | +def _find_markers(text: str) -> list[tuple[int, str]]: |
| 69 | + """Return (1-indexed line number, line text) for each aspirational marker.""" |
| 70 | + found: list[tuple[int, str]] = [] |
| 71 | + for index, line in enumerate(text.splitlines(), start=1): |
| 72 | + if _MARKER_RE.match(line.lstrip()): |
| 73 | + found.append((index, line.strip())) |
| 74 | + return found |
| 75 | + |
| 76 | + |
| 77 | +def _issue_state(repo: str, number: str, token: str) -> str | None: |
| 78 | + """Return the issue state ("open" / "closed") or None on any API failure.""" |
| 79 | + url = f"https://api.github.com/repos/{repo}/issues/{number}" |
| 80 | + req = urllib.request.Request( # noqa: S310 — fixed api.github.com host |
| 81 | + url, |
| 82 | + headers={ |
| 83 | + "Authorization": f"Bearer {token}", |
| 84 | + "Accept": "application/vnd.github+json", |
| 85 | + "X-GitHub-Api-Version": "2022-11-28", |
| 86 | + }, |
| 87 | + ) |
| 88 | + try: |
| 89 | + with urllib.request.urlopen(req, timeout=5) as response: # noqa: S310 |
| 90 | + payload = json.loads(response.read().decode("utf-8")) |
| 91 | + except urllib.error.URLError, TimeoutError, json.JSONDecodeError: |
| 92 | + return None |
| 93 | + state = payload.get("state") |
| 94 | + return state if isinstance(state, str) else None |
| 95 | + |
| 96 | + |
| 97 | +def _check_closed_cites( |
| 98 | + line_number: int, |
| 99 | + tickets: list[str], |
| 100 | + repo: str, |
| 101 | + token: str, |
| 102 | + *, |
| 103 | + strict: bool, |
| 104 | +) -> list[str]: |
| 105 | + """Return failure messages (strict only); print warnings otherwise.""" |
| 106 | + extra_failures: list[str] = [] |
| 107 | + for ticket in tickets: |
| 108 | + state = _issue_state(repo, ticket, token) |
| 109 | + if state != "closed": |
| 110 | + continue |
| 111 | + severity = "error" if strict else "warning" |
| 112 | + message = ( |
| 113 | + f"::{severity} file={INVARIANTS_DOC.as_posix()},line={line_number}::" |
| 114 | + f"cited ticket #{ticket} is closed. Promote the invariant to " |
| 115 | + "enforced (and remove the marker), or refile with a fresh ticket." |
| 116 | + ) |
| 117 | + if strict: |
| 118 | + extra_failures.append(message) |
| 119 | + else: |
| 120 | + print(message) |
| 121 | + return extra_failures |
| 122 | + |
| 123 | + |
| 124 | +def main() -> int: |
| 125 | + if not INVARIANTS_DOC.is_file(): |
| 126 | + print(f"::error::{INVARIANTS_DOC.as_posix()} not found; run from repo root") |
| 127 | + return 2 |
| 128 | + |
| 129 | + text = INVARIANTS_DOC.read_text(encoding="utf-8") |
| 130 | + markers = _find_markers(text) |
| 131 | + |
| 132 | + if not markers: |
| 133 | + print("No aspirational markers found in docs/INVARIANTS.md.") |
| 134 | + return 0 |
| 135 | + |
| 136 | + failures: list[str] = [] |
| 137 | + repo = os.environ.get("GITHUB_REPOSITORY", "") |
| 138 | + token = os.environ.get("GITHUB_TOKEN", "") |
| 139 | + can_check_state = bool(repo and token) |
| 140 | + strict_closed = os.environ.get("ASPIRATIONAL_STRICT", "") == "1" |
| 141 | + |
| 142 | + for line_number, line in markers: |
| 143 | + tickets = _TICKET_RE.findall(line) |
| 144 | + if not tickets: |
| 145 | + failures.append( |
| 146 | + f"::error file={INVARIANTS_DOC.as_posix()},line={line_number}::" |
| 147 | + "aspirational marker has no `#NNN` ticket reference. Add one or " |
| 148 | + "reword (see the doc's *How to add an invariant* footer)." |
| 149 | + ) |
| 150 | + continue |
| 151 | + print( |
| 152 | + f"{INVARIANTS_DOC.as_posix()}:{line_number} — " |
| 153 | + f"aspirational, cites: {', '.join('#' + t for t in tickets)}" |
| 154 | + ) |
| 155 | + if can_check_state: |
| 156 | + failures.extend( |
| 157 | + _check_closed_cites( |
| 158 | + line_number, tickets, repo, token, strict=strict_closed |
| 159 | + ) |
| 160 | + ) |
| 161 | + |
| 162 | + if failures: |
| 163 | + for line in failures: |
| 164 | + print(line) |
| 165 | + print( |
| 166 | + f"\n{len(failures)} aspirational marker(s) missing a ticket cite. " |
| 167 | + "Fix in this PR — there is no exemption mechanism, see the " |
| 168 | + "module docstring." |
| 169 | + ) |
| 170 | + return 1 |
| 171 | + |
| 172 | + suffix_parts: list[str] = [] |
| 173 | + if can_check_state: |
| 174 | + suffix_parts.append("with state lookup") |
| 175 | + if strict_closed: |
| 176 | + suffix_parts.append("strict closed-cite mode") |
| 177 | + suffix = f" ({', '.join(suffix_parts)})" if suffix_parts else "" |
| 178 | + print(f"Aspirational-ticket audit OK — {len(markers)} marker(s) checked{suffix}.") |
| 179 | + return 0 |
| 180 | + |
| 181 | + |
| 182 | +if __name__ == "__main__": |
| 183 | + sys.exit(main()) |
0 commit comments