annotate.py
python
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
33 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`` 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}") |
File History
2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
33 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62
baseline: rc14 re-baseline after rc3 store corruption recovery
Human
patch
33 days ago