newpr 0.1.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 (82) hide show
  1. package/README.md +189 -0
  2. package/package.json +78 -0
  3. package/src/analyzer/errors.ts +22 -0
  4. package/src/analyzer/pipeline.ts +299 -0
  5. package/src/analyzer/progress.ts +69 -0
  6. package/src/cli/args.ts +192 -0
  7. package/src/cli/auth.ts +82 -0
  8. package/src/cli/history-cmd.ts +64 -0
  9. package/src/cli/index.ts +115 -0
  10. package/src/cli/pretty.ts +79 -0
  11. package/src/config/index.ts +103 -0
  12. package/src/config/store.ts +50 -0
  13. package/src/diff/chunker.ts +30 -0
  14. package/src/diff/parser.ts +116 -0
  15. package/src/diff/stats.ts +37 -0
  16. package/src/github/auth.ts +16 -0
  17. package/src/github/fetch-diff.ts +24 -0
  18. package/src/github/fetch-pr.ts +90 -0
  19. package/src/github/parse-pr.ts +39 -0
  20. package/src/history/store.ts +96 -0
  21. package/src/history/types.ts +15 -0
  22. package/src/llm/claude-code-client.ts +134 -0
  23. package/src/llm/client.ts +240 -0
  24. package/src/llm/prompts.ts +176 -0
  25. package/src/llm/response-parser.ts +71 -0
  26. package/src/tui/App.tsx +97 -0
  27. package/src/tui/Footer.tsx +34 -0
  28. package/src/tui/Header.tsx +27 -0
  29. package/src/tui/HelpOverlay.tsx +46 -0
  30. package/src/tui/InputBar.tsx +65 -0
  31. package/src/tui/Loading.tsx +192 -0
  32. package/src/tui/Shell.tsx +384 -0
  33. package/src/tui/TabBar.tsx +31 -0
  34. package/src/tui/commands.ts +75 -0
  35. package/src/tui/narrative-parser.ts +143 -0
  36. package/src/tui/panels/FilesPanel.tsx +134 -0
  37. package/src/tui/panels/GroupsPanel.tsx +140 -0
  38. package/src/tui/panels/NarrativePanel.tsx +102 -0
  39. package/src/tui/panels/StoryPanel.tsx +296 -0
  40. package/src/tui/panels/SummaryPanel.tsx +59 -0
  41. package/src/tui/panels/WalkthroughPanel.tsx +149 -0
  42. package/src/tui/render.tsx +62 -0
  43. package/src/tui/theme.ts +44 -0
  44. package/src/types/config.ts +19 -0
  45. package/src/types/diff.ts +36 -0
  46. package/src/types/github.ts +28 -0
  47. package/src/types/output.ts +59 -0
  48. package/src/web/client/App.tsx +121 -0
  49. package/src/web/client/components/AppShell.tsx +203 -0
  50. package/src/web/client/components/DetailPane.tsx +141 -0
  51. package/src/web/client/components/ErrorScreen.tsx +119 -0
  52. package/src/web/client/components/InputScreen.tsx +41 -0
  53. package/src/web/client/components/LoadingTimeline.tsx +179 -0
  54. package/src/web/client/components/Markdown.tsx +109 -0
  55. package/src/web/client/components/ResizeHandle.tsx +45 -0
  56. package/src/web/client/components/ResultsScreen.tsx +185 -0
  57. package/src/web/client/components/SettingsPanel.tsx +299 -0
  58. package/src/web/client/hooks/useAnalysis.ts +153 -0
  59. package/src/web/client/hooks/useGithubUser.ts +24 -0
  60. package/src/web/client/hooks/useSessions.ts +17 -0
  61. package/src/web/client/hooks/useTheme.ts +34 -0
  62. package/src/web/client/main.tsx +12 -0
  63. package/src/web/client/panels/FilesPanel.tsx +85 -0
  64. package/src/web/client/panels/GroupsPanel.tsx +62 -0
  65. package/src/web/client/panels/NarrativePanel.tsx +9 -0
  66. package/src/web/client/panels/StoryPanel.tsx +54 -0
  67. package/src/web/client/panels/SummaryPanel.tsx +20 -0
  68. package/src/web/components/ui/button.tsx +46 -0
  69. package/src/web/components/ui/card.tsx +37 -0
  70. package/src/web/components/ui/scroll-area.tsx +39 -0
  71. package/src/web/components/ui/tabs.tsx +52 -0
  72. package/src/web/index.html +14 -0
  73. package/src/web/lib/utils.ts +6 -0
  74. package/src/web/server/routes.ts +202 -0
  75. package/src/web/server/session-manager.ts +147 -0
  76. package/src/web/server.ts +96 -0
  77. package/src/web/styles/globals.css +91 -0
  78. package/src/workspace/agent.ts +317 -0
  79. package/src/workspace/explore.ts +82 -0
  80. package/src/workspace/repo-cache.ts +69 -0
  81. package/src/workspace/types.ts +30 -0
  82. package/src/workspace/worktree.ts +129 -0
@@ -0,0 +1,96 @@
1
+ import { join, dirname } from "node:path";
2
+ import type { NewprConfig } from "../types/config.ts";
3
+ import { createRoutes } from "./server/routes.ts";
4
+ import index from "./index.html";
5
+
6
+ interface WebServerOptions {
7
+ port: number;
8
+ token: string;
9
+ config: NewprConfig;
10
+ }
11
+
12
+ function getCssPaths() {
13
+ const webDir = dirname(Bun.resolveSync("./src/web/index.html", process.cwd()));
14
+ return {
15
+ input: join(webDir, "styles", "globals.css"),
16
+ output: join(webDir, "styles", "built.css"),
17
+ bin: join(process.cwd(), "node_modules", ".bin", "tailwindcss"),
18
+ };
19
+ }
20
+
21
+ async function buildCss(bin: string, input: string, output: string): Promise<void> {
22
+ const proc = Bun.spawn(
23
+ [bin, "-i", input, "-o", output, "--minify"],
24
+ { cwd: process.cwd(), stderr: "pipe", stdout: "pipe" },
25
+ );
26
+ const exitCode = await proc.exited;
27
+ if (exitCode !== 0) {
28
+ const stderr = await new Response(proc.stderr).text();
29
+ throw new Error(`Tailwind CSS build failed (exit ${exitCode}): ${stderr}`);
30
+ }
31
+ }
32
+
33
+ export async function startWebServer(options: WebServerOptions): Promise<void> {
34
+ const { port, token, config } = options;
35
+ const routes = createRoutes(token, config);
36
+ const css = getCssPaths();
37
+
38
+ await buildCss(css.bin, css.input, css.output);
39
+
40
+ const server = Bun.serve({
41
+ port,
42
+ hostname: "127.0.0.1",
43
+ routes: {
44
+ "/": index,
45
+ "/styles.css": async () => {
46
+ await buildCss(css.bin, css.input, css.output);
47
+ const file = Bun.file(css.output);
48
+ return new Response(file, {
49
+ headers: {
50
+ "content-type": "text/css; charset=utf-8",
51
+ "cache-control": "no-cache, no-store, must-revalidate",
52
+ },
53
+ });
54
+ },
55
+ "/api/analysis": {
56
+ POST: routes["POST /api/analysis"],
57
+ },
58
+ "/api/sessions": {
59
+ GET: routes["GET /api/sessions"],
60
+ },
61
+ "/api/me": {
62
+ GET: routes["GET /api/me"],
63
+ },
64
+ "/api/config": {
65
+ GET: routes["GET /api/config"],
66
+ PUT: routes["PUT /api/config"],
67
+ },
68
+ },
69
+ fetch(req) {
70
+ const url = new URL(req.url);
71
+ const path = url.pathname;
72
+
73
+ if (path.match(/^\/api\/analysis\/[^/]+\/events$/) && req.method === "GET") {
74
+ return routes["GET /api/analysis/:id/events"](req);
75
+ }
76
+ if (path.match(/^\/api\/analysis\/[^/]+\/cancel$/) && req.method === "POST") {
77
+ return routes["POST /api/analysis/:id/cancel"](req);
78
+ }
79
+ if (path.match(/^\/api\/analysis\/[^/]+$/) && req.method === "GET") {
80
+ return routes["GET /api/analysis/:id"](req);
81
+ }
82
+ if (path.match(/^\/api\/sessions\/[^/]+$/) && req.method === "GET") {
83
+ return routes["GET /api/sessions/:id"](req);
84
+ }
85
+
86
+ return new Response("Not Found", { status: 404 });
87
+ },
88
+ development: {
89
+ hmr: true,
90
+ console: true,
91
+ },
92
+ });
93
+
94
+ console.log(`\n newpr web UI`);
95
+ console.log(` Local: http://localhost:${server.port}\n`);
96
+ }
@@ -0,0 +1,91 @@
1
+ @import "tailwindcss";
2
+
3
+ @custom-variant dark (&:is(.dark *));
4
+
5
+ @theme inline {
6
+ --color-background: var(--bg);
7
+ --color-foreground: var(--fg);
8
+ --color-card: var(--card);
9
+ --color-card-foreground: var(--card-fg);
10
+ --color-popover: var(--popover);
11
+ --color-popover-foreground: var(--popover-fg);
12
+ --color-primary: var(--primary);
13
+ --color-primary-foreground: var(--primary-fg);
14
+ --color-secondary: var(--secondary);
15
+ --color-secondary-foreground: var(--secondary-fg);
16
+ --color-muted: var(--muted);
17
+ --color-muted-foreground: var(--muted-fg);
18
+ --color-accent: var(--accent);
19
+ --color-accent-foreground: var(--accent-fg);
20
+ --color-destructive: var(--destructive);
21
+ --color-destructive-foreground: var(--destructive-fg);
22
+ --color-border: var(--border);
23
+ --color-input: var(--input);
24
+ --color-ring: var(--ring);
25
+ --color-success: hsl(142, 71%, 45%);
26
+ --color-warning: hsl(38, 92%, 50%);
27
+ --color-info: hsl(217, 91%, 60%);
28
+ }
29
+
30
+ @theme {
31
+ --radius: 0.5rem;
32
+ --font-sans: "Pretendard", "Inter", ui-sans-serif, system-ui, sans-serif;
33
+ --font-mono: "JetBrains Mono", ui-monospace, monospace;
34
+ }
35
+
36
+ :root {
37
+ --bg: #ffffff;
38
+ --fg: #0a0a0a;
39
+ --card: #ffffff;
40
+ --card-fg: #0a0a0a;
41
+ --popover: #ffffff;
42
+ --popover-fg: #0a0a0a;
43
+ --primary: #171717;
44
+ --primary-fg: #fafafa;
45
+ --secondary: #f5f5f5;
46
+ --secondary-fg: #171717;
47
+ --muted: #f5f5f5;
48
+ --muted-fg: #737373;
49
+ --accent: #f5f5f5;
50
+ --accent-fg: #171717;
51
+ --destructive: #ef4444;
52
+ --destructive-fg: #fafafa;
53
+ --border: #e5e5e5;
54
+ --input: #e5e5e5;
55
+ --ring: #0a0a0a;
56
+ }
57
+
58
+ .dark {
59
+ --bg: #0a0a0a;
60
+ --fg: #fafafa;
61
+ --card: #0a0a0a;
62
+ --card-fg: #fafafa;
63
+ --popover: #0a0a0a;
64
+ --popover-fg: #fafafa;
65
+ --primary: #fafafa;
66
+ --primary-fg: #171717;
67
+ --secondary: #262626;
68
+ --secondary-fg: #fafafa;
69
+ --muted: #262626;
70
+ --muted-fg: #a3a3a3;
71
+ --accent: #262626;
72
+ --accent-fg: #fafafa;
73
+ --destructive: #7f1d1d;
74
+ --destructive-fg: #fafafa;
75
+ --border: #262626;
76
+ --input: #262626;
77
+ --ring: #d4d4d4;
78
+ }
79
+
80
+ @layer base {
81
+ * {
82
+ border-color: var(--border);
83
+ }
84
+ body {
85
+ background-color: var(--bg);
86
+ color: var(--fg);
87
+ font-feature-settings: "rlig" 1, "calt" 1;
88
+ -webkit-font-smoothing: antialiased;
89
+ -moz-osx-font-smoothing: grayscale;
90
+ }
91
+ }
@@ -0,0 +1,317 @@
1
+ import type { AgentTool, AgentToolName, AgentResult } from "./types.ts";
2
+ import { INSTALL_INSTRUCTIONS } from "./types.ts";
3
+
4
+ const AGENT_PRIORITY: AgentToolName[] = ["claude", "opencode", "codex"];
5
+
6
+ const DETECTION_COMMANDS: Record<AgentToolName, string> = {
7
+ claude: "claude",
8
+ opencode: "opencode",
9
+ codex: "codex",
10
+ };
11
+
12
+ async function which(cmd: string): Promise<string | null> {
13
+ try {
14
+ const result = await Bun.$`which ${cmd}`.text();
15
+ const path = result.trim();
16
+ return path || null;
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ export async function detectAgents(): Promise<AgentTool[]> {
23
+ const agents: AgentTool[] = [];
24
+
25
+ for (const name of AGENT_PRIORITY) {
26
+ const path = await which(DETECTION_COMMANDS[name]);
27
+ if (path) {
28
+ agents.push({ name, path });
29
+ }
30
+ }
31
+
32
+ return agents;
33
+ }
34
+
35
+ export async function requireAgent(preferred?: AgentToolName): Promise<AgentTool> {
36
+ const agents = await detectAgents();
37
+
38
+ if (preferred) {
39
+ const found = agents.find((a) => a.name === preferred);
40
+ if (found) return found;
41
+
42
+ const instruction = INSTALL_INSTRUCTIONS[preferred];
43
+ throw new Error(
44
+ `Agent "${preferred}" is not installed.\n\n` +
45
+ `Install it with:\n ${instruction}\n\n` +
46
+ `Or use one of these available agents: ${agents.map((a) => a.name).join(", ") || "(none installed)"}`,
47
+ );
48
+ }
49
+
50
+ if (agents.length > 0) {
51
+ return agents[0]!;
52
+ }
53
+
54
+ const installList = AGENT_PRIORITY
55
+ .map((name) => ` ${name}: ${INSTALL_INSTRUCTIONS[name]}`)
56
+ .join("\n");
57
+
58
+ throw new Error(
59
+ "No agentic coding tool found.\n\n" +
60
+ "newpr requires at least one of the following tools for codebase exploration:\n\n" +
61
+ `${installList}\n\n` +
62
+ "Install any one of them and try again.",
63
+ );
64
+ }
65
+
66
+ interface RunOptions {
67
+ timeout?: number;
68
+ allowedTools?: string[];
69
+ onOutput?: (line: string) => void;
70
+ }
71
+
72
+ const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]/g;
73
+
74
+ function stripAnsi(text: string): string {
75
+ return text.replace(ANSI_RE, "");
76
+ }
77
+
78
+ async function streamLines(
79
+ stream: ReadableStream<Uint8Array>,
80
+ onLine: (line: string) => void,
81
+ ): Promise<void> {
82
+ const reader = stream.getReader();
83
+ const decoder = new TextDecoder();
84
+ let buffer = "";
85
+
86
+ for (;;) {
87
+ const { done, value } = await reader.read();
88
+ if (done) break;
89
+ buffer += decoder.decode(value, { stream: true });
90
+
91
+ const lines = buffer.split("\n");
92
+ buffer = lines.pop() ?? "";
93
+
94
+ for (const raw of lines) {
95
+ const line = stripAnsi(raw).trim();
96
+ if (line) onLine(line);
97
+ }
98
+ }
99
+
100
+ const tail = stripAnsi(buffer).trim();
101
+ if (tail) onLine(tail);
102
+ }
103
+
104
+ const TOOL_LABELS: Record<string, (input: Record<string, unknown>) => string> = {
105
+ Read: (i) => `Reading ${truncPath(i.file_path as string ?? i.filePath as string ?? "")}`,
106
+ Glob: (i) => `Glob ${i.pattern as string ?? ""}`,
107
+ Grep: (i) => `Grep "${i.pattern as string ?? ""}"`,
108
+ Bash: (i) => {
109
+ const cmd = (i.command as string ?? "").slice(0, 60);
110
+ return `$ ${cmd}`;
111
+ },
112
+ ListFiles: (i) => `Listing ${truncPath(i.path as string ?? ".")}`,
113
+ Search: (i) => `Search "${i.query as string ?? ""}"`,
114
+ };
115
+
116
+ function truncPath(p: string): string {
117
+ if (p.length <= 50) return p;
118
+ const parts = p.split("/");
119
+ if (parts.length <= 3) return `…${p.slice(-47)}`;
120
+ return `${parts[0]}/…/${parts.slice(-2).join("/")}`;
121
+ }
122
+
123
+ function formatToolUse(name: string, input: Record<string, unknown>): string | null {
124
+ const formatter = TOOL_LABELS[name];
125
+ if (formatter) return formatter(input);
126
+ return `${name}`;
127
+ }
128
+
129
+ export async function runAgent(
130
+ agent: AgentTool,
131
+ workdir: string,
132
+ prompt: string,
133
+ options?: RunOptions,
134
+ ): Promise<AgentResult> {
135
+ const timeout = options?.timeout ?? 60_000;
136
+
137
+ switch (agent.name) {
138
+ case "claude":
139
+ return runClaude(agent, workdir, prompt, timeout, options?.onOutput);
140
+ case "opencode":
141
+ return runOpencode(agent, workdir, prompt, timeout, options?.onOutput);
142
+ case "codex":
143
+ return runCodex(agent, workdir, prompt, timeout, options?.onOutput);
144
+ }
145
+ }
146
+
147
+ async function runClaude(
148
+ agent: AgentTool,
149
+ workdir: string,
150
+ prompt: string,
151
+ timeout: number,
152
+ onOutput?: (line: string) => void,
153
+ ): Promise<AgentResult> {
154
+ const start = Date.now();
155
+ const proc = Bun.spawn(
156
+ [
157
+ agent.path,
158
+ "-p",
159
+ "--output-format", "stream-json",
160
+ "--permission-mode", "bypassPermissions",
161
+ "--allowedTools", "Read", "Glob", "Grep", "Bash(find:*)", "Bash(wc:*)", "Bash(head:*)",
162
+ prompt,
163
+ ],
164
+ {
165
+ cwd: workdir,
166
+ stdin: "ignore",
167
+ stdout: "pipe",
168
+ stderr: "ignore",
169
+ },
170
+ );
171
+
172
+ let answer = "";
173
+ let costUsd: number | undefined;
174
+
175
+ const stdoutPromise = proc.stdout
176
+ ? streamLines(proc.stdout, (line) => {
177
+ try {
178
+ const event = JSON.parse(line);
179
+ if (event.type === "assistant" && event.message?.content) {
180
+ for (const block of event.message.content) {
181
+ if (block.type === "tool_use" && onOutput) {
182
+ const label = formatToolUse(
183
+ block.name ?? "",
184
+ (block.input as Record<string, unknown>) ?? {},
185
+ );
186
+ if (label) onOutput(label);
187
+ }
188
+ }
189
+ } else if (event.type === "result") {
190
+ answer = event.result ?? "";
191
+ costUsd = event.cost_usd ?? event.total_cost_usd;
192
+ }
193
+ } catch {
194
+ }
195
+ })
196
+ : Promise.resolve();
197
+
198
+ const timeoutId = setTimeout(() => proc.kill(), timeout);
199
+ await stdoutPromise;
200
+ clearTimeout(timeoutId);
201
+
202
+ const duration = Date.now() - start;
203
+
204
+ return {
205
+ answer,
206
+ cost_usd: costUsd,
207
+ duration_ms: duration,
208
+ tool_used: agent.name,
209
+ };
210
+ }
211
+
212
+ async function runOpencode(
213
+ agent: AgentTool,
214
+ workdir: string,
215
+ prompt: string,
216
+ timeout: number,
217
+ onOutput?: (line: string) => void,
218
+ ): Promise<AgentResult> {
219
+ const start = Date.now();
220
+ const proc = Bun.spawn(
221
+ [agent.path, workdir, "run", "--format", "json", prompt],
222
+ {
223
+ cwd: workdir,
224
+ stdin: "ignore",
225
+ stdout: "pipe",
226
+ stderr: "pipe",
227
+ },
228
+ );
229
+
230
+ const stderrPromise = onOutput && proc.stderr
231
+ ? streamLines(proc.stderr, onOutput)
232
+ : Promise.resolve();
233
+
234
+ const timeoutId = setTimeout(() => proc.kill(), timeout);
235
+ const output = await new Response(proc.stdout).text();
236
+ await stderrPromise;
237
+ clearTimeout(timeoutId);
238
+
239
+ const duration = Date.now() - start;
240
+
241
+ const lines = output.trim().split("\n");
242
+ const lastLine = lines[lines.length - 1] ?? "";
243
+ try {
244
+ const json = JSON.parse(lastLine);
245
+ return {
246
+ answer: json.content ?? json.text ?? lastLine,
247
+ duration_ms: duration,
248
+ tool_used: agent.name,
249
+ };
250
+ } catch {
251
+ return {
252
+ answer: output.trim(),
253
+ duration_ms: duration,
254
+ tool_used: agent.name,
255
+ };
256
+ }
257
+ }
258
+
259
+ async function runCodex(
260
+ agent: AgentTool,
261
+ workdir: string,
262
+ prompt: string,
263
+ timeout: number,
264
+ onOutput?: (line: string) => void,
265
+ ): Promise<AgentResult> {
266
+ const start = Date.now();
267
+ const proc = Bun.spawn(
268
+ [
269
+ agent.path, "exec",
270
+ "--json",
271
+ "--dangerously-bypass-approvals-and-sandbox",
272
+ "-C", workdir,
273
+ prompt,
274
+ ],
275
+ {
276
+ cwd: workdir,
277
+ stdin: "ignore",
278
+ stdout: "pipe",
279
+ stderr: "ignore",
280
+ },
281
+ );
282
+
283
+ let answer = "";
284
+
285
+ const stdoutPromise = proc.stdout
286
+ ? streamLines(proc.stdout, (line) => {
287
+ try {
288
+ const event = JSON.parse(line);
289
+ if (event.type === "item.completed" && event.item) {
290
+ if (event.item.type === "tool_call" && onOutput) {
291
+ const name = event.item.name ?? event.item.tool ?? "";
292
+ const args = event.item.arguments ?? event.item.input ?? {};
293
+ const parsed = typeof args === "string" ? JSON.parse(args) : args;
294
+ const label = formatToolUse(name, parsed as Record<string, unknown>);
295
+ if (label) onOutput(label);
296
+ }
297
+ if (event.item.type === "agent_message" && event.item.text) {
298
+ answer = event.item.text;
299
+ }
300
+ }
301
+ } catch {
302
+ }
303
+ })
304
+ : Promise.resolve();
305
+
306
+ const timeoutId = setTimeout(() => proc.kill(), timeout);
307
+ await stdoutPromise;
308
+ clearTimeout(timeoutId);
309
+
310
+ const duration = Date.now() - start;
311
+
312
+ return {
313
+ answer,
314
+ duration_ms: duration,
315
+ tool_used: agent.name,
316
+ };
317
+ }
@@ -0,0 +1,82 @@
1
+ import type { AgentTool } from "./types.ts";
2
+ import type { ExplorationResult } from "./types.ts";
3
+ import { runAgent } from "./agent.ts";
4
+
5
+ const STRUCTURE_PROMPT = `Analyze the project structure of this repository concisely.
6
+ Return:
7
+ 1. What kind of project this is (language, framework, purpose)
8
+ 2. The key directories and what they contain
9
+ 3. Important config files and what they indicate
10
+ 4. The overall architecture pattern (monorepo, MVC, microservices, etc.)
11
+ Keep it under 500 words. Focus on facts, not opinions.`;
12
+
13
+ function buildRelatedCodePrompt(changedFiles: string[], prTitle: string): string {
14
+ const fileList = changedFiles.slice(0, 30).join("\n ");
15
+ return `These files were changed in a PR titled "${prTitle}":
16
+ ${fileList}
17
+
18
+ For each changed file, find:
19
+ 1. What imports it / what it imports (direct dependencies)
20
+ 2. Key functions or classes it defines and where they are used
21
+ 3. Any tests that cover this file
22
+
23
+ Be concise. Focus on the most important relationships. Under 800 words total.`;
24
+ }
25
+
26
+ function buildIssuesPrompt(changedFiles: string[], diff: string): string {
27
+ const truncatedDiff = diff.length > 12000 ? `${diff.slice(0, 12000)}\n\n... (diff truncated)` : diff;
28
+ return `Review this PR diff for potential issues. The changed files are:
29
+ ${changedFiles.slice(0, 20).join(", ")}
30
+
31
+ Diff:
32
+ \`\`\`
33
+ ${truncatedDiff}
34
+ \`\`\`
35
+
36
+ Look at the actual codebase (not just the diff) to check:
37
+ 1. Are there breaking changes to public APIs that callers depend on?
38
+ 2. Are there missing error handling patterns that the rest of the codebase uses?
39
+ 3. Are there inconsistencies with existing code patterns/conventions?
40
+ 4. Are there missing test updates for changed functionality?
41
+
42
+ Only report real issues you can verify from the codebase. No speculation. Under 600 words.`;
43
+ }
44
+
45
+ const PHASE_LABELS = ["Analyzing project structure", "Finding related code", "Checking for issues"] as const;
46
+
47
+ export async function exploreCodebase(
48
+ agent: AgentTool,
49
+ headPath: string,
50
+ changedFiles: string[],
51
+ prTitle: string,
52
+ diff: string,
53
+ onProgress?: (msg: string, current?: number, total?: number) => void,
54
+ ): Promise<ExplorationResult> {
55
+ const timeout = 90_000;
56
+
57
+ onProgress?.(`${PHASE_LABELS[0]}...`, 1, 3);
58
+ const structureResult = await runAgent(agent, headPath, STRUCTURE_PROMPT, {
59
+ timeout,
60
+ onOutput: (line) => onProgress?.(`[1/3] ${line}`, 1, 3),
61
+ });
62
+
63
+ onProgress?.(`${PHASE_LABELS[1]}...`, 2, 3);
64
+ const relatedPrompt = buildRelatedCodePrompt(changedFiles, prTitle);
65
+ const relatedResult = await runAgent(agent, headPath, relatedPrompt, {
66
+ timeout,
67
+ onOutput: (line) => onProgress?.(`[2/3] ${line}`, 2, 3),
68
+ });
69
+
70
+ onProgress?.(`${PHASE_LABELS[2]}...`, 3, 3);
71
+ const issuesPrompt = buildIssuesPrompt(changedFiles, diff);
72
+ const issuesResult = await runAgent(agent, headPath, issuesPrompt, {
73
+ timeout,
74
+ onOutput: (line) => onProgress?.(`[3/3] ${line}`, 3, 3),
75
+ });
76
+
77
+ return {
78
+ project_structure: structureResult.answer,
79
+ related_code: relatedResult.answer,
80
+ potential_issues: issuesResult.answer,
81
+ };
82
+ }
@@ -0,0 +1,69 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { mkdirSync, existsSync, readFileSync, writeFileSync } from "node:fs";
4
+
5
+ const REPOS_DIR = join(homedir(), ".newpr", "repos");
6
+ const FETCH_COOLDOWN_MS = 5 * 60 * 1000;
7
+
8
+ function bareRepoPath(owner: string, repo: string): string {
9
+ return join(REPOS_DIR, "github.com", owner, `${repo}.git`);
10
+ }
11
+
12
+ function stampPath(repoPath: string): string {
13
+ return join(repoPath, ".newpr-fetched");
14
+ }
15
+
16
+ function needsFetch(repoPath: string): boolean {
17
+ const stamp = stampPath(repoPath);
18
+ if (!existsSync(stamp)) return true;
19
+ try {
20
+ const ts = Number(readFileSync(stamp, "utf-8").trim());
21
+ return Date.now() - ts > FETCH_COOLDOWN_MS;
22
+ } catch {
23
+ return true;
24
+ }
25
+ }
26
+
27
+ function touchFetchStamp(repoPath: string): void {
28
+ writeFileSync(stampPath(repoPath), String(Date.now()));
29
+ }
30
+
31
+ export async function ensureRepo(
32
+ owner: string,
33
+ repo: string,
34
+ token: string,
35
+ onProgress?: (msg: string) => void,
36
+ ): Promise<string> {
37
+ const repoPath = bareRepoPath(owner, repo);
38
+
39
+ if (existsSync(join(repoPath, "HEAD"))) {
40
+ if (needsFetch(repoPath)) {
41
+ onProgress?.("Fetching latest changes...");
42
+ const fetch = await Bun.$`git -C ${repoPath} fetch --all --prune`.quiet().nothrow();
43
+ if (fetch.exitCode !== 0) {
44
+ throw new Error(`git fetch failed (exit ${fetch.exitCode}): ${fetch.stderr.toString().trim()}`);
45
+ }
46
+ touchFetchStamp(repoPath);
47
+ } else {
48
+ onProgress?.("Repository cache is fresh.");
49
+ }
50
+ return repoPath;
51
+ }
52
+
53
+ const parentDir = join(repoPath, "..");
54
+ mkdirSync(parentDir, { recursive: true });
55
+
56
+ const cloneUrl = `https://x-access-token:${token}@github.com/${owner}/${repo}.git`;
57
+ onProgress?.(`Cloning ${owner}/${repo}...`);
58
+ const clone = await Bun.$`git clone --bare ${cloneUrl} ${repoPath}`.quiet().nothrow();
59
+ if (clone.exitCode !== 0) {
60
+ throw new Error(`git clone failed (exit ${clone.exitCode}): ${clone.stderr.toString().trim()}`);
61
+ }
62
+ touchFetchStamp(repoPath);
63
+
64
+ return repoPath;
65
+ }
66
+
67
+ export function getReposDir(): string {
68
+ return REPOS_DIR;
69
+ }
@@ -0,0 +1,30 @@
1
+ export type AgentToolName = "claude" | "opencode" | "codex";
2
+
3
+ export interface AgentTool {
4
+ name: AgentToolName;
5
+ path: string;
6
+ }
7
+
8
+ export interface AgentResult {
9
+ answer: string;
10
+ cost_usd?: number;
11
+ duration_ms: number;
12
+ tool_used: AgentToolName;
13
+ }
14
+
15
+ export interface WorktreeSet {
16
+ basePath: string;
17
+ headPath: string;
18
+ }
19
+
20
+ export interface ExplorationResult {
21
+ project_structure: string;
22
+ related_code: string;
23
+ potential_issues: string;
24
+ }
25
+
26
+ export const INSTALL_INSTRUCTIONS: Record<AgentToolName, string> = {
27
+ claude: "npm install -g @anthropic-ai/claude-code",
28
+ opencode: "npm install -g opencode",
29
+ codex: "npm install -g @openai/codex",
30
+ };