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>
487 lines
21 KiB
Python
487 lines
21 KiB
Python
#!/usr/bin/env python3
|
|
"""Deterministic dispatcher for PR review workers.
|
|
|
|
Replaced the long-running ``pr-review-supervisor.md`` polling session
|
|
with a host-level Python loop. The legacy supervisor agent file was
|
|
deleted on 2026-05-09 (see ``CHANGELOG.md`` and
|
|
``docs/development/auto-agents-tier-2-3-plan.md`` for the rationale)
|
|
and this dispatcher is now the only orchestrator for reviewer work.
|
|
It keeps review *judgment* in ``pr-review-worker``; this file owns
|
|
queue polling, pre-dispatch claims, worker watchdogs, cycle telemetry,
|
|
and (since 2026-05-07) every Forgejo read and write the worker used
|
|
to make.
|
|
|
|
The worker now consumes pre-fetched PR metadata, CI status, existing
|
|
reviews, PR comments, linked issues, and the unified diff from its
|
|
prompt — all gathered by this dispatcher in a single pre-dispatch pass.
|
|
After the worker session completes, this dispatcher parses the worker's
|
|
structured-JSON verdict and POSTs the review (or Tier 1F escalation)
|
|
back to Forgejo.
|
|
|
|
The motivation is documented in CHANGELOG under "Move review submission
|
|
out of the worker (2026-05-07)".
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import logging
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
_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]
|
|
|
|
|
|
_dispatch = _load_sibling("_dispatch_runtime", "_dispatch_runtime.py")
|
|
_review_pipeline = _load_sibling("_review_pipeline", "_review_pipeline.py")
|
|
_review_fetch = _load_sibling("_review_fetch", "_review_fetch.py")
|
|
_claim_runtime = _load_sibling("_claim_runtime", "_claim_runtime.py")
|
|
_pr_clone = _load_sibling("_pr_clone", "_pr_clone.py")
|
|
_pr_diff = _load_sibling("_pr_diff", "_pr_diff.py")
|
|
_review_prompt_mod = _load_sibling("_review_prompt", "_review_prompt.py")
|
|
_review_context = _load_sibling("_review_context", "_review_context.py")
|
|
_block_store = _load_sibling("_block_store", "_block_store.py")
|
|
|
|
|
|
DRIVER_NAME = "dispatch_review.py"
|
|
CLAIM_KIND = "reviewer"
|
|
|
|
_logger = logging.getLogger("dispatch_review")
|
|
|
|
|
|
# Backwards-compatible aliases. The diff fetch / section rendering
|
|
# helpers live in :mod:`_pr_diff` (the Phase 0 substrate rename of
|
|
# the formerly review-only diff module so the implementer
|
|
# dispatcher can share them) and the prompt factory +
|
|
# pre-dispatch fetch coordinator in :mod:`_review_prompt` so this
|
|
# driver can stay under the project's 500-line per-file budget.
|
|
_DEFAULT_DIFF_MAX_BYTES = _pr_diff.DEFAULT_DIFF_MAX_BYTES
|
|
_DIFF_BEGIN_MARKER = _pr_diff.DIFF_BEGIN_MARKER
|
|
_DIFF_END_MARKER = _pr_diff.DIFF_END_MARKER
|
|
_DIFF_REDACTED_MARKER = _pr_diff.DIFF_REDACTED_MARKER
|
|
_fetch_pr_diff = _pr_diff.fetch_pr_diff
|
|
_fetch_pr_diff_detailed = _pr_diff.fetch_pr_diff_detailed
|
|
_build_diff_section = _pr_diff.build_diff_section
|
|
_build_diff_section_full = _pr_diff.build_diff_section_full
|
|
_build_clone_section = _pr_diff.build_clone_section
|
|
_diff_section_skipped = _pr_diff.diff_section_skipped
|
|
_OUTPUT_CONTRACT = _review_prompt_mod.OUTPUT_CONTRACT
|
|
_fetch_review_context = _review_prompt_mod.fetch_review_context
|
|
_review_prompt = _review_prompt_mod.build_review_prompt
|
|
|
|
|
|
# ─── post_session_action: parse worker JSON and POST to Forgejo ─────────────
|
|
|
|
|
|
def _build_post_session_action(group_name: str):
|
|
"""Return a closure suitable for ``WorkGroup.post_session_action``.
|
|
|
|
The closure captures ``review_type`` (derived from the group name)
|
|
so :func:`_review_pipeline.finalize_review` knows which output-
|
|
contract branch to expect. The prompt-time
|
|
``request_changes_count`` and freshly fetched ``head_sha`` are
|
|
threaded through ``item["_dispatcher_review_context"]`` (set by
|
|
:func:`_review_prompt`) so the post-session action does NOT have
|
|
to re-fetch the review list a second time.
|
|
|
|
Each WorkGroup gets its own closure (rather than a single shared
|
|
function) because the closure binds the group's review_type at
|
|
construction time. Constructing one per group is cheap (six
|
|
closures total) and keeps the call site readable.
|
|
"""
|
|
review_type = {
|
|
"addressed_changes_ci_passing": "re_review",
|
|
"addressed_changes_ci_failing": "re_review",
|
|
"no_active_review_ci_passing": "first_review",
|
|
"no_active_review_ci_failing": "first_review",
|
|
"missing_ci_checks": "ci_flag",
|
|
}[group_name]
|
|
|
|
def _action(
|
|
cfg: Any,
|
|
item: dict[str, Any],
|
|
parsed_json: dict[str, Any] | None,
|
|
raw_response: str,
|
|
terminal_state: str,
|
|
*,
|
|
session_context: Any | None = None,
|
|
**_legacy_kwargs: Any,
|
|
) -> dict[str, Any]:
|
|
# ``session_context`` is the dispatcher's standard context
|
|
# carrier (a :class:`_dispatch_runtime.SessionContext`
|
|
# dataclass). The reviewer's closure captures
|
|
# ``review_type`` / ``group_name`` at construction time so
|
|
# it does NOT need to read ``session_context.work_group_name``;
|
|
# the timing fields are already recorded by the runtime in
|
|
# the cycle archive (``worker_wallclock_seconds``), so the
|
|
# reviewer post-session hook neither needs nor re-emits them.
|
|
# The argument is still named explicitly (rather than absorbed
|
|
# into a single ``**_kwargs``) so a typo at the dispatcher's
|
|
# call site cannot silently swallow a future required field.
|
|
# ``_legacy_kwargs`` absorbs the four pre-``SessionContext``
|
|
# flat kwargs (``work_group_name`` / ``session_started_at`` /
|
|
# ``session_completed_at`` / ``session_wallclock_seconds``)
|
|
# for back-compat with direct test invocations.
|
|
del session_context # Reviewer hook does not consume the dataclass.
|
|
# Read the freshly fetched head_sha, the pre-fetched
|
|
# request_changes_count, the pre-fetched PR comments, and
|
|
# the aggregate data_complete flag from the item the prompt
|
|
# builder stamped. Falls back to listing snapshot defaults
|
|
# when the prompt builder did not run (dry-run / direct
|
|
# unit test).
|
|
# Coerce the carrier into a typed ReviewContext. Tests that
|
|
# build dict literals continue to work via the dispatch in
|
|
# :func:`_review_context.coerce_review_context`. When the
|
|
# carrier is missing entirely (dry-run / direct unit-test
|
|
# invocation that didn't run the prompt builder) we fall
|
|
# back to listing-snapshot defaults derived from ``item``.
|
|
# Strict mode is enabled so a typo at the producer site
|
|
# (``{"head_shaa": "abc"}`` instead of ``head_sha``) raises
|
|
# immediately rather than silently defaulting and breaking
|
|
# the freshness check one cycle later. Production producers
|
|
# construct ``ReviewContext`` directly so strict mode only
|
|
# validates the dict path; legacy fixtures that pass dict
|
|
# literals must use real field names.
|
|
review_context = _review_context.coerce_review_context(
|
|
item.get("_dispatcher_review_context"), strict=True
|
|
)
|
|
# Hoist the type annotations OUT of the if/else branches so the
|
|
# variable is union-typed across both. The dataclass-present
|
|
# branch always gets a concrete int from the dataclass; the
|
|
# absent-context branch passes None to mean "we did not pre-fetch
|
|
# this at prompt-build time, fall back to listing-snapshot
|
|
# defaults" (a semantically distinct signal from "we counted
|
|
# zero REQUEST_CHANGES at prompt time"). finalize_review handles
|
|
# both: the threshold check uses ``isinstance(int) and >= threshold``
|
|
# so 0 and None behave identically below the threshold, but a
|
|
# future Tier-1F refinement that DOES want the zero-vs-absent
|
|
# distinction can read the dataclass field explicitly instead of
|
|
# inferring intent from a None.
|
|
head_sha: str
|
|
request_changes_count: int | None
|
|
pr_comments: list[dict[str, Any]] | None
|
|
data_complete: bool
|
|
clone_handle: Any = None
|
|
if review_context is not None:
|
|
head_sha = review_context.head_sha
|
|
request_changes_count = review_context.request_changes_count
|
|
pr_comments = review_context.pr_comments or None
|
|
data_complete = review_context.data_complete
|
|
clone_handle = review_context.clone_handle
|
|
else:
|
|
head = item.get("head") if isinstance(item.get("head"), dict) else {}
|
|
head_sha = str(head.get("sha") or item.get("head_sha") or "")
|
|
request_changes_count = None
|
|
pr_comments = None
|
|
data_complete = True
|
|
try:
|
|
return _review_pipeline.finalize_review(
|
|
cfg,
|
|
item,
|
|
parsed_json,
|
|
raw_response,
|
|
terminal_state,
|
|
review_type=review_type,
|
|
head_sha=head_sha,
|
|
request_changes_count=request_changes_count,
|
|
pr_comments=pr_comments,
|
|
data_complete=data_complete,
|
|
)
|
|
finally:
|
|
# Clean up the pre-cloned worktree regardless of how
|
|
# the review submission ended (submitted, failed,
|
|
# stale, raised). The handle is None when pre-clone
|
|
# was disabled or when it failed and the dispatcher
|
|
# fell through to the legacy fallback path.
|
|
if clone_handle is not None:
|
|
try:
|
|
clone_handle.cleanup(cfg)
|
|
except Exception as exc: # noqa: BLE001
|
|
_logger.warning(
|
|
"pre-cloned worktree cleanup raised for PR #%s: %s",
|
|
item.get("number"),
|
|
exc,
|
|
)
|
|
|
|
return _action
|
|
|
|
|
|
def _make_work_group(name: str, script_name: str) -> Any:
|
|
return _dispatch.WorkGroup(
|
|
name=name,
|
|
script_name=script_name,
|
|
item_kind="pr",
|
|
claim_kind=CLAIM_KIND,
|
|
worker_agent="pr-review-worker",
|
|
tag_prefix="AUTO-REV",
|
|
prompt_factory=_review_prompt,
|
|
post_session_action=_build_post_session_action(name),
|
|
)
|
|
|
|
|
|
WORK_GROUPS = [
|
|
_make_work_group(
|
|
"addressed_changes_ci_passing", "list_prs_addressed_changes_ci_passing"
|
|
),
|
|
_make_work_group(
|
|
"no_active_review_ci_passing", "list_prs_no_active_review_ci_passing"
|
|
),
|
|
_make_work_group(
|
|
"addressed_changes_ci_failing", "list_prs_addressed_changes_ci_failing"
|
|
),
|
|
_make_work_group(
|
|
"no_active_review_ci_failing", "list_prs_no_active_review_ci_failing"
|
|
),
|
|
_make_work_group("missing_ci_checks", "list_prs_missing_ci_checks"),
|
|
]
|
|
|
|
|
|
_ALLOW_NONREVIEWER_PAT_ENV = "REVIEW_DISPATCHER_ALLOW_NONREVIEWER_PAT"
|
|
|
|
|
|
def _is_nonreviewer_pat_bypass_enabled() -> bool:
|
|
return os.environ.get(_ALLOW_NONREVIEWER_PAT_ENV, "").lower() in (
|
|
"1",
|
|
"true",
|
|
"yes",
|
|
)
|
|
|
|
|
|
def load_config(*, dry_run: bool = False) -> Any:
|
|
# Require ``FORGEJO_REVIEWER_PAT`` so the dispatcher cannot
|
|
# silently authenticate as the merge-bot identity (which would
|
|
# post reviews from the same user that authored the PR and
|
|
# void the branch-protection ``reviewer != author`` rule). The
|
|
# bypass env var keeps dev / test workflows that share a single
|
|
# PAT across both bot identities working — at the cost of
|
|
# skipping the ``/user`` identity verification below.
|
|
if _is_nonreviewer_pat_bypass_enabled():
|
|
token = _dispatch.load_secret("FORGEJO_REVIEWER_PAT", "GITEA_TOKEN")
|
|
else:
|
|
token = _dispatch.load_secret("FORGEJO_REVIEWER_PAT")
|
|
return _dispatch.DispatchConfig(
|
|
token=token,
|
|
forgejo_url=_dispatch.derive_forgejo_url(),
|
|
owner=os.environ.get("FORGEJO_OWNER", _dispatch.REPO_OWNER),
|
|
repo=os.environ.get("FORGEJO_REPO", _dispatch.REPO_NAME),
|
|
server_url=os.environ.get(
|
|
"OPENCODE_SERVER_URL", "http://127.0.0.1:4096"
|
|
).rstrip("/"),
|
|
lock_path=_dispatch.resolve_lock_or_heartbeat(
|
|
"REVIEW_DISPATCHER_LOCK_PATH", "review-dispatcher.lock"
|
|
),
|
|
heartbeat_path=_dispatch.resolve_lock_or_heartbeat(
|
|
"REVIEW_DISPATCHER_HEARTBEAT_PATH", "review-dispatcher.heartbeat"
|
|
),
|
|
cycle_interval_seconds=int(
|
|
os.environ.get("REVIEW_DISPATCHER_CYCLE_SECONDS", "300")
|
|
),
|
|
# Default cap of 2 items per cycle. The legacy LLM
|
|
# supervisor used ``max_workers=4`` but its workers blocked
|
|
# on Forgejo round-trips, so wall-clock throughput was
|
|
# lower than the headline number suggests. The
|
|
# deterministic dispatcher pre-fetches all data outside
|
|
# the worker session, so each session is meaningfully
|
|
# shorter AND saturates the model context. Two items per
|
|
# cycle is the conservative bump (still well under the
|
|
# 4-worker ceiling the supervisor used) that takes back
|
|
# roughly half the parallelism we lost in the migration.
|
|
# Operators with constrained capacity (CI runners, model
|
|
# rate-limits) can still pin it to 1 via the env var;
|
|
# operators with extra headroom can lift it further.
|
|
max_items_per_cycle=int(
|
|
os.environ.get("REVIEW_DISPATCHER_MAX_ITEMS_PER_CYCLE", "2")
|
|
),
|
|
worker_timeout_seconds=int(
|
|
os.environ.get("REVIEW_DISPATCHER_WORKER_TIMEOUT_SECONDS", "1800")
|
|
),
|
|
claim_ttl_seconds=int(
|
|
os.environ.get("REVIEW_DISPATCHER_CLAIM_TTL_SECONDS", "1800")
|
|
),
|
|
api_retries=int(os.environ.get("REVIEW_DISPATCHER_API_RETRIES", "3")),
|
|
request_timeout_s=int(
|
|
os.environ.get("REVIEW_DISPATCHER_REQUEST_TIMEOUT_S", "30")
|
|
),
|
|
script_timeout_seconds=int(
|
|
os.environ.get("REVIEW_DISPATCHER_SCRIPT_TIMEOUT_SECONDS", "120")
|
|
),
|
|
table_name="dispatch_review_cycles",
|
|
dry_run=dry_run,
|
|
cycle_failure_budget=int(
|
|
os.environ.get("REVIEW_DISPATCHER_CYCLE_FAILURE_BUDGET", "5")
|
|
),
|
|
linked_issue_policy=_review_fetch.normalize_linked_issue_policy(
|
|
os.environ.get("REVIEW_DISPATCHER_LINKED_ISSUE_POLICY"),
|
|
),
|
|
)
|
|
|
|
|
|
def _read_reviewer_username() -> str | None:
|
|
"""Resolve the expected reviewer-bot login from env / .env file."""
|
|
explicit = os.environ.get("FORGEJO_REVIEWER_USERNAME")
|
|
if explicit:
|
|
return explicit.strip() or None
|
|
fallback = _dispatch._read_dotenv_value("FORGEJO_REVIEWER_USERNAME")
|
|
if fallback:
|
|
return fallback.strip() or None
|
|
return None
|
|
|
|
|
|
def assert_reviewer_identity(cfg: Any) -> None:
|
|
"""Refuse to start when the configured PAT does not actually
|
|
authenticate as the expected reviewer-bot identity.
|
|
|
|
The check is the only thing standing between an operator who
|
|
accidentally exported the merge-bot PAT in the
|
|
``FORGEJO_REVIEWER_PAT`` slot and a fleet of self-approving
|
|
reviews that void the branch-protection ``reviewer != author``
|
|
rule. We:
|
|
|
|
1. Skip entirely when ``REVIEW_DISPATCHER_ALLOW_NONREVIEWER_PAT=1``
|
|
so dev / fork-mode workflows that share one PAT across both
|
|
bot identities can still run. The bypass also covers the
|
|
:func:`load_config` env-var requirement, so the two are
|
|
coordinated.
|
|
2. Refuse when ``FORGEJO_REVIEWER_USERNAME`` is unset (we cannot
|
|
verify identity without a target login). The operator should
|
|
either configure the username or set the bypass.
|
|
3. Call ``GET /user`` with the PAT. Any non-200 / 401 / 403 /
|
|
network error / missing ``login`` field is a hard refusal:
|
|
the alternative is silently posting reviews from a misconfigured
|
|
account.
|
|
4. Refuse on login mismatch.
|
|
|
|
Refusals raise ``SystemExit`` with a single descriptive message
|
|
so the launcher / systemd unit can surface the failure cleanly
|
|
without traceback noise.
|
|
"""
|
|
if _is_nonreviewer_pat_bypass_enabled():
|
|
_logger.warning(
|
|
"%s skipping /user reviewer-identity check because %s is set",
|
|
DRIVER_NAME,
|
|
_ALLOW_NONREVIEWER_PAT_ENV,
|
|
)
|
|
return
|
|
expected = _read_reviewer_username()
|
|
if not expected:
|
|
raise SystemExit(
|
|
f"{DRIVER_NAME}: FORGEJO_REVIEWER_USERNAME is not set; cannot "
|
|
f"verify the reviewer-bot identity. Either configure it "
|
|
f"(recommended) or set {_ALLOW_NONREVIEWER_PAT_ENV}=1 to bypass "
|
|
f"the identity check entirely."
|
|
)
|
|
try:
|
|
response = _claim_runtime.get("/user", cfg)
|
|
except Exception as exc: # noqa: BLE001 — refuse on any network failure
|
|
raise SystemExit(
|
|
f"{DRIVER_NAME}: failed to call /user for reviewer-identity "
|
|
f"verification ({type(exc).__name__}: {exc}). Refusing to start."
|
|
)
|
|
status = int(response.get("status") or 0)
|
|
if status in (401, 403):
|
|
raise SystemExit(
|
|
f"{DRIVER_NAME}: /user returned HTTP {status} — "
|
|
f"FORGEJO_REVIEWER_PAT is invalid, expired, or lacks read scope. "
|
|
f"Refusing to start."
|
|
)
|
|
if status != 200:
|
|
raise SystemExit(
|
|
f"{DRIVER_NAME}: /user returned HTTP {status} — cannot verify "
|
|
f"reviewer identity. Refusing to start."
|
|
)
|
|
body = response.get("body")
|
|
actual = body.get("login") if isinstance(body, dict) else None
|
|
if not isinstance(actual, str) or not actual:
|
|
raise SystemExit(
|
|
f"{DRIVER_NAME}: /user response missing 'login' field. Refusing to start."
|
|
)
|
|
if actual != expected:
|
|
raise SystemExit(
|
|
f"{DRIVER_NAME}: PAT identity mismatch — expected "
|
|
f"FORGEJO_REVIEWER_USERNAME={expected!r}, but /user returned "
|
|
f"{actual!r}. The configured PAT does not authenticate as the "
|
|
f"reviewer-bot. Refusing to start."
|
|
)
|
|
_logger.info("%s verified reviewer identity: login=%s", DRIVER_NAME, actual)
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--once", action="store_true", help="run one cycle and exit")
|
|
parser.add_argument("--status", action="store_true", help="print config and exit")
|
|
parser.add_argument(
|
|
"--dry-run", action="store_true", help="claim nothing and do not dispatch"
|
|
)
|
|
args = parser.parse_args()
|
|
_dispatch._configure_logging("REVIEW_DISPATCHER_LOG_LEVEL")
|
|
cfg = load_config(dry_run=args.dry_run)
|
|
if args.status:
|
|
_dispatch.json_line(_dispatch.status_payload(cfg, driver_name=DRIVER_NAME))
|
|
return 0
|
|
if not args.dry_run:
|
|
# Reviewer-bot identity check: refuse to start if the
|
|
# configured PAT does not authenticate as the expected
|
|
# reviewer login. This is the only guard against an operator
|
|
# who accidentally exported the merge-bot PAT in the
|
|
# ``FORGEJO_REVIEWER_PAT`` slot and ends up with a fleet of
|
|
# self-approving reviews that void the branch-protection
|
|
# ``reviewer != author`` rule.
|
|
assert_reviewer_identity(cfg)
|
|
# Worktree janitor (R3, 2026-05-16): sweep stale + corrupted
|
|
# review worktrees BEFORE the main loop starts a new cycle.
|
|
# The previous run's SIGTERM teardown frequently leaves
|
|
# orphaned worktrees that the next cycle's
|
|
# ``git worktree add`` would collide with — the janitor +
|
|
# the in-prepare auto-prune-on-failure together remove both
|
|
# the orphan dir and the mirror's stale bookkeeping.
|
|
try:
|
|
_pr_clone.prune_orphan_worktrees(cfg, kind="review")
|
|
except Exception as exc: # noqa: BLE001
|
|
_logger.warning(
|
|
"review worktree janitor raised at startup; continuing: %s: %s",
|
|
type(exc).__name__,
|
|
exc,
|
|
)
|
|
# Block-store janitor: drop every expired row from the
|
|
# cross-process content block store. Best-effort, never
|
|
# raises (the janitor itself swallows OS/DB errors).
|
|
try:
|
|
removed = _block_store.janitor()
|
|
if removed:
|
|
_logger.info(
|
|
"block store janitor: removed %s expired rows at startup",
|
|
removed,
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
_logger.warning(
|
|
"block store janitor raised at startup; continuing: %s: %s",
|
|
type(exc).__name__,
|
|
exc,
|
|
)
|
|
if args.once:
|
|
_dispatch.json_line(
|
|
_dispatch.run_one_cycle(
|
|
cfg,
|
|
WORK_GROUPS,
|
|
driver_name=DRIVER_NAME,
|
|
sweep_claim_kind=CLAIM_KIND,
|
|
)
|
|
)
|
|
return 0
|
|
_dispatch.run_outer_loop(
|
|
cfg,
|
|
WORK_GROUPS,
|
|
driver_name=DRIVER_NAME,
|
|
sweep_claim_kind=CLAIM_KIND,
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|