Skip to content

Commit 308b673

Browse files
committed
🔀 merge upstream/main into feat/repo-preflight-onboarding
2 parents 0bfa0e9 + 0dc7548 commit 308b673

8 files changed

Lines changed: 878 additions & 9 deletions

File tree

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,20 @@ gh-llm pr thread-resolve PRRT_xxx --pr 77900 --repo PaddlePaddle/Paddle
151151
gh-llm pr thread-unresolve PRRT_xxx --pr 77900 --repo PaddlePaddle/Paddle
152152
```
153153

154+
### Environment Diagnosis
155+
156+
```bash
157+
gh-llm doctor
158+
gh llm doctor
159+
```
160+
161+
`doctor` prints the current entrypoint, resolved executable paths, `gh` / `gh-llm` versions,
162+
active-host `gh auth status`, a REST probe, a minimal GraphQL probe, and proxy-related environment variables.
163+
164+
When `gh-llm` hits transport errors such as GraphQL `EOF` / timeout failures, the CLI now reports the
165+
retry count and suggests concrete follow-up commands such as `gh auth status`,
166+
`gh api graphql -f query='query{viewer{login}}'`, and `gh-llm doctor`.
167+
154168
## PR Review Workflow
155169

156170
### 1) Start from diff hunks

skills/github-conversation/SKILL.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,17 @@ Command prefix mapping:
4848
1. If installed via `uv tool`, use `gh-llm ...`.
4949
2. If installed as `gh` extension, use `gh llm ...`.
5050

51+
### Environment preflight / troubleshooting
52+
53+
When `gh-llm` fails with unclear transport or auth symptoms (for example GraphQL `EOF`, timeout, or an environment mismatch between `gh-llm` and `gh llm`), run:
54+
55+
```bash
56+
gh-llm doctor
57+
gh llm doctor
58+
```
59+
60+
`doctor` prints the current entrypoint, resolved executable paths, active-host `gh auth status`, a REST probe, a minimal GraphQL probe, and proxy-related environment variables. Use this before guessing whether the issue is auth, network, proxy, or GitHub-side.
61+
5162
## Fast start
5263

5364
### Preflight an unfamiliar repo

src/gh_llm/cli.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55
import sys
66

77
from gh_llm import __version__
8+
from gh_llm.commands.doctor import register_doctor_parser
89
from gh_llm.commands.issue import register_issue_parser
910
from gh_llm.commands.pr import (
1011
parse_event_indexes as _parse_event_indexes,
1112
parse_review_ids as _parse_review_ids,
1213
register_pr_parser,
1314
)
1415
from gh_llm.commands.repo import register_repo_parser
16+
from gh_llm.diagnostics import GhCommandError, format_command_error
1517
from gh_llm.invocation import detect_prog_name
1618

1719

@@ -26,6 +28,10 @@ def run(argv: list[str]) -> int:
2628

2729
try:
2830
return int(handler(args))
31+
except GhCommandError as error:
32+
for line in format_command_error(error):
33+
print(line, file=sys.stderr)
34+
return 1
2935
except (RuntimeError, ValueError) as error:
3036
print(f"error: {error}", file=sys.stderr)
3137
return 1
@@ -56,6 +62,7 @@ def _build_parser() -> argparse.ArgumentParser:
5662
register_pr_parser(subparsers)
5763
register_issue_parser(subparsers)
5864
register_repo_parser(subparsers)
65+
register_doctor_parser(subparsers)
5966

6067
return parser
6168

src/gh_llm/commands/doctor.py

Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import os
5+
import shlex
6+
import shutil
7+
import subprocess
8+
import sys
9+
from dataclasses import dataclass
10+
from pathlib import Path
11+
from typing import TYPE_CHECKING, cast
12+
from urllib.parse import SplitResult, urlsplit, urlunsplit
13+
14+
from gh_llm import __version__
15+
from gh_llm.environment import build_auth_status_command, resolve_target_host
16+
from gh_llm.invocation import detect_prog_name, display_command
17+
18+
if TYPE_CHECKING:
19+
from collections.abc import Sequence
20+
from typing import Any
21+
22+
_GRAPHQL_PROBE_QUERY = "query{viewer{login}}"
23+
_ENV_KEYS = (
24+
"GH_LLM_DISPLAY_CMD",
25+
"GH_HOST",
26+
"GH_TOKEN",
27+
"GITHUB_TOKEN",
28+
"http_proxy",
29+
"https_proxy",
30+
"HTTP_PROXY",
31+
"HTTPS_PROXY",
32+
"all_proxy",
33+
"ALL_PROXY",
34+
"no_proxy",
35+
"NO_PROXY",
36+
)
37+
38+
39+
@dataclass(frozen=True)
40+
class _CommandResult:
41+
ok: bool
42+
output: str
43+
44+
45+
@dataclass(frozen=True)
46+
class _ProbeResult:
47+
name: str
48+
command: str
49+
ok: bool
50+
summary: str
51+
detail: str = ""
52+
critical: bool = True
53+
54+
55+
def register_doctor_parser(subparsers: Any) -> None:
56+
doctor_parser = subparsers.add_parser(
57+
"doctor",
58+
help="show environment, auth, and connectivity diagnostics",
59+
)
60+
doctor_parser.set_defaults(handler=cmd_doctor)
61+
62+
63+
def cmd_doctor(_: Any) -> int:
64+
entrypoint = display_command()
65+
argv0 = detect_prog_name(sys.argv[0])
66+
target_host = resolve_target_host()
67+
critical_probes = (
68+
_probe_entrypoint_version(entrypoint),
69+
_probe_gh_version(),
70+
_probe_auth_status(target_host),
71+
_probe_rest_user(),
72+
_probe_graphql_viewer(),
73+
)
74+
failed = [probe.name for probe in critical_probes if not probe.ok and probe.critical]
75+
76+
lines: list[str] = [
77+
"## Entrypoint",
78+
f"- entrypoint: {entrypoint}",
79+
f"- argv0: {argv0}",
80+
f"- argv0_path: {_resolve_argv0_path(sys.argv[0], argv0)}",
81+
f"- entrypoint_path: {_resolve_entrypoint_path(entrypoint)}",
82+
f"- gh_path: {_resolve_binary_path('gh')}",
83+
f"- gh_llm_path: {_resolve_binary_path('gh-llm')}",
84+
f"- target_host: {target_host}",
85+
f"- python: {sys.executable}",
86+
f"- cwd: {Path.cwd()}",
87+
"",
88+
"## Versions",
89+
f"- package: {__version__}",
90+
f"- python: {sys.version.split()[0]}",
91+
]
92+
lines.extend(_render_probe_lines(critical_probes[:2]))
93+
lines.extend(
94+
[
95+
"",
96+
"## Environment",
97+
]
98+
)
99+
lines.extend(_render_environment_lines())
100+
lines.extend(
101+
[
102+
"",
103+
"## Auth",
104+
]
105+
)
106+
lines.extend(_render_probe_lines((critical_probes[2],)))
107+
lines.extend(
108+
[
109+
"",
110+
"## Probes",
111+
]
112+
)
113+
lines.extend(_render_probe_lines(critical_probes[3:]))
114+
lines.extend(
115+
[
116+
"",
117+
"## Summary",
118+
f"status: {'ok' if not failed else 'unhealthy'}",
119+
]
120+
)
121+
if failed:
122+
lines.append(f"failed_checks: {', '.join(failed)}")
123+
124+
for line in lines:
125+
print(line)
126+
return 0 if not failed else 1
127+
128+
129+
def _render_probe_lines(probes: Sequence[_ProbeResult]) -> list[str]:
130+
lines: list[str] = []
131+
for probe in probes:
132+
lines.append(f"- {probe.name} (`{probe.command}`): {probe.summary}")
133+
detail = probe.detail.strip()
134+
if detail and detail != probe.summary:
135+
lines.extend(_indent_block(detail))
136+
return lines
137+
138+
139+
def _render_environment_lines() -> list[str]:
140+
lines: list[str] = []
141+
for key in _ENV_KEYS:
142+
lines.append(f"- {key}: {_format_env_value(key, os.environ.get(key))}")
143+
return lines
144+
145+
146+
def _probe_entrypoint_version(entrypoint: str) -> _ProbeResult:
147+
command = _split_command(entrypoint, suffix=("--version",))
148+
rendered = shlex.join(command)
149+
result = _run_command(command)
150+
return _probe_from_command_result(
151+
name="entrypoint version",
152+
command=rendered,
153+
result=result,
154+
success_summary=result.output.strip() or "ok",
155+
)
156+
157+
158+
def _probe_gh_version() -> _ProbeResult:
159+
command = ["gh", "--version"]
160+
result = _run_command(command)
161+
return _probe_from_command_result(
162+
name="gh version",
163+
command=shlex.join(command),
164+
result=result,
165+
success_summary=(result.output.splitlines()[0].strip() if result.output.strip() else "ok"),
166+
)
167+
168+
169+
def _probe_auth_status(target_host: str) -> _ProbeResult:
170+
command = list(build_auth_status_command(target_host=target_host))
171+
result = _run_command(command)
172+
summary = "ok" if result.ok else "failed"
173+
return _ProbeResult(
174+
name="auth status",
175+
command=shlex.join(command),
176+
ok=result.ok,
177+
summary=summary,
178+
detail=result.output,
179+
)
180+
181+
182+
def _probe_rest_user() -> _ProbeResult:
183+
command = ["gh", "api", "user"]
184+
result = _run_command(command)
185+
if not result.ok:
186+
return _ProbeResult(
187+
name="REST user probe",
188+
command=shlex.join(command),
189+
ok=False,
190+
summary="failed",
191+
detail=result.output,
192+
)
193+
194+
login = _extract_json_login(result.output, path=("login",))
195+
summary = f"ok (@{login})" if login else "ok"
196+
return _ProbeResult(
197+
name="REST user probe",
198+
command=shlex.join(command),
199+
ok=True,
200+
summary=summary,
201+
detail="",
202+
)
203+
204+
205+
def _probe_graphql_viewer() -> _ProbeResult:
206+
command = ["gh", "api", "graphql", "-f", f"query={_GRAPHQL_PROBE_QUERY}"]
207+
result = _run_command(command)
208+
if not result.ok:
209+
return _ProbeResult(
210+
name="GraphQL viewer probe",
211+
command="gh api graphql -f query='query{viewer{login}}'",
212+
ok=False,
213+
summary="failed",
214+
detail=result.output,
215+
)
216+
217+
login = _extract_json_login(result.output, path=("data", "viewer", "login"))
218+
summary = f"ok (@{login})" if login else "ok"
219+
return _ProbeResult(
220+
name="GraphQL viewer probe",
221+
command="gh api graphql -f query='query{viewer{login}}'",
222+
ok=True,
223+
summary=summary,
224+
detail="",
225+
)
226+
227+
228+
def _probe_from_command_result(
229+
*,
230+
name: str,
231+
command: str,
232+
result: _CommandResult,
233+
success_summary: str,
234+
) -> _ProbeResult:
235+
if result.ok:
236+
return _ProbeResult(name=name, command=command, ok=True, summary=success_summary, detail=result.output)
237+
return _ProbeResult(name=name, command=command, ok=False, summary="failed", detail=result.output)
238+
239+
240+
def _run_command(cmd: Sequence[str]) -> _CommandResult:
241+
try:
242+
completed = subprocess.run(list(cmd), check=False, capture_output=True, text=True)
243+
except OSError as error:
244+
return _CommandResult(ok=False, output=str(error))
245+
return _CommandResult(ok=completed.returncode == 0, output=_merge_outputs(completed.stdout, completed.stderr))
246+
247+
248+
def _merge_outputs(stdout: str, stderr: str) -> str:
249+
parts = [part.strip() for part in (stdout, stderr) if part.strip()]
250+
return "\n\n".join(parts)
251+
252+
253+
def _extract_json_login(payload: str, *, path: Sequence[str]) -> str | None:
254+
try:
255+
parsed = json.loads(payload)
256+
except json.JSONDecodeError:
257+
return None
258+
259+
current: object = parsed
260+
for key in path:
261+
if not isinstance(current, dict):
262+
return None
263+
current_dict = cast("dict[str, object]", current)
264+
current = current_dict.get(key)
265+
if isinstance(current, str) and current.strip():
266+
return current.strip()
267+
return None
268+
269+
270+
def _indent_block(text: str) -> list[str]:
271+
return [f" {line}" if line else "" for line in text.splitlines()]
272+
273+
274+
def _split_command(command: str, *, suffix: Sequence[str] = ()) -> list[str]:
275+
base = shlex.split(command)
276+
return [*base, *suffix]
277+
278+
279+
def _resolve_argv0_path(argv0_raw: str, argv0_name: str) -> str:
280+
if argv0_raw:
281+
candidate = Path(argv0_raw)
282+
if candidate.exists():
283+
return str(candidate.resolve())
284+
return _resolve_binary_path(argv0_name)
285+
286+
287+
def _resolve_entrypoint_path(entrypoint: str) -> str:
288+
parts = shlex.split(entrypoint)
289+
if not parts:
290+
return "(unknown)"
291+
return _resolve_binary_path(parts[0])
292+
293+
294+
def _resolve_binary_path(name: str) -> str:
295+
resolved = shutil.which(name)
296+
return resolved or "(not found)"
297+
298+
299+
def _format_env_value(key: str, value: str | None) -> str:
300+
if value is None or not value.strip():
301+
return "(unset)"
302+
if key in {"GH_TOKEN", "GITHUB_TOKEN"}:
303+
return "(set)"
304+
return _redact_url_credentials(value.strip())
305+
306+
307+
def _redact_url_credentials(value: str) -> str:
308+
if "://" not in value or "@" not in value:
309+
return value
310+
parsed = urlsplit(value)
311+
if not parsed.hostname:
312+
return value
313+
netloc = parsed.hostname
314+
if parsed.port is not None:
315+
netloc = f"{netloc}:{parsed.port}"
316+
if parsed.username is not None:
317+
netloc = f"***@{netloc}"
318+
redacted = SplitResult(
319+
scheme=parsed.scheme,
320+
netloc=netloc,
321+
path=parsed.path,
322+
query=parsed.query,
323+
fragment=parsed.fragment,
324+
)
325+
return urlunsplit(redacted)

0 commit comments

Comments
 (0)