Skip to content
Closed
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
183 changes: 183 additions & 0 deletions .github/workflows/auto-label-milestone.yml
Original file line number Diff line number Diff line change
@@ -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<string, string>}
*/
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<string, {label: string, lines: number}>}
*/
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,
} );
151 changes: 141 additions & 10 deletions bin/plugin/commands/changelog.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>}
*/
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',
Expand All @@ -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 = [
Expand All @@ -51,6 +74,11 @@ exports.options = [
argname: '-t, --token <token>',
description: 'GitHub token',
},
{
argname: '-l, --plugin-label <pluginLabel>',
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.',
},
];

/**
Expand All @@ -59,25 +87,93 @@ 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<IssuesListForRepoResponseItem[]>} 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.
*
* @return {Promise<IssuesListForRepoResponseItem[]>} 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,
Expand All @@ -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 ];
}

/**
Expand Down