Improve autofix: simplify config, add loop prevention, strengthen Fix mode (#181)

* Improve autofix

* UI

* remove unused TriggerField props, improve bot commit detection

- Remove `alternateEnabledValue` and `enabledContent` props from TriggerField
  (dead code, not used by any caller)
- Move `isBotCommit` to module scope and check both `author.name` and
  `committer.name` for [bot] suffix

* fix: truncate workflow_runs before schema change

existing records don't have repoId, causing NOT NULL constraint failure

* truncate workflow_runs before adding NOT NULL repoId

existing rows don't have repoId values and can't be migrated

---------

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
This commit is contained in:
Colin McDonnell
2026-01-27 03:54:46 +00:00
committed by pullfrog[bot]
parent d545a84027
commit 2514bb1cf7
4 changed files with 557 additions and 169 deletions
+258 -112
View File
@@ -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;
+30 -5
View File
@@ -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
+203 -52
View File
@@ -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,
};
}),
});
+66
View File
@@ -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",