diff --git a/.github/workflows/auto-label-milestone.yml b/.github/workflows/auto-label-milestone.yml new file mode 100644 index 0000000000..66d7768937 --- /dev/null +++ b/.github/workflows/auto-label-milestone.yml @@ -0,0 +1,183 @@ +--- +name: Auto Label and Assign Milestone + +on: + # pull_request_target is used instead of pull_request so that the workflow has write + # permissions to add labels and assign milestones even on PRs from forks. + # No code from the PR is checked out, so this is safe. + pull_request_target: + types: + - opened + - synchronize + - reopened + branches: + - trunk + - 'release/**' + - 'feature/**' + +# Cancels all previous workflow runs for pull requests that have not completed. +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref }} + cancel-in-progress: true + +# Disable permissions for all available scopes by default. +# Any needed permissions should be configured at the job level. +permissions: {} + +jobs: + auto-label-milestone: + name: Auto Label and Assign Milestone + runs-on: ubuntu-latest + permissions: + # Needed to add/remove labels and set the milestone on pull requests. + pull-requests: write + # Needed to query issues/milestones. + issues: write + contents: read + timeout-minutes: 10 + steps: + - name: Synchronize plugin labels and assign dominant plugin milestone + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + /** + * Mapping from plugin folder slug to its GitHub label. + * Plugin names are derived from the "Plugin Name" header in each plugin's main PHP file. + * + * @type {Object} + */ + const PLUGIN_LABEL_MAP = { + 'auto-sizes': '[Plugin] Enhanced Responsive Images', + 'dominant-color-images': '[Plugin] Image Placeholders', + 'embed-optimizer': '[Plugin] Embed Optimizer', + 'image-prioritizer': '[Plugin] Image Prioritizer', + 'optimization-detective': '[Plugin] Optimization Detective', + 'performance-lab': '[Plugin] Performance Lab', + 'speculation-rules': '[Plugin] Speculative Loading', + 'view-transitions': '[Plugin] View Transitions', + 'web-worker-offloading': '[Plugin] Web Worker Offloading', + 'webp-uploads': '[Plugin] Modern Image Formats', + }; + + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request.number; + + // Retrieve all files changed in this PR (handles pagination). + const changedFiles = await github.paginate( + github.rest.pulls.listFiles, + { owner, repo, pull_number: prNumber, per_page: 100 } + ); + + /** + * Accumulate changed lines (additions + deletions) per plugin slug. + * + * @type {Object} + */ + const pluginChanges = {}; + for ( const file of changedFiles ) { + for ( const [ slug, label ] of Object.entries( PLUGIN_LABEL_MAP ) ) { + if ( file.filename.startsWith( `plugins/${ slug }/` ) ) { + if ( ! pluginChanges[ slug ] ) { + pluginChanges[ slug ] = { label, lines: 0 }; + } + pluginChanges[ slug ].lines += file.additions + file.deletions; + } + } + } + + // Fetch the current state of the pull request to get existing labels. + const { data: pullRequest } = await github.rest.pulls.get( { + owner, + repo, + pull_number: prNumber, + } ); + + const currentLabels = pullRequest.labels.map( ( l ) => l.name ); + + // Determine which plugin labels should be applied. + const desiredPluginLabels = new Set( + Object.values( pluginChanges ).map( ( p ) => p.label ) + ); + + // Collect all known plugin labels so we can remove stale ones. + const allPluginLabels = new Set( Object.values( PLUGIN_LABEL_MAP ) ); + + // Labels to add: desired labels not yet on the PR. + const labelsToAdd = [ ...desiredPluginLabels ].filter( + ( label ) => ! currentLabels.includes( label ) + ); + + // Labels to remove: plugin labels currently on the PR that are no longer desired. + const labelsToRemove = currentLabels.filter( + ( label ) => allPluginLabels.has( label ) && ! desiredPluginLabels.has( label ) + ); + + // Apply label additions. + if ( labelsToAdd.length > 0 ) { + core.info( `Adding labels: ${ labelsToAdd.join( ', ' ) }` ); + await github.rest.issues.addLabels( { + owner, + repo, + issue_number: prNumber, + labels: labelsToAdd, + } ); + } + + // Apply label removals. + for ( const label of labelsToRemove ) { + core.info( `Removing stale label: ${ label }` ); + await github.rest.issues.removeLabel( { + owner, + repo, + issue_number: prNumber, + name: label, + } ); + } + + // Determine the dominant plugin: the one with the most changed lines. + let dominantSlug = null; + let maxLines = 0; + for ( const [ slug, { lines } ] of Object.entries( pluginChanges ) ) { + if ( lines > maxLines ) { + maxLines = lines; + dominantSlug = slug; + } + } + + if ( ! dominantSlug ) { + core.info( 'No plugin-specific changes detected; skipping milestone assignment.' ); + return; + } + + core.info( `Dominant plugin: ${ dominantSlug } (${ maxLines } changed lines)` ); + + // Find the open milestone whose title starts with the dominant plugin's slug. + // Convention: milestone titles follow the pattern "{plugin-slug} {version}" (e.g. "embed-optimizer n.e.x.t"). + const milestones = await github.paginate( + github.rest.issues.listMilestones, + { owner, repo, state: 'open', per_page: 100 } + ); + + const targetMilestone = milestones.find( ( m ) => + m.title.startsWith( `${ dominantSlug } ` ) + ); + + if ( ! targetMilestone ) { + core.info( `No open milestone found for plugin "${ dominantSlug }"; skipping milestone assignment.` ); + return; + } + + // Only update the milestone if it has changed. + if ( pullRequest.milestone && pullRequest.milestone.number === targetMilestone.number ) { + core.info( `Milestone "${ targetMilestone.title }" is already set; no update needed.` ); + return; + } + + core.info( `Assigning milestone: ${ targetMilestone.title }` ); + await github.rest.issues.update( { + owner, + repo, + issue_number: prNumber, + milestone: targetMilestone.number, + } ); diff --git a/bin/plugin/commands/changelog.js b/bin/plugin/commands/changelog.js index 5f6612e654..3d2f3fc698 100644 --- a/bin/plugin/commands/changelog.js +++ b/bin/plugin/commands/changelog.js @@ -15,6 +15,27 @@ const config = require( '../config' ); const MISSING_TYPE = 'MISSING_TYPE'; const TYPE_PREFIX = '[Type] '; + +/** + * Mapping from plugin folder slug to its GitHub label. + * Used to look up cross-plugin PRs when building a changelog. + * + * @since n.e.x.t + * + * @type {Object} + */ +const PLUGIN_SLUG_TO_LABEL_MAP = { + 'auto-sizes': '[Plugin] Enhanced Responsive Images', + 'dominant-color-images': '[Plugin] Image Placeholders', + 'embed-optimizer': '[Plugin] Embed Optimizer', + 'image-prioritizer': '[Plugin] Image Prioritizer', + 'optimization-detective': '[Plugin] Optimization Detective', + 'performance-lab': '[Plugin] Performance Lab', + 'speculation-rules': '[Plugin] Speculative Loading', + 'view-transitions': '[Plugin] View Transitions', + 'web-worker-offloading': '[Plugin] Web Worker Offloading', + 'webp-uploads': '[Plugin] Modern Image Formats', +}; const PRIMARY_TYPE_LABELS = { '[Type] Feature': 'Features', '[Type] Enhancement': 'Enhancements', @@ -29,17 +50,19 @@ const SKIP_CHANGELOG_LABEL = 'skip changelog'; /** * @typedef WPChangelogCommandOptions * - * @property {string} milestone Milestone title. - * @property {string=} token Optional personal access token. + * @property {string} milestone Milestone title. + * @property {string=} token Optional personal access token. + * @property {string=} pluginLabel Optional plugin label used to fetch cross-plugin PRs. */ /** * @typedef WPChangelogSettings * - * @property {string} owner Repository owner. - * @property {string} repo Repository name. - * @property {string} milestone Milestone title. - * @property {string=} token Optional personal access token. + * @property {string} owner Repository owner. + * @property {string} repo Repository name. + * @property {string} milestone Milestone title. + * @property {string=} token Optional personal access token. + * @property {string=} pluginLabel Optional plugin label used to fetch cross-plugin PRs. */ exports.options = [ @@ -51,6 +74,11 @@ exports.options = [ argname: '-t, --token ', description: 'GitHub token', }, + { + argname: '-l, --plugin-label ', + description: + 'Plugin label (e.g. "[Plugin] Embed Optimizer"). When provided, merged PRs carrying this label and assigned to any open milestone are also included in the changelog.', + }, ]; /** @@ -59,17 +87,85 @@ exports.options = [ * @param {WPChangelogCommandOptions} opt */ exports.handler = async ( opt ) => { + // If no plugin label was explicitly provided, try to derive it from the + // milestone title using the "plugin-slug version" convention. + let pluginLabel = opt.pluginLabel; + if ( ! pluginLabel ) { + const milestoneSlug = opt.milestone + ? opt.milestone.split( ' ' )[ 0 ] + : undefined; + pluginLabel = milestoneSlug + ? PLUGIN_SLUG_TO_LABEL_MAP[ milestoneSlug ] + : undefined; + } + await createChangelog( { owner: config.githubRepositoryOwner, repo: config.githubRepositoryName, milestone: opt.milestone, token: opt.token, + pluginLabel, } ); }; +/** + * Returns a promise resolving to merged pull requests that carry a given plugin + * label and are associated with any open milestone. This is used to include + * cross-plugin PRs in a changelog: a PR primarily assigned to Plugin A's + * milestone but also labeled for Plugin B will appear in Plugin B's changelog. + * + * @since n.e.x.t + * + * @param {GitHub} octokit GitHub REST client. + * @param {string} owner Repository owner. + * @param {string} repo Repository name. + * @param {string} pluginLabel Plugin label to search for (e.g. "[Plugin] Embed Optimizer"). + * + * @return {Promise} Promise resolving to merged pull requests. + */ +async function fetchCrossPluginPullRequests( + octokit, + owner, + repo, + pluginLabel +) { + // Fetch all open milestones so we can check PR membership. + const milestonesResponse = await octokit.paginate( + octokit.issues.listMilestones, + { owner, repo, state: 'open' } + ); + + if ( ! milestonesResponse.length ) { + return []; + } + + const openMilestoneNumbers = new Set( + milestonesResponse.map( ( m ) => m.number ) + ); + + // Search for closed issues with the plugin label. + const labeledIssues = await octokit.paginate( octokit.issues.listForRepo, { + owner, + repo, + labels: pluginLabel, + state: 'closed', + } ); + + // Keep only merged pull requests that are associated with an open milestone. + return labeledIssues.filter( + ( issue ) => + issue.pull_request && + issue.pull_request.merged_at && + issue.milestone && + openMilestoneNumbers.has( issue.milestone.number ) + ); +} + /** * Returns a promise resolving to an array of merged pull requests associated with the - * changelog settings object. + * changelog settings object. When a plugin label is provided (or derivable from the + * milestone title), merged PRs carrying that label and assigned to any open milestone + * are also included so that cross-plugin PRs appear in every relevant changelog. * * @param {GitHub} octokit GitHub REST client. * @param {WPChangelogSettings} settings Changelog settings. @@ -77,7 +173,7 @@ exports.handler = async ( opt ) => { * @return {Promise} Promise resolving to array of pull requests. */ async function fetchAllPullRequests( octokit, settings ) { - const { owner, repo, milestone: milestoneTitle } = settings; + const { owner, repo, milestone: milestoneTitle, pluginLabel } = settings; const milestone = await getMilestoneByTitle( octokit, owner, @@ -99,10 +195,45 @@ async function fetchAllPullRequests( octokit, settings ) { 'closed' ); - // Return all merged pull requests. - return issues.filter( + // Primary pull requests: merged PRs in the specified milestone. + const primaryPRs = issues.filter( ( issue ) => issue.pull_request && issue.pull_request.merged_at ); + + if ( ! pluginLabel ) { + return primaryPRs; + } + + // Bonus: also include merged PRs with the plugin label that are assigned to + // a different open milestone (cross-plugin PRs in a monorepo). + const crossPluginPRs = await fetchCrossPluginPullRequests( + octokit, + owner, + repo, + pluginLabel + ); + + if ( ! crossPluginPRs.length ) { + return primaryPRs; + } + + // Merge and deduplicate by PR number, giving precedence to primary PRs. + const seen = new Set( primaryPRs.map( ( pr ) => pr.number ) ); + const extraPRs = crossPluginPRs.filter( ( pr ) => { + // Exclude PRs already covered by the primary milestone. + if ( seen.has( pr.number ) ) { + return false; + } + // Exclude PRs whose milestone is the same as the one we're generating + // the changelog for (they are already included via primaryPRs). + if ( pr.milestone && pr.milestone.number === milestone.number ) { + return false; + } + seen.add( pr.number ); + return true; + } ); + + return [ ...primaryPRs, ...extraPRs ]; } /**