Skip to content

Commit 1de6d0b

Browse files
authored
Merge pull request #51 from WordPress/add/41-changelog-infra
Add changelog generator script
2 parents f5be653 + 224ffc7 commit 1de6d0b

9 files changed

Lines changed: 14672 additions & 796 deletions

File tree

‎.eslintrc.js‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* External dependencies
3+
*/
4+
const wpConfig = require( '@wordpress/scripts/config/.eslintrc.js' );
5+
6+
const config = {
7+
...wpConfig,
8+
rules: {
9+
...( wpConfig?.rules || {} ),
10+
'jsdoc/valid-types': 'off',
11+
},
12+
};
13+
14+
module.exports = config;

‎.gitignore‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ vendor/
4444
############
4545

4646
tests/logs
47-
bin/
4847
tmp/
4948
*.tmp
5049
*.bak

‎bin/plugin/cli.js‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* External dependencies
5+
*/
6+
const program = require( 'commander' );
7+
8+
const withOptions = ( command, options ) => {
9+
options.forEach( ( { description, argname } ) => {
10+
command = command.option( argname, description );
11+
} );
12+
return command;
13+
};
14+
15+
const catchException = ( handler ) => {
16+
return async ( ...args ) => {
17+
try {
18+
await handler( ...args );
19+
} catch ( error ) {
20+
console.error( error ); // eslint-disable-line no-console
21+
process.exitCode = 1;
22+
}
23+
};
24+
};
25+
26+
/**
27+
* Internal dependencies
28+
*/
29+
const {
30+
handler: changelogHandler,
31+
options: changelogOptions,
32+
} = require( './commands/changelog' );
33+
34+
withOptions( program.command( 'release-plugin-changelog' ), changelogOptions )
35+
.alias( 'changelog' )
36+
.description( 'Generates a changelog from merged pull requests' )
37+
.action( catchException( changelogHandler ) );
38+
39+
program.parse( process.argv );

‎bin/plugin/commands/changelog.js‎

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
/**
2+
* External dependencies
3+
*/
4+
const { groupBy } = require( 'lodash' );
5+
const Octokit = require( '@octokit/rest' );
6+
7+
/**
8+
* Internal dependencies
9+
*/
10+
const { log, formats } = require( '../lib/logger' );
11+
const {
12+
getMilestoneByTitle,
13+
getIssuesByMilestone,
14+
} = require( '../lib/milestone' );
15+
const config = require( '../config' );
16+
17+
/** @typedef {import('@octokit/rest')} GitHub */
18+
/** @typedef {import('@octokit/rest').IssuesListForRepoResponseItem} IssuesListForRepoResponseItem */
19+
20+
/**
21+
* @typedef WPChangelogCommandOptions
22+
*
23+
* @property {string} milestone Milestone title.
24+
* @property {string=} token Optional personal access token.
25+
*/
26+
27+
/**
28+
* @typedef WPChangelogSettings
29+
*
30+
* @property {string} owner Repository owner.
31+
* @property {string} repo Repository name.
32+
* @property {string} milestone Milestone title.
33+
* @property {string=} token Optional personal access token.
34+
*/
35+
36+
const options = [
37+
{
38+
argname: '-m, --milestone <milestone>',
39+
description: 'Milestone',
40+
},
41+
{
42+
argname: '-t, --token <token>',
43+
description: 'GitHub token',
44+
},
45+
];
46+
47+
/**
48+
* Command that generates the release changelog.
49+
*
50+
* @param {WPChangelogCommandOptions} opt
51+
*/
52+
async function handler( opt ) {
53+
await createChangelog( {
54+
owner: config.githubRepositoryOwner,
55+
repo: config.githubRepositoryName,
56+
milestone: opt.milestone,
57+
token: opt.token,
58+
} );
59+
}
60+
61+
module.exports = {
62+
options,
63+
handler,
64+
};
65+
66+
const MISSING_TYPE = 'MISSING_TYPE';
67+
const MISSING_FOCUS = 'MISSING_FOCUS';
68+
const TYPE_PREFIX = '[Type] ';
69+
const FOCUS_PREFIX = '[Focus] ';
70+
const INFRASTRUCTURE_LABEL = 'Infrastructure';
71+
const PRIMARY_TYPE_LABELS = {
72+
'[Type] Feature': 'Features',
73+
'[Type] Enhancement': 'Enhancements',
74+
'[Type] Bug': 'Bug Fixes',
75+
};
76+
const PRIMARY_TYPE_ORDER = Object.values( PRIMARY_TYPE_LABELS );
77+
78+
/**
79+
* Returns a promise resolving to an array of pull requests associated with the
80+
* changelog settings object.
81+
*
82+
* @param {GitHub} octokit GitHub REST client.
83+
* @param {WPChangelogSettings} settings Changelog settings.
84+
*
85+
* @return {Promise<IssuesListForRepoResponseItem[]>} Promise resolving to array of
86+
* pull requests.
87+
*/
88+
async function fetchAllPullRequests( octokit, settings ) {
89+
const { owner, repo, milestone: milestoneTitle } = settings;
90+
const milestone = await getMilestoneByTitle(
91+
octokit,
92+
owner,
93+
repo,
94+
milestoneTitle
95+
);
96+
97+
if ( ! milestone ) {
98+
throw new Error(
99+
`Cannot find milestone by title: ${ milestoneTitle }`
100+
);
101+
}
102+
103+
const issues = await getIssuesByMilestone(
104+
octokit,
105+
owner,
106+
repo,
107+
milestone.number,
108+
'closed'
109+
);
110+
return issues.filter( ( issue ) => issue.pull_request );
111+
}
112+
113+
/**
114+
* Returns a type label for a given issue object, or MISSING_TYPE if type
115+
* cannot be determined.
116+
*
117+
* @param {IssuesListForRepoResponseItem} issue Issue object.
118+
*
119+
* @return {string} Type label, or MISSING_TYPE.
120+
*/
121+
function getIssueType( issue ) {
122+
const typeLabels = issue.labels
123+
.map( ( { name } ) => name )
124+
.filter( ( label ) => label.startsWith( TYPE_PREFIX ) );
125+
126+
if ( ! typeLabels.length ) {
127+
return MISSING_TYPE;
128+
}
129+
130+
if ( PRIMARY_TYPE_LABELS[ typeLabels[ 0 ] ] ) {
131+
return PRIMARY_TYPE_LABELS[ typeLabels[ 0 ] ];
132+
}
133+
134+
return typeLabels[ 0 ].replace( TYPE_PREFIX, '' );
135+
}
136+
137+
/**
138+
* Returns a focus label for a given issue object, or MISSING_FOCUS if focus
139+
* cannot be determined.
140+
*
141+
* @param {IssuesListForRepoResponseItem} issue Issue object.
142+
*
143+
* @return {string} Focus label, or MISSING_FOCUS.
144+
*/
145+
function getIssueFocus( issue ) {
146+
const labelNames = issue.labels.map( ( { name } ) => name );
147+
const focusLabels = labelNames.filter( ( label ) =>
148+
label.startsWith( FOCUS_PREFIX )
149+
);
150+
151+
if ( ! focusLabels.length ) {
152+
if ( labelNames.includes( INFRASTRUCTURE_LABEL ) ) {
153+
return INFRASTRUCTURE_LABEL;
154+
}
155+
return MISSING_FOCUS;
156+
}
157+
158+
return focusLabels[ 0 ].replace( FOCUS_PREFIX, '' );
159+
}
160+
161+
/**
162+
* Formats the changelog string for a given list of pull requests.
163+
*
164+
* @param {string} milestone Milestone title.
165+
* @param {IssuesListForRepoResponseItem[]} pullRequests List of pull requests.
166+
*
167+
* @return {string} The formatted changelog string.
168+
*/
169+
function formatChangelog( milestone, pullRequests ) {
170+
let changelog = '= ' + milestone + ' =\n\n';
171+
172+
// Group PRs by type.
173+
const typeGroups = groupBy( pullRequests, getIssueType );
174+
if ( typeGroups[ MISSING_TYPE ] ) {
175+
const prURLs = typeGroups[ MISSING_TYPE ].map(
176+
( { html_url } ) => html_url // eslint-disable-line camelcase
177+
);
178+
throw new Error(
179+
`The following pull-requests are missing a "${ TYPE_PREFIX }xyz" label: ${ prURLs.join(
180+
', '
181+
) }`
182+
);
183+
}
184+
185+
// Sort types by changelog significance, then alphabetically.
186+
const typeGroupNames = Object.keys( typeGroups ).sort( ( a, b ) => {
187+
const aIndex = PRIMARY_TYPE_ORDER.indexOf( a );
188+
const bIndex = PRIMARY_TYPE_ORDER.indexOf( b );
189+
if ( aIndex > -1 && bIndex > -1 ) {
190+
return aIndex - bIndex;
191+
}
192+
if ( aIndex === -1 && bIndex === -1 ) {
193+
return a - b;
194+
}
195+
return aIndex > -1 ? -1 : 1;
196+
} );
197+
198+
for ( const group of typeGroupNames ) {
199+
// Start a new section within the changelog.
200+
changelog += '**' + group + '**\n\n';
201+
202+
// Group PRs within this section per focus.
203+
const focusGroups = groupBy( typeGroups[ group ], getIssueFocus );
204+
if ( focusGroups[ MISSING_FOCUS ] ) {
205+
const prURLs = focusGroups[ MISSING_FOCUS ].map(
206+
( { html_url } ) => html_url // eslint-disable-line camelcase
207+
);
208+
throw new Error(
209+
`The following pull-requests are missing a "${ FOCUS_PREFIX }xyz" or "${ INFRASTRUCTURE_LABEL }" label: ${ prURLs.join(
210+
', '
211+
) }`
212+
);
213+
}
214+
215+
// Sort focuses alphabetically, except infrastructure which comes last.
216+
const focusGroupNames = Object.keys( focusGroups ).sort( ( a, b ) => {
217+
if (
218+
( a !== INFRASTRUCTURE_LABEL && b !== INFRASTRUCTURE_LABEL ) ||
219+
a === b
220+
) {
221+
return a - b;
222+
}
223+
return a !== INFRASTRUCTURE_LABEL ? -1 : 1;
224+
} );
225+
226+
// Output all PRs within this section.
227+
focusGroupNames.forEach( ( featureName ) => {
228+
const focusGroupPRs = focusGroups[ featureName ];
229+
230+
focusGroupPRs
231+
.map( ( issue ) => {
232+
const title = issue.title
233+
// Strip feature name from title if used as prefix.
234+
.replace(
235+
new RegExp(
236+
`^${ featureName.toLowerCase() }: `,
237+
'i'
238+
),
239+
''
240+
)
241+
// Strip trailing whitespace.
242+
.trim()
243+
// Ensure first letter is uppercase.
244+
.replace( /^([a-z])/, ( _match, firstLetter ) =>
245+
firstLetter.toUpperCase()
246+
)
247+
// Add trailing period.
248+
.replace( /\s*\.?$/, '' )
249+
.concat( '.' );
250+
return `* ${ featureName }: ${ title } ([${ issue.number }](${ issue.html_url }))`; // eslint-disable-line camelcase
251+
} )
252+
.filter( Boolean )
253+
.sort()
254+
.forEach( ( entry ) => {
255+
changelog += `${ entry }\n`;
256+
} );
257+
} );
258+
259+
changelog += '\n';
260+
}
261+
262+
return changelog;
263+
}
264+
265+
/**
266+
* Returns a promise resolving to the changelog string for given settings.
267+
*
268+
* @param {WPChangelogSettings} settings Changelog settings.
269+
*
270+
* @return {Promise<string>} Promise resolving to changelog.
271+
*/
272+
async function getChangelog( settings ) {
273+
const octokit = new Octokit( {
274+
auth: settings.token,
275+
} );
276+
277+
const pullRequests = await fetchAllPullRequests( octokit, settings );
278+
if ( ! pullRequests.length ) {
279+
throw new Error(
280+
'There are no (closed) pull requests associated with the milestone.'
281+
);
282+
}
283+
284+
return formatChangelog( settings.milestone, pullRequests );
285+
}
286+
287+
/**
288+
* Generates and logs changelog for a milestone.
289+
*
290+
* @param {WPChangelogSettings} settings Changelog settings.
291+
*/
292+
async function createChangelog( settings ) {
293+
if ( settings.milestone === undefined ) {
294+
log(
295+
formats.error(
296+
'A milestone must be provided via the --milestone (-m) argument.'
297+
)
298+
);
299+
return;
300+
}
301+
302+
log(
303+
formats.title(
304+
`\n💃Preparing changelog for milestone: "${ settings.milestone }"\n\n`
305+
)
306+
);
307+
308+
let changelog;
309+
try {
310+
changelog = await getChangelog( settings );
311+
} catch ( error ) {
312+
if ( error instanceof Error ) {
313+
changelog = formats.error( error.stack );
314+
}
315+
}
316+
317+
log( changelog );
318+
}

‎bin/plugin/config.js‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* @typedef WPPluginCLIConfig
3+
*
4+
* @property {string} githubRepositoryOwner GitHub Repository Owner.
5+
* @property {string} githubRepositoryName GitHub Repository Name.
6+
*/
7+
8+
/**
9+
* @type {WPPluginCLIConfig}
10+
*/
11+
const config = {
12+
githubRepositoryOwner: 'WordPress',
13+
githubRepositoryName: 'performance',
14+
};
15+
16+
module.exports = config;

0 commit comments

Comments
 (0)