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:
committed by
pullfrog[bot]
parent
d545a84027
commit
2514bb1cf7
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user