diff --git a/tools/cli-core/src/git.ts b/tools/cli-core/src/git.ts index 59edd5f96e5..cc9467eba2c 100644 --- a/tools/cli-core/src/git.ts +++ b/tools/cli-core/src/git.ts @@ -20,6 +20,20 @@ export const getFilename = ( str: string ): string => { return str.replace( /^a(.*)\s.*/, '$1' ); }; +/** + * Get starting line number from patch + * + * @param {string} str String to extract starting line number from. + * @return {number} line number. + */ +export const getStartingLineNumber = ( str: string ): number => { + const lineNumber = str.replace( /^@@ -\d+,\d+ \+(\d+),\d+ @@.*?$/, '$1' ); + if ( ! lineNumber.match( /^\d+$/ ) ) { + throw new Error( 'Unable to parse line number from patch' ); + } + return parseInt( lineNumber, 10 ); +}; + /** * Get patches * @@ -190,6 +204,79 @@ export const getCommitHash = async ( baseDir: string, ref: string ) => { return ref; }; +/** + * Get the commit hash for the last change to a line within a specific file. + * + * @param {string} baseDir - the dir of the git repo to get the hash from. + * @param {string} filePath - the relative path to the file to check the commit hash of. + * @param {number} lineNumber - the line number from which to get the hash of the last commit. + * @return {string} - the commit hash of the last change to filePath at lineNumber. + */ +export const getLineCommitHash = async ( + baseDir: string, + filePath: string, + lineNumber: number +) => { + // Remove leading slash, if it exists. + const adjustedFilePath = filePath.replace( /^\//, '' ); + try { + const git = await simpleGit( { baseDir } ); + const blame = await git.raw( [ + 'blame', + `-L${ lineNumber },${ lineNumber }`, + adjustedFilePath, + ] ); + const hash = blame.match( /^([a-f0-9]+)\s+/ ); + if ( ! hash ) { + throw new Error( + `Unable to git blame ${ adjustedFilePath }:${ lineNumber }` + ); + } + return hash[ 1 ]; + } catch ( e ) { + throw new Error( + `Unable to git blame ${ adjustedFilePath }:${ lineNumber }` + ); + } +}; + +/** + * Get the commit hash for the last change to a line within a specific file. + * + * @param {string} baseDir - the dir of the git repo to get the PR number from. + * @param {string} hash - the hash to get the PR number from. + * @return {number} - the pull request number from the given inputs. + */ +export const getPullRequestNumberFromHash = async ( + baseDir: string, + hash: string +) => { + try { + const git = await simpleGit( { baseDir } ); + const formerHead = await git.revparse( 'HEAD' ); + await git.checkout( hash ); + const cmdOutput = await git.raw( [ + 'log', + '-1', + '--first-parent', + '--format=%cI\n%s', + ] ); + const cmdLines = cmdOutput.split( '\n' ); + await git.checkout( formerHead ); + const prNumber = cmdLines[ 1 ] + .trim() + .match( /(?:^Merge pull request #(\d+))|(?:\(#(\d+)\)$)/ ); + if ( prNumber ) { + return prNumber[ 1 ] + ? parseInt( prNumber[ 1 ], 10 ) + : parseInt( prNumber[ 2 ], 10 ); + } + throw new Error( `Unable to get PR number from hash ${ hash }.` ); + } catch ( e ) { + throw new Error( `Unable to get PR number from hash ${ hash }.` ); + } +}; + /** * generateDiff generates a diff for a given repo and 2 hashes or branch names. * diff --git a/tools/code-analyzer/src/lib/scan-changes.ts b/tools/code-analyzer/src/lib/scan-changes.ts index 829f4c84d65..df5617d381f 100644 --- a/tools/code-analyzer/src/lib/scan-changes.ts +++ b/tools/code-analyzer/src/lib/scan-changes.ts @@ -22,11 +22,19 @@ export const scanForChanges = async ( skipSchemaCheck: boolean, source: string, base: string, - outputStyle: string + outputStyle: string, + clonedPath?: string ) => { Logger.startTask( `Making temporary clone of ${ source }...` ); - const tmpRepoPath = await cloneRepo( source ); - Logger.endTask(); + + const tmpRepoPath = + typeof clonedPath !== 'undefined' + ? clonedPath + : await cloneRepo( source ); + + Logger.notice( + `Temporary clone of ${ source } created at ${ tmpRepoPath }` + ); Logger.notice( `Temporary clone of ${ source } created at ${ tmpRepoPath }` @@ -58,7 +66,11 @@ export const scanForChanges = async ( Logger.endTask(); Logger.startTask( 'Detecting template changes...' ); - const templateChanges = scanForTemplateChanges( diff, sinceVersion ); + const templateChanges = await scanForTemplateChanges( + diff, + sinceVersion, + tmpRepoPath + ); Logger.endTask(); Logger.startTask( 'Detecting DB changes...' ); diff --git a/tools/code-analyzer/src/lib/template-changes.ts b/tools/code-analyzer/src/lib/template-changes.ts index abecd850a5a..5605eef8858 100644 --- a/tools/code-analyzer/src/lib/template-changes.ts +++ b/tools/code-analyzer/src/lib/template-changes.ts @@ -1,16 +1,28 @@ /** * External dependencies */ -import { getFilename, getPatches } from 'cli-core/src/git'; +import { + getFilename, + getStartingLineNumber, + getPullRequestNumberFromHash, + getPatches, + getLineCommitHash, +} from 'cli-core/src/git'; +import { Logger } from 'cli-core/src/logger'; export type TemplateChangeDescription = { filePath: string; code: string; // We could probably move message out into linter later message: string; + pullRequests: number[]; }; -export const scanForTemplateChanges = ( content: string, version: string ) => { +export const scanForTemplateChanges = async ( + content: string, + version: string, + repositoryPath?: string +) => { const changes: Map< string, TemplateChangeDescription > = new Map(); if ( ! content.match( /diff --git a\/(.+)\/templates\/(.+)\.php/g ) ) { @@ -30,7 +42,9 @@ export const scanForTemplateChanges = ( content: string, version: string ) => { const patch = patches[ p ]; const lines = patch.split( '\n' ); const filePath = getFilename( lines[ 0 ] ); + const pullRequests = []; + let lineNumber = 1; let code = 'warning'; let message = 'This template may require a version bump!'; @@ -41,9 +55,48 @@ export const scanForTemplateChanges = ( content: string, version: string ) => { code = 'notice'; message = 'Version bump found'; } + + if ( repositoryPath ) { + // Don't parse the headers for the patch. + if ( parseInt( l, 10 ) < 4 ) { + continue; + } + + if ( line.match( /^@@/ ) ) { + // If we reach a chunk, update the line number, and then continue. + lineNumber = getStartingLineNumber( line ); + continue; + } + + if ( line.match( /^\+/ ) ) { + try { + const commitHash = await getLineCommitHash( + repositoryPath, + filePath, + lineNumber + ); + const prNumber = await getPullRequestNumberFromHash( + repositoryPath, + commitHash + ); + if ( -1 === pullRequests.indexOf( prNumber ) ) { + pullRequests.push( prNumber ); + } + } catch ( e: unknown ) { + Logger.notice( + `Unable to get PR number in ${ filePath }:${ lineNumber }` + ); + } + } + + // We shouldn't increment line numbers for the a-side of the patch. + if ( ! line.match( /^-/ ) ) { + lineNumber++; + } + } } - changes.set( filePath, { code, message, filePath } ); + changes.set( filePath, { code, message, filePath, pullRequests } ); } return changes; diff --git a/tools/release-posts/README.md b/tools/release-posts/README.md index 94ad81d82d7..27ab6497022 100644 --- a/tools/release-posts/README.md +++ b/tools/release-posts/README.md @@ -4,7 +4,7 @@ This is a cli tool designed to generate draft release posts for WooCommerce. Posts generated via the tool will be draft posted to https://developer.woocommerce.com. You can also generate an HTML representation of the post if you -don't have access to a wc.com auth token. +don't have access to a WordPress.com auth token. ### Setup @@ -18,7 +18,7 @@ the `GITHUB_ACCESS_TOKEN` is required. If you need help generating a token see [ This tool will publish draft posts to `https://developer.woocommerce.com` for you if you omit the `--outputOnly` flag. There is some minimal first time setup for this though: -1. Create an app on Wordpress.com [here](https://developer.wordpress.com/apps/). +1. Create an app on WordPress.com [here](https://developer.wordpress.com/apps/). 2. Recommended settings: * Name can be anything @@ -48,7 +48,7 @@ If you can't run anything on your localhost port 3000 you may want to override t Steps: 1. Add your preferred redirect URI to the `WPCOM_OAUTH_REDIRECT_URI` variable in `.env`. e.g. `http://localhost:4321/oauth` -2. When creating your app on [Wordpress.com](https://developer.wordpress.com/apps/) make sure the redirect URL you set matches the one set in `.env` +2. When creating your app on [WordPress.com](https://developer.wordpress.com/apps/) make sure the redirect URL you set matches the one set in `.env` 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 7dda60cf4fa..d03b637d5b5 100644 --- a/tools/release-posts/commands/release-post/release-post-release.ts +++ b/tools/release-posts/commands/release-post/release-post-release.ts @@ -6,6 +6,7 @@ import semver from 'semver'; import { writeFile } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from 'path'; +import { cloneRepo, getCommitHash } from 'cli-core/src/git'; import { Logger } from 'cli-core/src/logger'; import { Command } from '@commander-js/extra-typings'; import dotenv from 'dotenv'; @@ -14,11 +15,20 @@ import dotenv from 'dotenv'; * Internal dependencies */ import { renderTemplate } from '../../lib/render-template'; -import { createWpComDraftPost } from '../../lib/draft-post'; +import { + createWpComDraftPost, + fetchWpComPost, + editWpComPostContent, +} from '../../lib/draft-post'; +import { getWordpressComAuthToken } from '../../lib/oauth-helper'; +import { getEnvVar } from '../../lib/environment'; import { generateContributors } from '../../lib/contributors'; +import { editPostHTML } from '../../lib/edit-post'; const DEVELOPER_WOOCOMMERCE_SITE_ID = '96396764'; +const SOURCE_REPO = 'https://github.com/woocommerce/woocommerce.git'; + const VERSION_VALIDATION_REGEX = /^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+)?$/; @@ -30,117 +40,222 @@ const program = new Command() .description( 'CLI to automate generation of a release post.' ) .argument( '', - 'The version of the plugin to generate a post for, please use the tag version from Github.' + 'The current version in x.y.z or x.y.z-stage.n format. Ex: 7.1.0, 7.1.0-rc.1' + ) + .argument( + '', + 'The previous version in x.y.z format. Ex: 7.0.0' ) .option( '--outputOnly', 'Only output the post, do not publish it' ) - .option( - '--previousVersion ', - 'If you would like to compare against a version other than last minor you can provide a tag version from Github.' - ) + .option( '--editPostId ', 'Updates an existing post' ) .option( '--tags ', 'Comma separated list of tags to add to the post.', 'Releases,WooCommerce Core' ) - .action( async ( currentVersion, options ) => { - const tags = options.tags.split( ',' ).map( ( tag ) => tag.trim() ); + .option( + '--siteId ', + 'For posting to a non-default site (for testing)' + ) + .action( async ( currentVersion, previousVersion, options ) => { + const siteId = options.siteId || DEVELOPER_WOOCOMMERCE_SITE_ID; + const tags = ( options.tags && + options.tags.split( ',' ).map( ( tag ) => tag.trim() ) ) || [ + 'WooCommerce Core', + 'Releases', + ]; + const isOutputOnly = !! options.outputOnly; - const previousVersion = options.previousVersion - ? semver.parse( options.previousVersion ) - : semver.parse( currentVersion ); - - if ( ! options.previousVersion && previousVersion ) { - // e.g 6.8.0 -> 6.7.0 - previousVersion.major = - previousVersion.minor === 0 - ? previousVersion.major - 1 - : previousVersion.major; - - previousVersion.minor = - previousVersion.minor === 0 ? 9 : previousVersion.minor - 1; - - previousVersion.format(); + if ( ! VERSION_VALIDATION_REGEX.test( currentVersion ) ) { + throw new Error( + `Invalid current version: ${ currentVersion }. Provide current version in x.y.z or x.y.z-stage.n format.` + ); } - if ( previousVersion && previousVersion.major ) { - const isOutputOnly = !! options.outputOnly; + if ( ! VERSION_VALIDATION_REGEX.test( previousVersion ) ) { + throw new Error( + `Invalid previous version: ${ previousVersion }. Provide previous version in x.y.z format.` + ); + } - if ( ! VERSION_VALIDATION_REGEX.test( previousVersion.raw ) ) { + 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'; + const authToken = + isOutputOnly || + ( await getWordpressComAuthToken( + clientId, + clientSecret, + siteId, + redirectUri, + 'posts' + ) ); + + if ( ! authToken ) { + throw new Error( + 'Error getting auth token, check your env settings are correct.' + ); + } + + Logger.startTask( `Making temporary clone of ${ SOURCE_REPO }...` ); + const currentParsed = semver.parse( currentVersion ); + const previousParsed = semver.parse( previousVersion ); + const tmpRepoPath = await cloneRepo( SOURCE_REPO ); + Logger.endTask(); + let currentBranch; + let previousBranch; + let currentVersionRef; + let previousVersionRef; + + try { + if ( ! currentParsed ) { + throw new Error( 'Unable to parse current version' ); + } + currentBranch = `release/${ currentParsed.major }.${ currentParsed.minor }`; + currentVersionRef = await getCommitHash( + tmpRepoPath, + `remotes/origin/${ currentBranch }` + ); + } catch ( error: unknown ) { + Logger.notice( + `Unable to find '${ currentBranch }', using 'trunk'.` + ); + currentBranch = 'trunk'; + currentVersionRef = await getCommitHash( + tmpRepoPath, + 'remotes/origin/trunk' + ); + } + + try { + if ( ! previousParsed ) { + throw new Error( 'Unable to parse previous version' ); + } + previousBranch = `release/${ previousParsed.major }.${ previousParsed.minor }`; + previousVersionRef = await getCommitHash( + tmpRepoPath, + `remotes/origin/${ previousBranch }` + ); + } catch ( error: unknown ) { + throw new Error( + `Unable to find '${ previousBranch }'. Branch for previous version must exist.` + ); + } + + Logger.notice( + `Using ${ currentBranch }(${ currentVersionRef }) for current and ${ previousBranch }(${ previousVersionRef }) for previous.` + ); + + let postContent; + + if ( 'undefined' !== typeof options.editPostId ) { + try { + const prevPost = await fetchWpComPost( + siteId, + options.editPostId, + authToken + ); + postContent = prevPost.content; + } catch ( error: unknown ) { throw new Error( - `Invalid previous version: ${ previousVersion.raw }` - ); - } - if ( ! VERSION_VALIDATION_REGEX.test( currentVersion ) ) { - throw new Error( - `Invalid current version: ${ currentVersion }` + `Unable to fetch existing post with ID: ${ options.editPostId }` ); } + } - const changes = await scanForChanges( - currentVersion, - currentVersion, - false, - 'https://github.com/woocommerce/woocommerce.git', - previousVersion.toString(), - 'cli' + const changes = await scanForChanges( + currentVersionRef, + `${ previousParsed.major }.${ previousParsed.minor }.${ previousParsed.patch }`, + //false, + true, + SOURCE_REPO, + previousVersionRef, + 'cli', + tmpRepoPath + ); + + const schemaChanges = changes.schema.filter( ( s ) => ! s.areEqual ); + + Logger.startTask( 'Finding contributors' ); + const title = `WooCommerce ${ currentVersion } Released`; + + const contributors = await generateContributors( + currentVersion, + previousVersion.toString() + ); + + const postVariables = { + contributors, + title, + changes: { + ...changes, + schema: schemaChanges, + }, + displayVersion: currentVersion, + }; + + const html = + 'undefined' !== typeof options.editPostId + ? editPostHTML( postContent, { + hooks: await renderTemplate( + 'hooks.ejs', + postVariables + ), + database: await renderTemplate( + 'database.ejs', + postVariables + ), + templates: await renderTemplate( + 'templates.ejs', + postVariables + ), + contributors: await renderTemplate( + 'contributors.ejs', + postVariables + ), + } ) + : await renderTemplate( 'release.ejs', postVariables ); + + Logger.endTask(); + + if ( isOutputOnly ) { + const tmpFile = join( + tmpdir(), + `release-${ currentVersion }.html` ); - const schemaChanges = changes.schema.filter( - ( s ) => ! s.areEqual - ); + await writeFile( tmpFile, html ); - Logger.startTask( 'Finding contributors' ); - const title = `WooCommerce ${ currentVersion } Released`; + Logger.notice( `Output written to ${ tmpFile }` ); + } else { + Logger.startTask( 'Publishing draft release post' ); - const contributors = await generateContributors( - currentVersion, - previousVersion.toString() - ); + try { + const { URL } = + 'undefined' !== typeof options.editPostId + ? await editWpComPostContent( + siteId, + options.editPostId, + html, + authToken + ) + : await createWpComDraftPost( + siteId, + title, + html, + tags, + authToken + ); - const html = await renderTemplate( 'release.ejs', { - contributors, - title, - changes: { - ...changes, - schema: schemaChanges, - }, - displayVersion: currentVersion, - } ); - - Logger.endTask(); - - if ( isOutputOnly ) { - const tmpFile = join( - tmpdir(), - `release-${ currentVersion }.html` - ); - - await writeFile( tmpFile, html ); - - Logger.notice( `Output written to ${ tmpFile }` ); - } else { - Logger.startTask( 'Publishing draft release post' ); - - try { - const { URL } = await createWpComDraftPost( - DEVELOPER_WOOCOMMERCE_SITE_ID, - title, - html, - tags - ); - - Logger.notice( `Published draft release post at ${ URL }` ); - Logger.endTask(); - } catch ( error: unknown ) { - if ( error instanceof Error ) { - Logger.error( error.message ); - } + Logger.notice( `Published draft release post at ${ URL }` ); + Logger.endTask(); + } catch ( error: unknown ) { + if ( error instanceof Error ) { + Logger.error( error.message ); } } - } else { - throw new Error( - `Could not find previous version for ${ currentVersion }` - ); } } ); diff --git a/tools/release-posts/lib/draft-post.ts b/tools/release-posts/lib/draft-post.ts index 19940bf544b..ee298841264 100644 --- a/tools/release-posts/lib/draft-post.ts +++ b/tools/release-posts/lib/draft-post.ts @@ -5,10 +5,84 @@ import fetch from 'node-fetch'; import { Logger } from 'cli-core/src/logger'; /** - * Internal dependencies + * Fetch a post from WordPress.com + * + * @param {string} siteId - The site to fetch from. + * @param {string} postId - The id of the post to fetch. + * @param {string} authToken - WordPress.com auth token. + * @return {Promise} - A promise that resolves to the JSON API response. */ -import { getWordpressComAuthToken } from './oauth-helper'; -import { getEnvVar } from './environment'; +export const fetchWpComPost = async ( + siteId: string, + postId: string, + authToken: string +) => { + try { + const post = await fetch( + `https://public-api.wordpress.com/rest/v1.1/sites/${ siteId }/posts/${ postId }`, + { + headers: { + Authorization: `Bearer ${ authToken }`, + 'Content-Type': 'application/json', + }, + } + ); + + if ( post.status !== 200 ) { + const text = await post.text(); + throw new Error( `Error creating draft post: ${ text }` ); + } + + return post.json(); + } catch ( e: unknown ) { + if ( e instanceof Error ) { + Logger.error( e.message ); + } + } +}; + +/** + * Edit a post on wordpress.com + * + * @param {string} siteId - The site to post to. + * @param {string} postId - The post to edit. + * @param {string} postContent - Post content. + * @param {string} authToken - WordPress.com auth token. + * @return {Promise} - A promise that resolves to the JSON API response. + */ +export const editWpComPostContent = async ( + siteId: string, + postId: string, + postContent: string, + authToken: string +) => { + try { + const post = await fetch( + `https://public-api.wordpress.com/rest/v1.2/sites/${ siteId }/posts/${ postId }`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${ authToken }`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify( { + content: postContent, + } ), + } + ); + + if ( post.status !== 200 ) { + const text = await post.text(); + throw new Error( `Error creating draft post: ${ text }` ); + } + + return post.json(); + } catch ( e: unknown ) { + if ( e instanceof Error ) { + Logger.error( e.message ); + } + } +}; /** * Create a draft of a post on wordpress.com @@ -16,35 +90,17 @@ import { getEnvVar } from './environment'; * @param {string} siteId - The site to post to. * @param {string} postTitle - Post title. * @param {string} postContent - Post content. + * @param {string} authToken - WordPress.com auth token. * @return {Promise} - A promise that resolves to the JSON API response. */ export const createWpComDraftPost = async ( siteId: string, postTitle: string, postContent: string, - tags: string[] + tags: string[], + authToken: string ) => { - 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'; - try { - const authToken = await getWordpressComAuthToken( - clientId, - clientSecret, - siteId, - redirectUri, - 'posts' - ); - - if ( ! authToken ) { - throw new Error( - 'Error getting auth token, check your env settings are correct.' - ); - } - const post = await fetch( `https://public-api.wordpress.com/rest/v1.2/sites/${ siteId }/posts/new`, { diff --git a/tools/release-posts/lib/edit-post.ts b/tools/release-posts/lib/edit-post.ts new file mode 100644 index 00000000000..b1b647b9a4c --- /dev/null +++ b/tools/release-posts/lib/edit-post.ts @@ -0,0 +1,13 @@ + +export type EditPostVariables = { + hooks?: string; + database?: string; + templates?: string; + contributors?: string; +}; + +export const editPostHTML = ( postContent: string, postVariables: EditPostVariables ) => { + return postContent.replaceAll( /.*?/gm, ( match, key: string ) => { + return `${ postVariables[ key as keyof EditPostVariables ] || '' }`; + } ); +}; diff --git a/tools/release-posts/templates/release.ejs b/tools/release-posts/templates/release.ejs index 00621bf1c3f..5559e2096ff 100644 --- a/tools/release-posts/templates/release.ejs +++ b/tools/release-posts/templates/release.ejs @@ -49,9 +49,9 @@ -<%- include( 'hooks' ) %> -<%- include( 'database') %> -<%- include( 'templates' ) %> +<%- include( 'hooks' ) %> +<%- include( 'database' ) %> +<%- include( 'templates' ) %>

Deprecations

@@ -60,5 +60,5 @@

There are no deprecations in this release.

-<%- include('contributors') %> +<%- include('contributors') %> diff --git a/tools/release-posts/templates/templates.ejs b/tools/release-posts/templates/templates.ejs index e5d1bd5019b..b03b96ed8b7 100644 --- a/tools/release-posts/templates/templates.ejs +++ b/tools/release-posts/templates/templates.ejs @@ -10,6 +10,9 @@ Template + + GitHub Link + @@ -17,6 +20,11 @@ <% changes.templates.forEach((change) => { %> <%= change.filePath %> + + <% change.pullRequests.forEach( ( pullRequest ) => { %> + #<%= pullRequest %> + <% }) %> + <% }) %>