current commit
261015.6 · fix(git-node-vote): specify GH API version (#1222)
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(
      `/repos/${this.owner}/${this.repo}/contents/${
        encodeURIComponent(subPath)}?ref=${prInfo.commits.nodes[0].commit.oid}`,
      {
        agent: this.req.proxyAgent,
        headers: {
          Accept: 'application/vnd.github.raw+json',
          'X-GitHub-Api-Version': '2026-03-10'
        }
      }
    );
    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) => { throw 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:');
    console.log(keyPart); // Using `console.log` so this gets output to stdout, not stderr.
    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 { message, html_url } = await this.req.json(
        `/repos/${this.owner}/${this.repo}/issues/${this.prid}/comments`,
        {
          agent: this.req.proxyAgent,
          method: 'POST',
          headers: { Accept: 'application/vnd.github.antiope-preview+json' },
          body: JSON.stringify({ body })
        }
      );
      if (html_url) {
        this.cli.log(`Comment posted at: ${html_url}`);
        return;
      } else {
        this.cli.warn(message);
        this.cli.error('Failed to post comment');
      }
    }
    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`
      );
    } else {
      this.cli.log('\nPost the following comment on the PR thread:\n');
      this.cli.log(body);
    }
  }
}