Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 30d68e53a7 | |||
| 8a734c32f4 | |||
| 2e37fb3dfa | |||
| cbbcb64859 | |||
| df9598ea5f | |||
| 250fe7eaa1 | |||
| 4a8c432a48 |
@@ -41,6 +41,7 @@ jobs:
|
|||||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||||
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
|
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
|
||||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||||
|
PULLFROG_MODEL: ${{ vars.PULLFROG_MODEL }}
|
||||||
OPENCODE_MODEL: ${{ vars.OPENCODE_MODEL }}
|
OPENCODE_MODEL: ${{ vars.OPENCODE_MODEL }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|||||||
+5
-4
@@ -75,7 +75,7 @@ function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): s
|
|||||||
// ── model resolution (see wiki/model-resolution.md) ─────────────────────────────
|
// ── model resolution (see wiki/model-resolution.md) ─────────────────────────────
|
||||||
//
|
//
|
||||||
// priority:
|
// priority:
|
||||||
// 1. OPENCODE_MODEL env var (explicit override)
|
// 1. PULLFROG_MODEL or OPENCODE_MODEL env var (explicit override)
|
||||||
// 2. explicit slug from repo config / payload
|
// 2. explicit slug from repo config / payload
|
||||||
// 3. auto-select: `opencode models` → recommended aliases first, then secondary
|
// 3. auto-select: `opencode models` → recommended aliases first, then secondary
|
||||||
// 4. undefined → let OpenCode decide
|
// 4. undefined → let OpenCode decide
|
||||||
@@ -106,10 +106,11 @@ function resolveOpenCodeModel(ctx: {
|
|||||||
cliPath: string;
|
cliPath: string;
|
||||||
modelSlug?: string | undefined;
|
modelSlug?: string | undefined;
|
||||||
}): string | undefined {
|
}): string | undefined {
|
||||||
// 1. explicit env var override
|
// 1. explicit env var override (PULLFROG_MODEL takes precedence over OPENCODE_MODEL)
|
||||||
const envModel = process.env.OPENCODE_MODEL?.trim();
|
const envModel = process.env.PULLFROG_MODEL?.trim() || process.env.OPENCODE_MODEL?.trim();
|
||||||
if (envModel) {
|
if (envModel) {
|
||||||
log.info(`» model: ${envModel} (override via OPENCODE_MODEL)`);
|
const source = process.env.PULLFROG_MODEL?.trim() ? "PULLFROG_MODEL" : "OPENCODE_MODEL";
|
||||||
|
log.info(`» model: ${envModel} (override via ${source})`);
|
||||||
return envModel;
|
return envModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -131987,21 +131987,42 @@ var providers = {
|
|||||||
"big-pickle": {
|
"big-pickle": {
|
||||||
displayName: "Big Pickle",
|
displayName: "Big Pickle",
|
||||||
resolve: "opencode/big-pickle",
|
resolve: "opencode/big-pickle",
|
||||||
recommended: true
|
recommended: true,
|
||||||
|
envVars: [],
|
||||||
|
isFree: true
|
||||||
},
|
},
|
||||||
"claude-opus": { displayName: "Claude Opus", resolve: "opencode/claude-opus-4-6" },
|
"claude-opus": { displayName: "Claude Opus", resolve: "opencode/claude-opus-4-6" },
|
||||||
"claude-sonnet": { displayName: "Claude Sonnet", resolve: "opencode/claude-sonnet-4-6" },
|
"claude-sonnet": { displayName: "Claude Sonnet", resolve: "opencode/claude-sonnet-4-6" },
|
||||||
"claude-haiku": { displayName: "Claude Haiku", resolve: "opencode/claude-haiku-4-5" },
|
"claude-haiku": { displayName: "Claude Haiku", resolve: "opencode/claude-haiku-4-5" },
|
||||||
"gpt-codex": { displayName: "GPT Codex", resolve: "opencode/gpt-5.3-codex" },
|
"gpt-codex": { displayName: "GPT Codex", resolve: "opencode/gpt-5.3-codex" },
|
||||||
|
"gpt-codex-mini": { displayName: "GPT Codex Mini", resolve: "opencode/gpt-5.1-codex-mini" },
|
||||||
"gemini-pro": { displayName: "Gemini Pro", resolve: "opencode/gemini-3.1-pro" },
|
"gemini-pro": { displayName: "Gemini Pro", resolve: "opencode/gemini-3.1-pro" },
|
||||||
"gemini-flash": { displayName: "Gemini Flash", resolve: "opencode/gemini-3-flash" },
|
"gemini-flash": { displayName: "Gemini Flash", resolve: "opencode/gemini-3-flash" },
|
||||||
"kimi-k2": { displayName: "Kimi K2", resolve: "opencode/kimi-k2.5" },
|
"kimi-k2": { displayName: "Kimi K2", resolve: "opencode/kimi-k2.5" },
|
||||||
"gpt-5-nano": { displayName: "GPT-5 Nano", resolve: "opencode/gpt-5-nano" },
|
"gpt-5-nano": {
|
||||||
|
displayName: "GPT Nano",
|
||||||
|
resolve: "opencode/gpt-5-nano",
|
||||||
|
envVars: [],
|
||||||
|
isFree: true
|
||||||
|
},
|
||||||
"mimo-v2-flash-free": {
|
"mimo-v2-flash-free": {
|
||||||
displayName: "MiMo V2 Flash",
|
displayName: "MiMo V2 Flash",
|
||||||
resolve: "opencode/mimo-v2-flash-free"
|
resolve: "opencode/mimo-v2-flash-free",
|
||||||
|
envVars: [],
|
||||||
|
isFree: true
|
||||||
},
|
},
|
||||||
"minimax-m2.5-free": { displayName: "MiniMax M2.5", resolve: "opencode/minimax-m2.5-free" }
|
"minimax-m2.5-free": {
|
||||||
|
displayName: "MiniMax M2.5",
|
||||||
|
resolve: "opencode/minimax-m2.5-free",
|
||||||
|
envVars: [],
|
||||||
|
isFree: true
|
||||||
|
},
|
||||||
|
"nemotron-3-super-free": {
|
||||||
|
displayName: "Nemotron 3 Super",
|
||||||
|
resolve: "opencode/nemotron-3-super-free",
|
||||||
|
envVars: [],
|
||||||
|
isFree: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
openrouter: provider({
|
openrouter: provider({
|
||||||
@@ -132026,6 +132047,7 @@ var providers = {
|
|||||||
displayName: "GPT Codex Mini",
|
displayName: "GPT Codex Mini",
|
||||||
resolve: "openrouter/openai/gpt-5.1-codex-mini"
|
resolve: "openrouter/openai/gpt-5.1-codex-mini"
|
||||||
},
|
},
|
||||||
|
"o4-mini": { displayName: "O4 Mini", resolve: "openrouter/openai/o4-mini" },
|
||||||
"gemini-pro": {
|
"gemini-pro": {
|
||||||
displayName: "Gemini Pro",
|
displayName: "Gemini Pro",
|
||||||
resolve: "openrouter/google/gemini-3.1-pro-preview"
|
resolve: "openrouter/google/gemini-3.1-pro-preview"
|
||||||
@@ -132043,13 +132065,33 @@ var providers = {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
function parseModel(slug) {
|
||||||
|
const slashIdx = slug.indexOf("/");
|
||||||
|
if (slashIdx === -1) {
|
||||||
|
throw new Error(`invalid model slug "${slug}" \u2014 expected "provider/model"`);
|
||||||
|
}
|
||||||
|
return { provider: slug.slice(0, slashIdx), model: slug.slice(slashIdx + 1) };
|
||||||
|
}
|
||||||
|
function getModelEnvVars(slug) {
|
||||||
|
const parsed2 = parseModel(slug);
|
||||||
|
const providerConfig = providers[parsed2.provider];
|
||||||
|
if (!providerConfig) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const modelConfig = providerConfig.models[parsed2.model];
|
||||||
|
if (modelConfig?.envVars) {
|
||||||
|
return modelConfig.envVars.slice();
|
||||||
|
}
|
||||||
|
return providerConfig.envVars.slice();
|
||||||
|
}
|
||||||
var modelAliases = Object.entries(providers).flatMap(
|
var modelAliases = Object.entries(providers).flatMap(
|
||||||
([providerKey, config3]) => Object.entries(config3.models).map(([modelId, def]) => ({
|
([providerKey, config3]) => Object.entries(config3.models).map(([modelId, def]) => ({
|
||||||
slug: `${providerKey}/${modelId}`,
|
slug: `${providerKey}/${modelId}`,
|
||||||
provider: providerKey,
|
provider: providerKey,
|
||||||
displayName: def.displayName,
|
displayName: def.displayName,
|
||||||
resolve: def.resolve,
|
resolve: def.resolve,
|
||||||
recommended: def.recommended ?? false
|
recommended: def.recommended ?? false,
|
||||||
|
isFree: def.isFree ?? false
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
function resolveModelSlug(slug) {
|
function resolveModelSlug(slug) {
|
||||||
@@ -143506,10 +143548,15 @@ var findInstallationId = async (jwt2, 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 config3 = {
|
const config3 = {
|
||||||
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
|
||||||
};
|
};
|
||||||
@@ -144705,6 +144752,33 @@ async function checkoutPrBranch(pullNumber, params) {
|
|||||||
forkUrl: isFork ? `https://github.com/${headRepo.full_name}.git` : void 0
|
forkUrl: isFork ? `https://github.com/${headRepo.full_name}.git` : void 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
function deepenForBeforeSha(params) {
|
||||||
|
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(`\xBB before_sha ${params.beforeSha.slice(0, 7)} is now reachable`);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
log.debug(
|
||||||
|
`\xBB 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(`\xBB deepen for before_sha failed (force-push may have rewritten history)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.debug(
|
||||||
|
`\xBB before_sha ${params.beforeSha.slice(0, 7)} not reachable after ${maxIterations * 50} commits`
|
||||||
|
);
|
||||||
|
}
|
||||||
function CheckoutPrTool(ctx) {
|
function CheckoutPrTool(ctx) {
|
||||||
return tool({
|
return tool({
|
||||||
name: "checkout_pr",
|
name: "checkout_pr",
|
||||||
@@ -144720,6 +144794,13 @@ function CheckoutPrTool(ctx) {
|
|||||||
shell: ctx.payload.shell,
|
shell: ctx.payload.shell,
|
||||||
postCheckoutScript: ctx.postCheckoutScript
|
postCheckoutScript: ctx.postCheckoutScript
|
||||||
});
|
});
|
||||||
|
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,
|
||||||
@@ -144975,7 +145056,7 @@ function fixDoubleEscapedString(str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// mcp/comment.ts
|
// mcp/comment.ts
|
||||||
async function updatePlanCommentId(ctx, planCommentNodeId) {
|
async function updateCommentNodeId(ctx, field, nodeId) {
|
||||||
if (ctx.runId === void 0 || !ctx.apiToken) return;
|
if (ctx.runId === void 0 || !ctx.apiToken) return;
|
||||||
try {
|
try {
|
||||||
await retry(
|
await retry(
|
||||||
@@ -144987,7 +145068,7 @@ async function updatePlanCommentId(ctx, planCommentNodeId) {
|
|||||||
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(1e4)
|
signal: AbortSignal.timeout(1e4)
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(`PATCH workflow-run: ${response.status}`);
|
if (!response.ok) throw new Error(`PATCH workflow-run: ${response.status}`);
|
||||||
@@ -144995,11 +145076,11 @@ async function updatePlanCommentId(ctx, planCommentNodeId) {
|
|||||||
{
|
{
|
||||||
maxAttempts: 3,
|
maxAttempts: 3,
|
||||||
delayMs: 2e3,
|
delayMs: 2e3,
|
||||||
label: "updatePlanCommentId"
|
label: `updateCommentNodeId(${field})`
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (error49) {
|
} catch (error49) {
|
||||||
log.warning(`updatePlanCommentId exhausted retries: ${error49}`);
|
log.warning(`updateCommentNodeId(${field}) exhausted retries: ${error49}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function buildCommentFooter(params) {
|
async function buildCommentFooter(params) {
|
||||||
@@ -145038,17 +145119,37 @@ async function addFooter(ctx, body) {
|
|||||||
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()
|
||||||
});
|
});
|
||||||
function CreateCommentTool(ctx) {
|
function CreateCommentTool(ctx) {
|
||||||
return tool({
|
return tool({
|
||||||
name: "create_issue_comment",
|
name: "create_issue_comment",
|
||||||
description: "Create a comment on a GitHub issue. For progress/plan updates on the current run use report_progress instead. Use type: 'Plan' only when creating a standalone plan comment to record as this run's plan.",
|
description: "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 (commentType === "Summary" && ctx.toolState.existingSummaryCommentId) {
|
||||||
|
log.info(
|
||||||
|
`\xBB redirecting create_issue_comment(Summary) to update existing comment ${ctx.toolState.existingSummaryCommentId}`
|
||||||
|
);
|
||||||
|
const result2 = await ctx.octokit.rest.issues.updateComment({
|
||||||
|
owner: ctx.repo.owner,
|
||||||
|
repo: ctx.repo.name,
|
||||||
|
comment_id: ctx.toolState.existingSummaryCommentId,
|
||||||
|
body: bodyWithFooter
|
||||||
|
});
|
||||||
|
if (result2.data.node_id) {
|
||||||
|
await updateCommentNodeId(ctx, "summaryCommentNodeId", result2.data.node_id);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
commentId: result2.data.id,
|
||||||
|
url: result2.data.html_url,
|
||||||
|
body: result2.data.body
|
||||||
|
};
|
||||||
|
}
|
||||||
const result = await ctx.octokit.rest.issues.createComment({
|
const result = await ctx.octokit.rest.issues.createComment({
|
||||||
owner: ctx.repo.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.repo.name,
|
repo: ctx.repo.name,
|
||||||
@@ -145056,7 +145157,10 @@ function CreateCommentTool(ctx) {
|
|||||||
body: bodyWithFooter
|
body: bodyWithFooter
|
||||||
});
|
});
|
||||||
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 {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -145128,7 +145232,7 @@ async function reportProgress(ctx, params) {
|
|||||||
});
|
});
|
||||||
ctx.toolState.wasUpdated = true;
|
ctx.toolState.wasUpdated = true;
|
||||||
if (isPlanMode && result2.data.node_id) {
|
if (isPlanMode && result2.data.node_id) {
|
||||||
await updatePlanCommentId(ctx, result2.data.node_id);
|
await updateCommentNodeId(ctx, "planCommentNodeId", result2.data.node_id);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
commentId: result2.data.id,
|
commentId: result2.data.id,
|
||||||
@@ -145154,7 +145258,7 @@ async function reportProgress(ctx, params) {
|
|||||||
});
|
});
|
||||||
ctx.toolState.wasUpdated = true;
|
ctx.toolState.wasUpdated = true;
|
||||||
if (isPlanMode && result2.data.node_id) {
|
if (isPlanMode && result2.data.node_id) {
|
||||||
await updatePlanCommentId(ctx, result2.data.node_id);
|
await updateCommentNodeId(ctx, "planCommentNodeId", result2.data.node_id);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
commentId: result2.data.id,
|
commentId: result2.data.id,
|
||||||
@@ -145195,7 +145299,7 @@ async function reportProgress(ctx, params) {
|
|||||||
body: bodyWithPlanLink
|
body: bodyWithPlanLink
|
||||||
});
|
});
|
||||||
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 {
|
||||||
commentId: updateResult.data.id,
|
commentId: updateResult.data.id,
|
||||||
@@ -146219,6 +146323,7 @@ var NOSHELL_BLOCKED_SUBCOMMANDS = {
|
|||||||
bisect: "Blocked: git bisect run can execute arbitrary shell commands."
|
bisect: "Blocked: git bisect run can execute arbitrary shell commands."
|
||||||
};
|
};
|
||||||
var NOSHELL_BLOCKED_ARGS = ["--exec", "--extcmd", "--upload-pack", "--receive-pack"];
|
var NOSHELL_BLOCKED_ARGS = ["--exec", "--extcmd", "--upload-pack", "--receive-pack"];
|
||||||
|
var COLLAPSE_THRESHOLD = 200;
|
||||||
var subcommandPattern = regex("^[a-z][a-z0-9-]*$");
|
var subcommandPattern = regex("^[a-z][a-z0-9-]*$");
|
||||||
var Git = type({
|
var Git = type({
|
||||||
subcommand: type(subcommandPattern).describe("Git subcommand (e.g., 'status', 'log', 'diff')"),
|
subcommand: type(subcommandPattern).describe("Git subcommand (e.g., 'status', 'log', 'diff')"),
|
||||||
@@ -146253,6 +146358,14 @@ function GitTool(ctx) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const output = $("git", [subcommand, ...args2], { log: false });
|
const output = $("git", [subcommand, ...args2], { 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 };
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -146742,8 +146855,10 @@ function PullRequestInfoTool(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// mcp/review.ts
|
// mcp/review.ts
|
||||||
function isStatusError(err) {
|
function getHttpStatus(err) {
|
||||||
return typeof err === "object" && err !== null && "status" in err && typeof err.status === "number";
|
if (typeof err !== "object" || err === null) return void 0;
|
||||||
|
const status = err.status;
|
||||||
|
return typeof status === "number" ? status : void 0;
|
||||||
}
|
}
|
||||||
var CreatePullRequestReview = type({
|
var CreatePullRequestReview = type({
|
||||||
pull_number: type.number.describe("The pull request number to review"),
|
pull_number: type.number.describe("The pull request number to review"),
|
||||||
@@ -146759,7 +146874,7 @@ var CreatePullRequestReview = type({
|
|||||||
"The file path to comment on (relative to repo root). Must be a file that appears in the PR diff."
|
"The file path to comment on (relative to repo root). Must be a file that appears in the PR diff."
|
||||||
),
|
),
|
||||||
line: type.number.describe(
|
line: type.number.describe(
|
||||||
"End line of the comment range. For single-line comments, set equal to 'start_line'. Use NEW column from diff format."
|
"Line number to comment on. For multi-line ranges, this is the end line. Use NEW column from diff format."
|
||||||
),
|
),
|
||||||
side: type.enumerated("LEFT", "RIGHT").describe(
|
side: type.enumerated("LEFT", "RIGHT").describe(
|
||||||
"Side of the diff: LEFT (old code, lines starting with -) or RIGHT (new code, lines starting with + or unchanged). Defaults to RIGHT."
|
"Side of the diff: LEFT (old code, lines starting with -) or RIGHT (new code, lines starting with + or unchanged). Defaults to RIGHT."
|
||||||
@@ -146769,8 +146884,8 @@ var CreatePullRequestReview = type({
|
|||||||
"Full replacement code for the line range [start_line, line]. MUST preserve the exact indentation of the original code."
|
"Full replacement code for the line range [start_line, line]. MUST preserve the exact indentation of the original code."
|
||||||
).optional(),
|
).optional(),
|
||||||
start_line: type.number.describe(
|
start_line: type.number.describe(
|
||||||
"Start line of the comment range. For single-line comments, set equal to 'line'. The range [start_line, line] defines which lines a suggestion replaces."
|
"Start line for multi-line comment ranges. Omit for single-line comments. The range [start_line, line] defines which lines a suggestion replaces."
|
||||||
)
|
).optional()
|
||||||
}).array().describe(
|
}).array().describe(
|
||||||
"Inline comments on lines within diff hunks. Feedback about code outside the diff goes in 'body' instead."
|
"Inline comments on lines within diff hunks. Feedback about code outside the diff goes in 'body' instead."
|
||||||
).optional()
|
).optional()
|
||||||
@@ -146778,7 +146893,7 @@ var CreatePullRequestReview = type({
|
|||||||
function CreatePullRequestReviewTool(ctx) {
|
function CreatePullRequestReviewTool(ctx) {
|
||||||
return tool({
|
return tool({
|
||||||
name: "create_pull_request_review",
|
name: "create_pull_request_review",
|
||||||
description: `Submit a review for an existing pull request. IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. Only use 'body' for a 1-2 sentence summary with urgency and critical callouts. Use 'suggestion' to propose replacement code - MUST preserve exact indentation of original code. Example replacing lines 42-44 (3 lines) with 5 lines: { path: 'src/api.ts', start_line: 42, line: 44, suggestion: ' const result = await fetch(url);\\n if (!result.ok) {\\n log.error(result.status);\\n throw new Error("request failed");\\n }' } CONSTRAINT: Inline comments can ONLY target files and lines that appear in the PR diff. Commenting on files or lines outside the diff will cause GitHub API errors. Put feedback about code outside the diff in 'body' instead.`,
|
description: `Submit a review for an existing pull request. Each call creates a permanent, visible review on the PR \u2014 NEVER submit test or diagnostic reviews. IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. Only use 'body' for a 1-2 sentence summary with urgency and critical callouts. Use 'suggestion' to propose replacement code - MUST preserve exact indentation of original code. Example replacing lines 42-44 (3 lines) with 5 lines: { path: 'src/api.ts', start_line: 42, line: 44, suggestion: ' const result = await fetch(url);\\n if (!result.ok) {\\n log.error(result.status);\\n throw new Error("request failed");\\n }' } CONSTRAINT: Inline comments can ONLY target files and lines that appear in the PR diff. If GitHub rejects comments due to incorrect line numbers, re-read the diff and retry.`,
|
||||||
parameters: CreatePullRequestReview,
|
parameters: CreatePullRequestReview,
|
||||||
execute: execute(async ({ pull_number, body, approved, commit_id, comments = [] }) => {
|
execute: execute(async ({ pull_number, body, approved, commit_id, comments = [] }) => {
|
||||||
if (body) body = fixDoubleEscapedString(body);
|
if (body) body = fixDoubleEscapedString(body);
|
||||||
@@ -146794,6 +146909,7 @@ function CreatePullRequestReviewTool(ctx) {
|
|||||||
pull_number,
|
pull_number,
|
||||||
event
|
event
|
||||||
};
|
};
|
||||||
|
let latestHeadSha;
|
||||||
if (commit_id) {
|
if (commit_id) {
|
||||||
params.commit_id = commit_id;
|
params.commit_id = commit_id;
|
||||||
} else {
|
} else {
|
||||||
@@ -146802,42 +146918,54 @@ function CreatePullRequestReviewTool(ctx) {
|
|||||||
repo: ctx.repo.name,
|
repo: ctx.repo.name,
|
||||||
pull_number
|
pull_number
|
||||||
});
|
});
|
||||||
params.commit_id = pr.data.head.sha;
|
latestHeadSha = pr.data.head.sha;
|
||||||
|
params.commit_id = ctx.toolState.checkoutSha ?? latestHeadSha;
|
||||||
|
if (ctx.toolState.checkoutSha && latestHeadSha !== ctx.toolState.checkoutSha) {
|
||||||
|
log.info(
|
||||||
|
`anchoring review to checkout ${ctx.toolState.checkoutSha.slice(0, 7)} (HEAD is now ${latestHeadSha.slice(0, 7)})`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (comments.length > 0) {
|
const reviewComments = comments.map((comment) => {
|
||||||
params.comments = comments.map((comment) => {
|
let commentBody = fixDoubleEscapedString(comment.body || "");
|
||||||
let commentBody = fixDoubleEscapedString(comment.body || "");
|
if (comment.suggestion !== void 0) {
|
||||||
if (comment.suggestion !== void 0) {
|
const suggestionBlock = "```suggestion\n" + comment.suggestion + "\n```";
|
||||||
const suggestionBlock = "```suggestion\n" + comment.suggestion + "\n```";
|
commentBody = commentBody ? commentBody + "\n\n" + suggestionBlock : suggestionBlock;
|
||||||
commentBody = commentBody ? commentBody + "\n\n" + suggestionBlock : suggestionBlock;
|
}
|
||||||
}
|
const side = comment.side || "RIGHT";
|
||||||
const side = comment.side || "RIGHT";
|
const reviewComment = {
|
||||||
const reviewComment = {
|
path: comment.path,
|
||||||
path: comment.path,
|
line: comment.line,
|
||||||
line: comment.line,
|
body: commentBody,
|
||||||
body: commentBody,
|
side
|
||||||
side,
|
};
|
||||||
start_line: comment.start_line,
|
if (comment.start_line != null && comment.start_line !== comment.line) {
|
||||||
start_side: side
|
reviewComment.start_line = comment.start_line;
|
||||||
};
|
reviewComment.start_side = side;
|
||||||
return reviewComment;
|
}
|
||||||
});
|
return reviewComment;
|
||||||
|
});
|
||||||
|
if (reviewComments.length > 0) {
|
||||||
|
params.comments = reviewComments;
|
||||||
}
|
}
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
result = body ? await createAndSubmitWithFooter(ctx, params, {
|
result = body ? await createAndSubmitWithFooter(ctx, params, {
|
||||||
body,
|
body,
|
||||||
approved: approved ?? false,
|
approved: approved ?? false,
|
||||||
hasComments: comments.length > 0
|
hasComments: reviewComments.length > 0
|
||||||
}) : await ctx.octokit.rest.pulls.createReview(params);
|
}) : await ctx.octokit.rest.pulls.createReview(params);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isStatusError(err) && err.status === 422 && params.comments?.length) {
|
if (getHttpStatus(err) !== 422 || !params.comments?.length) throw err;
|
||||||
const paths = [...new Set(params.comments.map((comment) => comment.path))];
|
const details = params.comments.map((c) => {
|
||||||
throw new Error(
|
const line = c.line ?? 0;
|
||||||
`${err.message ?? "422 Unprocessable Entity"}. The review had ${params.comments.length} inline comment(s) targeting these paths: ${paths.join(", ")}. GitHub cannot resolve one or more of these paths in the PR diff (common when the PR has >100 changed files and some are truncated). Fix: remove the failing comment(s) and retry. Put their feedback in the review body instead.`
|
const startLine = c.start_line ?? line;
|
||||||
);
|
const range2 = startLine !== line ? `${startLine}-${line}` : `${line}`;
|
||||||
}
|
return `${c.path}:${range2} (${c.side ?? "RIGHT"})`;
|
||||||
throw err;
|
});
|
||||||
|
throw new Error(
|
||||||
|
`GitHub rejected inline comment(s) with "Line could not be resolved". This usually means the diff changed since you last read it (new commits pushed). Re-read the diff to get current line numbers, or move failing comments to the review body. Affected: ${details.join(", ")}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
log.debug(`createReview response: ${JSON.stringify(result.data)}`);
|
log.debug(`createReview response: ${JSON.stringify(result.data)}`);
|
||||||
if (!result.data.id) {
|
if (!result.data.id) {
|
||||||
@@ -146851,10 +146979,9 @@ function CreatePullRequestReviewTool(ctx) {
|
|||||||
nodeId: reviewNodeId,
|
nodeId: reviewNodeId,
|
||||||
reviewedSha: actuallyReviewedSha
|
reviewedSha: actuallyReviewedSha
|
||||||
};
|
};
|
||||||
const headMovedDuringReview = ctx.toolState.checkoutSha && params.commit_id !== ctx.toolState.checkoutSha;
|
if (ctx.toolState.checkoutSha && latestHeadSha && latestHeadSha !== ctx.toolState.checkoutSha) {
|
||||||
if (headMovedDuringReview) {
|
|
||||||
const fromSha = ctx.toolState.checkoutSha;
|
const fromSha = ctx.toolState.checkoutSha;
|
||||||
const toSha = params.commit_id;
|
const toSha = latestHeadSha;
|
||||||
ctx.toolState.checkoutSha = toSha;
|
ctx.toolState.checkoutSha = toSha;
|
||||||
log.info(
|
log.info(
|
||||||
`new commits detected during review: ${fromSha.slice(0, 7)}..${toSha.slice(0, 7)}`
|
`new commits detected during review: ${fromSha.slice(0, 7)}..${toSha.slice(0, 7)}`
|
||||||
@@ -147410,7 +147537,7 @@ function ResolveReviewThreadTool(ctx) {
|
|||||||
// mcp/selectMode.ts
|
// mcp/selectMode.ts
|
||||||
var SelectModeParams = type({
|
var 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)"
|
||||||
@@ -147577,7 +147704,37 @@ An existing plan comment was found for this issue. Update that comment with the
|
|||||||
3. Finalize:
|
3. Finalize:
|
||||||
- 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\` \u2014 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 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\` \u2014 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.`
|
||||||
};
|
};
|
||||||
var modeInstructionParent = {
|
var modeInstructionParent = {
|
||||||
IncrementalReview: "Review",
|
IncrementalReview: "Review",
|
||||||
@@ -147595,12 +147752,12 @@ function buildOrchestratorGuidance(mode, opts = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
async function fetchExistingPlanComment(ctx, issueNumber) {
|
async function fetchExistingPlanComment(ctx, issueNumber) {
|
||||||
if (!ctx.apiToken) return null;
|
if (!ctx.githubInstallationToken) return null;
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch({
|
const response = await apiFetch({
|
||||||
path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/issue/${issueNumber}/plan-comment`,
|
path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/issue/${issueNumber}/plan-comment`,
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: { authorization: `Bearer ${ctx.apiToken}` },
|
headers: { authorization: `Bearer ${ctx.githubInstallationToken}` },
|
||||||
signal: AbortSignal.timeout(1e4)
|
signal: AbortSignal.timeout(1e4)
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -147609,6 +147766,31 @@ async function fetchExistingPlanComment(ctx, issueNumber) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function fetchExistingSummaryComment(ctx, prNumber) {
|
||||||
|
if (!ctx.githubInstallationToken) {
|
||||||
|
log.warning("fetchExistingSummaryComment: no token, skipping");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const path3 = `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/pr/${prNumber}/summary-comment`;
|
||||||
|
try {
|
||||||
|
const response = await apiFetch({
|
||||||
|
path: path3,
|
||||||
|
method: "GET",
|
||||||
|
headers: { authorization: `Bearer ${ctx.githubInstallationToken}` },
|
||||||
|
signal: AbortSignal.timeout(1e4)
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.ok && "commentId" in data) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
const errMsg = "error" in data ? data.error : "(no error body)";
|
||||||
|
log.warning(`fetchExistingSummaryComment: ${response.status} ${path3} \u2014 ${errMsg}`);
|
||||||
|
return null;
|
||||||
|
} catch (error49) {
|
||||||
|
log.warning("fetchExistingSummaryComment failed:", error49);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
function SelectModeTool(ctx) {
|
function SelectModeTool(ctx) {
|
||||||
return tool({
|
return tool({
|
||||||
name: "select_mode",
|
name: "select_mode",
|
||||||
@@ -147651,6 +147833,23 @@ function SelectModeTool(ctx) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (selectedMode.name === "Summarize") {
|
||||||
|
const prNumber = ctx.payload.event.issue_number;
|
||||||
|
if (prNumber !== void 0) {
|
||||||
|
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);
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -148426,6 +148625,21 @@ Your job is to fix issues THIS PR introduced, not to fix all CI failures. If in
|
|||||||
5. **PROGRESS** - ${reportProgressInstruction}
|
5. **PROGRESS** - ${reportProgressInstruction}
|
||||||
|
|
||||||
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 \u2014 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 before/after framing.
|
||||||
|
|
||||||
|
4. **POST** - Call ${ghPullfrogMcpName}/create_issue_comment with type: 'Summary' and the summary body.
|
||||||
|
|
||||||
|
${permalinkTip}`
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -148629,9 +148843,10 @@ function getOpenCodeModels(cliPath) {
|
|||||||
}
|
}
|
||||||
var AUTO_SELECT_WARNING = "select a model explicitly in the Pullfrog console (https://pullfrog.com/console) to avoid this.";
|
var AUTO_SELECT_WARNING = "select a model explicitly in the Pullfrog console (https://pullfrog.com/console) to avoid this.";
|
||||||
function resolveOpenCodeModel(ctx) {
|
function resolveOpenCodeModel(ctx) {
|
||||||
const envModel = process.env.OPENCODE_MODEL?.trim();
|
const envModel = process.env.PULLFROG_MODEL?.trim() || process.env.OPENCODE_MODEL?.trim();
|
||||||
if (envModel) {
|
if (envModel) {
|
||||||
log.info(`\xBB model: ${envModel} (override via OPENCODE_MODEL)`);
|
const source = process.env.PULLFROG_MODEL?.trim() ? "PULLFROG_MODEL" : "OPENCODE_MODEL";
|
||||||
|
log.info(`\xBB model: ${envModel} (override via ${source})`);
|
||||||
return envModel;
|
return envModel;
|
||||||
}
|
}
|
||||||
if (ctx.modelSlug) {
|
if (ctx.modelSlug) {
|
||||||
@@ -149016,10 +149231,18 @@ to fix this, add the required secret to your GitHub repository:
|
|||||||
|
|
||||||
configure your model at ${settingsUrl}`;
|
configure your model at ${settingsUrl}`;
|
||||||
}
|
}
|
||||||
|
function hasEnvVar(name) {
|
||||||
|
const value2 = process.env[name];
|
||||||
|
return typeof value2 === "string" && value2.length > 0;
|
||||||
|
}
|
||||||
function validateAgentApiKey(params) {
|
function validateAgentApiKey(params) {
|
||||||
const hasAnyKey = Object.entries(process.env).some(
|
if (params.model) {
|
||||||
([key, value2]) => value2 && typeof value2 === "string" && knownApiKeys.has(key)
|
const requiredVars = getModelEnvVars(params.model);
|
||||||
);
|
if (requiredVars.length === 0) return;
|
||||||
|
if (requiredVars.some((v) => hasEnvVar(v))) return;
|
||||||
|
throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name }));
|
||||||
|
}
|
||||||
|
const hasAnyKey = [...knownApiKeys].some((k) => hasEnvVar(k));
|
||||||
if (!hasAnyKey) {
|
if (!hasAnyKey) {
|
||||||
throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name }));
|
throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name }));
|
||||||
}
|
}
|
||||||
@@ -149609,7 +149832,7 @@ import { isAbsolute, resolve } from "node:path";
|
|||||||
// package.json
|
// package.json
|
||||||
var package_default = {
|
var package_default = {
|
||||||
name: "@pullfrog/pullfrog",
|
name: "@pullfrog/pullfrog",
|
||||||
version: "0.0.179",
|
version: "0.0.181",
|
||||||
type: "module",
|
type: "module",
|
||||||
files: [
|
files: [
|
||||||
"index.js",
|
"index.js",
|
||||||
@@ -150178,6 +150401,7 @@ async function main() {
|
|||||||
const agent2 = resolveAgent();
|
const agent2 = resolveAgent();
|
||||||
validateAgentApiKey({
|
validateAgentApiKey({
|
||||||
agent: agent2,
|
agent: agent2,
|
||||||
|
model: payload.model,
|
||||||
owner: runContext.repo.owner,
|
owner: runContext.repo.owner,
|
||||||
name: runContext.repo.name
|
name: runContext.repo.name
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ export async function main(): Promise<MainResult> {
|
|||||||
|
|
||||||
validateAgentApiKey({
|
validateAgentApiKey({
|
||||||
agent,
|
agent,
|
||||||
|
model: payload.model,
|
||||||
owner: runContext.repo.owner,
|
owner: runContext.repo.owner,
|
||||||
name: runContext.repo.name,
|
name: runContext.repo.name,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+46
-12
@@ -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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,9 +114,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 +125,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 +162,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 {
|
||||||
@@ -241,7 +275,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 {
|
||||||
@@ -278,7 +312,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 {
|
||||||
@@ -336,7 +370,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 };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
+66
-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);
|
||||||
@@ -95,6 +97,7 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
|||||||
pull_number,
|
pull_number,
|
||||||
event,
|
event,
|
||||||
};
|
};
|
||||||
|
let latestHeadSha: string | undefined;
|
||||||
if (commit_id) {
|
if (commit_id) {
|
||||||
params.commit_id = commit_id;
|
params.commit_id = commit_id;
|
||||||
} else {
|
} else {
|
||||||
@@ -103,28 +106,39 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
|||||||
repo: ctx.repo.name,
|
repo: ctx.repo.name,
|
||||||
pull_number,
|
pull_number,
|
||||||
});
|
});
|
||||||
params.commit_id = pr.data.head.sha;
|
latestHeadSha = pr.data.head.sha;
|
||||||
|
// anchor to checkout sha so line numbers match the diff the agent analyzed
|
||||||
|
params.commit_id = ctx.toolState.checkoutSha ?? latestHeadSha;
|
||||||
|
if (ctx.toolState.checkoutSha && latestHeadSha !== ctx.toolState.checkoutSha) {
|
||||||
|
log.info(
|
||||||
|
`anchoring review to checkout ${ctx.toolState.checkoutSha.slice(0, 7)} ` +
|
||||||
|
`(HEAD is now ${latestHeadSha.slice(0, 7)})`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (comments.length > 0) {
|
type ReviewComment = NonNullable<typeof params.comments>[number];
|
||||||
type ReviewComment = (typeof params.comments & {})[number];
|
const reviewComments = comments.map((comment) => {
|
||||||
params.comments = comments.map((comment) => {
|
let commentBody = fixDoubleEscapedString(comment.body || "");
|
||||||
let commentBody = fixDoubleEscapedString(comment.body || "");
|
if (comment.suggestion !== undefined) {
|
||||||
if (comment.suggestion !== undefined) {
|
const suggestionBlock = "```suggestion\n" + comment.suggestion + "\n```";
|
||||||
const suggestionBlock = "```suggestion\n" + comment.suggestion + "\n```";
|
commentBody = commentBody ? commentBody + "\n\n" + suggestionBlock : suggestionBlock;
|
||||||
commentBody = commentBody ? commentBody + "\n\n" + suggestionBlock : suggestionBlock;
|
}
|
||||||
}
|
const side = comment.side || "RIGHT";
|
||||||
|
const reviewComment: ReviewComment = {
|
||||||
|
path: comment.path,
|
||||||
|
line: comment.line,
|
||||||
|
body: commentBody,
|
||||||
|
side,
|
||||||
|
};
|
||||||
|
if (comment.start_line != null && comment.start_line !== comment.line) {
|
||||||
|
reviewComment.start_line = comment.start_line;
|
||||||
|
reviewComment.start_side = side;
|
||||||
|
}
|
||||||
|
return reviewComment;
|
||||||
|
});
|
||||||
|
|
||||||
const side = comment.side || "RIGHT";
|
if (reviewComments.length > 0) {
|
||||||
const reviewComment: ReviewComment = {
|
params.comments = reviewComments;
|
||||||
path: comment.path,
|
|
||||||
line: comment.line,
|
|
||||||
body: commentBody,
|
|
||||||
side,
|
|
||||||
start_line: comment.start_line,
|
|
||||||
start_side: side,
|
|
||||||
};
|
|
||||||
return reviewComment;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// no body → single-step createReview (no footer needed)
|
// no body → single-step createReview (no footer needed)
|
||||||
@@ -135,20 +149,24 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
|||||||
? await createAndSubmitWithFooter(ctx, params, {
|
? await createAndSubmitWithFooter(ctx, params, {
|
||||||
body,
|
body,
|
||||||
approved: approved ?? false,
|
approved: approved ?? false,
|
||||||
hasComments: comments.length > 0,
|
hasComments: reviewComments.length > 0,
|
||||||
})
|
})
|
||||||
: await ctx.octokit.rest.pulls.createReview(params);
|
: await ctx.octokit.rest.pulls.createReview(params);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (isStatusError(err) && err.status === 422 && params.comments?.length) {
|
if (getHttpStatus(err) !== 422 || !params.comments?.length) throw err;
|
||||||
const paths = [...new Set(params.comments.map((comment) => comment.path))];
|
|
||||||
throw new Error(
|
const details = params.comments.map((c) => {
|
||||||
`${err.message ?? "422 Unprocessable Entity"}. ` +
|
const line = c.line ?? 0;
|
||||||
`The review had ${params.comments.length} inline comment(s) targeting these paths: ${paths.join(", ")}. ` +
|
const startLine = c.start_line ?? line;
|
||||||
`GitHub cannot resolve one or more of these paths in the PR diff (common when the PR has >100 changed files and some are truncated). ` +
|
const range = startLine !== line ? `${startLine}-${line}` : `${line}`;
|
||||||
`Fix: remove the failing comment(s) and retry. Put their feedback in the review body instead.`
|
return `${c.path}:${range} (${c.side ?? "RIGHT"})`;
|
||||||
);
|
});
|
||||||
}
|
throw new Error(
|
||||||
throw err;
|
`GitHub rejected inline comment(s) with "Line could not be resolved". ` +
|
||||||
|
`This usually means the diff changed since you last read it (new commits pushed). ` +
|
||||||
|
`Re-read the diff to get current line numbers, or move failing comments to the review body. ` +
|
||||||
|
`Affected: ${details.join(", ")}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
log.debug(`createReview response: ${JSON.stringify(result.data)}`);
|
log.debug(`createReview response: ${JSON.stringify(result.data)}`);
|
||||||
if (!result.data.id) {
|
if (!result.data.id) {
|
||||||
@@ -169,12 +187,13 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
|||||||
|
|
||||||
// detect commits pushed since checkout and guide the agent to review them
|
// detect commits pushed since checkout and guide the agent to review them
|
||||||
// inline instead of dispatching a separate workflow run
|
// inline instead of dispatching a separate workflow run
|
||||||
const headMovedDuringReview =
|
if (
|
||||||
ctx.toolState.checkoutSha && params.commit_id !== ctx.toolState.checkoutSha;
|
ctx.toolState.checkoutSha &&
|
||||||
|
latestHeadSha &&
|
||||||
if (headMovedDuringReview) {
|
latestHeadSha !== ctx.toolState.checkoutSha
|
||||||
const fromSha = ctx.toolState.checkoutSha!;
|
) {
|
||||||
const toSha = params.commit_id!;
|
const fromSha = ctx.toolState.checkoutSha;
|
||||||
|
const toSha = latestHeadSha;
|
||||||
// advance checkoutSha so the next review submission tracks correctly
|
// advance checkoutSha so the next review submission tracks correctly
|
||||||
ctx.toolState.checkoutSha = toSha;
|
ctx.toolState.checkoutSha = toSha;
|
||||||
|
|
||||||
|
|||||||
+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 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,6 +85,8 @@ 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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,18 @@ describe("getModelEnvVars", () => {
|
|||||||
it("returns empty array for unknown provider", () => {
|
it("returns empty array for unknown provider", () => {
|
||||||
expect(getModelEnvVars("unknown/model")).toEqual([]);
|
expect(getModelEnvVars("unknown/model")).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns empty env vars for free opencode models", () => {
|
||||||
|
expect(getModelEnvVars("opencode/big-pickle")).toEqual([]);
|
||||||
|
expect(getModelEnvVars("opencode/gpt-5-nano")).toEqual([]);
|
||||||
|
expect(getModelEnvVars("opencode/mimo-v2-flash-free")).toEqual([]);
|
||||||
|
expect(getModelEnvVars("opencode/minimax-m2.5-free")).toEqual([]);
|
||||||
|
expect(getModelEnvVars("opencode/nemotron-3-super-free")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still requires OPENCODE_API_KEY for non-free opencode models", () => {
|
||||||
|
expect(getModelEnvVars("opencode/claude-opus")).toEqual(["OPENCODE_API_KEY"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveModelSlug", () => {
|
describe("resolveModelSlug", () => {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export interface ModelAlias {
|
|||||||
resolve: string;
|
resolve: string;
|
||||||
/** top-tier pick for this provider — preferred during auto-select */
|
/** top-tier pick for this provider — preferred during auto-select */
|
||||||
recommended: boolean;
|
recommended: boolean;
|
||||||
|
/** whether this alias is free and requires no API key */
|
||||||
|
isFree: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModelDef {
|
interface ModelDef {
|
||||||
@@ -25,6 +27,8 @@ interface ModelDef {
|
|||||||
/** concrete models.dev specifier, e.g. "anthropic/claude-opus-4-6" */
|
/** concrete models.dev specifier, e.g. "anthropic/claude-opus-4-6" */
|
||||||
resolve: string;
|
resolve: string;
|
||||||
recommended?: boolean;
|
recommended?: boolean;
|
||||||
|
envVars?: readonly string[];
|
||||||
|
isFree?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderConfig {
|
export interface ProviderConfig {
|
||||||
@@ -110,20 +114,41 @@ export const providers = {
|
|||||||
displayName: "Big Pickle",
|
displayName: "Big Pickle",
|
||||||
resolve: "opencode/big-pickle",
|
resolve: "opencode/big-pickle",
|
||||||
recommended: true,
|
recommended: true,
|
||||||
|
envVars: [],
|
||||||
|
isFree: true,
|
||||||
},
|
},
|
||||||
"claude-opus": { displayName: "Claude Opus", resolve: "opencode/claude-opus-4-6" },
|
"claude-opus": { displayName: "Claude Opus", resolve: "opencode/claude-opus-4-6" },
|
||||||
"claude-sonnet": { displayName: "Claude Sonnet", resolve: "opencode/claude-sonnet-4-6" },
|
"claude-sonnet": { displayName: "Claude Sonnet", resolve: "opencode/claude-sonnet-4-6" },
|
||||||
"claude-haiku": { displayName: "Claude Haiku", resolve: "opencode/claude-haiku-4-5" },
|
"claude-haiku": { displayName: "Claude Haiku", resolve: "opencode/claude-haiku-4-5" },
|
||||||
"gpt-codex": { displayName: "GPT Codex", resolve: "opencode/gpt-5.3-codex" },
|
"gpt-codex": { displayName: "GPT Codex", resolve: "opencode/gpt-5.3-codex" },
|
||||||
|
"gpt-codex-mini": { displayName: "GPT Codex Mini", resolve: "opencode/gpt-5.1-codex-mini" },
|
||||||
"gemini-pro": { displayName: "Gemini Pro", resolve: "opencode/gemini-3.1-pro" },
|
"gemini-pro": { displayName: "Gemini Pro", resolve: "opencode/gemini-3.1-pro" },
|
||||||
"gemini-flash": { displayName: "Gemini Flash", resolve: "opencode/gemini-3-flash" },
|
"gemini-flash": { displayName: "Gemini Flash", resolve: "opencode/gemini-3-flash" },
|
||||||
"kimi-k2": { displayName: "Kimi K2", resolve: "opencode/kimi-k2.5" },
|
"kimi-k2": { displayName: "Kimi K2", resolve: "opencode/kimi-k2.5" },
|
||||||
"gpt-5-nano": { displayName: "GPT-5 Nano", resolve: "opencode/gpt-5-nano" },
|
"gpt-5-nano": {
|
||||||
|
displayName: "GPT Nano",
|
||||||
|
resolve: "opencode/gpt-5-nano",
|
||||||
|
envVars: [],
|
||||||
|
isFree: true,
|
||||||
|
},
|
||||||
"mimo-v2-flash-free": {
|
"mimo-v2-flash-free": {
|
||||||
displayName: "MiMo V2 Flash",
|
displayName: "MiMo V2 Flash",
|
||||||
resolve: "opencode/mimo-v2-flash-free",
|
resolve: "opencode/mimo-v2-flash-free",
|
||||||
|
envVars: [],
|
||||||
|
isFree: true,
|
||||||
|
},
|
||||||
|
"minimax-m2.5-free": {
|
||||||
|
displayName: "MiniMax M2.5",
|
||||||
|
resolve: "opencode/minimax-m2.5-free",
|
||||||
|
envVars: [],
|
||||||
|
isFree: true,
|
||||||
|
},
|
||||||
|
"nemotron-3-super-free": {
|
||||||
|
displayName: "Nemotron 3 Super",
|
||||||
|
resolve: "opencode/nemotron-3-super-free",
|
||||||
|
envVars: [],
|
||||||
|
isFree: true,
|
||||||
},
|
},
|
||||||
"minimax-m2.5-free": { displayName: "MiniMax M2.5", resolve: "opencode/minimax-m2.5-free" },
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
openrouter: provider({
|
openrouter: provider({
|
||||||
@@ -148,6 +173,7 @@ export const providers = {
|
|||||||
displayName: "GPT Codex Mini",
|
displayName: "GPT Codex Mini",
|
||||||
resolve: "openrouter/openai/gpt-5.1-codex-mini",
|
resolve: "openrouter/openai/gpt-5.1-codex-mini",
|
||||||
},
|
},
|
||||||
|
"o4-mini": { displayName: "O4 Mini", resolve: "openrouter/openai/o4-mini" },
|
||||||
"gemini-pro": {
|
"gemini-pro": {
|
||||||
displayName: "Gemini Pro",
|
displayName: "Gemini Pro",
|
||||||
resolve: "openrouter/google/gemini-3.1-pro-preview",
|
resolve: "openrouter/google/gemini-3.1-pro-preview",
|
||||||
@@ -183,8 +209,18 @@ export function getModelProvider(slug: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getModelEnvVars(slug: string): string[] {
|
export function getModelEnvVars(slug: string): string[] {
|
||||||
const p = getModelProvider(slug);
|
const parsed = parseModel(slug);
|
||||||
return (providers as Record<string, ProviderConfig>)[p]?.envVars.slice() ?? [];
|
const providerConfig = (providers as Record<string, ProviderConfig>)[parsed.provider];
|
||||||
|
if (!providerConfig) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelConfig = providerConfig.models[parsed.model];
|
||||||
|
if (modelConfig?.envVars) {
|
||||||
|
return modelConfig.envVars.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
return providerConfig.envVars.slice();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── derived flat list ──────────────────────────────────────────────────────────
|
// ── derived flat list ──────────────────────────────────────────────────────────
|
||||||
@@ -197,6 +233,7 @@ export const modelAliases: ModelAlias[] = Object.entries(providers).flatMap(
|
|||||||
displayName: def.displayName,
|
displayName: def.displayName,
|
||||||
resolve: def.resolve,
|
resolve: def.resolve,
|
||||||
recommended: def.recommended ?? false,
|
recommended: def.recommended ?? false,
|
||||||
|
isFree: def.isFree ?? false,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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 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.181",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"index.js",
|
"index.js",
|
||||||
|
|||||||
@@ -41256,8 +41256,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 +41284,7 @@ var core3 = __toESM(require_core(), 1);
|
|||||||
// package.json
|
// package.json
|
||||||
var package_default = {
|
var package_default = {
|
||||||
name: "@pullfrog/pullfrog",
|
name: "@pullfrog/pullfrog",
|
||||||
version: "0.0.179",
|
version: "0.0.181",
|
||||||
type: "module",
|
type: "module",
|
||||||
files: [
|
files: [
|
||||||
"index.js",
|
"index.js",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ exports[`latest model per provider snapshot > matches snapshot 1`] = `
|
|||||||
"releaseDate": "2026-01",
|
"releaseDate": "2026-01",
|
||||||
},
|
},
|
||||||
"openai": {
|
"openai": {
|
||||||
"modelId": "gpt-5.4",
|
"modelId": "gpt-5.4-pro",
|
||||||
"releaseDate": "2026-03-05",
|
"releaseDate": "2026-03-05",
|
||||||
},
|
},
|
||||||
"opencode": {
|
"opencode": {
|
||||||
@@ -31,8 +31,8 @@ exports[`latest model per provider snapshot > matches snapshot 1`] = `
|
|||||||
"releaseDate": "2026-03-11",
|
"releaseDate": "2026-03-11",
|
||||||
},
|
},
|
||||||
"xai": {
|
"xai": {
|
||||||
"modelId": "grok-4.20-experimental-beta-0304-reasoning",
|
"modelId": "grok-4-1-fast-non-reasoning",
|
||||||
"releaseDate": "2026-03-04",
|
"releaseDate": "2025-11-19",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ const dynamicAgentsExpression = "$" + "{{ fromJSON(needs.changes.outputs.agents)
|
|||||||
const expectedAgentEnvVars = [
|
const expectedAgentEnvVars = [
|
||||||
"GITHUB_TOKEN",
|
"GITHUB_TOKEN",
|
||||||
...new Set(Object.values(providers).flatMap((p) => [...p.envVars])),
|
...new Set(Object.values(providers).flatMap((p) => [...p.envVars])),
|
||||||
|
"PULLFROG_MODEL",
|
||||||
"OPENCODE_MODEL",
|
"OPENCODE_MODEL",
|
||||||
].sort();
|
].sort();
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -58,10 +58,16 @@ describe("latest model per provider snapshot", async () => {
|
|||||||
|
|
||||||
let latest: { modelId: string; releaseDate: string } | undefined;
|
let latest: { modelId: string; releaseDate: string } | undefined;
|
||||||
for (const [modelId, model] of Object.entries(providerData.models)) {
|
for (const [modelId, model] of Object.entries(providerData.models)) {
|
||||||
if (model.status === "deprecated") continue;
|
// skip non-GA models so beta/nightly churn doesn't break the snapshot
|
||||||
|
if (model.status) continue;
|
||||||
const rd = model.release_date;
|
const rd = model.release_date;
|
||||||
if (!rd) continue;
|
if (!rd) continue;
|
||||||
if (!latest || rd > latest.releaseDate) {
|
// tiebreak by modelId for stable ordering when release dates match
|
||||||
|
if (
|
||||||
|
!latest ||
|
||||||
|
rd > latest.releaseDate ||
|
||||||
|
(rd === latest.releaseDate && modelId > latest.modelId)
|
||||||
|
) {
|
||||||
latest = { modelId, releaseDate: rd };
|
latest = { modelId, releaseDate: rd };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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-flash-free",
|
||||||
|
"opencode/minimax-m2.5-free",
|
||||||
|
"opencode/nemotron-3-super-free",
|
||||||
|
]) {
|
||||||
|
expect(() => validateAgentApiKey({ ...base, model: slug })).not.toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("keyed model", () => {
|
||||||
|
it("passes when the required key is present", () => {
|
||||||
|
process.env.ANTHROPIC_API_KEY = "sk-test";
|
||||||
|
expect(() => validateAgentApiKey({ ...base, model: "anthropic/claude-opus" })).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the required key is missing", () => {
|
||||||
|
expect(() => validateAgentApiKey({ ...base, model: "anthropic/claude-opus" })).toThrow(
|
||||||
|
"no API key found"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes for opencode keyed model with OPENCODE_API_KEY", () => {
|
||||||
|
process.env.OPENCODE_API_KEY = "sk-test";
|
||||||
|
expect(() => validateAgentApiKey({ ...base, model: "opencode/claude-opus" })).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for opencode keyed model without OPENCODE_API_KEY", () => {
|
||||||
|
expect(() => validateAgentApiKey({ ...base, model: "opencode/claude-opus" })).toThrow(
|
||||||
|
"no API key found"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("no model (auto-select)", () => {
|
||||||
|
it("passes when any known provider key is present", () => {
|
||||||
|
process.env.OPENAI_API_KEY = "sk-test";
|
||||||
|
expect(() => validateAgentApiKey({ ...base, model: undefined })).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when no provider keys are present", () => {
|
||||||
|
expect(() => validateAgentApiKey({ ...base, model: undefined })).toThrow("no API key found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+18
-4
@@ -1,4 +1,4 @@
|
|||||||
import { providers } from "../models.ts";
|
import { getModelEnvVars, providers } from "../models.ts";
|
||||||
import { getApiUrl } from "./apiUrl.ts";
|
import { getApiUrl } from "./apiUrl.ts";
|
||||||
|
|
||||||
const knownApiKeys: Set<string> = new Set(Object.values(providers).flatMap((p) => [...p.envVars]));
|
const knownApiKeys: Set<string> = new Set(Object.values(providers).flatMap((p) => [...p.envVars]));
|
||||||
@@ -23,15 +23,29 @@ to fix this, add the required secret to your GitHub repository:
|
|||||||
configure your model at ${settingsUrl}`;
|
configure your model at ${settingsUrl}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasEnvVar(name: string): boolean {
|
||||||
|
const value = process.env[name];
|
||||||
|
return typeof value === "string" && value.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function validateAgentApiKey(params: {
|
export function validateAgentApiKey(params: {
|
||||||
agent: { name: string };
|
agent: { name: string };
|
||||||
|
model: string | undefined;
|
||||||
owner: string;
|
owner: string;
|
||||||
name: string;
|
name: string;
|
||||||
}): void {
|
}): void {
|
||||||
const hasAnyKey = Object.entries(process.env).some(
|
// if a specific model is configured, only check that model's required env vars
|
||||||
([key, value]) => value && typeof value === "string" && knownApiKeys.has(key)
|
if (params.model) {
|
||||||
);
|
const requiredVars = getModelEnvVars(params.model);
|
||||||
|
// free models have no required env vars — skip validation entirely
|
||||||
|
if (requiredVars.length === 0) return;
|
||||||
|
if (requiredVars.some((v) => hasEnvVar(v))) return;
|
||||||
|
|
||||||
|
throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// no model configured — auto-select requires at least one known provider key
|
||||||
|
const hasAnyKey = [...knownApiKeys].some((k) => hasEnvVar(k));
|
||||||
if (!hasAnyKey) {
|
if (!hasAnyKey) {
|
||||||
throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name }));
|
throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ const testEnvAllowList = new Set([
|
|||||||
"ANTHROPIC_API_KEY",
|
"ANTHROPIC_API_KEY",
|
||||||
"GEMINI_API_KEY",
|
"GEMINI_API_KEY",
|
||||||
"GOOGLE_GENERATIVE_AI_API_KEY",
|
"GOOGLE_GENERATIVE_AI_API_KEY",
|
||||||
|
"PULLFROG_MODEL",
|
||||||
"OPENCODE_MODEL",
|
"OPENCODE_MODEL",
|
||||||
"LOG_LEVEL",
|
"LOG_LEVEL",
|
||||||
"DEBUG",
|
"DEBUG",
|
||||||
|
|||||||
+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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user