Skip to content

Commit b58d86a

Browse files
authored
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.
1 parent 352d332 commit b58d86a

File tree

15 files changed

+463
-469
lines changed

15 files changed

+463
-469
lines changed

bin/release-please.js

+17-2
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,18 @@ const [branch] = process.argv.slice(2)
99
const setOutput = (key, val) => {
1010
if (val && (!Array.isArray(val) || val.length)) {
1111
if (dryRun) {
12-
console.log(key, JSON.stringify(val, null, 2))
12+
if (key === 'pr') {
13+
console.log('PR:', val.title.toString())
14+
console.log('='.repeat(40))
15+
console.log(val.body.toString())
16+
console.log('='.repeat(40))
17+
for (const update of val.updates.filter(u => u.updater.changelogEntry)) {
18+
console.log('CHANGELOG:', update.path)
19+
console.log('-'.repeat(40))
20+
console.log(update.updater.changelogEntry)
21+
console.log('-'.repeat(40))
22+
}
23+
}
1324
} else {
1425
core.setOutput(key, JSON.stringify(val))
1526
}
@@ -27,5 +38,9 @@ main({
2738
setOutput('release', release)
2839
return null
2940
}).catch(err => {
30-
core.setFailed(`failed: ${err}`)
41+
if (dryRun) {
42+
console.error(err)
43+
} else {
44+
core.setFailed(`failed: ${err}`)
45+
}
3146
})

lib/content/release-please-config.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"separate-pull-requests": {{{del}}},
3-
"plugins": {{#if isMono}}["node-workspace", "workspace-deps"]{{else}}{{{del}}}{{/if}},
3+
"plugins": {{#if isMono}}["node-workspace"]{{else}}{{{del}}}{{/if}},
44
"exclude-packages-from-root": true,
55
"group-pull-request-title-pattern": "chore: release ${version}",
66
"pull-request-title-pattern": "chore: release${component} ${version}",

lib/release-please/changelog.js

+58-200
Original file line numberDiff line numberDiff line change
@@ -1,225 +1,83 @@
1-
const RP = require('release-please/build/src/changelog-notes/default')
1+
const makeGh = require('./github.js')
2+
const { link, code, specRe, list, dateFmt } = require('./util')
23

3-
module.exports = class DefaultChangelogNotes extends RP.DefaultChangelogNotes {
4+
module.exports = class ChangelogNotes {
45
constructor (options) {
5-
super(options)
6-
this.github = options.github
6+
this.gh = makeGh(options.github)
77
}
88

9-
async buildDefaultNotes (commits, options) {
10-
// The default generator has a title with the version and date
11-
// and a link to the diff between the last two versions
12-
const notes = await super.buildNotes(commits, options)
13-
const lines = notes.split('\n')
14-
15-
let foundBreakingHeader = false
16-
let foundNextHeader = false
17-
const breaking = lines.reduce((acc, line) => {
18-
if (line.match(/^### .* BREAKING CHANGES$/)) {
19-
foundBreakingHeader = true
20-
} else if (!foundNextHeader && foundBreakingHeader && line.match(/^### /)) {
21-
foundNextHeader = true
22-
}
23-
if (foundBreakingHeader && !foundNextHeader) {
24-
acc.push(line)
25-
}
26-
return acc
27-
}, []).join('\n')
9+
buildEntry (commit, authors = []) {
10+
const breaking = commit.notes
11+
.filter(n => n.title === 'BREAKING CHANGE')
12+
.map(n => n.text)
2813

29-
return {
30-
title: lines[0],
31-
breaking: breaking.trim(),
32-
}
33-
}
34-
35-
async buildNotes (commits, options) {
36-
const { title, breaking } = await this.buildDefaultNotes(commits, options)
37-
const body = await generateChangelogBody(commits, { github: this.github, ...options })
38-
return [title, breaking, body].filter(Boolean).join('\n\n')
39-
}
40-
}
14+
const entry = []
4115

42-
// a naive implementation of console.log/group for indenting console
43-
// output but keeping it in a buffer to be output to a file or console
44-
const logger = (init) => {
45-
let indent = 0
46-
const step = 2
47-
const buffer = [init]
48-
return {
49-
toString () {
50-
return buffer.join('\n').trim()
51-
},
52-
group (s) {
53-
this.log(s)
54-
indent += step
55-
},
56-
groupEnd () {
57-
indent -= step
58-
},
59-
log (s) {
60-
if (!s) {
61-
buffer.push('')
62-
} else {
63-
buffer.push(s.split('\n').map((l) => ' '.repeat(indent) + l).join('\n'))
64-
}
65-
},
66-
}
67-
}
68-
69-
const generateChangelogBody = async (_commits, { github, changelogSections }) => {
70-
const changelogMap = new Map(
71-
changelogSections.filter(c => !c.hidden).map((c) => [c.type, c.section])
72-
)
73-
74-
const { repository } = await github.graphql(
75-
`fragment commitCredit on GitObject {
76-
... on Commit {
77-
message
78-
url
79-
abbreviatedOid
80-
authors (first:10) {
81-
nodes {
82-
user {
83-
login
84-
url
85-
}
86-
email
87-
name
88-
}
89-
}
90-
associatedPullRequests (first:10) {
91-
nodes {
92-
number
93-
url
94-
merged
95-
}
96-
}
97-
}
16+
if (commit.sha) {
17+
// A link to the commit
18+
entry.push(link(code(commit.sha.slice(0, 7)), this.gh.commit(commit.sha)))
9819
}
9920

100-
query {
101-
repository (owner:"${github.repository.owner}", name:"${github.repository.repo}") {
102-
${_commits.map(({ sha: s }) => `_${s}: object (expression: "${s}") { ...commitCredit }`)}
103-
}
104-
}`
105-
)
106-
107-
// collect commits by valid changelog type
108-
const commits = [...changelogMap.values()].reduce((acc, type) => {
109-
acc[type] = []
110-
return acc
111-
}, {})
112-
113-
const allCommits = Object.values(repository)
114-
115-
for (const commit of allCommits) {
116-
// get changelog type of commit or bail if there is not a valid one
117-
const [, type] = /(^\w+)[\s(:]?/.exec(commit.message) || []
118-
const changelogType = changelogMap.get(type)
119-
if (!changelogType) {
120-
continue
21+
// A link to the pull request if the commit has one
22+
const prNumber = commit.pullRequest && commit.pullRequest.number
23+
if (prNumber) {
24+
entry.push(link(`#${prNumber}`, this.gh.pull(prNumber)))
12125
}
12226

123-
const message = commit.message
124-
.trim() // remove leading/trailing spaces
125-
.replace(/(\r?\n)+/gm, '\n') // replace multiple newlines with one
126-
.replace(/([^\s]+@\d+\.\d+\.\d+.*)/gm, '`$1`') // wrap package@version in backticks
127-
128-
// the title is the first line of the commit, 'let' because we change it later
129-
let [title, ...body] = message.split('\n')
130-
131-
const prs = commit.associatedPullRequests.nodes.filter((pull) => pull.merged)
132-
133-
// external squashed PRs dont get the associated pr node set
134-
// so we try to grab it from the end of the commit title
135-
// since thats where it goes by default
136-
const [, titleNumber] = title.match(/\s+\(#(\d+)\)$/) || []
137-
if (titleNumber && !prs.find((pr) => pr.number === +titleNumber)) {
138-
try {
139-
// it could also reference an issue so we do one extra check
140-
// to make sure it is really a pr that has been merged
141-
const { data: realPr } = await github.octokit.pulls.get({
142-
owner: github.repository.owner,
143-
repo: github.repository.repo,
144-
pull_number: titleNumber,
145-
})
146-
if (realPr.state === 'MERGED') {
147-
prs.push(realPr)
148-
}
149-
} catch {
150-
// maybe an issue or something else went wrong
151-
// not super important so keep going
152-
}
27+
// The title of the commit, with the optional scope as a prefix
28+
const scope = commit.scope && `${commit.scope}:`
29+
const subject = commit.bareMessage.replace(specRe, code('$1'))
30+
entry.push([scope, subject].filter(Boolean).join(' '))
31+
32+
// A list og the authors github handles or names
33+
if (authors.length && commit.type !== 'deps') {
34+
entry.push(`(${authors.join(', ')})`)
15335
}
15436

155-
for (const pr of prs) {
156-
title = title.replace(new RegExp(`\\s*\\(#${pr.number}\\)`, 'g'), '')
37+
return {
38+
entry: entry.join(' '),
39+
breaking,
15740
}
41+
}
15842

159-
body = body
160-
.map((line) => line.trim()) // remove artificial line breaks
161-
.filter(Boolean) // remove blank lines
162-
.join('\n') // rejoin on new lines
163-
.split(/^[*-]/gm) // split on lines starting with bullets
164-
.map((line) => line.trim()) // remove spaces around bullets
165-
.filter((line) => !title.includes(line)) // rm lines that exist in the title
166-
// replace new lines for this bullet with spaces and re-bullet it
167-
.map((line) => `* ${line.trim().replace(/\n/gm, ' ')}`)
168-
.join('\n') // re-join with new lines
169-
170-
commits[changelogType].push({
171-
hash: commit.abbreviatedOid,
172-
url: commit.url,
173-
title,
174-
type: changelogType,
175-
body,
176-
prs,
177-
credit: commit.authors.nodes.map((author) => {
178-
if (author.user && author.user.login) {
179-
return {
180-
name: `@${author.user.login}`,
181-
url: author.user.url,
182-
}
183-
}
184-
// if the commit used an email that's not associated with a github account
185-
// then the user field will be empty, so we fall back to using the committer's
186-
// name and email as specified by git
187-
return {
188-
name: author.name,
189-
url: `mailto:${author.email}`,
43+
async buildNotes (rawCommits, { version, previousTag, currentTag, changelogSections }) {
44+
const changelog = changelogSections.reduce((acc, c) => {
45+
if (!c.hidden) {
46+
acc[c.type] = {
47+
title: c.section,
48+
entries: [],
19049
}
191-
}),
50+
}
51+
return acc
52+
}, {
53+
breaking: {
54+
title: '⚠️ BREAKING CHANGES',
55+
entries: [],
56+
},
19257
})
193-
}
19458

195-
const output = logger()
59+
// Only continue with commits that will make it to our changelog
60+
const commits = rawCommits.filter(c => changelog[c.type])
19661

197-
for (const key of Object.keys(commits)) {
198-
if (commits[key].length > 0) {
199-
output.group(`### ${key}\n`)
62+
const authorsByCommit = await this.gh.authors(commits)
20063

201-
for (const commit of commits[key]) {
202-
let groupCommit = `* [\`${commit.hash}\`](${commit.url})`
64+
// Group commits by type
65+
for (const commit of commits) {
66+
const { entry, breaking } = this.buildEntry(commit, authorsByCommit[commit.sha])
20367

204-
for (const pr of commit.prs) {
205-
groupCommit += ` [#${pr.number}](${pr.url})`
206-
}
68+
// Collect commits by type
69+
changelog[commit.type].entries.push(entry)
20770

208-
groupCommit += ` ${commit.title}`
209-
if (key !== 'Dependencies') {
210-
for (const user of commit.credit) {
211-
groupCommit += ` (${user.name})`
212-
}
213-
}
71+
// And push breaking changes to its own section
72+
changelog.breaking.entries.push(...breaking)
73+
}
21474

215-
output.group(groupCommit)
216-
output.groupEnd()
217-
}
75+
const sections = Object.values(changelog)
76+
.filter((s) => s.entries.length)
77+
.map(({ title, entries }) => [`### ${title}`, entries.map(list).join('\n')].join('\n\n'))
21878

219-
output.log()
220-
output.groupEnd()
221-
}
222-
}
79+
const title = `## ${link(version, this.gh.compare(previousTag, currentTag))} (${dateFmt()})`
22380

224-
return output.toString()
81+
return [title, ...sections].join('\n\n').trim()
82+
}
22583
}

lib/release-please/github.js

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
module.exports = (gh) => {
2+
const { owner, repo } = gh.repository
3+
4+
const authors = async (commits) => {
5+
const response = {}
6+
7+
const shas = commits.map(c => c.sha).filter(Boolean)
8+
9+
if (!shas.length) {
10+
return response
11+
}
12+
13+
const { repository } = await gh.graphql(
14+
`fragment CommitAuthors on GitObject {
15+
... on Commit {
16+
authors (first:10) {
17+
nodes {
18+
user { login }
19+
name
20+
}
21+
}
22+
}
23+
}
24+
query {
25+
repository (owner:"${owner}", name:"${repo}") {
26+
${shas.map((s) => {
27+
return `_${s}: object (expression: "${s}") { ...CommitAuthors }`
28+
})}
29+
}
30+
}`
31+
)
32+
33+
for (const [key, commit] of Object.entries(repository)) {
34+
if (commit) {
35+
response[key.slice(1)] = commit.authors.nodes
36+
.map((a) => a.user && a.user.login ? `@${a.user.login}` : a.name)
37+
.filter(Boolean)
38+
}
39+
}
40+
41+
return response
42+
}
43+
44+
const url = (...p) => `https://github.com/${owner}/${repo}/${p.join('/')}`
45+
46+
return {
47+
authors,
48+
pull: (number) => url('pull', number),
49+
commit: (sha) => url('commit', sha),
50+
compare: (a, b) => a ? url('compare', `${a.toString()}...${b.toString()}`) : null,
51+
}
52+
}

lib/release-please/index.js

+6-10
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
11
const RP = require('release-please')
2-
const logger = require('./logger.js')
2+
const { CheckpointLogger } = require('release-please/build/src/util/logger.js')
33
const ChangelogNotes = require('./changelog.js')
44
const Version = require('./version.js')
5-
const WorkspaceDeps = require('./workspace-deps.js')
6-
const NodeWorkspace = require('./node-workspace.js')
5+
const NodeWs = require('./node-workspace.js')
76

8-
RP.setLogger(logger)
9-
RP.registerChangelogNotes('default', (options) => new ChangelogNotes(options))
10-
RP.registerVersioningStrategy('default', (options) => new Version(options))
11-
RP.registerPlugin('workspace-deps', (o) =>
12-
new WorkspaceDeps(o.github, o.targetBranch, o.repositoryConfig))
13-
RP.registerPlugin('node-workspace', (o) =>
14-
new NodeWorkspace(o.github, o.targetBranch, o.repositoryConfig))
7+
RP.setLogger(new CheckpointLogger(true, true))
8+
RP.registerChangelogNotes('default', (o) => new ChangelogNotes(o))
9+
RP.registerVersioningStrategy('default', (o) => new Version(o))
10+
RP.registerPlugin('node-workspace', (o) => new NodeWs(o.github, o.targetBranch, o.repositoryConfig))
1511

1612
const main = async ({ repo: fullRepo, token, dryRun, branch }) => {
1713
if (!token) {

0 commit comments

Comments
 (0)