Add PR links to release post template output, and allow blog ID to be specified. (#36026)

* Update release template to be editable

* Correct issues in README

* Update to allow edits; refactor auth

* Update templates to add PR link

* Remove commented test code

* Update tools/code-analyzer/src/lib/scan-changes.ts

Co-authored-by: Sam Seay <samueljseay@gmail.com>

* Address typescript issues; prettier

* Resolve more typescript issues

Co-authored-by: Sam Seay <samueljseay@gmail.com>
This commit is contained in:
jonathansadowski 2023-01-23 08:31:59 -06:00 committed by GitHub
parent d5a679c3f2
commit f8d8a42fd7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 472 additions and 128 deletions

View File

@ -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.
*

View File

@ -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...' );

View File

@ -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;
}
changes.set( filePath, { code, message, filePath } );
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, pullRequests } );
}
return changes;

View File

@ -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`

View File

@ -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,64 +40,143 @@ const program = new Command()
.description( 'CLI to automate generation of a release post.' )
.argument(
'<currentVersion>',
'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(
'<previousVersion>',
'The previous version in x.y.z format. Ex: 7.0.0'
)
.option( '--outputOnly', 'Only output the post, do not publish it' )
.option(
'--previousVersion <previousVersion>',
'If you would like to compare against a version other than last minor you can provide a tag version from Github.'
)
.option( '--editPostId <postId>', 'Updates an existing post' )
.option(
'--tags <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() );
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 ( previousVersion && previousVersion.major ) {
.option(
'--siteId <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;
if ( ! VERSION_VALIDATION_REGEX.test( previousVersion.raw ) ) {
throw new Error(
`Invalid previous version: ${ previousVersion.raw }`
);
}
if ( ! VERSION_VALIDATION_REGEX.test( currentVersion ) ) {
throw new Error(
`Invalid current version: ${ currentVersion }`
`Invalid current version: ${ currentVersion }. Provide current version in x.y.z or x.y.z-stage.n format.`
);
}
if ( ! VERSION_VALIDATION_REGEX.test( previousVersion ) ) {
throw new Error(
`Invalid previous version: ${ previousVersion }. Provide previous version in x.y.z format.`
);
}
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(
`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'
currentVersionRef,
`${ previousParsed.major }.${ previousParsed.minor }.${ previousParsed.patch }`,
//false,
true,
SOURCE_REPO,
previousVersionRef,
'cli',
tmpRepoPath
);
const schemaChanges = changes.schema.filter(
( s ) => ! s.areEqual
);
const schemaChanges = changes.schema.filter( ( s ) => ! s.areEqual );
Logger.startTask( 'Finding contributors' );
const title = `WooCommerce ${ currentVersion } Released`;
@ -97,7 +186,7 @@ const program = new Command()
previousVersion.toString()
);
const html = await renderTemplate( 'release.ejs', {
const postVariables = {
contributors,
title,
changes: {
@ -105,7 +194,29 @@ const program = new Command()
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();
@ -122,11 +233,20 @@ const program = new Command()
Logger.startTask( 'Publishing draft release post' );
try {
const { URL } = await createWpComDraftPost(
DEVELOPER_WOOCOMMERCE_SITE_ID,
const { URL } =
'undefined' !== typeof options.editPostId
? await editWpComPostContent(
siteId,
options.editPostId,
html,
authToken
)
: await createWpComDraftPost(
siteId,
title,
html,
tags
tags,
authToken
);
Logger.notice( `Published draft release post at ${ URL }` );
@ -137,11 +257,6 @@ const program = new Command()
}
}
}
} else {
throw new Error(
`Could not find previous version for ${ currentVersion }`
);
}
} );
program.parse( process.argv );

View File

@ -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`,
{

View File

@ -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( /<!-- release:([a-z]+) -->.*?<!-- \/release:\1 -->/gm, ( match, key: string ) => {
return `<!-- release:${ key } -->${ postVariables[ key as keyof EditPostVariables ] || '' }<!-- /release:${ key } -->`;
} );
};

View File

@ -49,9 +49,9 @@
<!-- /wp:paragraph -->
<%- include( 'hooks' ) %>
<%- include( 'database') %>
<%- include( 'templates' ) %>
<!-- release:hooks --><%- include( 'hooks' ) %><!-- /release:hooks -->
<!-- release:database --><%- include( 'database' ) %><!-- /release:database -->
<!-- release:templates --><%- include( 'templates' ) %><!-- /release:templates -->
<!-- wp:heading -->
<h2 id="deprecations">Deprecations</h2>
@ -60,5 +60,5 @@
<p>There are no deprecations in this release.</p>
<!-- /wp:paragraph -->
<%- include('contributors') %>
<!-- release:contributors--><%- include('contributors') %><!-- /release:contributors -->

View File

@ -10,6 +10,9 @@
<th>
<strong>Template</strong>
</th>
<th>
<strong>GitHub Link</strong>
</th>
</thead>
<tbody>
@ -17,6 +20,11 @@
<% changes.templates.forEach((change) => { %>
<tr>
<td><%= change.filePath %></td>
<td>
<% change.pullRequests.forEach( ( pullRequest ) => { %>
<a href="https://github.com/woocommerce/woocommerce/pull/<%= pullRequest %>">#<%= pullRequest %></a>
<% }) %>
</td>
</tr>
<% }) %>
</tbody>