annotate.py file-level

at main · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:c docs(KD-STAGING): sync governance after KD-6b DONE Mirror workspace go… · aaronrene · Jul 10, 2026
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 - ``--remove-reviewer`` removes a reviewer from the stored list.
9 - ``--test-run`` increments the commit's ``test_runs`` field using
10 **GCounter** semantics (monotonically increasing).
11
12 These annotations are persisted directly in the commit JSON on disk.
13
14 Commit reference
15 ----------------
16 The *commit* argument accepts any reference that ``resolve_commit_ref``
17 understands:
18
19 - ``HEAD`` or omitted — the most recent commit on the current branch.
20 - ``HEAD~N`` — *N* first-parent steps back from HEAD.
21 - A full 64-character hex commit ID.
22 - A short hex prefix (e.g. ``abc1234``) — resolved by prefix scan.
23
24 Security model
25 --------------
26 - All reviewer names are validated before storage: control characters,
27 ANSI escapes, and names longer than 200 characters are rejected.
28 - All user-controlled values are sanitized via ``sanitize_display()``
29 before appearing in human-readable terminal output.
30 - All error messages go to **stderr**; **stdout** carries only data.
31 - Commit references are resolved through the safe-prefix scan in
32 ``resolve_commit_ref`` — glob metacharacters cannot escape the scan.
33
34 Agent UX
35 --------
36 Pass ``--json`` to receive a machine-readable object on stdout.
37
38 Usage::
39
40 muse annotate # show HEAD annotations
41 muse annotate abc1234 # show commit annotations
42 muse annotate abc1234 --reviewed-by agent-x
43 muse annotate abc1234 --reviewed-by alice --reviewed-by bob
44 muse annotate abc1234 --reviewed-by 'alice,claude-v4'
45 muse annotate abc1234 --remove-reviewer agent-old
46 muse annotate abc1234 --test-run
47 muse annotate abc1234 --dry-run --reviewed-by agent-x
48 muse annotate abc1234 --json
49
50 JSON schema (``--json``)::
51
52 {
53 "commit_id": "<full 64-char hex>",
54 "message": "<commit message>",
55 "branch": "<branch name>",
56 "author": "<author>",
57 "committed_at": "<ISO-8601>",
58 "reviewed_by": ["alice", "bob"],
59 "test_runs": 3,
60 "changed": true,
61 "dry_run": false
62 }
63
64 Exit codes
65 ----------
66 - 0 — success (show or mutation applied)
67 - 1 — user error (bad reviewer name, commit not found, invalid args)
68 - 2 — not inside a Muse repository
69 """
70 from __future__ import annotations
71
72 import argparse
73 import json
74 import logging
75 import sys
76 from typing import TYPE_CHECKING, TypedDict
77
78 from muse.core.errors import ExitCode
79 from muse.core.repo import read_repo_id, require_repo
80 from muse.core.store import (
81 CommitRecord,
82 overwrite_commit,
83 read_current_branch,
84 resolve_commit_ref,
85 )
86 from muse.core.validation import sanitize_display, sanitize_provenance
87
88 if TYPE_CHECKING:
89 import pathlib
90
91 logger = logging.getLogger(__name__)
92
93 # Maximum length for a reviewer name — prevents outsized stored values.
94 _MAX_REVIEWER_LEN: int = 200
95
96
97 # ---------------------------------------------------------------------------
98 # JSON TypedDict — stable machine-readable output schema
99 # ---------------------------------------------------------------------------
100
101
102 class _AnnotateJson(TypedDict):
103 """JSON output of ``muse annotate [--json]``."""
104
105 commit_id: str
106 message: str
107 branch: str
108 author: str
109 committed_at: str
110 reviewed_by: list[str]
111 test_runs: int
112 changed: bool
113 dry_run: bool
114
115
116 # ---------------------------------------------------------------------------
117 # Internal helpers
118 # ---------------------------------------------------------------------------
119
120
121 def _validate_reviewer(name: str) -> str:
122 """Return *name* unchanged if it is an acceptable reviewer identifier.
123
124 Raises:
125 SystemExit(USER_ERROR): if *name* contains control characters or
126 exceeds ``_MAX_REVIEWER_LEN`` characters.
127 """
128 if not name:
129 print("❌ reviewer name must not be empty.", file=sys.stderr)
130 raise SystemExit(ExitCode.USER_ERROR.value)
131 if len(name) > _MAX_REVIEWER_LEN:
132 print(
133 f"❌ reviewer name too long ({len(name)} chars, max {_MAX_REVIEWER_LEN}).",
134 file=sys.stderr,
135 )
136 raise SystemExit(ExitCode.USER_ERROR.value)
137 # Use sanitize_provenance to detect — if sanitised form differs, it
138 # contained control characters.
139 sanitised = sanitize_provenance(name)
140 if sanitised != name:
141 print(
142 f"❌ reviewer name contains control characters: {name!r}",
143 file=sys.stderr,
144 )
145 raise SystemExit(ExitCode.USER_ERROR.value)
146 return name
147
148
149 def _parse_reviewer_list(raw_values: list[str]) -> list[str]:
150 """Expand comma-separated reviewer lists and validate each name.
151
152 Accepts both ``--reviewed-by alice --reviewed-by bob`` (multiple flags,
153 each value in *raw_values*) and ``--reviewed-by 'alice,bob'`` (a single
154 comma-separated value). Whitespace is stripped from each name.
155
156 Returns:
157 Deduplicated, validated list of reviewer names (insertion order).
158 """
159 seen: set[str] = set()
160 result: list[str] = []
161 for raw in raw_values:
162 for part in raw.split(","):
163 name = part.strip()
164 if not name:
165 continue
166 _validate_reviewer(name)
167 if name not in seen:
168 seen.add(name)
169 result.append(name)
170 return result
171
172
173 def _commit_to_json(
174 record: CommitRecord,
175 *,
176 changed: bool,
177 dry_run: bool,
178 ) -> _AnnotateJson:
179 """Build the JSON payload from a ``CommitRecord``."""
180 return _AnnotateJson(
181 commit_id=record.commit_id,
182 message=record.message,
183 branch=record.branch,
184 author=record.author,
185 committed_at=record.committed_at.isoformat(),
186 reviewed_by=list(record.reviewed_by),
187 test_runs=record.test_runs,
188 changed=changed,
189 dry_run=dry_run,
190 )
191
192
193 # ---------------------------------------------------------------------------
194 # Command registration
195 # ---------------------------------------------------------------------------
196
197
198 def register(
199 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
200 ) -> None:
201 """Register the ``annotate`` subcommand.
202
203 Every output path accepts ``--json`` for machine-readable stdout.
204 All diagnostic messages go to stderr.
205 """
206 parser = subparsers.add_parser(
207 "annotate",
208 help="Attach CRDT-backed annotations to an existing commit.",
209 description=__doc__,
210 formatter_class=argparse.RawDescriptionHelpFormatter,
211 )
212 parser.add_argument(
213 "commit_arg",
214 nargs="?",
215 default=None,
216 metavar="COMMIT",
217 help=(
218 "Commit to annotate: full SHA, short prefix, HEAD~N, or omit for HEAD."
219 ),
220 )
221 parser.add_argument(
222 "--reviewed-by",
223 action="append",
224 dest="reviewed_by",
225 default=None,
226 metavar="NAME[,NAME…]",
227 help=(
228 "Add a reviewer (ORSet semantics — once added, never lost). "
229 "Accepts comma-separated names or multiple flags."
230 ),
231 )
232 parser.add_argument(
233 "--remove-reviewer",
234 action="append",
235 dest="remove_reviewer",
236 default=None,
237 metavar="NAME[,NAME…]",
238 help=(
239 "Remove a reviewer from the stored list. "
240 "Comma-separated or multiple flags."
241 ),
242 )
243 parser.add_argument(
244 "--test-run",
245 action="store_true",
246 dest="test_run",
247 help="Increment the GCounter test-run count for this commit.",
248 )
249 parser.add_argument(
250 "--dry-run",
251 action="store_true",
252 dest="dry_run",
253 help="Show what would change without writing to disk.",
254 )
255 parser.add_argument(
256 "--json",
257 action="store_true",
258 dest="output_json",
259 default=False,
260 help="Emit a JSON object to stdout.",
261 )
262 parser.set_defaults(func=run)
263
264
265 # ---------------------------------------------------------------------------
266 # Command handler
267 # ---------------------------------------------------------------------------
268
269
270 def run(args: argparse.Namespace) -> None:
271 """Attach CRDT-backed annotations to an existing commit.
272
273 When called with no mutation flags, displays the current annotations for
274 the specified commit (or HEAD). Mutation flags modify the stored record:
275
276 ``--reviewed-by NAME``
277 ORSet union — merges new names into the existing reviewer set.
278 A name once added is never removed by concurrent additions.
279
280 ``--remove-reviewer NAME``
281 Removes a name from the stored reviewer list. Not CRDT-safe for
282 concurrent removals, but useful for correcting mistakes.
283
284 ``--test-run``
285 GCounter increment — monotonically increases ``test_runs`` by 1.
286
287 ``--dry-run``
288 Resolves the commit and computes the new annotation state without
289 writing anything to disk. Useful for previewing changes.
290 """
291 commit_arg: str | None = args.commit_arg
292 reviewed_by_raw: list[str] | None = args.reviewed_by
293 remove_reviewer_raw: list[str] | None = args.remove_reviewer
294 test_run: bool = args.test_run
295 dry_run: bool = args.dry_run
296 output_json: bool = args.output_json
297
298 root = require_repo()
299 repo_id = read_repo_id(root)
300 branch = read_current_branch(root)
301
302 # Resolve the commit reference (supports short IDs, HEAD~N, HEAD).
303 record = resolve_commit_ref(root, repo_id, branch, commit_arg)
304 if record is None:
305 ref_display = sanitize_display(commit_arg or "HEAD")
306 print(f"❌ commit {ref_display!r} not found.", file=sys.stderr)
307 raise SystemExit(ExitCode.NOT_FOUND.value)
308
309 # Parse and validate reviewer arguments before any mutation.
310 add_reviewers: list[str] = _parse_reviewer_list(reviewed_by_raw or [])
311 remove_reviewers: list[str] = _parse_reviewer_list(remove_reviewer_raw or [])
312
313 is_show_mode = not add_reviewers and not remove_reviewers and not test_run
314
315 if is_show_mode:
316 _show(record, output_json=output_json)
317 return
318
319 # Apply mutations to a copy of the record fields.
320 current_set: set[str] = set(record.reviewed_by)
321
322 added: list[str] = []
323 removed: list[str] = []
324
325 if add_reviewers:
326 for name in add_reviewers:
327 if name not in current_set:
328 current_set.add(name)
329 added.append(name)
330
331 if remove_reviewers:
332 for name in remove_reviewers:
333 if name in current_set:
334 current_set.discard(name)
335 removed.append(name)
336
337 new_reviewed_by: list[str] = sorted(current_set)
338 new_test_runs: int = record.test_runs + (1 if test_run else 0)
339 changed = (
340 new_reviewed_by != sorted(record.reviewed_by)
341 or new_test_runs != record.test_runs
342 )
343
344 if not dry_run and changed:
345 record.reviewed_by = new_reviewed_by
346 record.test_runs = new_test_runs
347 overwrite_commit(root, record)
348
349 if output_json:
350 # If dry_run: report the *prospective* state (not yet written).
351 payload = _AnnotateJson(
352 commit_id=record.commit_id,
353 message=record.message,
354 branch=record.branch,
355 author=record.author,
356 committed_at=record.committed_at.isoformat(),
357 reviewed_by=new_reviewed_by,
358 test_runs=new_test_runs,
359 changed=changed,
360 dry_run=dry_run,
361 )
362 print(json.dumps(payload, indent=2))
363 return
364
365 # Human-readable output.
366 cid = sanitize_display(record.commit_id[:8])
367 prefix = "[dry-run] " if dry_run else ""
368
369 for name in added:
370 print(f"✅ {prefix}Added reviewer: {sanitize_display(name)}")
371
372 for name in removed:
373 print(f"✅ {prefix}Removed reviewer: {sanitize_display(name)}")
374
375 # Warn about names that were requested for removal but weren't present.
376 removed_set: set[str] = set(removed)
377 for name in remove_reviewers:
378 if name not in removed_set:
379 print(
380 f"⚠️ reviewer {sanitize_display(name)!r} was not present.",
381 file=sys.stderr,
382 )
383
384 if test_run:
385 print(f"✅ {prefix}Test run recorded (total: {new_test_runs})")
386
387 if changed:
388 print(f"[{cid}] annotation {'would be ' if dry_run else ''}updated")
389 else:
390 print(f"[{cid}] no changes (annotations already up to date)")
391
392
393 def _show(record: CommitRecord, *, output_json: bool) -> None:
394 """Display the current annotations for *record*."""
395 if output_json:
396 payload = _commit_to_json(record, changed=False, dry_run=False)
397 print(json.dumps(payload, indent=2))
398 return
399
400 cid = sanitize_display(record.commit_id[:8])
401 print(f"ℹ️ commit {cid}")
402 if record.reviewed_by:
403 reviewers = ", ".join(sanitize_display(r) for r in sorted(record.reviewed_by))
404 print(f" reviewed-by: {reviewers}")
405 else:
406 print(" reviewed-by: (none)")
407 print(f" test-runs: {record.test_runs}")