f3d18401ac
* eager signup credit + free-OpenCode fallback when BYOK has no key
addresses the silent-churn pattern that took out 15 first-run-failure
accounts post-launch: GH Actions secret references resolved to empty
strings (because the secrets didn't exist on the repo), the action
launched Claude Code with no key, the LLM provider 401'd, and the run
died in seconds with a synthetic "Invalid API key" message. those
accounts had no Router credits to fall back to because the lazy claim
required a dashboard visit they never made.
three changes, one PR:
1. Eager $10 signup credit at account creation. Both account-creation
sites (`upsertAccountByClerkId` for dashboard signin, `fetchOrCreateRepo`
for CLI / GH-App-only) now insert the `CreditGrant { reason: "signup" }`
in the same transaction as the `accounts` row. CLI installers who
never sign in get the credit. The dashboard `/signup-credit/claim`
POST stays as an idempotent backstop for accounts created before
this shipped.
2. Free-OpenCode fallback in the action. When the configured BYOK slug
needs a provider key the runner doesn't have, swap to
`opencode/minimax-m2.5-free` before agent selection so the run still
succeeds. Surfaced via a `» fell back from <slug> to <free>` warning
in the action log. Skipped on Router runs (Pullfrog mints the key)
and when no model is configured (auto-select-with-throw still fires
for the genuinely-misconfigured case).
3. New action-test fixture `byok-no-keys-fallback` that empty-strings
every known provider key (matching how GH Actions handles missing
secrets) and asserts the run succeeds with the fallback log line
present. plus a unit test for the helper covering each skip case.
skipping the schema flip from `byok` to `router` — that's coming via
the onboarding-stepper PR (#762).
* fallback: skip Bedrock + surface in PR-comment footer
addresses copilot review on #789 (real bug — parseModel throws on
Bedrock raw IDs that have no slash, would crash before
validateBedrockSetup could surface its own error) and the user-side
ask to make the fallback visible in PR comments.
- selectFallbackModelIfNeeded skips when resolvedModel has no '/' so
Bedrock routing IDs (e.g. us.anthropic.claude-opus-4-7) don't crash
inside hasProviderKey -> parseModel. unit test covers it.
- toolState.modelFallback records the configured slug we fell back
from. set in main.ts when fallback engages.
- buildPullfrogFooter accepts fallbackFrom and renders
"Using `MiniMax M2.5` (free) (credentials for Claude Opus not
configured)" so the substitution is visible in PR comments,
reviews, PR bodies, and error reports.
- threaded through all four action-side footer call sites
(mcp/comment, mcp/pr, mcp/review, utils/errorReport). server-side
call sites in triggerWorkflow.ts / handleWorkflowRunWebhook.ts
fire pre-action and don't have toolState — left as-is.
* fallback footer: use provider display name + document email asymmetry
addresses pullfrog reviewer findings on #789:
- footer now shows 'credentials for Anthropic not configured' (provider
display name from `providers.anthropic.displayName`) instead of the
per-model name. credentials are provider-scoped (ANTHROPIC_API_KEY
covers all Anthropic models), so this matches what the user actually
needs to fix.
- document the intentional asymmetry between eager and lazy signup
credit paths: eager skips both the signupCreditClaimedEmail and the
per-grant team@ alert. comment explains why (the 'new account
created' alert already covers it on the eager path; the user-facing
email assumes a user-initiated action that hasn't happened yet for
CLI/GH-App-only signups).
- skipping the backfill for the 15 historical accounts per user's
earlier decision — they all uninstalled, so the cohort self-selected
out of being reachable.
* fallback: gate on resolvedModel + skip resolveModel re-resolve post-swap
local agnostic fixture run surfaced two real bugs the unit tests didn't
catch:
1. fallback gate was on configuredSlug (=payload.model) but the test
uses PULLFROG_MODEL to set the model, which is read by resolveModel
AFTER its slug arg. configuredSlug stayed undefined → fallback never
fired. drop configuredSlug from the helper signature; gate purely on
resolvedModel since that's the same value regardless of how the
model was specified (DB config vs PULLFROG_MODEL env).
2. when fallback engaged, the post-swap resolveModel({slug: fallback.to})
call was ALSO honoring PULLFROG_MODEL, re-overriding the fallback
target back to the unkeyed model. validateAgentApiKey then threw
"no API key found" against the original model. fix: skip the
re-resolve. fallback.to is already a CLI-ready specifier.
unit tests updated for the new helper signature (8 tests, all pass).
fallback log line confirmed emitted in the local run pre-second-fix;
the second fix unblocks the validation that previously threw.
121 lines
4.1 KiB
TypeScript
121 lines
4.1 KiB
TypeScript
import { type } from "arktype";
|
|
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
|
|
import { log } from "../utils/cli.ts";
|
|
import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts";
|
|
import { patchWorkflowRunFields } from "../utils/patchWorkflowRunFields.ts";
|
|
import { $ } from "../utils/shell.ts";
|
|
import type { ToolContext } from "./server.ts";
|
|
import { execute, tool } from "./shared.ts";
|
|
|
|
export const PullRequest = type({
|
|
title: type.string.describe("the title of the pull request"),
|
|
body: type.string.describe("the body content of the pull request"),
|
|
base: type.string.describe("the base branch to merge into (e.g., 'main')"),
|
|
"draft?": type.boolean.describe(
|
|
"if true, create the pull request as a draft. use when the user explicitly asks for a draft PR."
|
|
),
|
|
});
|
|
|
|
function buildPrBodyWithFooter(ctx: ToolContext, body: string): string {
|
|
const footer = buildPullfrogFooter({
|
|
triggeredBy: true,
|
|
workflowRun: ctx.runId
|
|
? { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId }
|
|
: undefined,
|
|
model: ctx.toolState.model,
|
|
fallbackFrom: ctx.toolState.modelFallback?.from,
|
|
});
|
|
|
|
const bodyWithoutFooter = stripExistingFooter(fixDoubleEscapedString(body));
|
|
return `${bodyWithoutFooter}${footer}`;
|
|
}
|
|
|
|
export const UpdatePullRequestBody = type({
|
|
pull_number: type.number.describe("the pull request number to update"),
|
|
body: type.string.describe("the new body content for the pull request"),
|
|
});
|
|
|
|
export function UpdatePullRequestBodyTool(ctx: ToolContext) {
|
|
return tool({
|
|
name: "update_pull_request_body",
|
|
description: "Update the body/description of an existing pull request",
|
|
parameters: UpdatePullRequestBody,
|
|
execute: execute(async (params) => {
|
|
const bodyWithFooter = buildPrBodyWithFooter(ctx, params.body);
|
|
|
|
const result = await ctx.octokit.rest.pulls.update({
|
|
owner: ctx.repo.owner,
|
|
repo: ctx.repo.name,
|
|
pull_number: params.pull_number,
|
|
body: bodyWithFooter,
|
|
});
|
|
log.info(`» updated pull request #${result.data.number}`);
|
|
|
|
ctx.toolState.wasUpdated = true;
|
|
|
|
return {
|
|
success: true,
|
|
number: result.data.number,
|
|
url: result.data.html_url,
|
|
};
|
|
}),
|
|
});
|
|
}
|
|
|
|
export function CreatePullRequestTool(ctx: ToolContext) {
|
|
return tool({
|
|
name: "create_pull_request",
|
|
description: "Create a pull request from the current branch",
|
|
parameters: PullRequest,
|
|
execute: execute(async (params) => {
|
|
const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
|
|
log.debug(`Current branch: ${currentBranch}`);
|
|
|
|
const bodyWithFooter = buildPrBodyWithFooter(ctx, params.body);
|
|
|
|
const result = await ctx.octokit.rest.pulls.create({
|
|
owner: ctx.repo.owner,
|
|
repo: ctx.repo.name,
|
|
title: params.title,
|
|
body: bodyWithFooter,
|
|
head: currentBranch,
|
|
base: params.base,
|
|
draft: params.draft ?? false,
|
|
});
|
|
log.info(`» created pull request #${result.data.number} (id ${result.data.id})`);
|
|
|
|
// best-effort: request review from the user who triggered the workflow
|
|
const reviewer = ctx.payload.triggerer;
|
|
if (reviewer) {
|
|
try {
|
|
log.debug(`requesting review from ${reviewer} on PR #${result.data.number}`);
|
|
await ctx.octokit.rest.pulls.requestReviewers({
|
|
owner: ctx.repo.owner,
|
|
repo: ctx.repo.name,
|
|
pull_number: result.data.number,
|
|
reviewers: [reviewer],
|
|
});
|
|
} catch {
|
|
log.info(`failed to request review from ${reviewer} on PR #${result.data.number}`);
|
|
}
|
|
}
|
|
|
|
if (typeof result.data.node_id === "string" && result.data.node_id.length > 0) {
|
|
await patchWorkflowRunFields(ctx, {
|
|
prNodeId: result.data.node_id,
|
|
});
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
pullRequestId: result.data.id,
|
|
number: result.data.number,
|
|
url: result.data.html_url,
|
|
title: result.data.title,
|
|
head: result.data.head.ref,
|
|
base: result.data.base.ref,
|
|
};
|
|
}),
|
|
});
|
|
}
|