-
Notifications
You must be signed in to change notification settings - Fork 19
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix: use conventional commits from release-please for changelog (#183)
`release-please` already fetches the commits and parses them into conventional commit objects, so we are able to reuse most of that instead of fetching it from GitHub again. This also adds tests for the changelog output. This also removes the workspace-deps plugin in favor of extending the builtin node-workspace plugin. This fixes the issue of workspaces sometimes not getting the correct tag name and changelog title if they were only bumped as part of a workspace dep.
- Loading branch information
1 parent
352d332
commit b58d86a
Showing
15 changed files
with
463 additions
and
469 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,225 +1,83 @@ | ||
const RP = require('release-please/build/src/changelog-notes/default') | ||
const makeGh = require('./github.js') | ||
const { link, code, specRe, list, dateFmt } = require('./util') | ||
|
||
module.exports = class DefaultChangelogNotes extends RP.DefaultChangelogNotes { | ||
module.exports = class ChangelogNotes { | ||
constructor (options) { | ||
super(options) | ||
this.github = options.github | ||
this.gh = makeGh(options.github) | ||
} | ||
|
||
async buildDefaultNotes (commits, options) { | ||
// The default generator has a title with the version and date | ||
// and a link to the diff between the last two versions | ||
const notes = await super.buildNotes(commits, options) | ||
const lines = notes.split('\n') | ||
|
||
let foundBreakingHeader = false | ||
let foundNextHeader = false | ||
const breaking = lines.reduce((acc, line) => { | ||
if (line.match(/^### .* BREAKING CHANGES$/)) { | ||
foundBreakingHeader = true | ||
} else if (!foundNextHeader && foundBreakingHeader && line.match(/^### /)) { | ||
foundNextHeader = true | ||
} | ||
if (foundBreakingHeader && !foundNextHeader) { | ||
acc.push(line) | ||
} | ||
return acc | ||
}, []).join('\n') | ||
buildEntry (commit, authors = []) { | ||
const breaking = commit.notes | ||
.filter(n => n.title === 'BREAKING CHANGE') | ||
.map(n => n.text) | ||
|
||
return { | ||
title: lines[0], | ||
breaking: breaking.trim(), | ||
} | ||
} | ||
|
||
async buildNotes (commits, options) { | ||
const { title, breaking } = await this.buildDefaultNotes(commits, options) | ||
const body = await generateChangelogBody(commits, { github: this.github, ...options }) | ||
return [title, breaking, body].filter(Boolean).join('\n\n') | ||
} | ||
} | ||
const entry = [] | ||
|
||
// a naive implementation of console.log/group for indenting console | ||
// output but keeping it in a buffer to be output to a file or console | ||
const logger = (init) => { | ||
let indent = 0 | ||
const step = 2 | ||
const buffer = [init] | ||
return { | ||
toString () { | ||
return buffer.join('\n').trim() | ||
}, | ||
group (s) { | ||
this.log(s) | ||
indent += step | ||
}, | ||
groupEnd () { | ||
indent -= step | ||
}, | ||
log (s) { | ||
if (!s) { | ||
buffer.push('') | ||
} else { | ||
buffer.push(s.split('\n').map((l) => ' '.repeat(indent) + l).join('\n')) | ||
} | ||
}, | ||
} | ||
} | ||
|
||
const generateChangelogBody = async (_commits, { github, changelogSections }) => { | ||
const changelogMap = new Map( | ||
changelogSections.filter(c => !c.hidden).map((c) => [c.type, c.section]) | ||
) | ||
|
||
const { repository } = await github.graphql( | ||
`fragment commitCredit on GitObject { | ||
... on Commit { | ||
message | ||
url | ||
abbreviatedOid | ||
authors (first:10) { | ||
nodes { | ||
user { | ||
login | ||
url | ||
} | ||
name | ||
} | ||
} | ||
associatedPullRequests (first:10) { | ||
nodes { | ||
number | ||
url | ||
merged | ||
} | ||
} | ||
} | ||
if (commit.sha) { | ||
// A link to the commit | ||
entry.push(link(code(commit.sha.slice(0, 7)), this.gh.commit(commit.sha))) | ||
} | ||
|
||
query { | ||
repository (owner:"${github.repository.owner}", name:"${github.repository.repo}") { | ||
${_commits.map(({ sha: s }) => `_${s}: object (expression: "${s}") { ...commitCredit }`)} | ||
} | ||
}` | ||
) | ||
|
||
// collect commits by valid changelog type | ||
const commits = [...changelogMap.values()].reduce((acc, type) => { | ||
acc[type] = [] | ||
return acc | ||
}, {}) | ||
|
||
const allCommits = Object.values(repository) | ||
|
||
for (const commit of allCommits) { | ||
// get changelog type of commit or bail if there is not a valid one | ||
const [, type] = /(^\w+)[\s(:]?/.exec(commit.message) || [] | ||
const changelogType = changelogMap.get(type) | ||
if (!changelogType) { | ||
continue | ||
// A link to the pull request if the commit has one | ||
const prNumber = commit.pullRequest && commit.pullRequest.number | ||
if (prNumber) { | ||
entry.push(link(`#${prNumber}`, this.gh.pull(prNumber))) | ||
} | ||
|
||
const message = commit.message | ||
.trim() // remove leading/trailing spaces | ||
.replace(/(\r?\n)+/gm, '\n') // replace multiple newlines with one | ||
.replace(/([^\s]+@\d+\.\d+\.\d+.*)/gm, '`$1`') // wrap package@version in backticks | ||
|
||
// the title is the first line of the commit, 'let' because we change it later | ||
let [title, ...body] = message.split('\n') | ||
|
||
const prs = commit.associatedPullRequests.nodes.filter((pull) => pull.merged) | ||
|
||
// external squashed PRs dont get the associated pr node set | ||
// so we try to grab it from the end of the commit title | ||
// since thats where it goes by default | ||
const [, titleNumber] = title.match(/\s+\(#(\d+)\)$/) || [] | ||
if (titleNumber && !prs.find((pr) => pr.number === +titleNumber)) { | ||
try { | ||
// it could also reference an issue so we do one extra check | ||
// to make sure it is really a pr that has been merged | ||
const { data: realPr } = await github.octokit.pulls.get({ | ||
owner: github.repository.owner, | ||
repo: github.repository.repo, | ||
pull_number: titleNumber, | ||
}) | ||
if (realPr.state === 'MERGED') { | ||
prs.push(realPr) | ||
} | ||
} catch { | ||
// maybe an issue or something else went wrong | ||
// not super important so keep going | ||
} | ||
// The title of the commit, with the optional scope as a prefix | ||
const scope = commit.scope && `${commit.scope}:` | ||
const subject = commit.bareMessage.replace(specRe, code('$1')) | ||
entry.push([scope, subject].filter(Boolean).join(' ')) | ||
|
||
// A list og the authors github handles or names | ||
if (authors.length && commit.type !== 'deps') { | ||
entry.push(`(${authors.join(', ')})`) | ||
} | ||
|
||
for (const pr of prs) { | ||
title = title.replace(new RegExp(`\\s*\\(#${pr.number}\\)`, 'g'), '') | ||
return { | ||
entry: entry.join(' '), | ||
breaking, | ||
} | ||
} | ||
|
||
body = body | ||
.map((line) => line.trim()) // remove artificial line breaks | ||
.filter(Boolean) // remove blank lines | ||
.join('\n') // rejoin on new lines | ||
.split(/^[*-]/gm) // split on lines starting with bullets | ||
.map((line) => line.trim()) // remove spaces around bullets | ||
.filter((line) => !title.includes(line)) // rm lines that exist in the title | ||
// replace new lines for this bullet with spaces and re-bullet it | ||
.map((line) => `* ${line.trim().replace(/\n/gm, ' ')}`) | ||
.join('\n') // re-join with new lines | ||
|
||
commits[changelogType].push({ | ||
hash: commit.abbreviatedOid, | ||
url: commit.url, | ||
title, | ||
type: changelogType, | ||
body, | ||
prs, | ||
credit: commit.authors.nodes.map((author) => { | ||
if (author.user && author.user.login) { | ||
return { | ||
name: `@${author.user.login}`, | ||
url: author.user.url, | ||
} | ||
} | ||
// if the commit used an email that's not associated with a github account | ||
// then the user field will be empty, so we fall back to using the committer's | ||
// name and email as specified by git | ||
return { | ||
name: author.name, | ||
url: `mailto:${author.email}`, | ||
async buildNotes (rawCommits, { version, previousTag, currentTag, changelogSections }) { | ||
const changelog = changelogSections.reduce((acc, c) => { | ||
if (!c.hidden) { | ||
acc[c.type] = { | ||
title: c.section, | ||
entries: [], | ||
} | ||
}), | ||
} | ||
return acc | ||
}, { | ||
breaking: { | ||
title: '⚠️ BREAKING CHANGES', | ||
entries: [], | ||
}, | ||
}) | ||
} | ||
|
||
const output = logger() | ||
// Only continue with commits that will make it to our changelog | ||
const commits = rawCommits.filter(c => changelog[c.type]) | ||
|
||
for (const key of Object.keys(commits)) { | ||
if (commits[key].length > 0) { | ||
output.group(`### ${key}\n`) | ||
const authorsByCommit = await this.gh.authors(commits) | ||
|
||
for (const commit of commits[key]) { | ||
let groupCommit = `* [\`${commit.hash}\`](${commit.url})` | ||
// Group commits by type | ||
for (const commit of commits) { | ||
const { entry, breaking } = this.buildEntry(commit, authorsByCommit[commit.sha]) | ||
|
||
for (const pr of commit.prs) { | ||
groupCommit += ` [#${pr.number}](${pr.url})` | ||
} | ||
// Collect commits by type | ||
changelog[commit.type].entries.push(entry) | ||
|
||
groupCommit += ` ${commit.title}` | ||
if (key !== 'Dependencies') { | ||
for (const user of commit.credit) { | ||
groupCommit += ` (${user.name})` | ||
} | ||
} | ||
// And push breaking changes to its own section | ||
changelog.breaking.entries.push(...breaking) | ||
} | ||
|
||
output.group(groupCommit) | ||
output.groupEnd() | ||
} | ||
const sections = Object.values(changelog) | ||
.filter((s) => s.entries.length) | ||
.map(({ title, entries }) => [`### ${title}`, entries.map(list).join('\n')].join('\n\n')) | ||
|
||
output.log() | ||
output.groupEnd() | ||
} | ||
} | ||
const title = `## ${link(version, this.gh.compare(previousTag, currentTag))} (${dateFmt()})` | ||
|
||
return output.toString() | ||
return [title, ...sections].join('\n\n').trim() | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
module.exports = (gh) => { | ||
const { owner, repo } = gh.repository | ||
|
||
const authors = async (commits) => { | ||
const response = {} | ||
|
||
const shas = commits.map(c => c.sha).filter(Boolean) | ||
|
||
if (!shas.length) { | ||
return response | ||
} | ||
|
||
const { repository } = await gh.graphql( | ||
`fragment CommitAuthors on GitObject { | ||
... on Commit { | ||
authors (first:10) { | ||
nodes { | ||
user { login } | ||
name | ||
} | ||
} | ||
} | ||
} | ||
query { | ||
repository (owner:"${owner}", name:"${repo}") { | ||
${shas.map((s) => { | ||
return `_${s}: object (expression: "${s}") { ...CommitAuthors }` | ||
})} | ||
} | ||
}` | ||
) | ||
|
||
for (const [key, commit] of Object.entries(repository)) { | ||
if (commit) { | ||
response[key.slice(1)] = commit.authors.nodes | ||
.map((a) => a.user && a.user.login ? `@${a.user.login}` : a.name) | ||
.filter(Boolean) | ||
} | ||
} | ||
|
||
return response | ||
} | ||
|
||
const url = (...p) => `https://github.com/${owner}/${repo}/${p.join('/')}` | ||
|
||
return { | ||
authors, | ||
pull: (number) => url('pull', number), | ||
commit: (sha) => url('commit', sha), | ||
compare: (a, b) => a ? url('compare', `${a.toString()}...${b.toString()}`) : null, | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.