forked from WordPress/gutenberg
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathcherry-pick.mjs
525 lines (485 loc) · 14.3 KB
/
cherry-pick.mjs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
/**
* External dependencies
*/
import fetch from 'node-fetch';
import readline from 'readline';
import { spawnSync } from 'node:child_process';
const REPO = 'WordPress/gutenberg';
const LABEL = process.argv[ 2 ] || 'Backport to WP Beta/RC';
const BACKPORT_COMPLETED_LABEL = 'Backported to WP Core';
const BRANCH = getCurrentBranch();
const GITHUB_CLI_AVAILABLE = spawnSync( 'gh', [ 'auth', 'status' ] )
?.stdout?.toString()
.includes( '✓ Logged in to github.com' );
const AUTO_PROPAGATE_RESULTS_TO_GITHUB = GITHUB_CLI_AVAILABLE;
/**
* The main function of this script. It:
* * Confirms with the developer the current branch aligns with the expectations
* * Gets local branches in sync with the remote ones
* * Requests the list of PRs to cherry-pick from GitHub API (closed, label=`Backport to WP Beta/RC`)
* * Runs `git cherry-pick {commitHash}` for each PR
* * It keeps track of the failed cherry-picks and then retries them
* * Retrying keeps going as long as at least one cherry-pick succeeds
* * Pushes the local branch to `origin`
* * (optional) Uses the [`gh` console utility](https://cli.github.com/) to comment on the remote PRs and remove the labels
* * Reports the results
*/
async function main() {
if ( ! GITHUB_CLI_AVAILABLE ) {
await reportGhUnavailable();
}
console.log( `You are on branch "${ BRANCH }".` );
console.log( `This script will:` );
console.log(
`• Cherry-pick the merged PRs labeled as "${ LABEL }" to this branch`
);
console.log( `• Ask whether you want to push this branch` );
console.log( `• Comment on each PR` );
console.log( `• Remove the label from each PR` );
console.log(
`The last two actions will be performed USING YOUR GITHUB ACCOUNT that`
);
console.log( `you've linked to your GitHub CLI (gh command)` );
console.log( `` );
await promptDoYouWantToProceed();
console.log( `$ git pull origin ${ BRANCH } --rebase...` );
cli( 'git', [ 'pull', 'origin', BRANCH, '--rebase' ], true );
console.log( `$ git fetch origin trunk...` );
cli( 'git', [ 'fetch', 'origin', 'trunk' ], true );
const PRs = await fetchPRs();
console.log( 'Trying to cherry-pick one by one...' );
const [ successes, failures ] = cherryPickAll( PRs );
console.log( 'Cherry-picking finished!' );
reportSummaryNextSteps( successes, failures );
if ( successes.length ) {
if ( AUTO_PROPAGATE_RESULTS_TO_GITHUB ) {
console.log( `About to push to origin/${ BRANCH }` );
await promptDoYouWantToProceed();
cli( 'git', [ 'push', 'origin', BRANCH ] );
console.log( `Commenting and removing labels...` );
successes.forEach( GHcommentAndRemoveLabel );
} else {
console.log( 'Cherry-picked PRs with copy-able comments:' );
successes.forEach( reportSuccessManual );
}
}
if ( failures.length ) {
console.log( 'PRs that could not be cherry-picked automatically:' );
failures.forEach( reportFailure );
}
console.log( `Done!` );
}
/**
* Synchronously executes a CLI command and returns the result or throws an error on failure.
*
* @param {string} command A command to execute.
* @param {string[]} args CLI args.
* @param {boolean} pipe If true, pipes the output to this process's stdout and stderr.
* @return {string} Command's output.
*/
function cli( command, args, pipe = false ) {
const pipeOptions = {
cwd: process.cwd(),
env: process.env,
stdio: 'pipe',
encoding: 'utf-8',
};
const result = spawnSync(
command,
args,
...( pipe ? [ pipeOptions ] : [] )
);
if ( result.status !== 0 ) {
throw new Error( result.stderr?.toString()?.trim() );
}
return result.stdout.toString().trim();
}
/**
* Retrieves the details of PR we want to cherry-pick from GitHub API.
*
* @return {Promise<Object[]>} A list of relevant PR data objects.
*/
async function fetchPRs() {
const { items } = await GitHubFetch(
`/search/issues?per_page=100&q=is:pr state:closed sort:updated label:"${ LABEL }" repo:${ REPO }`
);
const PRs = items
// eslint-disable-next-line camelcase
.map( ( { id, number, title, pull_request } ) => ( {
id,
number,
title,
// eslint-disable-next-line camelcase
pull_request,
} ) )
// eslint-disable-next-line camelcase
.filter( ( { pull_request } ) => !! pull_request?.merged_at )
.sort(
( a, b ) =>
new Date( a?.pull_request?.merged_at ) -
new Date( b?.pull_request?.merged_at )
);
console.log(
'Found the following PRs to cherry-pick (sorted by closed date in ascending order): '
);
PRs.forEach( ( { number, title } ) =>
console.log( indent( `#${ number } – ${ title }` ) )
);
console.log( 'Fetching commit IDs...' );
const PRsWithMergeCommit = [];
for ( const PR of PRs ) {
const { merge_commit_sha: mergeCommitHash } = await GitHubFetch(
`/repos/${ REPO }/pulls/` + PR.number
);
PRsWithMergeCommit.push( {
...PR,
mergeCommitHash,
} );
if ( ! mergeCommitHash ) {
throw new Error(
`Cannot fetch the merge commit sha for ${ prToString( PR ) }`
);
}
}
console.log( 'Done!' );
PRsWithMergeCommit.forEach( ( msg ) =>
console.log( indent( `${ prToString( msg ) }` ) )
);
return PRsWithMergeCommit;
}
/**
* A utility function for GET requesting GitHub API.
*
* @param {string} path The API path to request.
* @return {Promise<Object>} Parsed response JSON.
*/
async function GitHubFetch( path ) {
const token = getGitHubAuthToken();
const response = await fetch( 'https://api.github.com' + path, {
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${ token }`,
},
} );
return await response.json();
}
/**
* Retrieves the GitHub authentication token using `gh auth token`.
*
* @return {string} The GitHub authentication token.
*/
function getGitHubAuthToken() {
return cli( 'gh', [ 'auth', 'token' ] );
}
/**
* Attempts to cherry-pick given PRs using `git` CLI command.
*
* Retries failed cherry-picks if any other PR got successfully cherry-picked
* success since the last attempt.
*
* @param {Object[]} PRs The list of PRs to cherry-pick.
* @return {Array} A two-tuple containing a list of successful cherry-picks and a list of failed ones.
*/
function cherryPickAll( PRs ) {
let remainingPRs = [ ...PRs ];
let i = 1;
let allSuccesses = [];
while ( remainingPRs.length ) {
console.log( `Cherry-picking round ${ i++ }: ` );
const [ successes, failures ] = cherryPickRound( remainingPRs );
allSuccesses = [ ...allSuccesses, ...successes ];
remainingPRs = failures;
if ( ! successes.length ) {
console.log(
'Nothing merged cleanly in the last round, breaking.'
);
break;
}
}
return [ allSuccesses, remainingPRs ];
}
/**
* Attempts to cherry-pick given PRs using `git` CLI command.
*
* Processes every PR once.
*
* @param {Object[]} PRs The list of PRs to cherry-pick.
* @return {Array} A two-tuple containing a list of successful cherry-picks and a list of failed ones.
*/
function cherryPickRound( PRs ) {
const stack = [ ...PRs ];
const successes = [];
const failures = [];
while ( stack.length ) {
const PR = stack.shift();
try {
const cherryPickHash = cherryPickOne( PR.mergeCommitHash );
successes.push( {
...PR,
cherryPickHash,
} );
console.log(
indent(
`✅ cherry-pick commit: ${ cherryPickHash } for PR: ${ prToString(
PR,
false
) }`
)
);
} catch ( e ) {
failures.push( {
...PR,
error: e.toString(),
} );
console.log( indent( `❌ ${ prToString( PR ) }` ) );
}
}
return [ successes, failures ];
}
/**
* Identity function
*
* @param {*} x Input.
* @return {*} Input
*/
const identity = ( x ) => x;
/**
* Formats a PR object in a human readable way.
*
* @param {Object} PR PR details.
* @param {number} PR.number
* @param {string} PR.mergeCommitHash
* @param {string} PR.title
* @param {boolean} withMergeCommitHash Should include the commit hash in the output?
* @return {string} Formatted text
*/
function prToString(
{ number, mergeCommitHash, title },
withMergeCommitHash = true
) {
return [
`#${ number }`,
withMergeCommitHash ? mergeCommitHash?.substr( 0, 20 ) : '',
`${ title?.substr( 0, 30 ) }${ title?.length > 30 ? '...' : '' }`,
]
.filter( identity )
.join( ' – ' );
}
/**
* Indents a block of text with {width} spaces
*
* @param {string} text The text to indent.
* @param {number} width Number of spaces to use.
* @return {string} Indented text.
*/
function indent( text, width = 3 ) {
const _indent = ' '.repeat( width );
return text
.split( '\n' )
.map( ( line ) => _indent + line )
.join( '\n' );
}
/**
* Attempts to cherry-pick a given commit into the current branch,
*
* @param {string} commit A commit hash.
* @return {string} Branch name.
*/
function cherryPickOne( commit ) {
const result = spawnSync( 'git', [ 'cherry-pick', commit ] );
const message = result.stdout.toString().trim();
if ( result.status !== 0 || ! message.includes( 'Author: ' ) ) {
spawnSync( 'git', [ 'reset', '--hard' ] );
throw new Error( result.stderr.toString().trim() );
}
const commitHashOutput = spawnSync( 'git', [
'rev-parse',
'--short',
'HEAD',
] );
return commitHashOutput.stdout.toString().trim();
}
/**
* When the cherry-picking phase is over, this function outputs the stats
* and informs about the next steps to take.
*
* @param {Array} successes Successful cherry-picks.
* @param {Array} failures Failed cherry-picks.
*/
function reportSummaryNextSteps( successes, failures ) {
console.log( 'Summary:' );
console.log(
indent( `✅ ${ successes.length } PRs got cherry-picked cleanly` )
);
console.log(
indent(
`${ failures.length > 0 ? '❌' : '✅' } ${
failures.length
} PRs failed`
)
);
console.log( '' );
const nextSteps = [];
if ( successes.length && ! AUTO_PROPAGATE_RESULTS_TO_GITHUB ) {
nextSteps.push( 'Push this branch' );
nextSteps.push( 'Go to each of the cherry-picked Pull Requests' );
nextSteps.push( `Remove the ${ LABEL } label` );
if ( LABEL === 'Backport to WP Beta/RC' ) {
nextSteps.push( `Add the "${ BACKPORT_COMPLETED_LABEL }" label` );
}
nextSteps.push( 'Request a backport to wordpress-develop if required' );
nextSteps.push( 'Comment, say that PR just got cherry-picked' );
}
if ( failures.length ) {
nextSteps.push( 'Manually cherry-pick the PRs that failed' );
}
if ( nextSteps.length ) {
console.log( 'Next steps:' );
for ( let i = 0; i < nextSteps.length; i++ ) {
console.log( indent( `${ i + 1 }. ${ nextSteps[ i ] }` ) );
}
console.log( '' );
}
}
/**
* Comment on a given PR to tell the author it's been cherry-picked into a release branch
* Also, removes the backport label (or any other label used to mark this PR for backporting).
*
* Uses the `gh` CLI utility.
*
* @param {Object} pr PR details.
*/
function GHcommentAndRemoveLabel( pr ) {
const { number, cherryPickHash } = pr;
const comment = prComment( cherryPickHash );
const repo = [ '--repo', REPO ];
try {
cli( 'gh', [ 'pr', 'comment', number, ...repo, '--body', comment ] );
cli( 'gh', [ 'pr', 'edit', number, ...repo, '--remove-label', LABEL ] );
if ( LABEL === 'Backport to WP Beta/RC' ) {
cli( 'gh', [
'pr',
'edit',
number,
...repo,
'--add-label',
BACKPORT_COMPLETED_LABEL,
] );
}
console.log( `✅ ${ number }: ${ comment }` );
} catch ( e ) {
console.log( `❌ ${ number }. ${ comment } ` );
console.log( indent( 'Error: ' ) );
console.error( e );
console.log( indent( 'You will need to manually process this PR: ' ) );
reportSuccessManual( pr );
}
}
/**
* When cherry-pick succeeds, this function outputs the manual next steps to take.
*
* @param {Object} PR PR details.
* @param {number} PR.number
* @param {string} PR.title
* @param {string} PR.cherryPickHash
*/
function reportSuccessManual( { number, title, cherryPickHash } ) {
console.log( indent( prUrl( number ) ) );
console.log( indent( `#${ number } ${ title }` ) );
console.log( indent( prComment( cherryPickHash ) ) );
console.log( '' );
}
/**
* When cherry-pick fails, this function outputs the details.
*
* @param {Object} PR PR details.
* @param {number} PR.number
* @param {string} PR.title
* @param {string} PR.error
* @param {string} PR.mergeCommitHash
*/
function reportFailure( { number, title, error, mergeCommitHash } ) {
console.log( indent( prUrl( number ) ) );
console.log( indent( `#${ number } ${ title }` ) );
console.log( indent( `git cherry-pick ${ mergeCommitHash }`, 6 ) );
console.log( indent( `failed with:`, 6 ) );
console.log( indent( `${ error }`, 6 ) );
console.log( '' );
}
/**
* Returns the URL of the Gutenberg PR given its number.
*
* @param {number} number
* @return {string} PR URL.
*/
function prUrl( number ) {
return `https://github.com/${ REPO }/pull/${ number } `;
}
/**
* Returns the comment informing that a PR was just cherry-picked to the
* release branch.
*
* @param {string} cherryPickHash
* @return {string} Comment contents.
*/
function prComment( cherryPickHash ) {
return `I just cherry-picked this PR to the ${ BRANCH } branch to get it included in the next release: ${ cherryPickHash }`;
}
/**
* Returns the current git branch.
*
* @return {string} Branch name.
*/
function getCurrentBranch() {
return spawnSync( 'git', [ 'rev-parse', '--abbrev-ref', 'HEAD' ] )
.stdout.toString()
.trim();
}
/**
* Reports when the gh CLI tool is missing, describes the consequences, asks
* whether to proceed.
*
* @return {Promise<void>}
*/
async function reportGhUnavailable() {
console.log(
'GitHub CLI is not setup. This script will not be able to automatically'
);
console.log(
'comment on the processed PRs and remove the backport label from them.'
);
console.log(
'Instead, you will see a detailed list of next steps to perform manually.'
);
console.log( '' );
console.log(
'To enable automatic handling, install the `gh` utility from https://cli.github.com/'
);
console.log( '' );
await promptDoYouWantToProceed();
}
/**
* Asks a CLI prompt whether the user wants to proceed.
* Exits if not.
*
* @return {Promise<void>}
*/
async function promptDoYouWantToProceed() {
const rl = readline.createInterface( {
input: process.stdin,
output: process.stdout,
} );
const question = ( prompt ) =>
new Promise( ( resolve ) => rl.question( prompt, resolve ) );
do {
const answer = await question( 'Do you want to proceed? (Y/n)' );
if ( ! answer || answer === 'Y' ) {
break;
}
if ( answer === 'n' ) {
process.exit( 0 );
}
} while ( true );
rl.close();
}
main();