@seanyao/roll 2.602.5 → 2.604.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 +61 -0
- package/bin/roll +500 -792
- package/lib/README.md +0 -1
- package/lib/changelog_audit.py +139 -145
- package/lib/changelog_generate.py +237 -30
- package/lib/consistency_check.py +409 -0
- package/lib/i18n/consistency.sh +8 -0
- package/lib/loop-fmt.py +2 -2
- package/lib/prices/snapshot-2026-05-22.json +1 -7
- package/lib/prices/snapshot-2026-05-23-deepseek.json +0 -2
- package/lib/prices/snapshot-2026-06-02-kimi.json +0 -1
- package/lib/prices_fetcher.py +312 -63
- package/lib/roll-loop-status.py +1 -1
- package/package.json +1 -1
- package/skills/roll-.changelog/SKILL.md +1 -1
- package/skills/roll-loop/SKILL.md +7 -23
- 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/__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/README.md
CHANGED
|
@@ -24,7 +24,6 @@ Python scripts and shell libraries that `bin/roll` delegates to for rendering-he
|
|
|
24
24
|
| `loop-fmt.py` | Loop log formatter (ANSI-strip, timestamp alignment) |
|
|
25
25
|
| `loop_unstick.py` | Diagnostic: detects and unsticks hung loop state |
|
|
26
26
|
| `backfill-pi-usage.py` | Backfills pi/deepseek token and cost data into existing cycle records |
|
|
27
|
-
| `changelog_audit.py` | Audits CHANGELOG.md against backlog entries |
|
|
28
27
|
| `i18n.sh` | Shell wrapper that delegates i18n string lookups to `lib/i18n/` |
|
|
29
28
|
| `slides-render.py` | Renders `.deck.md` → HTML slides |
|
|
30
29
|
| `slides-validate.py` | Validates deck file syntax and asset references |
|
package/lib/changelog_audit.py
CHANGED
|
@@ -1,155 +1,149 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
release tag that don't appear in CHANGELOG.md's ## Unreleased section.
|
|
2
|
+
"""Changelog audit module (US-CONSIST-002).
|
|
4
3
|
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
Checks that ✅ Done backlog stories are reflected in CHANGELOG.md.
|
|
5
|
+
Provides both coverage checking and gap reporting.
|
|
7
6
|
|
|
8
|
-
Usage:
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
7
|
+
Usage as library:
|
|
8
|
+
from lib.changelog_audit import check_changelog_coverage
|
|
9
|
+
result = check_changelog_coverage(done_stories, changelog_path)
|
|
16
10
|
"""
|
|
11
|
+
|
|
17
12
|
from __future__ import annotations
|
|
18
|
-
|
|
13
|
+
|
|
19
14
|
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
20
16
|
|
|
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
17
|
|
|
76
|
-
def
|
|
77
|
-
"""
|
|
78
|
-
if not
|
|
18
|
+
def _read_changelog_text(changelog_path: Path) -> str:
|
|
19
|
+
"""Read changelog text, returning empty string if file is missing."""
|
|
20
|
+
if not changelog_path.exists():
|
|
79
21
|
return ""
|
|
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
|
-
|
|
22
|
+
return changelog_path.read_text(encoding="utf-8")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def check_changelog_coverage(
|
|
26
|
+
done_stories: dict[str, list[str]],
|
|
27
|
+
changelog_path: Path,
|
|
28
|
+
) -> dict[str, Any]:
|
|
29
|
+
"""Check that Done stories appear in CHANGELOG.md.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
done_stories: {feature_name: [story_id, ...]} from backlog
|
|
33
|
+
changelog_path: Path to CHANGELOG.md
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
{"status": "pass"|"fail", "gaps": [descriptions...]}
|
|
37
|
+
"""
|
|
38
|
+
if not changelog_path.exists():
|
|
39
|
+
return {"status": "pass", "gaps": []}
|
|
40
|
+
|
|
41
|
+
changelog_text = _read_changelog_text(changelog_path)
|
|
42
|
+
gaps: list[str] = []
|
|
43
|
+
|
|
44
|
+
for feature_name, story_ids in done_stories.items():
|
|
45
|
+
for story_id in story_ids:
|
|
46
|
+
if story_id not in changelog_text:
|
|
47
|
+
gaps.append(
|
|
48
|
+
f"Story '{story_id}' (feature '{feature_name}') is Done "
|
|
49
|
+
"but not referenced in CHANGELOG.md"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
"status": "pass" if not gaps else "fail",
|
|
54
|
+
"gaps": gaps,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def check_features_md_coverage(
|
|
59
|
+
done_features: dict[str, list[str]],
|
|
60
|
+
features_md_path: Path,
|
|
61
|
+
) -> dict[str, Any]:
|
|
62
|
+
"""Check that features with Done stories are listed in features.md.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
done_features: {feature_name: [story_id, ...]} from backlog
|
|
66
|
+
features_md_path: Path to .roll/features.md
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
{"status": "pass"|"fail", "gaps": [descriptions...]}
|
|
70
|
+
"""
|
|
71
|
+
import re
|
|
72
|
+
|
|
73
|
+
if not features_md_path.exists():
|
|
74
|
+
return {"status": "pass", "gaps": []}
|
|
75
|
+
|
|
76
|
+
features_text = features_md_path.read_text(encoding="utf-8")
|
|
77
|
+
gaps: list[str] = []
|
|
78
|
+
|
|
79
|
+
for feature_name in done_features:
|
|
80
|
+
escaped = re.escape(feature_name)
|
|
81
|
+
if not re.search(r"(^|[\s/])" + escaped + r"([\s/).]|$)", features_text):
|
|
82
|
+
gaps.append(
|
|
83
|
+
f"Feature '{feature_name}' has Done stories but is missing "
|
|
84
|
+
"from features.md catalog"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
"status": "pass" if not gaps else "fail",
|
|
89
|
+
"gaps": gaps,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def check_guide_doc_coverage(
|
|
94
|
+
done_features: dict[str, list[str]],
|
|
95
|
+
guide_en_dir: Path,
|
|
96
|
+
) -> dict[str, Any]:
|
|
97
|
+
"""Check that features with Done stories have guide documentation.
|
|
98
|
+
|
|
99
|
+
Heuristic: checks whether a .md file in guide/en/ references the feature
|
|
100
|
+
name or its story IDs.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
done_features: {feature_name: [story_id, ...]} from backlog
|
|
104
|
+
guide_en_dir: Path to guide/en/
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
{"status": "pass"|"fail", "gaps": [descriptions...]}
|
|
108
|
+
"""
|
|
109
|
+
if not guide_en_dir.exists() or not guide_en_dir.is_dir():
|
|
110
|
+
return {"status": "pass", "gaps": []}
|
|
111
|
+
|
|
112
|
+
# Collect all guide text
|
|
113
|
+
all_guide_text = ""
|
|
114
|
+
for md_file in sorted(guide_en_dir.glob("*.md")):
|
|
115
|
+
try:
|
|
116
|
+
all_guide_text += md_file.read_text(encoding="utf-8")
|
|
117
|
+
except (OSError, UnicodeDecodeError):
|
|
114
118
|
continue
|
|
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
|
-
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())
|
|
119
|
+
|
|
120
|
+
# Also read practices/ subdirectory
|
|
121
|
+
practices_dir = guide_en_dir / "practices"
|
|
122
|
+
if practices_dir.exists() and practices_dir.is_dir():
|
|
123
|
+
for md_file in sorted(practices_dir.glob("*.md")):
|
|
124
|
+
try:
|
|
125
|
+
all_guide_text += md_file.read_text(encoding="utf-8")
|
|
126
|
+
except (OSError, UnicodeDecodeError):
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
gaps: list[str] = []
|
|
130
|
+
for feature_name, story_ids in done_features.items():
|
|
131
|
+
found = False
|
|
132
|
+
# Check if feature name appears in guide text
|
|
133
|
+
if feature_name.lower() in all_guide_text.lower():
|
|
134
|
+
found = True
|
|
135
|
+
# Check if any story ID appears
|
|
136
|
+
for sid in story_ids:
|
|
137
|
+
if sid in all_guide_text:
|
|
138
|
+
found = True
|
|
139
|
+
break
|
|
140
|
+
if not found:
|
|
141
|
+
gaps.append(
|
|
142
|
+
f"Feature '{feature_name}' has Done stories but no guide "
|
|
143
|
+
"documentation found in guide/en/"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
"status": "pass" if not gaps else "fail",
|
|
148
|
+
"gaps": gaps,
|
|
149
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""US-CL-006: changelog generate — deterministic draft generator.
|
|
2
|
+
"""US-CL-006+007: changelog generate — deterministic draft generator.
|
|
3
3
|
|
|
4
4
|
Extracts ✅ Done stories from .roll/backlog.md, filters internal entries,
|
|
5
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).
|
|
6
8
|
|
|
7
9
|
Usage:
|
|
8
10
|
python3 lib/changelog_generate.py # output draft to stdout
|
|
@@ -13,6 +15,7 @@ from __future__ import annotations
|
|
|
13
15
|
import argparse
|
|
14
16
|
import json
|
|
15
17
|
import re
|
|
18
|
+
import subprocess
|
|
16
19
|
import sys
|
|
17
20
|
from pathlib import Path
|
|
18
21
|
|
|
@@ -44,9 +47,9 @@ LINT_FILE_SUFFIX = re.compile(r"\.(md|sh|yml|ts|bats)([^A-Za-z0-9]|$)")
|
|
|
44
47
|
LINT_INTERNAL_WORD = re.compile(r"(Phase|Step)\s+[0-9]+|Helper|Schema|Fixture|Refactor")
|
|
45
48
|
LINT_PATH_FRAG = re.compile(r"(^|[^A-Za-z0-9_])(\.roll|docs|bin|tests|scripts)/")
|
|
46
49
|
|
|
47
|
-
|
|
48
50
|
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
49
51
|
|
|
52
|
+
|
|
50
53
|
def _read_done_stories(backlog_path: Path) -> list[tuple[str, str, str]]:
|
|
51
54
|
"""Extract ✅ Done rows from backlog table.
|
|
52
55
|
|
|
@@ -82,9 +85,8 @@ def _is_internal(desc: str) -> bool:
|
|
|
82
85
|
|
|
83
86
|
|
|
84
87
|
def _clean_description(desc: str) -> str:
|
|
85
|
-
# Remove depends-on
|
|
88
|
+
# Remove depends-on tags
|
|
86
89
|
desc = re.sub(r"`?depends-on:[^`|]+`?", "", desc)
|
|
87
|
-
desc = re.sub(r"`?manual-only:[^`|]+`?", "", desc)
|
|
88
90
|
# Remove markdown links — keep link text only
|
|
89
91
|
desc = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", desc)
|
|
90
92
|
# Collapse whitespace
|
|
@@ -135,24 +137,33 @@ def _lint_bullet(bullet: str) -> list[str]:
|
|
|
135
137
|
return viols
|
|
136
138
|
|
|
137
139
|
|
|
138
|
-
def _format_bullet(desc: str, source: str) -> str:
|
|
139
|
-
|
|
140
|
-
|
|
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}"
|
|
141
154
|
|
|
142
155
|
|
|
143
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.
|
|
144
159
|
lines = ["## Unreleased", ""]
|
|
145
160
|
for cat in CATEGORY_ORDER:
|
|
146
161
|
if cat not in groups:
|
|
147
162
|
continue
|
|
148
163
|
lines.append(f"### {cat}")
|
|
149
164
|
lines.append("")
|
|
150
|
-
for
|
|
151
|
-
|
|
152
|
-
viols = _lint_bullet(bullet)
|
|
153
|
-
if viols:
|
|
154
|
-
bullet += f" # lint: {', '.join(viols)}"
|
|
155
|
-
lines.append(bullet)
|
|
165
|
+
for story_id, desc, source in groups[cat]:
|
|
166
|
+
lines.append(_format_bullet(desc, source, story_id))
|
|
156
167
|
lines.append("")
|
|
157
168
|
return "\n".join(lines).rstrip() + "\n"
|
|
158
169
|
|
|
@@ -190,8 +201,158 @@ def _write_to_changelog(draft: str, changelog_path: Path) -> None:
|
|
|
190
201
|
changelog_path.write_text(text, encoding="utf-8")
|
|
191
202
|
|
|
192
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
|
+
|
|
193
353
|
# ─── Main ────────────────────────────────────────────────────────────────────
|
|
194
354
|
|
|
355
|
+
|
|
195
356
|
def main() -> int:
|
|
196
357
|
ap = argparse.ArgumentParser(
|
|
197
358
|
description="Generate a draft ## Unreleased section from backlog ✅ Done stories."
|
|
@@ -211,9 +372,23 @@ def main() -> int:
|
|
|
211
372
|
|
|
212
373
|
rows = _read_done_stories(backlog)
|
|
213
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
|
+
|
|
214
385
|
filtered: list[tuple[str, str, str, str]] = []
|
|
215
386
|
for story_id, desc, source in rows:
|
|
216
|
-
if
|
|
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):
|
|
217
392
|
continue
|
|
218
393
|
if _is_internal(desc):
|
|
219
394
|
continue
|
|
@@ -223,31 +398,63 @@ def main() -> int:
|
|
|
223
398
|
cat = _detect_category(cleaned)
|
|
224
399
|
filtered.append((story_id, cleaned, source, cat))
|
|
225
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
|
+
|
|
226
415
|
if args.json:
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
"
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
)
|
|
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)
|
|
240
429
|
print()
|
|
241
430
|
return 0
|
|
242
431
|
|
|
243
|
-
if not filtered:
|
|
432
|
+
if not filtered and not uncarded:
|
|
244
433
|
print("# No new ✅ Done stories found for CHANGELOG.")
|
|
245
434
|
return 0
|
|
246
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
|
+
|
|
247
455
|
groups: dict[str, list[tuple[str, str, str]]] = {}
|
|
248
|
-
for story_id, desc, source, cat in
|
|
456
|
+
for story_id, desc, source, cat in all_entries:
|
|
249
457
|
groups.setdefault(cat, []).append((story_id, desc, source))
|
|
250
|
-
|
|
251
458
|
draft = _build_draft(groups)
|
|
252
459
|
|
|
253
460
|
if args.write:
|