annotate.py
python
| 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`` merges into the commit's ``reviewed_by`` field using |
| 7 | **ORSet** semantics (set union — once added, a reviewer is never lost). |
| 8 | - ``--test-run`` increments the commit's ``test_runs`` field using |
| 9 | **GCounter** semantics (monotonically increasing). |
| 10 | |
| 11 | These annotations are persisted directly in the commit JSON on disk. If two |
| 12 | agents race to annotate the same commit, the last writer wins for the raw |
| 13 | JSON, but the semantics are CRDT-safe: ORSet entries are unioned and GCounter |
| 14 | values are taken as the max. |
| 15 | |
| 16 | Usage:: |
| 17 | |
| 18 | muse annotate abc1234 --reviewed-by agent-x |
| 19 | muse annotate abc1234 --reviewed-by human-bob --reviewed-by claude-v4 |
| 20 | muse annotate abc1234 --test-run |
| 21 | muse annotate abc1234 --reviewed-by ci-bot --test-run |
| 22 | muse annotate # annotate HEAD |
| 23 | """ |
| 24 | |
| 25 | from __future__ import annotations |
| 26 | |
| 27 | import argparse |
| 28 | import logging |
| 29 | import pathlib |
| 30 | import sys |
| 31 | |
| 32 | from muse.core.crdts.or_set import ORSet |
| 33 | from muse.core.repo import require_repo |
| 34 | from muse.core.store import get_head_commit_id, overwrite_commit, read_commit, read_current_branch |
| 35 | |
| 36 | logger = logging.getLogger(__name__) |
| 37 | |
| 38 | |
| 39 | def _resolve_commit_id(root: pathlib.Path, commit_arg: str | None) -> str | None: |
| 40 | """Return the resolved commit ID (HEAD branch if *commit_arg* is None).""" |
| 41 | if commit_arg is None: |
| 42 | branch = read_current_branch(root) |
| 43 | return get_head_commit_id(root, branch) |
| 44 | return commit_arg |
| 45 | |
| 46 | |
| 47 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 48 | """Register the annotate subcommand.""" |
| 49 | parser = subparsers.add_parser( |
| 50 | "annotate", |
| 51 | help="Attach CRDT-backed annotations to an existing commit.", |
| 52 | description=__doc__, |
| 53 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 54 | ) |
| 55 | parser.add_argument( |
| 56 | "commit_arg", nargs="?", default=None, |
| 57 | help="Commit ID to annotate (default: HEAD).", |
| 58 | ) |
| 59 | parser.add_argument( |
| 60 | "--reviewed-by", default=None, dest="reviewed_by", |
| 61 | help="Add a reviewer (comma-separated for multiple: --reviewed-by 'alice,bob').", |
| 62 | ) |
| 63 | parser.add_argument( |
| 64 | "--test-run", action="store_true", dest="test_run", |
| 65 | help="Increment the GCounter test-run count for this commit.", |
| 66 | ) |
| 67 | parser.set_defaults(func=run) |
| 68 | |
| 69 | |
| 70 | def run(args: argparse.Namespace) -> None: |
| 71 | """Attach CRDT-backed annotations to an existing commit. |
| 72 | |
| 73 | ``--reviewed-by`` uses ORSet semantics — a reviewer once added is never |
| 74 | removed. Pass multiple reviewers as a comma-separated |
| 75 | string: ``--reviewed-by 'alice,claude-v4'``. |
| 76 | ``--test-run`` uses GCounter semantics — the count is monotonically |
| 77 | increasing and concurrent increments are additive. |
| 78 | """ |
| 79 | commit_arg: str | None = args.commit_arg |
| 80 | reviewed_by: str | None = args.reviewed_by |
| 81 | test_run: bool = args.test_run |
| 82 | |
| 83 | root = require_repo() |
| 84 | |
| 85 | commit_id = _resolve_commit_id(root, commit_arg) |
| 86 | if commit_id is None: |
| 87 | print("❌ No commit found.") |
| 88 | raise SystemExit(1) |
| 89 | |
| 90 | record = read_commit(root, commit_id) |
| 91 | if record is None: |
| 92 | print(f"❌ Commit {commit_id!r} not found.") |
| 93 | raise SystemExit(1) |
| 94 | |
| 95 | # Parse comma-separated reviewers into a list. |
| 96 | reviewers: list[str] = ( |
| 97 | [r.strip() for r in reviewed_by.split(",") if r.strip()] |
| 98 | if reviewed_by else [] |
| 99 | ) |
| 100 | |
| 101 | if not reviewers and not test_run: |
| 102 | print(f"ℹ️ commit {commit_id[:8]}") |
| 103 | if record.reviewed_by: |
| 104 | print(f" reviewed-by: {', '.join(sorted(record.reviewed_by))}") |
| 105 | else: |
| 106 | print(" reviewed-by: (none)") |
| 107 | print(f" test-runs: {record.test_runs}") |
| 108 | return |
| 109 | |
| 110 | changed = False |
| 111 | |
| 112 | if reviewers: |
| 113 | # ORSet merge: current set ∪ new reviewers. |
| 114 | current_set: ORSet = ORSet() |
| 115 | for r in record.reviewed_by: |
| 116 | current_set, _tok = current_set.add(r) |
| 117 | for r in reviewers: |
| 118 | current_set, _tok = current_set.add(r) |
| 119 | new_list = sorted(current_set.elements()) |
| 120 | if new_list != sorted(record.reviewed_by): |
| 121 | record.reviewed_by = new_list |
| 122 | changed = True |
| 123 | for r in reviewers: |
| 124 | print(f"✅ Added reviewer: {r}") |
| 125 | |
| 126 | if test_run: |
| 127 | # GCounter semantics: the value is monotonically non-decreasing. |
| 128 | # Each call increments by 1 (the GCounter join of two replicas |
| 129 | # would take max(a, b) per agent key; here we model the single-writer |
| 130 | # common case as a simple increment). |
| 131 | record.test_runs += 1 |
| 132 | changed = True |
| 133 | print(f"✅ Test run recorded (total: {record.test_runs})") |
| 134 | |
| 135 | if changed: |
| 136 | overwrite_commit(root, record) |
| 137 | print(f"[{commit_id[:8]}] annotation updated") |