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 both CLI/ web console.
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 ( "===" ) } Connect a github repository` ) ) ;
58- const connectionId = generateConnectionId ( location ) ;
59- await getOrCreateConnection ( projectId , location , connectionId ) ;
81+ logger . info ( clc . bold ( `\n${ clc . white ( "===" ) } Connect a GitHub repository` ) ) ;
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, log in to GitHub, install 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 (Cloud Build)" ,
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 listAppHostingConnections ( 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