gabriel / muse public
test_release.py python
1,061 lines 41.7 KB
Raw
sha256:84df9126d09aeec0b8f1b908f0b06c10913feec28f3514b382efb1ba6d619385 refactor: rename StructuredMergePlugin to AddressedMergePlu… Sonnet 4.6 minor ⚠ breaking 23 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_defaults_to_stable(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 # rc is not a recognised channel prefix — defaults to stable
258 assert semver_channel(sv) == "stable"
259
260
261 # ---------------------------------------------------------------------------
262 # ReleaseRecord serialisation
263 # ---------------------------------------------------------------------------
264
265
266 class TestReleaseRecordSerialisation:
267 def _make_release(self) -> ReleaseRecord:
268 from muse.core.semver import SemVerTag
269
270 return ReleaseRecord(
271 release_id=content_hash({"_seed": "test-2"}),
272 repo_id=content_hash({"_seed": "test-1"}),
273 tag="v1.0.0",
274 semver=SemVerTag(major=1, minor=0, patch=0, pre="", build=""),
275 channel="stable",
276 commit_id="a" * 64,
277 snapshot_id="b" * 64,
278 title="First release",
279 body="Initial release notes.",
280 changelog=[],
281 )
282
283 def test_round_trip(self) -> None:
284 from muse.core.releases import ReleaseRecord
285
286 release = self._make_release()
287 d = release.to_dict()
288 restored = ReleaseRecord.from_dict(d)
289 assert restored.release_id == release.release_id
290 assert restored.tag == release.tag
291 assert restored.semver == release.semver
292 assert restored.channel == release.channel
293 assert restored.title == release.title
294 assert restored.is_draft is False
295
296 def test_draft_round_trip(self) -> None:
297 from muse.core.releases import ReleaseRecord
298
299 release = self._make_release()
300 release.is_draft = True
301 d = release.to_dict()
302 restored = ReleaseRecord.from_dict(d)
303 assert restored.is_draft is True
304
305 def test_invalid_channel_defaults_to_stable(self) -> None:
306 from muse.core.semver import SemVerTag
307 from muse.core.releases import ReleaseRecord
308
309 release = ReleaseRecord(
310 release_id=content_hash({"_seed": "test-4"}),
311 repo_id=content_hash({"_seed": "test-1"}),
312 tag="v1.0.0",
313 semver=SemVerTag(major=1, minor=0, patch=0, pre="", build=""),
314 channel="stable",
315 commit_id="a" * 64,
316 snapshot_id="b" * 64,
317 title="",
318 body="",
319 changelog=[],
320 )
321 d = release.to_dict()
322 d["channel"] = "unknown-channel"
323 restored = ReleaseRecord.from_dict(d)
324 assert restored.channel == "stable"
325
326
327 # ---------------------------------------------------------------------------
328 # Release store operations
329 # ---------------------------------------------------------------------------
330
331
332 class TestReleaseStore:
333 def test_write_and_read(self, tmp_path: pathlib.Path) -> None:
334 from muse.core.semver import SemVerTag
335 from muse.core.releases import (
336 ReleaseRecord,
337 read_release,
338 write_release,
339 )
340
341 root, repo_id = _init_repo(tmp_path)
342 release = ReleaseRecord(
343 release_id=content_hash({"_seed": "test-6"}),
344 repo_id=repo_id,
345 tag="v1.0.0",
346 semver=SemVerTag(major=1, minor=0, patch=0, pre="", build=""),
347 channel="stable",
348 commit_id="a" * 64,
349 snapshot_id="b" * 64,
350 title="v1.0.0",
351 body="",
352 changelog=[],
353 )
354 write_release(root, release)
355 loaded = read_release(root, repo_id, release.release_id)
356 assert loaded is not None
357 assert loaded.tag == "v1.0.0"
358
359 def test_read_missing_returns_none(self, tmp_path: pathlib.Path) -> None:
360 from muse.core.releases import read_release
361
362 root, repo_id = _init_repo(tmp_path)
363 assert read_release(root, repo_id, content_hash({"_seed": "test-7"})) is None
364
365 def test_list_releases_newest_first(self, tmp_path: pathlib.Path) -> None:
366 from muse.core.semver import SemVerTag
367 from muse.core.releases import (
368 ReleaseRecord,
369 list_releases,
370 write_release,
371 )
372 import time
373
374 root, repo_id = _init_repo(tmp_path)
375
376 for i, tag in enumerate(["v1.0.0", "v1.1.0", "v1.2.0"]):
377 sv = SemVerTag(major=1, minor=i, patch=0, pre="", build="")
378 r = ReleaseRecord(
379 release_id=content_hash({"_seed": f"test-8-{i}"}),
380 repo_id=repo_id,
381 tag=tag,
382 semver=sv,
383 channel="stable",
384 commit_id="a" * 64,
385 snapshot_id="b" * 64,
386 title=tag,
387 body="",
388 changelog=[],
389 created_at=datetime.datetime(2025, 1, i + 1, tzinfo=datetime.timezone.utc),
390 )
391 write_release(root, r)
392 time.sleep(0.01) # ensure distinct timestamps
393
394 releases = list_releases(root, repo_id)
395 assert len(releases) == 3
396 # newest first
397 assert releases[0].tag == "v1.2.0"
398 assert releases[-1].tag == "v1.0.0"
399
400 def test_list_excludes_drafts_by_default(self, tmp_path: pathlib.Path) -> None:
401 from muse.core.semver import SemVerTag
402 from muse.core.releases import (
403 ReleaseRecord,
404 list_releases,
405 write_release,
406 )
407
408 root, repo_id = _init_repo(tmp_path)
409 for j, (tag, draft) in enumerate([("v1.0.0", False), ("v1.1.0-beta.1", True)]):
410 sv_parts = tag.lstrip("v").split("-")
411 major, minor, patch = (int(x) for x in sv_parts[0].split("."))
412 pre = sv_parts[1] if len(sv_parts) > 1 else ""
413 r = ReleaseRecord(
414 release_id=content_hash({"_seed": f"test-9-{j}"}),
415 repo_id=repo_id,
416 tag=tag,
417 semver=SemVerTag(major=major, minor=minor, patch=patch, pre=pre, build=""),
418 channel="stable" if not pre else "beta",
419 commit_id="a" * 64,
420 snapshot_id="b" * 64,
421 title=tag,
422 body="",
423 changelog=[],
424 is_draft=draft,
425 )
426 write_release(root, r)
427
428 assert len(list_releases(root, repo_id)) == 1
429 assert len(list_releases(root, repo_id, include_drafts=True)) == 2
430
431 def test_filter_by_channel(self, tmp_path: pathlib.Path) -> None:
432 from muse.core.semver import (
433 ReleaseChannel,
434 SemVerTag,
435 )
436 from muse.core.releases import (
437 ReleaseRecord,
438 list_releases,
439 write_release,
440 )
441
442 _ch_map: _ChannelMap = {"stable": "stable", "beta": "beta", "alpha": "alpha", "nightly": "nightly"}
443 root, repo_id = _init_repo(tmp_path)
444 for k, (tag, channel_str) in enumerate([("v1.0.0", "stable"), ("v1.1.0-beta.1", "beta"), ("v1.2.0", "stable")]):
445 sv_parts = tag.lstrip("v").split("-")
446 major, minor, patch = (int(x) for x in sv_parts[0].split("."))
447 pre = sv_parts[1] if len(sv_parts) > 1 else ""
448 r = ReleaseRecord(
449 release_id=content_hash({"_seed": f"test-10-{k}"}),
450 repo_id=repo_id,
451 tag=tag,
452 semver=SemVerTag(major=major, minor=minor, patch=patch, pre=pre, build=""),
453 channel=_ch_map.get(channel_str, "stable"),
454 commit_id="a" * 64,
455 snapshot_id="b" * 64,
456 title=tag,
457 body="",
458 changelog=[],
459 )
460 write_release(root, r)
461
462 stable = list_releases(root, repo_id, channel="stable")
463 beta = list_releases(root, repo_id, channel="beta")
464 assert len(stable) == 2
465 assert len(beta) == 1
466
467 def test_delete_release(self, tmp_path: pathlib.Path) -> None:
468 from muse.core.semver import SemVerTag
469 from muse.core.releases import (
470 ReleaseRecord,
471 delete_release,
472 list_releases,
473 write_release,
474 )
475
476 root, repo_id = _init_repo(tmp_path)
477 release_id = content_hash({"_seed": "test-11"})
478 r = ReleaseRecord(
479 release_id=release_id,
480 repo_id=repo_id,
481 tag="v0.1.0",
482 semver=SemVerTag(major=0, minor=1, patch=0, pre="", build=""),
483 channel="stable",
484 commit_id="a" * 64,
485 snapshot_id="b" * 64,
486 title="",
487 body="",
488 changelog=[],
489 )
490 write_release(root, r)
491 assert len(list_releases(root, repo_id)) == 1
492 assert delete_release(root, repo_id, release_id) is True
493 assert len(list_releases(root, repo_id)) == 0
494
495 def test_delete_nonexistent_returns_false(self, tmp_path: pathlib.Path) -> None:
496 from muse.core.releases import delete_release
497
498 root, repo_id = _init_repo(tmp_path)
499 assert delete_release(root, repo_id, content_hash({"_seed": "test-12"})) is False
500
501 def test_get_release_for_tag(self, tmp_path: pathlib.Path) -> None:
502 from muse.core.semver import SemVerTag
503 from muse.core.releases import (
504 ReleaseRecord,
505 get_release_for_tag,
506 write_release,
507 )
508
509 root, repo_id = _init_repo(tmp_path)
510 r = ReleaseRecord(
511 release_id=content_hash({"_seed": "test-13"}),
512 repo_id=repo_id,
513 tag="v2.0.0",
514 semver=SemVerTag(major=2, minor=0, patch=0, pre="", build=""),
515 channel="stable",
516 commit_id="a" * 64,
517 snapshot_id="b" * 64,
518 title="",
519 body="",
520 changelog=[],
521 )
522 write_release(root, r)
523 assert get_release_for_tag(root, repo_id, "v2.0.0") is not None
524 assert get_release_for_tag(root, repo_id, "v9.9.9") is None
525
526
527 # ---------------------------------------------------------------------------
528 # build_changelog
529 # ---------------------------------------------------------------------------
530
531
532 class TestBuildChangelog:
533 def test_changelog_from_commits(self, tmp_path: pathlib.Path) -> None:
534 from muse.core.releases import build_changelog
535
536 root, repo_id = _init_repo(tmp_path)
537 c1 = _make_commit(root, repo_id, message="feat: first", sem_ver_bump="minor")
538 c2 = _make_commit(root, repo_id, message="fix: patch fix", sem_ver_bump="patch")
539 c3 = _make_commit(root, repo_id, message="feat!: breaking", sem_ver_bump="major",
540 breaking_changes=["API changed"])
541
542 changelog = build_changelog(root, None, c3)
543 assert len(changelog) == 3
544 assert changelog[0]["commit_id"] == c1 # oldest first
545 assert changelog[2]["sem_ver_bump"] == "major"
546 assert changelog[2]["breaking_changes"] == ["API changed"]
547
548 def test_changelog_bounded_by_from_commit(self, tmp_path: pathlib.Path) -> None:
549 from muse.core.releases import build_changelog
550
551 root, repo_id = _init_repo(tmp_path)
552 c1 = _make_commit(root, repo_id, message="chore: setup", sem_ver_bump="none")
553 c2 = _make_commit(root, repo_id, message="feat: add feature", sem_ver_bump="minor")
554 c3 = _make_commit(root, repo_id, message="fix: tiny fix", sem_ver_bump="patch")
555
556 # Only c2 and c3 are since c1
557 changelog = build_changelog(root, c1, c3)
558 assert len(changelog) == 2
559 assert changelog[0]["commit_id"] == c2
560
561 def test_empty_changelog_same_commit(self, tmp_path: pathlib.Path) -> None:
562 from muse.core.releases import build_changelog
563
564 root, repo_id = _init_repo(tmp_path)
565 c1 = _make_commit(root, repo_id)
566 changelog = build_changelog(root, c1, c1)
567 assert changelog == []
568
569 def test_changelog_includes_agent_provenance(self, tmp_path: pathlib.Path) -> None:
570 from muse.core.releases import build_changelog
571
572 root, repo_id = _init_repo(tmp_path)
573 _make_commit(root, repo_id, message="feat: add", sem_ver_bump="minor",
574 agent_id="my-agent", model_id="claude-4")
575 head_commit = _make_commit(root, repo_id, message="fix: patch", sem_ver_bump="patch")
576 changelog = build_changelog(root, None, head_commit)
577 assert changelog[0]["agent_id"] == "my-agent"
578 assert changelog[0]["model_id"] == "claude-4"
579
580
581 # ---------------------------------------------------------------------------
582 # WireTag in pack
583 # ---------------------------------------------------------------------------
584
585
586 class TestWireTagInPack:
587 def test_build_pack_includes_tags(self, tmp_path: pathlib.Path) -> None:
588 from muse.core.mpack import build_mpack
589 from muse.core.tags import (
590 TagRecord,
591 write_tag,
592 )
593
594 root, repo_id = _init_repo(tmp_path)
595 commit_id = _make_commit(root, repo_id)
596
597 tag = TagRecord(
598 tag_id=content_hash({"_seed": "test-14"}),
599 repo_id=repo_id,
600 commit_id=commit_id,
601 tag="v1.0.0",
602 )
603 write_tag(root, tag)
604
605 mpack = build_mpack(root, [commit_id], repo_id=repo_id)
606 assert "tags" in mpack
607 tags = mpack["tags"]
608 assert len(tags) == 1
609 assert tags[0]["tag"] == "v1.0.0"
610 assert tags[0]["commit_id"] == commit_id
611
612 def test_build_pack_no_tags_when_repo_id_omitted(self, tmp_path: pathlib.Path) -> None:
613 from muse.core.mpack import build_mpack
614 from muse.core.tags import (
615 TagRecord,
616 write_tag,
617 )
618
619 root, repo_id = _init_repo(tmp_path)
620 commit_id = _make_commit(root, repo_id)
621 write_tag(root, TagRecord(
622 tag_id=content_hash({"_seed": "test-15"}),
623 repo_id=repo_id,
624 commit_id=commit_id,
625 tag="v1.0.0",
626 ))
627
628 mpack = build_mpack(root, [commit_id]) # no repo_id
629 assert "tags" not in mpack
630
631 def test_apply_pack_writes_tags(self, tmp_path: pathlib.Path) -> None:
632 from muse.core.mpack import apply_mpack, build_mpack, WireTag
633 from muse.core.tags import (
634 TagRecord,
635 get_all_tags,
636 write_tag,
637 )
638
639 src = tmp_path / "src"
640 dst = tmp_path / "dst"
641 src.mkdir()
642 dst.mkdir()
643
644 root_src, repo_id = _init_repo(src)
645 root_dst, _ = _init_repo(dst)
646
647 commit_id = _make_commit(root_src, repo_id)
648 write_tag(root_src, TagRecord(
649 tag_id=content_hash({"_seed": "test-16"}),
650 repo_id=repo_id,
651 commit_id=commit_id,
652 tag="v1.0.0",
653 ))
654
655 mpack = build_mpack(root_src, [commit_id], repo_id=repo_id)
656 apply_mpack(root_dst, mpack)
657
658 tags = get_all_tags(root_dst, repo_id)
659 assert any(t.tag == "v1.0.0" for t in tags)
660
661
662 # ---------------------------------------------------------------------------
663 # E2E CLI: muse release
664 # ---------------------------------------------------------------------------
665
666
667 class TestReleaseCLI:
668 def test_release_add_basic(self, tmp_path: pathlib.Path) -> None:
669 root, repo_id = _init_repo(tmp_path)
670 _make_commit(root, repo_id, message="feat: initial", sem_ver_bump="minor")
671
672 result = runner.invoke(None, ["release", "add", "v0.1.0", "--title", "First"], env=_env(root))
673 assert result.exit_code == 0, result.output
674 assert "v0.1.0" in result.output
675
676 def test_release_add_invalid_semver(self, tmp_path: pathlib.Path) -> None:
677 root, repo_id = _init_repo(tmp_path)
678 _make_commit(root, repo_id)
679
680 result = runner.invoke(None, ["release", "add", "not-valid"], env=_env(root))
681 assert result.exit_code != 0
682
683 def test_release_add_duplicate_rejected(self, tmp_path: pathlib.Path) -> None:
684 root, repo_id = _init_repo(tmp_path)
685 _make_commit(root, repo_id)
686
687 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
688 result = runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
689 assert result.exit_code != 0
690 assert "already exists" in result.output.lower() or "already exists" in result.stderr.lower() if hasattr(result, 'stderr') else True
691
692 def test_release_add_draft(self, tmp_path: pathlib.Path) -> None:
693 root, repo_id = _init_repo(tmp_path)
694 _make_commit(root, repo_id)
695
696 result = runner.invoke(None, ["release", "add", "v1.0.0-beta.1", "--draft"], env=_env(root))
697 assert result.exit_code == 0
698 assert "draft" in result.output.lower()
699
700 def test_release_add_json_output(self, tmp_path: pathlib.Path) -> None:
701 root, repo_id = _init_repo(tmp_path)
702 _make_commit(root, repo_id)
703
704 result = runner.invoke(None, ["release", "add", "v1.0.0", "--json"], env=_env(root))
705 assert result.exit_code == 0, result.output
706 data = json.loads(result.output)
707 assert data["tag"] == "v1.0.0"
708 assert data["channel"] == "stable"
709 assert "release_id" in data
710
711 def test_release_list(self, tmp_path: pathlib.Path) -> None:
712 root, repo_id = _init_repo(tmp_path)
713 _make_commit(root, repo_id)
714 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
715
716 result = runner.invoke(None, ["release", "list"], env=_env(root))
717 assert result.exit_code == 0
718 assert "v1.0.0" in result.output
719
720 def test_release_list_empty(self, tmp_path: pathlib.Path) -> None:
721 root, repo_id = _init_repo(tmp_path)
722 result = runner.invoke(None, ["release", "list"], env=_env(root))
723 assert result.exit_code == 0
724 assert "No releases" in result.output
725
726 def test_release_list_json(self, tmp_path: pathlib.Path) -> None:
727 root, repo_id = _init_repo(tmp_path)
728 _make_commit(root, repo_id)
729 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
730
731 result = runner.invoke(None, ["release", "list", "--json"], env=_env(root))
732 assert result.exit_code == 0, result.output
733 data = json.loads(result.output)
734 assert isinstance(data, dict)
735 assert data["total"] == 1
736 assert data["releases"][0]["tag"] == "v1.0.0"
737
738 def test_release_read(self, tmp_path: pathlib.Path) -> None:
739 root, repo_id = _init_repo(tmp_path)
740 _make_commit(root, repo_id)
741 runner.invoke(None, ["release", "add", "v1.0.0", "--title", "Production"], env=_env(root))
742
743 result = runner.invoke(None, ["release", "read", "v1.0.0"], env=_env(root))
744 assert result.exit_code == 0
745 assert "v1.0.0" in result.output
746 assert "stable" in result.output
747
748 def test_release_read_not_found(self, tmp_path: pathlib.Path) -> None:
749 root, repo_id = _init_repo(tmp_path)
750 result = runner.invoke(None, ["release", "read", "v99.99.99"], env=_env(root))
751 assert result.exit_code != 0
752
753 def test_release_delete_draft(self, tmp_path: pathlib.Path) -> None:
754 root, repo_id = _init_repo(tmp_path)
755 _make_commit(root, repo_id)
756 runner.invoke(None, ["release", "add", "v1.0.0-alpha.1", "--draft"], env=_env(root))
757
758 result = runner.invoke(None, ["release", "delete", "v1.0.0-alpha.1", "--yes"], env=_env(root))
759 assert result.exit_code == 0
760 assert "deleted" in result.output.lower()
761
762 def test_release_delete_published_with_yes(self, tmp_path: pathlib.Path) -> None:
763 """Published releases can be deleted when --yes bypasses the tag-name prompt."""
764 root, repo_id = _init_repo(tmp_path)
765 _make_commit(root, repo_id)
766 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
767
768 result = runner.invoke(None, ["release", "delete", "v1.0.0", "--yes"], env=_env(root))
769 assert result.exit_code == 0
770 assert "deleted" in result.output.lower()
771
772 # Confirm it's gone from the list.
773 list_result = runner.invoke(None, ["release", "list"], env=_env(root))
774 assert "v1.0.0" not in list_result.output
775
776 def test_release_delete_published_non_tty_requires_yes(self, tmp_path: pathlib.Path) -> None:
777 """Non-TTY context without --yes must exit with USER_ERROR (no blocking input())."""
778 root, repo_id = _init_repo(tmp_path)
779 _make_commit(root, repo_id)
780 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
781
782 # CliRunner is non-TTY — without --yes, the guard fires before input() is called.
783 result = runner.invoke(None, ["release", "delete", "v1.0.0"], env=_env(root))
784 assert result.exit_code != 0
785 assert "TTY" in result.stderr or "--yes" in result.stderr
786
787 # Release must still exist.
788 list_result = runner.invoke(None, ["release", "list"], env=_env(root))
789 assert "v1.0.0" in list_result.output
790
791 def test_release_delete_published_with_yes_flag(self, tmp_path: pathlib.Path) -> None:
792 """Passing --yes skips the interactive confirmation in non-TTY contexts."""
793 root, repo_id = _init_repo(tmp_path)
794 _make_commit(root, repo_id)
795 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
796
797 result = runner.invoke(None, ["release", "delete", "v1.0.0", "--yes"], env=_env(root))
798 assert result.exit_code == 0
799 assert "deleted" in result.output.lower()
800
801 def test_release_channel_filter(self, tmp_path: pathlib.Path) -> None:
802 root, repo_id = _init_repo(tmp_path)
803 _make_commit(root, repo_id)
804 runner.invoke(None, ["release", "add", "v1.0.0", "--channel", "stable"], env=_env(root))
805 _make_commit(root, repo_id)
806 runner.invoke(None, ["release", "add", "v1.1.0-beta.1", "--channel", "beta"], env=_env(root))
807
808 result = runner.invoke(None, ["release", "list", "--channel", "stable"], env=_env(root))
809 assert result.exit_code == 0
810 assert "v1.0.0" in result.output
811 assert "v1.1.0" not in result.output
812
813 def test_release_changelog_in_json_output(self, tmp_path: pathlib.Path) -> None:
814 root, repo_id = _init_repo(tmp_path)
815 _make_commit(root, repo_id, message="feat: add API", sem_ver_bump="minor")
816 _make_commit(root, repo_id, message="fix: handle edge case", sem_ver_bump="patch")
817
818 result = runner.invoke(None, ["release", "add", "v1.0.0", "--json"], env=_env(root))
819 assert result.exit_code == 0, result.output
820 data = json.loads(result.output)
821 assert len(data["changelog"]) == 2
822 assert data["changelog"][0]["sem_ver_bump"] == "minor"
823
824
825 # ---------------------------------------------------------------------------
826 # muse release (no subcommand) — defaults to list
827 # ---------------------------------------------------------------------------
828
829
830 class TestReleaseDefaultToList:
831 """Bare ``muse release`` with no subcommand should behave like ``muse release list``."""
832
833 def test_bare_release_exits_zero(self, tmp_path: pathlib.Path) -> None:
834 root, repo_id = _init_repo(tmp_path)
835 _make_commit(root, repo_id)
836 result = runner.invoke(None, ["release"], env=_env(root))
837 assert result.exit_code == 0
838
839 def test_bare_release_empty_repo_prints_no_releases(self, tmp_path: pathlib.Path) -> None:
840 root, _ = _init_repo(tmp_path)
841 result = runner.invoke(None, ["release"], env=_env(root))
842 assert result.exit_code == 0
843 assert "No releases" in result.output
844
845 def test_bare_release_lists_existing_releases(self, tmp_path: pathlib.Path) -> None:
846 root, repo_id = _init_repo(tmp_path)
847 _make_commit(root, repo_id)
848 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
849 result = runner.invoke(None, ["release"], env=_env(root))
850 assert result.exit_code == 0
851 assert "v1.0.0" in result.output
852
853 def test_bare_release_json_flag(self, tmp_path: pathlib.Path) -> None:
854 """``muse release --json`` must emit a JSON dict with total and releases keys."""
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", "--json"], env=_env(root))
859 assert result.exit_code == 0, result.output
860 data = json.loads(result.output)
861 assert isinstance(data, dict)
862 assert data["total"] == 1
863 assert data["releases"][0]["tag"] == "v1.0.0"
864
865 def test_bare_release_channel_filter(self, tmp_path: pathlib.Path) -> None:
866 root, repo_id = _init_repo(tmp_path)
867 _make_commit(root, repo_id)
868 runner.invoke(None, ["release", "add", "v1.0.0", "--channel", "stable"], env=_env(root))
869 _make_commit(root, repo_id)
870 runner.invoke(None, ["release", "add", "v1.1.0-beta.1", "--channel", "beta"], env=_env(root))
871 result = runner.invoke(None, ["release", "--channel", "stable"], env=_env(root))
872 assert result.exit_code == 0
873 assert "v1.0.0" in result.output
874 assert "v1.1.0" not in result.output
875
876
877 # ---------------------------------------------------------------------------
878 # muse release suggest
879 # ---------------------------------------------------------------------------
880
881
882 class TestReleaseSuggest:
883 """``muse release suggest`` derives the next version from the commit graph."""
884
885 def test_no_commits_no_releases(self, tmp_path: pathlib.Path) -> None:
886 """Repo with no commits should exit with an error (no HEAD)."""
887 root, _ = _init_repo(tmp_path)
888 result = runner.invoke(None, ["release", "suggest"], env=_env(root))
889 assert result.exit_code != 0
890
891 def test_no_releases_all_none_bumps(self, tmp_path: pathlib.Path) -> None:
892 """All commits carry bump=none → no suggestion, exit 0."""
893 root, repo_id = _init_repo(tmp_path)
894 _make_commit(root, repo_id, message="chore: housekeeping", sem_ver_bump="none")
895 result = runner.invoke(None, ["release", "suggest"], env=_env(root))
896 assert result.exit_code == 0
897 assert "no version bump" in result.output.lower() or "none" in result.output.lower()
898
899 def test_no_releases_patch_bump(self, tmp_path: pathlib.Path) -> None:
900 """patch bump with no prior release → v0.0.1."""
901 root, repo_id = _init_repo(tmp_path)
902 _make_commit(root, repo_id, message="fix: typo", sem_ver_bump="patch")
903 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
904 assert result.exit_code == 0
905 data = json.loads(result.output)
906 assert data["suggested_tag"] == "v0.0.1"
907 assert data["inferred_bump"] == "patch"
908 assert data["pre_1_0_adjusted"] is True
909
910 def test_no_releases_minor_bump(self, tmp_path: pathlib.Path) -> None:
911 """minor bump with no prior release in pre-1.0 → v0.0.1."""
912 root, repo_id = _init_repo(tmp_path)
913 _make_commit(root, repo_id, message="feat: new helper", sem_ver_bump="minor")
914 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
915 assert result.exit_code == 0
916 data = json.loads(result.output)
917 assert data["suggested_tag"] == "v0.0.1"
918 assert data["inferred_bump"] == "minor"
919 assert data["pre_1_0_adjusted"] is True
920
921 def test_no_releases_major_bump(self, tmp_path: pathlib.Path) -> None:
922 """major bump in pre-1.0 repo with no prior release → bumps minor → v0.1.0."""
923 root, repo_id = _init_repo(tmp_path)
924 _make_commit(root, repo_id, message="feat!: breaking change",
925 sem_ver_bump="major", breaking_changes=["api.py::MyClass"])
926 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
927 assert result.exit_code == 0
928 data = json.loads(result.output)
929 assert data["suggested_tag"] == "v0.1.0"
930 assert data["inferred_bump"] == "major"
931 assert data["pre_1_0_adjusted"] is True
932
933 def test_takes_highest_bump_across_commits(self, tmp_path: pathlib.Path) -> None:
934 """max(patch, minor, major) = major drives the suggestion."""
935 root, repo_id = _init_repo(tmp_path)
936 _make_commit(root, repo_id, message="fix: small", sem_ver_bump="patch")
937 _make_commit(root, repo_id, message="feat: new", sem_ver_bump="minor")
938 _make_commit(root, repo_id, message="feat!: break", sem_ver_bump="major",
939 breaking_changes=["core.py::Fn"])
940 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
941 assert result.exit_code == 0
942 data = json.loads(result.output)
943 assert data["inferred_bump"] == "major"
944
945 def test_with_prior_release_patch(self, tmp_path: pathlib.Path) -> None:
946 """patch since v1.2.3 → v1.2.4 (post-1.0, no adjustment)."""
947 root, repo_id = _init_repo(tmp_path)
948 _make_commit(root, repo_id, message="initial", sem_ver_bump="none")
949 runner.invoke(None, ["release", "add", "v1.2.3"], env=_env(root))
950 _make_commit(root, repo_id, message="fix: edge case", sem_ver_bump="patch")
951 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
952 assert result.exit_code == 0
953 data = json.loads(result.output)
954 assert data["suggested_tag"] == "v1.2.4"
955 assert data["pre_1_0_adjusted"] is False
956 assert data["base_tag"] == "v1.2.3"
957
958 def test_with_prior_release_minor(self, tmp_path: pathlib.Path) -> None:
959 """minor since v1.2.3 → v1.3.0."""
960 root, repo_id = _init_repo(tmp_path)
961 _make_commit(root, repo_id, message="initial", sem_ver_bump="none")
962 runner.invoke(None, ["release", "add", "v1.2.3"], env=_env(root))
963 _make_commit(root, repo_id, message="feat: new thing", sem_ver_bump="minor")
964 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
965 assert result.exit_code == 0
966 data = json.loads(result.output)
967 assert data["suggested_tag"] == "v1.3.0"
968
969 def test_with_prior_release_major(self, tmp_path: pathlib.Path) -> None:
970 """major since v1.2.3 → v2.0.0."""
971 root, repo_id = _init_repo(tmp_path)
972 _make_commit(root, repo_id, message="initial", sem_ver_bump="none")
973 runner.invoke(None, ["release", "add", "v1.2.3"], env=_env(root))
974 _make_commit(root, repo_id, message="feat!: overhaul", sem_ver_bump="major",
975 breaking_changes=["core.py::API"])
976 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
977 assert result.exit_code == 0
978 data = json.loads(result.output)
979 assert data["suggested_tag"] == "v2.0.0"
980 assert data["pre_1_0_adjusted"] is False
981
982 def test_pre_1_0_major_bumps_minor(self, tmp_path: pathlib.Path) -> None:
983 """In 0.x.y, major structural break bumps minor not major (0.x+1.0)."""
984 root, repo_id = _init_repo(tmp_path)
985 _make_commit(root, repo_id, message="initial", sem_ver_bump="none")
986 runner.invoke(None, ["release", "add", "v0.2.5"], env=_env(root))
987 _make_commit(root, repo_id, message="feat!: break API", sem_ver_bump="major",
988 breaking_changes=["api.py::Client"])
989 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
990 assert result.exit_code == 0
991 data = json.loads(result.output)
992 assert data["suggested_tag"] == "v0.3.0"
993 assert data["pre_1_0_adjusted"] is True
994
995 def test_drivers_list_contains_bumping_commits(self, tmp_path: pathlib.Path) -> None:
996 """drivers list includes only commits with bump != none."""
997 root, repo_id = _init_repo(tmp_path)
998 _make_commit(root, repo_id, message="chore: no-op", sem_ver_bump="none")
999 _make_commit(root, repo_id, message="fix: something", sem_ver_bump="patch")
1000 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
1001 assert result.exit_code == 0
1002 data = json.loads(result.output)
1003 assert data["unreleased_count"] == 2
1004 assert len(data["drivers"]) == 1
1005 assert data["drivers"][0]["sem_ver_bump"] == "patch"
1006
1007 def test_unreleased_count_excludes_released_commits(self, tmp_path: pathlib.Path) -> None:
1008 """Commits before the base release are not counted."""
1009 root, repo_id = _init_repo(tmp_path)
1010 _make_commit(root, repo_id, message="old feat", sem_ver_bump="major")
1011 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
1012 _make_commit(root, repo_id, message="new fix", sem_ver_bump="patch")
1013 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
1014 assert result.exit_code == 0
1015 data = json.loads(result.output)
1016 assert data["unreleased_count"] == 1
1017
1018 def test_base_flag_overrides_latest_release(self, tmp_path: pathlib.Path) -> None:
1019 """--base selects a specific release as the starting point."""
1020 root, repo_id = _init_repo(tmp_path)
1021 _make_commit(root, repo_id, message="v1 base", sem_ver_bump="none")
1022 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
1023 _make_commit(root, repo_id, message="patch after v1", sem_ver_bump="patch")
1024 runner.invoke(None, ["release", "add", "v1.0.1"], env=_env(root))
1025 _make_commit(root, repo_id, message="feature after v1.0.1", sem_ver_bump="minor")
1026
1027 result = runner.invoke(None, ["release", "suggest", "--base", "v1.0.0", "--json"],
1028 env=_env(root))
1029 assert result.exit_code == 0
1030 data = json.loads(result.output)
1031 # Since v1.0.0: one patch + one minor → minor bump → v1.1.0
1032 assert data["suggested_tag"] == "v1.1.0"
1033 assert data["base_tag"] == "v1.0.0"
1034
1035 def test_base_flag_not_found(self, tmp_path: pathlib.Path) -> None:
1036 """--base with unknown tag exits with NOT_FOUND."""
1037 root, repo_id = _init_repo(tmp_path)
1038 _make_commit(root, repo_id)
1039 result = runner.invoke(None, ["release", "suggest", "--base", "v99.0.0"], env=_env(root))
1040 assert result.exit_code != 0
1041
1042 def test_json_schema_keys_present(self, tmp_path: pathlib.Path) -> None:
1043 """JSON output always contains all expected keys."""
1044 root, repo_id = _init_repo(tmp_path)
1045 _make_commit(root, repo_id, message="feat: something", sem_ver_bump="minor")
1046 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
1047 assert result.exit_code == 0
1048 data = json.loads(result.output)
1049 for key in ("suggested_tag", "inferred_bump", "pre_1_0_adjusted",
1050 "base_tag", "base_commit_id", "head_commit_id",
1051 "unreleased_count", "drivers"):
1052 assert key in data, f"missing key: {key}"
1053
1054 def test_text_output_contains_suggested_tag(self, tmp_path: pathlib.Path) -> None:
1055 """Human-readable output includes the suggested tag."""
1056 root, repo_id = _init_repo(tmp_path)
1057 _make_commit(root, repo_id, message="feat!: break", sem_ver_bump="major",
1058 breaking_changes=["mod.py::Fn"])
1059 result = runner.invoke(None, ["release", "suggest"], env=_env(root))
1060 assert result.exit_code == 0
1061 assert "v0.1.0" in result.output
File History 1 commit
sha256:84df9126d09aeec0b8f1b908f0b06c10913feec28f3514b382efb1ba6d619385 refactor: rename StructuredMergePlugin to AddressedMergePlu… Sonnet 4.6 minor 23 days ago