diff --git a/packages/web/server/lib/github/index.js b/packages/web/server/lib/github/index.js index 926584b7..893a26e5 100644 --- a/packages/web/server/lib/github/index.js +++ b/packages/web/server/lib/github/index.js @@ -21,6 +21,7 @@ export { export { getOctokitOrNull, + createOctokit, } from './octokit.js'; export { diff --git a/packages/web/server/lib/github/octokit.js b/packages/web/server/lib/github/octokit.js index b6cd15cc..caf05306 100644 --- a/packages/web/server/lib/github/octokit.js +++ b/packages/web/server/lib/github/octokit.js @@ -2,6 +2,26 @@ import { Octokit } from '@octokit/rest'; import { getGitHubAuth, isGhCliActive, isGhCliDisabled } from './auth.js'; import { getGhCliToken } from './gh-cli-credential.js'; +// Per-request timeout for every GitHub call. Octokit v22 uses native fetch, +// which has no built-in timeout — without this, a stuck connection hangs until +// some outer bound (the PR-status route's 12s overall budget) fires, and a +// single slow request can eat the whole budget. Bounding each request lets the +// caller fail fast and fall back to cached state instead. +const OCTOKIT_REQUEST_TIMEOUT_MS = 8000; + +const timeoutFetch = (url, options = {}) => { + // Respect a caller-provided signal if present; otherwise attach our timeout. + if (options.signal) { + return fetch(url, options); + } + return fetch(url, { ...options, signal: AbortSignal.timeout(OCTOKIT_REQUEST_TIMEOUT_MS) }); +}; + +/** Create an Octokit instance with a per-request timeout applied. */ +export function createOctokit(token) { + return new Octokit({ auth: token, request: { fetch: timeoutFetch } }); +} + export function getOctokitOrNull() { const auth = getGitHubAuth(); const ghToken = !isGhCliDisabled() ? getGhCliToken() : null; @@ -9,5 +29,5 @@ export function getOctokitOrNull() { if (!token) { return null; } - return new Octokit({ auth: token }); + return createOctokit(token); } diff --git a/packages/web/server/lib/github/routes.js b/packages/web/server/lib/github/routes.js index 6307c268..0f20ccc0 100644 --- a/packages/web/server/lib/github/routes.js +++ b/packages/web/server/lib/github/routes.js @@ -108,8 +108,8 @@ export function registerGitHubRoutes(app) { if (ghToken !== null && !ghCliDisabled) { try { - const { Octokit } = await import('@octokit/rest'); - ghCliUser = await getGitHubUserSummary(new Octokit({ auth: ghToken })); + const { createOctokit } = await import('./octokit.js'); + ghCliUser = await getGitHubUserSummary(createOctokit(ghToken)); } catch { ghCliUser = null; } @@ -246,8 +246,8 @@ export function registerGitHubRoutes(app) { return res.status(500).json({ error: 'Missing access_token from GitHub' }); } - const { Octokit } = await import('@octokit/rest'); - const octokit = new Octokit({ auth: accessToken }); + const { createOctokit } = await import('./octokit.js'); + const octokit = createOctokit(accessToken); const user = await getGitHubUserSummary(octokit); setGitHubAuth({ @@ -283,8 +283,8 @@ export function registerGitHubRoutes(app) { return res.status(404).json({ error: 'GitHub CLI account not found' }); } - const { Octokit } = await import('@octokit/rest'); - const user = await getGitHubUserSummary(new Octokit({ auth: ghToken })); + const { createOctokit } = await import('./octokit.js'); + const user = await getGitHubUserSummary(createOctokit(ghToken)); setGhCliActive(true); const accounts = getGitHubAuthAccounts() .map((account) => ({ ...account, current: false })) @@ -319,8 +319,8 @@ export function registerGitHubRoutes(app) { let ghCliUser = null; if (ghToken) { try { - const { Octokit } = await import('@octokit/rest'); - ghCliUser = await getGitHubUserSummary(new Octokit({ auth: ghToken })); + const { createOctokit } = await import('./octokit.js'); + ghCliUser = await getGitHubUserSummary(createOctokit(ghToken)); accounts = accounts.concat({ id: GH_CLI_ACCOUNT_ID, user: ghCliUser,