aboutsummaryrefslogtreecommitdiffstats
path: root/build/release/changelog.js
blob: 5a3722d9e703e9cbb86a34055c91a2e53dfb4c64 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
import { writeFile } from "node:fs/promises";
import { argv } from "node:process";
import { exec as nodeExec } from "node:child_process";
import util from "node:util";
import { marked } from "marked";

const exec = util.promisify( nodeExec );

const rbeforeHash = /.#$/;
const rendsWithHash = /#$/;
const rcherry = / \(cherry picked from commit [^)]+\)/;
const rcommit = /Fix(?:e[sd])? ((?:[a-zA-Z0-9_-]{1,39}\/[a-zA-Z0-9_-]{1,100}#)|#|gh-)(\d+)/g;
const rcomponent = /^([^ :]+):\s*([^\n]+)/;
const rnewline = /\r?\n/;

const prevVersion = argv[ 2 ];
const nextVersion = argv[ 3 ];
const blogUrl = process.env.BLOG_URL;

if ( !prevVersion || !nextVersion ) {
	throw new Error( "Usage: `node changelog.js PREV_VERSION NEXT_VERSION`" );
}

function ticketUrl( ticketId ) {
	return `https://github.com/jquery/jquery/issues/${ ticketId }`;
}

function getTicketsForCommit( commit ) {
	var tickets = [];

	commit.replace( rcommit, function( _match, refType, ticketId ) {
		var ticket = {
			url: ticketUrl( ticketId ),
			label: "#" + ticketId
		};

		// If the refType has anything before the #, assume it's a GitHub ref
		if ( rbeforeHash.test( refType ) ) {

			// console.log( refType );
			refType = refType.replace( rendsWithHash, "" );
			ticket.url = `https://github.com/${ refType }/issues/${ ticketId }`;
			ticket.label = refType + ticket.label;
		}

		tickets.push( ticket );
	} );

	return tickets;
}

async function getCommits() {
	const format =
		"__COMMIT__%n%s (__TICKETREF__[%h](https://github.com/jquery/jquery/commit/%H))%n%b";
	const { stdout } = await exec(
		`git log --format="${ format }" ${ prevVersion }..${ nextVersion }`
	);
	const commits = stdout.split( "__COMMIT__" ).slice( 1 );

	return removeReverts( commits.map( parseCommit ).sort( sortCommits ) );
}

function parseCommit( commit ) {
	const tickets = getTicketsForCommit( commit )
		.map( ( ticket ) => {
			return `[${ ticket.label }](${ ticket.url })`;
		} )
		.join( ", " );

	// Drop the commit message body
	let message = `${ commit.trim().split( rnewline )[ 0 ] }`;

	// Add any ticket references
	message = message.replace( "__TICKETREF__", tickets ? `${ tickets }, ` : "" );

	// Remove cherry pick references
	message = message.replace( rcherry, "" );

	return message;
}

function sortCommits( a, b ) {
	const aComponent = rcomponent.exec( a );
	const bComponent = rcomponent.exec( b );

	if ( aComponent && bComponent ) {
		if ( aComponent[ 1 ] < bComponent[ 1 ] ) {
			return -1;
		}
		if ( aComponent[ 1 ] > bComponent[ 1 ] ) {
			return 1;
		}
		return 0;
	}

	if ( a < b ) {
		return -1;
	}
	if ( a > b ) {
		return 1;
	}
	return 0;
}

/**
 * Remove all revert commits and the commit it is reverting
 */
function removeReverts( commits ) {
	const remove = [];

	commits.forEach( function( commit ) {
		const match = /\*\s*Revert "([^"]*)"/.exec( commit );

		// Ignore double reverts
		if ( match && !/^Revert "([^"]*)"/.test( match[ 0 ] ) ) {
			remove.push( commit, match[ 0 ] );
		}
	} );

	remove.forEach( function( message ) {
		const index = commits.findIndex( ( commit ) => commit.includes( message ) );
		if ( index > -1 ) {

			// console.log( "Removing ", commits[ index ] );
			commits.splice( index, 1 );
		}
	} );

	return commits;
}

function addHeaders( commits ) {
	const components = {};
	let markdown = "";

	commits.forEach( function( commit ) {
		const match = rcomponent.exec( commit );
		if ( match ) {
			let component = match[ 1 ];
			if ( !/^[A-Z]/.test( component ) ) {
				component =
					component.slice( 0, 1 ).toUpperCase() +
					component.slice( 1 ).toLowerCase();
			}
			if ( !components[ component.toLowerCase() ] ) {
				markdown += "\n## " + component + "\n\n";
				components[ component.toLowerCase() ] = true;
			}
			markdown += `- ${ match[ 2 ] }\n`;
		} else {
			markdown += `- ${ commit }\n`;
		}
	} );

	return markdown;
}

async function getGitHubContributor( sha ) {
	const response = await fetch(
		`https://api.github.com/repos/jquery/jquery/commits/${ sha }`,
		{
			headers: {
				Accept: "application/vnd.github+json",
				Authorization: `Bearer ${ process.env.JQUERY_GITHUB_TOKEN }`,
				"X-GitHub-Api-Version": "2022-11-28"
			}
		}
	);
	const data = await response.json();

	if ( !data.commit || !data.author ) {

		// The data may contain multiple helpful fields
		throw new Error( JSON.stringify( data ) );
	}
	return { name: data.commit.author.name, url: data.author.html_url };
}

function uniqueContributors( contributors ) {
	const seen = {};
	return contributors.filter( ( contributor ) => {
		if ( seen[ contributor.name ] ) {
			return false;
		}
		seen[ contributor.name ] = true;
		return true;
	} );
}

async function getContributors() {
	const { stdout } = await exec(
		`git log --format="%H" ${ prevVersion }..${ nextVersion }`
	);
	const shas = stdout.split( rnewline ).filter( Boolean );
	const contributors = await Promise.all( shas.map( getGitHubContributor ) );

	return uniqueContributors( contributors )

		// Sort by last name
		.sort( ( a, b ) => {
			const aName = a.name.split( " " );
			const bName = b.name.split( " " );
			return aName[ aName.length - 1 ].localeCompare( bName[ bName.length - 1 ] );
		} )
		.map( ( { name, url } ) => {
			if ( name === "Timmy Willison" || name.includes( "dependabot" ) ) {
				return;
			}
			return `<a href="${ url }">${ name }</a>`;
		} )
		.filter( Boolean ).join( "\n" );
}

async function generate() {
	const commits = await getCommits();
	const contributors = await getContributors();

	let changelog = "# Changelog\n";
	if ( blogUrl ) {
		changelog += `\n${ blogUrl }\n`;
	}
	changelog += addHeaders( commits );

	// Write markdown to changelog.md
	await writeFile( "changelog.md", changelog );

	// Write HTML to changelog.html for blog post
	await writeFile( "changelog.html", marked.parse( changelog ) );

	// Write contributors HTML for blog post
	await writeFile( "contributors.html", contributors );

	// Log regular changelog for release-it
	console.log( changelog );

	return changelog;
}

generate();