Skip to content
Merged
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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,9 +186,11 @@ gh llm doctor

`doctor` prints the current entrypoint, resolved executable paths, `gh` / `gh-llm` versions,
active-host `gh auth status`, a REST probe, a minimal GraphQL probe, and proxy-related environment variables.
If `gh auth status` is noisy but both API probes succeed, `doctor` reports that auth check as a warning instead
of failing the whole diagnosis.

When `gh-llm` hits transport errors such as GraphQL `EOF` / timeout failures, the CLI now reports the
retry count and suggests concrete follow-up commands such as `gh auth status`,
retry count and suggests concrete follow-up probes such as `gh api user`,
`gh api graphql -f query='query{viewer{login}}'`, and `gh-llm doctor`.

## PR Review Workflow
Expand Down
44 changes: 39 additions & 5 deletions src/gh_llm/commands/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,22 @@ def cmd_doctor(_: Any) -> int:
entrypoint = display_command()
argv0 = detect_prog_name(sys.argv[0])
target_host = resolve_target_host()
entrypoint_probe = _probe_entrypoint_version(entrypoint)
gh_version_probe = _probe_gh_version()
auth_status_probe = _probe_auth_status(target_host)
rest_user_probe = _probe_rest_user()
graphql_viewer_probe = _probe_graphql_viewer()
auth_status_probe = _reconcile_auth_status_probe(
auth_status_probe,
rest_user_probe=rest_user_probe,
graphql_viewer_probe=graphql_viewer_probe,
)
critical_probes = (
_probe_entrypoint_version(entrypoint),
_probe_gh_version(),
_probe_auth_status(target_host),
_probe_rest_user(),
_probe_graphql_viewer(),
entrypoint_probe,
gh_version_probe,
auth_status_probe,
rest_user_probe,
graphql_viewer_probe,
)
failed = [probe.name for probe in critical_probes if not probe.ok and probe.critical]

Expand Down Expand Up @@ -179,6 +189,30 @@ def _probe_auth_status(target_host: str) -> _ProbeResult:
)


def _reconcile_auth_status_probe(
auth_status_probe: _ProbeResult,
*,
rest_user_probe: _ProbeResult,
graphql_viewer_probe: _ProbeResult,
) -> _ProbeResult:
if auth_status_probe.ok or not (rest_user_probe.ok and graphql_viewer_probe.ok):
return auth_status_probe

detail_parts = [
part
for part in (auth_status_probe.detail.strip(), "API probes succeeded; treating auth status as a warning.")
if part
]
return _ProbeResult(
name=auth_status_probe.name,
command=auth_status_probe.command,
ok=False,
summary="warning (API probes ok)",
detail="\n\n".join(detail_parts),
critical=False,
)
Comment thread
ShigureNyako marked this conversation as resolved.


def _probe_rest_user() -> _ProbeResult:
command = ["gh", "api", "user"]
result = _run_command(command)
Expand Down
58 changes: 40 additions & 18 deletions src/gh_llm/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,38 @@
("gh", "pr", "view"),
("gh", "issue", "view"),
}
TRANSPORT_ERROR_PATTERNS = (
'post "https://api.github.com/graphql": eof',
"eof",
"timeout",
"i/o timeout",
"context deadline exceeded",
"client.timeout exceeded",
"request canceled",
"tls handshake timeout",
"remote error: tls",
"connection reset",
"connection reset by peer",
"connection refused",
"connection closed",
"connection aborted",
"broken pipe",
"temporary failure",
"temporarily unavailable",
"network is unreachable",
"server misbehaving",
"stream error",
"goaway",
"proxyconnect",
"http 500",
"http 502",
"http 503",
"http 504",
"500 internal server error",
"502 bad gateway",
"503 service unavailable",
"504 gateway timeout",
)


class GhCommandError(RuntimeError):
Expand Down Expand Up @@ -82,6 +114,11 @@ def format_command_error(error: GhCommandError) -> list[str]:
return lines


def looks_like_transport_error(message: str) -> bool:
lowered = message.lower()
return any(pattern in lowered for pattern in TRANSPORT_ERROR_PATTERNS)


def _diagnose_command_error(error: GhCommandError) -> _Diagnosis:
lowered = str(error).lower()
if _looks_like_rate_limit_error(lowered):
Expand All @@ -105,17 +142,17 @@ def _diagnose_command_error(error: GhCommandError) -> _Diagnosis:
),
)

if _is_graphql_backed_command(error.cmd) and _looks_like_transport_error(lowered):
if _is_graphql_backed_command(error.cmd) and looks_like_transport_error(lowered):
attempt_suffix = _format_attempt_suffix(error)
return _Diagnosis(
headline=f"GitHub GraphQL request failed{attempt_suffix}.",
category="GraphQL transport / network",
explanation=(
"The request appears to have failed while GitHub GraphQL data was being fetched. "
"This usually points to transient network, proxy, TLS, or GitHub-side transport issues."
"This usually points to transient network, proxy, TLS, or GitHub-side transport issues. "
"The direct REST and GraphQL probes are the useful source of truth here."
),
next_commands=(
_auth_status_command(),
_REST_PROBE_COMMAND,
_GRAPHQL_PROBE_COMMAND,
display_command_with("doctor"),
Expand Down Expand Up @@ -152,21 +189,6 @@ def _is_graphql_backed_command(cmd: Sequence[str]) -> bool:
return tuple(str(part) for part in cmd[:3]) in _GRAPHQL_BACKED_COMMANDS


def _looks_like_transport_error(lowered: str) -> bool:
patterns = (
'post "https://api.github.com/graphql": eof',
"eof",
"timeout",
"tls handshake timeout",
"connection reset",
"connection refused",
"temporary failure",
"network is unreachable",
"server misbehaving",
)
return any(pattern in lowered for pattern in patterns)


def _looks_like_auth_error(lowered: str) -> bool:
patterns = (
"authentication failed",
Expand Down
47 changes: 26 additions & 21 deletions src/gh_llm/github_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from typing import TYPE_CHECKING, cast
from urllib.parse import quote, urlparse

from gh_llm.diagnostics import GhCommandError
from gh_llm.diagnostics import GhCommandError, looks_like_transport_error
from gh_llm.invocation import display_command, display_command_with
from gh_llm.models import (
CheckItem,
Expand All @@ -37,9 +37,10 @@
MAX_INLINE_TEXT = 8000
MAX_INLINE_LINES = 200
DEFAULT_REVIEW_DIFF_HUNK_LINES = 12
GRAPHQL_MAX_ATTEMPTS = 4
GRAPHQL_MAX_ATTEMPTS = 6
GRAPHQL_MUTATION_MAX_ATTEMPTS = 4
GRAPHQL_BACKOFF_BASE_SECONDS = 0.25
GRAPHQL_BACKOFF_MAX_SECONDS = 2.0
GRAPHQL_BACKOFF_MAX_SECONDS = 4.0
DETAILS_BLOCK_RE = re.compile(r"(?is)<details\b[^>]*>(.*?)</details>")
SUMMARY_RE = re.compile(r"(?is)<summary\b[^>]*>(.*?)</summary>")
HTML_TAG_RE = re.compile(r"(?is)<[^>]+>")
Expand Down Expand Up @@ -1528,7 +1529,12 @@ def _try_update_pull_request_review_comment(self, *, comment_id: str, body: str)
return updated_id or None

def _get_viewer_login(self) -> str:
payload = _run_command_json(["gh", "api", "user"])
payload = _run_command_json(
["gh", "api", "user"],
max_attempts=GRAPHQL_MAX_ATTEMPTS,
backoff_base_seconds=GRAPHQL_BACKOFF_BASE_SECONDS,
backoff_max_seconds=GRAPHQL_BACKOFF_MAX_SECONDS,
)
login = _as_optional_str(payload.get("login"))
return login or ""

Expand Down Expand Up @@ -2160,7 +2166,7 @@ def _run_graphql_payload(query: str, variables: dict[str, str | int]) -> dict[st
cmd.extend(["-F", f"{key}={value}"])
return _run_command_json(
cmd,
max_attempts=GRAPHQL_MAX_ATTEMPTS,
max_attempts=_graphql_query_max_attempts(query),
backoff_base_seconds=GRAPHQL_BACKOFF_BASE_SECONDS,
backoff_max_seconds=GRAPHQL_BACKOFF_MAX_SECONDS,
)
Expand All @@ -2175,12 +2181,18 @@ def _run_graphql_payload_any(query: str, variables: dict[str, object]) -> dict[s
cmd.extend(["-F", f"{key}={value}"])
return _run_command_json(
cmd,
max_attempts=GRAPHQL_MAX_ATTEMPTS,
max_attempts=_graphql_query_max_attempts(query),
backoff_base_seconds=GRAPHQL_BACKOFF_BASE_SECONDS,
backoff_max_seconds=GRAPHQL_BACKOFF_MAX_SECONDS,
)


def _graphql_query_max_attempts(query: str) -> int:
if query.lstrip().startswith("mutation"):
return GRAPHQL_MUTATION_MAX_ATTEMPTS
return GRAPHQL_MAX_ATTEMPTS
Comment thread
ShigureNyako marked this conversation as resolved.


def _run_command_json(
cmd: list[str],
*,
Expand All @@ -2199,7 +2211,8 @@ def _run_command_json(
return {str(k): v for k, v in raw.items()}

stderr = result.stderr.strip()
if attempt >= attempts or not _is_retryable_gh_error(stderr):
error_output = _combine_command_error_output(result.stderr, result.stdout)
if attempt >= attempts or not looks_like_transport_error(error_output):
raise GhCommandError(
cmd=cmd,
stderr=stderr,
Expand Down Expand Up @@ -2228,7 +2241,8 @@ def _run_command_json_any(
return json.loads(result.stdout)

stderr = result.stderr.strip()
if attempt >= attempts or not _is_retryable_gh_error(stderr):
error_output = _combine_command_error_output(result.stderr, result.stdout)
if attempt >= attempts or not looks_like_transport_error(error_output):
raise GhCommandError(
cmd=cmd,
stderr=stderr,
Expand Down Expand Up @@ -2256,7 +2270,8 @@ def _run_command_text(
if result.returncode == 0:
return result.stdout
stderr = result.stderr.strip()
if attempt >= attempts or not _is_retryable_gh_error(stderr):
error_output = _combine_command_error_output(result.stderr, result.stdout)
if attempt >= attempts or not looks_like_transport_error(error_output):
raise GhCommandError(
cmd=cmd,
stderr=stderr,
Expand Down Expand Up @@ -3656,18 +3671,8 @@ def _reaction_emoji(content: str) -> str:
return mapping.get(content, "")


def _is_retryable_gh_error(stderr: str) -> bool:
lowered = stderr.lower()
retryable_patterns = (
'post "https://api.github.com/graphql": eof',
"eof",
"timeout",
"tls handshake timeout",
"connection reset",
"connection refused",
"temporary failure",
)
return any(pattern in lowered for pattern in retryable_patterns)
def _combine_command_error_output(stderr: str, stdout: str) -> str:
return "\n".join(part.strip() for part in (stderr, stdout) if part.strip())


def _is_check_run_passed(*, status: str, conclusion: str | None) -> bool:
Expand Down
Loading