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/src/web/server/routes.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import type { NewprConfig } from "../../types/config.ts";
|
|
2
|
-
import type { NewprOutput } from "../../types/output.ts";
|
|
2
|
+
import type { NewprOutput, ChatMessage, ChatToolCall, ChatSegment } from "../../types/output.ts";
|
|
3
3
|
import { DEFAULT_CONFIG } from "../../types/config.ts";
|
|
4
|
-
import { listSessions, loadSession } from "../../history/store.ts";
|
|
4
|
+
import { listSessions, loadSession, loadSinglePatch, savePatchesSidecar, loadCommentsSidecar, saveCommentsSidecar, loadChatSidecar, saveChatSidecar, loadPatchesSidecar, saveCartoonSidecar, loadCartoonSidecar } from "../../history/store.ts";
|
|
5
|
+
import type { DiffComment } from "../../types/output.ts";
|
|
6
|
+
import { fetchPrDiff } from "../../github/fetch-diff.ts";
|
|
7
|
+
import { fetchPrBody, fetchPrComments } from "../../github/fetch-pr.ts";
|
|
8
|
+
import { parseDiff } from "../../diff/parser.ts";
|
|
9
|
+
import { parsePrInput } from "../../github/parse-pr.ts";
|
|
5
10
|
import { writeStoredConfig, type StoredConfig } from "../../config/store.ts";
|
|
6
11
|
import { startAnalysis, getSession, cancelAnalysis, subscribe } from "./session-manager.ts";
|
|
7
12
|
import { generateCartoon } from "../../llm/cartoon.ts";
|
|
13
|
+
import { chatWithTools, type ChatTool, type ChatStreamEvent } from "../../llm/client.ts";
|
|
14
|
+
import { randomBytes } from "node:crypto";
|
|
8
15
|
|
|
9
16
|
function json(data: unknown, status = 200): Response {
|
|
10
17
|
return new Response(JSON.stringify(data), {
|
|
@@ -18,6 +25,156 @@ interface RouteOptions {
|
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
export function createRoutes(token: string, config: NewprConfig, options: RouteOptions = {}) {
|
|
28
|
+
const ghHeaders = {
|
|
29
|
+
Authorization: `token ${token}`,
|
|
30
|
+
Accept: "application/vnd.github.v3+json",
|
|
31
|
+
"User-Agent": "newpr-cli",
|
|
32
|
+
"Content-Type": "application/json",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
async function resolvePrUrl(sessionId: string): Promise<string | null> {
|
|
36
|
+
const stored = await loadSession(sessionId);
|
|
37
|
+
if (stored) return stored.meta.pr_url;
|
|
38
|
+
const live = getSession(sessionId);
|
|
39
|
+
if (live?.result?.meta?.pr_url) return live.result.meta.pr_url;
|
|
40
|
+
if (live?.historyId) {
|
|
41
|
+
const hist = await loadSession(live.historyId);
|
|
42
|
+
if (hist) return hist.meta.pr_url;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function fetchHeadSha(pr: { owner: string; repo: string; number: number }): Promise<string | null> {
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch(`https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}`, { headers: ghHeaders });
|
|
50
|
+
if (!res.ok) return null;
|
|
51
|
+
const data = await res.json() as { head?: { sha?: string } };
|
|
52
|
+
return data.head?.sha ?? null;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function fetchCurrentUser(): Promise<{ login: string; avatar_url?: string }> {
|
|
59
|
+
try {
|
|
60
|
+
const res = await fetch("https://api.github.com/user", { headers: ghHeaders });
|
|
61
|
+
if (res.ok) {
|
|
62
|
+
const user = await res.json() as Record<string, unknown>;
|
|
63
|
+
return { login: user.login as string, avatar_url: user.avatar_url as string | undefined };
|
|
64
|
+
}
|
|
65
|
+
} catch {}
|
|
66
|
+
return { login: "anonymous" };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function buildChatSystemPrompt(data: NewprOutput): string {
|
|
70
|
+
const fileSummaries = data.files
|
|
71
|
+
.map((f) => `- ${f.path} (${f.status}, +${f.additions}/-${f.deletions}): ${f.summary}`)
|
|
72
|
+
.join("\n");
|
|
73
|
+
const groupSummaries = data.groups
|
|
74
|
+
.map((g) => `- [${g.type}] ${g.name}: ${g.description}\n Files: ${g.files.join(", ")}`)
|
|
75
|
+
.join("\n");
|
|
76
|
+
|
|
77
|
+
return `You are an expert code reviewer assistant for a Pull Request analysis tool called "newpr".
|
|
78
|
+
You have access to the full analysis of PR #${data.meta.pr_number} "${data.meta.pr_title}" in ${data.meta.pr_url}.
|
|
79
|
+
|
|
80
|
+
## Analysis Context
|
|
81
|
+
|
|
82
|
+
**Author**: ${data.meta.author}
|
|
83
|
+
**Branches**: ${data.meta.head_branch} → ${data.meta.base_branch}
|
|
84
|
+
**Stats**: ${data.meta.total_files_changed} files, +${data.meta.total_additions} -${data.meta.total_deletions}
|
|
85
|
+
**Risk**: ${data.summary.risk_level}
|
|
86
|
+
|
|
87
|
+
**Purpose**: ${data.summary.purpose}
|
|
88
|
+
**Scope**: ${data.summary.scope}
|
|
89
|
+
**Impact**: ${data.summary.impact}
|
|
90
|
+
|
|
91
|
+
## File Changes
|
|
92
|
+
${fileSummaries}
|
|
93
|
+
|
|
94
|
+
## Change Groups
|
|
95
|
+
${groupSummaries}
|
|
96
|
+
|
|
97
|
+
## Narrative
|
|
98
|
+
${data.narrative}
|
|
99
|
+
|
|
100
|
+
${data.meta.pr_body ? `## PR Description\n${data.meta.pr_body}` : ""}
|
|
101
|
+
|
|
102
|
+
## Anchor Syntax (CRITICAL)
|
|
103
|
+
You MUST use these anchors whenever you mention a file or group. They become clickable links in the UI.
|
|
104
|
+
- File: [[file:exact/path/here.ts]] — renders as a clickable chip that opens the file diff in the sidebar.
|
|
105
|
+
- Group: [[group:Exact Group Name]] — renders as a clickable chip that opens the group detail in the sidebar.
|
|
106
|
+
- Use the EXACT paths and names from the lists above. Partial or invented names will not work.
|
|
107
|
+
- ALWAYS prefer anchors over plain text. Instead of "the auth module", write "the [[group:Auth Flow]] group". Instead of "in session.ts", write "in [[file:src/auth/session.ts]]".
|
|
108
|
+
- When listing multiple files, anchor each one: [[file:a.ts]], [[file:b.ts]], [[file:c.ts]].
|
|
109
|
+
- Even in short answers, use anchors. They are the primary way users navigate from your response to the code.
|
|
110
|
+
|
|
111
|
+
## Math / LaTeX
|
|
112
|
+
When expressing mathematical formulas, algorithms, or complexity analysis, use LaTeX syntax:
|
|
113
|
+
- Inline: $O(n \\log n)$, $\\sum_{i=1}^{n} x_i$
|
|
114
|
+
- Block:
|
|
115
|
+
$$
|
|
116
|
+
f(x) = \\int_{a}^{b} g(t) \\, dt
|
|
117
|
+
$$
|
|
118
|
+
|
|
119
|
+
## Instructions
|
|
120
|
+
- Answer questions about this PR thoroughly and precisely.
|
|
121
|
+
- Use your tools to fetch additional context when needed (file diffs, comments, reviews).
|
|
122
|
+
- When referencing code, include relevant snippets from the diff.
|
|
123
|
+
- Be concise but thorough. Use markdown formatting.
|
|
124
|
+
- If the user asks in Korean, respond in Korean. Match the user's language.`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function buildChatTools(): ChatTool[] {
|
|
128
|
+
return [
|
|
129
|
+
{
|
|
130
|
+
type: "function",
|
|
131
|
+
function: {
|
|
132
|
+
name: "get_file_diff",
|
|
133
|
+
description: "Get the full unified diff for a specific file in this PR. Use this to see exact code changes.",
|
|
134
|
+
parameters: {
|
|
135
|
+
type: "object",
|
|
136
|
+
properties: {
|
|
137
|
+
path: { type: "string", description: "File path (e.g. 'src/index.ts')" },
|
|
138
|
+
},
|
|
139
|
+
required: ["path"],
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
type: "function",
|
|
145
|
+
function: {
|
|
146
|
+
name: "list_files",
|
|
147
|
+
description: "List all changed files in this PR with their status, line counts, and summaries.",
|
|
148
|
+
parameters: { type: "object", properties: {} },
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
type: "function",
|
|
153
|
+
function: {
|
|
154
|
+
name: "get_pr_comments",
|
|
155
|
+
description: "Get all issue comments (discussion) on this PR from GitHub.",
|
|
156
|
+
parameters: { type: "object", properties: {} },
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
type: "function",
|
|
161
|
+
function: {
|
|
162
|
+
name: "get_review_comments",
|
|
163
|
+
description: "Get all inline review comments on specific lines of code in this PR from GitHub.",
|
|
164
|
+
parameters: { type: "object", properties: {} },
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
type: "function",
|
|
169
|
+
function: {
|
|
170
|
+
name: "get_pr_details",
|
|
171
|
+
description: "Get PR metadata from GitHub: state, mergeable status, labels, requested reviewers, etc.",
|
|
172
|
+
parameters: { type: "object", properties: {} },
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
];
|
|
176
|
+
}
|
|
177
|
+
|
|
21
178
|
return {
|
|
22
179
|
"POST /api/analysis": async (req: Request) => {
|
|
23
180
|
const body = await req.json() as { pr: string };
|
|
@@ -45,6 +202,7 @@ export function createRoutes(token: string, config: NewprConfig, options: RouteO
|
|
|
45
202
|
finishedAt: session.finishedAt,
|
|
46
203
|
error: session.error,
|
|
47
204
|
result: session.result,
|
|
205
|
+
historyId: session.historyId,
|
|
48
206
|
});
|
|
49
207
|
},
|
|
50
208
|
|
|
@@ -121,6 +279,132 @@ export function createRoutes(token: string, config: NewprConfig, options: RouteO
|
|
|
121
279
|
return json(data);
|
|
122
280
|
},
|
|
123
281
|
|
|
282
|
+
"GET /api/sessions/:id/diff": async (req: Request) => {
|
|
283
|
+
const url = new URL(req.url);
|
|
284
|
+
const segments = url.pathname.split("/");
|
|
285
|
+
const id = segments[segments.length - 2]!;
|
|
286
|
+
const filePath = url.searchParams.get("path");
|
|
287
|
+
if (!filePath) return json({ error: "Missing 'path' query parameter" }, 400);
|
|
288
|
+
|
|
289
|
+
const patch = await loadSinglePatch(id, filePath);
|
|
290
|
+
if (patch) return json({ patch, path: filePath });
|
|
291
|
+
|
|
292
|
+
let prUrl: string | null = null;
|
|
293
|
+
let storeId = id;
|
|
294
|
+
|
|
295
|
+
const storedSession = await loadSession(id);
|
|
296
|
+
if (storedSession) {
|
|
297
|
+
prUrl = storedSession.meta.pr_url;
|
|
298
|
+
} else {
|
|
299
|
+
const liveSession = getSession(id);
|
|
300
|
+
if (liveSession?.result?.meta?.pr_url) {
|
|
301
|
+
prUrl = liveSession.result.meta.pr_url;
|
|
302
|
+
if (liveSession.historyId) storeId = liveSession.historyId;
|
|
303
|
+
} else if (liveSession?.historyId) {
|
|
304
|
+
const histPatch = await loadSinglePatch(liveSession.historyId, filePath);
|
|
305
|
+
if (histPatch) return json({ patch: histPatch, path: filePath });
|
|
306
|
+
|
|
307
|
+
const histSession = await loadSession(liveSession.historyId);
|
|
308
|
+
if (histSession) {
|
|
309
|
+
prUrl = histSession.meta.pr_url;
|
|
310
|
+
storeId = liveSession.historyId;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (!prUrl) return json({ error: "Session not found" }, 404);
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
const pr = parsePrInput(prUrl);
|
|
319
|
+
const rawDiff = await fetchPrDiff(pr, token);
|
|
320
|
+
const parsed = parseDiff(rawDiff);
|
|
321
|
+
|
|
322
|
+
const allPatches: Record<string, string> = {};
|
|
323
|
+
for (const file of parsed.files) {
|
|
324
|
+
allPatches[file.path] = file.raw;
|
|
325
|
+
}
|
|
326
|
+
await savePatchesSidecar(storeId, allPatches).catch(() => {});
|
|
327
|
+
|
|
328
|
+
const backfilledPatch = allPatches[filePath];
|
|
329
|
+
if (!backfilledPatch) return json({ error: "File not found in diff" }, 404);
|
|
330
|
+
return json({ patch: backfilledPatch, path: filePath });
|
|
331
|
+
} catch (err) {
|
|
332
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
333
|
+
return json({ error: `Failed to fetch diff: ${msg}` }, 500);
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
|
|
337
|
+
"GET /api/sessions/:id/discussion": async (req: Request) => {
|
|
338
|
+
const url = new URL(req.url);
|
|
339
|
+
const segments = url.pathname.split("/");
|
|
340
|
+
const id = segments[segments.length - 2]!;
|
|
341
|
+
|
|
342
|
+
let prUrl: string | null = null;
|
|
343
|
+
let body: string | null = null;
|
|
344
|
+
|
|
345
|
+
const storedSession = await loadSession(id);
|
|
346
|
+
if (storedSession) {
|
|
347
|
+
prUrl = storedSession.meta.pr_url;
|
|
348
|
+
body = storedSession.meta.pr_body ?? null;
|
|
349
|
+
} else {
|
|
350
|
+
const liveSession = getSession(id);
|
|
351
|
+
if (liveSession?.result?.meta?.pr_url) {
|
|
352
|
+
prUrl = liveSession.result.meta.pr_url;
|
|
353
|
+
body = liveSession.result.meta.pr_body ?? null;
|
|
354
|
+
} else if (liveSession?.historyId) {
|
|
355
|
+
const histSession = await loadSession(liveSession.historyId);
|
|
356
|
+
if (histSession) {
|
|
357
|
+
prUrl = histSession.meta.pr_url;
|
|
358
|
+
body = histSession.meta.pr_body ?? null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (!prUrl) return json({ error: "Session not found" }, 404);
|
|
364
|
+
|
|
365
|
+
try {
|
|
366
|
+
const pr = parsePrInput(prUrl);
|
|
367
|
+
if (body === null) {
|
|
368
|
+
body = await fetchPrBody(pr, token);
|
|
369
|
+
}
|
|
370
|
+
const comments = await fetchPrComments(pr, token);
|
|
371
|
+
return json({ body, comments });
|
|
372
|
+
} catch (err) {
|
|
373
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
374
|
+
return json({ error: `Failed to fetch discussion: ${msg}` }, 500);
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
|
|
378
|
+
"GET /api/proxy": async (req: Request) => {
|
|
379
|
+
const url = new URL(req.url);
|
|
380
|
+
const target = url.searchParams.get("url");
|
|
381
|
+
if (!target) return json({ error: "Missing 'url' query parameter" }, 400);
|
|
382
|
+
|
|
383
|
+
const allowed = target.startsWith("https://github.com/") || target.startsWith("https://user-images.githubusercontent.com/");
|
|
384
|
+
if (!allowed) return json({ error: "URL not allowed" }, 403);
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
const res = await fetch(target, {
|
|
388
|
+
headers: {
|
|
389
|
+
"User-Agent": "newpr-cli",
|
|
390
|
+
Authorization: `token ${token}`,
|
|
391
|
+
},
|
|
392
|
+
redirect: "follow",
|
|
393
|
+
});
|
|
394
|
+
if (!res.ok) return new Response(null, { status: res.status });
|
|
395
|
+
|
|
396
|
+
const contentType = res.headers.get("content-type") ?? "application/octet-stream";
|
|
397
|
+
return new Response(res.body, {
|
|
398
|
+
headers: {
|
|
399
|
+
"Content-Type": contentType,
|
|
400
|
+
"Cache-Control": "public, max-age=86400, immutable",
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
} catch {
|
|
404
|
+
return new Response(null, { status: 502 });
|
|
405
|
+
}
|
|
406
|
+
},
|
|
407
|
+
|
|
124
408
|
"GET /api/me": async () => {
|
|
125
409
|
try {
|
|
126
410
|
const res = await fetch("https://api.github.com/user", {
|
|
@@ -209,6 +493,431 @@ export function createRoutes(token: string, config: NewprConfig, options: RouteO
|
|
|
209
493
|
return json({ cartoon: !!options.cartoon });
|
|
210
494
|
},
|
|
211
495
|
|
|
496
|
+
"GET /api/sessions/:id/comments": async (req: Request) => {
|
|
497
|
+
const url = new URL(req.url);
|
|
498
|
+
const segments = url.pathname.split("/");
|
|
499
|
+
const id = segments[3]!;
|
|
500
|
+
const filePath = url.searchParams.get("path");
|
|
501
|
+
|
|
502
|
+
const comments = await loadCommentsSidecar(id) ?? [];
|
|
503
|
+
const filtered = filePath ? comments.filter((c) => c.filePath === filePath) : comments;
|
|
504
|
+
return json(filtered);
|
|
505
|
+
},
|
|
506
|
+
|
|
507
|
+
"POST /api/sessions/:id/comments": async (req: Request) => {
|
|
508
|
+
const url = new URL(req.url);
|
|
509
|
+
const segments = url.pathname.split("/");
|
|
510
|
+
const sessionId = segments[3]!;
|
|
511
|
+
|
|
512
|
+
const body = await req.json() as { filePath?: string; line?: number; startLine?: number; side?: string; body?: string };
|
|
513
|
+
if (!body.filePath || body.line == null || !body.side || !body.body?.trim()) {
|
|
514
|
+
return json({ error: "Missing required fields" }, 400);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const user = await fetchCurrentUser();
|
|
518
|
+
const prUrl = await resolvePrUrl(sessionId);
|
|
519
|
+
|
|
520
|
+
let githubCommentId: number | undefined;
|
|
521
|
+
let githubCommentUrl: string | undefined;
|
|
522
|
+
if (prUrl) {
|
|
523
|
+
try {
|
|
524
|
+
const pr = parsePrInput(prUrl);
|
|
525
|
+
const sha = await fetchHeadSha(pr);
|
|
526
|
+
if (sha) {
|
|
527
|
+
const ghSide = body.side === "old" ? "LEFT" : "RIGHT";
|
|
528
|
+
const ghBody: Record<string, unknown> = {
|
|
529
|
+
commit_id: sha,
|
|
530
|
+
path: body.filePath,
|
|
531
|
+
line: body.line,
|
|
532
|
+
side: ghSide,
|
|
533
|
+
body: body.body.trim(),
|
|
534
|
+
};
|
|
535
|
+
if (body.startLine != null && body.startLine !== body.line) {
|
|
536
|
+
ghBody.start_line = body.startLine;
|
|
537
|
+
ghBody.start_side = ghSide;
|
|
538
|
+
}
|
|
539
|
+
const res = await fetch(
|
|
540
|
+
`https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}/comments`,
|
|
541
|
+
{
|
|
542
|
+
method: "POST",
|
|
543
|
+
headers: ghHeaders,
|
|
544
|
+
body: JSON.stringify(ghBody),
|
|
545
|
+
},
|
|
546
|
+
);
|
|
547
|
+
if (res.ok) {
|
|
548
|
+
const data = await res.json() as { id?: number; html_url?: string };
|
|
549
|
+
githubCommentId = data.id;
|
|
550
|
+
githubCommentUrl = data.html_url;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
} catch {}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const hasRange = body.startLine != null && body.startLine !== body.line;
|
|
557
|
+
const comment: DiffComment = {
|
|
558
|
+
id: randomBytes(8).toString("hex"),
|
|
559
|
+
sessionId,
|
|
560
|
+
filePath: body.filePath,
|
|
561
|
+
line: body.line,
|
|
562
|
+
...(hasRange ? { startLine: body.startLine } : {}),
|
|
563
|
+
side: body.side as "old" | "new",
|
|
564
|
+
body: body.body.trim(),
|
|
565
|
+
author: user.login,
|
|
566
|
+
authorAvatar: user.avatar_url,
|
|
567
|
+
createdAt: new Date().toISOString(),
|
|
568
|
+
githubUrl: githubCommentUrl,
|
|
569
|
+
githubCommentId,
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
const existing = await loadCommentsSidecar(sessionId) ?? [];
|
|
573
|
+
existing.push(comment);
|
|
574
|
+
await saveCommentsSidecar(sessionId, existing);
|
|
575
|
+
|
|
576
|
+
return json(comment, 201);
|
|
577
|
+
},
|
|
578
|
+
|
|
579
|
+
"PATCH /api/sessions/:id/comments/:commentId": async (req: Request) => {
|
|
580
|
+
const url = new URL(req.url);
|
|
581
|
+
const segments = url.pathname.split("/");
|
|
582
|
+
const sessionId = segments[3]!;
|
|
583
|
+
const commentId = segments[5]!;
|
|
584
|
+
|
|
585
|
+
const body = await req.json() as { body?: string };
|
|
586
|
+
if (!body.body?.trim()) return json({ error: "Missing body" }, 400);
|
|
587
|
+
|
|
588
|
+
const existing = await loadCommentsSidecar(sessionId) ?? [];
|
|
589
|
+
const comment = existing.find((c) => c.id === commentId);
|
|
590
|
+
if (!comment) return json({ error: "Comment not found" }, 404);
|
|
591
|
+
|
|
592
|
+
comment.body = body.body.trim();
|
|
593
|
+
|
|
594
|
+
if (comment.githubCommentId) {
|
|
595
|
+
const prUrl = await resolvePrUrl(sessionId);
|
|
596
|
+
if (prUrl) {
|
|
597
|
+
try {
|
|
598
|
+
const pr = parsePrInput(prUrl);
|
|
599
|
+
await fetch(
|
|
600
|
+
`https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/comments/${comment.githubCommentId}`,
|
|
601
|
+
{
|
|
602
|
+
method: "PATCH",
|
|
603
|
+
headers: ghHeaders,
|
|
604
|
+
body: JSON.stringify({ body: comment.body }),
|
|
605
|
+
},
|
|
606
|
+
);
|
|
607
|
+
} catch {}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
await saveCommentsSidecar(sessionId, existing);
|
|
612
|
+
return json(comment);
|
|
613
|
+
},
|
|
614
|
+
|
|
615
|
+
"DELETE /api/sessions/:id/comments/:commentId": async (req: Request) => {
|
|
616
|
+
const url = new URL(req.url);
|
|
617
|
+
const segments = url.pathname.split("/");
|
|
618
|
+
const sessionId = segments[3]!;
|
|
619
|
+
const commentId = segments[5]!;
|
|
620
|
+
|
|
621
|
+
const existing = await loadCommentsSidecar(sessionId) ?? [];
|
|
622
|
+
const idx = existing.findIndex((c) => c.id === commentId);
|
|
623
|
+
if (idx === -1) return json({ error: "Comment not found" }, 404);
|
|
624
|
+
|
|
625
|
+
const removed = existing[idx]!;
|
|
626
|
+
if (removed.githubCommentId) {
|
|
627
|
+
const prUrl = await resolvePrUrl(sessionId);
|
|
628
|
+
if (prUrl) {
|
|
629
|
+
try {
|
|
630
|
+
const pr = parsePrInput(prUrl);
|
|
631
|
+
await fetch(
|
|
632
|
+
`https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/comments/${removed.githubCommentId}`,
|
|
633
|
+
{ method: "DELETE", headers: ghHeaders },
|
|
634
|
+
);
|
|
635
|
+
} catch {}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
existing.splice(idx, 1);
|
|
640
|
+
await saveCommentsSidecar(sessionId, existing);
|
|
641
|
+
|
|
642
|
+
return json({ ok: true });
|
|
643
|
+
},
|
|
644
|
+
|
|
645
|
+
"GET /api/sessions/:id/chat": async (req: Request) => {
|
|
646
|
+
const url = new URL(req.url);
|
|
647
|
+
const segments = url.pathname.split("/");
|
|
648
|
+
const id = segments[3]!;
|
|
649
|
+
const messages = await loadChatSidecar(id) ?? [];
|
|
650
|
+
return json(messages);
|
|
651
|
+
},
|
|
652
|
+
|
|
653
|
+
"POST /api/sessions/:id/chat/undo": async (req: Request) => {
|
|
654
|
+
const url = new URL(req.url);
|
|
655
|
+
const segments = url.pathname.split("/");
|
|
656
|
+
const sessionId = segments[3]!;
|
|
657
|
+
const chatHistory = await loadChatSidecar(sessionId) ?? [];
|
|
658
|
+
if (chatHistory.length === 0) return json({ ok: true, removed: 0 });
|
|
659
|
+
const lastAssistantIdx = chatHistory.findLastIndex((m) => m.role === "assistant");
|
|
660
|
+
if (lastAssistantIdx === -1) return json({ ok: true, removed: 0 });
|
|
661
|
+
const lastUserIdx = chatHistory.slice(0, lastAssistantIdx).findLastIndex((m) => m.role === "user");
|
|
662
|
+
const removeFrom = lastUserIdx >= 0 ? lastUserIdx : lastAssistantIdx;
|
|
663
|
+
const removed = chatHistory.length - removeFrom;
|
|
664
|
+
const updated = chatHistory.slice(0, removeFrom);
|
|
665
|
+
await saveChatSidecar(sessionId, updated);
|
|
666
|
+
return json({ ok: true, removed });
|
|
667
|
+
},
|
|
668
|
+
|
|
669
|
+
"POST /api/sessions/:id/chat": async (req: Request) => {
|
|
670
|
+
const url = new URL(req.url);
|
|
671
|
+
const segments = url.pathname.split("/");
|
|
672
|
+
const sessionId = segments[3]!;
|
|
673
|
+
|
|
674
|
+
if (!config.openrouter_api_key) {
|
|
675
|
+
return json({ error: "OpenRouter API key required for chat" }, 400);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const body = await req.json() as { message: string };
|
|
679
|
+
if (!body.message?.trim()) return json({ error: "Missing message" }, 400);
|
|
680
|
+
|
|
681
|
+
const sessionData = await loadSession(sessionId);
|
|
682
|
+
if (!sessionData) return json({ error: "Session not found" }, 404);
|
|
683
|
+
|
|
684
|
+
const chatHistory = await loadChatSidecar(sessionId) ?? [];
|
|
685
|
+
|
|
686
|
+
const userMsg: ChatMessage = {
|
|
687
|
+
role: "user",
|
|
688
|
+
content: body.message.trim(),
|
|
689
|
+
timestamp: new Date().toISOString(),
|
|
690
|
+
};
|
|
691
|
+
chatHistory.push(userMsg);
|
|
692
|
+
await saveChatSidecar(sessionId, chatHistory);
|
|
693
|
+
|
|
694
|
+
const systemPrompt = buildChatSystemPrompt(sessionData);
|
|
695
|
+
|
|
696
|
+
const apiMessages: Array<{ role: string; content?: string | null; tool_calls?: unknown[]; tool_call_id?: string }> = [
|
|
697
|
+
{ role: "system", content: systemPrompt },
|
|
698
|
+
];
|
|
699
|
+
for (const msg of chatHistory) {
|
|
700
|
+
if (msg.role === "user") {
|
|
701
|
+
apiMessages.push({ role: "user", content: msg.content });
|
|
702
|
+
} else if (msg.role === "assistant") {
|
|
703
|
+
if (msg.toolCalls && msg.toolCalls.length > 0) {
|
|
704
|
+
apiMessages.push({
|
|
705
|
+
role: "assistant",
|
|
706
|
+
content: msg.content || null,
|
|
707
|
+
tool_calls: msg.toolCalls.map((tc) => ({
|
|
708
|
+
id: tc.id,
|
|
709
|
+
type: "function",
|
|
710
|
+
function: { name: tc.name, arguments: JSON.stringify(tc.arguments) },
|
|
711
|
+
})),
|
|
712
|
+
});
|
|
713
|
+
for (const tc of msg.toolCalls) {
|
|
714
|
+
if (tc.result !== undefined) {
|
|
715
|
+
apiMessages.push({
|
|
716
|
+
role: "tool",
|
|
717
|
+
content: tc.result,
|
|
718
|
+
tool_call_id: tc.id,
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
} else {
|
|
723
|
+
apiMessages.push({ role: "assistant", content: msg.content });
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const chatTools = buildChatTools();
|
|
729
|
+
|
|
730
|
+
const patches = await loadPatchesSidecar(sessionId);
|
|
731
|
+
|
|
732
|
+
const executeTool = async (name: string, args: Record<string, unknown>): Promise<string> => {
|
|
733
|
+
switch (name) {
|
|
734
|
+
case "get_file_diff": {
|
|
735
|
+
const filePath = args.path as string;
|
|
736
|
+
if (!filePath) return "Error: path argument required";
|
|
737
|
+
if (patches?.[filePath]) return patches[filePath];
|
|
738
|
+
const patch = await loadSinglePatch(sessionId, filePath);
|
|
739
|
+
if (patch) return patch;
|
|
740
|
+
try {
|
|
741
|
+
const pr = parsePrInput(sessionData.meta.pr_url);
|
|
742
|
+
const rawDiff = await fetchPrDiff(pr, token);
|
|
743
|
+
const parsed = parseDiff(rawDiff);
|
|
744
|
+
const file = parsed.files.find((f) => f.path === filePath);
|
|
745
|
+
return file?.raw ?? `File "${filePath}" not found in diff`;
|
|
746
|
+
} catch (err) {
|
|
747
|
+
return `Error fetching diff: ${err instanceof Error ? err.message : String(err)}`;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
case "list_files": {
|
|
751
|
+
return sessionData.files
|
|
752
|
+
.map((f) => `${f.path} (${f.status}, +${f.additions}/-${f.deletions}): ${f.summary}`)
|
|
753
|
+
.join("\n");
|
|
754
|
+
}
|
|
755
|
+
case "get_pr_comments": {
|
|
756
|
+
try {
|
|
757
|
+
const pr = parsePrInput(sessionData.meta.pr_url);
|
|
758
|
+
const comments = await fetchPrComments(pr, token);
|
|
759
|
+
if (comments.length === 0) return "No comments on this PR.";
|
|
760
|
+
return comments.map((c) => `@${c.author} (${c.created_at}):\n${c.body}`).join("\n\n---\n\n");
|
|
761
|
+
} catch (err) {
|
|
762
|
+
return `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
case "get_review_comments": {
|
|
766
|
+
try {
|
|
767
|
+
const pr = parsePrInput(sessionData.meta.pr_url);
|
|
768
|
+
const res = await fetch(
|
|
769
|
+
`https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}/comments?per_page=100`,
|
|
770
|
+
{ headers: ghHeaders },
|
|
771
|
+
);
|
|
772
|
+
if (!res.ok) return `GitHub API error: ${res.status}`;
|
|
773
|
+
const reviews = await res.json() as Array<{ user?: { login?: string }; path?: string; body?: string; created_at?: string; line?: number }>;
|
|
774
|
+
if (reviews.length === 0) return "No review comments on this PR.";
|
|
775
|
+
return reviews.map((r) =>
|
|
776
|
+
`@${r.user?.login ?? "unknown"} on ${r.path ?? "?"}${r.line ? `:${r.line}` : ""} (${r.created_at}):\n${r.body ?? ""}`,
|
|
777
|
+
).join("\n\n---\n\n");
|
|
778
|
+
} catch (err) {
|
|
779
|
+
return `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
case "get_pr_details": {
|
|
783
|
+
try {
|
|
784
|
+
const pr = parsePrInput(sessionData.meta.pr_url);
|
|
785
|
+
const res = await fetch(
|
|
786
|
+
`https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}`,
|
|
787
|
+
{ headers: ghHeaders },
|
|
788
|
+
);
|
|
789
|
+
if (!res.ok) return `GitHub API error: ${res.status}`;
|
|
790
|
+
const data = await res.json() as Record<string, unknown>;
|
|
791
|
+
return JSON.stringify({
|
|
792
|
+
title: data.title,
|
|
793
|
+
body: data.body,
|
|
794
|
+
state: data.state,
|
|
795
|
+
merged: data.merged,
|
|
796
|
+
mergeable: data.mergeable,
|
|
797
|
+
additions: data.additions,
|
|
798
|
+
deletions: data.deletions,
|
|
799
|
+
changed_files: data.changed_files,
|
|
800
|
+
labels: (data.labels as Array<{ name: string }>)?.map((l) => l.name),
|
|
801
|
+
requested_reviewers: (data.requested_reviewers as Array<{ login: string }>)?.map((r) => r.login),
|
|
802
|
+
}, null, 2);
|
|
803
|
+
} catch (err) {
|
|
804
|
+
return `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
default:
|
|
808
|
+
return `Unknown tool: ${name}`;
|
|
809
|
+
}
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
const encoder = new TextEncoder();
|
|
813
|
+
const stream = new ReadableStream({
|
|
814
|
+
async start(controller) {
|
|
815
|
+
const send = (eventType: string, data: string) => {
|
|
816
|
+
controller.enqueue(encoder.encode(`event: ${eventType}\ndata: ${data}\n\n`));
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
let fullText = "";
|
|
820
|
+
const collectedToolCalls: ChatToolCall[] = [];
|
|
821
|
+
const orderedSegments: ChatSegment[] = [];
|
|
822
|
+
let lastSegmentWasText = false;
|
|
823
|
+
|
|
824
|
+
try {
|
|
825
|
+
await chatWithTools(
|
|
826
|
+
{
|
|
827
|
+
api_key: config.openrouter_api_key,
|
|
828
|
+
model: config.model,
|
|
829
|
+
timeout: config.timeout,
|
|
830
|
+
},
|
|
831
|
+
apiMessages as Parameters<typeof chatWithTools>[1],
|
|
832
|
+
chatTools,
|
|
833
|
+
executeTool,
|
|
834
|
+
(event: ChatStreamEvent) => {
|
|
835
|
+
switch (event.type) {
|
|
836
|
+
case "text":
|
|
837
|
+
fullText += event.content ?? "";
|
|
838
|
+
if (lastSegmentWasText && orderedSegments.length > 0) {
|
|
839
|
+
const last = orderedSegments[orderedSegments.length - 1]!;
|
|
840
|
+
if (last.type === "text") {
|
|
841
|
+
last.content += event.content ?? "";
|
|
842
|
+
}
|
|
843
|
+
} else {
|
|
844
|
+
orderedSegments.push({ type: "text", content: event.content ?? "" });
|
|
845
|
+
lastSegmentWasText = true;
|
|
846
|
+
}
|
|
847
|
+
send("text", JSON.stringify({ content: event.content }));
|
|
848
|
+
break;
|
|
849
|
+
case "tool_call":
|
|
850
|
+
if (event.toolCall) {
|
|
851
|
+
let args: Record<string, unknown> = {};
|
|
852
|
+
try { args = JSON.parse(event.toolCall.arguments); } catch {}
|
|
853
|
+
const tc: ChatToolCall = {
|
|
854
|
+
id: event.toolCall.id,
|
|
855
|
+
name: event.toolCall.name,
|
|
856
|
+
arguments: args,
|
|
857
|
+
};
|
|
858
|
+
collectedToolCalls.push(tc);
|
|
859
|
+
orderedSegments.push({ type: "tool_call", toolCall: tc });
|
|
860
|
+
lastSegmentWasText = false;
|
|
861
|
+
send("tool_call", JSON.stringify({
|
|
862
|
+
id: event.toolCall.id,
|
|
863
|
+
name: event.toolCall.name,
|
|
864
|
+
arguments: args,
|
|
865
|
+
}));
|
|
866
|
+
}
|
|
867
|
+
break;
|
|
868
|
+
case "tool_result":
|
|
869
|
+
if (event.toolResult) {
|
|
870
|
+
const tc = collectedToolCalls.find((c) => c.id === event.toolResult!.id);
|
|
871
|
+
if (tc) tc.result = event.toolResult.result;
|
|
872
|
+
send("tool_result", JSON.stringify(event.toolResult));
|
|
873
|
+
}
|
|
874
|
+
break;
|
|
875
|
+
case "error":
|
|
876
|
+
send("chat_error", JSON.stringify({ message: event.error }));
|
|
877
|
+
break;
|
|
878
|
+
case "done":
|
|
879
|
+
break;
|
|
880
|
+
}
|
|
881
|
+
},
|
|
882
|
+
);
|
|
883
|
+
|
|
884
|
+
const assistantMsg: ChatMessage = {
|
|
885
|
+
role: "assistant",
|
|
886
|
+
content: fullText,
|
|
887
|
+
toolCalls: collectedToolCalls.length > 0 ? collectedToolCalls : undefined,
|
|
888
|
+
segments: orderedSegments.length > 0 ? orderedSegments : undefined,
|
|
889
|
+
timestamp: new Date().toISOString(),
|
|
890
|
+
};
|
|
891
|
+
chatHistory.push(assistantMsg);
|
|
892
|
+
await saveChatSidecar(sessionId, chatHistory);
|
|
893
|
+
|
|
894
|
+
send("done", JSON.stringify({}));
|
|
895
|
+
} catch (err) {
|
|
896
|
+
send("chat_error", JSON.stringify({ message: err instanceof Error ? err.message : String(err) }));
|
|
897
|
+
} finally {
|
|
898
|
+
controller.close();
|
|
899
|
+
}
|
|
900
|
+
},
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
return new Response(stream, {
|
|
904
|
+
headers: {
|
|
905
|
+
"Content-Type": "text/event-stream",
|
|
906
|
+
"Cache-Control": "no-cache",
|
|
907
|
+
"Connection": "keep-alive",
|
|
908
|
+
},
|
|
909
|
+
});
|
|
910
|
+
},
|
|
911
|
+
|
|
912
|
+
"GET /api/sessions/:id/cartoon": async (req: Request) => {
|
|
913
|
+
const url = new URL(req.url);
|
|
914
|
+
const segments = url.pathname.split("/");
|
|
915
|
+
const id = segments[3]!;
|
|
916
|
+
const cartoon = await loadCartoonSidecar(id);
|
|
917
|
+
if (!cartoon) return json(null);
|
|
918
|
+
return json(cartoon);
|
|
919
|
+
},
|
|
920
|
+
|
|
212
921
|
"POST /api/cartoon": async (req: Request) => {
|
|
213
922
|
if (!options.cartoon) return json({ error: "Cartoon mode not enabled. Start with --cartoon flag." }, 403);
|
|
214
923
|
if (!config.openrouter_api_key) return json({ error: "OpenRouter API key required for cartoon generation" }, 400);
|
|
@@ -226,18 +935,11 @@ export function createRoutes(token: string, config: NewprConfig, options: RouteO
|
|
|
226
935
|
const result = await generateCartoon(config.openrouter_api_key, data, config.language);
|
|
227
936
|
|
|
228
937
|
if (sessionId) {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
generatedAt: new Date().toISOString(),
|
|
235
|
-
};
|
|
236
|
-
const { join } = await import("node:path");
|
|
237
|
-
const { homedir } = await import("node:os");
|
|
238
|
-
const sessionsDir = join(homedir(), ".newpr", "history", "sessions");
|
|
239
|
-
await Bun.write(join(sessionsDir, `${sessionId}.json`), JSON.stringify(sessionData, null, 2));
|
|
240
|
-
}
|
|
938
|
+
await saveCartoonSidecar(sessionId, {
|
|
939
|
+
imageBase64: result.imageBase64,
|
|
940
|
+
mimeType: result.mimeType,
|
|
941
|
+
generatedAt: new Date().toISOString(),
|
|
942
|
+
});
|
|
241
943
|
}
|
|
242
944
|
|
|
243
945
|
return json(result);
|