123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478 |
- 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)
- })
- }
|