newpr 1.0.15 → 1.0.16

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.16",
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
 
@@ -79,6 +79,7 @@ interface PublishResultData {
79
79
  base_branch: string;
80
80
  head_branch: string;
81
81
  }>;
82
+ publishedAt?: number;
82
83
  }
83
84
 
84
85
  interface ServerStackState {
@@ -92,6 +93,7 @@ interface ServerStackState {
92
93
  plan: PlanData | null;
93
94
  execResult: ExecResultData | null;
94
95
  verifyResult: VerifyResultData | null;
96
+ publishResult: PublishResultData | null;
95
97
  startedAt: number;
96
98
  finishedAt: number | null;
97
99
  }
@@ -130,6 +132,7 @@ function applyServerState(server: ServerStackState): Partial<StackState> {
130
132
  plan: server.plan,
131
133
  execResult: server.execResult,
132
134
  verifyResult: server.verifyResult,
135
+ publishResult: server.publishResult,
133
136
  };
134
137
  }
135
138
 
@@ -259,11 +262,15 @@ export function useStack(sessionId: string | null | undefined, options?: UseStac
259
262
  if (!res.ok) throw new Error(data.error ?? "Publishing failed");
260
263
 
261
264
  const publishResult = data.publish_result as PublishResultData;
265
+ const serverState = (data as { state?: ServerStackState }).state;
266
+ const nextPublishResult = serverState?.publishResult ?? publishResult;
262
267
 
263
268
  setState((s) => ({
264
269
  ...s,
270
+ ...(serverState ? applyServerState(serverState) : {}),
265
271
  phase: "done",
266
- publishResult,
272
+ publishResult: nextPublishResult,
273
+ progressMessage: null,
267
274
  }));
268
275
 
269
276
  if (options?.onTrackAnalysis && publishResult?.prs?.length > 0) {
@@ -75,6 +75,14 @@ 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
+ : [];
78
86
 
79
87
  if (stack.phase === "idle") {
80
88
  return (
@@ -263,30 +271,57 @@ export function StackPanel({ sessionId, onTrackAnalysis }: StackPanelProps) {
263
271
  </button>
264
272
  )}
265
273
 
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>
274
+ {stack.publishResult && (
275
+ <div className="space-y-2 rounded-lg border border-border/70 bg-foreground/[0.015] p-2.5">
276
+ <div className="flex items-center justify-between px-1">
277
+ <div className="flex items-center gap-2">
278
+ <GitPullRequestArrow className="h-3.5 w-3.5 text-green-600/70 dark:text-green-400/70" />
279
+ <span className="text-[11px] font-medium text-foreground/80">Draft Publish Results</span>
280
+ </div>
281
+ <span className="text-[10px] text-muted-foreground/35 tabular-nums">{publishedCount}/{pushedCount} created</span>
282
+ </div>
283
+
284
+ {stack.publishResult.prs.length > 0 ? (
285
+ <div className="space-y-1.5">
286
+ {stack.publishResult.prs.map((pr) => (
287
+ <a
288
+ key={pr.number}
289
+ href={pr.url}
290
+ target="_blank"
291
+ rel="noopener noreferrer"
292
+ className="group flex items-center gap-3 rounded-lg px-2.5 py-2 hover:bg-accent/30 transition-colors"
293
+ >
294
+ <GitPullRequestArrow className="h-3.5 w-3.5 text-green-600/60 dark:text-green-400/60 shrink-0" />
295
+ <div className="flex-1 min-w-0">
296
+ <div className="flex items-center gap-2">
297
+ <span className="text-[11px] font-medium truncate">{pr.title}</span>
298
+ <span className="text-[10px] text-muted-foreground/25 tabular-nums shrink-0">#{pr.number}</span>
299
+ </div>
300
+ <div className="flex items-center gap-1 mt-0.5">
301
+ <span className="text-[10px] font-mono text-muted-foreground/25">{pr.base_branch}</span>
302
+ <ArrowRight className="h-2.5 w-2.5 text-muted-foreground/20" />
303
+ <span className="text-[10px] font-mono text-muted-foreground/25">{pr.head_branch}</span>
304
+ </div>
305
+ </div>
306
+ </a>
307
+ ))}
308
+ </div>
309
+ ) : (
310
+ <p className="text-[10px] text-muted-foreground/35 px-2.5 py-2">No draft PR URLs were returned.</p>
311
+ )}
312
+
313
+ {publishFailures.length > 0 && (
314
+ <div className="rounded-md bg-yellow-500/[0.06] px-2.5 py-2 space-y-1">
315
+ <p className="text-[10px] text-yellow-700/80 dark:text-yellow-300/80">
316
+ Some branches were pushed but PR creation did not complete.
317
+ </p>
318
+ {publishFailures.map((branch) => (
319
+ <div key={branch.name} className="text-[10px] font-mono text-yellow-700/70 dark:text-yellow-300/70 truncate">
320
+ {branch.name}
286
321
  </div>
287
- </div>
288
- </a>
289
- ))}
322
+ ))}
323
+ </div>
324
+ )}
290
325
  </div>
291
326
  )}
292
327
  </div>
@@ -16,7 +16,7 @@ import { chatWithTools, createLlmClient, type ChatTool, type ChatStreamEvent } f
16
16
  import { detectAgents, runAgent } from "../../workspace/agent.ts";
17
17
  import { randomBytes } from "node:crypto";
18
18
  import { publishStack } from "../../stack/publish.ts";
19
- import { startStack, getStackState, cancelStack, subscribeStack, restoreCompletedStacks } from "./stack-manager.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,9 @@ 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) });
1926
1927
  } catch (err) {
1927
1928
  const msg = err instanceof Error ? err.message : String(err);
1928
1929
  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
  // ---------------------------------------------------------------------------