Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions examples/value-connectors-github-public-probe-smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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,
Expand Down Expand Up @@ -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

Expand Down
116 changes: 101 additions & 15 deletions loopx/capabilities/issue_fix/github_public.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import hashlib
import json
import shutil
import subprocess
Expand All @@ -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"}
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -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")
Expand Down Expand Up @@ -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,
Expand All @@ -509,15 +563,15 @@ 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"}:
metadata = _fetch_issue_or_pull_metadata(ref, timeout_seconds=timeout_seconds)
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",
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 []
Expand Down
Loading