gabriel / muse public
test_release.py python
978 lines 40.4 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 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_pack / apply_pack
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_pack
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_pack(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_pack
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_pack(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_pack, build_pack, 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_pack(root_src, [commit_id], repo_id=repo_id)
575 apply_pack(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, list)
654 assert data[0]["tag"] == "v1.0.0"
655
656 def test_release_show(self, tmp_path: pathlib.Path) -> None:
657 root, repo_id = _init_repo(tmp_path)
658 _make_commit(root, repo_id)
659 runner.invoke(None, ["release", "add", "v1.0.0", "--title", "Production"], env=_env(root))
660
661 result = runner.invoke(None, ["release", "show", "v1.0.0"], env=_env(root))
662 assert result.exit_code == 0
663 assert "v1.0.0" in result.output
664 assert "stable" in result.output
665
666 def test_release_show_not_found(self, tmp_path: pathlib.Path) -> None:
667 root, repo_id = _init_repo(tmp_path)
668 result = runner.invoke(None, ["release", "show", "v99.99.99"], env=_env(root))
669 assert result.exit_code != 0
670
671 def test_release_delete_draft(self, tmp_path: pathlib.Path) -> None:
672 root, repo_id = _init_repo(tmp_path)
673 _make_commit(root, repo_id)
674 runner.invoke(None, ["release", "add", "v1.0.0-alpha.1", "--draft"], env=_env(root))
675
676 result = runner.invoke(None, ["release", "delete", "v1.0.0-alpha.1", "--yes"], env=_env(root))
677 assert result.exit_code == 0
678 assert "deleted" in result.output.lower()
679
680 def test_release_delete_published_with_yes(self, tmp_path: pathlib.Path) -> None:
681 """Published releases can be deleted when --yes bypasses the tag-name prompt."""
682 root, repo_id = _init_repo(tmp_path)
683 _make_commit(root, repo_id)
684 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
685
686 result = runner.invoke(None, ["release", "delete", "v1.0.0", "--yes"], env=_env(root))
687 assert result.exit_code == 0
688 assert "deleted" in result.output.lower()
689
690 # Confirm it's gone from the list.
691 list_result = runner.invoke(None, ["release", "list"], env=_env(root))
692 assert "v1.0.0" not in list_result.output
693
694 def test_release_delete_published_non_tty_requires_yes(self, tmp_path: pathlib.Path) -> None:
695 """Non-TTY context without --yes must exit with USER_ERROR (no blocking input())."""
696 root, repo_id = _init_repo(tmp_path)
697 _make_commit(root, repo_id)
698 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
699
700 # CliRunner is non-TTY — without --yes, the guard fires before input() is called.
701 result = runner.invoke(None, ["release", "delete", "v1.0.0"], env=_env(root))
702 assert result.exit_code != 0
703 assert "TTY" in result.output or "--yes" in result.output
704
705 # Release must still exist.
706 list_result = runner.invoke(None, ["release", "list"], env=_env(root))
707 assert "v1.0.0" in list_result.output
708
709 def test_release_delete_published_with_yes_flag(self, tmp_path: pathlib.Path) -> None:
710 """Passing --yes skips the interactive confirmation in non-TTY contexts."""
711 root, repo_id = _init_repo(tmp_path)
712 _make_commit(root, repo_id)
713 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
714
715 result = runner.invoke(None, ["release", "delete", "v1.0.0", "--yes"], env=_env(root))
716 assert result.exit_code == 0
717 assert "deleted" in result.output.lower()
718
719 def test_release_channel_filter(self, tmp_path: pathlib.Path) -> None:
720 root, repo_id = _init_repo(tmp_path)
721 _make_commit(root, repo_id)
722 runner.invoke(None, ["release", "add", "v1.0.0", "--channel", "stable"], env=_env(root))
723 _make_commit(root, repo_id)
724 runner.invoke(None, ["release", "add", "v1.1.0-beta.1", "--channel", "beta"], env=_env(root))
725
726 result = runner.invoke(None, ["release", "list", "--channel", "stable"], env=_env(root))
727 assert result.exit_code == 0
728 assert "v1.0.0" in result.output
729 assert "v1.1.0" not in result.output
730
731 def test_release_changelog_in_json_output(self, tmp_path: pathlib.Path) -> None:
732 root, repo_id = _init_repo(tmp_path)
733 _make_commit(root, repo_id, message="feat: add API", sem_ver_bump="minor")
734 _make_commit(root, repo_id, message="fix: handle edge case", sem_ver_bump="patch")
735
736 result = runner.invoke(None, ["release", "add", "v1.0.0", "--json"], env=_env(root))
737 assert result.exit_code == 0, result.output
738 data = json.loads(result.output)
739 assert len(data["changelog"]) == 2
740 assert data["changelog"][0]["sem_ver_bump"] == "minor"
741
742
743 # ---------------------------------------------------------------------------
744 # muse release (no subcommand) — defaults to list
745 # ---------------------------------------------------------------------------
746
747
748 class TestReleaseDefaultToList:
749 """Bare ``muse release`` with no subcommand should behave like ``muse release list``."""
750
751 def test_bare_release_exits_zero(self, tmp_path: pathlib.Path) -> None:
752 root, repo_id = _init_repo(tmp_path)
753 _make_commit(root, repo_id)
754 result = runner.invoke(None, ["release"], env=_env(root))
755 assert result.exit_code == 0
756
757 def test_bare_release_empty_repo_prints_no_releases(self, tmp_path: pathlib.Path) -> None:
758 root, _ = _init_repo(tmp_path)
759 result = runner.invoke(None, ["release"], env=_env(root))
760 assert result.exit_code == 0
761 assert "No releases" in result.output
762
763 def test_bare_release_lists_existing_releases(self, tmp_path: pathlib.Path) -> None:
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 result = runner.invoke(None, ["release"], env=_env(root))
768 assert result.exit_code == 0
769 assert "v1.0.0" in result.output
770
771 def test_bare_release_json_flag(self, tmp_path: pathlib.Path) -> None:
772 """``muse release --json`` must emit a JSON array, not an error."""
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 result = runner.invoke(None, ["release", "--json"], env=_env(root))
777 assert result.exit_code == 0, result.output
778 data = json.loads(result.output)
779 assert isinstance(data, list)
780 assert data[0]["tag"] == "v1.0.0"
781
782 def test_bare_release_channel_filter(self, tmp_path: pathlib.Path) -> None:
783 root, repo_id = _init_repo(tmp_path)
784 _make_commit(root, repo_id)
785 runner.invoke(None, ["release", "add", "v1.0.0", "--channel", "stable"], env=_env(root))
786 _make_commit(root, repo_id)
787 runner.invoke(None, ["release", "add", "v1.1.0-beta.1", "--channel", "beta"], env=_env(root))
788 result = runner.invoke(None, ["release", "--channel", "stable"], env=_env(root))
789 assert result.exit_code == 0
790 assert "v1.0.0" in result.output
791 assert "v1.1.0" not in result.output
792
793
794 # ---------------------------------------------------------------------------
795 # muse release suggest
796 # ---------------------------------------------------------------------------
797
798
799 class TestReleaseSuggest:
800 """``muse release suggest`` derives the next version from the commit graph."""
801
802 def test_no_commits_no_releases(self, tmp_path: pathlib.Path) -> None:
803 """Repo with no commits should exit with an error (no HEAD)."""
804 root, _ = _init_repo(tmp_path)
805 result = runner.invoke(None, ["release", "suggest"], env=_env(root))
806 assert result.exit_code != 0
807
808 def test_no_releases_all_none_bumps(self, tmp_path: pathlib.Path) -> None:
809 """All commits carry bump=none → no suggestion, exit 0."""
810 root, repo_id = _init_repo(tmp_path)
811 _make_commit(root, repo_id, message="chore: housekeeping", sem_ver_bump="none")
812 result = runner.invoke(None, ["release", "suggest"], env=_env(root))
813 assert result.exit_code == 0
814 assert "no version bump" in result.output.lower() or "none" in result.output.lower()
815
816 def test_no_releases_patch_bump(self, tmp_path: pathlib.Path) -> None:
817 """patch bump with no prior release → v0.0.1."""
818 root, repo_id = _init_repo(tmp_path)
819 _make_commit(root, repo_id, message="fix: typo", sem_ver_bump="patch")
820 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
821 assert result.exit_code == 0
822 data = json.loads(result.output)
823 assert data["suggested_tag"] == "v0.0.1"
824 assert data["inferred_bump"] == "patch"
825 assert data["pre_1_0_adjusted"] is True
826
827 def test_no_releases_minor_bump(self, tmp_path: pathlib.Path) -> None:
828 """minor bump with no prior release in pre-1.0 → v0.0.1."""
829 root, repo_id = _init_repo(tmp_path)
830 _make_commit(root, repo_id, message="feat: new helper", sem_ver_bump="minor")
831 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
832 assert result.exit_code == 0
833 data = json.loads(result.output)
834 assert data["suggested_tag"] == "v0.0.1"
835 assert data["inferred_bump"] == "minor"
836 assert data["pre_1_0_adjusted"] is True
837
838 def test_no_releases_major_bump(self, tmp_path: pathlib.Path) -> None:
839 """major bump in pre-1.0 repo with no prior release → bumps minor → v0.1.0."""
840 root, repo_id = _init_repo(tmp_path)
841 _make_commit(root, repo_id, message="feat!: breaking change",
842 sem_ver_bump="major", breaking_changes=["api.py::MyClass"])
843 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
844 assert result.exit_code == 0
845 data = json.loads(result.output)
846 assert data["suggested_tag"] == "v0.1.0"
847 assert data["inferred_bump"] == "major"
848 assert data["pre_1_0_adjusted"] is True
849
850 def test_takes_highest_bump_across_commits(self, tmp_path: pathlib.Path) -> None:
851 """max(patch, minor, major) = major drives the suggestion."""
852 root, repo_id = _init_repo(tmp_path)
853 _make_commit(root, repo_id, message="fix: small", sem_ver_bump="patch")
854 _make_commit(root, repo_id, message="feat: new", sem_ver_bump="minor")
855 _make_commit(root, repo_id, message="feat!: break", sem_ver_bump="major",
856 breaking_changes=["core.py::Fn"])
857 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
858 assert result.exit_code == 0
859 data = json.loads(result.output)
860 assert data["inferred_bump"] == "major"
861
862 def test_with_prior_release_patch(self, tmp_path: pathlib.Path) -> None:
863 """patch since v1.2.3 → v1.2.4 (post-1.0, no adjustment)."""
864 root, repo_id = _init_repo(tmp_path)
865 _make_commit(root, repo_id, message="initial", sem_ver_bump="none")
866 runner.invoke(None, ["release", "add", "v1.2.3"], env=_env(root))
867 _make_commit(root, repo_id, message="fix: edge case", sem_ver_bump="patch")
868 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
869 assert result.exit_code == 0
870 data = json.loads(result.output)
871 assert data["suggested_tag"] == "v1.2.4"
872 assert data["pre_1_0_adjusted"] is False
873 assert data["base_tag"] == "v1.2.3"
874
875 def test_with_prior_release_minor(self, tmp_path: pathlib.Path) -> None:
876 """minor since v1.2.3 → v1.3.0."""
877 root, repo_id = _init_repo(tmp_path)
878 _make_commit(root, repo_id, message="initial", sem_ver_bump="none")
879 runner.invoke(None, ["release", "add", "v1.2.3"], env=_env(root))
880 _make_commit(root, repo_id, message="feat: new thing", sem_ver_bump="minor")
881 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
882 assert result.exit_code == 0
883 data = json.loads(result.output)
884 assert data["suggested_tag"] == "v1.3.0"
885
886 def test_with_prior_release_major(self, tmp_path: pathlib.Path) -> None:
887 """major since v1.2.3 → v2.0.0."""
888 root, repo_id = _init_repo(tmp_path)
889 _make_commit(root, repo_id, message="initial", sem_ver_bump="none")
890 runner.invoke(None, ["release", "add", "v1.2.3"], env=_env(root))
891 _make_commit(root, repo_id, message="feat!: overhaul", sem_ver_bump="major",
892 breaking_changes=["core.py::API"])
893 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
894 assert result.exit_code == 0
895 data = json.loads(result.output)
896 assert data["suggested_tag"] == "v2.0.0"
897 assert data["pre_1_0_adjusted"] is False
898
899 def test_pre_1_0_major_bumps_minor(self, tmp_path: pathlib.Path) -> None:
900 """In 0.x.y, major structural break bumps minor not major (0.x+1.0)."""
901 root, repo_id = _init_repo(tmp_path)
902 _make_commit(root, repo_id, message="initial", sem_ver_bump="none")
903 runner.invoke(None, ["release", "add", "v0.2.5"], env=_env(root))
904 _make_commit(root, repo_id, message="feat!: break API", sem_ver_bump="major",
905 breaking_changes=["api.py::Client"])
906 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
907 assert result.exit_code == 0
908 data = json.loads(result.output)
909 assert data["suggested_tag"] == "v0.3.0"
910 assert data["pre_1_0_adjusted"] is True
911
912 def test_drivers_list_contains_bumping_commits(self, tmp_path: pathlib.Path) -> None:
913 """drivers list includes only commits with bump != none."""
914 root, repo_id = _init_repo(tmp_path)
915 _make_commit(root, repo_id, message="chore: no-op", sem_ver_bump="none")
916 _make_commit(root, repo_id, message="fix: something", sem_ver_bump="patch")
917 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
918 assert result.exit_code == 0
919 data = json.loads(result.output)
920 assert data["unreleased_count"] == 2
921 assert len(data["drivers"]) == 1
922 assert data["drivers"][0]["sem_ver_bump"] == "patch"
923
924 def test_unreleased_count_excludes_released_commits(self, tmp_path: pathlib.Path) -> None:
925 """Commits before the base release are not counted."""
926 root, repo_id = _init_repo(tmp_path)
927 _make_commit(root, repo_id, message="old feat", sem_ver_bump="major")
928 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
929 _make_commit(root, repo_id, message="new fix", sem_ver_bump="patch")
930 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
931 assert result.exit_code == 0
932 data = json.loads(result.output)
933 assert data["unreleased_count"] == 1
934
935 def test_base_flag_overrides_latest_release(self, tmp_path: pathlib.Path) -> None:
936 """--base selects a specific release as the starting point."""
937 root, repo_id = _init_repo(tmp_path)
938 _make_commit(root, repo_id, message="v1 base", sem_ver_bump="none")
939 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
940 _make_commit(root, repo_id, message="patch after v1", sem_ver_bump="patch")
941 runner.invoke(None, ["release", "add", "v1.0.1"], env=_env(root))
942 _make_commit(root, repo_id, message="feature after v1.0.1", sem_ver_bump="minor")
943
944 result = runner.invoke(None, ["release", "suggest", "--base", "v1.0.0", "--json"],
945 env=_env(root))
946 assert result.exit_code == 0
947 data = json.loads(result.output)
948 # Since v1.0.0: one patch + one minor → minor bump → v1.1.0
949 assert data["suggested_tag"] == "v1.1.0"
950 assert data["base_tag"] == "v1.0.0"
951
952 def test_base_flag_not_found(self, tmp_path: pathlib.Path) -> None:
953 """--base with unknown tag exits with NOT_FOUND."""
954 root, repo_id = _init_repo(tmp_path)
955 _make_commit(root, repo_id)
956 result = runner.invoke(None, ["release", "suggest", "--base", "v99.0.0"], env=_env(root))
957 assert result.exit_code != 0
958
959 def test_json_schema_keys_present(self, tmp_path: pathlib.Path) -> None:
960 """JSON output always contains all expected keys."""
961 root, repo_id = _init_repo(tmp_path)
962 _make_commit(root, repo_id, message="feat: something", sem_ver_bump="minor")
963 result = runner.invoke(None, ["release", "suggest", "--json"], env=_env(root))
964 assert result.exit_code == 0
965 data = json.loads(result.output)
966 for key in ("suggested_tag", "inferred_bump", "pre_1_0_adjusted",
967 "base_tag", "base_commit_id", "head_commit_id",
968 "unreleased_count", "drivers"):
969 assert key in data, f"missing key: {key}"
970
971 def test_text_output_contains_suggested_tag(self, tmp_path: pathlib.Path) -> None:
972 """Human-readable output includes the suggested tag."""
973 root, repo_id = _init_repo(tmp_path)
974 _make_commit(root, repo_id, message="feat!: break", sem_ver_bump="major",
975 breaking_changes=["mod.py::Fn"])
976 result = runner.invoke(None, ["release", "suggest"], env=_env(root))
977 assert result.exit_code == 0
978 assert "v0.1.0" in result.output
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago