utils/runStatusCheck.ts
Rama main · réplica de lectura del repositorio original.
/**
* Single source of truth for the `pullfrog` commit-status check-run — the row a PR's
* checks list shows while a run is in flight and after it ends.
*
* Pullfrog dispatches its workflow with `ref: <default branch>`, so the GitHub Actions
* run attaches its check suite to the default branch head and never to the PR head.
* Nothing about a run is therefore visible from the PR itself unless we post this
* check-run against the PR head sha ourselves.
*
* Created `in_progress` by the server at dispatch — before the runner boots, and
* upstream of every action-side failure that would otherwise leave the PR silent
* (billing 402, token resolution, bad model slug, missing provider key). Moved to a
* terminal conclusion by whichever of three actors reaches it first, all calling
* `finalizeRunStatusCheck`:
*
* 1. the action at run end (`reportStatusChecks`)
* 2. the `workflow_run.completed` webhook (covers cancel / SIGKILL / early throw)
* 3. the hourly stuck-run reaper (covers a dropped webhook)
*
* That three-layer close-out is what makes an `in_progress` check safe here: a check
* stranded `in_progress` on a required branch-protection rule would block merges
* forever, which is why this file did not exist before.
*/
/**
* the run-lifecycle check. verdict-agnostic — it reports that a run happened, not what it
* found.
*
* **DO NOT RENAME.** A check run has no separate display name, so this string is also the
* context branch protection matches on. Renaming it on 2026-08-02 stopped the old context
* reporting and blocked merges on four customer repos; it was reverted. Most installs'
* branch protection is unreadable to us, so the blast radius cannot even be measured.
* See wiki/review-approval.md.
*/
export const RUN_STATUS_CHECK_NAME = "pullfrog";
/**
* GitHub's own Actions app. It publishes a check run per job, named after the
* job — and the generated workflow's job is also called `pullfrog`, so the
* collision is structural rather than incidental. Renaming our check is not
* available: it was tried on 2026-08-02 and blocked merges on four customer
* repos (see above), so the reuse lookup filters instead. See #1196.
*/
export const GITHUB_ACTIONS_APP_SLUG = "github-actions";
/** the review-verdict check. opt-in, terminal-only, and deliberately separate from the above. */
export const APPROVAL_CHECK_NAME = "pullfrog-approval";
/**
* The terminal states we report: exactly GitHub's check-run `conclusion` enum.
*
* NOT `WorkflowRunStatus`. `stale` is a valid `workflow_run.conclusion` and is
* NOT a member of the Checks API enum, which GitHub's own 422 enumerates as
* `["success", "failure", "neutral", "cancelled", "timed_out",
* "action_required", "skipped"]`. Admitting it here let
* `checkConclusionFromStatus` type-check while producing a value the wire
* always rejects, so 100% of reaper-expired rows failed close-out — and since
* the failure left `checkRunId` set, the hourly sweep re-sent the same illegal
* conclusion forever. See [#1178](https://github.com/pullfrog/app/issues/1178).
*/
export type RunStatusCheckConclusion =
| "success"
| "failure"
| "cancelled"
| "timed_out"
| "action_required"
| "neutral"
| "skipped";
/**
* Parse the on-the-wire `{ id: string }` shape (the form carried in `JsonPayload`) into
* a check-run id. Mirrors `parseProgressComment` — returns undefined when the id isn't a
* positive integer so callers can short-circuit cleanly.
*/
export function parseCheckRunId(raw: { id: string } | null | undefined): number | undefined {
if (!raw?.id) return undefined;
const id = parseInt(raw.id, 10);
if (Number.isNaN(id) || id <= 0) return undefined;
return id;
}
// minimal structural Octokit shape, matching the convention in progressComment.ts: the
// action package is on @octokit/rest v22 and the root project on v21, so a nominal type
// would clash. only the two methods used here are listed.
interface CheckRunResponse {
data: { id: number };
}
type CheckRunStatus = "queued" | "in_progress" | "completed";
type CheckRunOutput = { title: string; summary: string };
// `details_url` is deliberately `?: string` and never `| undefined`: octokit types it
// that way and the action compiles with `exactOptionalPropertyTypes`, so it must be
// omitted rather than set to undefined. hence the conditional assignment below.
// a `type` (not `interface`) so it gains an implicit index signature and stays
// assignable to octokit's `RequestParameters & {...}` param.
type CreateCheckRunParams = {
owner: string;
repo: string;
name: string;
head_sha: string;
status?: CheckRunStatus;
conclusion?: RunStatusCheckConclusion;
details_url?: string;
output?: CheckRunOutput;
};
type UpdateCheckRunParams = {
owner: string;
repo: string;
check_run_id: number;
status?: CheckRunStatus;
conclusion?: RunStatusCheckConclusion;
details_url?: string;
output?: CheckRunOutput;
};
export interface RunStatusCheckOctokit {
rest: {
checks: {
create: (params: CreateCheckRunParams) => Promise<CheckRunResponse>;
update: (params: UpdateCheckRunParams) => Promise<CheckRunResponse>;
get: (params: {
owner: string;
repo: string;
check_run_id: number;
}) => Promise<{ data: { status: string } }>;
listForRef: (params: {
owner: string;
repo: string;
ref: string;
check_name?: string;
status?: "queued" | "in_progress" | "completed";
}) => Promise<{
data: { check_runs: { id: number; app?: { slug?: string | null } | null }[] };
}>;
};
};
}
const IN_PROGRESS_OUTPUT = {
title: "Pullfrog is running",
summary: "Pullfrog is working on this pull request. This check updates when the run finishes.",
};
/**
* Repo console deep-link, anchored at the Review PRs card that holds the toggle. Origin is
* hardcoded to match `billingErrors.ts`, the established pattern for console links from the
* action. Appended last to every non-success summary so a user whose merge is blocked by a
* check they never asked for can reach the off switch — but a one-off failure must not read
* as "the remedy is to turn this off", so the link names the exact toggle and never leads.
*/
function disableCheckLine(owner: string, repo: string): string {
const url = `https://pullfrog.com/console/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}#auto-review-prs`;
return `\n\nThis check reports run status only and gates nothing unless you required it in branch protection. [Turn off the run status check →](${url})`;
}
/**
* The logs link. GitHub renders `details_url` as "View more details on Pullfrog" whatever it
* points at, so a summary that says "see the run logs" without linking them leaves the user
* with no visible way to reach them (measured on a real failed check: the only link in the
* panel was the off switch). Every finalizer's `detailsUrl` is the Actions run or the
* pullfrog.com shortlink that redirects to it; undefined means no run ever existed.
*/
function logsLine(detailsUrl: string | undefined): string {
return detailsUrl ? `\n\n[View the run logs →](${detailsUrl})` : "";
}
const TERMINAL_OUTPUT: Record<RunStatusCheckConclusion, { title: string; summary: string }> = {
success: {
title: "Pullfrog run completed",
summary: "The Pullfrog run finished successfully.",
},
failure: {
title: "Pullfrog run failed",
summary: "The Pullfrog run failed.",
},
cancelled: {
title: "Pullfrog run cancelled",
summary: "The Pullfrog run was cancelled before it finished.",
},
timed_out: {
title: "Pullfrog run timed out",
summary: "The Pullfrog run exceeded its timeout.",
},
action_required: {
title: "Pullfrog run needs attention",
summary: "The Pullfrog run stopped and needs attention.",
},
neutral: {
title: "Pullfrog run finished",
summary: "The Pullfrog run finished without a pass or fail outcome.",
},
skipped: {
title: "Pullfrog run skipped",
summary: "This run was superseded by another Pullfrog run.",
},
};
/**
* Terminal output for a conclusion. A non-success links the run logs first — that is what a
* failed check is for — then the review, and only then the console deep-link, so the off
* switch is reachable from a check the user never asked for without being the headline.
*/
function terminalOutput(params: {
conclusion: RunStatusCheckConclusion;
owner: string;
repo: string;
detailsUrl: string | undefined;
reviewUrl: string | undefined;
note: string | undefined;
}): { title: string; summary: string } {
const base = TERMINAL_OUTPUT[params.conclusion];
// `details_url` can only carry one destination, so the rest go here — the summary is
// rendered markdown in the check's detail panel. (Check-run `actions` are NOT links:
// they are buttons that fire `check_run.requested_action`, so they cannot navigate.)
const review = params.reviewUrl
? `\n\n[View the review Pullfrog posted →](${params.reviewUrl})`
: "";
if (params.conclusion === "success") {
const note = params.note ? `\n\n${params.note}` : "";
return { title: base.title, summary: base.summary + review + note };
}
return {
title: base.title,
summary:
base.summary +
logsLine(params.detailsUrl) +
review +
disableCheckLine(params.owner, params.repo),
};
}
/**
* Post the `in_progress` check-run on `headSha` and return its id for the finalizers.
*
* Best-effort: a transient 5xx, a repo that revoked `checks: write`, or a PR whose head
* has already been deleted must never stop a run from dispatching. Returns undefined on
* failure, which every caller treats as "no check to finalize".
*/
export async function createRunStatusCheck(params: {
octokit: RunStatusCheckOctokit;
owner: string;
repo: string;
headSha: string;
detailsUrl: string | undefined;
}): Promise<number | undefined> {
// reuse an in-flight check on this sha rather than adding a second identical row.
// concurrent Pullfrog runs on one head are routine (an auto-review plus an @pullfrog
// mention, say), and "is Pullfrog running on this commit?" has one answer, not two.
// whichever run finishes first resolves it; the others re-PATCH the same conclusion.
const existing = await params.octokit.rest.checks
.listForRef({
owner: params.owner,
repo: params.repo,
ref: params.headSha,
check_name: RUN_STATUS_CHECK_NAME,
status: "in_progress",
})
.catch(() => undefined);
// ...but only a check we can actually PATCH. the generated workflow names its
// job `pullfrog`, so GitHub Actions publishes a check run with the SAME name
// for every Pullfrog run, owned by app 15368. adopting that one meant our own
// check was never created, `WorkflowRun.checkRunId` pointed at a foreign row,
// and every close-out path — action, webhook, both reaper sweeps — 403'd with
// `Invalid app_id 15368`. see #1196.
const reusable = existing?.data.check_runs.find(
(run) => run.app?.slug !== GITHUB_ACTIONS_APP_SLUG
);
if (reusable) return reusable.id;
const createParams: CreateCheckRunParams = {
owner: params.owner,
repo: params.repo,
name: RUN_STATUS_CHECK_NAME,
head_sha: params.headSha,
status: "in_progress",
output: IN_PROGRESS_OUTPUT,
};
if (params.detailsUrl) createParams.details_url = params.detailsUrl;
const created = await params.octokit.rest.checks.create(createParams);
return created.data.id;
}
/**
* Move an existing check-run to its terminal conclusion.
*
* A PATCH, not a second create: `POST /check-runs` with the same name + sha creates a
* SECOND row rather than replacing the first, so creating twice would show the PR two
* contradictory `pullfrog` rows. (That is a real bug today — `finalizeSuccessRun` posts
* success and a later throw posts failure, both as fresh rows.)
*/
export async function finalizeRunStatusCheck(params: {
octokit: RunStatusCheckOctokit;
owner: string;
repo: string;
checkRunId: number;
conclusion: RunStatusCheckConclusion;
detailsUrl: string | undefined;
reviewUrl?: string | undefined;
/** the run's own final report, shown on success when nothing else carries it. */
note?: string | undefined;
}): Promise<void> {
const updateParams: UpdateCheckRunParams = {
owner: params.owner,
repo: params.repo,
check_run_id: params.checkRunId,
status: "completed",
conclusion: params.conclusion,
output: terminalOutput({
conclusion: params.conclusion,
owner: params.owner,
repo: params.repo,
detailsUrl: params.detailsUrl,
reviewUrl: params.reviewUrl,
note: params.note,
}),
};
if (params.detailsUrl) updateParams.details_url = params.detailsUrl;
await params.octokit.rest.checks.update(updateParams);
}
/**
* Post an already-terminal check-run in one call.
*
* The fallback for runs that never got a server-created check to update: a rolling
* deploy where the dispatch payload predates `checkRun`, or a workflow invoked outside
* Pullfrog's own dispatch path (`prompt_file`, a hand-written step).
*/
export async function createTerminalRunStatusCheck(params: {
octokit: RunStatusCheckOctokit;
owner: string;
repo: string;
headSha: string;
conclusion: RunStatusCheckConclusion;
detailsUrl: string | undefined;
reviewUrl?: string | undefined;
note?: string | undefined;
}): Promise<void> {
const createParams: CreateCheckRunParams = {
owner: params.owner,
repo: params.repo,
name: RUN_STATUS_CHECK_NAME,
head_sha: params.headSha,
status: "completed",
conclusion: params.conclusion,
output: terminalOutput({
conclusion: params.conclusion,
owner: params.owner,
repo: params.repo,
detailsUrl: params.detailsUrl,
reviewUrl: params.reviewUrl,
note: params.note,
}),
};
if (params.detailsUrl) createParams.details_url = params.detailsUrl;
await params.octokit.rest.checks.create(createParams);
}
/**
* Whether this check still needs finalizing.
*
* The server-side close-outs are BACKSTOPS: they exist for runs the action could not
* finalize itself. Firing them unconditionally is actively harmful, because the action
* writes a richer summary than they can — it knows the review URL, they do not — so a
* blind re-PATCH silently strips that link seconds after it appears. It also resets
* `completed_at`, discarding the duration GitHub renders as "Successful in 2m".
*
* Returns false when GitHub cannot be reached: a backstop that cannot confirm the check
* is unfinished must not overwrite a good one.
*/
export async function runStatusCheckNeedsFinalizing(params: {
octokit: RunStatusCheckOctokit;
owner: string;
repo: string;
checkRunId: number;
}): Promise<boolean> {
try {
const existing = await params.octokit.rest.checks.get({
owner: params.owner,
repo: params.repo,
check_run_id: params.checkRunId,
});
return existing.data.status !== "completed";
} catch {
return false;
}
}