gabriel / musehub public
publish_issue.py python
156 lines 6.0 KB
Raw
sha256:7ab354833d0210a676c81cc3181984ba8898e914d39b10192bcd7c76f1584efd feat(dev-tools): one-way, on-demand staging→production tick… Sonnet 5 patch 2 days ago
1 #!/usr/bin/env python3
2 """Publish a single staging ticket's current content to the production repo.
3
4 #186: gabriel shares tickets on social media as part of developing in public,
5 but doesn't want to send people to staging infrastructure. This is
6 deliberately one-way and on-demand — not a tracker sync. Given this same
7 session found a phantom-success write bug and an issue-numbering collision
8 in staging's own issue tracking (#182/#183/#184), an always-on bidirectional
9 mirror would just add a second place for that class of bug to bite. Instead:
10 copy one ticket's current content to production, on request, idempotently.
11
12 Explicitly out of scope: comments, assignees, state transitions, and any
13 kind of two-way sync — see #186 for the full rationale.
14
15 Usage::
16
17 python3 publish_issue.py 185 --json
18 python3 publish_issue.py 185 --production-hub https://musehub.ai --json
19 """
20 from __future__ import annotations
21
22 import argparse
23 import json
24 import re
25 import subprocess
26 import sys
27 from typing import Protocol
28
29 _STAGING_URL_RE = re.compile(r"https?://staging\.musehub\.ai\S*")
30
31 DEFAULT_STAGING_HUB = "https://staging.musehub.ai"
32 DEFAULT_PRODUCTION_HUB = "https://musehub.ai"
33
34
35 def build_mirror_marker(staging_number: int) -> str:
36 """A stable, greppable marker appended to every published body.
37
38 Used to find an existing production mirror on re-publish, so re-running
39 this tool updates in place instead of creating a duplicate issue.
40 """
41 return f"_Mirrored from staging#{staging_number}._"
42
43
44 class HubClient(Protocol):
45 def read_issue(self, number: int, hub: str) -> dict: ...
46 def list_issues(self, hub: str, state: str = "all") -> list[dict]: ...
47 def create_issue(self, hub: str, *, title: str, body: str, labels: list[str]) -> dict: ...
48 def update_issue(self, hub: str, number: int, *, title: str, body: str) -> dict: ...
49 def set_labels(self, hub: str, number: int, labels: list[str]) -> None: ...
50
51
52 class MuseHubCliClient:
53 """Real implementation — shells out to `muse hub issue ...`."""
54
55 def _run(self, args: list[str]) -> dict:
56 proc = subprocess.run(
57 ["muse", "hub", *args, "--json"], capture_output=True, text=True,
58 )
59 if proc.returncode != 0:
60 raise RuntimeError(f"muse hub {' '.join(args)} failed: {proc.stderr.strip()}")
61 return json.loads(proc.stdout)
62
63 def read_issue(self, number: int, hub: str) -> dict:
64 return self._run(["issue", "read", str(number), "--hub", hub])
65
66 def list_issues(self, hub: str, state: str = "all") -> list[dict]:
67 return self._run(["issue", "list", "--hub", hub, "--state", state]).get("issues", [])
68
69 def create_issue(self, hub: str, *, title: str, body: str, labels: list[str]) -> dict:
70 args = ["issue", "create", "--hub", hub, "--title", title, "--body", body]
71 for label in labels:
72 args += ["--label", label]
73 return self._run(args)
74
75 def update_issue(self, hub: str, number: int, *, title: str, body: str) -> dict:
76 return self._run([
77 "issue", "update", str(number), "--hub", hub,
78 "--title", title, "--body", body,
79 ])
80
81 def set_labels(self, hub: str, number: int, labels: list[str]) -> None:
82 if not labels:
83 return
84 self._run(["issue", "label", str(number), "--hub", hub, "--set", *labels])
85
86
87 def find_existing_mirror(client: HubClient, production_hub: str, staging_number: int) -> dict | None:
88 marker = build_mirror_marker(staging_number)
89 for issue in client.list_issues(production_hub, state="all"):
90 if marker in (issue.get("body") or ""):
91 return issue
92 return None
93
94
95 def publish_issue(
96 client: HubClient, staging_number: int, *, staging_hub: str, production_hub: str,
97 ) -> dict:
98 """Copy staging issue ``staging_number``'s current content to ``production_hub``.
99
100 Idempotent: a second call for the same ``staging_number`` updates the
101 previously-created mirror in place rather than creating a duplicate.
102 """
103 src = client.read_issue(staging_number, staging_hub)
104
105 warnings: list[str] = []
106 if _STAGING_URL_RE.search(src.get("body") or ""):
107 warnings.append(
108 "Source body contains a staging.musehub.ai URL — review before sharing publicly."
109 )
110
111 marker = build_mirror_marker(staging_number)
112 mirrored_body = f"{src['body']}\n\n{marker}"
113 labels = src.get("labels", [])
114
115 existing = find_existing_mirror(client, production_hub, staging_number)
116 if existing is not None:
117 client.update_issue(production_hub, existing["number"], title=src["title"], body=mirrored_body)
118 client.set_labels(production_hub, existing["number"], labels)
119 return {
120 "action": "updated", "number": existing["number"],
121 "url": existing.get("url"), "warnings": warnings,
122 }
123
124 created = client.create_issue(production_hub, title=src["title"], body=mirrored_body, labels=labels)
125 return {
126 "action": "created", "number": created["number"],
127 "url": created.get("url"), "warnings": warnings,
128 }
129
130
131 def main(argv: list[str] | None = None) -> int:
132 parser = argparse.ArgumentParser(description=__doc__)
133 parser.add_argument("staging_number", type=int, help="Issue number on the staging hub to publish.")
134 parser.add_argument("--staging-hub", default=DEFAULT_STAGING_HUB)
135 parser.add_argument("--production-hub", default=DEFAULT_PRODUCTION_HUB)
136 parser.add_argument("--json", action="store_true", help="Emit JSON (the only supported output form).")
137 args = parser.parse_args(argv)
138
139 client = MuseHubCliClient()
140 try:
141 result = publish_issue(
142 client, args.staging_number,
143 staging_hub=args.staging_hub, production_hub=args.production_hub,
144 )
145 except (RuntimeError, KeyError) as e:
146 print(f"❌ {e}", file=sys.stderr)
147 return 1
148
149 for w in result["warnings"]:
150 print(f"⚠️ {w}", file=sys.stderr)
151 print(json.dumps(result))
152 return 0
153
154
155 if __name__ == "__main__":
156 sys.exit(main())
File History 1 commit
sha256:7ab354833d0210a676c81cc3181984ba8898e914d39b10192bcd7c76f1584efd feat(dev-tools): one-way, on-demand staging→production tick… Sonnet 5 patch 2 days ago