diff --git a/scripts/changelog.js b/scripts/changelog.js index 0c50b562defd1..3db16d6cab08c 100644 --- a/scripts/changelog.js +++ b/scripts/changelog.js @@ -8,6 +8,11 @@ const config = require('@npmcli/template-oss') const { resolve, relative } = require('path') const exec = (...args) => execSync(...args).toString().trim() +const today = () => { + const d = new Date() + const pad = s => s.toString().padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +} const usage = () => ` node ${relative(process.cwd(), __filename)} [--read|-r] [--write|-w] [tag] @@ -74,7 +79,7 @@ const RELEASE = { return s.startsWith(TAG_PREFIX) ? s : TAG_PREFIX + s }, date (d) { - return `(${d || exec('date +%Y-%m-%d')})` + return `(${d})` }, title (v, d) { return `${this.heading}${this.version(v)} ${this.date(d)}` @@ -313,7 +318,7 @@ const generateRelease = async (args) => { // 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}`) + const date = args.endTag ? exec(`git log -1 --date=short --format=%ad ${args.endTag}`) : today() const output = logger(RELEASE.title(version, date) + '\n') @@ -350,6 +355,7 @@ const generateRelease = async (args) => { } return { + date, version, release: output.toString(), } @@ -370,10 +376,13 @@ const main = async (argv) => { } // otherwise fetch the requested release from github - const { release, version } = await generateRelease(args) + const { release, version, date } = await generateRelease(args) - let msg = 'Edit release notes and run:\n' - msg += `git add CHANGELOG.md && git commit -m 'chore: changelog for ${version}'` + try { + exec(`node scripts/release-manager.js --update --version=${version.slice(1)} --date=${date}`) + } catch { + // optionally update release manager issue + } if (args.write) { const { release: existing, changelog } = findRelease(args, version) @@ -386,14 +395,12 @@ const main = async (argv) => { : 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')) + return console.log( + `Release notes for ${version} written to "./${relative(process.cwd(), args.file)}".` + ) } console.log(release) - console.error('\n' + msg) } main(process.argv.slice(2)) diff --git a/scripts/release-manager.js b/scripts/release-manager.js new file mode 100644 index 0000000000000..8622ac9982c17 --- /dev/null +++ b/scripts/release-manager.js @@ -0,0 +1,248 @@ +#!/usr/bin/env node + +const { basename, relative } = require('path') +const cp = require('child_process') + +const usage = () => ` + node ${relative(process.cwd(), __filename)} [flags] + + Copies the release process checklist to a GitHub issue, optionally updating the + version and date of the instructions. + + Flags: [--create] [--update[=]] [--date=] [--version=X.Y.Z] + + [--create] (default: true) + By default this will create a new issue in the repo. + + [--update[=]] + Update a specific issue number, or if set without a value it will update the most + recent issue created with the default tag. + + [--tag=] (default: "release-manager") + Issues will be created and looked up with this tag. + + [--version=X.Y.Z] + This script can be run before the next version number is known and then rerun + with this flag to update the checklist with the correct version number. + + [--date=] (default: ${date()}) + Set the date of the release in the release process checklist. +` + +const spawnSync = (cmd, args, options) => { + const res = cp.spawnSync(cmd, args, { ...options, encoding: 'utf-8' }) + if (res.status !== 0) { + throw new Error(res.stderr) + } + return res.stdout.trim() +} + +const get = url => + new Promise((resolve, reject) => { + require('https') + .get(url, resp => { + let d = '' + resp.on('data', c => (d += c)) + resp.on('end', () => resolve(d)) + }) + .on('error', reject) + }) + +const date = () => { + const d = new Date() + const pad = s => s.toString().padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +} + +const replaceAll = (str, rep) => + Object.entries(rep).reduce( + (a, [k, v]) => a.replace(new RegExp(k, 'g'), v), + str + ) + +const ghIssue = args => { + const label = ['-l', args.label] + const assignee = ['-a', args.assignee] + const title = ['-t', args.title] + const json = ['--json', 'body,title,number'] + const body = ['--body-file', '-'] + + const issue = (cmd, a, options) => + spawnSync('gh', ['issue', cmd, '-R', args.repo, ...a.flat()], options) + + const listIssues = () => { + const issues = JSON.parse(issue('list', [label, json])) + const ids = issues.map(i => '#' + i.number) + const msg = `Found existing label:${args.label} issues: ${ids.join(', ')}.` + return { issues, msg } + } + + switch (args.command) { + case 'list': { + // get the first matching issue + const { issues, msg } = listIssues() + if (issues.length > 1) { + throw new Error(`${msg} Rerun with --update= to target a specific issue.`) + } + return issues[0] + } + case 'view': + // get an issue by id + return JSON.parse(issue('view', [args.number, json])) + case 'create': { + const { issues, msg } = listIssues() + if (issues.length) { + throw new Error(`${msg} Close before creating a new one.`) + } + // create an issue + return issue('create', [assignee, label, title, body], { input: args.body }) + } + case 'edit': + // edit title and body of an issue + return issue('edit', [args.number, title, body], { input: args.body }) + default: + throw new Error(`Unknown command: ${JSON.stringify(args.command)}`) + } +} + +const getSection = (content, args) => { + const [, heading, section] = args.section.match(/^(#+)\s(.*)/) + + // remove the title since we are making a new one + const [title, ...lines] = content + .split(`${heading} `) + .find(s => s.split('\n')[0].match(new RegExp(section, 'i'))) + .trim() + .split('\n') + + // first task is to run this script, so thats done + const body = lines.join('\n').replace('- [ ] **0', '- [x] **0') + const created = `${basename(args.release)}${heading}${title}` + + return { + title: `Release Manager: v${args.version} (${args.date})`, + body: [ + `**Target Version**: v${args.version}`, + `**Target Date**: ${args.date}`, + // github markdown: 2x backticks + space will escape backticks within title + `**Created From:** [\`\` ${created} \`\`](${args.release})`, + body, + ] + .join('\n') + .trim(), + } +} + +const main = async args => { + const replace = s => replaceAll(s, args.replacements) + + const { body, title, number } = args.create + // get a section of the release process wiki doc + ? getSection(await get(args.release), args) + // get the contents of an existing gh issue by id + // or it will default to the most recent one by label + // this is so it will preserve state of checked todo items + : await ghIssue({ + ...args, + command: typeof args.update === 'string' ? 'view' : 'list', + number: args.update, + }) + + return ghIssue({ + ...args, + command: number ? 'edit' : 'create', + number, + body: replace(body), + title: replace(title), + }) +} + +const parseArgs = raw => { + const result = { + create: false, + update: null, + repo: 'npm/cli', + label: 'release: manager', + assignee: '@me', + date: date(), + version: 'X.Y.Z', + // look for that heading level with a match for the portion after + section: '### .*cli.*', + release: + 'https://raw.githubusercontent.com/wiki/npm/cli/Release-Process.md', + } + + const replacements = {} + + const clean = { + // this script will not work correctly with the tag style + // of the version (prefixed with a v) so strip it out + version: v => v.replace(/^v/g, ''), + } + + const shorts = { + R: 'repo', + l: 'label', + a: 'assignee', + d: 'date', + v: 'version', + c: 'create', + u: 'update', + } + + const camel = k => k.replace(/-([a-z])/g, a => a[1].toUpperCase()) + + // parse argv into array of [k,v] pairs + // works with --x=1 --x 1 --x -x + const argv = raw + .join(' ') // join to a string + .split(/(?:^|\s+)-/g) // split on starting dashses + .map(x => x.trim().replace(/\s+/g, ' ')) // collapse spaces + .filter(Boolean) // remove empties + .map(x => x.split(/[=\s]/)) // split on equal or space + .map(([k, v]) => [ + // we split on the initial dash previously so now + // 1 dash means 2 and 0 means 1 + ...(k.startsWith('-') ? ['--', k.slice(1)] : ['-', k]), + v ?? true, // default to true for no value + ]) + .map(([dash, key, value]) => ({ dash, key: camel(key), value })) + + for (const { dash, key, value } of argv) { + const k = dash.length < 2 ? shorts[key] : key + if (Object.hasOwn(result, k)) { + result[k] = clean[k] ? clean[k](value) : value + } else { + // any unknown arg is a replacement value + replacements[k] = value + } + } + + if (!result.create && !result.update) { + // set default to create if no command is specified + result.create = true + } else if (result.create && result.update) { + throw new Error('Cannot set both create and update') + } + + if (result.help) { + console.error(usage()) + return process.exit(0) + } + + return { + ...result, + replacements: { + '(\\d+\\.\\d+\\.\\d+|X\\.Y\\.Z)': result.version, + '(\\d{4}-\\d{2}-\\d{2}|YYYY-MM-DD)': result.date, + ...replacements, + }, + } +} + +main(parseArgs(process.argv.slice(2))) + .then(d => console.log(d)) + .catch(err => { + console.error(err) + process.exitCode = 1 + })