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) { } else if (node2.nodeType === 1) {
replacement = replacementForNode.call(self2, node2); replacement = replacementForNode.call(self2, node2);
} }
return join14(output, replacement); return join15(output, replacement);
}, ""); }, "");
} }
function postProcess(output) { function postProcess(output) {
var self2 = this; var self2 = this;
this.rules.forEach(function(rule) { this.rules.forEach(function(rule) {
if (typeof rule.append === "function") { 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]+$/, ""); 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(); if (whitespace.leading || whitespace.trailing) content = content.trim();
return whitespace.leading + rule.replacement(content, node2, this.options) + whitespace.trailing; return whitespace.leading + rule.replacement(content, node2, this.options) + whitespace.trailing;
} }
function join14(output, replacement) { function join15(output, replacement) {
var s1 = trimTrailingNewlines(output); var s1 = trimTrailingNewlines(output);
var s2 = trimLeadingNewlines(replacement); var s2 = trimLeadingNewlines(replacement);
var nls = Math.max(output.length - s1.length, replacement.length - s2.length); var nls = Math.max(output.length - s1.length, replacement.length - s2.length);
@@ -136940,15 +136940,81 @@ ${diffPreview}`);
} }
// mcp/checkSuite.ts // mcp/checkSuite.ts
import { mkdirSync, writeFileSync as writeFileSync3 } from "node:fs";
import { join as join3 } from "node:path";
var GetCheckSuiteLogs = type({ var GetCheckSuiteLogs = type({
check_suite_id: type.number.describe("the id from check_suite.id") 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) { function GetCheckSuiteLogsTool(ctx) {
return tool({ return tool({
name: "get_check_suite_logs", 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, 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( const workflowRuns = await ctx.octokit.paginate(
ctx.octokit.rest.actions.listWorkflowRunsForRepo, ctx.octokit.rest.actions.listWorkflowRunsForRepo,
{ {
@@ -136963,68 +137029,83 @@ function GetCheckSuiteLogsTool(ctx) {
return { return {
check_suite_id, check_suite_id,
message: "no failed workflow runs found for this check suite", message: "no failed workflow runs found for this check suite",
workflow_runs: [] failed_jobs: []
}; };
} }
const logsForRuns = await Promise.all( const tempDir = process.env.PULLFROG_TEMP_DIR;
failedRuns.map(async (run2) => { if (!tempDir) {
const jobs = await ctx.octokit.paginate(ctx.octokit.rest.actions.listJobsForWorkflowRun, { throw new Error("PULLFROG_TEMP_DIR not set");
owner: ctx.repo.owner, }
repo: ctx.repo.name, const logsDir = join3(tempDir, "ci-logs");
run_id: run2.id mkdirSync(logsDir, { recursive: true });
}); const jobResults = [];
const jobLogs = await Promise.all( for (const run2 of failedRuns) {
jobs.map(async (job) => { const jobs = await ctx.octokit.paginate(ctx.octokit.rest.actions.listJobsForWorkflowRun, {
try { owner: ctx.repo.owner,
const logsResponse = await ctx.octokit.rest.actions.downloadJobLogsForWorkflowRun({ repo: ctx.repo.name,
owner: ctx.repo.owner, run_id: run2.id
repo: ctx.repo.name, });
job_id: job.id const failedJobs = jobs.filter((job) => job.conclusion === "failure");
}); for (const job of failedJobs) {
const logsUrl = logsResponse.url; try {
const logsText = await fetch(logsUrl).then((r) => r.text()); const logsResponse = await ctx.octokit.rest.actions.downloadJobLogsForWorkflowRun({
return { owner: ctx.repo.owner,
job_id: job.id, repo: ctx.repo.name,
job_name: job.name, job_id: job.id
status: job.status, });
conclusion: job.conclusion, const logsUrl = logsResponse.url;
started_at: job.started_at, const logsText = await fetch(logsUrl).then((r) => r.text());
completed_at: job.completed_at, const logPath = join3(logsDir, `job-${job.id}.log`);
logs: logsText writeFileSync3(logPath, logsText);
}; const analysis = analyzeLog(logsText, 80);
} catch (error50) { const failedSteps = job.steps?.filter((s) => s.conclusion === "failure").map((s) => `Step ${s.number}: ${s.name}`) ?? [];
return { jobResults.push({
job_id: job.id, job_id: job.id,
job_name: job.name, job_name: job.name,
status: job.status, job_url: job.html_url ?? "",
conclusion: job.conclusion, failed_steps: failedSteps,
started_at: job.started_at, log_index: analysis.index,
completed_at: job.completed_at, excerpt: {
error: `failed to fetch logs: ${error50}` start_line: analysis.excerpt.startLine,
}; end_line: analysis.excerpt.endLine,
} total_lines: analysis.totalLines,
}) content: analysis.excerpt.content
); },
return { full_log_path: logPath
workflow_run_id: run2.id, });
workflow_name: run2.name, log.debug(`analyzed logs for job ${job.name}: ${analysis.index.length} indexed lines`);
html_url: run2.html_url, } catch (error50) {
conclusion: run2.conclusion, log.error(`failed to fetch logs for job ${job.id}: ${error50}`);
jobs: jobLogs }
}; }
}) }
);
return { 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, check_suite_id,
workflow_runs: logsForRuns repo: `${ctx.repo.owner}/${ctx.repo.name}`,
failed_jobs: jobResults
}; };
}) })
}); });
} }
// mcp/commitInfo.ts // mcp/commitInfo.ts
import { writeFileSync as writeFileSync3 } from "node:fs"; import { writeFileSync as writeFileSync4 } from "node:fs";
import { join as join3 } from "node:path"; import { join as join4 } from "node:path";
var CommitInfo = type({ var CommitInfo = type({
sha: type.string.describe("the commit SHA (full or abbreviated) to fetch") 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" "PULLFROG_TEMP_DIR not set - get_commit_info must run in pullfrog action context"
); );
} }
const diffFile = join3(tempDir, `commit-${sha.slice(0, 7)}.diff`); const diffFile = join4(tempDir, `commit-${sha.slice(0, 7)}.diff`);
writeFileSync3(diffFile, formatResult.content); writeFileSync4(diffFile, formatResult.content);
log.debug(`wrote commit diff to ${diffFile} (${formatResult.content.length} bytes)`); log.debug(`wrote commit diff to ${diffFile} (${formatResult.content.length} bytes)`);
return { return {
sha: data.sha, sha: data.sha,
@@ -137073,7 +137154,7 @@ function CommitInfoTool(ctx) {
// prep/installNodeDependencies.ts // prep/installNodeDependencies.ts
import { existsSync as existsSync2, readFileSync } from "node:fs"; 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 // node_modules/.pnpm/@ark+util@0.53.0/node_modules/@ark/util/out/domain.js
var domainDescriptions2 = { var domainDescriptions2 = {
@@ -137660,7 +137741,7 @@ async function isCommandAvailable(command) {
return result.exitCode === 0; return result.exitCode === 0;
} }
function getPackageManagerFromPackageJson() { function getPackageManagerFromPackageJson() {
const packageJsonPath = join4(process.cwd(), "package.json"); const packageJsonPath = join5(process.cwd(), "package.json");
try { try {
const content = readFileSync(packageJsonPath, "utf-8"); const content = readFileSync(packageJsonPath, "utf-8");
const pkg = JSON.parse(content); const pkg = JSON.parse(content);
@@ -137691,7 +137772,7 @@ async function installPackageManager(name, installSpec) {
return result.stderr || `failed to install ${name}`; return result.stderr || `failed to install ${name}`;
} }
if (name === "deno") { 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}`; process.env.PATH = `${denoPath}:${process.env.PATH}`;
} }
log.info(`\xBB installed ${name}`); log.info(`\xBB installed ${name}`);
@@ -137700,7 +137781,7 @@ async function installPackageManager(name, installSpec) {
var installNodeDependencies = { var installNodeDependencies = {
name: "installNodeDependencies", name: "installNodeDependencies",
shouldRun: () => { shouldRun: () => {
const packageJsonPath = join4(process.cwd(), "package.json"); const packageJsonPath = join5(process.cwd(), "package.json");
return existsSync2(packageJsonPath); return existsSync2(packageJsonPath);
}, },
run: async () => { run: async () => {
@@ -137768,7 +137849,7 @@ ${errorMessage}`]
// prep/installPythonDependencies.ts // prep/installPythonDependencies.ts
import { existsSync as existsSync3 } from "node:fs"; import { existsSync as existsSync3 } from "node:fs";
import { join as join5 } from "node:path"; import { join as join6 } from "node:path";
var PYTHON_CONFIGS = [ var PYTHON_CONFIGS = [
{ {
file: "requirements.txt", file: "requirements.txt",
@@ -137840,11 +137921,11 @@ var installPythonDependencies = {
return false; return false;
} }
const cwd2 = process.cwd(); 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 () => { run: async () => {
const cwd2 = process.cwd(); 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) { if (!config4) {
return { return {
language: "python", language: "python",
@@ -138664,8 +138745,8 @@ function CreatePullRequestReviewTool(ctx) {
} }
// mcp/reviewComments.ts // mcp/reviewComments.ts
import { writeFileSync as writeFileSync4 } from "node:fs"; import { writeFileSync as writeFileSync5 } from "node:fs";
import { join as join6 } from "node:path"; import { join as join7 } from "node:path";
var REVIEW_THREADS_QUERY = ` var REVIEW_THREADS_QUERY = `
query ($owner: String!, $name: String!, $prNumber: Int!) { query ($owner: String!, $name: String!, $prNumber: Int!) {
repository(owner: $owner, name: $name) { repository(owner: $owner, name: $name) {
@@ -138999,8 +139080,8 @@ function GetReviewCommentsTool(ctx) {
throw new Error("PULLFROG_TEMP_DIR not set"); throw new Error("PULLFROG_TEMP_DIR not set");
} }
const filename = `review-${params.review_id}-threads.md`; const filename = `review-${params.review_id}-threads.md`;
const commentsPath = join6(tempDir, filename); const commentsPath = join7(tempDir, filename);
writeFileSync4(commentsPath, formatted.content); writeFileSync5(commentsPath, formatted.content);
log.debug(`wrote ${threadBlocks.length} threads to ${commentsPath}`); log.debug(`wrote ${threadBlocks.length} threads to ${commentsPath}`);
return { return {
review_id: params.review_id, review_id: params.review_id,
@@ -139313,6 +139394,71 @@ function computeModes() {
4. Create a structured plan with clear milestones 4. Create a structured plan with clear milestones
5. ${reportProgressInstruction}` 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", name: "Prompt",
@@ -139346,14 +139492,14 @@ import { spawn as spawn3 } from "child_process";
import { createInterface } from "readline"; import { createInterface } from "readline";
import * as fs2 from "fs"; import * as fs2 from "fs";
import { stat as statPromise, open } from "fs/promises"; 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 { homedir } from "os";
import { dirname, join as join22 } from "path"; import { dirname, join as join22 } from "path";
import { cwd } from "process"; import { cwd } from "process";
import { realpathSync as realpathSync2 } from "fs"; import { realpathSync as realpathSync2 } from "fs";
import { randomUUID as randomUUID3 } from "crypto"; import { randomUUID as randomUUID3 } from "crypto";
import { randomUUID as randomUUID22 } 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 { join as join32 } from "path";
import { randomUUID as randomUUID32 } from "crypto"; import { randomUUID as randomUUID32 } from "crypto";
var __create3 = Object.create; var __create3 = Object.create;
@@ -146048,7 +146194,7 @@ function shouldShowDebugMessage(message, filter) {
return shouldShowDebugCategories(categories, filter); return shouldShowDebugCategories(categories, filter);
} }
function getClaudeConfigHomeDir() { function getClaudeConfigHomeDir() {
return process.env.CLAUDE_CONFIG_DIR ?? join7(homedir(), ".claude"); return process.env.CLAUDE_CONFIG_DIR ?? join8(homedir(), ".claude");
} }
function isEnvTruthy(envVar) { function isEnvTruthy(envVar) {
if (!envVar) if (!envVar)
@@ -146505,7 +146651,7 @@ function getOrCreateDebugFile() {
const debugDir = join32(getClaudeConfigHomeDir(), "debug"); const debugDir = join32(getClaudeConfigHomeDir(), "debug");
debugFilePath = join32(debugDir, `sdk-${randomUUID22()}.txt`); debugFilePath = join32(debugDir, `sdk-${randomUUID22()}.txt`);
if (!existsSync22(debugDir)) { if (!existsSync22(debugDir)) {
mkdirSync2(debugDir, { recursive: true }); mkdirSync22(debugDir, { recursive: true });
} }
process.stderr.write(`SDK debug logs: ${debugFilePath} 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 { chmodSync, createWriteStream as createWriteStream2, existsSync as existsSync5 } from "node:fs";
import { mkdtemp } from "node:fs/promises"; import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os"; 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"; import { pipeline } from "node:stream/promises";
async function installFromNpmTarball(params) { async function installFromNpmTarball(params) {
let resolvedVersion = params.version; let resolvedVersion = params.version;
@@ -155982,7 +156128,7 @@ async function installFromNpmTarball(params) {
} }
log.debug(`\xBB installing ${params.packageName}@${resolvedVersion}...`); log.debug(`\xBB installing ${params.packageName}@${resolvedVersion}...`);
const tempDir = process.env.PULLFROG_TEMP_DIR; 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"; const npmRegistry = process.env.NPM_REGISTRY || "https://registry.npmjs.org";
let tarballUrl; let tarballUrl;
if (params.packageName.startsWith("@")) { if (params.packageName.startsWith("@")) {
@@ -156011,8 +156157,8 @@ async function installFromNpmTarball(params) {
`Failed to extract tarball: ${extractResult.stderr || extractResult.stdout || "Unknown error"}` `Failed to extract tarball: ${extractResult.stderr || extractResult.stdout || "Unknown error"}`
); );
} }
const extractedDir = join8(tempDir, "package"); const extractedDir = join9(tempDir, "package");
const cliPath = join8(extractedDir, params.executablePath); const cliPath = join9(extractedDir, params.executablePath);
if (!existsSync5(cliPath)) { if (!existsSync5(cliPath)) {
throw new Error(`Executable not found in extracted package at ${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; const assetUrl = asset.browser_download_url;
log.debug(`\xBB downloading asset from ${assetUrl}...`); log.debug(`\xBB downloading asset from ${assetUrl}...`);
const tempDirPrefix = `${params.owner}-${params.repo}-github-`; 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 urlPath = new URL(assetUrl).pathname;
const fileName3 = urlPath.split("/").pop() || "asset"; 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"); const assetResponse = await fetchWithRetry(assetUrl, headers, "Failed to download asset");
if (!assetResponse.body) throw new Error("Response body is null"); if (!assetResponse.body) throw new Error("Response body is null");
const fileStream = createWriteStream2(downloadPath); const fileStream = createWriteStream2(downloadPath);
@@ -156085,7 +156231,7 @@ async function installFromGithub(params) {
log.debug(`\xBB downloaded asset to ${downloadPath}`); log.debug(`\xBB downloaded asset to ${downloadPath}`);
let cliPath; let cliPath;
if (params.executablePath) { if (params.executablePath) {
cliPath = join8(tempDirPath, params.executablePath); cliPath = join9(tempDirPath, params.executablePath);
} else { } else {
cliPath = downloadPath; cliPath = downloadPath;
} }
@@ -156099,7 +156245,7 @@ async function installFromGithub(params) {
async function installFromCurl(params) { async function installFromCurl(params) {
log.info(`\xBB installing ${params.executableName}...`); log.info(`\xBB installing ${params.executableName}...`);
const tempDir = process.env.PULLFROG_TEMP_DIR; 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}...`); log.debug(`\xBB downloading install script from ${params.installUrl}...`);
const installScriptResponse = await fetch(params.installUrl); const installScriptResponse = await fetch(params.installUrl);
if (!installScriptResponse.ok) { if (!installScriptResponse.ok) {
@@ -156118,7 +156264,7 @@ async function installFromCurl(params) {
// ensuring a fresh install for each run // ensuring a fresh install for each run
HOME: tempDir, HOME: tempDir,
// XDG_CONFIG_HOME must match HOME so CLI tools find config in the right place // 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, SHELL: process.env.SHELL,
USER: process.env.USER 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}` `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)) { if (!existsSync5(cliPath)) {
throw new Error(`Executable not found at ${cliPath}`); throw new Error(`Executable not found at ${cliPath}`);
} }
@@ -156312,8 +156458,8 @@ var messageHandlers = {
}; };
// agents/codex.ts // agents/codex.ts
import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync5 } from "node:fs"; import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync6 } from "node:fs";
import { join as join9 } from "node:path"; import { join as join10 } from "node:path";
// node_modules/.pnpm/@openai+codex-sdk@0.80.0/node_modules/@openai/codex-sdk/dist/index.js // node_modules/.pnpm/@openai+codex-sdk@0.80.0/node_modules/@openai/codex-sdk/dist/index.js
import { promises as fs3 } from "fs"; import { promises as fs3 } from "fs";
@@ -156673,9 +156819,9 @@ var codexReasoningEffort = {
max: "high" max: "high"
}; };
function writeCodexConfig(ctx) { function writeCodexConfig(ctx) {
const codexDir = join9(ctx.tmpdir, ".codex"); const codexDir = join10(ctx.tmpdir, ".codex");
mkdirSync3(codexDir, { recursive: true }); 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}`); log.info(`\xBB adding MCP server '${ghPullfrogMcpName}' at ${ctx.mcpServerUrl}`);
const mcpServerSections = [`[mcp_servers.${ghPullfrogMcpName}] const mcpServerSections = [`[mcp_servers.${ghPullfrogMcpName}]
url = "${ctx.mcpServerUrl}"`]; url = "${ctx.mcpServerUrl}"`];
@@ -156687,7 +156833,7 @@ url = "${ctx.mcpServerUrl}"`];
} }
const featuresSection = features.length > 0 ? `[features] const featuresSection = features.length > 0 ? `[features]
${features.join("\n")}` : ""; ${features.join("\n")}` : "";
writeFileSync5( writeFileSync6(
configPath, configPath,
`# written by pullfrog `# written by pullfrog
${featuresSection} ${featuresSection}
@@ -156710,7 +156856,7 @@ var codex = agent({
install: installCodex, install: installCodex,
run: async (ctx) => { run: async (ctx) => {
const cliPath = await installCodex(); const cliPath = await installCodex();
const configDir = join9(ctx.tmpdir, ".config", "codex"); const configDir = join10(ctx.tmpdir, ".config", "codex");
mkdirSync3(configDir, { recursive: true }); mkdirSync3(configDir, { recursive: true });
const codexDir = writeCodexConfig(ctx); const codexDir = writeCodexConfig(ctx);
process.env.HOME = ctx.tmpdir; process.env.HOME = ctx.tmpdir;
@@ -156855,9 +157001,9 @@ var messageHandlers2 = {
// agents/cursor.ts // agents/cursor.ts
import { spawn as spawn5 } from "node:child_process"; 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 { homedir as homedir2 } from "node:os";
import { join as join10 } from "node:path"; import { join as join11 } from "node:path";
var cursorEffortModels = { var cursorEffortModels = {
mini: null, mini: null,
// use default (auto) // use default (auto)
@@ -156882,7 +157028,7 @@ var cursor = agent({
const cliPath = await installCursor(); const cliPath = await installCursor();
configureCursorMcpServers(ctx); configureCursorMcpServers(ctx);
configureCursorTools(ctx); configureCursorTools(ctx);
const projectCliConfigPath = join10(process.cwd(), ".cursor", "cli.json"); const projectCliConfigPath = join11(process.cwd(), ".cursor", "cli.json");
let modelOverride = null; let modelOverride = null;
if (existsSync6(projectCliConfigPath)) { if (existsSync6(projectCliConfigPath)) {
try { try {
@@ -157053,21 +157199,21 @@ var cursor = agent({
} }
}); });
function getCursorConfigDir() { function getCursorConfigDir() {
return join10(homedir2(), ".cursor"); return join11(homedir2(), ".cursor");
} }
function configureCursorMcpServers(ctx) { function configureCursorMcpServers(ctx) {
const cursorConfigDir = getCursorConfigDir(); const cursorConfigDir = getCursorConfigDir();
const mcpConfigPath = join10(cursorConfigDir, "mcp.json"); const mcpConfigPath = join11(cursorConfigDir, "mcp.json");
mkdirSync4(cursorConfigDir, { recursive: true }); mkdirSync4(cursorConfigDir, { recursive: true });
const mcpServers = { const mcpServers = {
[ghPullfrogMcpName]: { type: "http", url: ctx.mcpServerUrl } [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}`); log.info(`\xBB MCP config written to ${mcpConfigPath}`);
} }
function configureCursorTools(ctx) { function configureCursorTools(ctx) {
const cursorConfigDir = getCursorConfigDir(); const cursorConfigDir = getCursorConfigDir();
const cliConfigPath = join10(cursorConfigDir, "cli-config.json"); const cliConfigPath = join11(cursorConfigDir, "cli-config.json");
mkdirSync4(cursorConfigDir, { recursive: true }); mkdirSync4(cursorConfigDir, { recursive: true });
const bash = ctx.payload.bash; const bash = ctx.payload.bash;
const deny = []; const deny = [];
@@ -157086,14 +157232,14 @@ function configureCursorTools(ctx) {
networkAccess: "allowlist" 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)); log.info(`\xBB CLI config written to ${cliConfigPath}`, JSON.stringify(config4, null, 2));
} }
// agents/gemini.ts // 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 { homedir as homedir3 } from "node:os";
import { join as join11 } from "node:path"; import { join as join12 } from "node:path";
var geminiEffortConfig = { var geminiEffortConfig = {
// https://ai.google.dev/gemini-api/docs/models // https://ai.google.dev/gemini-api/docs/models
// the docs mention needing to enable preview features for these models but if you // 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]; const { model, thinkingLevel } = geminiEffortConfig[ctx.payload.effort];
log.info(`\xBB using model: ${model}, thinkingLevel: ${thinkingLevel}`); log.info(`\xBB using model: ${model}, thinkingLevel: ${thinkingLevel}`);
const realHome = homedir3(); const realHome = homedir3();
const geminiConfigDir = join11(realHome, ".gemini"); const geminiConfigDir = join12(realHome, ".gemini");
const settingsPath = join11(geminiConfigDir, "settings.json"); const settingsPath = join12(geminiConfigDir, "settings.json");
mkdirSync5(geminiConfigDir, { recursive: true }); mkdirSync5(geminiConfigDir, { recursive: true });
let existingSettings = {}; let existingSettings = {};
try { try {
@@ -157302,7 +157448,7 @@ function configureGeminiSettings(ctx) {
// v0.3.0+ nested format // v0.3.0+ nested format
...exclude.length > 0 && { tools: { exclude } } ...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}`); log.info(`\xBB Gemini settings written to ${settingsPath}`);
if (exclude.length > 0) { if (exclude.length > 0) {
log.info(`\xBB excluded tools: ${exclude.join(", ")}`); log.info(`\xBB excluded tools: ${exclude.join(", ")}`);
@@ -157311,8 +157457,8 @@ function configureGeminiSettings(ctx) {
} }
// agents/opencode.ts // agents/opencode.ts
import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync8 } from "node:fs"; import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync9 } from "node:fs";
import { join as join12 } from "node:path"; import { join as join13 } from "node:path";
async function installOpencode() { async function installOpencode() {
return await installFromNpmTarball({ return await installFromNpmTarball({
packageName: "opencode-ai", packageName: "opencode-ai",
@@ -157327,7 +157473,7 @@ var opencode = agent({
run: async (ctx) => { run: async (ctx) => {
const cliPath = await installOpencode(); const cliPath = await installOpencode();
const tempHome = ctx.tmpdir; const tempHome = ctx.tmpdir;
const configDir = join12(tempHome, ".config", "opencode"); const configDir = join13(tempHome, ".config", "opencode");
mkdirSync6(configDir, { recursive: true }); mkdirSync6(configDir, { recursive: true });
configureOpenCode(ctx); configureOpenCode(ctx);
const args3 = ["run", ctx.instructions.full, "--format", "json"]; const args3 = ["run", ctx.instructions.full, "--format", "json"];
@@ -157335,7 +157481,7 @@ var opencode = agent({
const env3 = { const env3 = {
...process.env, ...process.env,
HOME: tempHome, 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) // 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 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) { function configureOpenCode(ctx) {
const configDir = join12(ctx.tmpdir, ".config", "opencode"); const configDir = join13(ctx.tmpdir, ".config", "opencode");
mkdirSync6(configDir, { recursive: true }); mkdirSync6(configDir, { recursive: true });
const configPath = join12(configDir, "opencode.json"); const configPath = join13(configDir, "opencode.json");
const opencodeMcpServers = { const opencodeMcpServers = {
[ghPullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl } [ghPullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl }
}; };
@@ -157458,7 +157604,7 @@ function configureOpenCode(ctx) {
}; };
const configJson = JSON.stringify(config4, null, 2); const configJson = JSON.stringify(config4, null, 2);
try { try {
writeFileSync8(configPath, configJson, "utf-8"); writeFileSync9(configPath, configJson, "utf-8");
} catch (error50) { } catch (error50) {
log.error( log.error(
`failed to write OpenCode config to ${configPath}: ${error50 instanceof Error ? error50.message : String(error50)}` `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 { execSync as execSync2 } from "node:child_process";
import { mkdtempSync } from "node:fs"; import { mkdtempSync } from "node:fs";
import { tmpdir as tmpdir2 } from "node:os"; import { tmpdir as tmpdir2 } from "node:os";
import { join as join13 } from "node:path"; import { join as join14 } from "node:path";
function createTempDirectory() { function createTempDirectory() {
const sharedTempDir = mkdtempSync(join13(tmpdir2(), "pullfrog-")); const sharedTempDir = mkdtempSync(join14(tmpdir2(), "pullfrog-"));
process.env.PULLFROG_TEMP_DIR = sharedTempDir; process.env.PULLFROG_TEMP_DIR = sharedTempDir;
log.info(`\xBB created temp dir at ${sharedTempDir}`); log.info(`\xBB created temp dir at ${sharedTempDir}`);
return 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 ### check suite tools
#### `get_check_suite_logs` #### `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:** **parameters:**
- `check_suite_id` (number): the id from check_suite.id in the webhook payload - `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` **replaces:** `gh run list` and `gh run view --log`
**returns:** **returns:**
all logs from all failed workflow runs in the check suite, including: structured failure information for each failed job:
- workflow run details (id, name, html_url, conclusion) - `_instructions`: explains how to use each field
- job details for each workflow run (id, name, status, conclusion, logs) - `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:** **example:**
```typescript ```typescript
// when handling a check_suite_completed webhook // 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 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 ### 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 { type } from "arktype";
import { log } from "../utils/log.ts";
import type { ToolContext } from "./server.ts"; import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
@@ -6,13 +9,127 @@ export const GetCheckSuiteLogs = type({
check_suite_id: type.number.describe("the id from check_suite.id"), 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) { export function GetCheckSuiteLogsTool(ctx: ToolContext) {
return tool({ return tool({
name: "get_check_suite_logs", name: "get_check_suite_logs",
description: 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, 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 // get workflow runs for this specific check suite
const workflowRuns = await ctx.octokit.paginate( const workflowRuns = await ctx.octokit.paginate(
ctx.octokit.rest.actions.listWorkflowRunsForRepo, ctx.octokit.rest.actions.listWorkflowRunsForRepo,
@@ -30,67 +147,101 @@ export function GetCheckSuiteLogsTool(ctx: ToolContext) {
return { return {
check_suite_id, check_suite_id,
message: "no failed workflow runs found for this check suite", 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 // get logs for each failed run
const logsForRuns = await Promise.all( for (const run of failedRuns) {
failedRuns.map(async (run) => { const jobs = await ctx.octokit.paginate(ctx.octokit.rest.actions.listJobsForWorkflowRun, {
const jobs = await ctx.octokit.paginate(ctx.octokit.rest.actions.listJobsForWorkflowRun, { owner: ctx.repo.owner,
owner: ctx.repo.owner, repo: ctx.repo.name,
repo: ctx.repo.name, run_id: run.id,
run_id: run.id, });
});
const jobLogs = await Promise.all( // only process failed jobs
jobs.map(async (job) => { const failedJobs = jobs.filter((job) => job.conclusion === "failure");
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; for (const job of failedJobs) {
const logsText = await fetch(logsUrl).then((r) => r.text()); try {
const logsResponse = await ctx.octokit.rest.actions.downloadJobLogsForWorkflowRun({
owner: ctx.repo.owner,
repo: ctx.repo.name,
job_id: job.id,
});
return { const logsUrl = logsResponse.url;
job_id: job.id, const logsText = await fetch(logsUrl).then((r) => r.text());
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}`,
};
}
})
);
return { // write full log to disk
workflow_run_id: run.id, const logPath = join(logsDir, `job-${job.id}.log`);
workflow_name: run.name, writeFileSync(logPath, logsText);
html_url: run.html_url,
conclusion: run.conclusion, // analyze log
jobs: jobLogs, 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 { 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, 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 4. Create a structured plan with clear milestones
5. ${reportProgressInstruction}`, 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", name: "Prompt",