3f12e4140c
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>
1057 lines
46 KiB
Python
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",
|
|
)
|