This commit is contained in:
2026-06-01 20:47:26 -05:00
parent 108b8243a9
commit eb7de776e4
5 changed files with 201 additions and 49 deletions
+6 -1
View File
@@ -26,9 +26,14 @@ export function formatReview(
files: string[], files: string[],
model: string, model: string,
): string { ): string {
console.log(
`Formatting review for ${files.length} file${files.length === 1 ? "" : "s"} with model ${model}...`,
);
const parts: string[] = []; 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 // Summary section — join per-chunk summaries
const summaries = review.summaries.filter(Boolean); const summaries = review.summaries.filter(Boolean);
+68 -18
View File
@@ -6,12 +6,29 @@ function estimateTokens(text: string): number {
} }
const SKIP_EXTENSIONS = new Set([ const SKIP_EXTENSIONS = new Set([
"lock", "sum", "lock",
"sum",
"map", "map",
"png", "jpg", "jpeg", "gif", "svg", "ico", "webp", "png",
"woff", "woff2", "ttf", "eot", "jpg",
"pdf", "zip", "tar", "gz", "jpeg",
"bin", "exe", "dll", "so", "dylib", "gif",
"svg",
"ico",
"webp",
"woff",
"woff2",
"ttf",
"eot",
"pdf",
"zip",
"tar",
"gz",
"bin",
"exe",
"dll",
"so",
"dylib",
]); ]);
const SKIP_PATTERNS = [ const SKIP_PATTERNS = [
@@ -25,35 +42,53 @@ const SKIP_PATTERNS = [
function shouldSkip(filename: string): boolean { function shouldSkip(filename: string): boolean {
if (!filename.includes(".")) return true; // no extension = likely binary if (!filename.includes(".")) return true; // no extension = likely binary
const ext = filename.split(".").pop()!.toLowerCase(); 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<string, string> = { const EXT_TO_LANG: Record<string, string> = {
ts: "TypeScript", tsx: "TypeScript", ts: "TypeScript",
js: "JavaScript", jsx: "JavaScript", mjs: "JavaScript", cjs: "JavaScript", tsx: "TypeScript",
js: "JavaScript",
jsx: "JavaScript",
mjs: "JavaScript",
cjs: "JavaScript",
py: "Python", py: "Python",
go: "Go", go: "Go",
rs: "Rust", rs: "Rust",
rb: "Ruby", rb: "Ruby",
java: "Java", java: "Java",
kt: "Kotlin", kts: "Kotlin", kt: "Kotlin",
kts: "Kotlin",
swift: "Swift", swift: "Swift",
cs: "C#", cs: "C#",
cpp: "C++", cc: "C++", cxx: "C++", hpp: "C++", cpp: "C++",
cc: "C++",
cxx: "C++",
hpp: "C++",
c: "C", c: "C",
php: "PHP", php: "PHP",
sh: "Shell", bash: "Shell", zsh: "Shell", sh: "Shell",
yaml: "YAML", yml: "YAML", bash: "Shell",
zsh: "Shell",
yaml: "YAML",
yml: "YAML",
json: "JSON", json: "JSON",
md: "Markdown", md: "Markdown",
sql: "SQL", sql: "SQL",
html: "HTML", htm: "HTML", html: "HTML",
css: "CSS", scss: "CSS", sass: "CSS", less: "CSS", htm: "HTML",
css: "CSS",
scss: "CSS",
sass: "CSS",
less: "CSS",
vue: "Vue", vue: "Vue",
svelte: "Svelte", svelte: "Svelte",
toml: "TOML", toml: "TOML",
xml: "XML", xml: "XML",
tf: "Terraform", hcl: "HCL", tf: "Terraform",
hcl: "HCL",
}; };
function detectLanguage(filename: string): string { function detectLanguage(filename: string): string {
@@ -68,6 +103,7 @@ export interface DiffFile {
} }
export function parseDiff(rawDiff: string): DiffFile[] { export function parseDiff(rawDiff: string): DiffFile[] {
console.log("Parsing diff...");
const files: DiffFile[] = []; const files: DiffFile[] = [];
const sections = rawDiff.split(/^diff --git /m).filter((s) => s.trim()); const sections = rawDiff.split(/^diff --git /m).filter((s) => s.trim());
@@ -82,7 +118,10 @@ export function parseDiff(rawDiff: string): DiffFile[] {
let filename: string; let filename: string;
if (plusLine?.startsWith("+++ b/")) { if (plusLine?.startsWith("+++ b/")) {
filename = plusLine.slice(6); 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 filename = minusLine.slice(6); // deleted file — use old path
} else { } else {
continue; continue;
@@ -113,6 +152,9 @@ export function parseDiff(rawDiff: string): DiffFile[] {
} }
export function chunkFile(file: DiffFile, maxTokens: number): DiffChunk[] { 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 }) => { return file.hunks.map(({ header, body }) => {
let hunk = body; let hunk = body;
@@ -122,13 +164,21 @@ export function chunkFile(file: DiffFile, maxTokens: number): DiffChunk[] {
const charBudget = maxTokens * 4; const charBudget = maxTokens * 4;
let chars = 0; let chars = 0;
let i = 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; chars += (lines[i]?.length ?? 0) + 1;
i++; i++;
} }
hunk = lines.slice(0, i).join("\n"); 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,
};
}); });
} }
+42 -5
View File
@@ -1,5 +1,10 @@
import { Gitea } from "@go-gitea/sdk.js"; 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"; import type { PRContext } from "./types.ts";
const client = new Gitea({ const client = new Gitea({
@@ -8,6 +13,7 @@ const client = new Gitea({
}); });
export async function getPRDiff(pr: PRContext): Promise<string> { export async function getPRDiff(pr: PRContext): Promise<string> {
console.log(`Fetching PR #${pr.prNumber} diff for ${pr.owner}/${pr.repo}`);
const res = await client.rest.repository.repoDownloadPullDiffOrPatch({ const res = await client.rest.repository.repoDownloadPullDiffOrPatch({
owner: pr.owner, owner: pr.owner,
repo: pr.repo, repo: pr.repo,
@@ -18,6 +24,9 @@ export async function getPRDiff(pr: PRContext): Promise<string> {
} }
export async function getPRFiles(pr: PRContext): Promise<ChangedFile[]> { export async function getPRFiles(pr: PRContext): Promise<ChangedFile[]> {
console.log(
`Fetching PR #${pr.prNumber} changed files for ${pr.owner}/${pr.repo}`,
);
const res = await client.rest.repository.repoGetPullRequestFiles({ const res = await client.rest.repository.repoGetPullRequestFiles({
owner: pr.owner, owner: pr.owner,
repo: pr.repo, repo: pr.repo,
@@ -26,7 +35,13 @@ export async function getPRFiles(pr: PRContext): Promise<ChangedFile[]> {
return res.data; return res.data;
} }
export async function postReviewComment(pr: PRContext, body: string): Promise<Comment> { export async function postReviewComment(
pr: PRContext,
body: string,
): Promise<Comment> {
console.log(
`Posting review comment on PR #${pr.prNumber} for ${pr.owner}/${pr.repo}`,
);
const res = await client.rest.issue.issueCreateComment({ const res = await client.rest.issue.issueCreateComment({
owner: pr.owner, owner: pr.owner,
repo: pr.repo, repo: pr.repo,
@@ -42,6 +57,9 @@ export async function postInlineComment(
line: number, line: number,
body: string, body: string,
): Promise<PullReview> { ): Promise<PullReview> {
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({ const res = await client.rest.repository.repoCreatePullReview({
owner: pr.owner, owner: pr.owner,
repo: pr.repo, repo: pr.repo,
@@ -83,6 +101,7 @@ export async function removeReaction(
} }
export async function getPR(owner: string, repo: string, prNumber: number) { 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({ const res = await client.rest.repository.repoGetPullRequest({
owner, owner,
repo, repo,
@@ -91,7 +110,13 @@ export async function getPR(owner: string, repo: string, prNumber: number) {
return res.data; return res.data;
} }
export async function getFileContents(pr: PRContext, filePath: string): Promise<string> { export async function getFileContents(
pr: PRContext,
filePath: string,
): Promise<string> {
console.log(
`Fetching contents of ${filePath} at PR #${pr.prNumber} head for ${pr.owner}/${pr.repo}`,
);
const res = await client.rest.repository.repoGetContents({ const res = await client.rest.repository.repoGetContents({
owner: pr.owner, owner: pr.owner,
repo: pr.repo, repo: pr.repo,
@@ -104,7 +129,13 @@ export async function getFileContents(pr: PRContext, filePath: string): Promise<
return Buffer.from(entry.content, "base64").toString("utf8"); return Buffer.from(entry.content, "base64").toString("utf8");
} }
export async function listDir(pr: PRContext, dirPath: string): Promise<string[]> { export async function listDir(
pr: PRContext,
dirPath: string,
): Promise<string[]> {
console.log(
`Listing directory ${dirPath} at PR #${pr.prNumber} head for ${pr.owner}/${pr.repo}`,
);
const res = await client.rest.repository.repoGetContents({ const res = await client.rest.repository.repoGetContents({
owner: pr.owner, owner: pr.owner,
repo: pr.repo, repo: pr.repo,
@@ -117,7 +148,13 @@ export async function listDir(pr: PRContext, dirPath: string): Promise<string[]>
// Gitea REST API v1 has no code search endpoint — returns repo names matching the query. // 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. // mcp.ts should strongly prefer disk grep (find_symbol) when workspace is available.
export async function searchCode(pr: PRContext, query: string): Promise<string[]> { export async function searchCode(
pr: PRContext,
query: string,
): Promise<string[]> {
console.log(
`Searching code for "${query}" at PR #${pr.prNumber} head for ${pr.owner}/${pr.repo}`,
);
const res = await client.rest.repository.repoSearch({ const res = await client.rest.repository.repoSearch({
q: query, q: query,
limit: 20, limit: 20,
+49 -17
View File
@@ -2,7 +2,11 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { join, resolve, relative } from "node:path"; import { join, resolve, relative } from "node:path";
import type { Tool } from "ollama"; 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"; import type { PRContext } from "./types.ts";
const MAX_FILE_LINES = 200; const MAX_FILE_LINES = 200;
@@ -18,7 +22,10 @@ export const TOOLS: Tool[] = [
type: "object", type: "object",
required: ["path"], required: ["path"],
properties: { 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", type: "function",
function: { function: {
name: "find_symbol", 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: { parameters: {
type: "object", type: "object",
required: ["symbol"], required: ["symbol"],
properties: { 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) { if (this.workspace) {
const full = join(this.workspace, safe); const full = join(this.workspace, safe);
// Verify resolved path stays inside workspace // 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]"; if (!existsSync(full)) return "[file not found]";
const content = readFileSync(full, "utf8"); const content = readFileSync(full, "utf8");
const lines = content.split("\n"); const lines = content.split("\n");
@@ -136,10 +148,13 @@ export class ToolServer {
if (this.workspace) { if (this.workspace) {
const full = join(this.workspace, safe); 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]"; if (!existsSync(full)) return "[directory not found]";
const entries = readdirSync(full, { withFileTypes: true }); 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); const entries = await giteaListDir(this.pr, safe);
@@ -155,20 +170,32 @@ export class ToolServer {
const out = execFileSync( const out = execFileSync(
"grep", "grep",
[ [
"-r", "-n", "-r",
"--include=*.ts", "--include=*.tsx", "-n",
"--include=*.js", "--include=*.jsx", "--include=*.ts",
"--include=*.py", "--include=*.go", "--include=*.tsx",
"--include=*.rs", "--include=*.rb", "--include=*.js",
"--include=*.java", "--include=*.kt", "--include=*.jsx",
"--include=*.py",
"--include=*.go",
"--include=*.rs",
"--include=*.rb",
"--include=*.java",
"--include=*.kt",
symbol, symbol,
this.workspace, this.workspace,
], ],
{ encoding: "utf8", timeout: 5000, maxBuffer: 256 * 1024 }, { 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 // 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 { } catch {
return "[not found]"; return "[not found]";
} }
@@ -179,8 +206,13 @@ export class ToolServer {
} }
} }
export function createMcpServer(pr: PRContext, maxToolCalls: number): ToolServer { export function createMcpServer(
const ws = process.env.GITEA_WORKSPACE ?? process.env.GITHUB_WORKSPACE ?? null; 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; const workspace = ws && existsSync(ws) ? ws : null;
return new ToolServer(pr, workspace, maxToolCalls); return new ToolServer(pr, workspace, maxToolCalls);
} }
+36 -8
View File
@@ -1,6 +1,12 @@
import { Ollama } from "ollama"; import { Ollama } from "ollama";
import type { Message } 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"; import type { ToolServer } from "./mcp.ts";
const ollama = new Ollama({ host: process.env.OLLAMA_HOST }); 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.`; 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} return `${userPrompt}
File: ${chunk.filename} (${chunk.language}) File: ${chunk.filename} (${chunk.language})
@@ -38,7 +47,8 @@ function parseReviewResult(content: string): ReviewResult | null {
.trim(); .trim();
try { try {
const parsed = JSON.parse(stripped); 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; return parsed as ReviewResult;
} catch { } catch {
return null; return null;
@@ -52,6 +62,7 @@ export async function reviewChunk(
contextWindow: number, contextWindow: number,
mcpServer: ToolServer, mcpServer: ToolServer,
): Promise<ChunkReview> { ): Promise<ChunkReview> {
console.log(`Reviewing chunk from ${chunk.filename} with model ${model}...`);
mcpServer.resetCallCount(); mcpServer.resetCallCount();
const messages: Message[] = [ const messages: Message[] = [
@@ -69,6 +80,7 @@ export async function reviewChunk(
tools: forceConclusion ? undefined : mcpServer.tools, tools: forceConclusion ? undefined : mcpServer.tools,
format: "json", format: "json",
stream: false, stream: false,
keep_alive: -1,
options: { num_ctx: contextWindow, temperature: 0.2 }, options: { num_ctx: contextWindow, temperature: 0.2 },
}); });
@@ -76,21 +88,30 @@ export async function reviewChunk(
// Model called one or more tools // Model called one or more tools
if (msg.tool_calls && msg.tool_calls.length > 0) { 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) { for (const call of msg.tool_calls) {
const result = await mcpServer.execute( const result = await mcpServer.execute(
call.function.name, call.function.name,
call.function.arguments as Record<string, unknown>, call.function.arguments as Record<string, unknown>,
); );
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) { if (mcpServer.atLimit) {
forceConclusion = true; forceConclusion = true;
messages.push({ messages.push({
role: "user", 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; continue;
@@ -121,16 +142,23 @@ export async function reviewChunk(
// Second failure — return raw content as summary rather than crash // Second failure — return raw content as summary rather than crash
return { return {
chunk, 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 // 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 { export function aggregateFindings(results: ChunkReview[]): AggregatedReview {
console.log(`Aggregating findings from ${results.length} reviewed chunks...`);
const findings: Finding[] = results.flatMap(({ chunk, result }) => const findings: Finding[] = results.flatMap(({ chunk, result }) =>
result.issues.map((issue) => ({ ...issue, filename: chunk.filename })), result.issues.map((issue) => ({ ...issue, filename: chunk.filename })),
); );