newpr 1.0.5 → 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 +1 -1
- package/src/stack/merge-groups.test.ts +123 -1
- package/src/stack/merge-groups.ts +70 -0
- package/src/stack/pr-title.ts +49 -32
- package/src/stack/publish.ts +36 -5
- package/src/web/client/components/ChatSection.tsx +9 -3
- package/src/web/client/components/ResultsScreen.tsx +4 -0
- package/src/web/client/hooks/useChatStore.ts +1 -1
- package/src/web/server/routes.ts +36 -10
- package/src/web/server/stack-manager.ts +29 -3
- package/src/web/server.ts +5 -4
- package/src/workspace/repo-cache.ts +16 -2
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/stack/pr-title.ts
CHANGED
|
@@ -1,22 +1,32 @@
|
|
|
1
1
|
import type { LlmClient } from "../llm/client.ts";
|
|
2
2
|
import type { StackGroup } from "./types.ts";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
13
|
-
|
|
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
|
|
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:
|
|
36
|
-
-
|
|
37
|
-
-
|
|
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:
|
|
43
|
-
- "fix: null user crash"
|
|
44
|
-
- "refactor: shared
|
|
45
|
-
- "chore: eslint config"
|
|
46
|
-
- "
|
|
47
|
-
- "
|
|
48
|
-
- "refactor:
|
|
49
|
-
|
|
50
|
-
Bad examples
|
|
51
|
-
- "feat:
|
|
52
|
-
- "
|
|
53
|
-
- "refactor:
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/stack/publish.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
]
|
|
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 () => {
|
|
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
|
-
|
|
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;
|
package/src/web/server/routes.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
397
|
+
safeClose();
|
|
385
398
|
} else {
|
|
386
399
|
send("progress", JSON.stringify(event));
|
|
387
400
|
}
|
|
388
401
|
} catch {
|
|
389
|
-
|
|
402
|
+
safeClose();
|
|
390
403
|
}
|
|
391
404
|
});
|
|
392
405
|
|
|
393
406
|
if (!unsubscribe) {
|
|
394
407
|
send("analysis_error", JSON.stringify({ message: "Session not found" }));
|
|
395
|
-
|
|
408
|
+
safeClose();
|
|
396
409
|
}
|
|
397
410
|
|
|
398
411
|
req.signal.addEventListener("abort", () => {
|
|
399
412
|
unsubscribe?.();
|
|
400
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1796
|
+
safeClose();
|
|
1771
1797
|
} else {
|
|
1772
1798
|
send("progress", JSON.stringify({ ...event, state: getStackState(id) }));
|
|
1773
1799
|
}
|
|
1774
1800
|
} catch {
|
|
1775
|
-
|
|
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
|
-
|
|
1812
|
+
safeClose();
|
|
1787
1813
|
}
|
|
1788
1814
|
|
|
1789
1815
|
req.signal.addEventListener("abort", () => {
|
|
1790
1816
|
unsubscribe?.();
|
|
1791
|
-
|
|
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
|
|
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 =
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
41
|
-
|
|
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
|
}
|