|
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') |
2 | 3 |
|
3 |
| -module.exports = class DefaultChangelogNotes extends RP.DefaultChangelogNotes { |
| 4 | +module.exports = class ChangelogNotes { |
4 | 5 | constructor (options) {
|
5 |
| - super(options) |
6 |
| - this.github = options.github |
| 6 | + this.gh = makeGh(options.github) |
7 | 7 | }
|
8 | 8 |
|
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) |
28 | 13 |
|
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 = [] |
41 | 15 |
|
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))) |
98 | 19 | }
|
99 | 20 |
|
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))) |
121 | 25 | }
|
122 | 26 |
|
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(', ')})`) |
153 | 35 | }
|
154 | 36 |
|
155 |
| - for (const pr of prs) { |
156 |
| - title = title.replace(new RegExp(`\\s*\\(#${pr.number}\\)`, 'g'), '') |
| 37 | + return { |
| 38 | + entry: entry.join(' '), |
| 39 | + breaking, |
157 | 40 | }
|
| 41 | + } |
158 | 42 |
|
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: [], |
190 | 49 | }
|
191 |
| - }), |
| 50 | + } |
| 51 | + return acc |
| 52 | + }, { |
| 53 | + breaking: { |
| 54 | + title: '⚠️ BREAKING CHANGES', |
| 55 | + entries: [], |
| 56 | + }, |
192 | 57 | })
|
193 |
| - } |
194 | 58 |
|
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]) |
196 | 61 |
|
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) |
200 | 63 |
|
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]) |
203 | 67 |
|
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) |
207 | 70 |
|
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 | + } |
214 | 74 |
|
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')) |
218 | 78 |
|
219 |
| - output.log() |
220 |
| - output.groupEnd() |
221 |
| - } |
222 |
| - } |
| 79 | + const title = `## ${link(version, this.gh.compare(previousTag, currentTag))} (${dateFmt()})` |
223 | 80 |
|
224 |
| - return output.toString() |
| 81 | + return [title, ...sections].join('\n\n').trim() |
| 82 | + } |
225 | 83 | }
|
0 commit comments