1- import  {   cloudbuildOrigin   }  from  "../../../api " ; 
2- import   {   FirebaseError   }   from   "../../../error" ; 
1+ import  *   as   clc  from  "colorette " ; 
2+ 
33import  *  as  gcb  from  "../../../gcp/cloudbuild" ; 
4- import  {  logger  }  from  "../../../logger" ; 
54import  *  as  poller  from  "../../../operation-poller" ; 
65import  *  as  utils  from  "../../../utils" ; 
6+ import  {  cloudbuildOrigin  }  from  "../../../api" ; 
7+ import  {  FirebaseError  }  from  "../../../error" ; 
8+ import  {  logger  }  from  "../../../logger" ; 
79import  {  promptOnce  }  from  "../../../prompt" ; 
8- import  *  as  clc  from  "colorette" ; 
10+ 
11+ export  interface  ConnectionNameParts  { 
12+   projectId : string ; 
13+   location : string ; 
14+   id : string ; 
15+ } 
916
1017const  FRAMEWORKS_CONN_PATTERN  =  / .+ \/ f r a m e w o r k s - g i t h u b - c o n n - .+ $ / ; 
18+ const  FRAMEWORKS_OAUTH_CONN_NAME  =  "frameworks-github-oauth" ; 
19+ const  CONNECTION_NAME_REGEX  = 
20+   / ^ p r o j e c t s \/ (?< projectId > [ ^ \/ ] + ) \/ l o c a t i o n s \/ (?< location > [ ^ \/ ] + ) \/ c o n n e c t i o n s \/ (?< id > [ ^ \/ ] + ) $ / ; 
21+ 
22+ /** 
23+  * Exported for unit testing. 
24+  */ 
25+ export  function  parseConnectionName ( name : string ) : ConnectionNameParts  |  undefined  { 
26+   const  match  =  name . match ( CONNECTION_NAME_REGEX ) ; 
27+ 
28+   if  ( ! match  ||  typeof  match . groups  ===  undefined )  { 
29+     return ; 
30+   } 
31+   const  {  projectId,  location,  id }  =  match . groups  as  unknown  as  ConnectionNameParts ; 
32+   return  { 
33+     projectId, 
34+     location, 
35+     id, 
36+   } ; 
37+ } 
1138
1239const  gcbPollerOptions : Omit < poller . OperationPollerOptions ,  "operationResourceName" >  =  { 
1340  apiOrigin : cloudbuildOrigin , 
@@ -30,21 +57,18 @@ function extractRepoSlugFromURI(remoteUri: string): string | undefined {
3057
3158/** 
3259 * Generates a repository ID. 
33-  * The relation is 1:* between Cloud Build Connection and Github  Repositories. 
60+  * The relation is 1:* between Cloud Build Connection and GitHub  Repositories. 
3461 */ 
3562function  generateRepositoryId ( remoteUri : string ) : string  |  undefined  { 
3663  return  extractRepoSlugFromURI ( remoteUri ) ?. replaceAll ( "/" ,  "-" ) ; 
3764} 
3865
3966/** 
40-  * The 'frameworks-' is prefixed, to seperate the Cloud Build connections created from 
41-  * Frameworks platforms with rest of manually created Cloud Build connections. 
42-  * 
43-  * The reason suffix 'location' is because of 
44-  * 1:1 relation between location and Cloud Build connection. 
67+  * Generates connection id that matches speicifc id format recognized by all Firebase clients. 
4568 */ 
46- function  generateConnectionId ( location : string ) : string  { 
47-   return  `frameworks-${ location }  ; 
69+ function  generateConnectionId ( ) : string  { 
70+   const  randomHash  =  Math . random ( ) . toString ( 36 ) . slice ( 6 ) ; 
71+   return  `frameworks-github-conn-${ randomHash }  ; 
4872} 
4973
5074/** 
@@ -54,70 +78,128 @@ export async function linkGitHubRepository(
5478  projectId : string , 
5579  location : string 
5680) : Promise < gcb . Repository >  { 
57-   logger . info ( clc . bold ( `\n${ clc . white ( "===" ) }  ) ) ; 
58-   const  connectionId  =  generateConnectionId ( location ) ; 
59-   await  getOrCreateConnection ( projectId ,  location ,  connectionId ) ; 
81+   logger . info ( clc . bold ( `\n${ clc . yellow ( "===" ) }  ) ) ; 
82+   const  existingConns  =  await  listAppHostingConnections ( projectId ) ; 
83+   if  ( existingConns . length  <  1 )  { 
84+     let  oauthConn  =  await  getOrCreateConnection ( projectId ,  location ,  FRAMEWORKS_OAUTH_CONN_NAME ) ; 
85+     while  ( oauthConn . installationState . stage  ===  "PENDING_USER_OAUTH" )  { 
86+       oauthConn  =  await  promptConnectionAuth ( oauthConn ) ; 
87+     } 
88+     // Create or get connection resource that contains reference to the GitHub oauth token. 
89+     // Oauth token associated with this connection should be used to create other connection resources. 
90+     const  connectionId  =  generateConnectionId ( ) ; 
91+     const  conn  =  await  createConnection ( projectId ,  location ,  connectionId ,  { 
92+       authorizerCredential : oauthConn . githubConfig ?. authorizerCredential , 
93+     } ) ; 
94+     let  refreshedConn  =  conn ; 
95+     while  ( refreshedConn . installationState . stage  !==  "COMPLETE" )  { 
96+       refreshedConn  =  await  promptAppInstall ( conn ) ; 
97+     } 
98+     existingConns . push ( refreshedConn ) ; 
99+   } 
60100
61-   let  remoteUri   =  await  promptRepositoryURI ( projectId ,  location ,  connectionId ) ; 
101+   let  {   remoteUri,  connection  }   =  await  promptRepositoryUri ( projectId ,  location ,  existingConns ) ; 
62102  while  ( remoteUri  ===  "" )  { 
63103    await  utils . openInBrowser ( "https://github.com/apps/google-cloud-build/installations/new" ) ; 
64104    await  promptOnce ( { 
65105      type : "input" , 
66106      message :
67107        "Press ENTER once you have finished configuring your installation's access settings." , 
68108    } ) ; 
69-     remoteUri  =  await  promptRepositoryURI ( projectId ,  location ,  connectionId ) ; 
109+     const  selection  =  await  promptRepositoryUri ( projectId ,  location ,  existingConns ) ; 
110+     remoteUri  =  selection . remoteUri ; 
111+     connection  =  selection . connection ; 
70112  } 
71113
114+   // Ensure that the selected connection exists in the same region as the backend 
115+   const  {  id : connectionId  }  =  parseConnectionName ( connection . name ) ! ; 
116+   await  getOrCreateConnection ( projectId ,  location ,  connectionId ,  { 
117+     authorizerCredential : connection . githubConfig ?. authorizerCredential , 
118+     appInstallationId : connection . githubConfig ?. appInstallationId , 
119+   } ) ; 
72120  const  repo  =  await  getOrCreateRepository ( projectId ,  location ,  connectionId ,  remoteUri ) ; 
73121  logger . info ( ) ; 
74122  utils . logSuccess ( `Successfully linked GitHub repository at remote URI:\n ${ remoteUri }  ) ; 
75123  return  repo ; 
76124} 
77125
78- async  function  promptRepositoryURI ( 
126+ async  function  promptRepositoryUri ( 
79127  projectId : string , 
80128  location : string , 
81-   connectionId : string 
82- ) : Promise < string >  { 
83-   const  resp  =  await  gcb . fetchLinkableRepositories ( projectId ,  location ,  connectionId ) ; 
84-   if  ( ! resp . repositories  ||  resp . repositories . length  ===  0 )  { 
85-     throw  new  FirebaseError ( 
86-       "The GitHub App does not have access to any repositories. Please configure "  + 
87-         "your app installation permissions at https://github.com/settings/installations." 
88-     ) ; 
129+   connections : gcb . Connection [ ] 
130+ ) : Promise < {  remoteUri : string ;  connection : gcb . Connection  } >  { 
131+   const  remoteUriToConnection : Record < string ,  gcb . Connection >  =  { } ; 
132+   for  ( const  conn  of  connections )  { 
133+     const  {  id }  =  parseConnectionName ( conn . name ) ! ; 
134+     const  resp  =  await  gcb . fetchLinkableRepositories ( projectId ,  location ,  id ) ; 
135+     if  ( resp . repositories  &&  resp . repositories . length  >  1 )  { 
136+       for  ( const  repo  of  resp . repositories )  { 
137+         remoteUriToConnection [ repo . remoteUri ]  =  conn ; 
138+       } 
139+     } 
89140  } 
90-   const  choices  =  resp . repositories . map ( ( repo : gcb . Repository )  =>  ( { 
91-     name : extractRepoSlugFromURI ( repo . remoteUri )  ||  repo . remoteUri , 
92-     value : repo . remoteUri , 
141+ 
142+   const  choices  =  Object . keys ( remoteUriToConnection ) . map ( ( remoteUri : string )  =>  ( { 
143+     name : extractRepoSlugFromURI ( remoteUri )  ||  remoteUri , 
144+     value : remoteUri , 
93145  } ) ) ; 
94146  choices . push ( { 
95147    name : "Missing a repo? Select this option to configure your installation's access settings" , 
96148    value : "" , 
97149  } ) ; 
98150
99-   return  await  promptOnce ( { 
151+   const   remoteUri   =  await  promptOnce ( { 
100152    type : "list" , 
101153    message : "Which of the following repositories would you like to deploy?" , 
102154    choices, 
103155  } ) ; 
156+   return  {  remoteUri,  connection : remoteUriToConnection [ remoteUri ]  } ; 
104157} 
105158
106- async  function  promptConnectionAuth ( 
107-   conn : gcb . Connection , 
108-   projectId : string , 
109-   location : string , 
110-   connectionId : string 
111- ) : Promise < gcb . Connection >  { 
112-   logger . info ( "First, log in to GitHub, install and authorize Cloud Build app:" ) ; 
113-   logger . info ( conn . installationState . actionUri ) ; 
114-   await  utils . openInBrowser ( conn . installationState . actionUri ) ; 
159+ async  function  promptConnectionAuth ( conn : gcb . Connection ) : Promise < gcb . Connection >  { 
160+   logger . info ( "You must authorize the Cloud Build GitHub app." ) ; 
161+   logger . info ( ) ; 
162+   logger . info ( "First, sign in to GitHub and authorize Cloud Build GitHub app:" ) ; 
163+   const  cleanup  =  await  utils . openInBrowserPopup ( 
164+     conn . installationState . actionUri , 
165+     "Authorize the GitHub app" 
166+   ) ; 
167+   await  promptOnce ( { 
168+     type : "input" , 
169+     message : "Press Enter once you have authorized the app" , 
170+   } ) ; 
171+   cleanup ( ) ; 
172+   const  {  projectId,  location,  id }  =  parseConnectionName ( conn . name ) ! ; 
173+   return  await  gcb . getConnection ( projectId ,  location ,  id ) ; 
174+ } 
175+ 
176+ async  function  promptAppInstall ( conn : gcb . Connection ) : Promise < gcb . Connection >  { 
177+   logger . info ( "Now, install the Cloud Build GitHub app:" ) ; 
178+   const  targetUri  =  conn . installationState . actionUri . replace ( "install_v2" ,  "direct_install_v2" ) ; 
179+   logger . info ( targetUri ) ; 
180+   await  utils . openInBrowser ( targetUri ) ; 
115181  await  promptOnce ( { 
116182    type : "input" , 
117183    message :
118-       "Press Enter once you have authorized the app (Cloud Build) to access your GitHub repo." , 
184+       "Press Enter once you have installed or configured the Cloud Build GitHub app to access your GitHub repo." , 
185+   } ) ; 
186+   const  {  projectId,  location,  id }  =  parseConnectionName ( conn . name ) ! ; 
187+   return  await  gcb . getConnection ( projectId ,  location ,  id ) ; 
188+ } 
189+ 
190+ export  async  function  createConnection ( 
191+   projectId : string , 
192+   location : string , 
193+   connectionId : string , 
194+   githubConfig ?: gcb . GitHubConfig 
195+ ) : Promise < gcb . Connection >  { 
196+   const  op  =  await  gcb . createConnection ( projectId ,  location ,  connectionId ,  githubConfig ) ; 
197+   const  conn  =  await  poller . pollOperation < gcb . Connection > ( { 
198+     ...gcbPollerOptions , 
199+     pollerName : `create-${ location } ${ connectionId }  , 
200+     operationResourceName : op . name , 
119201  } ) ; 
120-   return  await   gcb . getConnection ( projectId ,   location ,   connectionId ) ; 
202+   return  conn ; 
121203} 
122204
123205/** 
@@ -126,27 +208,19 @@ async function promptConnectionAuth(
126208export  async  function  getOrCreateConnection ( 
127209  projectId : string , 
128210  location : string , 
129-   connectionId : string 
211+   connectionId : string , 
212+   githubConfig ?: gcb . GitHubConfig 
130213) : Promise < gcb . Connection >  { 
131214  let  conn : gcb . Connection ; 
132215  try  { 
133216    conn  =  await  gcb . getConnection ( projectId ,  location ,  connectionId ) ; 
134217  }  catch  ( err : unknown )  { 
135218    if  ( ( err  as  FirebaseError ) . status  ===  404 )  { 
136-       const  op  =  await  gcb . createConnection ( projectId ,  location ,  connectionId ) ; 
137-       conn  =  await  poller . pollOperation < gcb . Connection > ( { 
138-         ...gcbPollerOptions , 
139-         pollerName : `create-${ location } ${ connectionId }  , 
140-         operationResourceName : op . name , 
141-       } ) ; 
219+       conn  =  await  createConnection ( projectId ,  location ,  connectionId ,  githubConfig ) ; 
142220    }  else  { 
143221      throw  err ; 
144222    } 
145223  } 
146- 
147-   while  ( conn . installationState . stage  !==  "COMPLETE" )  { 
148-     conn  =  await  promptConnectionAuth ( conn ,  projectId ,  location ,  connectionId ) ; 
149-   } 
150224  return  conn ; 
151225} 
152226
@@ -193,5 +267,10 @@ export async function getOrCreateRepository(
193267
194268export  async  function  listFrameworksConnections ( projectId : string )  { 
195269  const  conns  =  await  gcb . listConnections ( projectId ,  "-" ) ; 
196-   return  conns . filter ( ( conn )  =>  FRAMEWORKS_CONN_PATTERN . test ( conn . name ) ) ; 
270+   return  conns . filter ( 
271+     ( conn )  => 
272+       FRAMEWORKS_CONN_PATTERN . test ( conn . name )  && 
273+       conn . installationState . stage  ===  "COMPLETE"  && 
274+       ! conn . disabled 
275+   ) ; 
197276} 
0 commit comments