import { loadConfig } from "@sourcebot/shared"; import { env } from "../env.js"; import { createLogger } from "@sourcebot/logger"; import { getTokenFromConfig } from "@sourcebot/crypto"; import { PrismaClient } from "@sourcebot/db"; import { App } from "@octokit/app"; import { GitHubAppConfig } from "@sourcebot/schemas/v3/index.type"; const logger = createLogger('githubAppManager'); const GITHUB_DEFAULT_DEPLOYMENT_HOSTNAME = 'github.com'; type Installation = { id: number; appId: number; account: { login: string; type: 'organization' | 'user'; }; createdAt: string; expiresAt: string; token: string; }; export class GithubAppManager { private static instance: GithubAppManager | null = null; private octokitApps: Map; private installationMap: Map; private db: PrismaClient | null = null; private initialized: boolean = false; private constructor() { this.octokitApps = new Map(); this.installationMap = new Map(); } public static getInstance(): GithubAppManager { if (!GithubAppManager.instance) { GithubAppManager.instance = new GithubAppManager(); } return GithubAppManager.instance; } private ensureInitialized(): void { if (!this.initialized) { throw new Error('GithubAppManager must be initialized before use. Call init() first.'); } } public async init(db: PrismaClient) { this.db = db; const config = await loadConfig(env.CONFIG_PATH!); if (!config.apps) { return; } const githubApps = config.apps.filter(app => app.type === 'githubApp') as GitHubAppConfig[]; logger.info(`Found ${githubApps.length} GitHub apps in config`); for (const app of githubApps) { const deploymentHostname = app.deploymentHostname as string || GITHUB_DEFAULT_DEPLOYMENT_HOSTNAME; // @todo: we should move SINGLE_TENANT_ORG_ID to shared package or just remove the need to pass this in // when resolving tokens const SINGLE_TENANT_ORG_ID = 1; const privateKey = await getTokenFromConfig(app.privateKey, SINGLE_TENANT_ORG_ID, this.db); const octokitApp = new App({ appId: Number(app.id), privateKey: privateKey, }); this.octokitApps.set(Number(app.id), octokitApp); const installations = await octokitApp.octokit.request("GET /app/installations"); logger.info(`Found ${installations.data.length} GitHub App installations for ${deploymentHostname}/${app.id}:`); for (const installationData of installations.data) { if (!installationData.account || !installationData.account.login || !installationData.account.type) { logger.warn(`Skipping installation ${installationData.id}: missing account data (${installationData.account})`); continue; } logger.info(`\tInstallation ID: ${installationData.id}, Account: ${installationData.account.login}, Type: ${installationData.account.type}`); const owner = installationData.account.login; const accountType = installationData.account.type.toLowerCase() as 'organization' | 'user'; const installationOctokit = await octokitApp.getInstallationOctokit(installationData.id); const auth = await installationOctokit.auth({ type: "installation" }) as { expires_at: string, token: string }; const installation: Installation = { id: installationData.id, appId: Number(app.id), account: { login: owner, type: accountType, }, createdAt: installationData.created_at, expiresAt: auth.expires_at, token: auth.token }; this.installationMap.set(this.generateMapKey(owner, deploymentHostname), installation); } } this.initialized = true; } public async getInstallationToken(owner: string, deploymentHostname: string = GITHUB_DEFAULT_DEPLOYMENT_HOSTNAME): Promise { this.ensureInitialized(); const key = this.generateMapKey(owner, deploymentHostname); const installation = this.installationMap.get(key) as Installation | undefined; if (!installation) { throw new Error(`GitHub App Installation not found for ${key}`); } if (installation.expiresAt < new Date().toISOString()) { const octokitApp = this.octokitApps.get(installation.appId) as App; const installationOctokit = await octokitApp.getInstallationOctokit(installation.id); const auth = await installationOctokit.auth({ type: "installation" }) as { expires_at: string, token: string }; const newInstallation: Installation = { ...installation, expiresAt: auth.expires_at, token: auth.token }; this.installationMap.set(key, newInstallation); return newInstallation.token; } else { return installation.token; } } public appsConfigured() { return this.octokitApps.size > 0; } private generateMapKey(owner: string, deploymentHostname: string): string { return `${deploymentHostname}/${owner}`; } }