diff --git a/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.test.ts b/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.test.ts new file mode 100644 index 000000000..3baffe46c --- /dev/null +++ b/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import type { GitHubAppInstallationStatus } from "../../../shared/types"; +import { isGitHubAppRepoAccessPending, statusView } from "./GitHubAppInstallPanel"; + +function makeStatus(overrides: Partial = {}): GitHubAppInstallationStatus { + return { + repo: { owner: "arul28", name: "ADE" }, + appName: "ADE", + appSlug: "ade-for-github", + installUrl: "https://github.com/apps/ade-for-github/installations/new", + manageUrl: "https://github.com/settings/installations", + relayConfigured: true, + installed: false, + state: "error", + installationId: null, + repositorySelection: null, + lastSeenAt: null, + webhookEvents: [], + missingWebhookEvents: [], + webhookState: "unknown", + webhookLastSeenAt: null, + checkedAt: "2026-07-02T18:39:42.000Z", + error: "Not Found", + ...overrides, + }; +} + +describe("GitHubAppInstallPanel status helpers", () => { + it("treats post-authorization GitHub repo 404s as pending repo access", () => { + const status = makeStatus(); + const view = statusView(status, false, true); + + expect(isGitHubAppRepoAccessPending(status)).toBe(true); + expect(view.label).toBe("Checking access"); + expect(view.description("arul28/ADE")).toContain("GitHub accepted authorization"); + expect(view.description("arul28/ADE")).toContain("select this repo in GitHub"); + }); + + it("keeps non-propagation relay failures as check failures after authorization", () => { + const status = makeStatus({ error: "GitHub App relay status check failed (500)" }); + const view = statusView(status, false, true); + + expect(isGitHubAppRepoAccessPending(status)).toBe(false); + expect(view.label).toBe("Check failed"); + expect(view.description("arul28/ADE")).toBe("GitHub App relay status check failed (500)"); + }); + + it("detects repository-not-found error variants as pending repo access", () => { + expect(isGitHubAppRepoAccessPending(makeStatus({ error: "Repository not found." }))).toBe(true); + expect(isGitHubAppRepoAccessPending(makeStatus({ error: "Could not resolve to a Repository." }))).toBe(true); + }); + + it("ignores repo access errors when status guards do not match", () => { + expect(isGitHubAppRepoAccessPending(makeStatus({ relayConfigured: false }))).toBe(false); + expect(isGitHubAppRepoAccessPending(makeStatus({ installed: true }))).toBe(false); + expect(isGitHubAppRepoAccessPending(makeStatus({ state: "not_installed" }))).toBe(false); + }); +}); diff --git a/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.tsx b/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.tsx index 8044adff2..a1ec11647 100644 --- a/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.tsx +++ b/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.tsx @@ -12,6 +12,7 @@ import { COLORS, MONO_FONT, SANS_FONT, cardStyle, inlineBadge, outlineButton, pr const ADE_GITHUB_APP_NAME = "ADE"; const ADE_GITHUB_APP_INSTALL_URL = "https://github.com/apps/ade-for-github/installations/new"; const GITHUB_APP_INSTALLATIONS_URL = "https://github.com/settings/installations"; +const POST_AUTH_STATUS_RETRY_DELAYS_MS = [1_500, 3_000, 6_000] as const; type GitHubAppInstallPanelProps = { variant?: "settings" | "onboarding"; @@ -29,14 +30,35 @@ export function GitHubAppInstallPanel({ variant = "settings" }: GitHubAppInstall const autoRenewCountRef = useRef(0); const copyFeedbackTimeoutRef = useRef(null); const appAuthRef = useRef(null); + const statusRequestSeqRef = useRef(0); + const mountedRef = useRef(true); appAuthRef.current = appAuth; - const loadStatus = useCallback(async (forceRefresh = false) => { + const loadStatus = useCallback(async (forceRefresh = false, opts: { retryAfterAuthorization?: boolean } = {}) => { if (!window.ade?.github?.getAppInstallationStatus) return; + const requestSeq = statusRequestSeqRef.current + 1; + statusRequestSeqRef.current = requestSeq; setLoading(true); + let latestStatus: GitHubAppInstallationStatus | null = null; try { - setStatus(await window.ade.github.getAppInstallationStatus({ forceRefresh })); + const attemptCount = opts.retryAfterAuthorization + ? POST_AUTH_STATUS_RETRY_DELAYS_MS.length + 1 + : 1; + for (let attempt = 0; attempt < attemptCount; attempt += 1) { + latestStatus = await window.ade.github.getAppInstallationStatus({ + forceRefresh: forceRefresh || attempt > 0, + }); + if (!mountedRef.current || statusRequestSeqRef.current !== requestSeq) return; + setStatus(latestStatus); + if (!opts.retryAfterAuthorization || !isGitHubAppRepoAccessPending(latestStatus)) break; + const retryDelay = POST_AUTH_STATUS_RETRY_DELAYS_MS[attempt]; + if (retryDelay == null) break; + setDeviceMessage("GitHub authorization is complete. Waiting for repository access to appear..."); + await sleepMs(retryDelay); + if (!mountedRef.current || statusRequestSeqRef.current !== requestSeq) return; + } } catch (error) { + if (!mountedRef.current || statusRequestSeqRef.current !== requestSeq) return; setStatus({ repo: null, appName: ADE_GITHUB_APP_NAME, @@ -57,12 +79,24 @@ export function GitHubAppInstallPanel({ variant = "settings" }: GitHubAppInstall error: error instanceof Error ? error.message : String(error), }); } finally { + const isCurrentRequest = () => mountedRef.current && statusRequestSeqRef.current === requestSeq; // Read auth state AFTER the status call (success or failure): an // expired stored token can be cleared during the status check, and the // panel must reflect that immediately. - const authStatus = await window.ade.github.getAppUserAuthStatus?.().catch(() => null); - setAppAuth(authStatus ?? null); - setLoading(false); + if (isCurrentRequest()) { + const authStatus = await window.ade.github.getAppUserAuthStatus?.().catch(() => null); + if (isCurrentRequest()) { + setAppAuth(authStatus ?? null); + if (opts.retryAfterAuthorization) { + setDeviceMessage( + latestStatus && isGitHubAppRepoAccessPending(latestStatus) + ? "GitHub authorization is complete. Repository access is still warming up; use Refresh again in a moment." + : null, + ); + } + setLoading(false); + } + } } }, []); @@ -145,7 +179,8 @@ export function GitHubAppInstallPanel({ variant = "settings" }: GitHubAppInstall setDeviceMessage(result.message); if (result.status === "authorized") { autoRenewCountRef.current = 0; - await loadStatus(true); + setDeviceMessage("GitHub authorization is complete. Checking repository access..."); + await loadStatus(true, { retryAfterAuthorization: true }); } }, Math.max(1, deviceSession.intervalSec) * 1000); return () => { @@ -159,7 +194,10 @@ export function GitHubAppInstallPanel({ variant = "settings" }: GitHubAppInstall }, [deviceSession?.sessionId]); useEffect(() => { + mountedRef.current = true; return () => { + mountedRef.current = false; + statusRequestSeqRef.current += 1; if (copyFeedbackTimeoutRef.current != null) { window.clearTimeout(copyFeedbackTimeoutRef.current); } @@ -171,6 +209,7 @@ export function GitHubAppInstallPanel({ variant = "settings" }: GitHubAppInstall }, [loadStatus]); const appAuthorized = appAuth?.tokenStored === true; + const repoAccessPending = appAuthorized && isGitHubAppRepoAccessPending(status); const view = statusView(status, loading, appAuthorized); const repoLabel = status?.repo ? `${status.repo.owner}/${status.repo.name}` : null; @@ -221,7 +260,7 @@ export function GitHubAppInstallPanel({ variant = "settings" }: GitHubAppInstall {!appAuthorized && !deviceSession ?

One-time GitHub sign-off that lets ADE verify your repo access for instant PR updates.

: null}
- {!appAuthorized || status?.state === "error" ? ( + {!appAuthorized || (status?.state === "error" && !repoAccessPending) ? (