80d61de942
Eliminates the remaining LLM wrapper chain (``tier-dispatcher`` +
``tier-{min,0,1,2}`` selectors) between the Python dispatcher and the
``task-implementor`` worker. Follows the R2 implementation-worker
retirement (6e63073ad, 2026-05-16); both wrappers were pure routing
agents with no per-cycle judgment that could not be moved to Python.
Architecture
------------
Before (R2 baseline):
dispatch_implementer.py
→ tier-dispatcher (LLM)
→ estimator-implementation (LLM, judgment)
→ tier-N selector (LLM, pure pass-through)
→ task-implementor (LLM, the actual work, via `task` hop)
After (R3):
dispatch_implementer.py
→ estimator-implementation (LLM, judgment — invoked top-level)
→ task-implementor-tier-N (LLM, the actual work, NO `task` hops)
Two LLM hops eliminated per cycle. The ``task`` tool hop between the
tier-N selector and task-implementor is gone too, so the dispatcher's
prefetched ``## Pre-fetched …`` sections survive intact in the
worker's prompt — closing the structural cause of the ~30-80
per-session ``implementer_pr_context.py read --pr N`` round-trips
the worker burned to recover summarised-away context.
Cost savings (4-day measurement window, $-figures based on
local-claude pricing with caching):
- Eliminating tier-dispatcher sessions (32/day): ~$5-15/day
- Eliminating tier-N selector sessions (15/day): ~$2-5/day
- Eliminating prefetch round-trips (229/4d → expected near 0): ~$20-40/day
Aggregate at current traffic: roughly $30-60/day, $900-1,800/month.
What changed
------------
1. **New ``sync_tier_models.py`` scope** — generates per-tier
``task-implementor-{slot}.md`` + matching
``.opencode/models/task-implementor-{slot}.txt`` files from
``task-implementor.md`` (the byte source). Dropped: the bare
``tier-N.txt`` model files (no consumer) and the
tier-dispatcher.md mapping-table generation (no file).
2. **New ``_call_python_estimator``** in dispatch_implementer.py
invokes ``estimator-implementation`` as a top-level OpenCode
session, parses ``{is_confident, recommended_tier}``, returns the
tier integer or None. Includes a heartbeat-refresh on_poll so a
30-180 s estimator call cannot trigger the launcher's hung-
process watchdog. Estimator switched from ``mode: subagent`` to
``mode: all`` so the dispatcher can spawn it directly.
3. **New ``_resolve_task_implementor_for_tier(tier)`` helper** maps
manifest tier integers to the matching ``task-implementor-{slot}``
variant. Used by both the initial dispatch (in the prompt
factory) and the in-cycle escalation respawn.
4. **WorkGroup contract extended** with
``requires_worker_agent_override: bool`` (default False, opt-in
per group). The implementer's three WorkGroups set True;
``_resolve_effective_worker_agent`` raises a clear RuntimeError
if the prompt_factory failed to populate the override (a code
bug that would otherwise silently run every cycle at the static
fallback tier).
5. **``_implementation_prompt_dispatch`` refactored** to:
- Resolve the tier in Python (label-driven hint → estimator →
default 0), honouring both the in-cycle escalation flag and the
estimator-enabled flag.
- Stash the resolved ``task-implementor-tier-<slot>`` agent name
on the item context under
``WORKER_AGENT_OVERRIDE_ITEM_KEY`` (single source of truth in
``_dispatch_runtime``; imported into the higher layer).
- Emit the worker body with ``escalation_tier: \`N\``` directly —
no more ``escalation_tier_hint``, ``task_prompt:`` fence, or
``task_agent:``/``estimator_agent:`` outer parameters (all
consumed by the retired tier-dispatcher).
- Skip the estimator call on ``--dry-run`` so the operator-
visible no-I/O contract holds.
6. **Retired agent files DELETED**:
- ``.opencode/agents/tier-dispatcher.md``
- ``.opencode/agents/tier-{min,0,1,2}.md``
- ``.opencode/models/tier-{min,0,1,2}.txt``
- Matching entries in ``opencode.json``'s agent block.
7. **Prose updates** to ``task-implementor.md`` (the byte-source for
variants), ``estimator-implementation.md``, and production
docstrings (``_block_store.py``, ``_pr_context_sentinel.py``,
``implementer_workspace.py``, ``_review_post.py``,
``_review_finalize.py``) reflecting the post-R3 chain. The
filesystem handoff scripts (``implementer_pr_context.py``,
``implementer_workspace.py``) remain in place as the canonical
read path — defensive against any future regression that re-
introduces summarisation.
Tests
-----
2262 auto_agents passing (was 2268 pre-R3; net -6 from
removing tests pinning the retired wrapper-chain contract,
offset by +14 new tests pinning the post-R3 contract):
- ``TestEstimatorEnabledFlag`` rewritten to assert
``escalation_tier`` + agent-override semantics.
- New ``TestEstimatorPromptShape`` (5 tests) pins the body shape
the Python estimator helper passes to the agent and the
call shape into ``run_session_blocking``.
- New ``TestResolveEffectiveWorkerAgent`` (8 tests) directly
covers the override priority chain — override present, empty,
whitespace, non-string, whitespace-stripped, required-but-missing
(loud fail), required-and-present.
- ``test_dry_run_never_calls_estimator`` pins the dry-run no-I/O
contract via an exploding-stub guard on the estimator helper.
- ``TestDirectTierDispatch`` replaces the retired
``TestTierDispatcherShortCircuit`` suite in
``test_worker_permissions.py``.
- ``TestTaskImplementorVariantsAreByteIdentical`` ensures the
four per-tier variants never hand-diverge from each other.
- ``test_no_legacy_tier_agents_in_opencode_agent_block`` fails
loudly if any of the retired tier-* entries are re-introduced
to ``opencode.json``.
Operator notes
--------------
- The C3 footgun (model swaps need OpenCode restart) still applies
to the generated variants — edit ``tiers.yaml``, re-run
``python3 tools/sync_tier_models.py``, then restart OpenCode.
- The estimator now runs as a top-level OpenCode session; an
operator grepping the session archive will see
``[AUTO-IMP-PR-N-estimator] estimator-implementation`` entries
alongside the worker sessions.
- Roll-back: revert this commit + the R3 prep commit (b8c1e4903).
Both wrappers + the static-fallback ``worker_agent`` come back;
no schema migration needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
462 lines
20 KiB
Python
462 lines
20 KiB
Python
"""Review-cycle orchestrator for the dispatcher.
|
|
|
|
Extracted from :mod:`_review_pipeline` so the pipeline module can act
|
|
as a slim back-compat re-export hub and the orchestrator stays under
|
|
the project's 500-line per-file budget. The orchestrator is the only
|
|
slice that ties every subsystem together (parser, posters, head-sha
|
|
freshness check, Tier 1F escalation, data-completeness downgrade) so
|
|
giving it its own file keeps the decision matrix legible without
|
|
diluting the per-subsystem modules below it.
|
|
|
|
Public surface:
|
|
|
|
- :func:`finalize_review` -- the entry point
|
|
``_dispatch_runtime.dispatch_one`` calls in its post-session
|
|
action. Returns the cycle-telemetry dict.
|
|
- :func:`_compute_review_action` -- inner half of
|
|
:func:`finalize_review`. Decides what action to take and runs the
|
|
Forgejo wire calls, but does NOT post the operator-visibility
|
|
status comment (the wrapper handles that uniformly across every
|
|
outcome path).
|
|
- :data:`_REVIEW_VISIBILITY_ACTIONS` -- the set of ``review_action``
|
|
values that warrant an operator-status comment.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
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]
|
|
|
|
|
|
_review_parser = _load_sibling("_review_parser", "_review_parser.py")
|
|
_review_post = _load_sibling("_review_post", "_review_post.py")
|
|
_review_views = _load_sibling("_review_views", "_review_views.py")
|
|
_review_context = _load_sibling("_review_context", "_review_context.py")
|
|
_review_fetch = _load_sibling("_review_fetch", "_review_fetch.py")
|
|
|
|
|
|
_logger = logging.getLogger("review_pipeline")
|
|
|
|
|
|
# Set of ``review_action`` values that warrant a visibility comment on
|
|
# the PR timeline. ``submitted`` is excluded because the formal review
|
|
# itself is the visibility; ``tier_1f_escalation`` is excluded because
|
|
# :func:`_review_post.post_tier_1f_escalation` already posts its own
|
|
# (richer) comment.
|
|
#
|
|
# - ``failed``: a Forgejo POST returned non-2xx or the dispatcher
|
|
# raised mid-submission. The operator should know the pipeline
|
|
# tried and got an error.
|
|
# - ``error``: the worker reported ``outcome=error`` with notes.
|
|
# - ``stale``: head_sha drifted between dispatch and submit.
|
|
# - ``downgraded_due_to_truncation``: data_complete=False forced an
|
|
# APPROVED -> COMMENT downgrade.
|
|
#
|
|
# ``skipped`` is intentionally excluded: it covers benign cases where
|
|
# the operator does not need a per-cycle comment.
|
|
_REVIEW_VISIBILITY_ACTIONS = frozenset(
|
|
{"failed", "error", "stale", "downgraded_due_to_truncation"}
|
|
)
|
|
|
|
|
|
def _compute_review_action(
|
|
cfg: Any,
|
|
item: dict[str, Any],
|
|
parsed_json: dict[str, Any] | None,
|
|
raw_response: str,
|
|
terminal_state: str,
|
|
*,
|
|
review_type: str,
|
|
head_sha: str,
|
|
request_changes_count: int | None = None,
|
|
data_complete: bool = True,
|
|
) -> dict[str, Any]:
|
|
"""Inner half of :func:`finalize_review`. Decides what action to
|
|
take and runs any required API call (review submission or tier-1F
|
|
escalation) but does NOT post the operator-visibility status
|
|
comment. The wrapper handles that so it sees the final action /
|
|
reason on every code path uniformly.
|
|
|
|
When ``terminal_state == "completed"`` we ALWAYS run
|
|
:func:`_review_parser.parse_worker_review_json` on
|
|
``raw_response``. The ``parsed_json`` argument the dispatcher
|
|
passes in came from the generic JSON extractor in
|
|
``_opencode_worker._extract_last_json_object``, which is
|
|
*brace-balanced* but NOT *schema-validated*. Trusting that
|
|
pre-parsed dict directly let malformed verdicts (wrong
|
|
``event`` value, missing ``review.body``, etc.) reach
|
|
:func:`_review_post.submit_review` and produce silent 422s. The
|
|
strict re-parse closes that gap.
|
|
"""
|
|
pr_number = int(item["number"])
|
|
if terminal_state != "completed":
|
|
return {
|
|
"review_action": "skipped",
|
|
"review_action_reason": f"worker terminal_state={terminal_state}",
|
|
}
|
|
try:
|
|
parsed_json = _review_parser.parse_worker_review_json(
|
|
raw_response, review_type=review_type, head_sha=head_sha
|
|
)
|
|
except _review_parser.WorkerJSONError as exc:
|
|
_logger.warning(
|
|
"worker output parse failed for PR #%s: %s (excerpt=%r)",
|
|
pr_number,
|
|
exc.message,
|
|
exc.excerpt,
|
|
)
|
|
# A malformed verdict is a real failure mode (we cannot post a
|
|
# review), so it gets ``failed`` (visible to operators) rather
|
|
# than the legacy ``skipped`` (invisible).
|
|
return {
|
|
"review_action": "failed",
|
|
"review_action_reason": f"json-parse-failed: {exc.message}",
|
|
}
|
|
outcome = parsed_json.get("outcome")
|
|
threshold = _review_views.TIER_1F_REQUEST_CHANGES_THRESHOLD
|
|
# Defensive Tier 1F enforcement: even if the worker forgot to emit
|
|
# the escalation outcome, the dispatcher knows the count from its
|
|
# pre-fetch and can override. The count is a first-class kwarg
|
|
# (rather than smuggled on ``parsed_json``) so a future caller
|
|
# cannot forget to pass it.
|
|
if (
|
|
isinstance(request_changes_count, int)
|
|
and request_changes_count >= threshold
|
|
and outcome != "tier_1f_escalation"
|
|
):
|
|
_logger.warning(
|
|
"PR #%s has REQUEST_CHANGES count=%s >= threshold=%s but worker "
|
|
"outcome=%r; overriding to tier_1f_escalation",
|
|
pr_number,
|
|
request_changes_count,
|
|
threshold,
|
|
outcome,
|
|
)
|
|
outcome = "tier_1f_escalation"
|
|
parsed_json = {
|
|
**parsed_json,
|
|
"outcome": "tier_1f_escalation",
|
|
"tier_1f_comment": (
|
|
f"This PR has accumulated {request_changes_count} "
|
|
"REQUEST_CHANGES reviews - the dispatcher is escalating "
|
|
"to a higher implementer tier for a stronger model attempt."
|
|
),
|
|
}
|
|
# When the prompt-time aggregate ``data_complete`` is False (a
|
|
# pre-fetched section was truncated, or pagination did not finish,
|
|
# or a section's upstream fetch failed), the worker cannot
|
|
# legitimately APPROVE — it has not seen all the context.
|
|
# Downgrade APPROVED -> COMMENT and surface a distinct
|
|
# ``review_action`` so cycle telemetry shows the downgrade
|
|
# happened (rather than logging it as a normal submission).
|
|
downgrade_event: str | None = None
|
|
if (
|
|
not data_complete
|
|
and outcome == "review_drafted"
|
|
and isinstance(parsed_json.get("review"), dict)
|
|
and parsed_json["review"].get("event") == "APPROVED"
|
|
):
|
|
_logger.warning(
|
|
"PR #%s data_complete=False (truncated/partial prefetch); "
|
|
"downgrading APPROVED -> COMMENT",
|
|
pr_number,
|
|
)
|
|
downgrade_event = "COMMENT"
|
|
downgrade_review = {
|
|
**parsed_json["review"],
|
|
"event": "COMMENT",
|
|
"body": (parsed_json["review"].get("body") or "")
|
|
+ (
|
|
"\n\n_Note: the dispatcher's pre-fetched context was "
|
|
"INCOMPLETE for this review (truncated section or "
|
|
"partial pagination). The worker drafted APPROVED but "
|
|
"the dispatcher downgraded to COMMENT pending a fresh "
|
|
"cycle with complete context._"
|
|
),
|
|
}
|
|
parsed_json = {**parsed_json, "review": downgrade_review}
|
|
try:
|
|
if outcome in ("review_drafted", "ci_flag_drafted"):
|
|
review = parsed_json["review"]
|
|
# Re-fetch the PR's current head_sha and refuse to submit
|
|
# if it drifted from the value the worker built its verdict
|
|
# against. The author may have force-pushed during the
|
|
# worker session (Qwen3-35B at medium reasoning takes
|
|
# ~3-10 min wall-clock), in which case the review's
|
|
# ``commit_id`` would be a 422 from Forgejo — but more
|
|
# importantly the verdict reflects code that no longer
|
|
# exists at HEAD, so submitting it would mislead the
|
|
# operator. On a stale check we record
|
|
# ``review_action: stale`` and let the dispatcher post the
|
|
# operator-status comment; the next cycle re-claims and
|
|
# re-reviews against the freshly pushed SHA.
|
|
stale_check = _review_post._check_head_sha_freshness(
|
|
cfg, pr_number, expected_head_sha=head_sha
|
|
)
|
|
if stale_check is not None:
|
|
return stale_check
|
|
response = _review_post.submit_review(cfg, pr_number, review)
|
|
response_status = int(response.get("status") or 0)
|
|
_logger.info(
|
|
"submitted review for PR #%s: event=%s status=%s",
|
|
pr_number,
|
|
review.get("event"),
|
|
response_status,
|
|
)
|
|
# Merge-readiness gate (2026-05-16): keep ``auto/ready-to-merge``
|
|
# in lockstep with the reviewer's verdict so the merge driver's
|
|
# pick_candidates gate (Option A) sees an accurate signal.
|
|
# APPROVED adds; REQUEST_CHANGES removes; COMMENT no-ops. Only
|
|
# mutate on a successful submission (2xx) — a 422 from Forgejo
|
|
# means the verdict didn't land, so the label should not move
|
|
# off the reviewer's prior state.
|
|
if 200 <= response_status < 300:
|
|
try:
|
|
_review_post.update_ready_to_merge_label(
|
|
cfg, pr_number, review.get("event") or "",
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
_logger.warning(
|
|
"update_ready_to_merge_label failed for PR #%s "
|
|
"(verdict recorded; merge driver may pick the PR "
|
|
"without the gate seeing the latest signal — "
|
|
"Option B safety net covers this): %s",
|
|
pr_number, exc,
|
|
)
|
|
# ``_claim_runtime.post`` swallows HTTPError into ``{status,
|
|
# body}`` rather than raising, so a 422 from Forgejo
|
|
# previously rode through as ``review_action: submitted``.
|
|
# Treat any non-2xx as an explicit failure so cycle
|
|
# telemetry doesn't lie.
|
|
if not (200 <= response_status < 300):
|
|
response_body = response.get("body")
|
|
detail: str
|
|
if isinstance(response_body, dict):
|
|
detail = (
|
|
str(response_body.get("message"))
|
|
if response_body.get("message")
|
|
else json.dumps(response_body, sort_keys=True)[:300]
|
|
)
|
|
else:
|
|
detail = (str(response_body) or "")[:300]
|
|
return {
|
|
"review_action": "failed",
|
|
"review_action_reason": (
|
|
f"submit_review returned status={response_status}: "
|
|
f"{detail}"
|
|
),
|
|
"review_event": review.get("event"),
|
|
"review_status": response_status,
|
|
"comments_count": len(review.get("comments") or []),
|
|
}
|
|
if downgrade_event is not None:
|
|
return {
|
|
"review_action": "downgraded_due_to_truncation",
|
|
"review_action_reason": (
|
|
"data_complete=False at prompt build time; "
|
|
"APPROVED downgraded to COMMENT defensively"
|
|
),
|
|
"review_event": review.get("event"),
|
|
"review_status": response_status,
|
|
"comments_count": len(review.get("comments") or []),
|
|
}
|
|
return {
|
|
"review_action": "submitted",
|
|
"review_event": review.get("event"),
|
|
"review_status": response_status,
|
|
"comments_count": len(review.get("comments") or []),
|
|
}
|
|
if outcome == "tier_1f_escalation":
|
|
comment_body = parsed_json.get("tier_1f_comment") or (
|
|
f"This PR has accumulated >= {threshold} REQUEST_CHANGES "
|
|
"reviews - escalating to a higher implementer tier for a "
|
|
"stronger model attempt."
|
|
)
|
|
result = _review_post.post_tier_1f_escalation(
|
|
cfg, pr_number, comment_body=comment_body
|
|
)
|
|
_logger.info(
|
|
"posted tier-1F escalation for PR #%s: comment_status=%s "
|
|
"label_added=%s",
|
|
pr_number,
|
|
result.get("comment_status"),
|
|
result.get("label_added"),
|
|
)
|
|
return {"review_action": "tier_1f_escalation", **result}
|
|
if outcome == "error":
|
|
# Surface worker-reported errors with notes rather than
|
|
# collapsing them into ``skipped`` (which made operator
|
|
# triage impossible — ``skipped`` and ``error`` had
|
|
# identical telemetry shapes).
|
|
notes = parsed_json.get("notes")
|
|
reason_detail = (
|
|
str(notes) if isinstance(notes, str) and notes.strip()
|
|
else "reported error without notes"
|
|
)
|
|
return {
|
|
"review_action": "error",
|
|
"review_action_reason": f"worker outcome=error: {reason_detail}",
|
|
}
|
|
return {
|
|
"review_action": "skipped",
|
|
"review_action_reason": f"outcome={outcome}",
|
|
}
|
|
except Exception as exc: # noqa: BLE001 — the dispatcher must not crash
|
|
_logger.exception(
|
|
"review finalization failed for PR #%s: %s", pr_number, exc
|
|
)
|
|
return {
|
|
"review_action": "failed",
|
|
"review_action_reason": f"{type(exc).__name__}: {exc}",
|
|
}
|
|
|
|
|
|
def finalize_review(
|
|
cfg: Any,
|
|
item: dict[str, Any],
|
|
parsed_json: dict[str, Any] | None,
|
|
raw_response: str,
|
|
terminal_state: str,
|
|
*,
|
|
review_type: str,
|
|
head_sha: str,
|
|
request_changes_count: int | None = None,
|
|
pr_comments: list[dict[str, Any]] | None = None,
|
|
data_complete: bool = True,
|
|
) -> dict[str, Any]:
|
|
"""Run the dispatcher-side finalization for a review cycle.
|
|
|
|
Called by ``_dispatch_runtime.dispatch_one`` after the worker
|
|
session completes (and before the claim release in the finally
|
|
block). Translates the worker's structured-JSON verdict into the
|
|
appropriate Forgejo wire calls and returns a metadata dict for
|
|
cycle telemetry.
|
|
|
|
Decision matrix:
|
|
|
|
| terminal_state | parsed outcome | dispatcher action |
|
|
| -------------- | --------------------------- | -------------------------- |
|
|
| completed | review_drafted | submit_review (200..299) |
|
|
| completed | review_drafted (non-2xx) | record-only + status post |
|
|
| completed | ci_flag_drafted | submit_review (CI flag) |
|
|
| completed | tier_1f_escalation | post_tier_1f_escalation |
|
|
| completed | skipped | record-only + status post |
|
|
| completed | error | record-only + status post |
|
|
| completed | parse-failed | record-only + status post |
|
|
| timeout / * | (any) | record-only + status post |
|
|
|
|
On every ``skipped`` / ``failed`` / ``error`` outcome the
|
|
dispatcher posts an operator-visibility status comment on the PR
|
|
(gap #6 in the 2026-05-07 audit) so an operator looking at the
|
|
PR can see the pipeline tried. Failures of the visibility post
|
|
are recorded but never raised — the claim release in the
|
|
dispatcher's finally block must still run.
|
|
|
|
``request_changes_count`` is the active (non-dismissed)
|
|
REQUEST_CHANGES count the dispatcher captured when it built the
|
|
prompt. Threading it through avoids re-fetching the entire review
|
|
list a second time after the worker session — that would double
|
|
Forgejo load and introduce a TOCTOU window between the prompt-time
|
|
count the worker saw and the post-session count the dispatcher
|
|
decides on.
|
|
|
|
The pre-parsed ``parsed_json`` argument is retained for backward
|
|
compatibility but is NOT authoritative when
|
|
``terminal_state == "completed"``: :func:`_compute_review_action`
|
|
always re-parses ``raw_response`` via the strict
|
|
:func:`_review_parser.parse_worker_review_json`.
|
|
"""
|
|
pr_number = int(item["number"])
|
|
result = _compute_review_action(
|
|
cfg,
|
|
item,
|
|
parsed_json,
|
|
raw_response,
|
|
terminal_state,
|
|
review_type=review_type,
|
|
head_sha=head_sha,
|
|
request_changes_count=request_changes_count,
|
|
data_complete=data_complete,
|
|
)
|
|
# Stamp the prompt-time unresolved-link counts onto every cycle's
|
|
# telemetry so an operator can grep cycle archives for
|
|
# "approved-with-broken-link" outcomes (review_action=submitted +
|
|
# review_event=APPROVED + unresolved_link_count > 0) without
|
|
# re-parsing the prompt archive. The split-by-reason variants let
|
|
# the operator tell deterministic broken-link signals
|
|
# (``not-found``, no defensive downgrade) from transient ones
|
|
# (``fetch-error``, defensive downgrade fired).
|
|
# Coerce the carrier into a typed ReviewContext. The production
|
|
# producer (``_review_prompt``) constructs the dataclass directly,
|
|
# so its typo-safety property comes from Python's
|
|
# ``TypeError`` at construction (unknown kwargs cannot reach this
|
|
# call site). The dict path is exercised only by unit-test
|
|
# fixtures and direct-invocation callers — and ``strict=True``
|
|
# turns those into a typo'd-fixture safety net so a stale test
|
|
# fixture cannot silently exercise a corrupted code path against
|
|
# the production read site (``head_shaa`` would otherwise default
|
|
# ``head_sha=""`` and skew the next cycle's freshness check /
|
|
# telemetry).
|
|
review_context = _review_context.coerce_review_context(
|
|
item.get("_dispatcher_review_context"), strict=True
|
|
)
|
|
if review_context is not None:
|
|
# Flatten ``unresolved_link_counts`` from the dataclass dict
|
|
# field into one ``unresolved_link_count_<token>`` key per
|
|
# registry entry. The total is stamped under
|
|
# ``unresolved_link_count`` (operators grep this for
|
|
# "approved-with-broken-link" outcomes regardless of which
|
|
# reason fired). Iterating
|
|
# :data:`_review_fetch.KNOWN_UNRESOLVED_REASONS` instead of
|
|
# the dict's literal keys means every registered reason
|
|
# appears in the cycle archive even when its count is zero,
|
|
# so the per-reason key shape is stable across cycles
|
|
# regardless of which reasons happened to fire on a given
|
|
# PR (operator grep tools that key off the FIELD existing,
|
|
# not just the value being non-zero, stay reliable).
|
|
result["unresolved_link_count"] = (
|
|
review_context.unresolved_link_count_total
|
|
)
|
|
for reason in _review_fetch.KNOWN_UNRESOLVED_REASONS:
|
|
archive_key = (
|
|
f"unresolved_link_count_{reason.token.replace('-', '_')}"
|
|
)
|
|
result[archive_key] = int(
|
|
review_context.unresolved_link_counts.get(reason.token, 0)
|
|
)
|
|
action = str(result.get("review_action") or "")
|
|
if action not in _REVIEW_VISIBILITY_ACTIONS:
|
|
return result
|
|
try:
|
|
response = _review_post.post_operator_status_comment(
|
|
cfg,
|
|
pr_number,
|
|
action=action,
|
|
action_reason=str(result.get("review_action_reason") or ""),
|
|
terminal_state=terminal_state,
|
|
pr_comments=pr_comments,
|
|
)
|
|
result["status_comment_status"] = int(response.get("status") or 0)
|
|
result["status_comment_fingerprint"] = response.get("fingerprint")
|
|
if response.get("skipped_duplicate"):
|
|
result["status_comment_skipped_duplicate"] = True
|
|
except Exception as exc: # noqa: BLE001 — visibility is best-effort
|
|
_logger.warning(
|
|
"status-comment post failed for PR #%s: %s", pr_number, exc
|
|
)
|
|
result["status_comment_status"] = 0
|
|
result["status_comment_error"] = f"{type(exc).__name__}: {exc}"
|
|
return result
|
|
|
|
|
|
__all__ = (
|
|
"finalize_review",
|
|
)
|