test_publish_issue.py
python
sha256:411992ae51c0ea420190eb8cfc86e6f04de4d0af5f11238a7542e04cf80afc18
fix(publish_issue): hard-block publishing a body that leaks…
Sonnet 5
patch
21 hours ago
| 1 | """Tests for scripts/dev/publish_issue.py — one-way staging→production ticket publish (#186). |
| 2 | |
| 3 | Uses an in-memory fake HubClient so these tests never shell out to a real |
| 4 | `muse hub` CLI or hit a network. Real wiring (MuseHubCliClient) is exercised |
| 5 | manually against staging/production, not here. |
| 6 | """ |
| 7 | import sys |
| 8 | from pathlib import Path |
| 9 | |
| 10 | import pytest |
| 11 | |
| 12 | sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts" / "dev")) |
| 13 | from publish_issue import StagingUrlLeakError, build_mirror_marker, publish_issue # noqa: E402 |
| 14 | |
| 15 | STAGING_HUB = "https://staging.musehub.ai" |
| 16 | PRODUCTION_HUB = "https://musehub.ai" |
| 17 | |
| 18 | |
| 19 | class FakeHubClient: |
| 20 | """In-memory stand-in for the real `muse hub issue` CLI calls.""" |
| 21 | |
| 22 | def __init__(self) -> None: |
| 23 | self._issues: dict[tuple[str, int], dict] = {} |
| 24 | self._next_number: dict[str, int] = {} |
| 25 | |
| 26 | def seed(self, hub: str, number: int, *, title: str, body: str, labels: list[str]) -> None: |
| 27 | self._issues[(hub, number)] = { |
| 28 | "number": number, "title": title, "body": body, "labels": labels, |
| 29 | "url": f"{hub}/gabriel/musehub/issues/{number}", |
| 30 | } |
| 31 | self._next_number[hub] = max(self._next_number.get(hub, 0), number) + 1 |
| 32 | |
| 33 | def read_issue(self, number: int, hub: str) -> dict: |
| 34 | return dict(self._issues[(hub, number)]) |
| 35 | |
| 36 | def list_issues(self, hub: str, state: str = "all") -> list[dict]: |
| 37 | return [dict(v) for (h, _), v in self._issues.items() if h == hub] |
| 38 | |
| 39 | def create_issue(self, hub: str, *, title: str, body: str, labels: list[str]) -> dict: |
| 40 | number = self._next_number.get(hub, 1) |
| 41 | self._next_number[hub] = number + 1 |
| 42 | issue = { |
| 43 | "number": number, "title": title, "body": body, "labels": labels, |
| 44 | "url": f"{hub}/gabriel/musehub/issues/{number}", |
| 45 | } |
| 46 | self._issues[(hub, number)] = issue |
| 47 | return dict(issue) |
| 48 | |
| 49 | def update_issue(self, hub: str, number: int, *, title: str, body: str) -> dict: |
| 50 | issue = self._issues[(hub, number)] |
| 51 | issue["title"] = title |
| 52 | issue["body"] = body |
| 53 | return dict(issue) |
| 54 | |
| 55 | def set_labels(self, hub: str, number: int, labels: list[str]) -> None: |
| 56 | self._issues[(hub, number)]["labels"] = labels |
| 57 | |
| 58 | |
| 59 | @pytest.fixture |
| 60 | def client() -> FakeHubClient: |
| 61 | return FakeHubClient() |
| 62 | |
| 63 | |
| 64 | class TestPublishIssue: |
| 65 | def test_fresh_publish_creates_exactly_one_production_issue(self, client: FakeHubClient) -> None: |
| 66 | client.seed(STAGING_HUB, 42, title="feat: thing", body="Do the thing.", labels=["enhancement"]) |
| 67 | |
| 68 | result = publish_issue(client, 42, staging_hub=STAGING_HUB, production_hub=PRODUCTION_HUB) |
| 69 | |
| 70 | assert result["action"] == "created" |
| 71 | prod_issues = client.list_issues(PRODUCTION_HUB) |
| 72 | assert len(prod_issues) == 1 |
| 73 | assert prod_issues[0]["title"] == "feat: thing" |
| 74 | assert "Do the thing." in prod_issues[0]["body"] |
| 75 | assert build_mirror_marker(42) in prod_issues[0]["body"] |
| 76 | assert prod_issues[0]["labels"] == ["enhancement"] |
| 77 | |
| 78 | def test_republishing_same_staging_issue_updates_rather_than_duplicates(self, client: FakeHubClient) -> None: |
| 79 | client.seed(STAGING_HUB, 42, title="feat: thing", body="Do the thing.", labels=["enhancement"]) |
| 80 | publish_issue(client, 42, staging_hub=STAGING_HUB, production_hub=PRODUCTION_HUB) |
| 81 | |
| 82 | # Staging ticket gets edited after the first publish. |
| 83 | client.seed(STAGING_HUB, 42, title="feat: thing (updated)", body="Do the thing, better.", labels=["enhancement", "bug"]) |
| 84 | result = publish_issue(client, 42, staging_hub=STAGING_HUB, production_hub=PRODUCTION_HUB) |
| 85 | |
| 86 | assert result["action"] == "updated" |
| 87 | prod_issues = client.list_issues(PRODUCTION_HUB) |
| 88 | assert len(prod_issues) == 1, "must not create a second production issue for the same staging ticket" |
| 89 | assert prod_issues[0]["title"] == "feat: thing (updated)" |
| 90 | assert "better" in prod_issues[0]["body"] |
| 91 | assert prod_issues[0]["labels"] == ["enhancement", "bug"] |
| 92 | |
| 93 | def test_different_staging_issues_produce_separate_production_issues(self, client: FakeHubClient) -> None: |
| 94 | client.seed(STAGING_HUB, 1, title="A", body="body A", labels=[]) |
| 95 | client.seed(STAGING_HUB, 2, title="B", body="body B", labels=[]) |
| 96 | |
| 97 | publish_issue(client, 1, staging_hub=STAGING_HUB, production_hub=PRODUCTION_HUB) |
| 98 | publish_issue(client, 2, staging_hub=STAGING_HUB, production_hub=PRODUCTION_HUB) |
| 99 | |
| 100 | assert len(client.list_issues(PRODUCTION_HUB)) == 2 |
| 101 | |
| 102 | def test_staging_url_in_body_raises_and_blocks_publish(self, client: FakeHubClient) -> None: |
| 103 | """A hard block, not a warning — a warning is exactly the thing that |
| 104 | gets skimmed past at the moment someone's about to post publicly.""" |
| 105 | client.seed( |
| 106 | STAGING_HUB, 7, title="bug: X", |
| 107 | body="See https://staging.musehub.ai/gabriel/musehub/issues/6 for context.", |
| 108 | labels=[], |
| 109 | ) |
| 110 | |
| 111 | with pytest.raises(StagingUrlLeakError, match="staging.musehub.ai"): |
| 112 | publish_issue(client, 7, staging_hub=STAGING_HUB, production_hub=PRODUCTION_HUB) |
| 113 | |
| 114 | assert client.list_issues(PRODUCTION_HUB) == [], ( |
| 115 | "must not publish anything when the body leaks a staging URL" |
| 116 | ) |
| 117 | |
| 118 | def test_clean_body_has_no_warnings(self, client: FakeHubClient) -> None: |
| 119 | client.seed(STAGING_HUB, 8, title="bug: Y", body="No staging links here.", labels=[]) |
| 120 | |
| 121 | result = publish_issue(client, 8, staging_hub=STAGING_HUB, production_hub=PRODUCTION_HUB) |
| 122 | |
| 123 | assert result["warnings"] == [] |
| 124 | |
| 125 | def test_bare_issue_reference_in_body_triggers_warning(self, client: FakeHubClient) -> None: |
| 126 | client.seed( |
| 127 | STAGING_HUB, 9, title="bug: Z", |
| 128 | body="Related to #182 and #183, see also the discussion there.", |
| 129 | labels=[], |
| 130 | ) |
| 131 | |
| 132 | result = publish_issue(client, 9, staging_hub=STAGING_HUB, production_hub=PRODUCTION_HUB) |
| 133 | |
| 134 | assert any("#182" in w and "#183" in w for w in result["warnings"]) |
| 135 | |
| 136 | def test_markdown_heading_does_not_trigger_bare_reference_warning(self, client: FakeHubClient) -> None: |
| 137 | client.seed( |
| 138 | STAGING_HUB, 10, title="bug: W", |
| 139 | body="## Summary\n\nNo real issue references here, just headings.", |
| 140 | labels=[], |
| 141 | ) |
| 142 | |
| 143 | result = publish_issue(client, 10, staging_hub=STAGING_HUB, production_hub=PRODUCTION_HUB) |
| 144 | |
| 145 | assert result["warnings"] == [] |
| 146 | |
| 147 | def test_mirror_marker_is_stable_per_staging_number(self) -> None: |
| 148 | assert build_mirror_marker(42) == build_mirror_marker(42) |
| 149 | assert build_mirror_marker(42) != build_mirror_marker(43) |
File History
1 commit
sha256:411992ae51c0ea420190eb8cfc86e6f04de4d0af5f11238a7542e04cf80afc18
fix(publish_issue): hard-block publishing a body that leaks…
Sonnet 5
patch
21 hours ago