Files
shockbot/utils/reviewCleanup.ts
T
Colin McDonnell e2e29a19fc accept pullfrog.yaml as well as pullfrog.yml (#596)
* accept pullfrog.yaml as well as pullfrog.yml

centralize the accepted workflow filenames in `utils/github/pullfrogWorkflow.ts`
(`PULLFROG_WORKFLOW_FILES = ["pullfrog.yml", "pullfrog.yaml"]`) and use the new
`findExistingWorkflowFile` helper at every read path: `getWorkflow` (cached),
the verify-workflow API route, and the audit/sync/download/update scripts. `.yml`
is always tried first so the common case still costs exactly one API call.

webhook handlers (push cache-bust, `workflow_run_*`) now use the shared
`isPullfrogWorkflowPath` matcher.

action runtime (`reviewCleanup.ts`) derives the running workflow's filename from
`process.env.GITHUB_WORKFLOW_REF` instead of hardcoding `.yml`, so the safety-net
follow-up dispatch targets whichever file the user actually has — strictly more
correct than today.

write paths (`createWorkflowForRepo`, `createWorkflowPR`) intentionally still
create `.yml`; existing 422 collision handling covers the rare double-install
case. UI/wiki/onboarding copy keeps saying `pullfrog.yml`; one callout in
`docs/getting-started.mdx` mentions `.yaml` works too.

also drops dead code (`utils/github/findWorkflow.ts`, parallel single-file
implementation with no importers) and the now-unused `WORKFLOW_FILENAME` export.

* rename pullfrogWorkflow.ts -> findPullfrogWorkflow.ts (verb form)

* add pre-flight check to workflow create paths

`createWorkflowForRepo` and `createWorkflowPR` now check for any existing
pullfrog workflow file (`.yml` or `.yaml`) before doing work, preventing the
degenerate state where a repo with `pullfrog.yaml` ends up with both files
dispatching on every event.

costs one `getContent` call per first-time install. existing 422 branch in
`createWorkflowForRepo` is retained as a race-condition safety net; the 409
branch now also handles the case where `createWorkflowPR` discovers an
existing file in flight.

`createWorkflowPR` return shape becomes a discriminated union; the standalone
`/api/create-workflow-pr` route returns `{ alreadyInstalled: true }` instead
of creating a redundant PR.

* promote repo to active when /api/create-workflow-pr finds existing workflow

extracts `promoteRepoToActive` from `createWorkflowForRepo`'s closure to a
shared module-level function, and wires it into the standalone PR route's
`alreadyInstalled` branch so a `needs_setup` repo with an existing `.yaml`
file doesn't go stale (was only handled by the dashboard's own create path).

addresses pullfrog review on #596.
2026-05-07 18:04:07 +00:00

107 lines
3.5 KiB
TypeScript

import type { WriteablePayload } from "../external.ts";
import { reportReviewNodeId } from "../mcp/review.ts";
import type { ToolContext } from "../mcp/server.ts";
import { log } from "./cli.ts";
const RE_REVIEW_PREAMBLE =
"Incrementally re-review the new commits on this pull request. Use the IncrementalReview mode.";
/**
* post-agent review lifecycle: runs after the agent exits (success or timeout).
*
* normally the agent handles new commits inline: create_pull_request_review
* detects HEAD movement and tells the agent to pull and review the delta.
* this dispatch is a safety net for cases where the agent couldn't handle
* it (timeout, error, etc).
*
* ordering matters: reportReviewNodeId marks this run "done" FIRST so push
* webhooks stop being suppressed by dedup. the HEAD check runs SECOND to
* catch any pushes that were suppressed while this run was in-flight.
*/
export async function postReviewCleanup(ctx: ToolContext): Promise<void> {
const review = ctx.toolState.review;
if (!review) return;
delete ctx.toolState.review;
// mark review as submitted — unlocks webhook dedup for new pushes
await bestEffort(() => reportReviewNodeId(ctx, { nodeId: review.nodeId }), "reportReviewNodeId");
// dispatch follow-up if PR HEAD moved past the reviewed commit
if (review.reviewedSha) {
await bestEffort(
() => dispatchFollowUpReReview(ctx, review.reviewedSha!),
"follow-up re-review dispatch"
);
}
}
async function bestEffort(fn: () => Promise<unknown>, label: string): Promise<void> {
try {
await fn();
} catch (error) {
log.debug(`${label} failed: ${error}`);
}
}
async function dispatchFollowUpReReview(ctx: ToolContext, reviewedSha: string): Promise<void> {
const issueNumber = ctx.payload.event.issue_number;
if (!issueNumber) return;
const pr = await ctx.octokit.rest.pulls.get({
owner: ctx.repo.owner,
repo: ctx.repo.name,
pull_number: issueNumber,
});
if (pr.data.head.sha === reviewedSha) return;
if (pr.data.state !== "open") return;
if (pr.data.draft) return;
log.info(
`safety net: pr HEAD moved from ${reviewedSha.slice(0, 7)} to ${pr.data.head.sha.slice(0, 7)} ` +
`and agent did not review inline — dispatching follow-up re-review`
);
const event: WriteablePayload["event"] = {
trigger: "pull_request_synchronize",
issue_number: issueNumber,
is_pr: true,
title: pr.data.title,
body: null,
branch: pr.data.head.ref,
before_sha: reviewedSha,
silent: true,
};
if (ctx.payload.event.authorPermission) {
event.authorPermission = ctx.payload.event.authorPermission;
}
const payload: WriteablePayload = {
"~pullfrog": true,
version: ctx.payload.version,
model: ctx.payload.model,
prompt: "",
eventInstructions: RE_REVIEW_PREAMBLE,
event,
};
await ctx.octokit.rest.actions.createWorkflowDispatch({
owner: ctx.repo.owner,
repo: ctx.repo.name,
workflow_id: getCurrentWorkflowFilename(),
ref: pr.data.base.repo.default_branch,
inputs: { prompt: JSON.stringify(payload) },
});
}
/**
* derive the running workflow's filename from `GITHUB_WORKFLOW_REF`, which has the form
* `<owner>/<repo>/.github/workflows/<filename>@<ref>` (e.g. `.../pullfrog.yaml@refs/heads/main`).
* falls back to `pullfrog.yml` if the env var is missing or malformed (shouldn't happen in CI).
*/
function getCurrentWorkflowFilename(): string {
const ref = process.env.GITHUB_WORKFLOW_REF ?? "";
const match = ref.match(/\/([^/]+)@/);
return match?.[1] ?? "pullfrog.yml";
}