Skip to content

Commit

Permalink
fix: use conventional commits from release-please for changelog
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 refactors the node-workspace plugin to use the built-in
post processing hook to rewrite our workspace dep commits.
  • Loading branch information
lukekarrys committed Sep 8, 2022
1 parent ae75f91 commit 2136540
Show file tree
Hide file tree
Showing 14 changed files with 589 additions and 454 deletions.
1 change: 1 addition & 0 deletions bin/release-please.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,6 @@ main({
setOutput('release', release)
return null
}).catch(err => {
console.log(err)
core.setFailed(`failed: ${err}`)
})
265 changes: 65 additions & 200 deletions lib/release-please/changelog.js
Original file line number Diff line number Diff line change
@@ -1,225 +1,90 @@
const RP = require('release-please/build/src/changelog-notes/default')
const makeGh = require('./github.js')
const saveFixture = require('./save-fixture.js')
const { link, code, specRe, list } = 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(),
}
}
const entry = []

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')
}
}
// A link to the commit
entry.push(link(code(commit.sha.slice(0, 7)), this.gh.commit(commit.sha)))

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

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
}
// 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(' '))

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
}
// 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 }) {
await saveFixture(`changelog-${currentTag}`, {
commits: 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 changelogTitle = this.gh.title(version, currentTag, previousTag)

return output.toString()
return [changelogTitle, ...sections].join('\n\n').trim()
}
}
60 changes: 60 additions & 0 deletions lib/release-please/github.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
const { link, dateFmt } = require('./util')

module.exports = (gh) => {
return {
...gh,
authors: ghAuthors(gh),
...format(gh),
}
}

const format = (gh) => {
const base = `https://github.com/${gh.repository.owner}/${gh.repository.repo}`
const url = (...p) => `${base}/${p.join('/')}`
const compare = (prev, current) => url('compare', prev, '...', current)
return {
pull: (number) => url('pull', number),
compare: compare,
commit: (sha) => url('commit', sha),
release: (tag) => url('releases', 'tag', tag),
title: (version, currentTag, previousTag) =>
`## ${link(version, previousTag && compare(previousTag, currentTag))} (${dateFmt()})`,
tag: (component, version) => [component, `v${version.toString()}`].filter(Boolean).join('-'),
}
}

const ghAuthors = (gh) => async (commits) => {
const response = {}

if (!commits.length) {
return response
}

const { repository } = await gh.graphql(
`fragment CommitAuthors on GitObject {
... on Commit {
authors (first:10) {
nodes {
user { login }
name
}
}
}
}
query {
repository (owner:"${gh.repository.owner}", name:"${gh.repository.repo}") {
${commits.map(({ sha: s }) => {
return `_${s}: object (expression: "${s}") { ...CommitAuthors }`
})}
}
}`
)

for (const [sha, commit] of Object.entries(repository)) {
response[sha.slice(1)] = commit.authors.nodes
.map((a) => a.user && a.user.login ? `@${a.user.login}` : a.name)
.filter(Boolean)
}

return response
}
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
3 changes: 0 additions & 3 deletions lib/release-please/logger.js

This file was deleted.

Loading

0 comments on commit 2136540

Please sign in to comment.