newpr 1.0.21 → 1.0.22

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.
@@ -0,0 +1,317 @@
1
+ import { ChevronRight, GitBranch, ExternalLink, Plus, Minus, GitMerge } from "lucide-react";
2
+ import { useState } from "react";
3
+ import type { StackGroupStats } from "../../../stack/types.ts";
4
+
5
+ const TYPE_COLORS: Record<string, { dot: string; badge: string; text: string }> = {
6
+ feature: { dot: "bg-blue-500", badge: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400" },
7
+ refactor: { dot: "bg-purple-500", badge: "bg-purple-500/10", text: "text-purple-600 dark:text-purple-400" },
8
+ bugfix: { dot: "bg-red-500", badge: "bg-red-500/10", text: "text-red-600 dark:text-red-400" },
9
+ chore: { dot: "bg-neutral-400", badge: "bg-neutral-500/10", text: "text-neutral-500" },
10
+ docs: { dot: "bg-teal-500", badge: "bg-teal-500/10", text: "text-teal-600 dark:text-teal-400" },
11
+ test: { dot: "bg-yellow-500", badge: "bg-yellow-500/10", text: "text-yellow-600 dark:text-yellow-400" },
12
+ config: { dot: "bg-orange-500", badge: "bg-orange-500/10", text: "text-orange-600 dark:text-orange-400" },
13
+ };
14
+
15
+ function formatStat(n: number): string {
16
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
17
+ return String(n);
18
+ }
19
+
20
+ export interface DagGroup {
21
+ id: string;
22
+ name: string;
23
+ type: string;
24
+ description: string;
25
+ files: string[];
26
+ deps: string[];
27
+ order: number;
28
+ stats?: StackGroupStats;
29
+ pr_title?: string;
30
+ }
31
+
32
+ interface DagCommit {
33
+ group_id: string;
34
+ commit_sha: string;
35
+ branch_name: string;
36
+ }
37
+
38
+ interface DagPr {
39
+ group_id: string;
40
+ number: number;
41
+ url: string;
42
+ title: string;
43
+ }
44
+
45
+ interface DagNode {
46
+ group: DagGroup;
47
+ level: number;
48
+ isLastAtLevel: boolean;
49
+ parentIds: string[];
50
+ childIds: string[];
51
+ }
52
+
53
+ function buildDagNodes(groups: DagGroup[]): DagNode[] {
54
+ const byId = new Map(groups.map((g) => [g.id, g]));
55
+
56
+ const levels = new Map<string, number>();
57
+ const inDegree = new Map(groups.map((g) => [g.id, 0]));
58
+ for (const g of groups) {
59
+ for (const dep of (g.deps ?? [])) {
60
+ if (byId.has(dep)) inDegree.set(g.id, (inDegree.get(g.id) ?? 0) + 1);
61
+ }
62
+ }
63
+
64
+ const queue = groups.filter((g) => (inDegree.get(g.id) ?? 0) === 0).map((g) => g.id);
65
+ for (const id of queue) levels.set(id, 0);
66
+
67
+ while (queue.length > 0) {
68
+ const id = queue.shift()!;
69
+ const level = levels.get(id) ?? 0;
70
+ for (const g of groups) {
71
+ if ((g.deps ?? []).includes(id)) {
72
+ const newLevel = Math.max(levels.get(g.id) ?? 0, level + 1);
73
+ levels.set(g.id, newLevel);
74
+ const remaining = (inDegree.get(g.id) ?? 1) - 1;
75
+ inDegree.set(g.id, remaining);
76
+ if (remaining === 0) queue.push(g.id);
77
+ }
78
+ }
79
+ }
80
+
81
+ const levelCount = new Map<number, number>();
82
+ const levelSeen = new Map<number, number>();
83
+ for (const [, l] of levels) levelCount.set(l, (levelCount.get(l) ?? 0) + 1);
84
+
85
+ const childrenOf = new Map<string, string[]>();
86
+ for (const g of groups) {
87
+ for (const dep of (g.deps ?? [])) {
88
+ const arr = childrenOf.get(dep) ?? [];
89
+ arr.push(g.id);
90
+ childrenOf.set(dep, arr);
91
+ }
92
+ }
93
+
94
+ const sorted = [...groups].sort((a, b) => {
95
+ const la = levels.get(a.id) ?? 0;
96
+ const lb = levels.get(b.id) ?? 0;
97
+ if (la !== lb) return la - lb;
98
+ return a.order - b.order;
99
+ });
100
+
101
+ return sorted.map((g) => {
102
+ const level = levels.get(g.id) ?? 0;
103
+ const seenAtLevel = levelSeen.get(level) ?? 0;
104
+ const countAtLevel = levelCount.get(level) ?? 1;
105
+ levelSeen.set(level, seenAtLevel + 1);
106
+ return {
107
+ group: g,
108
+ level,
109
+ isLastAtLevel: seenAtLevel === countAtLevel - 1,
110
+ parentIds: (g.deps ?? []).filter((d) => byId.has(d)),
111
+ childIds: childrenOf.get(g.id) ?? [],
112
+ };
113
+ });
114
+ }
115
+
116
+
117
+ function DagNodeCard({
118
+ node,
119
+ commit,
120
+ pr,
121
+ allGroups,
122
+ }: {
123
+ node: DagNode;
124
+ commit?: DagCommit;
125
+ pr?: DagPr;
126
+ allGroups: DagGroup[];
127
+ }) {
128
+ const [expanded, setExpanded] = useState(false);
129
+ const { group, level } = node;
130
+ const stats = group.stats;
131
+ const colors = TYPE_COLORS[group.type] ?? TYPE_COLORS.chore!;
132
+
133
+ const depNames = (group.deps ?? []).map((depId) => {
134
+ const found = allGroups.find((g) => g.id === depId);
135
+ return found?.pr_title ?? found?.name ?? depId;
136
+ });
137
+
138
+ const isParallel = node.parentIds.length === 0
139
+ ? false
140
+ : allGroups.filter((g) => {
141
+ const gDeps = g.deps ?? [];
142
+ return node.parentIds.every((p) => gDeps.includes(p)) && g.id !== group.id;
143
+ }).length > 0;
144
+
145
+ return (
146
+ <div className="relative" style={{ marginLeft: `${level * 20}px` }}>
147
+ {level > 0 && (
148
+ <div
149
+ className="absolute top-[18px] h-px bg-border/30"
150
+ style={{ left: `-${20 - 9}px`, width: `${20 - 9}px` }}
151
+ />
152
+ )}
153
+
154
+ <div className="group/card relative">
155
+ <button
156
+ type="button"
157
+ onClick={() => setExpanded(!expanded)}
158
+ className="w-full flex items-start gap-2 px-2.5 py-2 text-left hover:bg-accent/20 transition-colors rounded-md"
159
+ >
160
+ <div className="flex-shrink-0 mt-[5px] flex flex-col items-center gap-[3px]">
161
+ <div className={`h-2 w-2 rounded-full ${colors.dot} ring-2 ring-background`} />
162
+ {node.childIds.length > 1 && (
163
+ <GitMerge className="h-2.5 w-2.5 text-muted-foreground/25 mt-0.5" />
164
+ )}
165
+ </div>
166
+
167
+ <div className="flex-1 min-w-0">
168
+ <div className="flex items-center gap-1.5 flex-wrap">
169
+ <span className={`text-[9px] font-medium px-1.5 py-px rounded ${colors.badge} ${colors.text} shrink-0`}>
170
+ {group.type}
171
+ </span>
172
+ {isParallel && (
173
+ <span className="text-[9px] text-muted-foreground/25 shrink-0">∥</span>
174
+ )}
175
+ <span className="text-[11.5px] font-medium text-foreground/90 truncate">
176
+ {group.pr_title ?? group.name}
177
+ </span>
178
+ </div>
179
+
180
+ {depNames.length > 0 && (
181
+ <div className="flex items-center gap-1 mt-0.5 flex-wrap">
182
+ <span className="text-[9px] text-muted-foreground/20">after</span>
183
+ {depNames.map((name, i) => (
184
+ <span key={i} className="text-[9px] text-muted-foreground/25 font-mono truncate max-w-[120px]">
185
+ {name}
186
+ </span>
187
+ ))}
188
+ </div>
189
+ )}
190
+ </div>
191
+
192
+ <div className="flex-shrink-0 flex items-center gap-2 mt-[3px]">
193
+ {stats ? (
194
+ <span className="flex items-center gap-1.5">
195
+ <span className="text-[10px] text-green-600/60 dark:text-green-400/60 tabular-nums">
196
+ +{formatStat(stats.additions)}
197
+ </span>
198
+ <span className="text-[10px] text-red-500/60 tabular-nums">
199
+ −{formatStat(stats.deletions)}
200
+ </span>
201
+ </span>
202
+ ) : (
203
+ <span className="text-[9px] text-muted-foreground/20 tabular-nums">{group.files.length}f</span>
204
+ )}
205
+ <ChevronRight className={`h-3 w-3 text-muted-foreground/20 transition-transform duration-150 ${expanded ? "rotate-90" : ""}`} />
206
+ </div>
207
+ </button>
208
+
209
+ {expanded && (
210
+ <div className="ml-4 pl-3 pb-3 pt-1 space-y-2 border-l border-border/30">
211
+ {group.description && (
212
+ <p className="text-[10.5px] text-muted-foreground/45 leading-[1.55]">{group.description}</p>
213
+ )}
214
+
215
+ {stats && (
216
+ <div className="flex items-center gap-3 text-[9.5px] text-muted-foreground/30 tabular-nums">
217
+ <span>{group.files.length} files</span>
218
+ <span className="flex items-center gap-0.5">
219
+ <Plus className="h-2 w-2 text-green-600/50 dark:text-green-400/50" />
220
+ {stats.additions.toLocaleString()}
221
+ </span>
222
+ <span className="flex items-center gap-0.5">
223
+ <Minus className="h-2 w-2 text-red-500/50" />
224
+ {stats.deletions.toLocaleString()}
225
+ </span>
226
+ {(stats.files_added > 0 || stats.files_modified > 0 || stats.files_deleted > 0) && (
227
+ <span>
228
+ {stats.files_added > 0 && `${stats.files_added}A `}
229
+ {stats.files_modified > 0 && `${stats.files_modified}M `}
230
+ {stats.files_deleted > 0 && `${stats.files_deleted}D`}
231
+ </span>
232
+ )}
233
+ </div>
234
+ )}
235
+
236
+ {commit && (
237
+ <div className="flex items-center gap-1.5 text-[9.5px] text-muted-foreground/25">
238
+ <GitBranch className="h-2.5 w-2.5 shrink-0" />
239
+ <span className="font-mono truncate">{commit.branch_name}</span>
240
+ <span className="text-muted-foreground/15">·</span>
241
+ <span className="font-mono shrink-0">{commit.commit_sha.slice(0, 7)}</span>
242
+ </div>
243
+ )}
244
+
245
+ {pr && (
246
+ <a
247
+ href={pr.url}
248
+ target="_blank"
249
+ rel="noopener noreferrer"
250
+ className="inline-flex items-center gap-1 text-[9.5px] text-foreground/40 hover:text-foreground/70 transition-colors"
251
+ >
252
+ <ExternalLink className="h-2.5 w-2.5 shrink-0" />
253
+ <span className="tabular-nums">#{pr.number}</span>
254
+ <span className="truncate max-w-[180px]">{pr.title}</span>
255
+ </a>
256
+ )}
257
+
258
+ <div className="space-y-0 pt-0.5">
259
+ {group.files.map((file) => (
260
+ <div key={file} className="text-[9px] font-mono text-muted-foreground/20 py-[1px] truncate">
261
+ {file}
262
+ </div>
263
+ ))}
264
+ </div>
265
+ </div>
266
+ )}
267
+ </div>
268
+ </div>
269
+ );
270
+ }
271
+
272
+ export function StackDagView({
273
+ groups,
274
+ groupCommits,
275
+ publishedPrs,
276
+ }: {
277
+ groups: DagGroup[];
278
+ groupCommits?: DagCommit[];
279
+ publishedPrs?: DagPr[];
280
+ }) {
281
+ const nodes = buildDagNodes(groups);
282
+ const isLinear = nodes.every((n) => n.level === n.group.order);
283
+
284
+ return (
285
+ <div className="relative">
286
+ {!isLinear && (
287
+ <div className="flex items-center gap-1.5 mb-2 px-1">
288
+ <GitMerge className="h-3 w-3 text-muted-foreground/25" />
289
+ <span className="text-[9px] text-muted-foreground/25 uppercase tracking-wider">DAG</span>
290
+ </div>
291
+ )}
292
+
293
+ <div className="relative space-y-0.5">
294
+ {nodes.map((node) => {
295
+ const commit = groupCommits?.find((c) => c.group_id === node.group.id);
296
+ const pr = publishedPrs?.find((p) => p.group_id === node.group.id);
297
+ return (
298
+ <DagNodeCard
299
+ key={node.group.id}
300
+ node={node}
301
+ commit={commit}
302
+ pr={pr}
303
+ allGroups={groups}
304
+ />
305
+ );
306
+ })}
307
+
308
+ {nodes.length > 0 && (
309
+ <div
310
+ className="absolute top-[14px] bottom-[14px] w-px bg-border/15 pointer-events-none"
311
+ style={{ left: "8px" }}
312
+ />
313
+ )}
314
+ </div>
315
+ </div>
316
+ );
317
+ }
@@ -45,6 +45,7 @@ interface StackGroupCardProps {
45
45
  description: string;
46
46
  files: string[];
47
47
  order: number;
48
+ deps?: string[];
48
49
  stats?: StackGroupStats;
49
50
  pr_title?: string;
50
51
  };
@@ -57,15 +58,28 @@ interface StackGroupCardProps {
57
58
  url: string;
58
59
  title: string;
59
60
  };
61
+ allGroups?: Array<{ id: string; name: string; pr_title?: string }>;
60
62
  }
61
63
 
62
- export function StackGroupCard({ group, commit, pr }: StackGroupCardProps) {
64
+ export function StackGroupCard({ group, commit, pr, allGroups }: StackGroupCardProps) {
63
65
  const [expanded, setExpanded] = useState(false);
64
66
  const stats = group.stats;
65
67
  const colors = TYPE_COLORS[group.type] ?? TYPE_COLORS.chore!;
68
+ const hasDeps = (group.deps ?? []).length > 0;
69
+ const depNames = (group.deps ?? []).map((depId) => {
70
+ const found = allGroups?.find((g) => g.id === depId);
71
+ return found?.pr_title ?? found?.name ?? depId;
72
+ });
66
73
 
67
74
  return (
68
75
  <div className="group/card">
76
+ {hasDeps && (
77
+ <div className="ml-[26px] px-3 pb-0.5 flex items-center gap-1.5 flex-wrap">
78
+ {depNames.map((name, i) => (
79
+ <span key={i} className="text-[9px] text-muted-foreground/25 font-mono leading-none">↑ {name}</span>
80
+ ))}
81
+ </div>
82
+ )}
69
83
  <button
70
84
  type="button"
71
85
  onClick={() => setExpanded(!expanded)}
@@ -8,12 +8,12 @@ declare global {
8
8
  const GA_ID = "G-L3SL6T6JQ1";
9
9
  const CONSENT_KEY = "newpr-analytics-consent";
10
10
 
11
- export type ConsentState = "granted" | "denied" | "pending";
11
+ export type ConsentState = "granted" | "denied";
12
12
 
13
13
  export function getConsent(): ConsentState {
14
14
  const stored = localStorage.getItem(CONSENT_KEY);
15
- if (stored === "granted" || stored === "denied") return stored;
16
- return "pending";
15
+ if (stored === "denied") return "denied";
16
+ return "granted";
17
17
  }
18
18
 
19
19
  export function setConsent(state: "granted" | "denied"): void {
@@ -49,7 +49,9 @@ function disableGA(): void {
49
49
  }
50
50
 
51
51
  export function initAnalytics(): void {
52
- if (getConsent() === "granted") {
52
+ if (getConsent() === "denied") {
53
+ disableGA();
54
+ } else {
53
55
  loadGA();
54
56
  }
55
57
  }
@@ -1,7 +1,7 @@
1
1
  import { Loader2, Play, Upload, RotateCcw, CheckCircle2, AlertTriangle, Circle, GitPullRequestArrow, ArrowRight, Layers, FileText, RefreshCw, XCircle, Trash2 } from "lucide-react";
2
2
  import { useStack } from "../hooks/useStack.ts";
3
3
  import { FeasibilityAlert } from "../components/FeasibilityAlert.tsx";
4
- import { StackGroupCard } from "../components/StackGroupCard.tsx";
4
+ import { StackDagView } from "../components/StackDagView.tsx";
5
5
  import { StackWarnings } from "../components/StackWarnings.tsx";
6
6
 
7
7
  type StackPhase = "idle" | "partitioning" | "planning" | "executing" | "publishing" | "done" | "error";
@@ -228,20 +228,11 @@ export function StackPanel({ sessionId, onTrackAnalysis }: StackPanelProps) {
228
228
  </span>
229
229
  )}
230
230
  </div>
231
- <div className="space-y-0">
232
- {stack.plan.groups.map((group) => {
233
- const commit = stack.execResult?.group_commits.find((gc) => gc.group_id === group.id);
234
- const pr = stack.publishResult?.prs.find((p) => p.group_id === group.id);
235
- return (
236
- <StackGroupCard
237
- key={group.id}
238
- group={group}
239
- commit={commit}
240
- pr={pr}
241
- />
242
- );
243
- })}
244
- </div>
231
+ <StackDagView
232
+ groups={stack.plan.groups}
233
+ groupCommits={stack.execResult?.group_commits}
234
+ publishedPrs={stack.publishResult?.prs}
235
+ />
245
236
  </div>
246
237
  )}
247
238
 
@@ -1927,7 +1927,16 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
1927
1927
  base_branch: state.context.base_branch,
1928
1928
  owner: state.context.owner,
1929
1929
  repo: state.context.repo,
1930
- plan_groups: state.plan?.groups,
1930
+ plan_groups: state.plan?.groups?.map((g) => ({
1931
+ id: g.id,
1932
+ name: g.name,
1933
+ description: g.description,
1934
+ files: g.files,
1935
+ order: g.order,
1936
+ type: g.type,
1937
+ pr_title: g.pr_title,
1938
+ deps: g.deps,
1939
+ })),
1931
1940
  llm_client: llmClient,
1932
1941
  language: config.language,
1933
1942
  publish_preview: state.publishPreview,
@@ -2070,7 +2079,16 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
2070
2079
  base_branch: state.context.base_branch,
2071
2080
  owner: state.context.owner,
2072
2081
  repo: state.context.repo,
2073
- plan_groups: state.plan?.groups,
2082
+ plan_groups: state.plan?.groups?.map((g) => ({
2083
+ id: g.id,
2084
+ name: g.name,
2085
+ description: g.description,
2086
+ files: g.files,
2087
+ order: g.order,
2088
+ type: g.type,
2089
+ pr_title: g.pr_title,
2090
+ deps: g.deps,
2091
+ })),
2074
2092
  llm_client: llmClient,
2075
2093
  language: config.language,
2076
2094
  });
@@ -629,14 +629,17 @@ async function runStackPipeline(
629
629
  ownership,
630
630
  group_order: feasibility.ordered_group_ids!,
631
631
  groups: currentGroups,
632
+ dependency_edges: feasibility.dependency_edges,
632
633
  });
633
634
 
634
635
  emit(session, "planning", "Computing group stats...");
636
+ const planDagParents = new Map(plan.groups.map((g) => [g.id, g.deps ?? []]));
635
637
  const groupStats = await computeGroupStats(
636
638
  repoPath,
637
639
  baseSha,
638
640
  feasibility.ordered_group_ids!,
639
641
  plan.expected_trees,
642
+ planDagParents,
640
643
  );
641
644
  for (const group of plan.groups) {
642
645
  const s = groupStats.get(group.id);