@rnw-scripts/generate-release-notes 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@rnw-scripts/generate-release-notes",
3
+ "entries": [
4
+ {
5
+ "date": "Tue, 22 Jul 2025 05:25:56 GMT",
6
+ "version": "1.0.1",
7
+ "tag": "@rnw-scripts/generate-release-notes_v1.0.1",
8
+ "comments": {
9
+ "patch": [
10
+ {
11
+ "author": "copilot@example.com",
12
+ "package": "@rnw-scripts/generate-release-notes",
13
+ "commit": "4f7ec119652c62b183151cee3a4ebc7afaf0d289",
14
+ "comment": "Add a new \"yarn release-notes\" script to generate release notes"
15
+ }
16
+ ]
17
+ }
18
+ }
19
+ ]
20
+ }
package/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # Change Log - @rnw-scripts/generate-release-notes
2
+
3
+ <!-- This log was last generated on Tue, 22 Jul 2025 05:25:56 GMT and should not be manually modified. -->
4
+
5
+ <!-- Start content -->
6
+
7
+ ## 1.0.1
8
+
9
+ Tue, 22 Jul 2025 05:25:56 GMT
10
+
11
+ ### Patches
12
+
13
+ - Add a new "yarn release-notes" script to generate release notes (copilot@example.com)
package/ReadMe.md ADDED
@@ -0,0 +1,43 @@
1
+ ### Type of Change
2
+ Automate release notes creation by adding a new yarn script. Automating the process of creating release notes so that we don't have to manually copy paste the commits.
3
+
4
+
5
+ ### Why
6
+ To save us some time when generating release notes. Fetches commit from start and end date range, ignores bots and creates the release notes md file. It also categorizes the commits. Please cross-check the generated release-notes.md file and update it manually if required like regrouping commits or updating the Summary/Explanation for the PR commit.
7
+
8
+ ## Format
9
+
10
+ `Explanation. [PRName (#11168) · microsoft/react-native-windows@aaaaaaa (github.com)](link)`
11
+
12
+ ### Steps to follow
13
+
14
+ #### 1. Set up your personal access token
15
+
16
+ - Go to GitHub and log in: https://github.com/
17
+ - Click on your profile picture (top-right corner), then click Settings
18
+ - On the left sidebar, click Developer settings
19
+ - Then click Personal access tokens > Tokens (classic)
20
+ - Click Generate new token > Generate new token (classic)
21
+ - Give it a name like "Release Notes Script"
22
+ - Set an expiration (choose less than 90 days)
23
+ - Under Scopes, select the permissions your script needs. For fetching commits and repo info, you typically need:
24
+ repo (full control of private repositories)
25
+ or at least repo:status, repo_deployment, public_repo (for public repos)
26
+ - Click Generate token
27
+ - Find the token you're using (whichever token you created).
28
+ - You should see a message or option to "Grant access to your organization" or "Authorize SAML SSO" for your token.
29
+ - Click that button to authorize the token with the organization.
30
+ - Copy the generated token
31
+
32
+ #### 2. Set env variables at root of the repo
33
+
34
+ ```
35
+ set GITHUB_TOKEN=<your-personal-access-token>
36
+ set RELEASE_TAG=0.80.0
37
+ set START_DATE=2025-06-01
38
+ set END_DATE=2025-07-16
39
+
40
+ ```
41
+ #### 3. Run "`yarn release-notes`" at the root of the repo
42
+
43
+ #### 4. You will see a release-notes.md file generated at packages\@rnw-scripts\generate-release-notes\release_notes.md which will have all the data you need.
@@ -0,0 +1,385 @@
1
+ import fetch from "node-fetch";
2
+ import fs from "fs";
3
+ import process from "process";
4
+
5
+ function printHelp() {
6
+ console.log(`
7
+ Usage:
8
+ yarn release-notes --token <GITHUB_TOKEN> --start <START_DATE> --end <END_DATE> [--repo <OWNER/REPO>] [--tag <RELEASE_TAG>]
9
+
10
+ Options:
11
+ --token (required) GitHub personal access token.
12
+ --start (required) Start date in YYYY-MM-DD.
13
+ --end (required) End date in YYYY-MM-DD.
14
+ --repo Repository in OWNER/REPO format. Default: microsoft/react-native-windows
15
+ --tag Release tag label. Default: Unreleased
16
+ --help Show this help message.
17
+ `);
18
+ }
19
+
20
+ function parseArgs() {
21
+ const args = process.argv.slice(2);
22
+ const options = {};
23
+ for (let i = 0; i < args.length; i++) {
24
+ const arg = args[i];
25
+ if (arg === "--help") {
26
+ printHelp();
27
+ process.exit(0);
28
+ } else if (arg === "--token") {
29
+ options.token = args[++i];
30
+ } else if (arg === "--start") {
31
+ options.start = args[++i];
32
+ } else if (arg === "--end") {
33
+ options.end = args[++i];
34
+ } else if (arg === "--repo") {
35
+ options.repo = args[++i];
36
+ } else if (arg === "--tag") {
37
+ options.tag = args[++i];
38
+ } else {
39
+ console.error(`Unknown argument: ${arg}`);
40
+ printHelp();
41
+ process.exit(1);
42
+ }
43
+ }
44
+ return options;
45
+ }
46
+
47
+ const args = parseArgs();
48
+
49
+ const GITHUB_TOKEN = args.token;
50
+ const REPO = args.repo || "microsoft/react-native-windows";
51
+ const RELEASE_TAG = args.tag || "Unreleased";
52
+ const START_DATE = args.start;
53
+ const END_DATE = args.end;
54
+
55
+ if (!GITHUB_TOKEN || !START_DATE || !END_DATE) {
56
+ console.error("Error: --token, --start, and --end are required.");
57
+ printHelp();
58
+ process.exit(1);
59
+ }
60
+
61
+ console.log(`Generating release notes for ${REPO} from ${START_DATE} to ${END_DATE}...`);
62
+
63
+ const HEADERS = {
64
+ Authorization: `token ${GITHUB_TOKEN}`,
65
+ Accept: "application/vnd.github+json",
66
+ };
67
+
68
+ function parseDate(dateStr) {
69
+ return dateStr ? new Date(dateStr) : null;
70
+ }
71
+
72
+ const START = parseDate(START_DATE);
73
+ const END = parseDate(END_DATE);
74
+
75
+ function isBotCommit(commit) {
76
+ const author = commit.author;
77
+ const commitAuthorName = (commit.commit.author.name || '').toLowerCase();
78
+ const authorLogin = (author?.login || '').toLowerCase();
79
+ const botIndicators = ['bot', 'dependabot', 'actions-user'];
80
+ const msg = commit.commit.message.toLowerCase();
81
+
82
+ if (
83
+ botIndicators.some(
84
+ (bot) => authorLogin.includes(bot) || commitAuthorName.includes(bot)
85
+ )
86
+ )
87
+ return true;
88
+ if (['bump', 'applying package updates', 'no_ci', 'no ci'].some((k) => msg.includes(k)))
89
+ return true;
90
+ return false;
91
+ }
92
+
93
+ function formatDate(date) {
94
+ return date ? new Date(date).toLocaleDateString('en-US') : 'N/A';
95
+ }
96
+
97
+ async function fetchCommits() {
98
+ const commits = [];
99
+ let page = 1;
100
+ const perPage = 100;
101
+
102
+ while (true) {
103
+ const url = new URL(`https://api.github.com/repos/${REPO}/commits`);
104
+ url.searchParams.set('per_page', perPage);
105
+ url.searchParams.set('page', page);
106
+ if (START_DATE) url.searchParams.set('since', START_DATE + 'T00:00:00Z');
107
+ if (END_DATE) url.searchParams.set('until', END_DATE + 'T23:59:59Z');
108
+
109
+ console.log(`Fetching commits from: ${url.toString()}`);
110
+
111
+ const res = await fetch(url, { headers: HEADERS });
112
+
113
+ if (!res.ok) {
114
+ console.error(`GitHub API request failed: ${res.status} ${res.statusText}`);
115
+ const errText = await res.text();
116
+ console.error('Response body:', errText);
117
+ break;
118
+ }
119
+
120
+ const data = await res.json();
121
+
122
+ if (!Array.isArray(data)) {
123
+ console.error('Unexpected response format:', data);
124
+ break;
125
+ }
126
+
127
+ console.log(`Fetched page ${page} with ${data.length} commits.`);
128
+
129
+ if (data.length === 0) break;
130
+
131
+ commits.push(...data);
132
+ page++;
133
+ }
134
+
135
+ console.log(`Total commits fetched: ${commits.length}`);
136
+ return commits;
137
+ }
138
+
139
+ function filterCommitsByDate(commits) {
140
+ return commits.filter((c) => {
141
+ if (isBotCommit(c)) return false;
142
+ const commitDate = new Date(c.commit.author.date);
143
+ if (START && commitDate < START) return false;
144
+ if (END && commitDate > END) return false;
145
+ return true;
146
+ });
147
+ }
148
+
149
+ function extractPRNumber(commitMessage) {
150
+ // Extract PR number from commit message like "(#14813)"
151
+ const match = commitMessage.match(/\(#(\d+)\)/);
152
+ return match ? parseInt(match[1]) : null;
153
+ }
154
+
155
+ async function fetchPRDetails(prNumber) {
156
+ if (!prNumber) return null;
157
+
158
+ try {
159
+ const url = `https://api.github.com/repos/${REPO}/pulls/${prNumber}`;
160
+ const res = await fetch(url, { headers: HEADERS });
161
+
162
+ if (!res.ok) {
163
+ console.warn(`Failed to fetch PR #${prNumber}: ${res.status}`);
164
+ return null;
165
+ }
166
+
167
+ return await res.json();
168
+ } catch (error) {
169
+ console.warn(`Error fetching PR #${prNumber}:`, error.message);
170
+ return null;
171
+ }
172
+ }
173
+
174
+ function shouldIncludeInReleaseNotes(prDescription) {
175
+ if (!prDescription) return false;
176
+
177
+ // Look for the inclusion marker
178
+ const marker = 'Should this change be included in the release notes:';
179
+ const markerIndex = prDescription.indexOf(marker);
180
+
181
+ if (markerIndex === -1) return true;
182
+
183
+ // Get text after the marker
184
+ const afterMarker = prDescription.substring(markerIndex + marker.length);
185
+
186
+ // Extract the next line or paragraph after the marker
187
+ const lines = afterMarker.split('\n').map(line => line.trim()).filter(line => line.length > 0);
188
+
189
+ if (lines.length === 0) return false;
190
+
191
+ // Check if the first non-empty line contains "no" or "_no_"
192
+ const firstLine = lines[0].toLowerCase();
193
+ return !(firstLine.includes('no') || firstLine.includes('_no_'));
194
+ }
195
+
196
+ function extractReleaseNotesSummary(prDescription) {
197
+ if (!prDescription) return null;
198
+
199
+ // Look for the release notes summary marker
200
+ const marker = 'Add a brief summary of the change to use in the release notes for the next release.';
201
+ const markerIndex = prDescription.indexOf(marker);
202
+
203
+ if (markerIndex === -1) return null;
204
+
205
+ // Get text after the marker
206
+ const afterMarker = prDescription.substring(markerIndex + marker.length);
207
+
208
+ // Split into lines and get all non-empty lines
209
+ const lines = afterMarker.split('\n')
210
+ .map(line => line.trim())
211
+ .filter(line => line.length > 0);
212
+
213
+ if (lines.length === 0) return null;
214
+
215
+ // Get the first non-empty line after the marker
216
+ let summary = lines[0];
217
+
218
+ // Remove Microsoft Reviewers text if it exists anywhere in the summary
219
+ const reviewersMarker = 'Microsoft Reviewers: [Open in CodeFlow';
220
+ if (summary.includes(reviewersMarker)) {
221
+ const reviewersIndex = summary.indexOf(reviewersMarker);
222
+ summary = summary.substring(0, reviewersIndex).trim();
223
+ }
224
+
225
+ // Filter out lines that contain Microsoft Reviewers text
226
+ if (!summary || summary.length === 0) {
227
+ // Try the next lines if the first one was entirely Microsoft Reviewers text
228
+ for (let i = 1; i < lines.length; i++) {
229
+ const line = lines[i];
230
+ if (!line.includes('Microsoft Reviewers: [Open in CodeFlow')) {
231
+ summary = line;
232
+ break;
233
+ }
234
+ }
235
+ }
236
+
237
+ return summary && summary.length > 0 ? summary : null;
238
+ }
239
+
240
+ function extractTypeOfChange(prDescription) {
241
+ if (!prDescription) return null;
242
+
243
+ // Look for the "### Type of Change" section
244
+ const marker = '### Type of Change';
245
+ const markerIndex = prDescription.indexOf(marker);
246
+
247
+ if (markerIndex === -1) return null;
248
+
249
+ // Get text after the marker until the next section (###) or end
250
+ const afterMarker = prDescription.substring(markerIndex + marker.length);
251
+ const nextSectionIndex = afterMarker.indexOf('###');
252
+ const sectionText = nextSectionIndex !== -1
253
+ ? afterMarker.substring(0, nextSectionIndex)
254
+ : afterMarker;
255
+
256
+ // Convert to lowercase for easier matching
257
+ const lowerSectionText = sectionText.toLowerCase();
258
+
259
+ // Check for each type of change
260
+ if (lowerSectionText.includes('bug fix')) {
261
+ return 'Bug fix';
262
+ } else if (lowerSectionText.includes('new feature')) {
263
+ return 'New feature';
264
+ } else if (lowerSectionText.includes('breaking change')) {
265
+ return 'Breaking change';
266
+ }
267
+
268
+ return null;
269
+ }
270
+
271
+ async function categorizeCommits(commits) {
272
+ const categories = {
273
+ 'All Commits': [],
274
+ 'Breaking Changes': [],
275
+ 'New Features': [],
276
+ 'Reliability': [],
277
+ 'New Architecture-specific changes': [],
278
+ Other: [],
279
+ };
280
+
281
+ for (const c of commits) {
282
+ const msg = c.commit.message;
283
+ const sha = c.sha.slice(0, 7);
284
+ const url = c.html_url;
285
+ const commitTitle = msg.split('\n')[0];
286
+
287
+ // Try to get a better summary from PR description
288
+ const prNumber = extractPRNumber(commitTitle);
289
+ let summary = commitTitle;
290
+ let shouldInclude = true; // Default to include if we can't determine
291
+ let category = 'Other'; // Default category
292
+
293
+ if (prNumber) {
294
+ console.log(`Fetching PR details for #${prNumber}...`);
295
+ const prDetails = await fetchPRDetails(prNumber);
296
+ if (prDetails) {
297
+ // Check if this PR should be included in release notes
298
+ shouldInclude = shouldIncludeInReleaseNotes(prDetails.body);
299
+
300
+ if (shouldInclude) {
301
+ const releaseNotesSummary = extractReleaseNotesSummary(prDetails.body);
302
+ if (releaseNotesSummary) {
303
+ summary = releaseNotesSummary;
304
+ console.log(`Found release notes summary for PR #${prNumber}: ${summary}`);
305
+ }
306
+
307
+ // Determine category based on PR description "Type of Change"
308
+ const typeOfChange = extractTypeOfChange(prDetails.body);
309
+ const prTitle = prDetails.title || '';
310
+ const prDescription = prDetails.body || '';
311
+
312
+ // Check for special architecture keywords first
313
+ const lowerTitle = prTitle.toLowerCase();
314
+ const lowerDescription = prDescription.toLowerCase();
315
+ const hasArchKeywords = lowerTitle.includes('fabric') ||
316
+ lowerTitle.includes('implement') ||
317
+ lowerTitle.includes('prop');
318
+
319
+ if (hasArchKeywords) {
320
+ category = 'New Architecture-specific changes';
321
+ } else if (typeOfChange === 'Bug fix') {
322
+ category = 'Reliability';
323
+ } else if (typeOfChange === 'New feature') {
324
+ category = 'New Features';
325
+ } else if (typeOfChange === 'Breaking change') {
326
+ category = 'Breaking Changes';
327
+ } else {
328
+ category = 'Other';
329
+ }
330
+
331
+ console.log(`PR #${prNumber}: Type of Change = "${typeOfChange}", Category = "${category}"`);
332
+ } else {
333
+ console.log(`Skipping PR #${prNumber} - not marked for inclusion in release notes`);
334
+ continue; // Skip this commit
335
+ }
336
+ }
337
+ }
338
+
339
+ const entry = `- ${summary} [${commitTitle} · ${REPO}@${sha} (github.com)](${url})`;
340
+
341
+ categories['All Commits'].push(entry);
342
+ categories[category].push(entry);
343
+ }
344
+
345
+ return categories;
346
+ }
347
+
348
+ function generateReleaseNotes(commits, categories) {
349
+ const start = formatDate(START || new Date(commits[0]?.commit.author.date));
350
+ const end = formatDate(END || new Date(commits.at(-1)?.commit.author.date));
351
+
352
+ const lines = [];
353
+ lines.push(`${RELEASE_TAG} Release Notes\n`);
354
+ lines.push(
355
+ `We're excited to release React Native Windows ${RELEASE_TAG} targeting React Native ${RELEASE_TAG}!`
356
+ );
357
+ lines.push(`This release includes the commits to React Native Windows from ${start} - ${end}.\n`);
358
+ lines.push('## How to upgrade');
359
+ lines.push(
360
+ 'You can view the changes made to the default new React Native Windows applications for C++ and C# using React Native Upgrade Helper. See this [document](https://microsoft.github.io/react-native-windows/docs/upgrade-app) for more details.\n'
361
+ );
362
+
363
+ for (const [category, entries] of Object.entries(categories)) {
364
+ if (entries.length > 0) {
365
+ lines.push(`## ${category}`);
366
+ lines.push(...entries);
367
+ lines.push('');
368
+ }
369
+ }
370
+
371
+ return lines.join('\n');
372
+ }
373
+
374
+ async function main() {
375
+ const commits = await fetchCommits();
376
+ const filtered = filterCommitsByDate(commits);
377
+ const categories = await categorizeCommits(filtered);
378
+ const notes = generateReleaseNotes(filtered, categories);
379
+ fs.writeFileSync('release_notes.md', notes, 'utf8');
380
+ }
381
+
382
+ main().catch((err) => {
383
+ console.error('Failed to generate release notes:', err);
384
+ process.exit(1);
385
+ });
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@rnw-scripts/generate-release-notes",
3
+ "version": "1.0.1",
4
+ "description": "Generates release notes for React Native Windows",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/microsoft/react-native-windows",
9
+ "directory": "packages/@rnw-scripts/generate-release-notes"
10
+ },
11
+ "main": "generate-release-notes.js",
12
+ "scripts": {
13
+ "release-notes": "node generate-release-notes.js"
14
+ },
15
+ "dependencies": {
16
+ "node-fetch": "^3.3.2"
17
+ },
18
+ "engines": {
19
+ "node": ">= 18"
20
+ },
21
+ "type": "module"
22
+ }