gabriel / muse public
annotate.py python
703 lines 23.8 KB
Raw
sha256:f9828efc523c2f8ccaee12ae76086a09a9a6a10d6dd20e53f0b13793f0fdcf50 docs: add symlog (#53) and reflog (#54) follow-up issue files Sonnet 4.6 18 days ago
1 """``muse annotate`` — attach CRDT-backed metadata to an existing commit.
2
3 Annotations use real CRDT semantics so that multiple agents can annotate the
4 same commit concurrently without conflicts:
5
6 - ``--reviewed-by NAME`` merges into ``reviewed_by`` using **ORSet** semantics
7 (set union — once added, a reviewer is never lost by concurrent additions).
8 - ``--remove-reviewer NAME`` removes a reviewer (not CRDT-safe across concurrent
9 removals, but useful for correcting mistakes).
10 - ``--test-run`` increments ``test_runs`` using **GCounter** semantics
11 (monotonically increasing counter).
12 - ``--label LABEL`` merges into ``labels`` using **ORSet** semantics.
13 - ``--remove-label LABEL`` removes a label.
14 - ``--status STATUS`` sets ``status`` using **LWW-Register** semantics
15 (last write wins). Must be one of: ``pending``, ``approved``, ``rejected``,
16 ``needs-review``, ``wip``. Pass an empty string ``""`` to clear.
17 - ``--note TEXT`` appends to ``notes`` (append-only, no dedup).
18 - ``--score FLOAT`` sets ``score`` using **LWW-Register** semantics
19 (last write wins). Must be in [0.0, 1.0].
20
21 These annotations are persisted directly in the commit record on disk.
22
23 Commit reference
24 ----------------
25 The *commit* argument accepts any reference that ``resolve_commit_ref``
26 understands:
27
28 - ``HEAD`` or omitted — the most recent commit on the current branch.
29 - ``HEAD~N`` — *N* first-parent steps back from HEAD.
30 - A full 64-character hex commit ID.
31 - A short hex prefix (e.g. ``abc1234``) — resolved by prefix scan.
32
33 Security model
34 --------------
35 - All user-controlled string values are validated before storage: control
36 characters, ANSI escapes, and oversized values are rejected.
37 - All values are sanitized via ``sanitize_display()`` before appearing in
38 human-readable terminal output.
39 - All error messages go to **stderr**; **stdout** carries only data.
40 - Commit references are resolved through the safe-prefix scan in
41 ``resolve_commit_ref`` — glob metacharacters cannot escape the scan.
42
43 Agent UX
44 --------
45 Pass ``--json`` to receive a machine-readable object on stdout.
46
47 Usage::
48
49 muse annotate # show HEAD annotations
50 muse annotate abc1234 # show commit annotations
51 muse annotate abc1234 --reviewed-by agent-x
52 muse annotate abc1234 --reviewed-by alice --reviewed-by bob
53 muse annotate abc1234 --reviewed-by 'alice,claude-v4'
54 muse annotate abc1234 --remove-reviewer agent-old
55 muse annotate abc1234 --test-run
56 muse annotate abc1234 --label hotfix --label perf
57 muse annotate abc1234 --remove-label hotfix
58 muse annotate abc1234 --status approved
59 muse annotate abc1234 --note "Checked edge cases in production."
60 muse annotate abc1234 --score 0.95
61 muse annotate abc1234 --dry-run --reviewed-by agent-x
62 muse annotate abc1234 --json
63
64 JSON schema (``--json``)::
65
66 {
67 "commit_id": "<full 64-char hex>",
68 "parent_commit_id": "<hex or null>",
69 "snapshot_id": "<hex>",
70 "message": "<commit message>",
71 "branch": "<branch name>",
72 "author": "<author>",
73 "agent_id": "<agent id or empty>",
74 "model_id": "<model id or empty>",
75 "committed_at": "<ISO-8601>",
76 "reviewed_by": ["alice", "bob"],
77 "test_runs": 3,
78 "labels": ["hotfix", "perf"],
79 "status": "approved",
80 "notes": ["Checked edge cases in production."],
81 "score": 0.95,
82 "changed": true,
83 "dry_run": false
84 }
85
86 Exit codes
87 ----------
88 - 0 — success (show or mutation applied)
89 - 1 — user error (bad input, commit not found, invalid args)
90 - 2 — not inside a Muse repository
91 """
92
93 import argparse
94 import json
95 import logging
96 import sys
97 from typing import TypedDict
98 import pathlib
99
100 from muse.core.envelope import EnvelopeJson, make_envelope
101 from muse.core.errors import ExitCode
102 from muse.core.repo import require_repo
103 from muse.core.timing import start_timer
104 from muse.core.refs import read_current_branch
105 from muse.core.commits import (
106 CommitRecord,
107 overwrite_commit,
108 resolve_commit_ref,
109 )
110 from muse.core.validation import sanitize_display, sanitize_provenance
111
112
113 logger = logging.getLogger(__name__)
114
115 # ---------------------------------------------------------------------------
116 # Validation constants
117 # ---------------------------------------------------------------------------
118
119 _MAX_REVIEWER_LEN: int = 200
120 _MAX_LABEL_LEN: int = 100
121 _MAX_NOTE_LEN: int = 4000
122
123 _STATUS_VALUES: frozenset[str] = frozenset(
124 {"", "pending", "approved", "rejected", "needs-review", "wip"}
125 )
126
127 # ---------------------------------------------------------------------------
128 # JSON TypedDict — stable machine-readable output schema
129 # ---------------------------------------------------------------------------
130
131 class _AnnotatePayload(TypedDict):
132 """Domain-only fields built by ``_record_to_json``."""
133
134 commit_id: str
135 parent_commit_id: str | None
136 snapshot_id: str
137 message: str
138 branch: str
139 author: str
140 agent_id: str
141 model_id: str
142 committed_at: str
143 reviewed_by: list[str]
144 test_runs: int
145 labels: list[str]
146 status: str
147 notes: list[str]
148 score: float | None
149 changed: bool
150 dry_run: bool
151
152 class _AnnotateJson(_AnnotatePayload, EnvelopeJson):
153 """Full wire shape for ``muse annotate --json``."""
154
155 # ---------------------------------------------------------------------------
156 # Sentinel for "not provided" (distinct from None)
157 # ---------------------------------------------------------------------------
158
159 class _Unset:
160 """Singleton sentinel — distinguishes 'not provided' from ``None``."""
161 _instance: "_Unset | None" = None
162
163 def __new__(cls) -> "_Unset":
164 if cls._instance is None:
165 cls._instance = super().__new__(cls)
166 return cls._instance
167
168 _UNSET = _Unset()
169
170 # ---------------------------------------------------------------------------
171 # Validators
172 # ---------------------------------------------------------------------------
173
174 def _validate_name(name: str, *, field: str, max_len: int) -> str:
175 """Return *name* unchanged if valid; raise SystemExit on bad input."""
176 if not name:
177 print(f"❌ {field} must not be empty.", file=sys.stderr)
178 raise SystemExit(ExitCode.USER_ERROR.value)
179 if len(name) > max_len:
180 print(
181 f"❌ {field} too long ({len(name)} chars, max {max_len}).",
182 file=sys.stderr,
183 )
184 raise SystemExit(ExitCode.USER_ERROR.value)
185 sanitised = sanitize_provenance(name)
186 if sanitised != name:
187 print(
188 f"❌ {field} contains control characters: {name!r}",
189 file=sys.stderr,
190 )
191 raise SystemExit(ExitCode.USER_ERROR.value)
192 return name
193
194 def _validate_reviewer(name: str) -> str:
195 return _validate_name(name, field="reviewer name", max_len=_MAX_REVIEWER_LEN)
196
197 def _validate_label(label: str) -> str:
198 return _validate_name(label, field="label", max_len=_MAX_LABEL_LEN)
199
200 def _validate_status(value: str) -> str:
201 """Return *value* if it is a recognised status string."""
202 if value not in _STATUS_VALUES:
203 valid = ", ".join(sorted(s for s in _STATUS_VALUES if s))
204 print(
205 f"❌ unknown status {value!r}. Valid values: {valid}",
206 file=sys.stderr,
207 )
208 raise SystemExit(ExitCode.USER_ERROR.value)
209 return value
210
211 def _validate_score(raw: str) -> float:
212 """Parse *raw* as a float in [0.0, 1.0]."""
213 try:
214 value = float(raw)
215 except ValueError:
216 print(f"❌ score must be a number, got {raw!r}.", file=sys.stderr)
217 raise SystemExit(ExitCode.USER_ERROR.value)
218 if not (0.0 <= value <= 1.0):
219 print(
220 f"❌ score must be in [0.0, 1.0], got {value}.",
221 file=sys.stderr,
222 )
223 raise SystemExit(ExitCode.USER_ERROR.value)
224 return value
225
226 def _validate_note(text: str) -> str:
227 """Validate a free-text note."""
228 if not text or not text.strip():
229 print("❌ note must not be empty.", file=sys.stderr)
230 raise SystemExit(ExitCode.USER_ERROR.value)
231 if len(text) > _MAX_NOTE_LEN:
232 print(
233 f"❌ note too long ({len(text)} chars, max {_MAX_NOTE_LEN}).",
234 file=sys.stderr,
235 )
236 raise SystemExit(ExitCode.USER_ERROR.value)
237 return text
238
239 # ---------------------------------------------------------------------------
240 # Comma-separated list parsers
241 # ---------------------------------------------------------------------------
242
243 def _parse_name_list(
244 raw_values: list[str],
245 *,
246 validator: "callable[[str], str]",
247 ) -> list[str]:
248 """Expand comma-separated lists and validate each name.
249
250 Accepts both ``--flag alice --flag bob`` and ``--flag 'alice,bob'``.
251 Whitespace is stripped; duplicates are removed (insertion order preserved).
252 """
253 seen: set[str] = set()
254 result: list[str] = []
255 for raw in raw_values:
256 for part in raw.split(","):
257 name = part.strip()
258 if not name:
259 continue
260 validator(name)
261 if name not in seen:
262 seen.add(name)
263 result.append(name)
264 return result
265
266 # ---------------------------------------------------------------------------
267 # Reviewer list helper
268 # ---------------------------------------------------------------------------
269
270 def _parse_reviewer_list(raw_values: list[str]) -> list[str]:
271 """Expand and validate a list of reviewer name tokens.
272
273 Convenience wrapper over ``_parse_name_list`` with ``_validate_reviewer``.
274 """
275 return _parse_name_list(raw_values, validator=_validate_reviewer)
276
277 # ---------------------------------------------------------------------------
278 # JSON builder
279 # ---------------------------------------------------------------------------
280
281 def _commit_to_json(
282 record: CommitRecord,
283 *,
284 changed: bool,
285 dry_run: bool,
286 ) -> "_AnnotatePayload":
287 """Build the annotation JSON payload from a ``CommitRecord``.
288
289 Convenience wrapper over ``_record_to_json`` with no overrides.
290 """
291 return _record_to_json(record, changed=changed, dry_run=dry_run)
292
293 def _record_to_json(
294 record: CommitRecord,
295 *,
296 override_reviewed_by: list[str] | None = None,
297 override_test_runs: int | None = None,
298 override_labels: list[str] | None = None,
299 override_status: str | None = None,
300 override_notes: list[str] | None = None,
301 override_score: "float | None | _Unset" = None,
302 use_score_override: bool = False,
303 changed: bool,
304 dry_run: bool,
305 ) -> _AnnotatePayload:
306 """Build the domain JSON payload from a ``CommitRecord``, with optional overrides."""
307 final_score = (
308 override_score # type: ignore[assignment]
309 if use_score_override
310 else record.score
311 )
312 return _AnnotatePayload(
313 commit_id=record.commit_id,
314 parent_commit_id=record.parent_commit_id,
315 snapshot_id=record.snapshot_id,
316 message=record.message,
317 branch=record.branch,
318 author=record.author,
319 agent_id=record.agent_id,
320 model_id=record.model_id,
321 committed_at=record.committed_at.isoformat(),
322 reviewed_by=(
323 override_reviewed_by
324 if override_reviewed_by is not None
325 else list(record.reviewed_by)
326 ),
327 test_runs=(
328 override_test_runs
329 if override_test_runs is not None
330 else record.test_runs
331 ),
332 labels=(
333 override_labels
334 if override_labels is not None
335 else list(record.labels)
336 ),
337 status=(
338 override_status
339 if override_status is not None
340 else record.status
341 ),
342 notes=(
343 override_notes
344 if override_notes is not None
345 else list(record.notes)
346 ),
347 score=final_score,
348 changed=changed,
349 dry_run=dry_run,
350 )
351
352 # ---------------------------------------------------------------------------
353 # Human-readable display
354 # ---------------------------------------------------------------------------
355
356 def _show(record: CommitRecord, *, output_json: bool, envelope: EnvelopeJson | None = None) -> None:
357 """Display the current annotations for *record*."""
358 if output_json:
359 payload = _record_to_json(record, changed=False, dry_run=False)
360 print(json.dumps(_AnnotateJson(**(envelope or {}), **payload)))
361 return
362
363 cid = sanitize_display(record.commit_id)
364 print(f"ℹ️ commit {cid}")
365 if record.reviewed_by:
366 reviewers = ", ".join(sanitize_display(r) for r in sorted(record.reviewed_by))
367 print(f" reviewed-by: {reviewers}")
368 else:
369 print(" reviewed-by: (none)")
370 print(f" test-runs: {record.test_runs}")
371 if record.labels:
372 labels = ", ".join(sanitize_display(lbl) for lbl in sorted(record.labels))
373 print(f" labels: {labels}")
374 else:
375 print(" labels: (none)")
376 print(f" status: {sanitize_display(record.status) or '(unset)'}")
377 if record.notes:
378 print(f" notes: {len(record.notes)} note(s)")
379 for i, note in enumerate(record.notes, 1):
380 print(f" [{i}] {sanitize_display(note)}")
381 else:
382 print(" notes: (none)")
383 score_str = f"{record.score:.4f}" if record.score is not None else "(unset)"
384 print(f" score: {score_str}")
385
386 # ---------------------------------------------------------------------------
387 # Command registration
388 # ---------------------------------------------------------------------------
389
390 def register(
391 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
392 ) -> None:
393 """Register the ``annotate`` subcommand."""
394 parser = subparsers.add_parser(
395 "annotate",
396 help="Attach CRDT-backed annotations to an existing commit.",
397 description=__doc__,
398 formatter_class=argparse.RawDescriptionHelpFormatter,
399 )
400 parser.add_argument(
401 "commit_arg",
402 nargs="?",
403 default=None,
404 metavar="COMMIT",
405 help="Commit to annotate: full SHA, short prefix, HEAD~N, or omit for HEAD.",
406 )
407 # --- reviewer flags ---
408 parser.add_argument(
409 "--reviewed-by",
410 action="append",
411 dest="reviewed_by",
412 default=None,
413 metavar="NAME[,NAME…]",
414 help=(
415 "Add a reviewer (ORSet semantics). "
416 "Accepts comma-separated names or multiple flags."
417 ),
418 )
419 parser.add_argument(
420 "--remove-reviewer",
421 action="append",
422 dest="remove_reviewer",
423 default=None,
424 metavar="NAME[,NAME…]",
425 help="Remove a reviewer. Comma-separated or multiple flags.",
426 )
427 # --- test-run counter ---
428 parser.add_argument(
429 "--test-run",
430 action="store_true",
431 dest="test_run",
432 help="Increment the GCounter test-run count for this commit.",
433 )
434 # --- label flags ---
435 parser.add_argument(
436 "--label",
437 action="append",
438 dest="label",
439 default=None,
440 metavar="LABEL[,LABEL…]",
441 help=(
442 "Add a label (ORSet semantics). "
443 "Accepts comma-separated labels or multiple flags."
444 ),
445 )
446 parser.add_argument(
447 "--remove-label",
448 action="append",
449 dest="remove_label",
450 default=None,
451 metavar="LABEL[,LABEL…]",
452 help="Remove a label. Comma-separated or multiple flags.",
453 )
454 # --- status (LWW) ---
455 parser.add_argument(
456 "--status",
457 dest="status",
458 default=None,
459 metavar="STATUS",
460 help=(
461 "Set commit status (LWW). "
462 f"Valid values: {', '.join(sorted(s for s in _STATUS_VALUES if s))}. "
463 "Pass empty string to clear."
464 ),
465 )
466 # --- note (append-only) ---
467 parser.add_argument(
468 "--note",
469 action="append",
470 dest="notes",
471 default=None,
472 metavar="TEXT",
473 help="Append a free-text note (append-only, no dedup).",
474 )
475 # --- score (LWW float) ---
476 parser.add_argument(
477 "--score",
478 dest="score_raw",
479 default=None,
480 metavar="FLOAT",
481 help="Set a quality score in [0.0, 1.0] (LWW semantics).",
482 )
483 # --- shared flags ---
484 parser.add_argument(
485 "--dry-run", "-n",
486 action="store_true",
487 dest="dry_run",
488 help="Show what would change without writing to disk.",
489 )
490 parser.add_argument(
491 "--json", "-j",
492 action="store_true",
493 dest="json_out",
494 help="Emit a JSON object to stdout.",
495 )
496 parser.set_defaults(func=run)
497
498 # ---------------------------------------------------------------------------
499 # Command handler
500 # ---------------------------------------------------------------------------
501
502 def run(args: argparse.Namespace) -> None:
503 """Attach CRDT-backed annotations to an existing commit.
504
505 Adds or removes reviewers, labels, status, notes, and quality scores
506 on any commit without rewriting history. Annotations are stored as
507 CRDT deltas and merged automatically across branches.
508
509 Agent quickstart
510 ----------------
511 ::
512
513 muse annotate --reviewed-by alice --label approved --json
514 muse annotate --status reviewed --score 0.95 --json
515 muse annotate <commit-id> --remove-label wip --json
516
517 JSON fields
518 -----------
519 commit_id Commit that was annotated.
520 reviewers Full list of reviewers after the update.
521 labels Full list of labels after the update.
522 status Current status string, or ``null``.
523 score Quality score float, or ``null``.
524 notes List of note strings.
525 dry_run ``true`` when ``--dry-run`` was passed (no writes occurred).
526
527 Exit codes
528 ----------
529 0 Annotated successfully.
530 1 Validation error or commit not found.
531 2 Not inside a Muse repository.
532 """
533 elapsed = start_timer()
534 commit_arg: str | None = args.commit_arg
535 reviewed_by_raw: list[str] | None = args.reviewed_by
536 remove_reviewer_raw: list[str] | None = args.remove_reviewer
537 test_run: bool = args.test_run
538 label_raw: list[str] | None = args.label
539 remove_label_raw: list[str] | None = args.remove_label
540 status_raw: str | None = args.status
541 notes_raw: list[str] | None = args.notes
542 score_raw: str | None = args.score_raw
543 dry_run: bool = args.dry_run
544 json_out: bool = args.json_out
545
546 root = require_repo()
547 branch = read_current_branch(root)
548
549 record = resolve_commit_ref(root, branch, commit_arg)
550 if record is None:
551 ref_display = sanitize_display(commit_arg or "HEAD")
552 print(f"❌ commit {ref_display!r} not found.", file=sys.stderr)
553 raise SystemExit(ExitCode.NOT_FOUND.value)
554
555 # Validate all inputs before touching any state.
556 add_reviewers = _parse_name_list(reviewed_by_raw or [], validator=_validate_reviewer)
557 remove_reviewers = _parse_name_list(remove_reviewer_raw or [], validator=_validate_reviewer)
558 add_labels = _parse_name_list(label_raw or [], validator=_validate_label)
559 remove_labels = _parse_name_list(remove_label_raw or [], validator=_validate_label)
560 new_status: str | None = _validate_status(status_raw) if status_raw is not None else None
561 new_notes: list[str] = [_validate_note(n) for n in (notes_raw or [])]
562 score_provided = score_raw is not None
563 new_score: float | None = _validate_score(score_raw) if score_provided else None
564
565 is_show_mode = (
566 not add_reviewers
567 and not remove_reviewers
568 and not test_run
569 and not add_labels
570 and not remove_labels
571 and new_status is None
572 and not new_notes
573 and not score_provided
574 )
575
576 if is_show_mode:
577 _show(record, output_json=json_out, envelope=make_envelope(elapsed))
578 return
579
580 # --- compute new state ---
581
582 # reviewed_by: ORSet
583 reviewer_set: set[str] = set(record.reviewed_by)
584 added_reviewers: list[str] = []
585 removed_reviewers: list[str] = []
586 for name in add_reviewers:
587 if name not in reviewer_set:
588 reviewer_set.add(name)
589 added_reviewers.append(name)
590 for name in remove_reviewers:
591 if name in reviewer_set:
592 reviewer_set.discard(name)
593 removed_reviewers.append(name)
594 final_reviewed_by = sorted(reviewer_set)
595
596 # test_runs: GCounter
597 final_test_runs = record.test_runs + (1 if test_run else 0)
598
599 # labels: ORSet
600 label_set: set[str] = set(record.labels)
601 added_labels: list[str] = []
602 removed_labels: list[str] = []
603 for lbl in add_labels:
604 if lbl not in label_set:
605 label_set.add(lbl)
606 added_labels.append(lbl)
607 for lbl in remove_labels:
608 if lbl in label_set:
609 label_set.discard(lbl)
610 removed_labels.append(lbl)
611 final_labels = sorted(label_set)
612
613 # status: LWW
614 final_status = new_status if new_status is not None else record.status
615
616 # notes: append-only
617 final_notes = list(record.notes) + new_notes
618
619 # score: LWW
620 final_score: float | None = new_score if score_provided else record.score
621
622 changed = (
623 final_reviewed_by != sorted(record.reviewed_by)
624 or final_test_runs != record.test_runs
625 or final_labels != sorted(record.labels)
626 or final_status != record.status
627 or final_notes != record.notes
628 or final_score != record.score
629 )
630
631 if not dry_run and changed:
632 record.reviewed_by = final_reviewed_by
633 record.test_runs = final_test_runs
634 record.labels = final_labels
635 record.status = final_status
636 record.notes = final_notes
637 record.score = final_score
638 overwrite_commit(root, record)
639
640 if json_out:
641 payload = _record_to_json(
642 record,
643 override_reviewed_by=final_reviewed_by,
644 override_test_runs=final_test_runs,
645 override_labels=final_labels,
646 override_status=final_status,
647 override_notes=final_notes,
648 override_score=final_score,
649 use_score_override=True,
650 changed=changed,
651 dry_run=dry_run,
652 )
653 print(json.dumps(_AnnotateJson(**make_envelope(elapsed), **payload)))
654 return
655
656 # Human-readable output.
657 cid = sanitize_display(record.commit_id)
658 prefix = "[dry-run] " if dry_run else ""
659
660 for name in added_reviewers:
661 print(f"✅ {prefix}Added reviewer: {sanitize_display(name)}")
662 for name in removed_reviewers:
663 print(f"✅ {prefix}Removed reviewer: {sanitize_display(name)}")
664
665 removed_reviewer_set = set(removed_reviewers)
666 for name in remove_reviewers:
667 if name not in removed_reviewer_set:
668 print(
669 f"⚠️ reviewer {sanitize_display(name)!r} was not present.",
670 file=sys.stderr,
671 )
672
673 if test_run:
674 print(f"✅ {prefix}Test run recorded (total: {final_test_runs})")
675
676 for lbl in added_labels:
677 print(f"✅ {prefix}Added label: {sanitize_display(lbl)}")
678 for lbl in removed_labels:
679 print(f"✅ {prefix}Removed label: {sanitize_display(lbl)}")
680
681 removed_label_set = set(removed_labels)
682 for lbl in remove_labels:
683 if lbl not in removed_label_set:
684 print(
685 f"⚠️ label {sanitize_display(lbl)!r} was not present.",
686 file=sys.stderr,
687 )
688
689 if new_status is not None:
690 status_display = sanitize_display(new_status) or "(cleared)"
691 print(f"✅ {prefix}Status set to: {status_display}")
692
693 for note in new_notes:
694 preview = sanitize_display(note[:60])
695 print(f"✅ {prefix}Note appended: {preview!r}")
696
697 if score_provided:
698 print(f"✅ {prefix}Score set to: {final_score:.4f}")
699
700 if changed:
701 print(f"[{cid}] annotation {'would be ' if dry_run else ''}updated")
702 else:
703 print(f"[{cid}] no changes (annotations already up to date)")
File History 1 commit
sha256:f9828efc523c2f8ccaee12ae76086a09a9a6a10d6dd20e53f0b13793f0fdcf50 docs: add symlog (#53) and reflog (#54) follow-up issue files Sonnet 4.6 18 days ago