Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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> = {}): 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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -29,14 +30,35 @@ export function GitHubAppInstallPanel({ variant = "settings" }: GitHubAppInstall
const autoRenewCountRef = useRef(0);
const copyFeedbackTimeoutRef = useRef<number | null>(null);
const appAuthRef = useRef<GitHubAppUserAuthStatus | null>(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,
Expand All @@ -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);
}
}
}
}, []);

Expand Down Expand Up @@ -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 () => {
Expand All @@ -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);
}
Expand All @@ -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;

Expand Down Expand Up @@ -221,7 +260,7 @@ export function GitHubAppInstallPanel({ variant = "settings" }: GitHubAppInstall
{!appAuthorized && !deviceSession ? <p style={authMessageStyle}>One-time GitHub sign-off that lets ADE verify your repo access for instant PR updates.</p> : null}

<div style={actionRowStyle}>
{!appAuthorized || status?.state === "error" ? (
{!appAuthorized || (status?.state === "error" && !repoAccessPending) ? (
<button
type="button"
style={
Expand Down Expand Up @@ -256,7 +295,7 @@ export function GitHubAppInstallPanel({ variant = "settings" }: GitHubAppInstall
<button
type="button"
style={outlineButton(compact ? compactSecondaryButtonStyle : undefined)}
onClick={() => void loadStatus(true)}
onClick={() => void loadStatus(true, { retryAfterAuthorization: appAuthorized })}
disabled={loading}
>
{loading ? <WarningCircle size={12} weight="bold" /> : <ArrowClockwise size={12} weight="bold" />}
Expand All @@ -267,7 +306,21 @@ export function GitHubAppInstallPanel({ variant = "settings" }: GitHubAppInstall
);
}

function statusView(status: GitHubAppInstallationStatus | null, loading: boolean, appAuthorized: boolean): {
function sleepMs(ms: number): Promise<void> {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
}

export function isGitHubAppRepoAccessPending(status: GitHubAppInstallationStatus | null): boolean {
if (status?.state !== "error" || !status.relayConfigured || status.installed) return false;
const error = status.error?.trim().toLowerCase() ?? "";
return error === "not found"
|| error.includes("repository not found")
|| error.includes("could not resolve to a repository");
}

export function statusView(status: GitHubAppInstallationStatus | null, loading: boolean, appAuthorized: boolean): {
label: string;
color: string;
description: (repoLabel: string | null) => string;
Expand Down Expand Up @@ -324,6 +377,16 @@ function statusView(status: GitHubAppInstallationStatus | null, loading: boolean
description: () => "Authorize ADE with GitHub to enable instant PR updates for this repo.",
};
}
if (isGitHubAppRepoAccessPending(status)) {
return {
label: "Checking access",
color: COLORS.warning,
description: (repoLabel) =>
repoLabel
? `GitHub accepted authorization. ADE is waiting for the GitHub App's repository access to become visible for ${repoLabel}. If this stays here, install the App or select this repo in GitHub.`
: "GitHub accepted authorization. ADE is waiting for the GitHub App's repository access to become visible. If this stays here, install the App or select this repo in GitHub.",
};
}
return {
label: "Check failed",
color: COLORS.danger,
Expand Down
10 changes: 7 additions & 3 deletions docs/features/onboarding-and-settings/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,13 @@ Renderer — settings:
`startAppUserDeviceAuth` surfaces the user code as a copyable chip plus a
waiting state and the verification URL, `pollAppUserDeviceAuth` drives the
poll loop and auto-renews an expired code up to 3 times, a pre-auth status
pill reflects `getAppUserAuthStatus` (stored token, signed-in login, expiry),
and `clearAppUserAuth` revokes the local token. Offers a Refresh. Rendered in
Settings and, in a compact `onboarding` variant, during setup. The
pill reflects `getAppUserAuthStatus` (stored token, signed-in login, expiry).
After device authorization succeeds, the panel force-refreshes the hosted
relay status with a short retry window and treats GitHub repo-access 404s as a
temporary "Checking access" state so GitHub App installation propagation does
not look like failed authorization. `clearAppUserAuth` revokes the local token.
Offers a Refresh. Rendered in Settings and, in a compact `onboarding` variant,
during setup. The
device-flow, token store, and single-flight refresh are backed by
`githubAppUserAuthService` in the main process (see the automations feature
doc's Source file map).
Expand Down
Loading