1
- import { cloudbuildOrigin } from "../../../api " ;
2
- import { FirebaseError } from "../../../error" ;
1
+ import * as clc from "colorette " ;
2
+
3
3
import * as gcb from "../../../gcp/cloudbuild" ;
4
- import { logger } from "../../../logger" ;
5
4
import * as poller from "../../../operation-poller" ;
6
5
import * as utils from "../../../utils" ;
6
+ import { cloudbuildOrigin } from "../../../api" ;
7
+ import { FirebaseError } from "../../../error" ;
8
+ import { logger } from "../../../logger" ;
7
9
import { promptOnce } from "../../../prompt" ;
8
- import * as clc from "colorette" ;
10
+
11
+ export interface ConnectionNameParts {
12
+ projectId : string ;
13
+ location : string ;
14
+ id : string ;
15
+ }
9
16
10
17
const 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
+ }
11
38
12
39
const gcbPollerOptions : Omit < poller . OperationPollerOptions , "operationResourceName" > = {
13
40
apiOrigin : cloudbuildOrigin ,
@@ -20,7 +47,7 @@ const gcbPollerOptions: Omit<poller.OperationPollerOptions, "operationResourceNa
20
47
* Example usage:
21
48
* extractRepoSlugFromURI("https://github.com/user/repo.git") => "user/repo"
22
49
*/
23
- function extractRepoSlugFromURI ( remoteUri : string ) : string | undefined {
50
+ function extractRepoSlugFromUri ( remoteUri : string ) : string | undefined {
24
51
const match = / g i t h u b .c o m \/ ( .+ ) .g i t / . exec ( remoteUri ) ;
25
52
if ( ! match ) {
26
53
return undefined ;
@@ -30,21 +57,18 @@ function extractRepoSlugFromURI(remoteUri: string): string | undefined {
30
57
31
58
/**
32
59
* 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.
34
61
*/
35
62
function generateRepositoryId ( remoteUri : string ) : string | undefined {
36
- return extractRepoSlugFromURI ( remoteUri ) ?. replaceAll ( "/" , "-" ) ;
63
+ return extractRepoSlugFromUri ( remoteUri ) ?. replaceAll ( "/" , "-" ) ;
37
64
}
38
65
39
66
/**
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 specific id format recognized by all Firebase clients.
45
68
*/
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 } ` ;
48
72
}
49
73
50
74
/**
@@ -54,70 +78,128 @@ export async function linkGitHubRepository(
54
78
projectId : string ,
55
79
location : string
56
80
) : 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 . yellow ( "===" ) } Connect a GitHub repository` ) ) ;
82
+ const existingConns = await listFrameworksConnections ( 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
+ }
60
100
61
- let remoteUri = await promptRepositoryURI ( projectId , location , connectionId ) ;
101
+ let { remoteUri, connection } = await promptRepositoryUri ( projectId , location , existingConns ) ;
62
102
while ( remoteUri === "" ) {
63
103
await utils . openInBrowser ( "https://github.com/apps/google-cloud-build/installations/new" ) ;
64
104
await promptOnce ( {
65
105
type : "input" ,
66
106
message :
67
107
"Press ENTER once you have finished configuring your installation's access settings." ,
68
108
} ) ;
69
- remoteUri = await promptRepositoryURI ( projectId , location , connectionId ) ;
109
+ const selection = await promptRepositoryUri ( projectId , location , existingConns ) ;
110
+ remoteUri = selection . remoteUri ;
111
+ connection = selection . connection ;
70
112
}
71
113
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
+ } ) ;
72
120
const repo = await getOrCreateRepository ( projectId , location , connectionId , remoteUri ) ;
73
121
logger . info ( ) ;
74
122
utils . logSuccess ( `Successfully linked GitHub repository at remote URI:\n ${ remoteUri } ` ) ;
75
123
return repo ;
76
124
}
77
125
78
- async function promptRepositoryURI (
126
+ async function promptRepositoryUri (
79
127
projectId : string ,
80
128
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
+ }
89
140
}
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 ,
93
145
} ) ) ;
94
146
choices . push ( {
95
147
name : "Missing a repo? Select this option to configure your installation's access settings" ,
96
148
value : "" ,
97
149
} ) ;
98
150
99
- return await promptOnce ( {
151
+ const remoteUri = await promptOnce ( {
100
152
type : "list" ,
101
153
message : "Which of the following repositories would you like to deploy?" ,
102
154
choices,
103
155
} ) ;
156
+ return { remoteUri, connection : remoteUriToConnection [ remoteUri ] } ;
104
157
}
105
158
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 ) ;
115
181
await promptOnce ( {
116
182
type : "input" ,
117
183
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 ,
119
201
} ) ;
120
- return await gcb . getConnection ( projectId , location , connectionId ) ;
202
+ return conn ;
121
203
}
122
204
123
205
/**
@@ -126,27 +208,19 @@ async function promptConnectionAuth(
126
208
export async function getOrCreateConnection (
127
209
projectId : string ,
128
210
location : string ,
129
- connectionId : string
211
+ connectionId : string ,
212
+ githubConfig ?: gcb . GitHubConfig
130
213
) : Promise < gcb . Connection > {
131
214
let conn : gcb . Connection ;
132
215
try {
133
216
conn = await gcb . getConnection ( projectId , location , connectionId ) ;
134
217
} catch ( err : unknown ) {
135
218
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 ) ;
142
220
} else {
143
221
throw err ;
144
222
}
145
223
}
146
-
147
- while ( conn . installationState . stage !== "COMPLETE" ) {
148
- conn = await promptConnectionAuth ( conn , projectId , location , connectionId ) ;
149
- }
150
224
return conn ;
151
225
}
152
226
@@ -166,7 +240,7 @@ export async function getOrCreateRepository(
166
240
let repo : gcb . Repository ;
167
241
try {
168
242
repo = await gcb . getRepository ( projectId , location , connectionId , repositoryId ) ;
169
- const repoSlug = extractRepoSlugFromURI ( repo . remoteUri ) ;
243
+ const repoSlug = extractRepoSlugFromUri ( repo . remoteUri ) ;
170
244
if ( repoSlug ) {
171
245
throw new FirebaseError ( `${ repoSlug } has already been linked.` ) ;
172
246
}
@@ -193,5 +267,10 @@ export async function getOrCreateRepository(
193
267
194
268
export async function listFrameworksConnections ( projectId : string ) {
195
269
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
+ ) ;
197
276
}
0 commit comments