gabriel / muse public
test_release.py python
1,070 lines 42.0 KB
Raw
sha256:c0eba5ad689cec79f4a3fcdc4f5da78556cb4b8cb7b330f944634356c10379ed chore: pivot to nightly channel — bump version to 0.2.0.dev… Sonnet 5 patch 13 days ago
1 """Tests for the Muse release system.
2
3 Covers:
4 - Unit: parse_semver, semver_to_str, semver_channel, semver edge cases
5 - Unit: ReleaseRecord serialisation round-trip
6 - Unit: write_release, read_release, list_releases, delete_release, get_release_for_tag
7 - Unit: build_changelog from typed commit metadata
8 - Unit: WireTag in build_mpack / apply_mpack
9 - Integration: full release lifecycle (add → show → delete)
10 - E2E: muse release add / list / show / push / delete via CLI
11 """
12
13 from __future__ import annotations
14
15 import datetime
16 import json
17 import pathlib
18
19 import pytest
20 from tests.cli_test_helper import CliRunner
21
22 from muse.core.semver import ReleaseChannel
23 from muse.core.releases import ReleaseRecord
24 from muse.core.types import content_hash, Manifest
25 from muse.domain import SemVerBump
26 from muse.core.paths import ref_path, muse_dir
27
28 type _BumpMap = dict[str, SemVerBump]
29 type _ChannelMap = dict[str, ReleaseChannel]
30
31 runner = CliRunner()
32
33
34 # ---------------------------------------------------------------------------
35 # Repository scaffolding helpers
36 # ---------------------------------------------------------------------------
37
38
39 def _env(root: pathlib.Path) -> Manifest:
40 return {"MUSE_REPO_ROOT": str(root)}
41
42
43 def _init_repo(tmp_path: pathlib.Path, domain: str = "code") -> tuple[pathlib.Path, str]:
44 dot_muse = muse_dir(tmp_path)
45 dot_muse.mkdir()
46 repo_id = content_hash({"_seed": "test-1"})
47 (dot_muse / "repo.json").write_text(json.dumps({
48 "repo_id": repo_id,
49 "domain": domain,
50 "default_branch": "main",
51 "created_at": "2025-01-01T00:00:00+00:00",
52 }), encoding="utf-8")
53 (dot_muse / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
54 (dot_muse / "refs" / "heads").mkdir(parents=True)
55 (dot_muse / "snapshots").mkdir()
56 (dot_muse / "commits").mkdir()
57 (dot_muse / "objects").mkdir()
58 return tmp_path, repo_id
59
60
61 def _make_commit(
62 root: pathlib.Path,
63 repo_id: str,
64 branch: str = "main",
65 message: str = "feat: add something",
66 sem_ver_bump: str = "minor",
67 breaking_changes: list[str] | None = None,
68 agent_id: str = "",
69 model_id: str = "",
70 ) -> str:
71 from muse.core.ids import hash_commit as compute_commit_id, hash_snapshot as compute_snapshot_id
72 from muse.core.commits import (
73 CommitRecord,
74 write_commit,
75 )
76 from muse.core.snapshots import (
77 SnapshotRecord,
78 write_snapshot,
79 )
80
81 ref_file = ref_path(root, branch)
82 raw_parent = ref_file.read_text().strip() if ref_file.exists() else ""
83 parent_id: str | None = raw_parent if raw_parent else None
84 manifest: Manifest = {}
85 snap_id = compute_snapshot_id(manifest)
86 snap = SnapshotRecord(snapshot_id=snap_id, manifest=manifest)
87 write_snapshot(root, snap)
88
89 now = datetime.datetime.now(datetime.timezone.utc)
90 parent_ids: list[str] = [parent_id] if parent_id else []
91 commit_id = compute_commit_id(
92 parent_ids=parent_ids,
93 snapshot_id=snap_id,
94 message=message,
95 committed_at_iso=now.isoformat(),
96 )
97 _bump_map: _BumpMap = {"major": "major", "minor": "minor", "patch": "patch", "none": "none"}
98 bump_val: SemVerBump = _bump_map.get(sem_ver_bump, "none")
99
100 commit = CommitRecord(
101 commit_id=commit_id,
102 branch=branch,
103 snapshot_id=snap_id,
104 message=message,
105 committed_at=now,
106 parent_commit_id=parent_id,
107 sem_ver_bump=bump_val,
108 breaking_changes=breaking_changes or [],
109 agent_id=agent_id,
110 model_id=model_id,
111 )
112 write_commit(root, commit)
113 ref_file.write_text(commit_id, encoding="utf-8")
114 return commit_id
115
116
117 # ---------------------------------------------------------------------------
118 # Semver parsing
119 # ---------------------------------------------------------------------------
120
121
122 class TestParseSemver:
123 def test_stable_version(self) -> None:
124 from muse.core.semver import parse_semver
125
126 sv = parse_semver("v1.2.3")
127 assert sv["major"] == 1
128 assert sv["minor"] == 2
129 assert sv["patch"] == 3
130 assert sv["pre"] == ""
131 assert sv["build"] == ""
132
133 def test_no_v_prefix(self) -> None:
134 from muse.core.semver import parse_semver
135
136 sv = parse_semver("2.0.0")
137 assert sv["major"] == 2
138
139 def test_pre_release(self) -> None:
140 from muse.core.semver import parse_semver
141
142 sv = parse_semver("v1.3.0-beta.1")
143 assert sv["pre"] == "beta.1"
144
145 def test_alpha_pre_release(self) -> None:
146 from muse.core.semver import parse_semver
147
148 sv = parse_semver("v0.5.0-alpha.2")
149 assert sv["pre"] == "alpha.2"
150
151 def test_build_metadata(self) -> None:
152 from muse.core.semver import parse_semver
153
154 sv = parse_semver("v1.0.0+20240101")
155 assert sv["build"] == "20240101"
156 assert sv["pre"] == ""
157
158 def test_pre_and_build(self) -> None:
159 from muse.core.semver import parse_semver
160
161 sv = parse_semver("v2.0.0-rc.1+build.42")
162 assert sv["pre"] == "rc.1"
163 assert sv["build"] == "build.42"
164
165 def test_invalid_raises(self) -> None:
166 from muse.core.semver import parse_semver
167
168 with pytest.raises(ValueError, match="not valid semver"):
169 parse_semver("not-a-version")
170
171 def test_missing_patch_raises(self) -> None:
172 from muse.core.semver import parse_semver
173
174 with pytest.raises(ValueError):
175 parse_semver("v1.2")
176
177 def test_leading_zero_minor_valid(self) -> None:
178 from muse.core.semver import parse_semver
179
180 sv = parse_semver("v1.0.0")
181 assert sv["minor"] == 0
182
183
184 class TestSemverToStr:
185 def test_round_trip_stable(self) -> None:
186 from muse.core.semver import (
187 SemVerTag,
188 semver_to_str,
189 )
190
191 sv = SemVerTag(major=1, minor=2, patch=3, pre="", build="")
192 assert semver_to_str(sv) == "v1.2.3"
193
194 def test_round_trip_prerelease(self) -> None:
195 from muse.core.semver import (
196 SemVerTag,
197 semver_to_str,
198 )
199
200 sv = SemVerTag(major=1, minor=3, patch=0, pre="beta.1", build="")
201 assert semver_to_str(sv) == "v1.3.0-beta.1"
202
203 def test_round_trip_with_build(self) -> None:
204 from muse.core.semver import (
205 SemVerTag,
206 semver_to_str,
207 )
208
209 sv = SemVerTag(major=2, minor=0, patch=0, pre="rc.1", build="42")
210 assert semver_to_str(sv) == "v2.0.0-rc.1+42"
211
212
213 class TestSemverChannel:
214 def test_stable_channel_no_pre(self) -> None:
215 from muse.core.semver import (
216 SemVerTag,
217 semver_channel,
218 )
219
220 sv = SemVerTag(major=1, minor=0, patch=0, pre="", build="")
221 assert semver_channel(sv) == "stable"
222
223 def test_beta_channel(self) -> None:
224 from muse.core.semver import (
225 SemVerTag,
226 semver_channel,
227 )
228
229 sv = SemVerTag(major=1, minor=0, patch=0, pre="beta.1", build="")
230 assert semver_channel(sv) == "beta"
231
232 def test_alpha_channel(self) -> None:
233 from muse.core.semver import (
234 SemVerTag,
235 semver_channel,
236 )
237
238 sv = SemVerTag(major=1, minor=0, patch=0, pre="alpha.3", build="")
239 assert semver_channel(sv) == "alpha"
240
241 def test_nightly_channel(self) -> None:
242 from muse.core.semver import (
243 SemVerTag,
244 semver_channel,
245 )
246
247 sv = SemVerTag(major=0, minor=0, patch=1, pre="nightly", build="")
248 assert semver_channel(sv) == "nightly"
249
250 def test_rc_channel(self) -> None:
251 from muse.core.semver import (
252 SemVerTag,
253 semver_channel,
254 )
255
256 sv = SemVerTag(major=1, minor=0, patch=0, pre="rc.1", build="")
257 assert semver_channel(sv) == "rc"
258
259 def test_unrecognized_prefix_defaults_to_stable(self) -> None:
260 from muse.core.semver import (
261 SemVerTag,
262 semver_channel,
263 )
264
265 sv = SemVerTag(major=1, minor=0, patch=0, pre="dev.1", build="")
266 # Only nightly/alpha/beta/rc are recognised channel prefixes.
267 assert semver_channel(sv) == "stable"
268
269
270 # ---------------------------------------------------------------------------
271 # ReleaseRecord serialisation
272 # ---------------------------------------------------------------------------
273
274
275 class TestReleaseRecordSerialisation:
276 def _make_release(self) -> ReleaseRecord:
277 from muse.core.semver import SemVerTag
278
279 return ReleaseRecord(
280 release_id=content_hash({"_seed": "test-2"}),
281 repo_id=content_hash({"_seed": "test-1"}),
282 tag="v1.0.0",
283 semver=SemVerTag(major=1, minor=0, patch=0, pre="", build=""),
284 channel="stable",
285 commit_id="a" * 64,
286 snapshot_id="b" * 64,
287 title="First release",
288 body="Initial release notes.",
289 changelog=[],
290 )
291
292 def test_round_trip(self) -> None:
293 from muse.core.releases import ReleaseRecord
294
295 release = self._make_release()
296 d = release.to_dict()
297 restored = ReleaseRecord.from_dict(d)
298 assert restored.release_id == release.release_id
299 assert restored.tag == release.tag
300 assert restored.semver == release.semver
301 assert restored.channel == release.channel
302 assert restored.title == release.title
303 assert restored.is_draft is False
304
305 def test_draft_round_trip(self) -> None:
306 from muse.core.releases import ReleaseRecord
307
308 release = self._make_release()
309 release.is_draft = True
310 d = release.to_dict()
311 restored = ReleaseRecord.from_dict(d)
312 assert restored.is_draft is True
313
314 def test_invalid_channel_defaults_to_stable(self) -> None:
315 from muse.core.semver import SemVerTag
316 from muse.core.releases import ReleaseRecord
317
318 release = ReleaseRecord(
319 release_id=content_hash({"_seed": "test-4"}),
320 repo_id=content_hash({"_seed": "test-1"}),
321 tag="v1.0.0",
322 semver=SemVerTag(major=1, minor=0, patch=0, pre="", build=""),
323 channel="stable",
324 commit_id="a" * 64,
325 snapshot_id="b" * 64,
326 title="",
327 body="",
328 changelog=[],
329 )
330 d = release.to_dict()
331 d["channel"] = "unknown-channel"
332 restored = ReleaseRecord.from_dict(d)
333 assert restored.channel == "stable"
334
335
336 # ---------------------------------------------------------------------------
337 # Release store operations
338 # ---------------------------------------------------------------------------
339
340
341 class TestReleaseStore:
342 def test_write_and_read(self, tmp_path: pathlib.Path) -> None:
343 from muse.core.semver import SemVerTag
344 from muse.core.releases import (
345 ReleaseRecord,
346 read_release,
347 write_release,
348 )
349
350 root, repo_id = _init_repo(tmp_path)
351 release = ReleaseRecord(
352 release_id=content_hash({"_seed": "test-6"}),
353 repo_id=repo_id,
354 tag="v1.0.0",
355 semver=SemVerTag(major=1, minor=0, patch=0, pre="", build=""),
356 channel="stable",
357 commit_id="a" * 64,
358 snapshot_id="b" * 64,
359 title="v1.0.0",
360 body="",
361 changelog=[],
362 )
363 write_release(root, release)
364 loaded = read_release(root, repo_id, release.release_id)
365 assert loaded is not None
366 assert loaded.tag == "v1.0.0"
367
368 def test_read_missing_returns_none(self, tmp_path: pathlib.Path) -> None:
369 from muse.core.releases import read_release
370
371 root, repo_id = _init_repo(tmp_path)
372 assert read_release(root, repo_id, content_hash({"_seed": "test-7"})) is None
373
374 def test_list_releases_newest_first(self, tmp_path: pathlib.Path) -> None:
375 from muse.core.semver import SemVerTag
376 from muse.core.releases import (
377 ReleaseRecord,
378 list_releases,
379 write_release,
380 )
381 import time
382
383 root, repo_id = _init_repo(tmp_path)
384
385 for i, tag in enumerate(["v1.0.0", "v1.1.0", "v1.2.0"]):
386 sv = SemVerTag(major=1, minor=i, patch=0, pre="", build="")
387 r = ReleaseRecord(
388 release_id=content_hash({"_seed": f"test-8-{i}"}),
389 repo_id=repo_id,
390 tag=tag,
391 semver=sv,
392 channel="stable",
393 commit_id="a" * 64,
394 snapshot_id="b" * 64,
395 title=tag,
396 body="",
397 changelog=[],
398 created_at=datetime.datetime(2025, 1, i + 1, tzinfo=datetime.timezone.utc),
399 )
400 write_release(root, r)
401 time.sleep(0.01) # ensure distinct timestamps
402
403 releases = list_releases(root, repo_id)
404 assert len(releases) == 3
405 # newest first
406 assert releases[0].tag == "v1.2.0"
407 assert releases[-1].tag == "v1.0.0"
408
409 def test_list_excludes_drafts_by_default(self, tmp_path: pathlib.Path) -> None:
410 from muse.core.semver import SemVerTag
411 from muse.core.releases import (
412 ReleaseRecord,
413 list_releases,
414 write_release,
415 )
416
417 root, repo_id = _init_repo(tmp_path)
418 for j, (tag, draft) in enumerate([("v1.0.0", False), ("v1.1.0-beta.1", True)]):
419 sv_parts = tag.lstrip("v").split("-")
420 major, minor, patch = (int(x) for x in sv_parts[0].split("."))
421 pre = sv_parts[1] if len(sv_parts) > 1 else ""
422 r = ReleaseRecord(
423 release_id=content_hash({"_seed": f"test-9-{j}"}),
424 repo_id=repo_id,
425 tag=tag,
426 semver=SemVerTag(major=major, minor=minor, patch=patch, pre=pre, build=""),
427 channel="stable" if not pre else "beta",
428 commit_id="a" * 64,
429 snapshot_id="b" * 64,
430 title=tag,
431 body="",
432 changelog=[],
433 is_draft=draft,
434 )
435 write_release(root, r)
436
437 assert len(list_releases(root, repo_id)) == 1
438 assert len(list_releases(root, repo_id, include_drafts=True)) == 2
439
440 def test_filter_by_channel(self, tmp_path: pathlib.Path) -> None:
441 from muse.core.semver import (
442 ReleaseChannel,
443 SemVerTag,
444 )
445 from muse.core.releases import (
446 ReleaseRecord,
447 list_releases,
448 write_release,
449 )
450
451 _ch_map: _ChannelMap = {"stable": "stable", "beta": "beta", "alpha": "alpha", "nightly": "nightly"}
452 root, repo_id = _init_repo(tmp_path)
453 for k, (tag, channel_str) in enumerate([("v1.0.0", "stable"), ("v1.1.0-beta.1", "beta"), ("v1.2.0", "stable")]):
454 sv_parts = tag.lstrip("v").split("-")
455 major, minor, patch = (int(x) for x in sv_parts[0].split("."))
456 pre = sv_parts[1] if len(sv_parts) > 1 else ""
457 r = ReleaseRecord(
458 release_id=content_hash({"_seed": f"test-10-{k}"}),
459 repo_id=repo_id,
460 tag=tag,
461 semver=SemVerTag(major=major, minor=minor, patch=patch, pre=pre, build=""),
462 channel=_ch_map.get(channel_str, "stable"),
463 commit_id="a" * 64,
464 snapshot_id="b" * 64,
465 title=tag,
466 body="",
467 changelog=[],
468 )
469 write_release(root, r)
470
471 stable = list_releases(root, repo_id, channel="stable")
472 beta = list_releases(root, repo_id, channel="beta")
473 assert len(stable) == 2
474 assert len(beta) == 1
475
476 def test_delete_release(self, tmp_path: pathlib.Path) -> None:
477 from muse.core.semver import SemVerTag
478 from muse.core.releases import (
479 ReleaseRecord,
480 delete_release,
481 list_releases,
482 write_release,
483 )
484
485 root, repo_id = _init_repo(tmp_path)
486 release_id = content_hash({"_seed": "test-11"})
487 r = ReleaseRecord(
488 release_id=release_id,
489 repo_id=repo_id,
490 tag="v0.1.0",
491 semver=SemVerTag(major=0, minor=1, patch=0, pre="", build=""),
492 channel="stable",
493 commit_id="a" * 64,
494 snapshot_id="b" * 64,
495 title="",
496 body="",
497 changelog=[],
498 )
499 write_release(root, r)
500 assert len(list_releases(root, repo_id)) == 1
501 assert delete_release(root, repo_id, release_id) is True
502 assert len(list_releases(root, repo_id)) == 0
503
504 def test_delete_nonexistent_returns_false(self, tmp_path: pathlib.Path) -> None:
505 from muse.core.releases import delete_release
506
507 root, repo_id = _init_repo(tmp_path)
508 assert delete_release(root, repo_id, content_hash({"_seed": "test-12"})) is False
509
510 def test_get_release_for_tag(self, tmp_path: pathlib.Path) -> None:
511 from muse.core.semver import SemVerTag
512 from muse.core.releases import (
513 ReleaseRecord,
514 get_release_for_tag,
515 write_release,
516 )
517
518 root, repo_id = _init_repo(tmp_path)
519 r = ReleaseRecord(
520 release_id=content_hash({"_seed": "test-13"}),
521 repo_id=repo_id,
522 tag="v2.0.0",
523 semver=SemVerTag(major=2, minor=0, patch=0, pre="", build=""),
524 channel="stable",
525 commit_id="a" * 64,
526 snapshot_id="b" * 64,
527 title="",
528 body="",
529 changelog=[],
530 )
531 write_release(root, r)
532 assert get_release_for_tag(root, repo_id, "v2.0.0") is not None
533 assert get_release_for_tag(root, repo_id, "v9.9.9") is None
534
535
536 # ---------------------------------------------------------------------------
537 # build_changelog
538 # ---------------------------------------------------------------------------
539
540
541 class TestBuildChangelog:
542 def test_changelog_from_commits(self, tmp_path: pathlib.Path) -> None:
543 from muse.core.releases import build_changelog
544
545 root, repo_id = _init_repo(tmp_path)
546 c1 = _make_commit(root, repo_id, message="feat: first", sem_ver_bump="minor")
547 c2 = _make_commit(root, repo_id, message="fix: patch fix", sem_ver_bump="patch")
548 c3 = _make_commit(root, repo_id, message="feat!: breaking", sem_ver_bump="major",
549 breaking_changes=["API changed"])
550
551 changelog = build_changelog(root, None, c3)
552 assert len(changelog) == 3
553 assert changelog[0]["commit_id"] == c1 # oldest first
554 assert changelog[2]["sem_ver_bump"] == "major"
555 assert changelog[2]["breaking_changes"] == ["API changed"]
556
557 def test_changelog_bounded_by_from_commit(self, tmp_path: pathlib.Path) -> None:
558 from muse.core.releases import build_changelog
559
560 root, repo_id = _init_repo(tmp_path)
561 c1 = _make_commit(root, repo_id, message="chore: setup", sem_ver_bump="none")
562 c2 = _make_commit(root, repo_id, message="feat: add feature", sem_ver_bump="minor")
563 c3 = _make_commit(root, repo_id, message="fix: tiny fix", sem_ver_bump="patch")
564
565 # Only c2 and c3 are since c1
566 changelog = build_changelog(root, c1, c3)
567 assert len(changelog) == 2
568 assert changelog[0]["commit_id"] == c2
569
570 def test_empty_changelog_same_commit(self, tmp_path: pathlib.Path) -> None:
571 from muse.core.releases import build_changelog
572
573 root, repo_id = _init_repo(tmp_path)
574 c1 = _make_commit(root, repo_id)
575 changelog = build_changelog(root, c1, c1)
576 assert changelog == []
577
578 def test_changelog_includes_agent_provenance(self, tmp_path: pathlib.Path) -> None:
579 from muse.core.releases import build_changelog
580
581 root, repo_id = _init_repo(tmp_path)
582 _make_commit(root, repo_id, message="feat: add", sem_ver_bump="minor",
583 agent_id="my-agent", model_id="claude-4")
584 head_commit = _make_commit(root, repo_id, message="fix: patch", sem_ver_bump="patch")
585 changelog = build_changelog(root, None, head_commit)
586 assert changelog[0]["agent_id"] == "my-agent"
587 assert changelog[0]["model_id"] == "claude-4"
588
589
590 # ---------------------------------------------------------------------------
591 # WireTag in pack
592 # ---------------------------------------------------------------------------
593
594
595 class TestWireTagInPack:
596 def test_build_pack_includes_tags(self, tmp_path: pathlib.Path) -> None:
597 from muse.core.mpack import build_mpack
598 from muse.core.tags import (
599 TagRecord,
600 write_tag,
601 )
602
603 root, repo_id = _init_repo(tmp_path)
604 commit_id = _make_commit(root, repo_id)
605
606 tag = TagRecord(
607 tag_id=content_hash({"_seed": "test-14"}),
608 repo_id=repo_id,
609 commit_id=commit_id,
610 tag="v1.0.0",
611 )
612 write_tag(root, tag)
613
614 mpack = build_mpack(root, [commit_id], repo_id=repo_id)
615 assert "tags" in mpack
616 tags = mpack["tags"]
617 assert len(tags) == 1
618 assert tags[0]["tag"] == "v1.0.0"
619 assert tags[0]["commit_id"] == commit_id
620
621 def test_build_pack_no_tags_when_repo_id_omitted(self, tmp_path: pathlib.Path) -> None:
622 from muse.core.mpack import build_mpack
623 from muse.core.tags import (
624 TagRecord,
625 write_tag,
626 )
627
628 root, repo_id = _init_repo(tmp_path)
629 commit_id = _make_commit(root, repo_id)
630 write_tag(root, TagRecord(
631 tag_id=content_hash({"_seed": "test-15"}),
632 repo_id=repo_id,
633 commit_id=commit_id,
634 tag="v1.0.0",
635 ))
636
637 mpack = build_mpack(root, [commit_id]) # no repo_id
638 assert "tags" not in mpack
639
640 def test_apply_pack_writes_tags(self, tmp_path: pathlib.Path) -> None:
641 from muse.core.mpack import apply_mpack, build_mpack, WireTag
642 from muse.core.tags import (
643 TagRecord,
644 get_all_tags,
645 write_tag,
646 )
647
648 src = tmp_path / "src"
649 dst = tmp_path / "dst"
650 src.mkdir()
651 dst.mkdir()
652
653 root_src, repo_id = _init_repo(src)
654 root_dst, _ = _init_repo(dst)
655
656 commit_id = _make_commit(root_src, repo_id)
657 write_tag(root_src, TagRecord(
658 tag_id=content_hash({"_seed": "test-16"}),
659 repo_id=repo_id,
660 commit_id=commit_id,
661 tag="v1.0.0",
662 ))
663
664 mpack = build_mpack(root_src, [commit_id], repo_id=repo_id)
665 apply_mpack(root_dst, mpack)
666
667 tags = get_all_tags(root_dst, repo_id)
668 assert any(t.tag == "v1.0.0" for t in tags)
669
670
671 # ---------------------------------------------------------------------------
672 # E2E CLI: muse release
673 # ---------------------------------------------------------------------------
674
675
676 class TestReleaseCLI:
677 def test_release_add_basic(self, tmp_path: pathlib.Path) -> None:
678 root, repo_id = _init_repo(tmp_path)
679 _make_commit(root, repo_id, message="feat: initial", sem_ver_bump="minor")
680
681 result = runner.invoke(None, ["release", "add", "v0.1.0", "--title", "First"], env=_env(root))
682 assert result.exit_code == 0, result.output
683 assert "v0.1.0" in result.output
684
685 def test_release_add_invalid_semver(self, tmp_path: pathlib.Path) -> None:
686 root, repo_id = _init_repo(tmp_path)
687 _make_commit(root, repo_id)
688
689 result = runner.invoke(None, ["release", "add", "not-valid"], env=_env(root))
690 assert result.exit_code != 0
691
692 def test_release_add_duplicate_rejected(self, tmp_path: pathlib.Path) -> None:
693 root, repo_id = _init_repo(tmp_path)
694 _make_commit(root, repo_id)
695
696 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
697 result = runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
698 assert result.exit_code != 0
699 assert "already exists" in result.output.lower() or "already exists" in result.stderr.lower() if hasattr(result, 'stderr') else True
700
701 def test_release_add_draft(self, tmp_path: pathlib.Path) -> None:
702 root, repo_id = _init_repo(tmp_path)
703 _make_commit(root, repo_id)
704
705 result = runner.invoke(None, ["release", "add", "v1.0.0-beta.1", "--draft"], env=_env(root))
706 assert result.exit_code == 0
707 assert "draft" in result.output.lower()
708
709 def test_release_add_json_output(self, tmp_path: pathlib.Path) -> None:
710 root, repo_id = _init_repo(tmp_path)
711 _make_commit(root, repo_id)
712
713 result = runner.invoke(None, ["release", "add", "v1.0.0", "--json"], env=_env(root))
714 assert result.exit_code == 0, result.output
715 data = json.loads(result.output)
716 assert data["tag"] == "v1.0.0"
717 assert data["channel"] == "stable"
718 assert "release_id" in data
719
720 def test_release_list(self, tmp_path: pathlib.Path) -> None:
721 root, repo_id = _init_repo(tmp_path)
722 _make_commit(root, repo_id)
723 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
724
725 result = runner.invoke(None, ["release", "list"], env=_env(root))
726 assert result.exit_code == 0
727 assert "v1.0.0" in result.output
728
729 def test_release_list_empty(self, tmp_path: pathlib.Path) -> None:
730 root, repo_id = _init_repo(tmp_path)
731 result = runner.invoke(None, ["release", "list"], env=_env(root))
732 assert result.exit_code == 0
733 assert "No releases" in result.output
734
735 def test_release_list_json(self, tmp_path: pathlib.Path) -> None:
736 root, repo_id = _init_repo(tmp_path)
737 _make_commit(root, repo_id)
738 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
739
740 result = runner.invoke(None, ["release", "list", "--json"], env=_env(root))
741 assert result.exit_code == 0, result.output
742 data = json.loads(result.output)
743 assert isinstance(data, dict)
744 assert data["total"] == 1
745 assert data["releases"][0]["tag"] == "v1.0.0"
746
747 def test_release_read(self, tmp_path: pathlib.Path) -> None:
748 root, repo_id = _init_repo(tmp_path)
749 _make_commit(root, repo_id)
750 runner.invoke(None, ["release", "add", "v1.0.0", "--title", "Production"], env=_env(root))
751
752 result = runner.invoke(None, ["release", "read", "v1.0.0"], env=_env(root))
753 assert result.exit_code == 0
754 assert "v1.0.0" in result.output
755 assert "stable" in result.output
756
757 def test_release_read_not_found(self, tmp_path: pathlib.Path) -> None:
758 root, repo_id = _init_repo(tmp_path)
759 result = runner.invoke(None, ["release", "read", "v99.99.99"], env=_env(root))
760 assert result.exit_code != 0
761
762 def test_release_delete_draft(self, tmp_path: pathlib.Path) -> None:
763 root, repo_id = _init_repo(tmp_path)
764 _make_commit(root, repo_id)
765 runner.invoke(None, ["release", "add", "v1.0.0-alpha.1", "--draft"], env=_env(root))
766
767 result = runner.invoke(None, ["release", "delete", "v1.0.0-alpha.1", "--yes"], env=_env(root))
768 assert result.exit_code == 0
769 assert "deleted" in result.output.lower()
770
771 def test_release_delete_published_with_yes(self, tmp_path: pathlib.Path) -> None:
772 """Published releases can be deleted when --yes bypasses the tag-name prompt."""
773 root, repo_id = _init_repo(tmp_path)
774 _make_commit(root, repo_id)
775 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
776
777 result = runner.invoke(None, ["release", "delete", "v1.0.0", "--yes"], env=_env(root))
778 assert result.exit_code == 0
779 assert "deleted" in result.output.lower()
780
781 # Confirm it's gone from the list.
782 list_result = runner.invoke(None, ["release", "list"], env=_env(root))
783 assert "v1.0.0" not in list_result.output
784
785 def test_release_delete_published_non_tty_requires_yes(self, tmp_path: pathlib.Path) -> None:
786 """Non-TTY context without --yes must exit with USER_ERROR (no blocking input())."""
787 root, repo_id = _init_repo(tmp_path)
788 _make_commit(root, repo_id)
789 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
790
791 # CliRunner is non-TTY — without --yes, the guard fires before input() is called.
792 result = runner.invoke(None, ["release", "delete", "v1.0.0"], env=_env(root))
793 assert result.exit_code != 0
794 assert "TTY" in result.stderr or "--yes" in result.stderr
795
796 # Release must still exist.
797 list_result = runner.invoke(None, ["release", "list"], env=_env(root))
798 assert "v1.0.0" in list_result.output
799
800 def test_release_delete_published_with_yes_flag(self, tmp_path: pathlib.Path) -> None:
801 """Passing --yes skips the interactive confirmation in non-TTY contexts."""
802 root, repo_id = _init_repo(tmp_path)
803 _make_commit(root, repo_id)
804 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
805
806 result = runner.invoke(None, ["release", "delete", "v1.0.0", "--yes"], env=_env(root))
807 assert result.exit_code == 0
808 assert "deleted" in result.output.lower()
809
810 def test_release_channel_filter(self, tmp_path: pathlib.Path) -> None:
811 root, repo_id = _init_repo(tmp_path)
812 _make_commit(root, repo_id)
813 runner.invoke(None, ["release", "add", "v1.0.0", "--channel", "stable"], env=_env(root))
814 _make_commit(root, repo_id)
815 runner.invoke(None, ["release", "add", "v1.1.0-beta.1", "--channel", "beta"], env=_env(root))
816
817 result = runner.invoke(None, ["release", "list", "--channel", "stable"], env=_env(root))
818 assert result.exit_code == 0
819 assert "v1.0.0" in result.output
820 assert "v1.1.0" not in result.output
821
822 def test_release_changelog_in_json_output(self, tmp_path: pathlib.Path) -> None:
823 root, repo_id = _init_repo(tmp_path)
824 _make_commit(root, repo_id, message="feat: add API", sem_ver_bump="minor")
825 _make_commit(root, repo_id, message="fix: handle edge case", sem_ver_bump="patch")
826
827 result = runner.invoke(None, ["release", "add", "v1.0.0", "--json"], env=_env(root))
828 assert result.exit_code == 0, result.output
829 data = json.loads(result.output)
830 assert len(data["changelog"]) == 2
831 assert data["changelog"][0]["sem_ver_bump"] == "minor"
832
833
834 # ---------------------------------------------------------------------------
835 # muse release (no subcommand) — defaults to list
836 # ---------------------------------------------------------------------------
837
838
839 class TestReleaseDefaultToList:
840 """Bare ``muse release`` with no subcommand should behave like ``muse release list``."""
841
842 def test_bare_release_exits_zero(self, tmp_path: pathlib.Path) -> None:
843 root, repo_id = _init_repo(tmp_path)
844 _make_commit(root, repo_id)
845 result = runner.invoke(None, ["release"], env=_env(root))
846 assert result.exit_code == 0
847
848 def test_bare_release_empty_repo_prints_no_releases(self, tmp_path: pathlib.Path) -> None:
849 root, _ = _init_repo(tmp_path)
850 result = runner.invoke(None, ["release"], env=_env(root))
851 assert result.exit_code == 0
852 assert "No releases" in result.output
853
854 def test_bare_release_lists_existing_releases(self, tmp_path: pathlib.Path) -> None:
855 root, repo_id = _init_repo(tmp_path)
856 _make_commit(root, repo_id)
857 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
858 result = runner.invoke(None, ["release"], env=_env(root))
859 assert result.exit_code == 0
860 assert "v1.0.0" in result.output
861
862 def test_bare_release_json_flag(self, tmp_path: pathlib.Path) -> None:
863 """``muse release --json`` must emit a JSON dict with total and releases keys."""
864 root, repo_id = _init_repo(tmp_path)
865 _make_commit(root, repo_id)
866 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
867 result = runner.invoke(None, ["release", "--json"], env=_env(root))
868 assert result.exit_code == 0, result.output
869 data = json.loads(result.output)
870 assert isinstance(data, dict)
871 assert data["total"] == 1
872 assert data["releases"][0]["tag"] == "v1.0.0"
873
874 def test_bare_release_channel_filter(self, tmp_path: pathlib.Path) -> None:
875 root, repo_id = _init_repo(tmp_path)
876 _make_commit(root, repo_id)
877 runner.invoke(None, ["release", "add", "v1.0.0", "--channel", "stable"], env=_env(root))
878 _make_commit(root, repo_id)
879 runner.invoke(None, ["release", "add", "v1.1.0-beta.1", "--channel", "beta"], env=_env(root))
880 result = runner.invoke(None, ["release", "--channel", "stable"], env=_env(root))
881 assert result.exit_code == 0
882 assert "v1.0.0" in result.output
883 assert "v1.1.0" not in result.output
884
885
886 # ---------------------------------------------------------------------------
887 # muse release suggest
888 # ---------------------------------------------------------------------------
889
890
891 class TestReleaseSuggest:
892 """``muse release suggest`` derives the next version from the commit graph."""
893
894 def test_no_commits_no_releases(self, tmp_path: pathlib.Path) -> None:
895 """Repo with no commits should exit with an error (no HEAD)."""
896 root, _ = _init_repo(tmp_path)
897 result = runner.invoke(None, ["release", "suggest"], env=_env(root))
898 assert result.exit_code != 0
899
900 def test_no_releases_all_none_bumps(self, tmp_path: pathlib.Path) -> None:
901 """All commits carry bump=none → no suggestion, exit 0."""
902 root, repo_id = _init_repo(tmp_path)
903 _make_commit(root, repo_id, message="chore: housekeeping", sem_ver_bump="none")
904 result = runner.invoke(None, ["release", "suggest"], env=_env(root))
905 assert result.exit_code == 0
906 assert "no version bump" in result.output.lower() or "none" in result.output.lower()
907
908 def test_no_releases_patch_bump(self, tmp_path: pathlib.Path) -> None:
909 """patch bump with no prior release → v0.0.1."""
910 root, repo_id = _init_repo(tmp_path)
911 _make_commit(root, repo_id, message="fix: typo", sem_ver_bump="patch")
912 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
913 assert result.exit_code == 0
914 data = json.loads(result.output)
915 assert data["suggested_tag"] == "v0.0.1"
916 assert data["inferred_bump"] == "patch"
917 assert data["pre_1_0_adjusted"] is True
918
919 def test_no_releases_minor_bump(self, tmp_path: pathlib.Path) -> None:
920 """minor bump with no prior release in pre-1.0 → v0.0.1."""
921 root, repo_id = _init_repo(tmp_path)
922 _make_commit(root, repo_id, message="feat: new helper", sem_ver_bump="minor")
923 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
924 assert result.exit_code == 0
925 data = json.loads(result.output)
926 assert data["suggested_tag"] == "v0.0.1"
927 assert data["inferred_bump"] == "minor"
928 assert data["pre_1_0_adjusted"] is True
929
930 def test_no_releases_major_bump(self, tmp_path: pathlib.Path) -> None:
931 """major bump in pre-1.0 repo with no prior release → bumps minor → v0.1.0."""
932 root, repo_id = _init_repo(tmp_path)
933 _make_commit(root, repo_id, message="feat!: breaking change",
934 sem_ver_bump="major", breaking_changes=["api.py::MyClass"])
935 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
936 assert result.exit_code == 0
937 data = json.loads(result.output)
938 assert data["suggested_tag"] == "v0.1.0"
939 assert data["inferred_bump"] == "major"
940 assert data["pre_1_0_adjusted"] is True
941
942 def test_takes_highest_bump_across_commits(self, tmp_path: pathlib.Path) -> None:
943 """max(patch, minor, major) = major drives the suggestion."""
944 root, repo_id = _init_repo(tmp_path)
945 _make_commit(root, repo_id, message="fix: small", sem_ver_bump="patch")
946 _make_commit(root, repo_id, message="feat: new", sem_ver_bump="minor")
947 _make_commit(root, repo_id, message="feat!: break", sem_ver_bump="major",
948 breaking_changes=["core.py::Fn"])
949 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
950 assert result.exit_code == 0
951 data = json.loads(result.output)
952 assert data["inferred_bump"] == "major"
953
954 def test_with_prior_release_patch(self, tmp_path: pathlib.Path) -> None:
955 """patch since v1.2.3 → v1.2.4 (post-1.0, no adjustment)."""
956 root, repo_id = _init_repo(tmp_path)
957 _make_commit(root, repo_id, message="initial", sem_ver_bump="none")
958 runner.invoke(None, ["release", "add", "v1.2.3"], env=_env(root))
959 _make_commit(root, repo_id, message="fix: edge case", sem_ver_bump="patch")
960 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
961 assert result.exit_code == 0
962 data = json.loads(result.output)
963 assert data["suggested_tag"] == "v1.2.4"
964 assert data["pre_1_0_adjusted"] is False
965 assert data["base_tag"] == "v1.2.3"
966
967 def test_with_prior_release_minor(self, tmp_path: pathlib.Path) -> None:
968 """minor since v1.2.3 → v1.3.0."""
969 root, repo_id = _init_repo(tmp_path)
970 _make_commit(root, repo_id, message="initial", sem_ver_bump="none")
971 runner.invoke(None, ["release", "add", "v1.2.3"], env=_env(root))
972 _make_commit(root, repo_id, message="feat: new thing", sem_ver_bump="minor")
973 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
974 assert result.exit_code == 0
975 data = json.loads(result.output)
976 assert data["suggested_tag"] == "v1.3.0"
977
978 def test_with_prior_release_major(self, tmp_path: pathlib.Path) -> None:
979 """major since v1.2.3 → v2.0.0."""
980 root, repo_id = _init_repo(tmp_path)
981 _make_commit(root, repo_id, message="initial", sem_ver_bump="none")
982 runner.invoke(None, ["release", "add", "v1.2.3"], env=_env(root))
983 _make_commit(root, repo_id, message="feat!: overhaul", sem_ver_bump="major",
984 breaking_changes=["core.py::API"])
985 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
986 assert result.exit_code == 0
987 data = json.loads(result.output)
988 assert data["suggested_tag"] == "v2.0.0"
989 assert data["pre_1_0_adjusted"] is False
990
991 def test_pre_1_0_major_bumps_minor(self, tmp_path: pathlib.Path) -> None:
992 """In 0.x.y, major structural break bumps minor not major (0.x+1.0)."""
993 root, repo_id = _init_repo(tmp_path)
994 _make_commit(root, repo_id, message="initial", sem_ver_bump="none")
995 runner.invoke(None, ["release", "add", "v0.2.5"], env=_env(root))
996 _make_commit(root, repo_id, message="feat!: break API", sem_ver_bump="major",
997 breaking_changes=["api.py::Client"])
998 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
999 assert result.exit_code == 0
1000 data = json.loads(result.output)
1001 assert data["suggested_tag"] == "v0.3.0"
1002 assert data["pre_1_0_adjusted"] is True
1003
1004 def test_drivers_list_contains_bumping_commits(self, tmp_path: pathlib.Path) -> None:
1005 """drivers list includes only commits with bump != none."""
1006 root, repo_id = _init_repo(tmp_path)
1007 _make_commit(root, repo_id, message="chore: no-op", sem_ver_bump="none")
1008 _make_commit(root, repo_id, message="fix: something", sem_ver_bump="patch")
1009 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
1010 assert result.exit_code == 0
1011 data = json.loads(result.output)
1012 assert data["unreleased_count"] == 2
1013 assert len(data["drivers"]) == 1
1014 assert data["drivers"][0]["sem_ver_bump"] == "patch"
1015
1016 def test_unreleased_count_excludes_released_commits(self, tmp_path: pathlib.Path) -> None:
1017 """Commits before the base release are not counted."""
1018 root, repo_id = _init_repo(tmp_path)
1019 _make_commit(root, repo_id, message="old feat", sem_ver_bump="major")
1020 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
1021 _make_commit(root, repo_id, message="new fix", sem_ver_bump="patch")
1022 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
1023 assert result.exit_code == 0
1024 data = json.loads(result.output)
1025 assert data["unreleased_count"] == 1
1026
1027 def test_base_flag_overrides_latest_release(self, tmp_path: pathlib.Path) -> None:
1028 """--base selects a specific release as the starting point."""
1029 root, repo_id = _init_repo(tmp_path)
1030 _make_commit(root, repo_id, message="v1 base", sem_ver_bump="none")
1031 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
1032 _make_commit(root, repo_id, message="patch after v1", sem_ver_bump="patch")
1033 runner.invoke(None, ["release", "add", "v1.0.1"], env=_env(root))
1034 _make_commit(root, repo_id, message="feature after v1.0.1", sem_ver_bump="minor")
1035
1036 result = runner.invoke(None, ["release", "suggest", "--base", "v1.0.0", "--json"],
1037 env=_env(root))
1038 assert result.exit_code == 0
1039 data = json.loads(result.output)
1040 # Since v1.0.0: one patch + one minor → minor bump → v1.1.0
1041 assert data["suggested_tag"] == "v1.1.0"
1042 assert data["base_tag"] == "v1.0.0"
1043
1044 def test_base_flag_not_found(self, tmp_path: pathlib.Path) -> None:
1045 """--base with unknown tag exits with NOT_FOUND."""
1046 root, repo_id = _init_repo(tmp_path)
1047 _make_commit(root, repo_id)
1048 result = runner.invoke(None, ["release", "suggest", "--base", "v99.0.0"], env=_env(root))
1049 assert result.exit_code != 0
1050
1051 def test_json_schema_keys_present(self, tmp_path: pathlib.Path) -> None:
1052 """JSON output always contains all expected keys."""
1053 root, repo_id = _init_repo(tmp_path)
1054 _make_commit(root, repo_id, message="feat: something", sem_ver_bump="minor")
1055 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
1056 assert result.exit_code == 0
1057 data = json.loads(result.output)
1058 for key in ("suggested_tag", "inferred_bump", "pre_1_0_adjusted",
1059 "base_tag", "base_commit_id", "head_commit_id",
1060 "unreleased_count", "drivers"):
1061 assert key in data, f"missing key: {key}"
1062
1063 def test_text_output_contains_suggested_tag(self, tmp_path: pathlib.Path) -> None:
1064 """Human-readable output includes the suggested tag."""
1065 root, repo_id = _init_repo(tmp_path)
1066 _make_commit(root, repo_id, message="feat!: break", sem_ver_bump="major",
1067 breaking_changes=["mod.py::Fn"])
1068 result = runner.invoke(None, ["release", "suggest"], env=_env(root))
1069 assert result.exit_code == 0
1070 assert "v0.1.0" in result.output
File History 10 commits
sha256:c287f599c5429903a139eadf3c5db5d930520e57cb0c3c575d9570e953c3b2d6 chore: bump version to 0.2.0.dev2 — nightly.2 Sonnet 4.6 patch 12 days ago
sha256:8de4334a98c945aace420969d389ad678aa926d4ab4e886b2ac4c4241cb3bf2b revert: keep pyproject.toml in canonical PEP 440 form Sonnet 4.6 patch 15 days ago
sha256:50bb615c573aa4b928fca75b60f82ba2a659910066507cec6a95c412ae22ccb9 docs: add issue docs for push have-negotiation bug (#55) an… Sonnet 4.6 18 days ago
sha256:d90d175cded68aae1d4ffcf4858917854195d0cd8ce1fe73cee4dbc02541cb74 chore: trigger push to surface null-OID paths Sonnet 4.6 24 days ago
sha256:e452ad9a6ace6ccc6d875a35e06caf9da5576a970c1c36133b69a891ce5fefa8 chore: prebuild timing test Sonnet 4.6 34 days ago
sha256:0008ab6695e3e064b3e236b24fd19e538fef6a588eb0d211622f4466d919c0b1 merge: pull staging/dev — advance to 0.2.0rc12 Sonnet 4.6 patch 36 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub … Sonnet 4.6 47 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 50 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 56 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 56 days ago