1+ import { GithubAppConfig , SourcebotConfig } from "@sourcebot/schemas/v3/index.type" ;
2+ import { loadConfig } from "@sourcebot/shared" ;
3+ import { env } from "../env.js" ;
4+ import { createLogger } from "@sourcebot/logger" ;
5+ import { getTokenFromConfig } from "../utils.js" ;
6+ import { PrismaClient } from "@sourcebot/db" ;
7+ import { App } from "@octokit/app" ;
8+
9+ const logger = createLogger ( 'githubAppManager' ) ;
10+ const GITHUB_DEFAULT_DEPLOYMENT_HOSTNAME = 'github.com' ;
11+
12+ type Installation = {
13+ id : number ;
14+ appId : number ;
15+ account : {
16+ login : string ;
17+ type : 'organization' | 'user' ;
18+ } ;
19+ createdAt : string ;
20+ expiresAt : string ;
21+ token : string ;
22+ } ;
23+
24+ export class GithubAppManager {
25+ private static instance : GithubAppManager | null = null ;
26+ private octokitApps : Map < number , App > ;
27+ private installationMap : Map < string , Installation > ;
28+ private db : PrismaClient | null = null ;
29+ private initialized : boolean = false ;
30+
31+ private constructor ( ) {
32+ this . octokitApps = new Map < number , App > ( ) ;
33+ this . installationMap = new Map < string , Installation > ( ) ;
34+ }
35+
36+ public static getInstance ( ) : GithubAppManager {
37+ if ( ! GithubAppManager . instance ) {
38+ GithubAppManager . instance = new GithubAppManager ( ) ;
39+ }
40+ return GithubAppManager . instance ;
41+ }
42+
43+ private ensureInitialized ( ) : void {
44+ if ( ! this . initialized ) {
45+ throw new Error ( 'GithubAppManager must be initialized before use. Call init() first.' ) ;
46+ }
47+ }
48+
49+ public async init ( db : PrismaClient ) {
50+ this . db = db ;
51+ const config = await loadConfig ( env . CONFIG_PATH ! ) ;
52+ const githubApps = config . apps ?. filter ( app => app . type === 'githubApp' ) as GithubAppConfig [ ] ;
53+
54+ logger . info ( `Found ${ githubApps . length } GitHub apps in config` ) ;
55+
56+ for ( const app of githubApps ) {
57+ const deploymentHostname = app . deploymentHostname as string || GITHUB_DEFAULT_DEPLOYMENT_HOSTNAME ;
58+
59+ // @todo : we should move SINGLE_TENANT_ORG_ID to shared package or just remove the need to pass this in
60+ // when resolving tokens
61+ const SINGLE_TENANT_ORG_ID = 1 ;
62+ const privateKey = await getTokenFromConfig ( app . privateKey , SINGLE_TENANT_ORG_ID , this . db ! ) ;
63+
64+ const octokitApp = new App ( {
65+ appId : Number ( app . id ) ,
66+ privateKey : privateKey ,
67+ } ) ;
68+ this . octokitApps . set ( Number ( app . id ) , octokitApp ) ;
69+
70+ const installations = await octokitApp . octokit . request ( "GET /app/installations" ) ;
71+ logger . info ( `Found ${ installations . data . length } GitHub App installations for ${ deploymentHostname } /${ app . id } :` ) ;
72+
73+ for ( const installationData of installations . data ) {
74+ logger . info ( `\tInstallation ID: ${ installationData . id } , Account: ${ installationData . account ?. login } , Type: ${ installationData . account ?. type } ` ) ;
75+
76+ const owner = installationData . account ?. login ! ;
77+ const accountType = installationData . account ?. type ! . toLowerCase ( ) as 'organization' | 'user' ;
78+ const installationOctokit = await octokitApp . getInstallationOctokit ( installationData . id ) ;
79+ const auth = await installationOctokit . auth ( { type : "installation" } ) as { expires_at : string , token : string } ;
80+
81+ const installation : Installation = {
82+ id : installationData . id ,
83+ appId : Number ( app . id ) ,
84+ account : {
85+ login : owner ,
86+ type : accountType ,
87+ } ,
88+ createdAt : installationData . created_at ,
89+ expiresAt : auth . expires_at ,
90+ token : auth . token
91+ } ;
92+ this . installationMap . set ( this . generateMapKey ( owner , deploymentHostname ) , installation ) ;
93+ }
94+ }
95+
96+ this . initialized = true ;
97+ }
98+
99+ public async getInstallationToken ( owner : string , deploymentHostname : string = GITHUB_DEFAULT_DEPLOYMENT_HOSTNAME ) : Promise < string > {
100+ this . ensureInitialized ( ) ;
101+
102+ const key = this . generateMapKey ( owner , deploymentHostname ) ;
103+ const installation = this . installationMap . get ( key ) as Installation | undefined ;
104+ if ( ! installation ) {
105+ throw new Error ( `GitHub App Installation not found for ${ key } ` ) ;
106+ }
107+
108+ if ( installation . expiresAt < new Date ( ) . toISOString ( ) ) {
109+ const octokitApp = this . octokitApps . get ( installation . appId ) as App ;
110+ const installationOctokit = await octokitApp . getInstallationOctokit ( installation . id ) ;
111+ const auth = await installationOctokit . auth ( { type : "installation" } ) as { expires_at : string , token : string } ;
112+
113+ const newInstallation : Installation = {
114+ ...installation ,
115+ expiresAt : auth . expires_at ,
116+ token : auth . token
117+ } ;
118+ this . installationMap . set ( key , newInstallation ) ;
119+
120+ return newInstallation . token ;
121+ } else {
122+ return installation . token ;
123+ }
124+ }
125+
126+ public appsConfigured ( ) {
127+ return this . octokitApps . size > 0 ;
128+ }
129+
130+ private generateMapKey ( owner : string , deploymentHostname : string ) : string {
131+ return `${ deploymentHostname } /${ owner } ` ;
132+ }
133+ }
0 commit comments