From 4b5464675ceb28090fe8a68f2121e433202705fa Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Wed, 14 Jun 2023 12:51:57 +0200 Subject: [PATCH 1/7] feat(git-node): add `git node vote` --- components/git/vote.js | 84 +++++++++++++++++++++++++ lib/queries/VotePRInfo.gql | 28 +++++++++ lib/session.js | 25 ++++---- lib/voting_session.js | 125 +++++++++++++++++++++++++++++++++++++ package.json | 2 + 5 files changed, 252 insertions(+), 12 deletions(-) create mode 100644 components/git/vote.js create mode 100644 lib/queries/VotePRInfo.gql create mode 100644 lib/voting_session.js diff --git a/components/git/vote.js b/components/git/vote.js new file mode 100644 index 00000000..a1805194 --- /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 = + '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', { + 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/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..800106f6 --- /dev/null +++ b/lib/voting_session.js @@ -0,0 +1,125 @@ +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 '@aduh95/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); + const username = (await runAsync('git', ['config', '--get', 'user.name'], + { captureStdout: true })).trim(); + const emailAddress = (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); + 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: '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); + } + } +} diff --git a/package.json b/package.json index 04c4f17d..18fb3de1 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ ], "license": "MIT", "dependencies": { + "@aduh95/caritat": "^0.6.0", "branch-diff": "^2.1.1", "chalk": "^5.2.0", "changelog-maker": "^3.2.4", @@ -45,6 +46,7 @@ "figures": "^5.0.0", "ghauth": "^5.0.1", "inquirer": "^9.2.6", + "js-yaml": "^4.1.0", "listr2": "^6.6.0", "lodash": "^4.17.21", "log-symbols": "^5.1.0", From ee1951d2207b6f7aa0dbe582a2cab37f68e2e889 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Thu, 29 Jun 2023 01:11:27 +0200 Subject: [PATCH 2/7] switch to `node-core` scope --- lib/voting_session.js | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/voting_session.js b/lib/voting_session.js index 800106f6..dee01f25 100644 --- a/lib/voting_session.js +++ b/lib/voting_session.js @@ -10,7 +10,7 @@ import { getEditor } from './utils.js'; -import voteUsingGit from '@aduh95/caritat/voteUsingGit'; +import voteUsingGit from '@node-core/caritat/voteUsingGit'; import * as yaml from 'js-yaml'; function getHTTPRepoURL(repoURL, login) { diff --git a/package.json b/package.json index 18fb3de1..4c49e5d8 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ ], "license": "MIT", "dependencies": { - "@aduh95/caritat": "^0.6.0", + "@node-core/caritat": "^0.6.0", "branch-diff": "^2.1.1", "chalk": "^5.2.0", "changelog-maker": "^3.2.4", From 5368c46a7851e632b6853b920a6b5fa88a08a6cd Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Thu, 29 Jun 2023 01:11:57 +0200 Subject: [PATCH 3/7] fix version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4c49e5d8..42456ba9 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ ], "license": "MIT", "dependencies": { - "@node-core/caritat": "^0.6.0", + "@node-core/caritat": "^1.0.1", "branch-diff": "^2.1.1", "chalk": "^5.2.0", "changelog-maker": "^3.2.4", From e22e7cb3e85ccd80528c7271701f7c2efe9b4d3c Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Thu, 29 Jun 2023 10:16:23 +0200 Subject: [PATCH 4/7] add support for git env variables --- lib/voting_session.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/voting_session.js b/lib/voting_session.js index dee01f25..e080f08d 100644 --- a/lib/voting_session.js +++ b/lib/voting_session.js @@ -46,10 +46,11 @@ export default class VotingSession extends Session { console.warn('The pull request appears to have been closed already.'); } if (this.closeVote) return this.decryptKeyPart(repository.pullRequest); - const username = (await runAsync('git', ['config', '--get', 'user.name'], - { captureStdout: true })).trim(); - const emailAddress = (await runAsync('git', ['config', '--get', 'user.email'], - { captureStdout: true })).trim(); + // @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', From fd579261c21c5ca07971f39cb49c006468622324 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Fri, 30 Jun 2023 10:44:45 +0200 Subject: [PATCH 5/7] Update components/git/vote.js Co-authored-by: Mohammed Keyvanzadeh --- components/git/vote.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/components/git/vote.js b/components/git/vote.js index a1805194..ab390a42 100644 --- a/components/git/vote.js +++ b/components/git/vote.js @@ -45,11 +45,11 @@ export function builder(yargs) { 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. ') + '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') + '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'); + 'Uses gpg to decrypt a key part to close the vote happening on https://github.com/nodejs/TSC/pull/12344.'); } export function handler(argv) { From 992fb0ae023afbf6e52ad786a55c7ad8277381f4 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Fri, 30 Jun 2023 12:28:05 +0200 Subject: [PATCH 6/7] Apply suggestions from code review + more --- README.md | 4 ++++ components/git/vote.js | 2 +- docs/git-node.md | 36 +++++++++++++++++++++++++++++++++++- lib/voting_session.js | 36 +++++++++++++++++++++++------------- 4 files changed, 63 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 2dcb5d40..4f562fee 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,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 grand 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 index ab390a42..128552be 100644 --- a/components/git/vote.js +++ b/components/git/vote.js @@ -7,7 +7,7 @@ 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'; + 'Cast a vote, or decrypt a key part to close a vote'; const voteOptions = { abstain: { diff --git a/docs/git-node.md b/docs/git-node.md index 79c6000e..fcf0c640 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) @@ -387,6 +390,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 land $PR_URL --abstain # Cast an empty ballot +$ git node land $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/voting_session.js b/lib/voting_session.js index e080f08d..19ac908a 100644 --- a/lib/voting_session.js +++ b/lib/voting_session.js @@ -7,7 +7,7 @@ import { } from './run.js'; import Session from './session.js'; import { - getEditor + getEditor, isGhAvailable } from './utils.js'; import voteUsingGit from '@node-core/caritat/voteUsingGit'; @@ -41,9 +41,9 @@ export default class VotingSession extends Session { 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.'); + this.cli.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.'); + 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 @@ -69,7 +69,8 @@ export default class VotingSession extends Session { } async decryptKeyPart(prInfo) { - const subPath = `${prInfo.headRefName}/vote.yml`; + 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, @@ -79,9 +80,11 @@ export default class VotingSession extends Session { 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'], { @@ -101,9 +104,15 @@ export default class VotingSession extends Session { }) ); ac.abort(); + this.cli.stopSpinner('Found one key part.'); - const keyPart = out.toString('base64'); - console.log('Your key part is', keyPart); + 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, @@ -113,14 +122,15 @@ export default class VotingSession extends Session { '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' - }) + body: JSON.stringify({ body }) }); - console.log('Comment posted at ', html_url); + 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` + ); } } } From a92209fcbd249b718c400692c517bc397f5390c5 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Sat, 1 Jul 2023 13:43:59 +0200 Subject: [PATCH 7/7] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Michaƫl Zasso --- README.md | 2 +- docs/git-node.md | 4 ++-- lib/voting_session.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4f562fee..91cbe84b 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ 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 grand write access so `git-node` can write comments: +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). diff --git a/docs/git-node.md b/docs/git-node.md index fcf0c640..519a5a06 100644 --- a/docs/git-node.md +++ b/docs/git-node.md @@ -409,8 +409,8 @@ Additionally, if you want to close the vote, you also need: Steps to cast a vote: ============================================================================== $ git node vote $PR_URL # Start a voting session -$ git node land $PR_URL --abstain # Cast an empty ballot -$ git node land $PR_URL --protocol ssh # Instruct git-node to use SSH +$ 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: diff --git a/lib/voting_session.js b/lib/voting_session.js index 19ac908a..fb91996f 100644 --- a/lib/voting_session.js +++ b/lib/voting_session.js @@ -112,7 +112,7 @@ export default class VotingSession extends Session { 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` + `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,