From 4f11ba03ba7828bc1975ac5eddba95cc205dddc0 Mon Sep 17 00:00:00 2001 From: zerlinpi <105773292+zerlinpi@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:08:32 +0800 Subject: [PATCH] Add automatic plugin milestone assignment --- .github/workflows/add-pr-labels.yml | 10 + .github/workflows/js-lint.yml | 2 + bin/plugin/lib/assign-plugin-milestone.js | 240 ++++++++++++++++++ .../lib/assign-plugin-milestone.test.js | 113 +++++++++ package.json | 1 + 5 files changed, 366 insertions(+) create mode 100644 bin/plugin/lib/assign-plugin-milestone.js create mode 100644 bin/plugin/lib/assign-plugin-milestone.test.js diff --git a/.github/workflows/add-pr-labels.yml b/.github/workflows/add-pr-labels.yml index 1138f77e83..140a7ed66b 100644 --- a/.github/workflows/add-pr-labels.yml +++ b/.github/workflows/add-pr-labels.yml @@ -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 }); diff --git a/.github/workflows/js-lint.yml b/.github/workflows/js-lint.yml index 89dd8a595b..dfe10e699e 100644 --- a/.github/workflows/js-lint.yml +++ b/.github/workflows/js-lint.yml @@ -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 diff --git a/bin/plugin/lib/assign-plugin-milestone.js b/bin/plugin/lib/assign-plugin-milestone.js new file mode 100644 index 0000000000..3b8b38f087 --- /dev/null +++ b/bin/plugin/lib/assign-plugin-milestone.js @@ -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} Changed line totals keyed by plugin slug. + */ +function countPluginLineChanges( files, pluginSlugs = plugins ) { + /** @type {Record} */ + 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} 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} 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, +}; diff --git a/bin/plugin/lib/assign-plugin-milestone.test.js b/bin/plugin/lib/assign-plugin-milestone.test.js new file mode 100644 index 0000000000..e7f60e52e6 --- /dev/null +++ b/bin/plugin/lib/assign-plugin-milestone.test.js @@ -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 + ); +} ); diff --git a/package.json b/package.json index 48fae558a7..83bf8d664b 100644 --- a/package.json +++ b/package.json @@ -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",