Skip to content

Commit cd0679a

Browse files
authored
fix: add deeplinking to posthog code git setup (#1733)
## TLDR The GitHub integration flow previously relied entirely on polling to detect when a user completed the GitHub App install in their browser. This meant the app had no reliable way to know when the flow succeeded or failed, and the UI couldn't react immediately or surface meaningful error states. ## Changes - Added a deep link handler in `GitHubIntegrationService` that listens for `integration` callbacks from PostHog Cloud after the GitHub App install completes. The service emits typed events (`Callback`, `FlowTimedOut`) and queues callbacks that arrive before the renderer has subscribed. - Added a 5-minute flow timeout in the service that fires `FlowTimedOut` if no deep link callback is received. - Exposed three new tRPC endpoints: `onCallback` and `onFlowTimedOut` subscriptions, and a `consumePendingCallback` query to drain any callback that arrived before the renderer mounted. - Added a `useGitHubIntegrationCallback` hook that subscribes to both events and drains any pending callback on mount, calling `onSuccess`, `onError`, or `onTimedOut` accordingly. - Polling is now only used in dev mode (where the deep link protocol is not registered) and as a safety fallback timeout in production. - The onboarding `GitIntegrationStep` now shows a timeout banner with a retry prompt, displays the connected GitHub account name when available, and surfaces a callout when GitHub is already connected on a different project. - `GitHubIntegrationSection` in settings follows the same pattern, replacing polling-based detection with the new callback hook. - The `next` redirect URL after GitHub authorization now points to `/account/social-connected` with query params instead of the project root, enabling the deep link callback to carry status, installation ID, and error details back to the app.
1 parent 2149edd commit cd0679a

7 files changed

Lines changed: 384 additions & 26 deletions

File tree

apps/code/src/main/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { registerMcpSandboxProtocol } from "./protocols/mcp-sandbox";
1313
import type { AppLifecycleService } from "./services/app-lifecycle/service";
1414
import type { AuthService } from "./services/auth/service";
1515
import type { ExternalAppsService } from "./services/external-apps/service";
16+
import type { GitHubIntegrationService } from "./services/github-integration/service";
1617
import type { NotificationService } from "./services/notification/service";
1718
import type { OAuthService } from "./services/oauth/service";
1819
import {
@@ -43,6 +44,7 @@ async function initializeServices(): Promise<void> {
4344
container.get<NotificationService>(MAIN_TOKENS.NotificationService);
4445
container.get<UpdatesService>(MAIN_TOKENS.UpdatesService);
4546
container.get<TaskLinkService>(MAIN_TOKENS.TaskLinkService);
47+
container.get<GitHubIntegrationService>(MAIN_TOKENS.GitHubIntegrationService);
4648
container.get<ExternalAppsService>(MAIN_TOKENS.ExternalAppsService);
4749
container.get<PosthogPluginService>(MAIN_TOKENS.PosthogPluginService);
4850

Lines changed: 116 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,146 @@
1+
import type { IMainWindow } from "@posthog/platform/main-window";
12
import type { IUrlLauncher } from "@posthog/platform/url-launcher";
23
import { getCloudUrlFromRegion } from "@shared/utils/urls";
34
import { inject, injectable } from "inversify";
45
import { MAIN_TOKENS } from "../../di/tokens";
56
import { logger } from "../../utils/logger";
7+
import { TypedEventEmitter } from "../../utils/typed-event-emitter";
8+
import type { DeepLinkService } from "../deep-link/service";
69
import type { CloudRegion, StartGitHubFlowOutput } from "./schemas";
710

811
const log = logger.scope("github-integration-service");
912

13+
const FLOW_TIMEOUT_MS = 5 * 60 * 1000;
14+
15+
export const GitHubIntegrationEvent = {
16+
Callback: "callback",
17+
FlowTimedOut: "flowTimedOut",
18+
} as const;
19+
20+
export interface IntegrationCallback {
21+
provider: string;
22+
projectId: number | null;
23+
installationId: string | null;
24+
status: "success" | "error";
25+
errorCode: string | null;
26+
errorMessage: string | null;
27+
}
28+
29+
export interface FlowTimedOut {
30+
projectId: number;
31+
}
32+
33+
export interface GitHubIntegrationEvents {
34+
[GitHubIntegrationEvent.Callback]: IntegrationCallback;
35+
[GitHubIntegrationEvent.FlowTimedOut]: FlowTimedOut;
36+
}
37+
1038
@injectable()
11-
export class GitHubIntegrationService {
39+
export class GitHubIntegrationService extends TypedEventEmitter<GitHubIntegrationEvents> {
40+
private pendingCallback: IntegrationCallback | null = null;
41+
private flowTimeout: ReturnType<typeof setTimeout> | null = null;
42+
1243
constructor(
13-
@inject(MAIN_TOKENS.UrlLauncher) private readonly urlLauncher: IUrlLauncher,
14-
) {}
44+
@inject(MAIN_TOKENS.DeepLinkService)
45+
private readonly deepLinkService: DeepLinkService,
46+
@inject(MAIN_TOKENS.UrlLauncher)
47+
private readonly urlLauncher: IUrlLauncher,
48+
@inject(MAIN_TOKENS.MainWindow)
49+
private readonly mainWindow: IMainWindow,
50+
) {
51+
super();
52+
53+
this.deepLinkService.registerHandler("integration", (_path, params) =>
54+
this.handleCallback(params),
55+
);
56+
}
1557

1658
public async startFlow(
1759
region: CloudRegion,
1860
projectId: number,
1961
): Promise<StartGitHubFlowOutput> {
2062
try {
2163
const cloudUrl = getCloudUrlFromRegion(region);
22-
const next = `${cloudUrl}/project/${projectId}`;
23-
const authorizeUrl = `${cloudUrl}/api/environments/${projectId}/integrations/authorize/?kind=github&next=${encodeURIComponent(next)}`;
64+
const nextPath = `/account/social-connected?provider=github&project_id=${projectId}&connect_from=posthog_code`;
65+
const authorizeUrl = `${cloudUrl}/api/environments/${projectId}/integrations/authorize/?kind=github&next=${encodeURIComponent(nextPath)}`;
66+
67+
this.clearFlowTimeout();
68+
this.flowTimeout = setTimeout(() => {
69+
log.warn("GitHub integration flow timed out", { projectId });
70+
this.flowTimeout = null;
71+
this.emit(GitHubIntegrationEvent.FlowTimedOut, { projectId });
72+
}, FLOW_TIMEOUT_MS);
2473

25-
log.info("Opening GitHub authorization URL in browser");
2674
await this.urlLauncher.launch(authorizeUrl);
2775

2876
return { success: true };
2977
} catch (error) {
78+
this.clearFlowTimeout();
79+
log.error("Failed to start GitHub integration flow", {
80+
projectId,
81+
error: error instanceof Error ? error.message : String(error),
82+
});
3083
return {
3184
success: false,
3285
error: error instanceof Error ? error.message : "Unknown error",
3386
};
3487
}
3588
}
89+
90+
public consumePendingCallback(): IntegrationCallback | null {
91+
const pending = this.pendingCallback;
92+
this.pendingCallback = null;
93+
return pending;
94+
}
95+
96+
private handleCallback(params: URLSearchParams): boolean {
97+
const projectIdRaw = params.get("project_id");
98+
const parsedProjectId = projectIdRaw ? Number(projectIdRaw) : null;
99+
const status = params.get("status") === "error" ? "error" : "success";
100+
101+
const callback: IntegrationCallback = {
102+
provider: params.get("provider") ?? "",
103+
projectId:
104+
parsedProjectId !== null && Number.isFinite(parsedProjectId)
105+
? parsedProjectId
106+
: null,
107+
installationId: params.get("installation_id") || null,
108+
status,
109+
errorCode: params.get("error_code") || null,
110+
errorMessage: params.get("error_message") || null,
111+
};
112+
113+
this.clearFlowTimeout();
114+
115+
if (status === "error") {
116+
log.error("Received integration callback with error", {
117+
provider: callback.provider,
118+
projectId: callback.projectId,
119+
errorCode: callback.errorCode,
120+
errorMessage: callback.errorMessage,
121+
});
122+
}
123+
124+
const hasListeners =
125+
this.listenerCount(GitHubIntegrationEvent.Callback) > 0;
126+
if (hasListeners) {
127+
this.emit(GitHubIntegrationEvent.Callback, callback);
128+
} else {
129+
this.pendingCallback = callback;
130+
}
131+
132+
if (this.mainWindow.isMinimized()) {
133+
this.mainWindow.restore();
134+
}
135+
this.mainWindow.focus();
136+
137+
return true;
138+
}
139+
140+
private clearFlowTimeout(): void {
141+
if (this.flowTimeout) {
142+
clearTimeout(this.flowTimeout);
143+
this.flowTimeout = null;
144+
}
145+
}
36146
}

apps/code/src/main/trpc/routers/github-integration.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import {
44
startGitHubFlowInput,
55
startGitHubFlowOutput,
66
} from "../../services/github-integration/schemas";
7-
import type { GitHubIntegrationService } from "../../services/github-integration/service";
7+
import {
8+
type FlowTimedOut,
9+
GitHubIntegrationEvent,
10+
type GitHubIntegrationService,
11+
type IntegrationCallback,
12+
} from "../../services/github-integration/service";
813
import { publicProcedure, router } from "../trpc";
914

1015
const getService = () =>
@@ -17,4 +22,40 @@ export const githubIntegrationRouter = router({
1722
.mutation(({ input }) =>
1823
getService().startFlow(input.region, input.projectId),
1924
),
25+
26+
/**
27+
* Subscribe to GitHub integration deep link callbacks emitted after the user
28+
* completes (or errors out of) the GitHub App install flow on PostHog Cloud.
29+
*/
30+
onCallback: publicProcedure.subscription(async function* (opts) {
31+
const service = getService();
32+
const iterable = service.toIterable(GitHubIntegrationEvent.Callback, {
33+
signal: opts.signal,
34+
});
35+
for await (const data of iterable) {
36+
yield data;
37+
}
38+
}),
39+
40+
/**
41+
* Subscribe to flow timeout events (5 minutes with no deep link callback).
42+
*/
43+
onFlowTimedOut: publicProcedure.subscription(async function* (opts) {
44+
const service = getService();
45+
const iterable = service.toIterable(GitHubIntegrationEvent.FlowTimedOut, {
46+
signal: opts.signal,
47+
});
48+
for await (const data of iterable) {
49+
yield data;
50+
}
51+
}),
52+
53+
/**
54+
* Get any integration callback that arrived before the renderer subscribed.
55+
*/
56+
consumePendingCallback: publicProcedure.query(
57+
(): IntegrationCallback | null => getService().consumePendingCallback(),
58+
),
2059
});
60+
61+
export type { IntegrationCallback, FlowTimedOut };
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { trpcClient, useTRPC } from "@renderer/trpc/client";
2+
import { useSubscription } from "@trpc/tanstack-react-query";
3+
import { logger } from "@utils/logger";
4+
import { useEffect, useRef } from "react";
5+
6+
const log = logger.scope("github-integration-callback-hook");
7+
8+
const DEFAULT_ERROR_MESSAGE =
9+
"GitHub install failed. Please try connecting again.";
10+
11+
interface Options {
12+
onSuccess: (projectId: number | null) => void;
13+
onError: (message: string) => void;
14+
onTimedOut?: () => void;
15+
}
16+
17+
/**
18+
* Subscribes to GitHub integration deep link callbacks and drains any pending
19+
* callback that arrived before the subscription was established (cold-start).
20+
*/
21+
export function useGitHubIntegrationCallback({
22+
onSuccess,
23+
onError,
24+
onTimedOut,
25+
}: Options): void {
26+
const trpcReact = useTRPC();
27+
const hasConsumedPendingRef = useRef(false);
28+
29+
const optsRef = useRef({ onSuccess, onError, onTimedOut });
30+
optsRef.current = { onSuccess, onError, onTimedOut };
31+
32+
useSubscription(
33+
trpcReact.githubIntegration.onCallback.subscriptionOptions(undefined, {
34+
onData: (data) => {
35+
log.info("Received integration deep link callback", data);
36+
if (data.status === "error") {
37+
optsRef.current.onError(data.errorMessage ?? DEFAULT_ERROR_MESSAGE);
38+
return;
39+
}
40+
optsRef.current.onSuccess(data.projectId);
41+
},
42+
}),
43+
);
44+
45+
useSubscription(
46+
trpcReact.githubIntegration.onFlowTimedOut.subscriptionOptions(undefined, {
47+
onData: (data) => {
48+
log.info("GitHub integration flow timed out", data);
49+
optsRef.current.onTimedOut?.();
50+
},
51+
}),
52+
);
53+
54+
useEffect(() => {
55+
if (hasConsumedPendingRef.current) return;
56+
hasConsumedPendingRef.current = true;
57+
void (async () => {
58+
try {
59+
const pending =
60+
await trpcClient.githubIntegration.consumePendingCallback.query();
61+
if (!pending) return;
62+
log.info("Consumed pending integration callback on mount", pending);
63+
if (pending.status === "error") {
64+
optsRef.current.onError(
65+
pending.errorMessage ?? DEFAULT_ERROR_MESSAGE,
66+
);
67+
return;
68+
}
69+
optsRef.current.onSuccess(pending.projectId);
70+
} catch (error) {
71+
log.error("Failed to consume pending integration callback", error);
72+
}
73+
})();
74+
}, []);
75+
}

apps/code/src/renderer/features/integrations/stores/integrationStore.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
11
import { create } from "zustand";
22

3+
export interface IntegrationAccount {
4+
name?: string;
5+
type?: string;
6+
}
7+
8+
export interface IntegrationConfig {
9+
account?: IntegrationAccount;
10+
[key: string]: unknown;
11+
}
12+
313
export interface Integration {
414
id: number;
515
kind: string;
16+
config?: IntegrationConfig;
17+
display_name?: string;
618
[key: string]: unknown;
719
}
820

0 commit comments

Comments
 (0)