"""Shared primitives for the auto-agents self-validation CLIs. Phase 0 extraction from :mod:`_review_validate_helpers` and :mod:`review_validate` so the implementer's forthcoming :mod:`implementer_validate` CLI can reuse the same building blocks without duplicating the dataclasses, the git-diff helper, or the argparse-flag-resolution chain. What's here: - :class:`DiffResult` / :data:`DiffErrorKind` -- typed return envelope for :func:`diff_from_worktree` discriminating ``empty-output`` (path not in diff) from the three validator-internal failure kinds (``git-timeout`` / ``git-not-found`` / ``git-error``). The CLI maps the failure kinds onto exit-code-2 errors so the worker does NOT silently drop comments / file-budget violations on a sick validator. - :func:`diff_from_worktree` -- ``git diff ...HEAD -- `` with timeout, not-found, OSError, and non-zero-exit each mapped onto a discriminated :class:`DiffResult`. - :func:`commit_from_worktree` -- ``git log -1 --pretty=...`` pulling ``(message, committer_email)`` out of one commit by SHA. - :func:`_excerpt_around` / :func:`_excerpt_for_field` -- text-window helpers used by both CLIs to ground error messages in the actual draft / commit body. - :func:`resolve_base_ref` -- the ``--base-ref`` → env-var fallback chain (``REVIEW_VALIDATE_BASE_REF`` → ``FORGEJO_DEFAULT_BRANCH`` → ``origin/master``). Reused verbatim by both CLIs. - :func:`emit_error` -- single-line JSON error with optional ``error_kind`` discriminator. Both CLIs honour the same shape so a worker reading the output JSON can pattern-match on it. Why the underscore-prefixed names: callers historically reached into ``review_validate.py``'s module-private ``_resolve_base_ref`` and ``_emit_error`` directly. To keep test patches working without a renaming churn, the public names mirror the private ones with a re-export layer in the consuming CLIs. New callers (e.g. ``implementer_validate``) should prefer the un-prefixed :func:`resolve_base_ref` / :func:`emit_error` aliases. """ from __future__ import annotations import json import logging import os import subprocess import sys from dataclasses import dataclass from typing import IO, Any, Literal _logger = logging.getLogger("validate_cli_common") _DEFAULT_BASE_REF = "origin/master" DiffErrorKind = Literal[ "git-timeout", "git-not-found", "git-error", "empty-output", ] @dataclass(frozen=True) class DiffResult: """Outcome of :func:`diff_from_worktree`. Exactly one of ``text`` / ``error_kind`` is set: - On success: ``text`` is the unified diff (non-empty), ``error_kind`` is ``None``. - On a git internal failure (timeout, missing binary, non-zero exit, OSError): ``text`` is ``None`` and ``error_kind`` is one of ``git-timeout`` / ``git-not-found`` / ``git-error``. ``stderr`` carries up to 500 bytes of git's stderr for operator triage. - On a clean run that produced empty output (the path is genuinely not part of the diff, e.g. an unmodified file): ``text`` is ``None`` and ``error_kind`` is ``empty-output``. The CLIs map the three internal failure kinds onto exit-code-2 errors so a sick validator does NOT silently get treated as a structured ``path-not-in-diff`` rejection. """ text: str | None error_kind: DiffErrorKind | None stderr: str = "" def diff_from_worktree( worktree: str, path: str, *, base_ref: str = "origin/master" ) -> DiffResult: """Run ``git -C diff ...HEAD -- ``. ``base_ref`` is configurable so deployments whose default branch is not ``master`` (Forgejo / Gitea projects commonly use ``main``, some teams use ``develop``) get a correct merge-base diff. Both CLIs resolve the base ref via :func:`resolve_base_ref` so the ``--base-ref`` / ``REVIEW_VALIDATE_BASE_REF`` / ``FORGEJO_DEFAULT_BRANCH`` precedence is identical between them. Returns a :class:`DiffResult` discriminating success, "git ran cleanly but produced no output" (empty-output → path is genuinely not in this PR), and three classes of git-internal failure (timeout / not-found / error). The CLIs surface the three internal failures as exit-code-2 errors so the worker distinguishes "drop this comment, the path is not in the PR" from "the validator itself is unhealthy, do not silently drop." ``capture_output=True`` keeps stderr out of the CLI's own stdout JSON line; the CLI's caller (the worker) parses stdout-as-JSON. """ try: result = subprocess.run( [ "git", "-C", worktree, "diff", f"{base_ref}...HEAD", "--", path, ], check=False, timeout=30, capture_output=True, text=True, ) except subprocess.TimeoutExpired as exc: return DiffResult( text=None, error_kind="git-timeout", stderr=(exc.stderr or b"")[:500].decode("utf-8", errors="replace") if isinstance(exc.stderr, (bytes, bytearray)) else (exc.stderr or "")[:500], ) except FileNotFoundError as exc: return DiffResult( text=None, error_kind="git-not-found", stderr=str(exc)[:500] ) except OSError as exc: return DiffResult( text=None, error_kind="git-error", stderr=str(exc)[:500] ) if result.returncode != 0: return DiffResult( text=None, error_kind="git-error", stderr=(result.stderr or "")[:500], ) text = result.stdout if not text.strip(): return DiffResult(text=None, error_kind="empty-output") return DiffResult(text=text, error_kind=None) def commit_from_worktree( worktree: str, sha: str ) -> tuple[str, str] | None: """Return ``(commit_message, committer_email)`` or ``None`` on git error. Uses a sentinel separator (``---SEP---``) between the body and the committer email so the parser does not confuse the two. ``%B`` emits the full message (subject + body + footer), ``%ce`` the committer email. Each failure mode logs at warning level so an operator tailing the validator's stderr can distinguish "git refused" from "git timed out" from "the parse failed because the commit message contained the sentinel" without grepping git's stderr. """ try: result = subprocess.run( [ "git", "-C", worktree, "log", "-1", "--pretty=format:%B%n---SEP---%n%ce", sha, ], check=False, timeout=15, capture_output=True, text=True, ) except subprocess.TimeoutExpired as exc: _logger.warning( "commit_from_worktree timed out for sha=%s: %s", sha[:12], exc ) return None except OSError as exc: _logger.warning( "commit_from_worktree git invocation failed for sha=%s: %s", sha[:12], exc, ) return None if result.returncode != 0: _logger.warning( "commit_from_worktree git exited rc=%s for sha=%s; stderr=%s", result.returncode, sha[:12], (result.stderr or "")[:200], ) return None if not result.stdout: _logger.warning( "commit_from_worktree produced empty stdout for sha=%s", sha[:12] ) return None parts = result.stdout.rsplit("\n---SEP---\n", 1) if len(parts) != 2: _logger.warning( "commit_from_worktree could not parse sentinel for sha=%s", sha[:12] ) return None message = parts[0] committer_email = parts[1].strip() return message, committer_email def _excerpt_around(text: str, needle: str, window: int = 80) -> str: """Return up to ``2 * window`` chars of ``text`` centred on the first occurrence of ``needle``. Empty string when ``needle`` is not in ``text`` so the caller can fall back to a synthesized excerpt rather than the entire draft.""" if not text or not needle: return "" idx = text.find(needle) if idx < 0: return "" start = max(0, idx - window) end = min(len(text), idx + len(needle) + window) return text[start:end] def _excerpt_for_field( text: str, field: str, value: str, window: int = 80 ) -> str: """Locate a JSON ``"":""`` (or unquoted-value) occurrence in ``text`` and return a window around it. Tries quoted form first (``"commit_id":"abc..."``), then whitespace-tolerant variants (``"commit_id" : "abc..."``). Returns empty string when no plausible match is found so the caller can fall back to a less-specific locator. Anchoring on the field name ensures the excerpt always points at the actual offending assignment rather than coincidentally identical SHAs elsewhere in the draft (e.g. quoted in ``review.body`` discussing prior commits).""" if not text or not field or not value: return "" candidates = ( f'"{field}":"{value}"', f'"{field}": "{value}"', f'"{field}" : "{value}"', f'"{field}" :"{value}"', ) for needle in candidates: idx = text.find(needle) if idx >= 0: start = max(0, idx - window) end = min(len(text), idx + len(needle) + window) return text[start:end] return "" def resolve_base_ref(flag_value: str | None) -> str: """Resolve the base ref the diff is computed against. Priority (most specific wins): 1. ``--base-ref`` when supplied (any non-``None`` value, even the literal ``origin/master``). The flag's argparse default is ``None``, so a user who explicitly types ``--base-ref origin/master`` on a ``main``-default deployment gets ``origin/master`` — not silently overridden by the env var fallbacks. 2. ``REVIEW_VALIDATE_BASE_REF`` env var — most specific, an explicit override the dispatcher / operator can set per worker session. 3. ``FORGEJO_DEFAULT_BRANCH`` env var — already exported by ``launch_fork.sh`` and read by every other tool in this directory. Treated as a bare branch name and prefixed with ``origin/`` if not already namespaced (the diff is against the *remote-tracking* ref, not the local branch). 4. ``origin/master``. """ if flag_value is not None: return flag_value explicit = os.environ.get("REVIEW_VALIDATE_BASE_REF") if explicit: return explicit forgejo_default = os.environ.get("FORGEJO_DEFAULT_BRANCH") if forgejo_default: return ( forgejo_default if "/" in forgejo_default else f"origin/{forgejo_default}" ) return _DEFAULT_BASE_REF def emit_error( message: str, *, error_kind: str | None = None, stream: IO[str] | None = None, ) -> None: """Single-line JSON error parseable by the worker. ``error_kind`` is an optional discriminator the worker can pattern- match on to decide whether the failure is recoverable (e.g. the diff-walker uses ``git-timeout`` / ``git-not-found`` / ``git-error`` so the worker does not treat a sick validator as a structured ``path-not-in-diff`` rejection). ``stream`` defaults to ``sys.stdout`` resolved at call time (NOT at function-definition time) so a pytest ``capsys`` / ``capfd`` redirect that swaps ``sys.stdout`` after this module was imported sees the printed JSON. """ payload: dict[str, Any] = {"ok": False, "error": message} if error_kind is not None: payload["error_kind"] = error_kind target: IO[str] = sys.stdout if stream is None else stream print(json.dumps(payload), file=target) # Underscore-prefixed aliases for back-compat with the original # ``review_validate._resolve_base_ref`` / ``review_validate._emit_error`` # names that tests monkeypatch directly. New code should call the # public spellings above; the aliases let us move the implementations # without touching every test in the same Phase 0 commit. _resolve_base_ref = resolve_base_ref _emit_error = emit_error __all__ = ( "DiffErrorKind", "DiffResult", "_DEFAULT_BASE_REF", "_emit_error", "_excerpt_around", "_excerpt_for_field", "_resolve_base_ref", "commit_from_worktree", "diff_from_worktree", "emit_error", "resolve_base_ref", )