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