-
Notifications
You must be signed in to change notification settings - Fork 113
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
Changes from 5 commits
4b54646
ee1951d
5368c46
c2e4af0
e22e7cb
fd57926
992fb0a
a92209f
0a4d41e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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'; | ||
|
||
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(); | ||
} |
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 | ||
} | ||
} | ||
} |
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', | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
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 ;)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch!