From b58d86adc26d3d6fc07c682391a597398dd3a5b3 Mon Sep 17 00:00:00 2001 From: Luke Karrys Date: Tue, 13 Sep 2022 13:45:47 -0700 Subject: [PATCH] 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. --- bin/release-please.js | 19 +- lib/content/release-please-config.json | 2 +- lib/release-please/changelog.js | 258 ++++-------------- lib/release-please/github.js | 52 ++++ lib/release-please/index.js | 16 +- lib/release-please/logger.js | 3 - lib/release-please/node-workspace.js | 190 ++++++++++++- lib/release-please/util.js | 14 + lib/release-please/version.js | 41 ++- lib/release-please/workspace-deps.js | 99 ------- .../test/apply/full-content.js.test.cjs | 6 +- tap-snapshots/test/check/diffs.js.test.cjs | 1 - test/fixtures/header.js | 1 - test/release-please/changelog.js | 98 +++++++ test/release-please/workspace-deps.js | 132 --------- 15 files changed, 463 insertions(+), 469 deletions(-) create mode 100644 lib/release-please/github.js delete mode 100644 lib/release-please/logger.js create mode 100644 lib/release-please/util.js delete mode 100644 lib/release-please/workspace-deps.js create mode 100644 test/release-please/changelog.js delete mode 100644 test/release-please/workspace-deps.js diff --git a/bin/release-please.js b/bin/release-please.js index 10dd1930..3ee33f75 100755 --- a/bin/release-please.js +++ b/bin/release-please.js @@ -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)) } @@ -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}`) + } }) diff --git a/lib/content/release-please-config.json b/lib/content/release-please-config.json index 66209f2f..6976fd32 100644 --- a/lib/content/release-please-config.json +++ b/lib/content/release-please-config.json @@ -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}", diff --git a/lib/release-please/changelog.js b/lib/release-please/changelog.js index 8cac2931..766abeaf 100644 --- a/lib/release-please/changelog.js +++ b/lib/release-please/changelog.js @@ -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() + } } diff --git a/lib/release-please/github.js b/lib/release-please/github.js new file mode 100644 index 00000000..18c033fa --- /dev/null +++ b/lib/release-please/github.js @@ -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, + } +} diff --git a/lib/release-please/index.js b/lib/release-please/index.js index 19f38fa6..2464143c 100644 --- a/lib/release-please/index.js +++ b/lib/release-please/index.js @@ -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) { diff --git a/lib/release-please/logger.js b/lib/release-please/logger.js deleted file mode 100644 index 3c30bc37..00000000 --- a/lib/release-please/logger.js +++ /dev/null @@ -1,3 +0,0 @@ -const { CheckpointLogger } = require('release-please/build/src/util/logger') - -module.exports = new CheckpointLogger(true, true) diff --git a/lib/release-please/node-workspace.js b/lib/release-please/node-workspace.js index d06c7da1..a43b0345 100644 --- a/lib/release-please/node-workspace.js +++ b/lib/release-please/node-workspace.js @@ -1,11 +1,183 @@ -const Version = require('./version.js') -const RP = require('release-please/build/src/plugins/node-workspace') - -module.exports = class NodeWorkspace extends RP.NodeWorkspace { - bumpVersion (pkg) { - // The default release please node-workspace plugin forces a patch - // bump for the root if it only includes workspace dep updates. - // This does the same thing except it respects the prerelease config. - return new Version(pkg).bump(pkg.version, [{ type: 'fix' }]) +const { NodeWorkspace } = require('release-please/build/src/plugins/node-workspace.js') +const { RawContent } = require('release-please/build/src/updaters/raw-content.js') +const { jsonStringify } = require('release-please/build/src/util/json-stringify.js') +const { addPath } = require('release-please/build/src/plugins/workspace.js') +const { TagName } = require('release-please/build/src/util/tag-name.js') +const { ROOT_PROJECT_PATH } = require('release-please/build/src/manifest.js') +const makeGh = require('./github.js') +const { link, code } = require('./util.js') + +const SCOPE = '__REPLACE_WORKSPACE_DEP__' +const WORKSPACE_DEP = new RegExp(`${SCOPE}: (\\S+) (\\S+)`, 'gm') + +module.exports = class extends NodeWorkspace { + constructor (github, ...args) { + super(github, ...args) + this.gh = makeGh(github) + } + + async preconfigure (strategiesByPath, commitsByPath, releasesByPath) { + // First build a list of all releases that will happen based on + // the conventional commits + const candidates = [] + for (const path in strategiesByPath) { + const pullRequest = await strategiesByPath[path].buildReleasePullRequest( + commitsByPath[path], + releasesByPath[path] + ) + if (pullRequest?.version) { + candidates.push({ path, pullRequest }) + } + } + + // Then build the graph of all those releases + any other connected workspaces + const { allPackages, candidatesByPackage } = await this.buildAllPackages(candidates) + const orderedPackages = this.buildGraphOrder( + await this.buildGraph(allPackages), + Object.keys(candidatesByPackage) + ) + + // Then build a list of all the updated versions + const updatedVersions = new Map() + for (const pkg of orderedPackages) { + const path = this.pathFromPackage(pkg) + const packageName = this.packageNameFromPackage(pkg) + + let version = null + const existingCandidate = candidatesByPackage[packageName] + if (existingCandidate) { + // If there is an existing pull request use that version + version = existingCandidate.pullRequest.version + } else { + // Otherwise build another pull request (that will be discarded) just + // to see what the version would be if it only contained a deps commit. + // This is to make sure we use any custom versioning or release strategy. + const strategy = strategiesByPath[path] + const depsSection = strategy.changelogSections.find(c => c.section === 'Dependencies') + const releasePullRequest = await strategiesByPath[path].buildReleasePullRequest( + [{ message: `${depsSection.type}:` }], + releasesByPath[path] + ) + version = releasePullRequest.version + } + + updatedVersions.set(packageName, version) + } + + // Save some data about the preconfiugred releases so we can look it up later + // when rewriting the changelogs + this.releasesByPackage = new Map() + this.pathsByComponent = new Map() + + // Then go through all the packages again and add deps commits + // for each updated workspace + for (const pkg of orderedPackages) { + const path = this.pathFromPackage(pkg) + const packageName = this.packageNameFromPackage(pkg) + const graphPackage = this.packageGraph.get(pkg.name) + + // Update dependency versions + for (const [depName, resolved] of graphPackage.localDependencies) { + const depVersion = updatedVersions.get(depName) + const isNotDir = resolved.type !== 'directory' + // Changelog entries are only added for dependencies and not any other type + const isDep = Object.prototype.hasOwnProperty.call(pkg.dependencies, depName) + if (depVersion && isNotDir && isDep) { + commitsByPath[path].push({ + message: `deps(${SCOPE}): ${depName} ${depVersion.toString()}`, + }) + } + } + + const component = await strategiesByPath[path].getComponent() + this.pathsByComponent.set(component, path) + this.releasesByPackage.set(packageName, { + path, + component, + currentTag: releasesByPath[path]?.tag, + }) + } + + return strategiesByPath + } + + // This is copied from the release-please node-workspace plugin + // except it only updates the package.json instead of appending + // anything to changelogs since we've already done that in preconfigure. + updateCandidate (candidate, pkg, updatedVersions) { + const graphPackage = this.packageGraph.get(pkg.name) + const updatedPackage = pkg.clone() + + for (const [depName, resolved] of graphPackage.localDependencies) { + const depVersion = updatedVersions.get(depName) + if (depVersion && resolved.type !== 'directory') { + updatedPackage.updateLocalDependency(resolved, depVersion.toString(), '^') + } + } + + for (const update of candidate.pullRequest.updates) { + if (update.path === addPath(candidate.path, 'package.json')) { + update.updater = new RawContent( + jsonStringify(updatedPackage.toJSON(), updatedPackage.rawContent) + ) + } + } + + return candidate + } + + postProcessCandidates (candidates) { + for (const candidate of candidates) { + for (const release of candidate.pullRequest.body.releaseData) { + // Update notes with a link to each workspaces release notes + // now that we have all of the releases in a single pull request + release.notes = release.notes.replace(WORKSPACE_DEP, (_, depName, depVersion) => { + const { currentTag, path, component } = this.releasesByPackage.get(depName) + + const url = this.gh.compare(currentTag, new TagName( + depVersion, + component, + this.repositoryConfig[path].tagSeparator, + this.repositoryConfig[path].includeVInTag + )) + + return `${link('Workspace', url)}: ${code(`${depName}@${depVersion}`)}` + }) + + // Find the associated changelog and update that too + const path = this.pathsByComponent.get(release.component) + for (const update of candidate.pullRequest.updates) { + if (update.path === addPath(path, 'CHANGELOG.md')) { + update.updater.changelogEntry = release.notes + } + } + } + + // Sort root release to the top of the pull request + candidate.pullRequest.body.releaseData.sort((a, b) => { + const aPath = this.pathsByComponent.get(a.component) + const bPath = this.pathsByComponent.get(b.component) + if (aPath === ROOT_PROJECT_PATH) { + return -1 + } + if (bPath === ROOT_PROJECT_PATH) { + return 1 + } + return 0 + }) + } + + return candidates + } + + // Stub these methods with errors since the preconfigure method should negate these + // ever being called from the release please base class. If they are called then + // something has changed that would likely break us in other ways. + bumpVersion () { + throw new Error('Should not bump packages. This should be done in preconfigure.') + } + + newCandidate () { + throw new Error('Should not create new candidates. This should be done in preconfigure.') } } diff --git a/lib/release-please/util.js b/lib/release-please/util.js new file mode 100644 index 00000000..7fd527e5 --- /dev/null +++ b/lib/release-please/util.js @@ -0,0 +1,14 @@ +const semver = require('semver') + +module.exports.specRe = new RegExp(`([^\\s]+@${semver.src[semver.tokens.FULLPLAIN]})`, 'g') + +module.exports.code = (c) => `\`${c}\`` +module.exports.link = (text, url) => url ? `[${text}](${url})` : text +module.exports.list = (text) => `* ${text}` + +module.exports.dateFmt = (date = new Date()) => { + const year = date.getFullYear() + const month = (date.getMonth() + 1).toString().padStart(2, '0') + const day = date.getDate().toString().padStart(2, '0') + return [year, month, day].join('-') +} diff --git a/lib/release-please/version.js b/lib/release-please/version.js index b08bc28e..29960ee7 100644 --- a/lib/release-please/version.js +++ b/lib/release-please/version.js @@ -1,5 +1,5 @@ const semver = require('semver') -const RP = require('release-please/build/src/version.js') +const { Version } = require('release-please/build/src/version.js') // A way to compare the "level" of a release since we ignore some things during prereleases const LEVELS = new Map([['prerelease', 4], ['major', 3], ['minor', 2], ['patch', 1]] @@ -7,37 +7,62 @@ const LEVELS = new Map([['prerelease', 4], ['major', 3], ['minor', 2], ['patch', const parseVersion = (v) => { const { prerelease, minor, patch, version } = semver.parse(v) + + // This looks at whether there are 0s in certain positions of the version + // 1.0.0 => major + // 1.5.0 => minor + // 1.5.6 => patch + const release = !patch + ? (minor ? LEVELS.get('minor') : LEVELS.get('major')) + : LEVELS.get('patch') + + // Keep track of whether the version has any prerelease identifier const hasPre = prerelease.length > 0 + // Even if it is a prerelease version, this might be an empty string const preId = prerelease.filter(p => typeof p === 'string').join('.') - const release = !patch ? (minor ? LEVELS.get('minor') : LEVELS.get('major')) : LEVELS.get('patch') - return { version, release, prerelease: hasPre, preId } + + return { + version, + release, + prerelease: hasPre, + preId, + } } const parseCommits = (commits, prerelease) => { + // Default is a patch level change let release = LEVELS.get('patch') + for (const commit of commits) { if (commit.breaking) { + // If any breaking commit is present, its a major release = LEVELS.get('major') break } else if (['feat', 'feature'].includes(commit.type)) { + // Otherwise a feature is a minor release release = LEVELS.get('minor') } } - return { release, prerelease: !!prerelease } + + return { + release, + prerelease: !!prerelease, + } } const preInc = ({ version, prerelease, preId }, release) => { if (!release.startsWith('pre')) { release = `pre${release}` } + // `pre` is the default prerelease identifier when creating a new // prerelease version return semver.inc(version, release, prerelease ? preId : 'pre') } -const releasePleaseVersion = (v) => { +const semverToVersion = (v) => { const { major, minor, patch, prerelease } = semver.parse(v) - return new RP.Version(major, minor, patch, prerelease.join('.')) + return new Version(major, minor, patch, prerelease.join('.')) } // This does not account for pre v1 semantics since we don't publish those @@ -71,6 +96,8 @@ module.exports = class DefaultVersioningStrategy { const releaseVersion = next.prerelease ? preInc(current, release) : semver.inc(current.version, release) - return releasePleaseVersion(releaseVersion) + return semverToVersion(releaseVersion) } } + +module.exports.semverToVersion = semverToVersion diff --git a/lib/release-please/workspace-deps.js b/lib/release-please/workspace-deps.js deleted file mode 100644 index fd33b64c..00000000 --- a/lib/release-please/workspace-deps.js +++ /dev/null @@ -1,99 +0,0 @@ -const { ManifestPlugin } = require('release-please/build/src/plugin') -const { Changelog } = require('release-please/build/src/updaters/changelog.js') -const { PackageJson } = require('release-please/build/src/updaters/node/package-json.js') - -const matchLine = (line, re) => { - const trimmed = line.trim().replace(/^[*\s]+/, '') - if (typeof re === 'string') { - return trimmed === re - } - return trimmed.match(re) -} - -module.exports = class WorkspaceDeps extends ManifestPlugin { - run (pullRequests) { - try { - for (const { pullRequest } of pullRequests) { - const getChangelog = (release) => pullRequest.updates.find((u) => { - const isChangelog = u.updater instanceof Changelog - const isComponent = release.component && u.path.startsWith(release.component) - const isRoot = !release.component && !u.path.includes('/') - return isChangelog && (isComponent || isRoot) - }) - - const getComponent = (pkgName) => pullRequest.updates.find((u) => { - const isPkg = u.updater instanceof PackageJson - return isPkg && JSON.parse(u.updater.rawContent).name === pkgName - }).path.replace(/\/package\.json$/, '') - - const depLinksByComponent = pullRequest.body.releaseData.reduce((acc, release) => { - if (release.component) { - const path = [ - this.github.repository.owner, - this.github.repository.repo, - 'releases', - 'tag', - release.tag.toString(), - ] - acc[release.component] = `https://github.com/${path.join('/')}` - } - return acc - }, {}) - - for (const release of pullRequest.body.releaseData) { - const lines = release.notes.split('\n') - const newLines = [] - - let inWorkspaceDeps = false - let collectWorkspaceDeps = false - - for (const line of lines) { - if (matchLine(line, 'The following workspace dependencies were updated')) { - // We are in the section with our workspace deps - // Set the flag and discard this line since we dont want it in the final output - inWorkspaceDeps = true - } else if (inWorkspaceDeps) { - if (collectWorkspaceDeps) { - const depMatch = matchLine(line, /^(\S+) bumped from \S+ to (\S+)$/) - if (depMatch) { - // If we have a line that is a workspace dep update, then reformat - // it and save it to the new lines - const [, depName, newVersion] = depMatch - const depSpec = `\`${depName}@${newVersion}\`` - const url = depLinksByComponent[getComponent(depName)] - newLines.push(` * deps: [${depSpec}](${url})`) - } else { - // Anything else means we are done with dependencies so ignore - // this line and dont look for any more - collectWorkspaceDeps = false - } - } else if (matchLine(line, 'dependencies')) { - // Only collect dependencies discard dev deps and everything else - collectWorkspaceDeps = true - } else if (matchLine(line, '') || matchLine(line, /^#/)) { - inWorkspaceDeps = false - newLines.push(line) - } - } else { - newLines.push(line) - } - } - - let newNotes = newLines.join('\n').trim() - const emptyDeps = newNotes.match(/### Dependencies[\n]+(### .*)/m) - if (emptyDeps) { - newNotes = newNotes.replace(emptyDeps[0], emptyDeps[1]) - } - - release.notes = newNotes - getChangelog(release).updater.changelogEntry = newNotes - } - } - } catch { - // Always return pull requests even if we failed so - // we dont fail the release - } - - return pullRequests - } -} diff --git a/tap-snapshots/test/apply/full-content.js.test.cjs b/tap-snapshots/test/apply/full-content.js.test.cjs index fddf69da..5ad8fac3 100644 --- a/tap-snapshots/test/apply/full-content.js.test.cjs +++ b/tap-snapshots/test/apply/full-content.js.test.cjs @@ -1634,8 +1634,7 @@ release-please-config.json ======================================== { "plugins": [ - "node-workspace", - "workspace-deps" + "node-workspace" ], "exclude-packages-from-root": true, "group-pull-request-title-pattern": "chore: release \${version}", @@ -2274,8 +2273,7 @@ release-please-config.json ======================================== { "plugins": [ - "node-workspace", - "workspace-deps" + "node-workspace" ], "exclude-packages-from-root": true, "group-pull-request-title-pattern": "chore: release \${version}", diff --git a/tap-snapshots/test/check/diffs.js.test.cjs b/tap-snapshots/test/check/diffs.js.test.cjs index 8da852c1..87b168ec 100644 --- a/tap-snapshots/test/check/diffs.js.test.cjs +++ b/tap-snapshots/test/check/diffs.js.test.cjs @@ -24,7 +24,6 @@ To correct it: npx template-oss-apply --force exports[`test/check/diffs.js TAP different headers > source after apply 1`] = ` content/index.js ======================================== - module.exports = { rootRepo: { add: { diff --git a/test/fixtures/header.js b/test/fixtures/header.js index 335c0bbe..317dc4f2 100644 --- a/test/fixtures/header.js +++ b/test/fixtures/header.js @@ -1,4 +1,3 @@ - module.exports = { rootRepo: { add: { diff --git a/test/release-please/changelog.js b/test/release-please/changelog.js new file mode 100644 index 00000000..4350cfcb --- /dev/null +++ b/test/release-please/changelog.js @@ -0,0 +1,98 @@ +const t = require('tap') +const ChangelogNotes = require('../../lib/release-please/changelog.js') + +const mockChangelog = async ({ shas = true, authors = true, previousTag = true } = {}) => { + const commits = [{ + sha: 'a', + type: 'feat', + notes: [], + bareMessage: 'Hey now', + scope: 'bin', + }, { + sha: 'b', + type: 'feat', + notes: [{ title: 'BREAKING CHANGE', text: 'breaking' }], + bareMessage: 'b', + pullRequest: { + number: '100', + }, + }, { + sha: 'c', + type: 'deps', + bareMessage: 'test@1.2.3', + notes: [], + }, { + sha: 'd', + type: 'fix', + bareMessage: 'this fixes it', + notes: [], + }].map(({ sha, ...rest }) => shas ? { sha, ...rest } : rest) + + const github = { + repository: { owner: 'npm', repo: 'cli' }, + graphql: () => ({ + repository: commits.reduce((acc, c, i) => { + if (c.sha) { + if (c.sha === 'd') { + // simulate a bad sha passed in that doesnt return a commit + acc[`_${c.sha}`] = null + } else { + const author = i % 2 + ? { user: { login: 'username' } } + : { name: 'Name' } + acc[`_${c.sha}`] = { authors: { nodes: authors ? [author] : [] } } + } + } + return acc + }, {}), + }), + + } + + const changelog = new ChangelogNotes({ github }) + + const notes = await changelog.buildNotes(commits, { + version: '1.0.0', + previousTag: previousTag ? 'v0.1.0' : null, + currentTag: 'v1.0.0', + changelogSections: require('../../release-please-config.json')['changelog-sections'], + }) + + return notes + .split('\n') + .map((l) => l.replace(/\d{4}-\d{2}-\d{2}/g, 'DATE')) + .filter(Boolean) +} + +t.test('changelog', async t => { + const changelog = await mockChangelog() + t.strictSame(changelog, [ + '## [1.0.0](https://github.com/npm/cli/compare/v0.1.0...v1.0.0) (DATE)', + '### ⚠️ BREAKING CHANGES', + '* breaking', + '### Features', + '* [`a`](https://github.com/npm/cli/commit/a) bin: Hey now (Name)', + // eslint-disable-next-line max-len + '* [`b`](https://github.com/npm/cli/commit/b) [#100](https://github.com/npm/cli/pull/100) b (@username)', + '### Bug Fixes', + '* [`d`](https://github.com/npm/cli/commit/d) this fixes it', + '### Dependencies', + '* [`c`](https://github.com/npm/cli/commit/c) `test@1.2.3`', + ]) +}) + +t.test('no tag/authors/shas', async t => { + const changelog = await mockChangelog({ authors: false, previousTag: false, shas: false }) + t.strictSame(changelog, [ + '## 1.0.0 (DATE)', + '### ⚠️ BREAKING CHANGES', + '* breaking', + '### Features', + '* bin: Hey now', + '* [#100](https://github.com/npm/cli/pull/100) b', + '### Bug Fixes', + '* this fixes it', + '### Dependencies', + '* `test@1.2.3`', + ]) +}) diff --git a/test/release-please/workspace-deps.js b/test/release-please/workspace-deps.js deleted file mode 100644 index 8159bb1d..00000000 --- a/test/release-please/workspace-deps.js +++ /dev/null @@ -1,132 +0,0 @@ -const t = require('tap') -const WorkspaceDeps = require('../../lib/release-please/workspace-deps.js') -const { Changelog } = require('release-please/build/src/updaters/changelog.js') -const { PackageJson } = require('release-please/build/src/updaters/node/package-json.js') -const { TagName } = require('release-please/build/src/util/tag-name.js') -const { Version } = require('release-please/build/src/version.js') - -const mockWorkspaceDeps = (notes) => { - const releases = [{ - component: '', - notes: notes.join('\n'), - }, { - component: 'pkg2', - notes: '### no link', - tag: new TagName(new Version(2, 0, 0), 'pkg2'), - }, { - component: 'pkg1', - notes: '### [sdsfsdf](http://url1)', - tag: new TagName(new Version(2, 0, 0), 'pkg1'), - }, { - component: 'pkg3', - notes: '### [sdsfsdf](http://url3)', - tag: new TagName(new Version(2, 0, 0), 'pkg3'), - }] - - const options = { github: { repository: { owner: 'npm', repo: 'cli' } } } - - const pullRequests = new WorkspaceDeps(options.github).run([{ - pullRequest: { - body: { - releaseData: releases, - }, - updates: [ - ...releases.map(r => ({ - path: `${r.component ? `${r.component}/` : ''}CHANGELOG.md`, - updater: new Changelog({ changelogEntry: notes.join('\n') }), - })), - ...releases.map(r => { - const pkg = new PackageJson({ version: '1.0.0' }) - pkg.rawContent = JSON.stringify({ - name: r.component === 'pkg1' ? '@scope/pkg1' : (r.component || 'root'), - }) - return { - path: `${r.component ? `${r.component}/` : ''}package.json`, - updater: pkg, - } - }), - ], - }, - }]) - return { - pr: pullRequests[0].pullRequest.body.releaseData[0].notes.split('\n'), - changelog: pullRequests[0].pullRequest.updates[0].updater.changelogEntry.split('\n'), - } -} - -const fixtures = { - wsDeps: [ - '### Feat', - '', - ' * xyz', - '', - '### Dependencies', - '', - '* The following workspace dependencies were updated', - ' * peerDependencies', - ' * pkgA bumped from ^1.0.0 to ^2.0.0', - ' * pkgB bumped from ^1.0.0 to ^2.0.0', - ' * dependencies', - ' * @scope/pkg1 bumped from ^1.0.0 to ^2.0.0', - ' * pkg2 bumped from ^1.0.0 to ^2.0.0', - ' * pkg3 bumped from ^1.0.0 to ^2.0.0', - ' * devDependencies', - ' * pkgC bumped from ^1.0.0 to ^2.0.0', - ' * pkgD bumped from ^1.0.0 to ^2.0.0', - '', - '### Next', - '', - ' * xyz', - ], - empty: [ - '### Feat', - '', - ' * xyz', - '', - '### Dependencies', - '', - '* The following workspace dependencies were updated', - ' * peerDependencies', - ' * pkgA bumped from ^1.0.0 to ^2.0.0', - ' * pkgB bumped from ^1.0.0 to ^2.0.0', - ' * devDependencies', - ' * pkgC bumped from ^1.0.0 to ^2.0.0', - ' * pkgD bumped from ^1.0.0 to ^2.0.0', - '', - '### Other', - '', - ' * xyz', - ], -} - -t.test('rewrite deps', async (t) => { - const mockWs = mockWorkspaceDeps(fixtures.wsDeps) - t.strictSame(mockWs.pr, [ - '### Feat', - '', - ' * xyz', - '', - '### Dependencies', - '', - ' * deps: [`@scope/pkg1@^2.0.0`](https://github.com/npm/cli/releases/tag/pkg1-v2.0.0)', - ' * deps: [`pkg2@^2.0.0`](https://github.com/npm/cli/releases/tag/pkg2-v2.0.0)', - ' * deps: [`pkg3@^2.0.0`](https://github.com/npm/cli/releases/tag/pkg3-v2.0.0)', - '', - '### Next', - '', - ' * xyz', - ]) - t.strictSame(mockWs.pr, mockWs.changelog) - - const mockEmpty = mockWorkspaceDeps(fixtures.empty) - t.strictSame(mockEmpty.pr, [ - '### Feat', - '', - ' * xyz', - '', - '### Other', - '', - ' * xyz', - ]) - t.strictSame(mockEmpty.pr, mockEmpty.changelog) -})