newpr 0.6.5 → 1.0.0

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.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/src/history/store.ts +25 -0
  3. package/src/stack/balance.ts +128 -0
  4. package/src/stack/coupling.test.ts +158 -0
  5. package/src/stack/coupling.ts +135 -0
  6. package/src/stack/delta.test.ts +223 -0
  7. package/src/stack/delta.ts +264 -0
  8. package/src/stack/execute.test.ts +176 -0
  9. package/src/stack/execute.ts +194 -0
  10. package/src/stack/feasibility.test.ts +185 -0
  11. package/src/stack/feasibility.ts +286 -0
  12. package/src/stack/integration.test.ts +266 -0
  13. package/src/stack/merge-groups.test.ts +97 -0
  14. package/src/stack/merge-groups.ts +87 -0
  15. package/src/stack/partition.test.ts +233 -0
  16. package/src/stack/partition.ts +273 -0
  17. package/src/stack/plan.test.ts +154 -0
  18. package/src/stack/plan.ts +139 -0
  19. package/src/stack/pr-title.ts +64 -0
  20. package/src/stack/publish.ts +96 -0
  21. package/src/stack/split.ts +173 -0
  22. package/src/stack/types.ts +202 -0
  23. package/src/stack/verify.test.ts +137 -0
  24. package/src/stack/verify.ts +201 -0
  25. package/src/web/client/components/FeasibilityAlert.tsx +64 -0
  26. package/src/web/client/components/InputScreen.tsx +100 -89
  27. package/src/web/client/components/ResultsScreen.tsx +10 -2
  28. package/src/web/client/components/StackGroupCard.tsx +171 -0
  29. package/src/web/client/components/StackWarnings.tsx +135 -0
  30. package/src/web/client/hooks/useStack.ts +301 -0
  31. package/src/web/client/panels/StackPanel.tsx +289 -0
  32. package/src/web/server/routes.ts +114 -0
  33. package/src/web/server/stack-manager.ts +580 -0
  34. package/src/web/server.ts +15 -0
  35. package/src/web/styles/built.css +1 -1
@@ -0,0 +1,289 @@
1
+ import { Loader2, Play, Upload, RotateCcw, CheckCircle2, AlertTriangle, Circle, GitPullRequestArrow, ArrowRight, Layers } from "lucide-react";
2
+ import { useStack } from "../hooks/useStack.ts";
3
+ import { FeasibilityAlert } from "../components/FeasibilityAlert.tsx";
4
+ import { StackGroupCard } from "../components/StackGroupCard.tsx";
5
+ import { StackWarnings } from "../components/StackWarnings.tsx";
6
+
7
+ type StackPhase = "idle" | "partitioning" | "planning" | "executing" | "publishing" | "done" | "error";
8
+
9
+ const PIPELINE_STEPS = [
10
+ { phase: "partitioning" as const, label: "Partition", description: "Assigning files to groups" },
11
+ { phase: "planning" as const, label: "Plan", description: "Building stack plan" },
12
+ { phase: "executing" as const, label: "Execute", description: "Creating commits" },
13
+ ] as const;
14
+
15
+ function getStepState(stepPhase: string, currentPhase: StackPhase, isDone: boolean) {
16
+ const order = ["partitioning", "planning", "executing"];
17
+ const stepIdx = order.indexOf(stepPhase);
18
+ const currentIdx = order.indexOf(currentPhase);
19
+
20
+ if (isDone || (currentIdx > stepIdx)) return "done";
21
+ if (currentPhase === stepPhase) return "active";
22
+ return "pending";
23
+ }
24
+
25
+ function PipelineTimeline({ phase }: { phase: StackPhase }) {
26
+ const isDone = phase === "done" || phase === "publishing";
27
+
28
+ return (
29
+ <div className="relative flex flex-col gap-0 py-1">
30
+ {PIPELINE_STEPS.map((step, i) => {
31
+ const state = getStepState(step.phase, phase, isDone);
32
+ const isLast = i === PIPELINE_STEPS.length - 1;
33
+
34
+ return (
35
+ <div key={step.phase} className="relative flex items-start gap-3">
36
+ {!isLast && (
37
+ <div className={`absolute left-[9px] top-[20px] w-px h-[calc(100%-8px)] ${
38
+ state === "done" ? "bg-foreground/20" : "bg-border"
39
+ }`} />
40
+ )}
41
+
42
+ <div className="relative z-10 mt-0.5 shrink-0">
43
+ {state === "done" ? (
44
+ <CheckCircle2 className="h-[18px] w-[18px] text-foreground/70" />
45
+ ) : state === "active" ? (
46
+ <Loader2 className="h-[18px] w-[18px] text-foreground animate-spin" />
47
+ ) : (
48
+ <Circle className="h-[18px] w-[18px] text-muted-foreground/20" />
49
+ )}
50
+ </div>
51
+
52
+ <div className={`pb-4 min-w-0 ${isLast ? "pb-0" : ""}`}>
53
+ <span className={`text-[12px] font-medium ${
54
+ state === "done" ? "text-muted-foreground"
55
+ : state === "active" ? "text-foreground"
56
+ : "text-muted-foreground/40"
57
+ }`}>
58
+ {step.label}
59
+ </span>
60
+ {state === "active" && (
61
+ <p className="text-[11px] text-muted-foreground/50 mt-0.5">{step.description}</p>
62
+ )}
63
+ </div>
64
+ </div>
65
+ );
66
+ })}
67
+ </div>
68
+ );
69
+ }
70
+
71
+ export function StackPanel({ sessionId }: { sessionId?: string | null }) {
72
+ const stack = useStack(sessionId);
73
+
74
+ if (stack.phase === "idle") {
75
+ return (
76
+ <div className="pt-6 px-1">
77
+ <div className="flex items-center gap-2.5 mb-5">
78
+ <div className="h-8 w-8 rounded-lg bg-foreground/[0.04] flex items-center justify-center">
79
+ <Layers className="h-4 w-4 text-foreground/50" />
80
+ </div>
81
+ <div>
82
+ <h3 className="text-[13px] font-semibold text-foreground">PR Stacking</h3>
83
+ <p className="text-[11px] text-muted-foreground/50">Split into focused, reviewable PRs</p>
84
+ </div>
85
+ </div>
86
+
87
+ <p className="text-[11px] text-muted-foreground/40 leading-[1.6] mb-5">
88
+ Automatically split this PR into a stack of smaller draft PRs based on the analysis groups. Each group becomes its own PR with proper dependency ordering.
89
+ </p>
90
+
91
+ <div className="flex items-center gap-3 mb-5">
92
+ <span className="text-[11px] text-muted-foreground/50">Max PRs</span>
93
+ <div className="flex items-center">
94
+ <button
95
+ type="button"
96
+ onClick={() => stack.setMaxGroups(Math.max(1, (stack.maxGroups ?? 2) - 1))}
97
+ className="h-7 w-7 rounded-l-md border border-r-0 bg-transparent text-[11px] text-muted-foreground/50 hover:bg-accent/30 transition-colors flex items-center justify-center"
98
+ >
99
+
100
+ </button>
101
+ <input
102
+ type="number"
103
+ min={1}
104
+ placeholder="auto"
105
+ value={stack.maxGroups ?? ""}
106
+ onChange={(e) => stack.setMaxGroups(e.target.value ? Number(e.target.value) : null)}
107
+ className="h-7 w-12 border-y bg-transparent text-[11px] text-center tabular-nums placeholder:text-muted-foreground/25 focus:outline-none"
108
+ />
109
+ <button
110
+ type="button"
111
+ onClick={() => stack.setMaxGroups((stack.maxGroups ?? 2) + 1)}
112
+ className="h-7 w-7 rounded-r-md border border-l-0 bg-transparent text-[11px] text-muted-foreground/50 hover:bg-accent/30 transition-colors flex items-center justify-center"
113
+ >
114
+ +
115
+ </button>
116
+ </div>
117
+ </div>
118
+
119
+ <button
120
+ type="button"
121
+ onClick={stack.runFullPipeline}
122
+ className="w-full flex items-center justify-center gap-2 rounded-lg bg-foreground text-background px-4 py-2.5 text-[12px] font-medium hover:bg-foreground/90 transition-colors"
123
+ >
124
+ <Play className="h-3.5 w-3.5" />
125
+ Start Stacking
126
+ </button>
127
+ </div>
128
+ );
129
+ }
130
+
131
+ const isRunning = ["partitioning", "planning", "executing", "publishing"].includes(stack.phase);
132
+
133
+ return (
134
+ <div className="pt-6 px-1 space-y-5">
135
+ <div className="flex items-center gap-2">
136
+ <div className="flex items-center gap-2 flex-1 min-w-0">
137
+ <Layers className="h-3.5 w-3.5 text-muted-foreground/40 shrink-0" />
138
+ <span className="text-[12px] font-semibold text-foreground">PR Stacking</span>
139
+ {stack.phase === "done" && !stack.publishResult && (
140
+ <span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-foreground/[0.06] text-muted-foreground/60">Ready</span>
141
+ )}
142
+ {stack.publishResult && (
143
+ <span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-green-500/10 text-green-600 dark:text-green-400">Published</span>
144
+ )}
145
+ </div>
146
+ {stack.phase === "done" && (
147
+ <button
148
+ type="button"
149
+ onClick={stack.reset}
150
+ className="flex items-center gap-1.5 text-[10px] text-muted-foreground/30 hover:text-muted-foreground transition-colors"
151
+ >
152
+ <RotateCcw className="h-3 w-3" />
153
+ </button>
154
+ )}
155
+ </div>
156
+
157
+ {isRunning && (
158
+ <>
159
+ <PipelineTimeline phase={stack.phase} />
160
+ {stack.progressMessage && (
161
+ <p className="text-[10px] text-muted-foreground/30 px-1 -mt-2">{stack.progressMessage}</p>
162
+ )}
163
+ </>
164
+ )}
165
+
166
+ {stack.phase === "error" && (
167
+ <div className="rounded-lg bg-red-500/[0.04] px-3.5 py-3 space-y-3">
168
+ <div className="flex items-start gap-2.5">
169
+ <AlertTriangle className="h-3.5 w-3.5 text-red-500/70 shrink-0 mt-px" />
170
+ <span className="text-[11px] text-red-600/80 dark:text-red-400/80 break-all leading-relaxed">
171
+ {stack.error}
172
+ </span>
173
+ </div>
174
+ <button
175
+ type="button"
176
+ onClick={stack.reset}
177
+ className="flex items-center gap-1.5 text-[11px] text-muted-foreground/40 hover:text-foreground transition-colors"
178
+ >
179
+ <RotateCcw className="h-3 w-3" />
180
+ Try again
181
+ </button>
182
+ </div>
183
+ )}
184
+
185
+ {stack.feasibility && (
186
+ <FeasibilityAlert result={stack.feasibility} />
187
+ )}
188
+
189
+ {stack.partition && stack.partition.structured_warnings.length > 0 && (
190
+ <StackWarnings warnings={stack.partition.structured_warnings} />
191
+ )}
192
+
193
+ {stack.plan && (
194
+ <div className="space-y-1">
195
+ <div className="flex items-center justify-between mb-2">
196
+ <span className="text-[11px] font-medium text-muted-foreground/40">
197
+ {stack.plan.groups.length} PRs
198
+ </span>
199
+ {stack.plan.groups.length > 0 && (
200
+ <span className="text-[10px] text-muted-foreground/25 tabular-nums">
201
+ {stack.plan.groups.reduce((sum, g) => sum + g.files.length, 0)} files total
202
+ </span>
203
+ )}
204
+ </div>
205
+ <div className="space-y-0">
206
+ {stack.plan.groups.map((group) => {
207
+ const commit = stack.execResult?.group_commits.find((gc) => gc.group_id === group.id);
208
+ const pr = stack.publishResult?.prs.find((p) => p.group_id === group.id);
209
+ return (
210
+ <StackGroupCard
211
+ key={group.id}
212
+ group={group}
213
+ commit={commit}
214
+ pr={pr}
215
+ />
216
+ );
217
+ })}
218
+ </div>
219
+ </div>
220
+ )}
221
+
222
+ {stack.verifyResult && (
223
+ <div className="space-y-2">
224
+ <div className={`flex items-center gap-2.5 rounded-lg px-3.5 py-2.5 ${
225
+ stack.verifyResult.verified
226
+ ? "bg-green-500/[0.04]"
227
+ : "bg-red-500/[0.04]"
228
+ }`}>
229
+ {stack.verifyResult.verified
230
+ ? <CheckCircle2 className="h-3.5 w-3.5 text-green-600/70 dark:text-green-400/70 shrink-0" />
231
+ : <AlertTriangle className="h-3.5 w-3.5 text-red-500/70 shrink-0" />
232
+ }
233
+ <span className={`text-[11px] ${
234
+ stack.verifyResult.verified
235
+ ? "text-green-700/70 dark:text-green-300/70"
236
+ : "text-red-600/80 dark:text-red-400/80"
237
+ }`}>
238
+ {stack.verifyResult.verified
239
+ ? "Tree equivalence verified"
240
+ : `Verification failed: ${stack.verifyResult.errors.join(", ")}`
241
+ }
242
+ </span>
243
+ </div>
244
+ {stack.verifyResult.structured_warnings.length > 0 && (
245
+ <StackWarnings warnings={stack.verifyResult.structured_warnings} defaultCollapsed={stack.verifyResult.verified} />
246
+ )}
247
+ </div>
248
+ )}
249
+
250
+ {stack.phase === "done" && stack.execResult && !stack.publishResult && (
251
+ <button
252
+ type="button"
253
+ onClick={stack.startPublish}
254
+ className="w-full flex items-center justify-center gap-2 rounded-lg bg-foreground text-background px-4 py-2.5 text-[12px] font-medium hover:bg-foreground/90 transition-colors"
255
+ >
256
+ <Upload className="h-3.5 w-3.5" />
257
+ Publish as Draft PRs
258
+ </button>
259
+ )}
260
+
261
+ {stack.publishResult && stack.publishResult.prs.length > 0 && (
262
+ <div className="space-y-1.5">
263
+ {stack.publishResult.prs.map((pr) => (
264
+ <a
265
+ key={pr.number}
266
+ href={pr.url}
267
+ target="_blank"
268
+ rel="noopener noreferrer"
269
+ className="group flex items-center gap-3 rounded-lg px-3.5 py-2.5 hover:bg-accent/30 transition-colors"
270
+ >
271
+ <GitPullRequestArrow className="h-3.5 w-3.5 text-green-600/60 dark:text-green-400/60 shrink-0" />
272
+ <div className="flex-1 min-w-0">
273
+ <div className="flex items-center gap-2">
274
+ <span className="text-[11px] font-medium truncate">{pr.title}</span>
275
+ <span className="text-[10px] text-muted-foreground/25 tabular-nums shrink-0">#{pr.number}</span>
276
+ </div>
277
+ <div className="flex items-center gap-1 mt-0.5">
278
+ <span className="text-[10px] font-mono text-muted-foreground/25">{pr.base_branch}</span>
279
+ <ArrowRight className="h-2.5 w-2.5 text-muted-foreground/20" />
280
+ <span className="text-[10px] font-mono text-muted-foreground/25">{pr.head_branch}</span>
281
+ </div>
282
+ </div>
283
+ </a>
284
+ ))}
285
+ </div>
286
+ )}
287
+ </div>
288
+ );
289
+ }
@@ -15,6 +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 } from "./stack-manager.ts";
18
20
 
19
21
  function json(data: unknown, status = 200): Response {
20
22
  return new Response(JSON.stringify(data), {
@@ -1703,5 +1705,117 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
1703
1705
  if (!job) return json({ status: "idle" });
1704
1706
  return json(job);
1705
1707
  },
1708
+
1709
+ "POST /api/stack/start": async (req: Request) => {
1710
+ try {
1711
+ const body = await req.json() as { sessionId: string; maxGroups?: number };
1712
+ if (!body.sessionId) return json({ error: "Missing sessionId" }, 400);
1713
+ const result = startStack(body.sessionId, body.maxGroups ?? null, token, config);
1714
+ if ("error" in result) return json({ error: result.error }, result.status);
1715
+ return json({ ok: true });
1716
+ } catch (err) {
1717
+ const msg = err instanceof Error ? err.message : String(err);
1718
+ return json({ error: msg }, 500);
1719
+ }
1720
+ },
1721
+
1722
+ "GET /api/stack/:id": (req: Request) => {
1723
+ const url = new URL(req.url);
1724
+ const id = url.pathname.split("/").pop()!;
1725
+ const state = getStackState(id);
1726
+ if (!state) return json({ state: null });
1727
+ return json({ state });
1728
+ },
1729
+
1730
+ "POST /api/stack/:id/cancel": (req: Request) => {
1731
+ const url = new URL(req.url);
1732
+ const segments = url.pathname.split("/");
1733
+ const id = segments[segments.length - 2]!;
1734
+ const ok = cancelStack(id);
1735
+ return json({ ok });
1736
+ },
1737
+
1738
+ "GET /api/stack/:id/events": (req: Request) => {
1739
+ const url = new URL(req.url);
1740
+ const segments = url.pathname.split("/");
1741
+ const id = segments[segments.length - 2]!;
1742
+
1743
+ const stream = new ReadableStream({
1744
+ start(controller) {
1745
+ const encoder = new TextEncoder();
1746
+ const send = (eventType: string, data: string) => {
1747
+ controller.enqueue(encoder.encode(`event: ${eventType}\ndata: ${data}\n\n`));
1748
+ };
1749
+
1750
+ const unsubscribe = subscribeStack(id, (event) => {
1751
+ try {
1752
+ if ("type" in event && event.type === "done") {
1753
+ send("done", JSON.stringify({ state: getStackState(id) }));
1754
+ controller.close();
1755
+ } else if ("type" in event && event.type === "error") {
1756
+ send("stack_error", JSON.stringify({ message: event.data ?? "Unknown error", state: getStackState(id) }));
1757
+ controller.close();
1758
+ } else {
1759
+ send("progress", JSON.stringify({ ...event, state: getStackState(id) }));
1760
+ }
1761
+ } catch {
1762
+ controller.close();
1763
+ }
1764
+ });
1765
+
1766
+ if (!unsubscribe) {
1767
+ const existingState = getStackState(id);
1768
+ if (existingState) {
1769
+ send("done", JSON.stringify({ state: existingState }));
1770
+ } else {
1771
+ send("stack_error", JSON.stringify({ message: "No stack session found" }));
1772
+ }
1773
+ controller.close();
1774
+ }
1775
+
1776
+ req.signal.addEventListener("abort", () => {
1777
+ unsubscribe?.();
1778
+ try { controller.close(); } catch {}
1779
+ });
1780
+ },
1781
+ });
1782
+
1783
+ return new Response(stream, {
1784
+ headers: {
1785
+ "Content-Type": "text/event-stream",
1786
+ "Cache-Control": "no-cache",
1787
+ Connection: "keep-alive",
1788
+ },
1789
+ });
1790
+ },
1791
+
1792
+ "POST /api/stack/publish": async (req: Request) => {
1793
+ try {
1794
+ const body = await req.json() as { sessionId: string };
1795
+ if (!body.sessionId) return json({ error: "Missing sessionId" }, 400);
1796
+
1797
+ const state = getStackState(body.sessionId);
1798
+ if (!state) return json({ error: "No stack state found" }, 404);
1799
+ if (!state.execResult) return json({ error: "Stack not executed yet" }, 400);
1800
+ if (!state.context) return json({ error: "Missing context" }, 400);
1801
+
1802
+ const stored = await loadSession(body.sessionId);
1803
+ if (!stored) return json({ error: "Session not found" }, 404);
1804
+
1805
+ const result = await publishStack({
1806
+ repo_path: state.context.repo_path,
1807
+ exec_result: state.execResult,
1808
+ pr_meta: stored.meta,
1809
+ base_branch: state.context.base_branch,
1810
+ owner: state.context.owner,
1811
+ repo: state.context.repo,
1812
+ });
1813
+
1814
+ return json({ publish_result: result });
1815
+ } catch (err) {
1816
+ const msg = err instanceof Error ? err.message : String(err);
1817
+ return json({ error: msg }, 500);
1818
+ }
1819
+ },
1706
1820
  };
1707
1821
  }