@seanyao/roll 3.609.2 → 3.610.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.
- package/CHANGELOG.md +23 -1
- package/README.md +1 -2
- package/dist/roll.mjs +20572 -18445
- package/package.json +1 -2
- package/bin/roll +0 -15361
- package/lib/backfill-pi-usage.py +0 -243
- package/lib/changelog_audit.py +0 -149
- package/lib/changelog_generate.py +0 -470
- package/lib/consistency_check.py +0 -409
- package/lib/context_feed_budget.sh +0 -194
- package/lib/github_sync.py +0 -876
- package/lib/i18n.sh +0 -211
- package/lib/loop-exit-summary.py +0 -393
- package/lib/loop-fmt.py +0 -589
- package/lib/loop_pick_agent.py +0 -316
- package/lib/loop_result_eval.py +0 -469
- package/lib/loop_unstick.py +0 -180
- package/lib/model_prices.py +0 -194
- package/lib/prices_fetcher.py +0 -534
- package/lib/roll-backlog.py +0 -225
- package/lib/roll-brief.py +0 -286
- package/lib/roll-help.py +0 -158
- package/lib/roll-home.py +0 -556
- package/lib/roll-init.py +0 -156
- package/lib/roll-loop-status.py +0 -1691
- package/lib/roll-loop-story.py +0 -191
- package/lib/roll-peer.py +0 -252
- package/lib/roll-setup.py +0 -102
- package/lib/roll-status.py +0 -367
- package/lib/roll_git.py +0 -41
- package/lib/roll_render.py +0 -414
- package/lib/slides-render.py +0 -778
- package/lib/slides-validate.py +0 -357
- package/lib/test_quality_gate.py +0 -143
|
@@ -1,470 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""US-CL-006+007: 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
|
-
Also detects merged PRs since the last release tag that lack a corresponding
|
|
7
|
-
Done story or CHANGELOG entry (gap detection).
|
|
8
|
-
|
|
9
|
-
Usage:
|
|
10
|
-
python3 lib/changelog_generate.py # output draft to stdout
|
|
11
|
-
python3 lib/changelog_generate.py --write # append to CHANGELOG.md
|
|
12
|
-
python3 lib/changelog_generate.py --json # machine-readable
|
|
13
|
-
"""
|
|
14
|
-
from __future__ import annotations
|
|
15
|
-
import argparse
|
|
16
|
-
import json
|
|
17
|
-
import re
|
|
18
|
-
import subprocess
|
|
19
|
-
import sys
|
|
20
|
-
from pathlib import Path
|
|
21
|
-
|
|
22
|
-
# ─── Filters ─────────────────────────────────────────────────────────────────
|
|
23
|
-
# Skip patterns: test infrastructure, internal contracts, dev-only changes.
|
|
24
|
-
# These mirror the filter rules in $roll-.changelog SKILL.md Section 3.
|
|
25
|
-
SKIP_PATTERNS = [
|
|
26
|
-
re.compile(r"test\s+infrastructure|bats\s|fixture|teardown|isolation|CI\s+时序", re.I),
|
|
27
|
-
re.compile(r"SKILL\.md|prompt\s+内部|schema\s+|contract\s+test|enum\s+强制", re.I),
|
|
28
|
-
re.compile(r"内部重构|提取函数|变量改名|目录调整|死代码|消重", re.I),
|
|
29
|
-
re.compile(r"发版脚本自身|release\.sh\s+逻辑|TCR\s+节奏|pre-commit|钩子", re.I),
|
|
30
|
-
re.compile(r"仅开发者|只开发者|维护者可见|内部可见", re.I),
|
|
31
|
-
]
|
|
32
|
-
|
|
33
|
-
# Category detection (best-effort keyword matching)
|
|
34
|
-
CATEGORIES = [
|
|
35
|
-
("新功能", [r"新增", r"添加", r"支持", r"新命令", r"新功能", r"引入", r"上线"]),
|
|
36
|
-
("稳定性", [r"修复", r"崩溃", r"卡死", r"误报", r"泄漏", r"竞争", r"并发", r"死锁", r"幽灵"]),
|
|
37
|
-
("可见性", [r"显示", r"dashboard", r"状态", r"可见", r"查看", r"实时", r"弹窗", r"日志"]),
|
|
38
|
-
("自动化流水线", [r"PR\s", r"合并", r"auto-merge", r"loop\s", r"调度", r"launchd", r"定时"]),
|
|
39
|
-
("工程和测试", [r"测试", r"CI\s", r"重构", r"提取", r"优化", r"提速", r"并行"]),
|
|
40
|
-
]
|
|
41
|
-
|
|
42
|
-
CATEGORY_ORDER = ["新功能", "稳定性", "可见性", "自动化流水线", "工程和测试", "其他"]
|
|
43
|
-
|
|
44
|
-
# Changelog lint rules (inline copy of _changelog_lint_bullet for portability).
|
|
45
|
-
LINT_BACKTICK_ID = re.compile(r"`[^`]*(_|\(\))[^`]*`")
|
|
46
|
-
LINT_FILE_SUFFIX = re.compile(r"\.(md|sh|yml|ts|bats)([^A-Za-z0-9]|$)")
|
|
47
|
-
LINT_INTERNAL_WORD = re.compile(r"(Phase|Step)\s+[0-9]+|Helper|Schema|Fixture|Refactor")
|
|
48
|
-
LINT_PATH_FRAG = re.compile(r"(^|[^A-Za-z0-9_])(\.roll|docs|bin|tests|scripts)/")
|
|
49
|
-
|
|
50
|
-
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def _read_done_stories(backlog_path: Path) -> list[tuple[str, str, str]]:
|
|
54
|
-
"""Extract ✅ Done rows from backlog table.
|
|
55
|
-
|
|
56
|
-
Returns list of (story_id, description, source_tag).
|
|
57
|
-
"""
|
|
58
|
-
rows: list[tuple[str, str, str]] = []
|
|
59
|
-
text = backlog_path.read_text(encoding="utf-8")
|
|
60
|
-
for line in text.splitlines():
|
|
61
|
-
if not line.startswith("|") or line.count("|") < 4:
|
|
62
|
-
continue
|
|
63
|
-
if "✅ Done" not in line:
|
|
64
|
-
continue
|
|
65
|
-
parts = line.split("|")
|
|
66
|
-
if len(parts) < 4:
|
|
67
|
-
continue
|
|
68
|
-
# story id from first column
|
|
69
|
-
id_m = re.search(r"\[([A-Z]+-[A-Z0-9-]+-\d+|FIX-\d+|REFACTOR-\d+)\]", parts[1])
|
|
70
|
-
story_id = id_m.group(1) if id_m else ""
|
|
71
|
-
desc = parts[2].strip()
|
|
72
|
-
if not desc or desc.lower() == "description":
|
|
73
|
-
continue
|
|
74
|
-
# source tag: loop-executed stories get [loop]
|
|
75
|
-
source = "loop" if re.search(r"US-AUTO|US-LOOP|FIX-|REFACTOR-", story_id) else ""
|
|
76
|
-
rows.append((story_id, desc, source))
|
|
77
|
-
return rows
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def _is_internal(desc: str) -> bool:
|
|
81
|
-
for pat in SKIP_PATTERNS:
|
|
82
|
-
if pat.search(desc):
|
|
83
|
-
return True
|
|
84
|
-
return False
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def _clean_description(desc: str) -> str:
|
|
88
|
-
# Remove depends-on tags
|
|
89
|
-
desc = re.sub(r"`?depends-on:[^`|]+`?", "", desc)
|
|
90
|
-
# Remove markdown links — keep link text only
|
|
91
|
-
desc = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", desc)
|
|
92
|
-
# Collapse whitespace
|
|
93
|
-
desc = re.sub(r"\s+", " ", desc).strip()
|
|
94
|
-
# Remove leading em-dash / hyphen noise
|
|
95
|
-
desc = re.sub(r"^[—\-]\s*", "", desc)
|
|
96
|
-
return desc
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def _detect_category(desc: str) -> str:
|
|
100
|
-
for cat, patterns in CATEGORIES:
|
|
101
|
-
for pat in patterns:
|
|
102
|
-
if re.search(pat, desc, re.I):
|
|
103
|
-
return cat
|
|
104
|
-
return "其他"
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def _already_in_changelog(story_id: str, desc: str, changelog_path: Path) -> bool:
|
|
108
|
-
if not changelog_path.exists():
|
|
109
|
-
return False
|
|
110
|
-
text = changelog_path.read_text(encoding="utf-8")
|
|
111
|
-
if story_id in text:
|
|
112
|
-
return True
|
|
113
|
-
# Also check by description text (cleaned, first 20 chars) to catch
|
|
114
|
-
# entries that don't carry the story ID.
|
|
115
|
-
desc_stub = desc[:20].strip()
|
|
116
|
-
if desc_stub and desc_stub in text:
|
|
117
|
-
return True
|
|
118
|
-
return False
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def _lint_bullet(bullet: str) -> list[str]:
|
|
122
|
-
"""Return list of violation tags (empty = clean)."""
|
|
123
|
-
viols: list[str] = []
|
|
124
|
-
stripped = re.sub(r"`[^`]*`", "", bullet)
|
|
125
|
-
if LINT_BACKTICK_ID.search(bullet):
|
|
126
|
-
viols.append("backtick-identifier")
|
|
127
|
-
if LINT_FILE_SUFFIX.search(stripped):
|
|
128
|
-
viols.append("file-suffix")
|
|
129
|
-
if LINT_INTERNAL_WORD.search(bullet):
|
|
130
|
-
viols.append("internal-word")
|
|
131
|
-
# length: visible chars > 50
|
|
132
|
-
vis_len = len(stripped.strip())
|
|
133
|
-
if vis_len > 50:
|
|
134
|
-
viols.append("over-length")
|
|
135
|
-
if LINT_PATH_FRAG.search(stripped):
|
|
136
|
-
viols.append("path-fragment")
|
|
137
|
-
return viols
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def _format_bullet(desc: str, source: str, story_id: str = "") -> str:
|
|
141
|
-
"""Render one clean changelog bullet (deterministic & idempotent, FIX-178):
|
|
142
|
-
|
|
143
|
-
- <description>(<ID>) `[source]`
|
|
144
|
-
|
|
145
|
-
The story id is appended at the END (never spliced into the sentence) so it
|
|
146
|
-
is always traceable / audit-matchable without ever mangling the prose. The
|
|
147
|
-
bold-headline polish in the project's voice is applied by a separate AI pass
|
|
148
|
-
(configured agent) on top of this raw bullet — deterministic prose splitting
|
|
149
|
-
on punctuation mangles parentheses/arrows, so it is intentionally avoided.
|
|
150
|
-
"""
|
|
151
|
-
tag = f" `[{source}]`" if source else ""
|
|
152
|
-
idref = f"({story_id})" if story_id and story_id not in desc else ""
|
|
153
|
-
return f"- {desc}{idref}{tag}"
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
def _build_draft(groups: dict[str, list[tuple[str, str, str]]]) -> str:
|
|
157
|
-
# FIX-178: emit clean styled bullets only — lint markers are a separate
|
|
158
|
-
# concern (stderr summary in main), never inlined into the deliverable.
|
|
159
|
-
lines = ["## Unreleased", ""]
|
|
160
|
-
for cat in CATEGORY_ORDER:
|
|
161
|
-
if cat not in groups:
|
|
162
|
-
continue
|
|
163
|
-
lines.append(f"### {cat}")
|
|
164
|
-
lines.append("")
|
|
165
|
-
for story_id, desc, source in groups[cat]:
|
|
166
|
-
lines.append(_format_bullet(desc, source, story_id))
|
|
167
|
-
lines.append("")
|
|
168
|
-
return "\n".join(lines).rstrip() + "\n"
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
def _write_to_changelog(draft: str, changelog_path: Path) -> None:
|
|
172
|
-
"""Merge draft into CHANGELOG.md, avoiding duplicates."""
|
|
173
|
-
if changelog_path.exists():
|
|
174
|
-
text = changelog_path.read_text(encoding="utf-8")
|
|
175
|
-
else:
|
|
176
|
-
text = "# Changelog\n\n"
|
|
177
|
-
|
|
178
|
-
if "## Unreleased" not in text:
|
|
179
|
-
# Insert right after the title
|
|
180
|
-
text = text.rstrip("\n") + "\n\n" + draft
|
|
181
|
-
else:
|
|
182
|
-
# Extract existing Unreleased section, merge new bullets
|
|
183
|
-
# Pattern: from ## Unreleased up to next ## or EOF
|
|
184
|
-
m = re.search(r"^(## Unreleased\s*\n)(.*?)(?=\n## |\Z)", text, re.MULTILINE | re.DOTALL)
|
|
185
|
-
if not m:
|
|
186
|
-
text = text.rstrip("\n") + "\n\n" + draft
|
|
187
|
-
else:
|
|
188
|
-
existing = m.group(2)
|
|
189
|
-
new_lines = draft.splitlines()[2:] # drop "## Unreleased" and blank line
|
|
190
|
-
merged = existing.rstrip("\n") + "\n"
|
|
191
|
-
for line in new_lines:
|
|
192
|
-
# Skip category headers already present
|
|
193
|
-
if line.startswith("### ") and line in existing:
|
|
194
|
-
continue
|
|
195
|
-
# Skip bullets already present
|
|
196
|
-
bullet_core = re.sub(r"\s*\[loop\]\s*$", "", line).strip()
|
|
197
|
-
if bullet_core and bullet_core not in existing:
|
|
198
|
-
merged += line + "\n"
|
|
199
|
-
text = text[: m.start()] + "## Unreleased\n" + merged + text[m.end() :]
|
|
200
|
-
|
|
201
|
-
changelog_path.write_text(text, encoding="utf-8")
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
# ─── US-CL-007: merged PR gap detection ──────────────────────────────────────
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
def _latest_release_tag() -> str | None:
|
|
208
|
-
"""Find the latest v* tag using git."""
|
|
209
|
-
try:
|
|
210
|
-
result = subprocess.run(
|
|
211
|
-
["git", "describe", "--tags", "--abbrev=0", "--match", "v*"],
|
|
212
|
-
capture_output=True, text=True, check=True
|
|
213
|
-
)
|
|
214
|
-
return result.stdout.strip()
|
|
215
|
-
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
216
|
-
return None
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
def _commit_log_since_last_release() -> str | None:
|
|
220
|
-
"""Concatenated commit subjects since the last release tag (FIX-177).
|
|
221
|
-
|
|
222
|
-
Used for release-aware unreleased detection: a ✅ Done story is unreleased
|
|
223
|
-
iff its id appears here. Returns None when there is no release tag or git is
|
|
224
|
-
unavailable, so the caller falls back to the CHANGELOG-text dedup.
|
|
225
|
-
"""
|
|
226
|
-
tag = _latest_release_tag()
|
|
227
|
-
if not tag:
|
|
228
|
-
return None
|
|
229
|
-
try:
|
|
230
|
-
result = subprocess.run(
|
|
231
|
-
["git", "log", f"{tag}..HEAD", "--pretty=format:%s"],
|
|
232
|
-
capture_output=True, text=True, check=True
|
|
233
|
-
)
|
|
234
|
-
return result.stdout
|
|
235
|
-
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
236
|
-
return None
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
def _gh_available() -> bool:
|
|
240
|
-
"""Check whether the gh CLI is installed and on PATH."""
|
|
241
|
-
try:
|
|
242
|
-
result = subprocess.run(
|
|
243
|
-
["gh", "--version"],
|
|
244
|
-
capture_output=True, text=True, timeout=5
|
|
245
|
-
)
|
|
246
|
-
return result.returncode == 0
|
|
247
|
-
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
248
|
-
return False
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
def _merged_prs_since_tag(tag: str) -> list[tuple[str, str, str]]:
|
|
252
|
-
"""Return list of (pr_number, title, commit_msg) for PRs merged since tag.
|
|
253
|
-
|
|
254
|
-
PR numbers are extracted from commit messages (e.g. ``(#123)``).
|
|
255
|
-
Titles are enriched via ``gh pr view`` when available.
|
|
256
|
-
"""
|
|
257
|
-
try:
|
|
258
|
-
result = subprocess.run(
|
|
259
|
-
["git", "log", f"{tag}..HEAD", "--pretty=format:%H %s"],
|
|
260
|
-
capture_output=True, text=True, check=True
|
|
261
|
-
)
|
|
262
|
-
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
263
|
-
return []
|
|
264
|
-
|
|
265
|
-
prs: list[tuple[str, str, str]] = []
|
|
266
|
-
seen: set[str] = set()
|
|
267
|
-
for line in result.stdout.strip().splitlines():
|
|
268
|
-
if not line:
|
|
269
|
-
continue
|
|
270
|
-
parts = line.split(" ", 1)
|
|
271
|
-
if len(parts) < 2:
|
|
272
|
-
continue
|
|
273
|
-
_commit_hash, subject = parts
|
|
274
|
-
m = re.search(r"\(#(\d+)\)", subject)
|
|
275
|
-
if not m:
|
|
276
|
-
continue
|
|
277
|
-
pr_num = m.group(1)
|
|
278
|
-
if pr_num in seen:
|
|
279
|
-
continue
|
|
280
|
-
seen.add(pr_num)
|
|
281
|
-
|
|
282
|
-
title = subject
|
|
283
|
-
if _gh_available():
|
|
284
|
-
try:
|
|
285
|
-
gh_result = subprocess.run(
|
|
286
|
-
["gh", "pr", "view", pr_num, "--json", "title"],
|
|
287
|
-
capture_output=True, text=True, timeout=10
|
|
288
|
-
)
|
|
289
|
-
if gh_result.returncode == 0:
|
|
290
|
-
gh_data = json.loads(gh_result.stdout)
|
|
291
|
-
title = gh_data.get("title", subject)
|
|
292
|
-
except (json.JSONDecodeError, subprocess.TimeoutExpired):
|
|
293
|
-
pass
|
|
294
|
-
|
|
295
|
-
prs.append((pr_num, title, subject))
|
|
296
|
-
return prs
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
def _pr_in_done_rows(pr_number: str, backlog_path: Path) -> bool:
|
|
300
|
-
"""Check whether the PR number appears in any ✅ Done row of the backlog."""
|
|
301
|
-
text = backlog_path.read_text(encoding="utf-8")
|
|
302
|
-
for line in text.splitlines():
|
|
303
|
-
if "✅ Done" in line and f"#{pr_number}" in line:
|
|
304
|
-
return True
|
|
305
|
-
return False
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
def _pr_is_covered(
|
|
309
|
-
pr_number: str,
|
|
310
|
-
pr_title: str,
|
|
311
|
-
commit_msg: str,
|
|
312
|
-
done_story_ids: set[str],
|
|
313
|
-
changelog_text: str,
|
|
314
|
-
) -> bool:
|
|
315
|
-
"""Check if a merged PR is already represented in backlog or changelog."""
|
|
316
|
-
# By PR number in CHANGELOG
|
|
317
|
-
if f"#{pr_number}" in changelog_text:
|
|
318
|
-
return True
|
|
319
|
-
# By story ID appearing in PR title / commit message
|
|
320
|
-
for story_id in done_story_ids:
|
|
321
|
-
if story_id and (story_id in pr_title or story_id in commit_msg):
|
|
322
|
-
return True
|
|
323
|
-
return False
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
def _uncarded_to_entries(uncarded: list[tuple[str, str]]) -> list[tuple[str, str, str, str]]:
|
|
327
|
-
"""FIX-179: convert uncarded merged PRs (pr_num, title) into draft entries
|
|
328
|
-
(id, desc, source, category) so they fold into the categorized draft as
|
|
329
|
-
normal bullets. The old behaviour emitted a separate '⚠️ 待确认 … 请确认'
|
|
330
|
-
block — a maintainer prompt that must NEVER reach the published CHANGELOG
|
|
331
|
-
(it leaked into v2.603.1). The "lacked a card" notice now goes to stderr.
|
|
332
|
-
|
|
333
|
-
Uses the PR title as the description (stripped of conventional-commit
|
|
334
|
-
prefixes and the leading id); the story/fix id when present, else PR#<n>.
|
|
335
|
-
"""
|
|
336
|
-
entries: list[tuple[str, str, str, str]] = []
|
|
337
|
-
for pr_num, title in uncarded:
|
|
338
|
-
t = re.sub(
|
|
339
|
-
r"^\s*(Fix|tcr|docs|chore|feat|refactor|perf|test|Story\s+\d+)\s*[::]\s*",
|
|
340
|
-
"", title, flags=re.I,
|
|
341
|
-
)
|
|
342
|
-
idm = re.search(r"\b(US-[A-Z]+-\d+|FIX-\d+|REFACTOR-\d+)\b", title)
|
|
343
|
-
sid = idm.group(1) if idm else f"PR#{pr_num}"
|
|
344
|
-
if idm:
|
|
345
|
-
t = re.sub(r"\b" + re.escape(idm.group(1)) + r"\b\s*[::]?\s*", "", t).strip()
|
|
346
|
-
cleaned = _clean_description(t) or t.strip()
|
|
347
|
-
cat = _detect_category(cleaned)
|
|
348
|
-
src = "loop" if re.search(r"US-AUTO|US-LOOP|FIX-|REFACTOR-", sid) else ""
|
|
349
|
-
entries.append((sid, cleaned, src, cat))
|
|
350
|
-
return entries
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
# ─── Main ────────────────────────────────────────────────────────────────────
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
def main() -> int:
|
|
357
|
-
ap = argparse.ArgumentParser(
|
|
358
|
-
description="Generate a draft ## Unreleased section from backlog ✅ Done stories."
|
|
359
|
-
)
|
|
360
|
-
ap.add_argument("--backlog", default=".roll/backlog.md", help="Path to backlog file")
|
|
361
|
-
ap.add_argument("--changelog", default="CHANGELOG.md", help="Path to CHANGELOG.md")
|
|
362
|
-
ap.add_argument("--write", action="store_true", help="Write draft to CHANGELOG.md")
|
|
363
|
-
ap.add_argument("--json", action="store_true", help="Machine-readable output")
|
|
364
|
-
args = ap.parse_args()
|
|
365
|
-
|
|
366
|
-
backlog = Path(args.backlog)
|
|
367
|
-
changelog = Path(args.changelog)
|
|
368
|
-
|
|
369
|
-
if not backlog.exists():
|
|
370
|
-
print("Error: backlog file not found", file=sys.stderr)
|
|
371
|
-
return 1
|
|
372
|
-
|
|
373
|
-
rows = _read_done_stories(backlog)
|
|
374
|
-
|
|
375
|
-
# FIX-177: only draft stories that are actually UNRELEASED. The backlog holds
|
|
376
|
-
# every ✅ Done story ever (500+), most already shipped in past versions; the
|
|
377
|
-
# CHANGELOG only carries recent versions, so filtering by "already in
|
|
378
|
-
# CHANGELOG text" let hundreds of long-released stories leak into the draft.
|
|
379
|
-
# Release-aware rule: a story is unreleased iff its id is referenced by a
|
|
380
|
-
# commit merged since the last release tag (git log <tag>..HEAD). Old stories
|
|
381
|
-
# never appear there and are correctly excluded. Falls back to the
|
|
382
|
-
# CHANGELOG-text filter when git/tag are unavailable (e.g. test sandboxes).
|
|
383
|
-
since_tag_log = _commit_log_since_last_release()
|
|
384
|
-
|
|
385
|
-
filtered: list[tuple[str, str, str, str]] = []
|
|
386
|
-
for story_id, desc, source in rows:
|
|
387
|
-
if since_tag_log is not None:
|
|
388
|
-
# Release-aware: skip stories not named in any post-release commit.
|
|
389
|
-
if not story_id or story_id not in since_tag_log:
|
|
390
|
-
continue
|
|
391
|
-
elif _already_in_changelog(story_id, desc, changelog):
|
|
392
|
-
continue
|
|
393
|
-
if _is_internal(desc):
|
|
394
|
-
continue
|
|
395
|
-
cleaned = _clean_description(desc)
|
|
396
|
-
if not cleaned:
|
|
397
|
-
continue
|
|
398
|
-
cat = _detect_category(cleaned)
|
|
399
|
-
filtered.append((story_id, cleaned, source, cat))
|
|
400
|
-
|
|
401
|
-
# ── US-CL-007: gap detection ───────────────────────────────────────────
|
|
402
|
-
uncarded: list[tuple[str, str]] = []
|
|
403
|
-
tag = _latest_release_tag()
|
|
404
|
-
if tag and _gh_available():
|
|
405
|
-
merged_prs = _merged_prs_since_tag(tag)
|
|
406
|
-
done_story_ids = {sid for sid, _desc, _src in rows}
|
|
407
|
-
changelog_text = changelog.read_text(encoding="utf-8") if changelog.exists() else ""
|
|
408
|
-
for pr_num, pr_title, commit_msg in merged_prs:
|
|
409
|
-
if _pr_in_done_rows(pr_num, backlog):
|
|
410
|
-
continue
|
|
411
|
-
if _pr_is_covered(pr_num, pr_title, commit_msg, done_story_ids, changelog_text):
|
|
412
|
-
continue
|
|
413
|
-
uncarded.append((pr_num, pr_title))
|
|
414
|
-
|
|
415
|
-
if args.json:
|
|
416
|
-
payload = {
|
|
417
|
-
"stories_found": len(rows),
|
|
418
|
-
"stories_drafted": len(filtered),
|
|
419
|
-
"draft": [
|
|
420
|
-
{"id": sid, "desc": d, "category": c, "source": s}
|
|
421
|
-
for sid, d, s, c in filtered
|
|
422
|
-
],
|
|
423
|
-
"uncarded_merged": [
|
|
424
|
-
{"pr": num, "title": title}
|
|
425
|
-
for num, title in uncarded
|
|
426
|
-
],
|
|
427
|
-
}
|
|
428
|
-
json.dump(payload, sys.stdout, indent=2, ensure_ascii=False)
|
|
429
|
-
print()
|
|
430
|
-
return 0
|
|
431
|
-
|
|
432
|
-
if not filtered and not uncarded:
|
|
433
|
-
print("# No new ✅ Done stories found for CHANGELOG.")
|
|
434
|
-
return 0
|
|
435
|
-
|
|
436
|
-
# FIX-179: fold uncarded merged PRs INTO the categorized draft (complete
|
|
437
|
-
# coverage) rather than a separate '请确认' warning block — that maintainer
|
|
438
|
-
# prompt must never reach the published CHANGELOG. The "lacked a card"
|
|
439
|
-
# notice goes to STDERR so the human still sees what to back-fill.
|
|
440
|
-
all_entries: list[tuple[str, str, str, str]] = list(filtered) + _uncarded_to_entries(uncarded)
|
|
441
|
-
if uncarded:
|
|
442
|
-
pr_list = " ".join(f"#{p}" for p, _t in uncarded)
|
|
443
|
-
print(
|
|
444
|
-
f"note: {len(uncarded)} 个 merged PR 未建卡,已按 PR 标题并入草稿,建议补卡: {pr_list}",
|
|
445
|
-
file=sys.stderr,
|
|
446
|
-
)
|
|
447
|
-
|
|
448
|
-
# FIX-178: style-lint warnings go to STDERR so the stdout draft stays clean
|
|
449
|
-
# (no inline `# lint:` markers in the deliverable). The human still sees them.
|
|
450
|
-
for story_id, desc, source, _cat in all_entries:
|
|
451
|
-
viols = _lint_bullet(_format_bullet(desc, source, story_id))
|
|
452
|
-
if viols:
|
|
453
|
-
print(f"lint: {story_id or '?'}: {', '.join(viols)}", file=sys.stderr)
|
|
454
|
-
|
|
455
|
-
groups: dict[str, list[tuple[str, str, str]]] = {}
|
|
456
|
-
for story_id, desc, source, cat in all_entries:
|
|
457
|
-
groups.setdefault(cat, []).append((story_id, desc, source))
|
|
458
|
-
draft = _build_draft(groups)
|
|
459
|
-
|
|
460
|
-
if args.write:
|
|
461
|
-
_write_to_changelog(draft, changelog)
|
|
462
|
-
print(f"Updated {changelog}")
|
|
463
|
-
else:
|
|
464
|
-
print(draft, end="")
|
|
465
|
-
|
|
466
|
-
return 0
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
if __name__ == "__main__":
|
|
470
|
-
sys.exit(main())
|