newpr 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +11 -1
- package/src/analyzer/pipeline.ts +22 -5
- package/src/github/fetch-pr.ts +43 -1
- package/src/history/store.ts +106 -1
- package/src/llm/client.ts +197 -0
- package/src/llm/prompts.ts +33 -8
- package/src/tui/Shell.tsx +7 -2
- package/src/types/github.ts +11 -0
- package/src/types/output.ts +44 -0
- package/src/web/client/App.tsx +29 -3
- package/src/web/client/components/AppShell.tsx +94 -47
- package/src/web/client/components/ChatSection.tsx +427 -0
- package/src/web/client/components/DetailPane.tsx +163 -75
- package/src/web/client/components/DiffViewer.tsx +679 -0
- package/src/web/client/components/InputScreen.tsx +110 -26
- package/src/web/client/components/Markdown.tsx +169 -43
- package/src/web/client/components/ResultsScreen.tsx +66 -71
- package/src/web/client/components/TipTapEditor.tsx +405 -0
- package/src/web/client/hooks/useAnalysis.ts +8 -1
- package/src/web/client/lib/shiki.ts +63 -0
- package/src/web/client/panels/CartoonPanel.tsx +94 -37
- package/src/web/client/panels/DiscussionPanel.tsx +158 -0
- package/src/web/client/panels/FilesPanel.tsx +435 -54
- package/src/web/client/panels/GroupsPanel.tsx +49 -40
- package/src/web/client/panels/StoryPanel.tsx +42 -22
- package/src/web/components/ui/tabs.tsx +3 -3
- package/src/web/server/routes.ts +716 -14
- package/src/web/server/session-manager.ts +11 -2
- package/src/web/server.ts +33 -0
- package/src/web/styles/built.css +1 -1
- package/src/web/styles/globals.css +117 -1
- package/src/web/client/panels/NarrativePanel.tsx +0 -9
- package/src/web/client/panels/SummaryPanel.tsx +0 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "newpr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "AI-powered large PR review tool - understand PRs with 1000+ lines of changes",
|
|
5
5
|
"module": "src/cli/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -61,16 +61,26 @@
|
|
|
61
61
|
"@radix-ui/react-slot": "^1.2.4",
|
|
62
62
|
"@radix-ui/react-tabs": "^1.1.13",
|
|
63
63
|
"@tailwindcss/cli": "^4.1.18",
|
|
64
|
+
"@tiptap/extension-mention": "^3.19.0",
|
|
65
|
+
"@tiptap/extension-placeholder": "^3.19.0",
|
|
66
|
+
"@tiptap/pm": "^3.19.0",
|
|
67
|
+
"@tiptap/react": "^3.19.0",
|
|
68
|
+
"@tiptap/starter-kit": "^3.19.0",
|
|
64
69
|
"class-variance-authority": "^0.7.1",
|
|
65
70
|
"clsx": "^2.1.1",
|
|
66
71
|
"ink": "6.6.0",
|
|
67
72
|
"ink-spinner": "5.0.0",
|
|
68
73
|
"ink-text-input": "6.0.0",
|
|
74
|
+
"katex": "^0.16.28",
|
|
69
75
|
"lucide-react": "^0.567.0",
|
|
70
76
|
"react": "19.1.0",
|
|
71
77
|
"react-dom": "19.1.0",
|
|
72
78
|
"react-markdown": "^10.1.0",
|
|
79
|
+
"rehype-katex": "^7.0.1",
|
|
80
|
+
"rehype-raw": "^7.0.0",
|
|
73
81
|
"remark-gfm": "^4.0.1",
|
|
82
|
+
"remark-math": "^6.0.0",
|
|
83
|
+
"shiki": "^3.22.0",
|
|
74
84
|
"tailwind-merge": "^3.4.1",
|
|
75
85
|
"tailwindcss": "^4.1.18",
|
|
76
86
|
"tailwindcss-animate": "^1.0.7"
|
package/src/analyzer/pipeline.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { ExplorationResult } from "../workspace/types.ts";
|
|
|
6
6
|
import type { AgentToolName } from "../workspace/types.ts";
|
|
7
7
|
import { parseDiff } from "../diff/parser.ts";
|
|
8
8
|
import { chunkDiff } from "../diff/chunker.ts";
|
|
9
|
-
import { fetchPrData } from "../github/fetch-pr.ts";
|
|
9
|
+
import { fetchPrData, fetchPrComments } from "../github/fetch-pr.ts";
|
|
10
10
|
import { fetchPrDiff } from "../github/fetch-diff.ts";
|
|
11
11
|
import { createLlmClient, type LlmClient, type LlmResponse } from "../llm/client.ts";
|
|
12
12
|
import {
|
|
@@ -50,6 +50,7 @@ interface PipelineOptions {
|
|
|
50
50
|
token: string;
|
|
51
51
|
config: NewprConfig;
|
|
52
52
|
onProgress?: ProgressCallback;
|
|
53
|
+
onFilePatches?: (patches: Record<string, string>) => void;
|
|
53
54
|
noClone?: boolean;
|
|
54
55
|
preferredAgent?: AgentToolName;
|
|
55
56
|
}
|
|
@@ -158,12 +159,13 @@ export async function analyzePr(options: PipelineOptions): Promise<NewprOutput>
|
|
|
158
159
|
timeout: config.timeout,
|
|
159
160
|
});
|
|
160
161
|
|
|
161
|
-
progress({ stage: "fetching", message: "Fetching PR data and
|
|
162
|
-
const [prData, rawDiff] = await Promise.all([
|
|
162
|
+
progress({ stage: "fetching", message: "Fetching PR data, diff, and discussion..." });
|
|
163
|
+
const [prData, rawDiff, prComments] = await Promise.all([
|
|
163
164
|
fetchPrData(pr, token),
|
|
164
165
|
fetchPrDiff(pr, token),
|
|
166
|
+
fetchPrComments(pr, token).catch(() => []),
|
|
165
167
|
]);
|
|
166
|
-
progress({ stage: "fetching", message: `#${prData.number} "${prData.title}" by ${prData.author} · +${prData.additions} −${prData.deletions}` });
|
|
168
|
+
progress({ stage: "fetching", message: `#${prData.number} "${prData.title}" by ${prData.author} · +${prData.additions} −${prData.deletions} · ${prComments.length} comments` });
|
|
167
169
|
|
|
168
170
|
progress({ stage: "parsing", message: "Parsing diff..." });
|
|
169
171
|
const parsed = parseDiff(rawDiff);
|
|
@@ -175,6 +177,15 @@ export async function analyzePr(options: PipelineOptions): Promise<NewprOutput>
|
|
|
175
177
|
const totalDel = chunks.reduce((s, c) => s + c.deletions, 0);
|
|
176
178
|
progress({ stage: "parsing", message: `${chunks.length} files · +${totalAdd} −${totalDel}${wasTruncated ? ` (${allChunks.length - config.max_files} skipped)` : ""}` });
|
|
177
179
|
|
|
180
|
+
const changedFilesSet = new Set(changedFiles);
|
|
181
|
+
const filePatches: Record<string, string> = {};
|
|
182
|
+
for (const fileDiff of parsed.files) {
|
|
183
|
+
if (changedFilesSet.has(fileDiff.path)) {
|
|
184
|
+
filePatches[fileDiff.path] = fileDiff.raw;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
options.onFilePatches?.(filePatches);
|
|
188
|
+
|
|
178
189
|
let exploration: ExplorationResult | null = null;
|
|
179
190
|
if (!noClone) {
|
|
180
191
|
exploration = await tryExploreCodebase(
|
|
@@ -183,7 +194,12 @@ export async function analyzePr(options: PipelineOptions): Promise<NewprOutput>
|
|
|
183
194
|
);
|
|
184
195
|
}
|
|
185
196
|
|
|
186
|
-
const promptCtx: PromptContext = {
|
|
197
|
+
const promptCtx: PromptContext = {
|
|
198
|
+
commits: prData.commits,
|
|
199
|
+
language: config.language,
|
|
200
|
+
prBody: prData.body,
|
|
201
|
+
discussion: prComments.map((c) => ({ author: c.author, body: c.body })),
|
|
202
|
+
};
|
|
187
203
|
const enrichedTag = exploration ? " + codebase context" : "";
|
|
188
204
|
|
|
189
205
|
progress({
|
|
@@ -279,6 +295,7 @@ export async function analyzePr(options: PipelineOptions): Promise<NewprOutput>
|
|
|
279
295
|
meta: {
|
|
280
296
|
pr_number: prData.number,
|
|
281
297
|
pr_title: prData.title,
|
|
298
|
+
pr_body: prData.body || undefined,
|
|
282
299
|
pr_url: prData.url,
|
|
283
300
|
base_branch: prData.base_branch,
|
|
284
301
|
head_branch: prData.head_branch,
|
package/src/github/fetch-pr.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { GithubPrData, PrCommit, PrIdentifier } from "../types/github.ts";
|
|
1
|
+
import type { GithubPrData, PrComment, PrCommit, PrIdentifier } from "../types/github.ts";
|
|
2
2
|
|
|
3
3
|
export function mapPrResponse(json: Record<string, unknown>): Omit<GithubPrData, "commits"> {
|
|
4
4
|
const user = json.user as Record<string, unknown> | undefined;
|
|
@@ -8,6 +8,7 @@ export function mapPrResponse(json: Record<string, unknown>): Omit<GithubPrData,
|
|
|
8
8
|
return {
|
|
9
9
|
number: json.number as number,
|
|
10
10
|
title: json.title as string,
|
|
11
|
+
body: (json.body as string) ?? "",
|
|
11
12
|
url: json.html_url as string,
|
|
12
13
|
base_branch: (base?.ref as string) ?? "unknown",
|
|
13
14
|
head_branch: (head?.ref as string) ?? "unknown",
|
|
@@ -88,3 +89,44 @@ export async function fetchPrData(pr: PrIdentifier, token: string): Promise<Gith
|
|
|
88
89
|
|
|
89
90
|
return { ...base, commits };
|
|
90
91
|
}
|
|
92
|
+
|
|
93
|
+
interface GithubCommentResponse {
|
|
94
|
+
id: number;
|
|
95
|
+
user: { login: string; avatar_url?: string } | null;
|
|
96
|
+
body: string;
|
|
97
|
+
created_at: string;
|
|
98
|
+
updated_at: string;
|
|
99
|
+
html_url: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function fetchPrComments(pr: PrIdentifier, token: string): Promise<PrComment[]> {
|
|
103
|
+
const allComments: GithubCommentResponse[] = [];
|
|
104
|
+
let page = 1;
|
|
105
|
+
|
|
106
|
+
while (true) {
|
|
107
|
+
const url = `https://api.github.com/repos/${pr.owner}/${pr.repo}/issues/${pr.number}/comments?per_page=100&page=${page}`;
|
|
108
|
+
const response = await githubGet(url, token);
|
|
109
|
+
const items = (await response.json()) as GithubCommentResponse[];
|
|
110
|
+
if (items.length === 0) break;
|
|
111
|
+
allComments.push(...items);
|
|
112
|
+
if (items.length < 100) break;
|
|
113
|
+
page++;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return allComments.map((c) => ({
|
|
117
|
+
id: c.id,
|
|
118
|
+
author: c.user?.login ?? "unknown",
|
|
119
|
+
author_avatar: c.user?.avatar_url ?? undefined,
|
|
120
|
+
body: c.body,
|
|
121
|
+
created_at: c.created_at,
|
|
122
|
+
updated_at: c.updated_at,
|
|
123
|
+
html_url: c.html_url,
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function fetchPrBody(pr: PrIdentifier, token: string): Promise<string> {
|
|
128
|
+
const url = `https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}`;
|
|
129
|
+
const response = await githubGet(url, token);
|
|
130
|
+
const json = (await response.json()) as Record<string, unknown>;
|
|
131
|
+
return (json.body as string) ?? "";
|
|
132
|
+
}
|
package/src/history/store.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { mkdirSync, rmSync, existsSync } from "node:fs";
|
|
4
4
|
import { randomBytes } from "node:crypto";
|
|
5
|
-
import type { NewprOutput } from "../types/output.ts";
|
|
5
|
+
import type { NewprOutput, DiffComment, ChatMessage, CartoonImage } from "../types/output.ts";
|
|
6
6
|
import type { SessionRecord } from "./types.ts";
|
|
7
7
|
|
|
8
8
|
const HISTORY_DIR = join(homedir(), ".newpr", "history");
|
|
@@ -91,6 +91,111 @@ export async function clearHistory(): Promise<void> {
|
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
export async function savePatchesSidecar(
|
|
95
|
+
id: string,
|
|
96
|
+
patches: Record<string, string>,
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
ensureDirs();
|
|
99
|
+
await Bun.write(
|
|
100
|
+
join(SESSIONS_DIR, `${id}.patches.json`),
|
|
101
|
+
JSON.stringify(patches),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function loadPatchesSidecar(
|
|
106
|
+
id: string,
|
|
107
|
+
): Promise<Record<string, string> | null> {
|
|
108
|
+
try {
|
|
109
|
+
const filePath = join(SESSIONS_DIR, `${id}.patches.json`);
|
|
110
|
+
const file = Bun.file(filePath);
|
|
111
|
+
if (!(await file.exists())) return null;
|
|
112
|
+
return JSON.parse(await file.text()) as Record<string, string>;
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function loadSinglePatch(
|
|
119
|
+
id: string,
|
|
120
|
+
filePath: string,
|
|
121
|
+
): Promise<string | null> {
|
|
122
|
+
const patches = await loadPatchesSidecar(id);
|
|
123
|
+
if (!patches) return null;
|
|
124
|
+
return patches[filePath] ?? null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function saveCommentsSidecar(
|
|
128
|
+
id: string,
|
|
129
|
+
comments: DiffComment[],
|
|
130
|
+
): Promise<void> {
|
|
131
|
+
ensureDirs();
|
|
132
|
+
await Bun.write(
|
|
133
|
+
join(SESSIONS_DIR, `${id}.comments.json`),
|
|
134
|
+
JSON.stringify(comments, null, 2),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function loadCommentsSidecar(
|
|
139
|
+
id: string,
|
|
140
|
+
): Promise<DiffComment[] | null> {
|
|
141
|
+
try {
|
|
142
|
+
const filePath = join(SESSIONS_DIR, `${id}.comments.json`);
|
|
143
|
+
const file = Bun.file(filePath);
|
|
144
|
+
if (!(await file.exists())) return null;
|
|
145
|
+
return JSON.parse(await file.text()) as DiffComment[];
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function saveChatSidecar(
|
|
152
|
+
id: string,
|
|
153
|
+
messages: ChatMessage[],
|
|
154
|
+
): Promise<void> {
|
|
155
|
+
ensureDirs();
|
|
156
|
+
await Bun.write(
|
|
157
|
+
join(SESSIONS_DIR, `${id}.chat.json`),
|
|
158
|
+
JSON.stringify(messages, null, 2),
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function loadChatSidecar(
|
|
163
|
+
id: string,
|
|
164
|
+
): Promise<ChatMessage[] | null> {
|
|
165
|
+
try {
|
|
166
|
+
const filePath = join(SESSIONS_DIR, `${id}.chat.json`);
|
|
167
|
+
const file = Bun.file(filePath);
|
|
168
|
+
if (!(await file.exists())) return null;
|
|
169
|
+
return JSON.parse(await file.text()) as ChatMessage[];
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function saveCartoonSidecar(
|
|
176
|
+
id: string,
|
|
177
|
+
cartoon: CartoonImage,
|
|
178
|
+
): Promise<void> {
|
|
179
|
+
ensureDirs();
|
|
180
|
+
await Bun.write(
|
|
181
|
+
join(SESSIONS_DIR, `${id}.cartoon.json`),
|
|
182
|
+
JSON.stringify(cartoon),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function loadCartoonSidecar(
|
|
187
|
+
id: string,
|
|
188
|
+
): Promise<CartoonImage | null> {
|
|
189
|
+
try {
|
|
190
|
+
const filePath = join(SESSIONS_DIR, `${id}.cartoon.json`);
|
|
191
|
+
const file = Bun.file(filePath);
|
|
192
|
+
if (!(await file.exists())) return null;
|
|
193
|
+
return JSON.parse(await file.text()) as CartoonImage;
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
94
199
|
export function getHistoryPath(): string {
|
|
95
200
|
return HISTORY_DIR;
|
|
96
201
|
}
|
package/src/llm/client.ts
CHANGED
|
@@ -238,3 +238,200 @@ export function createLlmClient(options: LlmClientOptions): LlmClient {
|
|
|
238
238
|
const { createClaudeCodeClient: create } = require("./claude-code-client.ts");
|
|
239
239
|
return create(options.timeout);
|
|
240
240
|
}
|
|
241
|
+
|
|
242
|
+
export interface ChatTool {
|
|
243
|
+
type: "function";
|
|
244
|
+
function: {
|
|
245
|
+
name: string;
|
|
246
|
+
description: string;
|
|
247
|
+
parameters: Record<string, unknown>;
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export interface ChatToolCallDelta {
|
|
252
|
+
id: string;
|
|
253
|
+
name: string;
|
|
254
|
+
arguments: string;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export interface ChatStreamEvent {
|
|
258
|
+
type: "text" | "tool_call" | "tool_result" | "done" | "error";
|
|
259
|
+
content?: string;
|
|
260
|
+
toolCall?: ChatToolCallDelta;
|
|
261
|
+
toolResult?: { id: string; result: string };
|
|
262
|
+
error?: string;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export type ChatStreamCallback = (event: ChatStreamEvent) => void;
|
|
266
|
+
|
|
267
|
+
interface OpenRouterChatMessage {
|
|
268
|
+
role: "system" | "user" | "assistant" | "tool";
|
|
269
|
+
content?: string | null;
|
|
270
|
+
tool_calls?: Array<{
|
|
271
|
+
id: string;
|
|
272
|
+
type: "function";
|
|
273
|
+
function: { name: string; arguments: string };
|
|
274
|
+
}>;
|
|
275
|
+
tool_call_id?: string;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
interface OpenRouterStreamToolCallDelta {
|
|
279
|
+
index?: number;
|
|
280
|
+
id?: string;
|
|
281
|
+
type?: string;
|
|
282
|
+
function?: { name?: string; arguments?: string };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
interface OpenRouterStreamChunkWithTools {
|
|
286
|
+
choices: Array<{
|
|
287
|
+
delta: {
|
|
288
|
+
content?: string;
|
|
289
|
+
tool_calls?: OpenRouterStreamToolCallDelta[];
|
|
290
|
+
};
|
|
291
|
+
finish_reason?: string | null;
|
|
292
|
+
}>;
|
|
293
|
+
model?: string;
|
|
294
|
+
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export async function chatWithTools(
|
|
298
|
+
options: LlmClientOptions,
|
|
299
|
+
messages: OpenRouterChatMessage[],
|
|
300
|
+
tools: ChatTool[],
|
|
301
|
+
executeTool: (name: string, args: Record<string, unknown>) => Promise<string>,
|
|
302
|
+
onEvent: ChatStreamCallback,
|
|
303
|
+
): Promise<void> {
|
|
304
|
+
const MAX_TOOL_ROUNDS = 10;
|
|
305
|
+
|
|
306
|
+
let currentMessages = [...messages];
|
|
307
|
+
|
|
308
|
+
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
|
309
|
+
const controller = new AbortController();
|
|
310
|
+
const timeoutId = setTimeout(() => controller.abort(), options.timeout * 1000);
|
|
311
|
+
|
|
312
|
+
let response: Response;
|
|
313
|
+
try {
|
|
314
|
+
response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
|
315
|
+
method: "POST",
|
|
316
|
+
signal: controller.signal,
|
|
317
|
+
headers: {
|
|
318
|
+
Authorization: `Bearer ${options.api_key}`,
|
|
319
|
+
"Content-Type": "application/json",
|
|
320
|
+
"HTTP-Referer": "https://github.com/sionic/newpr",
|
|
321
|
+
"X-Title": "newpr",
|
|
322
|
+
},
|
|
323
|
+
body: JSON.stringify({
|
|
324
|
+
model: options.model,
|
|
325
|
+
messages: currentMessages,
|
|
326
|
+
tools: tools.length > 0 ? tools : undefined,
|
|
327
|
+
temperature: 0.3,
|
|
328
|
+
stream: true,
|
|
329
|
+
}),
|
|
330
|
+
});
|
|
331
|
+
} catch (err) {
|
|
332
|
+
clearTimeout(timeoutId);
|
|
333
|
+
onEvent({ type: "error", error: err instanceof Error ? err.message : String(err) });
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
clearTimeout(timeoutId);
|
|
338
|
+
|
|
339
|
+
if (!response.ok) {
|
|
340
|
+
const body = await response.text();
|
|
341
|
+
onEvent({ type: "error", error: `OpenRouter API error ${response.status}: ${body}` });
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const reader = response.body!.getReader();
|
|
346
|
+
const decoder = new TextDecoder();
|
|
347
|
+
let buffer = "";
|
|
348
|
+
let textContent = "";
|
|
349
|
+
const toolCalls = new Map<number, { id: string; name: string; arguments: string }>();
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
while (true) {
|
|
353
|
+
const { done, value } = await reader.read();
|
|
354
|
+
if (done) break;
|
|
355
|
+
|
|
356
|
+
buffer += decoder.decode(value, { stream: true });
|
|
357
|
+
const lines = buffer.split("\n");
|
|
358
|
+
buffer = lines.pop() ?? "";
|
|
359
|
+
|
|
360
|
+
for (const line of lines) {
|
|
361
|
+
const trimmed = line.trim();
|
|
362
|
+
if (!trimmed || trimmed === "data: [DONE]") continue;
|
|
363
|
+
if (!trimmed.startsWith("data: ")) continue;
|
|
364
|
+
|
|
365
|
+
try {
|
|
366
|
+
const chunk = JSON.parse(trimmed.slice(6)) as OpenRouterStreamChunkWithTools;
|
|
367
|
+
const delta = chunk.choices[0]?.delta;
|
|
368
|
+
if (!delta) continue;
|
|
369
|
+
|
|
370
|
+
if (delta.content) {
|
|
371
|
+
textContent += delta.content;
|
|
372
|
+
onEvent({ type: "text", content: delta.content });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (delta.tool_calls) {
|
|
376
|
+
for (const tc of delta.tool_calls) {
|
|
377
|
+
const idx = tc.index ?? 0;
|
|
378
|
+
if (!toolCalls.has(idx)) {
|
|
379
|
+
toolCalls.set(idx, { id: tc.id ?? "", name: "", arguments: "" });
|
|
380
|
+
}
|
|
381
|
+
const entry = toolCalls.get(idx)!;
|
|
382
|
+
if (tc.id) entry.id = tc.id;
|
|
383
|
+
if (tc.function?.name) entry.name += tc.function.name;
|
|
384
|
+
if (tc.function?.arguments) entry.arguments += tc.function.arguments;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
} catch {}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
} finally {
|
|
391
|
+
reader.releaseLock();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (toolCalls.size === 0) {
|
|
395
|
+
onEvent({ type: "done" });
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const assistantMsg: OpenRouterChatMessage = {
|
|
400
|
+
role: "assistant",
|
|
401
|
+
content: textContent || null,
|
|
402
|
+
tool_calls: [...toolCalls.values()].map((tc) => ({
|
|
403
|
+
id: tc.id,
|
|
404
|
+
type: "function" as const,
|
|
405
|
+
function: { name: tc.name, arguments: tc.arguments },
|
|
406
|
+
})),
|
|
407
|
+
};
|
|
408
|
+
currentMessages.push(assistantMsg);
|
|
409
|
+
|
|
410
|
+
for (const tc of toolCalls.values()) {
|
|
411
|
+
onEvent({
|
|
412
|
+
type: "tool_call",
|
|
413
|
+
toolCall: { id: tc.id, name: tc.name, arguments: tc.arguments },
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
let args: Record<string, unknown> = {};
|
|
417
|
+
try { args = JSON.parse(tc.arguments); } catch {}
|
|
418
|
+
|
|
419
|
+
let result: string;
|
|
420
|
+
try {
|
|
421
|
+
result = await executeTool(tc.name, args);
|
|
422
|
+
} catch (err) {
|
|
423
|
+
result = `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
onEvent({ type: "tool_result", toolResult: { id: tc.id, result } });
|
|
427
|
+
|
|
428
|
+
currentMessages.push({
|
|
429
|
+
role: "tool",
|
|
430
|
+
content: result,
|
|
431
|
+
tool_call_id: tc.id,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
onEvent({ type: "done" });
|
|
437
|
+
}
|
package/src/llm/prompts.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface FileSummaryInput {
|
|
|
17
17
|
export interface PromptContext {
|
|
18
18
|
commits?: PrCommit[];
|
|
19
19
|
language?: string;
|
|
20
|
+
prBody?: string;
|
|
21
|
+
discussion?: Array<{ author: string; body: string }>;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
function langDirective(lang?: string): string {
|
|
@@ -24,6 +26,21 @@ function langDirective(lang?: string): string {
|
|
|
24
26
|
return `\nCRITICAL LANGUAGE RULE: ALL text values in your response MUST be written in ${lang}. This includes every summary, description, name, purpose, scope, and impact field. JSON keys stay in English, but ALL string values MUST be in ${lang}. Do NOT use English for any descriptive text.`;
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
function formatDiscussion(ctx?: PromptContext): string {
|
|
30
|
+
const parts: string[] = [];
|
|
31
|
+
if (ctx?.prBody?.trim()) {
|
|
32
|
+
parts.push(`PR Description:\n${ctx.prBody.trim()}`);
|
|
33
|
+
}
|
|
34
|
+
if (ctx?.discussion && ctx.discussion.length > 0) {
|
|
35
|
+
const comments = ctx.discussion
|
|
36
|
+
.map((c) => `@${c.author}: ${c.body.length > 500 ? `${c.body.slice(0, 500)}…` : c.body}`)
|
|
37
|
+
.join("\n\n");
|
|
38
|
+
parts.push(`Discussion (${ctx.discussion.length} comments):\n${comments}`);
|
|
39
|
+
}
|
|
40
|
+
if (parts.length === 0) return "";
|
|
41
|
+
return `\n\n--- PR DISCUSSION ---\n${parts.join("\n\n")}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
27
44
|
function formatCommitHistory(commits: PrCommit[]): string {
|
|
28
45
|
if (commits.length === 0) return "";
|
|
29
46
|
const lines = commits.map((c) => {
|
|
@@ -44,13 +61,15 @@ export function buildFileSummaryPrompt(chunks: DiffChunk[], ctx?: PromptContext)
|
|
|
44
61
|
|
|
45
62
|
const commitCtx = ctx?.commits ? formatCommitHistory(ctx.commits) : "";
|
|
46
63
|
|
|
64
|
+
const discussionCtx = formatDiscussion(ctx);
|
|
65
|
+
|
|
47
66
|
return {
|
|
48
67
|
system: `You are an expert code reviewer. Analyze the given diff and provide a 1-line summary for each changed file.
|
|
49
|
-
Use the commit history to understand the intent behind each change — why the change was made, not just what changed.
|
|
68
|
+
Use the commit history and PR discussion to understand the intent behind each change — why the change was made, not just what changed.
|
|
50
69
|
Respond ONLY with a JSON array. Each element: {"path": "file/path", "summary": "one line description of what changed"}.
|
|
51
70
|
The "path" value must be the exact file path. The "summary" value is a human-readable description.
|
|
52
71
|
No markdown, no explanation, just the JSON array.${langDirective(ctx?.language)}`,
|
|
53
|
-
user: `${fileList}${commitCtx}`,
|
|
72
|
+
user: `${fileList}${commitCtx}${discussionCtx}`,
|
|
54
73
|
};
|
|
55
74
|
}
|
|
56
75
|
|
|
@@ -61,15 +80,17 @@ export function buildGroupingPrompt(fileSummaries: FileSummaryInput[], ctx?: Pro
|
|
|
61
80
|
|
|
62
81
|
const commitCtx = ctx?.commits ? formatCommitHistory(ctx.commits) : "";
|
|
63
82
|
|
|
83
|
+
const discussionCtx = formatDiscussion(ctx);
|
|
84
|
+
|
|
64
85
|
return {
|
|
65
86
|
system: `You are an expert code reviewer. Group the following changed files by their semantic purpose.
|
|
66
87
|
Each group should have a descriptive name, a type (one of: feature, refactor, bugfix, chore, docs, test, config), a description, and a list of file paths.
|
|
67
88
|
A file MAY appear in multiple groups if it serves multiple purposes (e.g., index.ts re-exporting for both a feature and a refactor).
|
|
68
|
-
Use the commit history to understand which changes belong together logically.
|
|
89
|
+
Use the commit history and PR discussion to understand which changes belong together logically.
|
|
69
90
|
Respond ONLY with a JSON array. Each element: {"name": "group name", "type": "feature|refactor|bugfix|chore|docs|test|config", "description": "what this group of changes does", "files": ["path1", "path2"]}.
|
|
70
91
|
The "name" and "description" values are human-readable text. The "type" value must be one of the English keywords listed above. File paths stay as-is.
|
|
71
92
|
Every file must appear in at least one group. No markdown, no explanation, just the JSON array.${langDirective(ctx?.language)}`,
|
|
72
|
-
user: `Changed files:\n${fileList}${commitCtx}`,
|
|
93
|
+
user: `Changed files:\n${fileList}${commitCtx}${discussionCtx}`,
|
|
73
94
|
};
|
|
74
95
|
}
|
|
75
96
|
|
|
@@ -86,13 +107,15 @@ export function buildOverallSummaryPrompt(
|
|
|
86
107
|
const fileList = fileSummaries.map((f) => `- ${f.path}: ${f.summary}`).join("\n");
|
|
87
108
|
const commitCtx = ctx?.commits ? formatCommitHistory(ctx.commits) : "";
|
|
88
109
|
|
|
110
|
+
const discussionCtx = formatDiscussion(ctx);
|
|
111
|
+
|
|
89
112
|
return {
|
|
90
113
|
system: `You are an expert code reviewer. Provide an overall summary of this Pull Request.
|
|
91
|
-
Use the commit history to understand the development progression and intent.
|
|
114
|
+
Use the commit history and PR discussion to understand the development progression and intent. The PR description and reviewer comments provide essential context about why changes were made.
|
|
92
115
|
Respond ONLY with a JSON object: {"purpose": "why this PR exists (1-2 sentences)", "scope": "what areas of code are affected", "impact": "what is the impact of these changes", "risk_level": "low|medium|high"}.
|
|
93
116
|
The "purpose", "scope", and "impact" values are human-readable text. The "risk_level" must be one of: low, medium, high (in English).
|
|
94
117
|
No markdown, no explanation, just the JSON object.${langDirective(ctx?.language)}`,
|
|
95
|
-
user: `PR Title: ${prTitle}\n\nChange Groups:\n${groupList}\n\nFile Summaries:\n${fileList}${commitCtx}`,
|
|
118
|
+
user: `PR Title: ${prTitle}\n\nChange Groups:\n${groupList}\n\nFile Summaries:\n${fileList}${commitCtx}${discussionCtx}`,
|
|
96
119
|
};
|
|
97
120
|
}
|
|
98
121
|
|
|
@@ -109,17 +132,19 @@ export function buildNarrativePrompt(
|
|
|
109
132
|
const commitCtx = ctx?.commits ? formatCommitHistory(ctx.commits) : "";
|
|
110
133
|
const lang = ctx?.language && ctx.language !== "English" ? ctx.language : null;
|
|
111
134
|
|
|
135
|
+
const discussionCtx = formatDiscussion(ctx);
|
|
136
|
+
|
|
112
137
|
return {
|
|
113
138
|
system: `You are an expert code reviewer writing a review walkthrough for other developers.
|
|
114
139
|
Write a clear, concise narrative that tells the "story" of this PR — what changes were made and in what logical order.
|
|
115
|
-
Use the commit history to understand the development progression: which changes came first, how the PR evolved, and the intent behind each step.
|
|
140
|
+
Use the commit history and PR discussion to understand the development progression: which changes came first, how the PR evolved, and the intent behind each step. The PR description often explains the author's motivation and approach.
|
|
116
141
|
Use markdown formatting. Write 2-5 paragraphs. Do NOT use JSON. Write natural prose.
|
|
117
142
|
${lang ? `CRITICAL: Write the ENTIRE narrative in ${lang}. Every sentence must be in ${lang}. Do NOT use English except for code identifiers, file paths, and [[group:...]]/[[file:...]] tokens.` : "If the PR title is in a non-English language, write the narrative in that same language."}
|
|
118
143
|
|
|
119
144
|
IMPORTANT: When referencing a change group, wrap it as [[group:Group Name]]. When referencing a specific file, wrap it as [[file:path/to/file.ts]].
|
|
120
145
|
Use the EXACT group names and file paths provided. Every group MUST be referenced at least once. Reference key files where relevant.
|
|
121
146
|
Example: "The [[group:Auth Flow]] group introduces session management via [[file:src/auth/session.ts]] and [[file:src/auth/token.ts]]."`,
|
|
122
|
-
user: `PR Title: ${prTitle}\n\nSummary:\n- Purpose: ${summary.purpose}\n- Scope: ${summary.scope}\n- Impact: ${summary.impact}\n- Risk: ${summary.risk_level}\n\nChange Groups:\n${groupDetails}${commitCtx}`,
|
|
147
|
+
user: `PR Title: ${prTitle}\n\nSummary:\n- Purpose: ${summary.purpose}\n- Scope: ${summary.scope}\n- Impact: ${summary.impact}\n- Risk: ${summary.risk_level}\n\nChange Groups:\n${groupDetails}${commitCtx}${discussionCtx}`,
|
|
123
148
|
};
|
|
124
149
|
}
|
|
125
150
|
|
package/src/tui/Shell.tsx
CHANGED
|
@@ -7,7 +7,7 @@ import type { SessionRecord } from "../history/types.ts";
|
|
|
7
7
|
import type { AgentToolName } from "../workspace/types.ts";
|
|
8
8
|
import { parsePrInput } from "../github/parse-pr.ts";
|
|
9
9
|
import { analyzePr } from "../analyzer/pipeline.ts";
|
|
10
|
-
import { saveSession, listSessions, loadSession } from "../history/store.ts";
|
|
10
|
+
import { saveSession, savePatchesSidecar, listSessions, loadSession } from "../history/store.ts";
|
|
11
11
|
import { detectAgents } from "../workspace/agent.ts";
|
|
12
12
|
import { App } from "./App.tsx";
|
|
13
13
|
import { InputBar } from "./InputBar.tsx";
|
|
@@ -94,10 +94,12 @@ export function Shell({ token, config: initialConfig, initialPr }: ShellProps) {
|
|
|
94
94
|
setState({ phase: "loading", steps: [], startTime });
|
|
95
95
|
setElapsed(0);
|
|
96
96
|
|
|
97
|
+
let capturedPatches: Record<string, string> = {};
|
|
97
98
|
const result = await analyzePr({
|
|
98
99
|
pr,
|
|
99
100
|
token,
|
|
100
101
|
config: liveConfig,
|
|
102
|
+
onFilePatches: (patches) => { capturedPatches = patches; },
|
|
101
103
|
onProgress: (event: ProgressEvent) => {
|
|
102
104
|
const stamped = { ...event, timestamp: event.timestamp ?? Date.now() };
|
|
103
105
|
const prev = eventsRef.current;
|
|
@@ -117,7 +119,10 @@ export function Shell({ token, config: initialConfig, initialPr }: ShellProps) {
|
|
|
117
119
|
},
|
|
118
120
|
});
|
|
119
121
|
|
|
120
|
-
await saveSession(result);
|
|
122
|
+
const record = await saveSession(result);
|
|
123
|
+
if (Object.keys(capturedPatches).length > 0) {
|
|
124
|
+
await savePatchesSidecar(record.id, capturedPatches).catch(() => {});
|
|
125
|
+
}
|
|
121
126
|
const updated = await listSessions(10);
|
|
122
127
|
setSessions(updated);
|
|
123
128
|
|
package/src/types/github.ts
CHANGED
|
@@ -12,9 +12,20 @@ export interface PrCommit {
|
|
|
12
12
|
files: string[];
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
export interface PrComment {
|
|
16
|
+
id: number;
|
|
17
|
+
author: string;
|
|
18
|
+
author_avatar?: string;
|
|
19
|
+
body: string;
|
|
20
|
+
created_at: string;
|
|
21
|
+
updated_at: string;
|
|
22
|
+
html_url: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
15
25
|
export interface GithubPrData {
|
|
16
26
|
number: number;
|
|
17
27
|
title: string;
|
|
28
|
+
body: string;
|
|
18
29
|
url: string;
|
|
19
30
|
base_branch: string;
|
|
20
31
|
head_branch: string;
|