0bc734c020
Applies `ruff format` to the accumulated formatting debt on this branch. Formatting-only — no behavioral changes. Required for CI/lint's format gate (`nox -s format -- --check`), which the branch was failing on 288 tracked files that drifted from ruff's canonical style. In-progress WIP files are intentionally excluded so this commit stays a clean formatting-only diff. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1011 lines
40 KiB
Python
1011 lines
40 KiB
Python
"""Prompt assembly for the deterministic implementer dispatcher.
|
|
|
|
Mirrors :mod:`_review_prompt` on the implementer side. Renders the
|
|
pre-fetched context from :mod:`_implementer_prefetch` into a single
|
|
worker prompt with every third-party body wrapped in
|
|
``UNTRUSTED CONTENT`` fences (see :mod:`_pr_prompt` for the marker
|
|
contract — closing-marker redaction prevents a forged fence in a
|
|
commit message from escaping).
|
|
|
|
Three per-work-group prompt builders:
|
|
|
|
- :func:`build_pr_fix_prompt` — failing-CI PR.
|
|
- :func:`build_request_changes_prompt`— PR with active REQUEST_CHANGES.
|
|
- :func:`build_new_issue_prompt` — new issue implementation.
|
|
|
|
Each builds on the same minimal-prompt scaffold the legacy
|
|
``_implementation_prompt`` produced (forgejo_url / owner / repo /
|
|
work_type / work_number / work_title / claim note / PR Compliance
|
|
Checklist / output JSON contract) so an operator switching
|
|
``IMPLEMENTER_DISPATCHER_PREFETCH=0`` ↔ ``=1`` does NOT see the worker
|
|
suddenly miss any of the rules already documented for the legacy
|
|
mode.
|
|
|
|
What the pre-fetch adds on top of the legacy prompt:
|
|
|
|
- ``## Pre-fetched PR description`` / ``## Pre-fetched issue body``
|
|
— the body of the PR / issue, fenced.
|
|
- ``## Pre-fetched diff`` (pr_fix / request_changes_pr only) —
|
|
reuses :func:`_pr_diff.build_diff_section_full`, same renderer
|
|
the reviewer uses; the worker is told to read it instead of
|
|
cloning.
|
|
- ``## Pre-fetched CI status`` (pr_fix / request_changes_pr) —
|
|
combined status + per-check detail when overall != success.
|
|
- ``## Pre-fetched PR comments`` (pr_fix / request_changes_pr) /
|
|
``## Pre-fetched issue comments`` (new_issue) — paginated.
|
|
- ``## Pre-fetched active REQUEST_CHANGES reviews``
|
|
(request_changes_pr only) — non-dismissed, non-stale, with their
|
|
inline review comments.
|
|
- ``## Pre-fetched linked issues`` — Closes / Fixes / Refs targets.
|
|
- ``## Pre-fetched Epic`` — when the body has ``Epic: #N`` /
|
|
``Parent: #N``.
|
|
- ``## Pre-cloned working copy`` (Phase 3) — gated on a non-None
|
|
clone handle. Falls through to the no-handle stanza otherwise.
|
|
|
|
The output-JSON contract (``{"outcome": ..., "files_touched": [...]}``)
|
|
is preserved verbatim from the legacy prompt — the post-session
|
|
contract has not changed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
_TOOLS_DIR = str(Path(__file__).resolve().parent)
|
|
if _TOOLS_DIR not in sys.path:
|
|
sys.path.insert(0, _TOOLS_DIR)
|
|
from _loader import ( # noqa: E402 type: ignore[import-not-found]
|
|
load_sibling as _load_sibling,
|
|
)
|
|
|
|
_pr_diff = _load_sibling("_pr_diff", "_pr_diff.py")
|
|
_pr_prompt = _load_sibling("_pr_prompt", "_pr_prompt.py")
|
|
_implementer_prefetch = _load_sibling(
|
|
"_implementer_prefetch", "_implementer_prefetch.py"
|
|
)
|
|
_block_prompt = _load_sibling("_block_prompt", "_block_prompt.py")
|
|
|
|
|
|
# ─── Output-JSON contract (preserved from legacy _implementation_prompt) ─────
|
|
|
|
|
|
OUTPUT_CONTRACT = (
|
|
"When the implementation work is complete, include exactly one JSON object in\n"
|
|
"your final response:\n"
|
|
'{"outcome": "resolved", "files_touched": ["path/changed"]}\n'
|
|
"\n"
|
|
"If you cannot complete the implementation because of an unrecoverable setup,\n"
|
|
"API, or repository problem, include:\n"
|
|
'{"outcome": "rebase-failed", "files_touched": []}'
|
|
)
|
|
|
|
|
|
# ─── PR Compliance Checklist (preserved from legacy prompt) ─────────────────
|
|
|
|
|
|
# Public name (intentionally not name-mangled): the implementer
|
|
# dispatcher embeds this string in its task-prompt scaffolding so
|
|
# the worker session sees the same compliance checklist in either
|
|
# the prefetch-on or prefetch-off branch. Re-exported via
|
|
# ``__all__`` so re-typing the same Markdown in two places (and
|
|
# drifting them apart) is impossible.
|
|
PR_COMPLIANCE_CHECKLIST = (
|
|
"PR Compliance Checklist "
|
|
"(MANDATORY - complete ALL items before creating a PR):\n"
|
|
"[ ] 1. CHANGELOG.md — add entry under [Unreleased] section\n"
|
|
"[ ] 2. CONTRIBUTORS.md — add or update contribution entry\n"
|
|
"[ ] 3. Commit footer — include `ISSUES CLOSED: #<issue-number>` in the "
|
|
"commit message\n"
|
|
"[ ] 4. CI passes — all quality gates and tests green before requesting "
|
|
"review\n"
|
|
"[ ] 5. BDD/Behave tests — added or updated for the changed behaviour\n"
|
|
"[ ] 6. Epic reference — PR description references the parent Epic issue "
|
|
"number\n"
|
|
"[ ] 7. Labels — applied via forgejo-label-manager: State/In Review, "
|
|
"Priority/<level>, MoSCoW/<level>, Type/<type>\n"
|
|
"[ ] 8. Milestone — PR assigned to the earliest open milestone matching "
|
|
"the issue"
|
|
)
|
|
|
|
|
|
# ─── Body truncation helper ─────────────────────────────────────────────────
|
|
|
|
|
|
def _truncate(body: str | None, max_chars: int) -> tuple[str, bool]:
|
|
"""Truncate ``body`` at the last newline boundary inside
|
|
``max_chars`` and append a ``[... N more chars truncated ...]``
|
|
marker. Returns ``(text, truncated_flag)``.
|
|
|
|
The newline alignment matches :func:`_pr_diff.fetch_pr_diff_detailed`
|
|
so a paragraph never gets cut mid-line. Empty input returns the
|
|
empty string with ``truncated=False``.
|
|
"""
|
|
if not body:
|
|
return "", False
|
|
if len(body) <= max_chars:
|
|
return body, False
|
|
cut = body.rfind("\n", 0, max_chars)
|
|
if cut < 0:
|
|
cut = max_chars
|
|
chars_dropped = len(body) - cut
|
|
head = body[:cut].rstrip()
|
|
return (
|
|
f"{head}\n\n[... {chars_dropped} more chars truncated; fetch the "
|
|
f"full body via the Forgejo API if critical ...]"
|
|
), True
|
|
|
|
|
|
# ─── Section builders ───────────────────────────────────────────────────────
|
|
|
|
|
|
def _section_unavailable(title: str, reason: str) -> str:
|
|
"""Render a placeholder when a section's pre-fetch failed.
|
|
|
|
The worker reads the explicit "X unavailable" stanza and decides
|
|
whether the missing data is critical to the fix. We do NOT
|
|
silently omit the section; an empty stanza would be ambiguous
|
|
(was it not fetched, or did the fetch return nothing?).
|
|
|
|
Recovery guidance is intentionally cautious: in-session HTTP
|
|
(``curl`` / ``webfetch``) is a **last resort** because Phase 4
|
|
telemetry treats ``webfetch_call_count > 0`` as a regression
|
|
against the prefetch substrate. Prefer reading from the
|
|
pre-cloned worktree (when one is provided in this prompt) or
|
|
deferring the fix until a later cycle re-prefetches the data.
|
|
"""
|
|
return f"""## {title} unavailable
|
|
|
|
The dispatcher attempted to pre-fetch this section but it was not
|
|
available ({reason}). Prefer reading the pre-cloned worktree (when
|
|
this prompt provides one) or, if the missing data is not critical,
|
|
proceed with the fix and call out the gap in the PR description.
|
|
Only as a last resort, fall back to in-session HTTP (``curl`` /
|
|
``webfetch``) — Phase 4 telemetry treats those calls as a
|
|
regression against the prefetch substrate."""
|
|
|
|
|
|
def _build_pr_description_section(pr_details: dict[str, Any] | None) -> str:
|
|
if not isinstance(pr_details, dict):
|
|
return _section_unavailable("Pre-fetched PR description", "no PR details")
|
|
body = str(pr_details.get("body") or "")
|
|
text, truncated = _truncate(body, _implementer_prefetch.DEFAULT_BODY_MAX_CHARS)
|
|
return _pr_prompt.wrap_untrusted_section(
|
|
"Pre-fetched PR description",
|
|
"pr_description",
|
|
text,
|
|
attrs={
|
|
"title": json.dumps(str(pr_details.get("title") or "")),
|
|
"state": str(pr_details.get("state") or ""),
|
|
"truncated": str(truncated).lower(),
|
|
},
|
|
)
|
|
|
|
|
|
def _build_issue_body_section(issue_body: str, item_title: str) -> str:
|
|
if not issue_body:
|
|
return _section_unavailable(
|
|
"Pre-fetched issue body", "issue body empty or fetch failed"
|
|
)
|
|
text, truncated = _truncate(
|
|
issue_body, _implementer_prefetch.DEFAULT_BODY_MAX_CHARS
|
|
)
|
|
return _pr_prompt.wrap_untrusted_section(
|
|
"Pre-fetched issue body",
|
|
"issue_body",
|
|
text,
|
|
attrs={
|
|
"title": json.dumps(item_title),
|
|
"truncated": str(truncated).lower(),
|
|
},
|
|
)
|
|
|
|
|
|
def _build_ci_status_section(ci_status: dict[str, Any] | None, completed: bool) -> str:
|
|
if ci_status is None or not isinstance(ci_status, dict):
|
|
return _section_unavailable(
|
|
"Pre-fetched CI status",
|
|
"fetch failed" if not completed else "no CI status reported",
|
|
)
|
|
state = str(ci_status.get("state") or "unknown")
|
|
statuses = ci_status.get("statuses") or []
|
|
summary_lines = [f"overall_state: {state}"]
|
|
if isinstance(statuses, list):
|
|
for s in statuses[:20]:
|
|
if not isinstance(s, dict):
|
|
continue
|
|
ctx = str(s.get("context") or "")
|
|
st = str(s.get("state") or "")
|
|
url = str(s.get("target_url") or "")
|
|
summary_lines.append(
|
|
f"- [{st}] {ctx} ({url})" if url else f"- [{st}] {ctx}"
|
|
)
|
|
return _pr_prompt.wrap_untrusted_section(
|
|
"Pre-fetched CI status",
|
|
"ci_status",
|
|
"\n".join(summary_lines),
|
|
attrs={"state": state, "completed": str(completed).lower()},
|
|
)
|
|
|
|
|
|
def _build_ci_detail_section(ci_detail: list[dict[str, Any]], completed: bool) -> str:
|
|
if not ci_detail:
|
|
return ""
|
|
lines: list[str] = []
|
|
for entry in ci_detail[:20]:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
ctx = str(entry.get("context") or "")
|
|
st = str(entry.get("state") or "")
|
|
url = str(entry.get("target_url") or "")
|
|
desc = str(entry.get("description") or "")
|
|
head = f"- [{st}] {ctx}"
|
|
if url:
|
|
head += f" ({url})"
|
|
if desc:
|
|
head += f" — {desc}"
|
|
lines.append(head)
|
|
if not lines:
|
|
return ""
|
|
return _pr_prompt.wrap_untrusted_section(
|
|
"Pre-fetched CI per-check detail",
|
|
"ci_detail",
|
|
"\n".join(lines),
|
|
attrs={"completed": str(completed).lower()},
|
|
postscript=(
|
|
"Each per-check entry's ``target_url`` points at the CI "
|
|
"log. For failing checks the dispatcher pre-fetches the "
|
|
"log tail and inlines it in the ``## Pre-fetched CI "
|
|
"failure logs`` section below — read that FIRST. Only "
|
|
"follow ``target_url`` manually when the failure logs "
|
|
"section has ``fetch_error`` for the entry you care "
|
|
"about."
|
|
),
|
|
)
|
|
|
|
|
|
def _build_ci_failure_logs_section(
|
|
payload: dict[str, Any] | None, completed: bool
|
|
) -> str:
|
|
"""Render the pre-fetched failing-CI log tails into the
|
|
implementer prompt. Mirrors the reviewer-side section, but
|
|
formatted as text blocks rather than embedded JSON so the
|
|
implementer reads it as a triage feed.
|
|
|
|
Empty render when ``payload`` is None (CI status was 'success'
|
|
or pre-fetch was skipped) — the dropping ``\\n\\n``-join in the
|
|
caller deals with that cleanly.
|
|
"""
|
|
if not payload:
|
|
return ""
|
|
failing = payload.get("failing_jobs") or []
|
|
if not failing:
|
|
return ""
|
|
blocks: list[str] = []
|
|
for j in failing:
|
|
if not isinstance(j, dict):
|
|
continue
|
|
ctx = str(j.get("context") or "?")
|
|
state = str(j.get("state") or "?")
|
|
url = str(j.get("log_url") or "")
|
|
err = j.get("fetch_error")
|
|
log_tail = str(j.get("log_tail") or "").rstrip()
|
|
truncated = bool(j.get("log_truncated"))
|
|
bytes_seen = j.get("log_bytes_seen")
|
|
header = f"### [{state}] {ctx}"
|
|
if url:
|
|
header += f"\nfull log: {url}"
|
|
if err:
|
|
body = f"_log unavailable_: ``{err}``"
|
|
elif not log_tail:
|
|
body = "_log was empty_"
|
|
else:
|
|
meta = []
|
|
if bytes_seen is not None:
|
|
meta.append(f"bytes_seen={bytes_seen}")
|
|
if truncated:
|
|
meta.append("truncated=true")
|
|
meta_line = ("(" + ", ".join(meta) + ")\n") if meta else ""
|
|
body = f"{meta_line}```\n{log_tail}\n```"
|
|
blocks.append(f"{header}\n\n{body}")
|
|
if not blocks:
|
|
return ""
|
|
return _pr_prompt.wrap_untrusted_section(
|
|
"Pre-fetched CI failure logs",
|
|
"ci_failure_logs",
|
|
"\n\n".join(blocks),
|
|
attrs={"completed": str(completed).lower(), "count": str(len(blocks))},
|
|
postscript=(
|
|
"Each block is the LAST N chars of the corresponding "
|
|
"failing job's CI log (failing assertions live at the "
|
|
"tail). When ``fetch_error`` is set, follow ``full log`` "
|
|
"in a browser if needed. Read these BEFORE invoking "
|
|
"``ci_run_local_gate`` — most fixes are obvious from the "
|
|
"log tail and don't require a full local nox run "
|
|
"(which can take 10+ minutes for ``coverage_report``)."
|
|
),
|
|
)
|
|
|
|
|
|
def _comments_digest_preamble(digest: dict[str, Any] | None) -> str:
|
|
"""One-paragraph attempt-history rollup rendered above the verbatim
|
|
comments. Older bot ``**Implementation Attempt**`` comments are
|
|
summarised here rather than embedded in full — only the most-recent
|
|
window plus any human/reviewer comments reach the section body (see
|
|
``_implementer_prefetch._bounded_comment_view``). Returns "" when
|
|
there is no digest, so the ``\\n\\n``-join in the prompt builder
|
|
drops it cleanly."""
|
|
rendered = str((digest or {}).get("rendered") or "").strip()
|
|
if not rendered:
|
|
return ""
|
|
return (
|
|
"Attempt-history digest (older bot attempt comments are "
|
|
"summarised here, not listed verbatim below):\n\n" + rendered
|
|
)
|
|
|
|
|
|
def _build_comments_section(
|
|
title: str,
|
|
section_name: str,
|
|
comments: list[dict[str, Any]],
|
|
completed: bool,
|
|
digest: dict[str, Any] | None = None,
|
|
filter_summary: dict[str, Any] | None = None,
|
|
) -> str:
|
|
"""Render a fenced comments section.
|
|
|
|
``comments`` is the list embedded verbatim. When ``digest`` is
|
|
given the caller has already bounded that list (most-recent N +
|
|
humans) and the digest's one-paragraph summary of the older bot
|
|
attempt comments is rendered as a preamble.
|
|
|
|
``filter_summary`` (when set) is the cache's bot-filter rollup
|
|
(count + by-author). When non-zero, the section header attrs and
|
|
preamble surface "N bot status comments filtered" so the worker
|
|
knows there's a lower bound on raw activity that the cache
|
|
deliberately dropped.
|
|
"""
|
|
preamble = _comments_digest_preamble(digest)
|
|
filter_note = _comments_filter_note(filter_summary)
|
|
if filter_note:
|
|
preamble = f"{preamble}\n\n{filter_note}" if preamble else filter_note
|
|
total = int((digest or {}).get("total_comments") or len(comments))
|
|
filter_attrs = {}
|
|
if isinstance(filter_summary, dict) and int(filter_summary.get("count") or 0) > 0:
|
|
filter_attrs["filtered_bots"] = str(int(filter_summary["count"]))
|
|
if not comments:
|
|
if completed:
|
|
return _pr_prompt.wrap_untrusted_section(
|
|
title,
|
|
section_name,
|
|
"(no comments)",
|
|
attrs={"count": str(total), "completed": "true", **filter_attrs},
|
|
preamble=preamble,
|
|
)
|
|
return _section_unavailable(title, "comment pagination partial")
|
|
cap = _implementer_prefetch.DEFAULT_MAX_PROMPT_COMMENTS
|
|
# A digest (even an empty one) means the caller passed a
|
|
# pre-bounded view (humans kept beyond the recent-N window) —
|
|
# render it whole so those early human comments are not re-sliced
|
|
# away. ``digest is None`` is the issue-comments path: the list is
|
|
# unbounded, so take the most recent N here.
|
|
to_render = comments if digest is not None else comments[-cap:]
|
|
rendered: list[str] = []
|
|
for entry in to_render:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
author = ""
|
|
user = entry.get("user") if isinstance(entry.get("user"), dict) else {}
|
|
if isinstance(user, dict):
|
|
author = str(user.get("login") or "")
|
|
body, _truncated = _truncate(
|
|
str(entry.get("body") or ""),
|
|
_implementer_prefetch.DEFAULT_COMMENT_MAX_CHARS,
|
|
)
|
|
created = str(entry.get("created_at") or "")
|
|
rendered.append(f"### @{author or 'anonymous'} at {created}\n\n{body}")
|
|
return _pr_prompt.wrap_untrusted_section(
|
|
title,
|
|
section_name,
|
|
"\n\n---\n\n".join(rendered),
|
|
attrs={
|
|
"count": str(total),
|
|
"shown": str(len(rendered)),
|
|
"completed": str(completed).lower(),
|
|
**filter_attrs,
|
|
},
|
|
preamble=preamble,
|
|
)
|
|
|
|
|
|
def _comments_filter_note(summary: dict[str, Any] | None) -> str:
|
|
"""One-line operator-visible breakdown of the bot status comments
|
|
the comments cache dropped at write time. Returns ``""`` when
|
|
there's nothing to report (no filter, or zero filtered)."""
|
|
if not isinstance(summary, dict):
|
|
return ""
|
|
count = int(summary.get("count") or 0)
|
|
if count <= 0:
|
|
return ""
|
|
by_author = summary.get("by_author") or {}
|
|
# Legacy cache rows (pre-2026-05-17 fix) wrote ``null`` as the
|
|
# author key when the comment's user field was missing — render
|
|
# those as "unknown" instead of the literal string "None". Use an
|
|
# accumulator (not a dict comprehension) so a row with BOTH a
|
|
# legacy ``null`` key AND a fresh ``"unknown"`` key SUMS the
|
|
# counts rather than the later iteration silently dropping one.
|
|
normalized: dict[str, int] = {}
|
|
for login, n in by_author.items():
|
|
key = login if isinstance(login, str) and login else "unknown"
|
|
try:
|
|
value = int(n or 0)
|
|
except (TypeError, ValueError):
|
|
value = 0
|
|
normalized[key] = normalized.get(key, 0) + value
|
|
by_author_str = (
|
|
", ".join(f"{login}={n}" for login, n in sorted(normalized.items()))
|
|
or "anonymous bot"
|
|
)
|
|
return (
|
|
f"**Note:** {count} bot status/claim/release/sentinel comments "
|
|
f"were filtered from the cache at write time ({by_author_str}). "
|
|
"The bot's structured `**Implementation Attempt**` markers are "
|
|
"still in the cache and summarised below; the dropped comments "
|
|
"carried no signal the worker needs to act on."
|
|
)
|
|
|
|
|
|
def _build_active_reviews_section(
|
|
reviews: list[dict[str, Any]], completed: bool
|
|
) -> str:
|
|
if not reviews:
|
|
if completed:
|
|
return _pr_prompt.wrap_untrusted_section(
|
|
"Pre-fetched active REQUEST_CHANGES reviews",
|
|
"active_request_changes",
|
|
"(no active REQUEST_CHANGES reviews — none of the listed "
|
|
"PRs were claimed at fetch time)",
|
|
attrs={"count": "0", "completed": "true"},
|
|
)
|
|
return _section_unavailable(
|
|
"Pre-fetched active REQUEST_CHANGES reviews",
|
|
"review pagination partial",
|
|
)
|
|
rendered: list[str] = []
|
|
for review in reviews:
|
|
if not isinstance(review, dict):
|
|
continue
|
|
author = ""
|
|
user = review.get("user") if isinstance(review.get("user"), dict) else {}
|
|
if isinstance(user, dict):
|
|
author = str(user.get("login") or "")
|
|
commit_id = str(review.get("commit_id") or "")[:12]
|
|
submitted = str(review.get("submitted_at") or "")
|
|
body, _ = _truncate(
|
|
str(review.get("body") or ""),
|
|
_implementer_prefetch.DEFAULT_COMMENT_MAX_CHARS,
|
|
)
|
|
review_block = (
|
|
f"### REQUEST_CHANGES from @{author or 'anonymous'} "
|
|
f"at {submitted} (commit {commit_id})\n\n{body or '(no body)'}"
|
|
)
|
|
inline = review.get("comments") or []
|
|
if isinstance(inline, list) and inline:
|
|
review_block += "\n\nInline comments:"
|
|
for c in inline:
|
|
if not isinstance(c, dict):
|
|
continue
|
|
path = str(c.get("path") or "")
|
|
pos = c.get("new_position")
|
|
ic_body = str(c.get("body") or "")
|
|
review_block += f"\n- **{path}:{pos}** — {ic_body}"
|
|
rendered.append(review_block)
|
|
return _pr_prompt.wrap_untrusted_section(
|
|
"Pre-fetched active REQUEST_CHANGES reviews",
|
|
"active_request_changes",
|
|
"\n\n---\n\n".join(rendered),
|
|
attrs={"count": str(len(reviews)), "completed": str(completed).lower()},
|
|
postscript=(
|
|
"Every blocking concern above must be addressed before "
|
|
"you push. Partially addressing a REQUEST_CHANGES review "
|
|
"is grounds for the next reviewer cycle to re-block."
|
|
),
|
|
)
|
|
|
|
|
|
def _build_comment_reviews_section(
|
|
reviews: list[dict[str, Any]], completed: bool
|
|
) -> str:
|
|
"""Render non-RC review feedback (COMMENT + APPROVE) as advisory
|
|
context for the implementer.
|
|
|
|
Added R3.4 (2026-05-17). Before this, the implementer never saw
|
|
these — they're the reviewer's substantive prose feedback in
|
|
cycles where ``data_complete=False`` forced a COMMENT-only
|
|
downgrade (so they're NOT a REQUEST_CHANGES the implementer is
|
|
formally blocked on, but they ARE the reviewer's actual
|
|
observations about the PR). Treat as context, not as blocking
|
|
requirements — the REQUEST_CHANGES section above is what blocks.
|
|
"""
|
|
if not reviews:
|
|
if completed:
|
|
return _pr_prompt.wrap_untrusted_section(
|
|
"Pre-fetched reviewer comments and approvals",
|
|
"comment_reviews",
|
|
"(no COMMENT or APPROVE reviews on this PR yet)",
|
|
attrs={"count": "0", "completed": "true"},
|
|
)
|
|
return _section_unavailable(
|
|
"Pre-fetched reviewer comments and approvals",
|
|
"review pagination partial",
|
|
)
|
|
rendered: list[str] = []
|
|
for review in reviews:
|
|
if not isinstance(review, dict):
|
|
continue
|
|
author = ""
|
|
user = review.get("user") if isinstance(review.get("user"), dict) else {}
|
|
if isinstance(user, dict):
|
|
author = str(user.get("login") or "")
|
|
event = str(review.get("state") or review.get("event") or "COMMENT").upper()
|
|
commit_id = str(review.get("commit_id") or "")[:12]
|
|
submitted = str(review.get("submitted_at") or "")
|
|
body, _ = _truncate(
|
|
str(review.get("body") or ""),
|
|
_implementer_prefetch.DEFAULT_COMMENT_MAX_CHARS,
|
|
)
|
|
review_block = (
|
|
f"### {event} from @{author or 'anonymous'} "
|
|
f"at {submitted} (commit {commit_id})\n\n{body or '(no body)'}"
|
|
)
|
|
inline = review.get("comments") or []
|
|
if isinstance(inline, list) and inline:
|
|
review_block += "\n\nInline comments:"
|
|
for c in inline:
|
|
if not isinstance(c, dict):
|
|
continue
|
|
path = str(c.get("path") or "")
|
|
pos = c.get("new_position")
|
|
ic_body = str(c.get("body") or "")
|
|
review_block += f"\n- **{path}:{pos}** — {ic_body}"
|
|
rendered.append(review_block)
|
|
return _pr_prompt.wrap_untrusted_section(
|
|
"Pre-fetched reviewer comments and approvals",
|
|
"comment_reviews",
|
|
"\n\n---\n\n".join(rendered),
|
|
attrs={"count": str(len(reviews)), "completed": str(completed).lower()},
|
|
postscript=(
|
|
"These reviews are ADVISORY — they are not "
|
|
"REQUEST_CHANGES, so you are not formally blocked on "
|
|
"them. They are the reviewer's substantive observations "
|
|
"about your work. Consider them when implementing, "
|
|
"especially if multiple reviewers raise the same concern."
|
|
),
|
|
)
|
|
|
|
|
|
def _build_linked_issues_section(linked: list[dict[str, Any]], completed: bool) -> str:
|
|
if not linked:
|
|
if completed:
|
|
return ""
|
|
return _section_unavailable(
|
|
"Pre-fetched linked issues", "link pagination partial"
|
|
)
|
|
rendered: list[str] = []
|
|
for entry in linked:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
number = entry.get("number")
|
|
title = str(entry.get("title") or "")
|
|
state = str(entry.get("state") or "")
|
|
body, _ = _truncate(
|
|
str(entry.get("body") or ""),
|
|
_implementer_prefetch.DEFAULT_BODY_MAX_CHARS,
|
|
)
|
|
meta = entry.get("_review_pipeline_link")
|
|
unresolved_note = ""
|
|
if isinstance(meta, dict) and meta.get("unresolved"):
|
|
reason = str(meta.get("unresolved_reason") or "unknown")
|
|
unresolved_note = (
|
|
f" (UNRESOLVED — {reason}; treat the body below as absent)"
|
|
)
|
|
body_or_placeholder = body or "(no body)"
|
|
rendered.append(
|
|
f"### Issue #{number}: {title} "
|
|
f"[{state}]{unresolved_note}\n\n{body_or_placeholder}"
|
|
)
|
|
return _pr_prompt.wrap_untrusted_section(
|
|
"Pre-fetched linked issues",
|
|
"linked_issues",
|
|
"\n\n---\n\n".join(rendered),
|
|
attrs={"count": str(len(linked)), "completed": str(completed).lower()},
|
|
)
|
|
|
|
|
|
def _build_epic_section(epic: dict[str, Any] | None, completed: bool) -> str:
|
|
if epic is None:
|
|
if not completed:
|
|
return _section_unavailable(
|
|
"Pre-fetched Epic", "Epic reference present but fetch failed"
|
|
)
|
|
return ""
|
|
if not isinstance(epic, dict):
|
|
return ""
|
|
number = epic.get("number")
|
|
title = str(epic.get("title") or "")
|
|
state = str(epic.get("state") or "")
|
|
body, _ = _truncate(
|
|
str(epic.get("body") or ""),
|
|
_implementer_prefetch.DEFAULT_BODY_MAX_CHARS,
|
|
)
|
|
return _pr_prompt.wrap_untrusted_section(
|
|
"Pre-fetched Epic",
|
|
"epic",
|
|
f"### Epic #{number}: {title} [{state}]\n\n{body or '(no body)'}",
|
|
attrs={"number": str(number), "state": state},
|
|
postscript=(
|
|
"The Epic body above defines the parent acceptance criteria "
|
|
"for this work. Your PR description MUST include an `Epic: "
|
|
f"#{number}` reference so PR Compliance Checklist item 6 "
|
|
"passes."
|
|
),
|
|
)
|
|
|
|
|
|
def _build_diff_section_text(
|
|
*,
|
|
head_sha: str,
|
|
prefetch_diff_text: str,
|
|
prefetch_truncated: bool,
|
|
prefetch_unavailable: bool,
|
|
prefetch_info: dict[str, int],
|
|
) -> str:
|
|
"""Render the implementer's diff section by delegating to the
|
|
shared renderer in :mod:`_pr_diff`.
|
|
|
|
The reviewer side calls :func:`_pr_diff.build_diff_section_full`
|
|
which does its OWN HTTP fetch; the implementer already paid that
|
|
cost during prefetch, so we use
|
|
:func:`_pr_diff.render_diff_section_from_prefetch` with
|
|
``audience="fix"`` to drive the same fence shape from data we
|
|
already have. Single source of truth for the fence — drift
|
|
across dispatchers is structurally impossible.
|
|
"""
|
|
return _pr_diff.render_diff_section_from_prefetch(
|
|
head_sha=head_sha,
|
|
prefetch_diff_text=prefetch_diff_text,
|
|
prefetch_truncated=prefetch_truncated,
|
|
prefetch_unavailable=prefetch_unavailable,
|
|
prefetch_info=prefetch_info,
|
|
audience="fix",
|
|
)
|
|
|
|
|
|
# ─── Per-work-group prompt assembly ─────────────────────────────────────────
|
|
|
|
|
|
PR_CLAIM_NOTE = (
|
|
"The deterministic dispatcher already claimed "
|
|
"`auto/claimed-implementer` before starting this worker. If your "
|
|
"startup claim step sees the label already present, treat that as "
|
|
"success and continue normally. Still run your release step before "
|
|
"exiting; the dispatcher will also release in its finally block."
|
|
)
|
|
|
|
ISSUE_CLAIM_NOTE = (
|
|
"No PR exists yet for this issue work, so there is no "
|
|
"`auto/claimed-implementer` claim to acquire before dispatch."
|
|
)
|
|
|
|
|
|
def _resolve_worker_credentials(cfg: Any) -> dict[str, str]:
|
|
"""Return the credential triple the implementer worker needs in
|
|
its prompt: ``forgejo_pat``, ``git_user_name``, ``git_user_email``.
|
|
|
|
``forgejo_pat`` comes from ``cfg.token`` (already loaded via
|
|
:func:`_dispatch.load_secret` against ``FORGEJO_PAT`` /
|
|
``GITEA_TOKEN``). The two git identity values come from the
|
|
dispatcher's process environment, which is populated by
|
|
``tools/launch_fork.sh`` before the dispatcher launches.
|
|
|
|
Returns only the keys that have non-empty values. An empty dict is
|
|
the rendered "credentials section is empty — fall through to env
|
|
fallback" signal. We do NOT raise when values are missing because
|
|
the launch-time check belongs in the launcher, not the prompt
|
|
builder; the worker's ``Required?`` table catches the gap at
|
|
runtime with a clear error.
|
|
"""
|
|
import os as _os
|
|
|
|
out: dict[str, str] = {}
|
|
token = getattr(cfg, "token", "") or ""
|
|
if isinstance(token, str) and token:
|
|
out["forgejo_pat"] = token
|
|
git_user_name = _os.environ.get("GIT_USER_NAME", "")
|
|
if git_user_name:
|
|
out["git_user_name"] = git_user_name
|
|
git_user_email = _os.environ.get("GIT_USER_EMAIL", "")
|
|
if git_user_email:
|
|
out["git_user_email"] = git_user_email
|
|
return out
|
|
|
|
|
|
def render_worker_credentials_section(credentials: dict[str, str]) -> str:
|
|
"""Render the worker-credentials block embedded near the top of
|
|
every implementer worker prompt.
|
|
|
|
Returns an empty string when ``credentials`` is empty so the
|
|
section can be ``"\\n\\n".join(...)``-filtered out cleanly by the
|
|
per-work-group builders.
|
|
|
|
The section is intentionally formatted to match the
|
|
``key: <backtick>value<backtick>`` shape the worker is already
|
|
trained to expect elsewhere in the prompt (forgejo_url, etc.). This
|
|
means the worker does NOT have to burn 1-4 turns probing for these
|
|
via ``printenv FORGEJO_PAT`` / ``GIT_USER_NAME`` /
|
|
``GIT_USER_EMAIL`` — the values are already in the conversation
|
|
when the worker starts.
|
|
|
|
The ``forgejo_pat`` value is redacted from on-disk session archives
|
|
by ``_opencode_worker._archive_session``; see ``redact_values`` in
|
|
:func:`_opencode_worker.run_session_blocking`. The live OpenCode
|
|
session keeps the value verbatim until DELETE — that's a deliberate
|
|
trust boundary (anyone with API access to OpenCode can also read
|
|
env vars on the host).
|
|
"""
|
|
if not credentials:
|
|
return ""
|
|
lines = ["## Worker credentials (use these instead of env vars)"]
|
|
lines.append(
|
|
"Treat the values below as authoritative. They were resolved "
|
|
"by the dispatcher from the same environment your `printenv` "
|
|
"fallback would read, so reading them again is wasted turns. "
|
|
"The PAT is masked in the on-disk session archive."
|
|
)
|
|
lines.append("")
|
|
for key in ("forgejo_pat", "git_user_name", "git_user_email"):
|
|
if key in credentials:
|
|
lines.append(f"{key}: `{credentials[key]}`")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def assemble_header(cfg: Any, item: dict[str, Any], group: Any, claim_note: str) -> str:
|
|
"""Render the leading scaffold of every implementer worker prompt.
|
|
|
|
Public so :mod:`dispatch_implementer`'s legacy-prompt branch can
|
|
call it directly without having to reach into a private symbol —
|
|
the legacy and prefetch branches share this header verbatim, and a
|
|
cross-module caller is a legitimate consumer (not an
|
|
encapsulation leak).
|
|
|
|
The worker-credentials block is appended automatically when the
|
|
dispatcher's ``cfg.token`` / ``GIT_USER_NAME`` / ``GIT_USER_EMAIL``
|
|
are populated. When they are not (dry-run, unit test), the section
|
|
is silently omitted and the worker falls back to its existing
|
|
env-var lookup — same behaviour as before this change.
|
|
"""
|
|
work_type = "issue_impl" if group.item_kind == "issue" else "pr_fix"
|
|
title = str(item.get("title") or "")
|
|
number = int(item["number"])
|
|
credentials_section = render_worker_credentials_section(
|
|
_resolve_worker_credentials(cfg)
|
|
)
|
|
credentials_block = f"\n\n{credentials_section}" if credentials_section else ""
|
|
return f"""Implement or fix the indicated issue or pull request.
|
|
|
|
forgejo_url: `{cfg.forgejo_url}`
|
|
forgejo_owner: `{cfg.owner}`
|
|
forgejo_repo: `{cfg.repo}`
|
|
|
|
work_type: {json.dumps(work_type)}
|
|
work_number: {number}
|
|
work_title: {json.dumps(title)}{credentials_block}
|
|
|
|
{PR_COMPLIANCE_CHECKLIST}
|
|
|
|
{claim_note}"""
|
|
|
|
|
|
# Backwards-compatible underscore aliases — kept for one release so
|
|
# any caller that captured the previous private names continues to
|
|
# work. New code MUST use the public names above. Removing these
|
|
# aliases is tracked in the parity-plan follow-up sprint.
|
|
_assemble_header = assemble_header
|
|
_PR_CLAIM_NOTE = PR_CLAIM_NOTE
|
|
_ISSUE_CLAIM_NOTE = ISSUE_CLAIM_NOTE
|
|
|
|
|
|
def _build_available_blocks_section(result: Any) -> str:
|
|
"""Render the ``## Available blocks`` section from
|
|
``result.block_refs``. Empty string when no refs (block store
|
|
disabled, every registration failed, or this is a dry-run path)."""
|
|
refs = list(getattr(result, "block_refs", None) or [])
|
|
return _block_prompt.render_available_blocks_section(refs)
|
|
|
|
|
|
def _data_completeness_section(result: Any) -> str:
|
|
return f"""## Data completeness
|
|
|
|
The dispatcher's prompt-time aggregate ``data_complete`` is
|
|
``{result.data_complete}``. When this is ``False``, at least one
|
|
pre-fetched section was truncated, paginated partially, or had its
|
|
upstream fetch fail. The fix you push will be evaluated against the
|
|
same body of work the next CI run sees, so consult the affected
|
|
section's ``unavailable`` stanza (or its ``completed=false``
|
|
attribute) and decide whether the missing data is critical. Prefer
|
|
reading the pre-cloned worktree (when present) over in-session
|
|
HTTP — Phase 4 telemetry treats ``webfetch`` / ``curl`` calls as a
|
|
regression against the prefetch substrate."""
|
|
|
|
|
|
def build_pr_fix_prompt(
|
|
cfg: Any,
|
|
item: dict[str, Any],
|
|
group: Any,
|
|
result: Any,
|
|
clone_section: str,
|
|
) -> str:
|
|
"""Assemble the full prompt for a ``pr_fix`` (failing-CI) work
|
|
group. ``result`` is an :class:`_implementer_prefetch.ImplementerPrefetchResult`;
|
|
``clone_section`` is the pre-rendered ``## Pre-cloned working
|
|
copy`` block (or the no-handle stanza when Phase 3 pre-clone is
|
|
disabled / failed)."""
|
|
header = assemble_header(cfg, item, group, PR_CLAIM_NOTE)
|
|
diff_section = _build_diff_section_text(
|
|
head_sha=result.head_sha,
|
|
prefetch_diff_text=result.diff_text,
|
|
prefetch_truncated=result.diff_truncated,
|
|
prefetch_unavailable=result.diff_unavailable,
|
|
prefetch_info=result.diff_info,
|
|
)
|
|
sections = [
|
|
header,
|
|
diff_section,
|
|
clone_section,
|
|
_build_pr_description_section(result.pr_details),
|
|
_build_ci_status_section(result.ci_status, result.ci_status_completed),
|
|
_build_ci_detail_section(result.ci_detail, result.ci_detail_completed),
|
|
_build_ci_failure_logs_section(
|
|
result.ci_failure_logs,
|
|
result.ci_failure_logs_completed,
|
|
),
|
|
# R3.4 follow-up (2026-05-17): pr_fix used to fetch zero
|
|
# reviews and render zero review sections. Now it fetches
|
|
# both via ``fetch_pr_fix_context(include_active_reviews=True)``
|
|
# and renders BOTH sections — the active RC reviews
|
|
# (blocking) and the COMMENT/APPROVE reviews (advisory).
|
|
# Without these renders the worker had no visibility into
|
|
# the reviewer's substantive feedback on a failing-CI PR,
|
|
# even though the reviewer was actively posting reviews.
|
|
_build_active_reviews_section(
|
|
result.request_changes_reviews,
|
|
result.request_changes_reviews_completed,
|
|
),
|
|
_build_comment_reviews_section(
|
|
result.comment_reviews,
|
|
result.comment_reviews_completed,
|
|
),
|
|
_build_comments_section(
|
|
"Pre-fetched PR comments",
|
|
"pr_comments",
|
|
result.pr_comments_view,
|
|
result.pr_comments_completed,
|
|
digest=result.pr_comments_digest,
|
|
filter_summary=getattr(result, "pr_comments_filter_summary", None),
|
|
),
|
|
_build_linked_issues_section(
|
|
result.linked_issues, result.linked_issues_completed
|
|
),
|
|
_build_epic_section(result.epic_issue, result.epic_completed),
|
|
_build_available_blocks_section(result),
|
|
_data_completeness_section(result),
|
|
OUTPUT_CONTRACT,
|
|
]
|
|
return "\n\n".join(s for s in sections if s)
|
|
|
|
|
|
def build_request_changes_prompt(
|
|
cfg: Any,
|
|
item: dict[str, Any],
|
|
group: Any,
|
|
result: Any,
|
|
clone_section: str,
|
|
) -> str:
|
|
"""Assemble the prompt for a ``request_changes_pr`` work group.
|
|
|
|
Same shape as :func:`build_pr_fix_prompt` plus the active
|
|
REQUEST_CHANGES review section. Order: header → diff → clone →
|
|
PR description → CI status → reviews → comments → linked → epic
|
|
→ data-completeness → output contract.
|
|
"""
|
|
header = assemble_header(cfg, item, group, PR_CLAIM_NOTE)
|
|
diff_section = _build_diff_section_text(
|
|
head_sha=result.head_sha,
|
|
prefetch_diff_text=result.diff_text,
|
|
prefetch_truncated=result.diff_truncated,
|
|
prefetch_unavailable=result.diff_unavailable,
|
|
prefetch_info=result.diff_info,
|
|
)
|
|
sections = [
|
|
header,
|
|
diff_section,
|
|
clone_section,
|
|
_build_pr_description_section(result.pr_details),
|
|
_build_ci_status_section(result.ci_status, result.ci_status_completed),
|
|
_build_ci_detail_section(result.ci_detail, result.ci_detail_completed),
|
|
_build_ci_failure_logs_section(
|
|
result.ci_failure_logs,
|
|
result.ci_failure_logs_completed,
|
|
),
|
|
_build_active_reviews_section(
|
|
result.request_changes_reviews,
|
|
result.request_changes_reviews_completed,
|
|
),
|
|
_build_comment_reviews_section(
|
|
result.comment_reviews,
|
|
result.comment_reviews_completed,
|
|
),
|
|
_build_comments_section(
|
|
"Pre-fetched PR comments",
|
|
"pr_comments",
|
|
result.pr_comments_view,
|
|
result.pr_comments_completed,
|
|
digest=result.pr_comments_digest,
|
|
filter_summary=getattr(result, "pr_comments_filter_summary", None),
|
|
),
|
|
_build_linked_issues_section(
|
|
result.linked_issues, result.linked_issues_completed
|
|
),
|
|
_build_epic_section(result.epic_issue, result.epic_completed),
|
|
_build_available_blocks_section(result),
|
|
_data_completeness_section(result),
|
|
OUTPUT_CONTRACT,
|
|
]
|
|
return "\n\n".join(s for s in sections if s)
|
|
|
|
|
|
def build_new_issue_prompt(
|
|
cfg: Any,
|
|
item: dict[str, Any],
|
|
group: Any,
|
|
result: Any,
|
|
) -> str:
|
|
"""Assemble the prompt for a ``new_issue`` work group. No diff,
|
|
no clone (no PR exists yet — the worker creates the branch); just
|
|
the issue body, comments, linked bodies, and Epic."""
|
|
header = assemble_header(cfg, item, group, ISSUE_CLAIM_NOTE)
|
|
title = str(item.get("title") or "")
|
|
sections = [
|
|
header,
|
|
_build_issue_body_section(result.issue_body, title),
|
|
_build_comments_section(
|
|
"Pre-fetched issue comments",
|
|
"issue_comments",
|
|
result.issue_comments,
|
|
result.issue_comments_completed,
|
|
),
|
|
_build_linked_issues_section(
|
|
result.linked_issues, result.linked_issues_completed
|
|
),
|
|
_build_epic_section(result.epic_issue, result.epic_completed),
|
|
_build_available_blocks_section(result),
|
|
_data_completeness_section(result),
|
|
OUTPUT_CONTRACT,
|
|
]
|
|
return "\n\n".join(s for s in sections if s)
|
|
|
|
|
|
__all__ = (
|
|
"ISSUE_CLAIM_NOTE",
|
|
"OUTPUT_CONTRACT",
|
|
"PR_CLAIM_NOTE",
|
|
"PR_COMPLIANCE_CHECKLIST",
|
|
"assemble_header",
|
|
"build_new_issue_prompt",
|
|
"build_pr_fix_prompt",
|
|
"build_request_changes_prompt",
|
|
"render_worker_credentials_section",
|
|
)
|