diff --git a/examples/value-connectors-github-public-probe-smoke.py b/examples/value-connectors-github-public-probe-smoke.py index ba16117c1c..4651a47c4e 100644 --- a/examples/value-connectors-github-public-probe-smoke.py +++ b/examples/value-connectors-github-public-probe-smoke.py @@ -12,6 +12,7 @@ import tempfile from pathlib import Path from typing import Any +from urllib.error import HTTPError, URLError REPO_ROOT = Path(__file__).resolve().parents[1] @@ -21,6 +22,7 @@ from loopx.capabilities.issue_fix.github_public import ( # noqa: E402 GITHUB_PUBLIC_CHANNEL_PROBE_PACKET_SCHEMA_VERSION, GITHUB_PUBLIC_REPLY_MONITOR_PACKET_SCHEMA_VERSION, + github_public_read_failure_row, ) from loopx.capabilities.value_connectors.install_check import ( # noqa: E402 VALUE_CONNECTOR_INSTALL_CHECK_PACKET_SCHEMA_VERSION, @@ -593,6 +595,30 @@ def main() -> int: assert "recommended_action: `wait_no_bump`" in reply_markdown, reply_markdown assert_public_safe(reply_markdown) + # A failed public read must surface as a typed row instead of the provider's + # own text: steward answers used to quote "Cache miss" and + # "invalid_arguments" instead of naming what was unread and how to repair + # it. The provider message is replaced by its digest. + failure = github_public_read_failure_row(RuntimeError("gh request failed: Cache miss")) + assert failure["code"] == "provider_tool_unavailable", failure + assert failure["source_id"] == "github_public_channel", failure + assert "unread" in failure["coverage_effect"], failure + assert failure["next_action"], failure + assert failure["provider_message_digest"].startswith("sha256:"), failure + assert "Cache miss" not in json.dumps(failure), failure + assert_public_safe(failure) + assert github_public_read_failure_row(subprocess.TimeoutExpired("gh", 5))[ + "code" + ] == "provider_timeout" + assert github_public_read_failure_row( + URLError("nodename nor servname provided") + )["code"] == "provider_network_unavailable" + http_failure = github_public_read_failure_row( + HTTPError("https://api.github.com/repos/o/r/issues/1", 403, "Forbidden", None, None) + ) + assert http_failure["code"] == "provider_response_rejected", http_failure + assert http_failure["provider_status"] == 403, http_failure + print("value-connectors-github-public-probe-smoke: ok") return 0 diff --git a/loopx/capabilities/issue_fix/github_public.py b/loopx/capabilities/issue_fix/github_public.py index f9597316b1..2e4ba8a005 100644 --- a/loopx/capabilities/issue_fix/github_public.py +++ b/loopx/capabilities/issue_fix/github_public.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import json import shutil import subprocess @@ -20,6 +21,20 @@ "github_public_reply_monitor_packet_v0" ) GITHUB_PUBLIC_REPLY_SIGNAL_SCHEMA_VERSION = "github_public_reply_signal_v0" +GITHUB_PUBLIC_READ_FAILURE_SCHEMA_VERSION = "github_public_read_failure_v0" + +# A public read that failed is reported as one of these reasons, never as the +# provider's own text: the steward answer contract forbids quoting a provider +# failure into an answer, and a manager that repeats "Cache miss" instead of a +# typed reason cannot say what is unread or how to repair it. +GITHUB_PUBLIC_READ_FAILURE_CODES = ( + "provider_timeout", + "provider_network_unavailable", + "provider_response_rejected", + "provider_result_unreadable", + "provider_tool_unavailable", + "provider_read_failed", +) ALLOWED_GITHUB_REF_TYPES = {"issue", "pull", "discussion"} MAINTAINER_ASSOCIATIONS = {"COLLABORATOR", "MEMBER", "OWNER"} @@ -116,9 +131,48 @@ def _normalise_github_issue_comment_url(url: str) -> tuple[str, dict[str, Any]]: } -def _compact_error(exc: BaseException) -> str: - text = " ".join(str(exc).split()) - return text[:180] +def github_public_read_failure_row(exc: BaseException) -> dict[str, Any]: + """One failed public read as a typed row the answer can carry. + + The row names the source, a typed reason, the coverage effect of the failed + read and the next action. The provider's message is replaced by its digest: + it stays correlatable with the provider log without letting an answer quote + it, which is what "Cache miss" and "invalid_arguments" leaking into steward + answers required. + """ + + if isinstance(exc, (subprocess.TimeoutExpired, TimeoutError)): + code = "provider_timeout" + elif isinstance(exc, HTTPError): + code = "provider_response_rejected" + elif isinstance(exc, URLError): + code = "provider_network_unavailable" + elif isinstance(exc, json.JSONDecodeError): + code = "provider_result_unreadable" + elif isinstance(exc, RuntimeError): + code = "provider_tool_unavailable" + else: + code = "provider_read_failed" + compact = " ".join(str(exc).split()) + row: dict[str, Any] = { + "schema_version": GITHUB_PUBLIC_READ_FAILURE_SCHEMA_VERSION, + "source_id": "github_public_channel", + "code": code, + "coverage_effect": ( + "the public GitHub metadata for this target was not read in this " + "call; the target is unread rather than inactive" + ), + "next_action": ( + "retry with the GitHub CLI or authenticated tooling, or pass the " + "metadata explicitly; do not report the target as having no activity" + ), + "provider_message_digest": "sha256:" + + hashlib.sha256(compact.encode("utf-8")).hexdigest(), + } + status = getattr(exc, "code", None) + if isinstance(status, int) and not isinstance(status, bool): + row["provider_status"] = status + return row def _fetch_issue_or_pull_metadata(ref: Mapping[str, Any], *, timeout_seconds: float) -> dict[str, Any]: @@ -392,7 +446,7 @@ def build_github_public_reply_monitor_packet( ) if not same_target: raise ValueError("--issue-url and --after-comment-url must point at the same GitHub thread") - read_error: str | None = None + read_failure: dict[str, Any] | None = None live_payload: Mapping[str, Any] | None = None if fetch_metadata: try: @@ -402,7 +456,7 @@ def build_github_public_reply_monitor_packet( json.JSONDecodeError, subprocess.TimeoutExpired, ) as exc: - read_error = _compact_error(exc) + read_failure = github_public_read_failure_row(exc) payload_source = live_payload if live_payload is not None else provider_payload comments = _provider_comments(payload_source) @@ -428,7 +482,7 @@ def build_github_public_reply_monitor_packet( metadata_collected = bool(comments) validation_errors: list[str] = [] validation_warnings: list[str] = [] - if read_error: + if read_failure: validation_errors.append("metadata read failed; retry with gh auth/tooling or use --metadata-json") if metadata_collected and anchor_created_at is None: validation_errors.append("anchor LoopX comment was not found in comment metadata") @@ -487,7 +541,7 @@ def build_github_public_reply_monitor_packet( "money_signal": money_signal, "recommended_action": recommended_action, "stop_condition": "do not bump or draft a triage note until public maintainer interest appears", - "read_error": read_error, + "read_failure": read_failure, "validation": { "ok": not validation_errors, "errors": validation_errors, @@ -509,7 +563,7 @@ def build_github_public_channel_probe_packet( ) -> dict[str, Any]: normalised_url, ref = _normalise_github_url(url) metadata: dict[str, Any] | None = None - read_error: str | None = None + read_failure: dict[str, Any] | None = None if fetch_metadata: try: if ref["ref_type"] in {"issue", "pull"}: @@ -517,7 +571,7 @@ def build_github_public_channel_probe_packet( else: metadata = _fetch_discussion_metadata(ref, timeout_seconds=timeout_seconds) except (HTTPError, URLError, TimeoutError, RuntimeError, json.JSONDecodeError, subprocess.TimeoutExpired) as exc: - read_error = _compact_error(exc) + read_failure = github_public_read_failure_row(exc) connector_call = { "schema_version": "connector_call_intent_v0", "call_id": f"github_{ref['ref_type']}_{ref['number']}_metadata_probe", @@ -540,7 +594,7 @@ def build_github_public_channel_probe_packet( "promotion_target": "value_connector_plan_v0", } validation_errors: list[str] = [] - if read_error: + if read_failure: validation_errors.append("metadata read failed; retry with auth material/tooling or use no-fetch mode") return { "ok": not validation_errors, @@ -550,7 +604,7 @@ def build_github_public_channel_probe_packet( "ref": ref, "connector_call": connector_call, "metadata": metadata, - "read_error": read_error, + "read_failure": read_failure, "external_reads_performed": bool(fetch_metadata), "external_writes_performed": False, "raw_body_captured": False, @@ -605,8 +659,24 @@ def render_github_public_reply_monitor_markdown(payload: dict[str, Any]) -> str: f"maintainer_signal=`{signal.get('is_maintainer_signal')}` " f"url={signal.get('url')}" ) - if payload.get("read_error"): - lines.extend(["", "## Read Error", "", str(payload.get("read_error"))]) + failure = ( + payload.get("read_failure") + if isinstance(payload.get("read_failure"), Mapping) + else None + ) + if failure: + lines.extend( + [ + "", + "## Unread Source", + "", + f"- source_id: `{failure.get('source_id')}`", + f"- code: `{failure.get('code')}`", + f"- coverage_effect: {failure.get('coverage_effect')}", + f"- next_action: {failure.get('next_action')}", + f"- provider_message_digest: `{failure.get('provider_message_digest')}`", + ] + ) errors = validation.get("errors") if isinstance(validation.get("errors"), list) else [] warnings = validation.get("warnings") if isinstance(validation.get("warnings"), list) else [] if errors: @@ -650,8 +720,24 @@ def render_github_public_channel_probe_markdown(payload: dict[str, Any]) -> str: "", ] ) - if payload.get("read_error"): - lines.extend(["## Read Error", "", str(payload.get("read_error")), ""]) + failure = ( + payload.get("read_failure") + if isinstance(payload.get("read_failure"), Mapping) + else None + ) + if failure: + lines.extend( + [ + "## Unread Source", + "", + f"- source_id: `{failure.get('source_id')}`", + f"- code: `{failure.get('code')}`", + f"- coverage_effect: {failure.get('coverage_effect')}", + f"- next_action: {failure.get('next_action')}", + f"- provider_message_digest: `{failure.get('provider_message_digest')}`", + "", + ] + ) if payload.get("error"): lines.extend(["## Error", "", str(payload.get("error")), ""]) errors = validation.get("errors") if isinstance(validation.get("errors"), list) else []