index.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  1. const { GitProcess } = require('dugite')
  2. const Entities = require('html-entities').AllHtmlEntities
  3. const fetch = require('node-fetch')
  4. const fs = require('fs')
  5. const GitHub = require('github')
  6. const path = require('path')
  7. const semver = require('semver')
  8. const CACHE_DIR = path.resolve(__dirname, '.cache')
  9. // Fill this with tags to ignore if you are generating release notes for older
  10. // versions
  11. //
  12. // E.g. ['v3.0.0-beta.1'] to generate the release notes for 3.0.0-beta.1 :) from
  13. // the current 3-0-x branch
  14. const EXCLUDE_TAGS = []
  15. const entities = new Entities()
  16. const github = new GitHub()
  17. const gitDir = path.resolve(__dirname, '..', '..')
  18. github.authenticate({ type: 'token', token: process.env.ELECTRON_GITHUB_TOKEN })
  19. let currentBranch
  20. const semanticMap = new Map()
  21. for (const line of fs.readFileSync(path.resolve(__dirname, 'legacy-pr-semantic-map.csv'), 'utf8').split('\n')) {
  22. if (!line) continue
  23. const bits = line.split(',')
  24. if (bits.length !== 2) continue
  25. semanticMap.set(bits[0], bits[1])
  26. }
  27. const getCurrentBranch = async () => {
  28. if (currentBranch) return currentBranch
  29. const gitArgs = ['rev-parse', '--abbrev-ref', 'HEAD']
  30. const branchDetails = await GitProcess.exec(gitArgs, gitDir)
  31. if (branchDetails.exitCode === 0) {
  32. currentBranch = branchDetails.stdout.trim()
  33. return currentBranch
  34. }
  35. throw GitProcess.parseError(branchDetails.stderr)
  36. }
  37. const getBranchOffPoint = async (branchName) => {
  38. const gitArgs = ['merge-base', branchName, 'master']
  39. const commitDetails = await GitProcess.exec(gitArgs, gitDir)
  40. if (commitDetails.exitCode === 0) {
  41. return commitDetails.stdout.trim()
  42. }
  43. throw GitProcess.parseError(commitDetails.stderr)
  44. }
  45. const getTagsOnBranch = async (branchName) => {
  46. const gitArgs = ['tag', '--merged', branchName]
  47. const tagDetails = await GitProcess.exec(gitArgs, gitDir)
  48. if (tagDetails.exitCode === 0) {
  49. return tagDetails.stdout.trim().split('\n').filter(tag => !EXCLUDE_TAGS.includes(tag))
  50. }
  51. throw GitProcess.parseError(tagDetails.stderr)
  52. }
  53. const memLastKnownRelease = new Map()
  54. const getLastKnownReleaseOnBranch = async (branchName) => {
  55. if (memLastKnownRelease.has(branchName)) {
  56. return memLastKnownRelease.get(branchName)
  57. }
  58. const tags = await getTagsOnBranch(branchName)
  59. if (!tags.length) {
  60. throw new Error(`Branch ${branchName} has no tags, we have no idea what the last release was`)
  61. }
  62. const branchOffPointTags = await getTagsOnBranch(await getBranchOffPoint(branchName))
  63. if (branchOffPointTags.length >= tags.length) {
  64. // No release on this branch
  65. return null
  66. }
  67. memLastKnownRelease.set(branchName, tags[tags.length - 1])
  68. // Latest tag is the latest release
  69. return tags[tags.length - 1]
  70. }
  71. const getBranches = async () => {
  72. const gitArgs = ['branch', '--remote']
  73. const branchDetails = await GitProcess.exec(gitArgs, gitDir)
  74. if (branchDetails.exitCode === 0) {
  75. return branchDetails.stdout.trim().split('\n').map(b => b.trim()).filter(branch => branch !== 'origin/HEAD -> origin/master')
  76. }
  77. throw GitProcess.parseError(branchDetails.stderr)
  78. }
  79. const semverify = (v) => v.replace(/^origin\//, '').replace('x', '0').replace(/-/g, '.')
  80. const getLastReleaseBranch = async () => {
  81. const current = await getCurrentBranch()
  82. const allBranches = await getBranches()
  83. const releaseBranches = allBranches
  84. .filter(branch => /^origin\/[0-9]+-[0-9]+-x$/.test(branch))
  85. .filter(branch => branch !== current && branch !== `origin/${current}`)
  86. let latest = null
  87. for (const b of releaseBranches) {
  88. if (latest === null) latest = b
  89. if (semver.gt(semverify(b), semverify(latest))) {
  90. latest = b
  91. }
  92. }
  93. return latest
  94. }
  95. const commitBeforeTag = async (commit, tag) => {
  96. const gitArgs = ['tag', '--contains', commit]
  97. const tagDetails = await GitProcess.exec(gitArgs, gitDir)
  98. if (tagDetails.exitCode === 0) {
  99. return tagDetails.stdout.split('\n').includes(tag)
  100. }
  101. throw GitProcess.parseError(tagDetails.stderr)
  102. }
  103. const getCommitsMergedIntoCurrentBranchSincePoint = async (point) => {
  104. return getCommitsBetween(point, 'HEAD')
  105. }
  106. const getCommitsBetween = async (point1, point2) => {
  107. const gitArgs = ['rev-list', `${point1}..${point2}`]
  108. const commitsDetails = await GitProcess.exec(gitArgs, gitDir)
  109. if (commitsDetails.exitCode !== 0) {
  110. throw GitProcess.parseError(commitsDetails.stderr)
  111. }
  112. return commitsDetails.stdout.trim().split('\n')
  113. }
  114. const TITLE_PREFIX = 'Merged Pull Request: '
  115. const getCommitDetails = async (commitHash) => {
  116. const commitInfo = await (await fetch(`https://github.com/electron/electron/branch_commits/${commitHash}`)).text()
  117. const bits = commitInfo.split('</a>)')[0].split('>')
  118. const prIdent = bits[bits.length - 1].trim()
  119. if (!prIdent || commitInfo.indexOf('href="/electron/electron/pull') === -1) {
  120. console.warn(`WARNING: Could not track commit "${commitHash}" to a pull request, it may have been committed directly to the branch`)
  121. return null
  122. }
  123. const title = commitInfo.split('title="')[1].split('"')[0]
  124. if (!title.startsWith(TITLE_PREFIX)) {
  125. console.warn(`WARNING: Unknown PR title for commit "${commitHash}" in PR "${prIdent}"`)
  126. return null
  127. }
  128. return {
  129. mergedFrom: prIdent,
  130. prTitle: entities.decode(title.substr(TITLE_PREFIX.length))
  131. }
  132. }
  133. const doWork = async (items, fn, concurrent = 5) => {
  134. const results = []
  135. const toUse = [].concat(items)
  136. let i = 1
  137. const doBit = async () => {
  138. if (toUse.length === 0) return
  139. console.log(`Running ${i}/${items.length}`)
  140. i += 1
  141. const item = toUse.pop()
  142. const index = toUse.length
  143. results[index] = await fn(item)
  144. await doBit()
  145. }
  146. const bits = []
  147. for (let i = 0; i < concurrent; i += 1) {
  148. bits.push(doBit())
  149. }
  150. await Promise.all(bits)
  151. return results
  152. }
  153. const notes = new Map()
  154. const NoteType = {
  155. FIX: 'fix',
  156. FEATURE: 'feature',
  157. BREAKING_CHANGE: 'breaking-change',
  158. DOCUMENTATION: 'doc',
  159. OTHER: 'other',
  160. UNKNOWN: 'unknown'
  161. }
  162. class Note {
  163. constructor (trueTitle, prNumber, ignoreIfInVersion) {
  164. // Self bindings
  165. this.guessType = this.guessType.bind(this)
  166. this.fetchPrInfo = this.fetchPrInfo.bind(this)
  167. this._getPr = this._getPr.bind(this)
  168. if (!trueTitle.trim()) console.error(prNumber)
  169. this._ignoreIfInVersion = ignoreIfInVersion
  170. this.reverted = false
  171. if (notes.has(trueTitle)) {
  172. console.warn(`Duplicate PR trueTitle: "${trueTitle}", "${prNumber}" this might cause weird reversions (this would be RARE)`)
  173. }
  174. // Memoize
  175. notes.set(trueTitle, this)
  176. this.originalTitle = trueTitle
  177. this.title = trueTitle
  178. this.prNumber = prNumber
  179. this.stripColon = true
  180. if (this.guessType() !== NoteType.UNKNOWN && this.stripColon) {
  181. this.title = trueTitle.split(':').slice(1).join(':').trim()
  182. }
  183. }
  184. guessType () {
  185. if (this.originalTitle.startsWith('fix:') ||
  186. this.originalTitle.startsWith('Fix:')) return NoteType.FIX
  187. if (this.originalTitle.startsWith('feat:')) return NoteType.FEATURE
  188. if (this.originalTitle.startsWith('spec:') ||
  189. this.originalTitle.startsWith('build:') ||
  190. this.originalTitle.startsWith('test:') ||
  191. this.originalTitle.startsWith('chore:') ||
  192. this.originalTitle.startsWith('deps:') ||
  193. this.originalTitle.startsWith('refactor:') ||
  194. this.originalTitle.startsWith('tools:') ||
  195. this.originalTitle.startsWith('vendor:') ||
  196. this.originalTitle.startsWith('perf:') ||
  197. this.originalTitle.startsWith('style:') ||
  198. this.originalTitle.startsWith('ci')) return NoteType.OTHER
  199. if (this.originalTitle.startsWith('doc:') ||
  200. this.originalTitle.startsWith('docs:')) return NoteType.DOCUMENTATION
  201. this.stripColon = false
  202. if (this.pr && this.pr.data.labels.find(label => label.name === 'semver/breaking-change')) {
  203. return NoteType.BREAKING_CHANGE
  204. }
  205. // FIXME: Backported features will not be picked up by this
  206. if (this.pr && this.pr.data.labels.find(label => label.name === 'semver/nonbreaking-feature')) {
  207. return NoteType.FEATURE
  208. }
  209. const n = this.prNumber.replace('#', '')
  210. if (semanticMap.has(n)) {
  211. switch (semanticMap.get(n)) {
  212. case 'feat':
  213. return NoteType.FEATURE
  214. case 'fix':
  215. return NoteType.FIX
  216. case 'breaking-change':
  217. return NoteType.BREAKING_CHANGE
  218. case 'doc':
  219. return NoteType.DOCUMENTATION
  220. case 'build':
  221. case 'vendor':
  222. case 'refactor':
  223. case 'spec':
  224. return NoteType.OTHER
  225. default:
  226. throw new Error(`Unknown semantic mapping: ${semanticMap.get(n)}`)
  227. }
  228. }
  229. return NoteType.UNKNOWN
  230. }
  231. async _getPr (n) {
  232. const cachePath = path.resolve(CACHE_DIR, n)
  233. if (fs.existsSync(cachePath)) {
  234. return JSON.parse(fs.readFileSync(cachePath, 'utf8'))
  235. } else {
  236. try {
  237. const pr = await github.pullRequests.get({
  238. number: n,
  239. owner: 'electron',
  240. repo: 'electron'
  241. })
  242. fs.writeFileSync(cachePath, JSON.stringify({ data: pr.data }))
  243. return pr
  244. } catch (err) {
  245. console.info('#### FAILED:', `#${n}`)
  246. throw err
  247. }
  248. }
  249. }
  250. async fetchPrInfo () {
  251. if (this.pr) return
  252. const n = this.prNumber.replace('#', '')
  253. this.pr = await this._getPr(n)
  254. if (this.pr.data.labels.find(label => label.name === `merged/${this._ignoreIfInVersion.replace('origin/', '')}`)) {
  255. // This means we probably backported this PR, let's try figure out what
  256. // the corresponding backport PR would be by searching through comments
  257. // for trop
  258. let comments
  259. const cacheCommentsPath = path.resolve(CACHE_DIR, `${n}-comments`)
  260. if (fs.existsSync(cacheCommentsPath)) {
  261. comments = JSON.parse(fs.readFileSync(cacheCommentsPath, 'utf8'))
  262. } else {
  263. comments = await github.issues.getComments({
  264. number: n,
  265. owner: 'electron',
  266. repo: 'electron',
  267. per_page: 100
  268. })
  269. fs.writeFileSync(cacheCommentsPath, JSON.stringify({ data: comments.data }))
  270. }
  271. const tropComment = comments.data.find(
  272. c => (
  273. new RegExp(`We have automatically backported this PR to "${this._ignoreIfInVersion.replace('origin/', '')}", please check out #[0-9]+`)
  274. ).test(c.body)
  275. )
  276. if (tropComment) {
  277. const commentBits = tropComment.body.split('#')
  278. const tropPrNumber = commentBits[commentBits.length - 1]
  279. const tropPr = await this._getPr(tropPrNumber)
  280. if (tropPr.data.merged && tropPr.data.merge_commit_sha) {
  281. if (await commitBeforeTag(tropPr.data.merge_commit_sha, await getLastKnownReleaseOnBranch(this._ignoreIfInVersion))) {
  282. this.reverted = true
  283. console.log('PR', this.prNumber, 'was backported to a previous version, ignoring from notes')
  284. }
  285. }
  286. }
  287. }
  288. }
  289. }
  290. Note.findByTrueTitle = (trueTitle) => notes.get(trueTitle)
  291. class ReleaseNotes {
  292. constructor (ignoreIfInVersion) {
  293. this._ignoreIfInVersion = ignoreIfInVersion
  294. this._handledPrs = new Set()
  295. this._revertedPrs = new Set()
  296. this.other = []
  297. this.docs = []
  298. this.fixes = []
  299. this.features = []
  300. this.breakingChanges = []
  301. this.unknown = []
  302. }
  303. async parseCommits (commitHashes) {
  304. await doWork(commitHashes, async (commit) => {
  305. const info = await getCommitDetails(commit)
  306. if (!info) return
  307. // Only handle each PR once
  308. if (this._handledPrs.has(info.mergedFrom)) return
  309. this._handledPrs.add(info.mergedFrom)
  310. // Strip the trop backport prefix
  311. const trueTitle = info.prTitle.replace(/^Backport \([0-9]+-[0-9]+-x\) - /, '')
  312. if (this._revertedPrs.has(trueTitle)) return
  313. // Handle PRs that revert other PRs
  314. if (trueTitle.startsWith('Revert "')) {
  315. const revertedTrueTitle = trueTitle.substr(8, trueTitle.length - 9)
  316. this._revertedPrs.add(revertedTrueTitle)
  317. const existingNote = Note.findByTrueTitle(revertedTrueTitle)
  318. if (existingNote) {
  319. existingNote.reverted = true
  320. }
  321. return
  322. }
  323. // Add a note for this PR
  324. const note = new Note(trueTitle, info.mergedFrom, this._ignoreIfInVersion)
  325. try {
  326. await note.fetchPrInfo()
  327. } catch (err) {
  328. console.error(commit, info)
  329. throw err
  330. }
  331. switch (note.guessType()) {
  332. case NoteType.FIX:
  333. this.fixes.push(note)
  334. break
  335. case NoteType.FEATURE:
  336. this.features.push(note)
  337. break
  338. case NoteType.BREAKING_CHANGE:
  339. this.breakingChanges.push(note)
  340. break
  341. case NoteType.OTHER:
  342. this.other.push(note)
  343. break
  344. case NoteType.DOCUMENTATION:
  345. this.docs.push(note)
  346. break
  347. case NoteType.UNKNOWN:
  348. default:
  349. this.unknown.push(note)
  350. break
  351. }
  352. }, 20)
  353. }
  354. list (notes) {
  355. if (notes.length === 0) {
  356. return '_There are no items in this section this release_'
  357. }
  358. return notes
  359. .filter(note => !note.reverted)
  360. .sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase()))
  361. .map((note) => `* ${note.title.trim()} ${note.prNumber}`).join('\n')
  362. }
  363. render () {
  364. return `
  365. # Release Notes
  366. ## Breaking Changes
  367. ${this.list(this.breakingChanges)}
  368. ## Features
  369. ${this.list(this.features)}
  370. ## Fixes
  371. ${this.list(this.fixes)}
  372. ## Other Changes (E.g. Internal refactors or build system updates)
  373. ${this.list(this.other)}
  374. ## Documentation Updates
  375. Some documentation updates, fixes and reworks: ${
  376. this.docs.length === 0
  377. ? '_None in this release_'
  378. : this.docs.sort((a, b) => a.prNumber.localeCompare(b.prNumber)).map(note => note.prNumber).join(', ')
  379. }
  380. ${this.unknown.filter(n => !n.reverted).length > 0
  381. ? `## Unknown (fix these before publishing release)
  382. ${this.list(this.unknown)}
  383. ` : ''}`
  384. }
  385. }
  386. async function main () {
  387. if (!fs.existsSync(CACHE_DIR)) {
  388. fs.mkdirSync(CACHE_DIR)
  389. }
  390. const lastReleaseBranch = await getLastReleaseBranch()
  391. const notes = new ReleaseNotes(lastReleaseBranch)
  392. const lastKnownReleaseInCurrentStream = await getLastKnownReleaseOnBranch(await getCurrentBranch())
  393. const currentBranchOff = await getBranchOffPoint(await getCurrentBranch())
  394. const commits = await getCommitsMergedIntoCurrentBranchSincePoint(
  395. lastKnownReleaseInCurrentStream || currentBranchOff
  396. )
  397. if (!lastKnownReleaseInCurrentStream) {
  398. // This means we are the first release in our stream
  399. // FIXME: This will not work for minor releases!!!!
  400. const lastReleaseBranch = await getLastReleaseBranch()
  401. const lastBranchOff = await getBranchOffPoint(lastReleaseBranch)
  402. commits.push(...await getCommitsBetween(lastBranchOff, currentBranchOff))
  403. }
  404. await notes.parseCommits(commits)
  405. console.log(notes.render())
  406. const badNotes = notes.unknown.filter(n => !n.reverted).length
  407. if (badNotes > 0) {
  408. throw new Error(`You have ${badNotes.length} unknown release notes, please fix them before releasing`)
  409. }
  410. }
  411. if (process.mainModule === module) {
  412. main().catch((err) => {
  413. console.error('Error Occurred:', err)
  414. process.exit(1)
  415. })
  416. }