diff --git a/README.md b/README.md index 133e0df2..fc518914 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,9 @@ CLI tools for Node.js Core collaborators. - [Install](#install) - [Setting up credentials](#setting-up-credentials) - [`ncu-config`](#ncu-config) +- [`git-node`](#git-node) + - [Prerequistes](#prerequistes) + - [Demo & Usage](#demo--usage) - [`get-metadata`](#get-metadata) - [Git bash for Windows](#git-bash-for-windows) - [Features](#features) @@ -85,6 +88,48 @@ Options: --global [boolean] [default: false] ``` +## `git-node` + +A custom Git command for landing pull requests. You can run it as +`git-node` or `git node`. To see the help text, run `git node help`. + +### Prerequistes + +1. It's a Git command, so make sure you have Git installed, of course. +2. Install [core-validate-commit](https://github.com/nodejs/core-validate-commit) + + ``` + $ npm install -g core-validate-commit + ``` +3. Configure your upstream remote and branch name. By default it assumes your + remote pointing to https://github.com/nodejs/node is called `upstream`, and + the branch that you are trying to land PRs on is `master`. If that's not the + case: + + ``` + $ cd path/to/node/project + $ ncu-config upstream your-remote-name + $ ncu-config branch your-branch-name + ``` + +### Demo & Usage + +1. Landing multiple commits: https://asciinema.org/a/148627 +2. Landing one commit: https://asciinema.org/a/157445 + +``` +$ cd path/to/node/project +$ git node land --abort # Abort a landing session, just in case +$ git node land $PRID # Start a new landing session + +$ git rebase -i upstream/master # Put `edit` on every commit that's gonna stay + +$ git node land --amend # Regenerate commit messages in HEAD +$ git rebase --continue # Repeat until the rebase is done + +$ git node land --final # Verify all the commit messages +``` + ## `get-metadata` This tool is inspired by Evan Lucas's [node-review](https://github.com/evanlucas/node-review), diff --git a/bin/git-node b/bin/git-node new file mode 100755 index 00000000..d7e98e80 --- /dev/null +++ b/bin/git-node @@ -0,0 +1,21 @@ +#!/usr/bin/env node +'use strict'; + +const CMD = process.argv[2]; +const path = require('path'); +const { runAsync } = require('../lib/run'); +const fs = require('fs'); + +if (!CMD) { + console.log('Run `git node help` to see how to use this'); + process.exit(1); +} + +const script = path.join( + __dirname, '..', 'components', 'git', `git-node-${CMD}`); +if (!fs.existsSync(script)) { + console.error(`No such command: git node ${CMD}`); + process.exit(1); +} + +runAsync(script, process.argv.slice(3)); diff --git a/components/git/git-node-help b/components/git/git-node-help new file mode 100755 index 00000000..2bae462c --- /dev/null +++ b/components/git/git-node-help @@ -0,0 +1,17 @@ +#!/usr/bin/env node + +console.log(` +Steps to land a pull request: +============================================================================== +$ cd path/to/node/project +$ git node land --abort # Abort a landing session, just in case +$ git node land $PRID # Start a new landing session + +$ git rebase -i upstream/master # Put "edit" on every commit that's gonna stay + +$ git node land --amend # Regenerate commit messages in HEAD +$ git rebase --continue # Repeat until the rebase is done + +$ git node land --final # Verify all the commit messages +============================================================================== +Watch https://asciinema.org/a/148627 for a complete demo`); diff --git a/components/git/git-node-land b/components/git/git-node-land new file mode 100755 index 00000000..e4b2c7f6 --- /dev/null +++ b/components/git/git-node-land @@ -0,0 +1,85 @@ +#!/usr/bin/env node + +const getMetadata = require('../metadata'); +const CLI = require('../../lib/cli'); +const Request = require('../../lib/request'); +const { + runPromise +} = require('../../lib/run'); +const LandingSession = require('../../lib/landing_session'); + +const START = 'START'; +const APPLY = 'APPLY'; +const AMEND = 'AMEND'; +const FINAL = 'FINAL'; +const CONTINUE = 'CONTINUE'; +const ABORT = 'ABORT'; + +const states = [ + [START, (args) => !isNaN(parseInt(args[0]))], + [CONTINUE, (args) => args[0] === '--continue'], + [APPLY, (args) => args[0] === '--apply'], + [AMEND, (args) => args[0] === '--amend'], + [FINAL, (args) => args[0] === '--final'], + [ABORT, (args) => args[0] === '--abort'] +]; + +const cli = new CLI(process.stderr); +const req = new Request(); +const dir = process.cwd(); +const args = process.argv.slice(2); + +const result = states.filter(([state, pred]) => pred(args)); +if (result.length) { + const state = result[0][0]; + runPromise(main(state, args).catch((err) => { + if (cli.spinner.enabled) { + cli.spinner.fail(); + } + throw err; + })); +} else { + cli.error('Usage: `git node land `'); + process.exit(1); +} + +async function main(state, args) { + let session = new LandingSession(cli, req, dir); + + try { + session.restore(); + } catch (err) { // JSON error? + if (state === ABORT) { + await session.abort(); + return; + } + cli.warn( + 'Failed to detect previous session. ' + + 'please run `git node land --abort`'); + return; + } + + if (state === START) { + if (session.hasStarted()) { + cli.warn( + 'Previous `git node land` session for ' + + `${session.pullName} in progress.`); + cli.log('run `git node land --abort` before starting a new session'); + return; + } + session = new LandingSession(cli, req, dir, parseInt(args[0])); + const { repo, owner, prid } = session; + const metadata = await getMetadata({ repo, owner, prid }, cli); + return session.start(metadata); + } else if (state === APPLY) { + return session.apply(); + } else if (state === AMEND) { + return session.amend(); + } else if (state === FINAL) { + return session.final(); + } else if (state === ABORT) { + return session.abort(); + } else if (state === CONTINUE) { + return session.continue(); + } +} diff --git a/lib/cli.js b/lib/cli.js index f8f55d8f..855f7c77 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -3,6 +3,7 @@ const ora = require('ora'); const { EOL } = require('os'); const chalk = require('chalk'); +const read = require('read'); const { warning, error, info, success } = require('./figures'); @@ -25,6 +26,28 @@ class CLI { this.SPINNER_STATUS = SPINNER_STATUS; } + prompt(question, defaultAnswer = true) { + const option = + `[${(defaultAnswer ? 'Y' : 'y')}/${(defaultAnswer ? 'n' : 'N')}]`; + return new Promise((resolve, reject) => { + read({prompt: `${question} ${option} `}, (err, answer) => { + if (err) { + reject(err); + } + if (answer === undefined || answer === null) { + reject(new Error('__ignore__')); + } + const trimmed = answer.toLowerCase().trim(); + if (!trimmed) { + resolve(defaultAnswer); + } else if (trimmed === 'y') { + resolve(true); + } + resolve(false); + }); + }); + } + startSpinner(text) { this.spinner.text = text; this.spinner.start(); diff --git a/lib/landing_session.js b/lib/landing_session.js new file mode 100644 index 00000000..b7517585 --- /dev/null +++ b/lib/landing_session.js @@ -0,0 +1,250 @@ +'use strict'; + +const { + runAsync, runSync, forceRunAsync +} = require('./run'); +const Session = require('./session'); + +class LandingSession extends Session { + constructor(cli, req, dir, prid, config) { + super(dir, prid, config); + this.cli = cli; + this.req = req; + } + + async start(metadata) { + const { cli } = this; + this.startLanding(); + const status = metadata.status ? 'should be ready' : 'is not ready'; + const shouldContinue = await cli.prompt( + `This PR ${status} to land, do you want to continue?`); + if (!shouldContinue) { + return this.abort(); + } + + this.saveMetadata(metadata); + this.startApplying(); + return this.apply(); + } + + async abort() { + const { cli } = this; + this.cleanFiles(); + await this.tryResetBranch(); + cli.log(`Aborted \`git node land\` session in ${this.ncuDir}`); + } + + async apply() { + const { cli, req, repo, owner, prid } = this; + + if (!this.readyToApply()) { + cli.warn('This session can not proceed to apply patches, ' + + 'run `git node land --abort`'); + return; + } + await this.tryResetBranch(); + + // TODO: restore previously downloaded patches + cli.startSpinner(`Downloading patch for ${prid}`); + const patch = await req.promise({ + url: `https://github.com/${owner}/${repo}/pull/${prid}.patch` + }); + this.savePatch(patch); + cli.stopSpinner(`Downloaded patch to ${this.patchPath}`); + + // TODO: check that patches downloaded match metadata.commits + await runAsync('git', ['am', '--whitespace=fix', this.patchPath]); + cli.ok('Patches applied'); + + this.startAmending(); + if (/Subject: \[PATCH\]/.test(patch)) { + const shouldAmend = await cli.prompt( + 'There is only one commit in this PR.\n' + + 'do you want to amend the commit message?'); + if (!shouldAmend) { + return; + } + const canFinal = await this.amend(); + if (!canFinal) { + return; + } + return this.final(); + } + + const re = /Subject: \[PATCH 1\/(\d+)\]/; + const match = patch.match(re); + if (!match) { + cli.warn('Cannot get number of commits in the patch. ' + + 'It seems to be malformed'); + return; + } + const { upstream, branch } = this; + cli.log( + `There are ${match[1]} commits in the PR.\n` + + `Please run \`git rebase ${upstream}/${branch} -i\` ` + + 'and use `git node land --amend` to amend the commit messages'); + // TODO: do git rebase automatically? + } + + async amend() { + const { cli } = this; + if (!this.readyToAmend()) { + cli.warn('Not yet ready to amend, run `git node land --abort`'); + return; + } + + const rev = runSync('git', ['rev-parse', 'HEAD']); + const original = runSync('git', [ + 'show', 'HEAD', '-s', '--format=%B' + ]).trim(); + const metadata = this.metadata.trim().split('\n'); + const amended = original.split('\n'); + if (amended[amended.length - 1] !== '') { + amended.push(''); + } + + for (const line of metadata) { + if (original.includes(line)) { + if (line) { + cli.warn(`Found ${line}, skipping..`); + } + } else { + amended.push(line); + } + } + + const message = amended.join('\n'); + const messageFile = this.saveMessage(rev, message); + cli.separator('New Message'); + cli.log(message.trim()); + cli.separator(); + const takeMessage = await cli.prompt('Use this message?'); + if (takeMessage) { + await runAsync('git', ['commit', '--amend', '-F', messageFile]); + return true; + } + + // TODO: fire the configured git editor on that file + cli.log(`Please manually edit ${messageFile}, then run\n` + + `\`git commit --amend -F ${messageFile}\` ` + + 'to finish amending the message'); + return false; + } + + async final() { + const { cli } = this; + if (!this.readyToFinal()) { // check git rebase/am has been done + cli.warn('Not yet ready to final'); + return; + } + const upstream = this.upstream; + const branch = this.branch; + const notYetPushed = this.getNotYetPushedCommits(); + const notYetPushedVerbose = this.getNotYetPushedCommits(true); + await runAsync('core-validate-commit', notYetPushed); + cli.separator(); + cli.log('The following commits are ready to be pushed to ' + + `${upstream}/${branch}`); + cli.log(`- ${notYetPushedVerbose.join('\n- ')}`); + cli.separator(); + cli.log(`run \`git push ${upstream} ${branch}\` to finish landing`); + const shouldClean = await cli.prompt('Clean up generated temporary files?'); + if (shouldClean) { + this.cleanFiles(); + } + } + + async continue() { + const { cli } = this; + if (this.readyToFinal()) { + cli.log(`Running \`final\`..`); + return this.final(); + } + if (this.readyToAmend()) { + cli.log(`Running \`amend\`..`); + return this.amend(); + } + if (this.readyToApply()) { + cli.log(`Running \`apply\`..`); + return this.apply(); + } + if (this.hasStarted()) { + cli.log(`Running \`apply\`..`); + return this.apply(); + } + cli.log( + 'Please run `git node land to start a landing session`'); + } + + async status() { + // TODO + } + + getNotYetPushedCommits(verbose) { + const { upstream, branch } = this; + const ref = `${upstream}/${branch}...HEAD`; + const gitCmd = verbose ? ['log', '--oneline', ref] : ['rev-list', ref]; + const revs = runSync('git', gitCmd).trim(); + return revs ? revs.split('\n') : []; + } + + async tryAbortAm() { + const { cli } = this; + if (!this.amInProgress()) { + return cli.ok('No git am in progress'); + } + const shouldAbortAm = await cli.prompt( + 'Abort previous git am sessions?'); + if (shouldAbortAm) { + await forceRunAsync('git', ['am', '--abort']); + cli.ok('Aborted previous git am sessions'); + } + } + + async tryAbortRebase() { + const { cli } = this; + if (!this.rebaseInProgress()) { + return cli.ok('No git rebase in progress'); + } + const shouldAbortRebase = await cli.prompt( + 'Abort previous git rebase sessions?'); + if (shouldAbortRebase) { + await forceRunAsync('git', ['rebase', '--abort']); + cli.ok('Aborted previous git rebase sessions'); + } + } + + async tryResetHead() { + const { cli, upstream, branch } = this; + const branchName = `${upstream}/${branch}`; + cli.startSpinner(`Bringing ${branchName} up to date...`); + await runAsync('git', ['fetch', upstream, branch]); + cli.stopSpinner(`${branchName} is now up-to-date`); + const notYetPushed = this.getNotYetPushedCommits(true); + if (!notYetPushed.length) { + return; + } + cli.log(`Found stray commits in ${branchName}:\n` + + ` - ${notYetPushed.join('\n - ')}`); + const shouldReset = await cli.prompt(`Reset to ${branchName}?`); + if (shouldReset) { + await runAsync('git', ['reset', '--hard', branchName]); + cli.ok(`Reset to ${branchName}`); + } + } + + async tryResetBranch() { + const { cli, upstream, branch } = this; + await this.tryAbortAm(); + await this.tryAbortRebase(); + + const branchName = `${upstream}/${branch}`; + const shouldResetHead = await cli.prompt( + `Do you want to try reset the branch to ${branchName}?`); + if (shouldResetHead) { + await this.tryResetHead(); + } + } +} + +module.exports = LandingSession; diff --git a/lib/queries/SearchIssue.gql b/lib/queries/SearchIssue.gql new file mode 100644 index 00000000..78c26335 --- /dev/null +++ b/lib/queries/SearchIssue.gql @@ -0,0 +1,51 @@ +query SearchIssueByUser($queryString: String!, $isCommenter: Boolean!, $after: String) { + search(query: $queryString, type: ISSUE, first: 100, after: $after) { + nodes { + ... on PullRequest { + url + publishedAt + author { + login + } + title + reviews(last: 100) @include(if: $isCommenter) { + nodes { + publishedAt + author { + login + } + } + } + labels(first: 100) { + nodes { + name + } + } + comments(last: 100) @include(if: $isCommenter) { + nodes { + publishedAt + author { + login + } + } + } + } + ... on Issue { + publishedAt + url + author { + login + } + title + comments(last: 100) @include(if: $isCommenter) { + nodes { + publishedAt + author { + login + } + } + } + } + } + } +} diff --git a/lib/request.js b/lib/request.js index dfc21807..3371f39f 100644 --- a/lib/request.js +++ b/lib/request.js @@ -14,8 +14,10 @@ class Request { return fs.readFileSync(filePath, 'utf8'); } - async promise() { - return rp(...arguments); + async promise(options) { + return rp(Object.assign({ + gzip: true + }, options)); } async gql(name, variables, path) { diff --git a/lib/run.js b/lib/run.js new file mode 100644 index 00000000..a94ec0e6 --- /dev/null +++ b/lib/run.js @@ -0,0 +1,55 @@ +'use strict'; + +const { spawn, spawnSync } = require('child_process'); + +const IGNORE = '__ignore__'; + +function runAsyncBase(cmd, args, options) { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, Object.assign({ + cwd: process.cwd(), + stdio: 'inherit' + }, options)); + child.on('close', (code) => { + if (code !== 0) { + return reject(new Error(IGNORE)); + } + return resolve(); + }); + }); +} + +exports.forceRunAsync = function(cmd, args, options) { + return runAsyncBase(cmd, args, options).catch((error) => { + if (error.message !== IGNORE) { + console.error(error); + throw error; + } + }); +}; + +exports.runPromise = function runAsync(promise) { + return promise.catch((error) => { + if (error.message !== IGNORE) { + console.error(error); + } + process.exit(1); + }); +}; + +exports.runAsync = function(cmd, args, options) { + return exports.runPromise(runAsyncBase(cmd, args, options)); +}; + +exports.runSync = function(cmd, args, options) { + const child = spawnSync(cmd, args, Object.assign({ + cwd: process.cwd() + }, options)); + if (child.error) { + throw child.error; + } else if (child.stderr.length) { + throw new Error(child.stderr.toString()); + } else { + return child.stdout.toString(); + } +}; diff --git a/lib/session.js b/lib/session.js new file mode 100644 index 00000000..97e1a1c7 --- /dev/null +++ b/lib/session.js @@ -0,0 +1,192 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const { getMergedConfig, getNcuDir } = require('./config'); +const rimraf = require('rimraf'); +const mkdirp = require('mkdirp'); + +const { readJson, writeJson, readFile, writeFile } = require('./file'); +const APPLYING = 'applying'; +const STARTED = 'started'; +const AMENDING = 'AMENDING'; + +class Session { + constructor(dir, prid, config) { + this.dir = dir; + this.prid = prid; + this.config = config || getMergedConfig(this.dir); + } + + get session() { + return readJson(this.sessionPath); + } + + get gitDir() { + return path.join(this.dir, '.git'); + } + + get ncuDir() { + return getNcuDir(this.dir); + } + + get sessionPath() { + return path.join(this.ncuDir, 'land'); + } + + get owner() { + return this.config.owner || 'nodejs'; + } + + get repo() { + return this.config.repo || 'node'; + } + + get upstream() { + return this.config.upstream || 'upstream'; + } + + get branch() { + return this.config.branch || 'master'; + } + + get pullName() { + return `${this.owner}/${this.repo}/pulls/${this.prid}`; + } + + get pullDir() { + return path.join(this.ncuDir, `${this.prid}`); + } + + startLanding() { + mkdirp.sync(this.pullDir); + writeJson(this.sessionPath, { + state: STARTED, + prid: this.prid, + config: this.config + }); + } + + startApplying() { + this.updateSession({ + state: APPLYING + }); + } + + startAmending() { + this.updateSession({ + state: AMENDING + }); + } + + cleanFiles() { + var sess; + try { + sess = this.session; + } catch (err) { + return rimraf.sync(this.sessionPath); + } + + if (sess.prid && sess.prid === this.prid) { + rimraf.sync(this.pullDir); + } + rimraf.sync(this.sessionPath); + } + + get statusPath() { + return path.join(this.pullDir, 'status'); + } + + get status() { + return readJson(this.statusPath); + } + + get metadataPath() { + return path.join(this.pullDir, 'metadata'); + } + + get metadata() { + return readFile(this.metadataPath); + } + + get patchPath() { + return path.join(this.pullDir, 'patch'); + } + + get patch() { + return readFile(this.patchPath); + } + + getMessagePath(rev) { + return path.join(this.pullDir, `${rev.slice(0, 7)}-message`); + } + + updateSession(update) { + const old = this.session; + writeJson(this.sessionPath, Object.assign(old, update)); + } + + saveStatus(status) { + writeJson(this.statusPath, status); + } + + saveMetadata(status) { + writeFile(this.metadataPath, status.metadata); + } + + savePatch(patch) { + writeFile(this.patchPath, patch); + } + + saveMessage(rev, message) { + const file = this.getMessagePath(rev); + writeFile(file, message); + return file; + } + + hasStarted() { + return !!this.session.prid && this.session.prid === this.prid; + } + + readyToApply() { + return this.session.state === APPLYING; + } + + readyToAmend() { + return this.session.state === AMENDING; + } + + readyToFinal() { + if (this.amInProgress()) { + return false; // git am/rebase in progress + } + return this.session.state === AMENDING; + } + + // Refs: https://github.com/git/git/blob/99de064/git-rebase.sh#L208-L228 + amInProgress() { + const amPath = path.join(this.gitDir, 'rebase-apply', 'applying'); + return fs.existsSync(amPath); + } + + rebaseInProgress() { + if (this.amInProgress()) { + return false; + } + + const normalRebasePath = path.join(this.gitDir, 'rebase-apply'); + const mergeRebasePath = path.join(this.gitDir, 'rebase-merge'); + return fs.existsSync(normalRebasePath) || fs.existsSync(mergeRebasePath); + } + + restore() { + const sess = this.session; + if (sess.prid) { + this.prid = sess.prid; + this.config = sess.config; + } + return this; + } +} + +module.exports = Session; diff --git a/package.json b/package.json index cc952d16..bb3c2c0a 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "main": "./bin/metadata.js", "bin": { "get-metadata": "./bin/get-metadata", - "ncu-config": "./bin/ncu-config" + "ncu-config": "./bin/ncu-config", + "git-node": "./bin/git-node" }, "scripts": { "test": "npm run test-unit && npm run lint", @@ -34,6 +35,7 @@ "ghauth": "^3.2.1", "jsdom": "^11.3.0", "ora": "^1.3.0", + "read": "^1.0.7", "request": "^2.83.0", "request-promise-native": "^1.0.5", "yargs": "^10.0.3"