notes.js 20 KB

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