* fix: use version name in release notes

* fix: omit previously-released notes

* fix: sniff semantic commit types from PR subjects

instead of only from commit messages

* fix: do not use unrecognized semantic commit types

* chore: do not hardcode Release-Notes comment text

It used to be '<!-- One-line Change Summary Here-->',
it's currently a link to a best-practices page, and
it'll probably change again in the future. Let's just
match on <!--.*--> instead.

* chore: copyedit the help page

* chore: use clerk's OMIT_FROM_RELEASE_NOTES_KEYS

* chore: tweak comments

* chore: rename 'breaks' property as 'breaking'
This commit is contained in:
Charles Kerr 2019-01-10 14:01:38 -06:00 коммит произвёл GitHub
Родитель 102d8fe506
Коммит 2acf9ac72f
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
3 изменённых файлов: 116 добавлений и 40 удалений

Просмотреть файл

@ -50,12 +50,12 @@ async function getNewVersion (dryRun) {
} }
} }
async function getReleaseNotes (currentBranch) { async function getReleaseNotes (currentBranch, newVersion) {
if (bumpType === 'nightly') { if (bumpType === 'nightly') {
return { text: 'Nightlies do not get release notes, please compare tags for info.' } return { text: 'Nightlies do not get release notes, please compare tags for info.' }
} }
console.log(`Generating release notes for ${currentBranch}.`) console.log(`Generating release notes for ${currentBranch}.`)
const releaseNotes = await releaseNotesGenerator(currentBranch) const releaseNotes = await releaseNotesGenerator(currentBranch, newVersion)
if (releaseNotes.warning) { if (releaseNotes.warning) {
console.warn(releaseNotes.warning) console.warn(releaseNotes.warning)
} }
@ -63,8 +63,8 @@ async function getReleaseNotes (currentBranch) {
} }
async function createRelease (branchToTarget, isBeta) { async function createRelease (branchToTarget, isBeta) {
const releaseNotes = await getReleaseNotes(branchToTarget)
const newVersion = await getNewVersion() const newVersion = await getNewVersion()
const releaseNotes = await getReleaseNotes(branchToTarget, newVersion)
await tagRelease(newVersion) await tagRelease(newVersion)
console.log(`Checking for existing draft release.`) console.log(`Checking for existing draft release.`)
@ -194,7 +194,8 @@ async function prepareRelease (isBeta, notesOnly) {
} else { } else {
const currentBranch = (args.branch) ? args.branch : await getCurrentBranch(gitDir) const currentBranch = (args.branch) ? args.branch : await getCurrentBranch(gitDir)
if (notesOnly) { if (notesOnly) {
const releaseNotes = await getReleaseNotes(currentBranch) const newVersion = await getNewVersion(true)
const releaseNotes = await getReleaseNotes(currentBranch, newVersion)
console.log(`Draft release notes are: \n${releaseNotes.text}`) console.log(`Draft release notes are: \n${releaseNotes.text}`)
} else { } else {
const changes = await changesToRelease(currentBranch) const changes = await changesToRelease(currentBranch)

Просмотреть файл

@ -1,6 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
const { GitProcess } = require('dugite') const { GitProcess } = require('dugite')
const minimist = require('minimist')
const path = require('path') const path = require('path')
const semver = require('semver') const semver = require('semver')
@ -120,13 +121,17 @@ const getPreviousPoint = async (point) => {
} }
} }
async function getReleaseNotes (range, explicitLinks) { async function getReleaseNotes (range, newVersion, explicitLinks) {
const rangeList = range.split('..') || ['HEAD'] const rangeList = range.split('..') || ['HEAD']
const to = rangeList.pop() const to = rangeList.pop()
const from = rangeList.pop() || (await getPreviousPoint(to)) const from = rangeList.pop() || (await getPreviousPoint(to))
console.log(`Generating release notes between ${from} and ${to}`)
const notes = await notesGenerator.get(from, to) if (!newVersion) {
newVersion = to
}
console.log(`Generating release notes between ${from} and ${to} for version ${newVersion}`)
const notes = await notesGenerator.get(from, to, newVersion)
const ret = { const ret = {
text: notesGenerator.render(notes, explicitLinks) text: notesGenerator.render(notes, explicitLinks)
} }
@ -139,15 +144,33 @@ async function getReleaseNotes (range, explicitLinks) {
} }
async function main () { async function main () {
// TODO: minimist/commander const opts = minimist(process.argv.slice(2), {
const explicitLinks = process.argv.slice(2).some(arg => arg === '--explicit-links') boolean: [ 'explicit-links', 'help' ],
if (process.argv.length > 4) { string: [ 'version' ]
console.log('Use: script/release-notes/index.js [--explicit-links] [tag | tag1..tag2]') })
return 1 opts.range = opts._.shift()
if (opts.help || !opts.range) {
const name = path.basename(process.argv[1])
console.log(`
easy usage: ${name} version
full usage: ${name} [begin..]end [--version version] [--explicit-links]
* 'begin' and 'end' are two git references -- tags, branches, etc --
from which the release notes are generated.
* if omitted, 'begin' defaults to the previous tag in end's branch.
* if omitted, 'version' defaults to 'end'. Specifying a version is
useful if you're making notes on a new version that isn't tagged yet.
* 'explicit-links' makes every note's issue, commit, or pull an MD link
For example, these invocations are equivalent:
${process.argv[1]} v4.0.1
${process.argv[1]} v4.0.0..v4.0.1 --version v4.0.1
`)
return 0
} }
const range = process.argv[2] || 'HEAD' const notes = await getReleaseNotes(opts.range, opts.version, opts['explicit-links'])
const notes = await getReleaseNotes(range, explicitLinks)
console.log(notes.text) console.log(notes.text)
if (notes.warning) { if (notes.warning) {
throw new Error(notes.warning) throw new Error(notes.warning)

Просмотреть файл

@ -63,6 +63,18 @@ const setPullRequest = (commit, owner, repo, number) => {
} }
} }
// copied from https://github.com/electron/clerk/blob/master/src/index.ts#L4-L13
const OMIT_FROM_RELEASE_NOTES_KEYS = [
'no-notes',
'no notes',
'no_notes',
'none',
'no',
'nothing',
'empty',
'blank'
]
const getNoteFromBody = body => { const getNoteFromBody = body => {
if (!body) { if (!body) {
return null return null
@ -76,21 +88,15 @@ const getNoteFromBody = body => {
.find(paragraph => paragraph.startsWith(NOTE_PREFIX)) .find(paragraph => paragraph.startsWith(NOTE_PREFIX))
if (note) { if (note) {
const placeholder = '<!-- One-line Change Summary Here-->'
note = note note = note
.slice(NOTE_PREFIX.length) .slice(NOTE_PREFIX.length)
.replace(placeholder, '') .replace(/<!--.*-->/, '') // '<!-- change summary here-->'
.replace(/\r?\n/, ' ') // remove newlines .replace(/\r?\n/, ' ') // remove newlines
.trim() .trim()
} }
if (note) { if (note && OMIT_FROM_RELEASE_NOTES_KEYS.includes(note.toLowerCase())) {
if (note.match(/^[Nn]o[ _-][Nn]otes\.?$/)) { return NO_NOTES
return NO_NOTES
}
if (note.match(/^[Nn]one\.?$/)) {
return NO_NOTES
}
} }
return note return note
@ -137,8 +143,11 @@ const parseCommitMessage = (commitMessage, owner, repo, commit = {}) => {
// if the subject begins with 'word:', treat it as a semantic commit // if the subject begins with 'word:', treat it as a semantic commit
if ((match = subject.match(/^(\w+):\s(.*)$/))) { if ((match = subject.match(/^(\w+):\s(.*)$/))) {
commit.type = match[1].toLocaleLowerCase() const type = match[1].toLocaleLowerCase()
subject = match[2] if (knownTypes.has(type)) {
commit.type = type
subject = match[2]
}
} }
// Check for GitHub commit message that indicates a PR // Check for GitHub commit message that indicates a PR
@ -221,7 +230,6 @@ const parseCommitMessage = (commitMessage, owner, repo, commit = {}) => {
} }
commit.subject = subject.trim() commit.subject = subject.trim()
return commit return commit
} }
@ -389,7 +397,7 @@ const getDependencyCommits = async (pool, from, to) => {
**** Main **** Main
***/ ***/
const getNotes = async (fromRef, toRef) => { const getNotes = async (fromRef, toRef, newVersion) => {
if (!fs.existsSync(CACHE_DIR)) { if (!fs.existsSync(CACHE_DIR)) {
fs.mkdirSync(CACHE_DIR) fs.mkdirSync(CACHE_DIR)
} }
@ -437,6 +445,7 @@ const getNotes = async (fromRef, toRef) => {
// scrape PRs for release note 'Notes:' comments // scrape PRs for release note 'Notes:' comments
for (const commit of pool.commits) { for (const commit of pool.commits) {
let pr = commit.pr let pr = commit.pr
let prSubject
while (pr && !commit.note) { while (pr && !commit.note) {
const prData = await getPullRequest(pr.number, pr.owner, pr.repo) const prData = await getPullRequest(pr.number, pr.owner, pr.repo)
if (!prData || !prData.data) { if (!prData || !prData.data) {
@ -444,30 +453,73 @@ const getNotes = async (fromRef, toRef) => {
} }
// try to pull a release note from the pull comment // try to pull a release note from the pull comment
commit.note = getNoteFromBody(prData.data.body) const prParsed = {}
if (commit.note) { parseCommitMessage(`${prData.data.title}\n\n${prData.data.body}`, pr.owner, pr.repo, prParsed)
break commit.note = commit.note || prParsed.note
} commit.type = commit.type || prParsed.type
prSubject = prSubject || prParsed.subject
// if the PR references another PR, maybe follow it pr = prParsed.pr && (prParsed.pr.number !== pr.number) ? prParsed.pr : null
parseCommitMessage(`${prData.data.title}\n\n${prData.data.body}`, pr.owner, pr.repo, commit)
pr = pr.number !== commit.pr.number ? commit.pr : null
} }
// if we still don't have a note, it's because someone missed a 'Notes:
// comment in a PR somewhere... use the PR subject as a fallback.
commit.note = commit.note || prSubject
} }
// remove uninteresting commits // remove non-user-facing commits
pool.commits = pool.commits pool.commits = pool.commits
.filter(commit => commit.note !== NO_NOTES) .filter(commit => commit.note !== NO_NOTES)
.filter(commit => !((commit.note || commit.subject).match(/^[Bb]ump v\d+\.\d+\.\d+/))) .filter(commit => !((commit.note || commit.subject).match(/^[Bb]ump v\d+\.\d+\.\d+/)))
// if this is a stable release,
// remove notes for changes that already landed in a previous major/minor series
if (semver.valid(newVersion) && !semver.prerelease(newVersion)) {
// load all the prDatas
await Promise.all(
pool.commits.map(commit => new Promise(async (resolve) => {
const { pr } = commit
if (typeof pr === 'object') {
const prData = await getPullRequest(pr.number, pr.owner, pr.repo)
if (prData) {
commit.prData = prData
}
}
resolve()
}))
)
// remove items that already landed in a previous major/minor series
pool.commits = pool.commits
.filter(commit => {
if (!commit.prData) {
return true
}
const reducer = (accumulator, current) => {
if (!semver.valid(accumulator)) { return current }
if (!semver.valid(current)) { return accumulator }
return semver.lt(accumulator, current) ? accumulator : current
}
const earliestRelease = commit.prData.data.labels
.map(label => label.name.match(/merged\/(\d+)-(\d+)-x/))
.filter(label => !!label)
.map(label => `${label[1]}.${label[2]}.0`)
.reduce(reducer, null)
if (!semver.valid(earliestRelease)) {
return true
}
return semver.diff(earliestRelease, newVersion).includes('patch')
})
}
const notes = { const notes = {
breaks: [], breaking: [],
docs: [], docs: [],
feat: [], feat: [],
fix: [], fix: [],
other: [], other: [],
unknown: [], unknown: [],
ref: toRef name: newVersion
} }
pool.commits.forEach(commit => { pool.commits.forEach(commit => {
@ -475,7 +527,7 @@ const getNotes = async (fromRef, toRef) => {
if (!str) { if (!str) {
notes.unknown.push(commit) notes.unknown.push(commit)
} else if (breakTypes.has(str)) { } else if (breakTypes.has(str)) {
notes.breaks.push(commit) notes.breaking.push(commit)
} else if (docTypes.has(str)) { } else if (docTypes.has(str)) {
notes.docs.push(commit) notes.docs.push(commit)
} else if (featTypes.has(str)) { } else if (featTypes.has(str)) {
@ -562,7 +614,7 @@ const renderCommit = (commit, explicitLinks) => {
} }
const renderNotes = (notes, explicitLinks) => { const renderNotes = (notes, explicitLinks) => {
const rendered = [ `# Release Notes for ${notes.ref}\n\n` ] const rendered = [ `# Release Notes for ${notes.name}\n\n` ]
const renderSection = (title, commits) => { const renderSection = (title, commits) => {
if (commits.length === 0) { if (commits.length === 0) {
@ -582,7 +634,7 @@ const renderNotes = (notes, explicitLinks) => {
rendered.push(...lines.sort(), '\n') rendered.push(...lines.sort(), '\n')
} }
renderSection('Breaking Changes', notes.breaks) renderSection('Breaking Changes', notes.breaking)
renderSection('Features', notes.feat) renderSection('Features', notes.feat)
renderSection('Fixes', notes.fix) renderSection('Fixes', notes.fix)
renderSection('Other Changes', notes.other) renderSection('Other Changes', notes.other)