08e6ee575b
Co-authored-by: Codex <codex@noreply.local>
386 lines
16 KiB
TypeScript
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),
|
|
};
|
|
}
|