Files
pikasTech-unidesk/scripts/src/gh/client.ts
T
2026-06-26 00:57:39 +08:00

386 lines
16 KiB
TypeScript

// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split
// Moved mechanically from scripts/src/gh.ts:2087-2461.
import path from "node:path";
import { bodySafetySignals, preview, previewLines } from "./auth-and-safety";
import { isRecord } from "./notify-claudeqq";
import { GITHUB_API, REQUEST_TIMEOUT_MS, USER_AGENT } from "./types";
import type { GitHubCommandResult, GitHubDegradedReason, GitHubErrorPayload, GitHubOptions, GitHubTokenProbe, RunnerDisposition } from "./types";
export function writeBodyPlan(command: "issue create" | "issue comment create" | "issue comment update" | "pr create" | "pr comment" | "pr comment update", repo: string, body: string, bodySource: Record<string, unknown>, extra: Record<string, unknown> = {}): Record<string, unknown> {
const isIssueWrite = command === "issue create" || command === "issue comment create" || command === "issue comment update";
const isCommentUpdate = command === "issue comment update" || command === "pr comment update";
const source = String(bodySource.kind ?? "unknown");
const requestBody: Record<string, unknown> = {
bodyChars: body.length,
bodySource,
};
if (command === "issue create" && typeof extra.title === "string") requestBody.title = extra.title;
if (command === "issue create" && Array.isArray(extra.labels)) requestBody.labels = extra.labels;
return {
repo,
bodySource,
source,
bodyPreview: preview(body),
bodyPreviewLines: previewLines(body),
...bodySafetySignals(body),
request: {
method: isCommentUpdate ? "PATCH" : "POST",
path: isIssueWrite
? (command === "issue create"
? "/repos/{owner}/{repo}/issues"
: isCommentUpdate
? "/repos/{owner}/{repo}/issues/comments/{comment_id}"
: "/repos/{owner}/{repo}/issues/{issue_number}/comments")
: (command === "pr create"
? "/repos/{owner}/{repo}/pulls"
: isCommentUpdate
? "/repos/{owner}/{repo}/issues/comments/{comment_id}"
: "/repos/{owner}/{repo}/issues/{issue_number}/comments"),
body: requestBody,
},
validation: {
source,
rawText: source === "body-file"
? "read from file bytes without shell interpolation"
: source === "stdin"
? "read from stdin bytes"
: "short inline argument accepted only by command-specific safety rules",
},
...extra,
};
}
export function runnerDisposition(reason: GitHubDegradedReason): RunnerDisposition {
if (reason === "unsupported-command" || reason === "validation-failed" || reason === "issue-not-found" || reason === "pr-not-found") return "business-failed";
return "infra-blocked";
}
export function sanitizedErrorDetails(parsed: unknown): unknown {
if (typeof parsed !== "object" || parsed === null) return parsed;
const source = parsed as Record<string, unknown>;
const details: Record<string, unknown> = {};
if ("message" in source) details.message = source.message;
if ("documentation_url" in source) details.documentationUrl = source.documentation_url;
if ("status" in source) details.status = source.status;
if (Array.isArray(source.errors)) details.errors = source.errors.slice(0, 5);
if ("raw" in source) details.raw = source.raw;
return details;
}
export function errorProperty(value: unknown, key: string): string | null {
if (!isRecord(value)) return null;
const item = value[key];
return typeof item === "string" && item.length > 0 ? item : null;
}
export function errorChainText(error: unknown): string {
const parts: string[] = [];
let current: unknown = error;
const seen = new Set<unknown>();
while (current !== null && current !== undefined && !seen.has(current)) {
seen.add(current);
if (current instanceof Error) {
parts.push(current.name, current.message);
current = (current as Error & { cause?: unknown }).cause;
continue;
}
if (isRecord(current)) {
for (const key of ["name", "message", "code", "errno", "syscall", "hostname", "host"]) {
const value = errorProperty(current, key);
if (value !== null) parts.push(value);
}
current = current.cause;
continue;
}
parts.push(String(current));
break;
}
return parts.join(" ").replace(/\s+/gu, " ").trim();
}
export function firstErrorProperty(error: unknown, key: string): string | null {
let current: unknown = error;
const seen = new Set<unknown>();
while (current !== null && current !== undefined && !seen.has(current)) {
seen.add(current);
const value = errorProperty(current, key);
if (value !== null) return value;
current = isRecord(current) ? current.cause : current instanceof Error ? (current as Error & { cause?: unknown }).cause : null;
}
return null;
}
export function classifyGitHubFetchFailure(error: unknown): {
reason: GitHubDegradedReason;
message: string;
retryable?: boolean;
commanderAction?: string;
details: Record<string, unknown>;
} {
const text = errorChainText(error);
const lower = text.toLowerCase();
const isProxyFailure = /\bproxy\b/u.test(lower) && /could not resolve|connect|tunnel|proxy/i.test(text);
const isGitHubTransient = !isProxyFailure && (
/temporary failure in name resolution/iu.test(text)
|| /\b(eai_again|enotfound|etimedout|econnreset|econnrefused|ehostunreach|enetunreach)\b/iu.test(text)
|| /getaddrinfo|could not resolve host|name or service not known|failed to connect|connection timed out|connection reset|connection closed|other side closed|network is unreachable|socket connection/iu.test(text)
);
if (!isGitHubTransient) {
return {
reason: "network-proxy-failed",
message: text.length > 0 ? text : "GitHub request failed before receiving an HTTP response",
details: {
scope: "github-api",
transient: false,
errorName: error instanceof Error ? error.name : firstErrorProperty(error, "name"),
errorCode: firstErrorProperty(error, "code"),
host: firstErrorProperty(error, "hostname") ?? firstErrorProperty(error, "host"),
},
};
}
return {
reason: "github-transient",
message: text.length > 0 ? text : "GitHub DNS/API connection failed before receiving an HTTP response",
retryable: true,
commanderAction: "retry-backoff-or-keep-running-if-heartbeat-fresh",
details: {
scope: "github-api",
transient: true,
retryable: true,
authFailure: false,
semanticFailure: false,
errorName: error instanceof Error ? error.name : firstErrorProperty(error, "name"),
errorCode: firstErrorProperty(error, "code"),
host: firstErrorProperty(error, "hostname") ?? firstErrorProperty(error, "host"),
commanderAction: "retry/backoff; if the Code Queue task heartbeat or trace is fresh, keep supervising instead of closing or requeueing business work",
valuesPrinted: false,
},
};
}
export function errorPayload(reason: GitHubDegradedReason, message: string, extra: Omit<GitHubErrorPayload, "ok" | "degradedReason" | "runnerDisposition" | "message"> = {}): GitHubErrorPayload {
return {
ok: false,
degradedReason: reason,
runnerDisposition: runnerDisposition(reason),
message,
...extra,
};
}
export function commandError(command: string, repo: string, error: GitHubErrorPayload, extra: Record<string, unknown> = {}): GitHubCommandResult {
return {
ok: false,
command,
repo,
degradedReason: error.degradedReason,
runnerDisposition: error.runnerDisposition,
details: error,
...extra,
};
}
export function validationError(command: string, repo: string, message: string, extra: Record<string, unknown> = {}): GitHubCommandResult {
const error = errorPayload("validation-failed", message, { details: extra });
return commandError(command, repo, error, extra);
}
export function unsupportedCommand(command: string, repo: string, message: string, extra: Record<string, unknown> = {}): GitHubCommandResult {
const error = errorPayload("unsupported-command", message, { details: extra });
return commandError(command, repo, error, { message, ...extra });
}
export async function parseGitHubResponse(response: Response): Promise<unknown> {
const text = await response.text();
if (text.length === 0) return null;
try {
return JSON.parse(text) as unknown;
} catch {
return { raw: preview(text) };
}
}
export function classifyHttpStatus(status: number, message: string, path: string, response: Response): GitHubDegradedReason {
const lower = message.toLowerCase();
const acceptedScopes = response.headers.get("x-accepted-oauth-scopes");
if (status === 401) return "auth-failed";
if (status === 403) {
if (lower.includes("resource not accessible") || lower.includes("insufficient") || lower.includes("scope") || (acceptedScopes !== null && acceptedScopes.length > 0)) return "scope-insufficient";
if (path.startsWith("/repos/")) return "repo-forbidden";
return "permission-denied";
}
if (status === 404) {
if (/\/pulls\/\d+/u.test(path)) return "pr-not-found";
if (/\/issues\/\d+/u.test(path)) return "issue-not-found";
if (path.includes("/branches/") || path.includes("/compare/")) return "validation-failed";
return "repo-not-found";
}
if (status === 422) return "validation-failed";
return "invalid-response";
}
export async function githubRequest<T>(
token: string,
method: string,
path: string,
body?: Record<string, unknown>,
): Promise<T | GitHubErrorPayload> {
let response: Response;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
response = await fetch(`${GITHUB_API}${path}`, {
method,
signal: controller.signal,
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
"X-GitHub-Api-Version": "2022-11-28",
},
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch (error) {
const classified = classifyGitHubFetchFailure(error);
return errorPayload(classified.reason, classified.message, {
request: { method, path },
details: classified.details,
retryable: classified.retryable,
commanderAction: classified.commanderAction,
});
} finally {
clearTimeout(timeout);
}
const parsed = await parseGitHubResponse(response);
if (!response.ok) {
const message = typeof parsed === "object" && parsed !== null && "message" in parsed ? String((parsed as { message?: unknown }).message) : response.statusText;
return errorPayload(classifyHttpStatus(response.status, message, path, response), message, {
status: response.status,
details: sanitizedErrorDetails(parsed),
scopes: {
accepted: response.headers.get("x-accepted-oauth-scopes"),
token: response.headers.get("x-oauth-scopes"),
},
request: { method, path },
});
}
return parsed as T;
}
export async function githubGraphqlRequest<T>(
token: string,
query: string,
variables: Record<string, unknown>,
): Promise<T | GitHubErrorPayload> {
let response: Response;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
response = await fetch(`${GITHUB_API}/graphql`, {
method: "POST",
signal: controller.signal,
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
"X-GitHub-Api-Version": "2022-11-28",
},
body: JSON.stringify({ query, variables }),
});
} catch (error) {
const classified = classifyGitHubFetchFailure(error);
return errorPayload(classified.reason, classified.message, {
request: { method: "POST", path: "/graphql" },
details: classified.details,
retryable: classified.retryable,
commanderAction: classified.commanderAction,
});
} finally {
clearTimeout(timeout);
}
const parsed = await parseGitHubResponse(response);
if (!response.ok) {
const message = typeof parsed === "object" && parsed !== null && "message" in parsed ? String((parsed as { message?: unknown }).message) : response.statusText;
return errorPayload(classifyHttpStatus(response.status, message, "/graphql", response), message, {
status: response.status,
details: sanitizedErrorDetails(parsed),
scopes: {
accepted: response.headers.get("x-accepted-oauth-scopes"),
token: response.headers.get("x-oauth-scopes"),
},
request: { method: "POST", path: "/graphql" },
});
}
if (!isRecord(parsed)) {
return errorPayload("invalid-response", "GitHub GraphQL response was not an object", { details: parsed });
}
if (Array.isArray(parsed.errors) && parsed.errors.length > 0) {
const firstError = parsed.errors[0];
const message = isRecord(firstError) && typeof firstError.message === "string" ? firstError.message : "GitHub GraphQL query failed";
return errorPayload("validation-failed", message, {
details: sanitizedErrorDetails(parsed),
request: { method: "POST", path: "/graphql" },
});
}
const data = parsed.data;
if (!isRecord(data)) {
return errorPayload("invalid-response", "GitHub GraphQL response did not include an object data payload", { details: sanitizedErrorDetails(parsed) });
}
return data as T;
}
export function authRequired(repo: string, command: string, tokenProbe: GitHubTokenProbe): GitHubCommandResult | null {
if (!tokenProbe.present) {
if (tokenProbe.ghFallbackAttempted && tokenProbe.ghBinaryFound === false) {
return commandError(command, repo, errorPayload("missing-binary", "gh binary is missing and no GH_TOKEN/GITHUB_TOKEN is available", { details: tokenProbe }));
}
if (tokenProbe.ghFallbackAttempted && tokenProbe.ghBinaryFound === true && tokenProbe.ghAuthTokenAvailable === false) {
return commandError(command, repo, errorPayload("auth-failed", "gh auth token failed and no GH_TOKEN/GITHUB_TOKEN is available", { details: tokenProbe }));
}
return commandError(command, repo, errorPayload("missing-token", "GH_TOKEN or GITHUB_TOKEN is required", { details: tokenProbe }), {
degraded: ["missing-token"],
token: tokenProbe,
});
}
return null;
}
export function isGitHubError(value: unknown): value is GitHubErrorPayload {
return typeof value === "object" && value !== null && (value as { ok?: unknown }).ok === false && "degradedReason" in value;
}
export function issueBodyReadCommands(repo: string, issueNumber: number): Record<string, string> {
return {
body: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --json body`,
full: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --full`,
raw: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --raw`,
};
}
export function issueCommentReadCommands(repo: string, issueNumber: number): Record<string, string> {
return {
comments: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --json comments`,
comment: `bun scripts/cli.ts gh issue comment view <commentId> --repo ${repo} --full`,
full: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --full`,
raw: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --raw`,
};
}
export function issueWriteDisclosure(options: GitHubOptions, repo: string, issueNumber: number, dryRun: boolean): Record<string, unknown> {
const explicitFullDisclosure = options.raw || options.full;
return {
defaultCompact: dryRun || !explicitFullDisclosure,
explicitFullDisclosure,
fullBodyIncluded: explicitFullDisclosure && !dryRun,
bodyOmitted: dryRun || !explicitFullDisclosure,
dryRunBoundedPreview: dryRun,
note: explicitFullDisclosure && !dryRun
? "The returned issue object includes the full body because --full or --raw was explicitly requested."
: "Default issue write output omits full issue.body; use readCommands.full/raw or gh issue view --json body when full text is needed.",
readCommands: issueBodyReadCommands(repo, issueNumber),
};
}