notes.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738
  1. #!/usr/bin/env node
  2. const childProcess = require('child_process')
  3. const fs = require('fs')
  4. const os = require('os')
  5. const path = require('path')
  6. const { GitProcess } = require('dugite')
  7. const GitHub = require('github')
  8. const semver = require('semver')
  9. const CACHE_DIR = path.resolve(__dirname, '.cache')
  10. const NO_NOTES = 'No notes'
  11. const FOLLOW_REPOS = [ 'electron/electron', 'electron/libchromiumcontent', 'electron/node' ]
  12. const github = new GitHub()
  13. const gitDir = path.resolve(__dirname, '..', '..')
  14. github.authenticate({ type: 'token', token: process.env.ELECTRON_GITHUB_TOKEN })
  15. const breakTypes = new Set(['breaking-change'])
  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(['spec', 'build', 'test', 'chore', 'deps', 'refactor', 'tools', 'vendor', 'perf', 'style', 'ci'])
  20. const knownTypes = new Set([...breakTypes.keys(), ...docTypes.keys(), ...featTypes.keys(), ...fixTypes.keys(), ...otherTypes.keys()])
  21. const semanticMap = new Map()
  22. for (const line of fs.readFileSync(path.resolve(__dirname, 'legacy-pr-semantic-map.csv'), 'utf8').split('\n')) {
  23. if (!line) {
  24. continue
  25. }
  26. const bits = line.split(',')
  27. if (bits.length !== 2) {
  28. continue
  29. }
  30. semanticMap.set(bits[0], bits[1])
  31. }
  32. const runGit = async (dir, args) => {
  33. const response = await GitProcess.exec(args, dir)
  34. if (response.exitCode !== 0) {
  35. throw new Error(response.stderr.trim())
  36. }
  37. return response.stdout.trim()
  38. }
  39. const getCommonAncestor = async (dir, point1, point2) => {
  40. return runGit(dir, ['merge-base', point1, point2])
  41. }
  42. const setPullRequest = (commit, owner, repo, number) => {
  43. if (!owner || !repo || !number) {
  44. throw new Error(JSON.stringify({ owner, repo, number }, null, 2))
  45. }
  46. if (!commit.originalPr) {
  47. commit.originalPr = commit.pr
  48. }
  49. commit.pr = { owner, repo, number }
  50. if (!commit.originalPr) {
  51. commit.originalPr = commit.pr
  52. }
  53. }
  54. const getNoteFromClerk = async (number, owner, repo) => {
  55. const comments = await getComments(number, owner, repo)
  56. if (!comments || !comments.data) return
  57. const CLERK_LOGIN = 'release-clerk[bot]'
  58. const CLERK_NO_NOTES = '**No Release Notes**'
  59. const PERSIST_LEAD = '**Release Notes Persisted**\n\n'
  60. const QUOTE_LEAD = '> '
  61. for (const comment of comments.data.reverse()) {
  62. if (comment.user.login !== CLERK_LOGIN) {
  63. continue
  64. }
  65. if (comment.body === CLERK_NO_NOTES) {
  66. return NO_NOTES
  67. }
  68. if (comment.body.startsWith(PERSIST_LEAD)) {
  69. return comment.body
  70. .slice(PERSIST_LEAD.length).trim() // remove PERSIST_LEAD
  71. .split('\r?\n') // break into lines
  72. .map(line => line.trim())
  73. .filter(line => line.startsWith(QUOTE_LEAD)) // notes are quoted
  74. .map(line => line.slice(QUOTE_LEAD.length)) // unquote the lines
  75. .join(' ') // join the note lines
  76. .trim()
  77. }
  78. }
  79. }
  80. // copied from https://github.com/electron/clerk/blob/master/src/index.ts#L4-L13
  81. const OMIT_FROM_RELEASE_NOTES_KEYS = [
  82. 'no-notes',
  83. 'no notes',
  84. 'no_notes',
  85. 'none',
  86. 'no',
  87. 'nothing',
  88. 'empty',
  89. 'blank'
  90. ]
  91. const getNoteFromBody = body => {
  92. if (!body) {
  93. return null
  94. }
  95. const NOTE_PREFIX = 'Notes: '
  96. const NOTE_HEADER = '#### Release Notes'
  97. let note = body
  98. .split(/\r?\n\r?\n/) // split into paragraphs
  99. .map(paragraph => paragraph.trim())
  100. .map(paragraph => paragraph.startsWith(NOTE_HEADER) ? paragraph.slice(NOTE_HEADER.length).trim() : paragraph)
  101. .find(paragraph => paragraph.startsWith(NOTE_PREFIX))
  102. if (note) {
  103. note = note
  104. .slice(NOTE_PREFIX.length)
  105. .replace(/<!--.*-->/, '') // '<!-- change summary here-->'
  106. .replace(/\r?\n/, ' ') // remove newlines
  107. .trim()
  108. }
  109. if (note && OMIT_FROM_RELEASE_NOTES_KEYS.includes(note.toLowerCase())) {
  110. return NO_NOTES
  111. }
  112. return note
  113. }
  114. /**
  115. * Looks for our project's conventions in the commit message:
  116. *
  117. * 'semantic: some description' -- sets type, subject
  118. * 'some description (#99999)' -- sets subject, pr
  119. * 'Fixes #3333' -- sets issueNumber
  120. * 'Merge pull request #99999 from ${branchname}' -- sets pr
  121. * 'This reverts commit ${sha}' -- sets revertHash
  122. * line starting with 'BREAKING CHANGE' in body -- sets breakingChange
  123. * 'Backport of #99999' -- sets pr
  124. */
  125. const parseCommitMessage = (commitMessage, owner, repo, commit = {}) => {
  126. // split commitMessage into subject & body
  127. let subject = commitMessage
  128. let body = ''
  129. const pos = subject.indexOf('\n')
  130. if (pos !== -1) {
  131. body = subject.slice(pos).trim()
  132. subject = subject.slice(0, pos).trim()
  133. }
  134. if (!commit.originalSubject) {
  135. commit.originalSubject = subject
  136. }
  137. if (body) {
  138. commit.body = body
  139. const note = getNoteFromBody(body)
  140. if (note) { commit.note = note }
  141. }
  142. // if the subject ends in ' (#dddd)', treat it as a pull request id
  143. let match
  144. if ((match = subject.match(/^(.*)\s\(#(\d+)\)$/))) {
  145. setPullRequest(commit, owner, repo, parseInt(match[2]))
  146. subject = match[1]
  147. }
  148. // if the subject begins with 'word:', treat it as a semantic commit
  149. if ((match = subject.match(/^(\w+):\s(.*)$/))) {
  150. const type = match[1].toLocaleLowerCase()
  151. if (knownTypes.has(type)) {
  152. commit.type = type
  153. subject = match[2]
  154. }
  155. }
  156. // Check for GitHub commit message that indicates a PR
  157. if ((match = subject.match(/^Merge pull request #(\d+) from (.*)$/))) {
  158. setPullRequest(commit, owner, repo, parseInt(match[1]))
  159. commit.pr.branch = match[2].trim()
  160. }
  161. // Check for a trop comment that indicates a PR
  162. if ((match = commitMessage.match(/\bBackport of #(\d+)\b/))) {
  163. setPullRequest(commit, owner, repo, parseInt(match[1]))
  164. }
  165. // https://help.github.com/articles/closing-issues-using-keywords/
  166. if ((match = subject.match(/\b(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved|for)\s#(\d+)\b/))) {
  167. commit.issueNumber = parseInt(match[1])
  168. if (!commit.type) {
  169. commit.type = 'fix'
  170. }
  171. }
  172. // look for 'fixes' in markdown; e.g. 'Fixes [#8952](https://github.com/electron/electron/issues/8952)'
  173. if (!commit.issueNumber && ((match = commitMessage.match(/Fixes \[#(\d+)\]\(https:\/\/github.com\/(\w+)\/(\w+)\/issues\/(\d+)\)/)))) {
  174. commit.issueNumber = parseInt(match[1])
  175. if (commit.pr && commit.pr.number === commit.issueNumber) {
  176. commit.pr = null
  177. }
  178. if (commit.originalPr && commit.originalPr.number === commit.issueNumber) {
  179. commit.originalPr = null
  180. }
  181. if (!commit.type) {
  182. commit.type = 'fix'
  183. }
  184. }
  185. // https://www.conventionalcommits.org/en
  186. if (commitMessage
  187. .split(/\r?\n/) // split into lines
  188. .map(line => line.trim())
  189. .some(line => line.startsWith('BREAKING CHANGE'))) {
  190. commit.type = 'breaking-change'
  191. }
  192. // Check for a reversion commit
  193. if ((match = body.match(/This reverts commit ([a-f0-9]{40})\./))) {
  194. commit.revertHash = match[1]
  195. }
  196. // Edge case: manual backport where commit has `owner/repo#pull` notation
  197. if (commitMessage.toLowerCase().includes('backport') &&
  198. ((match = commitMessage.match(/\b(\w+)\/(\w+)#(\d+)\b/)))) {
  199. const [ , owner, repo, number ] = match
  200. if (FOLLOW_REPOS.includes(`${owner}/${repo}`)) {
  201. setPullRequest(commit, owner, repo, number)
  202. }
  203. }
  204. // Edge case: manual backport where commit has a link to the backport PR
  205. if (commitMessage.includes('ackport') &&
  206. ((match = commitMessage.match(/https:\/\/github\.com\/(\w+)\/(\w+)\/pull\/(\d+)/)))) {
  207. const [ , owner, repo, number ] = match
  208. if (FOLLOW_REPOS.includes(`${owner}/${repo}`)) {
  209. setPullRequest(commit, owner, repo, number)
  210. }
  211. }
  212. // Legacy commits: pre-semantic commits
  213. if (!commit.type || commit.type === 'chore') {
  214. const commitMessageLC = commitMessage.toLocaleLowerCase()
  215. if ((match = commitMessageLC.match(/\bchore\((\w+)\):/))) {
  216. // example: 'Chore(docs): description'
  217. commit.type = knownTypes.has(match[1]) ? match[1] : 'chore'
  218. } else if (commitMessageLC.match(/\b(?:fix|fixes|fixed)/)) {
  219. // example: 'fix a bug'
  220. commit.type = 'fix'
  221. } else if (commitMessageLC.match(/\[(?:docs|doc)\]/)) {
  222. // example: '[docs]
  223. commit.type = 'doc'
  224. }
  225. }
  226. commit.subject = subject.trim()
  227. return commit
  228. }
  229. const getLocalCommitHashes = async (dir, ref) => {
  230. const args = ['log', '-z', `--format=%H`, ref]
  231. return (await runGit(dir, args)).split(`\0`).map(hash => hash.trim())
  232. }
  233. /*
  234. * possible properties:
  235. * breakingChange, email, hash, issueNumber, originalSubject, parentHashes,
  236. * pr { owner, repo, number, branch }, revertHash, subject, type
  237. */
  238. const getLocalCommitDetails = async (module, point1, point2) => {
  239. const { owner, repo, dir } = module
  240. const fieldSep = '||'
  241. const format = ['%H', '%P', '%aE', '%B'].join(fieldSep)
  242. const args = ['log', '-z', '--cherry-pick', '--right-only', '--first-parent', `--format=${format}`, `${point1}..${point2}`]
  243. const commits = (await runGit(dir, args)).split(`\0`).map(field => field.trim())
  244. const details = []
  245. for (const commit of commits) {
  246. if (!commit) {
  247. continue
  248. }
  249. const [ hash, parentHashes, email, commitMessage ] = commit.split(fieldSep, 4).map(field => field.trim())
  250. details.push(parseCommitMessage(commitMessage, owner, repo, {
  251. email,
  252. hash,
  253. owner,
  254. repo,
  255. parentHashes: parentHashes.split()
  256. }))
  257. }
  258. return details
  259. }
  260. const checkCache = async (name, operation) => {
  261. const filename = path.resolve(CACHE_DIR, name)
  262. if (fs.existsSync(filename)) {
  263. return JSON.parse(fs.readFileSync(filename, 'utf8'))
  264. }
  265. const response = await operation()
  266. if (response) {
  267. fs.writeFileSync(filename, JSON.stringify(response))
  268. }
  269. return response
  270. }
  271. const getPullRequest = async (number, owner, repo) => {
  272. const name = `${owner}-${repo}-pull-${number}`
  273. return checkCache(name, async () => {
  274. try {
  275. return await github.pullRequests.get({ number, owner, repo })
  276. } catch (error) {
  277. // Silently eat 404s.
  278. // We can get a bad pull number if someone manually lists
  279. // an issue number in PR number notation, e.g. 'fix: foo (#123)'
  280. if (error.code !== 404) {
  281. throw error
  282. }
  283. }
  284. })
  285. }
  286. const getComments = async (number, owner, repo) => {
  287. const name = `${owner}-${repo}-pull-${number}-comments`
  288. return checkCache(name, async () => {
  289. try {
  290. return await github.issues.getComments({ number, owner, repo, per_page: 100 })
  291. } catch (error) {
  292. // Silently eat 404s.
  293. // We can get a bad pull number if someone manually lists
  294. // an issue number in PR number notation, e.g. 'fix: foo (#123)'
  295. if (error.code !== 404) {
  296. throw error
  297. }
  298. }
  299. })
  300. }
  301. const addRepoToPool = async (pool, repo, from, to) => {
  302. const commonAncestor = await getCommonAncestor(repo.dir, from, to)
  303. const oldHashes = await getLocalCommitHashes(repo.dir, from)
  304. oldHashes.forEach(hash => { pool.processedHashes.add(hash) })
  305. const commits = await getLocalCommitDetails(repo, commonAncestor, to)
  306. pool.commits.push(...commits)
  307. }
  308. /***
  309. **** Other Repos
  310. ***/
  311. // other repos - gyp
  312. const getGypSubmoduleRef = async (dir, point) => {
  313. // example: '160000 commit 028b0af83076cec898f4ebce208b7fadb715656e libchromiumcontent'
  314. const response = await runGit(
  315. path.dirname(dir),
  316. ['ls-tree', '-t', point, path.basename(dir)]
  317. )
  318. const line = response.split('\n').filter(line => line.startsWith('160000')).shift()
  319. const tokens = line ? line.split(/\s/).map(token => token.trim()) : null
  320. const ref = tokens && tokens.length >= 3 ? tokens[2] : null
  321. return ref
  322. }
  323. const getDependencyCommitsGyp = async (pool, fromRef, toRef) => {
  324. const commits = []
  325. const repos = [{
  326. owner: 'electron',
  327. repo: 'libchromiumcontent',
  328. dir: path.resolve(gitDir, 'vendor', 'libchromiumcontent')
  329. }, {
  330. owner: 'electron',
  331. repo: 'node',
  332. dir: path.resolve(gitDir, 'vendor', 'node')
  333. }]
  334. for (const repo of repos) {
  335. const from = await getGypSubmoduleRef(repo.dir, fromRef)
  336. const to = await getGypSubmoduleRef(repo.dir, toRef)
  337. await addRepoToPool(pool, repo, from, to)
  338. }
  339. return commits
  340. }
  341. // other repos - gn
  342. const getDepsVariable = async (ref, key) => {
  343. // get a copy of that reference point's DEPS file
  344. const deps = await runGit(gitDir, ['show', `${ref}:DEPS`])
  345. const filename = path.resolve(os.tmpdir(), 'DEPS')
  346. fs.writeFileSync(filename, deps)
  347. // query the DEPS file
  348. const response = childProcess.spawnSync(
  349. 'gclient',
  350. ['getdep', '--deps-file', filename, '--var', key],
  351. { encoding: 'utf8' }
  352. )
  353. // cleanup
  354. fs.unlinkSync(filename)
  355. return response.stdout.trim()
  356. }
  357. const getDependencyCommitsGN = async (pool, fromRef, toRef) => {
  358. const repos = [{ // just node
  359. owner: 'electron',
  360. repo: 'node',
  361. dir: path.resolve(gitDir, '..', 'third_party', 'electron_node'),
  362. deps_variable_name: 'node_version'
  363. }]
  364. for (const repo of repos) {
  365. // the 'DEPS' file holds the dependency reference point
  366. const key = repo.deps_variable_name
  367. const from = await getDepsVariable(fromRef, key)
  368. const to = await getDepsVariable(toRef, key)
  369. await addRepoToPool(pool, repo, from, to)
  370. }
  371. }
  372. // other repos - controller
  373. const getDependencyCommits = async (pool, from, to) => {
  374. const filename = path.resolve(gitDir, 'vendor', 'libchromiumcontent')
  375. const useGyp = fs.existsSync(filename)
  376. return useGyp
  377. ? getDependencyCommitsGyp(pool, from, to)
  378. : getDependencyCommitsGN(pool, from, to)
  379. }
  380. // Changes are interesting if they make a change relative to a previous
  381. // release in the same series. For example if you fix a Y.0.0 bug, that
  382. // should be included in the Y.0.1 notes even if it's also tropped back
  383. // to X.0.1.
  384. //
  385. // The phrase 'previous release' is important: if this is the first
  386. // prerelease or first stable release in a series, we omit previous
  387. // branches' changes. Otherwise we will have an overwhelmingly long
  388. // list of mostly-irrelevant changes.
  389. const shouldIncludeMultibranchChanges = (version) => {
  390. let show = true
  391. if (semver.valid(version)) {
  392. const prerelease = semver.prerelease(version)
  393. show = prerelease
  394. ? parseInt(prerelease.pop()) > 1
  395. : semver.patch(version) > 0
  396. }
  397. return show
  398. }
  399. /***
  400. **** Main
  401. ***/
  402. const getNotes = async (fromRef, toRef, newVersion) => {
  403. if (!fs.existsSync(CACHE_DIR)) {
  404. fs.mkdirSync(CACHE_DIR)
  405. }
  406. const pool = {
  407. processedHashes: new Set(),
  408. commits: []
  409. }
  410. // get the electron/electron commits
  411. const electron = { owner: 'electron', repo: 'electron', dir: gitDir }
  412. await addRepoToPool(pool, electron, fromRef, toRef)
  413. // Don't include submodules if comparing across major versions;
  414. // there's just too much churn otherwise.
  415. const includeDeps = semver.valid(fromRef) &&
  416. semver.valid(toRef) &&
  417. semver.major(fromRef) === semver.major(toRef)
  418. if (includeDeps) {
  419. await getDependencyCommits(pool, fromRef, toRef)
  420. }
  421. // remove any old commits
  422. pool.commits = pool.commits.filter(commit => !pool.processedHashes.has(commit.hash))
  423. // if a commmit _and_ revert occurred in the unprocessed set, skip them both
  424. for (const commit of pool.commits) {
  425. const revertHash = commit.revertHash
  426. if (!revertHash) {
  427. continue
  428. }
  429. const revert = pool.commits.find(commit => commit.hash === revertHash)
  430. if (!revert) {
  431. continue
  432. }
  433. commit.note = NO_NOTES
  434. revert.note = NO_NOTES
  435. pool.processedHashes.add(commit.hash)
  436. pool.processedHashes.add(revertHash)
  437. }
  438. // scrape PRs for release note 'Notes:' comments
  439. for (const commit of pool.commits) {
  440. let pr = commit.pr
  441. let prSubject
  442. while (pr && !commit.note) {
  443. const note = await getNoteFromClerk(pr.number, pr.owner, pr.repo)
  444. if (note) {
  445. commit.note = note
  446. }
  447. // if we already have all the data we need, stop scraping the PRs
  448. if (commit.note && commit.type && prSubject) {
  449. break
  450. }
  451. const prData = await getPullRequest(pr.number, pr.owner, pr.repo)
  452. if (!prData || !prData.data) {
  453. break
  454. }
  455. // try to pull a release note from the pull comment
  456. const prParsed = {}
  457. parseCommitMessage(`${prData.data.title}\n\n${prData.data.body}`, pr.owner, pr.repo, prParsed)
  458. commit.note = commit.note || prParsed.note
  459. commit.type = commit.type || prParsed.type
  460. prSubject = prSubject || prParsed.subject
  461. pr = prParsed.pr && (prParsed.pr.number !== pr.number) ? prParsed.pr : null
  462. }
  463. // if we still don't have a note, it's because someone missed a 'Notes:
  464. // comment in a PR somewhere... use the PR subject as a fallback.
  465. commit.note = commit.note || prSubject
  466. }
  467. // remove non-user-facing commits
  468. pool.commits = pool.commits
  469. .filter(commit => commit.note !== NO_NOTES)
  470. .filter(commit => !((commit.note || commit.subject).match(/^[Bb]ump v\d+\.\d+\.\d+/)))
  471. if (!shouldIncludeMultibranchChanges(newVersion)) {
  472. // load all the prDatas
  473. await Promise.all(
  474. pool.commits.map(commit => new Promise(async (resolve) => {
  475. const { pr } = commit
  476. if (typeof pr === 'object') {
  477. const prData = await getPullRequest(pr.number, pr.owner, pr.repo)
  478. if (prData) {
  479. commit.prData = prData
  480. }
  481. }
  482. resolve()
  483. }))
  484. )
  485. // remove items that already landed in a previous major/minor series
  486. pool.commits = pool.commits
  487. .filter(commit => {
  488. if (!commit.prData) {
  489. return true
  490. }
  491. const reducer = (accumulator, current) => {
  492. if (!semver.valid(accumulator)) { return current }
  493. if (!semver.valid(current)) { return accumulator }
  494. return semver.lt(accumulator, current) ? accumulator : current
  495. }
  496. const earliestRelease = commit.prData.data.labels
  497. .map(label => label.name.match(/merged\/(\d+)-(\d+)-x/))
  498. .filter(label => !!label)
  499. .map(label => `${label[1]}.${label[2]}.0`)
  500. .reduce(reducer, null)
  501. if (!semver.valid(earliestRelease)) {
  502. return true
  503. }
  504. return semver.diff(earliestRelease, newVersion).includes('patch')
  505. })
  506. }
  507. const notes = {
  508. breaking: [],
  509. docs: [],
  510. feat: [],
  511. fix: [],
  512. other: [],
  513. unknown: [],
  514. name: newVersion
  515. }
  516. pool.commits.forEach(commit => {
  517. const str = commit.type
  518. if (!str) {
  519. notes.unknown.push(commit)
  520. } else if (breakTypes.has(str)) {
  521. notes.breaking.push(commit)
  522. } else if (docTypes.has(str)) {
  523. notes.docs.push(commit)
  524. } else if (featTypes.has(str)) {
  525. notes.feat.push(commit)
  526. } else if (fixTypes.has(str)) {
  527. notes.fix.push(commit)
  528. } else if (otherTypes.has(str)) {
  529. notes.other.push(commit)
  530. } else {
  531. notes.unknown.push(commit)
  532. }
  533. })
  534. return notes
  535. }
  536. /***
  537. **** Render
  538. ***/
  539. const renderLink = (commit, explicitLinks) => {
  540. let link
  541. const pr = commit.originalPr
  542. if (pr) {
  543. const { owner, repo, number } = pr
  544. const url = `https://github.com/${owner}/${repo}/pull/${number}`
  545. const text = owner === 'electron' && repo === 'electron'
  546. ? `#${number}`
  547. : `${owner}/${repo}#${number}`
  548. link = explicitLinks ? `[${text}](${url})` : text
  549. } else {
  550. const { owner, repo, hash } = commit
  551. const url = `https://github.com/${owner}/${repo}/commit/${hash}`
  552. const text = owner === 'electron' && repo === 'electron'
  553. ? `${hash.slice(0, 8)}`
  554. : `${owner}/${repo}@${hash.slice(0, 8)}`
  555. link = explicitLinks ? `[${text}](${url})` : text
  556. }
  557. return link
  558. }
  559. const renderCommit = (commit, explicitLinks) => {
  560. // clean up the note
  561. let note = commit.note || commit.subject
  562. note = note.trim()
  563. if (note.length !== 0) {
  564. note = note[0].toUpperCase() + note.substr(1)
  565. if (!note.endsWith('.')) {
  566. note = note + '.'
  567. }
  568. const commonVerbs = {
  569. 'Added': [ 'Add' ],
  570. 'Backported': [ 'Backport' ],
  571. 'Cleaned': [ 'Clean' ],
  572. 'Disabled': [ 'Disable' ],
  573. 'Ensured': [ 'Ensure' ],
  574. 'Exported': [ 'Export' ],
  575. 'Fixed': [ 'Fix', 'Fixes' ],
  576. 'Handled': [ 'Handle' ],
  577. 'Improved': [ 'Improve' ],
  578. 'Made': [ 'Make' ],
  579. 'Removed': [ 'Remove' ],
  580. 'Repaired': [ 'Repair' ],
  581. 'Reverted': [ 'Revert' ],
  582. 'Stopped': [ 'Stop' ],
  583. 'Updated': [ 'Update' ],
  584. 'Upgraded': [ 'Upgrade' ]
  585. }
  586. for (const [key, values] of Object.entries(commonVerbs)) {
  587. for (const value of values) {
  588. const start = `${value} `
  589. if (note.startsWith(start)) {
  590. note = `${key} ${note.slice(start.length)}`
  591. }
  592. }
  593. }
  594. }
  595. const link = renderLink(commit, explicitLinks)
  596. return { note, link }
  597. }
  598. const renderNotes = (notes, explicitLinks) => {
  599. const rendered = [ `# Release Notes for ${notes.name}\n\n` ]
  600. const renderSection = (title, commits) => {
  601. if (commits.length === 0) {
  602. return
  603. }
  604. const notes = new Map()
  605. for (const note of commits.map(commit => renderCommit(commit, explicitLinks))) {
  606. if (!notes.has(note.note)) {
  607. notes.set(note.note, [note.link])
  608. } else {
  609. notes.get(note.note).push(note.link)
  610. }
  611. }
  612. rendered.push(`## ${title}\n\n`)
  613. const lines = []
  614. notes.forEach((links, key) => lines.push(` * ${key} ${links.map(link => link.toString()).sort().join(', ')}\n`))
  615. rendered.push(...lines.sort(), '\n')
  616. }
  617. renderSection('Breaking Changes', notes.breaking)
  618. renderSection('Features', notes.feat)
  619. renderSection('Fixes', notes.fix)
  620. renderSection('Other Changes', notes.other)
  621. if (notes.docs.length) {
  622. const docs = notes.docs.map(commit => renderLink(commit, explicitLinks)).sort()
  623. rendered.push('## Documentation\n\n', ` * Documentation changes: ${docs.join(', ')}\n`, '\n')
  624. }
  625. renderSection('Unknown', notes.unknown)
  626. return rendered.join('')
  627. }
  628. /***
  629. **** Module
  630. ***/
  631. module.exports = {
  632. get: getNotes,
  633. render: renderNotes
  634. }