11import express from 'express' ;
22import { v4 as uuid } from 'uuid' ;
33import { ObjectId } from 'mongodb' ;
4+ import { createHmac } from 'crypto' ;
45import { GitHubService } from './service' ;
56import { ContextFactories } from '../../types/graphql' ;
67import { RedisInstallStateStore } from './store/install-state.redis.store' ;
78import WorkspaceModel from '../../models/workspace' ;
89import { sgr , Effect } from '../../utils/ansi' ;
10+ import { databases } from '../../mongo' ;
911
1012/**
1113 * Create GitHub router
1214 *
1315 * @param factories - context factories for database access
1416 * @returns Express router with GitHub integration endpoints
1517 */
18+ /**
19+ * Default task threshold for automatic task creation
20+ * Minimum totalCount required to trigger auto-task creation
21+ */
22+ const DEFAULT_TASK_THRESHOLD_TOTAL_COUNT = 50 ;
23+
1624export function createGitHubRouter ( factories : ContextFactories ) : express . Router {
1725 const router = express . Router ( ) ;
1826 const githubService = new GitHubService ( ) ;
1927 const stateStore = new RedisInstallStateStore ( ) ;
2028
29+ /**
30+ * Build redirect URL to Garage frontend
31+ *
32+ * @param path - path on Garage (e.g., '/project/123/settings/task-manager')
33+ * @param params - URL search parameters (e.g., { success: 'true' } or { error: 'message' })
34+ * @returns Full URL string for redirect
35+ */
36+ function buildGarageRedirectUrl ( path : string , params ?: Record < string , string > ) : string {
37+ const garageUrl = process . env . GARAGE_URL || 'https://garage.hawk.so' ;
38+ const redirectUrl = new URL ( path , garageUrl ) ;
39+
40+ if ( params ) {
41+ for ( const [ key , value ] of Object . entries ( params ) ) {
42+ redirectUrl . searchParams . set ( key , value ) ;
43+ }
44+ }
45+
46+ return redirectUrl . toString ( ) ;
47+ }
48+
2149 /**
2250 * Log message with GitHub Integration prefix
2351 *
2452 * @param level - log level ('log', 'warn', 'error', 'info')
53+ * @param projectId - optional project ID to include in log prefix
2554 * @param args - arguments to log
2655 */
27- function log ( level : 'log' | 'warn' | 'error' | 'info' , ...args : unknown [ ] ) : void {
56+ function log ( level : 'log' | 'warn' | 'error' | 'info' , projectIdOrFirstArg ?: string | unknown , ...args : unknown [ ] ) : void {
2857 /**
2958 * Disable logging in test environment
3059 */
@@ -49,7 +78,28 @@ export function createGitHubRouter(factories: ContextFactories): express.Router
4978 logger = console . log ;
5079 }
5180
52- logger ( sgr ( '[GitHub Integration]' , colors [ level ] ) , ...args ) ;
81+ /**
82+ * Check if first argument is projectId (string) or regular log argument
83+ * projectId should be a string and valid ObjectId format
84+ */
85+ let projectId : string | undefined ;
86+ let logArgs : unknown [ ] ;
87+
88+ if ( typeof projectIdOrFirstArg === 'string' && ObjectId . isValid ( projectIdOrFirstArg ) ) {
89+ projectId = `pid: ${ projectIdOrFirstArg } ` ;
90+ logArgs = args ;
91+ } else {
92+ logArgs = projectIdOrFirstArg !== undefined ? [ projectIdOrFirstArg , ...args ] : args ;
93+ }
94+
95+ /**
96+ * Build log prefix with optional projectId
97+ */
98+ const prefix = projectId
99+ ? `${ sgr ( '[GitHub Integration]' , colors [ level ] ) } ${ sgr ( `[${ projectId } ]` , Effect . ForegroundCyan ) } `
100+ : sgr ( '[GitHub Integration]' , colors [ level ] ) ;
101+
102+ logger ( prefix , ...logArgs ) ;
53103 }
54104
55105 /**
@@ -157,14 +207,14 @@ export function createGitHubRouter(factories: ContextFactories): express.Router
157207
158208 await stateStore . saveState ( state , stateData ) ;
159209
160- log ( 'info' , `Created state for project ${ sgr ( projectId , Effect . ForegroundCyan ) } : ${ sgr ( state . slice ( 0 , 8 ) , Effect . ForegroundGray ) } ...` ) ;
210+ log ( 'info' , projectId , `Created state: ${ sgr ( state . slice ( 0 , 8 ) , Effect . ForegroundGray ) } ...` ) ;
161211
162212 /**
163213 * Generate GitHub installation URL with state
164214 */
165215 const installationUrl = githubService . getInstallationUrl ( state ) ;
166216
167- log ( 'info' , ` Generated GitHub installation URL for project ${ sgr ( projectId , Effect . ForegroundCyan ) } ` ) ;
217+ log ( 'info' , projectId , ' Generated GitHub installation URL: ' + sgr ( installationUrl , Effect . ForegroundGreen ) ) ;
168218
169219 /**
170220 * Return installation URL in JSON response
@@ -180,5 +230,281 @@ export function createGitHubRouter(factories: ContextFactories): express.Router
180230 }
181231 } ) ;
182232
233+ /**
234+ * GET /integration/github/callback?state=<state>&installation_id=<installation_id>
235+ * Handle GitHub App installation callback
236+ */
237+ router . get ( '/callback' , async ( req , res , next ) => {
238+ try {
239+ const { state, installation_id } = req . query ;
240+
241+ /**
242+ * Validate required parameters
243+ */
244+ if ( ! state || typeof state !== 'string' ) {
245+ return res . redirect ( buildGarageRedirectUrl ( '/project/error/settings/task-manager' , {
246+ error : 'Missing or invalid state' ,
247+ } ) ) ;
248+ }
249+
250+ if ( ! installation_id || typeof installation_id !== 'string' ) {
251+ return res . redirect ( buildGarageRedirectUrl ( '/project/error/settings/task-manager' , {
252+ error : 'Missing or invalid installation_id parameter' ,
253+ } ) ) ;
254+ }
255+
256+ /**
257+ * Verify state (CSRF protection)
258+ * getState() atomically gets and deletes the state, preventing reuse
259+ */
260+ const stateData = await stateStore . getState ( state ) ;
261+
262+ if ( ! stateData ) {
263+ log ( 'warn' , `Invalid or expired state: ${ sgr ( state . slice ( 0 , 8 ) , Effect . ForegroundGray ) } ...` ) ;
264+
265+ return res . redirect ( buildGarageRedirectUrl ( '/project/error/settings/task-manager' , {
266+ error : 'Invalid or expired state. Please try connecting again.' ,
267+ } ) ) ;
268+ }
269+
270+ const { projectId, userId } = stateData ;
271+
272+ log ( 'info' , projectId , `Processing callback initiated by user ${ sgr ( userId , Effect . ForegroundCyan ) } ` ) ;
273+
274+ /**
275+ * Verify project exists
276+ */
277+ const project = await factories . projectsFactory . findById ( projectId ) ;
278+
279+ if ( ! project ) {
280+ log ( 'error' , projectId , 'Project not found' ) ;
281+
282+ return res . redirect ( buildGarageRedirectUrl ( '/project/error/settings/task-manager' , {
283+ error : `Project not found: ${ projectId } ` ,
284+ } ) ) ;
285+ }
286+
287+ /**
288+ * Get installation info from GitHub
289+ */
290+ let installation ;
291+
292+ try {
293+ installation = await githubService . getInstallationForRepository ( installation_id ) ;
294+ log ( 'info' , projectId , `Retrieved installation info for installation_id: ${ sgr ( installation_id , Effect . ForegroundCyan ) } ` ) ;
295+ } catch ( error ) {
296+ log ( 'error' , projectId , `Failed to get installation info: ${ error instanceof Error ? error . message : String ( error ) } ` ) ;
297+
298+ return res . redirect ( buildGarageRedirectUrl ( `/project/${ projectId } /settings/task-manager` , {
299+ error : 'Failed to retrieve GitHub installation information. Please try again.' ,
300+ } ) ) ;
301+ }
302+
303+ /**
304+ * For now, we save only installationId
305+ * repoId and repoFullName will be set when creating the first issue or can be configured later
306+ * GitHub App installation can include multiple repositories, so we don't know which one to use yet
307+ */
308+ const taskManagerConfig = {
309+ type : 'github' ,
310+ autoTaskEnabled : false ,
311+ taskThresholdTotalCount : DEFAULT_TASK_THRESHOLD_TOTAL_COUNT ,
312+ assignAgent : false ,
313+ connectedAt : new Date ( ) ,
314+ updatedAt : new Date ( ) ,
315+ config : {
316+ installationId : installation_id ,
317+ repoId : '' ,
318+ repoFullName : '' ,
319+ } ,
320+ } ;
321+
322+ let successRedirectUrl = buildGarageRedirectUrl ( `/project/${ projectId } /settings/task-manager` , {
323+ success : 'true' ,
324+ } ) ;
325+
326+ /**
327+ * Save taskManager configuration to project
328+ */
329+ try {
330+ await project . updateProject ( ( {
331+ taskManager : taskManagerConfig ,
332+ } ) as any ) ;
333+
334+ log ( 'info' , projectId , 'Successfully connected GitHub integration. Redirecting to ' + sgr ( successRedirectUrl , Effect . ForegroundGreen ) ) ;
335+ } catch ( error ) {
336+ log ( 'error' , projectId , `Failed to save taskManager config: ${ error instanceof Error ? error . message : String ( error ) } ` ) ;
337+
338+ return res . redirect ( buildGarageRedirectUrl ( `/project/${ projectId } /settings/task-manager` , {
339+ error : 'Failed to save Task Manager configuration. Please try again.' ,
340+ } ) ) ;
341+ }
342+
343+ /**
344+ * Redirect to Garage with success parameter
345+ */
346+ return res . redirect ( successRedirectUrl ) ;
347+ } catch ( error ) {
348+ log ( 'error' , 'Error in /callback endpoint:' , error ) ;
349+ next ( error ) ;
350+ }
351+ } ) ;
352+
353+ /**
354+ * POST /integration/github/webhook
355+ * Handle GitHub App webhook events
356+ */
357+ router . post ( '/webhook' , express . raw ( { type : 'application/json' } ) , async ( req , res , next ) => {
358+ try {
359+ /**
360+ * Get webhook secret from environment
361+ */
362+ const webhookSecret = process . env . GITHUB_WEBHOOK_SECRET ;
363+
364+ if ( ! webhookSecret ) {
365+ log ( 'error' , 'GITHUB_WEBHOOK_SECRET is not configured' ) ;
366+ res . status ( 500 ) . json ( { error : 'Webhook secret not configured' } ) ;
367+
368+ return ;
369+ }
370+
371+ /**
372+ * Get signature from request headers
373+ * GitHub sends signature in X-Hub-Signature-256 header as sha256=<signature>
374+ */
375+ const signature = req . headers [ 'x-hub-signature-256' ] as string | undefined ;
376+
377+ if ( ! signature ) {
378+ log ( 'warn' , 'Missing X-Hub-Signature-256 header' ) ;
379+
380+ return res . status ( 401 ) . json ( { error : 'Missing signature header' } ) ;
381+ }
382+
383+ /**
384+ * Verify webhook signature using HMAC SHA-256
385+ */
386+ const payload = req . body as Buffer ;
387+ const hmac = createHmac ( 'sha256' , webhookSecret ) ;
388+ hmac . update ( payload as any ) ;
389+ const calculatedSignature = `sha256=${ hmac . digest ( 'hex' ) } ` ;
390+
391+ /**
392+ * Use timing-safe comparison to prevent timing attacks
393+ */
394+ let signatureValid = false ;
395+
396+ if ( signature . length === calculatedSignature . length ) {
397+ let match = true ;
398+
399+ for ( let i = 0 ; i < signature . length ; i ++ ) {
400+ if ( signature [ i ] !== calculatedSignature [ i ] ) {
401+ match = false ;
402+ }
403+ }
404+
405+ signatureValid = match ;
406+ }
407+
408+ if ( ! signatureValid ) {
409+ log ( 'warn' , 'Invalid webhook signature' ) ;
410+
411+ return res . status ( 401 ) . json ( { error : 'Invalid signature' } ) ;
412+ }
413+
414+ /**
415+ * Parse webhook payload
416+ */
417+ let payloadData : any ;
418+
419+ try {
420+ payloadData = JSON . parse ( payload . toString ( ) ) ;
421+ } catch ( error ) {
422+ log ( 'error' , 'Failed to parse webhook payload:' , error ) ;
423+
424+ return res . status ( 400 ) . json ( { error : 'Invalid JSON payload' } ) ;
425+ }
426+
427+ const eventType = req . headers [ 'x-github-event' ] as string | undefined ;
428+ const installationId = payloadData . installation ?. id ?. toString ( ) ;
429+
430+ log ( 'info' , `Received webhook event: ${ sgr ( eventType || 'unknown' , Effect . ForegroundCyan ) } ` ) ;
431+
432+ /**
433+ * Handle installation.deleted event
434+ */
435+ if ( eventType === 'installation' && payloadData . action === 'deleted' ) {
436+ if ( ! installationId ) {
437+ log ( 'warn' , 'installation.deleted event received but installation_id is missing' ) ;
438+
439+ return res . status ( 200 ) . json ( { message : 'Event received but no installation_id provided' } ) ;
440+ }
441+
442+ log ( 'info' , `Processing installation.deleted for installation_id: ${ sgr ( installationId , Effect . ForegroundCyan ) } ` ) ;
443+
444+ /**
445+ * Find all projects with this installationId
446+ * Using MongoDB query directly as projectsFactory doesn't have a method for this
447+ */
448+ const projectsCollection = databases . hawk ?. collection ( 'projects' ) ;
449+
450+ if ( ! projectsCollection ) {
451+ log ( 'error' , 'MongoDB projects collection is not available' ) ;
452+
453+ return res . status ( 500 ) . json ( { error : 'Database connection error' } ) ;
454+ }
455+
456+ try {
457+ const projects = await projectsCollection
458+ . find ( {
459+ 'taskManager.config.installationId' : installationId ,
460+ } )
461+ . toArray ( ) ;
462+
463+ log ( 'info' , `Found ${ sgr ( projects . length . toString ( ) , Effect . ForegroundCyan ) } project(s) with installation_id ${ installationId } ` ) ;
464+
465+ /**
466+ * Remove taskManager configuration from all projects
467+ */
468+ if ( projects . length > 0 ) {
469+ const projectIds = projects . map ( ( p ) => p . _id . toString ( ) ) ;
470+
471+ await projectsCollection . updateMany (
472+ {
473+ 'taskManager.config.installationId' : installationId ,
474+ } ,
475+ {
476+ $unset : {
477+ taskManager : '' ,
478+ } ,
479+ $set : {
480+ updatedAt : new Date ( ) ,
481+ } ,
482+ }
483+ ) ;
484+
485+ log ( 'info' , `Removed taskManager configuration from ${ sgr ( projects . length . toString ( ) , Effect . ForegroundCyan ) } project(s): ${ projectIds . join ( ', ' ) } ` ) ;
486+ }
487+ } catch ( error ) {
488+ log ( 'error' , `Failed to remove taskManager configurations: ${ error instanceof Error ? error . message : String ( error ) } ` ) ;
489+
490+ return res . status ( 500 ) . json ( { error : 'Failed to process installation.deleted event' } ) ;
491+ }
492+ } else {
493+ /**
494+ * Log other events for monitoring
495+ */
496+ log ( 'info' , `Unhandled webhook event: ${ sgr ( eventType || 'unknown' , Effect . ForegroundGray ) } (action: ${ sgr ( payloadData . action || 'unknown' , Effect . ForegroundGray ) } )` ) ;
497+ }
498+
499+ /**
500+ * Return 200 OK for successful processing
501+ */
502+ res . status ( 200 ) . json ( { message : 'Webhook processed successfully' } ) ;
503+ } catch ( error ) {
504+ log ( 'error' , 'Error in /webhook endpoint:' , error ) ;
505+ next ( error ) ;
506+ }
507+ } ) ;
508+
183509 return router ;
184510}
0 commit comments