Skip to content

Commit

Permalink
fix: use conventional commits from release-please for changelog (#183)
Browse files Browse the repository at this point in the history
`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
lukekarrys authored Sep 13, 2022
1 parent 352d332 commit b58d86a
Show file tree
Hide file tree
Showing 15 changed files with 463 additions and 469 deletions.
19 changes: 17 additions & 2 deletions bin/release-please.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,18 @@ const [branch] = process.argv.slice(2)
const setOutput = (key, val) => {
if (val && (!Array.isArray(val) || val.length)) {
if (dryRun) {
console.log(key, JSON.stringify(val, null, 2))
if (key === 'pr') {
console.log('PR:', val.title.toString())
console.log('='.repeat(40))
console.log(val.body.toString())
console.log('='.repeat(40))
for (const update of val.updates.filter(u => u.updater.changelogEntry)) {
console.log('CHANGELOG:', update.path)
console.log('-'.repeat(40))
console.log(update.updater.changelogEntry)
console.log('-'.repeat(40))
}
}
} else {
core.setOutput(key, JSON.stringify(val))
}
Expand All @@ -27,5 +38,9 @@ main({
setOutput('release', release)
return null
}).catch(err => {
core.setFailed(`failed: ${err}`)
if (dryRun) {
console.error(err)
} else {
core.setFailed(`failed: ${err}`)
}
})
2 changes: 1 addition & 1 deletion lib/content/release-please-config.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"separate-pull-requests": {{{del}}},
"plugins": {{#if isMono}}["node-workspace", "workspace-deps"]{{else}}{{{del}}}{{/if}},
"plugins": {{#if isMono}}["node-workspace"]{{else}}{{{del}}}{{/if}},
"exclude-packages-from-root": true,
"group-pull-request-title-pattern": "chore: release ${version}",
"pull-request-title-pattern": "chore: release${component} ${version}",
Expand Down
258 changes: 58 additions & 200 deletions lib/release-please/changelog.js
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
}
email
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()
}
}
52 changes: 52 additions & 0 deletions lib/release-please/github.js
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,
}
}
16 changes: 6 additions & 10 deletions lib/release-please/index.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
const RP = require('release-please')
const logger = require('./logger.js')
const { CheckpointLogger } = require('release-please/build/src/util/logger.js')
const ChangelogNotes = require('./changelog.js')
const Version = require('./version.js')
const WorkspaceDeps = require('./workspace-deps.js')
const NodeWorkspace = require('./node-workspace.js')
const NodeWs = require('./node-workspace.js')

RP.setLogger(logger)
RP.registerChangelogNotes('default', (options) => new ChangelogNotes(options))
RP.registerVersioningStrategy('default', (options) => new Version(options))
RP.registerPlugin('workspace-deps', (o) =>
new WorkspaceDeps(o.github, o.targetBranch, o.repositoryConfig))
RP.registerPlugin('node-workspace', (o) =>
new NodeWorkspace(o.github, o.targetBranch, o.repositoryConfig))
RP.setLogger(new CheckpointLogger(true, true))
RP.registerChangelogNotes('default', (o) => new ChangelogNotes(o))
RP.registerVersioningStrategy('default', (o) => new Version(o))
RP.registerPlugin('node-workspace', (o) => new NodeWs(o.github, o.targetBranch, o.repositoryConfig))

const main = async ({ repo: fullRepo, token, dryRun, branch }) => {
if (!token) {
Expand Down
Loading

0 comments on commit b58d86a

Please sign in to comment.