diff --git a/package.json b/package.json index 99b4cbaff3b..b8304993ff4 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "git:update-hooks": "rm -r .git/hooks && mkdir -p .git/hooks && husky install", "storybook": "./tools/storybook/import-wp-css-storybook.sh && BABEL_ENV=storybook STORYBOOK=true start-storybook -c ./tools/storybook/.storybook -p 6007 --ci", "storybook-rtl": "USE_RTL_STYLE=true pnpm run storybook", - "create-extension": "node ./tools/create-extension/index.js" + "create-extension": "node ./tools/create-extension/index.js", + "cherry-pick": "node ./tools/cherry-pick/bin/run" }, "devDependencies": { "@babel/preset-env": "^7.16.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c8a3966f57..e8c302e8063 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1737,6 +1737,14 @@ importers: sass: 1.49.9 stylelint: 13.8.0 + tools/cherry-pick: + specifiers: + node-fetch: ^3.2.6 + ora: ^6.1.2 + dependencies: + node-fetch: 3.2.8 + ora: 6.1.2 + tools/code-analyzer: specifiers: '@oclif/core': ^1 @@ -16845,7 +16853,6 @@ packages: /ansi-regex/6.0.1: resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} engines: {node: '>=12'} - dev: true /ansi-styles/2.2.1: resolution: {integrity: sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==} @@ -18243,6 +18250,14 @@ packages: readable-stream: 3.6.0 dev: true + /bl/5.0.0: + resolution: {integrity: sha512-8vxFNZ0pflFfi0WXA3WQXlj6CaMEwsmh63I1CNp0q+wWv8sD0ARx1KovSQd0l2GkwrMIOyedq0EF1FxI+RCZLQ==} + dependencies: + buffer: 6.0.3 + inherits: 2.0.4 + readable-stream: 3.6.0 + dev: false + /blob/0.0.5: resolution: {integrity: sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==} dev: false @@ -18545,6 +18560,13 @@ packages: base64-js: 1.5.1 ieee754: 1.2.1 + /buffer/6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + dev: false + /builtin-status-codes/2.0.0: resolution: {integrity: sha512-8KPx+JfZWi0K8L5sycIOA6/ZFZbaFKXDeUIXaqwUnhed1Ge1cB0wyq+bNDjKnL9AR2Uj3m/khkF6CDolsyMitA==} dev: false @@ -18877,6 +18899,11 @@ packages: ansi-styles: 4.3.0 supports-color: 7.2.0 + /chalk/5.0.1: + resolution: {integrity: sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + dev: false + /change-case/2.3.1: resolution: {integrity: sha1-LE/ePwY7tB0AzWjg1aCdthy+iU8=} dependencies: @@ -19168,6 +19195,13 @@ packages: dependencies: restore-cursor: 3.1.0 + /cli-cursor/4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + restore-cursor: 4.0.0 + dev: false + /cli-progress/3.10.0: resolution: {integrity: sha512-kLORQrhYCAtUPLZxqsAt2YJGOvRdt34+O6jl5cQGb7iF3dM55FQZlTR+rQyIK9JUcO9bBMwZsTlND+3dmFU2Cw==} engines: {node: '>=4'} @@ -19177,7 +19211,6 @@ packages: /cli-spinners/2.6.1: resolution: {integrity: sha512-x/5fWmGMnbKQAaNwN+UZlV79qBLM9JFnJuJ03gIi5whrob0xV0ofNVHy9DhwGdsMJQc2OKv0oGmLzvaqvAVv+g==} engines: {node: '>=6'} - dev: true /cli-table/0.3.11: resolution: {integrity: sha512-IqLQi4lO0nIB4tcdTpN4LCB9FI3uqrJZK7RC515EnhZ6qBaglkIgICb1wjeAqpdoOabm1+SuQtkXIPdYC93jhQ==} @@ -20341,6 +20374,11 @@ packages: dependencies: assert-plus: 1.0.0 + /data-uri-to-buffer/4.0.0: + resolution: {integrity: sha512-Vr3mLBA8qWmcuschSLAOogKgQ/Jwxulv3RNE4FXnYWRGujzrRWQI4m12fQqRkwX06C0KanhLr4hK+GydchZsaA==} + engines: {node: '>= 12'} + dev: false + /data-urls/1.1.0: resolution: {integrity: sha512-YTWYI9se1P55u58gL5GkQHW4P6VJBJ5iBT+B5a7i2Tjadhv52paJG0qHX4A0OR6/t52odI64KP2YvFpkDOi3eQ==} dependencies: @@ -23174,6 +23212,14 @@ packages: dependencies: pend: 1.2.0 + /fetch-blob/3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.2.1 + dev: false + /figgy-pudding/3.5.2: resolution: {integrity: sha512-0btnI/H8f2pavGMN8w40mlSKOfTK2SVJmBfBeVIj3kNw0swwgzyRq0d5TJVOwodFmtvpPeWPN/MCcfuWF0Ezbw==} dev: true @@ -23742,6 +23788,13 @@ packages: engines: {node: '>=0.4.x'} dev: true + /formdata-polyfill/4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + dependencies: + fetch-blob: 3.2.0 + dev: false + /formidable/1.2.2: resolution: {integrity: sha512-V8gLm+41I/8kguQ4/o1D3RIHRmhYFG4pnNyonvua+40rqcEmT4+V71yaZ3B457xbbgCsCfjSPi65u/W6vK1U5Q==} dev: false @@ -25907,6 +25960,11 @@ packages: engines: {node: '>=8'} dev: true + /is-interactive/2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + dev: false + /is-lambda/1.0.1: resolution: {integrity: sha1-PZh3iZ5qU+/AFgUEzeFfgubwYdU=} dev: true @@ -26104,6 +26162,11 @@ packages: engines: {node: '>=10'} dev: true + /is-unicode-supported/1.2.0: + resolution: {integrity: sha512-wH+U77omcRzevfIG8dDhTS0V9zZyweakfD01FULl97+0EHiJTTZtJqxPSkIIo/SDPv/i07k/C9jAPY+jwLLeUQ==} + engines: {node: '>=12'} + dev: false + /is-upper-case/1.1.2: resolution: {integrity: sha1-jQsfp+eTOh5YSDYA7H2WYcuvdW8=} dependencies: @@ -29502,6 +29565,14 @@ packages: is-unicode-supported: 0.1.0 dev: true + /log-symbols/5.1.0: + resolution: {integrity: sha512-l0x2DvrW294C9uDCoQe1VSU4gf529FkSZ6leBl4TiqZH/e+0R7hSfHQBNut2mNygDgHwvYHfFLn6Oxb3VWj2rA==} + engines: {node: '>=12'} + dependencies: + chalk: 5.0.1 + is-unicode-supported: 1.2.0 + dev: false + /log-update/4.0.0: resolution: {integrity: sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==} engines: {node: '>=10'} @@ -30608,6 +30679,11 @@ packages: minimatch: 3.1.2 dev: true + /node-domexception/1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + dev: false + /node-environment-flags/1.0.6: resolution: {integrity: sha512-5Evy2epuL+6TM0lCQGpFIj6KwiEsGh1SrHUhTbNX+sLbBtjidPZFAnVK9y5yU1+h//RitLbRHTIMyxQPtxMdHw==} dependencies: @@ -30644,6 +30720,15 @@ packages: dependencies: whatwg-url: 5.0.0 + /node-fetch/3.2.8: + resolution: {integrity: sha512-KtpD1YhGszhntMpBDyp5lyagk8KIMopC1LEb7cQUAh7zcosaX5uK8HnbNb2i3NTQK3sIawCItS0uFC3QzcLHdg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + data-uri-to-buffer: 4.0.0 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + dev: false + /node-gyp/8.4.1: resolution: {integrity: sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==} engines: {node: '>= 10.12.0'} @@ -31261,6 +31346,21 @@ packages: wcwidth: 1.0.1 dev: true + /ora/6.1.2: + resolution: {integrity: sha512-EJQ3NiP5Xo94wJXIzAyOtSb0QEIAUu7m8t6UZ9krbz0vAJqr92JpcK/lEXg91q6B9pEGqrykkd2EQplnifDSBw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + bl: 5.0.0 + chalk: 5.0.1 + cli-cursor: 4.0.0 + cli-spinners: 2.6.1 + is-interactive: 2.0.0 + is-unicode-supported: 1.2.0 + log-symbols: 5.1.0 + strip-ansi: 7.0.1 + wcwidth: 1.0.1 + dev: false + /os-browserify/0.3.0: resolution: {integrity: sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=} dev: true @@ -34837,6 +34937,14 @@ packages: onetime: 5.1.2 signal-exit: 3.0.7 + /restore-cursor/4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + dev: false + /ret/0.1.15: resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==} engines: {node: '>=0.12'} @@ -35966,7 +36074,6 @@ packages: engines: {node: '>=12'} dependencies: ansi-regex: 6.0.1 - dev: true /strip-bom-buf/1.0.0: resolution: {integrity: sha1-HLRar1dTD0yvhsf3UXnSyaUd1XI=} @@ -38543,6 +38650,11 @@ packages: resolution: {integrity: sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw==} dev: true + /web-streams-polyfill/3.2.1: + resolution: {integrity: sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==} + engines: {node: '>= 8'} + dev: false + /webidl-conversions/3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a322e576b86..41e2765ea0c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,4 +6,5 @@ packages: - 'tools/code-analyzer' - 'tools/create-extension' - 'tools/package-release' + - 'tools/cherry-pick' - 'tools/require-turbo' diff --git a/tools/cherry-pick/README.md b/tools/cherry-pick/README.md new file mode 100644 index 00000000000..c0a21dad704 --- /dev/null +++ b/tools/cherry-pick/README.md @@ -0,0 +1,13 @@ +# Cherry Pick + +A tool to automate cherry picking fixes into a release. + +### Prerequisite + +You will need to set two environment variable: +* **GITHUB_CHERRY_PICK_TOKEN** - Generate a personal access token from GitHub and make sure it has the `Repo` scope. Assign this token to the environment variable. +* **GITHUB_REMOTE_URL** - Depending on if you use `https` or `ssh` when you use `git clone`. Set the value `https` or `ssh` to that environment variable. + +### Usage + +Usage: `pnpm cherry-pick `. Separate pull request numbers with a comma (no space) if more than one. diff --git a/tools/cherry-pick/bin/run.js b/tools/cherry-pick/bin/run.js new file mode 100644 index 00000000000..6386bd1b8ce --- /dev/null +++ b/tools/cherry-pick/bin/run.js @@ -0,0 +1,580 @@ +/** + * 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 . 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 ); + } +} )(); diff --git a/tools/cherry-pick/package.json b/tools/cherry-pick/package.json new file mode 100644 index 00000000000..be637957c11 --- /dev/null +++ b/tools/cherry-pick/package.json @@ -0,0 +1,21 @@ +{ + "name": "cherry-pick", + "version": "0.0.0", + "description": "A tool to automate cherry picking fixes into a release.", + "author": "Automattic", + "scripts": { + "cherry-pick": "./bin/run" + }, + "type": "module", + "homepage": "https://github.com/woocommerce/woocommerce", + "license": "GPLv3", + "repository": "woocommerce/woocommerce", + "engines": { + "node": "^16.13.1", + "pnpm": "^6.24.2" + }, + "dependencies": { + "node-fetch": "^3.2.6", + "ora": "^6.1.2" + } +}