f3b10a5e72
Phase 0 of the implementer-parity plan: rename the formerly review-only
pre-clone + diff-fetch helpers so the implementer dispatcher can share
them in upcoming phases without forking the substrate or hauling
review-specific naming into a different work-group registry.
Renames (git rename-detected):
- tools/_review_clone.py -> tools/_pr_clone.py
- tools/_review_diff.py -> tools/_pr_diff.py
Extractions (new shared modules):
- tools/_pr_prompt.py UNTRUSTED-CONTENT marker helpers
(fence_markers, redact_marker,
wrap_untrusted_section) for Phase 2
pre-fetch consumers.
- tools/_commit_lint.py lint_commit_message + CONVENTIONAL_TYPES
+ _bot_committer_email so both
self-validation CLIs reuse one lint.
- tools/_validate_cli_common.py DiffResult / DiffErrorKind /
diff_from_worktree /
commit_from_worktree / _excerpt_*
/ resolve_base_ref / emit_error so
implementer_validate (Phase 1) does
not duplicate ~280 lines of CLI
plumbing.
Slim:
- tools/_review_validate_helpers.py shrinks 499 -> 223 lines and now
owns only review-specific validate_position_in_diff +
draft_strict_checks. Re-exports the moved helpers so existing test
monkeypatches (helpers.diff_from_worktree, helpers.subprocess) keep
working without churn.
API surface change:
- prepare_pr_worktree(cfg, n, sha, *, kind="review") -- back-compat
default; implementer dispatcher passes kind="implementer" in Phase 3.
- _worktree_base / _is_preclone_disabled now consult per-kind env
vars (REVIEW_DISPATCHER_WORKTREE_BASE vs.
IMPLEMENTER_DISPATCHER_WORKTREE_BASE; matching DISABLE_PRECLONE
toggles). Mirror is shared per repo regardless of kind.
- WorktreeHandle gains a `kind` field (defaulted) so cleanup paths
can discriminate.
Bug fix discovered along the way:
- _validate_cli_common.emit_error froze stream=sys.stdout at
function-definition time, which made pytest's capsys invisible to
the JSON error output. Now resolves sys.stdout at call time.
Tests:
- New tests/auto_agents/test_shared_substrate.py (14 tests) covers:
every public symbol the legacy modules exposed, helper-re-export
identity preservation, per-kind worktree-base + disable-toggle
semantics, the kind back-compat default, and the new _pr_prompt
fence/redact helpers. Also guards against the deleted
_review_clone.py / _review_diff.py reappearing on disk.
- All 24 importing call-sites updated; full reviewer test suite
remains green (637 passed, 3 skipped).
- `git grep '_review_clone\|_review_diff' tools/` returns zero
matches, the plan's Phase 0 exit criterion.
Sets up Phase 1 (implementer-helpers skill), Phase 2 (pre-fetch
parity), and Phase 3 (pre-cloned implementer worktrees) with no
shared reviewer-only code paths.
ISSUES CLOSED: #N/A
Co-authored-by: Cursor <cursoragent@cursor.com>
422 lines
17 KiB
Python
422 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
"""Mid-session self-validation CLI for ``pr-review-worker``.
|
|
|
|
The reviewer worker is restricted to a single structured-JSON verdict in
|
|
its final reply, but several decisions before that emit are easy for an
|
|
LLM to get wrong on the first try:
|
|
|
|
- Conventional-commit subject format on every commit.
|
|
- ``ISSUES CLOSED: #N`` footer on the head commit.
|
|
- Inline-comment ``(path, new_position)`` placement against the actual
|
|
diff (a stale or hallucinated line number 422s the entire review).
|
|
- Final verdict shape (``event`` value, presence of required fields).
|
|
|
|
This module exposes those same checks as a host-side CLI the worker
|
|
invokes via ``bash`` while it is still drafting. The worker's prompt
|
|
already knows how to write a file with ``printf > /tmp/...`` and run
|
|
``python3 tools/<script>.py ...`` per :doc:`bash-commands`. Keeping the
|
|
implementation in Python (rather than reimplementing in TypeScript like
|
|
the existing ``.opencode/skills/auto-agents-system/scripts/`` set)
|
|
means the JSON-shape validator (:func:`cmd_validate_draft`) calls
|
|
``_review_parser.parse_worker_review_json`` directly — it is the exact
|
|
same code path the dispatcher runs post-session, so a draft that passes
|
|
that validator cannot be rejected by the dispatcher for shape reasons.
|
|
|
|
The other two validators (:func:`cmd_validate_inline_comment`,
|
|
:func:`cmd_lint_commit`) are *strictly stronger* than the dispatcher:
|
|
they catch issues Forgejo enforces post-submit (HTTP 422 for a
|
|
mis-anchored inline comment) and project-policy issues nothing
|
|
enforces today (conventional-commit subject prefix / ISSUES CLOSED
|
|
footer per CONTRIBUTING.md). They do not promise dispatcher parity —
|
|
the dispatcher is silent on these conditions — they promise that a
|
|
draft that passes them is at least as well-formed as a draft that
|
|
silently slips past the dispatcher.
|
|
|
|
Subcommands (backed by reviewer-specific helpers in
|
|
:mod:`_review_validate_helpers` plus shared substrate from
|
|
:mod:`_validate_cli_common` and :mod:`_commit_lint` so this entry
|
|
point stays under the project's 500-line per-file budget):
|
|
|
|
- :func:`cmd_validate_draft` — wrap
|
|
:func:`tools._review_parser.parse_worker_review_json` and surface
|
|
``{ok, errors[], normalised}`` so the worker can self-check before
|
|
emitting its final JSON. Adds a strict ``commit_id == head_sha``
|
|
check on top of the parser's lenient default (Forgejo enforces it
|
|
with HTTP 422 post-submit).
|
|
- :func:`cmd_validate_inline_comment` — confirm a planned inline
|
|
comment's ``(path, new_position)`` lands on an actual ``+``-line
|
|
of the PR's diff. Reads the diff from the dispatcher's pre-clone
|
|
worktree when ``--worktree`` is supplied; otherwise from a
|
|
``--diff-file`` the worker wrote.
|
|
- :func:`cmd_lint_commit` — conventional-commit subject prefix +
|
|
``ISSUES CLOSED: #N`` footer (head only) + bot-author exemption
|
|
against a single commit. Reads commit metadata from the
|
|
dispatcher's pre-clone worktree by SHA.
|
|
|
|
Each subcommand prints exactly one JSON object on stdout and exits 0
|
|
when the validator ran (regardless of whether validation passed —
|
|
structured rejections are themselves a successful CLI run), 2 when
|
|
the CLI itself could not run (unreadable draft file, git-internal
|
|
failure under ``--worktree``). Argparse-detected usage errors
|
|
(missing required flag, both ``--worktree`` and ``--diff-file``,
|
|
etc.) are caught by argparse and produce a usage error on stderr
|
|
with its own ``SystemExit(2)`` before any subcommand runs. The
|
|
worker reads stdout JSON and decides whether to revise its draft
|
|
or emit it as-is.
|
|
|
|
Why one CLI dispatcher rather than three separate scripts: a single
|
|
``bash`` allow-list entry (``python3 tools/review_validate.py *``)
|
|
covers all current and future subcommands and the script imports the
|
|
canonical Python validators from :mod:`_review_parser`,
|
|
:mod:`_review_validate_helpers`, :mod:`_validate_cli_common`, and
|
|
:mod:`_commit_lint` without duplicating them.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# Make sibling tools/ modules importable regardless of how this CLI is
|
|
# invoked (``python3 tools/review_validate.py`` from repo root, or by
|
|
# absolute path from anywhere). Mirrors the prelude every other entry
|
|
# point in this directory uses.
|
|
_TOOLS_DIR = str(Path(__file__).resolve().parent)
|
|
if _TOOLS_DIR not in sys.path:
|
|
sys.path.insert(0, _TOOLS_DIR)
|
|
|
|
from _loader import load_sibling as _load_sibling # noqa: E402 type: ignore[import-not-found]
|
|
|
|
|
|
_review_parser = _load_sibling("_review_parser", "_review_parser.py")
|
|
_helpers = _load_sibling(
|
|
"_review_validate_helpers", "_review_validate_helpers.py"
|
|
)
|
|
_validate_common = _load_sibling(
|
|
"_validate_cli_common", "_validate_cli_common.py"
|
|
)
|
|
_commit_lint = _load_sibling("_commit_lint", "_commit_lint.py")
|
|
|
|
|
|
# Default merge base for ``validate-inline-comment --worktree``.
|
|
# The actual fallback chain lives in
|
|
# :func:`_validate_cli_common.resolve_base_ref` — this constant is
|
|
# kept for back-compat with tests / log lines that quote it.
|
|
_DEFAULT_BASE_REF = "origin/master"
|
|
|
|
|
|
# Re-export of :func:`_validate_cli_common._resolve_base_ref` so
|
|
# tests that monkeypatch ``review_validate._resolve_base_ref``
|
|
# continue to work without needing a churn-only edit to switch them
|
|
# over to the shared module. New code should call
|
|
# :func:`_validate_common.resolve_base_ref` directly.
|
|
_resolve_base_ref = _validate_common._resolve_base_ref
|
|
|
|
|
|
# ─── Subcommand: validate-draft ────────────────────────────────────────────
|
|
|
|
|
|
def cmd_validate_draft(args: argparse.Namespace) -> int:
|
|
"""Validate a worker draft JSON against the dispatcher's strict parser.
|
|
|
|
Reads the candidate verdict from ``--draft-file`` (the worker writes
|
|
it via ``printf > /tmp/...`` per the bash-commands rules), runs it
|
|
through :func:`_review_parser.parse_worker_review_json` followed by
|
|
:func:`_helpers.draft_strict_checks`, and emits a machine-readable
|
|
result so the worker can self-correct without the dispatcher ever
|
|
seeing the malformed draft.
|
|
|
|
Stdout shape::
|
|
|
|
{"ok": true, "normalised": {<parsed dict>}}
|
|
{"ok": false, "errors": [{"message": "...", "excerpt": "..."}]}
|
|
|
|
Exit code: 0 on validation pass, 0 on validation FAIL (the
|
|
validator ran successfully and reported issues — that's a
|
|
successful run of the CLI), 2 on usage error (missing draft
|
|
file etc.).
|
|
"""
|
|
try:
|
|
raw = Path(args.draft_file).read_text(encoding="utf-8", errors="replace")
|
|
except OSError as exc:
|
|
_validate_common._emit_error(
|
|
f"could not read --draft-file {args.draft_file!r}: {exc}"
|
|
)
|
|
return 2
|
|
try:
|
|
normalised = _review_parser.parse_worker_review_json(
|
|
raw, review_type=args.review_type, head_sha=args.head_sha
|
|
)
|
|
except _review_parser.WorkerJSONError as exc:
|
|
print(
|
|
json.dumps(
|
|
{
|
|
"ok": False,
|
|
"errors": [
|
|
{"message": exc.message, "excerpt": exc.excerpt}
|
|
],
|
|
}
|
|
)
|
|
)
|
|
return 0
|
|
strict_errors = _helpers.draft_strict_checks(
|
|
normalised, head_sha=args.head_sha, raw_draft=raw
|
|
)
|
|
if strict_errors:
|
|
print(json.dumps({"ok": False, "errors": strict_errors}))
|
|
return 0
|
|
print(json.dumps({"ok": True, "normalised": normalised}))
|
|
return 0
|
|
|
|
|
|
# ─── Subcommand: validate-inline-comment ──────────────────────────────────
|
|
|
|
|
|
def cmd_validate_inline_comment(args: argparse.Namespace) -> int:
|
|
"""Validate ``(path, new_position)`` lands on an added line.
|
|
|
|
Diff source (in priority order):
|
|
|
|
1. ``--worktree``: the dispatcher's pre-clone path. Run
|
|
``git -C <worktree> diff <base-ref>...HEAD -- <path>``, where
|
|
``<base-ref>`` defaults to ``origin/master`` and is overridable
|
|
via ``--base-ref`` (or the ``REVIEW_VALIDATE_BASE_REF`` env var
|
|
the dispatcher exports for the worker session). Local, no API
|
|
quota cost. The worker is expected to pass this when the
|
|
prompt's ``## Pre-cloned working copy`` section gave it a
|
|
``repo_dir``.
|
|
2. ``--diff-file``: a file the worker wrote containing the unified
|
|
diff for ``path`` (or the whole PR). Used when the worker
|
|
already sliced the embedded prompt diff into ``/tmp``.
|
|
|
|
Stdout shape::
|
|
|
|
{"ok": true, "matched_line": "<the + line content>"}
|
|
{"ok": false, "reason": "path-not-in-diff" | "line-not-added" |
|
|
"line-out-of-range",
|
|
"details": "<why>"}
|
|
|
|
Exit code: 0 on valid CLI run (regardless of placement), 2 on
|
|
usage error or git-internal failure (timeout / unreadable repo /
|
|
bad base-ref). The error kind is surfaced in the JSON output so
|
|
the worker does not silently treat git timeouts as
|
|
``path-not-in-diff``.
|
|
"""
|
|
# argparse's mutually-exclusive group enforces "exactly one of
|
|
# --worktree / --diff-file" with required=True, so reaching this
|
|
# function means args has exactly one of the two set.
|
|
diff_text: str
|
|
if args.worktree:
|
|
base_ref = _resolve_base_ref(args.base_ref)
|
|
diff = _validate_common.diff_from_worktree(
|
|
args.worktree, args.path, base_ref=base_ref
|
|
)
|
|
if diff.error_kind in ("git-timeout", "git-not-found", "git-error"):
|
|
# Distinguish validator-internal failure from "path not in
|
|
# diff" so the worker does not silently drop comments due
|
|
# to a slow disk / busy mirror / typo'd base-ref.
|
|
_validate_common._emit_error(
|
|
f"git failed for path={args.path!r} ({diff.error_kind}): "
|
|
f"{diff.stderr.strip() or '(no stderr)'}",
|
|
error_kind=diff.error_kind,
|
|
)
|
|
return 2
|
|
if diff.error_kind == "empty-output":
|
|
print(
|
|
json.dumps(
|
|
{
|
|
"ok": False,
|
|
"reason": "path-not-in-diff",
|
|
"details": (
|
|
f"git diff produced no output for path={args.path!r}; "
|
|
"the file is not part of this PR's changeset"
|
|
),
|
|
}
|
|
)
|
|
)
|
|
return 0
|
|
# ``error_kind is None`` is the success case; ``DiffResult``'s
|
|
# invariant guarantees ``text`` is a non-empty string here.
|
|
# The runtime check is a belt-and-suspenders guard for a
|
|
# future ``DiffResult`` constructor regression; ``assert``
|
|
# would be stripped under ``python -O``.
|
|
if diff.text is None:
|
|
raise RuntimeError(
|
|
"DiffResult invariant violated: error_kind=None but "
|
|
"text=None; this indicates a bug in diff_from_worktree"
|
|
)
|
|
diff_text = diff.text
|
|
else:
|
|
try:
|
|
diff_text = Path(args.diff_file).read_text(
|
|
encoding="utf-8", errors="replace"
|
|
)
|
|
except OSError as exc:
|
|
_validate_common._emit_error(f"could not read --diff-file: {exc}")
|
|
return 2
|
|
result = _helpers.validate_position_in_diff(
|
|
diff_text, args.path, args.new_position
|
|
)
|
|
print(json.dumps(result))
|
|
return 0
|
|
|
|
|
|
# ─── Subcommand: lint-commit ──────────────────────────────────────────────
|
|
|
|
|
|
def cmd_lint_commit(args: argparse.Namespace) -> int:
|
|
"""Check one commit against the project's conventional-commit rules.
|
|
|
|
Reads ``(message, committer_email)`` from
|
|
``git -C <worktree> log -1 --pretty=...`` so a multi-line commit
|
|
body is handled losslessly. ``--is-head`` activates the
|
|
``ISSUES CLOSED: #N`` footer check (only the head commit is
|
|
required to carry it per CONTRIBUTING.md).
|
|
|
|
Stdout shape::
|
|
|
|
{"ok": true, "exempt_bot": <bool>, "violations": []}
|
|
{"ok": false, "exempt_bot": <bool>,
|
|
"violations": [{"rule": "subject-prefix" | "issues-closed-footer",
|
|
"details": "..."}]}
|
|
|
|
Exit code: 0 on valid CLI run, 2 on usage error.
|
|
|
|
``--worktree`` and ``--sha`` are declared ``required=True`` on
|
|
the subparser so missing-flag enforcement runs in argparse
|
|
before this function is reached; we never need a manual
|
|
re-check here.
|
|
"""
|
|
info = _commit_from_worktree(args.worktree, args.sha)
|
|
if info is None:
|
|
_validate_common._emit_error(
|
|
f"git could not read commit {args.sha!r} from worktree "
|
|
f"{args.worktree!r}"
|
|
)
|
|
return 2
|
|
message, committer_email = info
|
|
result = _commit_lint.lint_commit_message(
|
|
message, committer_email, is_head=args.is_head
|
|
)
|
|
print(json.dumps(result))
|
|
return 0
|
|
|
|
|
|
def _commit_from_worktree(worktree: str, sha: str) -> tuple[str, str] | None:
|
|
"""Module-local indirection to :func:`_validate_common.commit_from_worktree`.
|
|
|
|
Tests monkeypatch this name directly to feed canned ``(message,
|
|
committer_email)`` tuples without spinning up a real git repo.
|
|
Without the indirection, the test would have to monkeypatch the
|
|
shared module — workable but brittle (the cmd_* function captures
|
|
the shared module at import time via :func:`_load_sibling`).
|
|
|
|
Asymmetry note: ``diff_from_worktree`` deliberately has NO
|
|
analogous wrapper. The diff path's CI coverage is via the
|
|
``real_git_repo`` integration tests (a real git invocation),
|
|
not stubs — that's where the regression risk lives (subprocess
|
|
wiring, base-ref handling, etc.). The commit path's logic is in
|
|
:func:`lint_commit_message` which is pure-Python and unit-tested
|
|
directly; only the git-fetch boundary needs stubbing, hence the
|
|
one wrapper.
|
|
"""
|
|
return _validate_common.commit_from_worktree(worktree, sha)
|
|
|
|
|
|
# Re-export of :func:`_validate_cli_common._emit_error` so tests
|
|
# that monkeypatch ``review_validate._emit_error`` continue to work.
|
|
# New code should call :func:`_validate_common.emit_error` directly.
|
|
_emit_error = _validate_common._emit_error
|
|
|
|
|
|
def _build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
prog="review_validate",
|
|
description="Mid-session self-validation CLI for pr-review-worker.",
|
|
)
|
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
|
|
p_draft = sub.add_parser(
|
|
"validate-draft",
|
|
help="Strict-parse a worker review draft JSON and report errors.",
|
|
)
|
|
p_draft.add_argument("--draft-file", required=True)
|
|
p_draft.add_argument("--review-type", required=True)
|
|
p_draft.add_argument("--head-sha", required=True)
|
|
p_draft.set_defaults(func=cmd_validate_draft)
|
|
|
|
p_inline = sub.add_parser(
|
|
"validate-inline-comment",
|
|
help="Confirm an inline comment's (path, new_position) is an added line.",
|
|
)
|
|
p_inline.add_argument("--path", required=True)
|
|
p_inline.add_argument("--new-position", type=int, required=True)
|
|
# --worktree and --diff-file are alternative diff sources; passing
|
|
# both is almost certainly a mistake, so make argparse reject it
|
|
# with a clear error rather than silently preferring one.
|
|
diff_source = p_inline.add_mutually_exclusive_group(required=True)
|
|
diff_source.add_argument(
|
|
"--worktree",
|
|
help=(
|
|
"Path to the dispatcher's pre-cloned PR worktree. The "
|
|
"validator runs 'git -C <worktree> diff <base-ref>...HEAD' "
|
|
"locally. Validator-internal failures (git timeout, missing "
|
|
"binary, bad base-ref) surface as exit-code-2 errors with "
|
|
"an 'error_kind' field, NOT as 'path-not-in-diff', so the "
|
|
"worker does not silently drop comments on a sick validator."
|
|
),
|
|
)
|
|
diff_source.add_argument(
|
|
"--diff-file",
|
|
help=(
|
|
"Path to a file containing the unified diff. Used as a "
|
|
"fallback when --worktree is not available (e.g. the "
|
|
"dispatcher's pre-clone failed and the worker sliced the "
|
|
"embedded prompt diff into /tmp)."
|
|
),
|
|
)
|
|
p_inline.add_argument(
|
|
"--base-ref",
|
|
default=None,
|
|
help=(
|
|
f"Merge base for the worktree-mode diff. When omitted, "
|
|
f"falls back to (in order) REVIEW_VALIDATE_BASE_REF env "
|
|
f"var, FORGEJO_DEFAULT_BRANCH env var (auto-prefixed "
|
|
f"with 'origin/'), then {_DEFAULT_BASE_REF!r}. Pass "
|
|
f"explicitly to override every fallback (e.g. "
|
|
f"'--base-ref origin/master' on a main-default "
|
|
f"deployment to compare against a master line). "
|
|
f"Ignored when --diff-file is supplied."
|
|
),
|
|
)
|
|
p_inline.set_defaults(func=cmd_validate_inline_comment)
|
|
|
|
p_lint = sub.add_parser(
|
|
"lint-commit",
|
|
help="Conventional-commit + ISSUES CLOSED footer + bot-author exemption.",
|
|
)
|
|
p_lint.add_argument("--worktree", required=True)
|
|
p_lint.add_argument("--sha", required=True)
|
|
p_lint.add_argument(
|
|
"--is-head",
|
|
action="store_true",
|
|
help=(
|
|
"Activate the 'ISSUES CLOSED: #N' footer check; required only "
|
|
"on the head commit per CONTRIBUTING.md."
|
|
),
|
|
)
|
|
p_lint.set_defaults(func=cmd_lint_commit)
|
|
|
|
return parser
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = _build_parser()
|
|
args = parser.parse_args(argv)
|
|
return int(args.func(args))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|