@ 261105.4
current
import os from 'node:os';

import {
  runAsync, runSync, forceRunAsync
} from './run.js';
import Session from './session.js';
import {
  shortSha, isGhAvailable, getEditor
} from './utils.js';
import { debuglog, isDebugVerbosity } from './verbosity.js';

const isWindows = process.platform === 'win32';

export const LINT_RESULTS = {
  SKIPPED: 'skipped',
  FAILED: 'failed',
  SUCCESS: 'success'
};

export default class LandingSession extends Session {
  constructor(cli, req, dir, {
    prid, backport, lint, autorebase, fixupAll,
    checkCI, oneCommitMax, ...argv
  } = {}) {
    super(cli, dir, prid, argv);
    this.req = req;
    this.backport = backport;
    this.lint = lint;
    this.autorebase = autorebase;
    this.fixupAll = fixupAll;
    this.oneCommitMax = oneCommitMax;
    this.expectedCommitShas = [];
    this.checkCI = !!checkCI;
  }

  get argv() {
    const args = super.argv;
    args.backport = this.backport;
    args.lint = this.lint;
    args.autorebase = this.autorebase;
    args.fixupAll = this.fixupAll;
    args.oneCommitMax = this.oneCommitMax;
    return args;
  }

  async start(metadata) {
    const { cli } = this;
    this.startLanding();
    this.expectedCommitShas =
      metadata.data.commits.map(({ commit }) => commit.oid);
    const status = metadata.status ? 'should be ready' : 'is not ready';
    // NOTE(mmarchini): default answer is yes. If --yes is given, we need to be
    // more careful though, and we change the default to the result of our
    // metadata check.
    const defaultAnswer = !cli.assumeYes ? true : metadata.status;
    const shouldContinue = await cli.prompt(
      `This PR ${status} to land, do you want to continue?`, { defaultAnswer });
    if (!shouldContinue) {
      await this.abort(false);
      return process.exit(1);
    }

    this.saveMetadata(metadata);
    this.startApplying();
    return this.apply();
  }

  async abort(tryResetBranch = true) {
    try {
      const { cli } = this;
      this.cleanFiles();
      if (tryResetBranch) {
        await this.tryResetBranch();
      }
      cli.ok(`Aborted \`git node land\` session in ${this.ncuDir}`);
    } catch (ex) {
      const { cli } = this;
      cli.setExitCode(1);
      cli.error(`Couldn't abort \`git node land\` session in ${this.ncuDir}`);
      throw ex;
    }
  }

  async downloadAndPatch() {
    const {
      cli, upstream, prid, expectedCommitShas, crossRepoPR, landedCommitSha
    } = this;

    if (crossRepoPR) {
      const { owner, repo } = this;
      cli.warn(`The configured remote "${upstream}" does not point to the ` +
        'Pull Request repository.');
      const isHeadAMergeCommit = () => {
        try {
          return runSync('git', ['rev-parse', 'FETCH_HEAD^2']).trim();
        } catch {}
      };
      do {
        cli.warn('Fetching commits from a different upstream might require user intervention.');

        cli.info('Run the following command to fetch the commit(s):');
        cli.info(`git fetch git@github.com:${owner}/${repo}.git refs/pull/${prid}/merge`);
        const response = await cli.prompt('Should NCU run the command for you?',
          { defaultAnswer: false });
        if (response) {
          cli.startSpinner(`Downloading patch for ${prid}`);
          await forceRunAsync('git',
            ['fetch', `git@github.com:${owner}/${repo}.git`, `refs/pull/${prid}/merge`],
            { ignoreFailure: false });
        }
      } while (!isHeadAMergeCommit());
    } else if (landedCommitSha) {
      // The PR already landed (e.g. a security fix released publicly), so
      // its merge ref is gone - fetch the commits from the base branch.
      cli.startSpinner(`Downloading landed commits for ${prid}`);
      await forceRunAsync('git', ['fetch', upstream, landedCommitSha],
        { ignoreFailure: false });
    } else {
      cli.startSpinner(`Downloading patch for ${prid}`);
      await runAsync('git', [
        'fetch', upstream,
        `refs/pull/${prid}/merge`]);
    }

    let base, head, rebaseHead;
    if (landedCommitSha && !crossRepoPR) {
      // FETCH_HEAD is the last commit of the PR as landed on the base branch.
      // Walk back while the PR-URL trailer still points to this PR to find
      // the base of the landed series (landing may have squashed commits).
      const prUrlRe = new RegExp(`^PR-URL: \\S+/pull/${prid}$`, 'm');
      let count = 0;
      while (count < expectedCommitShas.length) {
        const message = await runAsync('git',
          ['log', '-1', '--format=%B', `FETCH_HEAD~${count}`],
          { captureStdout: true });
        if (!prUrlRe.test(message)) break;
        count++;
      }
      if (count === 0) {
        cli.error(`Landed commit ${landedCommitSha} does not have a ` +
          `PR-URL trailer pointing to pull request ${prid}`);
        process.exit(1);
      }
      [base, head, rebaseHead] = await runAsync('git',
        ['rev-parse', `FETCH_HEAD~${count}`, 'FETCH_HEAD', 'HEAD'],
        { captureStdout: 'lines' });
    } else {
      // We fetched the commit that would result if we used `git merge`.
      // ^1 and ^2 refer to the PR base and the PR head, respectively.
      [base, head, rebaseHead] = await runAsync('git',
        ['rev-parse', 'FETCH_HEAD^1', 'FETCH_HEAD^2', 'HEAD'],
        { captureStdout: 'lines' });
    }
    const commitShas = await runAsync('git',
      ['rev-list', `${base}..${head}`],
      { captureStdout: 'lines' });
    cli.stopSpinner(`Fetched commits as ${shortSha(base)}..${shortSha(head)}`);
    cli.separator();

    if (landedCommitSha && !crossRepoPR) {
      // The landed commits have different SHAs from the ones in the PR, so
      // they cannot be compared - they were validated by their PR-URL
      // trailers above.
      cli.ok(`Using ${commitShas.length} commit(s) as landed for PR ${prid}`);
    } else {
      const mismatchedCommits = [
        ...commitShas.filter((sha) => !expectedCommitShas.includes(sha))
          .map((sha) => `Unexpected commit ${sha}`),
        ...expectedCommitShas.filter((sha) => !commitShas.includes(sha))
          .map((sha) => `Missing commit ${sha}`)
      ].join('\n');
      if (mismatchedCommits.length > 0) {
        cli.error(`Mismatched commits:\n${mismatchedCommits}`);
        process.exit(1);
      }
    }

    const commitInfo = { base, head, shas: commitShas, rebaseHead };
    this.saveCommitInfo(commitInfo);

    try {
      await forceRunAsync('git',
        ['cherry-pick', '--allow-empty', ...this.gpgSign, `${base}..${head}`],
        { ignoreFailure: false });
    } catch (ex) {
      if (isDebugVerbosity()) {
        debuglog('[LandingSession] Got error', ex);
      }
      cli.error('Failed to apply patches');
      process.exit(1);
    }

    cli.ok('Patches applied');
    return commitInfo;
  }

  getRebaseSuggestion(subjects) {
    const { upstream, branch } = this;
    let command = `git rebase ${upstream}/${branch} --no-keep-empty -i`;
    command += ' -x "git node land --amend"';

    const squashes = subjects.filter(
      line => line.includes('fixup!') || line.includes('squash!') || line.includes('amend!'));

    if (squashes.length !== 0) {
      command += ' --autosquash';
    }

    if (this.gpgSign) {
      command += ' ' + this.gpgSign.join(' ');
    }

    return command;
  }

  makeRebaseSuggestion(subjects) {
    const suggestion = this.getRebaseSuggestion(subjects);
    this.cli.log('Please run the following commands to complete landing\n\n' +
      `$ ${suggestion}\n` +
      '$ git node land --continue');
  }

  canAutomaticallyRebase(subjects) {
    return subjects.every(line => !line.startsWith('squash!'));
  }

  async validateLint() {
    // The linter is currently only run on non-Windows platforms.
    if (os.platform() === 'win32') {
      return LINT_RESULTS.SKIPPED;
    }

    if (!this.lint) {
      return LINT_RESULTS.SKIPPED;
    }

    try {
      await runAsync('make', ['lint']);
      return LINT_RESULTS.SUCCESS;
    } catch {
      return LINT_RESULTS.FAILED;
    }
  }

  async tryCompleteLanding(commitInfo) {
    const { cli } = this;
    const subjects = await runAsync('git',
      ['log', '--pretty=format:%s', `${commitInfo.base}..${commitInfo.head}`],
      { captureStdout: 'lines' });

    if (commitInfo.shas.length === 1) {
      const shouldAmend = await cli.prompt(
        'There is only one commit in this PR.\n' +
        'do you want to amend the commit message?');
      if (!shouldAmend) {
        return;
      }
      const canFinal = await this.amend();
      if (!canFinal) {
        return;
      }

      return this.final();
    } else if (this.fixupAll) {
      cli.log(`There are ${subjects.length} commits in the PR. ` +
        'Attempting to fixup everything into first commit.');
      await runAsync('git', ['reset', '--soft', `HEAD~${subjects.length - 1}`]);
      await runAsync('git', ['commit', '--amend', '--no-edit', ...this.gpgSign]);
      return await this.amend() && this.final();
    } else if (this.autorebase && this.canAutomaticallyRebase(subjects)) {
      // Run git rebase in interactive mode with autosquash but without editor
      // so that it will perform everything automatically.
      cli.log(`There are ${subjects.length} commits in the PR. ` +
        'Attempting autorebase.');
      const assumeYes = this.cli.assumeYes ? '--yes' : '';
      const msgAmend = `-x "git node land --amend ${assumeYes}"`;
      try {
        await forceRunAsync('git',
          ['rebase', ...this.gpgSign, commitInfo.rebaseHead,
            '--no-keep-empty', '-i', '--autosquash', msgAmend],
          {
            ignoreFailure: false,
            spawnArgs: {
              shell: true,
              env: { ...process.env, GIT_SEQUENCE_EDITOR: ':' }
            }
          });
        return this.final();
      } catch {
        await runAsync('git', ['rebase', '--abort']);
        const count = subjects.length;
        cli.log(`Couldn't rebase ${count} commits in the PR automatically`);
        this.makeRebaseSuggestion(subjects);
      }
    } else {
      this.makeRebaseSuggestion(subjects);
    }
  }

  async apply() {
    const { cli } = this;

    // Bail if another landing session is currently in progress.
    if (!this.isApplying()) {
      cli.warn('Landing session already in progress - ' +
        'to start a new one run `git node land --abort`');
      return;
    }
    await this.tryResetBranch();

    const commitInfo = await this.downloadAndPatch();

    const cleanLint = await this.validateLint();
    if (cleanLint === LINT_RESULTS.FAILED) {
      const tryFixLint = await cli.prompt(
        'Lint failed - try fixing with \'make lint-js-fix\'?');
      if (tryFixLint) {
        await runAsync('make', ['lint-js-fix']);
        const fixed = await this.validateLint();
        if (fixed === LINT_RESULTS.FAILED) {
          cli.warn('Patch still contains lint errors. ' +
            'Please fix manually before proceeding');
        }
      }

      const correctedLint = await cli.prompt('Corrected all lint errors?');
      if (correctedLint) {
        await runAsync('git', ['add', '.']);

        // Final message will be edited later - don't try to change it here.
        await runAsync('git', ['commit', '--amend', '--no-edit', ...this.gpgSign]);
      } else {
        cli.info('Please fix lint errors and then run ' +
        '`git node land --amend` followed by ' +
        '`git node land --continue`.');
        process.exit(1);
      }
    } else if (cleanLint === LINT_RESULTS.SUCCESS) {
      cli.ok('Lint passed cleanly');
    }

    this.startAmending();

    await this.tryCompleteLanding(commitInfo);
  }

  async generateAmendedMessage(original) {
    const { cli, metadata } = this;

    const BACKPORT_RE = /^BACKPORT-PR-URL\s*:\s*(\S+)$/i;
    const PR_RE = /^PR-URL\s*:\s*(\S+)$/i;
    const REVIEW_RE = /^Reviewed-By\s*:\s*(\S+)$/i;
    const CVE_RE = /^CVE-ID\s*:\s*(\S+)$/im;

    // git has very specific rules about what is a trailer and what is not.
    // Instead of trying to implement those ourselves, let git parse the
    // commit message and see if it outputs any trailers.
    const interpretTrailers = commitMessage => runSync('git', [
      'interpret-trailers', '--parse', '--no-divider'
    ], {
      input: `${commitMessage}\n`
    });

    const originalTrailers = interpretTrailers(original);
    const containCVETrailer = CVE_RE.test(originalTrailers);

    const amended = original.trim().split('\n');
    let keptTrailers = [];
    if (originalTrailers) {
      const stillInTrailers = () => {
        const result = interpretTrailers(amended.join('\n'));
        return result.length && originalTrailers.startsWith(result.trim());
      };
      // Remove all lines until we find an empty string, which should get of all
      // trailers in most cases.
      amended.splice(amended.lastIndexOf(''), Infinity);
      for (let i = amended.length - 1; amended[i] === '' || stillInTrailers(); i--) {
        // Remove last line until git no longer detects any trailers
        amended.pop();
      }

      // Only keep existing trailers that we won't add ourselves
      const trailersToFilterOut = ['BACKPORT-PR-URL', 'REVIEWED-BY'];
      if (!this.backport) trailersToFilterOut.push('PR-URL');
      keptTrailers = originalTrailers
        .split('\n')
        .map(trailer => {
          const separatorIndex = trailer.indexOf(':');
          return [trailer.slice(0, separatorIndex), trailer.slice(separatorIndex + 2)];
        })
        .slice(0, -1) // Remove the last line return entry
        .filter(
          ([key]) => !trailersToFilterOut.includes(key.toUpperCase())
        );
    }
    amended.push('', ...keptTrailers.map(([key, value]) => `${key}: ${value}`));

    for (const line of metadata.trim().split('\n')) {
      if (keptTrailers.some((trailer) => line.toUpperCase() === trailer.join(': ').toUpperCase())) {
        cli.warn(`Found ${line}, skipping..`);
        continue;
      }

      if (BACKPORT_RE.test(line)) {
        const prIndex =
          (amended.findIndex(datum => PR_RE.test(datum)) + 1) ||
          amended.findIndex(datum => REVIEW_RE.test(datum));
        if (prIndex !== -1) {
          amended.splice(prIndex, 0, line);
          continue;
        }
      }
      amended.push(line);
    }

    if (!containCVETrailer && this.includeCVE) {
      let cveID;
      if (this.cveIds && this.cveIds.length > 0) {
        cveID = this.cveIds.join(', ');
        cli.ok(`Using CVE-ID from vulnerabilities.json: ${cveID}`);
      } else {
        // Fallback: check if the original commit has a PR-URL trailer
        // and use it to look up CVE-IDs from the vulnerabilities map
        if (this.vulnCveMap) {
          const prUrlMatch = original.match(PR_RE);
          if (prUrlMatch) {
            const prUrl = prUrlMatch[1];
            const cveIds = this.vulnCveMap.get(prUrl);
            if (cveIds && cveIds.length > 0) {
              cveID = cveIds.join(', ');
              cli.ok(`Using CVE-ID from backport PR-URL (${prUrl}): ${cveID}`);
            }
          }
        }

        // Fall back to prompt if still not found
        if (!cveID) {
          cveID = await cli.prompt(
            'Git found no CVE-ID trailer in the original commit message. ' +
            'Please, provide the CVE-ID (e.g. CVE-2026-12345) or leave it empty',
            { questionType: 'input', defaultAnswer: '' }
          );
        }
      }
      // Some commits might not address a vulnerability, but it is necessary
      // for the security release to happen.
      if (cveID) {
        amended.push('CVE-ID: ' + cveID);
      }
    }
    return amended.join('\n');
  }

  async amend() {
    const { cli } = this;
    if (!this.readyToAmend()) {
      cli.warn('Not yet ready to amend, run `git node land --abort`');
      return;
    }

    this.startAmending();

    const rev = this.getCurrentRev();
    const original = runSync('git', [
      'show', 'HEAD', '-s', '--format=%B'
    ]).trim();

    const message = await this.generateAmendedMessage(original);
    const messageFile = this.saveMessage(rev, message);
    cli.separator('New Message');
    cli.log(message.trim());
    // `skipMessagePrompt` is only set by callers that already got a blanket
    // confirmation; it is undefined on a regular landing session.
    const takeMessage = this.skipMessagePrompt ||
      await cli.prompt('Use this message?');
    if (takeMessage) {
      await runAsync('git', ['commit', '--amend', '-F', messageFile, ...this.gpgSign]);
      return true;
    }

    const editor = await getEditor({ git: true });
    if (editor) {
      try {
        await forceRunAsync(
          editor,
          [`"${messageFile}"`],
          { ignoreFailure: false, spawnArgs: { shell: true } }
        );
        await runAsync('git', ['commit', '--amend', '-F', messageFile, ...this.gpgSign]);
        return true;
      } catch {
        cli.error('Failed to edit the message using the configured editor');
      }
    }

    cli.log(`Please manually edit ${messageFile}, then run\n` +
      `\`git commit --amend -F ${messageFile}\` ` +
      'to finish amending the message');
    process.exit(1);  // make it work with git rebase -x
  }

  async final() {
    const {
      cli, owner, repo, upstream, branch, prid, oneCommitMax
    } = this;

    // Check that git rebase/am has been completed.
    if (!this.readyToFinal()) {
      cli.warn('Not yet ready to final');
      cli.log('A git rebase/am is in progress.' +
        ' Please complete it before running git node land --final');
      return;
    }

    const stray = this.getStrayCommits();
    if (stray.length > 1) {
      const forceLand = await cli.prompt(
        'There is more than one commit in the PR. ' +
        'Do you still want to land it?',
        { defaultAnswer: !oneCommitMax });

      if (!forceLand) {
        cli.info(
          'GITHUB_ACTION' in process.env

            ? 'Add `commit-queue-squash` label to land the PR as one commit, ' +
            'or `commit-queue-rebase` to land as separate commits.'

            : 'Use --fixupAll option, squash the PR manually or land the PR ' +
            'from the command line.'
        );
        process.exit(1);
      }
    }
    const strayVerbose = this.getStrayCommits(true);
    const validateCommand = new URL(
      '../node_modules/.bin/core-validate-commit' + (isWindows ? '.cmd' : ''),
      import.meta.url
    );

    try {
      await forceRunAsync(validateCommand, stray, { ignoreFailure: false });
    } catch (e) {
      let forceLand = false;
      if (e.code === 1) {
        forceLand = await cli.prompt(
          'The commit did not pass the validation. ' +
          'Do you still want to land it?',
          { defaultAnswer: false });
      }

      if (!forceLand) {
        cli.info('Please fix the commit message and try again.');
        cli.log('Please manually ammend the commit message, by running\n' +
          '`git commit --amend`\n' +
          'Once commit message is fixed, finish the landing command running\n' +
          '`git node land --continue`');
        process.exit(1);
      }
    }

    cli.separator();
    cli.log('The following commits are landable on ' +
      `${upstream}/${branch}`);
    cli.log(`- ${strayVerbose.join('\n- ')}`);
    cli.separator();

    let willBeLanded = shortSha(stray[stray.length - 1]);
    if (stray.length > 1) {
      const head = shortSha(this.getUpstreamHead());
      willBeLanded = `${head}...${willBeLanded}`;
    }

    cli.startSpinner('Removing temporary files');
    this.cleanFiles();
    cli.stopSpinner('Temporary files removed.');
    if (this.crossRepoPR) {
      cli.warn(`The configured remote "${upstream}" does not point to the ` +
          'Pull Request repository.');
      cli.info(`${willBeLanded} should likely not be pushed to "${upstream}"`);
      return;
    }
    cli.log('To finish landing:');
    cli.log('1. Run: ');
    cli.log(`   git push ${upstream} ${branch}`);
    const url = `https://github.com/${owner}/${repo}/pull/${prid}`;
    cli.log(`2. Post "Landed in ${willBeLanded}" in ${url}`);
    if (isGhAvailable()) {
      cli.log(`   gh pr comment ${url} --body "Landed in ${willBeLanded}"`);
      cli.log(`   gh pr close ${url}`);
    }
  }

  async continue() {
    const { cli } = this;
    if (this.readyToFinal()) {
      cli.log('Running `final`..');
      return this.final();
    }
    if (this.readyToAmend()) {
      cli.log('Running `amend`..');
      return this.amend();
    }
    if (this.isApplying()) {
      // We're still resolving conflicts.
      if (this.cherryPickInProgress()) {
        cli.log('Looks like you are resolving a `git cherry-pick` conflict');
        cli.log('Please run `git status` for help');
      } else {
        // Conflicts has been resolved - amend.
        this.startAmending();
        return this.tryCompleteLanding(this.commitInfo);
      }
      return;
    }
    if (this.hasStarted()) {
      cli.log('Running `apply`..');
      return this.apply();
    }
    cli.log(
      'Please run `git node land <PRID> to start a landing session`');
  }

  async status() {
    // TODO
  }

  async warnForWrongBranch() {
    if (super.warnForWrongBranch()) {
      return true;
    }
    const rev = this.getCurrentBranch();
    const { repository: { defaultBranchRef } } = await this.req.gql(
      'DefaultBranchRef',
      { owner: this.owner, repo: this.repo });
    if ((rev === 'master' || rev === 'main') && defaultBranchRef.name !== rev) {
      this.cli.warn(`You are running git-node-land on \`${rev}\`,` +
                    ` but the default branch is \`${defaultBranchRef.name}\`.`);
      this.cli.setExitCode(1);
      return true;
    }
  }
}