Compare commits

...

15 Commits

Author SHA1 Message Date
Colin McDonnell e6d34ee01b add OpenRouter proxy for managed model routing and OSS program (#488)
* add OpenRouter proxy for managed model routing and OSS program

proxy layer that mints ephemeral OpenRouter keys for users without BYOK
API keys. two paths: pro plan users get their selected model proxied via
OpenRouter; OSS program repos (hard-coded allowlist in config.ts) get
free Claude Opus. BYOK env vars (PULLFROG_MODEL/OPENCODE_MODEL) always
take precedence.

frontend: OSS repos see a static "Opus (Free)" badge with the model
dropdown disabled and no API key requirement. all models now carry
openRouterResolve metadata for proxy target resolution.

Made-with: Cursor

* implement OSS program: proxy infrastructure for free model credits

server-side OSS allowlist determines eligible repos. action mints
ephemeral OpenRouter keys via OIDC-authenticated /api/proxy-token
endpoint (idempotent on runId, $10 per-key safety limit). keys are
disabled on workflow completion when no running refs remain. HWM-based
usage sync tracks cumulative spend per account.

schema: ProxyKey model, Account.usageUsd/activeKeyId, WorkflowRun.proxyKeyId
action: OIDC credential stashing, resolveProxyModel uses server oss flag
frontend: isOss flows from server page to components (no client allowlist)
Made-with: Cursor

* address PR review: repo cross-check, key retirement lifecycle, dead code removal

- proxy-token: verify runId belongs to OIDC-authenticated repo via repo relation
- add retireKey() shared primitive: disable in OpenRouter first, then mark disabled in DB
- rotateKey: retire old active key after swap to prevent orphans
- webhook: replace inline cleanupProxyKey with retireKey calls
- syncAccountUsage: skip disabled keys
- remove vestigial AccountPlan/plan field from action types
- add disabled field to ProxyKey schema + migration

Made-with: Cursor

* replace deprecated opencode/mimo-v2-flash-free with mimo-v2-pro-free

Made-with: Cursor

* fix migration ordering: rename disabled migration to sort after table creation

Made-with: Cursor

* squash proxy key migrations into single migration

Made-with: Cursor

* add preview repo to OSS allowlist for testing

Made-with: Cursor

* populate OSS allowlist from oss-program-invitees.json

Made-with: Cursor

* format oss-program-invitees.json

Made-with: Cursor

* add installed public repos to OSS allowlist

split ossRepos into three provenance-tracked lists:
- internalRepos (pullfrog, colinhacks, RobinTail)
- installedPublicRepos (external public non-fork repos with active installs)
- invitees (from oss-program-invitees.json)

also adds scripts/list-oss-candidates.ts to regenerate the installed list

Made-with: Cursor

* fix: resolve tokens before clearing OIDC env vars

resolveTokens → acquireNewToken → isOIDCAvailable() checks
ACTIONS_ID_TOKEN_REQUEST_URL/TOKEN env vars. The new OIDC
stashing code was deleting them in restricted shell mode before
resolveTokens ran, causing it to fall through to the GitHub App
path which requires GITHUB_APP_ID/GITHUB_PRIVATE_KEY.

Made-with: Cursor

* derive proxy-token auth from OIDC claims, add ensureWorkflowRun upsert

- proxy-token no longer requires body.runId; uses claims.run_id + claims.repository
- shared ensureWorkflowRun upsert called from both webhook and proxy-token
- workflow_run_requested handler now eagerly creates WorkflowRun records
- eliminates race condition between webhook and action proxy-token call

Made-with: Cursor

* hardcode PULLFROG_ACCOUNT_ID, document preview debugging lessons

GitHub node IDs are constant — no reason for this to be an env var.
Removes the trailing-newline bug that caused P2025 errors.
Adds wiki docs on workflow testing, Vercel env gotchas, and Neon
preview branch discovery.

Made-with: Cursor

* fix: parse OpenRouter create-key response correctly

the API returns `key` at the top level, not inside `data`

Made-with: Cursor

* onboarding cards, unlock OSS model selection, simplify console

- add OnboardingCard component with two states: workflow install
  and model+test (dispatches "Tell me a joke" for test run)
- replace PromptBox overlay gates with dedicated onboarding cards;
  PromptBox is now just the form, always enabled
- use hasWorkflowRuns DB check to decide onboarding vs promptbox
- unlock ModelSelector for OSS repos (was locked to Opus badge);
  resolve proxyModel from repo's selected model alias in run-context
- rename "API key" row to "Model costs" with pure client-side states:
  OSS covered, auto-resolve, free model, BYOK with env var names
- add "(Recommended)" badge to model aliases with recommended: true
- remove OSS_MODEL_DISPLAY_NAME constant and secrets-fetching logic

Made-with: Cursor

* update stale xai model snapshot

Made-with: Cursor

* rename Permissions to Security, hide git push toggle, add shell isolation toggle with disabled state for public repos, remove opentoad agent name from logs

Made-with: Cursor

* chevron hover states, sidebar hooks/security entries

Made-with: Cursor

* address PR review feedback: rename recommended→preferred, fix dispatch orphan, update wiki docs

- rename `recommended` to `preferred` in model alias registry to distinguish
  from the UI "Recommended" badge (which is hardcoded for opus + codex only)
- cancel precreated WorkflowRun when workflow lookup fails in dispatch-workflow
- replace run_sql/vercel env pull in wiki docs with neonctl + Prisma pattern
- extend scripts/neon-branch.ts to output DATABASE_URL via neonctl
- add Recommended badge to GPT Codex alongside Claude Opus in ModelSelector

Made-with: Cursor
2026-03-25 17:19:55 +00:00
Colin McDonnell 39525547b5 add in-memory trigger dedup with Zod-validated search params (#496)
* add in-memory trigger dedup with Zod-validated search params

replace scattered manual validation (isValidAction, required review_id/comment_id checks, silent action default) with a discriminated union Zod schema. dedup double-clicks via a module-level Map with 30s TTL — no migration, no new table.

Made-with: Cursor

* update model snapshot (xai latest → grok-4.20-multi-agent-0309)

Made-with: Cursor
2026-03-24 18:57:42 +00:00
Colin McDonnell 3ff11f97eb replace deprecated opencode/mimo-v2-flash-free with mimo-v2-pro-free, update model snapshot
Made-with: Cursor
2026-03-21 16:31:45 +00:00
Colin McDonnell b31800c213 clarify PR summary instructions for readable section titles
Made-with: Cursor
2026-03-20 16:17:59 +00:00
Colin McDonnell 3a1ffde545 update model snapshot (opencode latest → gpt-5.4-nano)
Made-with: Cursor
2026-03-18 19:13:04 +00:00
Mateusz Burzyński cccf1775d6 Update actions/setup-node 2026-03-18 12:55:52 +00:00
Colin McDonnell 026cc7a276 skip empty review submissions instead of posting noise
when create_pull_request_review is called with no body and no inline
comments, return early with a clear log message instead of hitting
GitHub's 422 or posting a useless "approved" comment.

Made-with: Cursor
2026-03-17 20:09:33 +00:00
Colin McDonnell c6a3ee0e9a show model name in footer, drop pullfrog.com link (#484)
* show model name in footer, drop pullfrog.com link

add model slug to buildPullfrogFooter so every Pullfrog comment
displays the active model (e.g. "Using `Big Pickle` (free)" or
"Using `Claude Opus`"). remove the pullfrog.com link from all footers.

Made-with: Cursor

* reject <br/> tags in comment bodies, add prompt guidance

add runtime validation in addFooter that throws if <br/> is followed
by a non-blank line (breaks GitHub heading rendering). the agent sees
the error and retries with clean markdown. also update Summarize mode
prompt to explicitly forbid <br/> tags.

Made-with: Cursor

* fix <br/> guidance: move to event instructions, clarify blank line rule

the formatting rule belongs in DEFAULT_PR_SUMMARY_INSTRUCTIONS (event
instructions), not the Summarize mode prompt. clarify that <br/> must
always be followed by a blank line before headings.

Made-with: Cursor

* generalize block-level HTML rule in summary instructions

add a prominent top-level rule about requiring blank lines between ALL
block-level HTML elements and markdown syntax, not just <br/>.

Made-with: Cursor

* move model to toolState instead of threading through params

model is set once at startup and read everywhere — it belongs on
toolState, not threaded as a separate param through 8 call sites.
postCleanup runs without toolState so it just omits the model label.

Made-with: Cursor

* update models.dev snapshot (openai latest changed)

Made-with: Cursor

* add comment to models snapshot test explaining its purpose

Made-with: Cursor
2026-03-17 19:59:11 +00:00
Colin McDonnell 30d68e53a7 fix: skip API key validation for free opencode models
free models (big-pickle, gpt-5-nano, etc.) define envVars: [] and
isFree: true but validateAgentApiKey always required at least one
provider key. now the validation is model-aware: free models bypass
the check, keyed models validate their specific vars, and auto-select
still requires at least one known key.

closes #483

Made-with: Cursor
2026-03-16 20:48:59 +00:00
Colin McDonnell 8a734c32f4 fix workflow detection, duplicate summaries, review resilience (#482)
* fix workflow detection when repos have many workflows

Switch workflow lookup to GitHub's direct workflow-by-filename API so pullfrog.yml is found even when list endpoints paginate, and paginate installation scans in maintenance scripts to avoid partial coverage.

Made-with: Cursor

* fix review comment line resolution: pre-validate against diff hunks + auto-bisect fallback

when submitting a review with inline comments, the tool now:
1. fetches the PR diff and validates each comment's line range against the actual hunk boundaries
2. moves invalid comments to the review body with a clear explanation
3. on 422 from GitHub (rare API quirks where valid-looking lines are rejected), bisects
   comments using disposable pending reviews to isolate failures
4. retries with only the comments GitHub accepts

also fixes getHttpStatus (previously isStatusError) which wasn't recognizing Octokit errors,
and warns in the tool description that each call creates a permanent visible review.

Made-with: Cursor

* remove bisect fallback, anchor review to checkout sha, make start_line optional

- drop the auto-bisect-on-422 logic entirely; pre-validation catches the
  real issues and the 422 catch now just throws a clear actionable error
- anchor review submission to checkoutSha so line numbers match the diff
  the agent actually analyzed (avoids stale-line 422s from new pushes)
- make start_line optional and only set start_line/start_side when it
  differs from line (single-line comments don't need the range fields)
- improve headMovedDuringReview detection to use latestHeadSha directly

Made-with: Cursor

* drop review comment pre-validation in favor of pinned commit_id

The pre-validation (listFiles + hunk parsing) was checking comments
against the current PR diff, but the review is now anchored to
checkoutSha. When HEAD moves, pre-validation checks the wrong diff
and can false-reject valid comments. GitHub's own commit_id-anchored
validation is the correct source of truth.

Made-with: Cursor

* add logging to fetchExistingSummaryComment for duplicate summary debug

Made-with: Cursor

* fix duplicate summary comments: guard create_issue_comment for existing summaries

When select_mode finds an existing summary comment (existingSummaryCommentId),
create_issue_comment with type: "Summary" now auto-redirects to update instead
of creating a new comment. Belt-and-suspenders for the token fix in selectMode.ts.

Made-with: Cursor

* document api auth patterns to prevent token misuse

add wiki/api-auth.md explaining the two auth patterns (GitHub token vs
Pullfrog JWT) and when to use each. add auth comments to all action-facing
routes and their callers so the correct token is obvious.

Made-with: Cursor

* fix models.dev snapshot: filter beta models, add tiebreaker

Skip models with any status (beta, deprecated) so nightly/experimental
releases don't cause snapshot churn. Add lexicographic tiebreaker for
stable ordering when release dates match.

Made-with: Cursor

* add tests to pre-push hook

Made-with: Cursor

* fix: decouple summary dispatch from re-review gate on pull_request_synchronize

The summary workflow was never dispatched on new commits because the
pull_request_synchronize handler broke early when prReReview was disabled,
before reaching the prSummaryComment check. Now re-review and summary
are dispatched independently.

Made-with: Cursor

* resolve merge conflicts in rebase.md and checkout.ts

Made-with: Cursor

* fix: restore checkout.ts and rebase.md from remote

Made-with: Cursor
2026-03-16 18:12:11 +00:00
Anna Bocharova 2e37fb3dfa fix(test): Updating snapshot. (#480) 2026-03-13 05:57:45 +00:00
Colin McDonnell cbbcb64859 restructure docs: split triggers into usage pages, add model resolution docs
- split triggers.mdx into direct-prompting, pr-reviews, issue-enrichment, coding-tasks
- rename manual-setup.mdx to headless-action.mdx (CI integration)
- reorganize sidebar into Getting started / Usage / Reference groups
- add redirects for /triggers and /manual-setup
- add PULLFROG_MODEL env var support across action, workflows, and docs
- rewrite models.mdx with aliases, free models, resolution chain, routers
- update all cross-references in app, components, and docs

Made-with: Cursor
2026-03-12 17:45:15 +00:00
Colin McDonnell df9598ea5f add free opencode model metadata and improve model picker UX
Made-with: Cursor
2026-03-12 16:32:30 +00:00
Colin McDonnell 250fe7eaa1 fix test token scoping: override GITHUB_TOKEN via OIDC in ensureGitHubToken
the runner's GITHUB_TOKEN (scoped to pullfrog/app) was leaking into
test subprocesses targeting pullfrog/test-repo, causing 400s from the
Pullfrog API on run-context fetches.

instead of deleting GITHUB_TOKEN from the subprocess env,
ensureGitHubToken now always mints a fresh OIDC token scoped to
GITHUB_REPOSITORY when OIDC is available — replacing any inherited
token with a correctly-scoped one.

also adds an informative throw in acquireTokenViaGitHubApp when
GITHUB_APP_ID/GITHUB_PRIVATE_KEY are missing.

Made-with: Cursor
2026-03-12 06:15:01 +00:00
Colin McDonnell 4a8c432a48 add Summarize mode for updatable PR summary comments (#470)
* add Summarize mode for updatable PR summary comments

Introduces a Summarize mode that manages a single summary comment per PR,
updated in place on subsequent pushes. Mirrors the Plan/PlanEdit pattern:
API endpoint for existing-comment lookup at select_mode time, node ID
tracking on WorkflowRun, and SummaryUpdate guidance for edits.

Also fixes summary format instructions: Before/After uses inline <br/>
to avoid double line breaks, metadata line placed after key changes,
SHA-256 anchor instructions strengthened against fabrication.

Made-with: Cursor

* fix pre-existing lint error in checkout.ts

Made-with: Cursor

* fix dead restricted param in deepenForBeforeSha

GitAuthOptions dropped the restricted field in the ASKPASS refactor (#478)
but deepenForBeforeSha (#471) still passed it. Remove the field and the
now-unused shell param from DeepenForBeforeShaParams.

Made-with: Cursor
2026-03-12 05:32:17 +00:00
36 changed files with 1707 additions and 309 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: "24"
cache: "pnpm"
+4 -4
View File
@@ -9,7 +9,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: "pnpm"
@@ -41,11 +41,11 @@ jobs:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENCODE_MODEL: ${{ vars.OPENCODE_MODEL }}
PULLFROG_MODEL: ${{ vars.PULLFROG_MODEL }}
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: "pnpm"
@@ -79,7 +79,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: "pnpm"
+13 -11
View File
@@ -75,9 +75,9 @@ function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): s
// ── model resolution (see wiki/model-resolution.md) ─────────────────────────────
//
// priority:
// 1. OPENCODE_MODEL env var (explicit override)
// 1. PULLFROG_MODEL env var (explicit override)
// 2. explicit slug from repo config / payload
// 3. auto-select: `opencode models` → recommended aliases first, then secondary
// 3. auto-select: `opencode models` → preferred aliases first, then secondary
// 4. undefined → let OpenCode decide
function getOpenCodeModels(cliPath: string): string[] {
@@ -107,9 +107,9 @@ function resolveOpenCodeModel(ctx: {
modelSlug?: string | undefined;
}): string | undefined {
// 1. explicit env var override
const envModel = process.env.OPENCODE_MODEL?.trim();
const envModel = process.env.PULLFROG_MODEL?.trim();
if (envModel) {
log.info(`» model: ${envModel} (override via OPENCODE_MODEL)`);
log.info(`» model: ${envModel} (override via PULLFROG_MODEL)`);
return envModel;
}
@@ -125,17 +125,17 @@ function resolveOpenCodeModel(ctx: {
// 3. auto-select: ask OpenCode what's available, pick our best curated match.
// `opencode models` returns `provider/model-id` specifiers matching our resolve values exactly.
// two-pass: recommended (top-tier per provider) first, then secondary models.
// two-pass: preferred (top-tier per provider) first, then secondary models.
const availableModels = getOpenCodeModels(ctx.cliPath);
const availableSet = new Set(availableModels);
if (availableSet.size > 0) {
log.debug(`» opencode models (${availableSet.size}): ${availableModels.join(", ")}`);
const match =
modelAliases.find((a) => a.recommended && availableSet.has(a.resolve)) ??
modelAliases.find((a) => a.preferred && availableSet.has(a.resolve)) ??
modelAliases.find((a) => availableSet.has(a.resolve));
if (match) {
log.info(
`» model: ${match.resolve} (auto-selected${match.recommended ? " — recommended" : ""} curated match)`
`» model: ${match.resolve} (auto-selected${match.preferred ? " — preferred" : ""} curated match)`
);
log.warning(`» model auto-selected. ${AUTO_SELECT_WARNING}`);
return match.resolve;
@@ -621,10 +621,12 @@ export const opentoad = agent({
run: async (ctx) => {
const cliPath = await installOpencodeCli();
const model = resolveOpenCodeModel({
cliPath,
modelSlug: ctx.payload.model,
});
const model =
ctx.payload.proxyModel ??
resolveOpenCodeModel({
cliPath,
modelSlug: ctx.payload.model,
});
const tempHome = ctx.tmpdir;
mkdirSync(join(tempHome, ".config", "opencode"), { recursive: true });
-1
View File
@@ -45,7 +45,6 @@ export const agent = (input: Agent): Agent => {
return {
...input,
run: async (ctx: AgentRunContext): Promise<AgentResult> => {
log.info(`» agent: ${input.name}`);
if (ctx.payload.model) log.info(`» model: ${ctx.payload.model}`);
if (ctx.payload.timeout) log.info(`» timeout: ${ctx.payload.timeout}`);
log.info(`» push: ${ctx.payload.push}`);
+1
View File
@@ -0,0 +1 @@
// action-level constants shared across the action runtime
+540 -128
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -12,6 +12,7 @@ export type { ModelAlias, ModelProvider, ProviderConfig } from "./models.ts";
export {
getModelEnvVars,
getModelProvider,
getProviderDisplayName,
modelAliases,
parseModel,
providers,
+6 -1
View File
@@ -25898,10 +25898,15 @@ var findInstallationId = async (jwt, repoOwner, repoName) => {
);
};
async function acquireTokenViaGitHubApp(opts) {
if (!process.env.GITHUB_APP_ID || !process.env.GITHUB_PRIVATE_KEY) {
throw new Error(
"cannot acquire token via GitHub App: GITHUB_APP_ID and GITHUB_PRIVATE_KEY must be set"
);
}
const repoContext = parseRepoContext();
const config = {
appId: process.env.GITHUB_APP_ID,
privateKey: process.env.GITHUB_PRIVATE_KEY?.replace(/\\n/g, "\n"),
privateKey: process.env.GITHUB_PRIVATE_KEY.replace(/\\n/g, "\n"),
repoOwner: repoContext.owner,
repoName: repoContext.name
};
+1
View File
@@ -18,6 +18,7 @@ export type {
export {
getModelEnvVars,
getModelProvider,
getProviderDisplayName,
ghPullfrogMcpName,
modelAliases,
parseModel,
+87 -3
View File
@@ -15,6 +15,7 @@ import {
DEFAULT_ACTIVITY_TIMEOUT_MS,
} from "./utils/activity.ts";
import { resolveAgent } from "./utils/agent.ts";
import { apiFetch } from "./utils/apiFetch.ts";
import { validateAgentApiKey } from "./utils/apiKeys.ts";
import { resolveBody } from "./utils/body.ts";
import { formatUsageSummary, log, writeSummary } from "./utils/cli.ts";
@@ -63,6 +64,71 @@ function resolveOutputSchema(): Record<string, unknown> | undefined {
return parsed as Record<string, unknown>;
}
import type { ResolvedPayload } from "./utils/payload.ts";
interface OidcCredentials {
requestUrl: string;
requestToken: string;
}
async function mintProxyKey(ctx: { oidcCredentials: OidcCredentials }): Promise<string | null> {
try {
process.env.ACTIONS_ID_TOKEN_REQUEST_URL = ctx.oidcCredentials.requestUrl;
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = ctx.oidcCredentials.requestToken;
const oidcToken = await core.getIDToken("pullfrog-api");
delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
const response = await apiFetch({
path: "/api/proxy-token",
method: "POST",
headers: { Authorization: `Bearer ${oidcToken}` },
});
if (!response.ok) {
log.warning(`proxy key mint failed (${response.status})`);
return null;
}
const data = (await response.json()) as { key: string };
return data.key;
} catch (error) {
log.warning(`proxy key mint error: ${error instanceof Error ? error.message : String(error)}`);
return null;
} finally {
delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
}
}
async function resolveProxyModel(ctx: {
payload: ResolvedPayload;
oss: boolean;
proxyModel?: string | undefined;
oidcCredentials: OidcCredentials | null;
}): Promise<void> {
// env override = BYOK escape hatch, don't proxy
if (process.env.PULLFROG_MODEL?.trim()) return;
// OSS: server decided the model
if (ctx.oss && ctx.proxyModel) {
if (!ctx.oidcCredentials) {
log.warning("» oss repo but no OIDC credentials available — skipping proxy");
return;
}
const key = await mintProxyKey({ oidcCredentials: ctx.oidcCredentials });
if (!key) return;
process.env.OPENROUTER_API_KEY = key;
core.setSecret(key);
ctx.payload.proxyModel = ctx.proxyModel;
log.info(`» proxy: oss → ${ctx.proxyModel}`);
return;
}
// managed billing will add its path here later
}
async function writeJobSummary(toolState: ToolState): Promise<void> {
const usageSummary = formatUsageSummary(toolState.usageEntries);
const summaryParts = [toolState.lastProgressBody, usageSummary].filter(Boolean);
@@ -103,18 +169,35 @@ export async function main(): Promise<MainResult> {
// resolve payload to determine shell permission
const payload = resolvePayload(resolvedPromptInput, runContext.repoSettings);
toolState.model = payload.model;
// resolve tokens:
// - gitToken: contents permission based on push setting (assumed exfiltratable)
// - mcpToken: full installation token (not exfiltratable via MCP tools)
// resolve tokens first — acquireNewToken needs OIDC env vars for token exchange
await using tokenRef = await resolveTokens({ push: payload.push });
// stash OIDC credentials in memory before wiping from process.env
// the agent's shell commands can't access JS variables, so this is safe
const oidcCredentials: OidcCredentials | null =
process.env.ACTIONS_ID_TOKEN_REQUEST_URL && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN
? {
requestUrl: process.env.ACTIONS_ID_TOKEN_REQUEST_URL,
requestToken: process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN,
}
: null;
// clear OIDC env vars in restricted mode to prevent agent from minting tokens
if (payload.shell !== "enabled") {
delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
}
// proxy decision: mint an OpenRouter key for OSS repos (or later, managed billing)
await resolveProxyModel({
payload,
oss: runContext.oss,
proxyModel: runContext.proxyModel,
oidcCredentials,
});
// create octokit with MCP token for GitHub API calls
const octokit = createOctokit(tokenRef.mcpToken);
@@ -151,6 +234,7 @@ export async function main(): Promise<MainResult> {
validateAgentApiKey({
agent,
model: payload.proxyModel ?? payload.model,
owner: runContext.repo.owner,
name: runContext.repo.name,
});
+48
View File
@@ -334,6 +334,44 @@ export async function checkoutPrBranch(
};
}
type DeepenForBeforeShaParams = {
gitToken: string;
beforeSha: string;
};
function deepenForBeforeSha(params: DeepenForBeforeShaParams): void {
const isShallow =
$("git", ["rev-parse", "--is-shallow-repository"], { log: false }).trim() === "true";
if (!isShallow) return;
const maxIterations = 10;
for (let i = 0; i < maxIterations; i++) {
try {
$("git", ["cat-file", "-t", params.beforeSha], { log: false });
log.debug(`» before_sha ${params.beforeSha.slice(0, 7)} is now reachable`);
return;
} catch {
// not reachable yet, deepen
}
log.debug(
`» deepening by 50 to reach before_sha ${params.beforeSha.slice(0, 7)} (attempt ${i + 1}/${maxIterations})`
);
try {
$git("fetch", ["--deepen=50", "--no-tags", "origin"], {
token: params.gitToken,
});
} catch {
log.debug(`» deepen for before_sha failed (force-push may have rewritten history)`);
return;
}
}
log.debug(
`» before_sha ${params.beforeSha.slice(0, 7)} not reachable after ${maxIterations * 50} commits`
);
}
export function CheckoutPrTool(ctx: ToolContext) {
return tool({
name: "checkout_pr",
@@ -352,6 +390,16 @@ export function CheckoutPrTool(ctx: ToolContext) {
postCheckoutScript: ctx.postCheckoutScript,
});
// for incremental review/rereview: deepen the clone to include before_sha
// so `git diff before_sha...HEAD` works without the agent needing to fetch manually
const event = ctx.payload.event;
if ("before_sha" in event && event.before_sha) {
deepenForBeforeSha({
gitToken: ctx.gitToken,
beforeSha: event.before_sha,
});
}
const pr = await ctx.octokit.rest.pulls.get({
owner: ctx.repo.owner,
repo: ctx.repo.name,
+61 -20
View File
@@ -9,8 +9,15 @@ import { retry } from "../utils/retry.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
/** PATCH workflow-run with plan comment node_id so plan revisions can update that comment in place. */
async function updatePlanCommentId(ctx: ToolContext, planCommentNodeId: string): Promise<void> {
type CommentNodeIdField = "planCommentNodeId" | "summaryCommentNodeId";
// IMPORTANT: this route authenticates via Pullfrog API JWT (verifyApiToken),
// NOT a GitHub token. use ctx.apiToken here. see wiki/api-auth.md.
export async function updateCommentNodeId(
ctx: ToolContext,
field: CommentNodeIdField,
nodeId: string
): Promise<void> {
if (ctx.runId === undefined || !ctx.apiToken) return;
try {
await retry(
@@ -22,7 +29,7 @@ async function updatePlanCommentId(ctx: ToolContext, planCommentNodeId: string):
authorization: `Bearer ${ctx.apiToken}`,
"content-type": "application/json",
},
body: JSON.stringify({ planCommentNodeId }),
body: JSON.stringify({ [field]: nodeId }),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) throw new Error(`PATCH workflow-run: ${response.status}`);
@@ -30,11 +37,11 @@ async function updatePlanCommentId(ctx: ToolContext, planCommentNodeId: string):
{
maxAttempts: 3,
delayMs: 2000,
label: "updatePlanCommentId",
label: `updateCommentNodeId(${field})`,
}
);
} catch (error) {
log.warning(`updatePlanCommentId exhausted retries: ${error}`);
log.warning(`updateCommentNodeId(${field}) exhausted retries: ${error}`);
}
}
@@ -48,6 +55,7 @@ export const LEAPING_INTO_ACTION_PREFIX = "Leaping into action";
interface BuildCommentFooterParams {
octokit?: OctokitWithPlugins | undefined;
customParts?: string[] | undefined;
model?: string | undefined;
}
async function buildCommentFooter(params: BuildCommentFooterParams): Promise<string> {
@@ -70,17 +78,14 @@ async function buildCommentFooter(params: BuildCommentFooterParams): Promise<str
}
}
const footerParams = {
return buildPullfrogFooter({
triggeredBy: true,
workflowRun: runId
? { owner: repoContext.owner, repo: repoContext.name, runId, jobId }
: undefined,
};
if (params.customParts && params.customParts.length > 0) {
return buildPullfrogFooter({ ...footerParams, customParts: params.customParts });
}
return buildPullfrogFooter(footerParams);
customParts: params.customParts,
model: params.model,
});
}
function buildImplementPlanLink(
@@ -95,11 +100,17 @@ function buildImplementPlanLink(
export interface AddFooterCtx {
octokit?: OctokitWithPlugins | undefined;
toolState?: { model?: string | undefined } | undefined;
}
export async function addFooter(ctx: AddFooterCtx, body: string): Promise<string> {
if (/<br\s*\/?>[ \t]*\n(?!\s*\n)/i.test(body)) {
throw new Error(
"body contains <br/> followed by a non-blank line, which breaks GitHub markdown rendering. always add a blank line after <br/> tags."
);
}
const bodyWithoutFooter = stripExistingFooter(fixDoubleEscapedString(body));
const footer = await buildCommentFooter({ octokit: ctx.octokit });
const footer = await buildCommentFooter({ octokit: ctx.octokit, model: ctx.toolState?.model });
return `${bodyWithoutFooter}${footer}`;
}
@@ -107,9 +118,9 @@ export const Comment = type({
issueNumber: type.number.describe("the issue number to comment on"),
body: type.string.describe("the comment body content"),
type: type
.enumerated("Plan", "Comment")
.enumerated("Plan", "Summary", "Comment")
.describe(
"Plan: record this comment as the plan for this run (use report_progress for progress/plan updates on the current run). Comment: regular comment (default)."
"Plan: record as the plan for this run. Summary: record as the PR summary comment (one per PR, updated in place). Comment: regular comment (default)."
)
.optional(),
});
@@ -118,11 +129,35 @@ export function CreateCommentTool(ctx: ToolContext) {
return tool({
name: "create_issue_comment",
description:
"Create a comment on a GitHub issue. For progress/plan updates on the current run use report_progress instead. Use type: 'Plan' only when creating a standalone plan comment to record as this run's plan.",
"Create a comment on a GitHub issue or PR. For progress/plan updates on the current run use report_progress instead. Use type: 'Plan' for plan comments, type: 'Summary' for PR summary comments.",
parameters: Comment,
execute: execute(async ({ issueNumber, body, type: commentType }) => {
const bodyWithFooter = await addFooter(ctx, body);
// if a summary comment already exists (found by select_mode), update instead of creating
if (commentType === "Summary" && ctx.toolState.existingSummaryCommentId) {
log.info(
`» redirecting create_issue_comment(Summary) to update existing comment ${ctx.toolState.existingSummaryCommentId}`
);
const result = await ctx.octokit.rest.issues.updateComment({
owner: ctx.repo.owner,
repo: ctx.repo.name,
comment_id: ctx.toolState.existingSummaryCommentId,
body: bodyWithFooter,
});
if (result.data.node_id) {
await updateCommentNodeId(ctx, "summaryCommentNodeId", result.data.node_id);
}
return {
success: true,
commentId: result.data.id,
url: result.data.html_url,
body: result.data.body,
};
}
const result = await ctx.octokit.rest.issues.createComment({
owner: ctx.repo.owner,
repo: ctx.repo.name,
@@ -131,7 +166,10 @@ export function CreateCommentTool(ctx: ToolContext) {
});
if (commentType === "Plan" && result.data.node_id) {
await updatePlanCommentId(ctx, result.data.node_id);
await updateCommentNodeId(ctx, "planCommentNodeId", result.data.node_id);
}
if (commentType === "Summary" && result.data.node_id) {
await updateCommentNodeId(ctx, "summaryCommentNodeId", result.data.node_id);
}
return {
@@ -228,6 +266,7 @@ export async function reportProgress(
const footer = await buildCommentFooter({
octokit: ctx.octokit,
customParts,
model: ctx.toolState.model,
});
const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
@@ -241,7 +280,7 @@ export async function reportProgress(
ctx.toolState.wasUpdated = true;
if (isPlanMode && result.data.node_id) {
await updatePlanCommentId(ctx, result.data.node_id);
await updateCommentNodeId(ctx, "planCommentNodeId", result.data.node_id);
}
return {
@@ -265,6 +304,7 @@ export async function reportProgress(
const footer = await buildCommentFooter({
octokit: ctx.octokit,
customParts,
model: ctx.toolState.model,
});
const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
@@ -278,7 +318,7 @@ export async function reportProgress(
ctx.toolState.wasUpdated = true;
if (isPlanMode && result.data.node_id) {
await updatePlanCommentId(ctx, result.data.node_id);
await updateCommentNodeId(ctx, "planCommentNodeId", result.data.node_id);
}
return {
@@ -325,6 +365,7 @@ export async function reportProgress(
const footer = await buildCommentFooter({
octokit: ctx.octokit,
customParts,
model: ctx.toolState.model,
});
const bodyWithPlanLink = `${bodyWithoutFooter}${footer}`;
@@ -336,7 +377,7 @@ export async function reportProgress(
});
if (updateResult.data.node_id) {
await updatePlanCommentId(ctx, updateResult.data.node_id);
await updateCommentNodeId(ctx, "planCommentNodeId", updateResult.data.node_id);
}
return {
+11
View File
@@ -218,6 +218,8 @@ const NOSHELL_BLOCKED_SUBCOMMANDS: Record<string, string> = {
// (avoids false positives like --exclude matching --exec)
const NOSHELL_BLOCKED_ARGS = ["--exec", "--extcmd", "--upload-pack", "--receive-pack"];
const COLLAPSE_THRESHOLD = 200;
// SECURITY: subcommand must match [a-z][a-z0-9-]* to reject flags passed as the subcommand.
// this blocks injection of global git options like -c, -C, --exec-path, --config-env, etc.
//
@@ -269,6 +271,15 @@ export function GitTool(ctx: ToolContext) {
}
const output = $("git", [subcommand, ...args], { log: false });
const lineCount = output.split("\n").length;
if (lineCount > COLLAPSE_THRESHOLD) {
log.group(`git ${subcommand} output (${lineCount} lines)`, () => {
log.info(output);
});
} else if (output) {
log.info(output);
}
return { success: true, output };
}),
});
+1
View File
@@ -21,6 +21,7 @@ function buildPrBodyWithFooter(ctx: ToolContext, body: string): string {
workflowRun: ctx.runId
? { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId }
: undefined,
model: ctx.toolState.model,
});
const bodyWithoutFooter = stripExistingFooter(fixDoubleEscapedString(body));
+79 -47
View File
@@ -8,10 +8,10 @@ import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
function isStatusError(err: unknown): err is { status: number; message?: string } {
return (
typeof err === "object" && err !== null && "status" in err && typeof err.status === "number"
);
function getHttpStatus(err: unknown): number | undefined {
if (typeof err !== "object" || err === null) return undefined;
const status = (err as Record<string, unknown>).status;
return typeof status === "number" ? status : undefined;
}
// one-shot review tool
@@ -35,7 +35,7 @@ export const CreatePullRequestReview = type({
"The file path to comment on (relative to repo root). Must be a file that appears in the PR diff."
),
line: type.number.describe(
"End line of the comment range. For single-line comments, set equal to 'start_line'. Use NEW column from diff format."
"Line number to comment on. For multi-line ranges, this is the end line. Use NEW column from diff format."
),
side: type
.enumerated("LEFT", "RIGHT")
@@ -51,9 +51,11 @@ export const CreatePullRequestReview = type({
"Full replacement code for the line range [start_line, line]. MUST preserve the exact indentation of the original code."
)
.optional(),
start_line: type.number.describe(
"Start line of the comment range. For single-line comments, set equal to 'line'. The range [start_line, line] defines which lines a suggestion replaces."
),
start_line: type.number
.describe(
"Start line for multi-line comment ranges. Omit for single-line comments. The range [start_line, line] defines which lines a suggestion replaces."
)
.optional(),
})
.array()
.describe(
@@ -67,14 +69,14 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
name: "create_pull_request_review",
description:
"Submit a review for an existing pull request. " +
"Each call creates a permanent, visible review on the PR — NEVER submit test or diagnostic reviews. " +
"IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. " +
"Only use 'body' for a 1-2 sentence summary with urgency and critical callouts. " +
"Use 'suggestion' to propose replacement code - MUST preserve exact indentation of original code. " +
"Example replacing lines 42-44 (3 lines) with 5 lines: " +
`{ path: 'src/api.ts', start_line: 42, line: 44, suggestion: ' const result = await fetch(url);\\n if (!result.ok) {\\n log.error(result.status);\\n throw new Error("request failed");\\n }' }` +
" CONSTRAINT: Inline comments can ONLY target files and lines that appear in the PR diff." +
" Commenting on files or lines outside the diff will cause GitHub API errors." +
" Put feedback about code outside the diff in 'body' instead.",
" If GitHub rejects comments due to incorrect line numbers, re-read the diff and retry.",
parameters: CreatePullRequestReview,
execute: execute(async ({ pull_number, body, approved, commit_id, comments = [] }) => {
if (body) body = fixDoubleEscapedString(body);
@@ -82,6 +84,18 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
// set issue context (PRs are issues)
ctx.toolState.issueNumber = pull_number;
// skip empty reviews (no body, no inline comments) — nothing to post
if (!body && comments.length === 0) {
log.info(
"review has no body and no inline comments — skipping submission (no issues found)"
);
return {
success: true,
skipped: true,
reason: "no issues found — nothing to post",
};
}
// enforce prApproveEnabled: downgrade APPROVE to COMMENT if disabled
let event: "APPROVE" | "COMMENT" = approved ? "APPROVE" : "COMMENT";
if (event === "APPROVE" && !ctx.prApproveEnabled) {
@@ -95,6 +109,7 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
pull_number,
event,
};
let latestHeadSha: string | undefined;
if (commit_id) {
params.commit_id = commit_id;
} else {
@@ -103,28 +118,39 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
repo: ctx.repo.name,
pull_number,
});
params.commit_id = pr.data.head.sha;
latestHeadSha = pr.data.head.sha;
// anchor to checkout sha so line numbers match the diff the agent analyzed
params.commit_id = ctx.toolState.checkoutSha ?? latestHeadSha;
if (ctx.toolState.checkoutSha && latestHeadSha !== ctx.toolState.checkoutSha) {
log.info(
`anchoring review to checkout ${ctx.toolState.checkoutSha.slice(0, 7)} ` +
`(HEAD is now ${latestHeadSha.slice(0, 7)})`
);
}
}
if (comments.length > 0) {
type ReviewComment = (typeof params.comments & {})[number];
params.comments = comments.map((comment) => {
let commentBody = fixDoubleEscapedString(comment.body || "");
if (comment.suggestion !== undefined) {
const suggestionBlock = "```suggestion\n" + comment.suggestion + "\n```";
commentBody = commentBody ? commentBody + "\n\n" + suggestionBlock : suggestionBlock;
}
type ReviewComment = NonNullable<typeof params.comments>[number];
const reviewComments = comments.map((comment) => {
let commentBody = fixDoubleEscapedString(comment.body || "");
if (comment.suggestion !== undefined) {
const suggestionBlock = "```suggestion\n" + comment.suggestion + "\n```";
commentBody = commentBody ? commentBody + "\n\n" + suggestionBlock : suggestionBlock;
}
const side = comment.side || "RIGHT";
const reviewComment: ReviewComment = {
path: comment.path,
line: comment.line,
body: commentBody,
side,
};
if (comment.start_line != null && comment.start_line !== comment.line) {
reviewComment.start_line = comment.start_line;
reviewComment.start_side = side;
}
return reviewComment;
});
const side = comment.side || "RIGHT";
const reviewComment: ReviewComment = {
path: comment.path,
line: comment.line,
body: commentBody,
side,
start_line: comment.start_line,
start_side: side,
};
return reviewComment;
});
if (reviewComments.length > 0) {
params.comments = reviewComments;
}
// no body → single-step createReview (no footer needed)
@@ -135,20 +161,24 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
? await createAndSubmitWithFooter(ctx, params, {
body,
approved: approved ?? false,
hasComments: comments.length > 0,
hasComments: reviewComments.length > 0,
})
: await ctx.octokit.rest.pulls.createReview(params);
} catch (err: unknown) {
if (isStatusError(err) && err.status === 422 && params.comments?.length) {
const paths = [...new Set(params.comments.map((comment) => comment.path))];
throw new Error(
`${err.message ?? "422 Unprocessable Entity"}. ` +
`The review had ${params.comments.length} inline comment(s) targeting these paths: ${paths.join(", ")}. ` +
`GitHub cannot resolve one or more of these paths in the PR diff (common when the PR has >100 changed files and some are truncated). ` +
`Fix: remove the failing comment(s) and retry. Put their feedback in the review body instead.`
);
}
throw err;
if (getHttpStatus(err) !== 422 || !params.comments?.length) throw err;
const details = params.comments.map((c) => {
const line = c.line ?? 0;
const startLine = c.start_line ?? line;
const range = startLine !== line ? `${startLine}-${line}` : `${line}`;
return `${c.path}:${range} (${c.side ?? "RIGHT"})`;
});
throw new Error(
`GitHub rejected inline comment(s) with "Line could not be resolved". ` +
`This usually means the diff changed since you last read it (new commits pushed). ` +
`Re-read the diff to get current line numbers, or move failing comments to the review body. ` +
`Affected: ${details.join(", ")}`
);
}
log.debug(`createReview response: ${JSON.stringify(result.data)}`);
if (!result.data.id) {
@@ -169,12 +199,13 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
// detect commits pushed since checkout and guide the agent to review them
// inline instead of dispatching a separate workflow run
const headMovedDuringReview =
ctx.toolState.checkoutSha && params.commit_id !== ctx.toolState.checkoutSha;
if (headMovedDuringReview) {
const fromSha = ctx.toolState.checkoutSha!;
const toSha = params.commit_id!;
if (
ctx.toolState.checkoutSha &&
latestHeadSha &&
latestHeadSha !== ctx.toolState.checkoutSha
) {
const fromSha = ctx.toolState.checkoutSha;
const toSha = latestHeadSha;
// advance checkoutSha so the next review submission tracks correctly
ctx.toolState.checkoutSha = toSha;
@@ -245,6 +276,7 @@ async function createAndSubmitWithFooter(
? { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId }
: undefined,
customParts,
model: ctx.toolState.model,
});
return ctx.octokit.rest.pulls.submitReview({
+89 -3
View File
@@ -2,12 +2,13 @@ import { type } from "arktype";
import { ghPullfrogMcpName } from "../external.ts";
import type { Mode } from "../modes.ts";
import { apiFetch } from "../utils/apiFetch.ts";
import { log } from "../utils/log.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
export const SelectModeParams = type({
mode: type.string.describe(
"the name of the mode to select (e.g., 'Build', 'Plan', 'Review', 'IncrementalReview', 'Fix', 'AddressReviews', 'Task', 'ResolveConflicts')"
"the name of the mode to select (e.g., 'Build', 'Plan', 'Review', 'IncrementalReview', 'Fix', 'AddressReviews', 'Task', 'ResolveConflicts', 'Summarize')"
),
"issue_number?": type("number").describe(
"optional issue number; when provided with Plan mode, used to look up an existing plan comment for this issue (edit vs create)"
@@ -185,6 +186,38 @@ An existing plan comment was found for this issue. Update that comment with the
- call \`${ghPullfrogMcpName}/report_progress\` with results
- if the task involved code changes, push via \`${ghPullfrogMcpName}/push_branch\` and create a PR via \`${ghPullfrogMcpName}/create_pull_request\`
- if the task involved labeling, commenting, or other GitHub operations, perform those directly`,
Summarize: `### Checklist
1. Checkout the PR via \`${ghPullfrogMcpName}/checkout_pr\` — this returns PR metadata and a \`diffPath\`.
2. Delegate a subagent to analyze the diff and produce a structured summary. Include in its prompt:
- the diff file path
- PR metadata (title, file count, commit count, base/head branches)
- format instructions from EVENT INSTRUCTIONS (if any); otherwise use default format: TL;DR, key changes list, per-change sections with plain-language \`##\` titles and before/after framing
- instruct it to use the TOC to selectively read relevant diff sections, not the entire file
- instruct it to return the full summary markdown via \`${ghPullfrogMcpName}/set_output\`
3. After the subagent completes, call \`${ghPullfrogMcpName}/create_issue_comment\` with \`type: "Summary"\` and the summary body.
### Effort
Use mini or auto effort.`,
SummaryUpdate: `### Checklist (updating existing summary)
An existing summary comment was found for this PR. Update it rather than creating a new one.
1. Use \`previousSummaryBody\` from this response as the current summary to revise.
2. Checkout the PR via \`${ghPullfrogMcpName}/checkout_pr\` — this returns PR metadata and a \`diffPath\`.
3. Delegate a subagent with:
- the diff file path and PR metadata
- the existing summary body (\`previousSummaryBody\`) so it can update rather than rewrite from scratch
- format instructions from EVENT INSTRUCTIONS (if any)
- instruct it to produce an updated summary reflecting the current state of the PR and return via \`${ghPullfrogMcpName}/set_output\`
4. After the subagent completes, call \`${ghPullfrogMcpName}/edit_issue_comment\` with \`commentId: existingSummaryCommentId\` (from this response) and the updated summary body.
### Effort
Use mini or auto effort.`,
};
type OrchestratorGuidance = {
@@ -218,16 +251,22 @@ function buildOrchestratorGuidance(mode: Mode, opts: BuildGuidanceOpts = {}): Or
// matches the API response for /repo/[owner]/[repo]/issue/[issueNumber]/plan-comment
export type PlanCommentResponsePayload = { error: string } | { commentId: number; body: string };
// matches the API response for /repo/[owner]/[repo]/pr/[prNumber]/summary-comment
export type SummaryCommentResponsePayload = { error: string } | { commentId: number; body: string };
// IMPORTANT: these routes authenticate via GitHub installation token (getEnrichedRepo),
// NOT the Pullfrog API JWT (ctx.apiToken). use ctx.githubInstallationToken here.
// see wiki/api-auth.md for the two auth patterns.
async function fetchExistingPlanComment(
ctx: ToolContext,
issueNumber: number
): Promise<Extract<PlanCommentResponsePayload, { commentId: number }> | null> {
if (!ctx.apiToken) return null;
if (!ctx.githubInstallationToken) return null;
try {
const response = await apiFetch({
path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/issue/${issueNumber}/plan-comment`,
method: "GET",
headers: { authorization: `Bearer ${ctx.apiToken}` },
headers: { authorization: `Bearer ${ctx.githubInstallationToken}` },
signal: AbortSignal.timeout(10_000),
});
const data = (await response.json()) as PlanCommentResponsePayload;
@@ -237,6 +276,35 @@ async function fetchExistingPlanComment(
}
}
async function fetchExistingSummaryComment(
ctx: ToolContext,
prNumber: number
): Promise<Extract<SummaryCommentResponsePayload, { commentId: number }> | null> {
if (!ctx.githubInstallationToken) {
log.warning("fetchExistingSummaryComment: no token, skipping");
return null;
}
const path = `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/pr/${prNumber}/summary-comment`;
try {
const response = await apiFetch({
path,
method: "GET",
headers: { authorization: `Bearer ${ctx.githubInstallationToken}` },
signal: AbortSignal.timeout(10_000),
});
const data = (await response.json()) as SummaryCommentResponsePayload;
if (response.ok && "commentId" in data) {
return data;
}
const errMsg = "error" in data ? data.error : "(no error body)";
log.warning(`fetchExistingSummaryComment: ${response.status} ${path}${errMsg}`);
return null;
} catch (error) {
log.warning("fetchExistingSummaryComment failed:", error);
return null;
}
}
export function SelectModeTool(ctx: ToolContext) {
return tool({
name: "select_mode",
@@ -287,6 +355,24 @@ export function SelectModeTool(ctx: ToolContext) {
}
}
if (selectedMode.name === "Summarize") {
const prNumber = ctx.payload.event.issue_number;
if (prNumber !== undefined) {
const existing = await fetchExistingSummaryComment(ctx, prNumber);
if (existing !== null) {
ctx.toolState.existingSummaryCommentId = existing.commentId;
return {
...buildOrchestratorGuidance(selectedMode, {
...guidanceOpts,
overrideGuidance: modeGuidance.SummaryUpdate,
}),
existingSummaryCommentId: existing.commentId,
previousSummaryBody: existing.body,
};
}
}
}
return buildOrchestratorGuidance(selectedMode, guidanceOpts);
}),
});
+3
View File
@@ -85,8 +85,11 @@ export interface ToolState {
// set by select_mode when Plan + issue_number and plan-comment API returns existing plan (for report_progress target_plan_comment)
existingPlanCommentId?: number;
previousPlanBody?: string;
// set by select_mode when Summarize mode and summary-comment API returns existing summary
existingSummaryCommentId?: number;
output?: string;
usageEntries: AgentUsage[];
model?: string | undefined;
}
interface InitToolStateParams {
+15 -3
View File
@@ -47,6 +47,18 @@ describe("getModelEnvVars", () => {
it("returns empty array for unknown provider", () => {
expect(getModelEnvVars("unknown/model")).toEqual([]);
});
it("returns empty env vars for free opencode models", () => {
expect(getModelEnvVars("opencode/big-pickle")).toEqual([]);
expect(getModelEnvVars("opencode/gpt-5-nano")).toEqual([]);
expect(getModelEnvVars("opencode/mimo-v2-pro-free")).toEqual([]);
expect(getModelEnvVars("opencode/minimax-m2.5-free")).toEqual([]);
expect(getModelEnvVars("opencode/nemotron-3-super-free")).toEqual([]);
});
it("still requires OPENCODE_API_KEY for non-free opencode models", () => {
expect(getModelEnvVars("opencode/claude-opus")).toEqual(["OPENCODE_API_KEY"]);
});
});
describe("resolveModelSlug", () => {
@@ -84,10 +96,10 @@ describe("modelAliases registry", () => {
}
});
it("has exactly one recommended model per provider", () => {
it("has exactly one preferred model per provider", () => {
for (const providerKey of Object.keys(providers)) {
const recommended = modelAliases.filter((a) => a.provider === providerKey && a.recommended);
expect(recommended.length, `${providerKey} should have exactly 1 recommended model`).toBe(1);
const preferred = modelAliases.filter((a) => a.provider === providerKey && a.preferred);
expect(preferred.length, `${providerKey} should have exactly 1 preferred model`).toBe(1);
}
});
+188 -37
View File
@@ -16,15 +16,23 @@ export interface ModelAlias {
displayName: string;
/** concrete models.dev specifier, e.g. "anthropic/claude-opus-4-6" */
resolve: string;
/** full models.dev specifier for the OpenRouter equivalent (undefined for free models) */
openRouterResolve: string | undefined;
/** top-tier pick for this provider — preferred during auto-select */
recommended: boolean;
preferred: boolean;
/** whether this alias is free and requires no API key */
isFree: boolean;
}
interface ModelDef {
displayName: string;
/** concrete models.dev specifier, e.g. "anthropic/claude-opus-4-6" */
resolve: string;
recommended?: boolean;
/** full models.dev specifier for the OpenRouter equivalent, e.g. "openrouter/anthropic/claude-opus-4.6" */
openRouterResolve?: string;
preferred?: boolean;
envVars?: readonly string[];
isFree?: boolean;
}
export interface ProviderConfig {
@@ -47,19 +55,40 @@ export const providers = {
"claude-opus": {
displayName: "Claude Opus",
resolve: "anthropic/claude-opus-4-6",
recommended: true,
openRouterResolve: "openrouter/anthropic/claude-opus-4.6",
preferred: true,
},
"claude-sonnet": {
displayName: "Claude Sonnet",
resolve: "anthropic/claude-sonnet-4-6",
openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6",
},
"claude-haiku": {
displayName: "Claude Haiku",
resolve: "anthropic/claude-haiku-4-5",
openRouterResolve: "openrouter/anthropic/claude-haiku-4.5",
},
"claude-sonnet": { displayName: "Claude Sonnet", resolve: "anthropic/claude-sonnet-4-6" },
"claude-haiku": { displayName: "Claude Haiku", resolve: "anthropic/claude-haiku-4-5" },
},
}),
openai: provider({
displayName: "OpenAI",
envVars: ["OPENAI_API_KEY"],
models: {
"gpt-codex": { displayName: "GPT Codex", resolve: "openai/gpt-5.3-codex", recommended: true },
"gpt-codex-mini": { displayName: "GPT Codex Mini", resolve: "openai/codex-mini-latest" },
o3: { displayName: "O3", resolve: "openai/o3" },
"gpt-codex": {
displayName: "GPT Codex",
resolve: "openai/gpt-5.3-codex",
openRouterResolve: "openrouter/openai/gpt-5.3-codex",
preferred: true,
},
"gpt-codex-mini": {
displayName: "GPT Codex Mini",
resolve: "openai/codex-mini-latest",
openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini",
},
o3: {
displayName: "O3",
resolve: "openai/o3",
},
},
}),
google: provider({
@@ -69,18 +98,36 @@ export const providers = {
"gemini-pro": {
displayName: "Gemini Pro",
resolve: "google/gemini-3.1-pro-preview",
recommended: true,
openRouterResolve: "openrouter/google/gemini-3.1-pro-preview",
preferred: true,
},
"gemini-flash": {
displayName: "Gemini Flash",
resolve: "google/gemini-3-flash-preview",
openRouterResolve: "openrouter/google/gemini-3-flash-preview",
},
"gemini-flash": { displayName: "Gemini Flash", resolve: "google/gemini-3-flash-preview" },
},
}),
xai: provider({
displayName: "xAI",
envVars: ["XAI_API_KEY"],
models: {
grok: { displayName: "Grok", resolve: "xai/grok-4", recommended: true },
"grok-fast": { displayName: "Grok Fast", resolve: "xai/grok-4-fast" },
"grok-code-fast": { displayName: "Grok Code Fast", resolve: "xai/grok-code-fast-1" },
grok: {
displayName: "Grok",
resolve: "xai/grok-4",
openRouterResolve: "openrouter/x-ai/grok-4",
preferred: true,
},
"grok-fast": {
displayName: "Grok Fast",
resolve: "xai/grok-4-fast",
openRouterResolve: "openrouter/x-ai/grok-4-fast",
},
"grok-code-fast": {
displayName: "Grok Code Fast",
resolve: "xai/grok-code-fast-1",
openRouterResolve: "openrouter/x-ai/grok-code-fast-1",
},
},
}),
deepseek: provider({
@@ -90,16 +137,26 @@ export const providers = {
"deepseek-reasoner": {
displayName: "DeepSeek Reasoner",
resolve: "deepseek/deepseek-reasoner",
recommended: true,
openRouterResolve: "openrouter/deepseek/deepseek-v3.2",
preferred: true,
},
"deepseek-chat": {
displayName: "DeepSeek Chat",
resolve: "deepseek/deepseek-chat",
openRouterResolve: "openrouter/deepseek/deepseek-v3.2",
},
"deepseek-chat": { displayName: "DeepSeek Chat", resolve: "deepseek/deepseek-chat" },
},
}),
moonshotai: provider({
displayName: "Moonshot AI",
envVars: ["MOONSHOT_API_KEY"],
models: {
"kimi-k2": { displayName: "Kimi K2", resolve: "moonshotai/kimi-k2.5", recommended: true },
"kimi-k2": {
displayName: "Kimi K2",
resolve: "moonshotai/kimi-k2.5",
openRouterResolve: "openrouter/moonshotai/kimi-k2.5",
preferred: true,
},
},
}),
opencode: provider({
@@ -109,21 +166,74 @@ export const providers = {
"big-pickle": {
displayName: "Big Pickle",
resolve: "opencode/big-pickle",
recommended: true,
preferred: true,
envVars: [],
isFree: true,
},
"claude-opus": { displayName: "Claude Opus", resolve: "opencode/claude-opus-4-6" },
"claude-sonnet": { displayName: "Claude Sonnet", resolve: "opencode/claude-sonnet-4-6" },
"claude-haiku": { displayName: "Claude Haiku", resolve: "opencode/claude-haiku-4-5" },
"gpt-codex": { displayName: "GPT Codex", resolve: "opencode/gpt-5.3-codex" },
"gemini-pro": { displayName: "Gemini Pro", resolve: "opencode/gemini-3.1-pro" },
"gemini-flash": { displayName: "Gemini Flash", resolve: "opencode/gemini-3-flash" },
"kimi-k2": { displayName: "Kimi K2", resolve: "opencode/kimi-k2.5" },
"gpt-5-nano": { displayName: "GPT-5 Nano", resolve: "opencode/gpt-5-nano" },
"mimo-v2-flash-free": {
displayName: "MiMo V2 Flash",
resolve: "opencode/mimo-v2-flash-free",
"claude-opus": {
displayName: "Claude Opus",
resolve: "opencode/claude-opus-4-6",
openRouterResolve: "openrouter/anthropic/claude-opus-4.6",
},
"claude-sonnet": {
displayName: "Claude Sonnet",
resolve: "opencode/claude-sonnet-4-6",
openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6",
},
"claude-haiku": {
displayName: "Claude Haiku",
resolve: "opencode/claude-haiku-4-5",
openRouterResolve: "openrouter/anthropic/claude-haiku-4.5",
},
"gpt-codex": {
displayName: "GPT Codex",
resolve: "opencode/gpt-5.3-codex",
openRouterResolve: "openrouter/openai/gpt-5.3-codex",
},
"gpt-codex-mini": {
displayName: "GPT Codex Mini",
resolve: "opencode/gpt-5.1-codex-mini",
openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini",
},
"gemini-pro": {
displayName: "Gemini Pro",
resolve: "opencode/gemini-3.1-pro",
openRouterResolve: "openrouter/google/gemini-3.1-pro-preview",
},
"gemini-flash": {
displayName: "Gemini Flash",
resolve: "opencode/gemini-3-flash",
openRouterResolve: "openrouter/google/gemini-3-flash-preview",
},
"kimi-k2": {
displayName: "Kimi K2",
resolve: "opencode/kimi-k2.5",
openRouterResolve: "openrouter/moonshotai/kimi-k2.5",
},
"gpt-5-nano": {
displayName: "GPT Nano",
resolve: "opencode/gpt-5-nano",
envVars: [],
isFree: true,
},
"mimo-v2-pro-free": {
displayName: "MiMo V2 Pro",
resolve: "opencode/mimo-v2-pro-free",
envVars: [],
isFree: true,
},
"minimax-m2.5-free": {
displayName: "MiniMax M2.5",
resolve: "opencode/minimax-m2.5-free",
envVars: [],
isFree: true,
},
"nemotron-3-super-free": {
displayName: "Nemotron 3 Super",
resolve: "opencode/nemotron-3-super-free",
envVars: [],
isFree: true,
},
"minimax-m2.5-free": { displayName: "MiniMax M2.5", resolve: "opencode/minimax-m2.5-free" },
},
}),
openrouter: provider({
@@ -133,35 +243,59 @@ export const providers = {
"claude-opus": {
displayName: "Claude Opus",
resolve: "openrouter/anthropic/claude-opus-4.6",
recommended: true,
openRouterResolve: "openrouter/anthropic/claude-opus-4.6",
preferred: true,
},
"claude-sonnet": {
displayName: "Claude Sonnet",
resolve: "openrouter/anthropic/claude-sonnet-4.6",
openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6",
},
"claude-haiku": {
displayName: "Claude Haiku",
resolve: "openrouter/anthropic/claude-haiku-4.5",
openRouterResolve: "openrouter/anthropic/claude-haiku-4.5",
},
"gpt-codex": {
displayName: "GPT Codex",
resolve: "openrouter/openai/gpt-5.3-codex",
openRouterResolve: "openrouter/openai/gpt-5.3-codex",
},
"gpt-codex": { displayName: "GPT Codex", resolve: "openrouter/openai/gpt-5.3-codex" },
"gpt-codex-mini": {
displayName: "GPT Codex Mini",
resolve: "openrouter/openai/gpt-5.1-codex-mini",
openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini",
},
"o4-mini": {
displayName: "O4 Mini",
resolve: "openrouter/openai/o4-mini",
openRouterResolve: "openrouter/openai/o4-mini",
},
"gemini-pro": {
displayName: "Gemini Pro",
resolve: "openrouter/google/gemini-3.1-pro-preview",
openRouterResolve: "openrouter/google/gemini-3.1-pro-preview",
},
"gemini-flash": {
displayName: "Gemini Flash",
resolve: "openrouter/google/gemini-3-flash-preview",
openRouterResolve: "openrouter/google/gemini-3-flash-preview",
},
grok: {
displayName: "Grok",
resolve: "openrouter/x-ai/grok-4",
openRouterResolve: "openrouter/x-ai/grok-4",
},
grok: { displayName: "Grok", resolve: "openrouter/x-ai/grok-4" },
"deepseek-chat": {
displayName: "DeepSeek Chat",
resolve: "openrouter/deepseek/deepseek-chat-v3.1",
resolve: "openrouter/deepseek/deepseek-v3.2",
openRouterResolve: "openrouter/deepseek/deepseek-v3.2",
},
"kimi-k2": {
displayName: "Kimi K2",
resolve: "openrouter/moonshotai/kimi-k2.5",
openRouterResolve: "openrouter/moonshotai/kimi-k2.5",
},
"kimi-k2": { displayName: "Kimi K2", resolve: "openrouter/moonshotai/kimi-k2.5" },
},
}),
} satisfies Record<string, ProviderConfig>;
@@ -182,9 +316,24 @@ export function getModelProvider(slug: string): string {
return parseModel(slug).provider;
}
export function getProviderDisplayName(slug: string): string | undefined {
const parsed = parseModel(slug);
return (providers as Record<string, ProviderConfig>)[parsed.provider]?.displayName;
}
export function getModelEnvVars(slug: string): string[] {
const p = getModelProvider(slug);
return (providers as Record<string, ProviderConfig>)[p]?.envVars.slice() ?? [];
const parsed = parseModel(slug);
const providerConfig = (providers as Record<string, ProviderConfig>)[parsed.provider];
if (!providerConfig) {
return [];
}
const modelConfig = providerConfig.models[parsed.model];
if (modelConfig?.envVars) {
return modelConfig.envVars.slice();
}
return providerConfig.envVars.slice();
}
// ── derived flat list ──────────────────────────────────────────────────────────
@@ -196,7 +345,9 @@ export const modelAliases: ModelAlias[] = Object.entries(providers).flatMap(
provider: providerKey,
displayName: def.displayName,
resolve: def.resolve,
recommended: def.recommended ?? false,
openRouterResolve: def.openRouterResolve,
preferred: def.preferred ?? false,
isFree: def.isFree ?? false,
}))
);
+16
View File
@@ -312,6 +312,22 @@ Your job is to fix issues THIS PR introduced, not to fix all CI failures. If in
Do NOT overwrite a good comment with links/details with a generic message like "I have completed the task." If your previous report_progress call already contains all the necessary information and links, skip the final call entirely.`,
},
{
name: "Summarize",
description:
"Summarize a PR with a structured comment that is updated in place on subsequent pushes",
prompt: `Follow these steps.
1. **CHECKOUT** - Call ${ghPullfrogMcpName}/checkout_pr with the PR number to get PR metadata and diffPath.
2. **ANALYZE** - Read the diff file. Use the TOC to selectively read relevant sections do not read the entire file unless the PR is small.
3. **SUMMARIZE** - Write a structured summary following the format from EVENT INSTRUCTIONS. If no format instructions are provided, produce a concise summary with a TL;DR, key changes list, and per-change sections with human-readable \`##\` titles and before/after framing.
4. **POST** - Call ${ghPullfrogMcpName}/create_issue_comment with type: 'Summary' and the summary body.
${permalinkTip}`,
},
];
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@pullfrog/pullfrog",
"version": "0.0.179",
"version": "0.0.182",
"type": "module",
"files": [
"index.js",
+280 -8
View File
@@ -37509,9 +37509,282 @@ function getApiUrl() {
return raw;
}
// models.ts
function provider(config) {
return config;
}
var providers = {
anthropic: provider({
displayName: "Anthropic",
envVars: ["ANTHROPIC_API_KEY"],
models: {
"claude-opus": {
displayName: "Claude Opus",
resolve: "anthropic/claude-opus-4-6",
openRouterResolve: "openrouter/anthropic/claude-opus-4.6",
preferred: true
},
"claude-sonnet": {
displayName: "Claude Sonnet",
resolve: "anthropic/claude-sonnet-4-6",
openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6"
},
"claude-haiku": {
displayName: "Claude Haiku",
resolve: "anthropic/claude-haiku-4-5",
openRouterResolve: "openrouter/anthropic/claude-haiku-4.5"
}
}
}),
openai: provider({
displayName: "OpenAI",
envVars: ["OPENAI_API_KEY"],
models: {
"gpt-codex": {
displayName: "GPT Codex",
resolve: "openai/gpt-5.3-codex",
openRouterResolve: "openrouter/openai/gpt-5.3-codex",
preferred: true
},
"gpt-codex-mini": {
displayName: "GPT Codex Mini",
resolve: "openai/codex-mini-latest",
openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini"
},
o3: {
displayName: "O3",
resolve: "openai/o3"
}
}
}),
google: provider({
displayName: "Google",
envVars: ["GOOGLE_GENERATIVE_AI_API_KEY", "GEMINI_API_KEY"],
models: {
"gemini-pro": {
displayName: "Gemini Pro",
resolve: "google/gemini-3.1-pro-preview",
openRouterResolve: "openrouter/google/gemini-3.1-pro-preview",
preferred: true
},
"gemini-flash": {
displayName: "Gemini Flash",
resolve: "google/gemini-3-flash-preview",
openRouterResolve: "openrouter/google/gemini-3-flash-preview"
}
}
}),
xai: provider({
displayName: "xAI",
envVars: ["XAI_API_KEY"],
models: {
grok: {
displayName: "Grok",
resolve: "xai/grok-4",
openRouterResolve: "openrouter/x-ai/grok-4",
preferred: true
},
"grok-fast": {
displayName: "Grok Fast",
resolve: "xai/grok-4-fast",
openRouterResolve: "openrouter/x-ai/grok-4-fast"
},
"grok-code-fast": {
displayName: "Grok Code Fast",
resolve: "xai/grok-code-fast-1",
openRouterResolve: "openrouter/x-ai/grok-code-fast-1"
}
}
}),
deepseek: provider({
displayName: "DeepSeek",
envVars: ["DEEPSEEK_API_KEY"],
models: {
"deepseek-reasoner": {
displayName: "DeepSeek Reasoner",
resolve: "deepseek/deepseek-reasoner",
openRouterResolve: "openrouter/deepseek/deepseek-v3.2",
preferred: true
},
"deepseek-chat": {
displayName: "DeepSeek Chat",
resolve: "deepseek/deepseek-chat",
openRouterResolve: "openrouter/deepseek/deepseek-v3.2"
}
}
}),
moonshotai: provider({
displayName: "Moonshot AI",
envVars: ["MOONSHOT_API_KEY"],
models: {
"kimi-k2": {
displayName: "Kimi K2",
resolve: "moonshotai/kimi-k2.5",
openRouterResolve: "openrouter/moonshotai/kimi-k2.5",
preferred: true
}
}
}),
opencode: provider({
displayName: "OpenCode",
envVars: ["OPENCODE_API_KEY"],
models: {
"big-pickle": {
displayName: "Big Pickle",
resolve: "opencode/big-pickle",
preferred: true,
envVars: [],
isFree: true
},
"claude-opus": {
displayName: "Claude Opus",
resolve: "opencode/claude-opus-4-6",
openRouterResolve: "openrouter/anthropic/claude-opus-4.6"
},
"claude-sonnet": {
displayName: "Claude Sonnet",
resolve: "opencode/claude-sonnet-4-6",
openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6"
},
"claude-haiku": {
displayName: "Claude Haiku",
resolve: "opencode/claude-haiku-4-5",
openRouterResolve: "openrouter/anthropic/claude-haiku-4.5"
},
"gpt-codex": {
displayName: "GPT Codex",
resolve: "opencode/gpt-5.3-codex",
openRouterResolve: "openrouter/openai/gpt-5.3-codex"
},
"gpt-codex-mini": {
displayName: "GPT Codex Mini",
resolve: "opencode/gpt-5.1-codex-mini",
openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini"
},
"gemini-pro": {
displayName: "Gemini Pro",
resolve: "opencode/gemini-3.1-pro",
openRouterResolve: "openrouter/google/gemini-3.1-pro-preview"
},
"gemini-flash": {
displayName: "Gemini Flash",
resolve: "opencode/gemini-3-flash",
openRouterResolve: "openrouter/google/gemini-3-flash-preview"
},
"kimi-k2": {
displayName: "Kimi K2",
resolve: "opencode/kimi-k2.5",
openRouterResolve: "openrouter/moonshotai/kimi-k2.5"
},
"gpt-5-nano": {
displayName: "GPT Nano",
resolve: "opencode/gpt-5-nano",
envVars: [],
isFree: true
},
"mimo-v2-pro-free": {
displayName: "MiMo V2 Pro",
resolve: "opencode/mimo-v2-pro-free",
envVars: [],
isFree: true
},
"minimax-m2.5-free": {
displayName: "MiniMax M2.5",
resolve: "opencode/minimax-m2.5-free",
envVars: [],
isFree: true
},
"nemotron-3-super-free": {
displayName: "Nemotron 3 Super",
resolve: "opencode/nemotron-3-super-free",
envVars: [],
isFree: true
}
}
}),
openrouter: provider({
displayName: "OpenRouter",
envVars: ["OPENROUTER_API_KEY"],
models: {
"claude-opus": {
displayName: "Claude Opus",
resolve: "openrouter/anthropic/claude-opus-4.6",
openRouterResolve: "openrouter/anthropic/claude-opus-4.6",
preferred: true
},
"claude-sonnet": {
displayName: "Claude Sonnet",
resolve: "openrouter/anthropic/claude-sonnet-4.6",
openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6"
},
"claude-haiku": {
displayName: "Claude Haiku",
resolve: "openrouter/anthropic/claude-haiku-4.5",
openRouterResolve: "openrouter/anthropic/claude-haiku-4.5"
},
"gpt-codex": {
displayName: "GPT Codex",
resolve: "openrouter/openai/gpt-5.3-codex",
openRouterResolve: "openrouter/openai/gpt-5.3-codex"
},
"gpt-codex-mini": {
displayName: "GPT Codex Mini",
resolve: "openrouter/openai/gpt-5.1-codex-mini",
openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini"
},
"o4-mini": {
displayName: "O4 Mini",
resolve: "openrouter/openai/o4-mini",
openRouterResolve: "openrouter/openai/o4-mini"
},
"gemini-pro": {
displayName: "Gemini Pro",
resolve: "openrouter/google/gemini-3.1-pro-preview",
openRouterResolve: "openrouter/google/gemini-3.1-pro-preview"
},
"gemini-flash": {
displayName: "Gemini Flash",
resolve: "openrouter/google/gemini-3-flash-preview",
openRouterResolve: "openrouter/google/gemini-3-flash-preview"
},
grok: {
displayName: "Grok",
resolve: "openrouter/x-ai/grok-4",
openRouterResolve: "openrouter/x-ai/grok-4"
},
"deepseek-chat": {
displayName: "DeepSeek Chat",
resolve: "openrouter/deepseek/deepseek-v3.2",
openRouterResolve: "openrouter/deepseek/deepseek-v3.2"
},
"kimi-k2": {
displayName: "Kimi K2",
resolve: "openrouter/moonshotai/kimi-k2.5",
openRouterResolve: "openrouter/moonshotai/kimi-k2.5"
}
}
})
};
var modelAliases = Object.entries(providers).flatMap(
([providerKey, config]) => Object.entries(config.models).map(([modelId, def]) => ({
slug: `${providerKey}/${modelId}`,
provider: providerKey,
displayName: def.displayName,
resolve: def.resolve,
openRouterResolve: def.openRouterResolve,
preferred: def.preferred ?? false,
isFree: def.isFree ?? false
}))
);
// utils/buildPullfrogFooter.ts
var PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->";
var FROG_LOGO = `<a href="https://pullfrog.com"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pullfrog.com/logos/frog-white-full-18px.png"><img src="https://pullfrog.com/logos/frog-green-full-18px.png" width="9px" height="9px" style="vertical-align: middle; " alt="Pullfrog"></picture></a>`;
function formatModelLabel(slug) {
const alias = modelAliases.find((a) => a.slug === slug);
if (!alias) return `\`${slug}\``;
return alias.isFree ? `\`${alias.displayName}\` (free)` : `\`${alias.displayName}\``;
}
function buildPullfrogFooter(params) {
const parts = [];
if (params.customParts) {
@@ -37527,11 +37800,10 @@ function buildPullfrogFooter(params) {
if (params.triggeredBy) {
parts.push("Triggered by [Pullfrog](https://pullfrog.com)");
}
const allParts = [
...parts,
"[pullfrog.com](https://pullfrog.com)",
"[\u{1D54F}](https://x.com/pullfrogai)"
];
if (params.model) {
parts.push(`Using ${formatModelLabel(params.model)}`);
}
const allParts = [...parts, "[\u{1D54F}](https://x.com/pullfrogai)"];
return `
${PULLFROG_DIVIDER}
<sup>${FROG_LOGO}&nbsp;&nbsp;\uFF5C ${allParts.join(" \uFF5C ")}</sup>`;
@@ -41256,8 +41528,8 @@ var LEAPING_INTO_ACTION_PREFIX = "Leaping into action";
var Comment = type({
issueNumber: type.number.describe("the issue number to comment on"),
body: type.string.describe("the comment body content"),
type: type.enumerated("Plan", "Comment").describe(
"Plan: record this comment as the plan for this run (use report_progress for progress/plan updates on the current run). Comment: regular comment (default)."
type: type.enumerated("Plan", "Summary", "Comment").describe(
"Plan: record as the plan for this run. Summary: record as the PR summary comment (one per PR, updated in place). Comment: regular comment (default)."
).optional()
});
var EditComment = type({
@@ -41284,7 +41556,7 @@ var core3 = __toESM(require_core(), 1);
// package.json
var package_default = {
name: "@pullfrog/pullfrog",
version: "0.0.179",
version: "0.0.182",
type: "module",
files: [
"index.js",
+8 -8
View File
@@ -19,20 +19,20 @@ exports[`latest model per provider snapshot > matches snapshot 1`] = `
"releaseDate": "2026-01",
},
"openai": {
"modelId": "gpt-5.4",
"releaseDate": "2026-03-05",
"modelId": "gpt-5.4-nano",
"releaseDate": "2026-03-17",
},
"opencode": {
"modelId": "nemotron-3-super-free",
"releaseDate": "2026-03-11",
"modelId": "mimo-v2-pro-free",
"releaseDate": "2026-03-18",
},
"openrouter": {
"modelId": "openrouter/hunter-alpha",
"releaseDate": "2026-03-11",
"modelId": "xiaomi/mimo-v2-pro",
"releaseDate": "2026-03-18",
},
"xai": {
"modelId": "grok-4.20-experimental-beta-0304-reasoning",
"releaseDate": "2026-03-04",
"modelId": "grok-4.20-multi-agent-0309",
"releaseDate": "2026-03-09",
},
}
`;
+1 -1
View File
@@ -63,7 +63,7 @@ const dynamicAgentsExpression = "$" + "{{ fromJSON(needs.changes.outputs.agents)
const expectedAgentEnvVars = [
"GITHUB_TOKEN",
...new Set(Object.values(providers).flatMap((p) => [...p.envVars])),
"OPENCODE_MODEL",
"PULLFROG_MODEL",
].sort();
const expectedAgnosticEnvVars = ["ANTHROPIC_API_KEY", "GITHUB_TOKEN"].sort();
+86 -2
View File
@@ -46,6 +46,82 @@ describe("models.dev validity", async () => {
}
});
// ── openRouterResolve coverage ─────────────────────────────────────────────────
// models that have no OpenRouter equivalent and require BYOK.
// add a model here ONLY when it genuinely doesn't exist on both models.dev and OpenRouter.
const BYOK_ONLY_MODELS = new Set(["openai/o3"]);
describe("openRouterResolve completeness", () => {
for (const alias of modelAliases) {
if (alias.isFree) continue;
if (BYOK_ONLY_MODELS.has(alias.slug)) continue;
it(`${alias.slug} has openRouterResolve`, () => {
expect(
alias.openRouterResolve,
`non-free model "${alias.slug}" is missing openRouterResolve — add it or add to BYOK_ONLY_MODELS`
).toBeDefined();
});
}
for (const alias of modelAliases) {
if (!alias.isFree) continue;
it(`${alias.slug} (free) does not need openRouterResolve`, () => {
expect(alias.openRouterResolve).toBeUndefined();
});
}
});
describe("openRouterResolve models.dev validity", async () => {
const data = await api;
const seen = new Set<string>();
for (const alias of modelAliases) {
if (!alias.openRouterResolve) continue;
if (seen.has(alias.openRouterResolve)) continue;
seen.add(alias.openRouterResolve);
const parsed = parseResolve(alias.openRouterResolve);
it(`${alias.openRouterResolve} exists on models.dev`, () => {
const providerData = data[parsed.provider];
expect(providerData, `provider "${parsed.provider}" not found on models.dev`).toBeDefined();
const model = providerData.models[parsed.modelId];
expect(
model,
`model "${parsed.modelId}" not found under ${parsed.provider} on models.dev`
).toBeDefined();
});
}
});
type OpenRouterModel = { id: string };
type OpenRouterModelsResponse = { data: OpenRouterModel[] };
const openRouterApi = fetch("https://openrouter.ai/api/v1/models").then(
(r) => r.json() as Promise<OpenRouterModelsResponse>
);
describe("openRouterResolve OpenRouter API validity", async () => {
const orData = await openRouterApi;
const orModelIds = new Set(orData.data.map((m) => m.id));
const seen = new Set<string>();
for (const alias of modelAliases) {
if (!alias.openRouterResolve) continue;
const orModelId = alias.openRouterResolve.slice("openrouter/".length);
if (seen.has(orModelId)) continue;
seen.add(orModelId);
it(`${orModelId} exists on OpenRouter`, () => {
expect(
orModelIds.has(orModelId),
`model "${orModelId}" not found in OpenRouter API (/api/v1/models)`
).toBe(true);
});
}
});
describe("latest model per provider snapshot", async () => {
const data = await api;
const providerKeys = Object.keys(providers) as ModelProvider[];
@@ -58,10 +134,16 @@ describe("latest model per provider snapshot", async () => {
let latest: { modelId: string; releaseDate: string } | undefined;
for (const [modelId, model] of Object.entries(providerData.models)) {
if (model.status === "deprecated") continue;
// skip non-GA models so beta/nightly churn doesn't break the snapshot
if (model.status) continue;
const rd = model.release_date;
if (!rd) continue;
if (!latest || rd > latest.releaseDate) {
// tiebreak by modelId for stable ordering when release dates match
if (
!latest ||
rd > latest.releaseDate ||
(rd === latest.releaseDate && modelId > latest.modelId)
) {
latest = { modelId, releaseDate: rd };
}
}
@@ -70,6 +152,8 @@ describe("latest model per provider snapshot", async () => {
}
}
// when this fails, a provider shipped a new model. check whether we need
// to add or update an alias in models.ts before updating the snapshot.
it("matches snapshot", () => {
expect(latestByProvider).toMatchSnapshot();
});
+1 -1
View File
@@ -307,7 +307,7 @@ async function runTestForAgent(ctx: RunContext): Promise<ValidationResult> {
// use anthropic sonnet to avoid google quota issues and gemini doom-looping
if (ctx.agent === "opentoad") {
env.OPENCODE_MODEL ??= "anthropic/claude-sonnet-4-5";
env.PULLFROG_MODEL ??= "anthropic/claude-sonnet-4-5";
}
// build file-based env vars for MCP servers that don't inherit parent env
-4
View File
@@ -216,10 +216,6 @@ export async function runAgentStreaming(options: RunStreamingOptions): Promise<A
GITHUB_OUTPUT: githubOutputFile,
};
// clear CI runner's GITHUB_TOKEN so ensureGitHubToken() mints a
// properly scoped token for the target GITHUB_REPOSITORY via OIDC
delete subEnv.GITHUB_TOKEN;
const child = spawn("node", ["play.ts", "--raw", JSON.stringify(fixture)], {
cwd: actionDir,
env: subEnv as Record<string, string>,
+75
View File
@@ -0,0 +1,75 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { validateAgentApiKey } from "./apiKeys.ts";
const base = {
agent: { name: "opentoad" },
owner: "test-owner",
name: "test-repo",
};
const savedEnv = { ...process.env };
beforeEach(() => {
// strip all known provider keys so tests start clean
for (const key of Object.keys(process.env)) {
if (key.endsWith("_API_KEY")) delete process.env[key];
}
});
afterEach(() => {
process.env = { ...savedEnv };
});
describe("validateAgentApiKey", () => {
describe("free model (no keys required)", () => {
it("passes with zero env keys", () => {
expect(() => validateAgentApiKey({ ...base, model: "opencode/big-pickle" })).not.toThrow();
});
it("passes for other free opencode models", () => {
for (const slug of [
"opencode/gpt-5-nano",
"opencode/mimo-v2-pro-free",
"opencode/minimax-m2.5-free",
"opencode/nemotron-3-super-free",
]) {
expect(() => validateAgentApiKey({ ...base, model: slug })).not.toThrow();
}
});
});
describe("keyed model", () => {
it("passes when the required key is present", () => {
process.env.ANTHROPIC_API_KEY = "sk-test";
expect(() => validateAgentApiKey({ ...base, model: "anthropic/claude-opus" })).not.toThrow();
});
it("throws when the required key is missing", () => {
expect(() => validateAgentApiKey({ ...base, model: "anthropic/claude-opus" })).toThrow(
"no API key found"
);
});
it("passes for opencode keyed model with OPENCODE_API_KEY", () => {
process.env.OPENCODE_API_KEY = "sk-test";
expect(() => validateAgentApiKey({ ...base, model: "opencode/claude-opus" })).not.toThrow();
});
it("throws for opencode keyed model without OPENCODE_API_KEY", () => {
expect(() => validateAgentApiKey({ ...base, model: "opencode/claude-opus" })).toThrow(
"no API key found"
);
});
});
describe("no model (auto-select)", () => {
it("passes when any known provider key is present", () => {
process.env.OPENAI_API_KEY = "sk-test";
expect(() => validateAgentApiKey({ ...base, model: undefined })).not.toThrow();
});
it("throws when no provider keys are present", () => {
expect(() => validateAgentApiKey({ ...base, model: undefined })).toThrow("no API key found");
});
});
});
+28 -5
View File
@@ -1,4 +1,4 @@
import { providers } from "../models.ts";
import { getModelEnvVars, providers } from "../models.ts";
import { getApiUrl } from "./apiUrl.ts";
const knownApiKeys: Set<string> = new Set(Object.values(providers).flatMap((p) => [...p.envVars]));
@@ -20,18 +20,41 @@ to fix this, add the required secret to your GitHub repository:
4. set the value to your API key
5. click "Add secret"
configure your model at ${settingsUrl}`;
configure your model at ${settingsUrl}
for full setup instructions, see https://docs.pullfrog.com/keys`;
}
function hasEnvVar(name: string): boolean {
const value = process.env[name];
return typeof value === "string" && value.length > 0;
}
/** check if the user has a BYOK key for the given model's provider (does not throw) */
export function hasProviderKey(model: string): boolean {
const requiredVars = getModelEnvVars(model);
if (requiredVars.length === 0) return true;
return requiredVars.some((v) => hasEnvVar(v));
}
export function validateAgentApiKey(params: {
agent: { name: string };
model: string | undefined;
owner: string;
name: string;
}): void {
const hasAnyKey = Object.entries(process.env).some(
([key, value]) => value && typeof value === "string" && knownApiKeys.has(key)
);
// if a specific model is configured, only check that model's required env vars
if (params.model) {
const requiredVars = getModelEnvVars(params.model);
// free models have no required env vars — skip validation entirely
if (requiredVars.length === 0) return;
if (requiredVars.some((v) => hasEnvVar(v))) return;
throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name }));
}
// no model configured — auto-select requires at least one known provider key
const hasAnyKey = [...knownApiKeys].some((k) => hasEnvVar(k));
if (!hasAnyKey) {
throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name }));
}
+18 -8
View File
@@ -1,3 +1,5 @@
import { modelAliases } from "../models.ts";
export const PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->";
const FROG_LOGO = `<a href="https://pullfrog.com"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pullfrog.com/logos/frog-white-full-18px.png"><img src="https://pullfrog.com/logos/frog-green-full-18px.png" width="9px" height="9px" style="vertical-align: middle; " alt="Pullfrog"></picture></a>`;
@@ -18,13 +20,21 @@ export interface BuildPullfrogFooterParams {
/** alternative: just pass a pre-built URL directly (for shortlinks etc.) */
workflowRunUrl?: string | undefined;
/** arbitrary custom parts (e.g., action links) */
customParts?: string[];
customParts?: string[] | undefined;
/** model slug from payload (e.g., "anthropic/claude-opus"). shown in footer as "Using `Model Name`" */
model?: string | undefined;
}
function formatModelLabel(slug: string): string {
const alias = modelAliases.find((a) => a.slug === slug);
if (!alias) return `\`${slug}\``;
return alias.isFree ? `\`${alias.displayName}\` (free)` : `\`${alias.displayName}\``;
}
/**
* build a pullfrog footer with configurable parts
* always includes: frog logo at start, pullfrog.com link and X link at end
* order: action links (customParts) > workflow run > attribution > reference links
* always includes: frog logo at start and X link at end
* order: action links (customParts) > workflow run > model > attribution > reference links
*/
export function buildPullfrogFooter(params: BuildPullfrogFooterParams): string {
const parts: string[] = [];
@@ -45,11 +55,11 @@ export function buildPullfrogFooter(params: BuildPullfrogFooterParams): string {
parts.push("Triggered by [Pullfrog](https://pullfrog.com)");
}
const allParts = [
...parts,
"[pullfrog.com](https://pullfrog.com)",
"[𝕏](https://x.com/pullfrogai)",
];
if (params.model) {
parts.push(`Using ${formatModelLabel(params.model)}`);
}
const allParts = [...parts, "[𝕏](https://x.com/pullfrogai)"];
return `
${PULLFROG_DIVIDER}
+1 -1
View File
@@ -117,7 +117,7 @@ const testEnvAllowList = new Set([
"ANTHROPIC_API_KEY",
"GEMINI_API_KEY",
"GOOGLE_GENERATIVE_AI_API_KEY",
"OPENCODE_MODEL",
"PULLFROG_MODEL",
"LOG_LEVEL",
"DEBUG",
"NODE_ENV",
+1
View File
@@ -36,6 +36,7 @@ export async function reportErrorToComment(ctx: ReportErrorParams): Promise<void
triggeredBy: true,
workflowRun: runId ? { owner: repoContext.owner, repo: repoContext.name, runId } : undefined,
customParts,
model: ctx.toolState.model,
});
await octokit.rest.issues.updateComment({
+28 -11
View File
@@ -277,11 +277,17 @@ const findInstallationId = async (
// for local development only
async function acquireTokenViaGitHubApp(opts?: AcquireTokenOptions): Promise<string> {
if (!process.env.GITHUB_APP_ID || !process.env.GITHUB_PRIVATE_KEY) {
throw new Error(
"cannot acquire token via GitHub App: GITHUB_APP_ID and GITHUB_PRIVATE_KEY must be set"
);
}
const repoContext = parseRepoContext();
const config: GitHubAppConfig = {
appId: process.env.GITHUB_APP_ID!,
privateKey: process.env.GITHUB_PRIVATE_KEY?.replace(/\\n/g, "\n")!,
appId: process.env.GITHUB_APP_ID,
privateKey: process.env.GITHUB_PRIVATE_KEY.replace(/\\n/g, "\n"),
repoOwner: repoContext.owner,
repoName: repoContext.name,
};
@@ -292,20 +298,31 @@ async function acquireTokenViaGitHubApp(opts?: AcquireTokenOptions): Promise<str
}
/**
* Ensure a GitHub token is available in the environment.
* ensure a GitHub token is available in the environment.
*
* If neither `GITHUB_TOKEN` nor `GH_TOKEN` is set, attempts to acquire an
* installation token using `GITHUB_APP_ID` / `GITHUB_PRIVATE_KEY`.
* when OIDC is available (CI), always mints a fresh token scoped to
* GITHUB_REPOSITORY overriding any inherited GITHUB_TOKEN that may
* be scoped to the wrong repo.
*
* **Not intended for production use** this is a convenience for local
* development and test harnesses where tokens aren't pre-provisioned.
* otherwise falls back to GitHub App credentials for local development.
*
* only called from play.ts (test/dev path) the live action calls
* main() directly and never calls this.
*/
export async function ensureGitHubToken(): Promise<void> {
// when OIDC is available, always mint a fresh token scoped to
// GITHUB_REPOSITORY. the inherited GITHUB_TOKEN may be scoped to a
// different repo (e.g., runner token for pullfrog/app when tests
// target pullfrog/test-repo).
if (isOIDCAvailable()) {
const token = await acquireNewToken();
process.env.GITHUB_TOKEN = token;
return;
}
if (!process.env.GITHUB_TOKEN && !process.env.GH_TOKEN) {
if (isOIDCAvailable() || (process.env.GITHUB_APP_ID && process.env.GITHUB_PRIVATE_KEY)) {
const token = await acquireNewToken();
process.env.GITHUB_TOKEN = token;
}
const token = await acquireNewToken();
process.env.GITHUB_TOKEN = token;
}
}
+3
View File
@@ -161,6 +161,9 @@ export function resolvePayload(
// permissions: inputs > repoSettings > fallbacks
push: inputs.push ?? repoSettings.push ?? "restricted",
shell: resolvedShell,
// set by proxy logic in main.ts when routing through OpenRouter
proxyModel: undefined as string | undefined,
};
}
+7
View File
@@ -23,6 +23,8 @@ export interface RepoSettings {
export interface RunContext {
settings: RepoSettings;
apiToken: string;
oss: boolean;
proxyModel?: string | undefined;
}
const defaultSettings: RepoSettings = {
@@ -39,6 +41,7 @@ const defaultSettings: RepoSettings = {
const defaultRunContext: RunContext = {
settings: defaultSettings,
apiToken: "",
oss: false,
};
/**
@@ -73,6 +76,8 @@ export async function fetchRunContext(params: {
const data = (await response.json()) as {
settings: RepoSettings | null;
apiToken: string;
oss?: boolean;
proxyModel?: string;
} | null;
if (data === null) {
@@ -88,6 +93,8 @@ export async function fetchRunContext(params: {
postCheckoutScript: data.settings?.postCheckoutScript ?? null,
},
apiToken: data.apiToken,
oss: data.oss ?? false,
proxyModel: data.proxyModel,
};
} catch {
clearTimeout(timeoutId);
+4
View File
@@ -12,6 +12,8 @@ export interface RunContextData {
};
repoSettings: RepoSettings;
apiToken: string;
oss: boolean;
proxyModel?: string | undefined;
}
interface ResolveRunContextDataParams {
@@ -42,5 +44,7 @@ export async function resolveRunContextData(
},
repoSettings: runContext.settings,
apiToken: runContext.apiToken,
oss: runContext.oss,
proxyModel: runContext.proxyModel,
};
}