newpr 1.0.15 → 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.15",
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",
@@ -2,6 +2,8 @@ import type { LlmClient } from "../llm/client.ts";
2
2
  import type { StackGroup } from "./types.ts";
3
3
 
4
4
  const MAX_TITLE_LENGTH = 72;
5
+ const KOREAN_NOUN_END_RE = /(추가|개선|수정|정리|분리|통합|구현|적용|도입|구성|지원|처리|보강|최적화|리팩터링|안정화|마이그레이션|업데이트|생성|검증|연동|변경|제거|작성|설정|관리|보호|강화|정의|확장|대응|복구|표시|유지|등록|삭제|작업)$/;
6
+ const KOREAN_VERB_END_RE = /(합니다|하였다|했다|한다|되었다|됐다|되다|하다)$/;
5
7
  const TYPE_PREFIX: Record<string, string> = {
6
8
  feature: "feat",
7
9
  feat: "feat",
@@ -21,15 +23,63 @@ function normalizeTypePrefix(type: string): string {
21
23
  return TYPE_PREFIX[type] ?? "chore";
22
24
  }
23
25
 
24
- function sanitizeTitle(raw: string): string {
25
- let title = raw.trim().replace(/\.+$/, "");
26
+ function isKoreanLanguage(languageHint: string | null): boolean {
27
+ if (!languageHint) return false;
28
+ return /korean|^ko$/i.test(languageHint);
29
+ }
30
+
31
+ function ensureNounFormDescription(description: string, languageHint: string | null): string {
32
+ let desc = description.trim().replace(/\.+$/, "");
33
+ if (!desc) return isKoreanLanguage(languageHint) ? "변경 작업" : "update";
34
+
35
+ const hasKoreanContext = /[가-힣]/.test(desc) || isKoreanLanguage(languageHint);
36
+ if (!hasKoreanContext) return desc;
37
+ if (KOREAN_NOUN_END_RE.test(desc)) return desc;
38
+
39
+ const verbTrimmed = desc.replace(KOREAN_VERB_END_RE, "").trim();
40
+ if (verbTrimmed) {
41
+ if (KOREAN_NOUN_END_RE.test(verbTrimmed)) return verbTrimmed;
42
+ return `${verbTrimmed} 작업`;
43
+ }
44
+
45
+ return "변경 작업";
46
+ }
47
+
48
+ function splitTitle(raw: string): { prefix: string; description: string } {
49
+ const idx = raw.indexOf(":");
50
+ if (idx < 0) return { prefix: "", description: raw.trim() };
51
+ return {
52
+ prefix: raw.slice(0, idx).trim(),
53
+ description: raw.slice(idx + 1).trim(),
54
+ };
55
+ }
56
+
57
+ function sanitizeTitle(raw: string, languageHint: string | null): string {
58
+ const parsed = splitTitle(raw);
59
+ const prefix = parsed.prefix;
60
+ let description = ensureNounFormDescription(parsed.description, languageHint);
61
+ let title = prefix ? `${prefix}: ${description}` : description;
62
+ title = title.trim().replace(/\.+$/, "");
63
+
26
64
  if (title.length > MAX_TITLE_LENGTH) {
27
- title = title.slice(0, MAX_TITLE_LENGTH).replace(/\s\S*$/, "").trimEnd();
65
+ if (prefix) {
66
+ const prefixText = `${prefix}: `;
67
+ const maxDesc = MAX_TITLE_LENGTH - prefixText.length;
68
+ description = description.slice(0, maxDesc).replace(/\s\S*$/, "").trimEnd();
69
+ description = ensureNounFormDescription(description, languageHint);
70
+ if (description.length > maxDesc) {
71
+ description = description.slice(0, maxDesc).trimEnd();
72
+ }
73
+ title = `${prefixText}${description || ensureNounFormDescription("", languageHint)}`;
74
+ } else {
75
+ title = title.slice(0, MAX_TITLE_LENGTH).replace(/\s\S*$/, "").trimEnd();
76
+ }
28
77
  }
78
+
29
79
  return title;
30
80
  }
31
81
 
32
- function fallbackTitle(g: StackGroup): string {
82
+ function fallbackTitle(g: StackGroup, languageHint: string | null): string {
33
83
  const desc = g.description || g.name;
34
84
  const cleaned = desc.replace(/[^\p{L}\p{N}\s\-/.,()]/gu, " ").replace(/\s+/g, " ").trim();
35
85
  const prefix = `${normalizeTypePrefix(g.type)}: `;
@@ -37,7 +87,7 @@ function fallbackTitle(g: StackGroup): string {
37
87
  const truncated = cleaned.length > maxDesc
38
88
  ? cleaned.slice(0, maxDesc).replace(/\s\S*$/, "").trimEnd()
39
89
  : cleaned;
40
- return truncated ? `${prefix}${truncated}` : `${prefix}${g.name}`;
90
+ return sanitizeTitle(truncated ? `${prefix}${truncated}` : `${prefix}${g.name}`, languageHint);
41
91
  }
42
92
 
43
93
  export async function generatePrTitles(
@@ -70,7 +120,7 @@ export async function generatePrTitles(
70
120
  Rules:
71
121
  - Format: "type: description"
72
122
  - type must be one of: feat | fix | refactor | chore | docs | test | perf | style | ci
73
- - description: 5-12 words, imperative mood, no trailing period
123
+ - description: 4-12 words, noun phrase only, must end with a noun, no trailing period
74
124
  - Target length: 40-72 characters total
75
125
  - Be specific about WHAT changed, not vague
76
126
  - Each title must be unique across the set
@@ -78,19 +128,20 @@ Rules:
78
128
  ${langRule}
79
129
 
80
130
  Good examples:
81
- - "feat: add JWT token refresh middleware for auth flow"
82
- - "fix: prevent null user crash on session expiry"
83
- - "refactor: extract shared validation logic into helpers"
84
- - "chore: migrate eslint config to flat format"
85
- - "feat: implement drag-and-drop reordering for canvas nodes"
86
- - "test: add integration tests for payment webhook handler"
87
- - "refactor: split monolithic API router into domain modules"
131
+ - "feat: JWT token refresh middleware integration"
132
+ - "fix: session expiry null user crash prevention"
133
+ - "refactor: shared validation helper extraction"
134
+ - "chore: eslint flat config migration"
135
+ - "feat: canvas node drag-and-drop reorder support"
136
+ - "test: payment webhook integration test coverage"
137
+ - "refactor: monolithic API router module split"
88
138
 
89
139
  Bad examples:
90
140
  - "feat: auth" (too vague)
91
141
  - "fix: bug" (meaningless)
92
142
  - "refactor: code" (says nothing)
93
143
  - "feat: jwt" (just a keyword, not a title)
144
+ - "feat: add JWT refresh middleware" (imperative verb)
94
145
  - "" (empty)
95
146
 
96
147
  Return ONLY a JSON array: [{"group_id": "...", "title": "..."}]`;
@@ -118,18 +169,18 @@ Generate a descriptive PR title (40-72 chars) for each group. Return JSON array:
118
169
  }
119
170
  for (const item of parsed) {
120
171
  if (item.group_id && item.title?.trim()) {
121
- titles.set(item.group_id, sanitizeTitle(item.title));
172
+ titles.set(item.group_id, sanitizeTitle(item.title, lang));
122
173
  }
123
174
  }
124
175
  } catch {
125
176
  for (const g of groups) {
126
- titles.set(g.id, fallbackTitle(g));
177
+ titles.set(g.id, fallbackTitle(g, lang));
127
178
  }
128
179
  }
129
180
 
130
181
  for (const g of groups) {
131
182
  if (!titles.has(g.id) || !titles.get(g.id)) {
132
- titles.set(g.id, fallbackTitle(g));
183
+ titles.set(g.id, fallbackTitle(g, lang));
133
184
  }
134
185
  }
135
186
 
@@ -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
 
@@ -79,6 +79,20 @@ interface PublishResultData {
79
79
  base_branch: string;
80
80
  head_branch: string;
81
81
  }>;
82
+ publishedAt?: number;
83
+ }
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
+ }>;
82
96
  }
83
97
 
84
98
  interface ServerStackState {
@@ -92,6 +106,7 @@ interface ServerStackState {
92
106
  plan: PlanData | null;
93
107
  execResult: ExecResultData | null;
94
108
  verifyResult: VerifyResultData | null;
109
+ publishResult: PublishResultData | null;
95
110
  startedAt: number;
96
111
  finishedAt: number | null;
97
112
  }
@@ -107,6 +122,9 @@ export interface StackState {
107
122
  execResult: ExecResultData | null;
108
123
  verifyResult: VerifyResultData | null;
109
124
  publishResult: PublishResultData | null;
125
+ publishPreview: PublishPreviewData | null;
126
+ publishPreviewLoading: boolean;
127
+ publishPreviewError: string | null;
110
128
  progressMessage: string | null;
111
129
  }
112
130
 
@@ -130,6 +148,7 @@ function applyServerState(server: ServerStackState): Partial<StackState> {
130
148
  plan: server.plan,
131
149
  execResult: server.execResult,
132
150
  verifyResult: server.verifyResult,
151
+ publishResult: server.publishResult,
133
152
  };
134
153
  }
135
154
 
@@ -149,6 +168,9 @@ export function useStack(sessionId: string | null | undefined, options?: UseStac
149
168
  execResult: null,
150
169
  verifyResult: null,
151
170
  publishResult: null,
171
+ publishPreview: null,
172
+ publishPreviewLoading: false,
173
+ publishPreviewError: null,
152
174
  progressMessage: null,
153
175
  });
154
176
 
@@ -259,11 +281,15 @@ export function useStack(sessionId: string | null | undefined, options?: UseStac
259
281
  if (!res.ok) throw new Error(data.error ?? "Publishing failed");
260
282
 
261
283
  const publishResult = data.publish_result as PublishResultData;
284
+ const serverState = (data as { state?: ServerStackState }).state;
285
+ const nextPublishResult = serverState?.publishResult ?? publishResult;
262
286
 
263
287
  setState((s) => ({
264
288
  ...s,
289
+ ...(serverState ? applyServerState(serverState) : {}),
265
290
  phase: "done",
266
- publishResult,
291
+ publishResult: nextPublishResult,
292
+ progressMessage: null,
267
293
  }));
268
294
 
269
295
  if (options?.onTrackAnalysis && publishResult?.prs?.length > 0) {
@@ -290,6 +316,47 @@ export function useStack(sessionId: string | null | undefined, options?: UseStac
290
316
  }
291
317
  }, [sessionId, options]);
292
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
+
293
360
  const reset = useCallback(() => {
294
361
  eventSourceRef.current?.close();
295
362
  eventSourceRef.current = null;
@@ -304,6 +371,9 @@ export function useStack(sessionId: string | null | undefined, options?: UseStac
304
371
  execResult: null,
305
372
  verifyResult: null,
306
373
  publishResult: null,
374
+ publishPreview: null,
375
+ publishPreviewLoading: false,
376
+ publishPreviewError: null,
307
377
  progressMessage: null,
308
378
  }));
309
379
  }, []);
@@ -319,6 +389,7 @@ export function useStack(sessionId: string | null | undefined, options?: UseStac
319
389
  setMaxGroups,
320
390
  runFullPipeline,
321
391
  startPublish,
392
+ loadPublishPreview,
322
393
  reset,
323
394
  };
324
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";
@@ -75,6 +75,15 @@ interface StackPanelProps {
75
75
 
76
76
  export function StackPanel({ sessionId, onTrackAnalysis }: StackPanelProps) {
77
77
  const stack = useStack(sessionId, { onTrackAnalysis });
78
+ const publishedCount = stack.publishResult?.prs.length ?? 0;
79
+ const pushedCount = stack.publishResult?.branches.filter((b) => b.pushed).length ?? 0;
80
+ const publishFailures = stack.publishResult
81
+ ? stack.publishResult.branches.filter((branch) => {
82
+ if (!branch.pushed) return true;
83
+ return !stack.publishResult?.prs.some((pr) => pr.head_branch === branch.name);
84
+ })
85
+ : [];
86
+ const previewItems = stack.publishPreview?.items ?? [];
78
87
 
79
88
  if (stack.phase === "idle") {
80
89
  return (
@@ -252,6 +261,67 @@ export function StackPanel({ sessionId, onTrackAnalysis }: StackPanelProps) {
252
261
  </div>
253
262
  )}
254
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
+
255
325
  {stack.phase === "done" && stack.execResult && !stack.publishResult && (
256
326
  <button
257
327
  type="button"
@@ -263,30 +333,57 @@ export function StackPanel({ sessionId, onTrackAnalysis }: StackPanelProps) {
263
333
  </button>
264
334
  )}
265
335
 
266
- {stack.publishResult && stack.publishResult.prs.length > 0 && (
267
- <div className="space-y-1.5">
268
- {stack.publishResult.prs.map((pr) => (
269
- <a
270
- key={pr.number}
271
- href={pr.url}
272
- target="_blank"
273
- rel="noopener noreferrer"
274
- className="group flex items-center gap-3 rounded-lg px-3.5 py-2.5 hover:bg-accent/30 transition-colors"
275
- >
276
- <GitPullRequestArrow className="h-3.5 w-3.5 text-green-600/60 dark:text-green-400/60 shrink-0" />
277
- <div className="flex-1 min-w-0">
278
- <div className="flex items-center gap-2">
279
- <span className="text-[11px] font-medium truncate">{pr.title}</span>
280
- <span className="text-[10px] text-muted-foreground/25 tabular-nums shrink-0">#{pr.number}</span>
281
- </div>
282
- <div className="flex items-center gap-1 mt-0.5">
283
- <span className="text-[10px] font-mono text-muted-foreground/25">{pr.base_branch}</span>
284
- <ArrowRight className="h-2.5 w-2.5 text-muted-foreground/20" />
285
- <span className="text-[10px] font-mono text-muted-foreground/25">{pr.head_branch}</span>
336
+ {stack.publishResult && (
337
+ <div className="space-y-2 rounded-lg border border-border/70 bg-foreground/[0.015] p-2.5">
338
+ <div className="flex items-center justify-between px-1">
339
+ <div className="flex items-center gap-2">
340
+ <GitPullRequestArrow className="h-3.5 w-3.5 text-green-600/70 dark:text-green-400/70" />
341
+ <span className="text-[11px] font-medium text-foreground/80">Draft Publish Results</span>
342
+ </div>
343
+ <span className="text-[10px] text-muted-foreground/35 tabular-nums">{publishedCount}/{pushedCount} created</span>
344
+ </div>
345
+
346
+ {stack.publishResult.prs.length > 0 ? (
347
+ <div className="space-y-1.5">
348
+ {stack.publishResult.prs.map((pr) => (
349
+ <a
350
+ key={pr.number}
351
+ href={pr.url}
352
+ target="_blank"
353
+ rel="noopener noreferrer"
354
+ className="group flex items-center gap-3 rounded-lg px-2.5 py-2 hover:bg-accent/30 transition-colors"
355
+ >
356
+ <GitPullRequestArrow className="h-3.5 w-3.5 text-green-600/60 dark:text-green-400/60 shrink-0" />
357
+ <div className="flex-1 min-w-0">
358
+ <div className="flex items-center gap-2">
359
+ <span className="text-[11px] font-medium truncate">{pr.title}</span>
360
+ <span className="text-[10px] text-muted-foreground/25 tabular-nums shrink-0">#{pr.number}</span>
361
+ </div>
362
+ <div className="flex items-center gap-1 mt-0.5">
363
+ <span className="text-[10px] font-mono text-muted-foreground/25">{pr.base_branch}</span>
364
+ <ArrowRight className="h-2.5 w-2.5 text-muted-foreground/20" />
365
+ <span className="text-[10px] font-mono text-muted-foreground/25">{pr.head_branch}</span>
366
+ </div>
367
+ </div>
368
+ </a>
369
+ ))}
370
+ </div>
371
+ ) : (
372
+ <p className="text-[10px] text-muted-foreground/35 px-2.5 py-2">No draft PR URLs were returned.</p>
373
+ )}
374
+
375
+ {publishFailures.length > 0 && (
376
+ <div className="rounded-md bg-yellow-500/[0.06] px-2.5 py-2 space-y-1">
377
+ <p className="text-[10px] text-yellow-700/80 dark:text-yellow-300/80">
378
+ Some branches were pushed but PR creation did not complete.
379
+ </p>
380
+ {publishFailures.map((branch) => (
381
+ <div key={branch.name} className="text-[10px] font-mono text-yellow-700/70 dark:text-yellow-300/70 truncate">
382
+ {branch.name}
286
383
  </div>
287
- </div>
288
- </a>
289
- ))}
384
+ ))}
385
+ </div>
386
+ )}
290
387
  </div>
291
388
  )}
292
389
  </div>
@@ -15,8 +15,8 @@ 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";
19
- import { startStack, getStackState, cancelStack, subscribeStack, restoreCompletedStacks } from "./stack-manager.ts";
18
+ import { publishStack, buildStackPublishPreview } from "../../stack/publish.ts";
19
+ import { startStack, getStackState, cancelStack, subscribeStack, restoreCompletedStacks, setStackPublishResult } from "./stack-manager.ts";
20
20
  import { getTelemetryConsent, setTelemetryConsent, telemetry } from "../../telemetry/index.ts";
21
21
 
22
22
  function json(data: unknown, status = 200): Response {
@@ -1921,8 +1921,42 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
1921
1921
  owner: state.context.owner,
1922
1922
  repo: state.context.repo,
1923
1923
  });
1924
+ await setStackPublishResult(body.sessionId, result);
1924
1925
 
1925
- return json({ publish_result: result });
1926
+ return json({ publish_result: result, state: getStackState(body.sessionId) });
1927
+ } catch (err) {
1928
+ const msg = err instanceof Error ? err.message : String(err);
1929
+ return json({ error: msg }, 500);
1930
+ }
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 });
1926
1960
  } catch (err) {
1927
1961
  const msg = err instanceof Error ? err.message : String(err);
1928
1962
  return json({ error: msg }, 500);
@@ -1,6 +1,6 @@
1
1
  import type { NewprConfig } from "../../types/config.ts";
2
2
  import type { FileGroup } from "../../types/output.ts";
3
- import type { StackWarning, FeasibilityResult, StackExecResult } from "../../stack/types.ts";
3
+ import type { StackWarning, FeasibilityResult, StackExecResult, StackPublishResult } from "../../stack/types.ts";
4
4
  import type { StackPlan } from "../../stack/types.ts";
5
5
  import { loadSession } from "../../history/store.ts";
6
6
  import { saveStackSidecar, loadStackSidecar } from "../../history/store.ts";
@@ -68,6 +68,12 @@ export interface StackVerifyData {
68
68
  structured_warnings: StackWarning[];
69
69
  }
70
70
 
71
+ export interface StackPublishData {
72
+ branches: StackPublishResult["branches"];
73
+ prs: StackPublishResult["prs"];
74
+ publishedAt: number;
75
+ }
76
+
71
77
  export interface StackStateSnapshot {
72
78
  status: StackStatus;
73
79
  phase: StackPhase | null;
@@ -79,6 +85,7 @@ export interface StackStateSnapshot {
79
85
  plan: StackPlanData | null;
80
86
  execResult: StackExecResult | null;
81
87
  verifyResult: StackVerifyData | null;
88
+ publishResult: StackPublishData | null;
82
89
  startedAt: number;
83
90
  finishedAt: number | null;
84
91
  }
@@ -95,6 +102,7 @@ interface StackSession {
95
102
  plan: StackPlanData | null;
96
103
  execResult: StackExecResult | null;
97
104
  verifyResult: StackVerifyData | null;
105
+ publishResult: StackPublishData | null;
98
106
  events: StackEvent[];
99
107
  subscribers: Set<(event: StackEvent | { type: "done" | "error"; data?: string }) => void>;
100
108
  startedAt: number;
@@ -131,6 +139,7 @@ function toSnapshot(session: StackSession): StackStateSnapshot {
131
139
  plan: session.plan,
132
140
  execResult: session.execResult,
133
141
  verifyResult: session.verifyResult,
142
+ publishResult: session.publishResult,
134
143
  startedAt: session.startedAt,
135
144
  finishedAt: session.finishedAt,
136
145
  };
@@ -173,6 +182,7 @@ export function startStack(
173
182
  plan: null,
174
183
  execResult: null,
175
184
  verifyResult: null,
185
+ publishResult: null,
176
186
  events: [],
177
187
  subscribers: new Set(),
178
188
  startedAt: Date.now(),
@@ -235,12 +245,13 @@ export async function restoreCompletedStacks(sessionIds: string[]): Promise<void
235
245
  context: snapshot.context,
236
246
  partition: snapshot.partition,
237
247
  feasibility: snapshot.feasibility,
238
- plan: snapshot.plan,
239
- execResult: snapshot.execResult,
240
- verifyResult: snapshot.verifyResult,
241
- events: [],
242
- subscribers: new Set(),
243
- startedAt: snapshot.startedAt,
248
+ plan: snapshot.plan,
249
+ execResult: snapshot.execResult,
250
+ verifyResult: snapshot.verifyResult,
251
+ publishResult: snapshot.publishResult ?? null,
252
+ events: [],
253
+ subscribers: new Set(),
254
+ startedAt: snapshot.startedAt,
244
255
  finishedAt: snapshot.finishedAt ?? Date.now(),
245
256
  abortController: new AbortController(),
246
257
  };
@@ -248,6 +259,22 @@ export async function restoreCompletedStacks(sessionIds: string[]): Promise<void
248
259
  }
249
260
  }
250
261
 
262
+ export async function setStackPublishResult(
263
+ analysisSessionId: string,
264
+ result: StackPublishResult,
265
+ ): Promise<void> {
266
+ const session = sessions.get(analysisSessionId);
267
+ if (!session) return;
268
+
269
+ session.publishResult = {
270
+ branches: result.branches,
271
+ prs: result.prs,
272
+ publishedAt: Date.now(),
273
+ };
274
+
275
+ await saveStackSidecar(session.analysisSessionId, toSnapshot(session)).catch(() => {});
276
+ }
277
+
251
278
  // ---------------------------------------------------------------------------
252
279
  // Pipeline
253
280
  // ---------------------------------------------------------------------------