Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6d34ee01b | |||
| 39525547b5 | |||
| 3ff11f97eb | |||
| b31800c213 | |||
| 3a1ffde545 | |||
| cccf1775d6 | |||
| 026cc7a276 | |||
| c6a3ee0e9a | |||
| 30d68e53a7 | |||
| 8a734c32f4 | |||
| 2e37fb3dfa | |||
| cbbcb64859 | |||
| df9598ea5f | |||
| 250fe7eaa1 | |||
| 4a8c432a48 |
@@ -25,7 +25,7 @@ jobs:
|
|||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
@@ -41,11 +41,11 @@ 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 }}
|
||||||
OPENCODE_MODEL: ${{ vars.OPENCODE_MODEL }}
|
PULLFROG_MODEL: ${{ vars.PULLFROG_MODEL }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
@@ -79,7 +79,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|||||||
+13
-11
@@ -75,9 +75,9 @@ 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 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` → preferred aliases first, then secondary
|
||||||
// 4. undefined → let OpenCode decide
|
// 4. undefined → let OpenCode decide
|
||||||
|
|
||||||
function getOpenCodeModels(cliPath: string): string[] {
|
function getOpenCodeModels(cliPath: string): string[] {
|
||||||
@@ -107,9 +107,9 @@ function resolveOpenCodeModel(ctx: {
|
|||||||
modelSlug?: string | undefined;
|
modelSlug?: string | undefined;
|
||||||
}): string | undefined {
|
}): string | undefined {
|
||||||
// 1. explicit env var override
|
// 1. explicit env var override
|
||||||
const envModel = process.env.OPENCODE_MODEL?.trim();
|
const envModel = process.env.PULLFROG_MODEL?.trim();
|
||||||
if (envModel) {
|
if (envModel) {
|
||||||
log.info(`» model: ${envModel} (override via OPENCODE_MODEL)`);
|
log.info(`» model: ${envModel} (override via PULLFROG_MODEL)`);
|
||||||
return envModel;
|
return envModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,17 +125,17 @@ function resolveOpenCodeModel(ctx: {
|
|||||||
|
|
||||||
// 3. auto-select: ask OpenCode what's available, pick our best curated match.
|
// 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.
|
// `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 availableModels = getOpenCodeModels(ctx.cliPath);
|
||||||
const availableSet = new Set(availableModels);
|
const availableSet = new Set(availableModels);
|
||||||
if (availableSet.size > 0) {
|
if (availableSet.size > 0) {
|
||||||
log.debug(`» opencode models (${availableSet.size}): ${availableModels.join(", ")}`);
|
log.debug(`» opencode models (${availableSet.size}): ${availableModels.join(", ")}`);
|
||||||
const match =
|
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));
|
modelAliases.find((a) => availableSet.has(a.resolve));
|
||||||
if (match) {
|
if (match) {
|
||||||
log.info(
|
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}`);
|
log.warning(`» model auto-selected. ${AUTO_SELECT_WARNING}`);
|
||||||
return match.resolve;
|
return match.resolve;
|
||||||
@@ -621,10 +621,12 @@ export const opentoad = agent({
|
|||||||
run: async (ctx) => {
|
run: async (ctx) => {
|
||||||
const cliPath = await installOpencodeCli();
|
const cliPath = await installOpencodeCli();
|
||||||
|
|
||||||
const model = resolveOpenCodeModel({
|
const model =
|
||||||
cliPath,
|
ctx.payload.proxyModel ??
|
||||||
modelSlug: ctx.payload.model,
|
resolveOpenCodeModel({
|
||||||
});
|
cliPath,
|
||||||
|
modelSlug: ctx.payload.model,
|
||||||
|
});
|
||||||
|
|
||||||
const tempHome = ctx.tmpdir;
|
const tempHome = ctx.tmpdir;
|
||||||
mkdirSync(join(tempHome, ".config", "opencode"), { recursive: true });
|
mkdirSync(join(tempHome, ".config", "opencode"), { recursive: true });
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ export const agent = (input: Agent): Agent => {
|
|||||||
return {
|
return {
|
||||||
...input,
|
...input,
|
||||||
run: async (ctx: AgentRunContext): Promise<AgentResult> => {
|
run: async (ctx: AgentRunContext): Promise<AgentResult> => {
|
||||||
log.info(`» agent: ${input.name}`);
|
|
||||||
if (ctx.payload.model) log.info(`» model: ${ctx.payload.model}`);
|
if (ctx.payload.model) log.info(`» model: ${ctx.payload.model}`);
|
||||||
if (ctx.payload.timeout) log.info(`» timeout: ${ctx.payload.timeout}`);
|
if (ctx.payload.timeout) log.info(`» timeout: ${ctx.payload.timeout}`);
|
||||||
log.info(`» push: ${ctx.payload.push}`);
|
log.info(`» push: ${ctx.payload.push}`);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type { ModelAlias, ModelProvider, ProviderConfig } from "./models.ts";
|
|||||||
export {
|
export {
|
||||||
getModelEnvVars,
|
getModelEnvVars,
|
||||||
getModelProvider,
|
getModelProvider,
|
||||||
|
getProviderDisplayName,
|
||||||
modelAliases,
|
modelAliases,
|
||||||
parseModel,
|
parseModel,
|
||||||
providers,
|
providers,
|
||||||
|
|||||||
@@ -25898,10 +25898,15 @@ var findInstallationId = async (jwt, repoOwner, repoName) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
async function acquireTokenViaGitHubApp(opts) {
|
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 repoContext = parseRepoContext();
|
||||||
const config = {
|
const config = {
|
||||||
appId: process.env.GITHUB_APP_ID,
|
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,
|
repoOwner: repoContext.owner,
|
||||||
repoName: repoContext.name
|
repoName: repoContext.name
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type {
|
|||||||
export {
|
export {
|
||||||
getModelEnvVars,
|
getModelEnvVars,
|
||||||
getModelProvider,
|
getModelProvider,
|
||||||
|
getProviderDisplayName,
|
||||||
ghPullfrogMcpName,
|
ghPullfrogMcpName,
|
||||||
modelAliases,
|
modelAliases,
|
||||||
parseModel,
|
parseModel,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
DEFAULT_ACTIVITY_TIMEOUT_MS,
|
DEFAULT_ACTIVITY_TIMEOUT_MS,
|
||||||
} from "./utils/activity.ts";
|
} from "./utils/activity.ts";
|
||||||
import { resolveAgent } from "./utils/agent.ts";
|
import { resolveAgent } from "./utils/agent.ts";
|
||||||
|
import { apiFetch } from "./utils/apiFetch.ts";
|
||||||
import { validateAgentApiKey } from "./utils/apiKeys.ts";
|
import { validateAgentApiKey } from "./utils/apiKeys.ts";
|
||||||
import { resolveBody } from "./utils/body.ts";
|
import { resolveBody } from "./utils/body.ts";
|
||||||
import { formatUsageSummary, log, writeSummary } from "./utils/cli.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>;
|
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> {
|
async function writeJobSummary(toolState: ToolState): Promise<void> {
|
||||||
const usageSummary = formatUsageSummary(toolState.usageEntries);
|
const usageSummary = formatUsageSummary(toolState.usageEntries);
|
||||||
const summaryParts = [toolState.lastProgressBody, usageSummary].filter(Boolean);
|
const summaryParts = [toolState.lastProgressBody, usageSummary].filter(Boolean);
|
||||||
@@ -103,18 +169,35 @@ export async function main(): Promise<MainResult> {
|
|||||||
|
|
||||||
// resolve payload to determine shell permission
|
// resolve payload to determine shell permission
|
||||||
const payload = resolvePayload(resolvedPromptInput, runContext.repoSettings);
|
const payload = resolvePayload(resolvedPromptInput, runContext.repoSettings);
|
||||||
|
toolState.model = payload.model;
|
||||||
|
|
||||||
// resolve tokens:
|
// resolve tokens first — acquireNewToken needs OIDC env vars for token exchange
|
||||||
// - gitToken: contents permission based on push setting (assumed exfiltratable)
|
|
||||||
// - mcpToken: full installation token (not exfiltratable via MCP tools)
|
|
||||||
await using tokenRef = await resolveTokens({ push: payload.push });
|
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
|
// clear OIDC env vars in restricted mode to prevent agent from minting tokens
|
||||||
if (payload.shell !== "enabled") {
|
if (payload.shell !== "enabled") {
|
||||||
delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
||||||
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
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
|
// create octokit with MCP token for GitHub API calls
|
||||||
const octokit = createOctokit(tokenRef.mcpToken);
|
const octokit = createOctokit(tokenRef.mcpToken);
|
||||||
|
|
||||||
@@ -151,6 +234,7 @@ export async function main(): Promise<MainResult> {
|
|||||||
|
|
||||||
validateAgentApiKey({
|
validateAgentApiKey({
|
||||||
agent,
|
agent,
|
||||||
|
model: payload.proxyModel ?? payload.model,
|
||||||
owner: runContext.repo.owner,
|
owner: runContext.repo.owner,
|
||||||
name: runContext.repo.name,
|
name: runContext.repo.name,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
export function CheckoutPrTool(ctx: ToolContext) {
|
||||||
return tool({
|
return tool({
|
||||||
name: "checkout_pr",
|
name: "checkout_pr",
|
||||||
@@ -352,6 +390,16 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
|||||||
postCheckoutScript: ctx.postCheckoutScript,
|
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({
|
const pr = await ctx.octokit.rest.pulls.get({
|
||||||
owner: ctx.repo.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.repo.name,
|
repo: ctx.repo.name,
|
||||||
|
|||||||
+61
-20
@@ -9,8 +9,15 @@ import { retry } from "../utils/retry.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";
|
||||||
|
|
||||||
/** PATCH workflow-run with plan comment node_id so plan revisions can update that comment in place. */
|
type CommentNodeIdField = "planCommentNodeId" | "summaryCommentNodeId";
|
||||||
async function updatePlanCommentId(ctx: ToolContext, planCommentNodeId: string): Promise<void> {
|
|
||||||
|
// 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;
|
if (ctx.runId === undefined || !ctx.apiToken) return;
|
||||||
try {
|
try {
|
||||||
await retry(
|
await retry(
|
||||||
@@ -22,7 +29,7 @@ async function updatePlanCommentId(ctx: ToolContext, planCommentNodeId: string):
|
|||||||
authorization: `Bearer ${ctx.apiToken}`,
|
authorization: `Bearer ${ctx.apiToken}`,
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ planCommentNodeId }),
|
body: JSON.stringify({ [field]: nodeId }),
|
||||||
signal: AbortSignal.timeout(10_000),
|
signal: AbortSignal.timeout(10_000),
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(`PATCH workflow-run: ${response.status}`);
|
if (!response.ok) throw new Error(`PATCH workflow-run: ${response.status}`);
|
||||||
@@ -30,11 +37,11 @@ async function updatePlanCommentId(ctx: ToolContext, planCommentNodeId: string):
|
|||||||
{
|
{
|
||||||
maxAttempts: 3,
|
maxAttempts: 3,
|
||||||
delayMs: 2000,
|
delayMs: 2000,
|
||||||
label: "updatePlanCommentId",
|
label: `updateCommentNodeId(${field})`,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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 {
|
interface BuildCommentFooterParams {
|
||||||
octokit?: OctokitWithPlugins | undefined;
|
octokit?: OctokitWithPlugins | undefined;
|
||||||
customParts?: string[] | undefined;
|
customParts?: string[] | undefined;
|
||||||
|
model?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildCommentFooter(params: BuildCommentFooterParams): Promise<string> {
|
async function buildCommentFooter(params: BuildCommentFooterParams): Promise<string> {
|
||||||
@@ -70,17 +78,14 @@ async function buildCommentFooter(params: BuildCommentFooterParams): Promise<str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const footerParams = {
|
return buildPullfrogFooter({
|
||||||
triggeredBy: true,
|
triggeredBy: true,
|
||||||
workflowRun: runId
|
workflowRun: runId
|
||||||
? { owner: repoContext.owner, repo: repoContext.name, runId, jobId }
|
? { owner: repoContext.owner, repo: repoContext.name, runId, jobId }
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
customParts: params.customParts,
|
||||||
|
model: params.model,
|
||||||
if (params.customParts && params.customParts.length > 0) {
|
});
|
||||||
return buildPullfrogFooter({ ...footerParams, customParts: params.customParts });
|
|
||||||
}
|
|
||||||
return buildPullfrogFooter(footerParams);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildImplementPlanLink(
|
function buildImplementPlanLink(
|
||||||
@@ -95,11 +100,17 @@ function buildImplementPlanLink(
|
|||||||
|
|
||||||
export interface AddFooterCtx {
|
export interface AddFooterCtx {
|
||||||
octokit?: OctokitWithPlugins | undefined;
|
octokit?: OctokitWithPlugins | undefined;
|
||||||
|
toolState?: { model?: string | undefined } | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addFooter(ctx: AddFooterCtx, body: string): Promise<string> {
|
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 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}`;
|
return `${bodyWithoutFooter}${footer}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,9 +118,9 @@ export const Comment = type({
|
|||||||
issueNumber: type.number.describe("the issue number to comment on"),
|
issueNumber: type.number.describe("the issue number to comment on"),
|
||||||
body: type.string.describe("the comment body content"),
|
body: type.string.describe("the comment body content"),
|
||||||
type: type
|
type: type
|
||||||
.enumerated("Plan", "Comment")
|
.enumerated("Plan", "Summary", "Comment")
|
||||||
.describe(
|
.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(),
|
.optional(),
|
||||||
});
|
});
|
||||||
@@ -118,11 +129,35 @@ export function CreateCommentTool(ctx: ToolContext) {
|
|||||||
return tool({
|
return tool({
|
||||||
name: "create_issue_comment",
|
name: "create_issue_comment",
|
||||||
description:
|
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,
|
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 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,
|
||||||
@@ -131,7 +166,10 @@ export function CreateCommentTool(ctx: ToolContext) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (commentType === "Plan" && result.data.node_id) {
|
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 {
|
return {
|
||||||
@@ -228,6 +266,7 @@ export async function reportProgress(
|
|||||||
const footer = await buildCommentFooter({
|
const footer = await buildCommentFooter({
|
||||||
octokit: ctx.octokit,
|
octokit: ctx.octokit,
|
||||||
customParts,
|
customParts,
|
||||||
|
model: ctx.toolState.model,
|
||||||
});
|
});
|
||||||
const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
|
const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
|
||||||
|
|
||||||
@@ -241,7 +280,7 @@ export async function reportProgress(
|
|||||||
ctx.toolState.wasUpdated = true;
|
ctx.toolState.wasUpdated = true;
|
||||||
|
|
||||||
if (isPlanMode && result.data.node_id) {
|
if (isPlanMode && result.data.node_id) {
|
||||||
await updatePlanCommentId(ctx, result.data.node_id);
|
await updateCommentNodeId(ctx, "planCommentNodeId", result.data.node_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -265,6 +304,7 @@ export async function reportProgress(
|
|||||||
const footer = await buildCommentFooter({
|
const footer = await buildCommentFooter({
|
||||||
octokit: ctx.octokit,
|
octokit: ctx.octokit,
|
||||||
customParts,
|
customParts,
|
||||||
|
model: ctx.toolState.model,
|
||||||
});
|
});
|
||||||
const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
|
const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
|
||||||
|
|
||||||
@@ -278,7 +318,7 @@ export async function reportProgress(
|
|||||||
ctx.toolState.wasUpdated = true;
|
ctx.toolState.wasUpdated = true;
|
||||||
|
|
||||||
if (isPlanMode && result.data.node_id) {
|
if (isPlanMode && result.data.node_id) {
|
||||||
await updatePlanCommentId(ctx, result.data.node_id);
|
await updateCommentNodeId(ctx, "planCommentNodeId", result.data.node_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -325,6 +365,7 @@ export async function reportProgress(
|
|||||||
const footer = await buildCommentFooter({
|
const footer = await buildCommentFooter({
|
||||||
octokit: ctx.octokit,
|
octokit: ctx.octokit,
|
||||||
customParts,
|
customParts,
|
||||||
|
model: ctx.toolState.model,
|
||||||
});
|
});
|
||||||
const bodyWithPlanLink = `${bodyWithoutFooter}${footer}`;
|
const bodyWithPlanLink = `${bodyWithoutFooter}${footer}`;
|
||||||
|
|
||||||
@@ -336,7 +377,7 @@ export async function reportProgress(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (updateResult.data.node_id) {
|
if (updateResult.data.node_id) {
|
||||||
await updatePlanCommentId(ctx, updateResult.data.node_id);
|
await updateCommentNodeId(ctx, "planCommentNodeId", updateResult.data.node_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
+11
@@ -218,6 +218,8 @@ const NOSHELL_BLOCKED_SUBCOMMANDS: Record<string, string> = {
|
|||||||
// (avoids false positives like --exclude matching --exec)
|
// (avoids false positives like --exclude matching --exec)
|
||||||
const NOSHELL_BLOCKED_ARGS = ["--exec", "--extcmd", "--upload-pack", "--receive-pack"];
|
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.
|
// 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.
|
// 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 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 };
|
return { success: true, output };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ function buildPrBodyWithFooter(ctx: ToolContext, body: string): string {
|
|||||||
workflowRun: ctx.runId
|
workflowRun: ctx.runId
|
||||||
? { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId }
|
? { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId }
|
||||||
: undefined,
|
: undefined,
|
||||||
|
model: ctx.toolState.model,
|
||||||
});
|
});
|
||||||
|
|
||||||
const bodyWithoutFooter = stripExistingFooter(fixDoubleEscapedString(body));
|
const bodyWithoutFooter = stripExistingFooter(fixDoubleEscapedString(body));
|
||||||
|
|||||||
+79
-47
@@ -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);
|
||||||
@@ -82,6 +84,18 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
|||||||
// set issue context (PRs are issues)
|
// set issue context (PRs are issues)
|
||||||
ctx.toolState.issueNumber = pull_number;
|
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
|
// enforce prApproveEnabled: downgrade APPROVE to COMMENT if disabled
|
||||||
let event: "APPROVE" | "COMMENT" = approved ? "APPROVE" : "COMMENT";
|
let event: "APPROVE" | "COMMENT" = approved ? "APPROVE" : "COMMENT";
|
||||||
if (event === "APPROVE" && !ctx.prApproveEnabled) {
|
if (event === "APPROVE" && !ctx.prApproveEnabled) {
|
||||||
@@ -95,6 +109,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 +118,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 +161,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 +199,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;
|
||||||
|
|
||||||
@@ -245,6 +276,7 @@ async function createAndSubmitWithFooter(
|
|||||||
? { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId }
|
? { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId }
|
||||||
: undefined,
|
: undefined,
|
||||||
customParts,
|
customParts,
|
||||||
|
model: ctx.toolState.model,
|
||||||
});
|
});
|
||||||
|
|
||||||
return ctx.octokit.rest.pulls.submitReview({
|
return ctx.octokit.rest.pulls.submitReview({
|
||||||
|
|||||||
+89
-3
@@ -2,12 +2,13 @@ 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";
|
||||||
|
|
||||||
export const SelectModeParams = type({
|
export const SelectModeParams = type({
|
||||||
mode: type.string.describe(
|
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(
|
"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)"
|
"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
|
- 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 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`,
|
- 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 = {
|
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
|
// matches the API response for /repo/[owner]/[repo]/issue/[issueNumber]/plan-comment
|
||||||
export type PlanCommentResponsePayload = { error: string } | { commentId: number; body: string };
|
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(
|
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;
|
||||||
@@ -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) {
|
export function SelectModeTool(ctx: ToolContext) {
|
||||||
return tool({
|
return tool({
|
||||||
name: "select_mode",
|
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);
|
return buildOrchestratorGuidance(selectedMode, guidanceOpts);
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)
|
// set by select_mode when Plan + issue_number and plan-comment API returns existing plan (for report_progress target_plan_comment)
|
||||||
existingPlanCommentId?: number;
|
existingPlanCommentId?: number;
|
||||||
previousPlanBody?: string;
|
previousPlanBody?: string;
|
||||||
|
// set by select_mode when Summarize mode and summary-comment API returns existing summary
|
||||||
|
existingSummaryCommentId?: number;
|
||||||
output?: string;
|
output?: string;
|
||||||
usageEntries: AgentUsage[];
|
usageEntries: AgentUsage[];
|
||||||
|
model?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InitToolStateParams {
|
interface InitToolStateParams {
|
||||||
|
|||||||
+15
-3
@@ -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-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", () => {
|
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)) {
|
for (const providerKey of Object.keys(providers)) {
|
||||||
const recommended = modelAliases.filter((a) => a.provider === providerKey && a.recommended);
|
const preferred = modelAliases.filter((a) => a.provider === providerKey && a.preferred);
|
||||||
expect(recommended.length, `${providerKey} should have exactly 1 recommended model`).toBe(1);
|
expect(preferred.length, `${providerKey} should have exactly 1 preferred model`).toBe(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,15 +16,23 @@ export interface ModelAlias {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
/** 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;
|
||||||
|
/** 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 */
|
/** 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 {
|
interface ModelDef {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
/** 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;
|
/** 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 {
|
export interface ProviderConfig {
|
||||||
@@ -47,19 +55,40 @@ export const providers = {
|
|||||||
"claude-opus": {
|
"claude-opus": {
|
||||||
displayName: "Claude Opus",
|
displayName: "Claude Opus",
|
||||||
resolve: "anthropic/claude-opus-4-6",
|
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({
|
openai: provider({
|
||||||
displayName: "OpenAI",
|
displayName: "OpenAI",
|
||||||
envVars: ["OPENAI_API_KEY"],
|
envVars: ["OPENAI_API_KEY"],
|
||||||
models: {
|
models: {
|
||||||
"gpt-codex": { displayName: "GPT Codex", resolve: "openai/gpt-5.3-codex", recommended: true },
|
"gpt-codex": {
|
||||||
"gpt-codex-mini": { displayName: "GPT Codex Mini", resolve: "openai/codex-mini-latest" },
|
displayName: "GPT Codex",
|
||||||
o3: { displayName: "O3", resolve: "openai/o3" },
|
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({
|
google: provider({
|
||||||
@@ -69,18 +98,36 @@ export const providers = {
|
|||||||
"gemini-pro": {
|
"gemini-pro": {
|
||||||
displayName: "Gemini Pro",
|
displayName: "Gemini Pro",
|
||||||
resolve: "google/gemini-3.1-pro-preview",
|
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({
|
xai: provider({
|
||||||
displayName: "xAI",
|
displayName: "xAI",
|
||||||
envVars: ["XAI_API_KEY"],
|
envVars: ["XAI_API_KEY"],
|
||||||
models: {
|
models: {
|
||||||
grok: { displayName: "Grok", resolve: "xai/grok-4", recommended: true },
|
grok: {
|
||||||
"grok-fast": { displayName: "Grok Fast", resolve: "xai/grok-4-fast" },
|
displayName: "Grok",
|
||||||
"grok-code-fast": { displayName: "Grok Code Fast", resolve: "xai/grok-code-fast-1" },
|
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({
|
deepseek: provider({
|
||||||
@@ -90,16 +137,26 @@ export const providers = {
|
|||||||
"deepseek-reasoner": {
|
"deepseek-reasoner": {
|
||||||
displayName: "DeepSeek Reasoner",
|
displayName: "DeepSeek Reasoner",
|
||||||
resolve: "deepseek/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({
|
moonshotai: provider({
|
||||||
displayName: "Moonshot AI",
|
displayName: "Moonshot AI",
|
||||||
envVars: ["MOONSHOT_API_KEY"],
|
envVars: ["MOONSHOT_API_KEY"],
|
||||||
models: {
|
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({
|
opencode: provider({
|
||||||
@@ -109,21 +166,74 @@ export const providers = {
|
|||||||
"big-pickle": {
|
"big-pickle": {
|
||||||
displayName: "Big Pickle",
|
displayName: "Big Pickle",
|
||||||
resolve: "opencode/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-opus": {
|
||||||
"claude-sonnet": { displayName: "Claude Sonnet", resolve: "opencode/claude-sonnet-4-6" },
|
displayName: "Claude Opus",
|
||||||
"claude-haiku": { displayName: "Claude Haiku", resolve: "opencode/claude-haiku-4-5" },
|
resolve: "opencode/claude-opus-4-6",
|
||||||
"gpt-codex": { displayName: "GPT Codex", resolve: "opencode/gpt-5.3-codex" },
|
openRouterResolve: "openrouter/anthropic/claude-opus-4.6",
|
||||||
"gemini-pro": { displayName: "Gemini Pro", resolve: "opencode/gemini-3.1-pro" },
|
},
|
||||||
"gemini-flash": { displayName: "Gemini Flash", resolve: "opencode/gemini-3-flash" },
|
"claude-sonnet": {
|
||||||
"kimi-k2": { displayName: "Kimi K2", resolve: "opencode/kimi-k2.5" },
|
displayName: "Claude Sonnet",
|
||||||
"gpt-5-nano": { displayName: "GPT-5 Nano", resolve: "opencode/gpt-5-nano" },
|
resolve: "opencode/claude-sonnet-4-6",
|
||||||
"mimo-v2-flash-free": {
|
openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6",
|
||||||
displayName: "MiMo V2 Flash",
|
},
|
||||||
resolve: "opencode/mimo-v2-flash-free",
|
"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({
|
openrouter: provider({
|
||||||
@@ -133,35 +243,59 @@ export const providers = {
|
|||||||
"claude-opus": {
|
"claude-opus": {
|
||||||
displayName: "Claude Opus",
|
displayName: "Claude Opus",
|
||||||
resolve: "openrouter/anthropic/claude-opus-4.6",
|
resolve: "openrouter/anthropic/claude-opus-4.6",
|
||||||
recommended: true,
|
openRouterResolve: "openrouter/anthropic/claude-opus-4.6",
|
||||||
|
preferred: true,
|
||||||
},
|
},
|
||||||
"claude-sonnet": {
|
"claude-sonnet": {
|
||||||
displayName: "Claude Sonnet",
|
displayName: "Claude Sonnet",
|
||||||
resolve: "openrouter/anthropic/claude-sonnet-4.6",
|
resolve: "openrouter/anthropic/claude-sonnet-4.6",
|
||||||
|
openRouterResolve: "openrouter/anthropic/claude-sonnet-4.6",
|
||||||
},
|
},
|
||||||
"claude-haiku": {
|
"claude-haiku": {
|
||||||
displayName: "Claude Haiku",
|
displayName: "Claude Haiku",
|
||||||
resolve: "openrouter/anthropic/claude-haiku-4.5",
|
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": {
|
"gpt-codex-mini": {
|
||||||
displayName: "GPT Codex Mini",
|
displayName: "GPT Codex Mini",
|
||||||
resolve: "openrouter/openai/gpt-5.1-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": {
|
"gemini-pro": {
|
||||||
displayName: "Gemini Pro",
|
displayName: "Gemini Pro",
|
||||||
resolve: "openrouter/google/gemini-3.1-pro-preview",
|
resolve: "openrouter/google/gemini-3.1-pro-preview",
|
||||||
|
openRouterResolve: "openrouter/google/gemini-3.1-pro-preview",
|
||||||
},
|
},
|
||||||
"gemini-flash": {
|
"gemini-flash": {
|
||||||
displayName: "Gemini Flash",
|
displayName: "Gemini Flash",
|
||||||
resolve: "openrouter/google/gemini-3-flash-preview",
|
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": {
|
"deepseek-chat": {
|
||||||
displayName: "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>;
|
} satisfies Record<string, ProviderConfig>;
|
||||||
@@ -182,9 +316,24 @@ export function getModelProvider(slug: string): string {
|
|||||||
return parseModel(slug).provider;
|
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[] {
|
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 ──────────────────────────────────────────────────────────
|
||||||
@@ -196,7 +345,9 @@ export const modelAliases: ModelAlias[] = Object.entries(providers).flatMap(
|
|||||||
provider: providerKey,
|
provider: providerKey,
|
||||||
displayName: def.displayName,
|
displayName: def.displayName,
|
||||||
resolve: def.resolve,
|
resolve: def.resolve,
|
||||||
recommended: def.recommended ?? false,
|
openRouterResolve: def.openRouterResolve,
|
||||||
|
preferred: def.preferred ?? false,
|
||||||
|
isFree: def.isFree ?? false,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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.`,
|
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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@pullfrog/pullfrog",
|
"name": "@pullfrog/pullfrog",
|
||||||
"version": "0.0.179",
|
"version": "0.0.182",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"index.js",
|
"index.js",
|
||||||
|
|||||||
@@ -37509,9 +37509,282 @@ function getApiUrl() {
|
|||||||
return raw;
|
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
|
// utils/buildPullfrogFooter.ts
|
||||||
var PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->";
|
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>`;
|
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) {
|
function buildPullfrogFooter(params) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (params.customParts) {
|
if (params.customParts) {
|
||||||
@@ -37527,11 +37800,10 @@ function buildPullfrogFooter(params) {
|
|||||||
if (params.triggeredBy) {
|
if (params.triggeredBy) {
|
||||||
parts.push("Triggered by [Pullfrog](https://pullfrog.com)");
|
parts.push("Triggered by [Pullfrog](https://pullfrog.com)");
|
||||||
}
|
}
|
||||||
const allParts = [
|
if (params.model) {
|
||||||
...parts,
|
parts.push(`Using ${formatModelLabel(params.model)}`);
|
||||||
"[pullfrog.com](https://pullfrog.com)",
|
}
|
||||||
"[\u{1D54F}](https://x.com/pullfrogai)"
|
const allParts = [...parts, "[\u{1D54F}](https://x.com/pullfrogai)"];
|
||||||
];
|
|
||||||
return `
|
return `
|
||||||
${PULLFROG_DIVIDER}
|
${PULLFROG_DIVIDER}
|
||||||
<sup>${FROG_LOGO} \uFF5C ${allParts.join(" \uFF5C ")}</sup>`;
|
<sup>${FROG_LOGO} \uFF5C ${allParts.join(" \uFF5C ")}</sup>`;
|
||||||
@@ -41256,8 +41528,8 @@ var LEAPING_INTO_ACTION_PREFIX = "Leaping into action";
|
|||||||
var Comment = type({
|
var Comment = type({
|
||||||
issueNumber: type.number.describe("the issue number to comment on"),
|
issueNumber: type.number.describe("the issue number to comment on"),
|
||||||
body: type.string.describe("the comment body content"),
|
body: type.string.describe("the comment body content"),
|
||||||
type: type.enumerated("Plan", "Comment").describe(
|
type: type.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()
|
).optional()
|
||||||
});
|
});
|
||||||
var EditComment = type({
|
var EditComment = type({
|
||||||
@@ -41284,7 +41556,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.179",
|
version: "0.0.182",
|
||||||
type: "module",
|
type: "module",
|
||||||
files: [
|
files: [
|
||||||
"index.js",
|
"index.js",
|
||||||
|
|||||||
@@ -19,20 +19,20 @@ 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-nano",
|
||||||
"releaseDate": "2026-03-05",
|
"releaseDate": "2026-03-17",
|
||||||
},
|
},
|
||||||
"opencode": {
|
"opencode": {
|
||||||
"modelId": "nemotron-3-super-free",
|
"modelId": "mimo-v2-pro-free",
|
||||||
"releaseDate": "2026-03-11",
|
"releaseDate": "2026-03-18",
|
||||||
},
|
},
|
||||||
"openrouter": {
|
"openrouter": {
|
||||||
"modelId": "openrouter/hunter-alpha",
|
"modelId": "xiaomi/mimo-v2-pro",
|
||||||
"releaseDate": "2026-03-11",
|
"releaseDate": "2026-03-18",
|
||||||
},
|
},
|
||||||
"xai": {
|
"xai": {
|
||||||
"modelId": "grok-4.20-experimental-beta-0304-reasoning",
|
"modelId": "grok-4.20-multi-agent-0309",
|
||||||
"releaseDate": "2026-03-04",
|
"releaseDate": "2026-03-09",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
+1
-1
@@ -63,7 +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])),
|
||||||
"OPENCODE_MODEL",
|
"PULLFROG_MODEL",
|
||||||
].sort();
|
].sort();
|
||||||
|
|
||||||
const expectedAgnosticEnvVars = ["ANTHROPIC_API_KEY", "GITHUB_TOKEN"].sort();
|
const expectedAgnosticEnvVars = ["ANTHROPIC_API_KEY", "GITHUB_TOKEN"].sort();
|
||||||
|
|||||||
+86
-2
@@ -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 () => {
|
describe("latest model per provider snapshot", async () => {
|
||||||
const data = await api;
|
const data = await api;
|
||||||
const providerKeys = Object.keys(providers) as ModelProvider[];
|
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;
|
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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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", () => {
|
it("matches snapshot", () => {
|
||||||
expect(latestByProvider).toMatchSnapshot();
|
expect(latestByProvider).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -307,7 +307,7 @@ async function runTestForAgent(ctx: RunContext): Promise<ValidationResult> {
|
|||||||
|
|
||||||
// use anthropic sonnet to avoid google quota issues and gemini doom-looping
|
// use anthropic sonnet to avoid google quota issues and gemini doom-looping
|
||||||
if (ctx.agent === "opentoad") {
|
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
|
// build file-based env vars for MCP servers that don't inherit parent env
|
||||||
|
|||||||
@@ -216,10 +216,6 @@ export async function runAgentStreaming(options: RunStreamingOptions): Promise<A
|
|||||||
GITHUB_OUTPUT: githubOutputFile,
|
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)], {
|
const child = spawn("node", ["play.ts", "--raw", JSON.stringify(fixture)], {
|
||||||
cwd: actionDir,
|
cwd: actionDir,
|
||||||
env: subEnv as Record<string, string>,
|
env: subEnv as Record<string, string>,
|
||||||
|
|||||||
@@ -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
@@ -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]));
|
||||||
@@ -20,18 +20,41 @@ to fix this, add the required secret to your GitHub repository:
|
|||||||
4. set the value to your API key
|
4. set the value to your API key
|
||||||
5. click "Add secret"
|
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: {
|
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,3 +1,5 @@
|
|||||||
|
import { modelAliases } from "../models.ts";
|
||||||
|
|
||||||
export const PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->";
|
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>`;
|
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.) */
|
/** alternative: just pass a pre-built URL directly (for shortlinks etc.) */
|
||||||
workflowRunUrl?: string | undefined;
|
workflowRunUrl?: string | undefined;
|
||||||
/** arbitrary custom parts (e.g., action links) */
|
/** 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
|
* build a pullfrog footer with configurable parts
|
||||||
* always includes: frog logo at start, pullfrog.com link and X link at end
|
* always includes: frog logo at start and X link at end
|
||||||
* order: action links (customParts) > workflow run > attribution > reference links
|
* order: action links (customParts) > workflow run > model > attribution > reference links
|
||||||
*/
|
*/
|
||||||
export function buildPullfrogFooter(params: BuildPullfrogFooterParams): string {
|
export function buildPullfrogFooter(params: BuildPullfrogFooterParams): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
@@ -45,11 +55,11 @@ export function buildPullfrogFooter(params: BuildPullfrogFooterParams): string {
|
|||||||
parts.push("Triggered by [Pullfrog](https://pullfrog.com)");
|
parts.push("Triggered by [Pullfrog](https://pullfrog.com)");
|
||||||
}
|
}
|
||||||
|
|
||||||
const allParts = [
|
if (params.model) {
|
||||||
...parts,
|
parts.push(`Using ${formatModelLabel(params.model)}`);
|
||||||
"[pullfrog.com](https://pullfrog.com)",
|
}
|
||||||
"[𝕏](https://x.com/pullfrogai)",
|
|
||||||
];
|
const allParts = [...parts, "[𝕏](https://x.com/pullfrogai)"];
|
||||||
|
|
||||||
return `
|
return `
|
||||||
${PULLFROG_DIVIDER}
|
${PULLFROG_DIVIDER}
|
||||||
|
|||||||
+1
-1
@@ -117,7 +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",
|
||||||
"OPENCODE_MODEL",
|
"PULLFROG_MODEL",
|
||||||
"LOG_LEVEL",
|
"LOG_LEVEL",
|
||||||
"DEBUG",
|
"DEBUG",
|
||||||
"NODE_ENV",
|
"NODE_ENV",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export async function reportErrorToComment(ctx: ReportErrorParams): Promise<void
|
|||||||
triggeredBy: true,
|
triggeredBy: true,
|
||||||
workflowRun: runId ? { owner: repoContext.owner, repo: repoContext.name, runId } : undefined,
|
workflowRun: runId ? { owner: repoContext.owner, repo: repoContext.name, runId } : undefined,
|
||||||
customParts,
|
customParts,
|
||||||
|
model: ctx.toolState.model,
|
||||||
});
|
});
|
||||||
|
|
||||||
await octokit.rest.issues.updateComment({
|
await octokit.rest.issues.updateComment({
|
||||||
|
|||||||
+28
-11
@@ -277,11 +277,17 @@ const findInstallationId = async (
|
|||||||
|
|
||||||
// for local development only
|
// for local development only
|
||||||
async function acquireTokenViaGitHubApp(opts?: AcquireTokenOptions): Promise<string> {
|
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 repoContext = parseRepoContext();
|
||||||
|
|
||||||
const config: GitHubAppConfig = {
|
const config: GitHubAppConfig = {
|
||||||
appId: process.env.GITHUB_APP_ID!,
|
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,
|
repoOwner: repoContext.owner,
|
||||||
repoName: repoContext.name,
|
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
|
* when OIDC is available (CI), always mints a fresh token scoped to
|
||||||
* installation token using `GITHUB_APP_ID` / `GITHUB_PRIVATE_KEY`.
|
* 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
|
* otherwise falls back to GitHub App credentials for local development.
|
||||||
* development and test harnesses where tokens aren't pre-provisioned.
|
*
|
||||||
|
* only called from play.ts (test/dev path) — the live action calls
|
||||||
|
* main() directly and never calls this.
|
||||||
*/
|
*/
|
||||||
export async function ensureGitHubToken(): Promise<void> {
|
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 (!process.env.GITHUB_TOKEN && !process.env.GH_TOKEN) {
|
||||||
if (isOIDCAvailable() || (process.env.GITHUB_APP_ID && process.env.GITHUB_PRIVATE_KEY)) {
|
const token = await acquireNewToken();
|
||||||
const token = await acquireNewToken();
|
process.env.GITHUB_TOKEN = token;
|
||||||
process.env.GITHUB_TOKEN = token;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -161,6 +161,9 @@ export function resolvePayload(
|
|||||||
// permissions: inputs > repoSettings > fallbacks
|
// permissions: inputs > repoSettings > fallbacks
|
||||||
push: inputs.push ?? repoSettings.push ?? "restricted",
|
push: inputs.push ?? repoSettings.push ?? "restricted",
|
||||||
shell: resolvedShell,
|
shell: resolvedShell,
|
||||||
|
|
||||||
|
// set by proxy logic in main.ts when routing through OpenRouter
|
||||||
|
proxyModel: undefined as string | undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export interface RepoSettings {
|
|||||||
export interface RunContext {
|
export interface RunContext {
|
||||||
settings: RepoSettings;
|
settings: RepoSettings;
|
||||||
apiToken: string;
|
apiToken: string;
|
||||||
|
oss: boolean;
|
||||||
|
proxyModel?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultSettings: RepoSettings = {
|
const defaultSettings: RepoSettings = {
|
||||||
@@ -39,6 +41,7 @@ const defaultSettings: RepoSettings = {
|
|||||||
const defaultRunContext: RunContext = {
|
const defaultRunContext: RunContext = {
|
||||||
settings: defaultSettings,
|
settings: defaultSettings,
|
||||||
apiToken: "",
|
apiToken: "",
|
||||||
|
oss: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,6 +76,8 @@ export async function fetchRunContext(params: {
|
|||||||
const data = (await response.json()) as {
|
const data = (await response.json()) as {
|
||||||
settings: RepoSettings | null;
|
settings: RepoSettings | null;
|
||||||
apiToken: string;
|
apiToken: string;
|
||||||
|
oss?: boolean;
|
||||||
|
proxyModel?: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
if (data === null) {
|
if (data === null) {
|
||||||
@@ -88,6 +93,8 @@ export async function fetchRunContext(params: {
|
|||||||
postCheckoutScript: data.settings?.postCheckoutScript ?? null,
|
postCheckoutScript: data.settings?.postCheckoutScript ?? null,
|
||||||
},
|
},
|
||||||
apiToken: data.apiToken,
|
apiToken: data.apiToken,
|
||||||
|
oss: data.oss ?? false,
|
||||||
|
proxyModel: data.proxyModel,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export interface RunContextData {
|
|||||||
};
|
};
|
||||||
repoSettings: RepoSettings;
|
repoSettings: RepoSettings;
|
||||||
apiToken: string;
|
apiToken: string;
|
||||||
|
oss: boolean;
|
||||||
|
proxyModel?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResolveRunContextDataParams {
|
interface ResolveRunContextDataParams {
|
||||||
@@ -42,5 +44,7 @@ export async function resolveRunContextData(
|
|||||||
},
|
},
|
||||||
repoSettings: runContext.settings,
|
repoSettings: runContext.settings,
|
||||||
apiToken: runContext.apiToken,
|
apiToken: runContext.apiToken,
|
||||||
|
oss: runContext.oss,
|
||||||
|
proxyModel: runContext.proxyModel,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user