notes.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792
  1. #!/usr/bin/env node
  2. import { Octokit } from '@octokit/rest';
  3. import { GitProcess } from 'dugite';
  4. import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
  5. import { resolve as _resolve } from 'node:path';
  6. import { ELECTRON_DIR } from '../../lib/utils';
  7. import { createGitHubTokenStrategy } from '../github-token';
  8. import { ELECTRON_ORG, ELECTRON_REPO } from '../types';
  9. const octokit = new Octokit({
  10. authStrategy: createGitHubTokenStrategy(ELECTRON_REPO)
  11. });
  12. const MAX_FAIL_COUNT = 3;
  13. const CHECK_INTERVAL = 5000;
  14. const TROP_LOGIN = 'trop[bot]';
  15. const NO_NOTES = 'No notes';
  16. const docTypes = new Set(['doc', 'docs']);
  17. const featTypes = new Set(['feat', 'feature']);
  18. const fixTypes = new Set(['fix']);
  19. const otherTypes = new Set([
  20. 'spec',
  21. 'build',
  22. 'test',
  23. 'chore',
  24. 'deps',
  25. 'refactor',
  26. 'tools',
  27. 'perf',
  28. 'style',
  29. 'ci'
  30. ]);
  31. const knownTypes = new Set([
  32. ...docTypes.keys(),
  33. ...featTypes.keys(),
  34. ...fixTypes.keys(),
  35. ...otherTypes.keys()
  36. ]);
  37. const getCacheDir = () =>
  38. process.env.NOTES_CACHE_PATH || _resolve(__dirname, '.cache');
  39. /**
  40. ***
  41. **/
  42. type MinimalPR = {
  43. title: string;
  44. body: string | null;
  45. number: number;
  46. labels: {
  47. name: string;
  48. }[];
  49. base: { repo: { name: string; owner: { login: string } } };
  50. };
  51. // link to a GitHub item, e.g. an issue or pull request
  52. class GHKey {
  53. // eslint-disable-next-line no-useless-constructor
  54. constructor (
  55. public readonly owner: string,
  56. public readonly repo: string,
  57. public readonly number: number
  58. ) {}
  59. static NewFromPull (pull: MinimalPR) {
  60. const owner = pull.base.repo.owner.login;
  61. const repo = pull.base.repo.name;
  62. const number = pull.number;
  63. return new GHKey(owner, repo, number);
  64. }
  65. }
  66. class Commit {
  67. public isBreakingChange = false;
  68. public note: string | null = null;
  69. public trops = new Map<string, GHKey>();
  70. public readonly prKeys = new Set<GHKey>();
  71. public revertHash: string | null = null;
  72. public semanticType: string | null = null;
  73. public subject: string | null = null;
  74. // eslint-disable-next-line no-useless-constructor
  75. constructor (
  76. public readonly hash: string,
  77. public readonly owner: string,
  78. public readonly repo: string
  79. ) {}
  80. }
  81. class Pool {
  82. public commits: Commit[] = [];
  83. public processedHashes = new Set<string>();
  84. public pulls: Record<number, MinimalPR> = Object.create(null);
  85. }
  86. /**
  87. ***
  88. **/
  89. const runGit = async (dir: string, args: string[]) => {
  90. const response = await GitProcess.exec(args, dir);
  91. if (response.exitCode !== 0) {
  92. throw new Error(response.stderr.trim());
  93. }
  94. return response.stdout.trim();
  95. };
  96. const getCommonAncestor = async (
  97. dir: string,
  98. point1: string,
  99. point2: string
  100. ) => {
  101. return runGit(dir, ['merge-base', point1, point2]);
  102. };
  103. const getNoteFromClerk = async (ghKey: GHKey) => {
  104. const comments = await getComments(ghKey);
  105. if (!comments || !comments.data) return;
  106. const CLERK_LOGIN = 'release-clerk[bot]';
  107. const CLERK_NO_NOTES = '**No Release Notes**';
  108. const PERSIST_LEAD = '**Release Notes Persisted**';
  109. const QUOTE_LEAD = '> ';
  110. for (const comment of comments.data.reverse()) {
  111. if (comment.user?.login !== CLERK_LOGIN) {
  112. continue;
  113. }
  114. if (comment.body === CLERK_NO_NOTES) {
  115. return NO_NOTES;
  116. }
  117. if (comment.body?.startsWith(PERSIST_LEAD)) {
  118. let lines = comment.body
  119. .slice(PERSIST_LEAD.length)
  120. .trim() // remove PERSIST_LEAD
  121. .split(/\r?\n/) // split into lines
  122. .map((line) => line.trim())
  123. .map((line) => line.replace('&lt;', '<'))
  124. .map((line) => line.replace('&gt;', '>'))
  125. .filter((line) => line.startsWith(QUOTE_LEAD)) // notes are quoted
  126. .map((line) => line.slice(QUOTE_LEAD.length)); // unquote the lines
  127. const firstLine = lines.shift();
  128. // indent anything after the first line to ensure that
  129. // multiline notes with their own sub-lists don't get
  130. // parsed in the markdown as part of the top-level list
  131. // (example: https://github.com/electron/electron/pull/25216)
  132. lines = lines.map((line) => ' ' + line);
  133. return [firstLine, ...lines]
  134. .join('\n') // join the lines
  135. .trim();
  136. }
  137. }
  138. console.warn(`WARN: no notes found in ${buildPullURL(ghKey)}`);
  139. };
  140. /**
  141. * Looks for our project's conventions in the commit message:
  142. *
  143. * 'semantic: some description' -- sets semanticType, subject
  144. * 'some description (#99999)' -- sets subject, pr
  145. * 'Merge pull request #99999 from ${branchname}' -- sets pr
  146. * 'This reverts commit ${sha}' -- sets revertHash
  147. * line starting with 'BREAKING CHANGE' in body -- sets isBreakingChange
  148. * 'Backport of #99999' -- sets pr
  149. */
  150. const parseCommitMessage = (commitMessage: string, commit: Commit) => {
  151. const { owner, repo } = commit;
  152. // split commitMessage into subject & body
  153. let subject = commitMessage;
  154. let body = '';
  155. const pos = subject.indexOf('\n');
  156. if (pos !== -1) {
  157. body = subject.slice(pos).trim();
  158. subject = subject.slice(0, pos).trim();
  159. }
  160. // if the subject ends in ' (#dddd)', treat it as a pull request id
  161. let match;
  162. if ((match = subject.match(/^(.*)\s\(#(\d+)\)$/))) {
  163. commit.prKeys.add(new GHKey(owner, repo, parseInt(match[2])));
  164. subject = match[1];
  165. }
  166. // if the subject begins with 'word:', treat it as a semantic commit
  167. if ((match = subject.match(/^(\w+):\s(.*)$/))) {
  168. const semanticType = match[1].toLocaleLowerCase();
  169. if (knownTypes.has(semanticType)) {
  170. commit.semanticType = semanticType;
  171. subject = match[2];
  172. }
  173. }
  174. // Check for GitHub commit message that indicates a PR
  175. if ((match = subject.match(/^Merge pull request #(\d+) from (.*)$/))) {
  176. commit.prKeys.add(new GHKey(owner, repo, parseInt(match[1])));
  177. }
  178. // Check for a comment that indicates a PR
  179. const backportPattern =
  180. /(?:^|\n)(?:manual |manually )?backport.*(?:#(\d+)|\/pull\/(\d+))/im;
  181. if ((match = commitMessage.match(backportPattern))) {
  182. // This might be the first or second capture group depending on if it's a link or not.
  183. const backportNumber = match[1]
  184. ? parseInt(match[1], 10)
  185. : parseInt(match[2], 10);
  186. commit.prKeys.add(new GHKey(owner, repo, backportNumber));
  187. }
  188. // https://help.github.com/articles/closing-issues-using-keywords/
  189. if (
  190. body.match(
  191. /\b(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved|for)\s#(\d+)\b/i
  192. )
  193. ) {
  194. commit.semanticType = commit.semanticType || 'fix';
  195. }
  196. // https://www.conventionalcommits.org/en
  197. if (
  198. commitMessage
  199. .split(/\r?\n/) // split into lines
  200. .map((line) => line.trim())
  201. .some((line) => line.startsWith('BREAKING CHANGE'))
  202. ) {
  203. commit.isBreakingChange = true;
  204. }
  205. // Check for a reversion commit
  206. if ((match = body.match(/This reverts commit ([a-f0-9]{40})\./))) {
  207. commit.revertHash = match[1];
  208. }
  209. commit.subject = subject.trim();
  210. return commit;
  211. };
  212. const parsePullText = (pull: MinimalPR, commit: Commit) =>
  213. parseCommitMessage(`${pull.title}\n\n${pull.body}`, commit);
  214. const getLocalCommitHashes = async (dir: string, ref: string) => {
  215. const args = ['log', '--format=%H', ref];
  216. return (await runGit(dir, args))
  217. .split(/\r?\n/) // split into lines
  218. .map((hash) => hash.trim());
  219. };
  220. // return an array of Commits
  221. const getLocalCommits = async (
  222. module: LocalRepo,
  223. point1: string,
  224. point2: string
  225. ) => {
  226. const { owner, repo, dir } = module;
  227. const fieldSep = ',';
  228. const format = ['%H', '%s'].join(fieldSep);
  229. const args = [
  230. 'log',
  231. '--cherry-pick',
  232. '--right-only',
  233. '--first-parent',
  234. `--format=${format}`,
  235. `${point1}..${point2}`
  236. ];
  237. const logs = (await runGit(dir, args))
  238. .split(/\r?\n/) // split into lines
  239. .map((field) => field.trim());
  240. const commits = [];
  241. for (const log of logs) {
  242. if (!log) {
  243. continue;
  244. }
  245. const [hash, subject] = log.split(fieldSep, 2).map((field) => field.trim());
  246. commits.push(parseCommitMessage(subject, new Commit(hash, owner, repo)));
  247. }
  248. return commits;
  249. };
  250. const checkCache = async <T>(
  251. name: string,
  252. operation: () => Promise<T>
  253. ): Promise<T> => {
  254. const filename = _resolve(getCacheDir(), name);
  255. if (existsSync(filename)) {
  256. return JSON.parse(readFileSync(filename, 'utf8'));
  257. }
  258. process.stdout.write('.');
  259. const response = await operation();
  260. if (response) {
  261. writeFileSync(filename, JSON.stringify(response));
  262. }
  263. return response;
  264. };
  265. // helper function to add some resiliency to volatile GH api endpoints
  266. async function runRetryable<T> (
  267. fn: () => Promise<T>,
  268. maxRetries: number
  269. ): Promise<T | null> {
  270. let lastError: Error & { status?: number };
  271. for (let i = 0; i < maxRetries; i++) {
  272. try {
  273. return await fn();
  274. } catch (error) {
  275. await new Promise((resolve) => setTimeout(resolve, CHECK_INTERVAL));
  276. lastError = error as any;
  277. }
  278. }
  279. // Silently eat 404s.
  280. // Silently eat 422s, which come from "No commit found for SHA"
  281. // eslint-disable-next-line no-throw-literal
  282. if (lastError!.status !== 404 && lastError!.status !== 422) throw lastError!;
  283. return null;
  284. }
  285. const getPullCacheFilename = (ghKey: GHKey) =>
  286. `${ghKey.owner}-${ghKey.repo}-pull-${ghKey.number}`;
  287. const getCommitPulls = async (owner: string, repo: string, hash: string) => {
  288. const name = `${owner}-${repo}-commit-${hash}`;
  289. const retryableFunc = async () => {
  290. const { data } = await octokit.repos.listPullRequestsAssociatedWithCommit({
  291. owner,
  292. repo,
  293. commit_sha: hash
  294. });
  295. return {
  296. data
  297. };
  298. };
  299. let ret = await checkCache(name, () =>
  300. runRetryable(retryableFunc, MAX_FAIL_COUNT)
  301. );
  302. // only merged pulls belong in release notes
  303. if (ret && ret.data) {
  304. ret.data = ret.data.filter((pull) => pull.merged_at);
  305. }
  306. // cache the pulls
  307. if (ret && ret.data) {
  308. for (const pull of ret.data) {
  309. const cachefile = getPullCacheFilename(GHKey.NewFromPull(pull));
  310. const payload = { ...ret, data: pull };
  311. await checkCache(cachefile, async () => payload);
  312. }
  313. }
  314. // ensure the return value has the expected structure, even on failure
  315. if (!ret || !ret.data) {
  316. ret = { data: [] };
  317. }
  318. return ret;
  319. };
  320. const getPullRequest = async (ghKey: GHKey) => {
  321. const { number, owner, repo } = ghKey;
  322. const name = getPullCacheFilename(ghKey);
  323. const retryableFunc = () =>
  324. octokit.pulls.get({ pull_number: number, owner, repo });
  325. return checkCache(name, () => runRetryable(retryableFunc, MAX_FAIL_COUNT));
  326. };
  327. const getComments = async (ghKey: GHKey) => {
  328. const { number, owner, repo } = ghKey;
  329. const name = `${owner}-${repo}-issue-${number}-comments`;
  330. const retryableFunc = () =>
  331. octokit.issues.listComments({
  332. issue_number: number,
  333. owner,
  334. repo,
  335. per_page: 100
  336. });
  337. return checkCache(name, () => runRetryable(retryableFunc, MAX_FAIL_COUNT));
  338. };
  339. type LocalRepo = {
  340. owner: string;
  341. repo: string;
  342. dir: string;
  343. };
  344. const addRepoToPool = async (
  345. pool: Pool,
  346. repo: LocalRepo,
  347. from: string,
  348. to: string
  349. ) => {
  350. const commonAncestor = await getCommonAncestor(repo.dir, from, to);
  351. // mark the old branch's commits as old news
  352. for (const oldHash of await getLocalCommitHashes(repo.dir, from)) {
  353. pool.processedHashes.add(oldHash);
  354. }
  355. // get the new branch's commits and the pulls associated with them
  356. const commits = await getLocalCommits(repo, commonAncestor, to);
  357. for (const commit of commits) {
  358. const { owner, repo, hash } = commit;
  359. for (const pull of (await getCommitPulls(owner, repo, hash)).data) {
  360. commit.prKeys.add(GHKey.NewFromPull(pull));
  361. }
  362. }
  363. pool.commits.push(...commits);
  364. // add the pulls
  365. for (const commit of commits) {
  366. let prKey;
  367. for (prKey of commit.prKeys.values()) {
  368. const pull = await getPullRequest(prKey);
  369. if (!pull || !pull.data) continue; // couldn't get it
  370. pool.pulls[prKey.number] = pull.data;
  371. parsePullText(pull.data, commit);
  372. }
  373. }
  374. };
  375. type MinimalComment = {
  376. user: {
  377. login: string;
  378. } | null;
  379. body?: string;
  380. };
  381. // @return Map<string,GHKey>
  382. // where the key is a branch name (e.g. '7-1-x' or '8-x-y')
  383. // and the value is a GHKey to the PR
  384. async function getMergedTrops (commit: Commit, pool: Pool) {
  385. const branches = new Map();
  386. for (const prKey of commit.prKeys.values()) {
  387. const pull = pool.pulls[prKey.number];
  388. const mergedBranches = new Set(
  389. (pull && pull && pull.labels ? pull.labels : [])
  390. .map((label) =>
  391. (label && label.name ? label.name : '').match(
  392. /merged\/([0-9]+-[x0-9]-[xy0-9])/
  393. )
  394. )
  395. .filter((match) => !!match)
  396. .map((match) => match[1])
  397. );
  398. if (mergedBranches.size > 0) {
  399. const isTropComment = (comment: MinimalComment | null) =>
  400. comment && comment.user && comment.user.login === TROP_LOGIN;
  401. const ghKey = GHKey.NewFromPull(pull);
  402. const backportRegex =
  403. /backported this PR to "(.*)",\s+please check out #(\d+)/;
  404. const getBranchNameAndPullKey = (comment: MinimalComment) => {
  405. const match = (comment && comment.body ? comment.body : '').match(
  406. backportRegex
  407. );
  408. return match
  409. ? <const>[match[1], new GHKey(ghKey.owner, ghKey.repo, parseInt(match[2]))]
  410. : null;
  411. };
  412. const comments = await getComments(ghKey);
  413. (comments && comments.data ? comments.data : [])
  414. .filter(isTropComment)
  415. .map(getBranchNameAndPullKey)
  416. .filter((pair) => !!pair)
  417. .filter(([branch]) => mergedBranches.has(branch))
  418. .forEach(([branch, key]) => branches.set(branch, key));
  419. }
  420. }
  421. return branches;
  422. }
  423. // @return the shorthand name of the branch that `ref` is on,
  424. // e.g. a ref of '10.0.0-beta.1' will return '10-x-y'
  425. async function getBranchNameOfRef (ref: string, dir: string) {
  426. const result = await runGit(dir, [
  427. 'branch',
  428. '--all',
  429. '--contains',
  430. ref,
  431. '--sort',
  432. 'version:refname'
  433. ]);
  434. return result
  435. .split(/\r?\n/) // split into lines
  436. .shift()! // we sorted by refname and want the first result
  437. .match(/(?:\s?\*\s){0,1}(.*)/)![1] // if present, remove leading '* ' in case we're currently in that branch
  438. .match(/(?:.*\/)?(.*)/)![1] // 'remote/origins/10-x-y' -> '10-x-y'
  439. .trim();
  440. }
  441. /***
  442. **** Main
  443. ***/
  444. const getNotes = async (fromRef: string, toRef: string, newVersion: string) => {
  445. const cacheDir = getCacheDir();
  446. if (!existsSync(cacheDir)) {
  447. mkdirSync(cacheDir);
  448. }
  449. const pool = new Pool();
  450. const toBranch = await getBranchNameOfRef(toRef, ELECTRON_DIR);
  451. console.log(
  452. `Generating release notes between '${fromRef}' and '${toRef}' for version '${newVersion}' in branch '${toBranch}'`
  453. );
  454. // get the electron/electron commits
  455. const electron = { owner: ELECTRON_ORG, repo: ELECTRON_REPO, dir: ELECTRON_DIR };
  456. await addRepoToPool(pool, electron, fromRef, toRef);
  457. // remove any old commits
  458. pool.commits = pool.commits.filter(
  459. (commit) => !pool.processedHashes.has(commit.hash)
  460. );
  461. // if a commit _and_ revert occurred in the unprocessed set, skip them both
  462. for (const commit of pool.commits) {
  463. const revertHash = commit.revertHash;
  464. if (!revertHash) {
  465. continue;
  466. }
  467. const revert = pool.commits.find((commit) => commit.hash === revertHash);
  468. if (!revert) {
  469. continue;
  470. }
  471. commit.note = NO_NOTES;
  472. revert.note = NO_NOTES;
  473. pool.processedHashes.add(commit.hash);
  474. pool.processedHashes.add(revertHash);
  475. }
  476. // ensure the commit has a note
  477. for (const commit of pool.commits) {
  478. for (const prKey of commit.prKeys.values()) {
  479. if (commit.note) {
  480. break;
  481. }
  482. commit.note = await getNoteFromClerk(prKey) || null;
  483. }
  484. }
  485. // remove non-user-facing commits
  486. pool.commits = pool.commits
  487. .filter((commit) => commit && commit.note)
  488. .filter((commit) => commit.note !== NO_NOTES)
  489. .filter((commit) => commit.note!.match(/^[Bb]ump v\d+\.\d+\.\d+/) === null);
  490. for (const commit of pool.commits) {
  491. commit.trops = await getMergedTrops(commit, pool);
  492. }
  493. pool.commits = removeSupercededStackUpdates(pool.commits);
  494. const notes = {
  495. breaking: [] as Commit[],
  496. docs: [] as Commit[],
  497. feat: [] as Commit[],
  498. fix: [] as Commit[],
  499. other: [] as Commit[],
  500. unknown: [] as Commit[],
  501. name: newVersion,
  502. toBranch
  503. };
  504. for (const commit of pool.commits) {
  505. const str = commit.semanticType;
  506. if (commit.isBreakingChange) {
  507. notes.breaking.push(commit);
  508. } else if (!str) {
  509. notes.unknown.push(commit);
  510. } else if (docTypes.has(str)) {
  511. notes.docs.push(commit);
  512. } else if (featTypes.has(str)) {
  513. notes.feat.push(commit);
  514. } else if (fixTypes.has(str)) {
  515. notes.fix.push(commit);
  516. } else if (otherTypes.has(str)) {
  517. notes.other.push(commit);
  518. } else {
  519. notes.unknown.push(commit);
  520. }
  521. }
  522. return notes;
  523. };
  524. const compareVersions = (v1: string, v2: string) => {
  525. const [split1, split2] = [v1.split('.'), v2.split('.')];
  526. if (split1.length !== split2.length) {
  527. throw new Error(
  528. `Expected version strings to have same number of sections: ${split1} and ${split2}`
  529. );
  530. }
  531. for (let i = 0; i < split1.length; i++) {
  532. const p1 = parseInt(split1[i], 10);
  533. const p2 = parseInt(split2[i], 10);
  534. if (p1 > p2) return 1;
  535. else if (p1 < p2) return -1;
  536. // Continue checking the value if this portion is equal
  537. }
  538. return 0;
  539. };
  540. const removeSupercededStackUpdates = (commits: Commit[]) => {
  541. const updateRegex = /^Updated ([a-zA-Z.]+) to v?([\d.]+)/;
  542. const notupdates = [];
  543. const newest: Record<string, { commit: Commit; version: string }> = Object.create(null);
  544. for (const commit of commits) {
  545. const match = (commit.note || commit.subject)?.match(updateRegex);
  546. if (!match) {
  547. notupdates.push(commit);
  548. continue;
  549. }
  550. const [, dep, version] = match;
  551. if (!newest[dep] || compareVersions(version, newest[dep].version) > 0) {
  552. newest[dep] = { commit, version };
  553. }
  554. }
  555. return [...notupdates, ...Object.values(newest).map((o) => o.commit)];
  556. };
  557. /***
  558. **** Render
  559. ***/
  560. // @return the pull request's GitHub URL
  561. const buildPullURL = (ghKey: GHKey) =>
  562. `https://github.com/${ghKey.owner}/${ghKey.repo}/pull/${ghKey.number}`;
  563. const renderPull = (ghKey: GHKey) =>
  564. `[#${ghKey.number}](${buildPullURL(ghKey)})`;
  565. // @return the commit's GitHub URL
  566. const buildCommitURL = (commit: Commit) =>
  567. `https://github.com/${commit.owner}/${commit.repo}/commit/${commit.hash}`;
  568. const renderCommit = (commit: Commit) =>
  569. `[${commit.hash.slice(0, 8)}](${buildCommitURL(commit)})`;
  570. // @return a markdown link to the PR if available; otherwise, the git commit
  571. function renderLink (commit: Commit) {
  572. const maybePull = commit.prKeys.values().next();
  573. return maybePull.value ? renderPull(maybePull.value) : renderCommit(commit);
  574. }
  575. // @return a terser branch name,
  576. // e.g. '7-2-x' -> '7.2' and '8-x-y' -> '8'
  577. const renderBranchName = (name: string) =>
  578. name.replace(/-[a-zA-Z]/g, '').replace('-', '.');
  579. const renderTrop = (branch: string, ghKey: GHKey) =>
  580. `[${renderBranchName(branch)}](${buildPullURL(ghKey)})`;
  581. // @return markdown-formatted links to other branches' trops,
  582. // e.g. "(Also in 7.2, 8, 9)"
  583. function renderTrops (commit: Commit, excludeBranch: string) {
  584. const body = [...commit.trops.entries()]
  585. .filter(([branch]) => branch !== excludeBranch)
  586. .sort(([branchA], [branchB]) => parseInt(branchA) - parseInt(branchB)) // sort by semver major
  587. .map(([branch, key]) => renderTrop(branch, key))
  588. .join(', ');
  589. return body
  590. ? `<span style="font-size:small;">(Also in ${body})</span>`
  591. : body;
  592. }
  593. // @return a slightly cleaned-up human-readable change description
  594. function renderDescription (commit: Commit) {
  595. let note = commit.note || commit.subject || '';
  596. note = note.trim();
  597. // release notes bullet point every change, so if the note author
  598. // manually started the content with a bullet point, that will confuse
  599. // the markdown renderer -- remove the redundant bullet point
  600. // (example: https://github.com/electron/electron/pull/25216)
  601. if (note.startsWith('*')) {
  602. note = note.slice(1).trim();
  603. }
  604. if (note.length !== 0) {
  605. note = note[0].toUpperCase() + note.substr(1);
  606. if (!note.endsWith('.')) {
  607. note = note + '.';
  608. }
  609. const commonVerbs = {
  610. Added: ['Add'],
  611. Backported: ['Backport'],
  612. Cleaned: ['Clean'],
  613. Disabled: ['Disable'],
  614. Ensured: ['Ensure'],
  615. Exported: ['Export'],
  616. Fixed: ['Fix', 'Fixes'],
  617. Handled: ['Handle'],
  618. Improved: ['Improve'],
  619. Made: ['Make'],
  620. Removed: ['Remove'],
  621. Repaired: ['Repair'],
  622. Reverted: ['Revert'],
  623. Stopped: ['Stop'],
  624. Updated: ['Update'],
  625. Upgraded: ['Upgrade']
  626. };
  627. for (const [key, values] of Object.entries(commonVerbs)) {
  628. for (const value of values) {
  629. const start = `${value} `;
  630. if (note.startsWith(start)) {
  631. note = `${key} ${note.slice(start.length)}`;
  632. }
  633. }
  634. }
  635. }
  636. return note;
  637. }
  638. // @return markdown-formatted release note line item,
  639. // e.g. '* Fixed a foo. #12345 (Also in 7.2, 8, 9)'
  640. const renderNote = (commit: Commit, excludeBranch: string) =>
  641. `* ${renderDescription(commit)} ${renderLink(commit)} ${renderTrops(
  642. commit,
  643. excludeBranch
  644. )}\n`;
  645. const renderNotes = (notes: Awaited<ReturnType<typeof getNotes>>, unique = false) => {
  646. const rendered = [`# Release Notes for ${notes.name}\n\n`];
  647. const renderSection = (title: string, commits: Commit[], unique: boolean) => {
  648. if (unique) {
  649. // omit changes that also landed in other branches
  650. commits = commits.filter(
  651. (commit) => renderTrops(commit, notes.toBranch).length === 0
  652. );
  653. }
  654. if (commits.length > 0) {
  655. rendered.push(
  656. `## ${title}\n\n`,
  657. ...commits.map((commit) => renderNote(commit, notes.toBranch)).sort()
  658. );
  659. }
  660. };
  661. renderSection('Breaking Changes', notes.breaking, unique);
  662. renderSection('Features', notes.feat, unique);
  663. renderSection('Fixes', notes.fix, unique);
  664. renderSection('Other Changes', notes.other, unique);
  665. if (notes.docs.length) {
  666. const docs = notes.docs.map((commit) => renderLink(commit)).sort();
  667. rendered.push(
  668. '## Documentation\n\n',
  669. ` * Documentation changes: ${docs.join(', ')}\n`,
  670. '\n'
  671. );
  672. }
  673. renderSection('Unknown', notes.unknown, unique);
  674. return rendered.join('');
  675. };
  676. /***
  677. **** Module
  678. ***/
  679. export const get = getNotes;
  680. export const render = renderNotes;