gabriel / muse public
test_release.py python
736 lines 27.8 KB
e74bbfd6 chore: ignore .hypothesis, .pytest_cache, .mypy_cache, .ruff_cache; add… gabriel 8h 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
24
25 runner = CliRunner()
26
27
28 # ---------------------------------------------------------------------------
29 # Repository scaffolding helpers
30 # ---------------------------------------------------------------------------
31
32
33 def _env(root: pathlib.Path) -> dict[str, str]:
34 return {"MUSE_REPO_ROOT": str(root)}
35
36
37 def _init_repo(tmp_path: pathlib.Path, domain: str = "code") -> tuple[pathlib.Path, str]:
38 muse_dir = tmp_path / ".muse"
39 muse_dir.mkdir()
40 repo_id = str(uuid.uuid4())
41 (muse_dir / "repo.json").write_text(json.dumps({
42 "repo_id": repo_id,
43 "domain": domain,
44 "default_branch": "main",
45 "created_at": "2025-01-01T00:00:00+00:00",
46 }), encoding="utf-8")
47 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
48 (muse_dir / "refs" / "heads").mkdir(parents=True)
49 (muse_dir / "snapshots").mkdir()
50 (muse_dir / "commits").mkdir()
51 (muse_dir / "objects").mkdir()
52 return tmp_path, repo_id
53
54
55 def _make_commit(
56 root: pathlib.Path,
57 repo_id: str,
58 branch: str = "main",
59 message: str = "feat: add something",
60 sem_ver_bump: str = "minor",
61 breaking_changes: list[str] | None = None,
62 agent_id: str = "",
63 model_id: str = "",
64 ) -> str:
65 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
66 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
67
68 ref_file = root / ".muse" / "refs" / "heads" / branch
69 raw_parent = ref_file.read_text().strip() if ref_file.exists() else ""
70 parent_id: str | None = raw_parent if raw_parent else None
71 manifest: dict[str, str] = {}
72 snap_id = compute_snapshot_id(manifest)
73 snap = SnapshotRecord(snapshot_id=snap_id, manifest=manifest)
74 write_snapshot(root, snap)
75
76 now = datetime.datetime.now(datetime.timezone.utc)
77 parent_ids: list[str] = [parent_id] if parent_id else []
78 commit_id = compute_commit_id(parent_ids, snap_id, message, now.isoformat())
79 from muse.domain import SemVerBump
80 _bump_map: dict[str, SemVerBump] = {"major": "major", "minor": "minor", "patch": "patch", "none": "none"}
81 bump_val: SemVerBump = _bump_map.get(sem_ver_bump, "none")
82
83 commit = CommitRecord(
84 commit_id=commit_id,
85 repo_id=repo_id,
86 branch=branch,
87 snapshot_id=snap_id,
88 message=message,
89 committed_at=now,
90 parent_commit_id=parent_id,
91 sem_ver_bump=bump_val,
92 breaking_changes=breaking_changes or [],
93 agent_id=agent_id,
94 model_id=model_id,
95 )
96 write_commit(root, commit)
97 ref_file.write_text(commit_id, encoding="utf-8")
98 return commit_id
99
100
101 # ---------------------------------------------------------------------------
102 # Semver parsing
103 # ---------------------------------------------------------------------------
104
105
106 class TestParseSemver:
107 def test_stable_version(self) -> None:
108 from muse.core.store import parse_semver
109
110 sv = parse_semver("v1.2.3")
111 assert sv["major"] == 1
112 assert sv["minor"] == 2
113 assert sv["patch"] == 3
114 assert sv["pre"] == ""
115 assert sv["build"] == ""
116
117 def test_no_v_prefix(self) -> None:
118 from muse.core.store import parse_semver
119
120 sv = parse_semver("2.0.0")
121 assert sv["major"] == 2
122
123 def test_pre_release(self) -> None:
124 from muse.core.store import parse_semver
125
126 sv = parse_semver("v1.3.0-beta.1")
127 assert sv["pre"] == "beta.1"
128
129 def test_alpha_pre_release(self) -> None:
130 from muse.core.store import parse_semver
131
132 sv = parse_semver("v0.5.0-alpha.2")
133 assert sv["pre"] == "alpha.2"
134
135 def test_build_metadata(self) -> None:
136 from muse.core.store import parse_semver
137
138 sv = parse_semver("v1.0.0+20240101")
139 assert sv["build"] == "20240101"
140 assert sv["pre"] == ""
141
142 def test_pre_and_build(self) -> None:
143 from muse.core.store import parse_semver
144
145 sv = parse_semver("v2.0.0-rc.1+build.42")
146 assert sv["pre"] == "rc.1"
147 assert sv["build"] == "build.42"
148
149 def test_invalid_raises(self) -> None:
150 from muse.core.store import parse_semver
151
152 with pytest.raises(ValueError, match="not valid semver"):
153 parse_semver("not-a-version")
154
155 def test_missing_patch_raises(self) -> None:
156 from muse.core.store import parse_semver
157
158 with pytest.raises(ValueError):
159 parse_semver("v1.2")
160
161 def test_leading_zero_minor_valid(self) -> None:
162 from muse.core.store import parse_semver
163
164 sv = parse_semver("v1.0.0")
165 assert sv["minor"] == 0
166
167
168 class TestSemverToStr:
169 def test_round_trip_stable(self) -> None:
170 from muse.core.store import SemVerTag, semver_to_str
171
172 sv = SemVerTag(major=1, minor=2, patch=3, pre="", build="")
173 assert semver_to_str(sv) == "v1.2.3"
174
175 def test_round_trip_prerelease(self) -> None:
176 from muse.core.store import SemVerTag, semver_to_str
177
178 sv = SemVerTag(major=1, minor=3, patch=0, pre="beta.1", build="")
179 assert semver_to_str(sv) == "v1.3.0-beta.1"
180
181 def test_round_trip_with_build(self) -> None:
182 from muse.core.store import SemVerTag, semver_to_str
183
184 sv = SemVerTag(major=2, minor=0, patch=0, pre="rc.1", build="42")
185 assert semver_to_str(sv) == "v2.0.0-rc.1+42"
186
187
188 class TestSemverChannel:
189 def test_stable_channel_no_pre(self) -> None:
190 from muse.core.store import SemVerTag, semver_channel
191
192 sv = SemVerTag(major=1, minor=0, patch=0, pre="", build="")
193 assert semver_channel(sv) == "stable"
194
195 def test_beta_channel(self) -> None:
196 from muse.core.store import SemVerTag, semver_channel
197
198 sv = SemVerTag(major=1, minor=0, patch=0, pre="beta.1", build="")
199 assert semver_channel(sv) == "beta"
200
201 def test_alpha_channel(self) -> None:
202 from muse.core.store import SemVerTag, semver_channel
203
204 sv = SemVerTag(major=1, minor=0, patch=0, pre="alpha.3", build="")
205 assert semver_channel(sv) == "alpha"
206
207 def test_nightly_channel(self) -> None:
208 from muse.core.store import SemVerTag, semver_channel
209
210 sv = SemVerTag(major=0, minor=0, patch=1, pre="nightly", build="")
211 assert semver_channel(sv) == "nightly"
212
213 def test_rc_defaults_to_stable(self) -> None:
214 from muse.core.store import SemVerTag, semver_channel
215
216 sv = SemVerTag(major=1, minor=0, patch=0, pre="rc.1", build="")
217 # rc is not a recognised channel prefix — defaults to stable
218 assert semver_channel(sv) == "stable"
219
220
221 # ---------------------------------------------------------------------------
222 # ReleaseRecord serialisation
223 # ---------------------------------------------------------------------------
224
225
226 class TestReleaseRecordSerialisation:
227 def _make_release(self) -> ReleaseRecord:
228 from muse.core.store import SemVerTag
229
230 return ReleaseRecord(
231 release_id=str(uuid.uuid4()),
232 repo_id=str(uuid.uuid4()),
233 tag="v1.0.0",
234 semver=SemVerTag(major=1, minor=0, patch=0, pre="", build=""),
235 channel="stable",
236 commit_id="a" * 64,
237 snapshot_id="b" * 64,
238 title="First release",
239 body="Initial release notes.",
240 changelog=[],
241 )
242
243 def test_round_trip(self) -> None:
244 from muse.core.store import ReleaseRecord
245
246 release = self._make_release()
247 d = release.to_dict()
248 restored = ReleaseRecord.from_dict(d)
249 assert restored.release_id == release.release_id
250 assert restored.tag == release.tag
251 assert restored.semver == release.semver
252 assert restored.channel == release.channel
253 assert restored.title == release.title
254 assert restored.is_draft is False
255
256 def test_draft_round_trip(self) -> None:
257 from muse.core.store import ReleaseRecord
258
259 release = self._make_release()
260 release.is_draft = True
261 d = release.to_dict()
262 restored = ReleaseRecord.from_dict(d)
263 assert restored.is_draft is True
264
265 def test_invalid_channel_defaults_to_stable(self) -> None:
266 from muse.core.store import ReleaseRecord, SemVerTag
267
268 release = ReleaseRecord(
269 release_id=str(uuid.uuid4()),
270 repo_id=str(uuid.uuid4()),
271 tag="v1.0.0",
272 semver=SemVerTag(major=1, minor=0, patch=0, pre="", build=""),
273 channel="stable",
274 commit_id="a" * 64,
275 snapshot_id="b" * 64,
276 title="",
277 body="",
278 changelog=[],
279 )
280 d = release.to_dict()
281 d["channel"] = "unknown-channel"
282 restored = ReleaseRecord.from_dict(d)
283 assert restored.channel == "stable"
284
285
286 # ---------------------------------------------------------------------------
287 # Release store operations
288 # ---------------------------------------------------------------------------
289
290
291 class TestReleaseStore:
292 def test_write_and_read(self, tmp_path: pathlib.Path) -> None:
293 from muse.core.store import ReleaseRecord, SemVerTag, read_release, write_release
294
295 root, repo_id = _init_repo(tmp_path)
296 release = ReleaseRecord(
297 release_id=str(uuid.uuid4()),
298 repo_id=repo_id,
299 tag="v1.0.0",
300 semver=SemVerTag(major=1, minor=0, patch=0, pre="", build=""),
301 channel="stable",
302 commit_id="a" * 64,
303 snapshot_id="b" * 64,
304 title="v1.0.0",
305 body="",
306 changelog=[],
307 )
308 write_release(root, release)
309 loaded = read_release(root, repo_id, release.release_id)
310 assert loaded is not None
311 assert loaded.tag == "v1.0.0"
312
313 def test_read_missing_returns_none(self, tmp_path: pathlib.Path) -> None:
314 from muse.core.store import read_release
315
316 root, repo_id = _init_repo(tmp_path)
317 assert read_release(root, repo_id, str(uuid.uuid4())) is None
318
319 def test_list_releases_newest_first(self, tmp_path: pathlib.Path) -> None:
320 from muse.core.store import ReleaseRecord, SemVerTag, list_releases, write_release
321 import time
322
323 root, repo_id = _init_repo(tmp_path)
324
325 for i, tag in enumerate(["v1.0.0", "v1.1.0", "v1.2.0"]):
326 sv = SemVerTag(major=1, minor=i, patch=0, pre="", build="")
327 r = ReleaseRecord(
328 release_id=str(uuid.uuid4()),
329 repo_id=repo_id,
330 tag=tag,
331 semver=sv,
332 channel="stable",
333 commit_id="a" * 64,
334 snapshot_id="b" * 64,
335 title=tag,
336 body="",
337 changelog=[],
338 created_at=datetime.datetime(2025, 1, i + 1, tzinfo=datetime.timezone.utc),
339 )
340 write_release(root, r)
341 time.sleep(0.01) # ensure distinct timestamps
342
343 releases = list_releases(root, repo_id)
344 assert len(releases) == 3
345 # newest first
346 assert releases[0].tag == "v1.2.0"
347 assert releases[-1].tag == "v1.0.0"
348
349 def test_list_excludes_drafts_by_default(self, tmp_path: pathlib.Path) -> None:
350 from muse.core.store import ReleaseRecord, SemVerTag, list_releases, write_release
351
352 root, repo_id = _init_repo(tmp_path)
353 for tag, draft in [("v1.0.0", False), ("v1.1.0-beta.1", True)]:
354 sv_parts = tag.lstrip("v").split("-")
355 major, minor, patch = (int(x) for x in sv_parts[0].split("."))
356 pre = sv_parts[1] if len(sv_parts) > 1 else ""
357 r = ReleaseRecord(
358 release_id=str(uuid.uuid4()),
359 repo_id=repo_id,
360 tag=tag,
361 semver=SemVerTag(major=major, minor=minor, patch=patch, pre=pre, build=""),
362 channel="stable" if not pre else "beta",
363 commit_id="a" * 64,
364 snapshot_id="b" * 64,
365 title=tag,
366 body="",
367 changelog=[],
368 is_draft=draft,
369 )
370 write_release(root, r)
371
372 assert len(list_releases(root, repo_id)) == 1
373 assert len(list_releases(root, repo_id, include_drafts=True)) == 2
374
375 def test_filter_by_channel(self, tmp_path: pathlib.Path) -> None:
376 from muse.core.store import ReleaseChannel, ReleaseRecord, SemVerTag, list_releases, write_release
377
378 _ch_map: dict[str, ReleaseChannel] = {"stable": "stable", "beta": "beta", "alpha": "alpha", "nightly": "nightly"}
379 root, repo_id = _init_repo(tmp_path)
380 for tag, channel_str in [("v1.0.0", "stable"), ("v1.1.0-beta.1", "beta"), ("v1.2.0", "stable")]:
381 sv_parts = tag.lstrip("v").split("-")
382 major, minor, patch = (int(x) for x in sv_parts[0].split("."))
383 pre = sv_parts[1] if len(sv_parts) > 1 else ""
384 r = ReleaseRecord(
385 release_id=str(uuid.uuid4()),
386 repo_id=repo_id,
387 tag=tag,
388 semver=SemVerTag(major=major, minor=minor, patch=patch, pre=pre, build=""),
389 channel=_ch_map.get(channel_str, "stable"),
390 commit_id="a" * 64,
391 snapshot_id="b" * 64,
392 title=tag,
393 body="",
394 changelog=[],
395 )
396 write_release(root, r)
397
398 stable = list_releases(root, repo_id, channel="stable")
399 beta = list_releases(root, repo_id, channel="beta")
400 assert len(stable) == 2
401 assert len(beta) == 1
402
403 def test_delete_release(self, tmp_path: pathlib.Path) -> None:
404 from muse.core.store import ReleaseRecord, SemVerTag, delete_release, list_releases, write_release
405
406 root, repo_id = _init_repo(tmp_path)
407 release_id = str(uuid.uuid4())
408 r = ReleaseRecord(
409 release_id=release_id,
410 repo_id=repo_id,
411 tag="v0.1.0",
412 semver=SemVerTag(major=0, minor=1, patch=0, pre="", build=""),
413 channel="stable",
414 commit_id="a" * 64,
415 snapshot_id="b" * 64,
416 title="",
417 body="",
418 changelog=[],
419 )
420 write_release(root, r)
421 assert len(list_releases(root, repo_id)) == 1
422 assert delete_release(root, repo_id, release_id) is True
423 assert len(list_releases(root, repo_id)) == 0
424
425 def test_delete_nonexistent_returns_false(self, tmp_path: pathlib.Path) -> None:
426 from muse.core.store import delete_release
427
428 root, repo_id = _init_repo(tmp_path)
429 assert delete_release(root, repo_id, str(uuid.uuid4())) is False
430
431 def test_get_release_for_tag(self, tmp_path: pathlib.Path) -> None:
432 from muse.core.store import ReleaseRecord, SemVerTag, get_release_for_tag, write_release
433
434 root, repo_id = _init_repo(tmp_path)
435 r = ReleaseRecord(
436 release_id=str(uuid.uuid4()),
437 repo_id=repo_id,
438 tag="v2.0.0",
439 semver=SemVerTag(major=2, minor=0, patch=0, pre="", build=""),
440 channel="stable",
441 commit_id="a" * 64,
442 snapshot_id="b" * 64,
443 title="",
444 body="",
445 changelog=[],
446 )
447 write_release(root, r)
448 assert get_release_for_tag(root, repo_id, "v2.0.0") is not None
449 assert get_release_for_tag(root, repo_id, "v9.9.9") is None
450
451
452 # ---------------------------------------------------------------------------
453 # build_changelog
454 # ---------------------------------------------------------------------------
455
456
457 class TestBuildChangelog:
458 def test_changelog_from_commits(self, tmp_path: pathlib.Path) -> None:
459 from muse.core.store import build_changelog
460
461 root, repo_id = _init_repo(tmp_path)
462 c1 = _make_commit(root, repo_id, message="feat: first", sem_ver_bump="minor")
463 c2 = _make_commit(root, repo_id, message="fix: patch fix", sem_ver_bump="patch")
464 c3 = _make_commit(root, repo_id, message="feat!: breaking", sem_ver_bump="major",
465 breaking_changes=["API changed"])
466
467 changelog = build_changelog(root, None, c3)
468 assert len(changelog) == 3
469 assert changelog[0]["commit_id"] == c1 # oldest first
470 assert changelog[2]["sem_ver_bump"] == "major"
471 assert changelog[2]["breaking_changes"] == ["API changed"]
472
473 def test_changelog_bounded_by_from_commit(self, tmp_path: pathlib.Path) -> None:
474 from muse.core.store import build_changelog
475
476 root, repo_id = _init_repo(tmp_path)
477 c1 = _make_commit(root, repo_id, message="chore: setup", sem_ver_bump="none")
478 c2 = _make_commit(root, repo_id, message="feat: add feature", sem_ver_bump="minor")
479 c3 = _make_commit(root, repo_id, message="fix: tiny fix", sem_ver_bump="patch")
480
481 # Only c2 and c3 are since c1
482 changelog = build_changelog(root, c1, c3)
483 assert len(changelog) == 2
484 assert changelog[0]["commit_id"] == c2
485
486 def test_empty_changelog_same_commit(self, tmp_path: pathlib.Path) -> None:
487 from muse.core.store import build_changelog
488
489 root, repo_id = _init_repo(tmp_path)
490 c1 = _make_commit(root, repo_id)
491 changelog = build_changelog(root, c1, c1)
492 assert changelog == []
493
494 def test_changelog_includes_agent_provenance(self, tmp_path: pathlib.Path) -> None:
495 from muse.core.store import build_changelog
496
497 root, repo_id = _init_repo(tmp_path)
498 _make_commit(root, repo_id, message="feat: add", sem_ver_bump="minor",
499 agent_id="my-agent", model_id="claude-4")
500 head_commit = _make_commit(root, repo_id, message="fix: patch", sem_ver_bump="patch")
501 changelog = build_changelog(root, None, head_commit)
502 assert changelog[0]["agent_id"] == "my-agent"
503 assert changelog[0]["model_id"] == "claude-4"
504
505
506 # ---------------------------------------------------------------------------
507 # WireTag in pack
508 # ---------------------------------------------------------------------------
509
510
511 class TestWireTagInPack:
512 def test_build_pack_includes_tags(self, tmp_path: pathlib.Path) -> None:
513 from muse.core.pack import build_pack
514 from muse.core.store import TagRecord, write_tag
515
516 root, repo_id = _init_repo(tmp_path)
517 commit_id = _make_commit(root, repo_id)
518
519 tag = TagRecord(
520 tag_id=str(uuid.uuid4()),
521 repo_id=repo_id,
522 commit_id=commit_id,
523 tag="v1.0.0",
524 )
525 write_tag(root, tag)
526
527 bundle = build_pack(root, [commit_id], repo_id=repo_id)
528 assert "tags" in bundle
529 tags = bundle["tags"]
530 assert len(tags) == 1
531 assert tags[0]["tag"] == "v1.0.0"
532 assert tags[0]["commit_id"] == commit_id
533
534 def test_build_pack_no_tags_when_repo_id_omitted(self, tmp_path: pathlib.Path) -> None:
535 from muse.core.pack import build_pack
536 from muse.core.store import TagRecord, write_tag
537
538 root, repo_id = _init_repo(tmp_path)
539 commit_id = _make_commit(root, repo_id)
540 write_tag(root, TagRecord(
541 tag_id=str(uuid.uuid4()),
542 repo_id=repo_id,
543 commit_id=commit_id,
544 tag="v1.0.0",
545 ))
546
547 bundle = build_pack(root, [commit_id]) # no repo_id
548 assert "tags" not in bundle
549
550 def test_apply_pack_writes_tags(self, tmp_path: pathlib.Path) -> None:
551 from muse.core.pack import apply_pack, build_pack, WireTag
552 from muse.core.store import TagRecord, get_all_tags, write_tag
553
554 src = tmp_path / "src"
555 dst = tmp_path / "dst"
556 src.mkdir()
557 dst.mkdir()
558
559 root_src, repo_id = _init_repo(src)
560 root_dst, _ = _init_repo(dst)
561
562 commit_id = _make_commit(root_src, repo_id)
563 write_tag(root_src, TagRecord(
564 tag_id=str(uuid.uuid4()),
565 repo_id=repo_id,
566 commit_id=commit_id,
567 tag="v1.0.0",
568 ))
569
570 bundle = build_pack(root_src, [commit_id], repo_id=repo_id)
571 apply_pack(root_dst, bundle)
572
573 tags = get_all_tags(root_dst, repo_id)
574 assert any(t.tag == "v1.0.0" for t in tags)
575
576
577 # ---------------------------------------------------------------------------
578 # E2E CLI: muse release
579 # ---------------------------------------------------------------------------
580
581
582 class TestReleaseCLI:
583 def test_release_add_basic(self, tmp_path: pathlib.Path) -> None:
584 root, repo_id = _init_repo(tmp_path)
585 _make_commit(root, repo_id, message="feat: initial", sem_ver_bump="minor")
586
587 result = runner.invoke(None, ["release", "add", "v0.1.0", "--title", "First"], env=_env(root))
588 assert result.exit_code == 0, result.output
589 assert "v0.1.0" in result.output
590
591 def test_release_add_invalid_semver(self, tmp_path: pathlib.Path) -> None:
592 root, repo_id = _init_repo(tmp_path)
593 _make_commit(root, repo_id)
594
595 result = runner.invoke(None, ["release", "add", "not-valid"], env=_env(root))
596 assert result.exit_code != 0
597
598 def test_release_add_duplicate_rejected(self, tmp_path: pathlib.Path) -> None:
599 root, repo_id = _init_repo(tmp_path)
600 _make_commit(root, repo_id)
601
602 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
603 result = runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
604 assert result.exit_code != 0
605 assert "already exists" in result.output.lower() or "already exists" in result.stderr.lower() if hasattr(result, 'stderr') else True
606
607 def test_release_add_draft(self, tmp_path: pathlib.Path) -> None:
608 root, repo_id = _init_repo(tmp_path)
609 _make_commit(root, repo_id)
610
611 result = runner.invoke(None, ["release", "add", "v1.0.0-beta.1", "--draft"], env=_env(root))
612 assert result.exit_code == 0
613 assert "draft" in result.output.lower()
614
615 def test_release_add_json_output(self, tmp_path: pathlib.Path) -> None:
616 root, repo_id = _init_repo(tmp_path)
617 _make_commit(root, repo_id)
618
619 result = runner.invoke(None, ["release", "add", "v1.0.0", "--format", "json"], env=_env(root))
620 assert result.exit_code == 0
621 data = json.loads(result.output)
622 assert data["tag"] == "v1.0.0"
623 assert data["channel"] == "stable"
624 assert "release_id" in data
625
626 def test_release_list(self, tmp_path: pathlib.Path) -> None:
627 root, repo_id = _init_repo(tmp_path)
628 _make_commit(root, repo_id)
629 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
630
631 result = runner.invoke(None, ["release", "list"], env=_env(root))
632 assert result.exit_code == 0
633 assert "v1.0.0" in result.output
634
635 def test_release_list_empty(self, tmp_path: pathlib.Path) -> None:
636 root, repo_id = _init_repo(tmp_path)
637 result = runner.invoke(None, ["release", "list"], env=_env(root))
638 assert result.exit_code == 0
639 assert "No releases" in result.output
640
641 def test_release_list_json(self, tmp_path: pathlib.Path) -> None:
642 root, repo_id = _init_repo(tmp_path)
643 _make_commit(root, repo_id)
644 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
645
646 result = runner.invoke(None, ["release", "list", "--format", "json"], env=_env(root))
647 assert result.exit_code == 0
648 data = json.loads(result.output)
649 assert isinstance(data, list)
650 assert data[0]["tag"] == "v1.0.0"
651
652 def test_release_show(self, tmp_path: pathlib.Path) -> None:
653 root, repo_id = _init_repo(tmp_path)
654 _make_commit(root, repo_id)
655 runner.invoke(None, ["release", "add", "v1.0.0", "--title", "Production"], env=_env(root))
656
657 result = runner.invoke(None, ["release", "show", "v1.0.0"], env=_env(root))
658 assert result.exit_code == 0
659 assert "v1.0.0" in result.output
660 assert "stable" in result.output
661
662 def test_release_show_not_found(self, tmp_path: pathlib.Path) -> None:
663 root, repo_id = _init_repo(tmp_path)
664 result = runner.invoke(None, ["release", "show", "v99.99.99"], env=_env(root))
665 assert result.exit_code != 0
666
667 def test_release_delete_draft(self, tmp_path: pathlib.Path) -> None:
668 root, repo_id = _init_repo(tmp_path)
669 _make_commit(root, repo_id)
670 runner.invoke(None, ["release", "add", "v1.0.0-alpha.1", "--draft"], env=_env(root))
671
672 result = runner.invoke(None, ["release", "delete", "v1.0.0-alpha.1", "--yes"], env=_env(root))
673 assert result.exit_code == 0
674 assert "deleted" in result.output.lower()
675
676 def test_release_delete_published_with_yes(self, tmp_path: pathlib.Path) -> None:
677 """Published releases can be deleted when --yes bypasses the tag-name prompt."""
678 root, repo_id = _init_repo(tmp_path)
679 _make_commit(root, repo_id)
680 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
681
682 result = runner.invoke(None, ["release", "delete", "v1.0.0", "--yes"], env=_env(root))
683 assert result.exit_code == 0
684 assert "deleted" in result.output.lower()
685
686 # Confirm it's gone from the list.
687 list_result = runner.invoke(None, ["release", "list"], env=_env(root))
688 assert "v1.0.0" not in list_result.output
689
690 def test_release_delete_published_prompt_abort(self, tmp_path: pathlib.Path) -> None:
691 """Published releases require typing the tag name; wrong input aborts."""
692 root, repo_id = _init_repo(tmp_path)
693 _make_commit(root, repo_id)
694 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
695
696 # Simulate user typing something other than the exact tag name.
697 result = runner.invoke(None, ["release", "delete", "v1.0.0"], input="nope\n", env=_env(root))
698 assert result.exit_code == 0
699 assert "aborted" in result.output.lower()
700
701 # Release must still exist.
702 list_result = runner.invoke(None, ["release", "list"], env=_env(root))
703 assert "v1.0.0" in list_result.output
704
705 def test_release_delete_published_prompt_confirm(self, tmp_path: pathlib.Path) -> None:
706 """Typing the exact tag name at the prompt confirms deletion."""
707 root, repo_id = _init_repo(tmp_path)
708 _make_commit(root, repo_id)
709 runner.invoke(None, ["release", "add", "v1.0.0"], env=_env(root))
710
711 result = runner.invoke(None, ["release", "delete", "v1.0.0"], input="v1.0.0\n", env=_env(root))
712 assert result.exit_code == 0
713 assert "deleted" in result.output.lower()
714
715 def test_release_channel_filter(self, tmp_path: pathlib.Path) -> None:
716 root, repo_id = _init_repo(tmp_path)
717 _make_commit(root, repo_id)
718 runner.invoke(None, ["release", "add", "v1.0.0", "--channel", "stable"], env=_env(root))
719 _make_commit(root, repo_id)
720 runner.invoke(None, ["release", "add", "v1.1.0-beta.1", "--channel", "beta"], env=_env(root))
721
722 result = runner.invoke(None, ["release", "list", "--channel", "stable"], env=_env(root))
723 assert result.exit_code == 0
724 assert "v1.0.0" in result.output
725 assert "v1.1.0" not in result.output
726
727 def test_release_changelog_in_json_output(self, tmp_path: pathlib.Path) -> None:
728 root, repo_id = _init_repo(tmp_path)
729 _make_commit(root, repo_id, message="feat: add API", sem_ver_bump="minor")
730 _make_commit(root, repo_id, message="fix: handle edge case", sem_ver_bump="patch")
731
732 result = runner.invoke(None, ["release", "add", "v1.0.0", "--format", "json"], env=_env(root))
733 assert result.exit_code == 0
734 data = json.loads(result.output)
735 assert len(data["changelog"]) == 2
736 assert data["changelog"][0]["sem_ver_bump"] == "minor"