sourcebot/packages/backend/src/ee/githubAppManager.ts
2025-10-28 20:27:35 -07:00

141 lines
No EOL
5.6 KiB
TypeScript

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<number, App>;
private installationMap: Map<string, Installation>;
private db: PrismaClient | null = null;
private initialized: boolean = false;
private constructor() {
this.octokitApps = new Map<number, App>();
this.installationMap = new Map<string, Installation>();
}
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<string> {
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}`;
}
}