import { readFileSync } from "node:fs"; import type { ActionConfig, PRContext, ChunkReview } from "./types.ts"; import { getPR, getPRDiff, addReaction, removeReaction, postReviewComment } from "./gitea.ts"; import { parseDiff, chunkFile } from "./diff.ts"; import { createMcpServer } from "./mcp.ts"; import { reviewChunk, aggregateFindings } from "./review.ts"; import { formatReview } from "./comment.ts"; function readConfig(): ActionConfig { return { prompt: process.env.INPUT_PROMPT ?? "Review this pull request", model: process.env.INPUT_MODEL ?? "qwen3.6:35b", contextWindow: parseInt(process.env.INPUT_CONTEXT_WINDOW ?? "4096", 10), maxToolCalls: parseInt(process.env.INPUT_MAX_TOOL_CALLS ?? "10", 10), }; } function readEvent(): Record { const path = process.env.GITHUB_EVENT_PATH; if (!path) return {}; try { return JSON.parse(readFileSync(path, "utf8")) as Record; } catch { return {}; } } async function main(): Promise { const config = readConfig(); const eventName = process.env.GITHUB_EVENT_NAME ?? ""; const event = readEvent(); const repository = process.env.GITHUB_REPOSITORY ?? ""; const slashIdx = repository.indexOf("/"); if (slashIdx === -1) throw new Error(`Invalid GITHUB_REPOSITORY: ${repository}`); const owner = repository.slice(0, slashIdx); const repo = repository.slice(slashIdx + 1); let prNumber: number; let prompt: string; let triggerCommentId: number | null = null; if (eventName === "pull_request") { prNumber = event.number as number; prompt = config.prompt; } else if (eventName === "issue_comment") { const issue = event.issue as Record; if (!issue?.pull_request) { console.log("issue_comment on non-PR issue, skipping"); return; } const comment = event.comment as Record; const commentBody = String(comment?.body ?? ""); if (!commentBody.toLowerCase().includes("@shockbot")) { console.log("comment does not mention @shockbot, skipping"); return; } prNumber = issue.number as number; triggerCommentId = comment.id as number; prompt = commentBody.replace(/@shockbot\s*/gi, "").trim() || config.prompt; } else { console.log(`Unsupported event: ${eventName}, skipping`); return; } const prData = await getPR(owner, repo, prNumber); const headSha = prData.head?.sha; if (!headSha) throw new Error(`Could not get head SHA for PR #${prNumber}`); const pr: PRContext = { owner, repo, prNumber, headSha }; if (triggerCommentId !== null) { await addReaction(pr, triggerCommentId, "eyes").catch(() => {}); } try { const rawDiff = await getPRDiff(pr); if (!rawDiff.trim()) { await postReviewComment(pr, "shockbot: no diff found for this PR."); return; } const files = parseDiff(rawDiff); if (files.length === 0) { await postReviewComment(pr, "shockbot: no reviewable files in this PR (all files skipped)."); return; } const chunks = files.flatMap((f) => chunkFile(f, config.contextWindow)); const mcpServer = createMcpServer(pr, config.maxToolCalls); const results: ChunkReview[] = []; for (const chunk of chunks) { const result = await reviewChunk( chunk, prompt, config.model, config.contextWindow, mcpServer, ); results.push(result); } const aggregated = aggregateFindings(results); const body = formatReview(aggregated, files.map((f) => f.filename), config.model); await postReviewComment(pr, body); if (triggerCommentId !== null) { await removeReaction(pr, triggerCommentId, "eyes").catch(() => {}); await addReaction(pr, triggerCommentId, "+1").catch(() => {}); } } catch (err) { console.error("Review failed:", err); if (triggerCommentId !== null) { await removeReaction(pr, triggerCommentId, "eyes").catch(() => {}); await addReaction(pr, triggerCommentId, "-1").catch(() => {}); } process.exit(1); } } main();