Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(git-node): add git node vote #704

Merged
merged 9 commits into from
Sep 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
84 changes: 84 additions & 0 deletions components/git/vote.js
Original file line number Diff line number Diff line change
@@ -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();
}
36 changes: 35 additions & 1 deletion docs/git-node.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 <sha..>`](#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)
Expand Down Expand Up @@ -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:
Expand Down
28 changes: 28 additions & 0 deletions lib/queries/VotePRInfo.gql
Original file line number Diff line number Diff line change
@@ -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
}
}
}
25 changes: 13 additions & 12 deletions lib/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}

Expand Down
136 changes: 136 additions & 0 deletions lib/voting_session.js
Original file line number Diff line number Diff line change
@@ -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',
Copy link
Member

@joyeecheung joyeecheung Jun 29, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should require additional permissions from the token other than the ones specified by https://github.com/nodejs/node-core-utils/blob/main/README.md#setting-up-github-credentials? So the docs should probably be updated and/or there should be a hint if this returns a permission error code (403?)?

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`
);
}
}
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down