Skip to content

Commit a13882a

Browse files
committed
feat(backend): enhance GitHub installation verification and auto-linking
1 parent ff212f5 commit a13882a

File tree

2 files changed

+213
-5
lines changed

2 files changed

+213
-5
lines changed

services/backend/src/routes/teams/deploy/github.ts

Lines changed: 120 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ export default async function deployGitHubRoutes(server: FastifyInstance) {
255255
schema: {
256256
tags: ['Deployment'],
257257
summary: 'Check GitHub App installation status',
258-
description: 'Returns whether the team has installed the GitHub App',
258+
description: 'Returns whether the team has an active GitHub App installation. Verifies against GitHub API and auto-cleans stale records.',
259259
security: [{ cookieAuth: [] }],
260260
params: {
261261
type: 'object',
@@ -294,11 +294,126 @@ export default async function deployGitHubRoutes(server: FastifyInstance) {
294294

295295
const { teamId } = request.params as { teamId: string };
296296

297-
const hasInstallation = await credentialService.hasInstallation(teamId, 'github');
297+
// Step 1: Check database for installation record
298+
let installation = await credentialService.getInstallation(teamId, 'github');
299+
300+
if (!installation) {
301+
// No database record - try to auto-detect and link
302+
try {
303+
const installations = await githubService.listInstallations();
304+
305+
if (installations.length > 0) {
306+
// Found installations - select the best one (most recent, not suspended)
307+
const selectedInstallation = installations[0];
308+
309+
// Auto-link to this team
310+
await credentialService.storeInstallation({
311+
teamId,
312+
source: 'github',
313+
installationId: selectedInstallation.id.toString()
314+
});
315+
316+
server.log.info({
317+
teamId,
318+
installation_id: selectedInstallation.id,
319+
account_login: selectedInstallation.account.login,
320+
total_installations_found: installations.length,
321+
operation: 'auto_linked_installation'
322+
}, 'Auto-linked GitHub installation to team');
323+
324+
if (installations.length > 1) {
325+
server.log.info({
326+
teamId,
327+
total_installations: installations.length,
328+
selected_installation_id: selectedInstallation.id,
329+
selected_account: selectedInstallation.account.login,
330+
operation: 'multiple_installations_found'
331+
}, 'Multiple GitHub installations found, selected most recent');
332+
}
333+
334+
// Return connected
335+
const response: ConnectionStatusResponse = { connected: true };
336+
const jsonString = JSON.stringify(response);
337+
return reply.status(200).type('application/json').send(jsonString);
338+
}
298339

299-
const response: ConnectionStatusResponse = { connected: hasInstallation };
300-
const jsonString = JSON.stringify(response);
301-
return reply.status(200).type('application/json').send(jsonString);
340+
// No installations found
341+
server.log.info({
342+
teamId,
343+
operation: 'no_installations_found'
344+
}, 'No GitHub installations found for user');
345+
346+
const response: ConnectionStatusResponse = { connected: false };
347+
const jsonString = JSON.stringify(response);
348+
return reply.status(200).type('application/json').send(jsonString);
349+
} catch (error) {
350+
// Failed to list installations - log and return false
351+
server.log.warn({ error, teamId }, 'Failed to list GitHub installations during auto-detection');
352+
const response: ConnectionStatusResponse = { connected: false };
353+
const jsonString = JSON.stringify(response);
354+
return reply.status(200).type('application/json').send(jsonString);
355+
}
356+
}
357+
358+
// Step 2: Verify installation still exists on GitHub
359+
try {
360+
const isValid = await githubService.verifyInstallation(installation.installationId);
361+
362+
if (!isValid) {
363+
// Installation no longer exists on GitHub - clean up stale record
364+
await credentialService.deleteInstallation(teamId, 'github');
365+
366+
server.log.info({
367+
teamId,
368+
installation_id: installation.installationId,
369+
operation: 'stale_installation_cleaned'
370+
}, 'Cleaned up stale GitHub installation record');
371+
372+
// After cleaning up stale record, retry auto-detection
373+
try {
374+
const installations = await githubService.listInstallations();
375+
376+
if (installations.length > 0) {
377+
const selectedInstallation = installations[0];
378+
379+
await credentialService.storeInstallation({
380+
teamId,
381+
source: 'github',
382+
installationId: selectedInstallation.id.toString()
383+
});
384+
385+
server.log.info({
386+
teamId,
387+
installation_id: selectedInstallation.id,
388+
account_login: selectedInstallation.account.login,
389+
total_installations_found: installations.length,
390+
operation: 'auto_linked_installation_after_cleanup'
391+
}, 'Auto-linked GitHub installation to team after cleaning stale record');
392+
393+
const response: ConnectionStatusResponse = { connected: true };
394+
const jsonString = JSON.stringify(response);
395+
return reply.status(200).type('application/json').send(jsonString);
396+
}
397+
} catch (retryError) {
398+
server.log.warn({ error: retryError, teamId }, 'Failed to auto-detect installation after cleanup');
399+
}
400+
401+
const response: ConnectionStatusResponse = { connected: false };
402+
const jsonString = JSON.stringify(response);
403+
return reply.status(200).type('application/json').send(jsonString);
404+
}
405+
406+
// Installation is valid and active
407+
const response: ConnectionStatusResponse = { connected: true };
408+
const jsonString = JSON.stringify(response);
409+
return reply.status(200).type('application/json').send(jsonString);
410+
} catch (error) {
411+
// GitHub API error - return cached state optimistically
412+
server.log.warn({ error, teamId }, 'GitHub API verification failed, returning cached state');
413+
const response: ConnectionStatusResponse = { connected: true };
414+
const jsonString = JSON.stringify(response);
415+
return reply.status(200).type('application/json').send(jsonString);
416+
}
302417
});
303418

304419
// GET /api/teams/{teamId}/deploy/github/repositories

services/backend/src/services/deploymentGitHubService.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,99 @@ export class DeploymentGitHubService {
9292
}));
9393
}
9494

95+
/**
96+
* Verify that a GitHub App installation still exists and is active
97+
* Queries GitHub API to check installation status
98+
* Returns true if installation exists and is not suspended, false otherwise
99+
*/
100+
async verifyInstallation(installationId: string): Promise<boolean> {
101+
try {
102+
const config = await getGitHubAppConfig();
103+
104+
const appOctokit = new Octokit({
105+
authStrategy: createAppAuth,
106+
auth: {
107+
appId: config.appId,
108+
privateKey: config.privateKey
109+
}
110+
});
111+
112+
// Query GitHub API for this specific installation
113+
const { data: installation } = await appOctokit.apps.getInstallation({
114+
installation_id: parseInt(installationId, 10)
115+
});
116+
117+
// Check if installation is suspended
118+
if (installation.suspended_at) {
119+
return false;
120+
}
121+
122+
return true;
123+
} catch (error: unknown) {
124+
// Check if it's a 404 (installation doesn't exist)
125+
if (error && typeof error === 'object' && 'status' in error && error.status === 404) {
126+
return false;
127+
}
128+
129+
// Other errors (network, GitHub API down, etc.) - log and throw
130+
const message = error instanceof Error ? error.message : 'Unknown error';
131+
throw new Error(`GitHub installation verification failed: ${message}`);
132+
}
133+
}
134+
135+
/**
136+
* List all GitHub App installations accessible to this app
137+
* Returns installations sorted by most recent (updated_at descending)
138+
* Filters out suspended installations
139+
*/
140+
async listInstallations(): Promise<Array<{
141+
id: number;
142+
account: {
143+
login: string;
144+
id: number;
145+
};
146+
created_at: string;
147+
updated_at: string;
148+
repository_selection: 'all' | 'selected';
149+
suspended_at: string | null;
150+
}>> {
151+
try {
152+
const config = await getGitHubAppConfig();
153+
154+
const appOctokit = new Octokit({
155+
authStrategy: createAppAuth,
156+
auth: {
157+
appId: config.appId,
158+
privateKey: config.privateKey
159+
}
160+
});
161+
162+
// Query GitHub API for all installations
163+
const { data: installations } = await appOctokit.apps.listInstallations({
164+
per_page: 100
165+
});
166+
167+
// Filter out suspended installations and sort by most recent
168+
return installations
169+
.filter(installation => !installation.suspended_at)
170+
.map(installation => ({
171+
id: installation.id,
172+
account: {
173+
login: installation.account?.login || 'unknown',
174+
id: installation.account?.id || 0
175+
},
176+
created_at: installation.created_at,
177+
updated_at: installation.updated_at,
178+
repository_selection: installation.repository_selection as 'all' | 'selected',
179+
suspended_at: installation.suspended_at
180+
}))
181+
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
182+
} catch (error: unknown) {
183+
const message = error instanceof Error ? error.message : 'Unknown error';
184+
throw new Error(`Failed to list GitHub installations: ${message}`);
185+
}
186+
}
187+
95188
/**
96189
* Get repository details
97190
*/

0 commit comments

Comments
 (0)