gabriel / muse public
proposals.py python
1,149 lines 47.6 KB
Raw
sha256:371209e571fc5fc0010114aaa8f272b15179a2245abd7f62320fa700fbe2a60b feat: add url and base64 id to hub issue/proposal/repo crea… Sonnet 4.6 patch 9 days ago
1 import argparse
2 import base64
3 from ._core import *
4
5 def run_proposal_list(args: argparse.Namespace) -> None:
6 """List proposals on MuseHub.
7
8 Filters by state (default: ``open``) and respects ``--limit`` (default: 20).
9 Use ``--state all`` to list every proposal regardless of state.
10 Use ``--verbose`` / ``-v`` to show author and creation date per proposal.
11
12 JSON output goes to stdout; human-readable text goes to stderr.
13 With ``--json`` the response is a JSON **object** with envelope fields plus
14 ``proposals`` (array), ``total`` (int), and ``next_cursor`` (str|null).
15 ``--verbose`` has no effect when ``--json`` is set — callers receive the
16 full API response and can select any field with ``jq``.
17
18 Agent quickstart
19 ----------------
20 ::
21
22 muse hub proposal list --state open --json
23 muse hub proposal list --state all -n 100 --json | jq '.proposals[].proposalId'
24 muse hub proposal list --verbose
25
26 Exit codes
27 ----------
28 0 Success (including empty list).
29 1 Not authenticated or no hub configured.
30 2 Not inside a Muse repository.
31 3 API / network error.
32 """
33 state: str = args.state
34 limit: int = clamp_int(args.limit, 1, 10000, "limit")
35 json_output: bool = args.json_output
36 verbose: bool = getattr(args, "verbose", False)
37
38 elapsed = start_timer()
39 hub_url, identity = _get_hub_and_identity(hub_url_override=args.hub)
40 repo_id = _resolve_repo_id(hub_url, identity)
41
42 params = f"?limit={limit}"
43 if state != "all":
44 params += f"&state={state}"
45 data = _hub_api(hub_url, identity, "GET", f"/api/repos/{repo_id}/proposals{params}")
46 proposals_val = data.get("proposals", [])
47 proposals: list[_ProposalEntry] = (
48 [r for r in proposals_val if isinstance(r, dict)]
49 if isinstance(proposals_val, list) else []
50 )
51 prop_total: int = int(data.get("total", len(proposals)))
52 prop_next_cursor: str | None = data.get("nextCursor") or data.get("next_cursor") # type: ignore[assignment]
53
54 if json_output:
55 print(json.dumps({**make_envelope(elapsed), **{
56 "proposals": proposals,
57 "total": prop_total,
58 "next_cursor": prop_next_cursor,
59 }}))
60 return
61
62 if not proposals:
63 print(f" No proposals found (state={state}).", file=sys.stderr)
64 return
65
66 print(
67 f"\n Proposals — {sanitize_display(hub_url)} ({len(proposals)} shown)",
68 file=sys.stderr,
69 )
70 print(f" {'─' * 60}", file=sys.stderr)
71 for proposal in proposals:
72 print(_format_proposal(proposal, verbose=verbose), file=sys.stderr)
73 print("", file=sys.stderr)
74
75 def run_proposal_read(args: argparse.Namespace) -> None:
76 """Read a single proposal by ID.
77
78 Accepts the full ID or an 8-character prefix. Prefix resolution fetches
79 the most recent :data:`_PROPOSAL_PREFIX_RESOLVE_LIMIT` proposals — use the full ID
80 on large repositories to avoid ambiguity.
81
82 Text output (stderr) shows: state icon, title, ID, branches, author,
83 creation date, and the first :data:`_MAX_PROPOSAL_BODY_LINES` lines of the body.
84 When the body is truncated a hint is printed; pass ``--json`` to retrieve
85 the full body.
86
87 JSON output is the raw API response merged with the standard envelope.
88 API field names follow MuseHub's camelCase convention (``proposalId``,
89 ``fromBranch``, etc.).
90
91 Agent quickstart
92 ----------------
93 ::
94
95 muse hub proposal read af54753d --json | jq '.state'
96 muse hub proposal read af54753d --json | jq '{state,title,author}'
97
98 Exit codes
99 ----------
100 0 Proposal found and printed.
101 1 Prefix not found, ambiguous, or not authenticated.
102 2 Not inside a Muse repository.
103 3 API / network error.
104 """
105 proposal_id: str = args.proposal_id
106 json_output: bool = args.json_output
107
108 elapsed = start_timer()
109 hub_url, identity = _get_hub_and_identity(hub_url_override=args.hub)
110 repo_id = _resolve_repo_id(hub_url, identity)
111 proposal_id = _resolve_proposal_id(hub_url, identity, repo_id, proposal_id)
112
113 data = _hub_api(hub_url, identity, "GET", f"/api/repos/{repo_id}/proposals/{proposal_id}")
114
115 if json_output:
116 print(json.dumps({**make_envelope(elapsed), **data}))
117 return
118
119 title = sanitize_display(str(data.get("title", "(no title)")))
120 state = str(data.get("state", "?"))
121 from_b = sanitize_display(str(data.get("fromBranch", "?")))
122 to_b = sanitize_display(str(data.get("toBranch", "?")))
123 full_id = sanitize_display(str(data.get("proposalId", proposal_id)))
124 author = sanitize_display(str(data.get("author", "")))
125 created = sanitize_display(str(data.get("createdAt", ""))[:10])
126 body = str(data.get("body", ""))
127 # state_icon lookup uses the raw state string as a dict key — safe; the
128 # fallback "❓" handles any unexpected value. The display string is
129 # sanitized separately so ANSI-injected state values don't reach the terminal.
130 state_icon = {"open": "🟢", "merged": "🟣", "closed": "⛔"}.get(state, "❓")
131 state_display = sanitize_display(state)
132
133 print(f"\n {state_icon} [{state_display.upper()}] {title}", file=sys.stderr)
134 print(f" ID: {full_id}", file=sys.stderr)
135 print(f" Branch: {from_b} → {to_b}", file=sys.stderr)
136 if author:
137 print(f" By: {author} {created}", file=sys.stderr)
138 if body:
139 body_lines = body.strip().splitlines()
140 print(" Body:", file=sys.stderr)
141 for line in body_lines[:_MAX_PROPOSAL_BODY_LINES]:
142 print(f" {sanitize_display(line)}", file=sys.stderr)
143 if len(body_lines) > _MAX_PROPOSAL_BODY_LINES:
144 remaining = len(body_lines) - _MAX_PROPOSAL_BODY_LINES
145 print(
146 f" ... ({remaining} more line{'s' if remaining != 1 else ''}"
147 " — use --json to see full body)",
148 file=sys.stderr,
149 )
150 print("", file=sys.stderr)
151
152 def run_proposal_create(args: argparse.Namespace) -> None:
153 """Open a new proposal on MuseHub.
154
155 Uses the current branch as the source if ``--from-branch`` is not given.
156 Both Muse-native (``--from-branch`` / ``--to-branch``) and git-familiar
157 aliases (``--head`` / ``--base``) are accepted.
158
159 All local validation (title length, branch detection) runs before any
160 network I/O, so errors are reported immediately without contacting the hub.
161
162 Branch detection falls back to the current branch in ``.muse/HEAD``.
163 When the repository is in detached HEAD state and ``--from-branch`` is
164 not given, a clear error is printed and the command exits with code 1.
165
166 Title is validated client-side: must be non-empty and no longer than
167 :data:`_MAX_PROPOSAL_TITLE_LEN` characters.
168
169 JSON output is the raw API response merged with the standard envelope.
170 Human-readable text goes to stderr.
171
172 Agent quickstart
173 ----------------
174 ::
175
176 muse hub proposal create --title "feat: x" --from-branch feat/x --json
177 # → {"muse_version": "...", ..., "proposalId": "...", "state": "open"}
178
179 # Full idiomatic flow:
180 PROPOSAL=$(muse hub proposal create --title "feat: x" --json)
181 echo "$PROPOSAL" | jq '.proposalId'
182
183 Exit codes
184 ----------
185 0 Proposal created.
186 1 Title empty/too long, branch cannot be determined, or not authenticated.
187 2 Not inside a Muse repository.
188 3 API / network error.
189 """
190 title: str = args.title
191 body: str = _resolve_body(args)
192 to_branch: str = args.to_branch
193 json_output: bool = args.json_output
194
195 # ── Local validation first — fail fast before any network I/O ────────────
196
197 # Client-side title validation — better UX than a raw API 400.
198 if not title.strip():
199 print("❌ Proposal title must not be empty.", file=sys.stderr)
200 raise SystemExit(ExitCode.USER_ERROR)
201 if len(title) > _MAX_PROPOSAL_TITLE_LEN:
202 print(
203 f"❌ Proposal title is too long ({len(title)} chars); "
204 f"maximum is {_MAX_PROPOSAL_TITLE_LEN}.",
205 file=sys.stderr,
206 )
207 raise SystemExit(ExitCode.USER_ERROR)
208
209 # Resolve source branch before making any network calls so that a
210 # detached-HEAD error or missing branch is caught locally and quickly.
211 from_branch: str = args.from_branch or ""
212 if not from_branch:
213 ctx = find_repo_root()
214 if ctx is not None:
215 try:
216 from_branch = read_current_branch(ctx)
217 except ValueError as exc:
218 # Detached HEAD — read_current_branch raises ValueError.
219 print(f"❌ {exc}", file=sys.stderr)
220 print(
221 " Use --from-branch to specify the source branch explicitly.",
222 file=sys.stderr,
223 )
224 raise SystemExit(ExitCode.USER_ERROR) from exc
225 if not from_branch:
226 print(
227 "❌ Could not determine current branch. Use --from-branch.",
228 file=sys.stderr,
229 )
230 raise SystemExit(ExitCode.USER_ERROR)
231
232 # ── Network calls ─────────────────────────────────────────────────────────
233
234 elapsed = start_timer()
235 hub_url, identity = _get_hub_and_identity(hub_url_override=args.hub)
236 repo_id = _resolve_repo_id(hub_url, identity)
237
238 payload: _HubPayload = {
239 "title": title,
240 "body": body,
241 "fromBranch": from_branch,
242 "toBranch": to_branch,
243 }
244 data = _hub_api(
245 hub_url, identity, "POST",
246 f"/api/repos/{repo_id}/proposals",
247 body=payload,
248 )
249
250 raw_proposal_id = str(data.get("proposalId", ""))
251 if raw_proposal_id.startswith("sha256:"):
252 proposal_id_b64 = base64.urlsafe_b64encode(bytes.fromhex(raw_proposal_id[7:])).rstrip(b"=").decode()
253 else:
254 proposal_id_b64 = ""
255 parsed = urllib.parse.urlparse(hub_url)
256 parts = parsed.path.strip("/").split("/")
257 if len(parts) >= 2:
258 owner, slug = parts[0], parts[1]
259 server_root = f"{parsed.scheme}://{parsed.netloc}"
260 proposal_url = f"{server_root}/{owner}/{slug}/proposals/{raw_proposal_id}"
261 else:
262 proposal_url = data.get("url") or raw_proposal_id
263
264 if json_output:
265 extras = {"url": data.get("url") or proposal_url, "proposalIdB64": proposal_id_b64}
266 print(json.dumps({**make_envelope(elapsed), **data, **extras}))
267 return
268
269 if len(parts) >= 2:
270 print(f"✅ Proposal created: {sanitize_display(proposal_url)}", file=sys.stderr)
271 else:
272 print(f"✅ Proposal created: {sanitize_display(raw_proposal_id)}", file=sys.stderr)
273 print(
274 f" {sanitize_display(from_branch)} → {sanitize_display(to_branch)}: "
275 f"{sanitize_display(title)}",
276 file=sys.stderr,
277 )
278
279 def run_proposal_merge(args: argparse.Namespace) -> None:
280 """Merge a proposal.
281
282 Merges immediately — no confirmation dialog. The source branch is deleted
283 by default; pass ``--no-delete-branch`` to keep it.
284
285 Merge strategies: ``overlay`` (default), ``weave``, ``replay``, ``selective``.
286 Commit history styles: ``merge`` (default), ``squash``, ``rebase``.
287
288 The hub may return HTTP 200 with ``{"merged": false}`` when it refuses the
289 merge (e.g. conflict, branch-protection rule). This command treats that
290 as a failure in **both** text and JSON modes — exit code 3 is returned so
291 that agent pipelines can reliably use the exit code to detect success::
292
293 muse hub proposal merge af54753d --json && echo "merged" || echo "conflict"
294
295 JSON output is the raw API response merged with the standard envelope;
296 human-readable text goes to stderr.
297
298 Agent quickstart
299 ----------------
300 ::
301
302 muse hub proposal merge af54753d --strategy squash --json
303 # → {"muse_version": "...", ..., "merged": true, "mergeCommitId": "..."}
304
305 # Safe pipeline — exit code 3 on merge failure, even with --json:
306 PROPOSAL_RESULT=$(muse hub proposal merge af54753d --json) || exit 1
307 echo "$PROPOSAL_RESULT" | jq '.mergeCommitId'
308
309 Exit codes
310 ----------
311 0 Merged successfully.
312 1 Prefix not found, ambiguous, or not authenticated.
313 2 Not inside a Muse repository.
314 3 Merge rejected by hub (conflict, branch protection, etc.) or API error.
315 """
316 proposal_id: str = args.proposal_id
317 strategy: str = args.strategy
318 delete_branch: bool = args.delete_branch
319 json_output: bool = args.json_output
320
321 elapsed = start_timer()
322 hub_url, identity = _get_hub_and_identity(hub_url_override=args.hub)
323 repo_id = _resolve_repo_id(hub_url, identity)
324 proposal_id = _resolve_proposal_id(hub_url, identity, repo_id, proposal_id)
325
326 payload: _HubPayload = {
327 "mergeStrategy": strategy,
328 "deleteBranch": delete_branch,
329 }
330 data = _hub_api(
331 hub_url, identity, "POST",
332 f"/api/repos/{repo_id}/proposals/{proposal_id}/merge",
333 body=payload,
334 )
335
336 merged: bool = bool(data.get("merged", False))
337
338 if json_output:
339 print(json.dumps({**make_envelope(elapsed), **data}))
340 # Exit nonzero even in JSON mode so agent pipelines can rely on the
341 # exit code — a hub-side merge rejection must never silently exit 0.
342 if not merged:
343 raise SystemExit(ExitCode.INTERNAL_ERROR)
344 return
345
346 commit_id: str = str(data.get("mergeCommitId", "")) or ""
347 if merged:
348 sha = sanitize_display(commit_id) if commit_id else "(no SHA)"
349 print(
350 f"✅ Proposal {sanitize_display(proposal_id)} merged → {sha}",
351 file=sys.stderr,
352 )
353 if delete_branch:
354 print(" Source branch deleted.", file=sys.stderr)
355 else:
356 msg = str(data.get("message", "unknown error"))
357 print(f"❌ Merge failed: {sanitize_display(msg)}", file=sys.stderr)
358 raise SystemExit(ExitCode.INTERNAL_ERROR)
359
360 def run_proposal_comment_list(args: argparse.Namespace) -> None:
361 """List all comments on a proposal, threaded by parent.
362
363 Each top-level comment includes its direct replies nested under it.
364
365 JSON output is the raw API response merged with the standard envelope.
366 Human-readable text goes to stderr.
367
368 Agent quickstart
369 ----------------
370 ::
371
372 muse hub proposal comment list af54753d --json
373 muse hub proposal comment list af54753d --json | jq '.comments[].author'
374
375 Exit codes
376 ----------
377 0 Success.
378 1 Auth error or proposal not found.
379 2 Not inside a Muse repository.
380 3 API error.
381 """
382 proposal_id_or_prefix: str = args.proposal_id
383 json_output: bool = args.json_output
384
385 elapsed = start_timer()
386 hub_url, identity = _get_hub_and_identity(hub_url_override=args.hub)
387 repo_id = _resolve_repo_id(hub_url, identity)
388 proposal_id = _resolve_proposal_id(hub_url, identity, repo_id, proposal_id_or_prefix)
389
390 data = _hub_api(
391 hub_url, identity, "GET",
392 f"/api/repos/{repo_id}/proposals/{proposal_id}/comments",
393 )
394
395 if json_output:
396 print(json.dumps({**make_envelope(elapsed), **data}))
397 return
398
399 comments = data.get("comments", [])
400 total = data.get("total", len(comments))
401 if not comments:
402 print(f"No comments on proposal {sanitize_display(proposal_id)}.", file=sys.stderr)
403 return
404 print(f"Comments ({total}):", file=sys.stderr)
405 for c in comments:
406 author = sanitize_display(str(c.get("author", "")))
407 body = sanitize_display(str(c.get("body", "")))[:80]
408 print(f" [{author}] {body}")
409 for r in c.get("replies", []):
410 r_author = sanitize_display(str(r.get("author", "")))
411 r_body = sanitize_display(str(r.get("body", "")))[:70]
412 print(f" ↳ [{r_author}] {r_body}")
413
414 def run_proposal_reviewer_request(args: argparse.Namespace) -> None:
415 """Request one or more reviewers on a proposal.
416
417 Reviewer handles are passed as positional arguments.
418
419 JSON output is the raw API response merged with the standard envelope.
420 Human-readable text goes to stderr.
421
422 Agent quickstart
423 ----------------
424 ::
425
426 muse hub proposal reviewer request af54753d alice bob --json
427 muse hub proposal reviewer request af54753d alice --json | jq '.reviews[].state'
428
429 Exit codes
430 ----------
431 0 Success.
432 1 Auth error or not authorized.
433 2 Not inside a Muse repository.
434 3 API error.
435 """
436 proposal_id_or_prefix: str = args.proposal_id
437 reviewers: list[str] = args.reviewers
438 json_output: bool = args.json_output
439
440 elapsed = start_timer()
441 hub_url, identity = _get_hub_and_identity(hub_url_override=args.hub)
442 repo_id = _resolve_repo_id(hub_url, identity)
443 proposal_id = _resolve_proposal_id(hub_url, identity, repo_id, proposal_id_or_prefix)
444
445 data = _hub_api(
446 hub_url, identity, "POST",
447 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers",
448 body={"reviewers": reviewers},
449 )
450
451 if json_output:
452 print(json.dumps({**make_envelope(elapsed), **data}))
453 return
454
455 reviews = data.get("reviews", [])
456 print(f"✅ Reviewers requested ({len(reviews)} total):", file=sys.stderr)
457 for rv in reviews:
458 handle = sanitize_display(str(rv.get("reviewerUsername", rv.get("reviewer", ""))))
459 state = sanitize_display(str(rv.get("state", "")))
460 print(f" {handle:<32} {state}")
461
462 def run_proposal_reviewer_remove(args: argparse.Namespace) -> None:
463 """Remove a pending reviewer from a proposal.
464
465 Only pending review requests can be removed — submitted reviews are immutable.
466
467 JSON output is the raw API response merged with the standard envelope.
468 Human-readable text goes to stderr.
469
470 Agent quickstart
471 ----------------
472 ::
473
474 muse hub proposal reviewer remove af54753d alice --json
475 muse hub proposal reviewer remove af54753d alice --json | jq '.reviews | length'
476
477 Exit codes
478 ----------
479 0 Success.
480 1 Auth error, reviewer not found, or review already submitted.
481 2 Not inside a Muse repository.
482 3 API error.
483 """
484 proposal_id_or_prefix: str = args.proposal_id
485 reviewer: str = args.reviewer
486 json_output: bool = args.json_output
487
488 elapsed = start_timer()
489 hub_url, identity = _get_hub_and_identity(hub_url_override=args.hub)
490 repo_id = _resolve_repo_id(hub_url, identity)
491 proposal_id = _resolve_proposal_id(hub_url, identity, repo_id, proposal_id_or_prefix)
492
493 data = _hub_api(
494 hub_url, identity, "DELETE",
495 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers/{reviewer}",
496 )
497
498 if json_output:
499 print(json.dumps({**make_envelope(elapsed), **data}))
500 return
501
502 reviews = data.get("reviews", [])
503 print(
504 f"✅ Reviewer {sanitize_display(reviewer)} removed "
505 f"({len(reviews)} reviewers remaining).",
506 file=sys.stderr,
507 )
508
509 def run_proposal_review_list(args: argparse.Namespace) -> None:
510 """List all reviews for a proposal.
511
512 Includes pending assignments and submitted reviews (``approved``,
513 ``changes_requested``, ``dismissed``). Pass ``--state`` to filter.
514
515 JSON output is the raw API response merged with the standard envelope.
516 Human-readable text goes to stderr.
517
518 Agent quickstart
519 ----------------
520 ::
521
522 muse hub proposal review list af54753d --json
523 muse hub proposal review list af54753d --state approved --json | jq '.reviews[].reviewerUsername'
524
525 Exit codes
526 ----------
527 0 Success.
528 1 Auth error or proposal not found.
529 2 Not inside a Muse repository.
530 3 API error.
531 """
532 proposal_id_or_prefix: str = args.proposal_id
533 state: str | None = getattr(args, "state", None) or None
534 json_output: bool = args.json_output
535
536 elapsed = start_timer()
537 hub_url, identity = _get_hub_and_identity(hub_url_override=args.hub)
538 repo_id = _resolve_repo_id(hub_url, identity)
539 proposal_id = _resolve_proposal_id(hub_url, identity, repo_id, proposal_id_or_prefix)
540
541 path = f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews"
542 if state:
543 path += f"?state={state}"
544 data = _hub_api(hub_url, identity, "GET", path)
545
546 if json_output:
547 print(json.dumps({**make_envelope(elapsed), **data}))
548 return
549
550 reviews = data.get("reviews", [])
551 total = data.get("total", len(reviews))
552 if not reviews:
553 print(f"No reviews on proposal {sanitize_display(proposal_id)}.", file=sys.stderr)
554 return
555 print(f"Reviews ({total}):", file=sys.stderr)
556 for rv in reviews:
557 handle = sanitize_display(str(rv.get("reviewerUsername", rv.get("reviewer", ""))))
558 s = sanitize_display(str(rv.get("state", "")))
559 print(f" {handle:<32} {s}")
560
561 def run_proposal_comment_create(args: argparse.Namespace) -> None:
562 """Post a comment on a merge proposal on MuseHub.
563
564 The comment body is supplied via ``--body`` (inline) or ``--body-file``
565 (path to a file whose contents become the body). At least one is required.
566
567 JSON output is the raw API response merged with the standard envelope.
568 Human-readable text goes to stderr.
569
570 Agent quickstart
571 ----------------
572 ::
573
574 muse hub proposal comment create af54753d --body 'LGTM' --json
575 # → {"muse_version": "...", ..., "commentId": "...", "author": "...", ...}
576
577 JSON output keys (from hub): ``commentId``, ``proposalId``, ``author``,
578 ``body``, ``createdAt``.
579
580 Exit codes
581 ----------
582 0 Comment posted.
583 1 Validation or auth error.
584 2 Not inside a Muse repository.
585 3 API error.
586 """
587 body: str = _resolve_body(args)
588 if not body.strip():
589 print("❌ --body or --body-file must not be empty.", file=sys.stderr)
590 raise SystemExit(ExitCode.USER_ERROR)
591
592 elapsed = start_timer()
593 hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args))
594 repo_id = _resolve_repo_id(hub_url, identity)
595
596 payload = {"body": body}
597 data = _hub_api(hub_url, identity, "POST",
598 f"/api/repos/{repo_id}/proposals/{args.proposal_id}/comments",
599 body=payload)
600
601 if args.json_output:
602 print(json.dumps({**make_envelope(elapsed), **data}))
603 return
604
605 print(f"✅ Comment posted on proposal {args.proposal_id}.", file=sys.stderr)
606
607 def run_proposal_update(args: argparse.Namespace) -> None:
608 """Partially update a proposal (title, body, type, or strategy).
609
610 At least one of ``--title``, ``--body`` / ``--body-file``, ``--type``, or
611 ``--strategy`` must be supplied. Only the fields you pass are changed;
612 the rest are left untouched.
613
614 JSON output is the raw API response merged with the standard envelope.
615 Human-readable confirmation goes to stderr.
616
617 Agent quickstart
618 ----------------
619 ::
620
621 muse hub proposal update af54753d --title "New title" --json
622 muse hub proposal update af54753d --type canonical_release --json
623 muse hub proposal update af54753d --body-file notes.md --json
624
625 Exit codes
626 ----------
627 0 Proposal updated.
628 1 Validation error (no fields supplied, unknown type/strategy).
629 2 Not inside a Muse repository.
630 3 API / network error.
631 """
632 title: str | None = getattr(args, "title", None)
633 body_inline: str | None = getattr(args, "body", None)
634 body_file: str | None = getattr(args, "body_file", None)
635 proposal_type: str | None = getattr(args, "proposal_type", None)
636 merge_strategy: str | None = getattr(args, "merge_strategy", None)
637 json_output: bool = args.json_output
638
639 body: str | None = None
640 if body_file is not None:
641 import sys as _sys
642 if body_file == "-":
643 body = _sys.stdin.read()
644 else:
645 try:
646 body = open(body_file).read()
647 except OSError as exc:
648 print(f"❌ Cannot read --body-file: {exc}", file=sys.stderr)
649 raise SystemExit(ExitCode.USER_ERROR) from exc
650 elif body_inline is not None:
651 body = body_inline
652
653 payload: dict[str, str] = {}
654 if title is not None:
655 payload["title"] = title
656 if body is not None:
657 payload["body"] = body
658 if proposal_type is not None:
659 payload["proposal_type"] = proposal_type
660 if merge_strategy is not None:
661 payload["merge_strategy"] = merge_strategy
662
663 if not payload:
664 print(
665 "❌ At least one of --title, --body, --body-file, --type, or --strategy is required.",
666 file=sys.stderr,
667 )
668 raise SystemExit(ExitCode.USER_ERROR)
669
670 elapsed = start_timer()
671 try:
672 hub_url, identity = _get_hub_and_identity(hub_url_override=getattr(args, "hub", None))
673 repo_id = _resolve_repo_id(hub_url, identity)
674 proposal_id = _resolve_proposal_id(hub_url, identity, repo_id, args.proposal_id)
675 data = _hub_api(
676 hub_url, identity, "PATCH",
677 f"/api/repos/{repo_id}/proposals/{proposal_id}",
678 body=payload,
679 )
680 except Exception as exc:
681 print(f"❌ API error: {exc}", file=sys.stderr)
682 raise SystemExit(ExitCode.INTERNAL_ERROR) from exc
683
684 if json_output:
685 print(json.dumps({**make_envelope(elapsed), **data}))
686 return
687
688 proposal_id_out = str(data.get("proposalId", args.proposal_id))
689 print(f"✅ Proposal {proposal_id_out} updated.", file=sys.stderr)
690
691
692 def run_proposal_close(args: argparse.Namespace) -> None:
693 """Close an open proposal without merging.
694
695 Issues ``POST .../proposals/{id}/close`` on MuseHub.
696 Returns 409 if the proposal is already closed or merged.
697
698 Agent quickstart
699 ----------------
700 ::
701
702 muse hub proposal close af54753d --json
703 # → {"muse_version": "...", ..., "state": "closed", ...}
704
705 Exit codes
706 ----------
707 0 Closed successfully.
708 1 Prefix not found, ambiguous, or not authenticated.
709 2 Not inside a Muse repository.
710 3 Proposal already closed/merged, or API error.
711 """
712 proposal_id: str = args.proposal_id
713 json_output: bool = args.json_output
714
715 elapsed = start_timer()
716 hub_url, identity = _get_hub_and_identity(hub_url_override=args.hub)
717 repo_id = _resolve_repo_id(hub_url, identity)
718 proposal_id = _resolve_proposal_id(hub_url, identity, repo_id, proposal_id)
719
720 data = _hub_api(
721 hub_url, identity, "POST",
722 f"/api/repos/{repo_id}/proposals/{proposal_id}/close",
723 )
724
725 if json_output:
726 print(json.dumps({**make_envelope(elapsed), **data}))
727 return
728
729 print(f"✅ Proposal {sanitize_display(proposal_id)} closed.", file=sys.stderr)
730
731
732 def run_proposal_review_submit(args: argparse.Namespace) -> None:
733 """Submit an approve, request-changes, or comment review on a proposal.
734
735 Verdicts: ``approve``, ``request_changes``, ``comment``.
736 A ``--body`` is optional for ``approve`` and required for ``request_changes``.
737
738 JSON output is the raw API response merged with the standard envelope.
739 Human-readable text goes to stderr.
740
741 Agent quickstart
742 ----------------
743 ::
744
745 muse hub proposal review submit af54753d --verdict approve --json
746 muse hub proposal review submit af54753d --verdict request_changes --body 'Fix X' --json
747 # → {"muse_version": "...", ..., "reviewId": "...", "verdict": "..."}
748
749 JSON output keys (from hub): ``reviewId``, ``proposalId``, ``author``,
750 ``verdict``, ``body``, ``createdAt``.
751
752 Exit codes
753 ----------
754 0 Review submitted.
755 1 Auth error.
756 2 Not inside a Muse repository.
757 3 API error.
758 """
759 elapsed = start_timer()
760 hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args))
761 repo_id = _resolve_repo_id(hub_url, identity)
762
763 payload = {"verdict": args.verdict, "body": _resolve_body(args)}
764 data = _hub_api(hub_url, identity, "POST",
765 f"/api/repos/{repo_id}/proposals/{args.proposal_id}/reviews",
766 body=payload)
767
768 if args.json_output:
769 print(json.dumps({**make_envelope(elapsed), **data}))
770 return
771
772 print(f"✅ Review ({args.verdict}) submitted on proposal {args.proposal_id}.", file=sys.stderr)
773
774 def register(subs: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
775 """Register proposals subcommands."""
776 # ── proposal ──────────────────────────────────────────────────────────────
777 proposal_p = subs.add_parser(
778 "proposal",
779 help="Manage proposals on MuseHub.",
780 formatter_class=argparse.RawDescriptionHelpFormatter,
781 )
782 proposal_subs = proposal_p.add_subparsers(dest="proposal_subcommand", metavar="PROPOSAL_COMMAND")
783 proposal_subs.required = True
784
785 proposal_create_p = proposal_subs.add_parser(
786 "create",
787 help="Open a new proposal.",
788 description=(
789 "Open a new proposal on MuseHub.\n\n"
790 "Uses the current branch as the source if --from-branch is omitted.\n"
791 "Both Muse-native flags (--from-branch / --to-branch) and familiar\n"
792 "aliases (--head / --base) are accepted.\n\n"
793 f"Title must be non-empty and ≤ {_MAX_PROPOSAL_TITLE_LEN} characters.\n"
794 "Detached HEAD is rejected — pass --from-branch explicitly.\n\n"
795 "Agent quickstart:\n"
796 " muse hub proposal create --title 'feat: x' --from-branch feat/x --json\n"
797 " PROPOSAL=$(muse hub proposal create --title 'feat: x' --json)\n"
798 " echo \"$PROPOSAL\" | jq '.proposalId'\n\n"
799 "Exit codes: 0 created, 1 validation/branch error, 2 not in repo, 3 API error."
800 ),
801 formatter_class=argparse.RawDescriptionHelpFormatter,
802 )
803 proposal_create_p.add_argument(
804 "--hub", dest="hub", default=None, metavar="URL",
805 help="Override the hub URL from config.",
806 )
807 proposal_create_p.add_argument("--title", "-t", required=True, help="Proposal title.")
808 proposal_create_p.add_argument("--body", "-b", default="", help="Proposal body / description (inline text).")
809 proposal_create_p.add_argument(
810 "--body-file", default=None, metavar="PATH", dest="body_file",
811 help="Read proposal body from PATH. Pass '-' for stdin. Takes precedence over --body.",
812 )
813 proposal_create_p.add_argument(
814 "--from-branch", "--head", "-f", dest="from_branch", default=None,
815 help=(
816 "Source branch (default: current branch). "
817 "--head is accepted as a git-familiar alias."
818 ),
819 )
820 proposal_create_p.add_argument(
821 "--to-branch", "--base", dest="to_branch", default="dev",
822 help="Target branch (default: dev). --base is accepted as a git-familiar alias.",
823 )
824 proposal_create_p.add_argument(
825 "--json", "-j", action="store_true", dest="json_output",
826 help="Emit JSON with the created proposal.",
827 )
828 proposal_create_p.set_defaults(func=run_proposal_create)
829
830 proposal_list_p = proposal_subs.add_parser(
831 "list",
832 help="List proposals.",
833 description=(
834 "List proposals on the configured MuseHub.\n\n"
835 "Filters by state (default: open). --limit caps the number of results.\n"
836 "Use --state all to see every proposal regardless of state.\n"
837 "Use --verbose to also show author and creation date per proposal.\n\n"
838 "Agent quickstart:\n"
839 " muse hub proposal list --state open --json\n"
840 " muse hub proposal list --state all -n 100 --json | jq '.[] | .proposalId'\n\n"
841 "Exit codes: 0 success, 1 not authenticated, 2 not in repo, 3 API error."
842 ),
843 formatter_class=argparse.RawDescriptionHelpFormatter,
844 )
845 proposal_list_p.add_argument(
846 "--hub", dest="hub", default=None, metavar="URL",
847 help="Override the hub URL from config (e.g. http://host.docker.internal:10003/owner/repo).",
848 )
849 proposal_list_p.add_argument(
850 "--state", default="open", choices=["open", "merged", "closed", "all"],
851 help="Filter by state: open (default), merged, closed, all.",
852 )
853 proposal_list_p.add_argument(
854 "--limit", "-n", type=int, default=20,
855 help="Maximum number of proposals to return (default: 20).",
856 )
857 proposal_list_p.add_argument(
858 "--verbose", "-v", action="store_true", dest="verbose",
859 help="Show author and creation date for each proposal.",
860 )
861 proposal_list_p.add_argument(
862 "--json", "-j", action="store_true", dest="json_output",
863 help="Emit JSON to stdout instead of human-readable output.",
864 )
865 proposal_list_p.set_defaults(func=run_proposal_list)
866
867 proposal_merge_p = proposal_subs.add_parser(
868 "merge",
869 help="Merge a proposal.",
870 description=(
871 "Merge a proposal immediately — no confirmation dialog.\n\n"
872 "The source branch is deleted by default; pass --no-delete-branch\n"
873 "to keep it.\n\n"
874 "Content merge strategies (--strategy): overlay (default), weave, replay, selective.\n"
875 "Commit history styles (--history): merge (default), squash, rebase.\n\n"
876 "Exit code 3 is returned on merge rejection in BOTH text and JSON mode\n"
877 "so agent pipelines can always rely on the exit code:\n"
878 " muse hub proposal merge af54753d --json && echo ok || echo conflict\n\n"
879 "Agent quickstart:\n"
880 " muse hub proposal merge af54753d --strategy overlay --history merge --json\n"
881 " # → {\"merged\": true, \"mergeCommitId\": \"...\", ...}\n\n"
882 "Exit codes: 0 merged, 1 not found/not authenticated,\n"
883 " 2 not in repo, 3 merge rejected or API error."
884 ),
885 formatter_class=argparse.RawDescriptionHelpFormatter,
886 )
887 proposal_merge_p.add_argument("proposal_id", help="Proposal ID (full ID or 8-char prefix).")
888 proposal_merge_p.add_argument(
889 "--hub", dest="hub", default=None, metavar="URL",
890 help="Override the hub URL from config.",
891 )
892 proposal_merge_p.add_argument(
893 "--strategy", default="merge_commit",
894 choices=["merge_commit", "squash", "rebase"],
895 help="Commit history style (default: merge_commit).",
896 )
897 proposal_merge_p.add_argument(
898 "--no-delete-branch", dest="delete_branch", action="store_false",
899 help="Do not delete the source branch after merging.",
900 )
901 proposal_merge_p.add_argument(
902 "--json", "-j", action="store_true", dest="json_output",
903 help="Emit JSON with the proposal merge result.",
904 )
905 proposal_merge_p.set_defaults(func=run_proposal_merge, delete_branch=True)
906
907 proposal_read_p = proposal_subs.add_parser(
908 "read",
909 help="Read a single proposal.",
910 description=(
911 "Read a single proposal by ID.\n\n"
912 "Accepts the full ID or an 8-character prefix. Prefix resolution\n"
913 f"fetches the most recent {_PROPOSAL_PREFIX_RESOLVE_LIMIT} proposals — use the full ID\n"
914 "on large repositories to avoid ambiguity.\n\n"
915 "Text output shows state, title, ID, branches, author, date, and\n"
916 f"up to {_MAX_PROPOSAL_BODY_LINES} body lines (truncation hint printed when exceeded).\n"
917 "JSON output is the raw API response (camelCase field names).\n\n"
918 "Agent quickstart:\n"
919 " muse hub proposal read af54753d --json | jq '.state'\n"
920 " muse hub proposal read af54753d --json | jq '{state,title,author}'\n\n"
921 "Exit codes: 0 found, 1 not found/ambiguous, 2 not in repo, 3 API error."
922 ),
923 formatter_class=argparse.RawDescriptionHelpFormatter,
924 )
925 proposal_read_p.add_argument("proposal_id", help="Proposal ID (full ID or 8-char prefix).")
926 proposal_read_p.add_argument(
927 "--hub", dest="hub", default=None, metavar="URL",
928 help="Override the hub URL from config.",
929 )
930 proposal_read_p.add_argument(
931 "--json", "-j", action="store_true", dest="json_output", help="Emit JSON.",
932 )
933 proposal_read_p.set_defaults(func=run_proposal_read)
934
935 # ── proposal comment ──────────────────────────────────────────────────────
936 proposal_comment_p = proposal_subs.add_parser(
937 "comment",
938 help="Manage proposal comments.",
939 description="Manage review comments on a merge proposal.",
940 formatter_class=argparse.RawDescriptionHelpFormatter,
941 )
942 proposal_comment_subs = proposal_comment_p.add_subparsers(
943 dest="proposal_comment_subcommand", metavar="COMMENT_COMMAND",
944 )
945 proposal_comment_subs.required = True
946
947 proposal_comment_list_p = proposal_comment_subs.add_parser(
948 "list", help="List all comments on a proposal (threaded).",
949 )
950 proposal_comment_list_p.add_argument("proposal_id", help="Proposal ID (full ID or 8-char prefix).")
951 proposal_comment_list_p.add_argument("--hub", dest="hub", default=None, metavar="URL")
952 proposal_comment_list_p.add_argument("--json", "-j", action="store_true", dest="json_output")
953 proposal_comment_list_p.set_defaults(func=run_proposal_comment_list)
954
955 proposal_comment_create_p = proposal_comment_subs.add_parser(
956 "create", help="Post a new comment on a proposal.",
957 description=(
958 "Post a Markdown comment on a merge proposal.\n\n"
959 " muse hub proposal comment create <proposal_id> --body 'LGTM'\n"
960 " muse hub proposal comment create <proposal_id> --body 'See inline' --json\n\n"
961 "Exit codes: 0 success, 1 auth error, 2 not in repo, 3 API error."
962 ),
963 formatter_class=argparse.RawDescriptionHelpFormatter,
964 )
965 proposal_comment_create_p.add_argument("proposal_id", help="ID of the proposal.")
966 proposal_comment_create_p.add_argument("--hub", dest="hub", default=None, metavar="URL",
967 help="Override the hub URL from config.")
968 proposal_comment_create_p.add_argument("--repo", dest="repo", default=None, metavar="OWNER/REPO",
969 help="Specify repo as owner/repo.")
970 proposal_comment_create_p.add_argument("--body", "-b", default=None,
971 help="Comment body (Markdown, inline text).")
972 proposal_comment_create_p.add_argument(
973 "--body-file", default=None, metavar="PATH", dest="body_file",
974 help="Read comment body from PATH. Pass '-' for stdin. Takes precedence over --body.",
975 )
976 proposal_comment_create_p.add_argument("--json", "-j", action="store_true", dest="json_output",
977 help="Emit JSON output.")
978 proposal_comment_create_p.set_defaults(func=run_proposal_comment_create)
979
980 proposal_comment_p.set_defaults(func=lambda a: proposal_comment_p.print_help())
981
982 # ── proposal reviewer ─────────────────────────────────────────────────────
983 proposal_reviewer_p = proposal_subs.add_parser(
984 "reviewer",
985 help="Manage proposal reviewers.",
986 description="Request or remove reviewers on a merge proposal.",
987 formatter_class=argparse.RawDescriptionHelpFormatter,
988 )
989 proposal_reviewer_subs = proposal_reviewer_p.add_subparsers(
990 dest="proposal_reviewer_subcommand", metavar="REVIEWER_COMMAND",
991 )
992 proposal_reviewer_subs.required = True
993
994 proposal_reviewer_request_p = proposal_reviewer_subs.add_parser(
995 "request", help="Request reviewers on a proposal.",
996 )
997 proposal_reviewer_request_p.add_argument("proposal_id", help="Proposal ID (full ID or 8-char prefix).")
998 proposal_reviewer_request_p.add_argument("reviewers", nargs="+", help="MSign handles to request.")
999 proposal_reviewer_request_p.add_argument("--hub", dest="hub", default=None, metavar="URL")
1000 proposal_reviewer_request_p.add_argument("--json", "-j", action="store_true", dest="json_output")
1001 proposal_reviewer_request_p.set_defaults(func=run_proposal_reviewer_request)
1002
1003 proposal_reviewer_remove_p = proposal_reviewer_subs.add_parser(
1004 "remove", help="Remove a pending reviewer from a proposal.",
1005 )
1006 proposal_reviewer_remove_p.add_argument("proposal_id", help="Proposal ID (full ID or 8-char prefix).")
1007 proposal_reviewer_remove_p.add_argument("reviewer", help="MSign handle of the reviewer to remove.")
1008 proposal_reviewer_remove_p.add_argument("--hub", dest="hub", default=None, metavar="URL")
1009 proposal_reviewer_remove_p.add_argument("--json", "-j", action="store_true", dest="json_output")
1010 proposal_reviewer_remove_p.set_defaults(func=run_proposal_reviewer_remove)
1011
1012 proposal_reviewer_p.set_defaults(func=lambda a: proposal_reviewer_p.print_help())
1013
1014 # ── proposal review ───────────────────────────────────────────────────────
1015 proposal_review_p = proposal_subs.add_parser(
1016 "review",
1017 help="Manage proposal reviews.",
1018 description="List or filter formal reviews on a merge proposal.",
1019 formatter_class=argparse.RawDescriptionHelpFormatter,
1020 )
1021 proposal_review_subs = proposal_review_p.add_subparsers(
1022 dest="proposal_review_subcommand", metavar="REVIEW_COMMAND",
1023 )
1024 proposal_review_subs.required = True
1025
1026 proposal_review_list_p = proposal_review_subs.add_parser(
1027 "list", help="List all reviews on a proposal.",
1028 )
1029 proposal_review_list_p.add_argument("proposal_id", help="Proposal ID (full ID or 8-char prefix).")
1030 proposal_review_list_p.add_argument(
1031 "--state",
1032 choices=["pending", "approved", "changes_requested", "dismissed"],
1033 default=None,
1034 help="Filter by review state.",
1035 )
1036 proposal_review_list_p.add_argument("--hub", dest="hub", default=None, metavar="URL")
1037 proposal_review_list_p.add_argument("--json", "-j", action="store_true", dest="json_output")
1038 proposal_review_list_p.set_defaults(func=run_proposal_review_list)
1039
1040 proposal_review_submit_p = proposal_review_subs.add_parser(
1041 "submit", help="Submit a review (approve, request-changes, or comment).",
1042 description=(
1043 "Submit an approve, request-changes, or comment review on a proposal.\n\n"
1044 " muse hub proposal review submit <proposal_id> --verdict approve\n"
1045 " muse hub proposal review submit <proposal_id> --verdict request_changes --body 'Fix X'\n"
1046 " muse hub proposal review submit <proposal_id> --verdict comment --body 'Notes' --json\n\n"
1047 "Exit codes: 0 success, 1 auth error, 2 not in repo, 3 API error."
1048 ),
1049 formatter_class=argparse.RawDescriptionHelpFormatter,
1050 )
1051 proposal_review_submit_p.add_argument("proposal_id", help="ID of the proposal.")
1052 proposal_review_submit_p.add_argument("--hub", dest="hub", default=None, metavar="URL",
1053 help="Override the hub URL from config.")
1054 proposal_review_submit_p.add_argument("--repo", dest="repo", default=None, metavar="OWNER/REPO",
1055 help="Specify repo as owner/repo.")
1056 proposal_review_submit_p.add_argument(
1057 "--verdict", required=True,
1058 choices=["approve", "request_changes", "comment"],
1059 help="Review verdict.",
1060 )
1061 proposal_review_submit_p.add_argument("--body", "-b", default="",
1062 help="Review body (Markdown, inline text).")
1063 proposal_review_submit_p.add_argument(
1064 "--body-file", default=None, metavar="PATH", dest="body_file",
1065 help="Read review body from PATH. Pass '-' for stdin. Takes precedence over --body.",
1066 )
1067 proposal_review_submit_p.add_argument("--json", "-j", action="store_true", dest="json_output",
1068 help="Emit JSON output.")
1069 proposal_review_submit_p.set_defaults(func=run_proposal_review_submit)
1070
1071 proposal_review_p.set_defaults(func=lambda a: proposal_review_p.print_help())
1072
1073 # ── proposal update ───────────────────────────────────────────────────────
1074 _PROPOSAL_TYPES = [
1075 "state_merge", "stem_integration", "midi_evolution",
1076 "payment_settlement", "agent_delegation", "identity_transition",
1077 "canonical_release",
1078 ]
1079 _MERGE_STRATEGIES = ["state_overlay", "state_rebase", "state_squash"]
1080
1081 proposal_update_p = proposal_subs.add_parser(
1082 "update",
1083 help="Partially update a proposal (title, body, type, strategy).",
1084 description=(
1085 "Partially update an open merge proposal on MuseHub.\n\n"
1086 "Only the fields you supply are changed — the rest are left untouched.\n"
1087 "At least one flag is required.\n\n"
1088 "Agent quickstart:\n"
1089 " muse hub proposal update <id> --title 'New title' --json\n"
1090 " muse hub proposal update <id> --type canonical_release --json\n"
1091 " muse hub proposal update <id> --body-file notes.md --json\n\n"
1092 "Exit codes: 0 updated, 1 validation error, 2 not in repo, 3 API error."
1093 ),
1094 formatter_class=argparse.RawDescriptionHelpFormatter,
1095 )
1096 proposal_update_p.add_argument("proposal_id", help="Proposal ID (full ID or 8-char prefix).")
1097 proposal_update_p.add_argument(
1098 "--hub", dest="hub", default=None, metavar="URL",
1099 help="Override the hub URL from config.",
1100 )
1101 proposal_update_p.add_argument("--title", "-t", default=None, help="New proposal title.")
1102 proposal_update_p.add_argument("--body", "-b", default=None, help="New proposal body (inline Markdown).")
1103 proposal_update_p.add_argument(
1104 "--body-file", default=None, metavar="PATH", dest="body_file",
1105 help="Read new body from PATH. Pass '-' for stdin. Takes precedence over --body.",
1106 )
1107 proposal_update_p.add_argument(
1108 "--type", dest="proposal_type", default=None, choices=_PROPOSAL_TYPES,
1109 metavar="TYPE", help=f"New proposal type. One of: {', '.join(_PROPOSAL_TYPES)}.",
1110 )
1111 proposal_update_p.add_argument(
1112 "--strategy", dest="merge_strategy", default=None, choices=_MERGE_STRATEGIES,
1113 metavar="STRATEGY", help=f"New merge strategy. One of: {', '.join(_MERGE_STRATEGIES)}.",
1114 )
1115 proposal_update_p.add_argument(
1116 "--json", "-j", action="store_true", dest="json_output",
1117 help="Emit JSON with the updated proposal.",
1118 )
1119 proposal_update_p.set_defaults(func=run_proposal_update)
1120
1121 # ── proposal close ────────────────────────────────────────────────────────
1122 proposal_close_p = proposal_subs.add_parser(
1123 "close",
1124 help="Close an open proposal without merging.",
1125 description=(
1126 "Close an open proposal without merging it.\n\n"
1127 "Issues POST .../proposals/{id}/close on MuseHub.\n"
1128 "Returns 409 if the proposal is already closed or merged.\n\n"
1129 "Agent quickstart:\n"
1130 " muse hub proposal close af54753d --json\n"
1131 " # → {\"state\": \"closed\", ...}\n\n"
1132 "Exit codes: 0 closed, 1 not found/not authenticated,\n"
1133 " 2 not in repo, 3 already closed/merged or API error."
1134 ),
1135 formatter_class=argparse.RawDescriptionHelpFormatter,
1136 )
1137 proposal_close_p.add_argument("proposal_id", help="Proposal ID (full ID or 8-char prefix).")
1138 proposal_close_p.add_argument(
1139 "--hub", dest="hub", default=None, metavar="URL",
1140 help="Override the hub URL from config.",
1141 )
1142 proposal_close_p.add_argument(
1143 "--json", "-j", action="store_true", dest="json_output",
1144 help="Emit JSON with the closed proposal.",
1145 )
1146 proposal_close_p.set_defaults(func=run_proposal_close)
1147
1148 proposal_p.set_defaults(func=lambda a: proposal_p.print_help())
1149
File History 6 commits
sha256:371209e571fc5fc0010114aaa8f272b15179a2245abd7f62320fa700fbe2a60b feat: add url and base64 id to hub issue/proposal/repo crea… Sonnet 4.6 patch 9 days ago
sha256:1ddad36d76d3a8d323f9b3664169cb184b7a38b39208214a2ae504154260826f fix: show full cryptographic IDs in all human-readable CLI output Sonnet 4.6 patch 9 days ago
sha256:99451767674c70e97323b61d5ef248ebe91530a91c2ab5902c2bb3e4acf250dd Run in a fresh repo so no stale MERGE_STATE.json can bleed in Human patch 9 days ago
sha256:0ab1022a97637701da7c18808e65556a5c774a1572b42e02599ff55efaf69ef4 feat: route merge.py through STRATEGY_MAP; update hub propo… Sonnet 4.6 patch 10 days ago
sha256:f1f585ee9ca4e1ada936668c1b14f42f961a1fa78a2c033b643595f9c1bf9ac7 fixes for proposal flow Human patch 14 days ago
sha256:61ca5239532bb0f2daa1c2de73fdebe4d09ccbf2b13272d4ee7b197cc8e4dd58 fix proposal merge flags Human patch 16 days ago