its-magic 0.1.2 → 0.1.3-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/bin/postinstall.js +72 -0
- package/installer.py +55 -0
- package/package.json +1 -1
- package/scripts/check_intake_template_parity.py +90 -0
- package/template/.cursor/agents/curator.mdc +1 -0
- package/template/.cursor/agents/po.mdc +1 -0
- package/template/.cursor/agents/release.mdc +1 -0
- package/template/.cursor/commands/release.md +18 -0
- package/template/.cursor/model-catalog.local.example.cursor-only.json +9 -0
- package/template/.cursor/model-catalog.local.example.json +9 -0
- package/template/.cursor/model-catalog.local.example.level-1-easy.json +9 -0
- package/template/.cursor/model-catalog.local.example.level-2-complex.json +9 -0
- package/template/.cursor/model-catalog.local.example.level-3-mega.json +9 -0
- package/template/.cursor/model-catalog.local.example.level-4-super.json +9 -0
- package/template/.cursor/model-catalog.local.example.role-based-balanced.json +18 -0
- package/template/.cursor/model-catalog.local.example.role-based-highend.json +18 -0
- package/template/.cursor/scratchpad.local.example.md +55 -0
- package/template/.cursor/scratchpad.md +62 -0
- package/template/CHANGELOG.md +11 -0
- package/template/docs/engineering/runbook.md +274 -6
- package/template/handoffs/releases/vX.Y.Z-release-notes.md.example +21 -0
- package/template/scripts/check_intake_template_parity.py +90 -0
- package/template/scripts/dev_environment_lib.py +138 -0
- package/template/scripts/model_tier_lib.py +680 -0
- package/template/scripts/model_tier_validate.py +368 -0
- package/template/scripts/release-all.sh +249 -0
- package/template/scripts/release_changelog_backfill.py +153 -0
- package/template/scripts/release_changelog_lib.py +544 -0
- package/template/scripts/release_changelog_validate.py +134 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Idempotent one-time backfill for release changelog tiers A/B/C (US-0100 / DEC-0085).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
14
|
+
_REPO_ROOT = os.path.normpath(os.path.join(_SCRIPT_DIR, ".."))
|
|
15
|
+
if _REPO_ROOT not in sys.path:
|
|
16
|
+
sys.path.insert(0, _REPO_ROOT)
|
|
17
|
+
if _SCRIPT_DIR not in sys.path:
|
|
18
|
+
sys.path.insert(0, _SCRIPT_DIR)
|
|
19
|
+
|
|
20
|
+
import release_changelog_lib as rcl # noqa: E402
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
import yaml # type: ignore
|
|
24
|
+
except ImportError:
|
|
25
|
+
yaml = None # type: ignore
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
MANIFEST_REL = os.path.join(
|
|
29
|
+
"docs", "engineering", "context", "release-version-backfill.manifest.yaml"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _load_manifest(repo_root: str) -> dict:
|
|
34
|
+
path = os.path.join(repo_root, MANIFEST_REL)
|
|
35
|
+
if not os.path.isfile(path):
|
|
36
|
+
return {"schema_version": 1, "entries": []}
|
|
37
|
+
text = rcl.read_utf8(path)
|
|
38
|
+
if yaml is not None:
|
|
39
|
+
data = yaml.safe_load(text) or {}
|
|
40
|
+
else:
|
|
41
|
+
# Minimal stdlib fallback for schema_version + entries
|
|
42
|
+
data = {"schema_version": 1, "entries": []}
|
|
43
|
+
m = re.search(r"schema_version:\s*(\d+)", text)
|
|
44
|
+
if m:
|
|
45
|
+
data["schema_version"] = int(m.group(1))
|
|
46
|
+
for em in re.finditer(
|
|
47
|
+
r"-\s*sprint_id:\s*(S\d{4})\s*\n\s*semver:\s*[\"']?([^\"'\n]+)[\"']?",
|
|
48
|
+
text,
|
|
49
|
+
):
|
|
50
|
+
data.setdefault("entries", []).append(
|
|
51
|
+
{"sprint_id": em.group(1), "semver": em.group(2).strip()}
|
|
52
|
+
)
|
|
53
|
+
return data
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _synthetic_semver(sprint_id: str) -> str:
|
|
57
|
+
num = int(sprint_id[1:])
|
|
58
|
+
return f"0.0.0-wf.{num:03d}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def assign_semvers(repo_root: str) -> dict[str, str]:
|
|
62
|
+
"""Tier A → B → C precedence; returns sprint_id → semver."""
|
|
63
|
+
rows = sorted(
|
|
64
|
+
[r for r in rcl.parse_queue_rows(repo_root) if r.status == "released"],
|
|
65
|
+
key=lambda r: r.last_updated,
|
|
66
|
+
)
|
|
67
|
+
manifest = _load_manifest(repo_root)
|
|
68
|
+
tier_b: dict[str, str] = {}
|
|
69
|
+
for entry in manifest.get("entries") or []:
|
|
70
|
+
sid = entry.get("sprint_id", "").strip()
|
|
71
|
+
sem = entry.get("semver", "").strip()
|
|
72
|
+
if sid and sem:
|
|
73
|
+
tier_b[sid] = rcl.normalize_semver(sem)
|
|
74
|
+
# collision detection
|
|
75
|
+
inv: dict[str, list[str]] = {}
|
|
76
|
+
for sid, sem in tier_b.items():
|
|
77
|
+
inv.setdefault(sem, []).append(sid)
|
|
78
|
+
for sem, sids in inv.items():
|
|
79
|
+
if len(sids) > 1:
|
|
80
|
+
raise rcl.ReleaseChangelogError(
|
|
81
|
+
rcl.RELEASE_CHANGELOG_BACKFILL_AMBIGUOUS,
|
|
82
|
+
f"manifest maps {sids} → {sem}",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
assignment: dict[str, str] = {}
|
|
86
|
+
for row in rows:
|
|
87
|
+
if row.release_version.strip():
|
|
88
|
+
assignment[row.sprint_id] = rcl.normalize_semver(row.release_version)
|
|
89
|
+
elif row.sprint_id in tier_b:
|
|
90
|
+
assignment[row.sprint_id] = tier_b[row.sprint_id]
|
|
91
|
+
for row in rows:
|
|
92
|
+
if row.sprint_id in assignment:
|
|
93
|
+
continue
|
|
94
|
+
assignment[row.sprint_id] = _synthetic_semver(row.sprint_id)
|
|
95
|
+
return assignment
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def run_backfill(repo_root: str, dry_run: bool = False) -> int:
|
|
99
|
+
rcl.ensure_changelog_stub(repo_root)
|
|
100
|
+
try:
|
|
101
|
+
assignment = assign_semvers(repo_root)
|
|
102
|
+
except rcl.ReleaseChangelogError as exc:
|
|
103
|
+
print(f"{exc.code}: {exc}", file=sys.stderr)
|
|
104
|
+
return 1
|
|
105
|
+
|
|
106
|
+
# coalesce by semver
|
|
107
|
+
groups: dict[str, list[str]] = {}
|
|
108
|
+
for sid, sem in assignment.items():
|
|
109
|
+
groups.setdefault(sem, []).append(sid)
|
|
110
|
+
|
|
111
|
+
for semver, sprint_ids in sorted(groups.items()):
|
|
112
|
+
if dry_run:
|
|
113
|
+
print(f"would build {semver} <- {sprint_ids}")
|
|
114
|
+
continue
|
|
115
|
+
try:
|
|
116
|
+
rcl.build_version_doc(semver, sprint_ids, repo_root)
|
|
117
|
+
rcl.promote_unreleased(semver, sprint_ids, repo_root)
|
|
118
|
+
rcl.bind_queue_release_version(sprint_ids, semver, repo_root)
|
|
119
|
+
except rcl.ReleaseChangelogError as exc:
|
|
120
|
+
if exc.code == rcl.RELEASE_CHANGELOG_DUPLICATE_VERSION:
|
|
121
|
+
print(rcl.RELEASE_CHANGELOG_IDEMPOTENCY_OK, file=sys.stderr)
|
|
122
|
+
continue
|
|
123
|
+
print(f"{exc.code}: {exc}", file=sys.stderr)
|
|
124
|
+
return 1
|
|
125
|
+
|
|
126
|
+
print("[RELEASE_CHANGELOG_BACKFILL_OK]")
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def main() -> int:
|
|
131
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
132
|
+
parser.add_argument("--repo", default=_REPO_ROOT)
|
|
133
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
134
|
+
parser.add_argument(
|
|
135
|
+
"--ensure-version",
|
|
136
|
+
metavar="SEMVER",
|
|
137
|
+
help="Ensure handoffs/releases/{semver}-release-notes.md exists (release-all.sh).",
|
|
138
|
+
)
|
|
139
|
+
args = parser.parse_args()
|
|
140
|
+
root = os.path.abspath(args.repo)
|
|
141
|
+
if args.ensure_version:
|
|
142
|
+
try:
|
|
143
|
+
path = rcl.ensure_version_doc_for_release(args.ensure_version, root)
|
|
144
|
+
print(path)
|
|
145
|
+
return 0
|
|
146
|
+
except rcl.ReleaseChangelogError as exc:
|
|
147
|
+
print(f"{exc.code}: {exc}", file=sys.stderr)
|
|
148
|
+
return 1
|
|
149
|
+
return run_backfill(root, dry_run=args.dry_run)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
if __name__ == "__main__":
|
|
153
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Release changelog derivation, coalesce, promote, fingerprint idempotency (US-0100 / DEC-0085).
|
|
3
|
+
|
|
4
|
+
Derivation precedence (L4):
|
|
5
|
+
1. Sprint note ``## What's new`` bullets + inline US-xxxx / BUG-xxxx refs
|
|
6
|
+
2. Backlog ``## US-xxxx`` / ``### BUG-xxxx`` title + summary (one-liner)
|
|
7
|
+
3. Queue row ``story_refs`` (fallback when sprint note sparse)
|
|
8
|
+
|
|
9
|
+
Category map: US→Added, BUG→Fixed, user_visible:false→Changed.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from typing import Dict, List, Optional, Sequence, Tuple
|
|
21
|
+
|
|
22
|
+
# Derivation precedence (documented L4 order — do not reorder without DEC revision)
|
|
23
|
+
DERIVATION_PRECEDENCE: Tuple[str, ...] = (
|
|
24
|
+
"sprint_notes_whats_new",
|
|
25
|
+
"backlog_title_summary",
|
|
26
|
+
"queue_story_refs",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
US_ID = re.compile(r"\bUS-\d{4}\b")
|
|
30
|
+
BUG_ID = re.compile(r"\bBUG-\d{4}\b")
|
|
31
|
+
SEMVER_SECTION = re.compile(r"^##\s+\[(.+?)\]\s*(?:-\s*(\d{4}-\d{2}-\d{2}))?\s*$", re.MULTILINE)
|
|
32
|
+
USER_VISIBLE = re.compile(r"^-\s*user_visible:\s*(true|false)\s*$", re.IGNORECASE | re.MULTILINE)
|
|
33
|
+
SEMVER_VALID = re.compile(
|
|
34
|
+
r"^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Fail-closed reason codes (RELEASE_CHANGELOG_* family)
|
|
38
|
+
RELEASE_CHANGELOG_VERSION_MISSING = "RELEASE_CHANGELOG_VERSION_MISSING"
|
|
39
|
+
RELEASE_CHANGELOG_DUPLICATE_VERSION = "RELEASE_CHANGELOG_DUPLICATE_VERSION"
|
|
40
|
+
RELEASE_CHANGELOG_WORK_ITEM_GAP = "RELEASE_CHANGELOG_WORK_ITEM_GAP"
|
|
41
|
+
RELEASE_CHANGELOG_ORDER_INVALID = "RELEASE_CHANGELOG_ORDER_INVALID"
|
|
42
|
+
RELEASE_CHANGELOG_UNRELEASED_MISSING = "RELEASE_CHANGELOG_UNRELEASED_MISSING"
|
|
43
|
+
RELEASE_CHANGELOG_QUEUE_DRIFT = "RELEASE_CHANGELOG_QUEUE_DRIFT"
|
|
44
|
+
RELEASE_CHANGELOG_VERSION_DOC_MISSING = "RELEASE_CHANGELOG_VERSION_DOC_MISSING"
|
|
45
|
+
RELEASE_CHANGELOG_SPRINT_ORPHAN = "RELEASE_CHANGELOG_SPRINT_ORPHAN"
|
|
46
|
+
RELEASE_CHANGELOG_BACKFILL_AMBIGUOUS = "RELEASE_CHANGELOG_BACKFILL_AMBIGUOUS"
|
|
47
|
+
RELEASE_CHANGELOG_IDEMPOTENCY_VIOLATION = "RELEASE_CHANGELOG_IDEMPOTENCY_VIOLATION"
|
|
48
|
+
RELEASE_CHANGELOG_IDEMPOTENCY_OK = "RELEASE_CHANGELOG_IDEMPOTENCY_OK"
|
|
49
|
+
|
|
50
|
+
FAIL_CODES = (
|
|
51
|
+
RELEASE_CHANGELOG_VERSION_MISSING,
|
|
52
|
+
RELEASE_CHANGELOG_DUPLICATE_VERSION,
|
|
53
|
+
RELEASE_CHANGELOG_WORK_ITEM_GAP,
|
|
54
|
+
RELEASE_CHANGELOG_ORDER_INVALID,
|
|
55
|
+
RELEASE_CHANGELOG_UNRELEASED_MISSING,
|
|
56
|
+
RELEASE_CHANGELOG_QUEUE_DRIFT,
|
|
57
|
+
RELEASE_CHANGELOG_VERSION_DOC_MISSING,
|
|
58
|
+
RELEASE_CHANGELOG_SPRINT_ORPHAN,
|
|
59
|
+
RELEASE_CHANGELOG_BACKFILL_AMBIGUOUS,
|
|
60
|
+
RELEASE_CHANGELOG_IDEMPOTENCY_VIOLATION,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ReleaseChangelogError(Exception):
|
|
65
|
+
def __init__(self, code: str, message: str = "") -> None:
|
|
66
|
+
self.code = code
|
|
67
|
+
super().__init__(message or code)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class WorkItem:
|
|
72
|
+
item_id: str
|
|
73
|
+
title: str
|
|
74
|
+
summary: str
|
|
75
|
+
category: str # Added | Fixed | Changed
|
|
76
|
+
source: str = ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class QueueRow:
|
|
81
|
+
sprint_id: str
|
|
82
|
+
story_refs: str
|
|
83
|
+
status: str
|
|
84
|
+
last_updated: str
|
|
85
|
+
release_notes_ref: str
|
|
86
|
+
release_version: str
|
|
87
|
+
raw_line: str
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def read_utf8(path: str) -> str:
|
|
91
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
92
|
+
return f.read()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def write_utf8(path: str, content: str) -> None:
|
|
96
|
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
97
|
+
with open(path, "w", encoding="utf-8", newline="\n") as f:
|
|
98
|
+
f.write(content)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def normalize_semver(raw: str) -> str:
|
|
102
|
+
"""Strip leading v; semver-parse; raise VERSION_MISSING on empty/invalid."""
|
|
103
|
+
if raw is None:
|
|
104
|
+
raise ReleaseChangelogError(RELEASE_CHANGELOG_VERSION_MISSING, "version is None")
|
|
105
|
+
s = raw.strip()
|
|
106
|
+
if s.lower().startswith("v"):
|
|
107
|
+
s = s[1:]
|
|
108
|
+
if not s:
|
|
109
|
+
raise ReleaseChangelogError(RELEASE_CHANGELOG_VERSION_MISSING, "empty version")
|
|
110
|
+
if not SEMVER_VALID.match(s):
|
|
111
|
+
raise ReleaseChangelogError(
|
|
112
|
+
RELEASE_CHANGELOG_VERSION_MISSING, f"invalid semver: {raw!r}"
|
|
113
|
+
)
|
|
114
|
+
return s
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def version_fingerprint(semver: str, work_item_ids: Sequence[str]) -> str:
|
|
118
|
+
"""Idempotency key: semver + sorted work_item_ids."""
|
|
119
|
+
norm = normalize_semver(semver)
|
|
120
|
+
ids = sorted(set(work_item_ids))
|
|
121
|
+
return f"{norm}|{','.join(ids)}"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _one_liner(text: str, max_len: int = 160) -> str:
|
|
125
|
+
line = " ".join(text.split())
|
|
126
|
+
if len(line) > max_len:
|
|
127
|
+
return line[: max_len - 3] + "..."
|
|
128
|
+
return line
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def parse_queue_rows(repo_root: str) -> List[QueueRow]:
|
|
132
|
+
path = os.path.join(repo_root, "handoffs", "release_queue.md")
|
|
133
|
+
if not os.path.isfile(path):
|
|
134
|
+
return []
|
|
135
|
+
rows: List[QueueRow] = []
|
|
136
|
+
for line in read_utf8(path).splitlines():
|
|
137
|
+
if not line.startswith("| S"):
|
|
138
|
+
continue
|
|
139
|
+
parts = [p.strip() for p in line.split("|")]
|
|
140
|
+
if len(parts) < 9:
|
|
141
|
+
continue
|
|
142
|
+
rows.append(
|
|
143
|
+
QueueRow(
|
|
144
|
+
sprint_id=parts[1],
|
|
145
|
+
story_refs=parts[2],
|
|
146
|
+
status=parts[3],
|
|
147
|
+
last_updated=parts[4],
|
|
148
|
+
release_notes_ref=parts[5],
|
|
149
|
+
release_version=parts[7] if len(parts) > 7 else "",
|
|
150
|
+
raw_line=line,
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
return rows
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _queue_row_map(repo_root: str) -> Dict[str, QueueRow]:
|
|
157
|
+
return {r.sprint_id: r for r in parse_queue_rows(repo_root)}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _parse_backlog_items(repo_root: str) -> Dict[str, WorkItem]:
|
|
161
|
+
backlog_path = os.path.join(repo_root, "docs", "product", "backlog.md")
|
|
162
|
+
if not os.path.isfile(backlog_path):
|
|
163
|
+
return {}
|
|
164
|
+
text = read_utf8(backlog_path)
|
|
165
|
+
items: Dict[str, WorkItem] = {}
|
|
166
|
+
for m in re.finditer(r"^##\s+(US-\d{4})\s*[—\-]\s*(.+)$", text, re.MULTILINE):
|
|
167
|
+
item_id, title = m.group(1), m.group(2).strip()
|
|
168
|
+
block = text[m.end() : text.find("\n## ", m.end())]
|
|
169
|
+
uv = USER_VISIBLE.search(block)
|
|
170
|
+
user_vis = uv.group(1).lower() == "true" if uv else True
|
|
171
|
+
sm = re.search(r"^-\s*Summary:\s*(.+)$", block, re.MULTILINE)
|
|
172
|
+
summary = _one_liner(sm.group(1)) if sm else title
|
|
173
|
+
if not user_vis:
|
|
174
|
+
cat = "Changed"
|
|
175
|
+
else:
|
|
176
|
+
cat = "Added"
|
|
177
|
+
items[item_id] = WorkItem(item_id, title, summary, cat, "backlog_title_summary")
|
|
178
|
+
for m in re.finditer(r"^###\s+(BUG-\d{4})\s*[—\-]\s*(.+)$", text, re.MULTILINE):
|
|
179
|
+
item_id, title = m.group(1), m.group(2).strip()
|
|
180
|
+
block = text[m.end() : text.find("\n### ", m.end())]
|
|
181
|
+
block = block.split("\n## ")[0]
|
|
182
|
+
sm = re.search(r"^-\s*Summary:\s*(.+)$", block, re.MULTILINE)
|
|
183
|
+
summary = _one_liner(sm.group(1)) if sm else title
|
|
184
|
+
items[item_id] = WorkItem(item_id, title, summary, "Fixed", "backlog_title_summary")
|
|
185
|
+
return items
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _extract_whats_new(repo_root: str, sprint_id: str) -> List[str]:
|
|
189
|
+
notes_path = os.path.join(
|
|
190
|
+
repo_root, "handoffs", "releases", f"{sprint_id}-release-notes.md"
|
|
191
|
+
)
|
|
192
|
+
if not os.path.isfile(notes_path):
|
|
193
|
+
return []
|
|
194
|
+
text = read_utf8(notes_path)
|
|
195
|
+
m = re.search(r"^##\s+What's new\s*$", text, re.MULTILINE | re.IGNORECASE)
|
|
196
|
+
if not m:
|
|
197
|
+
return []
|
|
198
|
+
rest = text[m.end() :]
|
|
199
|
+
end = re.search(r"^##\s+", rest, re.MULTILINE)
|
|
200
|
+
section = rest[: end.start()] if end else rest
|
|
201
|
+
bullets: List[str] = []
|
|
202
|
+
for line in section.splitlines():
|
|
203
|
+
s = line.strip()
|
|
204
|
+
if s.startswith("- "):
|
|
205
|
+
bullets.append(s[2:].strip())
|
|
206
|
+
return bullets
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _category_for_id(item_id: str, backlog: Dict[str, WorkItem]) -> str:
|
|
210
|
+
if item_id in backlog:
|
|
211
|
+
return backlog[item_id].category
|
|
212
|
+
if item_id.startswith("BUG-"):
|
|
213
|
+
return "Fixed"
|
|
214
|
+
return "Added"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def derive_work_items(sprint_ids: Sequence[str], repo_root: str) -> List[WorkItem]:
|
|
218
|
+
"""L4 precedence: sprint notes → backlog → queue story_refs."""
|
|
219
|
+
backlog = _parse_backlog_items(repo_root)
|
|
220
|
+
qmap = _queue_row_map(repo_root)
|
|
221
|
+
seen: Dict[str, WorkItem] = {}
|
|
222
|
+
|
|
223
|
+
for sprint_id in sprint_ids:
|
|
224
|
+
# 1. Sprint notes What's new
|
|
225
|
+
for bullet in _extract_whats_new(repo_root, sprint_id):
|
|
226
|
+
for pat in (US_ID, BUG_ID):
|
|
227
|
+
for item_id in pat.findall(bullet):
|
|
228
|
+
if item_id in seen:
|
|
229
|
+
continue
|
|
230
|
+
title = backlog[item_id].title if item_id in backlog else item_id
|
|
231
|
+
summary = _one_liner(bullet) if bullet else title
|
|
232
|
+
seen[item_id] = WorkItem(
|
|
233
|
+
item_id,
|
|
234
|
+
title,
|
|
235
|
+
summary,
|
|
236
|
+
_category_for_id(item_id, backlog),
|
|
237
|
+
"sprint_notes_whats_new",
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# 2. Backlog for refs mentioned in sprint notes full text
|
|
241
|
+
notes_path = os.path.join(
|
|
242
|
+
repo_root, "handoffs", "releases", f"{sprint_id}-release-notes.md"
|
|
243
|
+
)
|
|
244
|
+
if os.path.isfile(notes_path):
|
|
245
|
+
note_text = read_utf8(notes_path)
|
|
246
|
+
for item_id in US_ID.findall(note_text) + BUG_ID.findall(note_text):
|
|
247
|
+
if item_id in seen:
|
|
248
|
+
continue
|
|
249
|
+
if item_id in backlog:
|
|
250
|
+
wi = backlog[item_id]
|
|
251
|
+
seen[item_id] = WorkItem(
|
|
252
|
+
wi.item_id, wi.title, wi.summary, wi.category, wi.source
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
# 3. Queue story_refs fallback
|
|
256
|
+
row = qmap.get(sprint_id)
|
|
257
|
+
if row:
|
|
258
|
+
for token in re.split(r"[,;\s]+", row.story_refs):
|
|
259
|
+
token = token.strip()
|
|
260
|
+
if not token or token in seen:
|
|
261
|
+
continue
|
|
262
|
+
if token in backlog:
|
|
263
|
+
wi = backlog[token]
|
|
264
|
+
seen[token] = WorkItem(
|
|
265
|
+
wi.item_id, wi.title, wi.summary, wi.category, "queue_story_refs"
|
|
266
|
+
)
|
|
267
|
+
elif US_ID.fullmatch(token) or BUG_ID.fullmatch(token):
|
|
268
|
+
seen[token] = WorkItem(
|
|
269
|
+
token,
|
|
270
|
+
token,
|
|
271
|
+
token,
|
|
272
|
+
_category_for_id(token, backlog),
|
|
273
|
+
"queue_story_refs",
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def sort_key(w: WorkItem) -> Tuple[int, int]:
|
|
277
|
+
if w.item_id.startswith("US-"):
|
|
278
|
+
return (0, int(w.item_id.split("-")[1]))
|
|
279
|
+
return (1, int(w.item_id.split("-")[1]))
|
|
280
|
+
|
|
281
|
+
return sorted(seen.values(), key=sort_key)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def coalesce_sprints_by_semver(
|
|
285
|
+
rows: Optional[Sequence[QueueRow]], repo_root: str
|
|
286
|
+
) -> Dict[str, List[str]]:
|
|
287
|
+
"""Group released queue rows by normalized semver (non-empty release_version)."""
|
|
288
|
+
if rows is None:
|
|
289
|
+
rows = parse_queue_rows(repo_root)
|
|
290
|
+
groups: Dict[str, List[str]] = {}
|
|
291
|
+
for row in rows:
|
|
292
|
+
if row.status != "released":
|
|
293
|
+
continue
|
|
294
|
+
if not row.release_version.strip():
|
|
295
|
+
continue
|
|
296
|
+
try:
|
|
297
|
+
key = normalize_semver(row.release_version)
|
|
298
|
+
except ReleaseChangelogError:
|
|
299
|
+
continue
|
|
300
|
+
groups.setdefault(key, []).append(row.sprint_id)
|
|
301
|
+
for key in groups:
|
|
302
|
+
groups[key] = sorted(set(groups[key]))
|
|
303
|
+
return groups
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def version_doc_path(repo_root: str, semver: str) -> str:
|
|
307
|
+
norm = normalize_semver(semver)
|
|
308
|
+
return os.path.join(repo_root, "handoffs", "releases", f"{norm}-release-notes.md")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _format_version_doc(
|
|
312
|
+
semver: str,
|
|
313
|
+
work_items: Sequence[WorkItem],
|
|
314
|
+
sprint_ids: Sequence[str],
|
|
315
|
+
fingerprint: str,
|
|
316
|
+
) -> str:
|
|
317
|
+
lines = [
|
|
318
|
+
f"# Release notes — {normalize_semver(semver)}",
|
|
319
|
+
"",
|
|
320
|
+
f"<!-- release_changelog_fingerprint: {fingerprint} -->",
|
|
321
|
+
"",
|
|
322
|
+
"> Per-version GitHub `-F` SOT (**US-0100**). Sprint-scoped evidence remains in "
|
|
323
|
+
"`handoffs/releases/Sxxxx-release-notes.md` — do not overwrite unrelated version files.",
|
|
324
|
+
"",
|
|
325
|
+
"## Work items",
|
|
326
|
+
"",
|
|
327
|
+
]
|
|
328
|
+
for wi in work_items:
|
|
329
|
+
lines.append(f"- **{wi.item_id}** — {wi.summary}")
|
|
330
|
+
lines.extend(["", "## Sprint evidence", ""])
|
|
331
|
+
for sid in sorted(set(sprint_ids)):
|
|
332
|
+
lines.append(f"- [`{sid}`](handoffs/releases/{sid}-release-notes.md)")
|
|
333
|
+
lines.append("")
|
|
334
|
+
return "\n".join(lines)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def build_version_doc(semver: str, sprint_ids: Sequence[str], repo_root: str) -> str:
|
|
338
|
+
"""Write per-version doc; return path. Idempotent on matching fingerprint."""
|
|
339
|
+
norm = normalize_semver(semver)
|
|
340
|
+
work_items = derive_work_items(sprint_ids, repo_root)
|
|
341
|
+
fp = version_fingerprint(norm, [w.item_id for w in work_items])
|
|
342
|
+
path = version_doc_path(repo_root, norm)
|
|
343
|
+
|
|
344
|
+
if os.path.isfile(path):
|
|
345
|
+
existing = read_utf8(path)
|
|
346
|
+
m = re.search(r"<!-- release_changelog_fingerprint:\s*(.+?)\s*-->", existing)
|
|
347
|
+
if m and m.group(1).strip() == fp:
|
|
348
|
+
print(RELEASE_CHANGELOG_IDEMPOTENCY_OK, file=__import__("sys").stderr)
|
|
349
|
+
return path
|
|
350
|
+
if m and m.group(1).strip() != fp:
|
|
351
|
+
raise ReleaseChangelogError(
|
|
352
|
+
RELEASE_CHANGELOG_DUPLICATE_VERSION,
|
|
353
|
+
f"fingerprint mismatch for {norm}",
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
content = _format_version_doc(norm, work_items, sprint_ids, fp)
|
|
357
|
+
write_utf8(path, content)
|
|
358
|
+
return path
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def changelog_path(repo_root: str) -> str:
|
|
362
|
+
return os.path.join(repo_root, "CHANGELOG.md")
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def ensure_changelog_stub(repo_root: str) -> None:
|
|
366
|
+
path = changelog_path(repo_root)
|
|
367
|
+
if os.path.isfile(path):
|
|
368
|
+
return
|
|
369
|
+
stub = (
|
|
370
|
+
"# Changelog\n\n"
|
|
371
|
+
"All notable changes to this project will be documented in this file.\n\n"
|
|
372
|
+
"The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),\n"
|
|
373
|
+
"and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n"
|
|
374
|
+
"<!-- semver-sections-newest-first -->\n\n"
|
|
375
|
+
"## [Unreleased]\n\n"
|
|
376
|
+
)
|
|
377
|
+
write_utf8(path, stub)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def extract_changelog_section(semver: str, repo_root: str) -> Optional[str]:
|
|
381
|
+
"""Read cumulative section text for semver (body only, no heading)."""
|
|
382
|
+
path = changelog_path(repo_root)
|
|
383
|
+
if not os.path.isfile(path):
|
|
384
|
+
return None
|
|
385
|
+
norm = normalize_semver(semver)
|
|
386
|
+
text = read_utf8(path)
|
|
387
|
+
pattern = re.compile(
|
|
388
|
+
rf"^##\s+\[{re.escape(norm)}\]\s*(?:-\s*\d{{4}}-\d{{2}}-\d{{2}})?\s*$",
|
|
389
|
+
re.MULTILINE,
|
|
390
|
+
)
|
|
391
|
+
m = pattern.search(text)
|
|
392
|
+
if not m:
|
|
393
|
+
return None
|
|
394
|
+
rest = text[m.end() :]
|
|
395
|
+
nxt = re.search(r"^##\s+\[", rest, re.MULTILINE)
|
|
396
|
+
body = rest[: nxt.start()] if nxt else rest
|
|
397
|
+
return body.strip()
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _split_changelog_sections(text: str) -> Tuple[str, str, List[Tuple[str, str, str]]]:
|
|
401
|
+
"""Return (header, unreleased_body, [(semver, date, body), ...])."""
|
|
402
|
+
m = re.search(r"^##\s+\[Unreleased\]\s*$", text, re.MULTILINE)
|
|
403
|
+
if not m:
|
|
404
|
+
raise ReleaseChangelogError(RELEASE_CHANGELOG_UNRELEASED_MISSING)
|
|
405
|
+
header = text[: m.start()]
|
|
406
|
+
rest = text[m.end() :]
|
|
407
|
+
sections: List[Tuple[str, str, str]] = []
|
|
408
|
+
pos = 0
|
|
409
|
+
for sm in SEMVER_SECTION.finditer(rest):
|
|
410
|
+
if sm.start() > pos:
|
|
411
|
+
# content before first semver section is unreleased body
|
|
412
|
+
pass
|
|
413
|
+
semver_raw = sm.group(1)
|
|
414
|
+
if semver_raw.lower() == "unreleased":
|
|
415
|
+
continue
|
|
416
|
+
date = sm.group(2) or ""
|
|
417
|
+
start = sm.end()
|
|
418
|
+
nxt = SEMVER_SECTION.search(rest, start)
|
|
419
|
+
body = rest[start : nxt.start() if nxt else len(rest)]
|
|
420
|
+
sections.append((semver_raw, date, body.strip()))
|
|
421
|
+
unreleased_end = SEMVER_SECTION.search(rest)
|
|
422
|
+
unreleased_body = rest[: unreleased_end.start()].strip() if unreleased_end else rest.strip()
|
|
423
|
+
return header, unreleased_body, sections
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _format_work_items_changelog(work_items: Sequence[WorkItem]) -> str:
|
|
427
|
+
by_cat: Dict[str, List[WorkItem]] = {"Added": [], "Fixed": [], "Changed": []}
|
|
428
|
+
for wi in work_items:
|
|
429
|
+
by_cat.setdefault(wi.category, []).append(wi)
|
|
430
|
+
parts: List[str] = []
|
|
431
|
+
for cat in ("Added", "Fixed", "Changed"):
|
|
432
|
+
items = by_cat.get(cat) or []
|
|
433
|
+
if not items:
|
|
434
|
+
continue
|
|
435
|
+
parts.append(f"### {cat}")
|
|
436
|
+
parts.append("")
|
|
437
|
+
for wi in items:
|
|
438
|
+
parts.append(f"- **{wi.item_id}** — {wi.summary}")
|
|
439
|
+
parts.append("")
|
|
440
|
+
return "\n".join(parts).strip()
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def append_unreleased(work_items: Sequence[WorkItem], repo_root: str) -> None:
|
|
444
|
+
"""Append categorized bullets under top [Unreleased] only."""
|
|
445
|
+
ensure_changelog_stub(repo_root)
|
|
446
|
+
path = changelog_path(repo_root)
|
|
447
|
+
text = read_utf8(path)
|
|
448
|
+
header, unreleased_body, sections = _split_changelog_sections(text)
|
|
449
|
+
new_block = _format_work_items_changelog(work_items)
|
|
450
|
+
if new_block:
|
|
451
|
+
if unreleased_body:
|
|
452
|
+
unreleased_body = unreleased_body + "\n\n" + new_block
|
|
453
|
+
else:
|
|
454
|
+
unreleased_body = new_block
|
|
455
|
+
out = header + "## [Unreleased]\n\n"
|
|
456
|
+
if unreleased_body:
|
|
457
|
+
out += unreleased_body + "\n\n"
|
|
458
|
+
for semver, date, body in sections:
|
|
459
|
+
out += f"## [{semver}]"
|
|
460
|
+
if date:
|
|
461
|
+
out += f" - {date}"
|
|
462
|
+
out += "\n\n" + body + "\n\n"
|
|
463
|
+
write_utf8(path, out.rstrip() + "\n")
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def promote_unreleased(
|
|
467
|
+
semver: str,
|
|
468
|
+
sprint_ids: Sequence[str],
|
|
469
|
+
repo_root: str,
|
|
470
|
+
release_date: Optional[str] = None,
|
|
471
|
+
) -> None:
|
|
472
|
+
"""Move [Unreleased] items into ## [semver] - date; recreate empty [Unreleased]."""
|
|
473
|
+
ensure_changelog_stub(repo_root)
|
|
474
|
+
path = changelog_path(repo_root)
|
|
475
|
+
text = read_utf8(path)
|
|
476
|
+
header, unreleased_body, sections = _split_changelog_sections(text)
|
|
477
|
+
norm = normalize_semver(semver)
|
|
478
|
+
work_items = derive_work_items(sprint_ids, repo_root)
|
|
479
|
+
promoted = _format_work_items_changelog(work_items)
|
|
480
|
+
if unreleased_body:
|
|
481
|
+
promoted = (unreleased_body + "\n\n" + promoted).strip() if promoted else unreleased_body
|
|
482
|
+
date = release_date or datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
483
|
+
# Remove existing section for same semver
|
|
484
|
+
sections = [(s, d, b) for s, d, b in sections if normalize_semver(s) != norm]
|
|
485
|
+
sections.insert(0, (norm, date, promoted))
|
|
486
|
+
out = header + "## [Unreleased]\n\n\n"
|
|
487
|
+
for s, d, b in sections:
|
|
488
|
+
out += f"## [{s}]"
|
|
489
|
+
if d:
|
|
490
|
+
out += f" - {d}"
|
|
491
|
+
out += "\n\n" + b + "\n\n"
|
|
492
|
+
write_utf8(path, out.rstrip() + "\n")
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def bind_queue_release_version(
|
|
496
|
+
sprint_ids: Sequence[str], semver: str, repo_root: str
|
|
497
|
+
) -> None:
|
|
498
|
+
"""Mutate only specified sprint rows in handoffs/release_queue.md."""
|
|
499
|
+
path = os.path.join(repo_root, "handoffs", "release_queue.md")
|
|
500
|
+
if not os.path.isfile(path):
|
|
501
|
+
raise ReleaseChangelogError(
|
|
502
|
+
RELEASE_CHANGELOG_QUEUE_DRIFT, "release_queue.md missing"
|
|
503
|
+
)
|
|
504
|
+
norm = normalize_semver(semver)
|
|
505
|
+
targets = set(sprint_ids)
|
|
506
|
+
lines = read_utf8(path).splitlines()
|
|
507
|
+
out: List[str] = []
|
|
508
|
+
for line in lines:
|
|
509
|
+
if not line.startswith("| S"):
|
|
510
|
+
out.append(line)
|
|
511
|
+
continue
|
|
512
|
+
cols = [p.strip() for p in line.split("|")[1:-1]]
|
|
513
|
+
if len(cols) < 7:
|
|
514
|
+
out.append(line)
|
|
515
|
+
continue
|
|
516
|
+
if cols[0] in targets:
|
|
517
|
+
cols[6] = norm
|
|
518
|
+
out.append("| " + " | ".join(cols) + " |")
|
|
519
|
+
else:
|
|
520
|
+
out.append(line)
|
|
521
|
+
write_utf8(path, "\n".join(out) + "\n")
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def ensure_version_doc_for_release(semver: str, repo_root: str) -> str:
|
|
525
|
+
"""Coalesce released rows for semver (or all unbound since tag) and build doc."""
|
|
526
|
+
norm = normalize_semver(semver)
|
|
527
|
+
rows = parse_queue_rows(repo_root)
|
|
528
|
+
sprint_ids: List[str] = []
|
|
529
|
+
for row in rows:
|
|
530
|
+
if row.status != "released":
|
|
531
|
+
continue
|
|
532
|
+
rv = row.release_version.strip()
|
|
533
|
+
if rv and normalize_semver(rv) == norm:
|
|
534
|
+
sprint_ids.append(row.sprint_id)
|
|
535
|
+
elif not rv:
|
|
536
|
+
sprint_ids.append(row.sprint_id)
|
|
537
|
+
if not sprint_ids:
|
|
538
|
+
sprint_ids = [r.sprint_id for r in rows if r.status == "released"][-1:]
|
|
539
|
+
return build_version_doc(norm, sprint_ids, repo_root)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def runtime_proof_hash(payload: Dict[str, object]) -> str:
|
|
543
|
+
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
|
544
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|