_core.py
python
sha256:1ddad36d76d3a8d323f9b3664169cb184b7a38b39208214a2ae504154260826f
fix: show full cryptographic IDs in all human-readable CLI output
Sonnet 4.6
patch
38 days ago
| 1 | """muse hub — MuseHub fabric connection management. |
| 2 | |
| 3 | The hub is not just a remote. It is the shared fabric where versioned |
| 4 | multidimensional state flows across agents, humans, and repositories. |
| 5 | Connecting a repo to a hub anchors it to the synchronisation layer that |
| 6 | enables push/pull, plugin discovery, and multi-agent coordination. |
| 7 | |
| 8 | Separation of concerns |
| 9 | ----------------------- |
| 10 | - ``muse remote`` manages generic push/pull endpoints (any Muse server). |
| 11 | - ``muse hub`` manages the *primary identity fabric* — the one hub this |
| 12 | repo belongs to for authentication, discovery, and coordination. |
| 13 | |
| 14 | A repo has at most **one** hub. It may have many remotes. |
| 15 | |
| 16 | Security model |
| 17 | -------------- |
| 18 | - ``_normalise_url`` enforces HTTPS for non-loopback hosts at connect time. |
| 19 | - ``_hub_api`` validates the URL scheme before every network request, |
| 20 | preventing SSRF from a tampered ``config.toml``. |
| 21 | - ``_hub_api`` caps the response body at ``_MAX_API_RESPONSE_BYTES`` to |
| 22 | prevent OOM from a hostile hub. |
| 23 | - All user-controlled values (proposal titles, branch names, hub URLs) are passed |
| 24 | through ``sanitize_display()`` before being printed. |
| 25 | - All diagnostic messages go to **stderr**; **stdout** is reserved for JSON |
| 26 | and machine-readable data. |
| 27 | |
| 28 | Subcommands |
| 29 | ----------- |
| 30 | :: |
| 31 | |
| 32 | muse hub connect <url> Attach this repo to a MuseHub instance. |
| 33 | muse hub status [--json] Show connection and identity information. |
| 34 | muse hub disconnect [--json] Remove the hub association from this repo. |
| 35 | muse hub ping [--json] Test HTTP connectivity to the hub. |
| 36 | |
| 37 | muse hub proposal list [--json] List proposals on MuseHub. |
| 38 | muse hub proposal read <proposal-id> [--json] Show a single proposal. |
| 39 | muse hub proposal create [--json] Open a new proposal. |
| 40 | muse hub proposal merge <proposal-id> [--json] Merge a proposal. |
| 41 | |
| 42 | JSON schemas |
| 43 | ------------ |
| 44 | ``muse hub connect --json``:: |
| 45 | |
| 46 | {"status": "ok", "hub_url": "<url>", "hostname": "<host>", |
| 47 | "authenticated": true|false, "identity_name": "<name>", |
| 48 | "identity_type": "<type>"} |
| 49 | |
| 50 | ``muse hub status --json``:: |
| 51 | |
| 52 | {"hub_url": "<url>", "hostname": "<host>", |
| 53 | "authenticated": true|false, "identity_type": "<type>", |
| 54 | "identity_name": "<name>", "identity_id": "<id>"} |
| 55 | |
| 56 | ``muse hub disconnect --json``:: |
| 57 | |
| 58 | {"status": "ok"|"nothing_to_do", "hostname": "<host>"} |
| 59 | |
| 60 | ``muse hub ping --json``:: |
| 61 | |
| 62 | {"status": "ok"|"error", "hub_url": "<url>", |
| 63 | "hostname": "<host>", "reachable": true|false, "message": "<msg>"} |
| 64 | |
| 65 | Agent workflow examples |
| 66 | ----------------------- |
| 67 | :: |
| 68 | |
| 69 | # Verify hub auth before starting work |
| 70 | muse hub status --json | python3 -c "import json,sys; d=json.load(sys.stdin); sys.exit(0 if d['authenticated'] else 1)" |
| 71 | |
| 72 | # Create a proposal and capture the ID |
| 73 | muse hub proposal create --title "feat: x" --json | python3 -c "import json,sys; print(json.load(sys.stdin)['proposalId'])" |
| 74 | |
| 75 | # List open proposals (machine-readable) |
| 76 | muse hub proposal list --json |
| 77 | |
| 78 | # Merge with squash strategy |
| 79 | muse hub proposal merge af54753d --strategy squash --json |
| 80 | """ |
| 81 | |
| 82 | import argparse |
| 83 | import http.client |
| 84 | import json |
| 85 | import logging |
| 86 | import re |
| 87 | import sys |
| 88 | import textwrap |
| 89 | import urllib.error |
| 90 | import urllib.parse |
| 91 | import urllib.request |
| 92 | from collections.abc import Mapping |
| 93 | from typing import IO, TypedDict |
| 94 | |
| 95 | from muse.cli.config import ( |
| 96 | clear_hub_url, |
| 97 | get_hub_url, |
| 98 | get_remote, |
| 99 | list_remotes, |
| 100 | set_hub_url, |
| 101 | ) |
| 102 | from muse.core.envelope import EnvelopeJson, make_envelope |
| 103 | from muse.core.errors import ExitCode |
| 104 | from muse.core.identity import IdentityEntry, load_identity |
| 105 | from muse.core.repo import find_repo_root |
| 106 | from muse.core.refs import read_current_branch |
| 107 | from muse.core.timing import start_timer |
| 108 | from muse.core.types import Metadata, short_id, split_id |
| 109 | from muse.core.validation import clamp_int, sanitize_display |
| 110 | |
| 111 | logger = logging.getLogger(__name__) |
| 112 | |
| 113 | type _HubPayload = dict[str, str | bool | list[str]] |
| 114 | type _ProposalPayload = dict[str, str | list[str]] |
| 115 | |
| 116 | _CONNECT_TIMEOUT = 8 # seconds for ping/status health check |
| 117 | _MAX_API_RESPONSE_BYTES = 4 * 1024 * 1024 # 4 MiB cap on API responses |
| 118 | |
| 119 | # Only allow http and https — no file://, ftp://, data://, etc. |
| 120 | _ALLOWED_API_SCHEMES = frozenset({"http", "https"}) |
| 121 | |
| 122 | # Maximum proposals fetched when resolving an 8-char prefix — repos with more proposals |
| 123 | # than this will not find older ones by prefix. Intentionally conservative; |
| 124 | # raise if real-world repos hit the limit. |
| 125 | _PROPOSAL_PREFIX_RESOLVE_LIMIT = 100 |
| 126 | _MAX_PROPOSAL_BODY_LINES = 20 # body lines shown in text mode before truncation hint |
| 127 | _MAX_PROPOSAL_TITLE_LEN = 512 # client-side guard: titles longer than this are rejected |
| 128 | _MAX_ISSUE_TITLE_LEN = 512 # client-side guard: issue titles longer than this are rejected |
| 129 | _MAX_REPO_NAME_LEN = 255 # mirrors CreateRepoRequest.name max_length on the server |
| 130 | _MAX_REPO_DESC_LEN = 1024 # reasonable cap on description before sending to server |
| 131 | _MAX_ISSUE_LABEL_LEN = 255 # mirrors label name max_length on the server |
| 132 | _MAX_ISSUE_COMMENT_LEN = 50_000 # mirrors IssueCommentCreate.body max_length on the server |
| 133 | _MAX_LABEL_NAME_LEN = 50 # mirrors LabelCreate.name max_length on the server |
| 134 | _MAX_LABEL_DESC_LEN = 200 # mirrors LabelCreate.description max_length on the server |
| 135 | _MAX_HANDLE_LEN = 64 # mirrors hub identity handle max_length on the server |
| 136 | |
| 137 | # Handle format: starts with letter/digit; continues with letters, digits, |
| 138 | # hyphens, underscores. No leading/trailing whitespace; no shell metacharacters; |
| 139 | # no Unicode confusables. Enforced before any network I/O. |
| 140 | _HANDLE_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$") |
| 141 | |
| 142 | # ── TypedDicts ──────────────────────────────────────────────────────────────── |
| 143 | |
| 144 | class _ConnectJson(EnvelopeJson): |
| 145 | """JSON output for ``muse hub connect --json``. |
| 146 | |
| 147 | Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`. |
| 148 | |
| 149 | Fields |
| 150 | ------ |
| 151 | status Always ``"ok"`` on success — all error paths raise SystemExit |
| 152 | before any JSON is emitted. |
| 153 | hub_url Normalised hub URL with no trailing slash (e.g. ``https://musehub.ai``). |
| 154 | hostname Host and optional port in display form (e.g. ``musehub.ai``). |
| 155 | authenticated ``True`` when an Ed25519 identity is stored for this hub in |
| 156 | ``~/.muse/identity.toml``. |
| 157 | identity_name Handle of the stored identity, or ``""`` when not authenticated. |
| 158 | identity_type ``"human"`` | ``"agent"`` | ``""`` when not authenticated. |
| 159 | """ |
| 160 | |
| 161 | status: str |
| 162 | hub_url: str |
| 163 | hostname: str |
| 164 | authenticated: bool |
| 165 | identity_name: str |
| 166 | identity_type: str |
| 167 | |
| 168 | class _StatusJson(EnvelopeJson): |
| 169 | """JSON output for ``muse hub status --json``. |
| 170 | |
| 171 | Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`. |
| 172 | |
| 173 | All domain keys are always present so agents can access them without guard checks. |
| 174 | |
| 175 | Fields |
| 176 | ------ |
| 177 | hub_url URL stored in ``.muse/config.toml``. |
| 178 | hostname Host and optional port in display form. |
| 179 | authenticated ``True`` when an Ed25519 identity is stored for this hub. |
| 180 | identity_type ``"human"`` | ``"agent"`` | ``""`` when not authenticated. |
| 181 | identity_name Handle of the stored identity, or ``""`` when not authenticated. |
| 182 | identity_id Fingerprint of the stored key, or ``""`` when not authenticated. |
| 183 | capabilities List of capability strings (e.g. ``["read:*"]``); ``[]`` for |
| 184 | humans or when not authenticated. |
| 185 | """ |
| 186 | |
| 187 | hub_url: str |
| 188 | hostname: str |
| 189 | authenticated: bool |
| 190 | identity_type: str |
| 191 | identity_name: str |
| 192 | identity_id: str |
| 193 | capabilities: list[str] |
| 194 | |
| 195 | class _DisconnectJson(EnvelopeJson): |
| 196 | """JSON output for ``muse hub disconnect --json``. |
| 197 | |
| 198 | Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`. |
| 199 | |
| 200 | Fields |
| 201 | ------ |
| 202 | status ``"ok"`` when a hub was disconnected; ``"nothing_to_do"`` when |
| 203 | no hub was configured (idempotent — always exits 0). |
| 204 | hub_url Full normalised URL that was removed, or ``""`` on nothing_to_do. |
| 205 | hostname Host[:port] display form, or ``""`` on nothing_to_do. |
| 206 | """ |
| 207 | |
| 208 | status: str |
| 209 | hub_url: str |
| 210 | hostname: str |
| 211 | |
| 212 | class _PingJson(EnvelopeJson): |
| 213 | """JSON output for ``muse hub ping --json``. |
| 214 | |
| 215 | Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`. |
| 216 | |
| 217 | Fields |
| 218 | ------ |
| 219 | status ``"ok"`` when the hub returned HTTP 2xx; ``"error"`` otherwise. |
| 220 | hub_url URL that was pinged. |
| 221 | hostname Host[:port] display form. |
| 222 | reachable ``True`` when the hub returned a 2xx response. |
| 223 | message Human-readable result: ``"HTTP 200 OK"`` on success, or an error |
| 224 | reason (timeout string, DNS error, HTTP status code, etc.). |
| 225 | """ |
| 226 | |
| 227 | status: str |
| 228 | hub_url: str |
| 229 | hostname: str |
| 230 | reachable: bool |
| 231 | message: str |
| 232 | |
| 233 | class _RepoEntry(TypedDict, total=False): |
| 234 | """A single repository entry in MuseHub list and search API responses. |
| 235 | |
| 236 | All fields are optional (``total=False``) because different endpoints |
| 237 | return different subsets. Callers must guard with ``.get()`` or check |
| 238 | key presence before accessing. |
| 239 | |
| 240 | Fields |
| 241 | ------ |
| 242 | owner Handle of the repository owner (e.g. ``"gabriel"``). |
| 243 | slug URL-safe repository slug (e.g. ``"muse"``). |
| 244 | repoId ID of the repository (camelCase — matches the raw API response). |
| 245 | """ |
| 246 | |
| 247 | owner: str |
| 248 | slug: str |
| 249 | repoId: str |
| 250 | |
| 251 | class _ProposalEntry(TypedDict, total=False): |
| 252 | """A single proposal entry in MuseHub proposal list API responses. |
| 253 | |
| 254 | All fields are optional (``total=False``) because list responses may |
| 255 | omit fields that require an extra database lookup. The ``read`` endpoint |
| 256 | populates the full set; the ``list`` endpoint populates a subset. |
| 257 | |
| 258 | Fields |
| 259 | ------ |
| 260 | proposalId ID of the proposal (camelCase — matches raw API response). |
| 261 | title Human-readable proposal title. |
| 262 | state Lifecycle state: ``"open"`` | ``"merged"`` | ``"closed"``. |
| 263 | fromBranch Source branch name (camelCase — matches raw API response). |
| 264 | toBranch Target branch name (camelCase — matches raw API response). |
| 265 | author Handle of the proposal author. |
| 266 | createdAt ISO-8601 UTC creation timestamp (camelCase — matches raw API). |
| 267 | """ |
| 268 | |
| 269 | proposalId: str |
| 270 | title: str |
| 271 | state: str |
| 272 | fromBranch: str |
| 273 | toBranch: str |
| 274 | author: str |
| 275 | createdAt: str |
| 276 | |
| 277 | class _RepoJson(EnvelopeJson): |
| 278 | """JSON output for ``muse hub repo create --json``. |
| 279 | |
| 280 | Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`. |
| 281 | |
| 282 | Fields |
| 283 | ------ |
| 284 | repo_id ID of the newly created repository. |
| 285 | name Repository name as provided by the caller. |
| 286 | owner Handle of the owner (authenticated user or ``--owner`` override). |
| 287 | slug URL-safe slug derived from the name. |
| 288 | visibility ``"public"`` | ``"private"``. |
| 289 | description Free-text description (may be empty string). |
| 290 | clone_url Full push/pull URL for ``muse remote add`` / ``muse clone``. |
| 291 | tags List of topic tags applied at creation time. |
| 292 | created_at ISO-8601 UTC timestamp of creation (e.g. ``"2025-01-15T12:00:00Z"``). |
| 293 | """ |
| 294 | |
| 295 | repo_id: str |
| 296 | name: str |
| 297 | owner: str |
| 298 | slug: str |
| 299 | visibility: str |
| 300 | description: str |
| 301 | clone_url: str |
| 302 | tags: list[str] |
| 303 | created_at: str |
| 304 | |
| 305 | class _HubApiResponse(TypedDict, total=False): |
| 306 | """Generic MuseHub API response envelope used internally by ``_hub_api``. |
| 307 | |
| 308 | All fields are optional (``total=False``) because different endpoints |
| 309 | populate different subsets. This TypedDict is an internal contract — it |
| 310 | is never emitted directly to callers. Callers extract the specific fields |
| 311 | they need and construct domain-typed output dicts. |
| 312 | |
| 313 | Fields |
| 314 | ------ |
| 315 | repo_id ID of a repo, present on create/read repo endpoints. |
| 316 | repos List of :class:`_RepoEntry` dicts from list/search endpoints. |
| 317 | proposals List of :class:`_ProposalEntry` dicts from proposal endpoints. |
| 318 | """ |
| 319 | |
| 320 | repo_id: str |
| 321 | repos: list[_RepoEntry] |
| 322 | proposals: list[_ProposalEntry] |
| 323 | |
| 324 | class _LabelEntry(TypedDict, total=False): |
| 325 | """A single label row returned by the MuseHub labels API. |
| 326 | |
| 327 | All fields are optional (``total=False``) because create, read, and list |
| 328 | endpoints return different subsets. The ``list`` endpoint populates all |
| 329 | fields; the ``create`` response may omit ``repo_id``. |
| 330 | |
| 331 | Fields |
| 332 | ------ |
| 333 | label_id ID of the label. |
| 334 | repo_id ID of the repository this label belongs to. |
| 335 | name Display name of the label (e.g. ``"bug"``, ``"enhancement"``). |
| 336 | color Hex color code without ``#`` prefix (e.g. ``"d73a4a"``). |
| 337 | description Free-text description shown on the label chip. |
| 338 | """ |
| 339 | |
| 340 | label_id: str |
| 341 | repo_id: str |
| 342 | name: str |
| 343 | color: str |
| 344 | description: str |
| 345 | |
| 346 | # ── Security — redirect refusal for ping ────────────────────────────────────── |
| 347 | |
| 348 | class _NoRedirectHandler(urllib.request.HTTPRedirectHandler): |
| 349 | """Refuse all HTTP redirects for the ping health check. |
| 350 | |
| 351 | No credentials travel on the ping request, but silently following |
| 352 | redirects (potentially cross-scheme or cross-host) is misleading about |
| 353 | what was actually reached and normalises insecure redirect behaviour. |
| 354 | """ |
| 355 | |
| 356 | def redirect_request( |
| 357 | self, |
| 358 | req: urllib.request.Request, |
| 359 | fp: IO[bytes], |
| 360 | code: int, |
| 361 | msg: str, |
| 362 | headers: http.client.HTTPMessage, |
| 363 | newurl: str, |
| 364 | ) -> urllib.request.Request | None: |
| 365 | raise urllib.error.HTTPError( |
| 366 | req.full_url, |
| 367 | code, |
| 368 | f"Redirect refused ({code}): hub redirected to {newurl!r}. Update the hub URL.", |
| 369 | headers, |
| 370 | fp, |
| 371 | ) |
| 372 | |
| 373 | _PING_OPENER = urllib.request.build_opener(_NoRedirectHandler()) |
| 374 | |
| 375 | # ── URL / hostname helpers ──────────────────────────────────────────────────── |
| 376 | |
| 377 | def _normalise_url(url: str) -> str: |
| 378 | """Normalise *url* to an https:// URL (or http:// for loopback addresses). |
| 379 | |
| 380 | Adds ``https://`` when no scheme is present. Raises ``ValueError`` when |
| 381 | an explicit ``http://`` scheme is given for a non-loopback host — sending |
| 382 | sending credentials over cleartext HTTP to a remote server is never acceptable. |
| 383 | |
| 384 | Exception: ``http://localhost``, ``http://127.0.0.1``, and |
| 385 | ``http://[::1]`` (with or without a port) are allowed because loopback |
| 386 | traffic never leaves the machine and cannot be intercepted in transit. |
| 387 | |
| 388 | Args: |
| 389 | url: Raw user-supplied URL. |
| 390 | |
| 391 | Returns: |
| 392 | Normalised URL without a trailing slash. |
| 393 | |
| 394 | Raises: |
| 395 | ValueError: If the URL uses ``http://`` for a non-loopback host, or |
| 396 | uses a disallowed scheme (``file://``, ``ftp://``, etc.). |
| 397 | """ |
| 398 | stripped = url.strip().rstrip("/") |
| 399 | |
| 400 | # Check for an explicit URL scheme BEFORE adding https://, catching |
| 401 | # file:///etc/passwd, ftp://, data:text/plain, javascript:, etc. |
| 402 | # |
| 403 | # We can't unconditionally rely on urlparse's scheme detection because |
| 404 | # bare host:port inputs like "musehub.ai:8443" are misidentified: |
| 405 | # urlparse("musehub.ai:8443").scheme == "musehub.ai". |
| 406 | # |
| 407 | # Heuristic: if the URL contains "://" OR if the part after the first ":" |
| 408 | # is NOT a pure port number (digits), treat the prefix as a scheme. |
| 409 | # Examples: |
| 410 | # "musehub.ai:8443" → after ":" is "8443" (digits) → host:port, skip |
| 411 | # "localhost:8080" → after ":" is "8080" (digits) → host:port, skip |
| 412 | # "data:text/plain,x" → after ":" is "text/plain,x" → scheme, check |
| 413 | # "javascript:alert()" → after ":" is "alert()" → scheme, check |
| 414 | # "https://musehub.ai" → "://" present → scheme, check |
| 415 | colon_idx = stripped.find(":") |
| 416 | if colon_idx > 0: |
| 417 | after_colon = stripped[colon_idx + 1:].lstrip("/") |
| 418 | is_port = after_colon.isdigit() # bare port: "8443", "10003", etc. |
| 419 | if not is_port: |
| 420 | pre_scheme = urllib.parse.urlparse(stripped).scheme.lower() |
| 421 | if pre_scheme and pre_scheme not in _ALLOWED_API_SCHEMES: |
| 422 | raise ValueError( |
| 423 | f"URL scheme '{pre_scheme}' is not allowed. " |
| 424 | "Use https:// (or http:// for localhost)." |
| 425 | ) |
| 426 | |
| 427 | if not stripped.startswith(("http://", "https://")): |
| 428 | stripped = f"https://{stripped}" |
| 429 | |
| 430 | if stripped.startswith("http://"): |
| 431 | # Use urlparse to correctly extract the hostname — handles IPv6 |
| 432 | # bracket notation (e.g. http://[::1]:8080) where manual split(":")[0] |
| 433 | # would yield "[" instead of "::1". |
| 434 | _loopback = {"localhost", "127.0.0.1", "::1"} |
| 435 | parsed_host = urllib.parse.urlparse(stripped).hostname or "" |
| 436 | if parsed_host not in _loopback: |
| 437 | host = stripped[len("http://"):] |
| 438 | raise ValueError( |
| 439 | f"Insecure URL rejected: {stripped!r}\n" |
| 440 | f"MuseHub requires HTTPS for non-loopback hosts. Did you mean: https://{host}" |
| 441 | ) |
| 442 | return stripped |
| 443 | |
| 444 | def _hub_hostname(url: str) -> str: |
| 445 | """Extract the display hostname (host:port) from a hub URL.""" |
| 446 | stripped = url.strip().rstrip("/") |
| 447 | if "://" in stripped: |
| 448 | stripped = stripped.split("://", 1)[1] |
| 449 | return stripped.split("/")[0] |
| 450 | |
| 451 | def _ping_hub(url: str) -> tuple[bool, str]: |
| 452 | """Attempt an HTTP GET to ``<url>/health``. |
| 453 | |
| 454 | Returns a ``(reachable, message)`` tuple. Never raises — all errors are |
| 455 | captured and surfaced as human-readable strings. |
| 456 | |
| 457 | Security note |
| 458 | ------------- |
| 459 | Only ``http`` and ``https`` schemes are attempted. Any other scheme |
| 460 | (``file://``, ``ftp://``, etc.) returns ``(False, "scheme not allowed")`` |
| 461 | without opening a socket — prevents callers from accidentally probing the |
| 462 | local filesystem via a ``--hub`` override. |
| 463 | |
| 464 | Exception coverage |
| 465 | ------------------ |
| 466 | - ``urllib.error.HTTPError`` — non-2xx from a reachable server |
| 467 | - ``urllib.error.URLError`` — DNS failure, connection refused |
| 468 | - ``TimeoutError`` — connect/read timeout |
| 469 | - ``OSError`` — network-layer errors (SSL, connection reset, etc.) |
| 470 | - ``http.client.HTTPException`` — malformed HTTP response |
| 471 | (``BadStatusLine``, ``InvalidURL``) from a misbehaving server; |
| 472 | NOT a subclass of OSError so must be caught separately |
| 473 | """ |
| 474 | # Scheme guard — never probe non-HTTP(S) targets. |
| 475 | scheme = urllib.parse.urlparse(url).scheme.lower() |
| 476 | if scheme not in _ALLOWED_API_SCHEMES: |
| 477 | return False, f"URL scheme '{scheme}' is not allowed (use http or https)" |
| 478 | |
| 479 | # For localhost HTTPS, build a one-shot opener that trusts the mkcert CA. |
| 480 | # _PING_OPENER uses the default CA bundle (no mkcert), so it fails against |
| 481 | # the local dev server's self-signed cert. |
| 482 | ssl_ctx = _hub_api_ssl_context(url) |
| 483 | if ssl_ctx is not None: |
| 484 | opener = urllib.request.build_opener( |
| 485 | _NoRedirectHandler(), |
| 486 | urllib.request.HTTPSHandler(context=ssl_ctx), |
| 487 | ) |
| 488 | else: |
| 489 | opener = _PING_OPENER |
| 490 | |
| 491 | health_url = f"{url.rstrip('/')}/health" |
| 492 | try: |
| 493 | req = urllib.request.Request(health_url, method="GET") |
| 494 | with opener.open(req, timeout=_CONNECT_TIMEOUT) as resp: |
| 495 | status = resp.status |
| 496 | if 200 <= status < 300: |
| 497 | return True, f"HTTP {status} OK" |
| 498 | return False, f"HTTP {status}" |
| 499 | except urllib.error.HTTPError as exc: |
| 500 | return False, f"HTTP {exc.code} {exc.reason}" |
| 501 | except urllib.error.URLError as exc: |
| 502 | return False, str(exc.reason) |
| 503 | except TimeoutError: |
| 504 | return False, "timed out" |
| 505 | except OSError as exc: |
| 506 | return False, str(exc) |
| 507 | except http.client.HTTPException as exc: |
| 508 | # Malformed HTTP response (BadStatusLine, InvalidURL, etc.). |
| 509 | # Not an OSError subclass — must be caught explicitly. |
| 510 | return False, f"malformed response: {type(exc).__name__}" |
| 511 | |
| 512 | # ── MuseHub HTTP helper ─────────────────────────────────────────────────────── |
| 513 | |
| 514 | def _hub_api_ssl_context(url: str) -> "ssl.SSLContext | None": |
| 515 | """Return an SSL context appropriate for *url*. |
| 516 | |
| 517 | - ``https://localhost`` / ``https://127.0.0.1``: loads the local-dev |
| 518 | self-signed CA cert bundled with musehub so that muse CLI tools work |
| 519 | against the local hub without installing the cert system-wide. |
| 520 | - All other HTTPS URLs: return ``None``, letting ``urllib`` use the OS CA |
| 521 | store (standard verification). |
| 522 | - HTTP URLs: return ``None`` (no TLS at all). |
| 523 | |
| 524 | The CA cert path mirrors ``muse.core.transport._httpx_verify`` so both the |
| 525 | push (httpx) and hub-API (urllib) paths trust the same certificate. |
| 526 | |
| 527 | Path layout (6 levels up from this file reaches the ecosystem root): |
| 528 | ``hub/_core.py → hub/ → commands/ → cli/ → muse/ → muse/ → ecosystem/`` |
| 529 | """ |
| 530 | import ssl |
| 531 | from muse.core.transport import _mkcert_ca |
| 532 | |
| 533 | if url.startswith("https://localhost") or url.startswith("https://127.0.0.1"): |
| 534 | ca = _mkcert_ca() |
| 535 | if ca is not None: |
| 536 | ctx = ssl.create_default_context(cafile=str(ca)) |
| 537 | return ctx |
| 538 | return None |
| 539 | |
| 540 | # Read-only HTTP methods that are permitted without an Authorization header so |
| 541 | # that unauthenticated clients can access public resources (e.g. reading issues |
| 542 | # on a public repo). Mutating methods (POST/PUT/DELETE/PATCH) always require |
| 543 | # a signing identity. |
| 544 | _READ_ONLY_METHODS: frozenset[str] = frozenset({"GET", "HEAD", "OPTIONS"}) |
| 545 | |
| 546 | def _hub_api( |
| 547 | hub_url: str, |
| 548 | identity: IdentityEntry, |
| 549 | method: str, |
| 550 | path: str, |
| 551 | body: Mapping[str, str | bool | list[str] | None] | None = None, |
| 552 | timeout: float = 10.0, |
| 553 | ) -> _HubApiResponse: |
| 554 | """Make a JSON request to the MuseHub API, signing it when credentials exist. |
| 555 | |
| 556 | For read-only methods (GET/HEAD/OPTIONS) on public resources this function |
| 557 | proceeds without an ``Authorization`` header when no signing identity is |
| 558 | configured — allowing unauthenticated access to public repos (e.g. reading |
| 559 | issues, listing releases). |
| 560 | |
| 561 | For mutating methods (POST/PUT/DELETE/PATCH) a signing identity is always |
| 562 | required and the function exits non-zero when none is found. |
| 563 | |
| 564 | Security: |
| 565 | - Validates the hub URL scheme (``http``/``https`` only) before opening |
| 566 | any socket — prevents SSRF if ``config.toml`` is tampered with. |
| 567 | - Caps the response body at ``_MAX_API_RESPONSE_BYTES`` to prevent OOM |
| 568 | from a hostile or misbehaving hub. |
| 569 | - Sanitizes error detail from HTTP responses before printing. |
| 570 | - For ``https://localhost`` uses the local-dev CA cert (not CERT_NONE) so |
| 571 | self-signed certs are verified against a known CA rather than bypassed. |
| 572 | |
| 573 | Args: |
| 574 | hub_url: Repository-level hub URL (e.g. ``https://localhost:1337/gabriel/muse``). |
| 575 | identity: Identity entry from ``~/.muse/identity.toml``. |
| 576 | method: HTTP method (``GET``, ``POST``, ``DELETE``, …). |
| 577 | path: API path appended to the server root (e.g. ``/gabriel/muse/issues/5``). |
| 578 | body: Optional JSON body for POST/PUT requests. |
| 579 | timeout: Connect+read timeout in seconds. |
| 580 | |
| 581 | Returns: |
| 582 | Parsed JSON response body as a dict. |
| 583 | |
| 584 | Raises: |
| 585 | SystemExit: On invalid URL scheme, missing token for mutating requests, |
| 586 | network errors, or non-2xx responses. |
| 587 | """ |
| 588 | parsed = urllib.parse.urlparse(hub_url) |
| 589 | scheme = parsed.scheme.lower() |
| 590 | if scheme not in _ALLOWED_API_SCHEMES: |
| 591 | print( |
| 592 | f"❌ Hub URL scheme '{sanitize_display(scheme)}' is not allowed. " |
| 593 | "Use http or https.", |
| 594 | file=sys.stderr, |
| 595 | ) |
| 596 | raise SystemExit(ExitCode.USER_ERROR) |
| 597 | |
| 598 | server_root = f"{parsed.scheme}://{parsed.netloc}" |
| 599 | url = f"{server_root}{path}" |
| 600 | |
| 601 | from muse.cli.config import get_signing_identity |
| 602 | signing = get_signing_identity(remote_url=hub_url) |
| 603 | |
| 604 | normalized_method = method.upper() |
| 605 | if not signing and normalized_method not in _READ_ONLY_METHODS: |
| 606 | print( |
| 607 | "❌ No signing identity for this hub. Run:\n" |
| 608 | " muse auth keygen --hub <url>\n" |
| 609 | " muse auth register --hub <url> --handle <your-handle>", |
| 610 | file=sys.stderr, |
| 611 | ) |
| 612 | raise SystemExit(ExitCode.USER_ERROR) |
| 613 | |
| 614 | data: bytes | None = None |
| 615 | body_bytes: bytes | None = None |
| 616 | if body is not None: |
| 617 | body_bytes = json.dumps(body).encode() |
| 618 | data = body_bytes |
| 619 | |
| 620 | headers: Metadata = {"Accept": "application/json"} |
| 621 | if signing: |
| 622 | from muse.core.msign import build_msign_header |
| 623 | headers["Authorization"] = build_msign_header(signing, normalized_method, url, body_bytes) |
| 624 | if body_bytes is not None: |
| 625 | headers["Content-Type"] = "application/json" |
| 626 | |
| 627 | ssl_ctx = _hub_api_ssl_context(url) |
| 628 | req = urllib.request.Request(url, data=data, headers=headers, method=normalized_method) |
| 629 | try: |
| 630 | with urllib.request.urlopen(req, timeout=timeout, context=ssl_ctx) as resp: # noqa: S310 |
| 631 | raw = resp.read(_MAX_API_RESPONSE_BYTES + 1) |
| 632 | if len(raw) > _MAX_API_RESPONSE_BYTES: |
| 633 | print( |
| 634 | f"❌ Response from {sanitize_display(url)} exceeds size limit.", |
| 635 | file=sys.stderr, |
| 636 | ) |
| 637 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 638 | text = raw.decode("utf-8", errors="replace") |
| 639 | parsed = json.loads(text) if text.strip() else {} |
| 640 | return parsed if isinstance(parsed, dict) else {} |
| 641 | except urllib.error.HTTPError as exc: |
| 642 | raw_body = exc.read().decode(errors="replace") |
| 643 | try: |
| 644 | detail = json.loads(raw_body).get("detail", raw_body) |
| 645 | except (json.JSONDecodeError, AttributeError): |
| 646 | detail = raw_body[:200] |
| 647 | print( |
| 648 | f"❌ MuseHub API error {exc.code}: {sanitize_display(str(detail))}", |
| 649 | file=sys.stderr, |
| 650 | ) |
| 651 | raise SystemExit(ExitCode.INTERNAL_ERROR) from exc |
| 652 | except urllib.error.URLError as exc: |
| 653 | print( |
| 654 | f"❌ Cannot reach MuseHub: {sanitize_display(str(exc.reason))}", |
| 655 | file=sys.stderr, |
| 656 | ) |
| 657 | raise SystemExit(ExitCode.INTERNAL_ERROR) from exc |
| 658 | |
| 659 | # ── Repo / identity resolution helpers ─────────────────────────────────────── |
| 660 | |
| 661 | def _enrich_hub_url_from_remote(hub_base_url: str) -> str: |
| 662 | """Append owner/slug to a bare hub URL by cross-referencing configured remotes. |
| 663 | |
| 664 | When the hub URL stored by ``muse hub connect`` is a bare base URL |
| 665 | (e.g. ``https://localhost:1337``) with no owner/slug path component, |
| 666 | this function searches the configured remotes for one whose netloc matches |
| 667 | the hub netloc and extracts the owner/slug from its path. |
| 668 | |
| 669 | Returns the enriched URL on a match; returns *hub_base_url* unchanged when |
| 670 | no matching remote is found or when the caller is not inside a repo. |
| 671 | """ |
| 672 | base_parsed = urllib.parse.urlparse(hub_base_url) |
| 673 | if len(base_parsed.path.strip("/").split("/")) >= 2 and base_parsed.path.strip("/"): |
| 674 | # Already has owner/slug — nothing to do. |
| 675 | return hub_base_url |
| 676 | root = find_repo_root() |
| 677 | if root is None: |
| 678 | return hub_base_url |
| 679 | for remote in list_remotes(root): |
| 680 | r = urllib.parse.urlparse(remote["url"]) |
| 681 | if r.netloc == base_parsed.netloc: |
| 682 | parts = r.path.strip("/").split("/") |
| 683 | if len(parts) >= 2 and parts[0]: |
| 684 | return f"{base_parsed.scheme}://{base_parsed.netloc}/{parts[0]}/{parts[1]}" |
| 685 | return hub_base_url |
| 686 | |
| 687 | def _resolve_repo_id(hub_url: str, identity: IdentityEntry) -> str: |
| 688 | """Look up the MuseHub repo ID from the hub URL's owner/slug path. |
| 689 | |
| 690 | The hub URL is ``http://host/owner/slug``. This calls |
| 691 | ``GET /{owner}/{slug}/refs`` to obtain the ID, falling back to the |
| 692 | search endpoint if the refs response does not include ``repo_id``. |
| 693 | |
| 694 | When the hub URL is a bare base URL (no owner/slug), the function first |
| 695 | attempts to enrich it from the configured remotes via |
| 696 | :func:`_enrich_hub_url_from_remote`. |
| 697 | |
| 698 | Raises: |
| 699 | SystemExit: If the URL has no owner/slug or the repo ID cannot be resolved. |
| 700 | """ |
| 701 | hub_url = _enrich_hub_url_from_remote(hub_url) |
| 702 | parsed = urllib.parse.urlparse(hub_url) |
| 703 | parts = parsed.path.strip("/").split("/") |
| 704 | if len(parts) < 2: |
| 705 | print( |
| 706 | f"❌ Hub URL '{sanitize_display(hub_url)}' does not contain " |
| 707 | "owner/slug — cannot resolve repo ID.", |
| 708 | file=sys.stderr, |
| 709 | ) |
| 710 | raise SystemExit(ExitCode.USER_ERROR) |
| 711 | owner, slug = parts[0], parts[1] |
| 712 | |
| 713 | data = _hub_api(hub_url, identity, "GET", f"/{owner}/{slug}/refs") |
| 714 | repo_id: str = str(data.get("repo_id", "")) |
| 715 | if not repo_id: |
| 716 | # Fallback: search endpoint |
| 717 | search = _hub_api( |
| 718 | hub_url, identity, "GET", |
| 719 | f"/api/search?q={urllib.parse.quote(slug)}&limit=5", |
| 720 | ) |
| 721 | repos_val = search.get("repos", []) |
| 722 | repos_list: list[_RepoEntry] = ( |
| 723 | [r for r in repos_val if isinstance(r, dict)] |
| 724 | if isinstance(repos_val, list) else [] |
| 725 | ) |
| 726 | for repo in repos_list: |
| 727 | if repo.get("owner") == owner and repo.get("slug") == slug: |
| 728 | repo_id = str(repo.get("repoId", "")) |
| 729 | break |
| 730 | if not repo_id: |
| 731 | print( |
| 732 | f"❌ Could not resolve repo ID for {sanitize_display(owner)}/{sanitize_display(slug)}.", |
| 733 | file=sys.stderr, |
| 734 | ) |
| 735 | raise SystemExit(ExitCode.USER_ERROR) |
| 736 | return repo_id |
| 737 | |
| 738 | def _resolve_repo_id_from_owner_repo( |
| 739 | hub_url: str, |
| 740 | identity: IdentityEntry, |
| 741 | owner_repo: str, |
| 742 | ) -> str: |
| 743 | """Resolve a repo ID from an ``owner/repo`` string. |
| 744 | |
| 745 | Calls ``GET /{owner}/{slug}/refs`` to obtain the ID. |
| 746 | |
| 747 | Args: |
| 748 | hub_url: Base hub URL (e.g. ``https://localhost:1337``). |
| 749 | identity: Caller identity for auth. |
| 750 | owner_repo: String in ``owner/slug`` format (e.g. ``gabriel/muse``). |
| 751 | |
| 752 | Raises: |
| 753 | SystemExit: If owner_repo is malformed or the repo cannot be resolved. |
| 754 | """ |
| 755 | parts = owner_repo.strip("/").split("/") |
| 756 | if len(parts) != 2: |
| 757 | print(f"❌ --repo must be in owner/repo format, got {owner_repo!r}.", file=sys.stderr) |
| 758 | raise SystemExit(ExitCode.USER_ERROR) |
| 759 | owner, slug = parts[0], parts[1] |
| 760 | |
| 761 | # Build a repo-scoped URL to piggyback on _resolve_repo_id |
| 762 | base = hub_url.split("/api")[0].split("/#")[0].rstrip("/") |
| 763 | repo_hub_url = f"{base}/{owner}/{slug}" |
| 764 | return _resolve_repo_id(repo_hub_url, identity) |
| 765 | |
| 766 | def _resolve_body(args: argparse.Namespace) -> str: |
| 767 | """Return the effective body string from ``--body`` or ``--body-file``. |
| 768 | |
| 769 | Resolution order: |
| 770 | 1. ``--body-file PATH`` — read the entire file at PATH as UTF-8. |
| 771 | Pass ``-`` to read from stdin instead. |
| 772 | 2. ``--body TEXT`` — use the literal string. |
| 773 | 3. (neither) — return ``""`` (empty string). |
| 774 | |
| 775 | If both are provided ``--body-file`` wins and ``--body`` is ignored. |
| 776 | |
| 777 | This helper exists because large bodies (markdown, code blocks, ASCII art) |
| 778 | cannot survive shell quoting when passed inline via ``--body``. Writing |
| 779 | the body to a temp file and using ``--body-file`` is the ergonomic path |
| 780 | for agents and humans alike. |
| 781 | |
| 782 | Args: |
| 783 | args: Parsed :class:`argparse.Namespace`. Must have ``body`` and |
| 784 | ``body_file`` attributes (both optional/defaulting to ``None``). |
| 785 | |
| 786 | Returns: |
| 787 | Body string (may be empty). |
| 788 | |
| 789 | Raises: |
| 790 | SystemExit: If ``--body-file PATH`` cannot be read (file not found, |
| 791 | permission denied, etc.). |
| 792 | """ |
| 793 | import os |
| 794 | |
| 795 | body_file: str | None = getattr(args, "body_file", None) |
| 796 | body: str = getattr(args, "body", "") or "" |
| 797 | |
| 798 | if not body_file: |
| 799 | return body |
| 800 | |
| 801 | if body_file == "-": |
| 802 | return sys.stdin.read() |
| 803 | |
| 804 | try: |
| 805 | with open(body_file, encoding="utf-8") as fh: |
| 806 | return fh.read() |
| 807 | except OSError as exc: |
| 808 | print(f"❌ Cannot read --body-file {body_file!r}: {exc}", file=sys.stderr) |
| 809 | raise SystemExit(ExitCode.USER_ERROR) from exc |
| 810 | |
| 811 | def _validate_assignee(handle: str, *, allow_empty: bool = False) -> None: |
| 812 | """Validate a MuseHub handle before sending it to the API. |
| 813 | |
| 814 | Called on every user-supplied handle — at create time (``--assignee``), |
| 815 | update time (``--assign``), and the dedicated ``issue assign`` command. |
| 816 | |
| 817 | Threat model |
| 818 | ------------ |
| 819 | Handles are echoed back to the terminal and embedded in API requests. |
| 820 | Malformed values could cause: |
| 821 | |
| 822 | * **Terminal injection** — ANSI escape sequences hidden inside handle |
| 823 | strings can rewrite terminal output after the fact. |
| 824 | * **Null-byte truncation** — ``\\x00`` in a string truncates C-string |
| 825 | paths, and some JSON parsers mis-handle it. |
| 826 | * **Newline injection** — ``\\n`` in a value echoed to stdout/stderr can |
| 827 | make error messages look like success messages. |
| 828 | * **Control-character abuse** — backspace, carriage-return, etc. can |
| 829 | visually overwrite earlier output. |
| 830 | |
| 831 | All of the above are rejected before any network I/O or terminal output. |
| 832 | |
| 833 | Args: |
| 834 | handle: The handle string to validate. Trailing/leading |
| 835 | whitespace has already been stripped by argparse. |
| 836 | allow_empty: When ``True`` (used by ``issue assign``), an empty |
| 837 | string is accepted and means "unassign". When ``False`` |
| 838 | (used by ``issue create``), an empty string is an error. |
| 839 | |
| 840 | Raises: |
| 841 | SystemExit(ExitCode.USER_ERROR): On any validation failure. |
| 842 | """ |
| 843 | if not handle: |
| 844 | if allow_empty: |
| 845 | return |
| 846 | print("❌ --assignee must not be empty.", file=sys.stderr) |
| 847 | raise SystemExit(ExitCode.USER_ERROR) |
| 848 | |
| 849 | # Reject null bytes and every ASCII control character (0x00–0x1F, 0x7F). |
| 850 | # These can cause terminal injection, string truncation, or newline spoofing. |
| 851 | if any(ord(c) < 0x20 or ord(c) == 0x7F for c in handle): |
| 852 | print( |
| 853 | "❌ --assignee contains a control character (null byte, newline, " |
| 854 | "etc.) which is not allowed in handles.", |
| 855 | file=sys.stderr, |
| 856 | ) |
| 857 | raise SystemExit(ExitCode.USER_ERROR) |
| 858 | |
| 859 | # Reject non-ASCII entirely — Unicode confusables and RTL override |
| 860 | # characters can impersonate other handles visually. |
| 861 | if not handle.isascii(): |
| 862 | print( |
| 863 | f"❌ --assignee {sanitize_display(handle)!r} contains non-ASCII " |
| 864 | "characters; handles must be plain ASCII.", |
| 865 | file=sys.stderr, |
| 866 | ) |
| 867 | raise SystemExit(ExitCode.USER_ERROR) |
| 868 | |
| 869 | if len(handle) > _MAX_HANDLE_LEN: |
| 870 | print( |
| 871 | f"❌ --assignee is too long ({len(handle)} chars); " |
| 872 | f"maximum is {_MAX_HANDLE_LEN}.", |
| 873 | file=sys.stderr, |
| 874 | ) |
| 875 | raise SystemExit(ExitCode.USER_ERROR) |
| 876 | |
| 877 | if not _HANDLE_RE.match(handle): |
| 878 | print( |
| 879 | f"❌ --assignee {sanitize_display(handle)!r} is not a valid handle. " |
| 880 | "Handles must start with a letter or digit and contain only " |
| 881 | "letters, digits, hyphens (-), and underscores (_).", |
| 882 | file=sys.stderr, |
| 883 | ) |
| 884 | raise SystemExit(ExitCode.USER_ERROR) |
| 885 | |
| 886 | def _get_hub_and_identity( |
| 887 | remote: str | None = None, |
| 888 | hub_url_override: str | None = None, |
| 889 | ) -> tuple[str, IdentityEntry]: |
| 890 | """Load hub URL and identity for the current repo, or exit with a helpful error. |
| 891 | |
| 892 | Falls back to a named remote's URL when no hub is configured — useful for |
| 893 | local development where the remote IS the hub. |
| 894 | |
| 895 | When *hub_url_override* is provided it takes precedence over the hub URL |
| 896 | stored in ``.muse/config.toml``. This is the value supplied via the |
| 897 | ``--hub`` CLI flag and lets callers (e.g. containerised agents) target a |
| 898 | hub at a different address than the one recorded in the repo config without |
| 899 | having to modify ``config.toml``. |
| 900 | |
| 901 | Raises: |
| 902 | SystemExit: If not inside a repo, no hub/remote is configured, or not authenticated. |
| 903 | """ |
| 904 | root = find_repo_root() |
| 905 | if root is None: |
| 906 | print("❌ Not inside a Muse repository.", file=sys.stderr) |
| 907 | raise SystemExit(ExitCode.REPO_NOT_FOUND) |
| 908 | |
| 909 | hub_url = hub_url_override or get_hub_url(root) |
| 910 | |
| 911 | if hub_url is None: |
| 912 | # No hub configured — try the specified remote, then 'local', then any remote. |
| 913 | candidate_name = remote or "local" |
| 914 | candidate_url = get_remote(candidate_name, root) |
| 915 | if candidate_url is None and remote is None: |
| 916 | remotes = list_remotes(root) |
| 917 | candidate_url = remotes[0]["url"] if remotes else None |
| 918 | if candidate_url: |
| 919 | candidate_name = remotes[0]["name"] |
| 920 | if candidate_url: |
| 921 | hub_url = candidate_url |
| 922 | logger.debug( |
| 923 | "No hub configured — using remote '%s' as hub URL: %s", |
| 924 | candidate_name, hub_url, |
| 925 | ) |
| 926 | else: |
| 927 | print("❌ No hub connected and no remotes configured.", file=sys.stderr) |
| 928 | print( |
| 929 | " Run `muse hub connect <url>` or `muse remote add local <url>`.", |
| 930 | file=sys.stderr, |
| 931 | ) |
| 932 | raise SystemExit(ExitCode.USER_ERROR) |
| 933 | |
| 934 | identity = load_identity(hub_url) |
| 935 | if identity is None: |
| 936 | print( |
| 937 | f"❌ Not authenticated for {sanitize_display(hub_url)}.", |
| 938 | file=sys.stderr, |
| 939 | ) |
| 940 | print(f" Run: muse auth register --hub {hub_url}", file=sys.stderr) |
| 941 | raise SystemExit(ExitCode.USER_ERROR) |
| 942 | |
| 943 | return hub_url, identity |
| 944 | |
| 945 | def _get_hub_and_optional_identity( |
| 946 | remote: str | None = None, |
| 947 | hub_url_override: str | None = None, |
| 948 | ) -> tuple[str, IdentityEntry]: |
| 949 | """Like ``_get_hub_and_identity`` but returns an empty identity on missing auth. |
| 950 | |
| 951 | Use this for read-only commands (``hub issue read``, ``hub issue list``, …) |
| 952 | that should work against public repos even when no signing identity is |
| 953 | configured. The empty identity dict is passed to ``_hub_api``, which will |
| 954 | omit the Authorization header for GET requests — allowing unauthenticated |
| 955 | access to public resources. |
| 956 | |
| 957 | When an identity IS configured it is returned normally, so authenticated |
| 958 | callers get signed requests and can access private repos. |
| 959 | |
| 960 | Mutating operations must use ``_get_hub_and_identity`` (strict) to fail |
| 961 | fast with a helpful error before making any network request. |
| 962 | |
| 963 | Returns: |
| 964 | ``(hub_url, identity)`` where *identity* may be ``{"type": "human"}`` |
| 965 | when no signing identity is configured. |
| 966 | """ |
| 967 | import io as _io |
| 968 | |
| 969 | from muse.core.repo import find_repo_root |
| 970 | from muse.cli.config import get_hub_url, get_remote, list_remotes |
| 971 | |
| 972 | # Resolve the hub URL first (independent of identity). |
| 973 | root = find_repo_root() |
| 974 | hub_url: str | None = hub_url_override or (get_hub_url(root) if root else None) |
| 975 | if hub_url is None and root: |
| 976 | candidate_name = remote or "local" |
| 977 | candidate_url = get_remote(candidate_name, root) |
| 978 | if candidate_url is None and remote is None: |
| 979 | remotes = list_remotes(root) |
| 980 | candidate_url = remotes[0]["url"] if remotes else None |
| 981 | hub_url = candidate_url |
| 982 | |
| 983 | if not hub_url: |
| 984 | print("❌ No hub connected and no remotes configured.", file=sys.stderr) |
| 985 | print( |
| 986 | " Run `muse hub connect <url>` or `muse remote add local <url>`.", |
| 987 | file=sys.stderr, |
| 988 | ) |
| 989 | raise SystemExit(ExitCode.USER_ERROR) |
| 990 | |
| 991 | # Try loading the identity; silently fall back to empty dict on failure so |
| 992 | # callers can proceed with unauthenticated GET requests on public resources. |
| 993 | identity = load_identity(hub_url) |
| 994 | if identity is None: |
| 995 | empty_identity: IdentityEntry = {"type": "human"} |
| 996 | return hub_url, empty_identity |
| 997 | |
| 998 | return hub_url, identity |
| 999 | |
| 1000 | def _resolve_proposal_id( |
| 1001 | hub_url: str, |
| 1002 | identity: IdentityEntry, |
| 1003 | repo_id: str, |
| 1004 | proposal_id_or_prefix: str, |
| 1005 | ) -> str: |
| 1006 | """Resolve a proposal ID prefix to a full ID. |
| 1007 | |
| 1008 | If *proposal_id_or_prefix* looks like a full ID (≥32 chars with hyphens), it |
| 1009 | is returned as-is. Otherwise the most recent ``_PROPOSAL_PREFIX_RESOLVE_LIMIT`` |
| 1010 | proposals are fetched and the first match whose ``proposalId`` starts with the prefix |
| 1011 | is returned. |
| 1012 | |
| 1013 | All user-supplied and API-sourced strings are sanitized before printing. |
| 1014 | |
| 1015 | Note |
| 1016 | ---- |
| 1017 | Prefix resolution is capped at :data:`_PROPOSAL_PREFIX_RESOLVE_LIMIT` proposals. |
| 1018 | On repositories with many more open proposals, very old proposals may not be findable |
| 1019 | by prefix — pass the full ID in that case. |
| 1020 | |
| 1021 | Raises: |
| 1022 | SystemExit: When no match is found or multiple proposals match the prefix. |
| 1023 | """ |
| 1024 | # Recognise a full content-addressed ID (sha256:<64-hex>, blake3:<64-hex>) |
| 1025 | # or a legacy hyphenated UUID — return as-is without fetching the list. |
| 1026 | try: |
| 1027 | split_id(proposal_id_or_prefix) |
| 1028 | return proposal_id_or_prefix |
| 1029 | except ValueError: |
| 1030 | pass |
| 1031 | if "-" in proposal_id_or_prefix and len(proposal_id_or_prefix) >= 32: |
| 1032 | return proposal_id_or_prefix # legacy UUID passthrough |
| 1033 | |
| 1034 | data = _hub_api( |
| 1035 | hub_url, identity, "GET", |
| 1036 | f"/api/repos/{repo_id}/proposals?limit={_PROPOSAL_PREFIX_RESOLVE_LIMIT}", |
| 1037 | ) |
| 1038 | proposals_val = data.get("proposals", []) |
| 1039 | proposals: list[_ProposalEntry] = ( |
| 1040 | [r for r in proposals_val if isinstance(r, dict)] |
| 1041 | if isinstance(proposals_val, list) else [] |
| 1042 | ) |
| 1043 | matches = [p for p in proposals if str(p.get("proposalId", "")).startswith(proposal_id_or_prefix)] |
| 1044 | |
| 1045 | if not matches: |
| 1046 | print( |
| 1047 | f"❌ No proposal found with ID prefix '{sanitize_display(proposal_id_or_prefix)}'.", |
| 1048 | file=sys.stderr, |
| 1049 | ) |
| 1050 | raise SystemExit(ExitCode.USER_ERROR) |
| 1051 | if len(matches) > 1: |
| 1052 | print( |
| 1053 | f"❌ Ambiguous prefix '{sanitize_display(proposal_id_or_prefix)}' " |
| 1054 | f"— matches {len(matches)} proposals:", |
| 1055 | file=sys.stderr, |
| 1056 | ) |
| 1057 | for m in matches: |
| 1058 | print( |
| 1059 | f" {sanitize_display(str(m.get('proposalId', '')))} " |
| 1060 | f"{sanitize_display(str(m.get('title', '')))}", |
| 1061 | file=sys.stderr, |
| 1062 | ) |
| 1063 | raise SystemExit(ExitCode.USER_ERROR) |
| 1064 | return str(matches[0].get("proposalId", proposal_id_or_prefix)) |
| 1065 | |
| 1066 | def _format_proposal(entry: _ProposalEntry, *, verbose: bool = False) -> str: |
| 1067 | """Format a single proposal for human-readable display. |
| 1068 | |
| 1069 | All fields from the API response are sanitized via :func:`sanitize_display` |
| 1070 | before inclusion in the returned string — including ``author`` and |
| 1071 | ``createdAt`` in verbose mode. |
| 1072 | |
| 1073 | Args: |
| 1074 | entry: Raw proposal dict from the MuseHub API. |
| 1075 | verbose: When ``True``, append author name and creation date (YYYY-MM-DD). |
| 1076 | """ |
| 1077 | proposal_id = sanitize_display(str(entry.get("proposalId", ""))) |
| 1078 | title = sanitize_display(str(entry.get("title", "(no title)"))) |
| 1079 | state = str(entry.get("state", "?")) |
| 1080 | from_b = sanitize_display(str(entry.get("fromBranch", "?"))) |
| 1081 | to_b = sanitize_display(str(entry.get("toBranch", "?"))) |
| 1082 | state_icon = {"open": "🟢", "merged": "🟣", "closed": "⛔"}.get(state, "❓") |
| 1083 | line = f" {state_icon} {proposal_id} {from_b} → {to_b} {title}" |
| 1084 | if verbose: |
| 1085 | author = sanitize_display(str(entry.get("author", ""))) |
| 1086 | created = sanitize_display(str(entry.get("createdAt", ""))[:10]) |
| 1087 | line += f"\n by {author or '?'} {created}" |
| 1088 | return line |
| 1089 | |
| 1090 | def _resolve_hub_override(args: argparse.Namespace) -> str | None: |
| 1091 | """Return the effective hub URL override, resolving --repo as an alias for --hub. |
| 1092 | |
| 1093 | ``--repo owner/repo`` is the idiomatic git/gh CLI style. When provided, |
| 1094 | the hub base URL is read from the repo config and the owner/repo path is |
| 1095 | appended, producing a full ``--hub``-compatible URL. |
| 1096 | """ |
| 1097 | if args.hub: |
| 1098 | return args.hub |
| 1099 | repo_arg: str | None = getattr(args, "repo", None) |
| 1100 | if not repo_arg: |
| 1101 | return None |
| 1102 | root = find_repo_root() |
| 1103 | if root is not None: |
| 1104 | configured = get_hub_url(root) |
| 1105 | if configured: |
| 1106 | # Strip any trailing owner/repo path — keep only scheme://host[:port]. |
| 1107 | parsed = urllib.parse.urlparse(configured) |
| 1108 | base = f"{parsed.scheme}://{parsed.netloc}" |
| 1109 | return f"{base}/{repo_arg.strip('/')}" |
| 1110 | # Fallback: no config — caller will get a helpful error from _get_hub_and_identity. |
| 1111 | return None |
| 1112 | |
| 1113 | __all__ = ['logger', '_CONNECT_TIMEOUT', '_MAX_API_RESPONSE_BYTES', '_ALLOWED_API_SCHEMES', '_PROPOSAL_PREFIX_RESOLVE_LIMIT', '_MAX_PROPOSAL_BODY_LINES', '_MAX_PROPOSAL_TITLE_LEN', '_MAX_ISSUE_TITLE_LEN', '_MAX_REPO_NAME_LEN', '_MAX_REPO_DESC_LEN', '_MAX_ISSUE_LABEL_LEN', '_MAX_ISSUE_COMMENT_LEN', '_MAX_LABEL_NAME_LEN', '_MAX_LABEL_DESC_LEN', '_MAX_HANDLE_LEN', '_HANDLE_RE', '_ConnectJson', '_StatusJson', '_DisconnectJson', '_PingJson', '_RepoEntry', '_ProposalEntry', '_RepoJson', '_HubApiResponse', '_LabelEntry', '_NoRedirectHandler', '_PING_OPENER', '_normalise_url', '_hub_hostname', '_ping_hub', '_hub_api', '_enrich_hub_url_from_remote', '_resolve_repo_id', '_resolve_repo_id_from_owner_repo', '_resolve_body', '_validate_assignee', '_get_hub_and_identity', '_get_hub_and_optional_identity', '_resolve_proposal_id', '_format_proposal', '_resolve_hub_override', 'argparse', 'http', 'json', 'logging', 're', 'sys', 'textwrap', 'urllib', 'Mapping', 'IO', 'TypedDict', 'clear_hub_url', 'get_hub_url', 'get_remote', 'list_remotes', 'set_hub_url', 'ExitCode', 'IdentityEntry', 'load_identity', 'find_repo_root', 'read_current_branch', 'Metadata', 'short_id', 'clamp_int', 'sanitize_display', |
| 1114 | 'EnvelopeJson', 'make_envelope', 'start_timer', '_HubPayload', '_ProposalPayload'] |
File History
3 commits
sha256:1ddad36d76d3a8d323f9b3664169cb184b7a38b39208214a2ae504154260826f
fix: show full cryptographic IDs in all human-readable CLI output
Sonnet 4.6
patch
38 days ago
sha256:79ffe87f5fe2ec146e35f05521218bbf54dffdb0440c07f970bad05f16efb89f
chore: merge main — carry all urllib/typing/test fixes from dev
Sonnet 4.6
minor
⚠
50 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea
fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub …
Sonnet 4.6
50 days ago