newpr 1.0.16 → 1.0.17

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "newpr",
3
- "version": "1.0.16",
3
+ "version": "1.0.17",
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",
@@ -10,6 +10,21 @@ export interface PublishInput {
10
10
  repo: string;
11
11
  }
12
12
 
13
+ export interface StackPublishPreviewItem {
14
+ group_id: string;
15
+ title: string;
16
+ base_branch: string;
17
+ head_branch: string;
18
+ order: number;
19
+ total: number;
20
+ body: string;
21
+ }
22
+
23
+ export interface StackPublishPreviewResult {
24
+ template_path: string | null;
25
+ items: StackPublishPreviewItem[];
26
+ }
27
+
13
28
  const PR_TEMPLATE_PATHS = [
14
29
  ".github/PULL_REQUEST_TEMPLATE.md",
15
30
  ".github/pull_request_template.md",
@@ -28,6 +43,11 @@ const PR_TEMPLATE_PATHS = [
28
43
  const TEMPLATE_DIR_RE = /^\.github\/(?:PULL_REQUEST_TEMPLATE|pull_request_template)\/.+\.md$/i;
29
44
  const STACK_NAV_COMMENT_MARKER = "<!-- newpr:stack-navigation -->";
30
45
 
46
+ interface PrTemplateData {
47
+ path: string;
48
+ content: string;
49
+ }
50
+
31
51
  async function listCommitFiles(repoPath: string, headSha: string): Promise<string[]> {
32
52
  const result = await Bun.$`git -C ${repoPath} ls-tree -r --name-only ${headSha}`.quiet().nothrow();
33
53
  if (result.exitCode !== 0) return [];
@@ -51,14 +71,14 @@ function collectTemplateCandidates(files: string[]): string[] {
51
71
  return deduped;
52
72
  }
53
73
 
54
- async function readPrTemplate(repoPath: string, headSha: string): Promise<string | null> {
74
+ async function readPrTemplate(repoPath: string, headSha: string): Promise<PrTemplateData | null> {
55
75
  const commitFiles = await listCommitFiles(repoPath, headSha);
56
76
  const candidates = collectTemplateCandidates(commitFiles);
57
77
  for (const path of candidates) {
58
78
  const result = await Bun.$`git -C ${repoPath} show ${headSha}:${path}`.quiet().nothrow();
59
79
  if (result.exitCode === 0) {
60
80
  const content = result.stdout.toString().trim();
61
- if (content) return content;
81
+ if (content) return { path, content };
62
82
  }
63
83
  }
64
84
  return null;
@@ -79,7 +99,8 @@ export async function publishStack(input: PublishInput): Promise<StackPublishRes
79
99
  const ghRepo = `${owner}/${repo}`;
80
100
 
81
101
  const headSha = exec_result.group_commits.at(-1)?.commit_sha ?? "HEAD";
82
- const prTemplate = await readPrTemplate(repo_path, headSha);
102
+ const prTemplateData = await readPrTemplate(repo_path, headSha);
103
+ const prTemplate = prTemplateData?.content ?? null;
83
104
 
84
105
  const branches: BranchInfo[] = [];
85
106
  const prs: PrInfo[] = [];
@@ -109,9 +130,7 @@ export async function publishStack(input: PublishInput): Promise<StackPublishRes
109
130
  if (!prBase) continue;
110
131
 
111
132
  const order = i + 1;
112
- const title = gc.pr_title
113
- ? `[${order}/${total}] ${gc.pr_title}`
114
- : `[Stack ${order}/${total}] ${gc.group_id}`;
133
+ const title = buildStackPrTitle(gc, pr_meta, order, total);
115
134
 
116
135
  const placeholder = buildPlaceholderBody(gc.group_id, order, total, pr_meta, prTemplate);
117
136
 
@@ -144,6 +163,34 @@ export async function publishStack(input: PublishInput): Promise<StackPublishRes
144
163
  return { branches, prs };
145
164
  }
146
165
 
166
+ export async function buildStackPublishPreview(input: PublishInput): Promise<StackPublishPreviewResult> {
167
+ const { repo_path, exec_result, pr_meta, base_branch } = input;
168
+ const headSha = exec_result.group_commits.at(-1)?.commit_sha ?? "HEAD";
169
+ const prTemplateData = await readPrTemplate(repo_path, headSha);
170
+ const prTemplate = prTemplateData?.content ?? null;
171
+ const total = exec_result.group_commits.length;
172
+
173
+ const items = exec_result.group_commits.map((gc, i) => {
174
+ const order = i + 1;
175
+ const title = buildStackPrTitle(gc, pr_meta, order, total);
176
+ const prBase = i === 0 ? base_branch : exec_result.group_commits[i - 1]?.branch_name ?? base_branch;
177
+ return {
178
+ group_id: gc.group_id,
179
+ title,
180
+ base_branch: prBase,
181
+ head_branch: gc.branch_name,
182
+ order,
183
+ total,
184
+ body: buildDescriptionBody(gc.group_id, order, total, pr_meta, prTemplate),
185
+ };
186
+ });
187
+
188
+ return {
189
+ template_path: prTemplateData?.path ?? null,
190
+ items,
191
+ };
192
+ }
193
+
147
194
  async function updatePrBodies(ghRepo: string, prs: PrInfo[], prMeta: PrMeta, prTemplate: string | null): Promise<void> {
148
195
  if (prs.length === 0) return;
149
196
 
@@ -216,16 +263,25 @@ function buildPlaceholderBody(
216
263
  return lines.join("\n");
217
264
  }
218
265
 
219
- function buildFullBody(
220
- current: PrInfo,
221
- index: number,
222
- allPrs: PrInfo[],
266
+ function buildStackPrTitle(
267
+ groupCommit: StackExecResult["group_commits"][number],
223
268
  prMeta: PrMeta,
224
- prTemplate: string | null,
269
+ order: number,
270
+ total: number,
225
271
  ): string {
226
- const total = allPrs.length;
227
- const order = index + 1;
272
+ const stackPrefix = `[PR#${prMeta.pr_number} ${order}/${total}]`;
273
+ return groupCommit.pr_title
274
+ ? `${stackPrefix} ${groupCommit.pr_title}`
275
+ : `${stackPrefix} ${groupCommit.group_id}`;
276
+ }
228
277
 
278
+ function buildDescriptionBody(
279
+ groupId: string,
280
+ order: number,
281
+ total: number,
282
+ prMeta: PrMeta,
283
+ prTemplate: string | null,
284
+ ): string {
229
285
  const lines = [
230
286
  `> **Stack ${order}/${total}** — This PR is part of a stacked PR chain created by [newpr](https://github.com/jiwonMe/newpr).`,
231
287
  `> Source: #${prMeta.pr_number} ${prMeta.pr_title}`,
@@ -233,18 +289,28 @@ function buildFullBody(
233
289
  ``,
234
290
  `---`,
235
291
  ``,
236
- `## ${current.group_id}`,
292
+ `## ${groupId}`,
237
293
  ``,
238
294
  `*From PR [#${prMeta.pr_number}](${prMeta.pr_url}): ${prMeta.pr_title}*`,
239
295
  ];
240
296
 
241
297
  if (prTemplate) {
242
- lines.push(``, `---`, ``, prTemplate);
298
+ lines.push("", "---", "", prTemplate);
243
299
  }
244
300
 
245
301
  return lines.join("\n");
246
302
  }
247
303
 
304
+ function buildFullBody(
305
+ current: PrInfo,
306
+ index: number,
307
+ allPrs: PrInfo[],
308
+ prMeta: PrMeta,
309
+ prTemplate: string | null,
310
+ ): string {
311
+ return buildDescriptionBody(current.group_id, index + 1, allPrs.length, prMeta, prTemplate);
312
+ }
313
+
248
314
  function buildStackNavigationComment(index: number, allPrs: PrInfo[]): string {
249
315
  const total = allPrs.length;
250
316
  const order = index + 1;
@@ -254,7 +320,7 @@ function buildStackNavigationComment(index: number, allPrs: PrInfo[]): string {
254
320
  const isCurrent = i === index;
255
321
  const marker = isCurrent ? "👉" : statusEmoji(i, index);
256
322
  const link = `[#${pr.number}](${pr.url})`;
257
- const titleText = pr.title.replace(/^\[\d+\/\d+\]\s*/, "");
323
+ const titleText = pr.title.replace(/^\[(?:PR#\d+\s+\d+\/\d+|Stack\s+\d+\/\d+|\d+\/\d+)\]\s*/i, "");
258
324
  return `| ${marker} | ${num}/${total} | ${link} | ${titleText} |`;
259
325
  }).join("\n");
260
326
 
@@ -82,6 +82,19 @@ interface PublishResultData {
82
82
  publishedAt?: number;
83
83
  }
84
84
 
85
+ interface PublishPreviewData {
86
+ template_path: string | null;
87
+ items: Array<{
88
+ group_id: string;
89
+ title: string;
90
+ base_branch: string;
91
+ head_branch: string;
92
+ order: number;
93
+ total: number;
94
+ body: string;
95
+ }>;
96
+ }
97
+
85
98
  interface ServerStackState {
86
99
  status: string;
87
100
  phase: string | null;
@@ -109,6 +122,9 @@ export interface StackState {
109
122
  execResult: ExecResultData | null;
110
123
  verifyResult: VerifyResultData | null;
111
124
  publishResult: PublishResultData | null;
125
+ publishPreview: PublishPreviewData | null;
126
+ publishPreviewLoading: boolean;
127
+ publishPreviewError: string | null;
112
128
  progressMessage: string | null;
113
129
  }
114
130
 
@@ -152,6 +168,9 @@ export function useStack(sessionId: string | null | undefined, options?: UseStac
152
168
  execResult: null,
153
169
  verifyResult: null,
154
170
  publishResult: null,
171
+ publishPreview: null,
172
+ publishPreviewLoading: false,
173
+ publishPreviewError: null,
155
174
  progressMessage: null,
156
175
  });
157
176
 
@@ -297,6 +316,47 @@ export function useStack(sessionId: string | null | undefined, options?: UseStac
297
316
  }
298
317
  }, [sessionId, options]);
299
318
 
319
+ const loadPublishPreview = useCallback(async (force = false) => {
320
+ if (!sessionId) return;
321
+
322
+ setState((s) => {
323
+ if (!force && s.publishPreviewLoading) return s;
324
+ return { ...s, publishPreviewLoading: true, publishPreviewError: null };
325
+ });
326
+
327
+ try {
328
+ const res = await fetch("/api/stack/publish/preview", {
329
+ method: "POST",
330
+ headers: { "Content-Type": "application/json" },
331
+ body: JSON.stringify({ sessionId }),
332
+ });
333
+ const data = await res.json() as { preview?: PublishPreviewData; error?: string };
334
+ if (!res.ok || !data.preview) throw new Error(data.error ?? "Failed to load publish preview");
335
+
336
+ setState((s) => ({
337
+ ...s,
338
+ publishPreview: data.preview!,
339
+ publishPreviewLoading: false,
340
+ publishPreviewError: null,
341
+ }));
342
+ } catch (err) {
343
+ setState((s) => ({
344
+ ...s,
345
+ publishPreviewLoading: false,
346
+ publishPreviewError: err instanceof Error ? err.message : String(err),
347
+ }));
348
+ }
349
+ }, [sessionId]);
350
+
351
+ useEffect(() => {
352
+ if (!sessionId) return;
353
+ if (state.phase !== "done") return;
354
+ if (!state.execResult) return;
355
+ if (state.publishResult) return;
356
+ if (state.publishPreview || state.publishPreviewLoading || state.publishPreviewError) return;
357
+ loadPublishPreview();
358
+ }, [sessionId, state.phase, state.execResult, state.publishResult, state.publishPreview, state.publishPreviewLoading, state.publishPreviewError, loadPublishPreview]);
359
+
300
360
  const reset = useCallback(() => {
301
361
  eventSourceRef.current?.close();
302
362
  eventSourceRef.current = null;
@@ -311,6 +371,9 @@ export function useStack(sessionId: string | null | undefined, options?: UseStac
311
371
  execResult: null,
312
372
  verifyResult: null,
313
373
  publishResult: null,
374
+ publishPreview: null,
375
+ publishPreviewLoading: false,
376
+ publishPreviewError: null,
314
377
  progressMessage: null,
315
378
  }));
316
379
  }, []);
@@ -326,6 +389,7 @@ export function useStack(sessionId: string | null | undefined, options?: UseStac
326
389
  setMaxGroups,
327
390
  runFullPipeline,
328
391
  startPublish,
392
+ loadPublishPreview,
329
393
  reset,
330
394
  };
331
395
  }
@@ -1,4 +1,4 @@
1
- import { Loader2, Play, Upload, RotateCcw, CheckCircle2, AlertTriangle, Circle, GitPullRequestArrow, ArrowRight, Layers } from "lucide-react";
1
+ import { Loader2, Play, Upload, RotateCcw, CheckCircle2, AlertTriangle, Circle, GitPullRequestArrow, ArrowRight, Layers, FileText, RefreshCw } from "lucide-react";
2
2
  import { useStack } from "../hooks/useStack.ts";
3
3
  import { FeasibilityAlert } from "../components/FeasibilityAlert.tsx";
4
4
  import { StackGroupCard } from "../components/StackGroupCard.tsx";
@@ -83,6 +83,7 @@ export function StackPanel({ sessionId, onTrackAnalysis }: StackPanelProps) {
83
83
  return !stack.publishResult?.prs.some((pr) => pr.head_branch === branch.name);
84
84
  })
85
85
  : [];
86
+ const previewItems = stack.publishPreview?.items ?? [];
86
87
 
87
88
  if (stack.phase === "idle") {
88
89
  return (
@@ -260,6 +261,67 @@ export function StackPanel({ sessionId, onTrackAnalysis }: StackPanelProps) {
260
261
  </div>
261
262
  )}
262
263
 
264
+ {stack.phase === "done" && stack.execResult && !stack.publishResult && (
265
+ <div className="space-y-2 rounded-lg border border-border/70 bg-foreground/[0.015] p-2.5">
266
+ <div className="flex items-center justify-between px-1">
267
+ <div className="flex items-center gap-2">
268
+ <FileText className="h-3.5 w-3.5 text-muted-foreground/60" />
269
+ <span className="text-[11px] font-medium text-foreground/80">Description Preview</span>
270
+ </div>
271
+ <button
272
+ type="button"
273
+ onClick={() => stack.loadPublishPreview(true)}
274
+ className="inline-flex items-center gap-1 text-[10px] text-muted-foreground/45 hover:text-foreground/70 transition-colors"
275
+ >
276
+ <RefreshCw className="h-3 w-3" />
277
+ Refresh
278
+ </button>
279
+ </div>
280
+
281
+ <p className="text-[10px] text-muted-foreground/35 px-1">
282
+ Template: {stack.publishPreview?.template_path ?? "(none found, using stack metadata body only)"}
283
+ </p>
284
+
285
+ {stack.publishPreviewLoading && (
286
+ <div className="flex items-center gap-2 px-2.5 py-2 text-[10px] text-muted-foreground/45">
287
+ <Loader2 className="h-3 w-3 animate-spin" />
288
+ Preparing preview bodies...
289
+ </div>
290
+ )}
291
+
292
+ {stack.publishPreviewError && (
293
+ <div className="rounded-md bg-red-500/[0.06] px-2.5 py-2 text-[10px] text-red-600/80 dark:text-red-400/80">
294
+ {stack.publishPreviewError}
295
+ </div>
296
+ )}
297
+
298
+ {!stack.publishPreviewLoading && !stack.publishPreviewError && previewItems.length > 0 && (
299
+ <div className="space-y-1.5">
300
+ {previewItems.map((item) => (
301
+ <details key={`${item.group_id}-${item.order}`} className="rounded-md border border-border/60 bg-background/40">
302
+ <summary className="cursor-pointer list-none px-2.5 py-2 hover:bg-accent/25 transition-colors">
303
+ <div className="flex items-center justify-between gap-2">
304
+ <span className="text-[11px] font-medium truncate">{item.title}</span>
305
+ <span className="text-[10px] text-muted-foreground/35 tabular-nums shrink-0">{item.order}/{item.total}</span>
306
+ </div>
307
+ <div className="flex items-center gap-1 mt-0.5">
308
+ <span className="text-[10px] font-mono text-muted-foreground/30">{item.base_branch}</span>
309
+ <ArrowRight className="h-2.5 w-2.5 text-muted-foreground/20" />
310
+ <span className="text-[10px] font-mono text-muted-foreground/30">{item.head_branch}</span>
311
+ </div>
312
+ </summary>
313
+ <div className="px-2.5 pb-2.5">
314
+ <pre className="whitespace-pre-wrap break-words text-[10px] leading-relaxed text-foreground/75 bg-muted/40 rounded-md p-2.5 overflow-x-auto">
315
+ {item.body}
316
+ </pre>
317
+ </div>
318
+ </details>
319
+ ))}
320
+ </div>
321
+ )}
322
+ </div>
323
+ )}
324
+
263
325
  {stack.phase === "done" && stack.execResult && !stack.publishResult && (
264
326
  <button
265
327
  type="button"
@@ -15,7 +15,7 @@ import { getPlugin, getAllPlugins } from "../../plugins/registry.ts";
15
15
  import { chatWithTools, createLlmClient, type ChatTool, type ChatStreamEvent } from "../../llm/client.ts";
16
16
  import { detectAgents, runAgent } from "../../workspace/agent.ts";
17
17
  import { randomBytes } from "node:crypto";
18
- import { publishStack } from "../../stack/publish.ts";
18
+ import { publishStack, buildStackPublishPreview } from "../../stack/publish.ts";
19
19
  import { startStack, getStackState, cancelStack, subscribeStack, restoreCompletedStacks, setStackPublishResult } from "./stack-manager.ts";
20
20
  import { getTelemetryConsent, setTelemetryConsent, telemetry } from "../../telemetry/index.ts";
21
21
 
@@ -1929,5 +1929,38 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
1929
1929
  return json({ error: msg }, 500);
1930
1930
  }
1931
1931
  },
1932
+
1933
+ "POST /api/stack/publish/preview": async (req: Request) => {
1934
+ try {
1935
+ const body = await req.json() as { sessionId: string };
1936
+ if (!body.sessionId) return json({ error: "Missing sessionId" }, 400);
1937
+
1938
+ let state = getStackState(body.sessionId);
1939
+ if (!state) {
1940
+ await restoreCompletedStacks([body.sessionId]);
1941
+ state = getStackState(body.sessionId);
1942
+ }
1943
+ if (!state) return json({ error: "No stack state found" }, 404);
1944
+ if (!state.execResult) return json({ error: "Stack not executed yet" }, 400);
1945
+ if (!state.context) return json({ error: "Missing context" }, 400);
1946
+
1947
+ const stored = await loadSession(body.sessionId);
1948
+ if (!stored) return json({ error: "Session not found" }, 404);
1949
+
1950
+ const preview = await buildStackPublishPreview({
1951
+ repo_path: state.context.repo_path,
1952
+ exec_result: state.execResult,
1953
+ pr_meta: stored.meta,
1954
+ base_branch: state.context.base_branch,
1955
+ owner: state.context.owner,
1956
+ repo: state.context.repo,
1957
+ });
1958
+
1959
+ return json({ preview });
1960
+ } catch (err) {
1961
+ const msg = err instanceof Error ? err.message : String(err);
1962
+ return json({ error: msg }, 500);
1963
+ }
1964
+ },
1932
1965
  };
1933
1966
  }