From 34bd5e1bf008598f791a7f788ff82560aac9297a Mon Sep 17 00:00:00 2001 From: Paul Sealock Date: Wed, 26 Apr 2023 10:53:19 +1200 Subject: [PATCH] Code Freeze CLI: Add branch command (#37914) --- pnpm-lock.yaml | 20 ++- tools/monorepo-utils/package.json | 1 + .../src/code-freeze/commands/branch/index.ts | 152 ++++++++++++++++++ .../src/code-freeze/commands/branch/types.ts | 8 + .../src/code-freeze/commands/index.ts | 6 +- .../code-freeze/commands/milestone/index.ts | 4 +- tools/monorepo-utils/src/github/repo.ts | 96 ++++++++++- 7 files changed, 274 insertions(+), 13 deletions(-) create mode 100644 tools/monorepo-utils/src/code-freeze/commands/branch/index.ts create mode 100644 tools/monorepo-utils/src/code-freeze/commands/branch/types.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a5db6e6e05e..c6dafe56b80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3296,6 +3296,9 @@ importers: ora: specifier: ^5.4.1 version: 5.4.1 + promptly: + specifier: ^3.2.0 + version: 3.2.0 semver: specifier: ^7.3.2 version: 7.3.5 @@ -8733,9 +8736,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): @@ -10178,7 +10181,7 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.17.8 - '@babel/helper-plugin-utils': 7.20.2 + '@babel/helper-plugin-utils': 7.19.0 '@babel/plugin-proposal-unicode-property-regex': 7.18.6(@babel/core@7.17.8) '@babel/plugin-transform-dotall-regex': 7.18.6(@babel/core@7.17.8) '@babel/types': 7.21.3 @@ -10191,7 +10194,7 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.21.3 - '@babel/helper-plugin-utils': 7.20.2 + '@babel/helper-plugin-utils': 7.19.0 '@babel/plugin-proposal-unicode-property-regex': 7.18.6(@babel/core@7.21.3) '@babel/plugin-transform-dotall-regex': 7.18.6(@babel/core@7.21.3) '@babel/types': 7.21.3 @@ -20863,8 +20866,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 @@ -22406,6 +22409,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==} @@ -27384,12 +27388,12 @@ packages: vue-template-compiler: optional: true dependencies: - '@babel/code-frame': 7.18.6 + '@babel/code-frame': 7.16.7 '@types/json-schema': 7.0.9 chalk: 4.1.2 chokidar: 3.5.3 cosmiconfig: 6.0.0 - deepmerge: 4.3.0 + deepmerge: 4.2.2 eslint: 8.32.0 fs-extra: 9.1.0 glob: 7.2.3 diff --git a/tools/monorepo-utils/package.json b/tools/monorepo-utils/package.json index d460c6b9870..f517fdab66e 100644 --- a/tools/monorepo-utils/package.json +++ b/tools/monorepo-utils/package.json @@ -18,6 +18,7 @@ "graphql": "^16.6.0", "octokit": "^2.0.14", "ora": "^5.4.1", + "promptly": "^3.2.0", "semver": "^7.3.2" }, "devDependencies": { diff --git a/tools/monorepo-utils/src/code-freeze/commands/branch/index.ts b/tools/monorepo-utils/src/code-freeze/commands/branch/index.ts new file mode 100644 index 00000000000..3b9115e0b7c --- /dev/null +++ b/tools/monorepo-utils/src/code-freeze/commands/branch/index.ts @@ -0,0 +1,152 @@ +/** + * External dependencies + */ +import { Command } from '@commander-js/extra-typings'; +import { parse } from 'semver'; +import { confirm } from 'promptly'; +import chalk from 'chalk'; +import ora from 'ora'; +import { setOutput } from '@actions/core'; + +/** + * Internal dependencies + */ +import { + getLatestReleaseVersion, + doesGithubBranchExist, + getRefFromGithubBranch, + createGithubBranch, + deleteGithubBranch, +} from '../../../github/repo'; +import { WPIncrement } from '../milestone/utils'; +import { Options } from './types'; + +const getNextReleaseBranch = async ( options: Options ) => { + const latestReleaseVersion = await getLatestReleaseVersion( options ); + const nextReleaseVersion = WPIncrement( latestReleaseVersion ); + const parsedNextReleaseVersion = parse( nextReleaseVersion ); + const nextReleaseMajorMinor = `${ parsedNextReleaseVersion.major }.${ parsedNextReleaseVersion.minor }`; + return `release/${ nextReleaseMajorMinor }`; +}; + +export const branchCommand = new Command( 'branch' ) + .description( 'Create a new release branch' ) + .option( + '-g --github', + 'CLI command is used in the Github Actions context.' + ) + .option( '-d --dryRun', 'Prepare the branch but do not create it.' ) + .option( + '-o --owner ', + 'Repository owner. Default: woocommerce', + 'woocommerce' + ) + .option( + '-n --name ', + 'Repository name. Default: woocommerce', + 'woocommerce' + ) + .option( + '-b --branch ', + 'Release branch to create. The branch will be determined from Github if none is supplied' + ) + .option( + '-s --source ', + 'Branch to create the release branch from. Default: trunk', + 'trunk' + ) + .action( async ( options: Options ) => { + const { github, source, branch, owner, name, dryRun } = options; + let nextReleaseBranch; + + if ( ! branch ) { + const versionSpinner = ora( + chalk.yellow( + 'No branch supplied, going off the latest release version' + ) + ).start(); + nextReleaseBranch = await getNextReleaseBranch( options ); + console.log( + chalk.yellow( + `The next release branch is ${ nextReleaseBranch }` + ) + ); + versionSpinner.succeed(); + } else { + nextReleaseBranch = branch; + } + + const branchSpinner = ora( + chalk.yellow( + `Check to see if branch ${ nextReleaseBranch } exists on ${ owner }/${ name }` + ) + ).start(); + + const branchExists = await doesGithubBranchExist( + options, + nextReleaseBranch + ); + branchSpinner.succeed(); + + if ( branchExists ) { + if ( github ) { + console.log( + chalk.red( + `Release branch ${ nextReleaseBranch } already exists` + ) + ); + // When in Github Actions, we don't want to prompt the user for input. + process.exit( 0 ); + } + const deleteExistingReleaseBranch = await confirm( + chalk.yellow( + `Release branch ${ nextReleaseBranch } already exists on ${ owner }/${ name }, do you want to delete it and create a new one from ${ source }? [y/n]` + ) + ); + if ( deleteExistingReleaseBranch ) { + const deleteBranchSpinner = ora( + chalk.yellow( + `Delete branch ${ nextReleaseBranch } on ${ owner }/${ name } and create new one from ${ source }` + ) + ).start(); + await deleteGithubBranch( options, nextReleaseBranch ); + deleteBranchSpinner.succeed(); + } else { + console.log( + chalk.green( + `Branch ${ nextReleaseBranch } already exist on ${ owner }/${ name }, no action taken.` + ) + ); + process.exit( 0 ); + } + } + + const createBranchSpinner = ora( + chalk.yellow( `Create branch ${ nextReleaseBranch }` ) + ).start(); + + if ( dryRun ) { + createBranchSpinner.succeed(); + console.log( + chalk.green( + `DRY RUN: Skipping actual creation of branch ${ nextReleaseBranch } on ${ owner }/${ name }` + ) + ); + + process.exit( 0 ); + } + + const ref = await getRefFromGithubBranch( options, source ); + await createGithubBranch( options, nextReleaseBranch, ref ); + createBranchSpinner.succeed(); + + if ( github ) { + setOutput( 'nextReleaseBranch', nextReleaseBranch ); + } + + console.log( + chalk.green( + `Branch ${ nextReleaseBranch } successfully created on ${ owner }/${ name }` + ) + ); + } ); diff --git a/tools/monorepo-utils/src/code-freeze/commands/branch/types.ts b/tools/monorepo-utils/src/code-freeze/commands/branch/types.ts new file mode 100644 index 00000000000..13ff65b4b98 --- /dev/null +++ b/tools/monorepo-utils/src/code-freeze/commands/branch/types.ts @@ -0,0 +1,8 @@ +export type Options = { + github?: boolean; + dryRun?: boolean; + owner?: string; + name?: string; + branch?: string; + source?: string; +}; diff --git a/tools/monorepo-utils/src/code-freeze/commands/index.ts b/tools/monorepo-utils/src/code-freeze/commands/index.ts index 546b509f220..49fd5e52b5a 100644 --- a/tools/monorepo-utils/src/code-freeze/commands/index.ts +++ b/tools/monorepo-utils/src/code-freeze/commands/index.ts @@ -7,11 +7,13 @@ import { Command } from '@commander-js/extra-typings'; * Internal dependencies */ import { verifyDayCommand } from './verify-day'; -import { milesStoneCommand } from './milestone'; +import { milestoneCommand } from './milestone'; +import { branchCommand } from './branch'; const program = new Command( 'code-freeze' ) .description( 'Code freeze utilities' ) .addCommand( verifyDayCommand ) - .addCommand( milesStoneCommand ); + .addCommand( milestoneCommand ) + .addCommand( branchCommand ); export default program; diff --git a/tools/monorepo-utils/src/code-freeze/commands/milestone/index.ts b/tools/monorepo-utils/src/code-freeze/commands/milestone/index.ts index d759ad63f86..9ce2ccdc795 100644 --- a/tools/monorepo-utils/src/code-freeze/commands/milestone/index.ts +++ b/tools/monorepo-utils/src/code-freeze/commands/milestone/index.ts @@ -10,10 +10,10 @@ import ora from 'ora'; */ import { getLatestReleaseVersion } from '../../../github/repo'; import { octokitWithAuth } from '../../../github/api'; -import { WPIncrement, setGithubMilestoneOutputs } from './utils'; +import { setGithubMilestoneOutputs, WPIncrement } from './utils'; import { Options } from './types'; -export const milesStoneCommand = new Command( 'milestone' ) +export const milestoneCommand = new Command( 'milestone' ) .description( 'Create a milestone' ) .option( '-g --github', diff --git a/tools/monorepo-utils/src/github/repo.ts b/tools/monorepo-utils/src/github/repo.ts index 5d96ac55e6c..c26297347fe 100644 --- a/tools/monorepo-utils/src/github/repo.ts +++ b/tools/monorepo-utils/src/github/repo.ts @@ -6,7 +6,7 @@ import { Repository } from '@octokit/graphql-schema'; /** * Internal dependencies */ -import { graphqlWithAuth } from './api'; +import { graphqlWithAuth, octokitWithAuth } from './api'; export const getLatestReleaseVersion = async ( options: { owner?: string; @@ -34,3 +34,97 @@ export const getLatestReleaseVersion = async ( options: { ( tagName ) => tagName.isLatest ).tagName; }; + +export const doesGithubBranchExist = async ( + options: { + owner?: string; + name?: string; + }, + nextReleaseBranch: string +): Promise< boolean > => { + const { owner, name } = options; + try { + const branchOnGithub = await octokitWithAuth.request( + 'GET /repos/{owner}/{repo}/branches/{branch}', + { + owner, + repo: name, + branch: nextReleaseBranch, + } + ); + return branchOnGithub.data.name === nextReleaseBranch; + } catch ( e ) { + if ( + e.status === 404 && + e.response.data.message === 'Branch not found' + ) { + return false; + } + throw new Error( e ); + } +}; + +export const getRefFromGithubBranch = async ( + options: { + owner?: string; + name?: string; + }, + source: string +): Promise< string > => { + const { owner, name } = options; + const { repository } = await graphqlWithAuth< { + repository: Repository; + } >( ` + { + repository(owner:"${ owner }", name:"${ name }") { + ref(qualifiedName: "refs/heads/${ source }") { + target { + ... on Commit { + history(first: 1) { + edges{ node{ oid } } + } + } + } + } + } + } + ` ); + + // @ts-ignore: The graphql query is typed, but the response is not. + return repository.ref.target.history.edges.shift().node.oid; +}; + +export const createGithubBranch = async ( + options: { + owner?: string; + name?: string; + }, + branch: string, + ref: string +): Promise< void > => { + const { owner, name } = options; + await octokitWithAuth.request( 'POST /repos/{owner}/{repo}/git/refs', { + owner, + repo: name, + ref: `refs/heads/${ branch }`, + sha: ref, + } ); +}; + +export const deleteGithubBranch = async ( + options: { + owner?: string; + name?: string; + }, + branch: string +): Promise< void > => { + const { owner, name } = options; + await octokitWithAuth.request( + 'DELETE /repos/{owner}/{repo}/git/refs/heads/{ref}', + { + owner, + repo: name, + ref: branch, + } + ); +};