gabriel / muse public
labels.py python
486 lines 18.6 KB
Raw
sha256:c0eba5ad689cec79f4a3fcdc4f5da78556cb4b8cb7b330f944634356c10379ed chore: pivot to nightly channel — bump version to 0.2.0.dev… Sonnet 5 patch 12 days ago
1 import argparse
2 from ._core import *
3
4 def _validate_hex_color(color: str) -> bool:
5 """Return True if *color* is a valid 7-character hex color string (e.g. '#d73a4a').
6
7 Validates without importing ``re`` — checks length, ``#`` prefix, and hex
8 digit range explicitly so there is no import overhead on every CLI invocation.
9 """
10 if len(color) != 7 or color[0] != "#":
11 return False
12 try:
13 int(color[1:], 16)
14 except ValueError:
15 return False
16 return True
17
18 def _lookup_label_by_name(
19 hub_url: str,
20 identity: IdentityEntry,
21 repo_id: str,
22 name: str,
23 ) -> _LabelEntry | None:
24 """Fetch the label list and return the entry whose name matches *name*.
25
26 Returns ``None`` if no label with that name exists. Case-sensitive match.
27 """
28 data = _hub_api(hub_url, identity, "GET", f"/api/repos/{repo_id}/labels")
29 items_val = data.get("items", [])
30 items: list[_LabelEntry] = (
31 [r for r in items_val if isinstance(r, dict)]
32 if isinstance(items_val, list) else []
33 )
34 for item in items:
35 if item.get("name") == name:
36 return item
37 return None
38
39 def run_label_create(args: argparse.Namespace) -> None:
40 """Create a new label on MuseHub.
41
42 Validates name length and colour format locally before any network call.
43 Prints the label_id to stdout in text mode; use ``--json`` for the full
44 API response merged with the standard envelope.
45
46 Agent quickstart
47 ----------------
48 ::
49
50 muse hub label create --name bug --color '#d73a4a' --json
51 muse hub label create --name enhancement --color '#a2eeef' --json
52 # → {"muse_version": "...", ..., "label_id": "...", "name": "bug"}
53
54 Exit codes
55 ----------
56 0 Label created.
57 1 Validation error, conflict (name already exists), or not authenticated.
58 2 Not inside a Muse repository.
59 3 API error.
60 """
61 name: str = args.name.strip()
62 color: str = args.color.strip()
63 description: str | None = args.description
64 json_output: bool = args.json_output
65
66 # ── Local validation — fail fast before any network I/O ──────────────────
67 if not name:
68 print("❌ Label name must not be empty.", file=sys.stderr)
69 raise SystemExit(ExitCode.USER_ERROR)
70 if len(name) > _MAX_LABEL_NAME_LEN:
71 print(
72 f"❌ Label name is too long ({len(name)} chars); "
73 f"maximum is {_MAX_LABEL_NAME_LEN}.",
74 file=sys.stderr,
75 )
76 raise SystemExit(ExitCode.USER_ERROR)
77 if not _validate_hex_color(color):
78 print(
79 f"❌ Invalid colour '{sanitize_display(color)}'. "
80 "Must be a 7-character hex string starting with '#' (e.g. '#d73a4a').",
81 file=sys.stderr,
82 )
83 raise SystemExit(ExitCode.USER_ERROR)
84 if description is not None and len(description) > _MAX_LABEL_DESC_LEN:
85 print(
86 f"❌ Description is too long ({len(description)} chars); "
87 f"maximum is {_MAX_LABEL_DESC_LEN}.",
88 file=sys.stderr,
89 )
90 raise SystemExit(ExitCode.USER_ERROR)
91
92 # ── Network calls ─────────────────────────────────────────────────────────
93 elapsed = start_timer()
94 hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args))
95 repo_id = _resolve_repo_id(hub_url, identity)
96
97 payload: _HubPayload = {"name": name, "color": color}
98 if description is not None:
99 payload["description"] = description
100 data = _hub_api(hub_url, identity, "POST", f"/api/repos/{repo_id}/labels", body=payload)
101
102 if json_output:
103 print(json.dumps({**make_envelope(elapsed), **data}))
104 return
105
106 label_id = sanitize_display(str(data.get("label_id", "")))
107 print(f"✅ Label '{sanitize_display(name)}' created ({label_id}).", file=sys.stderr)
108 print(label_id)
109
110 def run_label_list(args: argparse.Namespace) -> None:
111 """List all labels for the current repo.
112
113 The list endpoint is public — no authentication required for public repos.
114
115 JSON output includes envelope fields plus ``labels`` (array) and
116 ``total`` (int).
117
118 Agent quickstart
119 ----------------
120 ::
121
122 muse hub label list --json
123 muse hub label list --json | jq '.labels[].name'
124
125 Exit codes
126 ----------
127 0 Success (including empty list).
128 1 Not authenticated (private repo).
129 2 Not inside a Muse repository.
130 3 API error.
131 """
132 json_output: bool = args.json_output
133
134 elapsed = start_timer()
135 hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args))
136 repo_id = _resolve_repo_id(hub_url, identity)
137
138 data = _hub_api(hub_url, identity, "GET", f"/api/repos/{repo_id}/labels")
139 items_val = data.get("items", [])
140 items: list[_LabelEntry] = (
141 [r for r in items_val if isinstance(r, dict)]
142 if isinstance(items_val, list) else []
143 )
144 label_total: int = int(data.get("total", len(items)))
145
146 if json_output:
147 print(json.dumps({**make_envelope(elapsed), **{"labels": items, "total": label_total}}))
148 return
149
150 if not items:
151 print(" No labels defined in this repo.", file=sys.stderr)
152 return
153
154 print(f"\n Labels — {sanitize_display(hub_url)}", file=sys.stderr)
155 print(f" {'─' * 50}", file=sys.stderr)
156 for lbl in items:
157 _name = sanitize_display(str(lbl.get("name", "?")))
158 _color = sanitize_display(str(lbl.get("color", "")))
159 _desc = sanitize_display(str(lbl.get("description") or ""))
160 suffix = f" {_desc}" if _desc else ""
161 print(f" {_color} {_name}{suffix}", file=sys.stderr)
162 print("", file=sys.stderr)
163
164 def run_label_update(args: argparse.Namespace) -> None:
165 """Update an existing label's name, colour, or description on MuseHub.
166
167 Looks up the label by its current name, then sends a PATCH request with
168 only the provided fields. At least one of ``--new-name``, ``--new-color``,
169 or ``--new-description`` must be supplied.
170
171 JSON output is the raw API response merged with the standard envelope.
172 Human-readable text goes to stderr.
173
174 Agent quickstart
175 ----------------
176 ::
177
178 muse hub label update --name bug --new-color '#b60205' --json
179 muse hub label update --name bug --new-name bug-report --json
180 # → {"muse_version": "...", ..., "name": "bug-report", "color": "#b60205"}
181
182 Exit codes
183 ----------
184 0 Label updated.
185 1 Validation error, label not found, or not authenticated.
186 2 Not inside a Muse repository.
187 3 API error.
188 """
189 name: str = args.name.strip()
190 new_name: str | None = args.new_name
191 new_color: str | None = args.new_color
192 new_description: str | None = args.new_description
193 json_output: bool = args.json_output
194
195 # ── Local validation ──────────────────────────────────────────────────────
196 if not name:
197 print("❌ Label name must not be empty.", file=sys.stderr)
198 raise SystemExit(ExitCode.USER_ERROR)
199 if new_name is None and new_color is None and new_description is None:
200 print(
201 "❌ Provide at least one of --new-name, --new-color, --new-description.",
202 file=sys.stderr,
203 )
204 raise SystemExit(ExitCode.USER_ERROR)
205 if new_name is not None:
206 new_name = new_name.strip()
207 if not new_name:
208 print("❌ New label name must not be empty.", file=sys.stderr)
209 raise SystemExit(ExitCode.USER_ERROR)
210 if len(new_name) > _MAX_LABEL_NAME_LEN:
211 print(
212 f"❌ New name is too long ({len(new_name)} chars); "
213 f"maximum is {_MAX_LABEL_NAME_LEN}.",
214 file=sys.stderr,
215 )
216 raise SystemExit(ExitCode.USER_ERROR)
217 if new_color is not None:
218 new_color = new_color.strip()
219 if not _validate_hex_color(new_color):
220 print(
221 f"❌ Invalid colour '{sanitize_display(new_color)}'. "
222 "Must be a 7-character hex string starting with '#' (e.g. '#d73a4a').",
223 file=sys.stderr,
224 )
225 raise SystemExit(ExitCode.USER_ERROR)
226 if new_description is not None and len(new_description) > _MAX_LABEL_DESC_LEN:
227 print(
228 f"❌ Description is too long ({len(new_description)} chars); "
229 f"maximum is {_MAX_LABEL_DESC_LEN}.",
230 file=sys.stderr,
231 )
232 raise SystemExit(ExitCode.USER_ERROR)
233
234 # ── Network calls ─────────────────────────────────────────────────────────
235 elapsed = start_timer()
236 hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args))
237 repo_id = _resolve_repo_id(hub_url, identity)
238
239 # Look up label by name to get the ID.
240 label = _lookup_label_by_name(hub_url, identity, repo_id, name)
241 if label is None:
242 print(
243 f"❌ Label '{sanitize_display(name)}' not found in this repo.",
244 file=sys.stderr,
245 )
246 raise SystemExit(ExitCode.USER_ERROR)
247
248 label_id = str(label.get("label_id", ""))
249 patch: _HubPayload = {}
250 if new_name is not None:
251 patch["name"] = new_name
252 if new_color is not None:
253 patch["color"] = new_color
254 if new_description is not None:
255 patch["description"] = new_description
256
257 data = _hub_api(
258 hub_url, identity, "PATCH",
259 f"/api/repos/{repo_id}/labels/{urllib.parse.quote(label_id, safe='')}",
260 body=patch,
261 )
262
263 if json_output:
264 print(json.dumps({**make_envelope(elapsed), **data}))
265 return
266
267 display_name = sanitize_display(str(data.get("name", name)))
268 print(f"✅ Label '{sanitize_display(name)}' updated → '{display_name}'.", file=sys.stderr)
269
270 def run_label_delete(args: argparse.Namespace) -> None:
271 """Delete a label from MuseHub and remove it from all issues and proposals.
272
273 Looks up the label by name, then sends a DELETE request. This operation
274 is irreversible.
275
276 JSON output includes envelope fields plus ``deleted`` (bool), ``name``
277 (str), and ``label_id`` (str).
278
279 Agent quickstart
280 ----------------
281 ::
282
283 muse hub label delete --name bug --json
284 # → {"muse_version": "...", ..., "deleted": true, "name": "bug", "label_id": "..."}
285
286 Exit codes
287 ----------
288 0 Label deleted.
289 1 Label not found, or not authenticated.
290 2 Not inside a Muse repository.
291 3 API error.
292 """
293 name: str = args.name.strip()
294 json_output: bool = args.json_output
295
296 if not name:
297 print("❌ Label name must not be empty.", file=sys.stderr)
298 raise SystemExit(ExitCode.USER_ERROR)
299
300 elapsed = start_timer()
301 hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args))
302 repo_id = _resolve_repo_id(hub_url, identity)
303
304 # Look up label by name to get the ID.
305 label = _lookup_label_by_name(hub_url, identity, repo_id, name)
306 if label is None:
307 print(
308 f"❌ Label '{sanitize_display(name)}' not found in this repo.",
309 file=sys.stderr,
310 )
311 raise SystemExit(ExitCode.USER_ERROR)
312
313 label_id = str(label.get("label_id", ""))
314 # DELETE returns 204 with no body; _hub_api returns {} for empty responses.
315 _hub_api(
316 hub_url, identity, "DELETE",
317 f"/api/repos/{repo_id}/labels/{urllib.parse.quote(label_id, safe='')}",
318 )
319
320 if json_output:
321 print(json.dumps({**make_envelope(elapsed), **{"deleted": True, "name": name, "label_id": label_id}}))
322 return
323
324 print(f"✅ Label '{sanitize_display(name)}' deleted.", file=sys.stderr)
325
326 def register(subs: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
327 """Register labels subcommands."""
328 # ── label ─────────────────────────────────────────────────────────────────
329 label_p = subs.add_parser(
330 "label",
331 help="Manage labels on MuseHub.",
332 formatter_class=argparse.RawDescriptionHelpFormatter,
333 )
334 label_subs = label_p.add_subparsers(dest="label_subcommand", metavar="LABEL_COMMAND")
335 label_subs.required = True
336
337 label_create_p = label_subs.add_parser(
338 "create",
339 help="Create a new label.",
340 description=(
341 "Create a repo-scoped label with a name, hex colour, and optional description.\n\n"
342 f"Name must be non-empty and ≤ {_MAX_LABEL_NAME_LEN} characters.\n"
343 "Colour must be a 7-character hex string starting with '#' (e.g. '#d73a4a').\n"
344 "Names must be unique within the repository.\n\n"
345 "Agent quickstart:\n"
346 " muse hub label create --name bug --color '#d73a4a' --json\n"
347 " muse hub label create --name enhancement --color '#a2eeef' "
348 "--description 'New feature or request' --json\n\n"
349 "JSON output keys: label_id, repo_id, name, color, description\n\n"
350 "Exit codes: 0 created, 1 validation/conflict/auth error,\n"
351 " 2 not in repo, 3 API/network error."
352 ),
353 formatter_class=argparse.RawDescriptionHelpFormatter,
354 )
355 label_create_p.add_argument(
356 "--hub", dest="hub", default=None, metavar="URL",
357 help="Override the hub URL from config.",
358 )
359 label_create_p.add_argument(
360 "--repo", dest="repo", default=None, metavar="OWNER/REPO",
361 help="Specify repo as owner/repo (hub base URL taken from config).",
362 )
363 label_create_p.add_argument(
364 "--name", "-n", required=True,
365 help="Label name (must be unique within the repo).",
366 )
367 label_create_p.add_argument(
368 "--color", "-c", required=True,
369 help="Hex colour string, e.g. '#d73a4a'.",
370 )
371 label_create_p.add_argument(
372 "--description", "-d", default=None,
373 help="Optional label description (≤ 200 characters).",
374 )
375 label_create_p.add_argument(
376 "--json", "-j", action="store_true", dest="json_output",
377 help="Emit JSON with the created label.",
378 )
379 label_create_p.set_defaults(func=run_label_create)
380
381 label_list_p = label_subs.add_parser(
382 "list",
383 help="List all labels for the current repo.",
384 description=(
385 "List every label defined in the repository.\n\n"
386 "The list endpoint is public — no authentication required for public repos.\n\n"
387 "Agent quickstart:\n"
388 " muse hub label list --json\n\n"
389 "JSON output: array of label objects (label_id, name, color, description)\n\n"
390 "Exit codes: 0 success, 1 auth error, 2 not in repo, 3 API error."
391 ),
392 formatter_class=argparse.RawDescriptionHelpFormatter,
393 )
394 label_list_p.add_argument(
395 "--hub", dest="hub", default=None, metavar="URL",
396 help="Override the hub URL from config.",
397 )
398 label_list_p.add_argument(
399 "--repo", dest="repo", default=None, metavar="OWNER/REPO",
400 help="Specify repo as owner/repo.",
401 )
402 label_list_p.add_argument(
403 "--json", "-j", action="store_true", dest="json_output",
404 help="Emit JSON array of labels.",
405 )
406 label_list_p.set_defaults(func=run_label_list)
407
408 label_update_p = label_subs.add_parser(
409 "update",
410 help="Update an existing label's name, colour, or description.",
411 description=(
412 "Partially update a label identified by its current name.\n\n"
413 "Only provided flags are changed; omitted fields are left unchanged.\n"
414 f"New name must be ≤ {_MAX_LABEL_NAME_LEN} characters.\n"
415 "Colour must be a 7-character hex string starting with '#'.\n\n"
416 "Agent quickstart:\n"
417 " muse hub label update --name bug --new-color '#b60205' --json\n"
418 " muse hub label update --name bug --new-name bug-report --json\n\n"
419 "JSON output keys: label_id, repo_id, name, color, description\n\n"
420 "Exit codes: 0 updated, 1 validation/auth error, 2 not in repo, 3 API error."
421 ),
422 formatter_class=argparse.RawDescriptionHelpFormatter,
423 )
424 label_update_p.add_argument(
425 "--hub", dest="hub", default=None, metavar="URL",
426 help="Override the hub URL from config.",
427 )
428 label_update_p.add_argument(
429 "--repo", dest="repo", default=None, metavar="OWNER/REPO",
430 help="Specify repo as owner/repo.",
431 )
432 label_update_p.add_argument(
433 "--name", "-n", required=True,
434 help="Current name of the label to update.",
435 )
436 label_update_p.add_argument(
437 "--new-name", dest="new_name", default=None,
438 help="New name for the label.",
439 )
440 label_update_p.add_argument(
441 "--new-color", dest="new_color", default=None,
442 help="New hex colour, e.g. '#b60205'.",
443 )
444 label_update_p.add_argument(
445 "--new-description", dest="new_description", default=None,
446 help="New description (pass empty string to clear).",
447 )
448 label_update_p.add_argument(
449 "--json", "-j", action="store_true", dest="json_output",
450 help="Emit JSON with the updated label.",
451 )
452 label_update_p.set_defaults(func=run_label_update)
453
454 label_delete_p = label_subs.add_parser(
455 "delete",
456 help="Delete a label and remove it from all issues.",
457 description=(
458 "Permanently delete a label by name and remove it from every\n"
459 "issue and proposal it is currently attached to.\n\n"
460 "This operation is irreversible.\n\n"
461 "Agent quickstart:\n"
462 " muse hub label delete --name bug --json\n\n"
463 "Exit codes: 0 deleted, 1 auth/not-found error, 2 not in repo, 3 API error."
464 ),
465 formatter_class=argparse.RawDescriptionHelpFormatter,
466 )
467 label_delete_p.add_argument(
468 "--hub", dest="hub", default=None, metavar="URL",
469 help="Override the hub URL from config.",
470 )
471 label_delete_p.add_argument(
472 "--repo", dest="repo", default=None, metavar="OWNER/REPO",
473 help="Specify repo as owner/repo.",
474 )
475 label_delete_p.add_argument(
476 "--name", "-n", required=True,
477 help="Name of the label to delete.",
478 )
479 label_delete_p.add_argument(
480 "--json", "-j", action="store_true", dest="json_output",
481 help="Emit JSON confirmation on success.",
482 )
483 label_delete_p.set_defaults(func=run_label_delete)
484
485 label_p.set_defaults(func=lambda a: label_p.print_help())
486
File History 10 commits
sha256:c287f599c5429903a139eadf3c5db5d930520e57cb0c3c575d9570e953c3b2d6 chore: bump version to 0.2.0.dev2 — nightly.2 Sonnet 4.6 patch 11 days ago
sha256:8de4334a98c945aace420969d389ad678aa926d4ab4e886b2ac4c4241cb3bf2b revert: keep pyproject.toml in canonical PEP 440 form Sonnet 4.6 patch 14 days ago
sha256:50bb615c573aa4b928fca75b60f82ba2a659910066507cec6a95c412ae22ccb9 docs: add issue docs for push have-negotiation bug (#55) an… Sonnet 4.6 18 days ago
sha256:d90d175cded68aae1d4ffcf4858917854195d0cd8ce1fe73cee4dbc02541cb74 chore: trigger push to surface null-OID paths Sonnet 4.6 23 days ago
sha256:e452ad9a6ace6ccc6d875a35e06caf9da5576a970c1c36133b69a891ce5fefa8 chore: prebuild timing test Sonnet 4.6 33 days ago
sha256:0008ab6695e3e064b3e236b24fd19e538fef6a588eb0d211622f4466d919c0b1 merge: pull staging/dev — advance to 0.2.0rc12 Sonnet 4.6 patch 35 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub … Sonnet 4.6 47 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 49 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 56 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 56 days ago