newpr 1.0.4 → 1.0.7

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.4",
3
+ "version": "1.0.7",
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",
@@ -1,6 +1,7 @@
1
1
  import { describe, test, expect } from "bun:test";
2
- import { mergeGroups } from "./merge-groups.ts";
2
+ import { mergeGroups, mergeEmptyGroups } from "./merge-groups.ts";
3
3
  import type { FileGroup } from "../types/output.ts";
4
+ import type { StackGroup, StackGroupStats } from "./types.ts";
4
5
 
5
6
  describe("mergeGroups", () => {
6
7
  test("no-op when already at or below target", () => {
@@ -95,3 +96,124 @@ describe("mergeGroups", () => {
95
96
  expect(result.groups[0]!.key_changes).toContain("Added UI");
96
97
  });
97
98
  });
99
+
100
+ function makeStackGroup(overrides: Partial<StackGroup> & { id: string; name: string }): StackGroup {
101
+ return {
102
+ type: "feature",
103
+ description: "",
104
+ files: [],
105
+ deps: [],
106
+ order: 0,
107
+ ...overrides,
108
+ };
109
+ }
110
+
111
+ const zeroStats: StackGroupStats = { additions: 0, deletions: 0, files_added: 0, files_modified: 0, files_deleted: 0 };
112
+ const nonZeroStats: StackGroupStats = { additions: 10, deletions: 5, files_added: 1, files_modified: 1, files_deleted: 0 };
113
+
114
+ describe("mergeEmptyGroups", () => {
115
+ test("no-op when no empty groups", () => {
116
+ const groups: StackGroup[] = [
117
+ makeStackGroup({ id: "A", name: "A", files: ["a.ts"], order: 0, stats: nonZeroStats }),
118
+ makeStackGroup({ id: "B", name: "B", files: ["b.ts"], order: 1, stats: nonZeroStats }),
119
+ ];
120
+ const ownership = new Map([["a.ts", "A"], ["b.ts", "B"]]);
121
+ const trees = new Map([["A", "tree-a"], ["B", "tree-b"]]);
122
+
123
+ const result = mergeEmptyGroups(groups, ownership, trees);
124
+ expect(result.groups.length).toBe(2);
125
+ expect(result.merges).toEqual([]);
126
+ });
127
+
128
+ test("merges empty group into next neighbor", () => {
129
+ const groups: StackGroup[] = [
130
+ makeStackGroup({ id: "A", name: "A", files: ["a.ts"], order: 0, stats: zeroStats }),
131
+ makeStackGroup({ id: "B", name: "B", files: ["b.ts"], order: 1, stats: nonZeroStats }),
132
+ ];
133
+ const ownership = new Map([["a.ts", "A"], ["b.ts", "B"]]);
134
+ const trees = new Map([["A", "tree-a"], ["B", "tree-b"]]);
135
+
136
+ const result = mergeEmptyGroups(groups, ownership, trees);
137
+ expect(result.groups.length).toBe(1);
138
+ expect(result.groups[0]!.id).toBe("B");
139
+ expect(result.groups[0]!.files).toContain("a.ts");
140
+ expect(result.groups[0]!.files).toContain("b.ts");
141
+ expect(result.ownership.get("a.ts")).toBe("B");
142
+ expect(result.expectedTrees.has("A")).toBe(false);
143
+ expect(result.expectedTrees.has("B")).toBe(true);
144
+ expect(result.merges).toEqual([{ absorbed: "A", into: "B" }]);
145
+ });
146
+
147
+ test("merges last empty group into previous neighbor", () => {
148
+ const groups: StackGroup[] = [
149
+ makeStackGroup({ id: "A", name: "A", files: ["a.ts"], order: 0, stats: nonZeroStats }),
150
+ makeStackGroup({ id: "B", name: "B", files: ["b.ts"], order: 1, stats: zeroStats }),
151
+ ];
152
+ const ownership = new Map([["a.ts", "A"], ["b.ts", "B"]]);
153
+ const trees = new Map([["A", "tree-a"], ["B", "tree-b"]]);
154
+
155
+ const result = mergeEmptyGroups(groups, ownership, trees);
156
+ expect(result.groups.length).toBe(1);
157
+ expect(result.groups[0]!.id).toBe("A");
158
+ expect(result.groups[0]!.files).toContain("b.ts");
159
+ expect(result.ownership.get("b.ts")).toBe("A");
160
+ });
161
+
162
+ test("merges multiple empty groups", () => {
163
+ const groups: StackGroup[] = [
164
+ makeStackGroup({ id: "A", name: "A", files: ["a.ts"], order: 0, stats: zeroStats }),
165
+ makeStackGroup({ id: "B", name: "B", files: ["b.ts"], order: 1, stats: nonZeroStats }),
166
+ makeStackGroup({ id: "C", name: "C", files: ["c.ts"], order: 2, stats: zeroStats }),
167
+ ];
168
+ const ownership = new Map([["a.ts", "A"], ["b.ts", "B"], ["c.ts", "C"]]);
169
+ const trees = new Map([["A", "tree-a"], ["B", "tree-b"], ["C", "tree-c"]]);
170
+
171
+ const result = mergeEmptyGroups(groups, ownership, trees);
172
+ expect(result.groups.length).toBe(1);
173
+ expect(result.groups[0]!.id).toBe("B");
174
+ expect(result.groups[0]!.files).toContain("a.ts");
175
+ expect(result.groups[0]!.files).toContain("b.ts");
176
+ expect(result.groups[0]!.files).toContain("c.ts");
177
+ expect(result.merges.length).toBe(2);
178
+ });
179
+
180
+ test("single group is never merged even if empty", () => {
181
+ const groups: StackGroup[] = [
182
+ makeStackGroup({ id: "A", name: "A", files: ["a.ts"], order: 0, stats: zeroStats }),
183
+ ];
184
+ const ownership = new Map([["a.ts", "A"]]);
185
+ const trees = new Map([["A", "tree-a"]]);
186
+
187
+ const result = mergeEmptyGroups(groups, ownership, trees);
188
+ expect(result.groups.length).toBe(1);
189
+ expect(result.merges).toEqual([]);
190
+ });
191
+
192
+ test("groups without stats are not considered empty", () => {
193
+ const groups: StackGroup[] = [
194
+ makeStackGroup({ id: "A", name: "A", files: ["a.ts"], order: 0 }),
195
+ makeStackGroup({ id: "B", name: "B", files: ["b.ts"], order: 1, stats: nonZeroStats }),
196
+ ];
197
+ const ownership = new Map([["a.ts", "A"], ["b.ts", "B"]]);
198
+ const trees = new Map([["A", "tree-a"], ["B", "tree-b"]]);
199
+
200
+ const result = mergeEmptyGroups(groups, ownership, trees);
201
+ expect(result.groups.length).toBe(2);
202
+ expect(result.merges).toEqual([]);
203
+ });
204
+
205
+ test("order is recalculated after merges", () => {
206
+ const groups: StackGroup[] = [
207
+ makeStackGroup({ id: "A", name: "A", files: ["a.ts"], order: 0, stats: zeroStats }),
208
+ makeStackGroup({ id: "B", name: "B", files: ["b.ts"], order: 1, stats: nonZeroStats }),
209
+ makeStackGroup({ id: "C", name: "C", files: ["c.ts"], order: 2, stats: nonZeroStats }),
210
+ ];
211
+ const ownership = new Map([["a.ts", "A"], ["b.ts", "B"], ["c.ts", "C"]]);
212
+ const trees = new Map([["A", "tree-a"], ["B", "tree-b"], ["C", "tree-c"]]);
213
+
214
+ const result = mergeEmptyGroups(groups, ownership, trees);
215
+ expect(result.groups.length).toBe(2);
216
+ expect(result.groups[0]!.order).toBe(0);
217
+ expect(result.groups[1]!.order).toBe(1);
218
+ });
219
+ });
@@ -1,4 +1,5 @@
1
1
  import type { FileGroup } from "../types/output.ts";
2
+ import type { StackGroup } from "./types.ts";
2
3
 
3
4
  export interface MergeResult {
4
5
  groups: FileGroup[];
@@ -6,6 +7,13 @@ export interface MergeResult {
6
7
  merges: Array<{ absorbed: string; into: string }>;
7
8
  }
8
9
 
10
+ export interface EmptyMergeResult {
11
+ groups: StackGroup[];
12
+ ownership: Map<string, string>;
13
+ expectedTrees: Map<string, string>;
14
+ merges: Array<{ absorbed: string; into: string }>;
15
+ }
16
+
9
17
  export function mergeGroups(
10
18
  groups: FileGroup[],
11
19
  ownership: Map<string, string>,
@@ -85,3 +93,65 @@ export function mergeGroups(
85
93
 
86
94
  return { groups: working, ownership: newOwnership, merges };
87
95
  }
96
+
97
+ export function mergeEmptyGroups(
98
+ groups: StackGroup[],
99
+ ownership: Map<string, string>,
100
+ expectedTrees: Map<string, string>,
101
+ ): EmptyMergeResult {
102
+ if (groups.length <= 1) {
103
+ return { groups: [...groups], ownership: new Map(ownership), expectedTrees: new Map(expectedTrees), merges: [] };
104
+ }
105
+
106
+ const working = groups.map((g) => ({ ...g, files: [...g.files] }));
107
+ const newOwnership = new Map(ownership);
108
+ const newTrees = new Map(expectedTrees);
109
+ const merges: Array<{ absorbed: string; into: string }> = [];
110
+
111
+ let i = 0;
112
+ while (i < working.length) {
113
+ const g = working[i]!;
114
+ const stats = g.stats;
115
+ const totalChanges = stats ? stats.additions + stats.deletions : -1;
116
+
117
+ if (totalChanges !== 0 || working.length <= 1) {
118
+ i++;
119
+ continue;
120
+ }
121
+
122
+ const neighborIdx = i < working.length - 1 ? i + 1 : i - 1;
123
+ const neighbor = working[neighborIdx]!;
124
+
125
+ for (const file of g.files) {
126
+ if (!neighbor.files.includes(file)) {
127
+ neighbor.files.push(file);
128
+ }
129
+ }
130
+
131
+ if (neighbor.stats && stats) {
132
+ neighbor.stats = {
133
+ additions: neighbor.stats.additions + stats.additions,
134
+ deletions: neighbor.stats.deletions + stats.deletions,
135
+ files_added: neighbor.stats.files_added + stats.files_added,
136
+ files_modified: neighbor.stats.files_modified + stats.files_modified,
137
+ files_deleted: neighbor.stats.files_deleted + stats.files_deleted,
138
+ };
139
+ }
140
+
141
+ for (const [path, groupId] of newOwnership) {
142
+ if (groupId === g.id) {
143
+ newOwnership.set(path, neighbor.id);
144
+ }
145
+ }
146
+
147
+ newTrees.delete(g.id);
148
+ merges.push({ absorbed: g.name, into: neighbor.name });
149
+ working.splice(i, 1);
150
+
151
+ for (let j = 0; j < working.length; j++) {
152
+ working[j]!.order = j;
153
+ }
154
+ }
155
+
156
+ return { groups: working, ownership: newOwnership, expectedTrees: newTrees, merges };
157
+ }
@@ -1,22 +1,32 @@
1
1
  import type { LlmClient } from "../llm/client.ts";
2
2
  import type { StackGroup } from "./types.ts";
3
3
 
4
- function truncateTitle(type: string, text: string): string {
5
- const words = text.split(/\s+/).filter(Boolean);
6
- const kept = words.slice(0, 5).join(" ");
7
- const title = `${type}: ${kept}`;
8
- return title.length > 40 ? title.slice(0, 40).trimEnd() : title;
4
+ const MAX_TITLE_LENGTH = 72;
5
+
6
+ function sanitizeTitle(raw: string): string {
7
+ let title = raw.trim().replace(/\.+$/, "");
8
+ if (title.length > MAX_TITLE_LENGTH) {
9
+ title = title.slice(0, MAX_TITLE_LENGTH).replace(/\s\S*$/, "").trimEnd();
10
+ }
11
+ return title;
9
12
  }
10
13
 
11
14
  function fallbackTitle(g: StackGroup): string {
12
- const slug = g.name.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
13
- return truncateTitle(g.type, slug);
15
+ const desc = g.description || g.name;
16
+ const cleaned = desc.replace(/[^\w\s\-/.,()]/g, " ").replace(/\s+/g, " ").trim();
17
+ const prefix = `${g.type}: `;
18
+ const maxDesc = MAX_TITLE_LENGTH - prefix.length;
19
+ const truncated = cleaned.length > maxDesc
20
+ ? cleaned.slice(0, maxDesc).replace(/\s\S*$/, "").trimEnd()
21
+ : cleaned;
22
+ return truncated ? `${prefix}${truncated}` : `${g.type}: ${g.name}`;
14
23
  }
15
24
 
16
25
  export async function generatePrTitles(
17
26
  llmClient: LlmClient,
18
27
  groups: StackGroup[],
19
28
  prTitle: string,
29
+ language?: string,
20
30
  ): Promise<Map<string, string>> {
21
31
  const groupSummaries = groups
22
32
  .map((g, i) => [
@@ -27,38 +37,46 @@ export async function generatePrTitles(
27
37
  ].join("\n"))
28
38
  .join("\n\n");
29
39
 
30
- const system = `You generate short PR titles for stacked PRs — like real GitHub PR titles.
40
+ const lang = language && language !== "English" ? language : null;
41
+ const langRule = lang
42
+ ? `- Write the description part in ${lang}. Keep the type prefix (feat/fix/etc.) in English.`
43
+ : "- Write the description in English.";
44
+
45
+ const system = `You generate PR titles for stacked PRs — concise but descriptive, like titles written by a senior engineer.
31
46
 
32
47
  Rules:
33
48
  - Format: "type: description"
34
- - type must be one of: feat | fix | refactor | chore | docs | test | perf
35
- - description: 2-5 words MAX. Imperative mood, lowercase, no period
36
- - HARD LIMIT: entire title must be under 40 characters total
37
- - Cut aggressively. Think of it as a git branch name in prose form
38
- - NO scope parentheses, NO filler words (add, implement, introduce, update, support, handle, ensure)
49
+ - type must be one of: feat | fix | refactor | chore | docs | test | perf | style | ci
50
+ - description: 5-12 words, imperative mood, no trailing period
51
+ - Target length: 40-72 characters total
52
+ - Be specific about WHAT changed, not vague
39
53
  - Each title must be unique across the set
54
+ - Never leave description empty
55
+ ${langRule}
40
56
 
41
57
  Good examples:
42
- - "feat: jwt token refresh"
43
- - "fix: null user crash"
44
- - "refactor: shared validators"
45
- - "chore: eslint config"
46
- - "test: auth edge cases"
47
- - "feat: loop node schema"
48
- - "refactor: canvas renderer"
49
-
50
- Bad examples (TOO LONG):
51
- - "feat: add jwt token refresh middleware for authentication" (way too long)
52
- - "feat: implement loop node support for workflow editor" (too many words)
53
- - "refactor: update canvas rendering logic to support new shapes" (sentence, not title)
54
-
55
- Return ONLY JSON array: [{"group_id": "...", "title": "..."}]`;
58
+ - "feat: add JWT token refresh middleware for auth flow"
59
+ - "fix: prevent null user crash on session expiry"
60
+ - "refactor: extract shared validation logic into helpers"
61
+ - "chore: migrate eslint config to flat format"
62
+ - "feat: implement drag-and-drop reordering for canvas nodes"
63
+ - "test: add integration tests for payment webhook handler"
64
+ - "refactor: split monolithic API router into domain modules"
65
+
66
+ Bad examples:
67
+ - "feat: auth" (too vague)
68
+ - "fix: bug" (meaningless)
69
+ - "refactor: code" (says nothing)
70
+ - "feat: jwt" (just a keyword, not a title)
71
+ - "" (empty)
72
+
73
+ Return ONLY a JSON array: [{"group_id": "...", "title": "..."}]`;
56
74
 
57
75
  const user = `Original PR: "${prTitle}"
58
76
 
59
77
  ${groupSummaries}
60
78
 
61
- Generate a unique, short PR title for each group (<40 chars). Return JSON array:
79
+ Generate a descriptive PR title (40-72 chars) for each group. Return JSON array:
62
80
  [{"group_id": "...", "title": "..."}]`;
63
81
 
64
82
  const response = await llmClient.complete(system, user);
@@ -69,9 +87,8 @@ Generate a unique, short PR title for each group (<40 chars). Return JSON array:
69
87
  const cleaned = response.content.replace(/```(?:json)?\s*/g, "").replace(/```\s*/g, "").trim();
70
88
  const parsed = JSON.parse(cleaned) as Array<{ group_id: string; title: string }>;
71
89
  for (const item of parsed) {
72
- if (item.group_id && item.title) {
73
- const t = item.title.length > 40 ? truncateTitle(item.title.split(":")[0] ?? "chore", item.title.split(":").slice(1).join(":").trim()) : item.title;
74
- titles.set(item.group_id, t);
90
+ if (item.group_id && item.title?.trim()) {
91
+ titles.set(item.group_id, sanitizeTitle(item.title));
75
92
  }
76
93
  }
77
94
  } catch {
@@ -81,7 +98,7 @@ Generate a unique, short PR title for each group (<40 chars). Return JSON array:
81
98
  }
82
99
 
83
100
  for (const g of groups) {
84
- if (!titles.has(g.id)) {
101
+ if (!titles.has(g.id) || !titles.get(g.id)) {
85
102
  titles.set(g.id, fallbackTitle(g));
86
103
  }
87
104
  }
@@ -10,10 +10,34 @@ export interface PublishInput {
10
10
  repo: string;
11
11
  }
12
12
 
13
+ const PR_TEMPLATE_PATHS = [
14
+ ".github/PULL_REQUEST_TEMPLATE.md",
15
+ ".github/pull_request_template.md",
16
+ ".github/PULL_REQUEST_TEMPLATE",
17
+ "PULL_REQUEST_TEMPLATE.md",
18
+ "PULL_REQUEST_TEMPLATE",
19
+ "docs/PULL_REQUEST_TEMPLATE.md",
20
+ "docs/pull_request_template.md",
21
+ ];
22
+
23
+ async function readPrTemplate(repoPath: string, headSha: string): Promise<string | null> {
24
+ for (const path of PR_TEMPLATE_PATHS) {
25
+ const result = await Bun.$`git -C ${repoPath} show ${headSha}:${path}`.quiet().nothrow();
26
+ if (result.exitCode === 0) {
27
+ const content = result.stdout.toString().trim();
28
+ if (content) return content;
29
+ }
30
+ }
31
+ return null;
32
+ }
33
+
13
34
  export async function publishStack(input: PublishInput): Promise<StackPublishResult> {
14
35
  const { repo_path, exec_result, pr_meta, base_branch, owner, repo } = input;
15
36
  const ghRepo = `${owner}/${repo}`;
16
37
 
38
+ const headSha = exec_result.group_commits.at(-1)?.commit_sha ?? "HEAD";
39
+ const prTemplate = await readPrTemplate(repo_path, headSha);
40
+
17
41
  const branches: BranchInfo[] = [];
18
42
  const prs: PrInfo[] = [];
19
43
  const total = exec_result.group_commits.length;
@@ -68,17 +92,17 @@ export async function publishStack(input: PublishInput): Promise<StackPublishRes
68
92
  }
69
93
  }
70
94
 
71
- await updatePrBodies(ghRepo, prs, pr_meta);
95
+ await updatePrBodies(ghRepo, prs, pr_meta, prTemplate);
72
96
 
73
97
  return { branches, prs };
74
98
  }
75
99
 
76
- async function updatePrBodies(ghRepo: string, prs: PrInfo[], prMeta: PrMeta): Promise<void> {
100
+ async function updatePrBodies(ghRepo: string, prs: PrInfo[], prMeta: PrMeta, prTemplate: string | null): Promise<void> {
77
101
  if (prs.length === 0) return;
78
102
 
79
103
  for (let i = 0; i < prs.length; i++) {
80
104
  const pr = prs[i]!;
81
- const body = buildFullBody(pr, i, prs, prMeta);
105
+ const body = buildFullBody(pr, i, prs, prMeta, prTemplate);
82
106
 
83
107
  await Bun.$`gh pr edit ${pr.number} --repo ${ghRepo} --body ${body}`.quiet().nothrow();
84
108
  }
@@ -105,6 +129,7 @@ function buildFullBody(
105
129
  index: number,
106
130
  allPrs: PrInfo[],
107
131
  prMeta: PrMeta,
132
+ prTemplate: string | null,
108
133
  ): string {
109
134
  const total = allPrs.length;
110
135
  const order = index + 1;
@@ -125,7 +150,7 @@ function buildFullBody(
125
150
  ? `➡️ Next: [#${allPrs[index + 1]!.number}](${allPrs[index + 1]!.url})`
126
151
  : "➡️ Next: top of stack";
127
152
 
128
- return [
153
+ const lines = [
129
154
  `> **Stack ${order}/${total}** — This PR is part of a stacked PR chain created by [newpr](https://github.com/jiwonMe/newpr).`,
130
155
  `> Source: #${prMeta.pr_number} ${prMeta.pr_title}`,
131
156
  ``,
@@ -142,7 +167,13 @@ function buildFullBody(
142
167
  `## ${current.group_id}`,
143
168
  ``,
144
169
  `*From PR [#${prMeta.pr_number}](${prMeta.pr_url}): ${prMeta.pr_title}*`,
145
- ].join("\n");
170
+ ];
171
+
172
+ if (prTemplate) {
173
+ lines.push(``, `---`, ``, prTemplate);
174
+ }
175
+
176
+ return lines.join("\n");
146
177
  }
147
178
 
148
179
  function statusEmoji(prIndex: number, currentIndex: number): string {
@@ -109,11 +109,13 @@ function ThrottledMarkdown({ content, onAnchorClick, activeId }: {
109
109
  timerRef.current = null;
110
110
  }, 150);
111
111
  }
112
- return () => {};
113
112
  }, [content]);
114
113
 
115
114
  useEffect(() => {
116
- return () => { if (timerRef.current) clearTimeout(timerRef.current); };
115
+ return () => {
116
+ if (timerRef.current) clearTimeout(timerRef.current);
117
+ setRendered(pendingRef.current);
118
+ };
117
119
  }, []);
118
120
 
119
121
  return <Markdown onAnchorClick={onAnchorClick} activeId={activeId}>{rendered}</Markdown>;
@@ -148,7 +150,11 @@ function AssistantMessage({ segments, activeToolName, isStreaming, onAnchorClick
148
150
  {activeToolName && (
149
151
  <div className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-accent/40 text-[11px] text-muted-foreground/50">
150
152
  <Loader2 className="h-2.5 w-2.5 animate-spin" />
151
- <span className="font-mono">{activeToolName}</span>
153
+ {activeToolName === "thinking" ? (
154
+ <span>Thinking…</span>
155
+ ) : (
156
+ <span className="font-mono">{activeToolName}</span>
157
+ )}
152
158
  </div>
153
159
  )}
154
160
  {isStreaming && !hasContent && !activeToolName && segments.length === 0 && (
@@ -69,6 +69,10 @@ export function ResultsScreen({
69
69
  const [reviewOpen, setReviewOpen] = useState(false);
70
70
  const outdated = useOutdatedCheck(sessionId);
71
71
 
72
+ useEffect(() => {
73
+ onTabChange?.(tab);
74
+ }, []);
75
+
72
76
  const stickyRef = useRef<HTMLDivElement>(null);
73
77
  const collapsibleRef = useRef<HTMLDivElement>(null);
74
78
  const compactRef = useRef<HTMLDivElement>(null);
@@ -140,7 +140,7 @@ class ChatStore {
140
140
  case "tool_result": {
141
141
  const tc = allToolCalls.find((c) => c.id === data.id);
142
142
  if (tc) tc.result = data.result;
143
- this.update(sessionId, { streaming: { segments: [...orderedSegments] } });
143
+ this.update(sessionId, { streaming: { segments: [...orderedSegments], activeToolName: "thinking" } });
144
144
  break;
145
145
  }
146
146
  case "done": break;
@@ -216,8 +216,9 @@ export function useStack(sessionId: string | null | undefined, options?: UseStac
216
216
  });
217
217
 
218
218
  es.onerror = () => {
219
- es.close();
220
- eventSourceRef.current = null;
219
+ if (es.readyState === EventSource.CLOSED) {
220
+ eventSourceRef.current = null;
221
+ }
221
222
  };
222
223
  }, []);
223
224
 
@@ -370,34 +370,47 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
370
370
  const stream = new ReadableStream({
371
371
  start(controller) {
372
372
  const encoder = new TextEncoder();
373
+ let closed = false;
373
374
  const send = (eventType: string, data: string) => {
375
+ if (closed) return;
374
376
  controller.enqueue(encoder.encode(`event: ${eventType}\ndata: ${data}\n\n`));
375
377
  };
378
+ const safeClose = () => {
379
+ if (closed) return;
380
+ closed = true;
381
+ clearInterval(heartbeat);
382
+ setTimeout(() => { try { controller.close(); } catch {} }, 50);
383
+ };
384
+
385
+ const heartbeat = setInterval(() => {
386
+ if (closed) return;
387
+ try { controller.enqueue(encoder.encode(":keepalive\n\n")); } catch { safeClose(); }
388
+ }, 15_000);
376
389
 
377
390
  const unsubscribe = subscribe(id, (event) => {
378
391
  try {
379
392
  if ("type" in event && event.type === "done") {
380
393
  send("done", JSON.stringify({}));
381
- controller.close();
394
+ safeClose();
382
395
  } else if ("type" in event && event.type === "error") {
383
396
  send("analysis_error", JSON.stringify({ message: event.data ?? "Unknown error" }));
384
- controller.close();
397
+ safeClose();
385
398
  } else {
386
399
  send("progress", JSON.stringify(event));
387
400
  }
388
401
  } catch {
389
- controller.close();
402
+ safeClose();
390
403
  }
391
404
  });
392
405
 
393
406
  if (!unsubscribe) {
394
407
  send("analysis_error", JSON.stringify({ message: "Session not found" }));
395
- controller.close();
408
+ safeClose();
396
409
  }
397
410
 
398
411
  req.signal.addEventListener("abort", () => {
399
412
  unsubscribe?.();
400
- try { controller.close(); } catch {}
413
+ safeClose();
401
414
  });
402
415
  },
403
416
  });
@@ -1756,23 +1769,36 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
1756
1769
  const stream = new ReadableStream({
1757
1770
  start(controller) {
1758
1771
  const encoder = new TextEncoder();
1772
+ let closed = false;
1759
1773
  const send = (eventType: string, data: string) => {
1774
+ if (closed) return;
1760
1775
  controller.enqueue(encoder.encode(`event: ${eventType}\ndata: ${data}\n\n`));
1761
1776
  };
1777
+ const safeClose = () => {
1778
+ if (closed) return;
1779
+ closed = true;
1780
+ clearInterval(heartbeat);
1781
+ setTimeout(() => { try { controller.close(); } catch {} }, 50);
1782
+ };
1783
+
1784
+ const heartbeat = setInterval(() => {
1785
+ if (closed) return;
1786
+ try { controller.enqueue(encoder.encode(":keepalive\n\n")); } catch { safeClose(); }
1787
+ }, 15_000);
1762
1788
 
1763
1789
  const unsubscribe = subscribeStack(id, (event) => {
1764
1790
  try {
1765
1791
  if ("type" in event && event.type === "done") {
1766
1792
  send("done", JSON.stringify({ state: getStackState(id) }));
1767
- controller.close();
1793
+ safeClose();
1768
1794
  } else if ("type" in event && event.type === "error") {
1769
1795
  send("stack_error", JSON.stringify({ message: event.data ?? "Unknown error", state: getStackState(id) }));
1770
- controller.close();
1796
+ safeClose();
1771
1797
  } else {
1772
1798
  send("progress", JSON.stringify({ ...event, state: getStackState(id) }));
1773
1799
  }
1774
1800
  } catch {
1775
- controller.close();
1801
+ safeClose();
1776
1802
  }
1777
1803
  });
1778
1804
 
@@ -1783,12 +1809,12 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
1783
1809
  } else {
1784
1810
  send("stack_error", JSON.stringify({ message: "No stack session found" }));
1785
1811
  }
1786
- controller.close();
1812
+ safeClose();
1787
1813
  }
1788
1814
 
1789
1815
  req.signal.addEventListener("abort", () => {
1790
1816
  unsubscribe?.();
1791
- try { controller.close(); } catch {}
1817
+ safeClose();
1792
1818
  });
1793
1819
  },
1794
1820
  });
@@ -12,7 +12,7 @@ import { partitionGroups } from "../../stack/partition.ts";
12
12
  import { applyCouplingRules } from "../../stack/coupling.ts";
13
13
  import { splitOversizedGroups } from "../../stack/split.ts";
14
14
  import { rebalanceGroups } from "../../stack/balance.ts";
15
- import { mergeGroups } from "../../stack/merge-groups.ts";
15
+ import { mergeGroups, mergeEmptyGroups } from "../../stack/merge-groups.ts";
16
16
  import { checkFeasibility } from "../../stack/feasibility.ts";
17
17
  import { createStackPlan } from "../../stack/plan.ts";
18
18
  import { executeStack } from "../../stack/execute.ts";
@@ -288,7 +288,7 @@ async function runStackPipeline(
288
288
  const baseBranch = baseObj.ref as string;
289
289
  const headBranch = headObj.ref as string;
290
290
 
291
- const repoPath = await ensureRepo(parsed.owner, parsed.repo, token);
291
+ const repoPath = await ensureRepo(parsed.owner, parsed.repo, token, undefined, [baseSha, headSha]);
292
292
 
293
293
  session.context = {
294
294
  repo_path: repoPath,
@@ -452,8 +452,34 @@ async function runStackPipeline(
452
452
  if (s) group.stats = s;
453
453
  }
454
454
 
455
+ const emptyMerged = mergeEmptyGroups(plan.groups, ownership, plan.expected_trees);
456
+ if (emptyMerged.merges.length > 0) {
457
+ plan.groups = emptyMerged.groups;
458
+ plan.expected_trees = emptyMerged.expectedTrees;
459
+ for (const [path, groupId] of emptyMerged.ownership) {
460
+ ownership.set(path, groupId);
461
+ }
462
+ const emptyDetails = emptyMerged.merges.map((m) => `"${m.absorbed}" → "${m.into}"`);
463
+ allWarnings.push(`Merged ${emptyMerged.merges.length} empty group(s): ${emptyDetails.join(", ")}`);
464
+ allStructuredWarnings.push({
465
+ category: "grouping",
466
+ severity: "info",
467
+ title: `${emptyMerged.merges.length} empty group(s) merged`,
468
+ message: "Groups with zero effective changes were absorbed into adjacent groups",
469
+ details: emptyDetails,
470
+ });
471
+ emit(session, "planning", `Merged ${emptyMerged.merges.length} empty group(s)...`);
472
+
473
+ session.partition = {
474
+ ...session.partition!,
475
+ ownership: Object.fromEntries(ownership),
476
+ warnings: allWarnings,
477
+ structured_warnings: allStructuredWarnings,
478
+ };
479
+ }
480
+
455
481
  emit(session, "planning", "Generating PR titles...");
456
- const prTitles = await generatePrTitles(llmClient, plan.groups, stored.meta.pr_title);
482
+ const prTitles = await generatePrTitles(llmClient, plan.groups, stored.meta.pr_title, config.language);
457
483
  for (const group of plan.groups) {
458
484
  const title = prTitles.get(group.id);
459
485
  if (title) group.pr_title = title;
package/src/web/server.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { join, dirname } from "node:path";
1
+ import { join } from "node:path";
2
2
  import type { NewprConfig } from "../types/config.ts";
3
3
  import { createRoutes } from "./server/routes.ts";
4
4
  import index from "./index.html";
@@ -16,11 +16,12 @@ interface WebServerOptions {
16
16
  }
17
17
 
18
18
  function getCssPaths() {
19
- const webDir = dirname(Bun.resolveSync("./src/web/index.html", process.cwd()));
19
+ const webDir = import.meta.dir;
20
+ const packageRoot = join(webDir, "..", "..");
20
21
  return {
21
22
  input: join(webDir, "styles", "globals.css"),
22
23
  output: join(webDir, "styles", "built.css"),
23
- bin: join(process.cwd(), "node_modules", ".bin", "tailwindcss"),
24
+ bin: join(packageRoot, "node_modules", ".bin", "tailwindcss"),
24
25
  };
25
26
  }
26
27
 
@@ -86,7 +87,7 @@ export async function startWebServer(options: WebServerOptions): Promise<void> {
86
87
  const path = url.pathname;
87
88
 
88
89
  if (path.startsWith("/assets/")) {
89
- const webDir = dirname(Bun.resolveSync("./src/web/index.html", process.cwd()));
90
+ const webDir = import.meta.dir;
90
91
  const filePath = join(webDir, path);
91
92
  const file = Bun.file(filePath);
92
93
  return file.exists().then((exists) => {
@@ -33,12 +33,18 @@ export async function ensureRepo(
33
33
  repo: string,
34
34
  token: string,
35
35
  onProgress?: (msg: string) => void,
36
+ requiredShas?: string[],
36
37
  ): Promise<string> {
37
38
  const repoPath = bareRepoPath(owner, repo);
38
39
 
39
40
  if (existsSync(join(repoPath, "HEAD"))) {
40
- if (needsFetch(repoPath)) {
41
- onProgress?.("Fetching latest changes...");
41
+ const stale = needsFetch(repoPath);
42
+ const missing = !stale && requiredShas?.length
43
+ ? await hasMissingShas(repoPath, requiredShas)
44
+ : false;
45
+
46
+ if (stale || missing) {
47
+ onProgress?.(missing ? "Fetching new commits..." : "Fetching latest changes...");
42
48
  const fetch = await Bun.$`git -C ${repoPath} fetch --all --prune`.quiet().nothrow();
43
49
  if (fetch.exitCode !== 0) {
44
50
  throw new Error(`git fetch failed (exit ${fetch.exitCode}): ${fetch.stderr.toString().trim()}`);
@@ -64,6 +70,14 @@ export async function ensureRepo(
64
70
  return repoPath;
65
71
  }
66
72
 
73
+ async function hasMissingShas(repoPath: string, shas: string[]): Promise<boolean> {
74
+ for (const sha of shas) {
75
+ const result = await Bun.$`git -C ${repoPath} cat-file -t ${sha}`.quiet().nothrow();
76
+ if (result.exitCode !== 0) return true;
77
+ }
78
+ return false;
79
+ }
80
+
67
81
  export function getReposDir(): string {
68
82
  return REPOS_DIR;
69
83
  }