Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/add-pr-labels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,18 @@ jobs:
contents: read
pull-requests: write
steps:
- name: Check out the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- name: Apply plugin labels from changed paths
uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.2.0
with:
configuration-path: .github/labeler-config.yml
sync-labels: true

- name: Assign the primary plugin milestone
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { assignPluginMilestone } = require('./bin/plugin/lib/assign-plugin-milestone');
await assignPluginMilestone({ github, context, core });
2 changes: 2 additions & 0 deletions .github/workflows/js-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ jobs:
run: npm ci
- name: JS Lint
run: npm run lint-js
- name: JS Unit Tests
run: npm run test-unit-js
- name: JSON Schema Validation
run: npm run lint-json
- name: TypeScript compile
Expand Down
240 changes: 240 additions & 0 deletions bin/plugin/lib/assign-plugin-milestone.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
/**
* Pull request milestone assignment helpers.
*
* @since n.e.x.t
*/

/**
* Internal dependencies
*/
const { plugins } = require( '../../../plugins.json' );

/** @typedef {import('@octokit/rest').Octokit} GitHub */

/**
* @typedef PullRequestFileStats
*
* @property {string} filename File path after the change.
* @property {number} additions Number of added lines.
* @property {number} deletions Number of deleted lines.
* @property {string=} previous_filename File path before a rename.
*/

/**
* @typedef MilestoneReference
*
* @property {number} number Milestone number.
* @property {string} title Milestone title.
*/

/**
* @typedef WorkflowContext
*
* @property {{ owner: string, repo: string }} repo Repository details.
* @property {{ pull_request?: { number: number, milestone?: MilestoneReference|null } }} payload Event payload.
*/

/**
* @typedef WorkflowLogger
*
* @property {(message: string) => void} info Logs an informational message.
* @property {(message: string) => void} warning Logs a warning message.
*/

/**
* Returns the plugin slug for a repository path.
*
* @since n.e.x.t
*
* @param {string} filePath Repository-relative file path.
* @param {string[]} pluginSlugs Known plugin slugs.
* @return {string|null} Plugin slug, or null when the path is not for a plugin.
*/
function getPluginSlugForPath( filePath, pluginSlugs = plugins ) {
const matches = filePath.match( /^plugins\/([^/]+)\// );
if ( ! matches || ! pluginSlugs.includes( matches[ 1 ] ) ) {
return null;
}

return matches[ 1 ];
}

/**
* Counts changed lines for each plugin represented in a pull request.
*
* For cross-plugin renames, additions belong to the destination plugin and
* deletions belong to the source plugin.
*
* @since n.e.x.t
*
* @param {PullRequestFileStats[]} files Pull request files.
* @param {string[]} pluginSlugs Known plugin slugs.
* @return {Record<string, number>} Changed line totals keyed by plugin slug.
*/
function countPluginLineChanges( files, pluginSlugs = plugins ) {
/** @type {Record<string, number>} */
const lineChanges = {};

for ( const file of files ) {
const pluginSlug = getPluginSlugForPath( file.filename, pluginSlugs );
const previousPluginSlug = file.previous_filename
? getPluginSlugForPath( file.previous_filename, pluginSlugs )
: null;

if ( file.previous_filename && pluginSlug !== previousPluginSlug ) {
if ( pluginSlug ) {
lineChanges[ pluginSlug ] =
( lineChanges[ pluginSlug ] || 0 ) + file.additions;
}
if ( previousPluginSlug ) {
lineChanges[ previousPluginSlug ] =
( lineChanges[ previousPluginSlug ] || 0 ) + file.deletions;
}
continue;
}

if ( pluginSlug ) {
lineChanges[ pluginSlug ] =
( lineChanges[ pluginSlug ] || 0 ) +
file.additions +
file.deletions;
}
}

return lineChanges;
}

/**
* Returns the plugin with the unique highest changed-line total.
*
* @since n.e.x.t
*
* @param {Record<string, number>} lineChanges Changed lines keyed by plugin slug.
* @return {string|null} Primary plugin slug, or null for no changes or a tie.
*/
function getPrimaryPluginSlug( lineChanges ) {
const rankedPlugins = Object.entries( lineChanges )
.filter( ( [ , changedLines ] ) => changedLines > 0 )
.sort(
( [ firstSlug, firstLines ], [ secondSlug, secondLines ] ) =>
secondLines - firstLines ||
firstSlug.localeCompare( secondSlug )
);

if (
! rankedPlugins.length ||
( rankedPlugins[ 1 ] &&
rankedPlugins[ 0 ][ 1 ] === rankedPlugins[ 1 ][ 1 ] )
) {
return null;
}

return rankedPlugins[ 0 ][ 0 ];
}

/**
* Selects the most appropriate open milestone for a plugin.
*
* The generic "n.e.x.t" milestone is preferred. A versioned milestone is
* selected only when it is the sole open candidate for the plugin.
*
* @since n.e.x.t
*
* @param {MilestoneReference[]} milestones Open repository milestones.
* @param {string} pluginSlug Plugin slug.
* @return {MilestoneReference|null} Selected milestone, or null when ambiguous.
*/
function selectPluginMilestone( milestones, pluginSlug ) {
const pluginMilestones = milestones.filter( ( milestone ) =>
milestone.title.startsWith( `${ pluginSlug } ` )
);
const nextMilestone = pluginMilestones.find(
( milestone ) => milestone.title === `${ pluginSlug } n.e.x.t`
);

if ( nextMilestone ) {
return nextMilestone;
}

return pluginMilestones.length === 1 ? pluginMilestones[ 0 ] : null;
}

/**
* Assigns a pull request to the milestone for its primary plugin.
*
* @since n.e.x.t
*
* @param {Object} options Assignment options.
* @param {GitHub} options.github Authenticated GitHub client.
* @param {WorkflowContext} options.context Workflow event context.
* @param {WorkflowLogger} options.core Workflow logger.
* @return {Promise<void>} Promise resolving when assignment is complete.
*/
async function assignPluginMilestone( { github, context, core } ) {
const pullRequest = context.payload.pull_request;
if ( ! pullRequest ) {
throw new Error(
'The workflow event does not contain a pull request.'
);
}

const files = await github.paginate( github.rest.pulls.listFiles, {
...context.repo,
pull_number: pullRequest.number,
per_page: 100,
} );
const lineChanges = countPluginLineChanges( files );
const pluginSlug = getPrimaryPluginSlug( lineChanges );

if ( ! pluginSlug ) {
core.info(
`No unique primary plugin found. Changed lines by plugin: ${ JSON.stringify(
lineChanges
) }`
);
return;
}

if ( pullRequest.milestone?.title.startsWith( `${ pluginSlug } ` ) ) {
core.info(
`Pull request is already assigned to ${ pullRequest.milestone.title }.`
);
return;
}

const milestones = await github.paginate(
github.rest.issues.listMilestones,
{
...context.repo,
state: 'open',
per_page: 100,
}
);
const milestone = selectPluginMilestone( milestones, pluginSlug );

if ( ! milestone ) {
core.warning(
`No unambiguous open milestone found for ${ pluginSlug }; leaving the milestone unchanged.`
);
return;
}

await github.rest.issues.update( {
...context.repo,
issue_number: pullRequest.number,
milestone: milestone.number,
} );
core.info(
`Assigned ${
milestone.title
} based on changed lines: ${ JSON.stringify( lineChanges ) }`
);
}

module.exports = {
assignPluginMilestone,
countPluginLineChanges,
getPluginSlugForPath,
getPrimaryPluginSlug,
selectPluginMilestone,
};
113 changes: 113 additions & 0 deletions bin/plugin/lib/assign-plugin-milestone.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* Tests for pull request milestone assignment helpers.
*
* @since n.e.x.t
*/

const assert = require( 'node:assert/strict' );
const { test } = require( 'node:test' );

const {
countPluginLineChanges,
getPluginSlugForPath,
getPrimaryPluginSlug,
selectPluginMilestone,
} = require( './assign-plugin-milestone' );

test( 'gets a known plugin slug from a repository path', () => {
assert.equal(
getPluginSlugForPath( 'plugins/auto-sizes/hooks.php' ),
'auto-sizes'
);
assert.equal( getPluginSlugForPath( 'plugins/unknown/hooks.php' ), null );
assert.equal( getPluginSlugForPath( 'README.md' ), null );
} );

test( 'counts additions and deletions by plugin', () => {
assert.deepEqual(
countPluginLineChanges( [
{
filename: 'plugins/auto-sizes/hooks.php',
additions: 4,
deletions: 2,
},
{
filename: 'plugins/auto-sizes/readme.txt',
additions: 1,
deletions: 1,
},
{
filename: 'plugins/embed-optimizer/load.php',
additions: 3,
deletions: 0,
},
{ filename: 'README.md', additions: 100, deletions: 100 },
] ),
{
'auto-sizes': 8,
'embed-optimizer': 3,
}
);
} );

test( 'splits a cross-plugin rename between its source and destination', () => {
assert.deepEqual(
countPluginLineChanges( [
{
filename: 'plugins/embed-optimizer/moved.php',
previous_filename: 'plugins/auto-sizes/moved.php',
additions: 5,
deletions: 7,
},
] ),
{
'auto-sizes': 7,
'embed-optimizer': 5,
}
);
} );

test( 'selects only a unique primary plugin', () => {
assert.equal(
getPrimaryPluginSlug( { 'auto-sizes': 8, 'embed-optimizer': 3 } ),
'auto-sizes'
);
assert.equal(
getPrimaryPluginSlug( { 'auto-sizes': 8, 'embed-optimizer': 8 } ),
null
);
assert.equal( getPrimaryPluginSlug( {} ), null );
} );

test( 'prefers the generic next milestone for a plugin', () => {
assert.deepEqual(
selectPluginMilestone(
[
{ number: 1, title: 'auto-sizes 3.0.0' },
{ number: 2, title: 'auto-sizes n.e.x.t' },
],
'auto-sizes'
),
{ number: 2, title: 'auto-sizes n.e.x.t' }
);
} );

test( 'selects a sole versioned milestone and rejects ambiguous versions', () => {
assert.deepEqual(
selectPluginMilestone(
[ { number: 1, title: 'optimization-detective 1.1.0' } ],
'optimization-detective'
),
{ number: 1, title: 'optimization-detective 1.1.0' }
);
assert.equal(
selectPluginMilestone(
[
{ number: 1, title: 'optimization-detective 1.0.0-beta6' },
{ number: 2, title: 'optimization-detective 1.1.0' },
],
'optimization-detective'
),
null
);
} );
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"test-e2e": "wp-scripts test-playwright --config tools/e2e/playwright.config.ts",
"test-e2e:debug": "wp-scripts test-playwright --config tools/e2e/playwright.config.ts --ui",
"test-e2e:auto-sizes": "wp-scripts test-playwright --config tools/e2e/playwright.config.ts --project=auto-sizes",
"test-unit-js": "node --test",
"lint-php": "composer lint:all",
"test-php": "wp-env --config=.wp-env.test.json run wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test:plugins",
"test-php-watch": "./bin/test-php-watch.sh",
Expand Down
Loading