tools: validate commit list as part of `lint-release-commit` · nodejs/node@4ff9aa7
1+#!/usr/bin/env node
2+3+// Takes a stream of JSON objects as inputs, validates the CHANGELOG contains a
4+// line corresponding, then outputs the prURL value.
5+//
6+// Example:
7+// $ git log upstream/vXX.x...upstream/vX.X.X-proposal \
8+// --format='{"prURL":"%(trailers:key=PR-URL,valueonly,separator=)","title":"%s","smallSha":"%h"}' \
9+// | ./lint-release-proposal-commit-list.mjs "path/to/CHANGELOG.md" "$(git rev-parse upstream/vX.X.X-proposal)"
10+11+const [,, CHANGELOG_PATH, RELEASE_COMMIT_SHA] = process.argv;
12+13+import assert from 'node:assert';
14+import { readFile } from 'node:fs/promises';
15+import { createInterface } from 'node:readline';
16+17+// Creating the iterator early to avoid missing any data:
18+const stdinLineByLine = createInterface(process.stdin)[Symbol.asyncIterator]();
19+20+const changelog = await readFile(CHANGELOG_PATH, 'utf-8');
21+const commitListingStart = changelog.indexOf('\n### Commits\n');
22+const commitListingEnd = changelog.indexOf('\n\n<a', commitListingStart);
23+const commitList = changelog.slice(commitListingStart, commitListingEnd === -1 ? undefined : commitListingEnd + 1)
24+// Checking for semverness is too expansive, it is left as a exercice for human reviewers.
25+.replaceAll('**(SEMVER-MINOR)** ', '')
26+// Correct Markdown escaping is validated by the linter, getting rid of it here helps.
27+.replaceAll('\\', '');
28+29+let expectedNumberOfCommitsLeft = commitList.match(/\n\* \[/g).length;
30+for await (const line of stdinLineByLine) {
31+const { smallSha, title, prURL } = JSON.parse(line);
32+33+if (smallSha === RELEASE_COMMIT_SHA.slice(0, 10)) {
34+assert.strictEqual(
35+expectedNumberOfCommitsLeft, 0,
36+'Some commits are listed without being included in the proposal, or are listed more than once',
37+);
38+continue;
39+}
40+41+const lineStart = commitList.indexOf(`\n* [[\`${smallSha}\`]`);
42+assert.notStrictEqual(lineStart, -1, `Cannot find ${smallSha} on the list`);
43+const lineEnd = commitList.indexOf('\n', lineStart + 1);
44+45+const colonIndex = title.indexOf(':');
46+const expectedCommitTitle = `${`**${title.slice(0, colonIndex)}`.replace('**Revert "', '_**Revert**_ "**')}**${title.slice(colonIndex)}`;
47+try {
48+assert(commitList.lastIndexOf(`/${smallSha})] - ${expectedCommitTitle} (`, lineEnd) > lineStart, 'Commit title doesn\'t match');
49+} catch (e) {
50+if (e?.code === 'ERR_ASSERTION') {
51+e.operator = 'includes';
52+e.expected = expectedCommitTitle;
53+e.actual = commitList.slice(lineStart + 1, lineEnd);
54+}
55+throw e;
56+}
57+assert.strictEqual(commitList.slice(lineEnd - prURL.length - 2, lineEnd), `(${prURL})`, `when checking ${smallSha} ${title}`);
58+59+expectedNumberOfCommitsLeft--;
60+console.log(prURL);
61+}
62+assert.strictEqual(expectedNumberOfCommitsLeft, 0, 'Release commit is not the last commit in the proposal');