gabriel / musehub public
publish_issue.py python
168 lines 6.6 KB
Raw
sha256:7c46d5416e26d73cc7a8353598caac8e0cfd13a60551dd7c9bfa081862d00823 feat(dev-tools): publish-issue.sh warns on bare #NNN cross-… Sonnet 5 patch 1 day 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 # Bare cross-references like "#182" — these are staging-only numbers and
31 # won't resolve to the same ticket (or anything at all) on production.
32 # Requires the digits immediately after '#' with no intervening whitespace,
33 # so markdown headings ("## Summary") never match.
34 _BARE_ISSUE_REF_RE = re.compile(r"(?<!\w)#\d+\b")
35
36 DEFAULT_STAGING_HUB = "https://staging.musehub.ai"
37 DEFAULT_PRODUCTION_HUB = "https://musehub.ai"
38
39
40 def build_mirror_marker(staging_number: int) -> str:
41 """A stable, greppable marker appended to every published body.
42
43 Used to find an existing production mirror on re-publish, so re-running
44 this tool updates in place instead of creating a duplicate issue.
45 """
46 return f"_Mirrored from staging#{staging_number}._"
47
48
49 class HubClient(Protocol):
50 def read_issue(self, number: int, hub: str) -> dict: ...
51 def list_issues(self, hub: str, state: str = "all") -> list[dict]: ...
52 def create_issue(self, hub: str, *, title: str, body: str, labels: list[str]) -> dict: ...
53 def update_issue(self, hub: str, number: int, *, title: str, body: str) -> dict: ...
54 def set_labels(self, hub: str, number: int, labels: list[str]) -> None: ...
55
56
57 class MuseHubCliClient:
58 """Real implementation — shells out to `muse hub issue ...`."""
59
60 def _run(self, args: list[str]) -> dict:
61 proc = subprocess.run(
62 ["muse", "hub", *args, "--json"], capture_output=True, text=True,
63 )
64 if proc.returncode != 0:
65 raise RuntimeError(f"muse hub {' '.join(args)} failed: {proc.stderr.strip()}")
66 return json.loads(proc.stdout)
67
68 def read_issue(self, number: int, hub: str) -> dict:
69 return self._run(["issue", "read", str(number), "--hub", hub])
70
71 def list_issues(self, hub: str, state: str = "all") -> list[dict]:
72 return self._run(["issue", "list", "--hub", hub, "--state", state]).get("issues", [])
73
74 def create_issue(self, hub: str, *, title: str, body: str, labels: list[str]) -> dict:
75 args = ["issue", "create", "--hub", hub, "--title", title, "--body", body]
76 for label in labels:
77 args += ["--label", label]
78 return self._run(args)
79
80 def update_issue(self, hub: str, number: int, *, title: str, body: str) -> dict:
81 return self._run([
82 "issue", "update", str(number), "--hub", hub,
83 "--title", title, "--body", body,
84 ])
85
86 def set_labels(self, hub: str, number: int, labels: list[str]) -> None:
87 if not labels:
88 return
89 self._run(["issue", "label", str(number), "--hub", hub, "--set", *labels])
90
91
92 def find_existing_mirror(client: HubClient, production_hub: str, staging_number: int) -> dict | None:
93 marker = build_mirror_marker(staging_number)
94 for issue in client.list_issues(production_hub, state="all"):
95 if marker in (issue.get("body") or ""):
96 return issue
97 return None
98
99
100 def publish_issue(
101 client: HubClient, staging_number: int, *, staging_hub: str, production_hub: str,
102 ) -> dict:
103 """Copy staging issue ``staging_number``'s current content to ``production_hub``.
104
105 Idempotent: a second call for the same ``staging_number`` updates the
106 previously-created mirror in place rather than creating a duplicate.
107 """
108 src = client.read_issue(staging_number, staging_hub)
109
110 warnings: list[str] = []
111 body_text = src.get("body") or ""
112 if _STAGING_URL_RE.search(body_text):
113 warnings.append(
114 "Source body contains a staging.musehub.ai URL — review before sharing publicly."
115 )
116 bare_refs = sorted(set(_BARE_ISSUE_REF_RE.findall(body_text)), key=lambda r: int(r[1:]))
117 if bare_refs:
118 warnings.append(
119 f"Source body contains bare issue references ({', '.join(bare_refs)}) that may "
120 "not resolve to the same tickets on production — review before sharing publicly."
121 )
122
123 marker = build_mirror_marker(staging_number)
124 mirrored_body = f"{src['body']}\n\n{marker}"
125 labels = src.get("labels", [])
126
127 existing = find_existing_mirror(client, production_hub, staging_number)
128 if existing is not None:
129 client.update_issue(production_hub, existing["number"], title=src["title"], body=mirrored_body)
130 client.set_labels(production_hub, existing["number"], labels)
131 return {
132 "action": "updated", "number": existing["number"],
133 "url": existing.get("url"), "warnings": warnings,
134 }
135
136 created = client.create_issue(production_hub, title=src["title"], body=mirrored_body, labels=labels)
137 return {
138 "action": "created", "number": created["number"],
139 "url": created.get("url"), "warnings": warnings,
140 }
141
142
143 def main(argv: list[str] | None = None) -> int:
144 parser = argparse.ArgumentParser(description=__doc__)
145 parser.add_argument("staging_number", type=int, help="Issue number on the staging hub to publish.")
146 parser.add_argument("--staging-hub", default=DEFAULT_STAGING_HUB)
147 parser.add_argument("--production-hub", default=DEFAULT_PRODUCTION_HUB)
148 parser.add_argument("--json", action="store_true", help="Emit JSON (the only supported output form).")
149 args = parser.parse_args(argv)
150
151 client = MuseHubCliClient()
152 try:
153 result = publish_issue(
154 client, args.staging_number,
155 staging_hub=args.staging_hub, production_hub=args.production_hub,
156 )
157 except (RuntimeError, KeyError) as e:
158 print(f"❌ {e}", file=sys.stderr)
159 return 1
160
161 for w in result["warnings"]:
162 print(f"⚠️ {w}", file=sys.stderr)
163 print(json.dumps(result))
164 return 0
165
166
167 if __name__ == "__main__":
168 sys.exit(main())
File History 1 commit
sha256:7c46d5416e26d73cc7a8353598caac8e0cfd13a60551dd7c9bfa081862d00823 feat(dev-tools): publish-issue.sh warns on bare #NNN cross-… Sonnet 5 patch 1 day ago