From 1188197a2f9c393f9effdbe417dec922353ed0ea Mon Sep 17 00:00:00 2001 From: Paul Sealock Date: Fri, 12 May 2023 10:07:41 +1200 Subject: [PATCH] Code Freeze CLI: Add release changelog command (#38082) --- pnpm-lock.yaml | 68 ++------ tools/monorepo-utils/package.json | 1 + .../code-freeze/commands/changelog/index.ts | 73 +++++++++ .../commands/changelog/lib/index.ts | 153 ++++++++++++++++++ .../code-freeze/commands/changelog/types.ts | 6 + .../src/code-freeze/commands/index.ts | 4 +- .../commands/version-bump/index.ts | 24 ++- tools/monorepo-utils/src/core/git.ts | 48 ++++++ tools/monorepo-utils/src/core/github/api.ts | 9 +- tools/monorepo-utils/src/core/github/repo.ts | 38 +++++ tools/monorepo-utils/src/core/github/types.ts | 7 + 11 files changed, 359 insertions(+), 72 deletions(-) create mode 100644 tools/monorepo-utils/src/code-freeze/commands/changelog/index.ts create mode 100644 tools/monorepo-utils/src/code-freeze/commands/changelog/lib/index.ts create mode 100644 tools/monorepo-utils/src/code-freeze/commands/changelog/types.ts create mode 100644 tools/monorepo-utils/src/core/github/types.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9238ad821ad..8b8725d5447 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3250,6 +3250,9 @@ importers: '@octokit/graphql-schema': specifier: ^14.1.0 version: 14.1.0 + '@octokit/types': + specifier: ^9.2.0 + version: 9.2.1 '@types/uuid': specifier: ^9.0.1 version: 9.0.1 @@ -3507,43 +3510,6 @@ importers: specifier: ^5.70.0 version: 5.70.0(uglify-js@3.14.5)(webpack-cli@4.9.2) - tools/version-bump: - dependencies: - '@commander-js/extra-typings': - specifier: ^0.1.0 - version: 0.1.0(commander@9.4.0) - '@woocommerce/monorepo-utils': - specifier: workspace:* - version: link:../monorepo-utils - chalk: - specifier: ^4.1.2 - version: 4.1.2 - commander: - specifier: 9.4.0 - version: 9.4.0 - express: - specifier: ^4.18.1 - version: 4.18.1 - ora: - specifier: ^5.4.1 - version: 5.4.1 - semver: - specifier: ^7.3.2 - version: 7.3.7 - ts-node: - specifier: ^10.9.1 - version: 10.9.1(@types/node@16.18.21)(typescript@4.9.5) - devDependencies: - '@tsconfig/node16': - specifier: ^1.0.3 - version: 1.0.3 - '@types/express': - specifier: ^4.17.13 - version: 4.17.14 - typescript: - specifier: ^4.9.5 - version: 4.9.5 - packages: /@actions/core@1.10.0: @@ -3871,7 +3837,7 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.21.3 - '@jridgewell/trace-mapping': 0.3.17 + '@jridgewell/trace-mapping': 0.3.16 commander: 4.1.1 convert-source-map: 1.8.0 fs-readdir-recursive: 1.1.0 @@ -8720,9 +8686,9 @@ packages: '@babel/core': 7.21.3 '@babel/helper-annotate-as-pure': 7.16.7 '@babel/helper-module-imports': 7.16.7 - '@babel/helper-plugin-utils': 7.18.9 + '@babel/helper-plugin-utils': 7.20.2 '@babel/plugin-syntax-jsx': 7.16.7(@babel/core@7.21.3) - '@babel/types': 7.17.0 + '@babel/types': 7.21.3 dev: true /@babel/plugin-transform-react-jsx@7.19.0(@babel/core@7.12.9): @@ -8951,8 +8917,8 @@ packages: dependencies: '@babel/core': 7.21.3 '@babel/helper-module-imports': 7.16.0 - '@babel/helper-plugin-utils': 7.20.2 - babel-plugin-polyfill-corejs2: 0.3.3(@babel/core@7.21.3) + '@babel/helper-plugin-utils': 7.14.5 + babel-plugin-polyfill-corejs2: 0.3.0(@babel/core@7.21.3) babel-plugin-polyfill-corejs3: 0.4.0(@babel/core@7.21.3) babel-plugin-polyfill-regenerator: 0.3.0(@babel/core@7.21.3) semver: 6.3.0 @@ -11969,7 +11935,6 @@ packages: dependencies: '@jridgewell/resolve-uri': 3.1.0 '@jridgewell/sourcemap-codec': 1.4.14 - dev: true /@jridgewell/trace-mapping@0.3.17: resolution: {integrity: sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==} @@ -12494,7 +12459,7 @@ packages: '@octokit/graphql': 4.8.0 '@octokit/request': 5.6.3 '@octokit/request-error': 2.1.0 - '@octokit/types': 6.34.0 + '@octokit/types': 6.41.0 before-after-hook: 2.2.2 universal-user-agent: 6.0.0 transitivePeerDependencies: @@ -12594,10 +12559,6 @@ packages: - encoding dev: false - /@octokit/openapi-types@11.2.0: - resolution: {integrity: sha512-PBsVO+15KSlGmiI8QAzaqvsNlZlrDlyAJYcrXBCvVUxCp7VnXjkwPoFHgjEJXx3WF9BAwkA6nfCUA7i9sODzKA==} - dev: true - /@octokit/openapi-types@12.11.0: resolution: {integrity: sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ==} @@ -12794,12 +12755,6 @@ packages: - encoding dev: false - /@octokit/types@6.34.0: - resolution: {integrity: sha512-s1zLBjWhdEI2zwaoSgyOFoKSl109CUcVBCc7biPJ3aAf6LGLU6szDvi31JPU7bxfla2lqfhjbbg/5DdFNxOwHw==} - dependencies: - '@octokit/openapi-types': 11.2.0 - dev: true - /@octokit/types@6.41.0: resolution: {integrity: sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==} dependencies: @@ -20857,8 +20812,8 @@ packages: peerDependencies: postcss: ^8.1.0 dependencies: - browserslist: 4.20.2 - caniuse-lite: 1.0.30001352 + browserslist: 4.21.4 + caniuse-lite: 1.0.30001418 fraction.js: 4.2.0 normalize-range: 0.1.2 picocolors: 1.0.0 @@ -22401,6 +22356,7 @@ packages: escalade: 3.1.1 node-releases: 2.0.6 picocolors: 1.0.0 + dev: true /browserslist@4.20.4: resolution: {integrity: sha512-ok1d+1WpnU24XYN7oC3QWgTyMhY/avPJ/r9T00xxvUOIparA/gc+UPUMaod3i+G6s+nI2nUb9xZ5k794uIwShw==} diff --git a/tools/monorepo-utils/package.json b/tools/monorepo-utils/package.json index 51d4327afa2..1817da51899 100644 --- a/tools/monorepo-utils/package.json +++ b/tools/monorepo-utils/package.json @@ -12,6 +12,7 @@ "@commander-js/extra-typings": "^10.0.3", "@octokit/graphql": "4.8.0", "@octokit/graphql-schema": "^14.1.0", + "@octokit/types": "^9.2.0", "@types/uuid": "^9.0.1", "chalk": "^4.1.2", "commander": "^10.0.1", diff --git a/tools/monorepo-utils/src/code-freeze/commands/changelog/index.ts b/tools/monorepo-utils/src/code-freeze/commands/changelog/index.ts new file mode 100644 index 00000000000..f2b2904b7ac --- /dev/null +++ b/tools/monorepo-utils/src/code-freeze/commands/changelog/index.ts @@ -0,0 +1,73 @@ +/** + * External dependencies + */ +import { Command } from '@commander-js/extra-typings'; +import { execSync } from 'child_process'; + +/** + * Internal dependencies + */ +import { Logger } from '../../../core/logger'; +import { cloneAuthenticatedRepo } from '../../../core/git'; +import { updateTrunkChangelog, updateReleaseBranchChangelogs } from './lib'; +import { Options } from './types'; + +export const changelogCommand = new Command( 'changelog' ) + .description( 'Create a new release branch' ) + .option( + '-o --owner ', + 'Repository owner. Default: woocommerce', + 'woocommerce' + ) + .option( + '-n --name ', + 'Repository name. Default: woocommerce', + 'woocommerce' + ) + .option( + '-d --dev-repo-path ', + 'Path to existing repo. Use this option to avoid cloning a fresh repo for development purposes. Note that using this option assumes dependencies are already installed.' + ) + .requiredOption( '-v, --version ', 'Version to bump to' ) + .action( async ( options: Options ) => { + const { owner, name, version, devRepoPath } = options; + Logger.startTask( + `Making a temporary clone of '${ owner }/${ name }'` + ); + // Use a supplied path, otherwise do a full clone of the repo, including history so that changelogs can be created with links to PRs. + const tmpRepoPath = devRepoPath + ? devRepoPath + : await cloneAuthenticatedRepo( options, false ); + + Logger.endTask(); + + Logger.notice( + `Temporary clone of '${ owner }/${ name }' created at ${ tmpRepoPath }` + ); + + // When a devRepoPath is provided, assume that the dependencies are already installed. + if ( ! devRepoPath ) { + Logger.notice( `Installing dependencies in ${ tmpRepoPath }` ); + execSync( 'pnpm install --filter woocommerce', { + cwd: tmpRepoPath, + stdio: 'inherit', + } ); + } + + const releaseBranch = `release/${ version }`; + + // Update the release branch. + const releaseBranchChanges = await updateReleaseBranchChangelogs( + options, + tmpRepoPath, + releaseBranch + ); + + // Update trunk. + await updateTrunkChangelog( + options, + tmpRepoPath, + releaseBranch, + releaseBranchChanges + ); + } ); diff --git a/tools/monorepo-utils/src/code-freeze/commands/changelog/lib/index.ts b/tools/monorepo-utils/src/code-freeze/commands/changelog/lib/index.ts new file mode 100644 index 00000000000..6ad974630fe --- /dev/null +++ b/tools/monorepo-utils/src/code-freeze/commands/changelog/lib/index.ts @@ -0,0 +1,153 @@ +/** + * External dependencies + */ +import simpleGit from 'simple-git'; +import { execSync } from 'child_process'; + +/** + * Internal dependencies + */ +import { Logger } from '../../../../core/logger'; +import { checkoutRemoteBranch } from '../../../../core/git'; +import { createPullRequest } from '../../../../core/github/repo'; +import { Options } from '../types'; + +/** + * Perform changelog operations on release branch by submitting a pull request. The release branch is a remote branch. + * + * @param {Object} options CLI options + * @param {string} tmpRepoPath temp repo path + * @param {string} releaseBranch release branch name. The release branch is a remote branch on Github. + * @return {Object} update data + */ +export const updateReleaseBranchChangelogs = async ( + options: Options, + tmpRepoPath: string, + releaseBranch: string +): Promise< { deletionCommitHash: string; prNumber: number } > => { + const { owner, name, version } = options; + try { + await checkoutRemoteBranch( tmpRepoPath, releaseBranch ); + } catch ( e ) { + if ( e.message.includes( "couldn't find remote ref" ) ) { + Logger.error( + `${ releaseBranch } does not exist on ${ owner }/${ name }.` + ); + } + Logger.error( e ); + } + + const git = simpleGit( { + baseDir: tmpRepoPath, + config: [ 'core.hooksPath=/dev/null' ], + } ); + + const branch = `update/${ version }-changelog`; + + try { + await git.checkout( { + '-b': null, + [ branch ]: null, + } ); + + Logger.notice( `Running the changelog script in ${ tmpRepoPath }` ); + execSync( + `pnpm --filter=woocommerce run changelog write --add-pr-num -n -vvv --use-version ${ version }`, + { + cwd: tmpRepoPath, + stdio: 'inherit', + } + ); + Logger.notice( `Committing deleted files in ${ tmpRepoPath }` ); + //Checkout pnpm-lock.yaml to prevent issues in case of an out of date lockfile. + await git.checkout( 'pnpm-lock.yaml' ); + await git.add( 'plugins/woocommerce/changelog/' ); + await git.commit( `Delete changelog files from ${ version } release` ); + const deletionCommitHash = await git.raw( [ 'rev-parse', 'HEAD' ] ); + Logger.notice( `git deletion hash: ${ deletionCommitHash }` ); + + Logger.notice( `Updating readme.txt in ${ tmpRepoPath }` ); + execSync( 'php .github/workflows/scripts/release-changelog.php', { + cwd: tmpRepoPath, + stdio: 'inherit', + } ); + + Logger.notice( + `Committing readme.txt changes in ${ branch } on ${ tmpRepoPath }` + ); + await git.add( 'plugins/woocommerce/readme.txt' ); + await git.commit( + `Update the readme files for the ${ version } release` + ); + await git.push( 'origin', branch ); + await git.checkout( '.' ); + + Logger.notice( `Creating PR for ${ branch }` ); + const pullRequest = await createPullRequest( { + owner, + name, + title: `Release: Prepare the changelog for ${ version }`, + body: `This pull request was automatically generated during the code freeze to prepare the changelog for ${ version }`, + head: branch, + base: releaseBranch, + } ); + Logger.notice( `Pull request created: ${ pullRequest.html_url }` ); + return { + deletionCommitHash: deletionCommitHash.trim(), + prNumber: pullRequest.number, + }; + } catch ( e ) { + Logger.error( e ); + } +}; + +/** + * Perform changelog operations on trunk by submitting a pull request. + * + * @param {Object} options CLI options + * @param {string} tmpRepoPath temp repo path + * @param {string} releaseBranch release branch name + * @param {Object} releaseBranchChanges update data from updateReleaseBranchChangelogs + * @param {Object} releaseBranchChanges.deletionCommitHash commit from the changelog deletions in updateReleaseBranchChangelogs + * @param {Object} releaseBranchChanges.prNumber pr number created in updateReleaseBranchChangelogs + */ +export const updateTrunkChangelog = async ( + options: Options, + tmpRepoPath: string, + releaseBranch: string, + releaseBranchChanges: { deletionCommitHash: string; prNumber: number } +): Promise< void > => { + const { owner, name, version } = options; + const { deletionCommitHash, prNumber } = releaseBranchChanges; + Logger.notice( `Deleting changelogs from trunk ${ tmpRepoPath }` ); + const git = simpleGit( { + baseDir: tmpRepoPath, + config: [ 'core.hooksPath=/dev/null' ], + } ); + + try { + await git.checkout( 'trunk' ); + const branch = `delete/${ version }-changelog`; + Logger.notice( + `Committing deletions in ${ branch } on ${ tmpRepoPath }` + ); + await git.checkout( { + '-b': null, + [ branch ]: null, + } ); + await git.raw( [ 'cherry-pick', deletionCommitHash ] ); + await git.push( 'origin', branch ); + Logger.notice( `Creating PR for ${ branch }` ); + const pullRequest = await createPullRequest( { + owner, + name, + title: `Release: Remove ${ version } change files`, + body: `This pull request was automatically generated during the code freeze to remove the changefiles from ${ version } that are compiled into the \`${ releaseBranch }\` branch via #${ prNumber }`, + head: branch, + base: 'trunk', + } ); + Logger.notice( `Pull request created: ${ pullRequest.html_url }` ); + } catch ( e ) { + Logger.error( e ); + } +}; diff --git a/tools/monorepo-utils/src/code-freeze/commands/changelog/types.ts b/tools/monorepo-utils/src/code-freeze/commands/changelog/types.ts new file mode 100644 index 00000000000..10ffb325a23 --- /dev/null +++ b/tools/monorepo-utils/src/code-freeze/commands/changelog/types.ts @@ -0,0 +1,6 @@ +export type Options = { + owner: string; + name: string; + version: string; + devRepoPath?: string; +}; diff --git a/tools/monorepo-utils/src/code-freeze/commands/index.ts b/tools/monorepo-utils/src/code-freeze/commands/index.ts index a09204c1d4f..dee3e3d4b70 100644 --- a/tools/monorepo-utils/src/code-freeze/commands/index.ts +++ b/tools/monorepo-utils/src/code-freeze/commands/index.ts @@ -10,12 +10,14 @@ import { verifyDayCommand } from './verify-day'; import { milestoneCommand } from './milestone'; import { branchCommand } from './branch'; import { versionBumpCommand } from './version-bump'; +import { changelogCommand } from './changelog'; const program = new Command( 'code-freeze' ) .description( 'Code freeze utilities' ) .addCommand( verifyDayCommand ) .addCommand( milestoneCommand ) .addCommand( branchCommand ) - .addCommand( versionBumpCommand ); + .addCommand( versionBumpCommand ) + .addCommand( changelogCommand ); export default program; diff --git a/tools/monorepo-utils/src/code-freeze/commands/version-bump/index.ts b/tools/monorepo-utils/src/code-freeze/commands/version-bump/index.ts index aeb601c675d..6964c2ae352 100644 --- a/tools/monorepo-utils/src/code-freeze/commands/version-bump/index.ts +++ b/tools/monorepo-utils/src/code-freeze/commands/version-bump/index.ts @@ -9,7 +9,7 @@ import simpleGit from 'simple-git'; */ import { Logger } from '../../../core/logger'; import { sparseCheckoutRepoShallow } from '../../../core/git'; -import { octokitWithAuth } from '../../../core/github/api'; +import { createPullRequest } from '../../../core/github/repo'; import { getEnvVar } from '../../../core/environment'; import { getMajorMinor } from '../../../core/version'; import { bumpFiles } from './bump'; @@ -101,18 +101,16 @@ export const versionBumpCommand = new Command( 'version-bump' ) try { Logger.startTask( 'Creating a pull request' ); - const pr = await octokitWithAuth.request( - 'POST /repos/{owner}/{repo}/pulls', - { - owner, - repo: name, - title: `Prep trunk for ${ majorMinor } cycle`, - body: `This PR updates the versions in trunk to ${ version } for next development cycle.`, - head: branch, - base, - } - ); - Logger.notice( `Pull request created: ${ pr.data.html_url }` ); + + const pullRequest = await createPullRequest( { + owner, + name, + title: `Prep trunk for ${ majorMinor } cycle`, + body: `This PR updates the versions in trunk to ${ version } for next development cycle.`, + head: branch, + base, + } ); + Logger.notice( `Pull request created: ${ pullRequest.html_url }` ); Logger.endTask(); } catch ( e ) { Logger.error( e ); diff --git a/tools/monorepo-utils/src/core/git.ts b/tools/monorepo-utils/src/core/git.ts index e07a409f359..19a46e64eff 100644 --- a/tools/monorepo-utils/src/core/git.ts +++ b/tools/monorepo-utils/src/core/git.ts @@ -10,6 +10,11 @@ import { v4 } from 'uuid'; import { mkdir, rm } from 'fs/promises'; import { URL } from 'node:url'; +/** + * Internal dependencies + */ +import { getEnvVar } from './environment'; + /** * Get filename from patch * @@ -110,6 +115,29 @@ export const cloneRepoShallow = async ( repoPath: string ) => { return await cloneRepo( repoPath, { '--depth': 1 } ); }; +/** + * Clone a repo using the authenticated token `GITHUB_TOKEN`. This allows the script to push branches to origin. + * + * @param {Object} options CLI options + * @param {string} options.owner repo owner + * @param {string} options.name repo name + * @param {boolean} isShallow whether to do a shallow clone or not. + * @return {string} temporary repo path + */ +export const cloneAuthenticatedRepo = async ( + options: { owner: string; name: string }, + isShallow = true +): Promise< string > => { + const { owner, name } = options; + const source = `github.com/${ owner }/${ name }`; + const token = getEnvVar( 'GITHUB_TOKEN' ); + const remote = `https://${ owner }:${ token }@${ source }`; + + return isShallow + ? await cloneRepoShallow( remote ) + : await cloneRepo( remote ); +}; + /** * Do a minimal sparse checkout of a github repo. * @@ -391,3 +419,23 @@ export const generateDiff = async ( return ''; } }; + +/** + * + * @param {string} tmpRepoPath path to temporary repo + * @param {string} branch remote branch to checkout + */ +export const checkoutRemoteBranch = async ( + tmpRepoPath: string, + branch: string +): Promise< void > => { + const git = simpleGit( { + baseDir: tmpRepoPath, + config: [ 'core.hooksPath=/dev/null' ], + } ); + + // When the clone is shallow, we need to call this before fetching. + await git.raw( [ 'remote', 'set-branches', '--add', 'origin', branch ] ); + await git.raw( [ 'fetch', 'origin', branch ] ); + await git.raw( [ 'checkout', '-b', branch, `origin/${ branch }` ] ); +}; diff --git a/tools/monorepo-utils/src/core/github/api.ts b/tools/monorepo-utils/src/core/github/api.ts index 8405e9301d3..8036aadeabd 100644 --- a/tools/monorepo-utils/src/core/github/api.ts +++ b/tools/monorepo-utils/src/core/github/api.ts @@ -4,12 +4,17 @@ import { graphql } from '@octokit/graphql'; import { Octokit } from 'octokit'; +/** + * Internal dependencies + */ +import { getEnvVar } from '../environment'; + export const graphqlWithAuth = graphql.defaults( { headers: { - authorization: `Bearer ${ process.env.GITHUB_TOKEN }`, + authorization: `Bearer ${ getEnvVar( 'GITHUB_TOKEN', true ) }`, }, } ); export const octokitWithAuth = new Octokit( { - auth: process.env.GITHUB_TOKEN, + auth: getEnvVar( 'GITHUB_TOKEN', true ), } ); diff --git a/tools/monorepo-utils/src/core/github/repo.ts b/tools/monorepo-utils/src/core/github/repo.ts index 17ff1e2608e..572351aefcd 100644 --- a/tools/monorepo-utils/src/core/github/repo.ts +++ b/tools/monorepo-utils/src/core/github/repo.ts @@ -7,6 +7,8 @@ import { Repository } from '@octokit/graphql-schema'; * Internal dependencies */ import { graphqlWithAuth, octokitWithAuth } from './api'; +import { Logger } from '../logger'; +import { PullRequestEndpointResponse } from './types'; export const getLatestGithubReleaseVersion = async ( options: { owner?: string; @@ -128,3 +130,39 @@ export const deleteGithubBranch = async ( } ); }; + +/** + * Create a pull request from branches on Github. + * + * @param {Object} options pull request options. + * @param {string} options.head branch name containing the changes you want to merge. + * @param {string} options.base branch name you want the changes pulled into. + * @param {string} options.owner repository owner. + * @param {string} options.name repository name. + * @param {string} options.title pull request title. + * @param {string} options.body pull request body. + * @return {Promise} pull request data. + */ +export const createPullRequest = async ( options: { + head: string; + base: string; + owner: string; + name: string; + title: string; + body: string; +} ): Promise< PullRequestEndpointResponse[ 'data' ] > => { + const { head, base, owner, name, title, body } = options; + const pullRequest = await octokitWithAuth.request( + 'POST /repos/{owner}/{repo}/pulls', + { + owner, + repo: name, + title, + body, + head, + base, + } + ); + //@ts-ignore There is a type mismatch between the graphql schema and the response. pullRequest.data.head.repo.has_discussions is a boolean, but the graphql schema doesn't have that field. + return pullRequest.data; +}; diff --git a/tools/monorepo-utils/src/core/github/types.ts b/tools/monorepo-utils/src/core/github/types.ts new file mode 100644 index 00000000000..e9b7dd22b9a --- /dev/null +++ b/tools/monorepo-utils/src/core/github/types.ts @@ -0,0 +1,7 @@ +/** + * External dependencies + */ +import { Endpoints } from '@octokit/types'; + +export type PullRequestEndpointResponse = + Endpoints[ 'POST /repos/{owner}/{repo}/pulls' ][ 'response' ];