1- import * as http from "node:http" ;
2- import type { Socket } from "node:net" ;
31import { getCloudUrlFromRegion } from "@shared/constants/oauth" ;
42import { shell } from "electron" ;
5- import { inject , injectable } from "inversify" ;
6- import { MAIN_TOKENS } from "../../di/tokens" ;
3+ import { injectable } from "inversify" ;
74import { logger } from "../../utils/logger" ;
8- import type { DeepLinkService } from "../deep-link/service" ;
9- import type {
10- CancelGitHubFlowOutput ,
11- CloudRegion ,
12- StartGitHubFlowOutput ,
13- } from "./schemas" ;
5+ import type { CloudRegion , StartGitHubFlowOutput } from "./schemas" ;
146
157const log = logger . scope ( "github-integration-service" ) ;
168
17- const PROTOCOL = "posthog-code" ;
18- const TIMEOUT_MS = 300_000 ; // 5 minutes
19- const DEV_CALLBACK_PORT = 8239 ; // Different from OAuth's 8237 and MCP's 8238
20-
21- // Use HTTP callback in development, deep link in production
22- const IS_DEV = process . defaultApp || false ;
23-
24- interface PendingFlow {
25- resolve : ( success : boolean ) => void ;
26- reject : ( error : Error ) => void ;
27- timeoutId : NodeJS . Timeout ;
28- server ?: http . Server ;
29- connections ?: Set < Socket > ;
30- }
31-
329@injectable ( )
3310export class GitHubIntegrationService {
34- private pendingFlow : PendingFlow | null = null ;
35-
36- constructor (
37- @inject ( MAIN_TOKENS . DeepLinkService )
38- private readonly deepLinkService : DeepLinkService ,
39- ) {
40- this . deepLinkService . registerHandler ( "github-connected" , ( ) =>
41- this . handleCallback ( ) ,
42- ) ;
43- log . info ( "Registered github-connected handler for deep links" ) ;
44- }
45-
46- private handleCallback ( ) : boolean {
47- if ( ! this . pendingFlow ) {
48- log . warn ( "Received GitHub callback but no pending flow" ) ;
49- return false ;
50- }
51- const { resolve, timeoutId } = this . pendingFlow ;
52- clearTimeout ( timeoutId ) ;
53- this . pendingFlow = null ;
54- resolve ( true ) ;
55- return true ;
56- }
57-
58- private getCallbackUrl ( ) : string {
59- return IS_DEV
60- ? `http://localhost:${ DEV_CALLBACK_PORT } /github-callback`
61- : `${ PROTOCOL } ://github-connected` ;
62- }
63-
6411 public async startFlow (
6512 region : CloudRegion ,
6613 projectId : number ,
6714 ) : Promise < StartGitHubFlowOutput > {
6815 try {
69- this . cancelFlow ( ) ;
70-
7116 const cloudUrl = getCloudUrlFromRegion ( region ) ;
72- const callbackUrl = this . getCallbackUrl ( ) ;
73- const authorizeUrl = `${ cloudUrl } /api/environments/${ projectId } /integrations/authorize/?kind=github&next=${ encodeURIComponent ( callbackUrl ) } ` ;
74-
75- const success = IS_DEV
76- ? await this . waitForHttpCallback ( authorizeUrl )
77- : await this . waitForDeepLinkCallback ( authorizeUrl ) ;
17+ const next = `${ cloudUrl } /projects/${ projectId } ` ;
18+ const authorizeUrl = `${ cloudUrl } /api/environments/${ projectId } /integrations/authorize/?kind=github&next=${ encodeURIComponent ( next ) } ` ;
7819
79- return { success } ;
80- } catch ( error ) {
81- return {
82- success : false ,
83- error : error instanceof Error ? error . message : "Unknown error" ,
84- } ;
85- }
86- }
20+ log . info ( "Opening GitHub authorization URL in browser" ) ;
21+ await shell . openExternal ( authorizeUrl ) ;
8722
88- public cancelFlow ( ) : CancelGitHubFlowOutput {
89- try {
90- if ( this . pendingFlow ) {
91- if ( this . pendingFlow . server ) {
92- this . cleanupHttpServer ( ) ;
93- } else {
94- clearTimeout ( this . pendingFlow . timeoutId ) ;
95- this . pendingFlow . reject ( new Error ( "GitHub flow cancelled" ) ) ;
96- this . pendingFlow = null ;
97- }
98- }
9923 return { success : true } ;
10024 } catch ( error ) {
10125 return {
@@ -104,116 +28,4 @@ export class GitHubIntegrationService {
10428 } ;
10529 }
10630 }
107-
108- private async waitForDeepLinkCallback (
109- authorizeUrl : string ,
110- ) : Promise < boolean > {
111- return new Promise < boolean > ( ( resolve , reject ) => {
112- const timeoutId = setTimeout ( ( ) => {
113- this . pendingFlow = null ;
114- reject ( new Error ( "Authorization timed out" ) ) ;
115- } , TIMEOUT_MS ) ;
116-
117- this . pendingFlow = { resolve, reject, timeoutId } ;
118-
119- shell . openExternal ( authorizeUrl ) . catch ( ( error ) => {
120- clearTimeout ( timeoutId ) ;
121- this . pendingFlow = null ;
122- reject ( new Error ( `Failed to open browser: ${ error . message } ` ) ) ;
123- } ) ;
124- } ) ;
125- }
126-
127- private async waitForHttpCallback ( authorizeUrl : string ) : Promise < boolean > {
128- return new Promise < boolean > ( ( resolve , reject ) => {
129- const connections = new Set < Socket > ( ) ;
130-
131- const server = http . createServer ( ( req , res ) => {
132- if ( ! req . url ) {
133- res . writeHead ( 400 ) ;
134- res . end ( ) ;
135- return ;
136- }
137-
138- const url = new URL ( req . url , `http://localhost:${ DEV_CALLBACK_PORT } ` ) ;
139-
140- if ( url . pathname === "/github-callback" ) {
141- res . writeHead ( 200 , { "Content-Type" : "text/html" } ) ;
142- res . end ( this . getCallbackHtml ( ) ) ;
143- this . cleanupHttpServer ( ) ;
144- resolve ( true ) ;
145- } else {
146- res . writeHead ( 404 ) ;
147- res . end ( ) ;
148- }
149- } ) ;
150-
151- server . on ( "connection" , ( conn ) => {
152- connections . add ( conn ) ;
153- conn . on ( "close" , ( ) => connections . delete ( conn ) ) ;
154- } ) ;
155-
156- const timeoutId = setTimeout ( ( ) => {
157- this . cleanupHttpServer ( ) ;
158- reject ( new Error ( "Authorization timed out" ) ) ;
159- } , TIMEOUT_MS ) ;
160-
161- this . pendingFlow = { resolve, reject, timeoutId, server, connections } ;
162-
163- server . listen ( DEV_CALLBACK_PORT , ( ) => {
164- log . info (
165- `Dev GitHub callback server listening on port ${ DEV_CALLBACK_PORT } ` ,
166- ) ;
167- shell . openExternal ( authorizeUrl ) . catch ( ( error ) => {
168- this . cleanupHttpServer ( ) ;
169- reject ( new Error ( `Failed to open browser: ${ error . message } ` ) ) ;
170- } ) ;
171- } ) ;
172-
173- server . on ( "error" , ( error ) => {
174- this . cleanupHttpServer ( ) ;
175- reject ( new Error ( `Failed to start callback server: ${ error . message } ` ) ) ;
176- } ) ;
177- } ) ;
178- }
179-
180- private getCallbackHtml ( ) : string {
181- return `<!DOCTYPE html>
182- <html class="radix-themes" data-is-root-theme="true" data-accent-color="orange" data-gray-color="slate" data-has-background="true" data-panel-background="translucent" data-radius="none" data-scaling="100%">
183- <head>
184- <meta charset="utf-8">
185- <title>GitHub connected</title>
186- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@radix-ui/themes@3.1.6/styles.css">
187- <script src="https://cdn.tailwindcss.com"></script>
188- <style>
189- @layer utilities {
190- .text-gray-12 { color: var(--gray-12); }
191- .text-gray-11 { color: var(--gray-11); }
192- .bg-gray-1 { background-color: var(--gray-1); }
193- }
194- </style>
195- </head>
196- <body class="dark bg-gray-1 h-screen overflow-hidden flex flex-col items-center justify-center m-0 gap-2">
197- <h1 class="text-gray-12 text-xl font-semibold">GitHub connected!</h1>
198- <p class="text-gray-11 text-sm">You can close this window and return to PostHog Code.</p>
199- <script>setTimeout(() => window.close(), 500);</script>
200- </body>
201- </html>` ;
202- }
203-
204- private cleanupHttpServer ( ) : void {
205- if ( this . pendingFlow ?. server ) {
206- if ( this . pendingFlow . connections ) {
207- for ( const conn of this . pendingFlow . connections ) {
208- conn . destroy ( ) ;
209- }
210- this . pendingFlow . connections . clear ( ) ;
211- }
212- this . pendingFlow . server . close ( ) ;
213- }
214- if ( this . pendingFlow ?. timeoutId ) {
215- clearTimeout ( this . pendingFlow . timeoutId ) ;
216- }
217- this . pendingFlow = null ;
218- }
21931}
0 commit comments