-
-
Notifications
You must be signed in to change notification settings - Fork 4
✨ feat: add doctor diagnostics and layered GraphQL error hints #31
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
SigureMo
merged 5 commits into
ShigureLab:main
from
ShigureNyako:fix/doctor-graphql-diagnostics
Mar 22, 2026
Merged
Changes from 3 commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
7139807
✨ feat: add doctor diagnostics and layered gh error hints
ShigureNyako ff14ee7
🩹 fix: follow up doctor review feedback
ShigureNyako 359775f
🎨 style: format CLI regression tests
ShigureNyako 504ecc7
🩹 fix: keep only doctor command
ShigureNyako 2384501
🩹 fix: align doctor env and auth hints
ShigureNyako File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,334 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import os | ||
| import shlex | ||
| import shutil | ||
| import subprocess | ||
| import sys | ||
| from dataclasses import dataclass | ||
| from pathlib import Path | ||
| from typing import TYPE_CHECKING, cast | ||
| from urllib.parse import SplitResult, urlsplit, urlunsplit | ||
|
|
||
| from gh_llm import __version__ | ||
| from gh_llm.invocation import detect_prog_name, display_command | ||
|
|
||
| if TYPE_CHECKING: | ||
| from collections.abc import Sequence | ||
| from typing import Any | ||
|
|
||
| _GRAPHQL_PROBE_QUERY = "query{viewer{login}}" | ||
| _DEFAULT_TARGET_HOST = "github.com" | ||
| _ENV_KEYS = ( | ||
| "GH_LLM_DISPLAY_CMD", | ||
| "GH_HOST", | ||
| "GH_TOKEN", | ||
| "GITHUB_TOKEN", | ||
| "http_proxy", | ||
| "https_proxy", | ||
| "HTTP_PROXY", | ||
| "HTTPS_PROXY", | ||
| "ALL_PROXY", | ||
| "NO_PROXY", | ||
|
ShigureNyako marked this conversation as resolved.
|
||
| ) | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class _CommandResult: | ||
| ok: bool | ||
| output: str | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class _ProbeResult: | ||
| name: str | ||
| command: str | ||
| ok: bool | ||
| summary: str | ||
| detail: str = "" | ||
| critical: bool = True | ||
|
|
||
|
|
||
| def register_doctor_parser(subparsers: Any) -> None: | ||
| doctor_parser = subparsers.add_parser( | ||
| "doctor", | ||
| help="show environment, auth, and connectivity diagnostics", | ||
| ) | ||
| doctor_parser.set_defaults(handler=cmd_doctor) | ||
|
|
||
| env_parser = subparsers.add_parser( | ||
| "env", | ||
| help="alias of `doctor`", | ||
| ) | ||
| env_parser.set_defaults(handler=cmd_doctor) | ||
|
ShigureNyako marked this conversation as resolved.
Outdated
|
||
|
|
||
|
|
||
| def cmd_doctor(_: Any) -> int: | ||
| entrypoint = display_command() | ||
| argv0 = detect_prog_name(sys.argv[0]) | ||
| target_host = _resolve_target_host() | ||
| critical_probes = ( | ||
| _probe_entrypoint_version(entrypoint), | ||
| _probe_gh_version(), | ||
| _probe_auth_status(target_host), | ||
| _probe_rest_user(), | ||
| _probe_graphql_viewer(), | ||
| ) | ||
| failed = [probe.name for probe in critical_probes if not probe.ok and probe.critical] | ||
|
|
||
| lines: list[str] = [ | ||
| "## Entrypoint", | ||
| f"- entrypoint: {entrypoint}", | ||
| f"- argv0: {argv0}", | ||
| f"- argv0_path: {_resolve_argv0_path(sys.argv[0], argv0)}", | ||
| f"- entrypoint_path: {_resolve_entrypoint_path(entrypoint)}", | ||
| f"- gh_path: {_resolve_binary_path('gh')}", | ||
| f"- gh_llm_path: {_resolve_binary_path('gh-llm')}", | ||
| f"- target_host: {target_host}", | ||
| f"- python: {sys.executable}", | ||
| f"- cwd: {Path.cwd()}", | ||
| "", | ||
| "## Versions", | ||
| f"- package: {__version__}", | ||
| f"- python: {sys.version.split()[0]}", | ||
| ] | ||
| lines.extend(_render_probe_lines(critical_probes[:2])) | ||
| lines.extend( | ||
| [ | ||
| "", | ||
| "## Environment", | ||
| ] | ||
| ) | ||
| lines.extend(_render_environment_lines()) | ||
| lines.extend( | ||
| [ | ||
| "", | ||
| "## Auth", | ||
| ] | ||
| ) | ||
| lines.extend(_render_probe_lines((critical_probes[2],))) | ||
| lines.extend( | ||
| [ | ||
| "", | ||
| "## Probes", | ||
| ] | ||
| ) | ||
| lines.extend(_render_probe_lines(critical_probes[3:])) | ||
| lines.extend( | ||
| [ | ||
| "", | ||
| "## Summary", | ||
| f"status: {'ok' if not failed else 'unhealthy'}", | ||
| ] | ||
| ) | ||
| if failed: | ||
| lines.append(f"failed_checks: {', '.join(failed)}") | ||
|
|
||
| for line in lines: | ||
| print(line) | ||
| return 0 if not failed else 1 | ||
|
|
||
|
|
||
| def _render_probe_lines(probes: Sequence[_ProbeResult]) -> list[str]: | ||
| lines: list[str] = [] | ||
| for probe in probes: | ||
| lines.append(f"- {probe.name} (`{probe.command}`): {probe.summary}") | ||
| detail = probe.detail.strip() | ||
| if detail and detail != probe.summary: | ||
| lines.extend(_indent_block(detail)) | ||
| return lines | ||
|
|
||
|
|
||
| def _render_environment_lines() -> list[str]: | ||
| lines: list[str] = [] | ||
| for key in _ENV_KEYS: | ||
| lines.append(f"- {key}: {_format_env_value(key, os.environ.get(key))}") | ||
| return lines | ||
|
|
||
|
|
||
| def _probe_entrypoint_version(entrypoint: str) -> _ProbeResult: | ||
| command = _split_command(entrypoint, suffix=("--version",)) | ||
| rendered = shlex.join(command) | ||
| result = _run_command(command) | ||
| return _probe_from_command_result( | ||
| name="entrypoint version", | ||
| command=rendered, | ||
| result=result, | ||
| success_summary=result.output.strip() or "ok", | ||
| ) | ||
|
|
||
|
|
||
| def _probe_gh_version() -> _ProbeResult: | ||
| command = ["gh", "--version"] | ||
| result = _run_command(command) | ||
| return _probe_from_command_result( | ||
| name="gh version", | ||
| command=shlex.join(command), | ||
| result=result, | ||
| success_summary=(result.output.splitlines()[0].strip() if result.output.strip() else "ok"), | ||
| ) | ||
|
|
||
|
|
||
| def _probe_auth_status(target_host: str) -> _ProbeResult: | ||
| command = ["gh", "auth", "status", "--active", "--hostname", target_host] | ||
| result = _run_command(command) | ||
| summary = "ok" if result.ok else "failed" | ||
| return _ProbeResult( | ||
| name="auth status", | ||
| command=shlex.join(command), | ||
| ok=result.ok, | ||
| summary=summary, | ||
| detail=result.output, | ||
| ) | ||
|
|
||
|
|
||
| def _probe_rest_user() -> _ProbeResult: | ||
| command = ["gh", "api", "user"] | ||
| result = _run_command(command) | ||
| if not result.ok: | ||
| return _ProbeResult( | ||
| name="REST user probe", | ||
| command=shlex.join(command), | ||
| ok=False, | ||
| summary="failed", | ||
| detail=result.output, | ||
| ) | ||
|
|
||
| login = _extract_json_login(result.output, path=("login",)) | ||
| summary = f"ok (@{login})" if login else "ok" | ||
| return _ProbeResult( | ||
| name="REST user probe", | ||
| command=shlex.join(command), | ||
| ok=True, | ||
| summary=summary, | ||
| detail="", | ||
| ) | ||
|
|
||
|
|
||
| def _probe_graphql_viewer() -> _ProbeResult: | ||
| command = ["gh", "api", "graphql", "-f", f"query={_GRAPHQL_PROBE_QUERY}"] | ||
| result = _run_command(command) | ||
| if not result.ok: | ||
| return _ProbeResult( | ||
| name="GraphQL viewer probe", | ||
| command="gh api graphql -f query='query{viewer{login}}'", | ||
| ok=False, | ||
| summary="failed", | ||
| detail=result.output, | ||
| ) | ||
|
|
||
| login = _extract_json_login(result.output, path=("data", "viewer", "login")) | ||
| summary = f"ok (@{login})" if login else "ok" | ||
| return _ProbeResult( | ||
| name="GraphQL viewer probe", | ||
| command="gh api graphql -f query='query{viewer{login}}'", | ||
| ok=True, | ||
| summary=summary, | ||
| detail="", | ||
| ) | ||
|
|
||
|
|
||
| def _probe_from_command_result( | ||
| *, | ||
| name: str, | ||
| command: str, | ||
| result: _CommandResult, | ||
| success_summary: str, | ||
| ) -> _ProbeResult: | ||
| if result.ok: | ||
| return _ProbeResult(name=name, command=command, ok=True, summary=success_summary, detail=result.output) | ||
| return _ProbeResult(name=name, command=command, ok=False, summary="failed", detail=result.output) | ||
|
|
||
|
|
||
| def _run_command(cmd: Sequence[str]) -> _CommandResult: | ||
| try: | ||
| completed = subprocess.run(list(cmd), check=False, capture_output=True, text=True) | ||
| except OSError as error: | ||
| return _CommandResult(ok=False, output=str(error)) | ||
| return _CommandResult(ok=completed.returncode == 0, output=_merge_outputs(completed.stdout, completed.stderr)) | ||
|
|
||
|
|
||
| def _merge_outputs(stdout: str, stderr: str) -> str: | ||
| parts = [part.strip() for part in (stdout, stderr) if part.strip()] | ||
| return "\n\n".join(parts) | ||
|
|
||
|
|
||
| def _extract_json_login(payload: str, *, path: Sequence[str]) -> str | None: | ||
| try: | ||
| parsed = json.loads(payload) | ||
| except json.JSONDecodeError: | ||
| return None | ||
|
|
||
| current: object = parsed | ||
| for key in path: | ||
| if not isinstance(current, dict): | ||
| return None | ||
| current_dict = cast("dict[str, object]", current) | ||
| current = current_dict.get(key) | ||
| if isinstance(current, str) and current.strip(): | ||
| return current.strip() | ||
| return None | ||
|
|
||
|
|
||
| def _indent_block(text: str) -> list[str]: | ||
| return [f" {line}" if line else "" for line in text.splitlines()] | ||
|
|
||
|
|
||
| def _split_command(command: str, *, suffix: Sequence[str] = ()) -> list[str]: | ||
| base = shlex.split(command) | ||
| return [*base, *suffix] | ||
|
|
||
|
|
||
| def _resolve_argv0_path(argv0_raw: str, argv0_name: str) -> str: | ||
| if argv0_raw: | ||
| candidate = Path(argv0_raw) | ||
| if candidate.exists(): | ||
| return str(candidate.resolve()) | ||
| return _resolve_binary_path(argv0_name) | ||
|
|
||
|
|
||
| def _resolve_entrypoint_path(entrypoint: str) -> str: | ||
| parts = shlex.split(entrypoint) | ||
| if not parts: | ||
| return "(unknown)" | ||
| return _resolve_binary_path(parts[0]) | ||
|
|
||
|
|
||
| def _resolve_binary_path(name: str) -> str: | ||
| resolved = shutil.which(name) | ||
| return resolved or "(not found)" | ||
|
|
||
|
|
||
| def _resolve_target_host() -> str: | ||
| raw = os.environ.get("GH_HOST", "").strip() | ||
| return raw or _DEFAULT_TARGET_HOST | ||
|
|
||
|
|
||
| def _format_env_value(key: str, value: str | None) -> str: | ||
| if value is None or not value.strip(): | ||
| return "(unset)" | ||
| if key in {"GH_TOKEN", "GITHUB_TOKEN"}: | ||
| return "(set)" | ||
| return _redact_url_credentials(value.strip()) | ||
|
|
||
|
|
||
| def _redact_url_credentials(value: str) -> str: | ||
| if "://" not in value or "@" not in value: | ||
| return value | ||
| parsed = urlsplit(value) | ||
| if not parsed.hostname: | ||
| return value | ||
| netloc = parsed.hostname | ||
| if parsed.port is not None: | ||
| netloc = f"{netloc}:{parsed.port}" | ||
| if parsed.username is not None: | ||
| netloc = f"***@{netloc}" | ||
| redacted = SplitResult( | ||
| scheme=parsed.scheme, | ||
| netloc=netloc, | ||
| path=parsed.path, | ||
| query=parsed.query, | ||
| fragment=parsed.fragment, | ||
| ) | ||
| return urlunsplit(redacted) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.