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 | from __future__ import annotations |
| 25 | |
| 26 | import logging |
| 27 | import pathlib |
| 28 | |
| 29 | import typer |
| 30 | |
| 31 | from muse.core.crdts.or_set import ORSet |
| 32 | from muse.core.repo import require_repo |
| 33 | from muse.core.store import get_head_commit_id, overwrite_commit, read_commit |
| 34 | |
| 35 | logger = logging.getLogger(__name__) |
| 36 | |
| 37 | app = typer.Typer() |
| 38 | |
| 39 | |
| 40 | def _resolve_commit_id(root: pathlib.Path, commit_arg: str | None) -> str | None: |
| 41 | """Return the resolved commit ID (HEAD branch if *commit_arg* is None).""" |
| 42 | if commit_arg is None: |
| 43 | head_ref = (root / ".muse" / "HEAD").read_text().strip() |
| 44 | branch = head_ref.removeprefix("refs/heads/").strip() |
| 45 | return get_head_commit_id(root, branch) |
| 46 | return commit_arg |
| 47 | |
| 48 | |
| 49 | @app.callback(invoke_without_command=True) |
| 50 | def annotate( |
| 51 | ctx: typer.Context, |
| 52 | commit_arg: str | None = typer.Argument(None, help="Commit ID to annotate (default: HEAD)."), |
| 53 | reviewed_by: str | None = typer.Option(None, "--reviewed-by", help="Add a reviewer (comma-separated for multiple: --reviewed-by 'alice,bob')."), |
| 54 | test_run: bool = typer.Option(False, "--test-run", help="Increment the GCounter test-run count for this commit."), |
| 55 | ) -> None: |
| 56 | """Attach CRDT-backed annotations to an existing commit. |
| 57 | |
| 58 | ``--reviewed-by`` uses ORSet semantics — a reviewer once added is never |
| 59 | removed. Pass multiple reviewers as a comma-separated |
| 60 | string: ``--reviewed-by 'alice,claude-v4'``. |
| 61 | ``--test-run`` uses GCounter semantics — the count is monotonically |
| 62 | increasing and concurrent increments are additive. |
| 63 | """ |
| 64 | root = require_repo() |
| 65 | |
| 66 | commit_id = _resolve_commit_id(root, commit_arg) |
| 67 | if commit_id is None: |
| 68 | typer.echo("❌ No commit found.") |
| 69 | raise typer.Exit(code=1) |
| 70 | |
| 71 | record = read_commit(root, commit_id) |
| 72 | if record is None: |
| 73 | typer.echo(f"❌ Commit {commit_id!r} not found.") |
| 74 | raise typer.Exit(code=1) |
| 75 | |
| 76 | # Parse comma-separated reviewers into a list. |
| 77 | reviewers: list[str] = ( |
| 78 | [r.strip() for r in reviewed_by.split(",") if r.strip()] |
| 79 | if reviewed_by else [] |
| 80 | ) |
| 81 | |
| 82 | if not reviewers and not test_run: |
| 83 | typer.echo(f"ℹ️ commit {commit_id[:8]}") |
| 84 | if record.reviewed_by: |
| 85 | typer.echo(f" reviewed-by: {', '.join(sorted(record.reviewed_by))}") |
| 86 | else: |
| 87 | typer.echo(" reviewed-by: (none)") |
| 88 | typer.echo(f" test-runs: {record.test_runs}") |
| 89 | return |
| 90 | |
| 91 | changed = False |
| 92 | |
| 93 | if reviewers: |
| 94 | # ORSet merge: current set ∪ new reviewers. |
| 95 | current_set: ORSet = ORSet() |
| 96 | for r in record.reviewed_by: |
| 97 | current_set, _tok = current_set.add(r) |
| 98 | for r in reviewers: |
| 99 | current_set, _tok = current_set.add(r) |
| 100 | new_list = sorted(current_set.elements()) |
| 101 | if new_list != sorted(record.reviewed_by): |
| 102 | record.reviewed_by = new_list |
| 103 | changed = True |
| 104 | for r in reviewers: |
| 105 | typer.echo(f"✅ Added reviewer: {r}") |
| 106 | |
| 107 | if test_run: |
| 108 | # GCounter semantics: the value is monotonically non-decreasing. |
| 109 | # Each call increments by 1 (the GCounter join of two replicas |
| 110 | # would take max(a, b) per agent key; here we model the single-writer |
| 111 | # common case as a simple increment). |
| 112 | record.test_runs += 1 |
| 113 | changed = True |
| 114 | typer.echo(f"✅ Test run recorded (total: {record.test_runs})") |
| 115 | |
| 116 | if changed: |
| 117 | overwrite_commit(root, record) |
| 118 | typer.echo(f"[{commit_id[:8]}] annotation updated") |