0bc734c020
Applies `ruff format` to the accumulated formatting debt on this branch. Formatting-only — no behavioral changes. Required for CI/lint's format gate (`nox -s format -- --check`), which the branch was failing on 288 tracked files that drifted from ruff's canonical style. In-progress WIP files are intentionally excluded so this commit stays a clean formatting-only diff. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
343 lines
12 KiB
Python
343 lines
12 KiB
Python
"""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 <base>...HEAD -- <path>``
|
|
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 <worktree> diff <base_ref>...HEAD -- <path>``.
|
|
|
|
``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 ``"<field>":"<value>"`` (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",
|
|
)
|