@seanyao/roll 0.5.0 → 2.602.2
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.
- package/CHANGELOG.md +736 -0
- package/LICENSE +21 -0
- package/README.md +65 -165
- package/bin/dream-test-quality-scan +110 -0
- package/bin/roll +15030 -814
- package/conventions/config.yaml +17 -1
- package/conventions/global/AGENTS.md +146 -100
- package/conventions/global/CLAUDE.md +1 -21
- package/conventions/global/GEMINI.md +8 -22
- package/conventions/global/project_rules.md +9 -0
- package/conventions/templates/backend-service/AGENTS.md +30 -81
- package/conventions/templates/backend-service/GEMINI.md +3 -3
- package/conventions/templates/backend-service/project_rules.md +16 -0
- package/conventions/templates/cli/AGENTS.md +31 -58
- package/conventions/templates/cli/CLAUDE.md +3 -5
- package/conventions/templates/cli/GEMINI.md +3 -3
- package/conventions/templates/cli/project_rules.md +16 -0
- package/conventions/templates/frontend-only/AGENTS.md +29 -64
- package/conventions/templates/frontend-only/GEMINI.md +3 -3
- package/conventions/templates/frontend-only/project_rules.md +14 -0
- package/conventions/templates/fullstack/AGENTS.md +31 -79
- package/conventions/templates/fullstack/CLAUDE.md +1 -1
- package/conventions/templates/fullstack/GEMINI.md +3 -3
- package/conventions/templates/fullstack/project_rules.md +15 -0
- package/lib/README.md +42 -0
- package/lib/__pycache__/github_sync.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop-fmt.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop_result_eval.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop_unstick.cpython-314.pyc +0 -0
- package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
- package/lib/__pycache__/prices_fetcher.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll_git.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll_render.cpython-314.pyc +0 -0
- package/lib/__pycache__/slides-render.cpython-314.pyc +0 -0
- package/lib/agent_usage/README.md +49 -0
- package/lib/agent_usage/__init__.py +108 -0
- package/lib/agent_usage/__pycache__/__init__.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/gemini.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/kimi.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/openai.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/pi.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/pi_emit.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/qwen.cpython-314.pyc +0 -0
- package/lib/agent_usage/gemini.py +127 -0
- package/lib/agent_usage/kimi.py +278 -0
- package/lib/agent_usage/kimi_emit.py +123 -0
- package/lib/agent_usage/openai.py +126 -0
- package/lib/agent_usage/pi.py +200 -0
- package/lib/agent_usage/pi_emit.py +135 -0
- package/lib/agent_usage/qwen.py +128 -0
- package/lib/backfill-pi-usage.py +243 -0
- package/lib/changelog_audit.py +155 -0
- package/lib/changelog_generate.py +263 -0
- package/lib/context_feed_budget.sh +194 -0
- package/lib/github_sync.py +876 -0
- package/lib/i18n/README.md +54 -0
- package/lib/i18n/agent.sh +75 -0
- package/lib/i18n/alert.sh +20 -0
- package/lib/i18n/backlog.sh +96 -0
- package/lib/i18n/brief.sh +5 -0
- package/lib/i18n/changelog.sh +5 -0
- package/lib/i18n/ci.sh +15 -0
- package/lib/i18n/debug.sh +0 -0
- package/lib/i18n/doctor.sh +44 -0
- package/lib/i18n/dream.sh +0 -0
- package/lib/i18n/init.sh +91 -0
- package/lib/i18n/lang.sh +10 -0
- package/lib/i18n/loop.sh +140 -0
- package/lib/i18n/migrate.sh +74 -0
- package/lib/i18n/offboard.sh +31 -0
- package/lib/i18n/onboard.sh +0 -0
- package/lib/i18n/peer.sh +41 -0
- package/lib/i18n/peer_help.sh +25 -0
- package/lib/i18n/peer_reset.sh +7 -0
- package/lib/i18n/peer_status.sh +5 -0
- package/lib/i18n/prices.sh +3 -0
- package/lib/i18n/prices_refresh.sh +17 -0
- package/lib/i18n/prices_show.sh +7 -0
- package/lib/i18n/propose.sh +0 -0
- package/lib/i18n/release.sh +0 -0
- package/lib/i18n/research.sh +0 -0
- package/lib/i18n/review_pr.sh +0 -0
- package/lib/i18n/sentinel.sh +0 -0
- package/lib/i18n/setup.sh +3 -0
- package/lib/i18n/shared.sh +157 -0
- package/lib/i18n/skills/roll-brief.sh +47 -0
- package/lib/i18n/skills/roll-build.sh +97 -0
- package/lib/i18n/skills/roll-design.sh +18 -0
- package/lib/i18n/skills/roll-fix.sh +53 -0
- package/lib/i18n/skills/roll-loop.sh +28 -0
- package/lib/i18n/skills/roll-onboard.sh +33 -0
- package/lib/i18n/skills_catalog.sh +30 -0
- package/lib/i18n/slides.sh +3 -0
- package/lib/i18n/slides_build.sh +38 -0
- package/lib/i18n/slides_delete.sh +19 -0
- package/lib/i18n/slides_list.sh +14 -0
- package/lib/i18n/slides_logs.sh +12 -0
- package/lib/i18n/slides_new.sh +15 -0
- package/lib/i18n/slides_preview.sh +14 -0
- package/lib/i18n/slides_templates.sh +7 -0
- package/lib/i18n/status.sh +21 -0
- package/lib/i18n/update.sh +24 -0
- package/lib/i18n.sh +211 -0
- package/lib/loop-exit-summary.py +393 -0
- package/lib/loop-fmt.py +589 -0
- package/lib/loop_pick_agent.py +316 -0
- package/lib/loop_result_eval.py +469 -0
- package/lib/loop_unstick.py +180 -0
- package/lib/model_prices.py +194 -0
- package/lib/prices/README.md +35 -0
- package/lib/prices/snapshot-2026-05-22.json +22 -0
- package/lib/prices/snapshot-2026-05-23-deepseek.json +15 -0
- package/lib/prices/snapshot-2026-05-23-kimi.json +15 -0
- package/lib/prices_fetcher.py +285 -0
- package/lib/roll-backlog.py +225 -0
- package/lib/roll-brief.py +286 -0
- package/lib/roll-help.py +158 -0
- package/lib/roll-home.py +556 -0
- package/lib/roll-init.py +156 -0
- package/lib/roll-loop-status.py +1683 -0
- package/lib/roll-loop-story.py +191 -0
- package/lib/roll-onboard-render.py +378 -0
- package/lib/roll-peer.py +252 -0
- package/lib/roll-plan-validate.py +386 -0
- package/lib/roll-setup.py +102 -0
- package/lib/roll-status.py +367 -0
- package/lib/roll_git.py +41 -0
- package/lib/roll_render.py +414 -0
- package/lib/slides/components/README.md +123 -0
- package/lib/slides/components/cards-2.html +9 -0
- package/lib/slides/components/cards-3.html +9 -0
- package/lib/slides/components/cards-4.html +9 -0
- package/lib/slides/components/compare.html +22 -0
- package/lib/slides/components/highlight.html +9 -0
- package/lib/slides/components/pipeline.html +12 -0
- package/lib/slides/components/plain.html +7 -0
- package/lib/slides/components/quote.html +4 -0
- package/lib/slides/components/timeline.html +9 -0
- package/lib/slides/templates/introduction-v3.html +571 -0
- package/lib/slides/templates/pitch.html +0 -0
- package/lib/slides-render.py +778 -0
- package/lib/slides-validate.py +357 -0
- package/lib/test_quality_gate.py +143 -0
- package/package.json +8 -7
- package/skills/roll-.changelog/SKILL.md +406 -33
- package/skills/roll-.clarify/SKILL.md +5 -2
- package/skills/roll-.dream/SKILL.md +374 -0
- package/skills/roll-.echo/SKILL.md +5 -2
- package/skills/roll-.qa/SKILL.md +57 -3
- package/skills/roll-.review/SKILL.md +42 -3
- package/skills/roll-brief/SKILL.md +209 -0
- package/skills/roll-build/SKILL.md +308 -63
- package/skills/roll-debug/SKILL.md +341 -162
- package/skills/roll-debug/injectable-bb.js +263 -0
- package/skills/roll-deck/SKILL.md +296 -0
- package/skills/roll-design/ENGINEERING_CHECKLIST.md +1 -1
- package/skills/roll-design/SKILL.md +733 -94
- package/skills/roll-doc/SKILL.md +595 -0
- package/skills/roll-doctor/SKILL.md +192 -0
- package/skills/roll-fix/SKILL.md +149 -32
- package/skills/{roll-jot → roll-idea}/SKILL.md +18 -10
- package/skills/roll-loop/SKILL.md +579 -0
- package/skills/roll-notes/SKILL.md +103 -0
- package/skills/roll-onboard/SKILL.md +234 -0
- package/skills/roll-peer/SKILL.md +336 -0
- package/skills/roll-propose/SKILL.md +157 -0
- package/skills/roll-review-pr/SKILL.md +58 -0
- package/skills/roll-sentinel/SKILL.md +11 -2
- package/skills/roll-spar/SKILL.md +8 -6
- package/template/.github/workflows/ci.yml +5 -2
- package/template/AGENTS.md +20 -74
- package/skills/roll-research/SKILL.md +0 -307
- package/skills/roll-research/references/schema.json +0 -162
- package/skills/roll-research/scripts/md_to_pdf.py +0 -289
- package/tools/roll-fetch/SKILL.md +0 -182
- package/tools/roll-fetch/package.json +0 -15
- package/tools/roll-fetch/smart-web-fetch.js +0 -558
- package/tools/roll-probe/SKILL.md +0 -84
- /package/template/{BACKLOG.md → .roll/backlog.md} +0 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
backfill-pi-usage — one-time, idempotent recovery of pi/deepseek token+cost
|
|
4
|
+
into an existing loop events file.
|
|
5
|
+
|
|
6
|
+
Why this exists
|
|
7
|
+
---------------
|
|
8
|
+
Before US-LOOP-026 the loop ran pi via ``pi -p`` (text mode), which prints no
|
|
9
|
+
usage. loop-fmt's old passthrough still appended a ``stage=="usage"`` event on
|
|
10
|
+
every retry attempt, each with ``model=="pi"`` and null tokens — so a single
|
|
11
|
+
cycle accumulated up to ~180 empty usage events. The dashboard SUMS token
|
|
12
|
+
fields across same-label usage events; with all-null tokens the SUM was 0
|
|
13
|
+
(harmless), but it means every affected cycle shows ``—/—``.
|
|
14
|
+
|
|
15
|
+
pi persists every session to ``~/.pi/agent/sessions/<enc-cwd>/<ts>_<uuid>.jsonl``
|
|
16
|
+
with real per-message usage. This script recovers that, and rewrites the events
|
|
17
|
+
file so each affected cycle is left with **exactly one** authoritative usage
|
|
18
|
+
event (real tokens, cost frozen in native CNY) — collapsing the N null events to
|
|
19
|
+
avoid the dashboard ×N inflation.
|
|
20
|
+
|
|
21
|
+
Safety / idempotency
|
|
22
|
+
--------------------
|
|
23
|
+
- Backs up the events file to ``<file>.bak-<UTC>`` first; aborts if backup fails.
|
|
24
|
+
- Only touches labels whose usage events are all pi-vendor (``model`` in
|
|
25
|
+
{"pi", "deepseek-v4-pro"}) AND carry null tokens AND match a pi session.
|
|
26
|
+
claude cycles, already-real cycles, and unmatched-null cycles are passed
|
|
27
|
+
through untouched.
|
|
28
|
+
- Re-runnable: once a label has a real-token usage event it is no longer a
|
|
29
|
+
candidate, so a second run is a no-op.
|
|
30
|
+
- FIX-065 tripwire: refuses to rewrite a production ``~/.shared/roll`` events
|
|
31
|
+
file from a test context (BATS / temp cwd) unless HOME itself is sandboxed.
|
|
32
|
+
|
|
33
|
+
Usage
|
|
34
|
+
-----
|
|
35
|
+
python3 lib/backfill-pi-usage.py --slug roll-ecf079
|
|
36
|
+
python3 lib/backfill-pi-usage.py --events /path/to/events.ndjson --dry-run
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
import argparse
|
|
40
|
+
import importlib.util
|
|
41
|
+
import json
|
|
42
|
+
import os
|
|
43
|
+
import shutil
|
|
44
|
+
import sys
|
|
45
|
+
from datetime import datetime, timezone
|
|
46
|
+
|
|
47
|
+
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
48
|
+
|
|
49
|
+
PI_VENDOR_MODELS = ("pi", "deepseek-v4-pro")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _load_pi_emit():
|
|
53
|
+
spec = importlib.util.spec_from_file_location(
|
|
54
|
+
"pi_emit", os.path.join(_THIS_DIR, "agent_usage", "pi_emit.py")
|
|
55
|
+
)
|
|
56
|
+
m = importlib.util.module_from_spec(spec)
|
|
57
|
+
spec.loader.exec_module(m)
|
|
58
|
+
return m
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _default_events_path(slug, shared=None):
|
|
62
|
+
base = shared or os.environ.get("LOOP_SHARED_ROOT") \
|
|
63
|
+
or os.path.expanduser("~/.shared/roll")
|
|
64
|
+
return os.path.join(base, "loop", "events-%s.ndjson" % slug)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _is_test_context():
|
|
68
|
+
return bool(os.environ.get("BATS_TEST_FILENAME")) or _cwd_is_temp()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _cwd_is_temp():
|
|
72
|
+
p = os.environ.get("PWD") or os.getcwd()
|
|
73
|
+
return any(seg in p for seg in ("/tmp/", "/private/tmp/", "/var/folders/", "/tmp."))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _home_is_sandbox():
|
|
77
|
+
home = os.environ.get("HOME") or ""
|
|
78
|
+
return any(seg in home for seg in ("/tmp/", "/private/tmp/", "/var/folders/", "/tmp."))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _tripwire(evfile):
|
|
82
|
+
"""FIX-065: refuse a prod write from a test context."""
|
|
83
|
+
home = os.environ.get("HOME") or ""
|
|
84
|
+
if not home or _home_is_sandbox():
|
|
85
|
+
return
|
|
86
|
+
prod = os.path.join(home, ".shared", "roll") + os.sep
|
|
87
|
+
if os.path.abspath(evfile).startswith(os.path.abspath(prod)) and _is_test_context():
|
|
88
|
+
raise SystemExit(
|
|
89
|
+
"[FIX-065] refusing to rewrite prod events file from test context: %s" % evfile
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _scan(lines):
|
|
94
|
+
"""Parse lines → (events_or_None list, per-label usage summary).
|
|
95
|
+
|
|
96
|
+
Returns (parsed, labels) where parsed is a list of (raw_line, obj_or_None)
|
|
97
|
+
preserving order, and labels maps label → {"pi": bool, "real": bool}.
|
|
98
|
+
"""
|
|
99
|
+
parsed = []
|
|
100
|
+
labels = {}
|
|
101
|
+
for raw in lines:
|
|
102
|
+
obj = None
|
|
103
|
+
try:
|
|
104
|
+
obj = json.loads(raw)
|
|
105
|
+
except (ValueError, TypeError):
|
|
106
|
+
obj = None
|
|
107
|
+
parsed.append((raw, obj))
|
|
108
|
+
if not obj or obj.get("stage") != "usage":
|
|
109
|
+
continue
|
|
110
|
+
lab = obj.get("label")
|
|
111
|
+
d = obj.get("detail") or {}
|
|
112
|
+
rec = labels.setdefault(lab, {"pi": False, "real": False})
|
|
113
|
+
if d.get("model") in PI_VENDOR_MODELS:
|
|
114
|
+
rec["pi"] = True
|
|
115
|
+
if d.get("input_tokens"):
|
|
116
|
+
rec["real"] = True
|
|
117
|
+
return parsed, labels
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def backfill(evfile, slug=None, shared=None, base_dir=None, dry_run=False):
|
|
121
|
+
"""Rewrite evfile so each recoverable pi cycle nets one real usage event.
|
|
122
|
+
|
|
123
|
+
Returns a stats dict.
|
|
124
|
+
"""
|
|
125
|
+
_tripwire(evfile)
|
|
126
|
+
pi_emit = _load_pi_emit()
|
|
127
|
+
|
|
128
|
+
with open(evfile) as f:
|
|
129
|
+
lines = f.readlines()
|
|
130
|
+
|
|
131
|
+
parsed, labels = _scan(lines)
|
|
132
|
+
|
|
133
|
+
# Candidate = pi-vendor, all-null, and a session match yields real usage.
|
|
134
|
+
candidates = [l for l, r in labels.items() if r["pi"] and not r["real"]]
|
|
135
|
+
replacement = {} # label -> detail payload
|
|
136
|
+
matched, unmatched = [], []
|
|
137
|
+
for lab in candidates:
|
|
138
|
+
cwd = os.path.join(
|
|
139
|
+
(shared or os.environ.get("LOOP_SHARED_ROOT")
|
|
140
|
+
or os.path.expanduser("~/.shared/roll")),
|
|
141
|
+
"worktrees", "%s-cycle-%s" % (slug, lab),
|
|
142
|
+
)
|
|
143
|
+
ev = pi_emit.build_event(cwd=cwd, cycle_id=lab, slug=slug, base_dir=base_dir)
|
|
144
|
+
if ev is None:
|
|
145
|
+
unmatched.append(lab)
|
|
146
|
+
continue
|
|
147
|
+
replacement[lab] = ev["detail"]
|
|
148
|
+
matched.append(lab)
|
|
149
|
+
|
|
150
|
+
if dry_run or not matched:
|
|
151
|
+
# Nothing recoverable to rewrite → no backup, no write (keeps re-runs
|
|
152
|
+
# a true no-op instead of spawning empty .bak files).
|
|
153
|
+
return {
|
|
154
|
+
"candidates": len(candidates),
|
|
155
|
+
"matched": len(matched),
|
|
156
|
+
"unmatched": len(unmatched),
|
|
157
|
+
"matched_labels": sorted(matched),
|
|
158
|
+
"unmatched_labels": sorted(unmatched),
|
|
159
|
+
"written": False,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# Backup before any write; abort the whole run if backup fails.
|
|
163
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
164
|
+
bak = "%s.bak-%s" % (evfile, stamp)
|
|
165
|
+
shutil.copy2(evfile, bak)
|
|
166
|
+
|
|
167
|
+
# Stream rewrite: for an affected label, the FIRST usage line becomes the
|
|
168
|
+
# real event (original ts preserved so it stays in its day bucket), every
|
|
169
|
+
# subsequent same-label usage line is dropped → exactly one per label.
|
|
170
|
+
emitted = set()
|
|
171
|
+
out = []
|
|
172
|
+
for raw, obj in parsed:
|
|
173
|
+
if not obj or obj.get("stage") != "usage":
|
|
174
|
+
out.append(raw)
|
|
175
|
+
continue
|
|
176
|
+
lab = obj.get("label")
|
|
177
|
+
if lab not in replacement:
|
|
178
|
+
out.append(raw) # claude / already-real / unmatched-null: untouched
|
|
179
|
+
continue
|
|
180
|
+
if lab in emitted:
|
|
181
|
+
continue # collapse the remaining null duplicates away
|
|
182
|
+
new_ev = {
|
|
183
|
+
"ts": obj.get("ts"),
|
|
184
|
+
"stage": "usage",
|
|
185
|
+
"label": lab,
|
|
186
|
+
"detail": replacement[lab],
|
|
187
|
+
"outcome": "ok",
|
|
188
|
+
}
|
|
189
|
+
out.append(json.dumps(new_ev) + "\n")
|
|
190
|
+
emitted.add(lab)
|
|
191
|
+
|
|
192
|
+
tmp = evfile + ".tmp-%s" % stamp
|
|
193
|
+
with open(tmp, "w") as f:
|
|
194
|
+
f.writelines(out)
|
|
195
|
+
os.replace(tmp, evfile)
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
"candidates": len(candidates),
|
|
199
|
+
"matched": len(matched),
|
|
200
|
+
"unmatched": len(unmatched),
|
|
201
|
+
"matched_labels": sorted(matched),
|
|
202
|
+
"unmatched_labels": sorted(unmatched),
|
|
203
|
+
"backup": bak,
|
|
204
|
+
"written": True,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def main(argv=None):
|
|
209
|
+
ap = argparse.ArgumentParser(description="backfill pi/deepseek usage into events file")
|
|
210
|
+
ap.add_argument("--slug", help="project slug (resolves default events path + session cwd)")
|
|
211
|
+
ap.add_argument("--events", help="explicit events file path (overrides --slug default)")
|
|
212
|
+
ap.add_argument("--shared", help="shared root (default ~/.shared/roll)")
|
|
213
|
+
ap.add_argument("--base-dir", help="pi sessions root override (tests)")
|
|
214
|
+
ap.add_argument("--dry-run", action="store_true", help="report only, write nothing")
|
|
215
|
+
args = ap.parse_args(argv)
|
|
216
|
+
|
|
217
|
+
evfile = args.events or _default_events_path(args.slug, args.shared)
|
|
218
|
+
if not os.path.isfile(evfile):
|
|
219
|
+
print("[backfill] no events file: %s" % evfile, file=sys.stderr)
|
|
220
|
+
return 1
|
|
221
|
+
if not args.slug:
|
|
222
|
+
# slug is needed to reconstruct session cwd; derive from filename.
|
|
223
|
+
base = os.path.basename(evfile)
|
|
224
|
+
if base.startswith("events-") and base.endswith(".ndjson"):
|
|
225
|
+
args.slug = base[len("events-"):-len(".ndjson")]
|
|
226
|
+
|
|
227
|
+
stats = backfill(
|
|
228
|
+
evfile, slug=args.slug, shared=args.shared,
|
|
229
|
+
base_dir=args.base_dir, dry_run=args.dry_run,
|
|
230
|
+
)
|
|
231
|
+
mode = "DRY-RUN" if args.dry_run else "WROTE"
|
|
232
|
+
print("[backfill] %s %s" % (mode, evfile))
|
|
233
|
+
print(" candidates=%d matched=%d unmatched=%d"
|
|
234
|
+
% (stats["candidates"], stats["matched"], stats["unmatched"]))
|
|
235
|
+
if stats.get("backup"):
|
|
236
|
+
print(" backup=%s" % stats["backup"])
|
|
237
|
+
if stats["unmatched_labels"]:
|
|
238
|
+
print(" unmatched (left as null): %s" % ", ".join(stats["unmatched_labels"]))
|
|
239
|
+
return 0
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
if __name__ == "__main__":
|
|
243
|
+
sys.exit(main())
|
|
@@ -0,0 +1,155 @@
|
|
|
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())
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""US-CL-006: changelog generate — deterministic draft generator.
|
|
3
|
+
|
|
4
|
+
Extracts ✅ Done stories from .roll/backlog.md, filters internal entries,
|
|
5
|
+
applies mechanical lint, and produces a draft ## Unreleased section.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
python3 lib/changelog_generate.py # output draft to stdout
|
|
9
|
+
python3 lib/changelog_generate.py --write # append to CHANGELOG.md
|
|
10
|
+
python3 lib/changelog_generate.py --json # machine-readable
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
# ─── Filters ─────────────────────────────────────────────────────────────────
|
|
20
|
+
# Skip patterns: test infrastructure, internal contracts, dev-only changes.
|
|
21
|
+
# These mirror the filter rules in $roll-.changelog SKILL.md Section 3.
|
|
22
|
+
SKIP_PATTERNS = [
|
|
23
|
+
re.compile(r"test\s+infrastructure|bats\s|fixture|teardown|isolation|CI\s+时序", re.I),
|
|
24
|
+
re.compile(r"SKILL\.md|prompt\s+内部|schema\s+|contract\s+test|enum\s+强制", re.I),
|
|
25
|
+
re.compile(r"内部重构|提取函数|变量改名|目录调整|死代码|消重", re.I),
|
|
26
|
+
re.compile(r"发版脚本自身|release\.sh\s+逻辑|TCR\s+节奏|pre-commit|钩子", re.I),
|
|
27
|
+
re.compile(r"仅开发者|只开发者|维护者可见|内部可见", re.I),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
# Category detection (best-effort keyword matching)
|
|
31
|
+
CATEGORIES = [
|
|
32
|
+
("新功能", [r"新增", r"添加", r"支持", r"新命令", r"新功能", r"引入", r"上线"]),
|
|
33
|
+
("稳定性", [r"修复", r"崩溃", r"卡死", r"误报", r"泄漏", r"竞争", r"并发", r"死锁", r"幽灵"]),
|
|
34
|
+
("可见性", [r"显示", r"dashboard", r"状态", r"可见", r"查看", r"实时", r"弹窗", r"日志"]),
|
|
35
|
+
("自动化流水线", [r"PR\s", r"合并", r"auto-merge", r"loop\s", r"调度", r"launchd", r"定时"]),
|
|
36
|
+
("工程和测试", [r"测试", r"CI\s", r"重构", r"提取", r"优化", r"提速", r"并行"]),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
CATEGORY_ORDER = ["新功能", "稳定性", "可见性", "自动化流水线", "工程和测试", "其他"]
|
|
40
|
+
|
|
41
|
+
# Changelog lint rules (inline copy of _changelog_lint_bullet for portability).
|
|
42
|
+
LINT_BACKTICK_ID = re.compile(r"`[^`]*(_|\(\))[^`]*`")
|
|
43
|
+
LINT_FILE_SUFFIX = re.compile(r"\.(md|sh|yml|ts|bats)([^A-Za-z0-9]|$)")
|
|
44
|
+
LINT_INTERNAL_WORD = re.compile(r"(Phase|Step)\s+[0-9]+|Helper|Schema|Fixture|Refactor")
|
|
45
|
+
LINT_PATH_FRAG = re.compile(r"(^|[^A-Za-z0-9_])(\.roll|docs|bin|tests|scripts)/")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
def _read_done_stories(backlog_path: Path) -> list[tuple[str, str, str]]:
|
|
51
|
+
"""Extract ✅ Done rows from backlog table.
|
|
52
|
+
|
|
53
|
+
Returns list of (story_id, description, source_tag).
|
|
54
|
+
"""
|
|
55
|
+
rows: list[tuple[str, str, str]] = []
|
|
56
|
+
text = backlog_path.read_text(encoding="utf-8")
|
|
57
|
+
for line in text.splitlines():
|
|
58
|
+
if not line.startswith("|") or line.count("|") < 4:
|
|
59
|
+
continue
|
|
60
|
+
if "✅ Done" not in line:
|
|
61
|
+
continue
|
|
62
|
+
parts = line.split("|")
|
|
63
|
+
if len(parts) < 4:
|
|
64
|
+
continue
|
|
65
|
+
# story id from first column
|
|
66
|
+
id_m = re.search(r"\[([A-Z]+-[A-Z0-9-]+-\d+|FIX-\d+|REFACTOR-\d+)\]", parts[1])
|
|
67
|
+
story_id = id_m.group(1) if id_m else ""
|
|
68
|
+
desc = parts[2].strip()
|
|
69
|
+
if not desc or desc.lower() == "description":
|
|
70
|
+
continue
|
|
71
|
+
# source tag: loop-executed stories get [loop]
|
|
72
|
+
source = "loop" if re.search(r"US-AUTO|US-LOOP|FIX-|REFACTOR-", story_id) else ""
|
|
73
|
+
rows.append((story_id, desc, source))
|
|
74
|
+
return rows
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _is_internal(desc: str) -> bool:
|
|
78
|
+
for pat in SKIP_PATTERNS:
|
|
79
|
+
if pat.search(desc):
|
|
80
|
+
return True
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _clean_description(desc: str) -> str:
|
|
85
|
+
# Remove depends-on / manual-only tags
|
|
86
|
+
desc = re.sub(r"`?depends-on:[^`|]+`?", "", desc)
|
|
87
|
+
desc = re.sub(r"`?manual-only:[^`|]+`?", "", desc)
|
|
88
|
+
# Remove markdown links — keep link text only
|
|
89
|
+
desc = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", desc)
|
|
90
|
+
# Collapse whitespace
|
|
91
|
+
desc = re.sub(r"\s+", " ", desc).strip()
|
|
92
|
+
# Remove leading em-dash / hyphen noise
|
|
93
|
+
desc = re.sub(r"^[—\-]\s*", "", desc)
|
|
94
|
+
return desc
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _detect_category(desc: str) -> str:
|
|
98
|
+
for cat, patterns in CATEGORIES:
|
|
99
|
+
for pat in patterns:
|
|
100
|
+
if re.search(pat, desc, re.I):
|
|
101
|
+
return cat
|
|
102
|
+
return "其他"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _already_in_changelog(story_id: str, desc: str, changelog_path: Path) -> bool:
|
|
106
|
+
if not changelog_path.exists():
|
|
107
|
+
return False
|
|
108
|
+
text = changelog_path.read_text(encoding="utf-8")
|
|
109
|
+
if story_id in text:
|
|
110
|
+
return True
|
|
111
|
+
# Also check by description text (cleaned, first 20 chars) to catch
|
|
112
|
+
# entries that don't carry the story ID.
|
|
113
|
+
desc_stub = desc[:20].strip()
|
|
114
|
+
if desc_stub and desc_stub in text:
|
|
115
|
+
return True
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _lint_bullet(bullet: str) -> list[str]:
|
|
120
|
+
"""Return list of violation tags (empty = clean)."""
|
|
121
|
+
viols: list[str] = []
|
|
122
|
+
stripped = re.sub(r"`[^`]*`", "", bullet)
|
|
123
|
+
if LINT_BACKTICK_ID.search(bullet):
|
|
124
|
+
viols.append("backtick-identifier")
|
|
125
|
+
if LINT_FILE_SUFFIX.search(stripped):
|
|
126
|
+
viols.append("file-suffix")
|
|
127
|
+
if LINT_INTERNAL_WORD.search(bullet):
|
|
128
|
+
viols.append("internal-word")
|
|
129
|
+
# length: visible chars > 50
|
|
130
|
+
vis_len = len(stripped.strip())
|
|
131
|
+
if vis_len > 50:
|
|
132
|
+
viols.append("over-length")
|
|
133
|
+
if LINT_PATH_FRAG.search(stripped):
|
|
134
|
+
viols.append("path-fragment")
|
|
135
|
+
return viols
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _format_bullet(desc: str, source: str) -> str:
|
|
139
|
+
tag = f" [{source}]" if source else ""
|
|
140
|
+
return f"- {desc}{tag}"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _build_draft(groups: dict[str, list[tuple[str, str, str]]]) -> str:
|
|
144
|
+
lines = ["## Unreleased", ""]
|
|
145
|
+
for cat in CATEGORY_ORDER:
|
|
146
|
+
if cat not in groups:
|
|
147
|
+
continue
|
|
148
|
+
lines.append(f"### {cat}")
|
|
149
|
+
lines.append("")
|
|
150
|
+
for _story_id, desc, source in groups[cat]:
|
|
151
|
+
bullet = _format_bullet(desc, source)
|
|
152
|
+
viols = _lint_bullet(bullet)
|
|
153
|
+
if viols:
|
|
154
|
+
bullet += f" # lint: {', '.join(viols)}"
|
|
155
|
+
lines.append(bullet)
|
|
156
|
+
lines.append("")
|
|
157
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _write_to_changelog(draft: str, changelog_path: Path) -> None:
|
|
161
|
+
"""Merge draft into CHANGELOG.md, avoiding duplicates."""
|
|
162
|
+
if changelog_path.exists():
|
|
163
|
+
text = changelog_path.read_text(encoding="utf-8")
|
|
164
|
+
else:
|
|
165
|
+
text = "# Changelog\n\n"
|
|
166
|
+
|
|
167
|
+
if "## Unreleased" not in text:
|
|
168
|
+
# Insert right after the title
|
|
169
|
+
text = text.rstrip("\n") + "\n\n" + draft
|
|
170
|
+
else:
|
|
171
|
+
# Extract existing Unreleased section, merge new bullets
|
|
172
|
+
# Pattern: from ## Unreleased up to next ## or EOF
|
|
173
|
+
m = re.search(r"^(## Unreleased\s*\n)(.*?)(?=\n## |\Z)", text, re.MULTILINE | re.DOTALL)
|
|
174
|
+
if not m:
|
|
175
|
+
text = text.rstrip("\n") + "\n\n" + draft
|
|
176
|
+
else:
|
|
177
|
+
existing = m.group(2)
|
|
178
|
+
new_lines = draft.splitlines()[2:] # drop "## Unreleased" and blank line
|
|
179
|
+
merged = existing.rstrip("\n") + "\n"
|
|
180
|
+
for line in new_lines:
|
|
181
|
+
# Skip category headers already present
|
|
182
|
+
if line.startswith("### ") and line in existing:
|
|
183
|
+
continue
|
|
184
|
+
# Skip bullets already present
|
|
185
|
+
bullet_core = re.sub(r"\s*\[loop\]\s*$", "", line).strip()
|
|
186
|
+
if bullet_core and bullet_core not in existing:
|
|
187
|
+
merged += line + "\n"
|
|
188
|
+
text = text[: m.start()] + "## Unreleased\n" + merged + text[m.end() :]
|
|
189
|
+
|
|
190
|
+
changelog_path.write_text(text, encoding="utf-8")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ─── Main ────────────────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
def main() -> int:
|
|
196
|
+
ap = argparse.ArgumentParser(
|
|
197
|
+
description="Generate a draft ## Unreleased section from backlog ✅ Done stories."
|
|
198
|
+
)
|
|
199
|
+
ap.add_argument("--backlog", default=".roll/backlog.md", help="Path to backlog file")
|
|
200
|
+
ap.add_argument("--changelog", default="CHANGELOG.md", help="Path to CHANGELOG.md")
|
|
201
|
+
ap.add_argument("--write", action="store_true", help="Write draft to CHANGELOG.md")
|
|
202
|
+
ap.add_argument("--json", action="store_true", help="Machine-readable output")
|
|
203
|
+
args = ap.parse_args()
|
|
204
|
+
|
|
205
|
+
backlog = Path(args.backlog)
|
|
206
|
+
changelog = Path(args.changelog)
|
|
207
|
+
|
|
208
|
+
if not backlog.exists():
|
|
209
|
+
print("Error: backlog file not found", file=sys.stderr)
|
|
210
|
+
return 1
|
|
211
|
+
|
|
212
|
+
rows = _read_done_stories(backlog)
|
|
213
|
+
|
|
214
|
+
filtered: list[tuple[str, str, str, str]] = []
|
|
215
|
+
for story_id, desc, source in rows:
|
|
216
|
+
if _already_in_changelog(story_id, desc, changelog):
|
|
217
|
+
continue
|
|
218
|
+
if _is_internal(desc):
|
|
219
|
+
continue
|
|
220
|
+
cleaned = _clean_description(desc)
|
|
221
|
+
if not cleaned:
|
|
222
|
+
continue
|
|
223
|
+
cat = _detect_category(cleaned)
|
|
224
|
+
filtered.append((story_id, cleaned, source, cat))
|
|
225
|
+
|
|
226
|
+
if args.json:
|
|
227
|
+
json.dump(
|
|
228
|
+
{
|
|
229
|
+
"stories_found": len(rows),
|
|
230
|
+
"stories_drafted": len(filtered),
|
|
231
|
+
"draft": [
|
|
232
|
+
{"id": sid, "desc": d, "category": c, "source": s}
|
|
233
|
+
for sid, d, s, c in filtered
|
|
234
|
+
],
|
|
235
|
+
},
|
|
236
|
+
sys.stdout,
|
|
237
|
+
indent=2,
|
|
238
|
+
ensure_ascii=False,
|
|
239
|
+
)
|
|
240
|
+
print()
|
|
241
|
+
return 0
|
|
242
|
+
|
|
243
|
+
if not filtered:
|
|
244
|
+
print("# No new ✅ Done stories found for CHANGELOG.")
|
|
245
|
+
return 0
|
|
246
|
+
|
|
247
|
+
groups: dict[str, list[tuple[str, str, str]]] = {}
|
|
248
|
+
for story_id, desc, source, cat in filtered:
|
|
249
|
+
groups.setdefault(cat, []).append((story_id, desc, source))
|
|
250
|
+
|
|
251
|
+
draft = _build_draft(groups)
|
|
252
|
+
|
|
253
|
+
if args.write:
|
|
254
|
+
_write_to_changelog(draft, changelog)
|
|
255
|
+
print(f"Updated {changelog}")
|
|
256
|
+
else:
|
|
257
|
+
print(draft, end="")
|
|
258
|
+
|
|
259
|
+
return 0
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
if __name__ == "__main__":
|
|
263
|
+
sys.exit(main())
|