Merge pull request #13359 from electron/release-notes-generator

build: New release notes generator
This commit is contained in:
John Kleinschmidt 2018-07-18 16:19:32 -04:00 committed by GitHub
commit f1cc40e3bf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 2600 additions and 1913 deletions

3838
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -15,14 +15,17 @@
"electron-docs-linter": "^2.3.4",
"electron-typescript-definitions": "^1.3.5",
"github": "^9.2.0",
"html-entities": "^1.2.1",
"husky": "^0.14.3",
"lint": "^1.1.2",
"minimist": "^1.2.0",
"node-fetch": "^2.1.2",
"nugget": "^2.0.1",
"octicons": "^7.3.0",
"remark-cli": "^4.0.0",
"remark-preset-lint-markdown-style-guide": "^2.1.1",
"request": "^2.68.0",
"semver": "^5.5.0",
"serve": "^6.5.3",
"standard": "^10.0.0",
"standard-markdown": "^4.0.0",

1
script/release-notes/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.cache

View file

@ -0,0 +1,478 @@
const { GitProcess } = require('dugite')
const Entities = require('html-entities').AllHtmlEntities
const fetch = require('node-fetch')
const fs = require('fs')
const GitHub = require('github')
const path = require('path')
const semver = require('semver')
const CACHE_DIR = path.resolve(__dirname, '.cache')
// Fill this with tags to ignore if you are generating release notes for older
// versions
//
// E.g. ['v3.0.0-beta.1'] to generate the release notes for 3.0.0-beta.1 :) from
// the current 3-0-x branch
const EXCLUDE_TAGS = []
const entities = new Entities()
const github = new GitHub()
const gitDir = path.resolve(__dirname, '..', '..')
github.authenticate({ type: 'token', token: process.env.ELECTRON_GITHUB_TOKEN })
let currentBranch
const semanticMap = new Map()
for (const line of fs.readFileSync(path.resolve(__dirname, 'legacy-pr-semantic-map.csv'), 'utf8').split('\n')) {
if (!line) continue
const bits = line.split(',')
if (bits.length !== 2) continue
semanticMap.set(bits[0], bits[1])
}
const getCurrentBranch = async () => {
if (currentBranch) return currentBranch
const gitArgs = ['rev-parse', '--abbrev-ref', 'HEAD']
const branchDetails = await GitProcess.exec(gitArgs, gitDir)
if (branchDetails.exitCode === 0) {
currentBranch = branchDetails.stdout.trim()
return currentBranch
}
throw GitProcess.parseError(branchDetails.stderr)
}
const getBranchOffPoint = async (branchName) => {
const gitArgs = ['merge-base', branchName, 'master']
const commitDetails = await GitProcess.exec(gitArgs, gitDir)
if (commitDetails.exitCode === 0) {
return commitDetails.stdout.trim()
}
throw GitProcess.parseError(commitDetails.stderr)
}
const getTagsOnBranch = async (branchName) => {
const gitArgs = ['tag', '--merged', branchName]
const tagDetails = await GitProcess.exec(gitArgs, gitDir)
if (tagDetails.exitCode === 0) {
return tagDetails.stdout.trim().split('\n').filter(tag => !EXCLUDE_TAGS.includes(tag))
}
throw GitProcess.parseError(tagDetails.stderr)
}
const memLastKnownRelease = new Map()
const getLastKnownReleaseOnBranch = async (branchName) => {
if (memLastKnownRelease.has(branchName)) {
return memLastKnownRelease.get(branchName)
}
const tags = await getTagsOnBranch(branchName)
if (!tags.length) {
throw new Error(`Branch ${branchName} has no tags, we have no idea what the last release was`)
}
const branchOffPointTags = await getTagsOnBranch(await getBranchOffPoint(branchName))
if (branchOffPointTags.length >= tags.length) {
// No release on this branch
return null
}
memLastKnownRelease.set(branchName, tags[tags.length - 1])
// Latest tag is the latest release
return tags[tags.length - 1]
}
const getBranches = async () => {
const gitArgs = ['branch', '--remote']
const branchDetails = await GitProcess.exec(gitArgs, gitDir)
if (branchDetails.exitCode === 0) {
return branchDetails.stdout.trim().split('\n').map(b => b.trim()).filter(branch => branch !== 'origin/HEAD -> origin/master')
}
throw GitProcess.parseError(branchDetails.stderr)
}
const semverify = (v) => v.replace(/^origin\//, '').replace('x', '0').replace(/-/g, '.')
const getLastReleaseBranch = async () => {
const current = await getCurrentBranch()
const allBranches = await getBranches()
const releaseBranches = allBranches
.filter(branch => /^origin\/[0-9]+-[0-9]+-x$/.test(branch))
.filter(branch => branch !== current && branch !== `origin/${current}`)
let latest = null
for (const b of releaseBranches) {
if (latest === null) latest = b
if (semver.gt(semverify(b), semverify(latest))) {
latest = b
}
}
return latest
}
const commitBeforeTag = async (commit, tag) => {
const gitArgs = ['tag', '--contains', commit]
const tagDetails = await GitProcess.exec(gitArgs, gitDir)
if (tagDetails.exitCode === 0) {
return tagDetails.stdout.split('\n').includes(tag)
}
throw GitProcess.parseError(tagDetails.stderr)
}
const getCommitsMergedIntoCurrentBranchSincePoint = async (point) => {
return getCommitsBetween(point, 'HEAD')
}
const getCommitsBetween = async (point1, point2) => {
const gitArgs = ['rev-list', `${point1}..${point2}`]
const commitsDetails = await GitProcess.exec(gitArgs, gitDir)
if (commitsDetails.exitCode !== 0) {
throw GitProcess.parseError(commitsDetails.stderr)
}
return commitsDetails.stdout.trim().split('\n')
}
const TITLE_PREFIX = 'Merged Pull Request: '
const getCommitDetails = async (commitHash) => {
const commitInfo = await (await fetch(`https://github.com/electron/electron/branch_commits/${commitHash}`)).text()
const bits = commitInfo.split('</a>)')[0].split('>')
const prIdent = bits[bits.length - 1].trim()
if (!prIdent || commitInfo.indexOf('href="/electron/electron/pull') === -1) {
console.warn(`WARNING: Could not track commit "${commitHash}" to a pull request, it may have been committed directly to the branch`)
return null
}
const title = commitInfo.split('title="')[1].split('"')[0]
if (!title.startsWith(TITLE_PREFIX)) {
console.warn(`WARNING: Unknown PR title for commit "${commitHash}" in PR "${prIdent}"`)
return null
}
return {
mergedFrom: prIdent,
prTitle: entities.decode(title.substr(TITLE_PREFIX.length))
}
}
const doWork = async (items, fn, concurrent = 5) => {
const results = []
const toUse = [].concat(items)
let i = 1
const doBit = async () => {
if (toUse.length === 0) return
console.log(`Running ${i}/${items.length}`)
i += 1
const item = toUse.pop()
const index = toUse.length
results[index] = await fn(item)
await doBit()
}
const bits = []
for (let i = 0; i < concurrent; i += 1) {
bits.push(doBit())
}
await Promise.all(bits)
return results
}
const notes = new Map()
const NoteType = {
FIX: 'fix',
FEATURE: 'feature',
BREAKING_CHANGE: 'breaking-change',
DOCUMENTATION: 'doc',
OTHER: 'other',
UNKNOWN: 'unknown'
}
class Note {
constructor (trueTitle, prNumber, ignoreIfInVersion) {
// Self bindings
this.guessType = this.guessType.bind(this)
this.fetchPrInfo = this.fetchPrInfo.bind(this)
this._getPr = this._getPr.bind(this)
if (!trueTitle.trim()) console.error(prNumber)
this._ignoreIfInVersion = ignoreIfInVersion
this.reverted = false
if (notes.has(trueTitle)) {
console.warn(`Duplicate PR trueTitle: "${trueTitle}", "${prNumber}" this might cause weird reversions (this would be RARE)`)
}
// Memoize
notes.set(trueTitle, this)
this.originalTitle = trueTitle
this.title = trueTitle
this.prNumber = prNumber
this.stripColon = true
if (this.guessType() !== NoteType.UNKNOWN && this.stripColon) {
this.title = trueTitle.split(':').slice(1).join(':').trim()
}
}
guessType () {
if (this.originalTitle.startsWith('fix:') ||
this.originalTitle.startsWith('Fix:')) return NoteType.FIX
if (this.originalTitle.startsWith('feat:')) return NoteType.FEATURE
if (this.originalTitle.startsWith('spec:') ||
this.originalTitle.startsWith('build:') ||
this.originalTitle.startsWith('test:') ||
this.originalTitle.startsWith('chore:') ||
this.originalTitle.startsWith('deps:') ||
this.originalTitle.startsWith('refactor:') ||
this.originalTitle.startsWith('tools:') ||
this.originalTitle.startsWith('vendor:') ||
this.originalTitle.startsWith('perf:') ||
this.originalTitle.startsWith('style:') ||
this.originalTitle.startsWith('ci')) return NoteType.OTHER
if (this.originalTitle.startsWith('doc:') ||
this.originalTitle.startsWith('docs:')) return NoteType.DOCUMENTATION
this.stripColon = false
if (this.pr && this.pr.data.labels.find(label => label.name === 'semver/breaking-change')) {
return NoteType.BREAKING_CHANGE
}
// FIXME: Backported features will not be picked up by this
if (this.pr && this.pr.data.labels.find(label => label.name === 'semver/nonbreaking-feature')) {
return NoteType.FEATURE
}
const n = this.prNumber.replace('#', '')
if (semanticMap.has(n)) {
switch (semanticMap.get(n)) {
case 'feat':
return NoteType.FEATURE
case 'fix':
return NoteType.FIX
case 'breaking-change':
return NoteType.BREAKING_CHANGE
case 'doc':
return NoteType.DOCUMENTATION
case 'build':
case 'vendor':
case 'refactor':
case 'spec':
return NoteType.OTHER
default:
throw new Error(`Unknown semantic mapping: ${semanticMap.get(n)}`)
}
}
return NoteType.UNKNOWN
}
async _getPr (n) {
const cachePath = path.resolve(CACHE_DIR, n)
if (fs.existsSync(cachePath)) {
return JSON.parse(fs.readFileSync(cachePath, 'utf8'))
} else {
try {
const pr = await github.pullRequests.get({
number: n,
owner: 'electron',
repo: 'electron'
})
fs.writeFileSync(cachePath, JSON.stringify({ data: pr.data }))
return pr
} catch (err) {
console.info('#### FAILED:', `#${n}`)
throw err
}
}
}
async fetchPrInfo () {
if (this.pr) return
const n = this.prNumber.replace('#', '')
this.pr = await this._getPr(n)
if (this.pr.data.labels.find(label => label.name === `merged/${this._ignoreIfInVersion.replace('origin/', '')}`)) {
// This means we probably backported this PR, let's try figure out what
// the corresponding backport PR would be by searching through comments
// for trop
let comments
const cacheCommentsPath = path.resolve(CACHE_DIR, `${n}-comments`)
if (fs.existsSync(cacheCommentsPath)) {
comments = JSON.parse(fs.readFileSync(cacheCommentsPath, 'utf8'))
} else {
comments = await github.issues.getComments({
number: n,
owner: 'electron',
repo: 'electron',
per_page: 100
})
fs.writeFileSync(cacheCommentsPath, JSON.stringify({ data: comments.data }))
}
const tropComment = comments.data.find(
c => (
new RegExp(`We have automatically backported this PR to "${this._ignoreIfInVersion.replace('origin/', '')}", please check out #[0-9]+`)
).test(c.body)
)
if (tropComment) {
const commentBits = tropComment.body.split('#')
const tropPrNumber = commentBits[commentBits.length - 1]
const tropPr = await this._getPr(tropPrNumber)
if (tropPr.data.merged && tropPr.data.merge_commit_sha) {
if (await commitBeforeTag(tropPr.data.merge_commit_sha, await getLastKnownReleaseOnBranch(this._ignoreIfInVersion))) {
this.reverted = true
console.log('PR', this.prNumber, 'was backported to a previous version, ignoring from notes')
}
}
}
}
}
}
Note.findByTrueTitle = (trueTitle) => notes.get(trueTitle)
class ReleaseNotes {
constructor (ignoreIfInVersion) {
this._ignoreIfInVersion = ignoreIfInVersion
this._handledPrs = new Set()
this._revertedPrs = new Set()
this.other = []
this.docs = []
this.fixes = []
this.features = []
this.breakingChanges = []
this.unknown = []
}
async parseCommits (commitHashes) {
await doWork(commitHashes, async (commit) => {
const info = await getCommitDetails(commit)
if (!info) return
// Only handle each PR once
if (this._handledPrs.has(info.mergedFrom)) return
this._handledPrs.add(info.mergedFrom)
// Strip the trop backport prefix
const trueTitle = info.prTitle.replace(/^Backport \([0-9]+-[0-9]+-x\) - /, '')
if (this._revertedPrs.has(trueTitle)) return
// Handle PRs that revert other PRs
if (trueTitle.startsWith('Revert "')) {
const revertedTrueTitle = trueTitle.substr(8, trueTitle.length - 9)
this._revertedPrs.add(revertedTrueTitle)
const existingNote = Note.findByTrueTitle(revertedTrueTitle)
if (existingNote) {
existingNote.reverted = true
}
return
}
// Add a note for this PR
const note = new Note(trueTitle, info.mergedFrom, this._ignoreIfInVersion)
try {
await note.fetchPrInfo()
} catch (err) {
console.error(commit, info)
throw err
}
switch (note.guessType()) {
case NoteType.FIX:
this.fixes.push(note)
break
case NoteType.FEATURE:
this.features.push(note)
break
case NoteType.BREAKING_CHANGE:
this.breakingChanges.push(note)
break
case NoteType.OTHER:
this.other.push(note)
break
case NoteType.DOCUMENTATION:
this.docs.push(note)
break
case NoteType.UNKNOWN:
default:
this.unknown.push(note)
break
}
}, 20)
}
list (notes) {
if (notes.length === 0) {
return '_There are no items in this section this release_'
}
return notes
.filter(note => !note.reverted)
.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase()))
.map((note) => `* ${note.title.trim()} ${note.prNumber}`).join('\n')
}
render () {
return `
# Release Notes
## Breaking Changes
${this.list(this.breakingChanges)}
## Features
${this.list(this.features)}
## Fixes
${this.list(this.fixes)}
## Other Changes (E.g. Internal refactors or build system updates)
${this.list(this.other)}
## Documentation Updates
Some documentation updates, fixes and reworks: ${
this.docs.length === 0
? '_None in this release_'
: this.docs.sort((a, b) => a.prNumber.localeCompare(b.prNumber)).map(note => note.prNumber).join(', ')
}
${this.unknown.filter(n => !n.reverted).length > 0
? `## Unknown (fix these before publishing release)
${this.list(this.unknown)}
` : ''}`
}
}
async function main () {
if (!fs.existsSync(CACHE_DIR)) {
fs.mkdirSync(CACHE_DIR)
}
const lastReleaseBranch = await getLastReleaseBranch()
const notes = new ReleaseNotes(lastReleaseBranch)
const lastKnownReleaseInCurrentStream = await getLastKnownReleaseOnBranch(await getCurrentBranch())
const currentBranchOff = await getBranchOffPoint(await getCurrentBranch())
const commits = await getCommitsMergedIntoCurrentBranchSincePoint(
lastKnownReleaseInCurrentStream || currentBranchOff
)
if (!lastKnownReleaseInCurrentStream) {
// This means we are the first release in our stream
// FIXME: This will not work for minor releases!!!!
const lastReleaseBranch = await getLastReleaseBranch()
const lastBranchOff = await getBranchOffPoint(lastReleaseBranch)
commits.push(...await getCommitsBetween(lastBranchOff, currentBranchOff))
}
await notes.parseCommits(commits)
console.log(notes.render())
const badNotes = notes.unknown.filter(n => !n.reverted).length
if (badNotes > 0) {
throw new Error(`You have ${badNotes.length} unknown release notes, please fix them before releasing`)
}
}
if (process.mainModule === module) {
main().catch((err) => {
console.error('Error Occurred:', err)
process.exit(1)
})
}

View file

@ -0,0 +1,193 @@
12884,fix
12093,feat
12595,doc
12674,doc
12577,doc
12084,doc
12103,doc
12948,build
12496,feat
13133,build
12651,build
12767,doc
12238,build
12646,build
12373,doc
12723,feat
12202,doc
12504,doc
12669,doc
13044,feat
12746,spec
12617,doc
12532,feat
12619,feat
12118,build
12921,build
13281,doc
12059,feat
12131,doc
12123,doc
12080,build
12904,fix
12562,fix
12122,spec
12817,spec
12254,fix
12999,vendor
13248,vendor
12104,build
12477,feat
12648,refactor
12649,refactor
12650,refactor
12673,refactor
12305,refactor
12168,refactor
12627,refactor
12446,doc
12304,refactor
12615,breaking-change
12135,feat
12155,doc
12975,fix
12501,fix
13065,fix
13089,build
12786,doc
12736,doc
11966,doc
12885,fix
12984,refactor
12187,build
12535,refactor
12538,feat
12190,fix
12139,fix
11328,fix
12828,feat
12614,feat
12546,feat
12647,refactor
12987,build
12900,doc
12389,doc
12387,doc
12232,doc
12742,build
12043,fix
12741,fix
12995,fix
12395,fix
12003,build
12216,fix
12132,fix
12062,fix
12968,doc
12422,doc
12149,doc
13339,build
12044,fix
12327,fix
12180,fix
12263,spec
12153,spec
13055,feat
12113,doc
12067,doc
12882,build
13029,build
13067,doc
12196,build
12797,doc
12013,fix
12507,fix
11607,feat
12837,build
11613,feat
12015,spec
12058,doc
12403,spec
12192,feat
12204,doc
13294,doc
12542,doc
12826,refactor
12781,doc
12157,fix
12319,fix
12188,build
12399,doc
12145,doc
12661,refactor
8953,fix
12037,fix
12186,spec
12397,fix
12040,doc
12886,refactor
12008,refactor
12716,refactor
12750,refactor
12787,refactor
12858,refactor
12140,refactor
12503,refactor
12514,refactor
12584,refactor
12596,refactor
12637,refactor
12660,refactor
12696,refactor
12877,refactor
13030,refactor
12916,build
12896,build
13039,breaking-change
11927,build
12847,doc
12852,doc
12194,fix
12870,doc
12924,fix
12682,doc
12004,refactor
12601,refactor
12998,fix
13105,vendor
12452,doc
12738,fix
12536,refactor
12189,spec
13122,spec
12662,fix
12665,doc
12419,feat
12756,doc
12616,refactor
12679,breaking-change
12000,doc
12372,build
12805,build
12348,fix
12315,doc
12072,doc
12912,doc
12982,fix
12105,doc
12917,spec
12400,doc
12101,feat
12642,build
13058,fix
12913,vendor
13298,vendor
13042,build
11230,feat
11459,feat
12476,vendor
11937,doc
12328,build
12539,refactor
12127,build
12537,build
1 12884 fix
2 12093 feat
3 12595 doc
4 12674 doc
5 12577 doc
6 12084 doc
7 12103 doc
8 12948 build
9 12496 feat
10 13133 build
11 12651 build
12 12767 doc
13 12238 build
14 12646 build
15 12373 doc
16 12723 feat
17 12202 doc
18 12504 doc
19 12669 doc
20 13044 feat
21 12746 spec
22 12617 doc
23 12532 feat
24 12619 feat
25 12118 build
26 12921 build
27 13281 doc
28 12059 feat
29 12131 doc
30 12123 doc
31 12080 build
32 12904 fix
33 12562 fix
34 12122 spec
35 12817 spec
36 12254 fix
37 12999 vendor
38 13248 vendor
39 12104 build
40 12477 feat
41 12648 refactor
42 12649 refactor
43 12650 refactor
44 12673 refactor
45 12305 refactor
46 12168 refactor
47 12627 refactor
48 12446 doc
49 12304 refactor
50 12615 breaking-change
51 12135 feat
52 12155 doc
53 12975 fix
54 12501 fix
55 13065 fix
56 13089 build
57 12786 doc
58 12736 doc
59 11966 doc
60 12885 fix
61 12984 refactor
62 12187 build
63 12535 refactor
64 12538 feat
65 12190 fix
66 12139 fix
67 11328 fix
68 12828 feat
69 12614 feat
70 12546 feat
71 12647 refactor
72 12987 build
73 12900 doc
74 12389 doc
75 12387 doc
76 12232 doc
77 12742 build
78 12043 fix
79 12741 fix
80 12995 fix
81 12395 fix
82 12003 build
83 12216 fix
84 12132 fix
85 12062 fix
86 12968 doc
87 12422 doc
88 12149 doc
89 13339 build
90 12044 fix
91 12327 fix
92 12180 fix
93 12263 spec
94 12153 spec
95 13055 feat
96 12113 doc
97 12067 doc
98 12882 build
99 13029 build
100 13067 doc
101 12196 build
102 12797 doc
103 12013 fix
104 12507 fix
105 11607 feat
106 12837 build
107 11613 feat
108 12015 spec
109 12058 doc
110 12403 spec
111 12192 feat
112 12204 doc
113 13294 doc
114 12542 doc
115 12826 refactor
116 12781 doc
117 12157 fix
118 12319 fix
119 12188 build
120 12399 doc
121 12145 doc
122 12661 refactor
123 8953 fix
124 12037 fix
125 12186 spec
126 12397 fix
127 12040 doc
128 12886 refactor
129 12008 refactor
130 12716 refactor
131 12750 refactor
132 12787 refactor
133 12858 refactor
134 12140 refactor
135 12503 refactor
136 12514 refactor
137 12584 refactor
138 12596 refactor
139 12637 refactor
140 12660 refactor
141 12696 refactor
142 12877 refactor
143 13030 refactor
144 12916 build
145 12896 build
146 13039 breaking-change
147 11927 build
148 12847 doc
149 12852 doc
150 12194 fix
151 12870 doc
152 12924 fix
153 12682 doc
154 12004 refactor
155 12601 refactor
156 12998 fix
157 13105 vendor
158 12452 doc
159 12738 fix
160 12536 refactor
161 12189 spec
162 13122 spec
163 12662 fix
164 12665 doc
165 12419 feat
166 12756 doc
167 12616 refactor
168 12679 breaking-change
169 12000 doc
170 12372 build
171 12805 build
172 12348 fix
173 12315 doc
174 12072 doc
175 12912 doc
176 12982 fix
177 12105 doc
178 12917 spec
179 12400 doc
180 12101 feat
181 12642 build
182 13058 fix
183 12913 vendor
184 13298 vendor
185 13042 build
186 11230 feat
187 11459 feat
188 12476 vendor
189 11937 doc
190 12328 build
191 12539 refactor
192 12127 build
193 12537 build