Files
cleveragents-core/tools/_implementer_prefetch.py
T
drew 3f12e4140c fix(auto-agents): R3.4 — cycle-cap signature + implementer sees full reviewer record
Two related fixes surfaced by the 2026-05-17 run-5 live observation:

1. **Cycle-cap signature now includes total_reviews count** so the
   reviewer's COMMENT-only path actually moves the signature. Before:
   the cap signature was ``sha + (approvals + has_active_RC +
   has_unaddressed_RC)`` — all three boolean axes ignore COMMENT
   reviews entirely. After: ``+ total_reviews`` term bumps on
   EVERY review submission. The ``data_complete=False → COMMENT
   downgrade`` path the reviewer takes in low-context cycles now
   reflects in the signature, so the cap stops firing falsely.

   Without this, run-5 observed 4 fresh PRs hitting count=5 in
   ~10 minutes — the reviewer was successfully posting reviews
   every cycle but the cap saw "no change" because COMMENT-only
   reviews don't bump approvals_count, has_active_RC, or
   has_unaddressed_RC.

2. **Implementer now sees ALL reviewer feedback**, not just active
   REQUEST_CHANGES. Before: ``fetch_pr_fix_context`` passed
   ``include_active_reviews=False`` so failing-CI PRs reached the
   implementer with zero reviewer data. ``fetch_request_changes_pr_context``
   only included active RC reviews — COMMENT-only feedback was
   invisible in both code paths.

   After: ``fetch_pr_fix_context`` also includes reviews, AND the
   fetcher now partitions reviews into TWO buckets — the existing
   ``request_changes_reviews`` (active blocking RC, unchanged
   semantic) and a new ``comment_reviews`` field carrying every
   non-dismissed non-RC review (COMMENT / APPROVE). Both are
   persisted in the PR-context sentinel and exposed via
   ``implementer_pr_context.py``'s ``comment_reviews`` field.

   New prompt section ``## Pre-fetched reviewer comments and
   approvals`` renders the comment_reviews bucket with author /
   event / commit / body / inline comments + a postscript marking
   them as ADVISORY (not blocking, unlike the existing RC section).

   This closes the architectural gap where the reviewer and
   implementer pools could work in silos on the same PR — the
   reviewer's substantive prose feedback now reaches the
   implementer regardless of which work-group routed it.

Files touched:
- tools/_pr_classification_cache.py — total_reviews in classify_pr +
  reactivity composite; docstring updated.
- tools/_implementer_prefetch.py — new comment_reviews +
  comment_reviews_completed fields; fetcher partitions reviews
  once; fetch_pr_fix_context now includes reviews.
- tools/_implementer_prompt.py — _build_comment_reviews_section;
  wired into prompt assembly between RC and PR-comments sections.
- tools/_pr_context_sentinel.py — comment_reviews in _to_dict.
- tools/implementer_pr_context.py — comment_reviews accessor for
  the worker's handoff read path.
- tests/auto_agents/test_pr_context_sentinel.py — expected_value_keys
  + fixture + round-trip test updated.
- .opencode/agents/estimator-implementation.md — restored canonical
  section header levels (####) for downstream test compatibility
  after R3.1 rewrite.

Full auto_agents suite: 2303 passing (+12 from this and adjacent
work, none broken).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:00:02 -04:00

1057 lines
46 KiB
Python

"""Pre-dispatch Forgejo fetches for the implementer dispatcher.
Mirrors :func:`_review_prompt.fetch_review_context` on the implementer
side. The implementer worker has historically issued every Forgejo
GET inside its LLM session (see the PR #30 post-mortem on 2026-05-08
where the estimator burned 10 min on five unauthed ``webfetch`` calls
before any code was written). This module pulls every read into a
single deterministic pre-dispatch pass so the worker's prompt is
self-contained — no in-session HTTP, no in-session pagination, no
tool-budget creep on ``webfetch``.
Three pre-fetch shapes, one per work group:
- ``pr_fix`` — failing-CI PR. Fetches PR details + diff +
CI combined status + per-check status detail (when overall != success)
+ paginated PR comments + linked-issue bodies + an Epic body when
the PR / linked issues reference one.
- ``request_changes_pr`` — PR with at least one active
``REQUEST_CHANGES`` review the author has not yet addressed.
Same as ``pr_fix`` plus the active reviews list (with their inline
comments) so the worker can read each blocking concern without
paging through the API itself.
- ``new_issue`` — implementing a fresh issue. No PR exists yet,
so we fetch the issue body + paginated issue comments + linked
bodies + Epic body only.
The Forgejo HTTP primitives come from :mod:`_review_fetch` — the
existing fetcher set is fully generic (see the per-function docstrings;
none of them are review-specific in behaviour, only in original
caller). Reusing them here closes the "implementer dispatcher does
zero pre-fetch" gap without forking a parallel ``_implementer_fetch``
module that would need its own ``_api_get_paginated`` retry / partial-
pagination handling.
Each fetcher swallows its own failures and returns ``None`` / ``[]``
so this module never raises. Sections downstream
(:mod:`_implementer_prompt`) handle the missing-data case explicitly
by emitting an "X unavailable" stanza instead of an empty one — the
worker is told what was attempted so it can decide whether to fall
back to its in-session HTTP rules.
The dispatcher's overall ``data_complete`` flag is the AND of every
section's ``completed`` signal. Today's implementer worker does not
short-circuit on incomplete data the way the reviewer dispatcher
defensively downgrades APPROVED → COMMENT, but the flag is still
emitted so a future post-session action (e.g. operator-status
fingerprinting per Phase 5b) can record "this cycle was dispatched
against a partial context" for triage.
"""
from __future__ import annotations
import logging
import os
import sys
from dataclasses import dataclass, field
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 ( # noqa: E402 type: ignore[import-not-found]
load_sibling as _load_sibling,
)
_review_fetch = _load_sibling("_review_fetch", "_review_fetch.py")
_ci_logs = _load_sibling("_ci_logs", "_ci_logs.py")
_pr_comments_cache = _load_sibling(
"_pr_comments_cache", "_pr_comments_cache.py"
)
_pr_diff = _load_sibling("_pr_diff", "_pr_diff.py")
_attempt_history = _load_sibling("_attempt_history", "_attempt_history.py")
_block_prompt = _load_sibling("_block_prompt", "_block_prompt.py")
_prefetch_section = _load_sibling("_prefetch_section", "_prefetch_section.py")
_bot_logins = _load_sibling("_bot_logins", "_bot_logins.py")
# Loaded at module scope (not deferred per-call) — the comment at
# the original call site warned about a circular-import risk that
# does not exist in practice (``implementer_validate`` does not
# import this module). Per-call ``_load_sibling`` was paying a
# dict lookup + cache hit on every prefetch; module-scope is
# cheaper and matches the load pattern used by ``_review_fetch``
# / ``_pr_diff`` above.
_implementer_validate = _load_sibling(
"implementer_validate", "implementer_validate.py"
)
_logger = logging.getLogger("implementer_prefetch")
# ─── Constants ──────────────────────────────────────────────────────────────
# Cap on the linked / Epic issue body characters embedded in the
# prompt. The worker can always paginate to the Forgejo issue itself
# if the body is enormous; the prompt budget exists so a 50 KB issue
# doesn't crowd out the diff.
DEFAULT_BODY_MAX_CHARS = 8000
# Max linked issues we will resolve. Mirrors the reviewer's setting.
DEFAULT_MAX_LINKED_ISSUES = 5
# Cap on per-comment body characters in the rendered prompt. Long
# comments truncate with the standard "[... N more lines truncated
# ...]" marker.
DEFAULT_COMMENT_MAX_CHARS = 4000
# Cap on how many comments are rendered verbatim into the worker
# prompt. The comment list is oldest-first (Forgejo creation order,
# preserved by _pr_comments_cache), so the renderer must take the
# LAST N — the worker needs the most recent attempt history to avoid
# repeating a just-failed approach, not the oldest. The section header
# still reports the true total so the worker knows more exist.
# (run-8 inspection: _build_comments_section took comments[:50] — the
# OLDEST 50 — so on a heavy PR like #30 with 1440 comments the worker
# was shown ancient history and missed every recent attempt.)
DEFAULT_MAX_PROMPT_COMMENTS = 50
# Cap on the CI failure log tail we embed when overall CI != success.
DEFAULT_CI_LOG_TAIL_CHARS = 4000
# Single source of truth for the dataclass's per-section completion
# flag attributes. Consumed by:
#
# - :func:`_init_completion_flags` (loops over the tuple to reset
# every flag rather than enumerating 10 explicit assignments).
# - The sentinel writer
# :mod:`_pr_context_sentinel.\_to_dict` (re-exports the
# tuple at module scope and loops to overlay every flag onto
# the projected dict; see that module's import block).
# - The worker-side reader
# :func:`tools.implementer_pr_context._project_field` (the
# ``metadata`` branch enumerates ``*_completed`` keys present
# in the payload — naturally tracking writer-side additions
# without sharing the tuple directly so the reader stays a
# stand-alone script with no dispatcher imports).
# - The fetcher-binding regression tests in
# ``tests/auto_agents/test_pr_context_sentinel.py`` (import this
# tuple to enumerate every flag a fetcher's preamble must reset
# — including any flag added in the future).
#
# Adding a new ``*_completed`` flag to
# :class:`ImplementerPrefetchResult` MUST also append the
# attribute name to this tuple — the binding tests will fail
# loudly if the new flag isn't initialised by the preamble.
# Conversely, a typo'd / stale entry in the tuple is caught by
# the belt-and-braces guard
# ``test_completion_flag_names_covers_every_dataclass_attribute``
# which asserts the tuple set equals the dataclass set
# (bidirectional drift check).
COMPLETION_FLAG_NAMES: tuple[str, ...] = (
"pr_details_completed",
"diff_completed",
"ci_status_completed",
"ci_detail_completed",
"ci_failure_logs_completed",
"pr_comments_completed",
"request_changes_reviews_completed",
"comment_reviews_completed",
"issue_body_completed",
"issue_comments_completed",
"linked_issues_completed",
"epic_completed",
)
# ─── Result dataclass ───────────────────────────────────────────────────────
@dataclass
class ImplementerPrefetchResult:
"""Aggregate result of one pre-dispatch fetch pass.
Mirrors the reviewer's ``ReviewContext`` carrier role: the
dispatcher stamps an instance onto ``item["_dispatcher_implementer_context"]``
so a future ``post_session_action`` (Phase 5b operator-status
comments) can read the same values without repeating any GETs.
All collection fields default to empty so a caller that only
populates the fields relevant to its work group still produces a
structurally-valid carrier.
Field semantics
---------------
- ``head_sha``: the freshly fetched ``pr_details.head.sha`` for
pr_fix / request_changes_pr; empty string for new_issue. Used
by Phase 3 pre-clone to ensure the diff and the worktree
check out the same commit.
- ``data_complete``: aggregate AND of every section's completion
signal. Diff truncation flips this to False even though the
diff body is still embedded. Linked-issue 404 does NOT flip
this (the placeholder is itself complete information; the
worker's quality-assurance procedure is expected to surface
a broken link as a code review concern).
- ``error_kinds``: free-form list of dispatcher-side fetch
failures (e.g. ``"pr_details:404"``, ``"ci_status:transient"``).
Empty list when every fetch succeeded. Used by the cycle
archive for triage; not embedded in the prompt.
"""
head_sha: str = ""
pr_details: dict[str, Any] | None = None
# True iff ``pr_details`` was fetched successfully. The
# ``description`` and ``title`` fields are derived from
# ``pr_details``; a legitimately-empty PR description with this
# flag True maps to "authoritative empty" (worker skips legacy
# GET) whereas an empty description with this flag False
# (transient fetch failure) maps to "fall through" via the
# worker-side three-case contract.
pr_details_completed: bool = True
diff_text: str = ""
diff_truncated: bool = False
diff_unavailable: bool = False
diff_info: dict[str, int] = field(default_factory=dict)
# True iff the diff was fetched successfully (truncation does
# not flip this — a truncated diff is still authoritative).
# Derives ``diff_completed`` in the sentinel projection.
diff_completed: bool = True
ci_status: dict[str, Any] | None = None
ci_status_completed: bool = True
ci_detail: list[dict[str, Any]] = field(default_factory=list)
ci_detail_completed: bool = True
# Per-job failing-CI log tails, pre-fetched once per head_sha via
# :mod:`_ci_logs` and stamped on the prompt as
# ``## Pre-fetched CI failure logs`` so the worker doesn't have
# to chase the failing assertion through file reads + a
# ~10-minute ``ci_run_local_gate`` invocation. ``None`` when CI
# is success (nothing to fetch).
ci_failure_logs: dict[str, Any] | None = None
ci_failure_logs_completed: bool = True
pr_comments: list[dict[str, Any]] = field(default_factory=list)
pr_comments_completed: bool = True
# Bounded subset of ``pr_comments`` to carry verbatim into the
# worker prompt + sentinel: the most-recent N plus every non-bot
# comment. The bot's older attempt comments are summarised by
# ``pr_comments_digest`` instead — together they keep a heavy PR's
# comment context bounded regardless of total volume (run-8
# findings R8-2 / R8-3). Empty for new_issue work.
pr_comments_view: list[dict[str, Any]] = field(default_factory=list)
# Deterministic attempt-history digest (see _attempt_history) —
# counts by tier / outcome, failing gates, last success. Empty
# dict for new_issue work or a PR with no comments.
pr_comments_digest: dict[str, Any] = field(default_factory=dict)
# Filter-summary stamp from :mod:`_pr_comments_cache` — counts
# bot status/claim/release/sentinel comments dropped at cache
# write time so the prompt section header can surface
# "N bot comments filtered" without storing the noise itself.
# Shape: ``{"count": N, "by_author": {login: N, ...}}`` or
# empty dict when the cache has no summary.
pr_comments_filter_summary: dict[str, Any] = field(default_factory=dict)
request_changes_reviews: list[dict[str, Any]] = field(default_factory=list)
request_changes_reviews_completed: bool = True
# Non-RC reviews (event=COMMENT or APPROVE) — the reviewer's
# advisory feedback that doesn't carry the "blocking" semantic
# but DOES carry substantive review prose + inline comments.
# Pre-R3.4 the implementer never saw these (request_changes_pr
# work group only included active RC; failing_ci_pr included
# no reviews at all) — the 2026-05-17 run-5 observation was
# that the reviewer's ``data_complete=False → COMMENT downgrade``
# path was producing reviews the implementer never saw, leaving
# the two pools working in independent silos on the same PR.
# Now populated for every work group whose ``include_reviews``
# flag is True so the implementer has the full reviewer record.
comment_reviews: list[dict[str, Any]] = field(default_factory=list)
comment_reviews_completed: bool = True
issue_body: str = ""
# True iff the issue body was fetched successfully (only
# meaningful for ``new_issue`` work; the field stays empty for
# ``pr_fix`` / ``request_changes_pr`` where the issue is not
# part of the work shape but the flag is still True because
# "nothing was attempted" is itself a complete answer).
issue_body_completed: bool = True
issue_comments: list[dict[str, Any]] = field(default_factory=list)
issue_comments_completed: bool = True
linked_issues: list[dict[str, Any]] = field(default_factory=list)
linked_issues_completed: bool = True
epic_issue: dict[str, Any] | None = None
epic_completed: bool = True
data_complete: bool = True
error_kinds: list[str] = field(default_factory=list)
# Cross-process block-store references. Populated after the
# per-section fetches have landed; each ref's ``key`` is what
# the worker passes to the ``block_store`` MCP's ``block_fetch``
# tool when it needs the original content of a section that an
# intermediate agent may have summarised. Empty when the block
# store is disabled or every registration failed (the inline
# sections in the prompt are still authoritative).
block_refs: list[Any] = field(default_factory=list)
# Map of carrier-attribute → operator-visible section name. Used by
# :func:`assess_prompt_completeness` so the loud-signal log/telemetry
# names sections in the same nomenclature the worker prompt's
# ``## Pre-fetched …`` headings use, not the Python attribute names.
_COMPLETION_FLAG_LABELS: dict[str, str] = {
"pr_details_completed": "pr_details",
"diff_completed": "diff",
"ci_status_completed": "ci_status",
"ci_detail_completed": "ci_detail",
"ci_failure_logs_completed": "ci_failure_logs",
"pr_comments_completed": "pr_comments",
"request_changes_reviews_completed": "request_changes_reviews",
"comment_reviews_completed": "comment_reviews",
"issue_body_completed": "issue_body",
"issue_comments_completed": "issue_comments",
"linked_issues_completed": "linked_issues",
"epic_completed": "epic_issue",
}
def assess_prompt_completeness(
result: "ImplementerPrefetchResult",
) -> dict[str, Any]:
"""W8 harvest (2026-05-15) — translate a prefetch result's
per-section completion flags into a loud, operator-readable
signal.
Returns ``{"degraded": bool, "missing_sections": list[str],
"error_kinds": list[str]}``. The aggregate ``data_complete`` flag
in :class:`ImplementerPrefetchResult` already encodes the same
AND — but as a single bool it loses the *which sections* signal
an operator needs to triage a degraded cycle. This helper keeps
the AND result AND the list of failing sections together.
Pure function; no I/O. The caller decides whether to log, write
telemetry, or post a status comment based on the result + an
operator-controlled flag.
"""
missing: list[str] = []
for attr, label in _COMPLETION_FLAG_LABELS.items():
if not bool(getattr(result, attr, True)):
missing.append(label)
# Diff truncation isn't a "completed=False" — the bytes are still
# in the carrier — but it IS a degraded signal an operator should
# see (a truncated diff makes some reviews and most full-context
# implementations unreliable). Surface it explicitly so the
# telemetry / log carries the same nuance ``data_complete`` does.
if getattr(result, "diff_truncated", False):
missing.append("diff_truncated")
return {
"degraded": bool(missing) or not bool(getattr(result, "data_complete", True)),
"missing_sections": missing,
"error_kinds": list(getattr(result, "error_kinds", []) or []),
}
# ─── Epic reference parsing ─────────────────────────────────────────────────
def parse_epic_reference(body: str | None) -> int | None:
"""Extract a single Epic / Parent issue reference from ``body``.
Mirrors :data:`implementer_validate.EPIC_REFERENCE_RE` — recognises
``Epic: #123``, ``Epic #123``, ``Parent: #123``, ``Parent #123``
(case-insensitive, on its own line). Returns the issue number or
``None`` if no reference is present.
The parsing is intentionally identical to the post-push validator
so a body that satisfies ``validate-pr-compliance`` is the same
body that resolves to an Epic body in the worker prompt. Adding
a future Epic-shape reference (e.g. ``Tracker:`` or
``Initiative:``) lands in both places by editing the shared
regex in :mod:`implementer_validate`.
"""
if not body:
return None
# Re-uses the regex from ``implementer_validate`` so a single
# edit (e.g. a future Tracker:/Initiative: spelling) propagates
# to both the post-push validator and this dispatcher-side
# prefetch.
match = _implementer_validate.EPIC_REFERENCE_RE.search(body)
if match is None:
return None
try:
return int(match.group("n"))
except (TypeError, ValueError, IndexError):
return None
def _fetch_issue(cfg: Any, number: int) -> tuple[dict[str, Any] | None, bool]:
"""GET a single issue. Returns ``(issue, completed)``. Mirrors
:func:`_review_fetch.fetch_pr_details` shape but for issues, since
:mod:`_review_fetch` exposes no public ``fetch_issue`` (only
linked-issue resolution which adds annotation we don't want here).
"""
if number <= 0:
return None, True
path = f"/repos/{cfg.owner}/{cfg.repo}/issues/{int(number)}"
try:
response = _review_fetch._claim_runtime.get(path, cfg)
except Exception as exc:
_logger.warning("issue-fetch #%s failed: %s", number, exc)
return None, False
status = int(response.get("status") or 0)
if status != 200:
# 4xx is a deterministic answer (issue not retrievable) — not
# a partial fetch. 5xx / transport failures already raised.
if 400 <= status < 500:
return None, True
return None, False
body = response.get("body")
if not isinstance(body, dict):
return None, False
return body, True
def _bounded_comment_view(
comments: list[dict[str, Any]], max_recent: int,
*, bot_logins: tuple[str, ...] | None = None,
) -> list[dict[str, Any]]:
"""The subset of ``comments`` to carry verbatim into the worker
prompt / sentinel.
Rule:
- Always keep the most-recent ``max_recent`` comments (gives the
worker the immediate attempt-history context regardless of who
authored them).
- Older than that: keep iff the comment is NOT authored by a known
bot AND is NOT a structured ``**Implementation Attempt**``
marker. The bot's own older claim/status/sentinel/attempt
comments are dropped (attempts are summarised in the digest;
other bot comments are routine noise). Real human / reviewer
comments survive.
Why author-based and not content-based: a content-only classifier
(``is_attempt_comment``) catches the structured attempt marker but
misses claim markers, status comments, sentinel posts, etc. — on
PR #30 those are ~1240 of the 1480 comments and would balloon the
"bounded" view to ~1252 (run-10 inspection). Author-based catches
everything the bots write while keeping every real human comment.
Order preserved (oldest-first); a comment that is both recent and
human is appended exactly once.
"""
bot_set = (
set(bot_logins) if bot_logins is not None
else _bot_logins.bot_logins()
)
if len(comments) <= max_recent:
return list(comments)
cutoff = len(comments) - max_recent
out: list[dict[str, Any]] = []
for i, comment in enumerate(comments):
if i >= cutoff:
# Always keep the recent window, regardless of author.
out.append(comment)
continue
if not isinstance(comment, dict):
continue
# Older than the recent window: drop if bot-authored OR if it
# is a structured attempt marker (belt-and-braces — should be
# covered by the author check, but ``is_attempt_comment``
# protects against a future bot that posts under an unknown
# login).
if _attempt_history.is_bot_authored(comment, bot_set):
continue
if _attempt_history.is_attempt_comment(comment):
continue
out.append(comment)
return out
# ─── Per-work-group fetchers ────────────────────────────────────────────────
def fetch_pr_fix_context(
cfg: Any, item: dict[str, Any]
) -> ImplementerPrefetchResult:
"""Pre-dispatch fetch for the ``pr_fix`` work group (failing CI).
Pulls everything the worker needs to:
- Read the PR description and the failing-CI feedback (combined
status + per-check detail when overall != success).
- Read paginated issue-style comments on the PR.
- Read the linked-issue bodies (Closes / Fixes / Refs) so the
worker can confirm the change still aligns with intent.
- Read the parent Epic body when the PR or any linked issue
references one (``Epic: #N`` / ``Parent: #N``).
Does NOT fetch active reviews — the failing-CI bucket is
review-state-agnostic. Use :func:`fetch_request_changes_context`
for the bucket where reviews are load-bearing.
Sections this work_type does NOT attempt (``issue_body``,
``issue_comments``, ``request_changes_reviews``) carry
``*_completed=False`` in the result so the worker-side
three-case contract correctly signals "not attempted; fall
through to legacy GET" instead of "fetched and confirmed
empty" for those fields. See :func:`_init_completion_flags`
for the preamble + success-flip pattern.
"""
# R3.4 (2026-05-17): even failing_ci_pr work benefits from the
# reviewer's feedback. The flag is misnamed (now fetches ALL
# reviews, partitioned into RC + comment), but kept for back-compat.
return _fetch_pr_context(cfg, item, include_active_reviews=True)
def fetch_request_changes_context(
cfg: Any, item: dict[str, Any]
) -> ImplementerPrefetchResult:
"""Pre-dispatch fetch for the ``request_changes_pr`` work group.
Same shape as :func:`fetch_pr_fix_context` plus the list of
active (non-dismissed) ``REQUEST_CHANGES`` reviews with their
inline comments. The worker uses these to drive its fix loop —
every blocking concern must be addressed before the worker
pushes.
Sections this work_type does NOT attempt (``issue_body``,
``issue_comments``) carry ``*_completed=False`` in the result
— same rationale as :func:`fetch_pr_fix_context`.
"""
return _fetch_pr_context(cfg, item, include_active_reviews=True)
def _init_completion_flags(result: ImplementerPrefetchResult) -> None:
"""Reset every ``*_completed`` flag on ``result`` to ``False``
before the fetcher attempts any Forgejo round-trip.
This is the preamble half of the "preamble + success-flip"
pattern used by every per-work-group fetcher. After this runs:
- Sections the work_type **never attempts** (e.g. ``issue_body``
for ``pr_fix``) stay ``False`` through to the on-disk
sentinel. The worker reading ``--field issue_body`` then
gets empty stdout and falls through to its legacy GET —
the correct behaviour for "dispatcher didn't try; you go
ask Forgejo yourself".
- Sections the work_type **does attempt** (e.g. ``pr_details``
for ``pr_fix``) start ``False`` and the fetcher's success
path flips them ``True`` explicitly. This lets early-exits
(e.g. ``pr_details`` 5xx → return before diff / CI / comments
/ reviews) leave the not-yet-attempted flags ``False`` so
the worker correctly falls through for those fields rather
than being told a stale ``[]`` / ``null`` is authoritative.
Why ONE helper for both ``_fetch_pr_context`` and
``fetch_new_issue_context``: the two work-shape families
differ in WHICH flags get flipped ``True`` on success, but
they don't differ in the preamble — every flag starts
``False``. Per-work-type intent is documented on the
individual fetcher docstrings, not duplicated across two
identical helper bodies.
Loops over :data:`COMPLETION_FLAG_NAMES` rather than
enumerating attributes explicitly so adding a new
``*_completed`` flag to the dataclass automatically extends
the preamble — provided the contributor also adds the name
to the tuple. The fetcher-binding regression tests share the
same tuple, so any drift between the dataclass and the
preamble fails a test rather than silently shipping a
half-reset result.
"""
for name in COMPLETION_FLAG_NAMES:
setattr(result, name, False)
def _fetch_pr_context(
cfg: Any,
item: dict[str, Any],
*,
include_active_reviews: bool,
) -> ImplementerPrefetchResult:
"""Shared pr_fix / request_changes_pr fetcher. The
``include_active_reviews`` flag toggles the
:func:`_review_fetch.fetch_existing_reviews` call so the failing-
CI bucket doesn't pay a paginated review fetch it doesn't use.
Every Forgejo round-trip is best-effort — failures are recorded in
:attr:`ImplementerPrefetchResult.error_kinds` and the relevant
section's ``*_completed`` flag flips. Aggregate
:attr:`ImplementerPrefetchResult.data_complete` reflects the
AND of every section's completion signal so a future
post_session_action can short-circuit on partial context.
"""
result = ImplementerPrefetchResult()
# Preamble: every ``*_completed`` flag starts ``False`` BEFORE
# any potential early-exit (invalid PR number, dry-run, fetch
# failure). The flag drives the worker-side three-case
# contract: False → empty stdout → worker falls through to
# legacy GET; True → either ``[]\n`` / ``null\n`` (authoritative
# empty) or content. See :func:`_init_completion_flags`.
#
# ``pr_fix`` work NEVER attempts ``issue_body`` /
# ``issue_comments`` / ``request_changes_reviews``; those
# flags STAY False through to the sentinel.
# ``request_changes_pr`` additionally attempts
# ``request_changes_reviews`` and flips its flag below.
_init_completion_flags(result)
if cfg.dry_run:
return result
pr_number = int(item.get("number") or 0)
if pr_number <= 0:
result.data_complete = False
result.error_kinds.append("invalid-pr-number")
return result
# PR details — head_sha lives here, used by both Phase 2 diff
# fetch AND Phase 3 pre-clone to keep them aligned.
pr_details = _review_fetch.fetch_pr_details(cfg, pr_number)
result.pr_details = pr_details
if pr_details is None:
# ``pr_details_completed`` is already False (set by the
# preamble); leave it False. Any downstream flag (diff,
# ci_*, pr_comments) also stays False because we early-
# exit before reaching those fetches.
result.data_complete = False
result.error_kinds.append("pr_details:fetch-failed")
return result
result.pr_details_completed = True
head = pr_details.get("head") if isinstance(pr_details.get("head"), dict) else {}
result.head_sha = str(head.get("sha") or item.get("head_sha") or "")
# Diff
diff_text, diff_truncated, diff_error, diff_info = (
_pr_diff.fetch_pr_diff_detailed(cfg, pr_number)
)
result.diff_text = diff_text
result.diff_truncated = diff_truncated
result.diff_info = diff_info
if not diff_text:
result.diff_unavailable = True
# ``diff_completed`` is already False (preamble); leave it.
result.data_complete = False
result.error_kinds.append(
f"diff:{diff_error or 'no-diff-returned'}"
)
else:
# Truncation does NOT flip ``diff_completed`` — a truncated
# diff is still authoritative (just hard-capped). It DOES
# flip ``data_complete`` so the cycle archive flags partial
# context.
result.diff_completed = True
if diff_truncated:
result.data_complete = False
# CI combined status
ci_status = _review_fetch.fetch_ci_status(cfg, result.head_sha)
result.ci_status = ci_status
if ci_status is None:
# ``ci_status_completed`` is already False (preamble).
result.data_complete = False
result.error_kinds.append("ci_status:fetch-failed")
else:
result.ci_status_completed = True
# Per-check detail (only when overall != success — the per-check
# endpoint is verbose and would burn quota for green PRs).
if isinstance(ci_status, dict):
overall_state = ci_status.get("state")
if overall_state == "success":
# Green PR: no failing-check detail to fetch. The empty
# ``ci_detail`` list IS authoritative; flip the flag
# True so the worker reads "0 failing checks" rather
# than "fetch failed; retry".
result.ci_detail_completed = True
elif overall_state:
ci_detail, ci_detail_completed = _review_fetch.fetch_ci_check_detail(
cfg, result.head_sha
)
result.ci_detail = ci_detail
result.ci_detail_completed = ci_detail_completed
if not ci_detail_completed:
result.data_complete = False
result.error_kinds.append("ci_detail:partial")
# Pre-fetch failing-job log tails so the worker sees the
# EXACT failing assertion without burning turns on file
# reads + a 10-minute local gate run. Cached per-head_sha
# so the second cycle on the same SHA is free. Failure
# to fetch must NEVER break prompt assembly — WARN and
# carry on; the worker still has ``ci_detail`` +
# ``target_url`` to follow manually.
try:
ci_failure_logs, ci_failure_logs_completed = (
_ci_logs.fetch_pr_failure_logs(
cfg, result.head_sha, ci_detail=ci_detail,
)
)
result.ci_failure_logs = ci_failure_logs
result.ci_failure_logs_completed = ci_failure_logs_completed
if not ci_failure_logs_completed:
result.data_complete = False
result.error_kinds.append("ci_failure_logs:partial")
except (OSError, ValueError) as exc:
# Transport / JSON-parse failure — substrate is
# best-effort. Mark partial + carry on; programmer
# errors propagate to the test suite.
result.ci_failure_logs = None
result.ci_failure_logs_completed = False
result.data_complete = False
result.error_kinds.append(
f"ci_failure_logs:{type(exc).__name__}"
)
# PR comments (issue-style) — use the persistent cache so we
# delta-fetch only new comments instead of paginating from
# page 1 every cycle. Falls back to a full fetch when the
# cache is missing, stale, or disabled.
pr_comments, pr_comments_completed = _pr_comments_cache.get_pr_comments(
cfg, pr_number
)
result.pr_comments = pr_comments
result.pr_comments_completed = pr_comments_completed
# Bounded carry + digest derived from the full list. The full list
# stays on ``result.pr_comments`` (and in the cache); the prompt /
# sentinel consumers read ``pr_comments_view`` + ``pr_comments_digest``.
result.pr_comments_view = _bounded_comment_view(
pr_comments, DEFAULT_MAX_PROMPT_COMMENTS
)
result.pr_comments_digest = _attempt_history.summarize_attempt_history(
pr_comments
).to_dict()
# Filter-summary stamp: how many bot status/claim/release/sentinel
# comments the cache dropped at write time. Empty dict when the
# cache module has no summary (older cache file pre-dating the
# filter feature, OR filter disabled via env). Best-effort —
# surfaces in the prompt section header for operator visibility.
summary = _pr_comments_cache.get_filter_summary(pr_number)
if isinstance(summary, dict):
result.pr_comments_filter_summary = summary
if not pr_comments_completed:
result.data_complete = False
result.error_kinds.append("pr_comments:partial")
# Reviews — fetched once, partitioned into:
# - ``request_changes_reviews``: active REQUEST_CHANGES (blocking,
# must be addressed before push)
# - ``comment_reviews``: every other non-dismissed review
# (COMMENT / APPROVE) — advisory feedback the implementer
# should consider but is not bound to address.
# Both populated whenever ``include_active_reviews`` is True; the
# flag is now misnamed but kept for backward-compat (R3.4,
# 2026-05-17 — pre-this the failing_ci_pr path never fetched
# reviews at all, leaving the implementer blind to the
# reviewer's COMMENT-only feedback the dispatcher's
# ``data_complete=False`` downgrade produces in low-context
# cycles).
if include_active_reviews:
reviews, reviews_completed = _review_fetch.fetch_existing_reviews(
cfg, pr_number
)
active_rc: list[dict[str, Any]] = []
other: list[dict[str, Any]] = []
for r in reviews:
if not isinstance(r, dict):
continue
if _is_active_request_changes_review(r):
active_rc.append(r)
continue
# Drop dismissed reviews; keep COMMENT + APPROVE
# (and any other non-dismissed event) so the implementer
# sees the full reviewer record on this PR. Dismissal
# is signalled via the ``dismissed`` boolean field, not
# via the ``state`` enum — matches the predicate
# ``_is_active_request_changes_review`` uses below.
if bool(r.get("dismissed")):
continue
other.append(r)
result.request_changes_reviews = active_rc
result.request_changes_reviews_completed = reviews_completed
result.comment_reviews = other
result.comment_reviews_completed = reviews_completed
if not reviews_completed:
result.data_complete = False
result.error_kinds.append("reviews:partial")
# Linked issues + Epic
body_for_links = pr_details.get("body") if isinstance(pr_details, dict) else None
_resolve_links_and_epic(cfg, result, body_for_links)
# Register every section as a block in the cross-process block
# store. Best-effort; the prompt's inline content is still the
# primary source if any registration fails.
result.block_refs.extend(_prefetch_section.register_sections(
_implementer_sections(result, diff_text=result.diff_text),
pr_number=pr_number,
head_sha=result.head_sha,
))
return result
def _is_active_request_changes_review(review: dict[str, Any]) -> bool:
"""True iff ``review`` is an active (non-stale, non-dismissed)
REQUEST_CHANGES verdict. Mirrors
:func:`_review_fetch.count_active_request_changes`'s predicate
but exposed here so we can filter the per-review list, not just
count it."""
if not isinstance(review, dict):
return False
state = str(review.get("state") or "").upper()
if state != "REQUEST_CHANGES":
return False
if bool(review.get("dismissed")):
return False
return not bool(review.get("stale"))
def fetch_new_issue_context(
cfg: Any, item: dict[str, Any]
) -> ImplementerPrefetchResult:
"""Pre-dispatch fetch for the ``new_issue`` work group.
No PR exists yet — there is no diff, no CI, no reviews. Pulls:
- The issue body + paginated comments (the worker reads both to
derive the implementation contract).
- Any linked issue bodies referenced from the issue body (a
typical pattern: parent epic links via ``Parent: #N`` and
sibling issues via ``See: #N``).
- The Epic body when the issue references one.
head_sha stays empty — the worker will create the branch itself.
Sections this work_type does NOT attempt (``pr_details`` and
everything derived from it: description, title, diff, CI,
pr_comments, request_changes_reviews) carry ``*_completed=False``
in the result so the worker-side three-case contract correctly
signals "not attempted; fall through to legacy GET" instead of
"fetched and confirmed empty" for those fields. See
:func:`_init_completion_flags` for the preamble + success-flip
pattern.
"""
result = ImplementerPrefetchResult()
# Preamble: every ``*_completed`` flag starts ``False`` BEFORE
# any potential early-exit (invalid issue number, dry-run,
# fetch failure). See :func:`_init_completion_flags`.
#
# ``new_issue`` work NEVER attempts PR-shaped sections
# (``pr_details`` and everything derived from it: ``title``,
# ``description``, ``diff``, ``ci_*``, ``pr_comments``,
# ``request_changes_reviews``); those flags STAY False
# through to the sentinel. The fetcher below flips
# ``issue_body`` / ``issue_comments`` / ``linked_issues`` /
# ``epic`` to True on successful fetch.
_init_completion_flags(result)
if cfg.dry_run:
return result
issue_number = int(item.get("number") or 0)
if issue_number <= 0:
result.data_complete = False
result.error_kinds.append("invalid-issue-number")
return result
issue, issue_completed = _fetch_issue(cfg, issue_number)
if issue is None:
# ``issue_body_completed`` is already False (preamble).
result.data_complete = False
result.error_kinds.append(
"issue:fetch-failed" if not issue_completed else "issue:not-found"
)
return result
body = issue.get("body") if isinstance(issue, dict) else None
result.issue_body = str(body or "")
# Successful fetch — even if ``body`` is empty, the empty value
# is authoritative.
result.issue_body_completed = True
# Issue comments — same paginated path as PR issue-style comments
# because Forgejo treats issues and PRs as one conversation
# endpoint under ``/issues/{N}/comments``. Use the persistent
# cache (C5, 2026-05-13) so we only fetch the delta.
issue_comments, issue_comments_completed = _pr_comments_cache.get_pr_comments(
cfg, issue_number
)
result.issue_comments = issue_comments
result.issue_comments_completed = issue_comments_completed
if not issue_comments_completed:
result.data_complete = False
result.error_kinds.append("issue_comments:partial")
_resolve_links_and_epic(cfg, result, result.issue_body)
# Register every section as a block in the cross-process block
# store. Best-effort; the prompt's inline content is still the
# primary source if any registration fails.
result.block_refs.extend(_prefetch_section.register_sections(
_implementer_sections(result, diff_text=None),
pr_number=None,
head_sha=result.head_sha,
))
return result
def _resolve_links_and_epic(
cfg: Any,
result: ImplementerPrefetchResult,
body_for_links: str | None,
) -> None:
"""Resolve ``Closes #N`` / ``Fixes #N`` / ``Refs #N`` references
in ``body_for_links`` plus the optional ``Epic: #N`` /
``Parent: #N`` reference, mutating ``result`` in-place.
We treat the Epic and the linked-issue list as separate buckets
in the prompt:
- The Epic gets its own section so the worker has the parent
tracking issue's intent (acceptance criteria, parent-Epic
milestone) front-and-center.
- The linked issues get a list section so cross-reference
bodies (e.g. an upstream bug report the PR fixes) are
available without poisoning the Epic's role.
De-duplication: if the same issue number appears as both a
linked-issue reference AND an Epic reference, we render it as
Epic only (the role is more specific). This avoids a confusing
prompt where the same body appears twice.
"""
linked_issues, linked_completed = _review_fetch.fetch_linked_issues(
cfg, body_for_links, max_issues=DEFAULT_MAX_LINKED_ISSUES
)
epic_number = parse_epic_reference(body_for_links)
if epic_number is None:
# No Epic referenced at all — authoritative "no Epic"
# answer. Flip the flag True so the worker's
# ``--field epic`` read emits ``null\n`` (authoritative)
# rather than empty stdout (fall through).
result.epic_completed = True
else:
# The linked-issue resolver may already have fetched the same
# number via a `Closes #N` reference. Rather than refetching,
# promote it from the linked list to the epic slot.
promoted: dict[str, Any] | None = None
remaining: list[dict[str, Any]] = []
for entry in linked_issues:
if isinstance(entry, dict) and int(entry.get("number") or 0) == epic_number:
promoted = entry
else:
remaining.append(entry)
if promoted is not None:
result.epic_issue = promoted
linked_issues = remaining
result.epic_completed = True
else:
epic_body, epic_completed = _fetch_issue(cfg, epic_number)
result.epic_issue = epic_body
if not epic_completed:
# Already False from preamble; leave it.
result.data_complete = False
result.error_kinds.append("epic:fetch-failed")
elif epic_body is None:
# 4xx — Epic referenced but not retrievable. Don't
# poison data_complete; this is a worker-side data-
# quality concern, like the reviewer's broken-link
# path. The downstream prompt section will surface
# the broken reference explicitly. Tag this in
# error_kinds so an operator scanning Phase 4
# telemetry can distinguish "Epic linked but not
# accessible" (likely permissions / deleted) from
# "Epic body fetched fine".
result.epic_completed = True
result.error_kinds.append("epic:not-found")
else:
# Epic body fetched successfully.
result.epic_completed = True
result.linked_issues = linked_issues
result.linked_issues_completed = linked_completed
if not linked_completed:
result.data_complete = False
result.error_kinds.append("linked_issues:partial")
def _implementer_sections(
result: ImplementerPrefetchResult,
*,
diff_text: str | None,
) -> list[Any]:
"""Build the implementer's prefetched-section registry. Empty
entries are filtered by :func:`_prefetch_section.register_sections`.
"""
Section = _prefetch_section.PrefetchSection
json = _block_prompt.serialise_json_block
n_failing = 0
if isinstance(result.ci_failure_logs, dict):
failing = result.ci_failure_logs.get("failing_jobs")
if isinstance(failing, list):
n_failing = len(failing)
return [
Section(
"diff", diff_text or "",
f"PR diff (head_sha={(result.head_sha or '')[:12]})",
),
Section(
"pr_details",
json(result.pr_details) if isinstance(result.pr_details, dict) else "",
"PR metadata (title, body, head, base)",
),
Section(
"ci_status",
json(result.ci_status) if isinstance(result.ci_status, dict) else "",
"combined CI status",
),
Section(
"ci_detail",
json(result.ci_detail) if result.ci_detail else "",
f"{len(result.ci_detail)} per-check CI status rows",
),
Section(
"ci_failure_logs",
json(result.ci_failure_logs) if result.ci_failure_logs else "",
f"{n_failing} failing-CI job log tails",
),
Section(
"pr_comments",
json(result.pr_comments) if result.pr_comments else "",
f"{len(result.pr_comments)} PR conversation comments (full list)",
),
Section(
"request_changes_reviews",
(
json(result.request_changes_reviews)
if result.request_changes_reviews else ""
),
f"{len(result.request_changes_reviews)} active REQUEST_CHANGES reviews",
),
Section(
"issue_body", result.issue_body or "",
"issue body (raw text)",
),
Section(
"issue_comments",
json(result.issue_comments) if result.issue_comments else "",
f"{len(result.issue_comments)} issue comments",
),
Section(
"linked_issues",
json(result.linked_issues) if result.linked_issues else "",
f"{len(result.linked_issues)} linked issues",
),
Section(
"epic",
json(result.epic_issue) if result.epic_issue else "",
"parent Epic issue",
),
]
__all__ = (
"COMPLETION_FLAG_NAMES",
"DEFAULT_BODY_MAX_CHARS",
"DEFAULT_CI_LOG_TAIL_CHARS",
"DEFAULT_COMMENT_MAX_CHARS",
"DEFAULT_MAX_LINKED_ISSUES",
"DEFAULT_MAX_PROMPT_COMMENTS",
"ImplementerPrefetchResult",
"fetch_new_issue_context",
"fetch_pr_fix_context",
"fetch_request_changes_context",
"parse_epic_reference",
)