Changelog CLI: Add command to make entry from PR description (#38357)

This commit is contained in:
Paul Sealock 2023-05-26 12:02:20 +12:00 committed by GitHub
parent 2f6ff0f3e6
commit 0cfbf0a653
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 1744 additions and 375 deletions

File diff suppressed because it is too large Load Diff

View File

@ -18,6 +18,7 @@
"commander": "^10.0.1",
"dotenv": "^10.0.0",
"figlet": "^1.6.0",
"glob": "^10.2.4",
"graphql": "^16.6.0",
"octokit": "^2.0.14",
"ora": "^5.4.1",

View File

@ -0,0 +1,5 @@
# Changelog CLI Utility
CLI for creating changelog entries from pull request details.
Usage: `pnpm utils changefile <pr-number>`

View File

@ -0,0 +1,196 @@
/**
* External dependencies
*/
import { Command } from '@commander-js/extra-typings';
import { execSync } from 'child_process';
import simpleGit from 'simple-git';
import nodePath from 'path';
import { existsSync } from 'fs';
/**
* Internal dependencies
*/
import { Logger } from '../core/logger';
import { isGithubCI } from '../core/environment';
import { cloneAuthenticatedRepo, checkoutRemoteBranch } from '../core/git';
import {
getPullRequestData,
shouldAutomateChangelog,
getChangelogDetails,
} from './lib/github';
import {
getAllProjectPaths,
getTouchedProjectsRequiringChangelog,
} from './lib/projects';
const program = new Command( 'changefile' )
.description( 'Changelog utilities' )
.option(
'-o --owner <owner>',
'Repository owner. Default: woocommerce',
'woocommerce'
)
.option(
'-n --name <name>',
'Repository name. Default: woocommerce',
'woocommerce'
)
.option(
'-d --dev-repo-path <devRepoPath>',
'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.'
)
.argument( '<pr-number>', 'Pull request number' )
.action(
async (
prNumber: string,
options: {
owner: string;
name: string;
devRepoPath?: string;
}
) => {
const { owner, name, devRepoPath } = options;
Logger.startTask(
`Getting pull request data for PR number ${ prNumber }`
);
const { prBody, headOwner, branch, fileName, head, base } =
await getPullRequestData( { owner, name }, prNumber );
Logger.endTask();
if ( ! shouldAutomateChangelog( prBody ) ) {
Logger.notice(
`PR #${ prNumber } does not have the "Automatically create a changelog entry from the details" checkbox checked. No changelog will be created.`
);
process.exit( 0 );
}
const { significance, type, message, comment } =
getChangelogDetails( prBody );
Logger.startTask(
`Making a temporary clone of '${ headOwner }/${ name }'`
);
const tmpRepoPath = devRepoPath
? devRepoPath
: await cloneAuthenticatedRepo(
{ owner: headOwner, name },
true
);
Logger.endTask();
Logger.notice(
`Temporary clone of '${ headOwner }/${ 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', {
cwd: tmpRepoPath,
stdio: 'inherit',
} );
}
Logger.notice( `Checking out remote branch ${ branch }` );
await checkoutRemoteBranch( tmpRepoPath, branch );
Logger.notice(
`Getting all touched projects requiring a changelog`
);
const touchedProjectsRequiringChangelog =
await getTouchedProjectsRequiringChangelog(
tmpRepoPath,
base,
head,
fileName
);
try {
const allProjectPaths = await getAllProjectPaths( tmpRepoPath );
// Remove any already existing changelog files in case a change is reverted and the entry is no longer needed.
allProjectPaths.forEach( ( projectPath ) => {
const path = nodePath.join(
tmpRepoPath,
projectPath,
'changelog',
fileName
);
if ( existsSync( path ) ) {
Logger.notice(
`Remove existing changelog file ${ path }`
);
execSync( `rm ${ path }`, {
cwd: tmpRepoPath,
stdio: 'inherit',
} );
}
} );
touchedProjectsRequiringChangelog.forEach( ( project ) => {
Logger.notice(
`Running changelog command for ${ project }`
);
const messageExpression = message
? `-e "${ message }"`
: '';
const commentExpression = comment
? `-c "${ comment }"`
: '';
const cmd = `pnpm --filter=${ project } run changelog add -f ${ fileName } -s ${ significance } -t ${ type } ${ messageExpression } ${ commentExpression } -n`;
execSync( cmd, { cwd: tmpRepoPath, stdio: 'inherit' } );
} );
} catch ( e ) {
Logger.error( e );
}
Logger.notice(
`Changelogs created for ${ touchedProjectsRequiringChangelog.join(
', '
) }`
);
const git = simpleGit( {
baseDir: tmpRepoPath,
config: [ 'core.hooksPath=/dev/null' ],
} );
if ( isGithubCI() ) {
await git.raw(
'config',
'--global',
'user.email',
'github-actions@github.com'
);
await git.raw(
'config',
'--global',
'user.name',
'github-actions'
);
}
const shortStatus = await git.raw( [ 'status', '--short' ] );
if ( shortStatus.length === 0 ) {
Logger.notice(
`No changes in changelog files. Skipping commit and push.`
);
process.exit( 0 );
}
Logger.notice( `Adding and committing changes` );
await git.add( '.' );
await git.commit( 'Adding changelog from automation.' );
await git.push( 'origin', branch );
Logger.notice( `Pushed changes to ${ branch }` );
}
);
export default program;

View File

@ -0,0 +1,401 @@
/**
* External dependencies
*/
import path from 'path';
/**
* Internal dependencies
*/
import {
getChangelogSignificance,
getChangelogType,
getChangelogDetails,
} from '../github';
import { Logger } from '../../../core/logger';
jest.mock( '../../../core/logger', () => {
return {
Logger: {
error: jest.fn(),
},
};
} );
describe( 'getChangelogSignificance', () => {
it( 'should return the selected significance', () => {
const body =
'### Changelog entry\r\n' +
'\r\n' +
'<!-- You can optionally choose to enter a changelog entry by checking the box and supplying data. -->\r\n' +
'\r\n' +
'- [x] Automatically create a changelog entry from the details below.\r\n' +
'\r\n' +
'<details>\r\n' +
'\r\n' +
'#### Significance\r\n' +
'<!-- Choose only one -->\r\n' +
'- [x] Patch\r\n' +
'- [ ] Minor\r\n' +
'- [ ] Major\r\n' +
'\r\n' +
'#### Type\r\n' +
'<!-- Choose only one -->\r\n' +
'- [x] Fix - Fixes an existing bug\r\n' +
'- [ ] Add - Adds functionality\r\n' +
'- [ ] Update - Update existing functionality\r\n' +
'- [ ] Dev - Development related task\r\n' +
'- [ ] Tweak - A minor adjustment to the codebase\r\n' +
'- [ ] Performance - Address performance issues\r\n' +
'- [ ] Enhancement\r\n' +
'\r\n' +
'#### Message\r\n' +
'<!-- Add a changelog message here -->\r\n' +
'This is a very useful fix.\r\n' +
'\r\n' +
'#### Comment\r\n' +
`<!-- If the changes in this pull request don't warrant a changelog entry, you can alternatively supply a comment here. Note that comments are only accepted with a significance of "Patch" -->\r\n` +
'\r\n' +
'</details>';
const significance = getChangelogSignificance( body );
expect( significance ).toBe( 'patch' );
} );
it( 'should error when no significance selected', () => {
const body =
'### Changelog entry\r\n' +
'\r\n' +
'<!-- You can optionally choose to enter a changelog entry by checking the box and supplying data. -->\r\n' +
'\r\n' +
'- [x] Automatically create a changelog entry from the details below.\r\n' +
'\r\n' +
'<details>\r\n' +
'\r\n' +
'#### Significance\r\n' +
'<!-- Choose only one -->\r\n' +
'- [ ] Patch\r\n' +
'- [ ] Minor\r\n' +
'- [ ] Major\r\n' +
'\r\n' +
'#### Type\r\n' +
'<!-- Choose only one -->\r\n' +
'- [x] Fix - Fixes an existing bug\r\n' +
'- [ ] Add - Adds functionality\r\n' +
'- [ ] Update - Update existing functionality\r\n' +
'- [ ] Dev - Development related task\r\n' +
'- [ ] Tweak - A minor adjustment to the codebase\r\n' +
'- [ ] Performance - Address performance issues\r\n' +
'- [ ] Enhancement\r\n' +
'\r\n' +
'#### Message\r\n' +
'<!-- Add a changelog message here -->\r\n' +
'This is a very useful fix.\r\n' +
'\r\n' +
'#### Comment\r\n' +
`<!-- If the changes in this pull request don't warrant a changelog entry, you can alternatively supply a comment here. Note that comments are only accepted with a significance of "Patch" -->\r\n` +
'\r\n' +
'</details>';
const significance = getChangelogSignificance( body );
expect( significance ).toBeUndefined();
expect( Logger.error ).toHaveBeenCalledWith(
'No changelog significance found'
);
} );
it( 'should error when more than one significance selected', () => {
const body =
'### Changelog entry\r\n' +
'\r\n' +
'<!-- You can optionally choose to enter a changelog entry by checking the box and supplying data. -->\r\n' +
'\r\n' +
'- [x] Automatically create a changelog entry from the details below.\r\n' +
'\r\n' +
'<details>\r\n' +
'\r\n' +
'#### Significance\r\n' +
'<!-- Choose only one -->\r\n' +
'- [x] Patch\r\n' +
'- [x] Minor\r\n' +
'- [ ] Major\r\n' +
'\r\n' +
'#### Type\r\n' +
'<!-- Choose only one -->\r\n' +
'- [x] Fix - Fixes an existing bug\r\n' +
'- [ ] Add - Adds functionality\r\n' +
'- [ ] Update - Update existing functionality\r\n' +
'- [ ] Dev - Development related task\r\n' +
'- [ ] Tweak - A minor adjustment to the codebase\r\n' +
'- [ ] Performance - Address performance issues\r\n' +
'- [ ] Enhancement\r\n' +
'\r\n' +
'#### Message\r\n' +
'<!-- Add a changelog message here -->\r\n' +
'This is a very useful fix.\r\n' +
'\r\n' +
'#### Comment\r\n' +
`<!-- If the changes in this pull request don't warrant a changelog entry, you can alternatively supply a comment here. Note that comments are only accepted with a significance of "Patch" -->\r\n` +
'\r\n' +
'</details>';
const significance = getChangelogSignificance( body );
expect( significance ).toBeUndefined();
expect( Logger.error ).toHaveBeenCalledWith(
'Multiple changelog significances found. Only one can be entered'
);
} );
} );
describe( 'getChangelogType', () => {
it( 'should return the selected changelog type', () => {
const body =
'### Changelog entry\r\n' +
'\r\n' +
'<!-- You can optionally choose to enter a changelog entry by checking the box and supplying data. -->\r\n' +
'\r\n' +
'- [x] Automatically create a changelog entry from the details below.\r\n' +
'\r\n' +
'<details>\r\n' +
'\r\n' +
'#### Significance\r\n' +
'<!-- Choose only one -->\r\n' +
'- [x] Patch\r\n' +
'- [ ] Minor\r\n' +
'- [ ] Major\r\n' +
'\r\n' +
'#### Type\r\n' +
'<!-- Choose only one -->\r\n' +
'- [x] Fix - Fixes an existing bug\r\n' +
'- [ ] Add - Adds functionality\r\n' +
'- [ ] Update - Update existing functionality\r\n' +
'- [ ] Dev - Development related task\r\n' +
'- [ ] Tweak - A minor adjustment to the codebase\r\n' +
'- [ ] Performance - Address performance issues\r\n' +
'- [ ] Enhancement\r\n' +
'\r\n' +
'#### Message\r\n' +
'<!-- Add a changelog message here -->\r\n' +
'This is a very useful fix.\r\n' +
'\r\n' +
'#### Comment\r\n' +
`<!-- If the changes in this pull request don't warrant a changelog entry, you can alternatively supply a comment here. Note that comments are only accepted with a significance of "Patch" -->\r\n` +
'\r\n' +
'</details>';
const type = getChangelogType( body );
expect( type ).toBe( 'fix' );
} );
it( 'should error when no type selected', () => {
const body =
'### Changelog entry\r\n' +
'\r\n' +
'<!-- You can optionally choose to enter a changelog entry by checking the box and supplying data. -->\r\n' +
'\r\n' +
'- [x] Automatically create a changelog entry from the details below.\r\n' +
'\r\n' +
'<details>\r\n' +
'\r\n' +
'#### Significance\r\n' +
'<!-- Choose only one -->\r\n' +
'- [x] Patch\r\n' +
'- [ ] Minor\r\n' +
'- [ ] Major\r\n' +
'\r\n' +
'#### Type\r\n' +
'<!-- Choose only one -->\r\n' +
'- [ ] Fix - Fixes an existing bug\r\n' +
'- [ ] Add - Adds functionality\r\n' +
'- [ ] Update - Update existing functionality\r\n' +
'- [ ] Dev - Development related task\r\n' +
'- [ ] Tweak - A minor adjustment to the codebase\r\n' +
'- [ ] Performance - Address performance issues\r\n' +
'- [ ] Enhancement\r\n' +
'\r\n' +
'#### Message\r\n' +
'<!-- Add a changelog message here -->\r\n' +
'This is a very useful fix.\r\n' +
'\r\n' +
'#### Comment\r\n' +
`<!-- If the changes in this pull request don't warrant a changelog entry, you can alternatively supply a comment here. Note that comments are only accepted with a significance of "Patch" -->\r\n` +
'\r\n' +
'</details>';
const type = getChangelogType( body );
expect( type ).toBeUndefined();
expect( Logger.error ).toHaveBeenCalledWith(
'No changelog type found'
);
} );
it( 'should error more than one type selected', () => {
const body =
'### Changelog entry\r\n' +
'\r\n' +
'<!-- You can optionally choose to enter a changelog entry by checking the box and supplying data. -->\r\n' +
'\r\n' +
'- [x] Automatically create a changelog entry from the details below.\r\n' +
'\r\n' +
'<details>\r\n' +
'\r\n' +
'#### Significance\r\n' +
'<!-- Choose only one -->\r\n' +
'- [x] Patch\r\n' +
'- [ ] Minor\r\n' +
'- [ ] Major\r\n' +
'\r\n' +
'#### Type\r\n' +
'<!-- Choose only one -->\r\n' +
'- [ ] Fix - Fixes an existing bug\r\n' +
'- [ ] Add - Adds functionality\r\n' +
'- [x] Update - Update existing functionality\r\n' +
'- [x] Dev - Development related task\r\n' +
'- [x] Tweak - A minor adjustment to the codebase\r\n' +
'- [ ] Performance - Address performance issues\r\n' +
'- [ ] Enhancement\r\n' +
'\r\n' +
'#### Message\r\n' +
'<!-- Add a changelog message here -->\r\n' +
'This is a very useful fix.\r\n' +
'\r\n' +
'#### Comment\r\n' +
`<!-- If the changes in this pull request don't warrant a changelog entry, you can alternatively supply a comment here. Note that comments are only accepted with a significance of "Patch" -->\r\n` +
'\r\n' +
'</details>';
const type = getChangelogType( body );
expect( type ).toBeUndefined();
expect( Logger.error ).toHaveBeenCalledWith(
'Multiple changelog types found. Only one can be entered'
);
} );
} );
describe( 'getChangelogDetails', () => {
it( 'should return the changelog details', () => {
const body =
'### Changelog entry\r\n' +
'\r\n' +
'<!-- You can optionally choose to enter a changelog entry by checking the box and supplying data. -->\r\n' +
'\r\n' +
'- [x] Automatically create a changelog entry from the details below.\r\n' +
'\r\n' +
'<details>\r\n' +
'\r\n' +
'#### Significance\r\n' +
'<!-- Choose only one -->\r\n' +
'- [x] Patch\r\n' +
'- [ ] Minor\r\n' +
'- [ ] Major\r\n' +
'\r\n' +
'#### Type\r\n' +
'<!-- Choose only one -->\r\n' +
'- [x] Fix - Fixes an existing bug\r\n' +
'- [ ] Add - Adds functionality\r\n' +
'- [ ] Update - Update existing functionality\r\n' +
'- [ ] Dev - Development related task\r\n' +
'- [ ] Tweak - A minor adjustment to the codebase\r\n' +
'- [ ] Performance - Address performance issues\r\n' +
'- [ ] Enhancement\r\n' +
'\r\n' +
'#### Message\r\n' +
'<!-- Add a changelog message here -->\r\n' +
'This is a very useful fix.\r\n' +
'\r\n' +
'#### Comment\r\n' +
`<!-- If the changes in this pull request don't warrant a changelog entry, you can alternatively supply a comment here. Note that comments are only accepted with a significance of "Patch" -->\r\n` +
'\r\n' +
'</details>';
const details = getChangelogDetails( body );
expect( details.significance ).toEqual( 'patch' );
expect( details.type ).toEqual( 'fix' );
expect( details.message ).toEqual( 'This is a very useful fix.' );
expect( details.comment ).toEqual( '' );
} );
it( 'should error if a comment and message are added', () => {
const body =
'### Changelog entry\r\n' +
'\r\n' +
'<!-- You can optionally choose to enter a changelog entry by checking the box and supplying data. -->\r\n' +
'\r\n' +
'- [x] Automatically create a changelog entry from the details below.\r\n' +
'\r\n' +
'<details>\r\n' +
'\r\n' +
'#### Significance\r\n' +
'<!-- Choose only one -->\r\n' +
'- [x] Patch\r\n' +
'- [ ] Minor\r\n' +
'- [ ] Major\r\n' +
'\r\n' +
'#### Type\r\n' +
'<!-- Choose only one -->\r\n' +
'- [x] Fix - Fixes an existing bug\r\n' +
'- [ ] Add - Adds functionality\r\n' +
'- [ ] Update - Update existing functionality\r\n' +
'- [ ] Dev - Development related task\r\n' +
'- [ ] Tweak - A minor adjustment to the codebase\r\n' +
'- [ ] Performance - Address performance issues\r\n' +
'- [ ] Enhancement\r\n' +
'\r\n' +
'#### Message\r\n' +
'<!-- Add a changelog message here -->\r\n' +
'This is a very useful fix.\r\n' +
'\r\n' +
'#### Comment\r\n' +
`<!-- If the changes in this pull request don't warrant a changelog entry, you can alternatively supply a comment here. Note that comments are only accepted with a significance of "Patch" -->\r\n` +
'This is a very useful comment.\r\n' +
'\r\n' +
'</details>';
const details = getChangelogDetails( body );
expect( details ).toBeUndefined();
expect( Logger.error ).toHaveBeenCalledWith(
'Both a message and comment were found. Only one can be entered'
);
} );
it( 'should error if a comment is entered with a significance other than patch', () => {
const body =
'### Changelog entry\r\n' +
'\r\n' +
'<!-- You can optionally choose to enter a changelog entry by checking the box and supplying data. -->\r\n' +
'\r\n' +
'- [x] Automatically create a changelog entry from the details below.\r\n' +
'\r\n' +
'<details>\r\n' +
'\r\n' +
'#### Significance\r\n' +
'<!-- Choose only one -->\r\n' +
'- [ ] Patch\r\n' +
'- [x] Minor\r\n' +
'- [ ] Major\r\n' +
'\r\n' +
'#### Type\r\n' +
'<!-- Choose only one -->\r\n' +
'- [x] Fix - Fixes an existing bug\r\n' +
'- [ ] Add - Adds functionality\r\n' +
'- [ ] Update - Update existing functionality\r\n' +
'- [ ] Dev - Development related task\r\n' +
'- [ ] Tweak - A minor adjustment to the codebase\r\n' +
'- [ ] Performance - Address performance issues\r\n' +
'- [ ] Enhancement\r\n' +
'\r\n' +
'#### Message\r\n' +
'<!-- Add a changelog message here -->\r\n' +
'\r\n' +
'#### Comment\r\n' +
`<!-- If the changes in this pull request don't warrant a changelog entry, you can alternatively supply a comment here. Note that comments are only accepted with a significance of "Patch" -->\r\n` +
'This is a very useful comment.\r\n' +
'\r\n' +
'</details>';
const details = getChangelogDetails( body );
expect( details ).toBeUndefined();
expect( Logger.error ).toHaveBeenCalledWith(
'Only patch changes can have a comment. Please change the significance to patch or remove the comment'
);
} );
} );

View File

@ -0,0 +1,138 @@
/**
* External dependencies
*/
import path from 'path';
/**
* Internal dependencies
*/
import {
getAllProjectsPathsFromWorkspace,
getChangeloggerProjectPaths,
getTouchedChangeloggerProjectsPathsMappedToProjects,
} from '../projects';
const sampleWorkspaceYaml = `
packages:
- 'folder-with-lots-of-projects/*'
- 'projects/cool-project'
- 'projects/very-cool-project'
- 'interesting-project'
`;
const tmpRepoPath = path.join( __dirname, 'test-repo' );
describe( 'Changelog project functions', () => {
it( 'getAllProjectsPathsFromWorkspace should provide a list of all projects supplied by pnpm-workspace.yml', async () => {
const projects = await getAllProjectsPathsFromWorkspace(
tmpRepoPath,
sampleWorkspaceYaml
);
const expectedProjects = [
'folder-with-lots-of-projects/project-b',
'folder-with-lots-of-projects/project-a',
'projects/cool-project',
'projects/very-cool-project',
'interesting-project',
];
expectedProjects.forEach( ( expectedProject ) => {
expect( projects ).toContain( expectedProject );
} );
expect( projects ).toHaveLength( expectedProjects.length );
} );
it( 'getChangeloggerProjectPaths should provide a list of all projects that use Jetpack changelogger', async () => {
const projects = await getAllProjectsPathsFromWorkspace(
tmpRepoPath,
sampleWorkspaceYaml
);
const changeloggerProjects = await getChangeloggerProjectPaths(
tmpRepoPath,
projects
);
const expectedChangeLoggerProjects = [
'folder-with-lots-of-projects/project-b',
'folder-with-lots-of-projects/project-a',
'projects/very-cool-project',
];
expectedChangeLoggerProjects.forEach(
( expectedChangeLoggerProject ) => {
expect( changeloggerProjects ).toContain(
expectedChangeLoggerProject
);
}
);
expect( changeloggerProjects ).toHaveLength(
expectedChangeLoggerProjects.length
);
} );
it( 'getTouchedChangeloggerProjectsPathsMappedToProjects should combine touched and changelogger projects and return a list that is a subset of both', async () => {
const touchedFiles = [
'folder-with-lots-of-projects/project-b/src/index.js',
'projects/very-cool-project/src/index.js',
];
const changeLoggerProjects = [
'folder-with-lots-of-projects/project-b',
'folder-with-lots-of-projects/project-a',
'projects/very-cool-project',
];
const intersectedProjects =
getTouchedChangeloggerProjectsPathsMappedToProjects(
touchedFiles,
changeLoggerProjects
);
expect( intersectedProjects ).toHaveLength( 2 );
expect( intersectedProjects ).toContain(
'folder-with-lots-of-projects/project-b'
);
expect( intersectedProjects ).toContain( 'projects/very-cool-project' );
} );
it( 'getTouchedChangeloggerProjectsPathsMappedToProjects should map plugins and js packages to the correct name', async () => {
const touchedFiles = [
'plugins/beta-tester/src/index.js',
'plugins/woocommerce/src/index.js',
'packages/js/components/src/index.js',
'packages/js/data/src/index.js',
];
const changeLoggerProjects = [
'plugins/woocommerce',
'plugins/beta-tester',
'packages/js/data',
'packages/js/components',
];
const intersectedProjects =
getTouchedChangeloggerProjectsPathsMappedToProjects(
touchedFiles,
changeLoggerProjects
);
expect( intersectedProjects ).toHaveLength( 4 );
expect( intersectedProjects ).toContain( 'woocommerce' );
expect( intersectedProjects ).toContain( 'beta-tester' );
expect( intersectedProjects ).toContain( '@woocommerce/components' );
expect( intersectedProjects ).toContain( '@woocommerce/data' );
} );
it( 'getTouchedChangeloggerProjectsPathsMappedToProjects should handle woocommerce-admin projects mapped to woocommerce core', async () => {
const touchedFiles = [
'plugins/beta-tester/src/index.js',
'plugins/woocommerce-admin/src/index.js',
];
const changeLoggerProjects = [ 'plugins/woocommerce' ];
const intersectedProjects =
getTouchedChangeloggerProjectsPathsMappedToProjects(
touchedFiles,
changeLoggerProjects
);
expect( intersectedProjects ).toHaveLength( 1 );
expect( intersectedProjects ).toContain( 'woocommerce' );
} );
} );

View File

@ -0,0 +1,5 @@
{
"require": {
"automattic/jetpack-changelogger": "99.99"
}
}

View File

@ -0,0 +1,5 @@
{
"require-dev": {
"automattic/jetpack-changelogger": "99.99"
}
}

View File

@ -0,0 +1,5 @@
{
"require": {
"automattic/jetpack-changelogger": "99.99"
}
}

View File

@ -0,0 +1,167 @@
/**
* Internal dependencies
*/
import { getPullRequest, isCommunityPullRequest } from '../../core/github/repo';
import { Logger } from '../../core/logger';
/**
* Get relevant data from a pull request.
*
* @param {Object} options
* @param {string} options.owner repository owner.
* @param {string} options.name repository name.
* @param {string} prNumber pull request number.
* @return {Promise<object>} pull request data.
*/
export const getPullRequestData = async (
options: { owner: string; name: string },
prNumber: string
) => {
const { owner, name } = options;
const prData = await getPullRequest( { owner, name, prNumber } );
const isCommunityPR = isCommunityPullRequest( prData, owner, name );
const headOwner = isCommunityPR ? prData.head.repo.owner.login : owner;
const branch = prData.head.ref;
const fileName = branch.replace( /\//g, '-' );
const prBody = prData.body;
const head = prData.head.sha;
const base = prData.base.sha;
return {
prBody,
isCommunityPR,
headOwner,
branch,
fileName,
head,
base,
};
};
/**
* Determine if a pull request description activates the changelog automation.
*
* @param {string} body pull request description.
* @return {boolean} if the pull request description activates the changelog automation.
*/
export const shouldAutomateChangelog = ( body: string ) => {
const regex =
/\[x\] Automatically create a changelog entry from the details/gm;
return regex.test( body );
};
/**
* Get the changelog significance from a pull request description.
*
* @param {string} body pull request description.
* @return {void|string} changelog significance.
*/
export const getChangelogSignificance = ( body: string ) => {
const regex = /\[x\] (Patch|Minor|Major)\r\n/gm;
const matches = body.match( regex );
if ( matches === null ) {
Logger.error( 'No changelog significance found' );
// Logger.error has a process.exit( 1 ) call, this return is purely for testing purposes.
return;
}
if ( matches.length > 1 ) {
Logger.error(
'Multiple changelog significances found. Only one can be entered'
);
// Logger.error has a process.exit( 1 ) call, this return is purely for testing purposes.
return;
}
const significance = regex.exec( body );
return significance[ 1 ].toLowerCase();
};
/**
* Get the changelog type from a pull request description.
*
* @param {string} body pull request description.
* @return {void|string} changelog type.
*/
export const getChangelogType = ( body: string ) => {
const regex =
/\[x\] (Fix|Add|Update|Dev|Tweak|Performance|Enhancement) -/gm;
const matches = body.match( regex );
if ( matches === null ) {
Logger.error( 'No changelog type found' );
// Logger.error has a process.exit( 1 ) call, this return is purely for testing purposes.
return;
}
if ( matches.length > 1 ) {
Logger.error(
'Multiple changelog types found. Only one can be entered'
);
// Logger.error has a process.exit( 1 ) call, this return is purely for testing purposes.
return;
}
const type = regex.exec( body );
return type[ 1 ].toLowerCase();
};
/**
* Get the changelog message from a pull request description.
*
* @param {string} body pull request description.
* @return {void|string} changelog message.
*/
export const getChangelogMessage = ( body: string ) => {
const messageRegex = /#### Message\r\n(<!--(.*)-->)?(.*)#### Comment/gms;
const match = messageRegex.exec( body );
if ( ! match ) {
Logger.error( 'No changelog message found' );
}
return match[ 3 ].trim();
};
/**
* Get the changelog comment from a pull request description.
*
* @param {string} body pull request description.
* @return {void|string} changelog comment.
*/
export const getChangelogComment = ( body: string ) => {
const commentRegex = /#### Comment\r\n(<!--(.*)-->)?(.*)<\/details>/gms;
const match = commentRegex.exec( body );
return match ? match[ 3 ].trim() : '';
};
export const getChangelogDetails = ( body: string ) => {
const message = getChangelogMessage( body );
const comment = getChangelogComment( body );
if ( comment && message ) {
Logger.error(
'Both a message and comment were found. Only one can be entered'
);
// Logger.error has a process.exit( 1 ) call, this return is purely for testing purposes.
return;
}
const significance = getChangelogSignificance( body );
if ( comment && significance !== 'patch' ) {
Logger.error(
'Only patch changes can have a comment. Please change the significance to patch or remove the comment'
);
// Logger.error has a process.exit( 1 ) call, this return is purely for testing purposes.
return;
}
return {
significance,
type: getChangelogType( body ),
message,
comment,
};
};

View File

@ -0,0 +1,192 @@
/**
* External dependencies
*/
import { existsSync, readFileSync } from 'fs';
import { readFile } from 'fs/promises';
import path from 'path';
import { glob } from 'glob';
import simpleGit from 'simple-git';
/**
* Get all projects listed in the workspace yaml file.
*
* @param {string} tmpRepoPath Path to the temporary repository.
* @param {string} workspaceYaml Contents of the workspace yaml file.
* @return {Array<string>} List of projects.
*/
export const getAllProjectsPathsFromWorkspace = async (
tmpRepoPath: string,
workspaceYaml: string
) => {
const rawProjects = workspaceYaml.split( '- ' );
// remove heading
rawProjects.shift();
const globbedProjects = await Promise.all(
rawProjects
.map( ( project ) => project.replace( /'/g, '' ).trim() )
.map( async ( project ) => {
if ( project.includes( '*' ) ) {
return await glob( project, { cwd: tmpRepoPath } );
}
return project;
} )
);
return globbedProjects.flat();
};
/**
* Get all projects that have Jetpack changelogger enabled
*
* @param {string} tmpRepoPath Path to the temporary repository.
* @param {Array<string>} projects all projects listed in the workspace yaml file
* @return {Array<string>} List of projects that have Jetpack changelogger enabled.
*/
export const getChangeloggerProjectPaths = async (
tmpRepoPath: string,
projects: Array< string >
) => {
const projectsWithComposer = projects.filter( ( project ) => {
return existsSync( `${ tmpRepoPath }/${ project }/composer.json` );
} );
return projectsWithComposer.filter( ( project ) => {
const composer = JSON.parse(
readFileSync(
`${ tmpRepoPath }/${ project }/composer.json`,
'utf8'
)
);
return (
( composer.require &&
composer.require[ 'automattic/jetpack-changelogger' ] ) ||
( composer[ 'require-dev' ] &&
composer[ 'require-dev' ][ 'automattic/jetpack-changelogger' ] )
);
} );
};
/**
* Get an array of all files changed in a PR.
*
* @param {string} tmpRepoPath Path to the temporary repository.
* @param {string} base base hash
* @param {string} head head hash
* @param {string} fileName changelog file name
* @return {Array<string>} List of files changed in a PR.
*/
export const getTouchedFilePaths = async (
tmpRepoPath: string,
base: string,
head: string,
fileName: string
) => {
const git = simpleGit( {
baseDir: tmpRepoPath,
config: [ 'core.hooksPath=/dev/null' ],
} );
// make sure base sha is available.
await git.raw( [
'remote',
'add',
'woocommerce',
'git@github.com:woocommerce/woocommerce.git',
] );
await git.raw( [ 'fetch', 'woocommerce', base ] );
const diff = await git.raw( [
'diff',
'--name-only',
`${ base }...${ head }`,
] );
return (
diff
.split( '\n' )
.filter( ( item ) => item.trim() )
// Don't count changelogs themselves as touched files.
.filter( ( item ) => ! item.includes( `/changelog/${ fileName }` ) )
);
};
/**
* Get an array of projects that have Jetpack changelogger enabled and have files changed in a PR. This function also maps names of projects that have been renamed in the monorepo from their paths.
*
* @param {Array<string>} touchedFiles List of files changed in a PR. touchedFiles
* @param {Array<string>} changeloggerProjects List of projects that have Jetpack changelogger enabled.
* @return {Array<string>} List of projects that have Jetpack changelogger enabled and have files changed in a PR.
*/
export const getTouchedChangeloggerProjectsPathsMappedToProjects = (
touchedFiles: Array< string >,
changeloggerProjects: Array< string >
) => {
const mappedTouchedFiles = touchedFiles.map( ( touchedProject ) => {
if ( touchedProject.includes( 'plugins/woocommerce-admin' ) ) {
return touchedProject.replace(
'plugins/woocommerce-admin',
'plugins/woocommerce'
);
}
return touchedProject;
} );
const touchedProjectPathsRequiringChangelog = changeloggerProjects.filter(
( project ) => {
return mappedTouchedFiles.some( ( touchedProject ) =>
touchedProject.includes( project + '/' )
);
}
);
return touchedProjectPathsRequiringChangelog.map( ( project ) => {
if ( project.includes( 'plugins/' ) ) {
return project.replace( 'plugins/', '' );
} else if ( project.includes( 'packages/js/' ) ) {
return project.replace( 'packages/js/', '@woocommerce/' );
}
return project;
} );
};
/**
* Get all projects listed in the workspace yaml file.
*
* @param {string} tmpRepoPath Path to the temporary repository.
* @return {Array<string>} List of projects.
*/
export const getAllProjectPaths = async ( tmpRepoPath: string ) => {
const workspaceYaml = await readFile(
path.join( tmpRepoPath, 'pnpm-workspace.yaml' ),
'utf8'
);
return await getAllProjectsPathsFromWorkspace( tmpRepoPath, workspaceYaml );
};
/**
* Get an array of projects that have Jetpack changelogger enabled and have files changed in a PR.
*
* @param {string} tmpRepoPath Path to the temporary repository.
* @param {string} base base hash
* @param {string} head head hash
* @param {string} fileName changelog file name
* @return {Array<string>} List of projects that have Jetpack changelogger enabled and have files changed in a PR.
*/
export const getTouchedProjectsRequiringChangelog = async (
tmpRepoPath: string,
base: string,
head: string,
fileName: string
) => {
const allProjectPaths = await getAllProjectPaths( tmpRepoPath );
const changeloggerProjectsPaths = await getChangeloggerProjectPaths(
tmpRepoPath,
allProjectPaths
);
const touchedFilePaths = await getTouchedFilePaths(
tmpRepoPath,
base,
head,
fileName
);
return getTouchedChangeloggerProjectsPathsMappedToProjects(
touchedFilePaths,
changeloggerProjectsPaths
);
};

View File

@ -7,7 +7,10 @@ import { Repository } from '@octokit/graphql-schema';
* Internal dependencies
*/
import { graphqlWithAuth, octokitWithAuth } from './api';
import { PullRequestEndpointResponse } from './types';
import {
CreatePullRequestEndpointResponse,
GetPullRequestEndpointResponse,
} from './types';
export const getLatestGithubReleaseVersion = async ( options: {
owner?: string;
@ -150,7 +153,7 @@ export const createPullRequest = async ( options: {
name: string;
title: string;
body: string;
} ): Promise< PullRequestEndpointResponse[ 'data' ] > => {
} ): Promise< CreatePullRequestEndpointResponse[ 'data' ] > => {
const { head, base, owner, name, title, body } = options;
const pullRequest = await octokitWithAuth().request(
'POST /repos/{owner}/{repo}/pulls',
@ -167,3 +170,52 @@ export const createPullRequest = async ( options: {
//@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;
};
/**
* Get a pull request from GitHub.
*
* @param {Object} options
* @param {string} options.owner repository owner.
* @param {string} options.name repository name.
* @param prNumber pull request number.
* @return {Promise<object>} pull request data.
*/
export const getPullRequest = async ( options: {
owner: string;
name: string;
prNumber: string;
} ): Promise< GetPullRequestEndpointResponse[ 'data' ] > => {
const { owner, name, prNumber } = options;
const pr = await octokitWithAuth().request(
'GET /repos/{owner}/{repo}/pulls/{pull_number}',
{
owner,
repo: name,
pull_number: Number( prNumber ),
}
);
//@ts-ignore Not sure why this error comes up. All versions are up to date and the schema is correct.
return pr.data;
};
/**
* Determine if a pull request is coming from a community contribution, i.e., not from a member of the WooCommerce organization.
*
* @param {Object} pullRequestData pull request data.
* @param {string} owner repository owner.
* @param {string} name repository name.
* @return {boolean} if a pull request is coming from a community contribution.
*/
export const isCommunityPullRequest = (
pullRequestData: GetPullRequestEndpointResponse[ 'data' ],
owner: string,
name: string
) => {
return (
pullRequestData.author_association === 'CONTRIBUTOR' ||
// It's possible a MEMBER can open a PR from their own fork.
( pullRequestData.author_association === 'MEMBER' &&
pullRequestData.head.repo.full_name !== `${ owner }/${ name }` )
);
};

View File

@ -3,5 +3,8 @@
*/
import { Endpoints } from '@octokit/types';
export type PullRequestEndpointResponse =
export type CreatePullRequestEndpointResponse =
Endpoints[ 'POST /repos/{owner}/{repo}/pulls' ][ 'response' ];
export type GetPullRequestEndpointResponse =
Endpoints[ 'GET /repos/{owner}/{repo}/pulls/{pull_number}' ][ 'response' ];

View File

@ -10,6 +10,7 @@ import chalk from 'chalk';
*/
import CodeFreeze from './code-freeze/commands';
import Slack from './slack/commands/slack';
import Changefile from './changefile';
import { Logger } from './core/logger';
import { isGithubCI } from './core/environment';
@ -25,7 +26,8 @@ const program = new Command()
.name( 'utils' )
.description( 'Monorepo utilities' )
.addCommand( CodeFreeze )
.addCommand( Slack );
.addCommand( Slack )
.addCommand( Changefile );
program.exitOverride();