Skip to content

Commit 7b9d962

Browse files
aduh95VoltrexKeyvatargos
authored
feat(git-node): add git node vote (#704)
Co-authored-by: Mohammed Keyvanzadeh <[email protected]> Co-authored-by: Michaël Zasso <[email protected]>
1 parent f30cdba commit 7b9d962

File tree

7 files changed

+302
-13
lines changed

7 files changed

+302
-13
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ When creating the token, the following boxes need to be checked:
7070
PR author in order to check if it matches the email of the commit author.
7171
- `read:org`: Used by `ncu-team` to read the list of team members.
7272

73+
Optionally, if you want to grant write access so `git-node` can write comments:
74+
75+
- `public_repo` (or `repo` if you intend to work with private repositories).
76+
7377
You can also edit the permission of existing tokens later.
7478

7579
After the token is generated, create an rc file with the following content:

components/git/vote.js

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import auth from '../../lib/auth.js';
2+
import { parsePRFromURL } from '../../lib/links.js';
3+
import CLI from '../../lib/cli.js';
4+
import Request from '../../lib/request.js';
5+
import { runPromise } from '../../lib/run.js';
6+
import VotingSession from '../../lib/voting_session.js';
7+
8+
export const command = 'vote [prid|options]';
9+
export const describe =
10+
'Cast a vote, or decrypt a key part to close a vote';
11+
12+
const voteOptions = {
13+
abstain: {
14+
type: 'boolean',
15+
default: false,
16+
describe: 'Abstain from the vote.'
17+
},
18+
'decrypt-key-part': {
19+
describe: 'Publish a key part as a comment to the vote PR.',
20+
default: false,
21+
type: 'boolean'
22+
},
23+
'gpg-sign': {
24+
describe: 'GPG-sign commits, will be passed to the git process',
25+
alias: 'S'
26+
},
27+
'post-comment': {
28+
describe: 'Post the comment on GitHub on the behalf of the user',
29+
default: false,
30+
type: 'boolean'
31+
},
32+
protocol: {
33+
describe: 'The protocol to use to clone the vote repository and push the eventual vote commit',
34+
type: 'string'
35+
}
36+
};
37+
38+
let yargsInstance;
39+
40+
export function builder(yargs) {
41+
yargsInstance = yargs;
42+
return yargs
43+
.options(voteOptions)
44+
.positional('prid', {
45+
describe: 'URL of the vote Pull Request'
46+
})
47+
.example('git node vote https://github.com/nodejs/TSC/pull/12344',
48+
'Start an interactive session to cast ballot for https://github.com/nodejs/TSC/pull/12344.')
49+
.example('git node vote https://github.com/nodejs/TSC/pull/12344 --abstain',
50+
'Cast an empty ballot for https://github.com/nodejs/TSC/pull/12344.')
51+
.example('git node vote https://github.com/nodejs/TSC/pull/12344 --decrypt-key-part',
52+
'Uses gpg to decrypt a key part to close the vote happening on https://github.com/nodejs/TSC/pull/12344.');
53+
}
54+
55+
export function handler(argv) {
56+
if (argv.prid) {
57+
const parsed = parsePRFromURL(argv.prid);
58+
if (parsed) {
59+
Object.assign(argv, parsed);
60+
return vote(argv);
61+
}
62+
}
63+
yargsInstance.showHelp();
64+
}
65+
66+
function vote(argv) {
67+
const cli = new CLI(process.stderr);
68+
const dir = process.cwd();
69+
70+
return runPromise(main(argv, cli, dir)).catch((err) => {
71+
if (cli.spinner.enabled) {
72+
cli.spinner.fail();
73+
}
74+
throw err;
75+
});
76+
}
77+
78+
async function main(argv, cli, dir) {
79+
const credentials = await auth({ github: true });
80+
const req = new Request(credentials);
81+
const session = new VotingSession(cli, req, dir, argv);
82+
83+
return session.start();
84+
}

docs/git-node.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ A custom Git command for managing pull requests. You can run it as
77
- [`git node land`](#git-node-land)
88
- [Prerequisites](#prerequisites)
99
- [Git bash for Windows](#git-bash-for-windows)
10-
- [Demo & Usage](#demo--usage)
10+
- [Demo \& Usage](#demo--usage)
1111
- [Optional Settings](#optional-settings)
1212
- [`git node backport`](#git-node-backport)
1313
- [Example](#example)
@@ -22,6 +22,9 @@ A custom Git command for managing pull requests. You can run it as
2222
- [`git node v8 minor`](#git-node-v8-minor)
2323
- [`git node v8 backport <sha..>`](#git-node-v8-backport-sha)
2424
- [General options](#general-options)
25+
- [`git node vote`](#git-node-vote)
26+
- [Prerequisites](#prerequisites-2)
27+
- [Usage](#usage)
2528
- [`git node status`](#git-node-status)
2629
- [Example](#example-2)
2730
- [`git node wpt`](#git-node-wpt)
@@ -393,6 +396,37 @@ Options:
393396
will be used instead of cloning V8 to `baseDir`.
394397
- `--verbose`: Enable verbose output.
395398

399+
## `git node vote`
400+
401+
### Prerequisites
402+
403+
1. See the readme on how to
404+
[set up credentials](../README.md#setting-up-credentials).
405+
1. It's a Git command, so make sure you have Git installed, of course.
406+
407+
Additionally, if you want to close the vote, you also need:
408+
409+
1. A GPG client. By default it will look at the `GPG_BIN` environment variable,
410+
and fallback to `gpg` if not provided.
411+
412+
### Usage
413+
414+
```
415+
Steps to cast a vote:
416+
==============================================================================
417+
$ git node vote $PR_URL # Start a voting session
418+
$ git node vote $PR_URL --abstain # Cast an empty ballot
419+
$ git node vote $PR_URL --protocol ssh # Instruct git-node to use SSH
420+
==============================================================================
421+
422+
Steps to close a vote:
423+
==============================================================================
424+
$ git node vote $PR_URL --decrypt-key-part # Outputs the user's key part
425+
$ git node vote \
426+
$PR_URL --decrypt-key-part --post-comment # Post the key part as comment
427+
==============================================================================
428+
```
429+
396430
## `git node status`
397431

398432
Return status and information about the current git-node land session. Shows the following information:

lib/queries/VotePRInfo.gql

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
query PR($prid: Int!, $owner: String!, $repo: String!) {
2+
repository(owner: $owner, name: $repo) {
3+
pullRequest(number: $prid) {
4+
commits(first: 1) {
5+
nodes {
6+
commit {
7+
oid
8+
}
9+
}
10+
}
11+
headRef {
12+
name
13+
repository {
14+
sshUrl
15+
url
16+
}
17+
}
18+
closed
19+
merged
20+
}
21+
}
22+
viewer {
23+
login
24+
publicKeys(first: 1) {
25+
totalCount
26+
}
27+
}
28+
}

lib/session.js

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,25 +15,26 @@ const STARTED = 'STARTED';
1515
const AMENDING = 'AMENDING';
1616

1717
export default class Session {
18-
constructor(cli, dir, prid) {
18+
constructor(cli, dir, prid, argv, warnForMissing = true) {
1919
this.cli = cli;
2020
this.dir = dir;
2121
this.prid = prid;
22-
this.config = getMergedConfig(this.dir);
22+
this.config = { ...getMergedConfig(this.dir), ...argv };
2323

24-
const { upstream, owner, repo } = this;
24+
if (warnForMissing) {
25+
const { upstream, owner, repo } = this;
26+
if (this.warnForMissing()) {
27+
throw new Error('Failed to create new session');
28+
}
2529

26-
if (this.warnForMissing()) {
27-
throw new Error('Failed to create new session');
28-
}
29-
30-
const upstreamHref = runSync('git', [
31-
'config', '--get',
30+
const upstreamHref = runSync('git', [
31+
'config', '--get',
3232
`remote.${upstream}.url`]).trim();
33-
if (!new RegExp(`${owner}/${repo}(?:.git)?$`).test(upstreamHref)) {
34-
cli.warn('Remote repository URL does not point to the expected ' +
33+
if (!new RegExp(`${owner}/${repo}(?:.git)?$`).test(upstreamHref)) {
34+
cli.warn('Remote repository URL does not point to the expected ' +
3535
`repository ${owner}/${repo}`);
36-
cli.setExitCode(1);
36+
cli.setExitCode(1);
37+
}
3738
}
3839
}
3940

lib/voting_session.js

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { spawn } from 'node:child_process';
2+
import { once } from 'node:events';
3+
import { env } from 'node:process';
4+
5+
import {
6+
runAsync
7+
} from './run.js';
8+
import Session from './session.js';
9+
import {
10+
getEditor, isGhAvailable
11+
} from './utils.js';
12+
13+
import voteUsingGit from '@node-core/caritat/voteUsingGit';
14+
import * as yaml from 'js-yaml';
15+
16+
function getHTTPRepoURL(repoURL, login) {
17+
const url = new URL(repoURL + '.git');
18+
url.username = login;
19+
return url.toString();
20+
}
21+
22+
export default class VotingSession extends Session {
23+
constructor(cli, req, dir, {
24+
prid, abstain, ...argv
25+
} = {}) {
26+
super(cli, dir, prid, argv, false);
27+
this.req = req;
28+
this.abstain = abstain;
29+
this.closeVote = argv['decrypt-key-part'];
30+
this.postComment = argv['post-comment'];
31+
this.gpgSign = argv['gpg-sign'];
32+
}
33+
34+
get argv() {
35+
const args = super.argv;
36+
args.decryptKeyPart = this.closeVote;
37+
return args;
38+
}
39+
40+
async start(metadata) {
41+
const { repository, viewer } = await this.req.gql('VotePRInfo',
42+
{ owner: this.owner, repo: this.repo, prid: this.prid });
43+
if (repository.pullRequest.merged) {
44+
this.cli.warn('The pull request appears to have been merged already.');
45+
} else if (repository.pullRequest.closed) {
46+
this.cli.warn('The pull request appears to have been closed already.');
47+
}
48+
if (this.closeVote) return this.decryptKeyPart(repository.pullRequest);
49+
// @see https://git-scm.com/book/en/v2/Git-Internals-Environment-Variables#_committing
50+
const username = process.env.GIT_AUTHOR_NAME || (await runAsync(
51+
'git', ['config', '--get', 'user.name'], { captureStdout: true })).trim();
52+
const emailAddress = process.env.GIT_AUTHOR_EMAIL || (await runAsync(
53+
'git', ['config', '--get', 'user.email'], { captureStdout: true })).trim();
54+
const { headRef } = repository.pullRequest;
55+
await voteUsingGit({
56+
GIT_BIN: 'git',
57+
abstain: this.abstain,
58+
EDITOR: await getEditor({ git: true }),
59+
handle: viewer.login,
60+
username,
61+
emailAddress,
62+
gpgSign: this.gpgSign,
63+
repoURL: viewer.publicKeys.totalCount
64+
? headRef.repository.sshUrl
65+
: getHTTPRepoURL(headRef.repository.url, viewer.login),
66+
branch: headRef.name,
67+
subPath: headRef.name
68+
});
69+
}
70+
71+
async decryptKeyPart(prInfo) {
72+
const subPath = `${prInfo.headRef.name}/vote.yml`;
73+
this.cli.startSpinner('Downloading vote file from remote...');
74+
const yamlString = await this.req.text(
75+
`https://api.github.com/repos/${this.owner}/${this.repo}/contents/${encodeURIComponent(subPath)}?ref=${prInfo.commits.nodes[0].commit.oid}`, {
76+
agent: this.req.proxyAgent,
77+
headers: {
78+
Authorization: `Basic ${this.req.credentials.github}`,
79+
'User-Agent': 'node-core-utils',
80+
Accept: 'application/vnd.github.raw'
81+
}
82+
});
83+
this.cli.stopSpinner('Download complete');
84+
85+
const { shares } = yaml.load(yamlString);
86+
const ac = new AbortController();
87+
this.cli.startSpinner('Decrypt key part...');
88+
const out = await Promise.any(
89+
shares.map(async(share) => {
90+
const cp = spawn(env.GPG_BIN || 'gpg', ['-d'], {
91+
stdio: ['pipe', 'pipe', 'inherit'],
92+
signal: ac.signal
93+
});
94+
// @ts-ignore toArray exists
95+
const stdout = cp.stdout.toArray();
96+
stdout.catch(Function.prototype); // ignore errors.
97+
cp.stdin.end(share);
98+
const [code] = await Promise.race([
99+
once(cp, 'exit'),
100+
once(cp, 'error').then((er) => Promise.reject(er))
101+
]);
102+
if (code !== 0) throw new Error('failed', { cause: code });
103+
return Buffer.concat(await stdout);
104+
})
105+
);
106+
ac.abort();
107+
this.cli.stopSpinner('Found one key part.');
108+
109+
const keyPart = '-----BEGIN SHAMIR KEY PART-----\n' +
110+
out.toString('base64') +
111+
'\n-----END SHAMIR KEY PART-----';
112+
this.cli.log('Your key part is:');
113+
this.cli.log(keyPart);
114+
const body = 'I would like to close this vote, and for this effect, I\'m revealing my ' +
115+
`key part:\n\n${'```'}\n${keyPart}\n${'```'}\n`;
116+
if (this.postComment) {
117+
const { html_url } = await this.req.json(`https://api.github.com/repos/${this.owner}/${this.repo}/issues/${this.prid}/comments`, {
118+
agent: this.req.proxyAgent,
119+
method: 'POST',
120+
headers: {
121+
Authorization: `Basic ${this.req.credentials.github}`,
122+
'User-Agent': 'node-core-utils',
123+
Accept: 'application/vnd.github.antiope-preview+json'
124+
},
125+
body: JSON.stringify({ body })
126+
});
127+
this.cli.log('Comment posted at:', html_url);
128+
} else if (isGhAvailable()) {
129+
this.cli.log('\nRun the following command to post the comment:\n');
130+
this.cli.log(
131+
`gh pr comment ${this.prid} --repo ${this.owner}/${this.repo} ` +
132+
`--body-file - <<'EOF'\n${body}\nEOF`
133+
);
134+
}
135+
}
136+
}

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
],
3535
"license": "MIT",
3636
"dependencies": {
37+
"@node-core/caritat": "^1.2.0",
3738
"branch-diff": "^2.1.3",
3839
"chalk": "^5.3.0",
3940
"changelog-maker": "^3.2.4",
@@ -45,6 +46,7 @@
4546
"figures": "^5.0.0",
4647
"ghauth": "^5.0.1",
4748
"inquirer": "^9.2.10",
49+
"js-yaml": "^4.1.0",
4850
"listr2": "^6.6.1",
4951
"lodash": "^4.17.21",
5052
"log-symbols": "^5.1.0",

0 commit comments

Comments
 (0)