From eb7de776e4c8df99776c4c58cba54800d22662da Mon Sep 17 00:00:00 2001 From: wolfy Date: Mon, 1 Jun 2026 20:47:26 -0500 Subject: [PATCH] idk --- comment.ts | 7 ++++- diff.ts | 86 ++++++++++++++++++++++++++++++++++++++++++------------ gitea.ts | 47 +++++++++++++++++++++++++---- mcp.ts | 66 ++++++++++++++++++++++++++++++----------- review.ts | 44 +++++++++++++++++++++++----- 5 files changed, 201 insertions(+), 49 deletions(-) diff --git a/comment.ts b/comment.ts index 211bf1f..a5234d3 100644 --- a/comment.ts +++ b/comment.ts @@ -26,9 +26,14 @@ export function formatReview( files: string[], model: string, ): string { + console.log( + `Formatting review for ${files.length} file${files.length === 1 ? "" : "s"} with model ${model}...`, + ); const parts: string[] = []; - parts.push(`## 🤖 shockbot review\n\n> Reviewed ${files.length} file${files.length === 1 ? "" : "s"} using \`${model}\``); + parts.push( + `## 🤖 shockbot review\n\n> Reviewed ${files.length} file${files.length === 1 ? "" : "s"} using \`${model}\``, + ); // Summary section — join per-chunk summaries const summaries = review.summaries.filter(Boolean); diff --git a/diff.ts b/diff.ts index 5333823..b9c168c 100644 --- a/diff.ts +++ b/diff.ts @@ -6,12 +6,29 @@ function estimateTokens(text: string): number { } const SKIP_EXTENSIONS = new Set([ - "lock", "sum", + "lock", + "sum", "map", - "png", "jpg", "jpeg", "gif", "svg", "ico", "webp", - "woff", "woff2", "ttf", "eot", - "pdf", "zip", "tar", "gz", - "bin", "exe", "dll", "so", "dylib", + "png", + "jpg", + "jpeg", + "gif", + "svg", + "ico", + "webp", + "woff", + "woff2", + "ttf", + "eot", + "pdf", + "zip", + "tar", + "gz", + "bin", + "exe", + "dll", + "so", + "dylib", ]); const SKIP_PATTERNS = [ @@ -25,35 +42,53 @@ const SKIP_PATTERNS = [ function shouldSkip(filename: string): boolean { if (!filename.includes(".")) return true; // no extension = likely binary const ext = filename.split(".").pop()!.toLowerCase(); - return SKIP_EXTENSIONS.has(ext) || SKIP_PATTERNS.some((p) => p.test(filename)); + return ( + SKIP_EXTENSIONS.has(ext) || SKIP_PATTERNS.some((p) => p.test(filename)) + ); } const EXT_TO_LANG: Record = { - ts: "TypeScript", tsx: "TypeScript", - js: "JavaScript", jsx: "JavaScript", mjs: "JavaScript", cjs: "JavaScript", + ts: "TypeScript", + tsx: "TypeScript", + js: "JavaScript", + jsx: "JavaScript", + mjs: "JavaScript", + cjs: "JavaScript", py: "Python", go: "Go", rs: "Rust", rb: "Ruby", java: "Java", - kt: "Kotlin", kts: "Kotlin", + kt: "Kotlin", + kts: "Kotlin", swift: "Swift", cs: "C#", - cpp: "C++", cc: "C++", cxx: "C++", hpp: "C++", + cpp: "C++", + cc: "C++", + cxx: "C++", + hpp: "C++", c: "C", php: "PHP", - sh: "Shell", bash: "Shell", zsh: "Shell", - yaml: "YAML", yml: "YAML", + sh: "Shell", + bash: "Shell", + zsh: "Shell", + yaml: "YAML", + yml: "YAML", json: "JSON", md: "Markdown", sql: "SQL", - html: "HTML", htm: "HTML", - css: "CSS", scss: "CSS", sass: "CSS", less: "CSS", + html: "HTML", + htm: "HTML", + css: "CSS", + scss: "CSS", + sass: "CSS", + less: "CSS", vue: "Vue", svelte: "Svelte", toml: "TOML", xml: "XML", - tf: "Terraform", hcl: "HCL", + tf: "Terraform", + hcl: "HCL", }; function detectLanguage(filename: string): string { @@ -68,6 +103,7 @@ export interface DiffFile { } export function parseDiff(rawDiff: string): DiffFile[] { + console.log("Parsing diff..."); const files: DiffFile[] = []; const sections = rawDiff.split(/^diff --git /m).filter((s) => s.trim()); @@ -82,7 +118,10 @@ export function parseDiff(rawDiff: string): DiffFile[] { let filename: string; if (plusLine?.startsWith("+++ b/")) { filename = plusLine.slice(6); - } else if (plusLine === "+++ /dev/null" && minusLine?.startsWith("--- a/")) { + } else if ( + plusLine === "+++ /dev/null" && + minusLine?.startsWith("--- a/") + ) { filename = minusLine.slice(6); // deleted file — use old path } else { continue; @@ -113,6 +152,9 @@ export function parseDiff(rawDiff: string): DiffFile[] { } export function chunkFile(file: DiffFile, maxTokens: number): DiffChunk[] { + console.log( + `Chunking file ${file.filename} with ${file.hunks.length} hunks...`, + ); return file.hunks.map(({ header, body }) => { let hunk = body; @@ -122,13 +164,21 @@ export function chunkFile(file: DiffFile, maxTokens: number): DiffChunk[] { const charBudget = maxTokens * 4; let chars = 0; let i = 0; - while (i < lines.length && chars + (lines[i]?.length ?? 0) + 1 <= charBudget) { + while ( + i < lines.length && + chars + (lines[i]?.length ?? 0) + 1 <= charBudget + ) { chars += (lines[i]?.length ?? 0) + 1; i++; } hunk = lines.slice(0, i).join("\n"); } - return { filename: file.filename, language: file.language, hunk, context: header }; + return { + filename: file.filename, + language: file.language, + hunk, + context: header, + }; }); } diff --git a/gitea.ts b/gitea.ts index be418c6..ad802d7 100644 --- a/gitea.ts +++ b/gitea.ts @@ -1,5 +1,10 @@ import { Gitea } from "@go-gitea/sdk.js"; -import type { Comment, ChangedFile, PullReview, Reaction } from "@go-gitea/sdk.js"; +import type { + Comment, + ChangedFile, + PullReview, + Reaction, +} from "@go-gitea/sdk.js"; import type { PRContext } from "./types.ts"; const client = new Gitea({ @@ -8,6 +13,7 @@ const client = new Gitea({ }); export async function getPRDiff(pr: PRContext): Promise { + console.log(`Fetching PR #${pr.prNumber} diff for ${pr.owner}/${pr.repo}`); const res = await client.rest.repository.repoDownloadPullDiffOrPatch({ owner: pr.owner, repo: pr.repo, @@ -18,6 +24,9 @@ export async function getPRDiff(pr: PRContext): Promise { } export async function getPRFiles(pr: PRContext): Promise { + console.log( + `Fetching PR #${pr.prNumber} changed files for ${pr.owner}/${pr.repo}`, + ); const res = await client.rest.repository.repoGetPullRequestFiles({ owner: pr.owner, repo: pr.repo, @@ -26,7 +35,13 @@ export async function getPRFiles(pr: PRContext): Promise { return res.data; } -export async function postReviewComment(pr: PRContext, body: string): Promise { +export async function postReviewComment( + pr: PRContext, + body: string, +): Promise { + console.log( + `Posting review comment on PR #${pr.prNumber} for ${pr.owner}/${pr.repo}`, + ); const res = await client.rest.issue.issueCreateComment({ owner: pr.owner, repo: pr.repo, @@ -42,6 +57,9 @@ export async function postInlineComment( line: number, body: string, ): Promise { + console.log( + `Posting inline review comment on PR #${pr.prNumber} for ${pr.owner}/${pr.repo} at ${file}:${line}`, + ); const res = await client.rest.repository.repoCreatePullReview({ owner: pr.owner, repo: pr.repo, @@ -83,6 +101,7 @@ export async function removeReaction( } export async function getPR(owner: string, repo: string, prNumber: number) { + console.log(`Fetching PR #${prNumber} data for ${owner}/${repo}`); const res = await client.rest.repository.repoGetPullRequest({ owner, repo, @@ -91,7 +110,13 @@ export async function getPR(owner: string, repo: string, prNumber: number) { return res.data; } -export async function getFileContents(pr: PRContext, filePath: string): Promise { +export async function getFileContents( + pr: PRContext, + filePath: string, +): Promise { + console.log( + `Fetching contents of ${filePath} at PR #${pr.prNumber} head for ${pr.owner}/${pr.repo}`, + ); const res = await client.rest.repository.repoGetContents({ owner: pr.owner, repo: pr.repo, @@ -104,7 +129,13 @@ export async function getFileContents(pr: PRContext, filePath: string): Promise< return Buffer.from(entry.content, "base64").toString("utf8"); } -export async function listDir(pr: PRContext, dirPath: string): Promise { +export async function listDir( + pr: PRContext, + dirPath: string, +): Promise { + console.log( + `Listing directory ${dirPath} at PR #${pr.prNumber} head for ${pr.owner}/${pr.repo}`, + ); const res = await client.rest.repository.repoGetContents({ owner: pr.owner, repo: pr.repo, @@ -117,7 +148,13 @@ export async function listDir(pr: PRContext, dirPath: string): Promise // Gitea REST API v1 has no code search endpoint — returns repo names matching the query. // mcp.ts should strongly prefer disk grep (find_symbol) when workspace is available. -export async function searchCode(pr: PRContext, query: string): Promise { +export async function searchCode( + pr: PRContext, + query: string, +): Promise { + console.log( + `Searching code for "${query}" at PR #${pr.prNumber} head for ${pr.owner}/${pr.repo}`, + ); const res = await client.rest.repository.repoSearch({ q: query, limit: 20, diff --git a/mcp.ts b/mcp.ts index c2c5c58..8e96817 100644 --- a/mcp.ts +++ b/mcp.ts @@ -2,7 +2,11 @@ import { existsSync, readFileSync, readdirSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { join, resolve, relative } from "node:path"; import type { Tool } from "ollama"; -import { getFileContents, listDir as giteaListDir, searchCode } from "./gitea.ts"; +import { + getFileContents, + listDir as giteaListDir, + searchCode, +} from "./gitea.ts"; import type { PRContext } from "./types.ts"; const MAX_FILE_LINES = 200; @@ -18,7 +22,10 @@ export const TOOLS: Tool[] = [ type: "object", required: ["path"], properties: { - path: { type: "string", description: "Repo-relative path to the file" }, + path: { + type: "string", + description: "Repo-relative path to the file", + }, }, }, }, @@ -41,12 +48,16 @@ export const TOOLS: Tool[] = [ type: "function", function: { name: "find_symbol", - description: "Search for a symbol, function, or pattern across the repository.", + description: + "Search for a symbol, function, or pattern across the repository.", parameters: { type: "object", required: ["symbol"], properties: { - symbol: { type: "string", description: "Symbol name or regex pattern to search for" }, + symbol: { + type: "string", + description: "Symbol name or regex pattern to search for", + }, }, }, }, @@ -117,7 +128,8 @@ export class ToolServer { if (this.workspace) { const full = join(this.workspace, safe); // Verify resolved path stays inside workspace - if (!resolve(full).startsWith(resolve(this.workspace))) return "[invalid path]"; + if (!resolve(full).startsWith(resolve(this.workspace))) + return "[invalid path]"; if (!existsSync(full)) return "[file not found]"; const content = readFileSync(full, "utf8"); const lines = content.split("\n"); @@ -136,10 +148,13 @@ export class ToolServer { if (this.workspace) { const full = join(this.workspace, safe); - if (!resolve(full).startsWith(resolve(this.workspace))) return "[invalid path]"; + if (!resolve(full).startsWith(resolve(this.workspace))) + return "[invalid path]"; if (!existsSync(full)) return "[directory not found]"; const entries = readdirSync(full, { withFileTypes: true }); - return entries.map((e) => `${e.isDirectory() ? "d" : "f"} ${e.name}`).join("\n"); + return entries + .map((e) => `${e.isDirectory() ? "d" : "f"} ${e.name}`) + .join("\n"); } const entries = await giteaListDir(this.pr, safe); @@ -155,20 +170,32 @@ export class ToolServer { const out = execFileSync( "grep", [ - "-r", "-n", - "--include=*.ts", "--include=*.tsx", - "--include=*.js", "--include=*.jsx", - "--include=*.py", "--include=*.go", - "--include=*.rs", "--include=*.rb", - "--include=*.java", "--include=*.kt", + "-r", + "-n", + "--include=*.ts", + "--include=*.tsx", + "--include=*.js", + "--include=*.jsx", + "--include=*.py", + "--include=*.go", + "--include=*.rs", + "--include=*.rb", + "--include=*.java", + "--include=*.kt", symbol, this.workspace, ], { encoding: "utf8", timeout: 5000, maxBuffer: 256 * 1024 }, ); - const lines = out.split("\n").filter(Boolean).slice(0, MAX_GREP_RESULTS); + const lines = out + .split("\n") + .filter(Boolean) + .slice(0, MAX_GREP_RESULTS); // Strip workspace prefix so paths are repo-relative - return lines.map((l) => l.replace(this.workspace! + "/", "")).join("\n") || "[not found]"; + return ( + lines.map((l) => l.replace(this.workspace! + "/", "")).join("\n") || + "[not found]" + ); } catch { return "[not found]"; } @@ -179,8 +206,13 @@ export class ToolServer { } } -export function createMcpServer(pr: PRContext, maxToolCalls: number): ToolServer { - const ws = process.env.GITEA_WORKSPACE ?? process.env.GITHUB_WORKSPACE ?? null; +export function createMcpServer( + pr: PRContext, + maxToolCalls: number, +): ToolServer { + console.log("Initializing tool server..."); + const ws = + process.env.GITEA_WORKSPACE ?? process.env.GITHUB_WORKSPACE ?? null; const workspace = ws && existsSync(ws) ? ws : null; return new ToolServer(pr, workspace, maxToolCalls); } diff --git a/review.ts b/review.ts index f829075..37fca18 100644 --- a/review.ts +++ b/review.ts @@ -1,6 +1,12 @@ import { Ollama } from "ollama"; import type { Message } from "ollama"; -import type { DiffChunk, ChunkReview, ReviewResult, AggregatedReview, Finding } from "./types.ts"; +import type { + DiffChunk, + ChunkReview, + ReviewResult, + AggregatedReview, + Finding, +} from "./types.ts"; import type { ToolServer } from "./mcp.ts"; const ollama = new Ollama({ host: process.env.OLLAMA_HOST }); @@ -21,7 +27,10 @@ When you are done gathering context and ready to report your findings, you MUST Do not include any text outside the JSON object.`; -export function buildReviewPrompt(chunk: DiffChunk, userPrompt: string): string { +export function buildReviewPrompt( + chunk: DiffChunk, + userPrompt: string, +): string { return `${userPrompt} File: ${chunk.filename} (${chunk.language}) @@ -38,7 +47,8 @@ function parseReviewResult(content: string): ReviewResult | null { .trim(); try { const parsed = JSON.parse(stripped); - if (!Array.isArray(parsed.issues) || typeof parsed.summary !== "string") return null; + if (!Array.isArray(parsed.issues) || typeof parsed.summary !== "string") + return null; return parsed as ReviewResult; } catch { return null; @@ -52,6 +62,7 @@ export async function reviewChunk( contextWindow: number, mcpServer: ToolServer, ): Promise { + console.log(`Reviewing chunk from ${chunk.filename} with model ${model}...`); mcpServer.resetCallCount(); const messages: Message[] = [ @@ -69,6 +80,7 @@ export async function reviewChunk( tools: forceConclusion ? undefined : mcpServer.tools, format: "json", stream: false, + keep_alive: -1, options: { num_ctx: contextWindow, temperature: 0.2 }, }); @@ -76,21 +88,30 @@ export async function reviewChunk( // Model called one or more tools if (msg.tool_calls && msg.tool_calls.length > 0) { - messages.push({ role: "assistant", content: msg.content ?? "", tool_calls: msg.tool_calls }); + messages.push({ + role: "assistant", + content: msg.content ?? "", + tool_calls: msg.tool_calls, + }); for (const call of msg.tool_calls) { const result = await mcpServer.execute( call.function.name, call.function.arguments as Record, ); - messages.push({ role: "tool", content: result, tool_name: call.function.name }); + messages.push({ + role: "tool", + content: result, + tool_name: call.function.name, + }); } if (mcpServer.atLimit) { forceConclusion = true; messages.push({ role: "user", - content: "You have reached the tool call limit. Respond now with your final JSON review.", + content: + "You have reached the tool call limit. Respond now with your final JSON review.", }); } continue; @@ -121,16 +142,23 @@ export async function reviewChunk( // Second failure — return raw content as summary rather than crash return { chunk, - result: { issues: [], summary: `[parse error] ${content.slice(0, 500)}` }, + result: { + issues: [], + summary: `[parse error] ${content.slice(0, 500)}`, + }, }; } // Empty response — shouldn't happen, but bail rather than loop forever - return { chunk, result: { issues: [], summary: "[empty response from model]" } }; + return { + chunk, + result: { issues: [], summary: "[empty response from model]" }, + }; } } export function aggregateFindings(results: ChunkReview[]): AggregatedReview { + console.log(`Aggregating findings from ${results.length} reviewed chunks...`); const findings: Finding[] = results.flatMap(({ chunk, result }) => result.issues.map((issue) => ({ ...issue, filename: chunk.filename })), );