@seanyao/roll 2.602.4 → 2.603.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,155 +0,0 @@
1
- #!/usr/bin/env python3
2
- """FIX-113: changelog audit — list PRs merged to main since the latest
3
- release tag that don't appear in CHANGELOG.md's ## Unreleased section.
4
-
5
- Run before `release.sh` so missing entries surface BEFORE the AI rewrite
6
- gets a chance to silently drop them.
7
-
8
- Usage:
9
- python3 lib/changelog_audit.py # report missing
10
- python3 lib/changelog_audit.py --since v2026.520.1
11
- python3 lib/changelog_audit.py --json # machine-readable
12
-
13
- Exit 0 always (read-only audit). Output:
14
- - "audit ok" + no missing list when CHANGELOG covers every merged PR
15
- - "audit found N PR(s) without a CHANGELOG entry:" + list otherwise
16
- """
17
- from __future__ import annotations
18
- import argparse, json, re, subprocess, sys
19
- from pathlib import Path
20
-
21
- PR_RE = re.compile(r"\(#(\d+)\)")
22
- SQUASH_RE = re.compile(r"^([a-z0-9]{7,40})\s+(.*?)\s*\(#(\d+)\)\s*$")
23
-
24
- # Heuristic: PR titles whose first token is one of these tags are usually
25
- # user-visible (need CHANGELOG entry). Tags like "chore:", "docs:" still
26
- # warrant a docs section entry; left to user judgement.
27
- USER_VISIBLE_PATTERNS = (
28
- re.compile(r"\b(US-[A-Z0-9-]+-\d+)\b"),
29
- re.compile(r"\b(FIX-\d+)\b"),
30
- re.compile(r"\b(REFACTOR-\d+)\b"),
31
- )
32
-
33
- def _latest_tag() -> str:
34
- """Return the most recent v* tag, or empty string if none."""
35
- try:
36
- out = subprocess.check_output(
37
- ["git", "tag", "--list", "v*", "--sort=-creatordate"],
38
- text=True, stderr=subprocess.DEVNULL,
39
- ).strip()
40
- for line in out.splitlines():
41
- if line:
42
- return line
43
- except Exception:
44
- pass
45
- return ""
46
-
47
- def _merged_prs_since(since: str):
48
- """Return list of (sha, subject, pr_number) for first-parent merges
49
- on main between <since> and HEAD."""
50
- cmd = ["git", "log", "--first-parent", "--oneline"]
51
- if since:
52
- cmd.append(f"{since}..HEAD")
53
- try:
54
- out = subprocess.check_output(cmd, text=True, stderr=subprocess.DEVNULL)
55
- except Exception:
56
- return []
57
- rows = []
58
- for line in out.splitlines():
59
- m = SQUASH_RE.match(line)
60
- if not m:
61
- # Older "Merge pull request #N from <branch>" format
62
- m2 = re.match(r"^([a-f0-9]{7,40})\s+Merge pull request #(\d+) from .*$", line)
63
- if m2:
64
- rows.append((m2.group(1), line.split(None, 1)[1], int(m2.group(2))))
65
- continue
66
- # Also look for parenthesized #N anywhere in subject
67
- m3 = re.match(r"^([a-f0-9]{7,40})\s+(.*)$", line)
68
- if m3:
69
- pr_match = PR_RE.search(m3.group(2))
70
- if pr_match:
71
- rows.append((m3.group(1), m3.group(2), int(pr_match.group(1))))
72
- continue
73
- rows.append((m.group(1), m.group(2), int(m.group(3))))
74
- return rows
75
-
76
- def _read_unreleased_section(changelog: Path) -> str:
77
- """Return the text of the ## Unreleased section (or empty string)."""
78
- if not changelog.exists():
79
- return ""
80
- text = changelog.read_text(errors="ignore")
81
- m = re.search(r"^## Unreleased\s*\n(.*?)(?=^## |\Z)", text, re.MULTILINE | re.DOTALL)
82
- return m.group(1) if m else ""
83
-
84
- def _is_in_changelog(subject: str, unreleased_text: str) -> bool:
85
- """A PR is considered covered if any story id from its subject appears in
86
- the Unreleased section text."""
87
- for pat in USER_VISIBLE_PATTERNS:
88
- for m in pat.finditer(subject):
89
- sid = m.group(1)
90
- if sid in unreleased_text:
91
- return True
92
- # Fallback: PR number explicit mention
93
- pr_m = PR_RE.search(subject)
94
- if pr_m and f"#{pr_m.group(1)}" in unreleased_text:
95
- return True
96
- return False
97
-
98
- def main():
99
- ap = argparse.ArgumentParser()
100
- ap.add_argument("--since", default="", help="Compare against this tag (default: latest v* tag)")
101
- ap.add_argument("--changelog", default="CHANGELOG.md")
102
- ap.add_argument("--json", action="store_true", help="Machine-readable output")
103
- args = ap.parse_args()
104
-
105
- since = args.since or _latest_tag()
106
- prs = _merged_prs_since(since)
107
- cl = _read_unreleased_section(Path(args.changelog))
108
-
109
- missing = []
110
- skipped_internal = []
111
- for sha, subject, pr_n in prs:
112
- # Skip merges that are themselves releases
113
- if subject.startswith("[release]") or subject.startswith("[ release]"):
114
- continue
115
- if _is_in_changelog(subject, cl):
116
- continue
117
- # Heuristic: subjects that don't contain a story id and start with
118
- # internal-only tags (chore: backlog ..., chore: rebase, etc.) are
119
- # marked as "internal", less likely to need user-facing entry.
120
- is_user_visible = any(p.search(subject) for p in USER_VISIBLE_PATTERNS)
121
- if is_user_visible:
122
- missing.append({"pr": pr_n, "sha": sha, "subject": subject})
123
- else:
124
- skipped_internal.append({"pr": pr_n, "sha": sha, "subject": subject})
125
-
126
- if args.json:
127
- json.dump({
128
- "since": since,
129
- "total_prs": len(prs),
130
- "missing_user_visible": missing,
131
- "skipped_internal": skipped_internal,
132
- }, sys.stdout, indent=2, ensure_ascii=False)
133
- print()
134
- return 0
135
-
136
- print(f"changelog audit since={since or '(no tag)'} scanned {len(prs)} PR(s)")
137
- print()
138
- if not missing:
139
- print(f" ✓ audit ok — every user-visible PR is mentioned in CHANGELOG.md Unreleased")
140
- if skipped_internal:
141
- print(f" · {len(skipped_internal)} internal/infra PR(s) skipped from audit")
142
- return 0
143
-
144
- print(f" ⚠ {len(missing)} user-visible PR(s) without a CHANGELOG entry:")
145
- for m in missing:
146
- print(f" #{m['pr']} {m['subject']}")
147
- print()
148
- print(" Add bullets under '## Unreleased' in CHANGELOG.md before release,")
149
- print(" or confirm these PRs are intentionally undocumented (rare).")
150
- if skipped_internal:
151
- print(f" ({len(skipped_internal)} internal PR(s) skipped from audit)")
152
- return 0
153
-
154
- if __name__ == "__main__":
155
- sys.exit(main())