6d25adfd1a
* agent & model refactor with ASKPASS git auth, UI restructure, clerk v7 Made-with: Cursor * fix stale agent/effort refs, add tests for askpass + model resolution - reviewCleanup.ts: payload.agent -> payload.model, remove effort - selectMode.ts PlanEdit: remove delegation/subagent/effort references - pullfrog.yml.ts: update env vars (drop GOOGLE_API_KEY/CURSOR_API_KEY, add GOOGLE_GENERATIVE_AI_API_KEY/XAI_API_KEY/MOONSHOT_API_KEY/OPENCODE_API_KEY) - FlagsSettings/RepoInstructionsSection: remove stale effort/timeout copy - new: gitAuthServer.test.ts (10 tests — lifecycle, token delivery, tamper detection, script gen) - new: agent.test.ts (4 tests — default opentoad, AGENT_OVERRIDE, invalid override) - new: models.test.ts (19 tests — parseModel, resolution, registry invariants) - update models.dev snapshot Made-with: Cursor * fix changed-agents.sh to filter legacy agent files from CI matrix legacy agent files (claude.ts, codex.ts, etc.) are @ts-nocheck and not exported from index.ts. changed-agents.sh now reads index.ts imports to build the active agent set and treats changes to inactive files as non-agent changes (opentoad canary only). Made-with: Cursor * remove MCP file tools, old agent harnesses, and obsolete security tests ASKPASS-based git auth makes the old MCP file tool security layer unnecessary: - token never in subprocess env, so symlink/gitattributes/hook attacks can't exfiltrate it - agents now use native file tools (OpenCode builtin read/edit) deleted: - action/mcp/file.ts (file_read, file_write, file_edit, file_delete, list_directory) - action/mcp/index.ts (dead re-export) - agent harnesses: claude.ts, codex.ts, cursor.ts, gemini.ts, opencode.ts - opencode-runner.ts (inlined into opentoad.ts) - security tests that validated MCP file tool restrictions - commented-out three-step review flow (~300 lines) - sanitizeSchema/wrapSchema dead code from mcp/shared.ts - OPENCODE_MODEL_MINI/MAX env vars (effort-level model overrides removed) updated test prompts to use generic file ops instead of MCP tool names. restored pkg-json-scripts + requirements-txt-attack (test --ignore-scripts defense). Made-with: Cursor * bump actions/checkout v4 → v6 (node 24) node 20 actions deprecated june 2, 2026. Made-with: Cursor * temporarily disable fail-fast on agnostic tests to debug checkout@v6 Made-with: Cursor * re-enable fail-fast on agnostic tests Made-with: Cursor * fix test token mismatch: mint OIDC tokens scoped to target repo CI tests override GITHUB_REPOSITORY to pullfrog/test-repo but inherit the runner's GITHUB_TOKEN (scoped to pullfrog/app), causing 401s on every run-context fetch. Clear GITHUB_TOKEN in the test subprocess so ensureGitHubToken() mints a properly scoped token via OIDC. Also centralizes the default GITHUB_REPOSITORY in runAgentStreaming instead of repeating it in every test file, and fixes preview-cleanup to remove workers from all queues (not just name-matching ones). Made-with: Cursor * fix ensureGitHubToken to try OIDC when app credentials are absent ensureGitHubToken only attempted token minting when GITHUB_APP_ID and GITHUB_PRIVATE_KEY were set. In CI, OIDC is available but app creds aren't exposed — so the guard prevented minting entirely. Made-with: Cursor * dead code cleanup: remove remnants of deleted agents, file tools, effort system remove unused @anthropic-ai/claude-agent-sdk and @openai/codex-sdk deps, orphaned file-tool security tests, dead GEMINI_MODEL passthrough, stale opencode-runner wiki refs, deleted test file references, and MCP file tool docs. rename docs/effort → docs/models. fix vitest setup: move dotenv to globalSetup (runs once before forks instead of per-file, 19s → 200ms). Made-with: Cursor * address review feedback: remove dead code, update stale references - remove AGENT_OVERRIDE (only opentoad exists) - remove shellToolName plumbing (always restricted shell) - bump action version to 0.0.179 - remove CURSOR_API_KEY from all workflows/configs - remove OPENCODE_MODEL_MINI/MAX from workflows/docs - delete wiki/effort.md, rewrite docs/effort.mdx as "Models" - rewrite wiki/modes.md: orchestrator/subagent → single agent - simplify flag system: drop builtin flag extraction (debug, effort, timeout, agent), keep custom flag replacement only - reserve all legacy flag names to prevent custom flag conflicts Made-with: Cursor * regenerate lockfile after removing claude-agent-sdk and codex-sdk Made-with: Cursor * fix import ordering, add lockfile check to pre-push hook Made-with: Cursor * remove dead debug payload field, stale packageExtensions Made-with: Cursor * merge proc-sandbox and token-exfil into a single test proc-sandbox and token-exfil were duplicative — both tested that SANDBOX_TEST_TOKEN couldn't be exfiltrated. consolidated into token-exfil with shell:restricted (which actually exercises filterEnv) and the /proc attack vector hints from proc-sandbox. Made-with: Cursor * fix wiki adversarial.md to match actual tokenExfil validator Made-with: Cursor
411 lines
14 KiB
TypeScript
411 lines
14 KiB
TypeScript
import { writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import type { Octokit, RestEndpointMethodTypes } from "@octokit/rest";
|
|
import { type } from "arktype";
|
|
import { log } from "../utils/cli.ts";
|
|
import { $git } from "../utils/gitAuth.ts";
|
|
import { executeLifecycleHook } from "../utils/lifecycle.ts";
|
|
import { $ } from "../utils/shell.ts";
|
|
import type { ToolContext } from "./server.ts";
|
|
import { execute, tool } from "./shared.ts";
|
|
|
|
type PullFile = RestEndpointMethodTypes["pulls"]["listFiles"]["response"]["data"][number];
|
|
|
|
export type FormatFilesResult = {
|
|
content: string;
|
|
toc: string;
|
|
};
|
|
|
|
/**
|
|
* formats PR files with explicit line numbers for each code line.
|
|
* preserves all original diff info (file headers, hunk headers) and adds:
|
|
* | OLD | NEW | TYPE | code
|
|
* returns both the formatted content and a TOC with line ranges per file.
|
|
*/
|
|
export function formatFilesWithLineNumbers(files: PullFile[]): FormatFilesResult {
|
|
const output: string[] = [];
|
|
const tocEntries: Array<{ filename: string; startLine: number; endLine: number }> = [];
|
|
|
|
// calculate TOC header size: "## Files (N)\n" + N entries + "\n---\n\n"
|
|
const tocHeaderSize = 1 + files.length + 2;
|
|
let currentLine = tocHeaderSize + 1;
|
|
|
|
for (const file of files) {
|
|
const fileStartLine = currentLine;
|
|
|
|
// file header
|
|
output.push(`diff --git a/${file.filename} b/${file.filename}`);
|
|
output.push(`--- a/${file.filename}`);
|
|
output.push(`+++ b/${file.filename}`);
|
|
currentLine += 3;
|
|
|
|
if (!file.patch) {
|
|
output.push("(binary file or no changes)");
|
|
output.push("");
|
|
currentLine += 2;
|
|
tocEntries.push({
|
|
filename: file.filename,
|
|
startLine: fileStartLine,
|
|
endLine: currentLine - 1,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// parse and format the patch with line numbers
|
|
const lines = file.patch.split("\n");
|
|
let oldLine = 0;
|
|
let newLine = 0;
|
|
|
|
for (const line of lines) {
|
|
// hunk header: @@ -OLD,COUNT +NEW,COUNT @@ optional context
|
|
const hunkMatch = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
if (hunkMatch) {
|
|
oldLine = parseInt(hunkMatch[1], 10);
|
|
newLine = parseInt(hunkMatch[2], 10);
|
|
output.push(line); // pass through unchanged
|
|
currentLine++;
|
|
continue;
|
|
}
|
|
|
|
// code lines within hunks
|
|
const changeType = line[0] || " ";
|
|
const code = line.slice(1);
|
|
|
|
if (changeType === "-") {
|
|
// removed line: show old line number, no new line number
|
|
output.push(`| ${padNum(oldLine)} | | - | ${code}`);
|
|
oldLine++;
|
|
} else if (changeType === "+") {
|
|
// added line: no old line number, show new line number
|
|
output.push(`| | ${padNum(newLine)} | + | ${code}`);
|
|
newLine++;
|
|
} else if (changeType === " " || changeType === "\\") {
|
|
// context line or "\ No newline at end of file"
|
|
if (changeType === "\\") {
|
|
output.push(line); // pass through as-is
|
|
} else {
|
|
output.push(`| ${padNum(oldLine)} | ${padNum(newLine)} | | ${code}`);
|
|
oldLine++;
|
|
newLine++;
|
|
}
|
|
} else {
|
|
// unknown line type, pass through
|
|
output.push(line);
|
|
}
|
|
currentLine++;
|
|
}
|
|
output.push(""); // blank line between files
|
|
currentLine++;
|
|
|
|
tocEntries.push({
|
|
filename: file.filename,
|
|
startLine: fileStartLine,
|
|
endLine: currentLine - 1,
|
|
});
|
|
}
|
|
|
|
// build TOC
|
|
const tocLines = [`## Files (${files.length})`];
|
|
for (const entry of tocEntries) {
|
|
tocLines.push(`- ${entry.filename} → lines ${entry.startLine}-${entry.endLine}`);
|
|
}
|
|
tocLines.push("");
|
|
tocLines.push("---");
|
|
tocLines.push("");
|
|
|
|
const toc = tocLines.join("\n");
|
|
const content = toc + output.join("\n");
|
|
|
|
return { content, toc };
|
|
}
|
|
|
|
function padNum(n: number): string {
|
|
return n.toString().padStart(4, " ");
|
|
}
|
|
|
|
export const CheckoutPr = type({
|
|
pull_number: type.number.describe("the pull request number to checkout"),
|
|
});
|
|
|
|
export type CheckoutPrResult = {
|
|
success: true;
|
|
number: number;
|
|
title: string;
|
|
base: string;
|
|
localBranch: string;
|
|
remoteBranch: string;
|
|
isFork: boolean;
|
|
maintainerCanModify: boolean;
|
|
url: string;
|
|
headRepo: string;
|
|
diffPath: string;
|
|
toc: string;
|
|
instructions: string;
|
|
};
|
|
|
|
type FetchPrDiffParams = {
|
|
octokit: Octokit;
|
|
owner: string;
|
|
repo: string;
|
|
pullNumber: number;
|
|
};
|
|
|
|
/**
|
|
* fetches PR files from GitHub and formats them with line numbers and TOC.
|
|
* this is the core diff formatting logic, extracted for testability.
|
|
*/
|
|
export async function fetchAndFormatPrDiff(params: FetchPrDiffParams): Promise<FormatFilesResult> {
|
|
const filesResponse = await params.octokit.rest.pulls.listFiles({
|
|
owner: params.owner,
|
|
repo: params.repo,
|
|
pull_number: params.pullNumber,
|
|
per_page: 100,
|
|
});
|
|
return formatFilesWithLineNumbers(filesResponse.data);
|
|
}
|
|
|
|
import type { GitContext } from "../utils/setup.ts";
|
|
|
|
type CheckoutPrBranchParams = GitContext;
|
|
|
|
interface CheckoutPrBranchResult {
|
|
prNumber: number;
|
|
isFork: boolean;
|
|
forkUrl?: string | undefined; // only set when isFork is true
|
|
}
|
|
|
|
/**
|
|
* Shared helper to checkout a PR branch and configure fork remotes.
|
|
* Assumes origin remote is already configured with authentication.
|
|
* Updates toolState.issueNumber and toolState.pushUrl (for fork PRs).
|
|
*/
|
|
export async function checkoutPrBranch(
|
|
pullNumber: number,
|
|
params: CheckoutPrBranchParams
|
|
): Promise<CheckoutPrBranchResult> {
|
|
const { octokit, owner, name, gitToken, toolState } = params;
|
|
log.info(`» checking out PR #${pullNumber}...`);
|
|
|
|
// fetch PR metadata
|
|
const pr = await octokit.rest.pulls.get({
|
|
owner,
|
|
repo: name,
|
|
pull_number: pullNumber,
|
|
});
|
|
|
|
const headRepo = pr.data.head.repo;
|
|
if (!headRepo) {
|
|
throw new Error(`PR #${pullNumber} source repository was deleted`);
|
|
}
|
|
|
|
const isFork = headRepo.full_name !== pr.data.base.repo.full_name;
|
|
const baseBranch = pr.data.base.ref;
|
|
const headBranch = pr.data.head.ref;
|
|
|
|
// always use pr-{number} as local branch name for consistency
|
|
// this avoids naming conflicts and makes push config simpler
|
|
const localBranch = `pr-${pullNumber}`;
|
|
|
|
// compute deepen depth for shallow clones. actions/checkout uses depth=1
|
|
// by default, which breaks rebase/log because git can't find the merge base.
|
|
// use the GitHub compare API to fetch exactly enough history.
|
|
const isShallow =
|
|
$("git", ["rev-parse", "--is-shallow-repository"], { log: false }).trim() === "true";
|
|
let deepenArgs: string[] = [];
|
|
if (isShallow) {
|
|
let depth = 1000; // fallback
|
|
try {
|
|
const comparison = await octokit.rest.repos.compareCommits({
|
|
owner,
|
|
repo: name,
|
|
base: baseBranch,
|
|
head: `pull/${pullNumber}/head`,
|
|
});
|
|
depth = comparison.data.behind_by + 10;
|
|
log.debug(
|
|
`» PR is ${comparison.data.behind_by} commits behind ${baseBranch}, deepening by ${depth}`
|
|
);
|
|
} catch {
|
|
log.debug(`» compare API failed, falling back to --deepen=${depth}`);
|
|
}
|
|
deepenArgs = [`--deepen=${depth}`];
|
|
}
|
|
|
|
// check if we're already on the correct commit (not just branch name)
|
|
// this handles fork PRs where head branch name might match base branch name
|
|
const currentSha = $("git", ["rev-parse", "HEAD"], { log: false }).trim();
|
|
const alreadyOnBranch = currentSha === pr.data.head.sha;
|
|
|
|
if (alreadyOnBranch) {
|
|
log.debug(`already on PR branch ${localBranch}, skipping checkout`);
|
|
} else {
|
|
// fetch base branch so origin/<base> exists for diff operations
|
|
log.debug(`» fetching base branch (${baseBranch})...`);
|
|
await $git("fetch", [...deepenArgs, "--no-tags", "origin", baseBranch], {
|
|
token: gitToken,
|
|
});
|
|
|
|
// checkout base branch first to avoid "refusing to fetch into current branch" error
|
|
// -B creates or resets the branch to match origin/baseBranch
|
|
$("git", ["checkout", "-B", baseBranch, `origin/${baseBranch}`], { log: false });
|
|
|
|
// fetch PR branch using pull/{n}/head refspec (works for both fork and same-repo PRs)
|
|
log.debug(`» fetching PR #${pullNumber} (${localBranch})...`);
|
|
await $git("fetch", ["--no-tags", "origin", `pull/${pullNumber}/head:${localBranch}`], {
|
|
token: gitToken,
|
|
});
|
|
|
|
// checkout the branch
|
|
$("git", ["checkout", localBranch], { log: false });
|
|
log.debug(`» checked out PR #${pullNumber}`);
|
|
}
|
|
|
|
// ensure base branch is fetched (needed for diff operations)
|
|
// fetch if we skipped checkout (already on branch) - otherwise already fetched above
|
|
if (alreadyOnBranch) {
|
|
log.debug(`» fetching base branch (${baseBranch})...`);
|
|
await $git("fetch", [...deepenArgs, "--no-tags", "origin", baseBranch], {
|
|
token: gitToken,
|
|
});
|
|
}
|
|
|
|
// configure push remote for this branch
|
|
// NOTE: This always runs regardless of alreadyOnBranch, because setupGit doesn't configure
|
|
// fork remotes. This ensures fork PRs can push even when checkout_pr is called after setupGit.
|
|
if (isFork) {
|
|
const remoteName = `pr-${pullNumber}`;
|
|
// SECURITY: fork URL without token - auth is injected via GIT_ASKPASS in $git()
|
|
const forkUrl = `https://github.com/${headRepo.full_name}.git`;
|
|
|
|
// add fork as a named remote (suppress logging to avoid "error: remote already exists" spam)
|
|
try {
|
|
$("git", ["remote", "add", remoteName, forkUrl], { log: false });
|
|
log.debug(`» added remote '${remoteName}' for fork ${headRepo.full_name}`);
|
|
} catch {
|
|
// remote already exists, update its URL
|
|
$("git", ["remote", "set-url", remoteName, forkUrl], { log: false });
|
|
log.debug(`» updated remote '${remoteName}' for fork ${headRepo.full_name}`);
|
|
}
|
|
|
|
// set branch push config so `git push` knows where to push
|
|
$("git", ["config", `branch.${localBranch}.pushRemote`, remoteName], { log: false });
|
|
// set merge ref so git knows the remote branch name (may differ from local)
|
|
$("git", ["config", `branch.${localBranch}.merge`, `refs/heads/${headBranch}`], { log: false });
|
|
log.debug(`» configured branch '${localBranch}' to push to '${remoteName}/${headBranch}'`);
|
|
|
|
// warn if maintainer can't modify (push will likely fail)
|
|
if (!pr.data.maintainer_can_modify) {
|
|
log.warning(
|
|
`» fork PR has maintainer_can_modify=false - push operations will fail. ` +
|
|
`ask the PR author to enable "Allow edits from maintainers" or the fork may be owned by an organization.`
|
|
);
|
|
}
|
|
} else {
|
|
// for same-repo PRs, push to origin
|
|
$("git", ["config", `branch.${localBranch}.pushRemote`, "origin"], { log: false });
|
|
$("git", ["config", `branch.${localBranch}.merge`, `refs/heads/${headBranch}`], { log: false });
|
|
}
|
|
|
|
// update toolState
|
|
toolState.issueNumber = pullNumber;
|
|
if (isFork) {
|
|
toolState.pushUrl = `https://github.com/${headRepo.full_name}.git`;
|
|
}
|
|
|
|
// store push destination so push_branch can use it directly
|
|
// git config is the primary mechanism, but toolState serves as a reliable fallback
|
|
// in case git config reads fail in certain environments
|
|
toolState.pushDest = {
|
|
remoteName: isFork ? `pr-${pullNumber}` : "origin",
|
|
remoteBranch: headBranch,
|
|
localBranch,
|
|
};
|
|
|
|
// execute post-checkout lifecycle hook
|
|
await executeLifecycleHook({
|
|
event: "post-checkout",
|
|
script: params.postCheckoutScript,
|
|
});
|
|
|
|
return {
|
|
prNumber: pullNumber,
|
|
isFork,
|
|
forkUrl: isFork ? `https://github.com/${headRepo.full_name}.git` : undefined,
|
|
};
|
|
}
|
|
|
|
export function CheckoutPrTool(ctx: ToolContext) {
|
|
return tool({
|
|
name: "checkout_pr",
|
|
description:
|
|
"Checkout a pull request branch locally. This fetches the PR branch and sets up push configuration for fork PRs. " +
|
|
"Returns diffPath pointing to the formatted diff file.",
|
|
parameters: CheckoutPr,
|
|
execute: execute(async ({ pull_number }) => {
|
|
await checkoutPrBranch(pull_number, {
|
|
octokit: ctx.octokit,
|
|
owner: ctx.repo.owner,
|
|
name: ctx.repo.name,
|
|
gitToken: ctx.gitToken,
|
|
toolState: ctx.toolState,
|
|
shell: ctx.payload.shell,
|
|
postCheckoutScript: ctx.postCheckoutScript,
|
|
});
|
|
|
|
const pr = await ctx.octokit.rest.pulls.get({
|
|
owner: ctx.repo.owner,
|
|
repo: ctx.repo.name,
|
|
pull_number,
|
|
});
|
|
|
|
const headRepo = pr.data.head.repo;
|
|
if (!headRepo) {
|
|
throw new Error(`PR #${pull_number} source repository was deleted`);
|
|
}
|
|
|
|
ctx.toolState.checkoutSha = pr.data.head.sha;
|
|
|
|
// fetch PR files and format with line numbers
|
|
const formatResult = await fetchAndFormatPrDiff({
|
|
octokit: ctx.octokit,
|
|
owner: ctx.repo.owner,
|
|
repo: ctx.repo.name,
|
|
pullNumber: pull_number,
|
|
});
|
|
const diffPreview = formatResult.content.split("\n").slice(0, 100).join("\n");
|
|
log.debug(`formatted diff preview (first 100 lines):\n${diffPreview}`);
|
|
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
|
if (!tempDir) {
|
|
throw new Error(
|
|
"PULLFROG_TEMP_DIR not set - checkout_pr must run in pullfrog action context"
|
|
);
|
|
}
|
|
const diffPath = join(tempDir, `pr-${pull_number}.diff`);
|
|
writeFileSync(diffPath, formatResult.content);
|
|
log.debug(`wrote diff to ${diffPath} (${formatResult.content.length} bytes)`);
|
|
|
|
return {
|
|
success: true,
|
|
number: pr.data.number,
|
|
title: pr.data.title,
|
|
base: pr.data.base.ref,
|
|
localBranch: `pr-${pull_number}`,
|
|
remoteBranch: `refs/heads/${pr.data.head.ref}`,
|
|
isFork: headRepo.full_name !== pr.data.base.repo.full_name,
|
|
maintainerCanModify: pr.data.maintainer_can_modify,
|
|
url: pr.data.html_url,
|
|
headRepo: headRepo.full_name,
|
|
diffPath,
|
|
toc: formatResult.toc,
|
|
instructions:
|
|
`the diff file at diffPath contains a table of contents (TOC) at the top listing every changed file with its line range. ` +
|
|
`use the line ranges to read specific files from the diff instead of reading the entire file. ` +
|
|
`for example, if the TOC says "src/foo.ts → lines 5-42", read lines 5-42 from diffPath to see that file's changes. ` +
|
|
`review files selectively based on relevance rather than reading everything sequentially. ` +
|
|
`the local branch is 'localBranch' (pr-{number}), not the remote branch name. ` +
|
|
`when pushing, omit branchName to use the current branch. do not use remoteBranch as a local branch name.`,
|
|
} satisfies CheckoutPrResult;
|
|
}),
|
|
});
|
|
}
|