From b1dcbcec2c6f3a87c101889a52b61d7a1550b681 Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Wed, 31 Mar 2021 14:15:53 -0230 Subject: [PATCH] Rewrite changelog script from Bash to JavaScript (#10782) The `auto-changelog` script has been rewritten from Bash to JavaScript. Functionally it should behave identically. --- development/auto-changelog.js | 86 +++++++++++++++++++++++++++++++++++ development/auto-changelog.sh | 60 ------------------------ development/lib/runCommand.js | 79 ++++++++++++++++++++++++++++++++ package.json | 2 +- 4 files changed, 166 insertions(+), 61 deletions(-) create mode 100755 development/auto-changelog.js delete mode 100755 development/auto-changelog.sh create mode 100644 development/lib/runCommand.js diff --git a/development/auto-changelog.js b/development/auto-changelog.js new file mode 100755 index 000000000..22743f32e --- /dev/null +++ b/development/auto-changelog.js @@ -0,0 +1,86 @@ +#!/usr/bin/env node +const fs = require('fs').promises; +const path = require('path'); +const runCommand = require('./lib/runCommand'); + +const URL = 'https://github.com/MetaMask/metamask-extension'; + +async function main() { + await runCommand('git', ['fetch', '--tags']); + + const [mostRecentTagCommitHash] = await runCommand('git', [ + 'rev-list', + '--tags', + '--max-count=1', + ]); + const [mostRecentTag] = await runCommand('git', [ + 'describe', + '--tags', + mostRecentTagCommitHash, + ]); + + const commitsSinceLastRelease = await runCommand('git', [ + 'rev-list', + `${mostRecentTag}..HEAD`, + ]); + + const changelogEntries = []; + for (const commit of commitsSinceLastRelease) { + const [subject] = await runCommand('git', [ + 'show', + '-s', + '--format=%s', + commit, + ]); + + let prefix; + let description = subject; + + // Squash & Merge: the commit subject is parsed as ` (#)` + if (subject.match(/\(#\d+\)/u)) { + const [, prNumber] = subject.match(/\(#(\d+)\)/u); + prefix = `[#${prNumber}](${URL}/pull/${prNumber})`; + description = subject.match(/^(.+)\s\(#\d+\)/u)[1]; + // Merge: the PR ID is parsed from the git subject (which is of the form `Merge pull request + // # from `, and the description is assumed to be the first line of the body. + // If no body is found, the description is set to the commit subject + } else if (subject.match(/#\d+\sfrom/u)) { + const [, prNumber] = subject.match(/#(\d+)\sfrom/u); + prefix = `[#${prNumber}](${URL}/pull/${prNumber})`; + const [firstLineOfBody] = await runCommand('git', [ + 'show', + '-s', + '--format=%b', + commit, + ]); + description = firstLineOfBody || subject; + } + // Otherwise: + // Normal commits: The commit subject is the description, and the PR ID is omitted. + + const changelogEntry = prefix + ? `- ${prefix}: ${description}` + : `- ${description}`; + changelogEntries.push(changelogEntry); + } + + const changelogFilename = path.resolve(__dirname, '..', 'CHANGELOG.md'); + const changelog = await fs.readFile(changelogFilename, { encoding: 'utf8' }); + const changelogLines = changelog.split('\n'); + const releaseHeaderIndex = changelogLines.findIndex( + (line) => line === '## Current Develop Branch', + ); + if (releaseHeaderIndex === -1) { + throw new Error('Failed to find release header'); + } + changelogLines.splice(releaseHeaderIndex + 1, 0, ...changelogEntries); + const updatedChangelog = changelogLines.join('\n'); + await fs.writeFile(changelogFilename, updatedChangelog); + + console.log('CHANGELOG updated'); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/development/auto-changelog.sh b/development/auto-changelog.sh deleted file mode 100755 index 26ab8e93f..000000000 --- a/development/auto-changelog.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env bash - -set -e -set -u -set -o pipefail - -readonly URL='https://github.com/MetaMask/metamask-extension' - -git fetch --tags - -most_recent_tag="$(git describe --tags "$(git rev-list --tags --max-count=1)")" - -git rev-list "${most_recent_tag}"..HEAD | while read -r commit -do - subject="$(git show -s --format="%s" "$commit")" - - # Squash & Merge: the commit subject is parsed as ` (#)` - if grep -E -q '\(#[[:digit:]]+\)' <<< "$subject" - then - pr="$(awk '{print $NF}' <<< "$subject" | tr -d '()')" - prefix="[$pr]($URL/pull/${pr###}): " - description="$(awk '{NF--; print $0}' <<< "$subject")" - - # Merge: the PR ID is parsed from the git subject (which is of the form `Merge pull request - # # from `, and the description is assumed to be the first line of the body. - # If no body is found, the description is set to the commit subject - elif grep -E -q '#[[:digit:]]+\sfrom' <<< "$subject" - then - pr="$(awk '{print $4}' <<< "$subject")" - prefix="[$pr]($URL/pull/${pr###}): " - - first_line_of_body="$(git show -s --format="%b" "$commit" | head -n 1 | tr -d '\r')" - if [[ -z "$first_line_of_body" ]] - then - description="$subject" - else - description="$first_line_of_body" - fi - - # Normal commits: The commit subject is the description, and the PR ID is omitted. - else - pr='' - prefix='' - description="$subject" - fi - - # add entry to CHANGELOG - if [[ "$OSTYPE" == "linux-gnu" ]] - then - # shellcheck disable=SC1004 - sed -i'' '/## Current Develop Branch/a\ -- '"$prefix$description"''$'\n' CHANGELOG.md - else - # shellcheck disable=SC1004 - sed -i '' '/## Current Develop Branch/a\ -- '"$prefix$description"''$'\n' CHANGELOG.md - fi -done - -echo 'CHANGELOG updated' diff --git a/development/lib/runCommand.js b/development/lib/runCommand.js new file mode 100644 index 000000000..2d92ffe99 --- /dev/null +++ b/development/lib/runCommand.js @@ -0,0 +1,79 @@ +const spawn = require('cross-spawn'); + +/** + * Run a command to completion using the system shell. + * + * This will run a command with the specified arguments, and resolve when the + * process has exited. The STDOUT stream is monitored for output, which is + * returned after being split into lines. All output is expected to be UTF-8 + * encoded, and empty lines are removed from the output. + * + * Anything received on STDERR is assumed to indicate a problem, and is tracked + * as an error. + * + * @param {string} command - The command to run + * @param {Array} [args] - The arguments to pass to the command + * @returns {Array} Lines of output received via STDOUT + */ +async function runCommand(command, args) { + const output = []; + let mostRecentError; + let errorSignal; + let errorCode; + const internalError = new Error('Internal'); + try { + await new Promise((resolve, reject) => { + const childProcess = spawn(command, args, { encoding: 'utf8' }); + childProcess.stdout.setEncoding('utf8'); + childProcess.stderr.setEncoding('utf8'); + + childProcess.on('error', (error) => { + mostRecentError = error; + }); + + childProcess.stdout.on('data', (message) => { + const nonEmptyLines = message.split('\n').filter((line) => line !== ''); + output.push(...nonEmptyLines); + }); + + childProcess.stderr.on('data', (message) => { + mostRecentError = new Error(message.trim()); + }); + + childProcess.once('exit', (code, signal) => { + if (code === 0) { + return resolve(); + } + errorCode = code; + errorSignal = signal; + return reject(internalError); + }); + }); + } catch (error) { + /** + * The error is re-thrown here in an `async` context to preserve the stack trace. If this was + * was thrown inside the Promise constructor, the stack trace would show a few frames of + * Node.js internals then end, without indicating where `runCommand` was called. + */ + if (error === internalError) { + let errorMessage; + if (errorCode !== null && errorSignal !== null) { + errorMessage = `Terminated by signal '${errorSignal}'; exited with code '${errorCode}'`; + } else if (errorSignal !== null) { + errorMessage = `Terminaled by signal '${errorSignal}'`; + } else if (errorCode === null) { + errorMessage = 'Exited with no code or signal'; + } else { + errorMessage = `Exited with code '${errorCode}'`; + } + const improvedError = new Error(errorMessage); + if (mostRecentError) { + improvedError.cause = mostRecentError; + } + throw improvedError; + } + } + return output; +} + +module.exports = runCommand; diff --git a/package.json b/package.json index 8dc90fedf..1a494067d 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "storybook": "start-storybook -p 6006 -c .storybook --static-dir ./app ./storybook/images", "storybook:build": "build-storybook -c .storybook -o storybook-build --static-dir ./app ./storybook/images", "storybook:deploy": "storybook-to-ghpages --existing-output-dir storybook-build --remote storybook --branch master", - "update-changelog": "./development/auto-changelog.sh", + "update-changelog": "node ./development/auto-changelog.js", "generate:migration": "./development/generate-migration.sh", "lavamoat:auto": "lavamoat ./development/build/index.js --writeAutoPolicy", "lavamoat:debug": "lavamoat ./development/build/index.js --writeAutoPolicyDebug"