Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

230 lines
7.0 KiB
Python

"""Read-only GitHub queries via the ``gh`` CLI.
Pure-function shape on top of an injectable ``run_command`` callback so
tests don't need to mock ``asyncio.subprocess``. The tool exposes a
restricted set of operations:
* ``pr`` — view a pull request (title, state, checks, reviews)
* ``issue`` — view an issue (title, state, body, comments)
* ``run`` — list recent workflow runs for a repo
* ``repo`` — show repo metadata
* ``api`` — generic ``gh api`` GET (read-only fallback for things not
covered by the four shortcuts above)
**Read-only by construction**: the command vocabulary below contains
*only* ``view`` / ``list`` and the ``api`` action is always invoked as a
GET. The tool does not expose mutation verbs (``close``, ``merge``,
``comment``, ``edit`` …) — the model can't get them through this
interface even by trying to be creative with ``target``.
If ``gh`` is not installed (which is the common case on most deploys)
:py:func:`run_github_query` returns a friendly ``ok=False`` outcome
instead of raising, so the LLM gets a clear "tool unavailable on this
system" message that it can relay to the user.
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
import logging
import shutil
from typing import Awaitable, Callable
logger = logging.getLogger(__name__)
DEFAULT_TIMEOUT_S = 20.0
MAX_OUTPUT_CHARS = 16_000
QueryType = str # "pr" | "issue" | "run" | "repo" | "api"
# Mapping from (query_type, target) → argv. Each is a strict *read-only*
# template — no mutation verbs.
_PR_FIELDS = "title,state,statusCheckRollup,reviews,url,author,createdAt,body"
_ISSUE_FIELDS = "title,state,body,comments,url,author,createdAt,labels"
_REPO_FIELDS = "description,defaultBranchRef,stargazerCount,forkCount,visibility,url"
_RUN_FIELDS = "name,status,conclusion,workflowName,event,createdAt,url"
@dataclass(frozen=True)
class GithubOutcome:
"""Result of one ``gh`` invocation."""
ok: bool
output: str = ""
error: str = ""
query_type: str = ""
target: str = ""
# A "command runner" — given an argv list and a timeout, returns
# (returncode, stdout, stderr). Injectable so the tool stays unit-
# testable without spawning real subprocesses.
CommandRunner = Callable[[list[str], float], Awaitable[tuple[int, str, str]]]
async def run_github_query(
*,
query_type: str,
target: str,
timeout_s: float = DEFAULT_TIMEOUT_S,
command_runner: CommandRunner | None = None,
gh_available: Callable[[], bool] | None = None,
) -> GithubOutcome:
"""Run one read-only ``gh`` command and return its output.
Arguments
---------
query_type : "pr" | "issue" | "run" | "repo" | "api"
Determines which read-only template to use.
target : str
``owner/repo[#number]`` or full URL for ``pr`` / ``issue``;
``owner/repo`` for ``run`` and ``repo``; raw ``gh api`` path
(e.g. ``users/octocat``) for ``api``.
"""
q = (query_type or "").strip().lower()
t = (target or "").strip()
if not q:
return GithubOutcome(ok=False, error="query_type is required.")
if not t:
return GithubOutcome(ok=False, query_type=q, error="target is required.")
is_available = gh_available or _default_gh_available
if not is_available():
return GithubOutcome(
ok=False,
query_type=q,
target=t,
error=(
"The `gh` CLI is not installed on this server, so GitHub "
"queries can't run here. Tell the user; they may need to "
"ask their admin to install it."
),
)
argv = _build_argv(q, t)
if argv is None:
return GithubOutcome(
ok=False,
query_type=q,
target=t,
error=(f"Unsupported query_type {q!r}. Choose one of: pr, issue, run, repo, api."),
)
runner = command_runner or _default_command_runner
try:
rc, stdout, stderr = await runner(argv, timeout_s)
except asyncio.TimeoutError:
return GithubOutcome(
ok=False,
query_type=q,
target=t,
error=f"`gh` timed out after {timeout_s:g}s.",
)
except Exception as exc: # pragma: no cover — defensive
return GithubOutcome(
ok=False,
query_type=q,
target=t,
error=f"`gh` invocation failed: {exc}",
)
if rc != 0:
err_line = (stderr or stdout).strip().splitlines()[:3]
return GithubOutcome(
ok=False,
query_type=q,
target=t,
error=" / ".join(err_line) or f"gh exited with code {rc}.",
)
out = stdout.strip()
if len(out) > MAX_OUTPUT_CHARS:
out = out[:MAX_OUTPUT_CHARS].rstrip() + "\n…[truncated]"
return GithubOutcome(ok=True, output=out, query_type=q, target=t)
# ---------------------------------------------------------------------------
# Internals
# ---------------------------------------------------------------------------
def _build_argv(query_type: str, target: str) -> list[str] | None:
if query_type == "pr":
return [
"gh",
"pr",
"view",
target,
"--json",
_PR_FIELDS,
]
if query_type == "issue":
return [
"gh",
"issue",
"view",
target,
"--json",
_ISSUE_FIELDS,
]
if query_type == "run":
return [
"gh",
"run",
"list",
"--repo",
target,
"--limit",
"10",
"--json",
_RUN_FIELDS,
]
if query_type == "repo":
return [
"gh",
"repo",
"view",
target,
"--json",
_REPO_FIELDS,
]
if query_type == "api":
# ``gh api`` defaults to GET; we never pass ``--method`` so even
# a creative ``target`` like ``-X POST repos/...`` can't sneak
# through because the shell isn't invoked.
return ["gh", "api", "-H", "Accept: application/vnd.github+json", target]
return None
def _default_gh_available() -> bool:
return shutil.which("gh") is not None
async def _default_command_runner(argv: list[str], timeout_s: float) -> tuple[int, str, str]:
"""Run ``argv`` as a subprocess, time-bounded, capturing stdout+stderr."""
proc = await asyncio.create_subprocess_exec(
*argv,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=timeout_s)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
raise
return (
proc.returncode if proc.returncode is not None else -1,
stdout_b.decode("utf-8", errors="replace") if stdout_b else "",
stderr_b.decode("utf-8", errors="replace") if stderr_b else "",
)
__all__ = [
"DEFAULT_TIMEOUT_S",
"GithubOutcome",
"QueryType",
"run_github_query",
]