annotate.py
python
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