Skip to content

Commit cda24b2

Browse files
committed
Add GitHub integration callback and webhook endpoints
Implemented /callback endpoint to handle GitHub App installation callbacks and save configuration to the project. Added /webhook endpoint to securely process GitHub webhook events, including removal of taskManager config on installation deletion. Improved logging with project context and added utility functions for URL building and signature verification.
1 parent 49b94a9 commit cda24b2

File tree

1 file changed

+330
-4
lines changed

1 file changed

+330
-4
lines changed

src/integrations/github/routes.ts

Lines changed: 330 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,59 @@
11
import express from 'express';
22
import { v4 as uuid } from 'uuid';
33
import { ObjectId } from 'mongodb';
4+
import { createHmac } from 'crypto';
45
import { GitHubService } from './service';
56
import { ContextFactories } from '../../types/graphql';
67
import { RedisInstallStateStore } from './store/install-state.redis.store';
78
import WorkspaceModel from '../../models/workspace';
89
import { 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+
1624
export 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

Comments
 (0)