Ctrl K
ComunidadGuías…

utils/autoMerge.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import * as yes from "yes";
import { addFooter } from "../mcp/comment.ts";
import type { ToolContext } from "../mcp/server.ts";
import { log } from "./cli.ts";
import { isPullfrog } from "./isPullfrog.ts";
import { retryTransientOctokit } from "./isTransientNetworkError.ts";
import { countOutstandingPullfrogThreads } from "./outstandingThreads.ts";

/** narrow view of a PR review — the only fields the blocking-verdict logic reads. */
type ReviewVerdict = { user: { login: string } | null; state: string };

/**
 * true when some non-Pullfrog reviewer's latest binding verdict is
 * CHANGES_REQUESTED. GitHub returns reviews in chronological order; a later
 * APPROVED or DISMISSED from the same reviewer clears an earlier
 * CHANGES_REQUESTED, and COMMENTED/PENDING never change standing. pure so the
 * blocking-verdict logic is inspectable without the merge round-trip. still
 * load-bearing on a CI-only-protected repo, which won't otherwise honor a human
 * CHANGES_REQUESTED as a required review.
 */
function hasBlockingHumanReview(reviews: ReviewVerdict[]): boolean {
  const latestByReviewer = new Map<string, "APPROVED" | "CHANGES_REQUESTED">();
  for (const review of reviews) {
    const login = review.user?.login;
    if (!login || isPullfrog(login)) continue;
    if (review.state === "APPROVED" || review.state === "CHANGES_REQUESTED") {
      latestByReviewer.set(login, review.state);
    } else if (review.state === "DISMISSED") {
      latestByReviewer.delete(login);
    }
  }
  for (const state of latestByReviewer.values()) {
    if (state === "CHANGES_REQUESTED") return true;
  }
  return false;
}

const ENABLE_AUTO_MERGE = /* GraphQL */ `
  mutation ($pullRequestId: ID!, $headSha: GitObjectID!) {
    enablePullRequestAutoMerge(input: {
      pullRequestId: $pullRequestId
      mergeMethod: SQUASH
      expectedHeadOid: $headSha
    }) { clientMutationId }
  }`;

/**
 * GitHub 422 `Pull request is in clean status` — nothing left for native
 * auto-merge to wait on, so the PR is immediately mergeable → merge directly.
 * `unstable status` is the same for every required check: only checks that are
 * not required are pending or failing, and GitHub allows the merge (#1533).
 */
const isAlreadyMergeable = (error: unknown): boolean =>
  error instanceof Error && /(clean|unstable) status/i.test(error.message);

/** the enable call's refusal on a base branch with no protection rules. */
const isUnprotectedBase = (error: unknown): boolean =>
  error instanceof Error && /protected branch rules not configured/i.test(error.message);

/**
 * GitHub rejects `enablePullRequestAutoMerge` when `expectedHeadOid` no longer
 * matches the head — a commit landed in the read→enable window, so we'd arm a
 * head we never reviewed. benign: the same "don't merge an unreviewed head"
 * outcome the direct-merge 409 guards, not a failure. it surfaces as a GraphQL
 * error (HTTP 200, no `.status`), so the caller's status===409 quieting can't
 * see it — classify it here as a skip.
 */
const isHeadChanged = (error: unknown): boolean =>
  error instanceof Error && /head has changed|expected head/i.test(error.message);

/**
 * GitHub rejects `enablePullRequestAutoMerge` with "Auto merge is not allowed
 * for this repository" when the repo-level `Allow auto-merge` setting is off
 * (Settings > General > Pull Requests, off by default). not the `clean status`
 * 422, so it would otherwise surface only as a server-side warn — classify it so
 * the caller can post an actionable PR comment instead.
 */
const isAutoMergeDisallowed = (error: unknown): boolean =>
  error instanceof Error && /not allowed/i.test(error.message);

/**
 * Run-end lifecycle action: hand any PR this run mechanically approved on its
 * current head to GitHub's NATIVE auto-merge — Pullfrog acting as a full repo
 * maintainer, contributor PRs included. There is deliberately NO agent-callable
 * merge tool — the merge is a deterministic consequence of the recorded approval
 * verdict, so a prompt-injected agent has no merge surface to reach for.
 *
 * Native auto-merge waits for all REQUIRED checks + reviews (un-fakeable by a
 * fork), respects the human veto via branch protection, and requires branch
 * protection to exist. We keep only the Pullfrog-specific pre-gates GitHub can't
 * express: armed toggles, approved-THIS-head, zero outstanding Pullfrog threads,
 * no human CHANGES_REQUESTED. Best-effort — the caller wraps this in `.catch`,
 * and a merge/comment failure can never flip the run's outcome.
 *
 * The invariant (all must hold):
 *   1. `ctx.autoMergeEnabled` — the per-repo toggle ANDed with the global
 *      `isAutonomousMaintenanceEnabled()` kill switch, server-side (run-context).
 *   2. `ctx.prApproveEnabled` — cannot auto-merge what it may not approve.
 *   3. this run recorded an APPROVE verdict (`toolState.approval.wouldApprove`).
 *   4. the PR is open and not a draft.
 *   5. `toolState.approval.sha === <current head sha>` — approved THIS head.
 *   6. `countOutstandingPullfrogThreads === 0` — re-queried at merge time.
 *   7. no un-dismissed human CHANGES_REQUESTED at handoff.
 * The remaining wait-for-required-checks + wait-for-required-reviews semantics
 * are GitHub's, via native auto-merge and branch protection.
 */
export async function autoMergeAfterApprove(ctx: ToolContext): Promise<void> {
  if (!ctx.autoMergeEnabled) return;
  if (!ctx.prApproveEnabled) return;

  const approval = ctx.toolState.approval;
  if (!approval?.wouldApprove) return;

  const pullNumber = ctx.payload.event.issue_number;
  if (typeof pullNumber !== "number") return;

  const skip = (reason: string): void =>
    log.info(
      `autoMerge: pr=#${pullNumber} approvalSha=${approval.sha?.slice(0, 7)} → skipped: ${reason}`
    );

  // attributable audit comment carrying the standard Pullfrog run footer.
  // best-effort — a failure must not undo the merge/enable or surface as a run error.
  const comment = (body: string): Promise<unknown> =>
    ctx.octokit.rest.issues
      .createComment({
        owner: ctx.repo.owner,
        repo: ctx.repo.name,
        issue_number: pullNumber,
        body: addFooter(ctx, body),
      })
      .catch((err) => log.debug(`autoMerge comment failed: ${err}`));

  const refuseUnprotected = async (): Promise<void> => {
    skip("base branch not protected — refusing CI-ungated merge");
    await comment(
      "> ⚠️ Approved, but not merged: the base branch has no branch protection. Add a rule with at least one required status check so Pullfrog can hand off to auto-merge."
    );
  };

  const pr = await ctx.octokit.rest.pulls.get({
    owner: ctx.repo.owner,
    repo: ctx.repo.name,
    pull_number: pullNumber,
  });

  if (pr.data.state !== "open") return skip("pr not open");
  if (pr.data.draft) return skip("pr is draft");
  if (pr.data.auto_merge) return skip("auto-merge already enabled");

  const headSha = pr.data.head.sha;
  if (approval.sha !== headSha) return skip(`approval sha != head ${headSha.slice(0, 7)}`);

  const outstanding = await countOutstandingPullfrogThreads(ctx, pullNumber);
  if (outstanding > 0) return skip(`${outstanding} unresolved Pullfrog thread(s)`);

  const reviews = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviews, {
    owner: ctx.repo.owner,
    repo: ctx.repo.name,
    pull_number: pullNumber,
    per_page: 100,
  });
  if (hasBlockingHumanReview(reviews)) return skip("human changes requested");

  // hand off to GitHub. enable native auto-merge (waits for required checks +
  // reviews, honors branch protection, expectedHeadOid rejects the enable if the
  // head moved since our read (guards the read→enable window, not the eventual
  // merged head)). a 422 `clean status` or `unstable status` means nothing required is
  // left to wait on → merge now. any other enable failure (e.g. a push-restricted
  // branch that also blocks the app) surfaces at warn via the caller's `.catch`.
  try {
    await yes.mutation({
      run: () =>
        ctx.octokit.graphql(ENABLE_AUTO_MERGE, {
          pullRequestId: pr.data.node_id,
          headSha,
        }),
      name: "enablePullRequestAutoMerge",
      retry: retryTransientOctokit,
    })();
    log.info(`autoMerge: pr=#${pullNumber} @ ${headSha.slice(0, 7)} → native auto-merge enabled`);
    await comment(
      "> ✅ Approved — auto-merge enabled; GitHub will merge once required checks pass."
    );
    return;
  } catch (error) {
    if (isHeadChanged(error)) return skip("head moved during enable");
    if (isUnprotectedBase(error)) return refuseUnprotected();
    if (isAutoMergeDisallowed(error)) {
      await comment(
        "> ⚠️ Approved, but auto-merge is off for this repo. Enable `Allow auto-merge` in Settings > General > Pull Requests."
      );
      return;
    }
    if (!isAlreadyMergeable(error)) throw error;
  }

  // `clean`/`unstable` status = immediately mergeable, which is EITHER a protected branch
  // whose requirements are already met (safe) OR an UNPROTECTED branch with no CI
  // gate at all. a direct merge on the latter would be CI-ungated — the regression
  // native auto-merge exists to avoid, and exactly what the UI/docs promise
  // against. so only direct-merge when the base branch is protected; else refuse
  // (native auto-merge needs branch protection anyway). fail-closed if the
  // metadata read fails.
  const base = await ctx.octokit.rest.repos
    .getBranch({ owner: ctx.repo.owner, repo: ctx.repo.name, branch: pr.data.base.ref })
    .catch(() => null);
  if (!base) return skip("base branch read failed — refusing to merge unverified");
  if (!base.data.protected) return refuseUnprotected();

  // `sha` pins the merge to the reviewed head — GitHub 409s (absorbed by the
  // caller's `.catch`) if a commit landed in the read→merge window, so a TOCTOU
  // push can't slip in.
  const merge = await ctx.octokit.rest.pulls.merge({
    owner: ctx.repo.owner,
    repo: ctx.repo.name,
    pull_number: pullNumber,
    sha: headSha,
    merge_method: "squash",
    commit_title: `${pr.data.title} (#${pullNumber})`,
  });
  log.info(
    `autoMerge: pr=#${pullNumber} @ ${headSha.slice(0, 7)} → merged directly ${merge.data.sha?.slice(0, 7)}`
  );
  await comment(
    "> ✅ Merged autonomously after Pullfrog approved this PR and resolved all of its own review threads."
  );
}