Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/npm/cli.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLuke Karrys <luke@lukekarrys.com>2022-04-03 10:14:45 +0300
committerGitHub <noreply@github.com>2022-04-03 10:14:45 +0300
commit79827ec7e7303db34894b9b6114696c5fedb8894 (patch)
treee050924605c66b52ad5bb207f4e7600827c6bf04 /scripts
parent2829cb28a432b5ff7beeeb3bf3e7e2e174c1121d (diff)
chore: add option for changelog to write to file (#4662)
Diffstat (limited to 'scripts')
-rw-r--r--scripts/changelog.js361
1 files changed, 285 insertions, 76 deletions
diff --git a/scripts/changelog.js b/scripts/changelog.js
index f0f46d8e8..8c88490e8 100644
--- a/scripts/changelog.js
+++ b/scripts/changelog.js
@@ -1,50 +1,216 @@
+/* eslint no-shadow:2 */
'use strict'
-const execSync = require('child_process').execSync
+const { execSync } = require('child_process')
+const semver = require('semver')
+const fs = require('fs')
+const config = require('@npmcli/template-oss')
+const { resolve, relative } = require('path')
-/*
-Usage:
+const exec = (...args) => execSync(...args).toString().trim()
-node scripts/changelog.js [comittish]
+const usage = () => `
+ node ${relative(process.cwd(), __filename)} [--read|-r] [--write|-w] [tag]
-Generates changelog entries in our format as best as its able based on
-commits starting at comittish, or if that's not passed, latest.
+ Generates changelog entries in our format starting from the most recent tag.
+ By default this script will print the release notes to stdout.
-Ordinarily this is run via the gen-changelog shell script, which appends
-the result to the changelog.
+ [tag] (defaults to most recent tag)
+ A tag to generate release notes for. Helpful for testing this script against
+ old releases. Leave this empty to look for the most recent tag.
-*/
+ [--write|-w] (default: false)
+ When set it will update the changelog with the new release.
+ If a release with the same version already exists it will replace it, otherwise
+ it will prepend it to the file directly after the top level changelog title.
+
+ [--read|-r] (default: false)
+ When set it will read the release notes for the [tag] from the CHANGELOG.md,
+ instead of fetching it. This is useful after release notes have been manually
+ edited and need to be pasted somewhere else.
+`
+
+// this script assumes that the tags it looks for all start with this prefix
+const TAG_PREFIX = 'v'
+
+// 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'))
+ }
+ },
+ }
+}
+
+// some helpers for generating common parts
+// of our markdown release notes
+const RELEASE = {
+ sep: '\n\n',
+ heading: '## ',
+ // versions in titles must be prefixed with a v
+ versionRe: semver.src[11].replace(TAG_PREFIX + '?', TAG_PREFIX),
+ get h1 () {
+ return '# Changelog' + this.sep
+ },
+ version (s) {
+ return s.startsWith(TAG_PREFIX) ? s : TAG_PREFIX + s
+ },
+ date (d) {
+ return `(${d || exec('date +%Y-%m-%d')})`
+ },
+ title (v, d) {
+ return `${this.heading}${this.version(v)} ${this.date(d)}`
+ },
+}
+
+// a map of all our changelog types that go into the release notes to be
+// looked up by commit type and return the section title
+const CHANGELOG = new Map(
+ config.changelogTypes.filter(c => !c.hidden).map((c) => [c.type, c.section]))
+
+const assertArgs = (args) => {
+ if (args.help) {
+ console.log(usage())
+ return process.exit(0)
+ }
+
+ if (args.unsafe) {
+ // just to make manual testing easier
+ return args
+ }
+
+ // we dont need to be up to date to read from our local changelog
+ if (!args.read) {
+ exec(`git fetch ${args.remote}`)
+ const remoteBranch = `${args.remote}/${args.branch}`
+ const current = exec(`git rev-parse --abbrev-ref HEAD`)
+
+ if (current !== args.branch) {
+ throw new Error(`Must be on branch "${args.branch}"`)
+ }
+
+ const localLog = exec(`git log ${remoteBranch}..HEAD`).length > 0
+ const remoteLog = exec(`git log HEAD..${remoteBranch}`).length > 0
+
+ if (current !== args.branch || localLog || remoteLog) {
+ throw new Error(`Must be in sync with "${remoteBranch}"`)
+ }
+ }
+
+ return args
+}
const parseArgs = (argv) => {
const result = {
+ tag: null,
+ file: resolve(__dirname, '..', 'CHANGELOG.md'),
+ branch: 'latest',
+ remote: 'origin',
releaseNotes: false,
- branch: 'origin/latest',
+ write: false,
+ read: false,
+ help: false,
+ unsafe: false,
}
for (const arg of argv) {
- if (arg === '--release-notes') {
- result.releaseNotes = true
- continue
+ if (arg.startsWith('--')) {
+ // dash to camel case. no value means boolean true
+ const [key, value = true] = arg.slice(2).split('=')
+ result[key.replace(/-([a-z])/g, (a) => a[1].toUpperCase())] = value
+ } else if (arg.startsWith('-')) {
+ // shorthands for read and write
+ const short = arg.slice(1)
+ const key = short === 'w' ? 'write' : short === 'r' ? 'read' : null
+ result[key] = true
+ } else {
+ // anything else without a -- or - is the tag
+ // force tag to start with a "v"
+ result.tag = arg.startsWith(TAG_PREFIX) ? arg : TAG_PREFIX + arg
}
-
- result.branch = arg
}
- return result
+ // previous tag to requested tag OR most recent tag and everything after
+ // only matches tags prefixed with "v" since only the cli is prefixed with v
+ const getTag = (t = '') => exec(['git', 'describe', '--tags', '--abbrev=0',
+ `--match="${TAG_PREFIX}*" ${t}`].join(' '))
+
+ return assertArgs({
+ ...result,
+ // if a tag is passed in get the previous tag to make a range between the two
+ // this is mostly for testing to generate release notes from old releases
+ startTag: result.tag ? getTag(`${result.tag}~1`) : getTag(),
+ endTag: result.tag || '',
+ })
+}
+
+// find an entire section of a release from the changelog from a version
+const findRelease = (args, version) => {
+ const changelog = fs.readFileSync(args.file, 'utf-8')
+ const escRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+
+ const titleSrc = (v) => [
+ '^',
+ RELEASE.heading,
+ v ? escRegExp(v) : RELEASE.versionRe,
+ ' ',
+ escRegExp(RELEASE.date()).replace(/\d/g, '\\d'),
+ '$',
+ ].join('')
+
+ const releaseSrc = [
+ '(',
+ titleSrc(RELEASE.version(version)),
+ '[\\s\\S]*?',
+ RELEASE.sep,
+ ')',
+ titleSrc(),
+ ].join('')
+
+ const [, release = ''] = changelog.match(new RegExp(releaseSrc, 'm')) || []
+ return {
+ release: release.trim(),
+ changelog,
+ }
}
-const main = async () => {
- const { branch, releaseNotes } = parseArgs(process.argv.slice(2))
+const generateRelease = async (args) => {
+ const range = `${args.startTag}...${args.endTag}`
- const log = execSync(`git log --reverse --pretty='format:%h' ${branch}...`)
- .toString()
- .split(/\n/)
+ const log = exec(`git log --reverse --pretty='format:%h' ${range}`)
+ .split('\n')
+ .filter(Boolean)
+ // prefix with underscore so its always a valid identifier
+ .map((sha) => `_${sha}: object (expression: "${sha}") { ...commitCredit }`)
+
+ if (!log.length) {
+ throw new Error(`No commits found for "${range}"`)
+ }
const query = `
fragment commitCredit on GitObject {
... on Commit {
message
url
+ abbreviatedOid
authors (first:10) {
nodes {
user {
@@ -67,65 +233,61 @@ const main = async () => {
query {
repository (owner:"npm", name:"cli") {
- ${log.map((sha) => `_${sha}: object (expression: "${sha}") {
- ...commitCredit
- }`).join('\n')}
+ ${log}
}
}
`
- const response = execSync(`gh api graphql -f query='${query}'`).toString()
- const body = JSON.parse(response)
+ const res = JSON.parse(exec(`gh api graphql -f query='${query}'`))
- const output = {
- Features: [],
- 'Bug Fixes': [],
- Documentation: [],
- Dependencies: [],
- }
+ // collect commits by valid changelog type
+ const commits = [...CHANGELOG.values()].reduce((acc, c) => {
+ acc[c] = []
+ return acc
+ }, {})
+
+ const allCommits = Object.values(res.data.repository)
- for (const [hash, data] of Object.entries(body.data.repository)) {
- if (!data) {
- console.error('no data for hash', hash)
+ 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 = CHANGELOG.get(type)
+ if (!changelogType) {
continue
}
- const message = data.message.replace(/^\s+/gm, '') // remove leading spaces
+ 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
- const lines = message.split('\n')
// the title is the first line of the commit, 'let' because we change it later
- let title = lines.shift()
- // the body is the rest of the commit with some normalization
- const body = lines.join('\n') // re-join our normalized commit into a string
- .split(/\n?\*/gm) // split on lines starting with a literal *
- .filter((line) => line.trim().length > 0) // remove blank lines
- .map((line) => {
- const clean = line.replace(/\n/gm, ' ') // replace new lines for this bullet with spaces
- return clean.startsWith('*') ? clean : `* ${clean}` // make sure the line starts with *
- })
- .join('\n') // re-join with new lines
-
- const type = title.startsWith('feat') ? 'Features'
- : title.startsWith('fix') ? 'Bug Fixes'
- : title.startsWith('docs') ? 'Documentation'
- : title.startsWith('deps') ? 'Dependencies'
- : null
+ let [title, ...body] = message.split('\n')
- const prs = data.associatedPullRequests.nodes.filter((pull) => pull.merged)
+ const prs = commit.associatedPullRequests.nodes.filter((pull) => pull.merged)
for (const pr of prs) {
title = title.replace(new RegExp(`\\s*\\(#${pr.number}\\)`, 'g'), '')
}
- const commit = {
- hash: hash.slice(1), // remove leading _
- url: data.url,
+ 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,
+ type: changelogType,
body,
prs,
- credit: data.authors.nodes.map((author) => {
+ credit: commit.authors.nodes.map((author) => {
if (author.user && author.user.login) {
return {
name: `@${author.user.login}`,
@@ -140,20 +302,26 @@ const main = async () => {
url: `mailto:${author.email}`,
}
}),
- }
+ })
+ }
- if (commit.type) {
- output[commit.type].push(commit)
- }
+ if (!Object.values(commits).flat().length) {
+ const messages = allCommits.map((c) => c.message.trim().split('\n')[0])
+ throw new Error(`No changelog commits found for "${range}":\n${messages.join('\n')}`)
}
- for (const key of Object.keys(output)) {
- if (output[key].length > 0) {
- const groupHeading = `### ${key}`
- console.group(groupHeading)
- console.log() // blank line after heading
+ // this doesnt work with majors but we dont do those very often
+ const semverBump = commits.Features.length ? 'minor' : 'patch'
+ const version = TAG_PREFIX + semver.parse(args.startTag).inc(semverBump).version
+ const date = args.endTag && exec(`git log -1 --date=short --format=%ad ${args.endTag}`)
- for (const commit of output[key]) {
+ const output = logger(RELEASE.title(version, date) + '\n')
+
+ for (const key of Object.keys(commits)) {
+ if (commits[key].length > 0) {
+ output.group(`### ${key}\n`)
+
+ for (const commit of commits[key]) {
let groupCommit = `* [\`${commit.hash}\`](${commit.url})`
for (const pr of commit.prs) {
groupCommit += ` [#${pr.number}](${pr.url})`
@@ -161,24 +329,65 @@ const main = async () => {
groupCommit += ` ${commit.title}`
if (key !== 'Dependencies') {
for (const user of commit.credit) {
- if (releaseNotes) {
+ if (args.releaseNotes) {
groupCommit += ` (${user.name})`
} else {
groupCommit += ` ([${user.name}](${user.url}))`
}
}
}
- console.group(groupCommit)
+
+ output.group(groupCommit)
if (commit.body && commit.body.length) {
- console.log(commit.body)
+ output.log(commit.body)
}
- console.groupEnd(groupCommit)
+ output.groupEnd()
}
- console.log() // blank line at end of group
- console.groupEnd(groupHeading)
+ output.log()
+ output.groupEnd()
}
}
+
+ return {
+ version,
+ release: output.toString(),
+ }
+}
+
+const main = async (argv) => {
+ const args = parseArgs(argv)
+
+ if (args.read) {
+ // this reads the release notes for that version
+ return console.log(findRelease(args, args.endTag || args.startTag).release)
+ }
+
+ // otherwise fetch the requested release from github
+ const { release, version } = await generateRelease(args)
+
+ let msg = 'Edit release notes and run:\n'
+ msg += `git add CHANGELOG.md && git commit -m 'chore: changelog for ${version}'`
+
+ if (args.write) {
+ const { release: existing, changelog } = findRelease(args, version)
+ fs.writeFileSync(
+ args.file,
+ existing
+ // replace existing release with the newly generated one
+ ? changelog.replace(existing, release)
+ // otherwise prepend a new release at the start of the changelog
+ : changelog.replace(RELEASE.h1, RELEASE.h1 + release + RELEASE.sep),
+ 'utf-8'
+ )
+ return console.error([
+ `Release notes for ${version} written to "./${relative(process.cwd(), args.file)}".`,
+ msg,
+ ].join('\n'))
+ }
+
+ console.log(release)
+ console.error('\n' + msg)
}
-main()
+main(process.argv.slice(2))