diff --git a/README.md b/README.md index d6d7092c..e2055d1c 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,10 @@ When creating the token, the following boxes need to be checked: PR author in order to check if it matches the email of the commit author. - `read:org`: Used by `ncu-team` to read the list of team members. +Optionally, if you want to grant write access so `git-node` can write comments: + +- `public_repo` (or `repo` if you intend to work with private repositories). + You can also edit the permission of existing tokens later. After the token is generated, create an rc file with the following content: diff --git a/components/git/vote.js b/components/git/vote.js new file mode 100644 index 00000000..128552be --- /dev/null +++ b/components/git/vote.js @@ -0,0 +1,84 @@ +import auth from '../../lib/auth.js'; +import { parsePRFromURL } from '../../lib/links.js'; +import CLI from '../../lib/cli.js'; +import Request from '../../lib/request.js'; +import { runPromise } from '../../lib/run.js'; +import VotingSession from '../../lib/voting_session.js'; + +export const command = 'vote [prid|options]'; +export const describe = + 'Cast a vote, or decrypt a key part to close a vote'; + +const voteOptions = { + abstain: { + type: 'boolean', + default: false, + describe: 'Abstain from the vote.' + }, + 'decrypt-key-part': { + describe: 'Publish a key part as a comment to the vote PR.', + default: false, + type: 'boolean' + }, + 'gpg-sign': { + describe: 'GPG-sign commits, will be passed to the git process', + alias: 'S' + }, + 'post-comment': { + describe: 'Post the comment on GitHub on the behalf of the user', + default: false, + type: 'boolean' + }, + protocol: { + describe: 'The protocol to use to clone the vote repository and push the eventual vote commit', + type: 'string' + } +}; + +let yargsInstance; + +export function builder(yargs) { + yargsInstance = yargs; + return yargs + .options(voteOptions) + .positional('prid', { + describe: 'URL of the vote Pull Request' + }) + .example('git node vote https://github.com/nodejs/TSC/pull/12344', + 'Start an interactive session to cast ballot for https://github.com/nodejs/TSC/pull/12344.') + .example('git node vote https://github.com/nodejs/TSC/pull/12344 --abstain', + 'Cast an empty ballot for https://github.com/nodejs/TSC/pull/12344.') + .example('git node vote https://github.com/nodejs/TSC/pull/12344 --decrypt-key-part', + 'Uses gpg to decrypt a key part to close the vote happening on https://github.com/nodejs/TSC/pull/12344.'); +} + +export function handler(argv) { + if (argv.prid) { + const parsed = parsePRFromURL(argv.prid); + if (parsed) { + Object.assign(argv, parsed); + return vote(argv); + } + } + yargsInstance.showHelp(); +} + +function vote(argv) { + const cli = new CLI(process.stderr); + const dir = process.cwd(); + + return runPromise(main(argv, cli, dir)).catch((err) => { + if (cli.spinner.enabled) { + cli.spinner.fail(); + } + throw err; + }); +} + +async function main(argv, cli, dir) { + const credentials = await auth({ github: true }); + const req = new Request(credentials); + const session = new VotingSession(cli, req, dir, argv); + + return session.start(); +} diff --git a/docs/git-node.md b/docs/git-node.md index c356adee..2d0f3bac 100644 --- a/docs/git-node.md +++ b/docs/git-node.md @@ -7,7 +7,7 @@ A custom Git command for managing pull requests. You can run it as - [`git node land`](#git-node-land) - [Prerequisites](#prerequisites) - [Git bash for Windows](#git-bash-for-windows) - - [Demo & Usage](#demo--usage) + - [Demo \& Usage](#demo--usage) - [Optional Settings](#optional-settings) - [`git node backport`](#git-node-backport) - [Example](#example) @@ -22,6 +22,9 @@ A custom Git command for managing pull requests. You can run it as - [`git node v8 minor`](#git-node-v8-minor) - [`git node v8 backport `](#git-node-v8-backport-sha) - [General options](#general-options) + - [`git node vote`](#git-node-vote) + - [Prerequisites](#prerequisites-2) + - [Usage](#usage) - [`git node status`](#git-node-status) - [Example](#example-2) - [`git node wpt`](#git-node-wpt) @@ -393,6 +396,37 @@ Options: will be used instead of cloning V8 to `baseDir`. - `--verbose`: Enable verbose output. +## `git node vote` + +### Prerequisites + +1. See the readme on how to + [set up credentials](../README.md#setting-up-credentials). +1. It's a Git command, so make sure you have Git installed, of course. + +Additionally, if you want to close the vote, you also need: + +1. A GPG client. By default it will look at the `GPG_BIN` environment variable, + and fallback to `gpg` if not provided. + +### Usage + +``` +Steps to cast a vote: +============================================================================== +$ git node vote $PR_URL # Start a voting session +$ git node vote $PR_URL --abstain # Cast an empty ballot +$ git node vote $PR_URL --protocol ssh # Instruct git-node to use SSH +============================================================================== + +Steps to close a vote: +============================================================================== +$ git node vote $PR_URL --decrypt-key-part # Outputs the user's key part +$ git node vote \ + $PR_URL --decrypt-key-part --post-comment # Post the key part as comment +============================================================================== +``` + ## `git node status` Return status and information about the current git-node land session. Shows the following information: diff --git a/lib/queries/VotePRInfo.gql b/lib/queries/VotePRInfo.gql new file mode 100644 index 00000000..55f99ab9 --- /dev/null +++ b/lib/queries/VotePRInfo.gql @@ -0,0 +1,28 @@ +query PR($prid: Int!, $owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $prid) { + commits(first: 1) { + nodes { + commit { + oid + } + } + } + headRef { + name + repository { + sshUrl + url + } + } + closed + merged + } + } + viewer { + login + publicKeys(first: 1) { + totalCount + } + } +} diff --git a/lib/session.js b/lib/session.js index 9b120f39..937de253 100644 --- a/lib/session.js +++ b/lib/session.js @@ -15,25 +15,26 @@ const STARTED = 'STARTED'; const AMENDING = 'AMENDING'; export default class Session { - constructor(cli, dir, prid) { + constructor(cli, dir, prid, argv, warnForMissing = true) { this.cli = cli; this.dir = dir; this.prid = prid; - this.config = getMergedConfig(this.dir); + this.config = { ...getMergedConfig(this.dir), ...argv }; - const { upstream, owner, repo } = this; + if (warnForMissing) { + const { upstream, owner, repo } = this; + if (this.warnForMissing()) { + throw new Error('Failed to create new session'); + } - if (this.warnForMissing()) { - throw new Error('Failed to create new session'); - } - - const upstreamHref = runSync('git', [ - 'config', '--get', + const upstreamHref = runSync('git', [ + 'config', '--get', `remote.${upstream}.url`]).trim(); - if (!new RegExp(`${owner}/${repo}(?:.git)?$`).test(upstreamHref)) { - cli.warn('Remote repository URL does not point to the expected ' + + if (!new RegExp(`${owner}/${repo}(?:.git)?$`).test(upstreamHref)) { + cli.warn('Remote repository URL does not point to the expected ' + `repository ${owner}/${repo}`); - cli.setExitCode(1); + cli.setExitCode(1); + } } } diff --git a/lib/voting_session.js b/lib/voting_session.js new file mode 100644 index 00000000..fb91996f --- /dev/null +++ b/lib/voting_session.js @@ -0,0 +1,136 @@ +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { env } from 'node:process'; + +import { + runAsync +} from './run.js'; +import Session from './session.js'; +import { + getEditor, isGhAvailable +} from './utils.js'; + +import voteUsingGit from '@node-core/caritat/voteUsingGit'; +import * as yaml from 'js-yaml'; + +function getHTTPRepoURL(repoURL, login) { + const url = new URL(repoURL + '.git'); + url.username = login; + return url.toString(); +} + +export default class VotingSession extends Session { + constructor(cli, req, dir, { + prid, abstain, ...argv + } = {}) { + super(cli, dir, prid, argv, false); + this.req = req; + this.abstain = abstain; + this.closeVote = argv['decrypt-key-part']; + this.postComment = argv['post-comment']; + this.gpgSign = argv['gpg-sign']; + } + + get argv() { + const args = super.argv; + args.decryptKeyPart = this.closeVote; + return args; + } + + async start(metadata) { + const { repository, viewer } = await this.req.gql('VotePRInfo', + { owner: this.owner, repo: this.repo, prid: this.prid }); + if (repository.pullRequest.merged) { + this.cli.warn('The pull request appears to have been merged already.'); + } else if (repository.pullRequest.closed) { + this.cli.warn('The pull request appears to have been closed already.'); + } + if (this.closeVote) return this.decryptKeyPart(repository.pullRequest); + // @see https://git-scm.com/book/en/v2/Git-Internals-Environment-Variables#_committing + const username = process.env.GIT_AUTHOR_NAME || (await runAsync( + 'git', ['config', '--get', 'user.name'], { captureStdout: true })).trim(); + const emailAddress = process.env.GIT_AUTHOR_EMAIL || (await runAsync( + 'git', ['config', '--get', 'user.email'], { captureStdout: true })).trim(); + const { headRef } = repository.pullRequest; + await voteUsingGit({ + GIT_BIN: 'git', + abstain: this.abstain, + EDITOR: await getEditor({ git: true }), + handle: viewer.login, + username, + emailAddress, + gpgSign: this.gpgSign, + repoURL: viewer.publicKeys.totalCount + ? headRef.repository.sshUrl + : getHTTPRepoURL(headRef.repository.url, viewer.login), + branch: headRef.name, + subPath: headRef.name + }); + } + + async decryptKeyPart(prInfo) { + const subPath = `${prInfo.headRef.name}/vote.yml`; + this.cli.startSpinner('Downloading vote file from remote...'); + const yamlString = await this.req.text( + `https://api.github.com/repos/${this.owner}/${this.repo}/contents/${encodeURIComponent(subPath)}?ref=${prInfo.commits.nodes[0].commit.oid}`, { + agent: this.req.proxyAgent, + headers: { + Authorization: `Basic ${this.req.credentials.github}`, + 'User-Agent': 'node-core-utils', + Accept: 'application/vnd.github.raw' + } + }); + this.cli.stopSpinner('Download complete'); + + const { shares } = yaml.load(yamlString); + const ac = new AbortController(); + this.cli.startSpinner('Decrypt key part...'); + const out = await Promise.any( + shares.map(async(share) => { + const cp = spawn(env.GPG_BIN || 'gpg', ['-d'], { + stdio: ['pipe', 'pipe', 'inherit'], + signal: ac.signal + }); + // @ts-ignore toArray exists + const stdout = cp.stdout.toArray(); + stdout.catch(Function.prototype); // ignore errors. + cp.stdin.end(share); + const [code] = await Promise.race([ + once(cp, 'exit'), + once(cp, 'error').then((er) => Promise.reject(er)) + ]); + if (code !== 0) throw new Error('failed', { cause: code }); + return Buffer.concat(await stdout); + }) + ); + ac.abort(); + this.cli.stopSpinner('Found one key part.'); + + const keyPart = '-----BEGIN SHAMIR KEY PART-----\n' + + out.toString('base64') + + '\n-----END SHAMIR KEY PART-----'; + this.cli.log('Your key part is:'); + this.cli.log(keyPart); + const body = 'I would like to close this vote, and for this effect, I\'m revealing my ' + + `key part:\n\n${'```'}\n${keyPart}\n${'```'}\n`; + if (this.postComment) { + const { html_url } = await this.req.json(`https://api.github.com/repos/${this.owner}/${this.repo}/issues/${this.prid}/comments`, { + agent: this.req.proxyAgent, + method: 'POST', + headers: { + Authorization: `Basic ${this.req.credentials.github}`, + 'User-Agent': 'node-core-utils', + Accept: 'application/vnd.github.antiope-preview+json' + }, + body: JSON.stringify({ body }) + }); + this.cli.log('Comment posted at:', html_url); + } else if (isGhAvailable()) { + this.cli.log('\nRun the following command to post the comment:\n'); + this.cli.log( + `gh pr comment ${this.prid} --repo ${this.owner}/${this.repo} ` + + `--body-file - <<'EOF'\n${body}\nEOF` + ); + } + } +} diff --git a/package.json b/package.json index 2b27bc90..c6d5fc77 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ ], "license": "MIT", "dependencies": { + "@node-core/caritat": "^1.2.0", "branch-diff": "^2.1.3", "chalk": "^5.3.0", "changelog-maker": "^3.2.4", @@ -45,6 +46,7 @@ "figures": "^5.0.0", "ghauth": "^5.0.1", "inquirer": "^9.2.10", + "js-yaml": "^4.1.0", "listr2": "^6.6.1", "lodash": "^4.17.21", "log-symbols": "^5.1.0",