diff --git a/entry b/entry index 80fddeb..81d23e7 100755 --- a/entry +++ b/entry @@ -91709,14 +91709,14 @@ var require_turndown_cjs = __commonJS({ } else if (node2.nodeType === 1) { replacement = replacementForNode.call(self2, node2); } - return join14(output, replacement); + return join15(output, replacement); }, ""); } function postProcess(output) { var self2 = this; this.rules.forEach(function(rule) { if (typeof rule.append === "function") { - output = join14(output, rule.append(self2.options)); + output = join15(output, rule.append(self2.options)); } }); return output.replace(/^[\t\r\n]+/, "").replace(/[\t\r\n\s]+$/, ""); @@ -91728,7 +91728,7 @@ var require_turndown_cjs = __commonJS({ if (whitespace.leading || whitespace.trailing) content = content.trim(); return whitespace.leading + rule.replacement(content, node2, this.options) + whitespace.trailing; } - function join14(output, replacement) { + function join15(output, replacement) { var s1 = trimTrailingNewlines(output); var s2 = trimLeadingNewlines(replacement); var nls = Math.max(output.length - s1.length, replacement.length - s2.length); @@ -136940,15 +136940,81 @@ ${diffPreview}`); } // mcp/checkSuite.ts +import { mkdirSync, writeFileSync as writeFileSync3 } from "node:fs"; +import { join as join3 } from "node:path"; var GetCheckSuiteLogs = type({ check_suite_id: type.number.describe("the id from check_suite.id") }); +function analyzeLog(logs, excerptLines = 80) { + const clean = logs.replace(/\x1b\[[0-9;]*m/g, ""); + const lines = clean.split("\n"); + const totalLines = lines.length; + const index = []; + const patterns = [ + { type: "error", pattern: /##\[error\]/i }, + { type: "error", pattern: /\bError:/i }, + { type: "error", pattern: /\bERR_/i }, + { type: "error", pattern: /exit code [1-9]/i }, + { type: "warning", pattern: /##\[warning\]/i }, + { type: "warning", pattern: /\bWARN\b/i, skip: /apt|dpkg|Reading package/i }, + { type: "failure", pattern: /\d+ failed/i }, + { type: "failure", pattern: /FAIL\b/i }, + { type: "failure", pattern: /✕|✗|×/ }, + { type: "trace", pattern: /^\s+at\s+/i } + ]; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + for (const p of patterns) { + if (p.pattern.test(line)) { + if (p.skip?.test(line)) continue; + if (p.type === "trace" && index.length > 0 && index[index.length - 1].type === "trace") { + continue; + } + const truncated = line.length > 120 ? line.slice(0, 117) + "..." : line; + index.push({ + line: i + 1, + content: truncated.trim(), + type: p.type + }); + break; + } + } + } + let errorLine = -1; + for (let i = lines.length - 1; i >= 0; i--) { + if (/##\[error\]/i.test(lines[i])) { + errorLine = i; + break; + } + } + let start; + let end; + if (errorLine === -1) { + start = Math.max(0, totalLines - excerptLines); + end = totalLines; + } else { + const contextAfter = 5; + const contextBefore = excerptLines - contextAfter; + start = Math.max(0, errorLine - contextBefore); + end = Math.min(totalLines, errorLine + contextAfter); + } + return { + totalLines, + index, + excerpt: { + content: lines.slice(start, end).join("\n"), + startLine: start + 1, + endLine: end + } + }; +} function GetCheckSuiteLogsTool(ctx) { return tool({ name: "get_check_suite_logs", - description: "get workflow run logs for a failed check suite. pass check_suite.id from the webhook payload.", + description: "get workflow run logs for a failed check suite. returns a log_index of interesting lines, a curated excerpt, and full_log_path for deeper investigation. pass check_suite.id from the webhook payload.", parameters: GetCheckSuiteLogs, - execute: execute(async ({ check_suite_id }) => { + execute: execute(async (params) => { + const check_suite_id = params.check_suite_id; const workflowRuns = await ctx.octokit.paginate( ctx.octokit.rest.actions.listWorkflowRunsForRepo, { @@ -136963,68 +137029,83 @@ function GetCheckSuiteLogsTool(ctx) { return { check_suite_id, message: "no failed workflow runs found for this check suite", - workflow_runs: [] + failed_jobs: [] }; } - const logsForRuns = await Promise.all( - failedRuns.map(async (run2) => { - const jobs = await ctx.octokit.paginate(ctx.octokit.rest.actions.listJobsForWorkflowRun, { - owner: ctx.repo.owner, - repo: ctx.repo.name, - run_id: run2.id - }); - const jobLogs = await Promise.all( - jobs.map(async (job) => { - try { - const logsResponse = await ctx.octokit.rest.actions.downloadJobLogsForWorkflowRun({ - owner: ctx.repo.owner, - repo: ctx.repo.name, - job_id: job.id - }); - const logsUrl = logsResponse.url; - const logsText = await fetch(logsUrl).then((r) => r.text()); - return { - job_id: job.id, - job_name: job.name, - status: job.status, - conclusion: job.conclusion, - started_at: job.started_at, - completed_at: job.completed_at, - logs: logsText - }; - } catch (error50) { - return { - job_id: job.id, - job_name: job.name, - status: job.status, - conclusion: job.conclusion, - started_at: job.started_at, - completed_at: job.completed_at, - error: `failed to fetch logs: ${error50}` - }; - } - }) - ); - return { - workflow_run_id: run2.id, - workflow_name: run2.name, - html_url: run2.html_url, - conclusion: run2.conclusion, - jobs: jobLogs - }; - }) - ); + const tempDir = process.env.PULLFROG_TEMP_DIR; + if (!tempDir) { + throw new Error("PULLFROG_TEMP_DIR not set"); + } + const logsDir = join3(tempDir, "ci-logs"); + mkdirSync(logsDir, { recursive: true }); + const jobResults = []; + for (const run2 of failedRuns) { + const jobs = await ctx.octokit.paginate(ctx.octokit.rest.actions.listJobsForWorkflowRun, { + owner: ctx.repo.owner, + repo: ctx.repo.name, + run_id: run2.id + }); + const failedJobs = jobs.filter((job) => job.conclusion === "failure"); + for (const job of failedJobs) { + try { + const logsResponse = await ctx.octokit.rest.actions.downloadJobLogsForWorkflowRun({ + owner: ctx.repo.owner, + repo: ctx.repo.name, + job_id: job.id + }); + const logsUrl = logsResponse.url; + const logsText = await fetch(logsUrl).then((r) => r.text()); + const logPath = join3(logsDir, `job-${job.id}.log`); + writeFileSync3(logPath, logsText); + const analysis = analyzeLog(logsText, 80); + const failedSteps = job.steps?.filter((s) => s.conclusion === "failure").map((s) => `Step ${s.number}: ${s.name}`) ?? []; + jobResults.push({ + job_id: job.id, + job_name: job.name, + job_url: job.html_url ?? "", + failed_steps: failedSteps, + log_index: analysis.index, + excerpt: { + start_line: analysis.excerpt.startLine, + end_line: analysis.excerpt.endLine, + total_lines: analysis.totalLines, + content: analysis.excerpt.content + }, + full_log_path: logPath + }); + log.debug(`analyzed logs for job ${job.name}: ${analysis.index.length} indexed lines`); + } catch (error50) { + log.error(`failed to fetch logs for job ${job.id}: ${error50}`); + } + } + } return { + _instructions: { + overview: "this result contains CI failure information. use log_index to find interesting lines, then read full_log_path for details.", + fields: { + log_index: "array of interesting lines (errors, warnings, failures) with line numbers. use these to navigate the full log.", + excerpt: "a curated ~80 line window around the last error. may not show all failures if they occur in different places.", + full_log_path: "path to the complete log file. read specific line ranges using the line numbers from log_index.", + failed_steps: "which CI steps failed. read the workflow yml to understand what commands these steps run." + }, + workflow: [ + "1. scan log_index to see where errors/warnings/failures are located", + "2. read excerpt for immediate context", + "3. if excerpt doesn't show what you need, read specific line ranges from full_log_path", + "4. check failed_steps to understand what command failed" + ] + }, check_suite_id, - workflow_runs: logsForRuns + repo: `${ctx.repo.owner}/${ctx.repo.name}`, + failed_jobs: jobResults }; }) }); } // mcp/commitInfo.ts -import { writeFileSync as writeFileSync3 } from "node:fs"; -import { join as join3 } from "node:path"; +import { writeFileSync as writeFileSync4 } from "node:fs"; +import { join as join4 } from "node:path"; var CommitInfo = type({ sha: type.string.describe("the commit SHA (full or abbreviated) to fetch") }); @@ -137048,8 +137129,8 @@ function CommitInfoTool(ctx) { "PULLFROG_TEMP_DIR not set - get_commit_info must run in pullfrog action context" ); } - const diffFile = join3(tempDir, `commit-${sha.slice(0, 7)}.diff`); - writeFileSync3(diffFile, formatResult.content); + const diffFile = join4(tempDir, `commit-${sha.slice(0, 7)}.diff`); + writeFileSync4(diffFile, formatResult.content); log.debug(`wrote commit diff to ${diffFile} (${formatResult.content.length} bytes)`); return { sha: data.sha, @@ -137073,7 +137154,7 @@ function CommitInfoTool(ctx) { // prep/installNodeDependencies.ts import { existsSync as existsSync2, readFileSync } from "node:fs"; -import { join as join4 } from "node:path"; +import { join as join5 } from "node:path"; // node_modules/.pnpm/@ark+util@0.53.0/node_modules/@ark/util/out/domain.js var domainDescriptions2 = { @@ -137660,7 +137741,7 @@ async function isCommandAvailable(command) { return result.exitCode === 0; } function getPackageManagerFromPackageJson() { - const packageJsonPath = join4(process.cwd(), "package.json"); + const packageJsonPath = join5(process.cwd(), "package.json"); try { const content = readFileSync(packageJsonPath, "utf-8"); const pkg = JSON.parse(content); @@ -137691,7 +137772,7 @@ async function installPackageManager(name, installSpec) { return result.stderr || `failed to install ${name}`; } if (name === "deno") { - const denoPath = join4(process.env.HOME || "", ".deno", "bin"); + const denoPath = join5(process.env.HOME || "", ".deno", "bin"); process.env.PATH = `${denoPath}:${process.env.PATH}`; } log.info(`\xBB installed ${name}`); @@ -137700,7 +137781,7 @@ async function installPackageManager(name, installSpec) { var installNodeDependencies = { name: "installNodeDependencies", shouldRun: () => { - const packageJsonPath = join4(process.cwd(), "package.json"); + const packageJsonPath = join5(process.cwd(), "package.json"); return existsSync2(packageJsonPath); }, run: async () => { @@ -137768,7 +137849,7 @@ ${errorMessage}`] // prep/installPythonDependencies.ts import { existsSync as existsSync3 } from "node:fs"; -import { join as join5 } from "node:path"; +import { join as join6 } from "node:path"; var PYTHON_CONFIGS = [ { file: "requirements.txt", @@ -137840,11 +137921,11 @@ var installPythonDependencies = { return false; } const cwd2 = process.cwd(); - return PYTHON_CONFIGS.some((config4) => existsSync3(join5(cwd2, config4.file))); + return PYTHON_CONFIGS.some((config4) => existsSync3(join6(cwd2, config4.file))); }, run: async () => { const cwd2 = process.cwd(); - const config4 = PYTHON_CONFIGS.find((c) => existsSync3(join5(cwd2, c.file))); + const config4 = PYTHON_CONFIGS.find((c) => existsSync3(join6(cwd2, c.file))); if (!config4) { return { language: "python", @@ -138664,8 +138745,8 @@ function CreatePullRequestReviewTool(ctx) { } // mcp/reviewComments.ts -import { writeFileSync as writeFileSync4 } from "node:fs"; -import { join as join6 } from "node:path"; +import { writeFileSync as writeFileSync5 } from "node:fs"; +import { join as join7 } from "node:path"; var REVIEW_THREADS_QUERY = ` query ($owner: String!, $name: String!, $prNumber: Int!) { repository(owner: $owner, name: $name) { @@ -138999,8 +139080,8 @@ function GetReviewCommentsTool(ctx) { throw new Error("PULLFROG_TEMP_DIR not set"); } const filename = `review-${params.review_id}-threads.md`; - const commentsPath = join6(tempDir, filename); - writeFileSync4(commentsPath, formatted.content); + const commentsPath = join7(tempDir, filename); + writeFileSync5(commentsPath, formatted.content); log.debug(`wrote ${threadBlocks.length} threads to ${commentsPath}`); return { review_id: params.review_id, @@ -139313,6 +139394,71 @@ function computeModes() { 4. Create a structured plan with clear milestones 5. ${reportProgressInstruction}` + }, + { + name: "Fix", + description: "Fix CI failures; debug failing tests or builds; investigate and resolve check suite failures", + prompt: `Follow these steps to fix CI failures. THINK HARDER. + +**CRITICAL RULE**: Only fix issues that were INTRODUCED BY THIS PR. If the CI failure is unrelated to the PR's changes, you MUST abort without committing anything and report why. + +1. **GET FAILURE INFO** - Call ${ghPullfrogMcpName}/get_check_suite_logs with the check_suite_id from EVENT DATA. This returns: + - \`log_index\`: array of interesting lines (errors, warnings, failures) with line numbers - scan this first + - \`excerpt\`: curated ~80 lines around the main error - read this for immediate context + - \`full_log_path\`: path to complete log file - read specific line ranges if needed + - \`failed_steps\`: which CI steps failed (e.g., "Step 6: Run tests") + +2. **CHECKOUT AND ASSESS CAUSATION** - Use ${ghPullfrogMcpName}/checkout_pr to get the PR diff. BEFORE attempting any fix, you MUST determine if this PR caused the failure: + + **Ask yourself**: "Could the changes in this PR have caused this failure?" + + - Read the PR diff carefully - what files were modified? + - What is failing? (test file, module, assertion) + - Is there a PLAUSIBLE CONNECTION between the PR changes and the failure? + + **ABORT immediately if any of these are true:** + - The failing test/file was NOT touched by this PR AND doesn't depend on changed code + - The error is infrastructure-related (network timeout, runner OOM, service unavailable) + - The error is a flaky test that passes/fails randomly + - The error existed before this PR (pre-existing bug in main branch) + - The error is in a dependency update not introduced by this PR + + **When aborting**, use ${ghPullfrogMcpName}/report_progress to explain: + "This CI failure appears unrelated to the PR's changes. [Describe the failure]. [Explain why it's not caused by the PR]. No changes made." + + **Only proceed** if there's a clear, logical connection between the PR changes and the failure. + +3. **UNDERSTAND HOW CI RUNS** - Read the workflow file to understand exactly what commands CI runs: + - Look at \`.github/workflows/*.yml\` files + - Find the job/step that failed (from \`failed_steps\`) + - Note the EXACT command (e.g., \`pnpm -r test --filter=action\`, not just \`pnpm test\`) + - Check for any CI-specific environment variables or setup steps + +4. ${dependencyInstallationStep} + +5. **REPRODUCE LOCALLY** - Run the EXACT same command that CI runs: + - Do NOT simplify (e.g., don't run \`pnpm test\` if CI runs \`pnpm -r test --filter=action\`) + - Check if CI uses specific flags, filters, or environment variables + - If CI runs multiple test suites, run them all + +6. **ANALYZE THE FAILURE** - Use the log_index and excerpt to understand: + - What exactly failed (test name, file, assertion) + - Are there earlier warnings that might explain the failure? + - Is the failure flaky or deterministic? + +7. **FIX THE ISSUE** - Make the necessary code changes. Common patterns: + - Test assertion failures: fix the code or update the test expectation + - Build failures: fix type errors, missing imports, syntax issues + - Lint failures: fix code style issues + - Timeout/flaky tests: investigate race conditions or increase timeouts + +8. **VERIFY THE FIX** - Run the EXACT same CI command again to confirm the fix works + +9. **COMMIT AND PUSH** - Use ${ghPullfrogMcpName}/commit_files and ${ghPullfrogMcpName}/push_branch + +10. ${reportProgressInstruction} + +**REMEMBER**: Your job is to fix issues THIS PR introduced, not to fix all CI failures. If in doubt about causation, abort and explain rather than making speculative changes.` }, { name: "Prompt", @@ -139346,14 +139492,14 @@ import { spawn as spawn3 } from "child_process"; import { createInterface } from "readline"; import * as fs2 from "fs"; import { stat as statPromise, open } from "fs/promises"; -import { join as join7 } from "path"; +import { join as join8 } from "path"; import { homedir } from "os"; import { dirname, join as join22 } from "path"; import { cwd } from "process"; import { realpathSync as realpathSync2 } from "fs"; import { randomUUID as randomUUID3 } from "crypto"; import { randomUUID as randomUUID22 } from "crypto"; -import { appendFileSync as appendFileSync2, existsSync as existsSync22, mkdirSync as mkdirSync2 } from "fs"; +import { appendFileSync as appendFileSync2, existsSync as existsSync22, mkdirSync as mkdirSync22 } from "fs"; import { join as join32 } from "path"; import { randomUUID as randomUUID32 } from "crypto"; var __create3 = Object.create; @@ -146048,7 +146194,7 @@ function shouldShowDebugMessage(message, filter) { return shouldShowDebugCategories(categories, filter); } function getClaudeConfigHomeDir() { - return process.env.CLAUDE_CONFIG_DIR ?? join7(homedir(), ".claude"); + return process.env.CLAUDE_CONFIG_DIR ?? join8(homedir(), ".claude"); } function isEnvTruthy(envVar) { if (!envVar) @@ -146505,7 +146651,7 @@ function getOrCreateDebugFile() { const debugDir = join32(getClaudeConfigHomeDir(), "debug"); debugFilePath = join32(debugDir, `sdk-${randomUUID22()}.txt`); if (!existsSync22(debugDir)) { - mkdirSync2(debugDir, { recursive: true }); + mkdirSync22(debugDir, { recursive: true }); } process.stderr.write(`SDK debug logs: ${debugFilePath} `); @@ -155958,7 +156104,7 @@ import { spawnSync as spawnSync2 } from "node:child_process"; import { chmodSync, createWriteStream as createWriteStream2, existsSync as existsSync5 } from "node:fs"; import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join as join8 } from "node:path"; +import { join as join9 } from "node:path"; import { pipeline } from "node:stream/promises"; async function installFromNpmTarball(params) { let resolvedVersion = params.version; @@ -155982,7 +156128,7 @@ async function installFromNpmTarball(params) { } log.debug(`\xBB installing ${params.packageName}@${resolvedVersion}...`); const tempDir = process.env.PULLFROG_TEMP_DIR; - const tarballPath = join8(tempDir, "package.tgz"); + const tarballPath = join9(tempDir, "package.tgz"); const npmRegistry = process.env.NPM_REGISTRY || "https://registry.npmjs.org"; let tarballUrl; if (params.packageName.startsWith("@")) { @@ -156011,8 +156157,8 @@ async function installFromNpmTarball(params) { `Failed to extract tarball: ${extractResult.stderr || extractResult.stdout || "Unknown error"}` ); } - const extractedDir = join8(tempDir, "package"); - const cliPath = join8(extractedDir, params.executablePath); + const extractedDir = join9(tempDir, "package"); + const cliPath = join9(extractedDir, params.executablePath); if (!existsSync5(cliPath)) { throw new Error(`Executable not found in extracted package at ${cliPath}`); } @@ -156074,10 +156220,10 @@ async function installFromGithub(params) { const assetUrl = asset.browser_download_url; log.debug(`\xBB downloading asset from ${assetUrl}...`); const tempDirPrefix = `${params.owner}-${params.repo}-github-`; - const tempDirPath = await mkdtemp(join8(tmpdir(), tempDirPrefix)); + const tempDirPath = await mkdtemp(join9(tmpdir(), tempDirPrefix)); const urlPath = new URL(assetUrl).pathname; const fileName3 = urlPath.split("/").pop() || "asset"; - const downloadPath = join8(tempDirPath, fileName3); + const downloadPath = join9(tempDirPath, fileName3); const assetResponse = await fetchWithRetry(assetUrl, headers, "Failed to download asset"); if (!assetResponse.body) throw new Error("Response body is null"); const fileStream = createWriteStream2(downloadPath); @@ -156085,7 +156231,7 @@ async function installFromGithub(params) { log.debug(`\xBB downloaded asset to ${downloadPath}`); let cliPath; if (params.executablePath) { - cliPath = join8(tempDirPath, params.executablePath); + cliPath = join9(tempDirPath, params.executablePath); } else { cliPath = downloadPath; } @@ -156099,7 +156245,7 @@ async function installFromGithub(params) { async function installFromCurl(params) { log.info(`\xBB installing ${params.executableName}...`); const tempDir = process.env.PULLFROG_TEMP_DIR; - const installScriptPath = join8(tempDir, "install.sh"); + const installScriptPath = join9(tempDir, "install.sh"); log.debug(`\xBB downloading install script from ${params.installUrl}...`); const installScriptResponse = await fetch(params.installUrl); if (!installScriptResponse.ok) { @@ -156118,7 +156264,7 @@ async function installFromCurl(params) { // ensuring a fresh install for each run HOME: tempDir, // XDG_CONFIG_HOME must match HOME so CLI tools find config in the right place - XDG_CONFIG_HOME: join8(tempDir, ".config"), + XDG_CONFIG_HOME: join9(tempDir, ".config"), SHELL: process.env.SHELL, USER: process.env.USER }, @@ -156131,7 +156277,7 @@ async function installFromCurl(params) { `Failed to install ${params.executableName}. Install script exited with code ${installResult.status}. Output: ${errorOutput}` ); } - const cliPath = join8(tempDir, ".local", "bin", params.executableName); + const cliPath = join9(tempDir, ".local", "bin", params.executableName); if (!existsSync5(cliPath)) { throw new Error(`Executable not found at ${cliPath}`); } @@ -156312,8 +156458,8 @@ var messageHandlers = { }; // agents/codex.ts -import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync5 } from "node:fs"; -import { join as join9 } from "node:path"; +import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync6 } from "node:fs"; +import { join as join10 } from "node:path"; // node_modules/.pnpm/@openai+codex-sdk@0.80.0/node_modules/@openai/codex-sdk/dist/index.js import { promises as fs3 } from "fs"; @@ -156673,9 +156819,9 @@ var codexReasoningEffort = { max: "high" }; function writeCodexConfig(ctx) { - const codexDir = join9(ctx.tmpdir, ".codex"); + const codexDir = join10(ctx.tmpdir, ".codex"); mkdirSync3(codexDir, { recursive: true }); - const configPath = join9(codexDir, "config.toml"); + const configPath = join10(codexDir, "config.toml"); log.info(`\xBB adding MCP server '${ghPullfrogMcpName}' at ${ctx.mcpServerUrl}`); const mcpServerSections = [`[mcp_servers.${ghPullfrogMcpName}] url = "${ctx.mcpServerUrl}"`]; @@ -156687,7 +156833,7 @@ url = "${ctx.mcpServerUrl}"`]; } const featuresSection = features.length > 0 ? `[features] ${features.join("\n")}` : ""; - writeFileSync5( + writeFileSync6( configPath, `# written by pullfrog ${featuresSection} @@ -156710,7 +156856,7 @@ var codex = agent({ install: installCodex, run: async (ctx) => { const cliPath = await installCodex(); - const configDir = join9(ctx.tmpdir, ".config", "codex"); + const configDir = join10(ctx.tmpdir, ".config", "codex"); mkdirSync3(configDir, { recursive: true }); const codexDir = writeCodexConfig(ctx); process.env.HOME = ctx.tmpdir; @@ -156855,9 +157001,9 @@ var messageHandlers2 = { // agents/cursor.ts import { spawn as spawn5 } from "node:child_process"; -import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync6 } from "node:fs"; +import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync7 } from "node:fs"; import { homedir as homedir2 } from "node:os"; -import { join as join10 } from "node:path"; +import { join as join11 } from "node:path"; var cursorEffortModels = { mini: null, // use default (auto) @@ -156882,7 +157028,7 @@ var cursor = agent({ const cliPath = await installCursor(); configureCursorMcpServers(ctx); configureCursorTools(ctx); - const projectCliConfigPath = join10(process.cwd(), ".cursor", "cli.json"); + const projectCliConfigPath = join11(process.cwd(), ".cursor", "cli.json"); let modelOverride = null; if (existsSync6(projectCliConfigPath)) { try { @@ -157053,21 +157199,21 @@ var cursor = agent({ } }); function getCursorConfigDir() { - return join10(homedir2(), ".cursor"); + return join11(homedir2(), ".cursor"); } function configureCursorMcpServers(ctx) { const cursorConfigDir = getCursorConfigDir(); - const mcpConfigPath = join10(cursorConfigDir, "mcp.json"); + const mcpConfigPath = join11(cursorConfigDir, "mcp.json"); mkdirSync4(cursorConfigDir, { recursive: true }); const mcpServers = { [ghPullfrogMcpName]: { type: "http", url: ctx.mcpServerUrl } }; - writeFileSync6(mcpConfigPath, JSON.stringify({ mcpServers }, null, 2), "utf-8"); + writeFileSync7(mcpConfigPath, JSON.stringify({ mcpServers }, null, 2), "utf-8"); log.info(`\xBB MCP config written to ${mcpConfigPath}`); } function configureCursorTools(ctx) { const cursorConfigDir = getCursorConfigDir(); - const cliConfigPath = join10(cursorConfigDir, "cli-config.json"); + const cliConfigPath = join11(cursorConfigDir, "cli-config.json"); mkdirSync4(cursorConfigDir, { recursive: true }); const bash = ctx.payload.bash; const deny = []; @@ -157086,14 +157232,14 @@ function configureCursorTools(ctx) { networkAccess: "allowlist" }; } - writeFileSync6(cliConfigPath, JSON.stringify(config4, null, 2), "utf-8"); + writeFileSync7(cliConfigPath, JSON.stringify(config4, null, 2), "utf-8"); log.info(`\xBB CLI config written to ${cliConfigPath}`, JSON.stringify(config4, null, 2)); } // agents/gemini.ts -import { mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync7 } from "node:fs"; +import { mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync8 } from "node:fs"; import { homedir as homedir3 } from "node:os"; -import { join as join11 } from "node:path"; +import { join as join12 } from "node:path"; var geminiEffortConfig = { // https://ai.google.dev/gemini-api/docs/models // the docs mention needing to enable preview features for these models but if you @@ -157264,8 +157410,8 @@ function configureGeminiSettings(ctx) { const { model, thinkingLevel } = geminiEffortConfig[ctx.payload.effort]; log.info(`\xBB using model: ${model}, thinkingLevel: ${thinkingLevel}`); const realHome = homedir3(); - const geminiConfigDir = join11(realHome, ".gemini"); - const settingsPath = join11(geminiConfigDir, "settings.json"); + const geminiConfigDir = join12(realHome, ".gemini"); + const settingsPath = join12(geminiConfigDir, "settings.json"); mkdirSync5(geminiConfigDir, { recursive: true }); let existingSettings = {}; try { @@ -157302,7 +157448,7 @@ function configureGeminiSettings(ctx) { // v0.3.0+ nested format ...exclude.length > 0 && { tools: { exclude } } }; - writeFileSync7(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8"); + writeFileSync8(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8"); log.info(`\xBB Gemini settings written to ${settingsPath}`); if (exclude.length > 0) { log.info(`\xBB excluded tools: ${exclude.join(", ")}`); @@ -157311,8 +157457,8 @@ function configureGeminiSettings(ctx) { } // agents/opencode.ts -import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync8 } from "node:fs"; -import { join as join12 } from "node:path"; +import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync9 } from "node:fs"; +import { join as join13 } from "node:path"; async function installOpencode() { return await installFromNpmTarball({ packageName: "opencode-ai", @@ -157327,7 +157473,7 @@ var opencode = agent({ run: async (ctx) => { const cliPath = await installOpencode(); const tempHome = ctx.tmpdir; - const configDir = join12(tempHome, ".config", "opencode"); + const configDir = join13(tempHome, ".config", "opencode"); mkdirSync6(configDir, { recursive: true }); configureOpenCode(ctx); const args3 = ["run", ctx.instructions.full, "--format", "json"]; @@ -157335,7 +157481,7 @@ var opencode = agent({ const env3 = { ...process.env, HOME: tempHome, - XDG_CONFIG_HOME: join12(tempHome, ".config"), + XDG_CONFIG_HOME: join13(tempHome, ".config"), // set GOOGLE_GENERATIVE_AI_API_KEY alias for Google provider compatibility (if not already set) GOOGLE_GENERATIVE_AI_API_KEY: process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY }; @@ -157438,9 +157584,9 @@ var opencode = agent({ } }); function configureOpenCode(ctx) { - const configDir = join12(ctx.tmpdir, ".config", "opencode"); + const configDir = join13(ctx.tmpdir, ".config", "opencode"); mkdirSync6(configDir, { recursive: true }); - const configPath = join12(configDir, "opencode.json"); + const configPath = join13(configDir, "opencode.json"); const opencodeMcpServers = { [ghPullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl } }; @@ -157458,7 +157604,7 @@ function configureOpenCode(ctx) { }; const configJson = JSON.stringify(config4, null, 2); try { - writeFileSync8(configPath, configJson, "utf-8"); + writeFileSync9(configPath, configJson, "utf-8"); } catch (error50) { log.error( `failed to write OpenCode config to ${configPath}: ${error50 instanceof Error ? error50.message : String(error50)}` @@ -158298,9 +158444,9 @@ async function handleAgentResult(result) { import { execSync as execSync2 } from "node:child_process"; import { mkdtempSync } from "node:fs"; import { tmpdir as tmpdir2 } from "node:os"; -import { join as join13 } from "node:path"; +import { join as join14 } from "node:path"; function createTempDirectory() { - const sharedTempDir = mkdtempSync(join13(tmpdir2(), "pullfrog-")); + const sharedTempDir = mkdtempSync(join14(tmpdir2(), "pullfrog-")); process.env.PULLFROG_TEMP_DIR = sharedTempDir; log.info(`\xBB created temp dir at ${sharedTempDir}`); return sharedTempDir; diff --git a/mcp/README.md b/mcp/README.md index 0d3db06..3e959ed 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -7,7 +7,7 @@ this directory contains the mcp (model context protocol) server tools for intera ### check suite tools #### `get_check_suite_logs` -get workflow run logs for a failed check suite. +get workflow run logs for a failed check suite with intelligent log analysis. **parameters:** - `check_suite_id` (number): the id from check_suite.id in the webhook payload @@ -15,16 +15,41 @@ get workflow run logs for a failed check suite. **replaces:** `gh run list` and `gh run view --log` **returns:** -all logs from all failed workflow runs in the check suite, including: -- workflow run details (id, name, html_url, conclusion) -- job details for each workflow run (id, name, status, conclusion, logs) +structured failure information for each failed job: +- `_instructions`: explains how to use each field +- `failed_jobs[]`: array of failed job results, each containing: + - `job_id`, `job_name`, `job_url`: job identification + - `failed_steps`: which CI steps failed (e.g., "Step 6: Run tests") + - `log_index`: array of interesting lines (errors, warnings, failures) with line numbers + - `excerpt`: ~80 line curated window around the last error + - `full_log_path`: path to complete log file for deeper investigation + +**log_index types:** +- `error`: lines matching `##[error]`, `Error:`, `ERR_`, `exit code N` +- `warning`: lines matching `##[warning]`, `WARN` +- `failure`: lines matching `N failed`, `FAIL`, `✕` +- `trace`: stack trace lines (deduplicated) + +**workflow for using results:** +1. scan `log_index` to see where errors/warnings/failures are located in the log +2. read `excerpt` for immediate context around the main error +3. if excerpt doesn't show what you need, read specific line ranges from `full_log_path` +4. check `failed_steps` and read the workflow yml to understand what command failed **example:** ```typescript // when handling a check_suite_completed webhook -await mcp.call("gh_pullfrog/get_check_suite_logs", { +const result = await mcp.call("gh_pullfrog/get_check_suite_logs", { check_suite_id: check_suite.id }); + +// result.failed_jobs[0].log_index shows: +// [ +// { line: 181, content: "WARN Failed to create bin...", type: "warning" }, +// { line: 1079, content: "Error: expect(received).toBe(expected)", type: "error" }, +// ... +// ] +// use these line numbers to read specific sections from full_log_path ``` ### review tools diff --git a/mcp/checkSuite.ts b/mcp/checkSuite.ts index c56c22b..d2a45a9 100644 --- a/mcp/checkSuite.ts +++ b/mcp/checkSuite.ts @@ -1,4 +1,7 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { type } from "arktype"; +import { log } from "../utils/log.ts"; import type { ToolContext } from "./server.ts"; import { execute, tool } from "./shared.ts"; @@ -6,13 +9,127 @@ export const GetCheckSuiteLogs = type({ check_suite_id: type.number.describe("the id from check_suite.id"), }); +type LogLine = { + line: number; + content: string; + type: "error" | "warning" | "failure" | "trace"; +}; + +type LogAnalysis = { + totalLines: number; + index: LogLine[]; + excerpt: { + content: string; + startLine: number; + endLine: number; + }; +}; + +function analyzeLog(logs: string, excerptLines = 80): LogAnalysis { + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape codes use control chars + const clean = logs.replace(/\x1b\[[0-9;]*m/g, ""); + const lines = clean.split("\n"); + const totalLines = lines.length; + + const index: LogLine[] = []; + + const patterns: Array<{ type: LogLine["type"]; pattern: RegExp; skip?: RegExp }> = [ + { type: "error", pattern: /##\[error\]/i }, + { type: "error", pattern: /\bError:/i }, + { type: "error", pattern: /\bERR_/i }, + { type: "error", pattern: /exit code [1-9]/i }, + { type: "warning", pattern: /##\[warning\]/i }, + { type: "warning", pattern: /\bWARN\b/i, skip: /apt|dpkg|Reading package/i }, + { type: "failure", pattern: /\d+ failed/i }, + { type: "failure", pattern: /FAIL\b/i }, + { type: "failure", pattern: /✕|✗|×/ }, + { type: "trace", pattern: /^\s+at\s+/i }, + ]; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + for (const p of patterns) { + if (p.pattern.test(line)) { + if (p.skip?.test(line)) continue; + + // dedupe consecutive traces + if (p.type === "trace" && index.length > 0 && index[index.length - 1].type === "trace") { + continue; + } + + // truncate long lines + const truncated = line.length > 120 ? line.slice(0, 117) + "..." : line; + + index.push({ + line: i + 1, + content: truncated.trim(), + type: p.type, + }); + break; + } + } + } + + // find excerpt range: focus on LAST ##[error] line + let errorLine = -1; + for (let i = lines.length - 1; i >= 0; i--) { + if (/##\[error\]/i.test(lines[i])) { + errorLine = i; + break; + } + } + + let start: number; + let end: number; + + if (errorLine === -1) { + start = Math.max(0, totalLines - excerptLines); + end = totalLines; + } else { + const contextAfter = 5; + const contextBefore = excerptLines - contextAfter; + start = Math.max(0, errorLine - contextBefore); + end = Math.min(totalLines, errorLine + contextAfter); + } + + return { + totalLines, + index, + excerpt: { + content: lines.slice(start, end).join("\n"), + startLine: start + 1, + endLine: end, + }, + }; +} + +type JobLogResult = { + job_id: number; + job_name: string; + job_url: string; + failed_steps: string[]; + log_index: LogLine[]; + excerpt: { + start_line: number; + end_line: number; + total_lines: number; + content: string; + }; + full_log_path: string; +}; + export function GetCheckSuiteLogsTool(ctx: ToolContext) { return tool({ name: "get_check_suite_logs", description: - "get workflow run logs for a failed check suite. pass check_suite.id from the webhook payload.", + "get workflow run logs for a failed check suite. returns a log_index of interesting lines, " + + "a curated excerpt, and full_log_path for deeper investigation. " + + "pass check_suite.id from the webhook payload.", parameters: GetCheckSuiteLogs, - execute: execute(async ({ check_suite_id }) => { + execute: execute(async (params) => { + const check_suite_id = params.check_suite_id; + // get workflow runs for this specific check suite const workflowRuns = await ctx.octokit.paginate( ctx.octokit.rest.actions.listWorkflowRunsForRepo, @@ -30,67 +147,101 @@ export function GetCheckSuiteLogsTool(ctx: ToolContext) { return { check_suite_id, message: "no failed workflow runs found for this check suite", - workflow_runs: [], + failed_jobs: [], }; } + // setup logs directory + const tempDir = process.env.PULLFROG_TEMP_DIR; + if (!tempDir) { + throw new Error("PULLFROG_TEMP_DIR not set"); + } + const logsDir = join(tempDir, "ci-logs"); + mkdirSync(logsDir, { recursive: true }); + + const jobResults: JobLogResult[] = []; + // get logs for each failed run - const logsForRuns = await Promise.all( - failedRuns.map(async (run) => { - const jobs = await ctx.octokit.paginate(ctx.octokit.rest.actions.listJobsForWorkflowRun, { - owner: ctx.repo.owner, - repo: ctx.repo.name, - run_id: run.id, - }); + for (const run of failedRuns) { + const jobs = await ctx.octokit.paginate(ctx.octokit.rest.actions.listJobsForWorkflowRun, { + owner: ctx.repo.owner, + repo: ctx.repo.name, + run_id: run.id, + }); - const jobLogs = await Promise.all( - jobs.map(async (job) => { - try { - const logsResponse = await ctx.octokit.rest.actions.downloadJobLogsForWorkflowRun({ - owner: ctx.repo.owner, - repo: ctx.repo.name, - job_id: job.id, - }); + // only process failed jobs + const failedJobs = jobs.filter((job) => job.conclusion === "failure"); - const logsUrl = logsResponse.url; - const logsText = await fetch(logsUrl).then((r) => r.text()); + for (const job of failedJobs) { + try { + const logsResponse = await ctx.octokit.rest.actions.downloadJobLogsForWorkflowRun({ + owner: ctx.repo.owner, + repo: ctx.repo.name, + job_id: job.id, + }); - return { - job_id: job.id, - job_name: job.name, - status: job.status, - conclusion: job.conclusion, - started_at: job.started_at, - completed_at: job.completed_at, - logs: logsText, - }; - } catch (error) { - return { - job_id: job.id, - job_name: job.name, - status: job.status, - conclusion: job.conclusion, - started_at: job.started_at, - completed_at: job.completed_at, - error: `failed to fetch logs: ${error}`, - }; - } - }) - ); + const logsUrl = logsResponse.url; + const logsText = await fetch(logsUrl).then((r) => r.text()); - return { - workflow_run_id: run.id, - workflow_name: run.name, - html_url: run.html_url, - conclusion: run.conclusion, - jobs: jobLogs, - }; - }) - ); + // write full log to disk + const logPath = join(logsDir, `job-${job.id}.log`); + writeFileSync(logPath, logsText); + + // analyze log + const analysis = analyzeLog(logsText, 80); + + // get failed steps + const failedSteps = + job.steps + ?.filter((s) => s.conclusion === "failure") + .map((s) => `Step ${s.number}: ${s.name}`) ?? []; + + jobResults.push({ + job_id: job.id, + job_name: job.name, + job_url: job.html_url ?? "", + failed_steps: failedSteps, + log_index: analysis.index, + excerpt: { + start_line: analysis.excerpt.startLine, + end_line: analysis.excerpt.endLine, + total_lines: analysis.totalLines, + content: analysis.excerpt.content, + }, + full_log_path: logPath, + }); + + log.debug(`analyzed logs for job ${job.name}: ${analysis.index.length} indexed lines`); + } catch (error) { + log.error(`failed to fetch logs for job ${job.id}: ${error}`); + } + } + } return { + _instructions: { + overview: + "this result contains CI failure information. use log_index to find interesting lines, then read full_log_path for details.", + fields: { + log_index: + "array of interesting lines (errors, warnings, failures) with line numbers. use these to navigate the full log.", + excerpt: + "a curated ~80 line window around the last error. may not show all failures if they occur in different places.", + full_log_path: + "path to the complete log file. read specific line ranges using the line numbers from log_index.", + failed_steps: + "which CI steps failed. read the workflow yml to understand what commands these steps run.", + }, + workflow: [ + "1. scan log_index to see where errors/warnings/failures are located", + "2. read excerpt for immediate context", + "3. if excerpt doesn't show what you need, read specific line ranges from full_log_path", + "4. check failed_steps to understand what command failed", + ], + }, check_suite_id, - workflow_runs: logsForRuns, + repo: `${ctx.repo.owner}/${ctx.repo.name}`, + failed_jobs: jobResults, }; }), }); diff --git a/modes.ts b/modes.ts index f8e0967..0c379dc 100644 --- a/modes.ts +++ b/modes.ts @@ -144,6 +144,72 @@ export function computeModes(): Mode[] { 4. Create a structured plan with clear milestones 5. ${reportProgressInstruction}`, + }, + { + name: "Fix", + description: + "Fix CI failures; debug failing tests or builds; investigate and resolve check suite failures", + prompt: `Follow these steps to fix CI failures. THINK HARDER. + +**CRITICAL RULE**: Only fix issues that were INTRODUCED BY THIS PR. If the CI failure is unrelated to the PR's changes, you MUST abort without committing anything and report why. + +1. **GET FAILURE INFO** - Call ${ghPullfrogMcpName}/get_check_suite_logs with the check_suite_id from EVENT DATA. This returns: + - \`log_index\`: array of interesting lines (errors, warnings, failures) with line numbers - scan this first + - \`excerpt\`: curated ~80 lines around the main error - read this for immediate context + - \`full_log_path\`: path to complete log file - read specific line ranges if needed + - \`failed_steps\`: which CI steps failed (e.g., "Step 6: Run tests") + +2. **CHECKOUT AND ASSESS CAUSATION** - Use ${ghPullfrogMcpName}/checkout_pr to get the PR diff. BEFORE attempting any fix, you MUST determine if this PR caused the failure: + + **Ask yourself**: "Could the changes in this PR have caused this failure?" + + - Read the PR diff carefully - what files were modified? + - What is failing? (test file, module, assertion) + - Is there a PLAUSIBLE CONNECTION between the PR changes and the failure? + + **ABORT immediately if any of these are true:** + - The failing test/file was NOT touched by this PR AND doesn't depend on changed code + - The error is infrastructure-related (network timeout, runner OOM, service unavailable) + - The error is a flaky test that passes/fails randomly + - The error existed before this PR (pre-existing bug in main branch) + - The error is in a dependency update not introduced by this PR + + **When aborting**, use ${ghPullfrogMcpName}/report_progress to explain: + "This CI failure appears unrelated to the PR's changes. [Describe the failure]. [Explain why it's not caused by the PR]. No changes made." + + **Only proceed** if there's a clear, logical connection between the PR changes and the failure. + +3. **UNDERSTAND HOW CI RUNS** - Read the workflow file to understand exactly what commands CI runs: + - Look at \`.github/workflows/*.yml\` files + - Find the job/step that failed (from \`failed_steps\`) + - Note the EXACT command (e.g., \`pnpm -r test --filter=action\`, not just \`pnpm test\`) + - Check for any CI-specific environment variables or setup steps + +4. ${dependencyInstallationStep} + +5. **REPRODUCE LOCALLY** - Run the EXACT same command that CI runs: + - Do NOT simplify (e.g., don't run \`pnpm test\` if CI runs \`pnpm -r test --filter=action\`) + - Check if CI uses specific flags, filters, or environment variables + - If CI runs multiple test suites, run them all + +6. **ANALYZE THE FAILURE** - Use the log_index and excerpt to understand: + - What exactly failed (test name, file, assertion) + - Are there earlier warnings that might explain the failure? + - Is the failure flaky or deterministic? + +7. **FIX THE ISSUE** - Make the necessary code changes. Common patterns: + - Test assertion failures: fix the code or update the test expectation + - Build failures: fix type errors, missing imports, syntax issues + - Lint failures: fix code style issues + - Timeout/flaky tests: investigate race conditions or increase timeouts + +8. **VERIFY THE FIX** - Run the EXACT same CI command again to confirm the fix works + +9. **COMMIT AND PUSH** - Use ${ghPullfrogMcpName}/commit_files and ${ghPullfrogMcpName}/push_branch + +10. ${reportProgressInstruction} + +**REMEMBER**: Your job is to fix issues THIS PR introduced, not to fix all CI failures. If in doubt about causation, abort and explain rather than making speculative changes.`, }, { name: "Prompt",