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