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>
519 lines
21 KiB
Python
Executable File
519 lines
21 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Worker-side reader for the dispatcher's PR-context handoff sentinel.
|
|
|
|
What this is
|
|
------------
|
|
|
|
The dispatcher pre-fetches everything the implementer worker needs to
|
|
make a code change — PR description, full diff, CI status + per-check
|
|
detail, PR comments, active REQUEST_CHANGES reviews, linked issues,
|
|
parent Epic body — and writes the lot to a sentinel at
|
|
``/tmp/cleveragents-implementer-handoff/pr-{N}.json``. The dispatcher
|
|
ALSO embeds those sections in the wrapper session's user prompt, but
|
|
each ``task`` tool call inside OpenCode re-summarises the prompt for
|
|
the child agent — by the time the chain reaches ``task-implementor``
|
|
at depth 3, only the diff section reliably survives.
|
|
|
|
This script is what the implementer worker calls (via the
|
|
``implementer-pr-context`` skill) to re-read any prefetched section
|
|
on demand, regardless of how the prompt got mangled upstream. It is
|
|
the read half of :mod:`tools._pr_context_sentinel`.
|
|
|
|
Contract
|
|
--------
|
|
|
|
Subcommand ``read``:
|
|
|
|
``python3 tools/implementer_pr_context.py read --pr <N> --field <name>``
|
|
|
|
Reads the sentinel at ``/tmp/cleveragents-implementer-handoff/pr-{N}.json``,
|
|
pulls the requested field, prints it on stdout, and exits 0. The
|
|
``--field`` value selects which slice of the prefetched data:
|
|
|
|
- ``description`` — the PR body text (plain string)
|
|
- ``title`` — the PR title (plain string)
|
|
- ``metadata`` — JSON with work_type / work_group / head_sha /
|
|
head_ref / base_ref / pr_number / data_complete / error_kinds
|
|
- ``ci`` — JSON with the latest CI ``state`` plus ``checks[]``
|
|
(per-status detail, including ``target_url`` and any prefetched
|
|
failure-log excerpts)
|
|
- ``comments`` — JSON list of PR / issue comments (chronological).
|
|
For PRs this is the BOUNDED view: the most-recent N comments plus
|
|
every human/reviewer comment — the older bot ``**Implementation
|
|
Attempt**`` comments are rolled up into ``comments_digest`` instead.
|
|
- ``comments_digest`` — JSON dict: the deterministic attempt-history
|
|
digest (counts by tier / outcome, gates failing most, last
|
|
success) computed from the FULL PR comment list. ``{}`` when no
|
|
attempt comments were parsed.
|
|
- ``reviews`` — JSON list of active REQUEST_CHANGES reviews + their
|
|
inline comments (for ``request_changes_pr`` work only)
|
|
- ``issues`` — JSON list of linked issues (the ``Closes #N``
|
|
targets, with their bodies inlined)
|
|
- ``epic`` — JSON dict with the parent Epic body, or empty if no
|
|
Epic reference was found
|
|
- ``diff`` — the unified diff as plain text (between
|
|
``BEGIN_PR_DIFF`` / ``END_PR_DIFF`` markers in the original
|
|
prompt)
|
|
- ``compliance_gaps`` — JSON dict of the dispatcher's deterministic
|
|
PR Compliance Checklist scan (worktree_clean, changelog,
|
|
contributors, issues-closed footer). Empty stdout when the
|
|
dispatcher did not run the scan this cycle.
|
|
- ``gate_preflight`` — JSON dict of the heavy flaky-test gate
|
|
pre-flight (two ``local_ci_gate.sh --fast`` runs, classification
|
|
of persistent vs. flaky failures). Empty stdout when the
|
|
dispatcher did not run the pre-flight this cycle.
|
|
- ``all`` — the entire sentinel payload as JSON (useful for
|
|
debugging; ``task-implementor`` should pull narrowly)
|
|
|
|
The script emits one of three signals on stdout, all with exit
|
|
code 0:
|
|
|
|
- **Empty stdout** — the sentinel is missing, malformed, or the
|
|
dispatcher's fetch for this field did not complete (per the
|
|
matching ``*_completed`` flag). The worker should fall through
|
|
to its legacy curl-based GET against the live Forgejo API.
|
|
- ``null\\n`` — the dispatcher fetched successfully and
|
|
authoritatively confirmed absence (e.g. the PR has no Epic
|
|
reference). The worker should SKIP its legacy GET; there is
|
|
nothing to fetch.
|
|
- Any other content — the field's value (plain text for
|
|
description/title/diff/issue_body, pretty-printed JSON for
|
|
everything else). The worker should use it verbatim.
|
|
|
|
This three-case contract is how the worker distinguishes "the
|
|
dispatcher didn't try" (fall through) from "the dispatcher tried
|
|
and confirmed there is nothing" (don't re-curl). Conflating the
|
|
two — as the original ``or None`` projection did — caused the
|
|
worker to redundantly hit Forgejo for sections the dispatcher
|
|
had already verified empty.
|
|
|
|
Exit codes
|
|
----------
|
|
|
|
- 0: success (or empty stdout for "no data available" — see above).
|
|
- 2: usage error (bad / missing CLI arg, unknown ``--field``).
|
|
- 3: schema-version mismatch (a config issue the operator must
|
|
notice; the worker should treat this as "no data available" but
|
|
the non-zero code makes the situation visible in logs).
|
|
|
|
Allow-rule
|
|
----------
|
|
|
|
This script's path is whitelisted under
|
|
``python3 tools/implementer_pr_context.py *`` in the agent's
|
|
permission allowlist (see :file:`.opencode/agents/task-implementor.md`).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
# Must match :data:`tools._pr_context_sentinel.SCHEMA_VERSION`.
|
|
EXPECTED_SCHEMA_VERSION = 1
|
|
|
|
_DEFAULT_HANDOFF_DIR = Path("/tmp/cleveragents-implementer-handoff")
|
|
|
|
|
|
def _handoff_dir() -> Path:
|
|
"""Resolve the on-disk directory. Mirrors
|
|
:func:`tools._pr_context_sentinel.handoff_dir`."""
|
|
return Path(
|
|
os.environ.get("IMPLEMENTER_DISPATCHER_PR_CONTEXT_DIR")
|
|
or str(_DEFAULT_HANDOFF_DIR)
|
|
)
|
|
|
|
|
|
def _handoff_path(pr_number: int) -> Path:
|
|
return _handoff_dir() / f"pr-{int(pr_number)}.json"
|
|
|
|
|
|
def _load(pr_number: int) -> tuple[dict[str, Any] | None, str | None]:
|
|
"""Read + parse + validate the sentinel.
|
|
|
|
Returns ``(payload, error)``:
|
|
|
|
- ``(payload, None)`` — sentinel exists, parses, schema matches.
|
|
- ``(None, None)`` — sentinel does not exist (normal: dispatcher
|
|
didn't prefetch, or already cleaned up).
|
|
- ``(None, error)`` — sentinel exists but is unusable (malformed,
|
|
schema mismatch). The caller emits the error to stderr.
|
|
"""
|
|
target = _handoff_path(pr_number)
|
|
if not target.exists():
|
|
return None, None
|
|
try:
|
|
raw = target.read_text(encoding="utf-8")
|
|
payload = json.loads(raw)
|
|
except (OSError, ValueError) as e:
|
|
return None, f"handoff read/parse failed at {target}: {e}"
|
|
if not isinstance(payload, dict):
|
|
return None, f"handoff at {target} is not a JSON object"
|
|
schema = payload.get("schema_version")
|
|
if schema != EXPECTED_SCHEMA_VERSION:
|
|
return None, (
|
|
f"handoff at {target} has schema_version={schema!r}, "
|
|
f"expected {EXPECTED_SCHEMA_VERSION}"
|
|
)
|
|
return payload, None
|
|
|
|
|
|
# Sentinel for "field is not present in this handoff" / "fetch failed
|
|
# upstream". Distinct from ``None`` so the worker contract can
|
|
# distinguish three cases:
|
|
#
|
|
# - ``_MISSING`` → emit NOTHING (empty stdout); worker falls through
|
|
# to its legacy GET. Used when the dispatcher didn't attempt this
|
|
# section (legacy prompt path) OR when the dispatcher attempted
|
|
# and failed (transient error reflected in the matching
|
|
# ``*_completed=False`` flag).
|
|
# - ``None`` → emit the JSON literal ``null`` (stdout = ``null\n``).
|
|
# Used when the dispatcher fetched successfully and confirmed
|
|
# absence (e.g. the PR has no Epic reference). The worker sees
|
|
# non-empty stdout and skips its legacy GET, treating "null" as
|
|
# the authoritative "no data" answer.
|
|
# - any other value → emit it; worker uses it verbatim.
|
|
#
|
|
# Without this distinction the worker re-curls the live Forgejo API
|
|
# every time the dispatcher confirmed an absence, defeating the
|
|
# whole point of the handoff.
|
|
_MISSING: Any = object()
|
|
|
|
|
|
def _gated_list(
|
|
payload: dict[str, Any], value_key: str, completed_key: str
|
|
) -> Any:
|
|
"""Three-case helper for list-valued fields. ``_MISSING`` when
|
|
``completed_key`` is False; the value (or ``[]`` if absent /
|
|
falsy) otherwise. Empty list serialises as ``[]\\n``, which
|
|
the worker reads as authoritative-empty (skip legacy GET)."""
|
|
if not payload.get(completed_key, True):
|
|
return _MISSING
|
|
return payload.get(value_key) or []
|
|
|
|
|
|
def _gated_text(
|
|
payload: dict[str, Any], value_key: str, completed_key: str
|
|
) -> Any:
|
|
"""Three-case helper for plain-text fields.
|
|
|
|
- completion flag False → ``_MISSING`` (worker falls through to
|
|
legacy GET).
|
|
- completion flag True + empty body → ``None`` (rendered as
|
|
``null\\n``; worker reads as authoritative-empty and skips
|
|
the legacy GET).
|
|
- completion flag True + non-empty body → the body verbatim.
|
|
|
|
The completion-flag-aware path is what lets a legitimately-empty
|
|
PR description (``description_completed=True``, ``description=""``)
|
|
cleanly signal "no description, don't re-fetch" rather than
|
|
forcing a redundant GET. Before the flag existed, every empty
|
|
description / title / diff / issue_body cost the worker a
|
|
redundant Forgejo round-trip.
|
|
"""
|
|
if not payload.get(completed_key, True):
|
|
return _MISSING
|
|
body = payload.get(value_key)
|
|
if not body:
|
|
return None
|
|
return body
|
|
|
|
|
|
def _project_field(payload: dict[str, Any], field: str) -> Any:
|
|
"""Return the slice of ``payload`` selected by ``--field``.
|
|
|
|
Implements the **three-case contract** the worker relies on:
|
|
|
|
- :data:`_MISSING` — the field is genuinely absent OR the
|
|
dispatcher's fetch for it did not complete successfully (per
|
|
the field's ``*_completed`` flag). :func:`_emit` renders this
|
|
as empty stdout; the worker falls through to its legacy GET.
|
|
- ``None`` — the dispatcher's fetch succeeded AND confirmed
|
|
the field is empty in a way the JSON schema can encode (e.g.
|
|
``epic=None`` means "PR has no parent Epic"). :func:`_emit`
|
|
renders this as ``null\\n``; the worker skips the legacy GET.
|
|
- any other value — the value itself, rendered verbatim (plain
|
|
text for ``description``/``title``/``diff``/``issue_body``,
|
|
pretty-printed JSON for the rest). Empty lists / empty dicts
|
|
ALSO fall through this case and render as ``[]\\n`` / ``{}\\n``
|
|
— they carry the "fetched, confirmed empty" semantics for
|
|
list/dict-valued fields.
|
|
|
|
The :data:`_MISSING` vs ``None`` vs value distinction is what
|
|
stops the worker from re-curling Forgejo for sections the
|
|
dispatcher has already confirmed are empty. Mapping all three
|
|
cases to ``None`` (the pre-2026-05-10 behavior) collapsed the
|
|
"didn't try" and "tried, confirmed empty" cases together and
|
|
every confirmed-empty section cost the worker a redundant GET.
|
|
"""
|
|
if field == "all":
|
|
return payload
|
|
if field == "description":
|
|
return _gated_text(payload, "description", "description_completed")
|
|
if field == "title":
|
|
return _gated_text(payload, "title", "title_completed")
|
|
if field == "diff":
|
|
return _gated_text(payload, "diff", "diff_completed")
|
|
if field == "issue_body":
|
|
return _gated_text(payload, "issue_body", "issue_body_completed")
|
|
if field == "metadata":
|
|
# The ``completion`` sub-object surfaces the per-section
|
|
# ``*_completed`` flags so an operator inspecting via
|
|
# ``--field metadata`` can see ALL fetcher state in one
|
|
# query without having to iterate every ``--field``. The
|
|
# worker's per-field reads still rely on the individual
|
|
# field projections — this is for human/operator triage.
|
|
#
|
|
# Enumerates payload keys (rather than hardcoding the
|
|
# name list) so a new ``*_completed`` flag added to the
|
|
# writer's ``COMPLETION_FLAG_NAMES`` tuple appears here
|
|
# automatically — keeps this reader script standalone
|
|
# (no dispatcher / prefetch imports) while still tracking
|
|
# writer-side additions without code changes. Stable
|
|
# sort order so two consecutive ``--field metadata``
|
|
# reads produce byte-identical output (worker / operator
|
|
# diff tooling depends on this).
|
|
completion = {
|
|
k.removesuffix("_completed"): payload.get(k, True)
|
|
for k in sorted(payload)
|
|
if k.endswith("_completed")
|
|
}
|
|
return {
|
|
"schema_version": payload.get("schema_version"),
|
|
"pr_number": payload.get("pr_number"),
|
|
"work_type": payload.get("work_type"),
|
|
"work_group": payload.get("work_group"),
|
|
"head_sha": payload.get("head_sha"),
|
|
"head_ref": payload.get("head_ref"),
|
|
"base_ref": payload.get("base_ref"),
|
|
"listing_title": payload.get("listing_title"),
|
|
"data_complete": payload.get("data_complete"),
|
|
"completion": completion,
|
|
"error_kinds": payload.get("error_kinds") or [],
|
|
"created_at": payload.get("created_at"),
|
|
"dispatcher_pid": payload.get("dispatcher_pid"),
|
|
}
|
|
if field == "ci":
|
|
# ─── Contract divergence (intentional) ────────────────
|
|
# Every OTHER field gated on a completion flag uses
|
|
# _gated_list / _gated_text and returns ``_MISSING``
|
|
# when the flag is False (empty stdout → worker falls
|
|
# through to legacy GET). ``ci`` is the exception: it
|
|
# ALWAYS emits the bundle when the sentinel exists at
|
|
# all, and the worker is expected to inspect the inner
|
|
# ``status_completed`` / ``detail_completed`` booleans
|
|
# to decide what to do.
|
|
#
|
|
# Why diverge:
|
|
# 1. CI carries TWO independent completion signals
|
|
# (status + detail). Collapsing them into one
|
|
# ``_MISSING`` would lose the case where the status
|
|
# summary fetch succeeded but the per-check detail
|
|
# fetch failed — the worker still has useful
|
|
# aggregate state ("PR is red"), it just lacks
|
|
# per-check evidence.
|
|
# 2. A green PR legitimately has ``ci_detail=[]`` (no
|
|
# failing checks to enumerate) with
|
|
# ``ci_detail_completed=True``. Emitting that as a
|
|
# bundle is the right answer.
|
|
#
|
|
# Workers reading ``--field ci`` MUST check both
|
|
# completion booleans before treating the bundle as
|
|
# authoritative. See SKILL.md "ci field exception" for
|
|
# the worker-side procedure.
|
|
return {
|
|
"status": payload.get("ci_status"),
|
|
"checks": payload.get("ci_detail") or [],
|
|
"status_completed": payload.get("ci_status_completed", True),
|
|
"detail_completed": payload.get("ci_detail_completed", True),
|
|
}
|
|
if field == "comments":
|
|
# Dispatch on ``work_type``: pr_fix / request_changes_pr
|
|
# cycles populate ``pr_comments``; new_issue cycles populate
|
|
# ``issue_comments``. Mixing them was a latent bug — if a
|
|
# future change ever populated both (e.g. an issue_impl
|
|
# cycle that also happened to have a stale PR), the previous
|
|
# ``or`` chain would return the wrong one.
|
|
if payload.get("work_type") == "issue_impl":
|
|
return _gated_list(
|
|
payload, "issue_comments", "issue_comments_completed"
|
|
)
|
|
return _gated_list(payload, "pr_comments", "pr_comments_completed")
|
|
if field == "comments_digest":
|
|
# The deterministic attempt-history digest the dispatcher
|
|
# computed from the FULL comment list at prefetch time (see
|
|
# _attempt_history.summarize_attempt_history). The verbatim
|
|
# ``comments`` field only carries the bounded view; this is the
|
|
# rollup of everything dropped from that window. Always emit the
|
|
# dict when the sentinel exists — an empty ``{}`` means
|
|
# "computed, zero attempts parsed", still authoritative. There
|
|
# is no legacy GET to fall through to.
|
|
return payload.get("pr_comments_digest") or {}
|
|
if field == "reviews":
|
|
return _gated_list(
|
|
payload,
|
|
"request_changes_reviews",
|
|
"request_changes_reviews_completed",
|
|
)
|
|
if field == "comment_reviews":
|
|
# R3.4 (2026-05-17): non-RC reviews (COMMENT/APPROVE) — the
|
|
# reviewer's advisory feedback. Separate field from RC so
|
|
# callers can keep the blocking-vs-advisory distinction.
|
|
return _gated_list(
|
|
payload,
|
|
"comment_reviews",
|
|
"comment_reviews_completed",
|
|
)
|
|
if field == "issues":
|
|
return _gated_list(payload, "linked_issues", "linked_issues_completed")
|
|
if field == "epic":
|
|
# ``epic_completed=False`` means the fetch failed; fall
|
|
# through. ``epic_completed=True`` with ``epic=None`` means
|
|
# the dispatcher confirmed the PR has no Epic reference —
|
|
# emit ``null`` so the worker skips its legacy GET.
|
|
if not payload.get("epic_completed", True):
|
|
return _MISSING
|
|
return payload.get("epic")
|
|
if field in (
|
|
"compliance_gaps", "gate_preflight", "recent_implementer_push",
|
|
):
|
|
# Deterministic / cross-cycle sections — BINARY contract
|
|
# (not the generic three-case prefetch contract). The
|
|
# dispatcher either computed this cycle (``*_completed=True``
|
|
# AND a populated dict, emitted as JSON) or it did not
|
|
# (both keys absent, emitted as empty stdout / worker falls
|
|
# through). The detectors always produce a structured dict
|
|
# when they run, so there is no "tried-but-empty" middle
|
|
# state to surface.
|
|
completed_key = f"{field}_completed"
|
|
if not payload.get(completed_key):
|
|
return _MISSING
|
|
return payload.get(field)
|
|
raise ValueError(f"unknown --field: {field}")
|
|
|
|
|
|
_ALLOWED_FIELDS = (
|
|
"all",
|
|
"description",
|
|
"title",
|
|
"diff",
|
|
"metadata",
|
|
"ci",
|
|
"comments",
|
|
"comments_digest",
|
|
"reviews",
|
|
"issues",
|
|
"epic",
|
|
"issue_body",
|
|
"compliance_gaps",
|
|
"gate_preflight",
|
|
"recent_implementer_push",
|
|
)
|
|
|
|
|
|
def _emit(value: Any, field: str) -> None:
|
|
"""Render ``value`` to stdout in the format the worker expects.
|
|
|
|
Three cases (see :data:`_MISSING` docstring for full rationale):
|
|
|
|
- ``value is _MISSING`` → emit nothing. The worker reads empty
|
|
stdout and falls through to its legacy GET.
|
|
- ``value is None`` → emit ``null\\n``. The worker reads
|
|
non-empty stdout and skips its legacy GET (the dispatcher
|
|
authoritatively confirmed absence).
|
|
- any other value → emit verbatim. Strings print as plain text
|
|
with a trailing newline; everything else prints as
|
|
pretty-printed JSON.
|
|
"""
|
|
if value is _MISSING:
|
|
return
|
|
if value is None:
|
|
# Authoritative "no data" answer (e.g. dispatcher confirmed
|
|
# no Epic). The worker contract reads this as "skip the
|
|
# legacy GET; there's nothing to fetch." JSON ``null`` is
|
|
# parseable in any language the worker might use.
|
|
sys.stdout.write("null\n")
|
|
return
|
|
if isinstance(value, str):
|
|
# Plain-text contract for these fields so the worker can
|
|
# pipe them straight into a file or display them inline
|
|
# without an extra json parse.
|
|
sys.stdout.write(value)
|
|
# Ensure a trailing newline so terminal usage looks right.
|
|
if not value.endswith("\n"):
|
|
sys.stdout.write("\n")
|
|
return
|
|
# Structured fields: pretty-print JSON so the worker (or an
|
|
# operator inspecting from the shell) gets a readable blob.
|
|
json.dump(value, sys.stdout, indent=2, default=str)
|
|
sys.stdout.write("\n")
|
|
|
|
|
|
def _cmd_read(args: argparse.Namespace) -> int:
|
|
"""Implement ``read``. Always exits 0 on the "no data" path —
|
|
that's a normal condition (e.g. epic-field on a PR with no
|
|
Epic reference). Exit 3 only on schema mismatch, which is a
|
|
config issue the operator must notice."""
|
|
payload, error = _load(int(args.pr))
|
|
if error:
|
|
print(f"warning: {error}", file=sys.stderr)
|
|
if "schema_version" in error:
|
|
return 3
|
|
return 0
|
|
if payload is None:
|
|
# No sentinel — the dispatcher didn't prefetch (or cleaned
|
|
# up already). Empty stdout is the "no data" signal.
|
|
return 0
|
|
try:
|
|
value = _project_field(payload, args.field)
|
|
except ValueError as e:
|
|
print(f"error: {e}", file=sys.stderr)
|
|
return 2
|
|
_emit(value, args.field)
|
|
return 0
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(
|
|
prog="implementer_pr_context",
|
|
description=(
|
|
"Worker-side reader for the dispatcher's PR-context "
|
|
"handoff sentinel. See module docstring for the full "
|
|
"contract."
|
|
),
|
|
)
|
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
|
|
p_read = sub.add_parser(
|
|
"read",
|
|
help=(
|
|
"Read one field from the PR's context handoff. Prints "
|
|
"the field on stdout (plain text for description/title/"
|
|
"diff/issue_body, JSON for everything else). Empty "
|
|
"stdout means 'no data available — fall back to curl'."
|
|
),
|
|
)
|
|
p_read.add_argument(
|
|
"--pr", required=True, type=int, help="PR number"
|
|
)
|
|
p_read.add_argument(
|
|
"--field",
|
|
required=True,
|
|
choices=_ALLOWED_FIELDS,
|
|
help="Which slice of the prefetched data to print",
|
|
)
|
|
|
|
args = parser.parse_args(argv)
|
|
if args.cmd == "read":
|
|
return _cmd_read(args)
|
|
parser.error(f"unknown subcommand {args.cmd!r}")
|
|
return 2 # pragma: no cover — argparse exits before reaching here
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|