ba52135a66
Two workstreams in one commit: 1. Quality-gate environment bootstrap + new quality-gates skill (round 8). The Tier 0 task-implementor was reporting "Failed — nox not available" on every PR because the worker's /tmp throwaway clone had no Python tooling. tools/local_ci_gate.sh now self-bootstraps nox via a three- step resolution chain (system PATH → project venv → uvx fallback) and exits 2 with an actionable diagnostic if none resolves. New .opencode/skills/quality-gates/SKILL.md gives the implementer worker a deterministic recipe + troubleshooting appendix. task-implementor.md adds the matching bash allow-rules (bash tools/local_ci_gate.sh *, uvx --quiet nox *) plus the skill on its allowlist; step 5 of the procedure now references the skill. 9 new unit tests in test_local_ci_gate.py pin the three-step resolution chain, the bad-shape error paths, and the "system-nox failure does not fall through to uvx" invariant. Verified end-to-end against a fresh /tmp clone with PATH restricted to system + uvx: both lint and typecheck gates ran cleanly via uvx fallback. 2. Filesystem-handoff hardening rounds 6 and 7 (P1/P2 follow-ups from the iterative critique loop). Round 6 propagated the round-5 single-source-of-truth pattern to the sentinel writer (_to_dict overlays COMPLETION_FLAG_NAMES) and the worker reader (--field metadata introspects payload keys), added the bidirectional drift guard test (tuple ↔ dataclass set-equality), and corrected round-5 CHANGELOG wording. Round 7 added defence-in-depth: an assert-based schema-base collision guard in _to_dict, schema-lock tests pinning the writer's full output key set, writer-side typo guard, and a reader self-adapts test for unknown future flags. Test results: 1,170 passing / 3 skipped (+10 vs the round-5 baseline, +9 in this round). Lint: 11 pre-existing errors across the changed files, unchanged baseline (corrects the round-6/7 entries' aspirational "7 pre-existing" claim — empirically the _to_dict refactor cleaned up zero lints). Co-authored-by: Cursor <cursoragent@cursor.com>
703 lines
30 KiB
Python
703 lines
30 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 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")
|
|
_pr_diff = _load_sibling("_pr_diff", "_pr_diff.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 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",
|
|
"pr_comments_completed",
|
|
"request_changes_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
|
|
pr_comments: list[dict[str, Any]] = field(default_factory=list)
|
|
pr_comments_completed: bool = True
|
|
request_changes_reviews: list[dict[str, Any]] = field(default_factory=list)
|
|
request_changes_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)
|
|
|
|
|
|
# ─── 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
|
|
|
|
|
|
# ─── 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.
|
|
"""
|
|
return _fetch_pr_context(cfg, item, include_active_reviews=False)
|
|
|
|
|
|
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")
|
|
|
|
# PR comments (issue-style)
|
|
pr_comments, pr_comments_completed = _review_fetch.fetch_pr_comments(
|
|
cfg, pr_number
|
|
)
|
|
result.pr_comments = pr_comments
|
|
result.pr_comments_completed = pr_comments_completed
|
|
if not pr_comments_completed:
|
|
result.data_complete = False
|
|
result.error_kinds.append("pr_comments:partial")
|
|
|
|
# Active REQUEST_CHANGES reviews (request_changes_pr only)
|
|
if include_active_reviews:
|
|
reviews, reviews_completed = _review_fetch.fetch_existing_reviews(
|
|
cfg, pr_number
|
|
)
|
|
# Filter to active REQUEST_CHANGES — those are the blocking
|
|
# ones the worker must address. Approved / commented / dismissed
|
|
# reviews don't drive the fix loop.
|
|
active = [
|
|
r for r in reviews if _is_active_request_changes_review(r)
|
|
]
|
|
result.request_changes_reviews = active
|
|
result.request_changes_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)
|
|
|
|
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``.
|
|
issue_comments, issue_comments_completed = _review_fetch.fetch_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)
|
|
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")
|
|
|
|
|
|
__all__ = (
|
|
"COMPLETION_FLAG_NAMES",
|
|
"DEFAULT_BODY_MAX_CHARS",
|
|
"DEFAULT_CI_LOG_TAIL_CHARS",
|
|
"DEFAULT_COMMENT_MAX_CHARS",
|
|
"DEFAULT_MAX_LINKED_ISSUES",
|
|
"ImplementerPrefetchResult",
|
|
"fetch_new_issue_context",
|
|
"fetch_pr_fix_context",
|
|
"fetch_request_changes_context",
|
|
"parse_epic_reference",
|
|
)
|