import type { RestEndpointMethodTypes } from "@octokit/rest"; import type { ToolContext } from "../mcp/server.ts"; import { primaryRepoState } from "../toolState.ts"; import { log } from "./cli.ts"; import { countOutstandingPullfrogThreads } from "./outstandingThreads.ts"; import { APPROVAL_CHECK_NAME, createTerminalRunStatusCheck, finalizeRunStatusCheck, GITHUB_ACTIONS_APP_SLUG, parseCheckRunId, RUN_STATUS_CHECK_NAME, } from "./runStatusCheck.ts"; /** * post the `pullfrog` (run lifecycle) and `pullfrog-approval` (review verdict) * commit-status check-runs. * * - `Pullfrog` is on by default (`Repo.statusChecks`). the server already created it * `in_progress` at dispatch, so the work here is a PATCH to its terminal conclusion — * see `runStatusCheck.ts` for why a second create would leave two contradictory rows. * the terminal-create fallback covers a payload with no `checkRun` (older server * build mid-rolling-deploy, or a workflow driven outside Pullfrog's dispatch path). * - `pullfrog-approval` stays opt-in (`Repo.approvalCheck`, default off) and terminal-only: * it asserts a review verdict, which only exists once a run produces one. anchored * to the exact reviewed sha so a mid-run push leaves the new head unapproved until * a follow-up re-review reports. a re-review that submits no review carries the * prior verdict forward instead (see `carriedApproval`). * * best-effort throughout: a check-post failure (transient 5xx, closed PR, revoked * permission) must never flip the run's own outcome. the `workflow_run.completed` webhook * and both stuck-run reaper sweeps close out a check this function fails to finalize. */ export async function reportStatusChecks( ctx: ToolContext, params: { runSucceeded: boolean } ): Promise { const event = ctx.payload.event; const pullNumber = event.issue_number; if (event.is_pr !== true || typeof pullNumber !== "number") return; // the check-run id is the authority, not the setting: if the server seeded a check, it // MUST be finalized even when this workflow opted out via `status_checks: disabled`. // the server never parses workflow YAML (it only sees `Repo.statusChecks`), so the // opt-out cannot prevent the seed — and a seeded check left `in_progress` because the // action declined to touch it is strictly worse than the check the user didn't want. const checkRunId = parseCheckRunId(ctx.payload.checkRun); if (checkRunId === undefined && !ctx.payload.runStatusCheck && !ctx.payload.approvalCheck) return; const conclusion = params.runSucceeded ? "success" : "failure"; const detailsUrl = ctx.runId ? `https://github.com/${ctx.repo.owner}/${ctx.repo.name}/actions/runs/${ctx.runId}` : undefined; const approval = ctx.toolState.approval; // a silent run has no progress comment, so a final report that is not a review — a // re-review's "no new review was warranted" — otherwise reaches nobody. const note = event.silent && !approval?.url ? ctx.toolState.lastProgressBody : undefined; if (checkRunId !== undefined) { await finalizeRunStatusCheck({ octokit: ctx.octokit, owner: ctx.repo.owner, repo: ctx.repo.name, checkRunId, conclusion, detailsUrl, reviewUrl: approval?.url, note, }) .then(() => log.info(`» finalized ${RUN_STATUS_CHECK_NAME} check (${conclusion})`)) .catch((err) => log.debug(`status checks: ${RUN_STATUS_CHECK_NAME} finalize failed: ${err}`)); } // a re-review that submits no review records no verdict of its own. const carriesApproval = !approval && ctx.toolState.selectedMode === "IncrementalReview"; // everything below needs a head sha, which costs an API call — skip it when there is // nothing left to post. const needsApprovalCheck = ctx.payload.approvalCheck && params.runSucceeded && (approval !== undefined || carriesApproval); const needsFallbackRunCheck = ctx.payload.runStatusCheck && checkRunId === undefined; if (!needsApprovalCheck && !needsFallbackRunCheck) return; let headSha: string; try { const pr = await ctx.octokit.rest.pulls.get({ owner: ctx.repo.owner, repo: ctx.repo.name, pull_number: pullNumber, }); headSha = pr.data.head.sha; } catch (err) { log.debug(`status checks: failed to resolve PR #${pullNumber} head sha: ${err}`); return; } if (needsFallbackRunCheck) { await createTerminalRunStatusCheck({ octokit: ctx.octokit, owner: ctx.repo.owner, repo: ctx.repo.name, headSha: primaryRepoState(ctx.toolState).checkoutSha ?? headSha, conclusion, detailsUrl, reviewUrl: approval?.url, note, }) .then(() => log.info(`» posted ${RUN_STATUS_CHECK_NAME} check (${conclusion})`)) .catch((err) => log.debug(`status checks: ${RUN_STATUS_CHECK_NAME} post failed: ${err}`)); } // only assert an approval verdict when the run cleanly completed. the verdict is // recorded before create_pull_request_review actually submits, so on a failed/crashed // run the review may not have landed — leave pullfrog-approval absent (the next run // resolves it) rather than post a stale verdict. if (!needsApprovalCheck) return; const verdict = approval ? { wouldApprove: approval.wouldApprove, sha: approval.sha ?? headSha, carriedLine: "" } : await carriedApproval(ctx, pullNumber).catch((err) => { log.debug(`status checks: ${APPROVAL_CHECK_NAME} carry-forward failed: ${err}`); return undefined; }); if (!verdict) return; const createParams: RestEndpointMethodTypes["checks"]["create"]["parameters"] = { owner: ctx.repo.owner, repo: ctx.repo.name, name: APPROVAL_CHECK_NAME, head_sha: verdict.sha, status: "completed", conclusion: verdict.wouldApprove ? "success" : "failure", output: { title: verdict.wouldApprove ? "Pullfrog would approve" : "Pullfrog would not approve", summary: (verdict.wouldApprove ? "Pullfrog has no outstanding review feedback on this PR." : "Pullfrog has outstanding review feedback or requested changes on this PR.") + verdict.carriedLine, }, }; if (detailsUrl) createParams.details_url = detailsUrl; await ctx.octokit.rest.checks .create(createParams) .then(() => log.info(`» posted ${APPROVAL_CHECK_NAME} check`)) .catch((err) => log.debug(`status checks: ${APPROVAL_CHECK_NAME} post failed: ${err}`)); } /** * the verdict for a re-review that submitted no review. recording none left a PR requiring * `pullfrog-approval` blocked on "Expected — Waiting for status to be reported" after every * base-branch merge. the verdict on `beforeSha` carries to the checked-out commit, and never * one from further back, which would approve changes no run reviewed. a rejection always * carries. an approval carries only when the agent declared this commit needs no review * (`no_review_needed`) and no Pullfrog finding is open: a run blocked mid-review also ends * on `report_progress`, so ending there proves nothing. */ async function carriedApproval(ctx: ToolContext, pullNumber: number) { const primary = primaryRepoState(ctx.toolState); if (!primary.beforeSha || !primary.checkoutSha) return undefined; // never shadow a verdict a concurrent review already posted on this commit. if ((await latestApprovalConclusion(ctx, primary.checkoutSha)) !== undefined) return undefined; const prior = await latestApprovalConclusion(ctx, primary.beforeSha); if (prior === undefined) return undefined; const from = primary.beforeSha.slice(0, 7); if (prior !== "success") { const carriedLine = `\n\nNo review covered this commit, so the verdict on ${from} carries forward.`; return { wouldApprove: false, sha: primary.checkoutSha, carriedLine }; } if (ctx.toolState.noReviewNeededSha !== primary.checkoutSha) return undefined; return { wouldApprove: (await countOutstandingPullfrogThreads(ctx, pullNumber)) === 0, sha: primary.checkoutSha, carriedLine: `\n\nPullfrog found nothing to review in the commits since ${from}.`, }; } /** the conclusion of the newest `pullfrog-approval` on `ref`, if one was posted. */ async function latestApprovalConclusion(ctx: ToolContext, ref: string) { const result = await ctx.octokit.rest.checks.listForRef({ owner: ctx.repo.owner, repo: ctx.repo.name, ref, check_name: APPROVAL_CHECK_NAME, status: "completed", }); // a workflow job of the same name posts as GitHub Actions — never a verdict Pullfrog issued. const latest = result.data.check_runs .filter((run) => run.app?.slug !== GITHUB_ACTIONS_APP_SLUG) .sort((a, b) => (b.completed_at ?? "").localeCompare(a.completed_at ?? ""))[0]; return latest?.conclusion ?? undefined; }