idk
This commit is contained in:
+6
-1
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user