581 lines
14 KiB
JavaScript
581 lines
14 KiB
JavaScript
|
/**
|
||
|
* Tool to automate steps to cherry pick fixes into release branch.
|
||
|
*
|
||
|
* @package
|
||
|
*/
|
||
|
|
||
|
import fetch from 'node-fetch';
|
||
|
import os from 'os';
|
||
|
import fs from 'node:fs';
|
||
|
import path from 'node:path';
|
||
|
import { spawnSync, spawn } from 'node:child_process';
|
||
|
import ora from 'ora';
|
||
|
|
||
|
const args = process.argv.slice( 2 );
|
||
|
const usage = 'Usage: pnpm cherry-pick <release_branch> <pull_request_number>. Separate pull request numbers with a comma (no space) if more than one.';
|
||
|
const releaseBranch = args[ 0 ];
|
||
|
const prs = args[ 1 ];
|
||
|
let githubToken = '';
|
||
|
let tempWooDir = '';
|
||
|
let changelogsToBeDeleted = [];
|
||
|
let prCommits = {};
|
||
|
let githubRemoteURL = 'https';
|
||
|
|
||
|
if ( typeof process.env.GITHUB_CHERRY_PICK_TOKEN === 'undefined' ) {
|
||
|
console.error( 'A GitHub token needs to be assigned to the "GITHUB_CHERRY_PICK_TOKEN" environment variable' );
|
||
|
process.exit( 1 );
|
||
|
}
|
||
|
|
||
|
if ( typeof process.env.GITHUB_REMOTE_URL !== 'undefined' ) {
|
||
|
githubRemoteURL = process.env.GITHUB_REMOTE_URL;
|
||
|
}
|
||
|
|
||
|
// If no arguments are passed.
|
||
|
if ( ! args.length ) {
|
||
|
console.error( usage );
|
||
|
process.exit( 1 );
|
||
|
}
|
||
|
|
||
|
// If missing one of the arguments.
|
||
|
if ( typeof releaseBranch === 'undefined' || typeof prs === 'undefined' ) {
|
||
|
console.error( usage );
|
||
|
process.exit( 1 );
|
||
|
}
|
||
|
|
||
|
// Accounts for multiple PRs.
|
||
|
const prsArr = prs.split( ',' );
|
||
|
const version = releaseBranch.replace( 'release/', '' );
|
||
|
const cherryPickBranch = 'cherry-pick-' + version + '/' + prsArr.toString().replace( ',', '-' );
|
||
|
githubToken = process.env.GITHUB_CHERRY_PICK_TOKEN;
|
||
|
|
||
|
async function getCommitFromPrs( prsArr ) {
|
||
|
let properties = {
|
||
|
method: 'GET',
|
||
|
headers: {
|
||
|
'Accept': 'application/vnd.github.v3+json',
|
||
|
'Authorization': `token ${githubToken}`
|
||
|
}
|
||
|
};
|
||
|
|
||
|
for ( const pr of prsArr ) {
|
||
|
const response = await fetch( 'https://api.github.com/repos/woocommerce/woocommerce/pulls/' + pr, properties );
|
||
|
|
||
|
if ( response.status !== 200 ) {
|
||
|
throw 'One or more of the PR reference was not found.';
|
||
|
}
|
||
|
|
||
|
await response.json().then( data => {
|
||
|
prCommits[ pr ] = data.merge_commit_sha;
|
||
|
} );
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Checks out a branch.
|
||
|
*
|
||
|
* @param string branch The branch to checkout.
|
||
|
* @param boolean newBranch A flag to create a new branch.
|
||
|
*/
|
||
|
function checkoutBranch( branch, newBranch = false ) {
|
||
|
const spinner = ora( {
|
||
|
spinner: 'bouncingBar',
|
||
|
color: 'green'
|
||
|
} );
|
||
|
|
||
|
let output = [];
|
||
|
|
||
|
return new Promise( ( resolve, reject ) => {
|
||
|
let response = spawn( 'git', [ 'checkout', branch ] );
|
||
|
|
||
|
if ( newBranch ) {
|
||
|
response = spawn( 'git', [ 'checkout', '-b', branch ] );
|
||
|
}
|
||
|
|
||
|
response.stdout.on( 'data', ( data ) => {
|
||
|
output.push( `${data}` );
|
||
|
} );
|
||
|
|
||
|
response.stderr.on( 'data', ( data ) => {
|
||
|
output.push( `${data}` );
|
||
|
} );
|
||
|
|
||
|
response.on( 'close', ( code ) => {
|
||
|
if ( `${code}` == 0 ) {
|
||
|
spinner.succeed( `Switched to '${branch}'` );
|
||
|
resolve();
|
||
|
}
|
||
|
|
||
|
reject( `error: ${output.toString().replace( ',', "\n" )}` );
|
||
|
} );
|
||
|
} ).catch( err => {
|
||
|
spinner.fail( 'Failed to switch branch' );
|
||
|
throw err;
|
||
|
} );
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Create the temp directory in system temp.
|
||
|
*
|
||
|
* @param string path The path to create.
|
||
|
*/
|
||
|
function createDir( path ) {
|
||
|
return new Promise( ( resolve, reject ) => {
|
||
|
fs.mkdtemp( path, ( err, directory ) => {
|
||
|
if ( err ) {
|
||
|
reject( err );
|
||
|
}
|
||
|
|
||
|
tempWooDir = directory;
|
||
|
|
||
|
resolve();
|
||
|
} );
|
||
|
} ).catch( err => {
|
||
|
throw err;
|
||
|
} );
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Clones the WooCommerce repo into temp dir.
|
||
|
*
|
||
|
* @param string woodir The temporary system directory.
|
||
|
*/
|
||
|
function cloneWoo() {
|
||
|
const spinner = ora( {
|
||
|
text: `Cloning WooCommerce into ${tempWooDir}/woocommerce`,
|
||
|
spinner: 'bouncingBar',
|
||
|
color: 'green'
|
||
|
} );
|
||
|
|
||
|
spinner.start();
|
||
|
|
||
|
let output = [];
|
||
|
|
||
|
return new Promise( ( resolve, reject ) => {
|
||
|
let url = 'https://github.com/woocommerce/woocommerce.git';
|
||
|
|
||
|
if ( githubRemoteURL === 'ssh' ) {
|
||
|
url = 'git@github.com:woocommerce/woocommerce.git';
|
||
|
}
|
||
|
|
||
|
const response = spawn( 'git', [ 'clone', url ] );
|
||
|
|
||
|
response.stdout.on( 'data', ( data ) => {
|
||
|
output.push( `${data}` );
|
||
|
} );
|
||
|
|
||
|
response.stderr.on( 'data', ( data ) => {
|
||
|
output.push( `${data}` );
|
||
|
} );
|
||
|
|
||
|
response.on( 'close', ( code ) => {
|
||
|
if ( `${code}` == 0 ) {
|
||
|
spinner.succeed();
|
||
|
resolve();
|
||
|
}
|
||
|
|
||
|
reject( `error: ${output.toString().replace( ',', "\n" )}` );
|
||
|
} );
|
||
|
} ).catch( err => {
|
||
|
spinner.fail( 'Fail to clone WooCommerce!' );
|
||
|
throw err;
|
||
|
} );
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Cherry picks.
|
||
|
*
|
||
|
* @param string commit The commit hash to cherry pick.
|
||
|
*/
|
||
|
function cherryPick( commit ) {
|
||
|
const spinner = ora( {
|
||
|
text: `Cherry picking ${commit}`,
|
||
|
spinner: 'bouncingBar',
|
||
|
color: 'green'
|
||
|
} );
|
||
|
|
||
|
spinner.start();
|
||
|
|
||
|
const response = spawnSync( 'git', [ 'cherry-pick', commit ] );
|
||
|
|
||
|
if ( response.status == 0 ) {
|
||
|
spinner.succeed();
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if ( response.stderr ) {
|
||
|
if ( response.stderr.match( 'fatal: bad revision' ) || response.stderr.match( 'error: could not apply' ) ) {
|
||
|
spinner.fail( `Fail cherry picking ${commit}` );
|
||
|
throw `stderr: ${response.stderr}`;
|
||
|
}
|
||
|
};
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Function to change directories.
|
||
|
*
|
||
|
* @param string dir The directory to change to.
|
||
|
*/
|
||
|
function chdir( dir ) {
|
||
|
try {
|
||
|
process.chdir( dir );
|
||
|
} catch ( e ) {
|
||
|
throw e;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Generates the changelog for readme.txt.
|
||
|
*
|
||
|
* @param string pr The PR to use.
|
||
|
* @param string commit The commit to use.
|
||
|
*/
|
||
|
async function generateChangelog( pr, commit ) {
|
||
|
let properties = {
|
||
|
method: 'GET',
|
||
|
headers: {
|
||
|
'Content-Type': 'application/vnd.github.v3+json'
|
||
|
}
|
||
|
};
|
||
|
|
||
|
if ( githubToken ) {
|
||
|
properties.headers.Authorization = 'token ' + githubToken;
|
||
|
}
|
||
|
|
||
|
const response = await fetch( 'https://api.github.com/repos/woocommerce/woocommerce/commits/' + commit, properties );
|
||
|
|
||
|
if ( response.status !== 200 ) {
|
||
|
throw 'Commit was not found.';
|
||
|
}
|
||
|
|
||
|
let changelogTxt = '';
|
||
|
|
||
|
await response.json().then( data => {
|
||
|
for ( const file of data.files ) {
|
||
|
if ( file.filename.match( 'plugins/woocommerce/changelog/' ) ) {
|
||
|
if ( changelogsToBeDeleted.indexOf( file.filename ) === -1 ) {
|
||
|
changelogsToBeDeleted.push( file.filename );
|
||
|
}
|
||
|
|
||
|
let changelogEntry = '';
|
||
|
let changelogEntryType = '';
|
||
|
|
||
|
fs.readFile( './' + file.filename, 'utf-8', function( err, data ) {
|
||
|
if ( err ) {
|
||
|
throw err;
|
||
|
}
|
||
|
|
||
|
const changelogEntryArr = data.split( "\n" );
|
||
|
changelogEntryType = data.match( /Type: (.+)/i );
|
||
|
changelogEntryType = changelogEntryType[ 1 ].charAt( 0 ).toUpperCase() + changelogEntryType[ 1 ].slice( 1 );
|
||
|
|
||
|
changelogEntry = changelogEntryArr.filter( el => {
|
||
|
return el !== null && typeof el !== 'undefined' && el !== '';
|
||
|
} );
|
||
|
changelogEntry = changelogEntry[ changelogEntry.length - 1 ];
|
||
|
|
||
|
// Check if changelogEntry is what we want.
|
||
|
if ( changelogEntry.length < 1 ) {
|
||
|
changelogEntry = false;
|
||
|
}
|
||
|
|
||
|
if ( changelogEntry.match( /significance:/i ) ) {
|
||
|
changelogEntry = false;
|
||
|
}
|
||
|
|
||
|
if ( changelogEntry.match( /type:/i ) ) {
|
||
|
changelogEntry = false;
|
||
|
}
|
||
|
|
||
|
if ( changelogEntry.match( /comment:/i ) ) {
|
||
|
changelogEntry = false;
|
||
|
}
|
||
|
} );
|
||
|
|
||
|
if ( changelogEntry === false ) {
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
fs.readFile( './plugins/woocommerce/readme.txt', 'utf-8', function( err, data ) {
|
||
|
if ( err ) {
|
||
|
throw err;
|
||
|
}
|
||
|
|
||
|
const spinner = ora( {
|
||
|
text: `Generating changelog entry for PR ${pr}`,
|
||
|
spinner: 'bouncingBar',
|
||
|
color: 'green'
|
||
|
} );
|
||
|
|
||
|
spinner.start();
|
||
|
|
||
|
changelogTxt = data.split( "\n" );
|
||
|
let isInRange = false;
|
||
|
let newChangelogTxt = [];
|
||
|
|
||
|
for ( const line of changelogTxt ) {
|
||
|
if ( isInRange === false && line === '== Changelog ==' ) {
|
||
|
isInRange = true;
|
||
|
}
|
||
|
|
||
|
if ( isInRange === true && line.match( /\*\*WooCommerce Blocks/ ) ) {
|
||
|
isInRange = false;
|
||
|
}
|
||
|
|
||
|
// Find the first match of the entry "Type".
|
||
|
if ( isInRange && line.match( `\\* ${changelogEntryType} -` ) ) {
|
||
|
newChangelogTxt.push( '* ' + changelogEntryType + ' - ' + changelogEntry + ` [#${pr}](https://github.com/woocommerce/woocommerce/pull/${pr})` );
|
||
|
newChangelogTxt.push( line );
|
||
|
isInRange = false;
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
newChangelogTxt.push( line );
|
||
|
}
|
||
|
|
||
|
fs.writeFile( './plugins/woocommerce/readme.txt', newChangelogTxt.join( "\n" ), err => {
|
||
|
if ( err ) {
|
||
|
spinner.fail( `Unable to generate the changelog entry for PR ${pr}` );
|
||
|
throw err;
|
||
|
}
|
||
|
|
||
|
spinner.succeed();
|
||
|
} );
|
||
|
} );
|
||
|
}
|
||
|
}
|
||
|
} );
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Git remove changelog files.
|
||
|
*
|
||
|
*/
|
||
|
function deleteChangelogFiles() {
|
||
|
const spinner = ora( {
|
||
|
spinner: 'bouncingBar',
|
||
|
color: 'green'
|
||
|
} );
|
||
|
|
||
|
const files = changelogsToBeDeleted.join( ' ' );
|
||
|
const filesFormatted = "\n" + files.replace( ' ', "\n" );
|
||
|
let output = [];
|
||
|
|
||
|
return new Promise( ( resolve, reject ) => {
|
||
|
const response = spawn( 'git', [ 'rm', files ], { shell: true } );
|
||
|
|
||
|
response.stdout.on( 'data', ( data ) => {
|
||
|
output.push( `${data}` );
|
||
|
} );
|
||
|
|
||
|
response.stderr.on( 'data', ( data ) => {
|
||
|
output.push( `${data}` );
|
||
|
} );
|
||
|
|
||
|
response.on( 'close', ( code ) => {
|
||
|
if ( `${code}` == 0 ) {
|
||
|
spinner.succeed( `Removed changelog files:${filesFormatted}` );
|
||
|
resolve();
|
||
|
}
|
||
|
|
||
|
reject( `error: ${output.toString().replace( ',', "\n" )}` );
|
||
|
} );
|
||
|
} ).catch( err => {
|
||
|
spinner.fail( 'Fail to delete changelog files' );
|
||
|
throw err;
|
||
|
} );
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Commit changes.
|
||
|
*
|
||
|
* @param string msg The message for the commit.
|
||
|
*/
|
||
|
function commitChanges( msg ) {
|
||
|
const spinner = ora( {
|
||
|
spinner: 'bouncingBar',
|
||
|
color: 'green'
|
||
|
} );
|
||
|
|
||
|
let output = [];
|
||
|
|
||
|
return new Promise( ( resolve, reject ) => {
|
||
|
const response = spawn( 'git', [ 'commit', '--no-verify', '-am', msg ] );
|
||
|
|
||
|
response.stdout.on( 'data', ( data ) => {
|
||
|
output.push( `${data}` );
|
||
|
} );
|
||
|
|
||
|
response.stderr.on( 'data', ( data ) => {
|
||
|
output.push( `${data}` );
|
||
|
} );
|
||
|
|
||
|
response.on( 'close', ( code ) => {
|
||
|
if ( `${code}` == 0 ) {
|
||
|
spinner.succeed( `Commited changes.` );
|
||
|
resolve();
|
||
|
}
|
||
|
|
||
|
reject( `error: ${output.toString().replace( ',', "\n" )}` );
|
||
|
} );
|
||
|
} ).catch( err => {
|
||
|
spinner.fail( 'Fail to commit changes.' );
|
||
|
throw err;
|
||
|
} );
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Push the branch up to GitHub.
|
||
|
*
|
||
|
* @param string branch The branch to push to GitHub.
|
||
|
*/
|
||
|
function pushBranch( branch ) {
|
||
|
const spinner = ora( {
|
||
|
spinner: 'bouncingBar',
|
||
|
color: 'green'
|
||
|
} );
|
||
|
|
||
|
spinner.start( `Pushing ${branch} to GitHub...` );
|
||
|
|
||
|
let output = [];
|
||
|
|
||
|
return new Promise( ( resolve, reject ) => {
|
||
|
const response = spawn( 'git', [ 'push', 'origin', branch ] );
|
||
|
|
||
|
response.stdout.on( 'data', ( data ) => {
|
||
|
output.push( `${data}` );
|
||
|
} );
|
||
|
|
||
|
response.stderr.on( 'data', ( data ) => {
|
||
|
output.push( `${data}` );
|
||
|
} );
|
||
|
|
||
|
response.on( 'close', ( code ) => {
|
||
|
if ( `${code}` == 0 ) {
|
||
|
spinner.succeed();
|
||
|
resolve();
|
||
|
}
|
||
|
|
||
|
reject( `error: ${output.toString().replace( ',', "\n" )}` );
|
||
|
} );
|
||
|
} ).catch( err => {
|
||
|
spinner.fail( `Fail to push ${branch} up.` );
|
||
|
throw err;
|
||
|
} );
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Create pull request on GitHub.
|
||
|
*
|
||
|
* @param string title The title of the PR.
|
||
|
* @param string body The body content of the PR.
|
||
|
* @param string head The head of the branch to use.
|
||
|
* @param string base The base branch to targe against.
|
||
|
*/
|
||
|
async function createPR( title, body, head, base ) {
|
||
|
const spinner = ora( {
|
||
|
spinner: 'bouncingBar',
|
||
|
color: 'green'
|
||
|
} );
|
||
|
|
||
|
spinner.start( `Creating pull request for ${head} on GitHub...` );
|
||
|
|
||
|
const response = await fetch( 'https://api.github.com/repos/woocommerce/woocommerce/pulls', {
|
||
|
method: 'POST',
|
||
|
headers: {
|
||
|
'Accept': 'application/vnd.github.v3+json',
|
||
|
'Authorization': `token ${githubToken}`,
|
||
|
'Content-Type': 'application/json'
|
||
|
},
|
||
|
body: JSON.stringify( {
|
||
|
"title": title,
|
||
|
"body": body,
|
||
|
"head": head,
|
||
|
"base": base
|
||
|
} )
|
||
|
} );
|
||
|
|
||
|
if ( response.status !== 201 ) {
|
||
|
throw 'Fail to create PR on GitHub.';
|
||
|
}
|
||
|
|
||
|
return await response.json().then( data => {
|
||
|
spinner.succeed();
|
||
|
return {
|
||
|
"url": data.url,
|
||
|
"number": data.number
|
||
|
}
|
||
|
} );
|
||
|
}
|
||
|
|
||
|
( async function() {
|
||
|
try {
|
||
|
// Creates a temp directory to work with.
|
||
|
await createDir( path.join( os.tmpdir(), 'woo-cherry-pick-') );
|
||
|
|
||
|
// Gets the commits from GitHub based on the PRs passed in.
|
||
|
await getCommitFromPrs( prsArr );
|
||
|
|
||
|
chdir( tempWooDir );
|
||
|
|
||
|
await cloneWoo();
|
||
|
|
||
|
chdir( tempWooDir + '/woocommerce' );
|
||
|
|
||
|
// This checks out the release branch.
|
||
|
await checkoutBranch( releaseBranch );
|
||
|
|
||
|
// This checks out a new branch based on the release branch.
|
||
|
await checkoutBranch( cherryPickBranch, true );
|
||
|
|
||
|
let cherryPickPRBody = "This PR cherry-picks the following PRs into the release branch:\n";
|
||
|
|
||
|
for ( const pr of Object.keys( prCommits ) ) {
|
||
|
cherryPickPRBody = `${cherryPickPRBody}` + `* #${pr}` + "\n";
|
||
|
cherryPick( prCommits[ pr ] );
|
||
|
await generateChangelog( pr, prCommits[ pr ] );
|
||
|
}
|
||
|
|
||
|
// Deletes the changelog files from the release branch.
|
||
|
await deleteChangelogFiles();
|
||
|
|
||
|
await commitChanges( `Prep for cherry pick ${prsArr.toString()}` );
|
||
|
|
||
|
await pushBranch( cherryPickBranch );
|
||
|
|
||
|
const cherryPickPR = await createPR(
|
||
|
`Prep for cherry pick ${prsArr.toString()}`,
|
||
|
cherryPickPRBody,
|
||
|
cherryPickBranch,
|
||
|
releaseBranch
|
||
|
);
|
||
|
|
||
|
await checkoutBranch( 'trunk' );
|
||
|
|
||
|
const deleteChangelogBranch = `delete-changelogs/${prsArr.toString().replace( ',', '-' )}`;
|
||
|
|
||
|
// This checks out a new branch based on the trunk branch.
|
||
|
await checkoutBranch( `${deleteChangelogBranch}`, true );
|
||
|
|
||
|
// Deletes the changelog files from the trunk branch.
|
||
|
await deleteChangelogFiles();
|
||
|
|
||
|
await commitChanges( `Delete changelog files for ${prsArr.toString()}` );
|
||
|
|
||
|
await pushBranch( `${deleteChangelogBranch}` );
|
||
|
|
||
|
const deleteChangelogsPR = await createPR(
|
||
|
`Delete changelog files based on PR ${cherryPickPR.number}`,
|
||
|
`Delete changelog files based on PR #${cherryPickPR.number}`,
|
||
|
deleteChangelogBranch,
|
||
|
'trunk'
|
||
|
);
|
||
|
|
||
|
console.log( `Two PRs created by this process:` );
|
||
|
console.log( cherryPickPR.url );
|
||
|
console.log( deleteChangelogsPR.url );
|
||
|
} catch ( e ) {
|
||
|
console.error( e );
|
||
|
process.exit( 1 );
|
||
|
}
|
||
|
} )();
|