diff --git a/plugins/github/server.ts b/plugins/github/server.ts index 2daeebe8d..bb4025007 100644 --- a/plugins/github/server.ts +++ b/plugins/github/server.ts @@ -12,6 +12,10 @@ import { defineRpcContract, type BbPluginApi } from "@get-bb/plugin-sdk"; import { z } from "zod"; const SYNC_INTERVAL_MS = 5 * 60_000; +// Retry cadence while `gh auth status` fails for a reason that is not a +// configuration problem (network blip, locked keychain, slow host): start at +// 30 s and back off to the regular sync interval. +const SYNC_RETRY_BASE_MS = 30_000; const ISSUE_PAGE = 100; const CLOSED_ISSUE_PAGE = 50; const PR_PAGE = 50; @@ -310,6 +314,10 @@ function needsConfiguration(message: string): Error { }); } +function isNeedsConfigurationError(error: unknown): error is Error { + return error instanceof Error && error.name === "NeedsConfigurationError"; +} + /** owner/name from any GitHub remote URL (https, ssh, git@), else null. */ export function parseGithubRemote(url: string): string | null { const match = url @@ -497,14 +505,37 @@ export default async function plugin(bb: BbPluginApi) { return stdout; } + // `gh auth status` is a network probe (it calls the GitHub API), so a + // failure does not by itself mean gh is unconfigured. Only two outcomes + // are configuration problems worth latching needs-configuration on: + // gh missing, and gh present but holding no credentials at all + // (`gh auth token` is local-only, so it answers that without the network). + // Anything else (network down, keychain locked, slow host, timeout) is + // reported as a plain error so callers retry instead of latching. async function checkAuth(): Promise { try { await gh(["auth", "status"], 10_000); ghAuthError = null; + return; } catch (error) { ghAuthError = error instanceof Error ? error.message : String(error); + if (isNeedsConfigurationError(error)) throw error; // gh not found + } + let hasToken = true; + try { + await gh(["auth", "token"], 5_000); + } catch (error) { + // Only gh's own "no credentials" answer is a configuration problem; a + // timeout or a crash of the local check is treated as transient too. + const message = error instanceof Error ? error.message : String(error); + hasToken = !/no oauth token|not logged in/i.test(message); + } + if (!hasToken) { throw needsConfiguration(`GitHub CLI is not authenticated. ${GH_HINT}`); } + throw new Error( + `gh auth status failed; gh has credentials, so this is probably transient and will be retried: ${ghAuthError}`, + ); } // ------------------------------------------------------------------ @@ -730,10 +761,27 @@ export default async function plugin(bb: BbPluginApi) { // instead of crash-looping. bb.background.service("sync", { async start(signal) { + let failures = 0; while (!signal.aborted) { - await syncAll(); + let delayMs = SYNC_INTERVAL_MS; + try { + await syncAll(); + failures = 0; + } catch (error) { + if (isNeedsConfigurationError(error)) throw error; + failures += 1; + delayMs = Math.min( + SYNC_RETRY_BASE_MS * 2 ** (failures - 1), + SYNC_INTERVAL_MS, + ); + bb.log.warn( + `sync failed (retry in ${Math.round(delayMs / 1000)}s): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } await new Promise((resolve) => { - const timer = setTimeout(resolve, SYNC_INTERVAL_MS); + const timer = setTimeout(resolve, delayMs); signal.addEventListener( "abort", () => { @@ -752,9 +800,11 @@ export default async function plugin(bb: BbPluginApi) { try { await checkAuth(); } catch (error) { - bb.status.needsConfiguration( - error instanceof Error ? error.message : String(error), - ); + if (isNeedsConfigurationError(error)) { + bb.status.needsConfiguration(error.message); + } else { + bb.log.warn(error instanceof Error ? error.message : String(error)); + } } // ------------------------------------------------------------------ @@ -907,6 +957,15 @@ export default async function plugin(bb: BbPluginApi) { bb.rpc.register(githubRpcContract, { /** () → auth/sync status for the panel banner. */ async status() { + // Re-probe on demand after a failed probe so a recovered gh is noticed + // the next time the panel asks, not only on the next sync tick. + if (ghAuthError !== null) { + try { + await checkAuth(); + } catch { + // ghAuthError already carries the failure + } + } const cursor = await bb.storage.kv.get<{ lastSyncedAt: string; repos: number;