Compare commits

...

5 Commits

Author SHA1 Message Date
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
17 changed files with 431 additions and 128 deletions
+1
View File
@@ -41,6 +41,7 @@ jobs:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }} MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
PULLFROG_MODEL: ${{ vars.PULLFROG_MODEL }}
OPENCODE_MODEL: ${{ vars.OPENCODE_MODEL }} OPENCODE_MODEL: ${{ vars.OPENCODE_MODEL }}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
+5 -4
View File
@@ -75,7 +75,7 @@ function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): s
// ── model resolution (see wiki/model-resolution.md) ───────────────────────────── // ── model resolution (see wiki/model-resolution.md) ─────────────────────────────
// //
// priority: // priority:
// 1. OPENCODE_MODEL env var (explicit override) // 1. PULLFROG_MODEL or OPENCODE_MODEL env var (explicit override)
// 2. explicit slug from repo config / payload // 2. explicit slug from repo config / payload
// 3. auto-select: `opencode models` → recommended aliases first, then secondary // 3. auto-select: `opencode models` → recommended aliases first, then secondary
// 4. undefined → let OpenCode decide // 4. undefined → let OpenCode decide
@@ -106,10 +106,11 @@ function resolveOpenCodeModel(ctx: {
cliPath: string; cliPath: string;
modelSlug?: string | undefined; modelSlug?: string | undefined;
}): string | undefined { }): string | undefined {
// 1. explicit env var override // 1. explicit env var override (PULLFROG_MODEL takes precedence over OPENCODE_MODEL)
const envModel = process.env.OPENCODE_MODEL?.trim(); const envModel = process.env.PULLFROG_MODEL?.trim() || process.env.OPENCODE_MODEL?.trim();
if (envModel) { if (envModel) {
log.info(`» model: ${envModel} (override via OPENCODE_MODEL)`); const source = process.env.PULLFROG_MODEL?.trim() ? "PULLFROG_MODEL" : "OPENCODE_MODEL";
log.info(`» model: ${envModel} (override via ${source})`);
return envModel; return envModel;
} }
+150 -54
View File
@@ -131987,21 +131987,42 @@ var providers = {
"big-pickle": { "big-pickle": {
displayName: "Big Pickle", displayName: "Big Pickle",
resolve: "opencode/big-pickle", resolve: "opencode/big-pickle",
recommended: true recommended: true,
envVars: [],
isFree: true
}, },
"claude-opus": { displayName: "Claude Opus", resolve: "opencode/claude-opus-4-6" }, "claude-opus": { displayName: "Claude Opus", resolve: "opencode/claude-opus-4-6" },
"claude-sonnet": { displayName: "Claude Sonnet", resolve: "opencode/claude-sonnet-4-6" }, "claude-sonnet": { displayName: "Claude Sonnet", resolve: "opencode/claude-sonnet-4-6" },
"claude-haiku": { displayName: "Claude Haiku", resolve: "opencode/claude-haiku-4-5" }, "claude-haiku": { displayName: "Claude Haiku", resolve: "opencode/claude-haiku-4-5" },
"gpt-codex": { displayName: "GPT Codex", resolve: "opencode/gpt-5.3-codex" }, "gpt-codex": { displayName: "GPT Codex", resolve: "opencode/gpt-5.3-codex" },
"gpt-codex-mini": { displayName: "GPT Codex Mini", resolve: "opencode/gpt-5.1-codex-mini" },
"gemini-pro": { displayName: "Gemini Pro", resolve: "opencode/gemini-3.1-pro" }, "gemini-pro": { displayName: "Gemini Pro", resolve: "opencode/gemini-3.1-pro" },
"gemini-flash": { displayName: "Gemini Flash", resolve: "opencode/gemini-3-flash" }, "gemini-flash": { displayName: "Gemini Flash", resolve: "opencode/gemini-3-flash" },
"kimi-k2": { displayName: "Kimi K2", resolve: "opencode/kimi-k2.5" }, "kimi-k2": { displayName: "Kimi K2", resolve: "opencode/kimi-k2.5" },
"gpt-5-nano": { displayName: "GPT-5 Nano", resolve: "opencode/gpt-5-nano" }, "gpt-5-nano": {
displayName: "GPT Nano",
resolve: "opencode/gpt-5-nano",
envVars: [],
isFree: true
},
"mimo-v2-flash-free": { "mimo-v2-flash-free": {
displayName: "MiMo V2 Flash", displayName: "MiMo V2 Flash",
resolve: "opencode/mimo-v2-flash-free" resolve: "opencode/mimo-v2-flash-free",
envVars: [],
isFree: true
}, },
"minimax-m2.5-free": { displayName: "MiniMax M2.5", resolve: "opencode/minimax-m2.5-free" } "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({ openrouter: provider({
@@ -132026,6 +132047,7 @@ var providers = {
displayName: "GPT Codex Mini", displayName: "GPT Codex Mini",
resolve: "openrouter/openai/gpt-5.1-codex-mini" resolve: "openrouter/openai/gpt-5.1-codex-mini"
}, },
"o4-mini": { displayName: "O4 Mini", resolve: "openrouter/openai/o4-mini" },
"gemini-pro": { "gemini-pro": {
displayName: "Gemini Pro", displayName: "Gemini Pro",
resolve: "openrouter/google/gemini-3.1-pro-preview" resolve: "openrouter/google/gemini-3.1-pro-preview"
@@ -132043,13 +132065,33 @@ var providers = {
} }
}) })
}; };
function parseModel(slug) {
const slashIdx = slug.indexOf("/");
if (slashIdx === -1) {
throw new Error(`invalid model slug "${slug}" \u2014 expected "provider/model"`);
}
return { provider: slug.slice(0, slashIdx), model: slug.slice(slashIdx + 1) };
}
function getModelEnvVars(slug) {
const parsed2 = parseModel(slug);
const providerConfig = providers[parsed2.provider];
if (!providerConfig) {
return [];
}
const modelConfig = providerConfig.models[parsed2.model];
if (modelConfig?.envVars) {
return modelConfig.envVars.slice();
}
return providerConfig.envVars.slice();
}
var modelAliases = Object.entries(providers).flatMap( var modelAliases = Object.entries(providers).flatMap(
([providerKey, config3]) => Object.entries(config3.models).map(([modelId, def]) => ({ ([providerKey, config3]) => Object.entries(config3.models).map(([modelId, def]) => ({
slug: `${providerKey}/${modelId}`, slug: `${providerKey}/${modelId}`,
provider: providerKey, provider: providerKey,
displayName: def.displayName, displayName: def.displayName,
resolve: def.resolve, resolve: def.resolve,
recommended: def.recommended ?? false recommended: def.recommended ?? false,
isFree: def.isFree ?? false
})) }))
); );
function resolveModelSlug(slug) { function resolveModelSlug(slug) {
@@ -145088,6 +145130,26 @@ function CreateCommentTool(ctx) {
parameters: Comment, parameters: Comment,
execute: execute(async ({ issueNumber, body, type: commentType }) => { execute: execute(async ({ issueNumber, body, type: commentType }) => {
const bodyWithFooter = await addFooter(ctx, body); const bodyWithFooter = await addFooter(ctx, body);
if (commentType === "Summary" && ctx.toolState.existingSummaryCommentId) {
log.info(
`\xBB redirecting create_issue_comment(Summary) to update existing comment ${ctx.toolState.existingSummaryCommentId}`
);
const result2 = await ctx.octokit.rest.issues.updateComment({
owner: ctx.repo.owner,
repo: ctx.repo.name,
comment_id: ctx.toolState.existingSummaryCommentId,
body: bodyWithFooter
});
if (result2.data.node_id) {
await updateCommentNodeId(ctx, "summaryCommentNodeId", result2.data.node_id);
}
return {
success: true,
commentId: result2.data.id,
url: result2.data.html_url,
body: result2.data.body
};
}
const result = await ctx.octokit.rest.issues.createComment({ const result = await ctx.octokit.rest.issues.createComment({
owner: ctx.repo.owner, owner: ctx.repo.owner,
repo: ctx.repo.name, repo: ctx.repo.name,
@@ -146793,8 +146855,10 @@ function PullRequestInfoTool(ctx) {
} }
// mcp/review.ts // mcp/review.ts
function isStatusError(err) { function getHttpStatus(err) {
return typeof err === "object" && err !== null && "status" in err && typeof err.status === "number"; if (typeof err !== "object" || err === null) return void 0;
const status = err.status;
return typeof status === "number" ? status : void 0;
} }
var CreatePullRequestReview = type({ var CreatePullRequestReview = type({
pull_number: type.number.describe("The pull request number to review"), pull_number: type.number.describe("The pull request number to review"),
@@ -146810,7 +146874,7 @@ var CreatePullRequestReview = type({
"The file path to comment on (relative to repo root). Must be a file that appears in the PR diff." "The file path to comment on (relative to repo root). Must be a file that appears in the PR diff."
), ),
line: type.number.describe( 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").describe( side: type.enumerated("LEFT", "RIGHT").describe(
"Side of the diff: LEFT (old code, lines starting with -) or RIGHT (new code, lines starting with + or unchanged). Defaults to RIGHT." "Side of the diff: LEFT (old code, lines starting with -) or RIGHT (new code, lines starting with + or unchanged). Defaults to RIGHT."
@@ -146820,8 +146884,8 @@ var CreatePullRequestReview = type({
"Full replacement code for the line range [start_line, line]. MUST preserve the exact indentation of the original code." "Full replacement code for the line range [start_line, line]. MUST preserve the exact indentation of the original code."
).optional(), ).optional(),
start_line: type.number.describe( 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 for multi-line comment ranges. Omit for single-line comments. The range [start_line, line] defines which lines a suggestion replaces."
) ).optional()
}).array().describe( }).array().describe(
"Inline comments on lines within diff hunks. Feedback about code outside the diff goes in 'body' instead." "Inline comments on lines within diff hunks. Feedback about code outside the diff goes in 'body' instead."
).optional() ).optional()
@@ -146829,7 +146893,7 @@ var CreatePullRequestReview = type({
function CreatePullRequestReviewTool(ctx) { function CreatePullRequestReviewTool(ctx) {
return tool({ return tool({
name: "create_pull_request_review", name: "create_pull_request_review",
description: `Submit a review for an existing pull request. 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.`, description: `Submit a review for an existing pull request. Each call creates a permanent, visible review on the PR \u2014 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. If GitHub rejects comments due to incorrect line numbers, re-read the diff and retry.`,
parameters: CreatePullRequestReview, parameters: CreatePullRequestReview,
execute: execute(async ({ pull_number, body, approved, commit_id, comments = [] }) => { execute: execute(async ({ pull_number, body, approved, commit_id, comments = [] }) => {
if (body) body = fixDoubleEscapedString(body); if (body) body = fixDoubleEscapedString(body);
@@ -146845,6 +146909,7 @@ function CreatePullRequestReviewTool(ctx) {
pull_number, pull_number,
event event
}; };
let latestHeadSha;
if (commit_id) { if (commit_id) {
params.commit_id = commit_id; params.commit_id = commit_id;
} else { } else {
@@ -146853,42 +146918,54 @@ function CreatePullRequestReviewTool(ctx) {
repo: ctx.repo.name, repo: ctx.repo.name,
pull_number pull_number
}); });
params.commit_id = pr.data.head.sha; latestHeadSha = pr.data.head.sha;
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) { const reviewComments = comments.map((comment) => {
params.comments = comments.map((comment) => { let commentBody = fixDoubleEscapedString(comment.body || "");
let commentBody = fixDoubleEscapedString(comment.body || ""); if (comment.suggestion !== void 0) {
if (comment.suggestion !== void 0) { const suggestionBlock = "```suggestion\n" + comment.suggestion + "\n```";
const suggestionBlock = "```suggestion\n" + comment.suggestion + "\n```"; commentBody = commentBody ? commentBody + "\n\n" + suggestionBlock : suggestionBlock;
commentBody = commentBody ? commentBody + "\n\n" + suggestionBlock : suggestionBlock; }
} const side = comment.side || "RIGHT";
const side = comment.side || "RIGHT"; const reviewComment = {
const reviewComment = { path: comment.path,
path: comment.path, line: comment.line,
line: comment.line, body: commentBody,
body: commentBody, side
side, };
start_line: comment.start_line, if (comment.start_line != null && comment.start_line !== comment.line) {
start_side: side reviewComment.start_line = comment.start_line;
}; reviewComment.start_side = side;
return reviewComment; }
}); return reviewComment;
});
if (reviewComments.length > 0) {
params.comments = reviewComments;
} }
let result; let result;
try { try {
result = body ? await createAndSubmitWithFooter(ctx, params, { result = body ? await createAndSubmitWithFooter(ctx, params, {
body, body,
approved: approved ?? false, approved: approved ?? false,
hasComments: comments.length > 0 hasComments: reviewComments.length > 0
}) : await ctx.octokit.rest.pulls.createReview(params); }) : await ctx.octokit.rest.pulls.createReview(params);
} catch (err) { } catch (err) {
if (isStatusError(err) && err.status === 422 && params.comments?.length) { if (getHttpStatus(err) !== 422 || !params.comments?.length) throw err;
const paths = [...new Set(params.comments.map((comment) => comment.path))]; const details = params.comments.map((c) => {
throw new Error( const line = c.line ?? 0;
`${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.` const startLine = c.start_line ?? line;
); const range2 = startLine !== line ? `${startLine}-${line}` : `${line}`;
} return `${c.path}:${range2} (${c.side ?? "RIGHT"})`;
throw err; });
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)}`); log.debug(`createReview response: ${JSON.stringify(result.data)}`);
if (!result.data.id) { if (!result.data.id) {
@@ -146902,10 +146979,9 @@ function CreatePullRequestReviewTool(ctx) {
nodeId: reviewNodeId, nodeId: reviewNodeId,
reviewedSha: actuallyReviewedSha reviewedSha: actuallyReviewedSha
}; };
const headMovedDuringReview = ctx.toolState.checkoutSha && params.commit_id !== ctx.toolState.checkoutSha; if (ctx.toolState.checkoutSha && latestHeadSha && latestHeadSha !== ctx.toolState.checkoutSha) {
if (headMovedDuringReview) {
const fromSha = ctx.toolState.checkoutSha; const fromSha = ctx.toolState.checkoutSha;
const toSha = params.commit_id; const toSha = latestHeadSha;
ctx.toolState.checkoutSha = toSha; ctx.toolState.checkoutSha = toSha;
log.info( log.info(
`new commits detected during review: ${fromSha.slice(0, 7)}..${toSha.slice(0, 7)}` `new commits detected during review: ${fromSha.slice(0, 7)}..${toSha.slice(0, 7)}`
@@ -147676,12 +147752,12 @@ function buildOrchestratorGuidance(mode, opts = {}) {
}; };
} }
async function fetchExistingPlanComment(ctx, issueNumber) { async function fetchExistingPlanComment(ctx, issueNumber) {
if (!ctx.apiToken) return null; if (!ctx.githubInstallationToken) return null;
try { try {
const response = await apiFetch({ const response = await apiFetch({
path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/issue/${issueNumber}/plan-comment`, path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/issue/${issueNumber}/plan-comment`,
method: "GET", method: "GET",
headers: { authorization: `Bearer ${ctx.apiToken}` }, headers: { authorization: `Bearer ${ctx.githubInstallationToken}` },
signal: AbortSignal.timeout(1e4) signal: AbortSignal.timeout(1e4)
}); });
const data = await response.json(); const data = await response.json();
@@ -147691,17 +147767,27 @@ async function fetchExistingPlanComment(ctx, issueNumber) {
} }
} }
async function fetchExistingSummaryComment(ctx, prNumber) { async function fetchExistingSummaryComment(ctx, prNumber) {
if (!ctx.apiToken) return null; if (!ctx.githubInstallationToken) {
log.warning("fetchExistingSummaryComment: no token, skipping");
return null;
}
const path3 = `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/pr/${prNumber}/summary-comment`;
try { try {
const response = await apiFetch({ const response = await apiFetch({
path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/pr/${prNumber}/summary-comment`, path: path3,
method: "GET", method: "GET",
headers: { authorization: `Bearer ${ctx.apiToken}` }, headers: { authorization: `Bearer ${ctx.githubInstallationToken}` },
signal: AbortSignal.timeout(1e4) signal: AbortSignal.timeout(1e4)
}); });
const data = await response.json(); const data = await response.json();
return response.ok && "commentId" in data ? data : null; if (response.ok && "commentId" in data) {
} catch { return data;
}
const errMsg = "error" in data ? data.error : "(no error body)";
log.warning(`fetchExistingSummaryComment: ${response.status} ${path3} \u2014 ${errMsg}`);
return null;
} catch (error49) {
log.warning("fetchExistingSummaryComment failed:", error49);
return null; return null;
} }
} }
@@ -148757,9 +148843,10 @@ function getOpenCodeModels(cliPath) {
} }
var AUTO_SELECT_WARNING = "select a model explicitly in the Pullfrog console (https://pullfrog.com/console) to avoid this."; var AUTO_SELECT_WARNING = "select a model explicitly in the Pullfrog console (https://pullfrog.com/console) to avoid this.";
function resolveOpenCodeModel(ctx) { function resolveOpenCodeModel(ctx) {
const envModel = process.env.OPENCODE_MODEL?.trim(); const envModel = process.env.PULLFROG_MODEL?.trim() || process.env.OPENCODE_MODEL?.trim();
if (envModel) { if (envModel) {
log.info(`\xBB model: ${envModel} (override via OPENCODE_MODEL)`); const source = process.env.PULLFROG_MODEL?.trim() ? "PULLFROG_MODEL" : "OPENCODE_MODEL";
log.info(`\xBB model: ${envModel} (override via ${source})`);
return envModel; return envModel;
} }
if (ctx.modelSlug) { if (ctx.modelSlug) {
@@ -149144,10 +149231,18 @@ to fix this, add the required secret to your GitHub repository:
configure your model at ${settingsUrl}`; configure your model at ${settingsUrl}`;
} }
function hasEnvVar(name) {
const value2 = process.env[name];
return typeof value2 === "string" && value2.length > 0;
}
function validateAgentApiKey(params) { function validateAgentApiKey(params) {
const hasAnyKey = Object.entries(process.env).some( if (params.model) {
([key, value2]) => value2 && typeof value2 === "string" && knownApiKeys.has(key) const requiredVars = getModelEnvVars(params.model);
); if (requiredVars.length === 0) return;
if (requiredVars.some((v) => hasEnvVar(v))) return;
throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name }));
}
const hasAnyKey = [...knownApiKeys].some((k) => hasEnvVar(k));
if (!hasAnyKey) { if (!hasAnyKey) {
throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name })); throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name }));
} }
@@ -149737,7 +149832,7 @@ import { isAbsolute, resolve } from "node:path";
// package.json // package.json
var package_default = { var package_default = {
name: "@pullfrog/pullfrog", name: "@pullfrog/pullfrog",
version: "0.0.180", version: "0.0.181",
type: "module", type: "module",
files: [ files: [
"index.js", "index.js",
@@ -150306,6 +150401,7 @@ async function main() {
const agent2 = resolveAgent(); const agent2 = resolveAgent();
validateAgentApiKey({ validateAgentApiKey({
agent: agent2, agent: agent2,
model: payload.model,
owner: runContext.repo.owner, owner: runContext.repo.owner,
name: runContext.repo.name name: runContext.repo.name
}); });
+1
View File
@@ -151,6 +151,7 @@ export async function main(): Promise<MainResult> {
validateAgentApiKey({ validateAgentApiKey({
agent, agent,
model: payload.model,
owner: runContext.repo.owner, owner: runContext.repo.owner,
name: runContext.repo.name, name: runContext.repo.name,
}); });
+26 -1
View File
@@ -11,7 +11,8 @@ import { execute, tool } from "./shared.ts";
type CommentNodeIdField = "planCommentNodeId" | "summaryCommentNodeId"; type CommentNodeIdField = "planCommentNodeId" | "summaryCommentNodeId";
/** PATCH workflow-run with a comment node_id so future runs can update that comment in place. */ // 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( export async function updateCommentNodeId(
ctx: ToolContext, ctx: ToolContext,
field: CommentNodeIdField, field: CommentNodeIdField,
@@ -129,6 +130,30 @@ export function CreateCommentTool(ctx: ToolContext) {
execute: execute(async ({ issueNumber, body, type: commentType }) => { execute: execute(async ({ issueNumber, body, type: commentType }) => {
const bodyWithFooter = await addFooter(ctx, body); 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({ const result = await ctx.octokit.rest.issues.createComment({
owner: ctx.repo.owner, owner: ctx.repo.owner,
repo: ctx.repo.name, repo: ctx.repo.name,
+66 -47
View File
@@ -8,10 +8,10 @@ import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts";
import type { ToolContext } from "./server.ts"; import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
function isStatusError(err: unknown): err is { status: number; message?: string } { function getHttpStatus(err: unknown): number | undefined {
return ( if (typeof err !== "object" || err === null) return undefined;
typeof err === "object" && err !== null && "status" in err && typeof err.status === "number" const status = (err as Record<string, unknown>).status;
); return typeof status === "number" ? status : undefined;
} }
// one-shot review tool // 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." "The file path to comment on (relative to repo root). Must be a file that appears in the PR diff."
), ),
line: type.number.describe( 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 side: type
.enumerated("LEFT", "RIGHT") .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." "Full replacement code for the line range [start_line, line]. MUST preserve the exact indentation of the original code."
) )
.optional(), .optional(),
start_line: type.number.describe( start_line: type.number
"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." .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() .array()
.describe( .describe(
@@ -67,14 +69,14 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
name: "create_pull_request_review", name: "create_pull_request_review",
description: description:
"Submit a review for an existing pull request. " + "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. " + "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. " + "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. " + "Use 'suggestion' to propose replacement code - MUST preserve exact indentation of original code. " +
"Example replacing lines 42-44 (3 lines) with 5 lines: " + "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 }' }` + `{ 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." + " 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." + " If GitHub rejects comments due to incorrect line numbers, re-read the diff and retry.",
" Put feedback about code outside the diff in 'body' instead.",
parameters: CreatePullRequestReview, parameters: CreatePullRequestReview,
execute: execute(async ({ pull_number, body, approved, commit_id, comments = [] }) => { execute: execute(async ({ pull_number, body, approved, commit_id, comments = [] }) => {
if (body) body = fixDoubleEscapedString(body); if (body) body = fixDoubleEscapedString(body);
@@ -95,6 +97,7 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
pull_number, pull_number,
event, event,
}; };
let latestHeadSha: string | undefined;
if (commit_id) { if (commit_id) {
params.commit_id = commit_id; params.commit_id = commit_id;
} else { } else {
@@ -103,28 +106,39 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
repo: ctx.repo.name, repo: ctx.repo.name,
pull_number, 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 = NonNullable<typeof params.comments>[number];
type ReviewComment = (typeof params.comments & {})[number]; const reviewComments = comments.map((comment) => {
params.comments = comments.map((comment) => { let commentBody = fixDoubleEscapedString(comment.body || "");
let commentBody = fixDoubleEscapedString(comment.body || ""); if (comment.suggestion !== undefined) {
if (comment.suggestion !== undefined) { const suggestionBlock = "```suggestion\n" + comment.suggestion + "\n```";
const suggestionBlock = "```suggestion\n" + comment.suggestion + "\n```"; commentBody = commentBody ? commentBody + "\n\n" + suggestionBlock : suggestionBlock;
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"; if (reviewComments.length > 0) {
const reviewComment: ReviewComment = { params.comments = reviewComments;
path: comment.path,
line: comment.line,
body: commentBody,
side,
start_line: comment.start_line,
start_side: side,
};
return reviewComment;
});
} }
// no body → single-step createReview (no footer needed) // no body → single-step createReview (no footer needed)
@@ -135,20 +149,24 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
? await createAndSubmitWithFooter(ctx, params, { ? await createAndSubmitWithFooter(ctx, params, {
body, body,
approved: approved ?? false, approved: approved ?? false,
hasComments: comments.length > 0, hasComments: reviewComments.length > 0,
}) })
: await ctx.octokit.rest.pulls.createReview(params); : await ctx.octokit.rest.pulls.createReview(params);
} catch (err: unknown) { } catch (err: unknown) {
if (isStatusError(err) && err.status === 422 && params.comments?.length) { if (getHttpStatus(err) !== 422 || !params.comments?.length) throw err;
const paths = [...new Set(params.comments.map((comment) => comment.path))];
throw new Error( const details = params.comments.map((c) => {
`${err.message ?? "422 Unprocessable Entity"}. ` + const line = c.line ?? 0;
`The review had ${params.comments.length} inline comment(s) targeting these paths: ${paths.join(", ")}. ` + const startLine = c.start_line ?? line;
`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). ` + const range = startLine !== line ? `${startLine}-${line}` : `${line}`;
`Fix: remove the failing comment(s) and retry. Put their feedback in the review body instead.` return `${c.path}:${range} (${c.side ?? "RIGHT"})`;
); });
} throw new Error(
throw err; `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)}`); log.debug(`createReview response: ${JSON.stringify(result.data)}`);
if (!result.data.id) { if (!result.data.id) {
@@ -169,12 +187,13 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
// detect commits pushed since checkout and guide the agent to review them // detect commits pushed since checkout and guide the agent to review them
// inline instead of dispatching a separate workflow run // inline instead of dispatching a separate workflow run
const headMovedDuringReview = if (
ctx.toolState.checkoutSha && params.commit_id !== ctx.toolState.checkoutSha; ctx.toolState.checkoutSha &&
latestHeadSha &&
if (headMovedDuringReview) { latestHeadSha !== ctx.toolState.checkoutSha
const fromSha = ctx.toolState.checkoutSha!; ) {
const toSha = params.commit_id!; const fromSha = ctx.toolState.checkoutSha;
const toSha = latestHeadSha;
// advance checkoutSha so the next review submission tracks correctly // advance checkoutSha so the next review submission tracks correctly
ctx.toolState.checkoutSha = toSha; ctx.toolState.checkoutSha = toSha;
+21 -7
View File
@@ -2,6 +2,7 @@ import { type } from "arktype";
import { ghPullfrogMcpName } from "../external.ts"; import { ghPullfrogMcpName } from "../external.ts";
import type { Mode } from "../modes.ts"; import type { Mode } from "../modes.ts";
import { apiFetch } from "../utils/apiFetch.ts"; import { apiFetch } from "../utils/apiFetch.ts";
import { log } from "../utils/log.ts";
import type { ToolContext } from "./server.ts"; import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
@@ -253,16 +254,19 @@ export type PlanCommentResponsePayload = { error: string } | { commentId: number
// matches the API response for /repo/[owner]/[repo]/pr/[prNumber]/summary-comment // matches the API response for /repo/[owner]/[repo]/pr/[prNumber]/summary-comment
export type SummaryCommentResponsePayload = { error: string } | { commentId: number; body: string }; 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( async function fetchExistingPlanComment(
ctx: ToolContext, ctx: ToolContext,
issueNumber: number issueNumber: number
): Promise<Extract<PlanCommentResponsePayload, { commentId: number }> | null> { ): Promise<Extract<PlanCommentResponsePayload, { commentId: number }> | null> {
if (!ctx.apiToken) return null; if (!ctx.githubInstallationToken) return null;
try { try {
const response = await apiFetch({ const response = await apiFetch({
path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/issue/${issueNumber}/plan-comment`, path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/issue/${issueNumber}/plan-comment`,
method: "GET", method: "GET",
headers: { authorization: `Bearer ${ctx.apiToken}` }, headers: { authorization: `Bearer ${ctx.githubInstallationToken}` },
signal: AbortSignal.timeout(10_000), signal: AbortSignal.timeout(10_000),
}); });
const data = (await response.json()) as PlanCommentResponsePayload; const data = (await response.json()) as PlanCommentResponsePayload;
@@ -276,17 +280,27 @@ async function fetchExistingSummaryComment(
ctx: ToolContext, ctx: ToolContext,
prNumber: number prNumber: number
): Promise<Extract<SummaryCommentResponsePayload, { commentId: number }> | null> { ): Promise<Extract<SummaryCommentResponsePayload, { commentId: number }> | null> {
if (!ctx.apiToken) return 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 { try {
const response = await apiFetch({ const response = await apiFetch({
path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/pr/${prNumber}/summary-comment`, path,
method: "GET", method: "GET",
headers: { authorization: `Bearer ${ctx.apiToken}` }, headers: { authorization: `Bearer ${ctx.githubInstallationToken}` },
signal: AbortSignal.timeout(10_000), signal: AbortSignal.timeout(10_000),
}); });
const data = (await response.json()) as SummaryCommentResponsePayload; const data = (await response.json()) as SummaryCommentResponsePayload;
return response.ok && "commentId" in data ? data : null; if (response.ok && "commentId" in data) {
} catch { 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; return null;
} }
} }
+12
View File
@@ -47,6 +47,18 @@ describe("getModelEnvVars", () => {
it("returns empty array for unknown provider", () => { it("returns empty array for unknown provider", () => {
expect(getModelEnvVars("unknown/model")).toEqual([]); 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-flash-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", () => { describe("resolveModelSlug", () => {
+41 -4
View File
@@ -18,6 +18,8 @@ export interface ModelAlias {
resolve: string; resolve: string;
/** top-tier pick for this provider — preferred during auto-select */ /** top-tier pick for this provider — preferred during auto-select */
recommended: boolean; recommended: boolean;
/** whether this alias is free and requires no API key */
isFree: boolean;
} }
interface ModelDef { interface ModelDef {
@@ -25,6 +27,8 @@ interface ModelDef {
/** concrete models.dev specifier, e.g. "anthropic/claude-opus-4-6" */ /** concrete models.dev specifier, e.g. "anthropic/claude-opus-4-6" */
resolve: string; resolve: string;
recommended?: boolean; recommended?: boolean;
envVars?: readonly string[];
isFree?: boolean;
} }
export interface ProviderConfig { export interface ProviderConfig {
@@ -110,20 +114,41 @@ export const providers = {
displayName: "Big Pickle", displayName: "Big Pickle",
resolve: "opencode/big-pickle", resolve: "opencode/big-pickle",
recommended: true, recommended: true,
envVars: [],
isFree: true,
}, },
"claude-opus": { displayName: "Claude Opus", resolve: "opencode/claude-opus-4-6" }, "claude-opus": { displayName: "Claude Opus", resolve: "opencode/claude-opus-4-6" },
"claude-sonnet": { displayName: "Claude Sonnet", resolve: "opencode/claude-sonnet-4-6" }, "claude-sonnet": { displayName: "Claude Sonnet", resolve: "opencode/claude-sonnet-4-6" },
"claude-haiku": { displayName: "Claude Haiku", resolve: "opencode/claude-haiku-4-5" }, "claude-haiku": { displayName: "Claude Haiku", resolve: "opencode/claude-haiku-4-5" },
"gpt-codex": { displayName: "GPT Codex", resolve: "opencode/gpt-5.3-codex" }, "gpt-codex": { displayName: "GPT Codex", resolve: "opencode/gpt-5.3-codex" },
"gpt-codex-mini": { displayName: "GPT Codex Mini", resolve: "opencode/gpt-5.1-codex-mini" },
"gemini-pro": { displayName: "Gemini Pro", resolve: "opencode/gemini-3.1-pro" }, "gemini-pro": { displayName: "Gemini Pro", resolve: "opencode/gemini-3.1-pro" },
"gemini-flash": { displayName: "Gemini Flash", resolve: "opencode/gemini-3-flash" }, "gemini-flash": { displayName: "Gemini Flash", resolve: "opencode/gemini-3-flash" },
"kimi-k2": { displayName: "Kimi K2", resolve: "opencode/kimi-k2.5" }, "kimi-k2": { displayName: "Kimi K2", resolve: "opencode/kimi-k2.5" },
"gpt-5-nano": { displayName: "GPT-5 Nano", resolve: "opencode/gpt-5-nano" }, "gpt-5-nano": {
displayName: "GPT Nano",
resolve: "opencode/gpt-5-nano",
envVars: [],
isFree: true,
},
"mimo-v2-flash-free": { "mimo-v2-flash-free": {
displayName: "MiMo V2 Flash", displayName: "MiMo V2 Flash",
resolve: "opencode/mimo-v2-flash-free", resolve: "opencode/mimo-v2-flash-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({ openrouter: provider({
@@ -148,6 +173,7 @@ export const providers = {
displayName: "GPT Codex Mini", displayName: "GPT Codex Mini",
resolve: "openrouter/openai/gpt-5.1-codex-mini", resolve: "openrouter/openai/gpt-5.1-codex-mini",
}, },
"o4-mini": { displayName: "O4 Mini", resolve: "openrouter/openai/o4-mini" },
"gemini-pro": { "gemini-pro": {
displayName: "Gemini Pro", displayName: "Gemini Pro",
resolve: "openrouter/google/gemini-3.1-pro-preview", resolve: "openrouter/google/gemini-3.1-pro-preview",
@@ -183,8 +209,18 @@ export function getModelProvider(slug: string): string {
} }
export function getModelEnvVars(slug: string): string[] { export function getModelEnvVars(slug: string): string[] {
const p = getModelProvider(slug); const parsed = parseModel(slug);
return (providers as Record<string, ProviderConfig>)[p]?.envVars.slice() ?? []; 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 ────────────────────────────────────────────────────────── // ── derived flat list ──────────────────────────────────────────────────────────
@@ -197,6 +233,7 @@ export const modelAliases: ModelAlias[] = Object.entries(providers).flatMap(
displayName: def.displayName, displayName: def.displayName,
resolve: def.resolve, resolve: def.resolve,
recommended: def.recommended ?? false, recommended: def.recommended ?? false,
isFree: def.isFree ?? false,
})) }))
); );
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@pullfrog/pullfrog", "name": "@pullfrog/pullfrog",
"version": "0.0.180", "version": "0.0.181",
"type": "module", "type": "module",
"files": [ "files": [
"index.js", "index.js",
+1 -1
View File
@@ -41284,7 +41284,7 @@ var core3 = __toESM(require_core(), 1);
// package.json // package.json
var package_default = { var package_default = {
name: "@pullfrog/pullfrog", name: "@pullfrog/pullfrog",
version: "0.0.180", version: "0.0.181",
type: "module", type: "module",
files: [ files: [
"index.js", "index.js",
+3 -3
View File
@@ -19,7 +19,7 @@ exports[`latest model per provider snapshot > matches snapshot 1`] = `
"releaseDate": "2026-01", "releaseDate": "2026-01",
}, },
"openai": { "openai": {
"modelId": "gpt-5.4", "modelId": "gpt-5.4-pro",
"releaseDate": "2026-03-05", "releaseDate": "2026-03-05",
}, },
"opencode": { "opencode": {
@@ -31,8 +31,8 @@ exports[`latest model per provider snapshot > matches snapshot 1`] = `
"releaseDate": "2026-03-11", "releaseDate": "2026-03-11",
}, },
"xai": { "xai": {
"modelId": "grok-4.20-experimental-beta-0304-reasoning", "modelId": "grok-4-1-fast-non-reasoning",
"releaseDate": "2026-03-04", "releaseDate": "2025-11-19",
}, },
} }
`; `;
+1
View File
@@ -63,6 +63,7 @@ const dynamicAgentsExpression = "$" + "{{ fromJSON(needs.changes.outputs.agents)
const expectedAgentEnvVars = [ const expectedAgentEnvVars = [
"GITHUB_TOKEN", "GITHUB_TOKEN",
...new Set(Object.values(providers).flatMap((p) => [...p.envVars])), ...new Set(Object.values(providers).flatMap((p) => [...p.envVars])),
"PULLFROG_MODEL",
"OPENCODE_MODEL", "OPENCODE_MODEL",
].sort(); ].sort();
+8 -2
View File
@@ -58,10 +58,16 @@ describe("latest model per provider snapshot", async () => {
let latest: { modelId: string; releaseDate: string } | undefined; let latest: { modelId: string; releaseDate: string } | undefined;
for (const [modelId, model] of Object.entries(providerData.models)) { 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; const rd = model.release_date;
if (!rd) continue; 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 }; latest = { modelId, releaseDate: rd };
} }
} }
+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-flash-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");
});
});
});
+18 -4
View File
@@ -1,4 +1,4 @@
import { providers } from "../models.ts"; import { getModelEnvVars, providers } from "../models.ts";
import { getApiUrl } from "./apiUrl.ts"; import { getApiUrl } from "./apiUrl.ts";
const knownApiKeys: Set<string> = new Set(Object.values(providers).flatMap((p) => [...p.envVars])); const knownApiKeys: Set<string> = new Set(Object.values(providers).flatMap((p) => [...p.envVars]));
@@ -23,15 +23,29 @@ to fix this, add the required secret to your GitHub repository:
configure your model at ${settingsUrl}`; configure your model at ${settingsUrl}`;
} }
function hasEnvVar(name: string): boolean {
const value = process.env[name];
return typeof value === "string" && value.length > 0;
}
export function validateAgentApiKey(params: { export function validateAgentApiKey(params: {
agent: { name: string }; agent: { name: string };
model: string | undefined;
owner: string; owner: string;
name: string; name: string;
}): void { }): void {
const hasAnyKey = Object.entries(process.env).some( // if a specific model is configured, only check that model's required env vars
([key, value]) => value && typeof value === "string" && knownApiKeys.has(key) 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) { if (!hasAnyKey) {
throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name })); throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name }));
} }
+1
View File
@@ -117,6 +117,7 @@ const testEnvAllowList = new Set([
"ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY",
"GEMINI_API_KEY", "GEMINI_API_KEY",
"GOOGLE_GENERATIVE_AI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY",
"PULLFROG_MODEL",
"OPENCODE_MODEL", "OPENCODE_MODEL",
"LOG_LEVEL", "LOG_LEVEL",
"DEBUG", "DEBUG",