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