mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-28 04:16:13 +00:00
fix(dev): bound gh-read API waits
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user