Skip to content

Commit

Permalink
cli: Ask to add changelogs for indirectly affected plugins (#39396)
Browse files Browse the repository at this point in the history
A change to, for example, `packages/backup` might also be of interest
for the `plugins/backup` changelog. But as things stand, if nothing in
the PR touches `plugins/backup` then no changelog entry will be created
for it unless the PR author thinks to add one manually.

With this PR, `jetpack changelog add` will now ask whether such
indirectly-affected plugins should have a changelog added so we have a
better chance of entries being created.
  • Loading branch information
anomiex authored Sep 16, 2024
1 parent ee3a5c5 commit ebad8da
Showing 1 changed file with 165 additions and 38 deletions.
203 changes: 165 additions & 38 deletions tools/cli/commands/changelog.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import process from 'process';
import { fileURLToPath } from 'url';
import chalk from 'chalk';
import enquirer from 'enquirer';
import { getDependencies, filterDeps } from '../helpers/dependencyAnalysis.js';
import { readComposerJson } from '../helpers/json.js';
import { normalizeProject } from '../helpers/normalizeArgv.js';
import { projectTypes, allProjects } from '../helpers/projectHelpers.js';
Expand Down Expand Up @@ -70,6 +71,16 @@ export function changelogDefine( yargs ) {
alias: 'c',
describe: 'Changelog comment',
type: 'string',
} )
.option( 'check-indirect-plugins', {
describe:
'Always ask whether affected plugins without direct changes should have change entries added.',
type: 'boolean',
} )
.option( 'no-check-indirect-plugins', {
describe:
'Never ask whether affected plugins without direct changes should have change entries added.',
type: 'boolean',
} );
},
async argv => {
Expand Down Expand Up @@ -300,34 +311,120 @@ async function changelogAdd( argv ) {
return;
}

let needChangelog;
const defaultProjects = [];
const uniqueProjects = [];
const defaultTypes = {
security: 'Improves or modifies the security of the project.',
added: 'Added new functionality.',
changed: 'Changed existing functionality.',
deprecated: 'Deprecated existing functionality.',
removed: 'Removed existing functionality.',
fixed: 'Fixed a bug.',
};
let changelogInfo, needChangelog;

if ( argv.project ) {
needChangelog = [ argv.project ];
argv.checkIndirectPlugins = false;
}

// If we weren't passed a project, check if any projects need changelogs.
if ( argv._[ 1 ] === 'add' && ! argv.project ) {
needChangelog = await checkChangelogFiles();
if ( needChangelog.length === 0 ) {
changelogInfo = await checkChangelogFiles();
needChangelog = changelogInfo.need;
}
if ( needChangelog.length === 0 ) {
console.log(
chalk.green(
// prettier-ignore
`Did not detect a touched project that still needs a changelog. You can still add a changelog manually by running \`${ chalk.cyan( 'jetpack changelog add <type/project>' ) }\``
)
);
} else if ( ! ( await doAddChangelogs( argv, needChangelog, changelogInfo?.files ) ) ) {
return;
}

if ( argv.checkIndirectPlugins ?? needChangelog.length > 0 ) {
changelogInfo ??= await checkChangelogFiles();
await addIndirectPlugins(
argv,
argv.checkIndirectPlugins ? changelogInfo.touched : needChangelog,
changelogInfo.files
);
}
}

/**
* Run the changelog add wizard, which checks if multiple projects need changelogs.
*
* @param {argv} argv - the arguments passed.
* @param {string[]} directProjects - Directly-affected projects.
* @param {Map<string,string[]>} changelogFiles - Detected change entry files.
*/
async function addIndirectPlugins( argv, directProjects, changelogFiles ) {
const indirectPlugins = [];

const deps = filterDeps( await getDependencies( process.cwd(), 'build', true ), directProjects, {
dependents: true,
} );
projloop: for ( const proj of deps.keys() ) {
if ( ! proj.startsWith( 'plugins/' ) ) {
continue;
}

// If it has a changelog file other than "Updated composer.lock" or "Updated dependencies", skip it.
// Either it's directly affected or we added an entry in a previous run.
if ( changelogFiles.has( proj ) ) {
for ( const file of changelogFiles.get( proj ) ) {
const contents = fs.readFileSync( file, 'utf-8' );
if (
! contents.match( /^Comment: Updated composer\.lock\.$/m ) ||
contents.match( /\r?\n\r?\n(?!Updated package dependencies\.[\r\n]*$)\s*\S/ )
) {
continue projloop;
}
}
}

indirectPlugins.push( proj );
}

if ( indirectPlugins.length > 0 ) {
console.log( '' );
console.log(
chalk.bold( 'The following plugins are indirectly affected by this commit:' ),
indirectPlugins.join( ' ' )
);
const userFacingResponse = await enquirer.prompt( {
type: 'confirm',
name: 'userFacing',
message:
'Is this change something an end user or site administrator of a site using any of those plugins would like to know about?',
initial: true,
} );
if ( userFacingResponse.userFacing ) {
argv.project = undefined; // so doAddChangelogs doesn't assume separate changelogs.
await doAddChangelogs( argv, indirectPlugins, changelogFiles );
} else {
console.log(
chalk.green(
'Did not detect a touched project that still needs a changelog. You can still add a changelog manually by running `jetpack changelog add <type/project>'
// prettier-ignore
`If you change your mind, run \`${ chalk.cyan( 'jetpack changelog add --check-indirect-plugins' ) }\` to ask again.`
)
);
return;
}
}
}

/**
* Do the prompting for a set of projects.
*
* @param {object} argv - Args.
* @param {string[]} needChangelog - Projects needing a changelog entry.
* @param {Map<string,string[]>|undefined} changelogFiles - If defined, record added files into this map.
* @return {boolean} Whether changelogs were successfully added.
*/
async function doAddChangelogs( argv, needChangelog, changelogFiles ) {
const defaultProjects = [];
const uniqueProjects = [];
const defaultTypes = {
security: 'Improves or modifies the security of the project.',
added: 'Added new functionality.',
changed: 'Changed existing functionality.',
deprecated: 'Deprecated existing functionality.',
removed: 'Removed existing functionality.',
fixed: 'Fixed a bug.',
};

const projectChangeTypes = await getProjectChangeTypes( needChangelog );

Expand All @@ -344,11 +441,11 @@ async function changelogAdd( argv ) {
// Confirm what projects we're adding a changelog to, and how we want to add them.
const promptConfirm = argv.project
? { separateChangelogFiles: true }
: await changelogAddPrompt( argv, defaultProjects, uniqueProjects );
: await changelogAddPrompt( defaultProjects, uniqueProjects );

if ( ! promptConfirm ) {
console.log( 'Changelog command cancelled' );
return;
return false;
}

if ( promptConfirm.separateChangelogFiles ) {
Expand All @@ -367,6 +464,14 @@ async function changelogAdd( argv ) {
for ( const proj of defaultProjects ) {
argv = await formatAutoArgs( proj, argv, response );
await changelogArgs( argv );
if ( changelogFiles ) {
if ( ! changelogFiles.has( proj ) ) {
changelogFiles.set( proj, [] );
}
changelogFiles
.get( proj )
.push( `projects/${ proj }/changelog/${ response.changelogName }` );
}
}
}

Expand All @@ -375,7 +480,15 @@ async function changelogAdd( argv ) {
const response = await promptChangelog( argv, [ proj ], projectChangeTypes[ proj ] );
argv = await formatAutoArgs( proj, argv, response );
await changelogArgs( argv );
if ( changelogFiles ) {
if ( ! changelogFiles.has( proj ) ) {
changelogFiles.set( proj, [] );
}
changelogFiles.get( proj ).push( `projects/${ proj }/changelog/${ response.changelogName }` );
}
}

return true;
}

/**
Expand Down Expand Up @@ -412,7 +525,12 @@ async function changelogArgs( argv ) {
} completed succesfully!`;
argv.error = `Command '${ argv.cmd || argv._[ 1 ] }' for ${ argv.project } has failed! See error`;
argv.args = [ argv.cmd || argv._[ 1 ], ...process.argv.slice( 4 ) ];
const removeArg = [ argv.project, ...projectTypes ];
const removeArg = [
argv.project,
...projectTypes,
'--check-indirect-plugins',
'--no-check-indirect-plugins',
];
let file;

if ( argv.auto ) {
Expand Down Expand Up @@ -563,7 +681,10 @@ async function gitAdd( argv ) {
/**
* Checks if changelog files are required.
*
* @return {Array} matchedProjects - projects that need a changelog.
* @return {object} as follows:
* - {string[]} touched - Touched projects.
* - {Map<string,string[]>} files - Change files by project.
* - {string[]} need - Projects needing changelogs.
*/
async function checkChangelogFiles() {
console.log( chalk.green( 'Checking if changelog files are needed. Just a sec...' ) );
Expand All @@ -577,9 +698,10 @@ async function checkChangelogFiles() {
return [];
}

const re = /^projects\/([^/]+\/[^/]+)\//; // regex matches project file path, ie 'project/packages/connection/..'
const modifiedProjects = new Set();
const changelogsAdded = new Set();
const re1 = /^projects\/([^/]+\/[^/]+)\//; // regex matches project file path, ie 'project/packages/connection/..'
const re2 = /^projects\/([^/]+\/[^/]+)\/changelog\/[^.]/; // regex matches project changelog dir path, ie 'project/packages/connection/changelog/..'
const touchedProjects = new Set();
const changelogFiles = new Map();
let touchedFiles = child_process.spawnSync( 'git', [
'-c',
'core.quotepath=off',
Expand All @@ -591,23 +713,29 @@ async function checkChangelogFiles() {
] );
touchedFiles = touchedFiles.stdout.toString().trim().split( '\n' );

// Check for any existing changelog files.
// Check for touched projects and change files.
for ( const file of touchedFiles ) {
const match = file.match( /^projects\/([^/]+\/[^/]+)\/changelog\/[^.]/ );
if ( match ) {
changelogsAdded.add( match[ 1 ] );
const match1 = file.match( re1 );
if ( match1 ) {
touchedProjects.add( match1[ 1 ] );
}
}

// Check for any touched projects without a changelog.
for ( const file of touchedFiles ) {
const match = file.match( re );
if ( match && ! changelogsAdded.has( match[ 1 ] ) ) {
modifiedProjects.add( match[ 1 ] );
const match2 = file.match( re2 );
if ( match2 ) {
if ( ! changelogFiles.has( match2[ 1 ] ) ) {
changelogFiles.set( match2[ 1 ], [] );
}
changelogFiles.get( match2[ 1 ] ).push( file );
}
}

return allProjects().filter( proj => modifiedProjects.has( proj ) );
return {
touched: allProjects().filter( proj => touchedProjects.has( proj ) ),
files: changelogFiles,
need: allProjects().filter(
proj => touchedProjects.has( proj ) && ! changelogFiles.has( proj )
),
};
}

/**
Expand Down Expand Up @@ -861,12 +989,11 @@ async function promptChangelog( argv, needChangelog, types ) {
/**
* Prompts you for how you want changelogger to run (add to all projects or not, etc).
*
* @param {object} argv - the arguments passed.
* @param {Array} defaultProjects - projects that use the default changelog types.
* @param {Array} uniqueProjects - projects with unique changelog types.
* @return {argv}.
* @param {Array} defaultProjects - projects that use the default changelog types.
* @param {Array} uniqueProjects - projects with unique changelog types.
* @return {object} Responses.
*/
async function changelogAddPrompt( argv, defaultProjects, uniqueProjects ) {
async function changelogAddPrompt( defaultProjects, uniqueProjects ) {
const totalProjects = [ ...defaultProjects, ...uniqueProjects ];
let prompts;

Expand Down

0 comments on commit ebad8da

Please sign in to comment.