From 9f9fef7ed3e65e8267053c1496dac42e724101b5 Mon Sep 17 00:00:00 2001 From: Sam Seay Date: Fri, 17 Mar 2023 08:37:53 +1300 Subject: [PATCH] Introduce a beta post command to the release post tool (#37142) --- pnpm-lock.yaml | 12 +- .../commands/release-post/index.ts | 1 + .../release-post/release-post-beta.ts | 225 ++++++++++++++++++ .../release-post/release-post-release.ts | 6 +- tools/release-posts/lib/dates.ts | 22 ++ tools/release-posts/lib/draft-post.ts | 41 ++++ tools/release-posts/lib/github-api.ts | 13 + tools/release-posts/package.json | 1 + .../release-posts/templates/beta-release.ejs | 164 +++++++++++++ tools/release-posts/tsconfig.json | 13 +- 10 files changed, 487 insertions(+), 11 deletions(-) create mode 100755 tools/release-posts/commands/release-post/release-post-beta.ts create mode 100644 tools/release-posts/lib/dates.ts create mode 100644 tools/release-posts/templates/beta-release.ejs diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9d1ba4ea06..c561616240f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2207,6 +2207,7 @@ importers: commander: 9.4.0 dotenv: ^10.0.0 ejs: ^3.1.8 + enquirer: ^2.3.6 express: ^4.18.1 form-data: ^4.0.0 lodash.shuffle: ^4.2.0 @@ -2223,6 +2224,7 @@ importers: commander: 9.4.0 dotenv: 10.0.0 ejs: 3.1.8 + enquirer: 2.3.6 express: 4.18.1 form-data: 4.0.0 lodash.shuffle: 4.2.0 @@ -18164,7 +18166,6 @@ packages: /ansi-colors/4.1.1: resolution: {integrity: sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==} engines: {node: '>=6'} - dev: true /ansi-escapes/3.2.0: resolution: {integrity: sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==} @@ -22760,7 +22761,6 @@ packages: engines: {node: '>=8.6'} dependencies: ansi-colors: 4.1.1 - dev: true /entities/1.1.2: resolution: {integrity: sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==} @@ -30417,7 +30417,7 @@ packages: log-update: 4.0.0 p-map: 4.0.0 rfdc: 1.3.0 - rxjs: 7.5.5 + rxjs: 7.8.0 through: 2.3.8 wrap-ansi: 7.0.0 dev: true @@ -36792,6 +36792,12 @@ packages: tslib: 2.5.0 dev: true + /rxjs/7.8.0: + resolution: {integrity: sha512-F2+gxDshqmIub1KdvZkaEfGDwLNpPvk9Fs6LD/MyQxNgMds/WH9OdDDXOmxUZpME+iSK3rQCctkL0DYyytUqMg==} + dependencies: + tslib: 2.5.0 + dev: true + /safe-buffer/5.1.1: resolution: {integrity: sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==} dev: true diff --git a/tools/release-posts/commands/release-post/index.ts b/tools/release-posts/commands/release-post/index.ts index 33f28b612ef..7c7b3ab3ef2 100644 --- a/tools/release-posts/commands/release-post/index.ts +++ b/tools/release-posts/commands/release-post/index.ts @@ -10,6 +10,7 @@ program .name( 'release-post' ) .version( '0.0.1' ) .command( 'release', 'Generate release post', { isDefault: true } ) + .command( 'beta', 'Generate draft beta release post' ) .command( 'contributors', 'Generate a list of contributors for a release post' diff --git a/tools/release-posts/commands/release-post/release-post-beta.ts b/tools/release-posts/commands/release-post/release-post-beta.ts new file mode 100755 index 00000000000..b5bb915537b --- /dev/null +++ b/tools/release-posts/commands/release-post/release-post-beta.ts @@ -0,0 +1,225 @@ +/** + * External dependencies + */ +import semver from 'semver'; +import { writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { Logger } from 'cli-core/src/logger'; +import { Command } from '@commander-js/extra-typings'; +import dotenv from 'dotenv'; +// @ts-expect-error - The enquirer types are incorrect. +// eslint-disable-next-line @woocommerce/dependency-group +import { Select } from 'enquirer'; + +/** + * Internal dependencies + */ +import { renderTemplate } from '../../lib/render-template'; +import { getWordpressComAuthToken } from '../../lib/oauth-helper'; +import { getEnvVar } from '../../lib/environment'; +import { getMostRecentFinal } from '../../lib/github-api'; +import { + getFirstTuesdayOfTheMonth, + getSecondTuesdayOfTheMonth, +} from '../../lib/dates'; +import { + createWpComDraftPost, + searchForPostsByCategory, +} from '../../lib/draft-post'; + +const DEVELOPER_WOOCOMMERCE_SITE_ID = '96396764'; + +dotenv.config(); + +// Define the release post command +const program = new Command() + .command( 'beta' ) + .description( 'CLI to automate generation of a draft beta release post.' ) + .argument( + '', + 'The version for this post in x.y.z-beta.n format. Ex: 7.1.0-beta.1' + ) + .option( + '--releaseDate ', + 'The date for the final release as mm-dd-yyyy, year inferred as current year, defaults to second tuesday of next month.', + getSecondTuesdayOfTheMonth( + new Date().getMonth() + 1 + ).toLocaleDateString( 'en-US', { + month: '2-digit', + day: '2-digit', + year: 'numeric', + } ) + ) + .option( '--outputOnly', 'Only output the post, do not publish it' ) + .option( + '--tags ', + 'Comma separated list of tags to add to the post.', + 'Releases,WooCommerce Core' + ) + .option( + '--siteId ', + 'For posting to a non-default site (for testing)' + ) + .action( async ( releaseVersion, options ) => { + const { + outputOnly, + siteId = DEVELOPER_WOOCOMMERCE_SITE_ID, + tags, + releaseDate, + } = options; + + const postTags = ( tags && + tags.split( ',' ).map( ( tag ) => tag.trim() ) ) || [ + 'WooCommerce Core', + 'Releases', + ]; + + const finalReleaseDate = new Date( releaseDate ); + const isOutputOnly = !! outputOnly; + const semverVersion = semver.parse( releaseVersion ); + + // This is supposed to be a beta post so throw if the version provided is not a beta version. + // Things we don't accept: + // * missing beta.x + // * any other kind of prerelease, e.g. rc + // * .x must be a number, so not: beta.1b or beta.1.1 but beta.1 is ok. + if ( + ! semverVersion || + ! semverVersion.prerelease.length || + typeof semverVersion.prerelease[ 1 ] === 'string' + ) { + throw new Error( + `Invalid current version: ${ releaseVersion }. Provide current version in x.y.z-beta.n format.` + ); + } else { + const [ , prereleaseVersion ] = semverVersion.prerelease; + + // Now infer the previous version, if the one you provide is beta.1 we'll need to find the last major release from + // Github releases. If what you provided is beta.2 we'll assume previous was beta.1 + const previousVersion = + prereleaseVersion === 1 + ? ( await getMostRecentFinal() ).tag_name + : `${ semverVersion.major }.${ semverVersion.minor }.${ + semverVersion.patch + }-beta.${ prereleaseVersion - 1 }`; + + const semverPreviousVersion = semver.parse( previousVersion ); + + if ( ! semverPreviousVersion ) { + throw new Error( + `Could not parse previous version from: ${ previousVersion }` + ); + } + + const clientId = getEnvVar( 'WPCOM_OAUTH_CLIENT_ID', true ); + const clientSecret = getEnvVar( 'WPCOM_OAUTH_CLIENT_SECRET', true ); + const redirectUri = + getEnvVar( 'WPCOM_OAUTH_REDIRECT_URI' ) || + 'http://localhost:3000/oauth'; + + Logger.startTask( + 'Getting auth token for WordPress.com (needed to find last beta post).' + ); + const authToken = await getWordpressComAuthToken( + clientId, + clientSecret, + siteId, + redirectUri, + 'posts' + ); + Logger.endTask(); + + const versionSearch = + prereleaseVersion === 1 + ? `WooCommerce ${ semverPreviousVersion.major }.${ semverPreviousVersion.minor }.${ semverPreviousVersion.patch }` + : `WooCommerce ${ semverPreviousVersion.major }.${ semverPreviousVersion.minor } Beta ${ semverPreviousVersion.prerelease[ 1 ] }`; + + Logger.startTask( + `Finding recent release posts with title: ${ versionSearch }` + ); + + const posts = + ( await searchForPostsByCategory( + siteId, + versionSearch, + 'WooCommerce Core', + authToken + ) ) || []; + + Logger.endTask(); + + const prompt = new Select( { + name: 'Previous post', + message: 'Choose the previous post to link to:', + choices: posts.length + ? posts.map( ( p ) => p.title ) + : [ 'No posts found - generate default link' ], + } ); + + const lastReleasePostTitle: string = await prompt.run(); + const lastReleasePost = posts.find( + ( p ) => p.title === lastReleasePostTitle + ); + + if ( ! lastReleasePost ) { + Logger.warn( + 'Could not find previous release post, make sure to update the link in the post before publishing.' + ); + } + + if ( ! authToken && ! isOutputOnly ) { + throw new Error( + 'Error getting auth token, check your env settings are correct.' + ); + } else { + const html = await renderTemplate( 'beta-release.ejs', { + releaseDate, + betaNumber: prereleaseVersion, + version: semverVersion, + previousVersion: semverPreviousVersion, + prettyVersion: `${ semverVersion.major }.${ semverVersion.minor }.${ semverVersion.patch } Beta ${ prereleaseVersion }`, + prettyPreviousVersion: `${ semverPreviousVersion.major }.${ + semverPreviousVersion.minor + }.${ semverPreviousVersion.patch }${ + semverPreviousVersion.prerelease.length + ? ' ' + + semverPreviousVersion.prerelease[ 0 ] + + ' ' + + semverPreviousVersion.prerelease[ 1 ] + : '' + }`, + rcReleaseDate: getFirstTuesdayOfTheMonth( + finalReleaseDate.getMonth() + ), + finalReleaseDate, + lastReleasePostUrl: + lastReleasePost?.URL || + 'https://developer.woocommerce.com/category/woocommerce-core-release-notes/', + } ); + + if ( isOutputOnly ) { + const tmpFile = join( + tmpdir(), + `beta-release-${ releaseVersion }.html` + ); + + await writeFile( tmpFile, html ); + + Logger.notice( `Output written to ${ tmpFile }` ); + } else { + Logger.startTask( 'Publishing draft release post' ); + await createWpComDraftPost( + siteId, + `WooCommerce ${ semverVersion.major }.${ semverVersion.minor } Beta ${ prereleaseVersion } Released`, + html, + postTags, + authToken + ); + Logger.endTask(); + } + } + } + } ); + +program.parse( process.argv ); diff --git a/tools/release-posts/commands/release-post/release-post-release.ts b/tools/release-posts/commands/release-post/release-post-release.ts index d03b637d5b5..bffd5b95405 100644 --- a/tools/release-posts/commands/release-post/release-post-release.ts +++ b/tools/release-posts/commands/release-post/release-post-release.ts @@ -150,7 +150,7 @@ const program = new Command() let postContent; - if ( 'undefined' !== typeof options.editPostId ) { + if ( typeof options.editPostId !== 'undefined' ) { try { const prevPost = await fetchWpComPost( siteId, @@ -197,7 +197,7 @@ const program = new Command() }; const html = - 'undefined' !== typeof options.editPostId + typeof options.editPostId !== 'undefined' ? editPostHTML( postContent, { hooks: await renderTemplate( 'hooks.ejs', @@ -234,7 +234,7 @@ const program = new Command() try { const { URL } = - 'undefined' !== typeof options.editPostId + typeof options.editPostId !== 'undefined' ? await editWpComPostContent( siteId, options.editPostId, diff --git a/tools/release-posts/lib/dates.ts b/tools/release-posts/lib/dates.ts new file mode 100644 index 00000000000..80d55766e09 --- /dev/null +++ b/tools/release-posts/lib/dates.ts @@ -0,0 +1,22 @@ +export const getFirstTuesdayOfTheMonth = ( month: number ): Date => { + // create a new Date object for the first day of the month + const firstDayOfMonth = new Date( new Date().getFullYear(), month, 1 ); + + // create a new Date object for the first Tuesday of the month + const firstTuesday = new Date( firstDayOfMonth ); + + firstTuesday.setDate( 1 + ( ( 2 - firstDayOfMonth.getDay() + 7 ) % 7 ) ); + + return firstTuesday; +}; + +export const getSecondTuesdayOfTheMonth = ( month: number ): Date => { + // create a new Date object for the first Tuesday of the month + const firstTuesday = getFirstTuesdayOfTheMonth( month ); + + // create a new Date object for the second Tuesday of the current month + const secondTuesday = new Date( firstTuesday ); + secondTuesday.setDate( secondTuesday.getDate() + 7 ); + + return secondTuesday; +}; diff --git a/tools/release-posts/lib/draft-post.ts b/tools/release-posts/lib/draft-post.ts index ee298841264..cc481a15e8a 100644 --- a/tools/release-posts/lib/draft-post.ts +++ b/tools/release-posts/lib/draft-post.ts @@ -4,6 +4,14 @@ import fetch from 'node-fetch'; import { Logger } from 'cli-core/src/logger'; +// Typing just the things we need from the WP.com Post object. +// (which is not the same as WP Post object or API Post object). +// See example response here: https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/posts/ to add more props. +type WordpressComPost = { + title: string; + URL: string; +}; + /** * Fetch a post from WordPress.com * @@ -41,6 +49,39 @@ export const fetchWpComPost = async ( } }; +export const searchForPostsByCategory = async ( + siteId: string, + search: string, + category: string, + authToken: string +) => { + try { + const post = await fetch( + `https://public-api.wordpress.com/rest/v1.1/sites/${ siteId }/posts?${ new URLSearchParams( + { search, category } + ) }`, + { + headers: { + Authorization: `Bearer ${ authToken }`, + 'Content-Type': 'application/json', + }, + method: 'GET', + } + ); + + if ( post.status !== 200 ) { + const text = await post.text(); + throw new Error( `Error creating draft post: ${ text }` ); + } + + return ( await post.json() ).posts as WordpressComPost[]; + } catch ( e: unknown ) { + if ( e instanceof Error ) { + Logger.error( e.message ); + } + } +}; + /** * Edit a post on wordpress.com * diff --git a/tools/release-posts/lib/github-api.ts b/tools/release-posts/lib/github-api.ts index ebd3504f2f7..bbd33a2525a 100644 --- a/tools/release-posts/lib/github-api.ts +++ b/tools/release-posts/lib/github-api.ts @@ -102,3 +102,16 @@ export const getContributorData = async ( headRef, } as ContributorData; }; + +export const getMostRecentFinal = async () => { + const octokit = new Octokit( { + auth: getEnvVar( 'GITHUB_ACCESS_TOKEN', true ), + } ); + + const release = await octokit.repos.getLatestRelease( { + owner: 'woocommerce', + repo: 'woocommerce', + } ); + + return release.data; +}; diff --git a/tools/release-posts/package.json b/tools/release-posts/package.json index 5aa9f43503a..11ae04cff16 100644 --- a/tools/release-posts/package.json +++ b/tools/release-posts/package.json @@ -31,6 +31,7 @@ "commander": "9.4.0", "dotenv": "^10.0.0", "ejs": "^3.1.8", + "enquirer": "^2.3.6", "express": "^4.18.1", "form-data": "^4.0.0", "lodash.shuffle": "^4.2.0", diff --git a/tools/release-posts/templates/beta-release.ejs b/tools/release-posts/templates/beta-release.ejs new file mode 100644 index 00000000000..0494302e451 --- /dev/null +++ b/tools/release-posts/templates/beta-release.ejs @@ -0,0 +1,164 @@ + +

+ Beta <%= betaNumber %> for the <%= + finalReleaseDate.toLocaleDateString('en-US', {month: 'long', day: + 'numeric'}) %> release of WooCommerce is now available for testing! You can + either + download it directly from WordPress.org + or install our + WooCommerce Beta Tester Plugin. +

+ + + +

Highlights

+ + + +

+ Since the release of + <%= prettyPreviousVersion %>, the + following changes have been made: +

+ + + +
    + +
  • List your changes here.
  • + +
+ + + +

+ For the complete list, view the changelog in the readme for this release. +

+ + + +

Actions and Filters

+ + + +

No changes introduced.

+ + + +

Database Changes

+ + + +

No changes introduced.

+ + + +

Template Changes

+ + + +

No changes introduced.

+ + + +

Release Schedule

+ + + +

+ We're still on track for our planned <%= + finalReleaseDate.toLocaleDateString('en-US', {month: 'long', day: + 'numeric'}) %> release. +

+ + + +
+ + + + + + + + + + + + + + + +
VersionRelease
Release Candidate + <%= rcReleaseDate.toLocaleDateString('en-US', { month: + 'long', day: 'numeric', year: 'numeric' }) %> +
Final Release + <%= finalReleaseDate.toLocaleDateString('en-US', { month: + 'long', day: 'numeric', year: 'numeric' }) %> +
+
+ + + +

Testing

+ + + +

+ If you'd like to dive in and help test this new release, our handy WooCommerce Beta Tester plugin allows you to switch between beta versions and release candidates. + You can also download the release from WordPress.org. +

+ + + +

+ A set of testing instructions has been published on our Wiki page in GitHub. We've also posted a helpful writeup on beta testing to help get you started. +

+ + + +

+ If you discover any bugs during the testing process, please let us know + by logging a report in GitHub. +

+ + + +

+ diff --git a/tools/release-posts/tsconfig.json b/tools/release-posts/tsconfig.json index b49d71cfb0a..0b425cd5c54 100644 --- a/tools/release-posts/tsconfig.json +++ b/tools/release-posts/tsconfig.json @@ -1,7 +1,10 @@ { - "extends": "@tsconfig/node16/tsconfig.json", - "ts-node": { - "transpileOnly": true, - "files": true, - } + "extends": "@tsconfig/node16/tsconfig.json", + "compilerOptions": { + "module": "Node16" + }, + "ts-node": { + "transpileOnly": true, + "files": true + } }