gabriel / muse public
releases.py python
411 lines 14.2 KB
Raw
sha256:e6465e8a9b7fa8e6223ed4a3576e96c568c913ae2caeb9c31f15e7a81b250b40 docs: add | jq convention to --json section of agent-guide Sonnet 4.6 1 day ago
1 """muse.core.releases — release layer for the Muse VCS.
2
3 Everything that reads, writes, or queries release records lives here.
4
5 Public API
6 ----------
7 ReleaseDict
8 JSON-serialisable TypedDict for ReleaseRecord wire format.
9
10 ReleaseRecord
11 Versioned release dataclass with to_dict / from_dict.
12
13 compute_release_id
14 Content-addressed ID derivation for releases.
15
16 release_path
17 On-disk path helper.
18
19 write_release / read_release
20 Core release I/O.
21
22 get_release_for_tag / list_releases / delete_release
23 Query and mutation helpers.
24
25 build_changelog
26 Walk the commit graph to produce a typed changelog for a release.
27 """
28 from __future__ import annotations
29
30 import datetime
31 import json as _json
32 import logging
33 import pathlib
34 from dataclasses import dataclass, field
35 from typing import TypedDict
36
37 from muse.core.io import _read_msgpack_dict, _write_json_atomic
38 from muse.core.paths import releases_dir as _releases_dir
39 from muse.core.record_helpers import _int_val, _str_list, _str_val
40 from muse.core.semver import (
41 ChangelogEntry,
42 ReleaseChannel,
43 SemVerTag,
44 _CHANNEL_MAP,
45 )
46 from muse.core.types import MsgpackDict, SemVerBump, content_hash, short_id, split_id
47
48 logger = logging.getLogger(__name__)
49
50
51 # ---------------------------------------------------------------------------
52 # Private deserialisation helpers
53 # ---------------------------------------------------------------------------
54
55 def _sem_ver_bump_val(d: MsgpackDict) -> SemVerBump:
56 """Extract and validate a ``sem_ver_bump`` field from a raw storage dict.
57
58 Falls back to ``"none"`` if the value is absent or not a recognised
59 Literal — guards against tampered or forward-versioned records.
60 """
61 val = _str_val(d, "sem_ver_bump", "none")
62 if val == "major":
63 return "major"
64 if val == "minor":
65 return "minor"
66 if val == "patch":
67 return "patch"
68 return "none"
69
70
71 def _parse_semver_tag(d: MsgpackDict, key: str) -> SemVerTag:
72 """Extract a nested :class:`SemVerTag` from a raw storage mapping.
73
74 Returns a zeroed ``SemVerTag`` if the key is absent or the value is not
75 a dict — callers can inspect ``major == 0`` to detect the fallback.
76 """
77 val = d.get(key)
78 if isinstance(val, dict):
79 return SemVerTag(
80 major=_int_val(val, "major"),
81 minor=_int_val(val, "minor"),
82 patch=_int_val(val, "patch"),
83 pre=_str_val(val, "pre"),
84 build=_str_val(val, "build"),
85 )
86 return SemVerTag(major=0, minor=0, patch=0, pre="", build="")
87
88
89 def _parse_changelog_entries(d: MsgpackDict, key: str) -> list[ChangelogEntry]:
90 """Extract a ``list[ChangelogEntry]`` from a raw storage mapping.
91
92 Silently skips items that are not dicts — defensive deserialization
93 at the boundary between wire format and in-memory types.
94 """
95 val = d.get(key)
96 if not isinstance(val, list):
97 return []
98 entries: list[ChangelogEntry] = []
99 for item in val:
100 if not isinstance(item, dict):
101 continue
102 entries.append(
103 ChangelogEntry(
104 commit_id=_str_val(item, "commit_id"),
105 message=_str_val(item, "message"),
106 sem_ver_bump=_sem_ver_bump_val(item),
107 breaking_changes=_str_list(item, "breaking_changes"),
108 author=_str_val(item, "author"),
109 committed_at=_str_val(item, "committed_at"),
110 agent_id=_str_val(item, "agent_id"),
111 model_id=_str_val(item, "model_id"),
112 )
113 )
114 return entries
115
116
117 # ---------------------------------------------------------------------------
118 # Wire-format TypedDict
119 # ---------------------------------------------------------------------------
120
121 class ReleaseDict(TypedDict):
122 """JSON-serialisable representation of a ReleaseRecord."""
123
124 release_id: str
125 repo_id: str
126 tag: str
127 semver: SemVerTag
128 channel: str
129 commit_id: str
130 snapshot_id: str
131 title: str
132 body: str
133 changelog: list[ChangelogEntry]
134 agent_id: str
135 model_id: str
136 is_draft: bool
137 gpg_signature: str
138 created_at: str
139
140
141 # ---------------------------------------------------------------------------
142 # ID derivation
143 # ---------------------------------------------------------------------------
144
145 def compute_release_id(repo_id: str, tag: str, commit_id: str) -> str:
146 """Return the content-addressed ``sha256:`` ID for a release.
147
148 The ID is derived from the three fields that uniquely identify a release:
149 its repository, the semver tag string, and the commit it pins.
150 """
151 return content_hash({"commit_id": commit_id, "repo_id": repo_id, "tag": tag})
152
153
154 # ---------------------------------------------------------------------------
155 # ReleaseRecord dataclass
156 # ---------------------------------------------------------------------------
157
158 @dataclass
159 class ReleaseRecord:
160 """A versioned release attached to a commit.
161
162 A release is richer than a Git tag:
163
164 - ``semver`` is a parsed struct (major/minor/patch/pre/build) — queryable
165 by version component without string parsing.
166 - ``channel`` replaces the boolean ``is_prerelease`` flag with a named
167 distribution channel: stable | beta | alpha | nightly.
168 - ``changelog`` is auto-generated from the typed ``sem_ver_bump`` and
169 ``breaking_changes`` fields on commits since the previous release, so no
170 conventional-commit parsing is needed.
171 - ``snapshot_id`` makes the release byte-for-byte reproducible from the
172 content-addressed object store forever.
173 - ``agent_id`` / ``model_id`` surface AI provenance from the tip commit.
174 - ``gpg_signature`` signs the release for tamper-evident distribution.
175 """
176
177 release_id: str
178 repo_id: str
179 tag: str
180 semver: SemVerTag
181 channel: ReleaseChannel
182 commit_id: str
183 snapshot_id: str
184 title: str
185 body: str
186 changelog: list[ChangelogEntry]
187 agent_id: str = ""
188 model_id: str = ""
189 is_draft: bool = False
190 gpg_signature: str = ""
191 created_at: datetime.datetime = field(
192 default_factory=lambda: datetime.datetime.now(datetime.timezone.utc)
193 )
194
195 def to_dict(self) -> ReleaseDict:
196 return ReleaseDict(
197 release_id=self.release_id,
198 repo_id=self.repo_id,
199 tag=self.tag,
200 semver=self.semver,
201 channel=self.channel,
202 commit_id=self.commit_id,
203 snapshot_id=self.snapshot_id,
204 title=self.title,
205 body=self.body,
206 changelog=list(self.changelog),
207 agent_id=self.agent_id,
208 model_id=self.model_id,
209 is_draft=self.is_draft,
210 gpg_signature=self.gpg_signature,
211 created_at=self.created_at.isoformat(),
212 )
213
214 @classmethod
215 def from_dict(cls, d: "MsgpackDict | ReleaseDict") -> "ReleaseRecord":
216 """Deserialise a :class:`ReleaseRecord` from a plain dict."""
217 created_at_str = _str_val(d, "created_at")
218 try:
219 created_at = datetime.datetime.fromisoformat(created_at_str)
220 except ValueError:
221 created_at = datetime.datetime.now(datetime.timezone.utc)
222 channel = _CHANNEL_MAP.get(_str_val(d, "channel", "stable"), "stable")
223 is_draft_val = d.get("is_draft", False)
224 return cls(
225 release_id=_str_val(d, "release_id"),
226 repo_id=_str_val(d, "repo_id"),
227 tag=_str_val(d, "tag"),
228 semver=_parse_semver_tag(d, "semver"),
229 channel=channel,
230 commit_id=_str_val(d, "commit_id"),
231 snapshot_id=_str_val(d, "snapshot_id"),
232 title=_str_val(d, "title"),
233 body=_str_val(d, "body"),
234 changelog=_parse_changelog_entries(d, "changelog"),
235 agent_id=_str_val(d, "agent_id"),
236 model_id=_str_val(d, "model_id"),
237 is_draft=bool(is_draft_val),
238 gpg_signature=_str_val(d, "gpg_signature"),
239 created_at=created_at,
240 )
241
242
243 # ---------------------------------------------------------------------------
244 # Path helper
245 # ---------------------------------------------------------------------------
246
247 def release_path(
248 repo_root: pathlib.Path, repo_id: str, release_id: str
249 ) -> pathlib.Path:
250 """Return the on-disk path for a release record.
251
252 Path shape: ``.muse/releases/<repo-algo>/<repo-hex>/<rel-algo>/<rel-hex>.json``
253
254 Follows the same two-level algorithm convention as ``tag_path``.
255
256 Args:
257 repo_root: Repository root directory.
258 repo_id: A ``<algo>:<hex>`` repo ID, or bare 64-char hex.
259 release_id: A ``<algo>:<hex>`` release ID, or bare 64-char hex.
260
261 Returns:
262 Absolute path to the JSON file for this release.
263 """
264 r_algo, r_hex = split_id(repo_id)
265 rl_algo, rl_hex = split_id(release_id)
266 return _releases_dir(repo_root) / r_algo / r_hex / rl_algo / f"{rl_hex}.json"
267
268
269 # ---------------------------------------------------------------------------
270 # Release I/O
271 # ---------------------------------------------------------------------------
272
273 def _read_release_or_migrate(path: pathlib.Path) -> "ReleaseRecord | None":
274 """Read a release from *path*, handling both .json and legacy .msgpack files.
275
276 If *path* is a .msgpack file, the record is silently migrated to .json
277 and the old file deleted.
278 """
279 try:
280 if path.suffix == ".json":
281 d: MsgpackDict = _json.loads(path.read_bytes().decode("utf-8"))
282 else:
283 d = _read_msgpack_dict(path)
284 json_path = path.with_suffix(".json")
285 _write_json_atomic(json_path, d)
286 path.unlink(missing_ok=True)
287 return ReleaseRecord.from_dict(d)
288 except Exception:
289 return None
290
291
292 def write_release(repo_root: pathlib.Path, release: ReleaseRecord) -> None:
293 """Persist a release record to ``.muse/releases/<repo-algo>/<repo-hex>/<rel-algo>/<rel-hex>.json``."""
294 path = release_path(repo_root, release.repo_id, release.release_id)
295 path.parent.mkdir(parents=True, exist_ok=True)
296 _write_json_atomic(path, release.to_dict())
297 logger.debug("✅ Stored release %r (%s)", release.tag, short_id(release.release_id))
298
299
300 def read_release(
301 repo_root: pathlib.Path, repo_id: str, release_id: str
302 ) -> ReleaseRecord | None:
303 """Load a release by its ID, or ``None`` if it does not exist or is corrupt."""
304 path = release_path(repo_root, repo_id, release_id)
305 if path.exists():
306 return _read_release_or_migrate(path)
307 # Fallback: legacy .msgpack file (silent upgrade to .json)
308 legacy = path.with_suffix(".msgpack")
309 if legacy.exists():
310 return _read_release_or_migrate(legacy)
311 return None
312
313
314 def get_release_for_tag(
315 repo_root: pathlib.Path, repo_id: str, tag: str
316 ) -> ReleaseRecord | None:
317 """Return the release whose version tag matches *tag*, or ``None``.
318
319 Searches all releases including drafts so callers can inspect or delete
320 a draft before it is published.
321 """
322 for release in list_releases(repo_root, repo_id, include_drafts=True):
323 if release.tag == tag:
324 return release
325 return None
326
327
328 def list_releases(
329 repo_root: pathlib.Path,
330 repo_id: str,
331 channel: ReleaseChannel | None = None,
332 include_drafts: bool = False,
333 ) -> list[ReleaseRecord]:
334 """Return all releases, newest first.
335
336 Args:
337 repo_root: Repository root.
338 repo_id: Content-addressed repo ID (``<algo>:<hex>`` or bare hex).
339 channel: Filter by channel; ``None`` returns all channels.
340 include_drafts: When ``False`` (default) draft releases are hidden.
341 """
342 r_algo, r_hex = split_id(repo_id)
343 repo_dir = _releases_dir(repo_root) / r_algo / r_hex
344 if not repo_dir.exists():
345 return []
346 results: list[ReleaseRecord] = []
347 for path in repo_dir.glob("*/*"):
348 if path.suffix not in (".json", ".msgpack"):
349 continue
350 r = _read_release_or_migrate(path)
351 if r is None:
352 logger.critical("❌ Corrupt release file %s — skipped in listing", path)
353 continue
354 if r.is_draft and not include_drafts:
355 continue
356 if channel is not None and r.channel != channel:
357 continue
358 results.append(r)
359 results.sort(key=lambda r: r.created_at, reverse=True)
360 return results
361
362
363 def delete_release(repo_root: pathlib.Path, repo_id: str, release_id: str) -> bool:
364 """Delete a release record. Returns ``True`` if it existed.
365
366 Callers are responsible for enforcing that only draft releases may be
367 deleted. This function performs no such guard — enforce at the CLI/API
368 layer.
369 """
370 path = release_path(repo_root, repo_id, release_id)
371 if path.exists():
372 path.unlink()
373 logger.debug("🗑️ Deleted release %s", short_id(release_id))
374 return True
375 return False
376
377
378 def build_changelog(
379 repo_root: pathlib.Path,
380 from_commit_id: str | None,
381 to_commit_id: str,
382 max_commits: int = 500,
383 ) -> list[ChangelogEntry]:
384 """Walk the commit graph from *to_commit_id* back to *from_commit_id*.
385
386 Returns a list of :class:`ChangelogEntry` dicts, oldest first, excluding
387 merge commits and ``sem_ver_bump="none"`` commits (they carry no user-visible
388 change). *from_commit_id* is excluded; *to_commit_id* is included.
389
390 Args:
391 repo_root: Repository root.
392 from_commit_id: The last release commit (exclusive start), or ``None``
393 for a first release (walks back to the initial commit).
394 to_commit_id: The HEAD commit to release (inclusive end).
395 max_commits: Safety cap — changelog never exceeds this many entries.
396 """
397 from muse.core.commits import walk_commits_between # local to avoid circular import
398 raw = walk_commits_between(repo_root, to_commit_id, from_commit_id, max_commits)
399 entries: list[ChangelogEntry] = []
400 for commit in reversed(raw): # oldest first
401 entries.append(ChangelogEntry(
402 commit_id=commit.commit_id,
403 message=commit.message,
404 sem_ver_bump=commit.sem_ver_bump,
405 breaking_changes=list(commit.breaking_changes),
406 author=commit.author,
407 committed_at=commit.committed_at.isoformat(),
408 agent_id=commit.agent_id,
409 model_id=commit.model_id,
410 ))
411 return entries
File History 1 commit
sha256:e6465e8a9b7fa8e6223ed4a3576e96c568c913ae2caeb9c31f15e7a81b250b40 docs: add | jq convention to --json section of agent-guide Sonnet 4.6 1 day ago