newpr 0.1.3 → 0.3.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.
Files changed (43) hide show
  1. package/package.json +11 -1
  2. package/src/analyzer/pipeline.ts +37 -15
  3. package/src/analyzer/progress.ts +2 -0
  4. package/src/cli/index.ts +7 -2
  5. package/src/cli/preflight.ts +126 -0
  6. package/src/github/fetch-pr.ts +53 -1
  7. package/src/history/store.ts +107 -1
  8. package/src/history/types.ts +1 -0
  9. package/src/llm/client.ts +197 -0
  10. package/src/llm/prompts.ts +80 -19
  11. package/src/llm/response-parser.ts +13 -1
  12. package/src/tui/Shell.tsx +7 -2
  13. package/src/types/github.ts +14 -0
  14. package/src/types/output.ts +50 -0
  15. package/src/web/client/App.tsx +33 -5
  16. package/src/web/client/components/AppShell.tsx +107 -47
  17. package/src/web/client/components/ChatSection.tsx +427 -0
  18. package/src/web/client/components/DetailPane.tsx +217 -77
  19. package/src/web/client/components/DiffViewer.tsx +713 -0
  20. package/src/web/client/components/InputScreen.tsx +178 -27
  21. package/src/web/client/components/LoadingTimeline.tsx +19 -6
  22. package/src/web/client/components/Markdown.tsx +220 -41
  23. package/src/web/client/components/ResultsScreen.tsx +109 -73
  24. package/src/web/client/components/ReviewModal.tsx +187 -0
  25. package/src/web/client/components/SettingsPanel.tsx +62 -86
  26. package/src/web/client/components/TipTapEditor.tsx +405 -0
  27. package/src/web/client/hooks/useAnalysis.ts +8 -1
  28. package/src/web/client/lib/shiki.ts +63 -0
  29. package/src/web/client/panels/CartoonPanel.tsx +94 -37
  30. package/src/web/client/panels/DiscussionPanel.tsx +158 -0
  31. package/src/web/client/panels/FilesPanel.tsx +435 -54
  32. package/src/web/client/panels/GroupsPanel.tsx +62 -40
  33. package/src/web/client/panels/StoryPanel.tsx +43 -23
  34. package/src/web/components/ui/tabs.tsx +3 -3
  35. package/src/web/server/routes.ts +856 -14
  36. package/src/web/server/session-manager.ts +11 -2
  37. package/src/web/server.ts +66 -4
  38. package/src/web/styles/built.css +1 -1
  39. package/src/web/styles/globals.css +117 -1
  40. package/src/workspace/agent.ts +22 -6
  41. package/src/workspace/explore.ts +41 -16
  42. package/src/web/client/panels/NarrativePanel.tsx +0 -9
  43. 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.1.3",
3
+ "version": "0.3.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"
@@ -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
  }
@@ -84,25 +85,25 @@ async function runExploration(
84
85
  ): Promise<ExplorationResult> {
85
86
  const agent = await requireAgent(preferredAgent);
86
87
 
87
- onProgress?.({ stage: "cloning", message: `${pr.owner}/${pr.repo}` });
88
+ onProgress?.({ stage: "cloning", message: `Cloning ${pr.owner}/${pr.repo}...` });
88
89
  const bareRepoPath = await ensureRepo(pr.owner, pr.repo, token, (msg) => {
89
- onProgress?.({ stage: "cloning", message: msg });
90
+ onProgress?.({ stage: "cloning", message: `📦 ${msg}` });
90
91
  });
91
- onProgress?.({ stage: "cloning", message: `${pr.owner}/${pr.repo} ready` });
92
+ onProgress?.({ stage: "cloning", message: `📦 ${pr.owner}/${pr.repo} cached` });
92
93
 
93
- onProgress?.({ stage: "checkout", message: `${baseBranch} ← PR #${pr.number}` });
94
+ onProgress?.({ stage: "checkout", message: `🌿 Preparing worktrees: ${baseBranch} ← PR #${pr.number}` });
94
95
  const worktrees = await createWorktrees(
95
96
  bareRepoPath, baseBranch, pr.number, pr.owner, pr.repo,
96
- (msg) => onProgress?.({ stage: "checkout", message: msg }),
97
+ (msg) => onProgress?.({ stage: "checkout", message: `🌿 ${msg}` }),
97
98
  );
98
- onProgress?.({ stage: "checkout", message: `${baseBranch} ← PR #${pr.number} worktrees ready` });
99
+ onProgress?.({ stage: "checkout", message: `🌿 Worktrees ready: ${baseBranch} ← PR #${pr.number}` });
99
100
 
100
- onProgress?.({ stage: "exploring", message: `${agent.name}: analyzing ${changedFiles.length} files...` });
101
+ onProgress?.({ stage: "exploring", message: `🤖 ${agent.name}: exploring ${changedFiles.length} changed files...` });
101
102
  const exploration = await exploreCodebase(
102
103
  agent, worktrees.headPath, changedFiles, prTitle, rawDiff,
103
104
  (msg, current, total) => onProgress?.({ stage: "exploring", message: msg, current, total }),
104
105
  );
105
- onProgress?.({ stage: "exploring", message: `${agent.name}: exploration complete` });
106
+ onProgress?.({ stage: "exploring", message: `🤖 ${agent.name}: exploration complete` });
106
107
 
107
108
  await cleanupWorktrees(bareRepoPath, pr.number, pr.owner, pr.repo).catch(() => {});
108
109
 
@@ -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 diff..." });
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`, pr_title: prData.title, pr_number: prData.number });
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 = { commits: prData.commits, language: config.language };
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({
@@ -243,9 +259,13 @@ export async function analyzePr(options: PipelineOptions): Promise<NewprOutput>
243
259
  progress({ stage: "summarizing", message: `${summary.risk_level} risk · ${summary.purpose.slice(0, 60)}` });
244
260
 
245
261
  progress({ stage: "narrating", message: `Writing narrative${enrichedTag}...` });
262
+ const fileDiffs = chunks.slice(0, 30).map((c) => ({
263
+ path: c.file_path,
264
+ diff: c.diff_content.length > 3000 ? `${c.diff_content.slice(0, 3000)}\n... (truncated)` : c.diff_content,
265
+ }));
246
266
  const narrativePrompt = exploration
247
- ? buildEnrichedNarrativePrompt(prData.title, summary, groups, exploration, promptCtx)
248
- : buildNarrativePrompt(prData.title, summary, groups, promptCtx);
267
+ ? buildEnrichedNarrativePrompt(prData.title, summary, groups, exploration, promptCtx, fileDiffs)
268
+ : buildNarrativePrompt(prData.title, summary, groups, promptCtx, fileDiffs);
249
269
  const narrativeResponse = await streamLlmCall(
250
270
  client, narrativePrompt.system, narrativePrompt.user, "narrating", "Writing narrative...", progress,
251
271
  );
@@ -279,7 +299,9 @@ export async function analyzePr(options: PipelineOptions): Promise<NewprOutput>
279
299
  meta: {
280
300
  pr_number: prData.number,
281
301
  pr_title: prData.title,
302
+ pr_body: prData.body || undefined,
282
303
  pr_url: prData.url,
304
+ pr_state: prData.state,
283
305
  base_branch: prData.base_branch,
284
306
  head_branch: prData.head_branch,
285
307
  author: prData.author,
@@ -17,6 +17,8 @@ export interface ProgressEvent {
17
17
  total?: number;
18
18
  partial_content?: string;
19
19
  timestamp?: number;
20
+ pr_title?: string;
21
+ pr_number?: number;
20
22
  }
21
23
 
22
24
  export type ProgressCallback = (event: ProgressEvent) => void;
package/src/cli/index.ts CHANGED
@@ -10,8 +10,9 @@ import { analyzePr } from "../analyzer/pipeline.ts";
10
10
  import { createStderrProgress, createSilentProgress, createStreamJsonProgress } from "../analyzer/progress.ts";
11
11
  import { renderLoading, renderShell } from "../tui/render.tsx";
12
12
  import { checkForUpdate, printUpdateNotice } from "./update-check.ts";
13
+ import { runPreflight, printPreflight } from "./preflight.ts";
13
14
 
14
- const VERSION = "0.1.3";
15
+ const VERSION = "0.3.0";
15
16
 
16
17
  async function main(): Promise<void> {
17
18
  const args = parseArgs(process.argv);
@@ -51,12 +52,14 @@ async function main(): Promise<void> {
51
52
 
52
53
  if (args.command === "web") {
53
54
  try {
55
+ const preflight = await runPreflight();
56
+ printPreflight(preflight);
54
57
  const config = await loadConfig({ model: args.model });
55
58
  const token = await getGithubToken();
56
59
  const updateInfo = await updatePromise;
57
60
  if (updateInfo) printUpdateNotice(updateInfo);
58
61
  const { startWebServer } = await import("../web/server.ts");
59
- await startWebServer({ port: args.port ?? 3000, token, config, cartoon: args.cartoon });
62
+ await startWebServer({ port: args.port ?? 3000, token, config, cartoon: args.cartoon, preflight });
60
63
  } catch (error) {
61
64
  const message = error instanceof Error ? error.message : String(error);
62
65
  process.stderr.write(`Error: ${message}\n`);
@@ -67,6 +70,8 @@ async function main(): Promise<void> {
67
70
 
68
71
  if (args.command === "shell") {
69
72
  try {
73
+ const preflight = await runPreflight();
74
+ printPreflight(preflight);
70
75
  const config = await loadConfig({ model: args.model });
71
76
  const token = await getGithubToken();
72
77
  const updateInfo = await updatePromise;
@@ -0,0 +1,126 @@
1
+ import type { AgentToolName } from "../workspace/types.ts";
2
+
3
+ export interface ToolStatus {
4
+ name: string;
5
+ installed: boolean;
6
+ version?: string;
7
+ detail?: string;
8
+ }
9
+
10
+ export interface PreflightResult {
11
+ github: ToolStatus & { authenticated: boolean; user?: string };
12
+ agents: ToolStatus[];
13
+ openrouterKey: boolean;
14
+ }
15
+
16
+ async function which(cmd: string): Promise<string | null> {
17
+ try {
18
+ const result = await Bun.$`which ${cmd}`.text();
19
+ return result.trim() || null;
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ async function getVersion(cmd: string, flag = "--version"): Promise<string | null> {
26
+ try {
27
+ const result = await Bun.$`${cmd} ${flag} 2>&1`.text();
28
+ const match = result.match(/[\d]+\.[\d]+[\d.]*/);
29
+ return match?.[0] ?? result.trim().slice(0, 30);
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ async function checkGithubCli(): Promise<PreflightResult["github"]> {
36
+ const path = await which("gh");
37
+ if (!path) {
38
+ return { name: "gh", installed: false, authenticated: false, detail: "brew install gh" };
39
+ }
40
+ const version = await getVersion("gh");
41
+ try {
42
+ const status = await Bun.$`gh auth status 2>&1`.text();
43
+ const userMatch = status.match(/Logged in to github\.com account (\S+)/i)
44
+ ?? status.match(/account (\S+)/i);
45
+ return {
46
+ name: "gh",
47
+ installed: true,
48
+ version: version ?? undefined,
49
+ authenticated: true,
50
+ user: userMatch?.[1]?.replace(/\s*\(.*/, ""),
51
+ };
52
+ } catch {
53
+ return { name: "gh", installed: true, version: version ?? undefined, authenticated: false, detail: "gh auth login" };
54
+ }
55
+ }
56
+
57
+ async function checkAgent(name: AgentToolName): Promise<ToolStatus> {
58
+ const path = await which(name);
59
+ if (!path) return { name, installed: false };
60
+ const version = await getVersion(name);
61
+ return { name, installed: true, version: version ?? undefined };
62
+ }
63
+
64
+ export async function runPreflight(): Promise<PreflightResult> {
65
+ const [github, claude, opencode, codex] = await Promise.all([
66
+ checkGithubCli(),
67
+ checkAgent("claude"),
68
+ checkAgent("opencode"),
69
+ checkAgent("codex"),
70
+ ]);
71
+
72
+ return {
73
+ github,
74
+ agents: [claude, opencode, codex],
75
+ openrouterKey: !!(process.env.OPENROUTER_API_KEY || await hasStoredApiKey()),
76
+ };
77
+ }
78
+
79
+ async function hasStoredApiKey(): Promise<boolean> {
80
+ try {
81
+ const { readStoredConfig } = await import("../config/store.ts");
82
+ const stored = await readStoredConfig();
83
+ return !!stored.openrouter_api_key;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ export function printPreflight(result: PreflightResult): void {
90
+ const check = "\x1b[32m✓\x1b[0m";
91
+ const cross = "\x1b[31m✗\x1b[0m";
92
+ const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
93
+ const bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
94
+
95
+ console.log("");
96
+ console.log(` ${bold("Preflight")}`);
97
+ console.log("");
98
+
99
+ const gh = result.github;
100
+ if (gh.installed && gh.authenticated) {
101
+ console.log(` ${check} gh ${dim(gh.version ?? "")} ${dim(`· ${gh.user ?? ""}`)}`);
102
+ } else if (gh.installed) {
103
+ console.log(` ${cross} gh ${dim(gh.version ?? "")} ${dim("· not authenticated")}`);
104
+ console.log(` ${dim(`run: ${gh.detail}`)}`);
105
+ } else {
106
+ console.log(` ${cross} gh ${dim("· not installed")}`);
107
+ console.log(` ${dim(`run: ${gh.detail}`)}`);
108
+ }
109
+
110
+ for (const agent of result.agents) {
111
+ if (agent.installed) {
112
+ console.log(` ${check} ${agent.name} ${dim(agent.version ?? "")}`);
113
+ } else {
114
+ console.log(` ${dim("·")} ${dim(agent.name)} ${dim("not found")}`);
115
+ }
116
+ }
117
+
118
+ if (result.openrouterKey) {
119
+ console.log(` ${check} OpenRouter API key`);
120
+ } else {
121
+ console.log(` ${cross} OpenRouter API key ${dim("· not configured")}`);
122
+ console.log(` ${dim("run: newpr auth")}`);
123
+ }
124
+
125
+ console.log("");
126
+ }
@@ -1,14 +1,25 @@
1
- import type { GithubPrData, PrCommit, PrIdentifier } from "../types/github.ts";
1
+ import type { GithubPrData, PrComment, PrCommit, PrIdentifier, PrState } 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;
5
5
  const base = json.base as Record<string, unknown> | undefined;
6
6
  const head = json.head as Record<string, unknown> | undefined;
7
7
 
8
+ let state: PrState = "open";
9
+ if (json.draft) {
10
+ state = "draft";
11
+ } else if (json.merged) {
12
+ state = "merged";
13
+ } else if (json.state === "closed") {
14
+ state = "closed";
15
+ }
16
+
8
17
  return {
9
18
  number: json.number as number,
10
19
  title: json.title as string,
20
+ body: (json.body as string) ?? "",
11
21
  url: json.html_url as string,
22
+ state,
12
23
  base_branch: (base?.ref as string) ?? "unknown",
13
24
  head_branch: (head?.ref as string) ?? "unknown",
14
25
  author: (user?.login as string) ?? "unknown",
@@ -88,3 +99,44 @@ export async function fetchPrData(pr: PrIdentifier, token: string): Promise<Gith
88
99
 
89
100
  return { ...base, commits };
90
101
  }
102
+
103
+ interface GithubCommentResponse {
104
+ id: number;
105
+ user: { login: string; avatar_url?: string } | null;
106
+ body: string;
107
+ created_at: string;
108
+ updated_at: string;
109
+ html_url: string;
110
+ }
111
+
112
+ export async function fetchPrComments(pr: PrIdentifier, token: string): Promise<PrComment[]> {
113
+ const allComments: GithubCommentResponse[] = [];
114
+ let page = 1;
115
+
116
+ while (true) {
117
+ const url = `https://api.github.com/repos/${pr.owner}/${pr.repo}/issues/${pr.number}/comments?per_page=100&page=${page}`;
118
+ const response = await githubGet(url, token);
119
+ const items = (await response.json()) as GithubCommentResponse[];
120
+ if (items.length === 0) break;
121
+ allComments.push(...items);
122
+ if (items.length < 100) break;
123
+ page++;
124
+ }
125
+
126
+ return allComments.map((c) => ({
127
+ id: c.id,
128
+ author: c.user?.login ?? "unknown",
129
+ author_avatar: c.user?.avatar_url ?? undefined,
130
+ body: c.body,
131
+ created_at: c.created_at,
132
+ updated_at: c.updated_at,
133
+ html_url: c.html_url,
134
+ }));
135
+ }
136
+
137
+ export async function fetchPrBody(pr: PrIdentifier, token: string): Promise<string> {
138
+ const url = `https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}`;
139
+ const response = await githubGet(url, token);
140
+ const json = (await response.json()) as Record<string, unknown>;
141
+ return (json.body as string) ?? "";
142
+ }
@@ -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");
@@ -25,6 +25,7 @@ export function buildSessionRecord(id: string, data: NewprOutput): SessionRecord
25
25
  pr_url: meta.pr_url,
26
26
  pr_number: meta.pr_number,
27
27
  pr_title: meta.pr_title,
28
+ pr_state: meta.pr_state,
28
29
  repo: repoParts?.[1] ?? "unknown",
29
30
  author: meta.author,
30
31
  analyzed_at: meta.analyzed_at,
@@ -91,6 +92,111 @@ export async function clearHistory(): Promise<void> {
91
92
  }
92
93
  }
93
94
 
95
+ export async function savePatchesSidecar(
96
+ id: string,
97
+ patches: Record<string, string>,
98
+ ): Promise<void> {
99
+ ensureDirs();
100
+ await Bun.write(
101
+ join(SESSIONS_DIR, `${id}.patches.json`),
102
+ JSON.stringify(patches),
103
+ );
104
+ }
105
+
106
+ export async function loadPatchesSidecar(
107
+ id: string,
108
+ ): Promise<Record<string, string> | null> {
109
+ try {
110
+ const filePath = join(SESSIONS_DIR, `${id}.patches.json`);
111
+ const file = Bun.file(filePath);
112
+ if (!(await file.exists())) return null;
113
+ return JSON.parse(await file.text()) as Record<string, string>;
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ export async function loadSinglePatch(
120
+ id: string,
121
+ filePath: string,
122
+ ): Promise<string | null> {
123
+ const patches = await loadPatchesSidecar(id);
124
+ if (!patches) return null;
125
+ return patches[filePath] ?? null;
126
+ }
127
+
128
+ export async function saveCommentsSidecar(
129
+ id: string,
130
+ comments: DiffComment[],
131
+ ): Promise<void> {
132
+ ensureDirs();
133
+ await Bun.write(
134
+ join(SESSIONS_DIR, `${id}.comments.json`),
135
+ JSON.stringify(comments, null, 2),
136
+ );
137
+ }
138
+
139
+ export async function loadCommentsSidecar(
140
+ id: string,
141
+ ): Promise<DiffComment[] | null> {
142
+ try {
143
+ const filePath = join(SESSIONS_DIR, `${id}.comments.json`);
144
+ const file = Bun.file(filePath);
145
+ if (!(await file.exists())) return null;
146
+ return JSON.parse(await file.text()) as DiffComment[];
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ export async function saveChatSidecar(
153
+ id: string,
154
+ messages: ChatMessage[],
155
+ ): Promise<void> {
156
+ ensureDirs();
157
+ await Bun.write(
158
+ join(SESSIONS_DIR, `${id}.chat.json`),
159
+ JSON.stringify(messages, null, 2),
160
+ );
161
+ }
162
+
163
+ export async function loadChatSidecar(
164
+ id: string,
165
+ ): Promise<ChatMessage[] | null> {
166
+ try {
167
+ const filePath = join(SESSIONS_DIR, `${id}.chat.json`);
168
+ const file = Bun.file(filePath);
169
+ if (!(await file.exists())) return null;
170
+ return JSON.parse(await file.text()) as ChatMessage[];
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+
176
+ export async function saveCartoonSidecar(
177
+ id: string,
178
+ cartoon: CartoonImage,
179
+ ): Promise<void> {
180
+ ensureDirs();
181
+ await Bun.write(
182
+ join(SESSIONS_DIR, `${id}.cartoon.json`),
183
+ JSON.stringify(cartoon),
184
+ );
185
+ }
186
+
187
+ export async function loadCartoonSidecar(
188
+ id: string,
189
+ ): Promise<CartoonImage | null> {
190
+ try {
191
+ const filePath = join(SESSIONS_DIR, `${id}.cartoon.json`);
192
+ const file = Bun.file(filePath);
193
+ if (!(await file.exists())) return null;
194
+ return JSON.parse(await file.text()) as CartoonImage;
195
+ } catch {
196
+ return null;
197
+ }
198
+ }
199
+
94
200
  export function getHistoryPath(): string {
95
201
  return HISTORY_DIR;
96
202
  }
@@ -3,6 +3,7 @@ export interface SessionRecord {
3
3
  pr_url: string;
4
4
  pr_number: number;
5
5
  pr_title: string;
6
+ pr_state?: string;
6
7
  repo: string;
7
8
  author: string;
8
9
  analyzed_at: string;