fix(dev): bound gh-read API waits

This commit is contained in:
Vincent Koc
2026-05-27 14:14:05 +02:00
parent e153eceea5
commit 5fb57b533e
2 changed files with 104 additions and 18 deletions

View File

@@ -2,12 +2,14 @@ import { execFileSync, spawnSync } from "node:child_process";
import { createPrivateKey, createSign } from "node:crypto";
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { parseStrictIntegerOption } from "./lib/dev-tooling-safety.ts";
const APP_ID_ENV = "OPENCLAW_GH_READ_APP_ID";
const KEY_FILE_ENV = "OPENCLAW_GH_READ_PRIVATE_KEY_FILE";
const INSTALLATION_ID_ENV = "OPENCLAW_GH_READ_INSTALLATION_ID";
const PERMISSIONS_ENV = "OPENCLAW_GH_READ_PERMISSIONS";
const API_VERSION = "2022-11-28";
const DEFAULT_GITHUB_FETCH_TIMEOUT_MS = 30_000;
const DEFAULT_READ_PERMISSION_KEYS = [
"actions",
"checks",
@@ -32,6 +34,11 @@ type AccessTokenResponse = {
token: string;
};
type GitHubJsonOptions = {
fetchImpl?: typeof fetch;
timeoutMs?: number;
};
export function parseRepoArg(args: string[]): string | null {
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
@@ -91,6 +98,15 @@ export function buildReadPermissions(
return permissions;
}
export function resolveGitHubFetchTimeoutMs(raw = process.env.OPENCLAW_GH_READ_FETCH_TIMEOUT_MS) {
return parseStrictIntegerOption({
fallback: DEFAULT_GITHUB_FETCH_TIMEOUT_MS,
label: "OPENCLAW_GH_READ_FETCH_TIMEOUT_MS",
min: 1,
raw,
});
}
function isMainModule() {
const entry = process.argv[1];
return entry ? import.meta.url === pathToFileURL(entry).href : false;
@@ -151,32 +167,65 @@ function createAppJwt(appId: string, privateKeyPem: string) {
return `${signingInput}.${base64UrlEncode(signature)}`;
}
async function githubJson<T>(
async function withGitHubFetchTimeout<T>(
label: string,
timeoutMs: number,
run: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
const controller = new AbortController();
let timeout: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<T>((_resolve, reject) => {
timeout = setTimeout(() => {
const error = new Error(`${label} exceeded timeout of ${timeoutMs}ms`);
reject(error);
controller.abort(error);
}, timeoutMs);
});
try {
return await Promise.race([run(controller.signal), timeoutPromise]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}
export async function githubJson<T>(
path: string,
bearerToken: string,
init?: {
method?: "GET" | "POST";
body?: unknown;
},
options: GitHubJsonOptions = {},
): Promise<T> {
const response = await fetch(`https://api.github.com${path}`, {
method: init?.method ?? "GET",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${bearerToken}`,
"Content-Type": "application/json",
"User-Agent": "openclaw-gh-read",
"X-GitHub-Api-Version": API_VERSION,
const fetchImpl = options.fetchImpl ?? fetch;
const timeoutMs = options.timeoutMs ?? resolveGitHubFetchTimeoutMs();
return await withGitHubFetchTimeout(
`GitHub API ${init?.method ?? "GET"} ${path}`,
timeoutMs,
async (signal) => {
const response = await fetchImpl(`https://api.github.com${path}`, {
method: init?.method ?? "GET",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${bearerToken}`,
"Content-Type": "application/json",
"User-Agent": "openclaw-gh-read",
"X-GitHub-Api-Version": API_VERSION,
},
body: init?.body === undefined ? undefined : JSON.stringify(init.body),
signal,
});
if (!response.ok) {
const text = await response.text();
fail(`${init?.method ?? "GET"} ${path} failed (${response.status}): ${text}`);
}
return (await response.json()) as T;
},
body: init?.body === undefined ? undefined : JSON.stringify(init.body),
});
if (!response.ok) {
const text = await response.text();
fail(`${init?.method ?? "GET"} ${path} failed (${response.status}): ${text}`);
}
return (await response.json()) as T;
);
}
async function resolveInstallation(

View File

@@ -1,9 +1,11 @@
import { describe, expect, it } from "vitest";
import {
buildReadPermissions,
githubJson,
normalizeRepo,
parsePermissionKeys,
parseRepoArg,
resolveGitHubFetchTimeoutMs,
} from "../../scripts/gh-read.js";
describe("gh-read helpers", () => {
@@ -49,4 +51,39 @@ describe("gh-read helpers", () => {
"issues",
]);
});
it("aborts stalled GitHub API fetches at the request timeout", async () => {
let signal: AbortSignal | undefined;
const request = githubJson("/app", "token", undefined, {
timeoutMs: 5,
fetchImpl: ((_url, init) => {
signal = init?.signal ?? undefined;
return new Promise(() => {});
}) as typeof fetch,
});
await expect(request).rejects.toThrow(/GitHub API GET \/app exceeded timeout/u);
expect(signal?.aborted).toBe(true);
});
it("times out stalled GitHub API response body reads", async () => {
const response = {
ok: true,
status: 200,
json: () => new Promise(() => {}),
} as Response;
const request = githubJson("/app/installations", "token", undefined, {
timeoutMs: 5,
fetchImpl: (() => Promise.resolve(response)) as typeof fetch,
});
await expect(request).rejects.toThrow(/GitHub API GET \/app\/installations exceeded timeout/u);
});
it("rejects invalid GitHub API timeout values", () => {
expect(resolveGitHubFetchTimeoutMs("1000")).toBe(1000);
expect(() => resolveGitHubFetchTimeoutMs("1s")).toThrow(
/OPENCLAW_GH_READ_FETCH_TIMEOUT_MS must be an integer/u,
);
});
});