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 5 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
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 =
'Manage the current landing session or start a new one for a pull request';
Copy link
Member

Choose a reason for hiding this comment

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

This doesn't seem quite right ;)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch!


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', {
MoLow marked this conversation as resolved.
Show resolved Hide resolved
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');
aduh95 marked this conversation as resolved.
Show resolved Hide resolved
}

export function handler(argv) {
if (argv.prid) {
const parsed = parsePRFromURL(argv.prid);
if (parsed) {
Object.assign(argv, parsed);
return vote(argv);
}
anonrig marked this conversation as resolved.
Show resolved Hide resolved
}
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();
}
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
126 changes: 126 additions & 0 deletions lib/voting_session.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
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
} 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) {
console.warn('The pull request appears to have been merged already.');
} else if (repository.pullRequest.closed) {
console.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.headRefName}/vote.yml`;
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'
}
});

const { shares } = yaml.load(yamlString);
const ac = new AbortController();
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();

const keyPart = out.toString('base64');
console.log('Your key part is', keyPart);
anonrig marked this conversation as resolved.
Show resolved Hide resolved
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: 'I would like to close this vote, and for this effect, I\'m revealing my ' +
'key part:\n\n```\n-----BEGIN SHAMIR KEY PART-----\n' +
keyPart +
'\n-----END SHAMIR KEY PART-----\n```\n'
})
});
console.log('Comment posted at ', html_url);
}
}
}
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.0.1",
"branch-diff": "^2.1.1",
"chalk": "^5.2.0",
"changelog-maker": "^3.2.4",
Expand All @@ -45,6 +46,7 @@
"figures": "^5.0.0",
"ghauth": "^5.0.1",
"inquirer": "^9.2.7",
"js-yaml": "^4.1.0",
"listr2": "^6.6.0",
"lodash": "^4.17.21",
"log-symbols": "^5.1.0",
Expand Down