const crypto = require("crypto"); const path = require("path"); const { validateProposedChanges, validateResolvedChanges } = require("./yaml-handler"); // Known safe error prefixes that can be shown to users const SAFE_ERROR_PREFIXES = [ "Refusing to commit", "Invalid file path", "CI auto-fix failed", ]; function sanitizeErrorMessage(err) { const msg = err.message || ""; if (msg.includes("Claude returned") || msg.includes("Raw response")) { return "Failed to process the AI response. Please try rephrasing your request."; } if (SAFE_ERROR_PREFIXES.some((p) => msg.startsWith(p))) { return msg; } if (err.status === 429 || msg.includes("rate_limit")) { return "Rate-limited by the AI service. Please wait a moment and try again."; } if (err.status === 529 || msg.includes("overloaded")) { return "The AI service is temporarily overloaded. Please try again in a minute."; } return "An unexpected error occurred. Please try again."; } function verifySignature(rawBody, signatureHeader, secret) { if (!signatureHeader) return false; const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); try { return crypto.timingSafeEqual( Buffer.from(signatureHeader), Buffer.from(expected) ); } catch { return false; } } function createWebhookHandler(config, github, claude) { return async function handleWebhook(req, res) { // Collect raw body from the IncomingMessage stream (capped at 1MB) const MAX_BODY_SIZE = 1024 * 1024; const buffers = []; let totalSize = 0; for await (const chunk of req) { totalSize += chunk.length; if (totalSize > MAX_BODY_SIZE) { res.writeHead(413); res.end("Request body too large"); return; } buffers.push(chunk); } const rawBody = Buffer.concat(buffers).toString("utf-8"); // Verify webhook signature const signature = req.headers["x-hub-signature-256"]; if (!verifySignature(rawBody, signature, config.webhook.secret)) { console.log("[webhook] Invalid signature, rejecting"); res.writeHead(401); res.end("Invalid signature"); return; } const event = req.headers["x-github-event"]; // Handle ping event if (event === "ping") { console.log("[webhook] Ping received"); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, message: "pong" })); return; } let payload; try { payload = JSON.parse(rawBody); } catch (err) { console.error("[webhook] Invalid JSON payload:", err.message); res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, message: "Invalid JSON payload" })); return; } // Route check_run events for CI auto-fix if (event === "check_run") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, message: "processing" })); if (config.ci.autoFix) { handleCheckRun(payload, config, github, claude).catch(async (err) => { console.error("[webhook] Error handling check_run:", err); const prNumber = payload.check_run?.pull_requests?.[0]?.number; if (prNumber) { try { await github.addPullRequestComment(prNumber, `🤖 **Fleet:** CI auto-fix failed: ${sanitizeErrorMessage(err)}`); } catch (replyErr) { console.error("[webhook] Failed to post CI error comment:", replyErr); } } }); } return; } // Only handle comment events from here if (event !== "issue_comment" && event !== "pull_request_review_comment") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, message: "ignored" })); return; } // Only handle new comments (not edits or deletions) if (payload.action !== "created") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, message: "ignored" })); return; } // For issue_comment, only handle comments on PRs (not plain issues) if (event === "issue_comment" && !payload.issue.pull_request) { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, message: "not a PR" })); return; } // Skip bot's own comments to prevent infinite loops const commentAuthor = payload.comment.user.login; const commentBody = payload.comment.body || ""; if ( commentAuthor === config.github.botUsername || payload.comment.user.type === "Bot" || commentBody.startsWith("🤖 **Fleet:**") ) { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, message: "skipping bot comment" })); return; } // Authorization: only process comments from repo collaborators const TRUSTED_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); const authorAssociation = payload.comment.author_association; if (!TRUSTED_ASSOCIATIONS.has(authorAssociation)) { console.log(`[webhook] Ignoring comment from ${commentAuthor} (association: ${authorAssociation})`); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, message: "unauthorized author" })); return; } // Respond to GitHub immediately to avoid timeout res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, message: "processing" })); // Extract PR number — different payload structure per event type const prNumber = event === "issue_comment" ? payload.issue.number : payload.pull_request.number; const commentId = payload.comment.id; console.log(`[webhook] PR #${prNumber} comment from ${commentAuthor}: "${commentBody.slice(0, 100)}"`); // Check if the bot was @mentioned const mentionedBot = config.github.botUsername && commentBody.toLowerCase().includes(`@${config.github.botUsername.toLowerCase()}`); processComment({ prNumber, commentBody, commentId, event, mentionedBot }, config, github, claude).catch( async (err) => { console.error("[webhook] Error processing comment:", err); try { await github.addPullRequestComment( prNumber, `🤖 **Fleet:** Error processing your request: ${sanitizeErrorMessage(err)}` ); } catch (replyErr) { console.error("[webhook] Failed to post error comment:", replyErr); } } ); }; } async function processComment({ prNumber, commentBody, commentId, event, mentionedBot }, config, github, claude) { // Only respond when explicitly @mentioned — avoid surprising users // by reacting to comments that weren't directed at the bot. if (!mentionedBot) { console.log(`[webhook] Ignoring PR #${prNumber} comment — bot was not @mentioned`); return; } // Fetch PR details console.log(`[webhook] Fetching PR #${prNumber} details...`); const pr = await github.getPullRequest(prNumber); if (pr.state !== "open") { console.log(`[webhook] Ignoring PR #${prNumber} — state is "${pr.state}"`); return; } // React with 👀 so the user knows we received their message try { if (event === "issue_comment") { await github.addIssueCommentReaction(commentId, "eyes"); } else { await github.addReviewCommentReaction(commentId, "eyes"); } console.log(`[webhook] Added 👀 reaction to comment ${commentId}`); } catch (err) { console.warn(`[webhook] Failed to add reaction: ${err.message}`); } // Fetch the files changed in the PR (only GitOps files) console.log(`[webhook] Fetching changed files for PR #${prNumber}...`); const prFiles = await github.getPullRequestFiles(prNumber); const gitopsPrefix = config.github.gitopsBasePath + "/"; const activeFiles = prFiles.filter((f) => f.status !== "removed" && f.filename.startsWith(gitopsPrefix)); console.log(`[webhook] ${activeFiles.length} GitOps files to read from branch ${pr.headBranch}`); // Read current contents of each file from the PR branch const currentFiles = {}; for (const file of activeFiles) { const content = await github.getFileContentFromRef(file.filename, pr.headBranch); if (content !== null) { const relPath = file.filename.slice(gitopsPrefix.length); currentFiles[relPath] = content; } } console.log(`[webhook] Read ${Object.keys(currentFiles).length} files: ${Object.keys(currentFiles).join(", ")}`); // Send to Claude for revisions console.log("[webhook] Sending revision request to Claude..."); const proposal = await claude.proposeRevisions(commentBody, currentFiles, pr.title); if (proposal.type === "info") { // Informational reply — no file changes, just post the answer console.log("[webhook] Claude returned an informational response"); await github.addPullRequestComment(prNumber, `🤖 **Fleet:** ${proposal.text}`); console.log(`[webhook] PR #${prNumber} info reply posted. Done.`); return; } console.log(`[webhook] Claude proposed ${proposal.changes.length} changes`); // Guard: reject placeholder or suspiciously short content validateProposedChanges(proposal.changes); // Validate file paths: prevent traversal and enforce GitOps allowlist const changes = []; for (const c of proposal.changes) { const normalized = path.posix.normalize(c.filePath); if (normalized.startsWith("..") || path.posix.isAbsolute(normalized) || !(normalized === "default.yml" || normalized.startsWith("fleets/") || normalized.startsWith("lib/"))) { throw new Error(`Invalid file path in response (must be under default.yml, fleets/, or lib/): ${c.filePath}`); } if (!c.content) { throw new Error(`Change for "${c.filePath}" is missing content`); } const fullPath = `${config.github.gitopsBasePath}/${normalized}`; changes.push({ path: fullPath, content: c.content, relPath: normalized }); } // Validate YAML schema on resolved content const warnings = validateResolvedChanges(changes); if (warnings.length > 0) { console.warn(`[webhook] YAML validation warnings:\n${warnings.join("\n")}`); } console.log(`[webhook] Committing ${changes.length} file(s) to ${pr.headBranch}...`); await github.commitChanges(pr.headBranch, changes, `Update: ${proposal.summary}`); // Reply on the PR const fileList = proposal.changes .map((c) => `- \`${c.filePath}\` — ${c.changeDescription}`) .join("\n"); const replyBody = `🤖 **Fleet:** Updated this PR based on your comment.\n\n**Summary:** ${proposal.summary}\n\n**Files changed:**\n${fileList}`; await github.addPullRequestComment(prNumber, replyBody); if (warnings.length > 0) { await github.addPullRequestComment(prNumber, `⚠️ **Validation warnings:**\n${warnings.map((w) => `- ${w}`).join("\n")}`); } console.log(`[webhook] PR #${prNumber} updated and reply posted. Done.`); } async function handleCheckRun(payload, config, github, claude) { const checkRun = payload.check_run; // Only handle completed, failed check runs matching our CI check name if (payload.action !== "completed" || checkRun.conclusion !== "failure") { return; } if (checkRun.name !== config.ci.checkName) { return; } // Need at least one associated PR const prRef = checkRun.pull_requests && checkRun.pull_requests[0]; if (!prRef) { console.log(`[ci-fix] Check run "${checkRun.name}" failed but has no associated PR, skipping`); return; } const prNumber = prRef.number; const headSha = checkRun.head_sha; console.log(`[ci-fix] Check "${checkRun.name}" failed on PR #${prNumber} (sha: ${headSha.slice(0, 8)})`); // Loop prevention: allow up to 2 consecutive CI fix attempts, then stop try { const commit = await github.getCommit(headSha); if (commit.message.startsWith("CI fix:")) { // Check the parent commit too — if it's also a CI fix, we've already retried once const parentSha = commit.parentSha; if (parentSha) { const parent = await github.getCommit(parentSha); if (parent.message.startsWith("CI fix:")) { console.log(`[ci-fix] Skipping — already attempted CI fix twice`); return; } } console.log(`[ci-fix] Previous CI fix failed, retrying (attempt 2)...`); } } catch (err) { console.warn(`[ci-fix] Could not check commit history: ${err.message}, skipping as a safety precaution`); return; } // Fetch PR details const pr = await github.getPullRequest(prNumber); if (!pr.headBranch.startsWith("fleet/")) { console.log(`[ci-fix] Ignoring PR #${prNumber} — branch "${pr.headBranch}" is not a fleet branch`); return; } if (pr.state !== "open") { console.log(`[ci-fix] Ignoring PR #${prNumber} — state is "${pr.state}"`); return; } // Authorization: only auto-fix PRs from trusted authors const TRUSTED_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); if (!TRUSTED_ASSOCIATIONS.has(pr.authorAssociation)) { console.log(`[ci-fix] Ignoring PR #${prNumber} — author is not a trusted collaborator (${pr.authorAssociation})`); return; } // Fetch the failed job logs console.log(`[ci-fix] Fetching CI logs for sha ${headSha.slice(0, 8)}...`); const rawLogs = await github.getFailedJobLogs(headSha, config.ci.checkName); if (!rawLogs) { console.log("[ci-fix] Could not find failed job logs, skipping"); return; } // Extract error lines from the logs const errorLines = extractErrors(rawLogs); if (!errorLines) { console.log("[ci-fix] No actionable errors found in logs, skipping"); return; } console.log(`[ci-fix] Extracted errors:\n${errorLines}`); // Fetch current files from the PR branch (only GitOps files) const prFiles = await github.getPullRequestFiles(prNumber); const gitopsPrefix = config.github.gitopsBasePath + "/"; const activeFiles = prFiles.filter((f) => f.status !== "removed" && f.filename.startsWith(gitopsPrefix)); const currentFiles = {}; for (const file of activeFiles) { const content = await github.getFileContentFromRef(file.filename, pr.headBranch); if (content !== null) { const relPath = file.filename.slice(gitopsPrefix.length); currentFiles[relPath] = content; } } console.log(`[ci-fix] Read ${Object.keys(currentFiles).length} files from ${pr.headBranch}`); // Send to Claude for a fix console.log("[ci-fix] Sending CI errors to Claude for auto-fix..."); const proposal = await claude.proposeCiFix(errorLines, currentFiles, pr.title); if (proposal.type === "info") { console.log("[ci-fix] Claude returned info instead of a fix, posting as comment"); await github.addPullRequestComment(prNumber, `🤖 **Fleet:** CI check \`${config.ci.checkName}\` failed. I analyzed the error but couldn't produce an automatic fix:\n\n${proposal.text}`); return; } console.log(`[ci-fix] Claude proposed ${proposal.changes.length} changes`); // Guard: reject placeholder or suspiciously short content validateProposedChanges(proposal.changes); // Validate file paths: prevent traversal and enforce GitOps allowlist const changes = []; for (const c of proposal.changes) { const normalized = path.posix.normalize(c.filePath); if (normalized.startsWith("..") || path.posix.isAbsolute(normalized) || !(normalized === "default.yml" || normalized.startsWith("fleets/") || normalized.startsWith("lib/"))) { throw new Error(`Invalid file path in CI fix response (must be under default.yml, fleets/, or lib/): ${c.filePath}`); } if (!c.content) { throw new Error(`Change for "${c.filePath}" is missing content`); } const fullPath = `${config.github.gitopsBasePath}/${normalized}`; changes.push({ path: fullPath, content: c.content, relPath: normalized }); } // Validate YAML schema on resolved content const ciWarnings = validateResolvedChanges(changes); if (ciWarnings.length > 0) { console.warn(`[ci-fix] YAML validation warnings:\n${ciWarnings.join("\n")}`); } console.log(`[ci-fix] Committing ${changes.length} file(s) to ${pr.headBranch}...`); await github.commitChanges(pr.headBranch, changes, `CI fix: ${proposal.summary}`); // Reply on the PR const fileList = proposal.changes .map((c) => `- \`${c.filePath}\` — ${c.changeDescription}`) .join("\n"); const replyBody = `🤖 **Fleet:** CI check \`${config.ci.checkName}\` failed. I pushed a fix.\n\n**Errors:**\n\`\`\`\n${errorLines}\n\`\`\`\n\n**Summary:** ${proposal.summary}\n\n**Files changed:**\n${fileList}`; await github.addPullRequestComment(prNumber, replyBody); if (ciWarnings.length > 0) { await github.addPullRequestComment(prNumber, `⚠️ **Validation warnings:**\n${ciWarnings.map((w) => `- ${w}`).join("\n")}`); } console.log(`[ci-fix] PR #${prNumber} fix committed and comment posted. Done.`); } function extractErrors(rawLogs) { const lines = rawLogs.split("\n"); const errorLines = []; for (let i = 0; i < lines.length; i++) { // Strip timestamp prefix (e.g. "2026-03-04T20:34:17.3948621Z ") const line = lines[i].replace(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s*/, "").trim(); if (line.startsWith("Error:") || line.startsWith("error:") || line.startsWith("* ")) { errorLines.push(line); } else if (line.includes("##[error]")) { errorLines.push(line.replace("##[error]", "").trim()); } } return errorLines.length > 0 ? errorLines.join("\n") : null; } module.exports = { createWebhookHandler };