newpr 0.6.6 → 1.0.1
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/history/store.ts +25 -0
- package/src/stack/balance.ts +128 -0
- package/src/stack/coupling.test.ts +158 -0
- package/src/stack/coupling.ts +135 -0
- package/src/stack/delta.test.ts +223 -0
- package/src/stack/delta.ts +264 -0
- package/src/stack/execute.test.ts +176 -0
- package/src/stack/execute.ts +194 -0
- package/src/stack/feasibility.test.ts +185 -0
- package/src/stack/feasibility.ts +286 -0
- package/src/stack/integration.test.ts +266 -0
- package/src/stack/merge-groups.test.ts +97 -0
- package/src/stack/merge-groups.ts +87 -0
- package/src/stack/partition.test.ts +233 -0
- package/src/stack/partition.ts +273 -0
- package/src/stack/plan.test.ts +154 -0
- package/src/stack/plan.ts +139 -0
- package/src/stack/pr-title.ts +90 -0
- package/src/stack/publish.ts +96 -0
- package/src/stack/split.ts +173 -0
- package/src/stack/types.ts +202 -0
- package/src/stack/verify.test.ts +137 -0
- package/src/stack/verify.ts +201 -0
- package/src/web/client/components/FeasibilityAlert.tsx +64 -0
- package/src/web/client/components/ResultsScreen.tsx +10 -2
- package/src/web/client/components/StackGroupCard.tsx +171 -0
- package/src/web/client/components/StackWarnings.tsx +135 -0
- package/src/web/client/hooks/useStack.ts +301 -0
- package/src/web/client/panels/StackPanel.tsx +289 -0
- package/src/web/server/routes.ts +114 -0
- package/src/web/server/stack-manager.ts +580 -0
- package/src/web/server.ts +15 -0
- package/src/web/styles/built.css +1 -1
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type { StackExecResult, StackWarning } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export interface VerifyInput {
|
|
4
|
+
repo_path: string;
|
|
5
|
+
base_sha: string;
|
|
6
|
+
head_sha: string;
|
|
7
|
+
exec_result: StackExecResult;
|
|
8
|
+
ownership: Map<string, string>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface VerifyResult {
|
|
12
|
+
verified: boolean;
|
|
13
|
+
errors: string[];
|
|
14
|
+
warnings: string[];
|
|
15
|
+
structured_warnings: StackWarning[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function verifyStack(input: VerifyInput): Promise<VerifyResult> {
|
|
19
|
+
const { repo_path, base_sha, head_sha, exec_result, ownership } = input;
|
|
20
|
+
const errors: string[] = [];
|
|
21
|
+
const warnings: string[] = [];
|
|
22
|
+
const structuredWarnings: StackWarning[] = [];
|
|
23
|
+
|
|
24
|
+
const scopeLeaks: string[] = [];
|
|
25
|
+
const unionMissing: string[] = [];
|
|
26
|
+
const unionExtra: string[] = [];
|
|
27
|
+
|
|
28
|
+
await verifyPerGroupDiffScope(repo_path, base_sha, exec_result, ownership, warnings, scopeLeaks);
|
|
29
|
+
await verifyUnionCompleteness(repo_path, base_sha, head_sha, exec_result, warnings, unionMissing, unionExtra);
|
|
30
|
+
await verifyFinalTreeEquivalence(repo_path, head_sha, exec_result, errors);
|
|
31
|
+
|
|
32
|
+
if (scopeLeaks.length > 0) {
|
|
33
|
+
structuredWarnings.push({
|
|
34
|
+
category: "verification.scope",
|
|
35
|
+
severity: "warn",
|
|
36
|
+
title: `${scopeLeaks.length} file(s) appear in wrong group's diff`,
|
|
37
|
+
message: "Some files changed in a group's commit but are owned by a different group — usually harmless with merge commits",
|
|
38
|
+
details: scopeLeaks,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
if (unionMissing.length > 0) {
|
|
42
|
+
structuredWarnings.push({
|
|
43
|
+
category: "verification.completeness",
|
|
44
|
+
severity: "warn",
|
|
45
|
+
title: `${unionMissing.length} file(s) in original diff but missing from stack`,
|
|
46
|
+
message: "These files were changed in the original PR but don't appear in the stacked commits",
|
|
47
|
+
details: unionMissing,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
if (unionExtra.length > 0) {
|
|
51
|
+
structuredWarnings.push({
|
|
52
|
+
category: "verification.completeness",
|
|
53
|
+
severity: "warn",
|
|
54
|
+
title: `${unionExtra.length} extra file(s) in stack not in original diff`,
|
|
55
|
+
message: "The stacked commits touch files not in the original PR diff — usually transient changes from merge commits",
|
|
56
|
+
details: unionExtra,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
verified: errors.length === 0,
|
|
62
|
+
errors,
|
|
63
|
+
warnings,
|
|
64
|
+
structured_warnings: structuredWarnings,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function verifyPerGroupDiffScope(
|
|
69
|
+
repoPath: string,
|
|
70
|
+
baseSha: string,
|
|
71
|
+
execResult: StackExecResult,
|
|
72
|
+
ownership: Map<string, string>,
|
|
73
|
+
warnings: string[],
|
|
74
|
+
scopeLeaks: string[],
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
let prevCommitSha = baseSha;
|
|
77
|
+
|
|
78
|
+
for (const gc of execResult.group_commits) {
|
|
79
|
+
const diffResult = await Bun.$`git -C ${repoPath} diff-tree -r --raw -z --no-commit-id ${prevCommitSha} ${gc.commit_sha}`.quiet().nothrow();
|
|
80
|
+
|
|
81
|
+
if (diffResult.exitCode !== 0) {
|
|
82
|
+
warnings.push(`Failed to diff group "${gc.group_id}": ${diffResult.stderr.toString().trim()}`);
|
|
83
|
+
prevCommitSha = gc.commit_sha;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const changedPaths = extractPathsFromRawDiff(diffResult.stdout);
|
|
88
|
+
|
|
89
|
+
for (const path of changedPaths) {
|
|
90
|
+
const fileOwner = ownership.get(path);
|
|
91
|
+
if (fileOwner !== gc.group_id) {
|
|
92
|
+
const detail = `"${path}" in "${gc.group_id}" diff, owned by "${fileOwner ?? "unassigned"}"`;
|
|
93
|
+
warnings.push(
|
|
94
|
+
`Group "${gc.group_id}" diff contains file "${path}" owned by "${fileOwner ?? "unassigned"}"`,
|
|
95
|
+
);
|
|
96
|
+
scopeLeaks.push(detail);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
prevCommitSha = gc.commit_sha;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function verifyUnionCompleteness(
|
|
105
|
+
repoPath: string,
|
|
106
|
+
baseSha: string,
|
|
107
|
+
headSha: string,
|
|
108
|
+
execResult: StackExecResult,
|
|
109
|
+
warnings: string[],
|
|
110
|
+
unionMissing: string[],
|
|
111
|
+
unionExtra: string[],
|
|
112
|
+
): Promise<void> {
|
|
113
|
+
const expectedResult = await Bun.$`git -C ${repoPath} diff-tree -r --raw -z --no-commit-id ${baseSha} ${headSha}`.quiet().nothrow();
|
|
114
|
+
|
|
115
|
+
if (expectedResult.exitCode !== 0) {
|
|
116
|
+
warnings.push(`Failed to get expected diff: ${expectedResult.stderr.toString().trim()}`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const expectedPaths = new Set(extractPathsFromRawDiff(expectedResult.stdout));
|
|
121
|
+
|
|
122
|
+
const actualPaths = new Set<string>();
|
|
123
|
+
let prevSha = baseSha;
|
|
124
|
+
for (const gc of execResult.group_commits) {
|
|
125
|
+
const diffResult = await Bun.$`git -C ${repoPath} diff-tree -r --raw -z --no-commit-id ${prevSha} ${gc.commit_sha}`.quiet().nothrow();
|
|
126
|
+
|
|
127
|
+
if (diffResult.exitCode === 0) {
|
|
128
|
+
for (const path of extractPathsFromRawDiff(diffResult.stdout)) {
|
|
129
|
+
actualPaths.add(path);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
prevSha = gc.commit_sha;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const path of expectedPaths) {
|
|
136
|
+
if (!actualPaths.has(path)) {
|
|
137
|
+
warnings.push(`File "${path}" present in original diff but missing from stack`);
|
|
138
|
+
unionMissing.push(path);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for (const path of actualPaths) {
|
|
143
|
+
if (!expectedPaths.has(path)) {
|
|
144
|
+
warnings.push(`File "${path}" present in stack but not in original diff`);
|
|
145
|
+
unionExtra.push(path);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function verifyFinalTreeEquivalence(
|
|
151
|
+
repoPath: string,
|
|
152
|
+
headSha: string,
|
|
153
|
+
execResult: StackExecResult,
|
|
154
|
+
errors: string[],
|
|
155
|
+
): Promise<void> {
|
|
156
|
+
const headTreeResult = await Bun.$`git -C ${repoPath} rev-parse ${headSha}^{tree}`.quiet().nothrow();
|
|
157
|
+
|
|
158
|
+
if (headTreeResult.exitCode !== 0) {
|
|
159
|
+
errors.push(`Failed to get HEAD tree: ${headTreeResult.stderr.toString().trim()}`);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const headTree = headTreeResult.stdout.toString().trim();
|
|
164
|
+
|
|
165
|
+
if (execResult.final_tree_sha !== headTree) {
|
|
166
|
+
errors.push(
|
|
167
|
+
`Final tree mismatch: stack top = ${execResult.final_tree_sha}, HEAD = ${headTree}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function extractPathsFromRawDiff(output: Buffer): string[] {
|
|
173
|
+
const paths: string[] = [];
|
|
174
|
+
const entries = output.toString("utf-8").split("\0").filter((s) => s.length > 0);
|
|
175
|
+
|
|
176
|
+
for (let i = 0; i < entries.length; i++) {
|
|
177
|
+
const entry = entries[i];
|
|
178
|
+
if (!entry) continue;
|
|
179
|
+
if (!entry.startsWith(":")) continue;
|
|
180
|
+
|
|
181
|
+
const match = entry.match(/^:(\d+) (\d+) ([0-9a-f]+) ([0-9a-f]+) ([AMDRC])(\d*)$/);
|
|
182
|
+
if (!match) continue;
|
|
183
|
+
|
|
184
|
+
const status = match[5];
|
|
185
|
+
const pathEntry = entries[i + 1];
|
|
186
|
+
if (!pathEntry) continue;
|
|
187
|
+
i++;
|
|
188
|
+
|
|
189
|
+
if (status === "R") {
|
|
190
|
+
const newPath = entries[i + 1];
|
|
191
|
+
if (newPath) {
|
|
192
|
+
paths.push(newPath);
|
|
193
|
+
i++;
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
paths.push(pathEntry);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return paths;
|
|
201
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { CheckCircle2, XCircle, ChevronRight } from "lucide-react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import type { FeasibilityResult } from "../../../stack/types.ts";
|
|
4
|
+
|
|
5
|
+
export function FeasibilityAlert({ result }: { result: FeasibilityResult }) {
|
|
6
|
+
const [expanded, setExpanded] = useState(false);
|
|
7
|
+
|
|
8
|
+
if (result.feasible) {
|
|
9
|
+
return (
|
|
10
|
+
<div className="flex items-center gap-2 px-3 py-2 rounded-md bg-green-500/[0.04]">
|
|
11
|
+
<CheckCircle2 className="h-3.5 w-3.5 text-green-600/60 dark:text-green-400/60 shrink-0" />
|
|
12
|
+
<span className="text-[11px] text-green-700/70 dark:text-green-300/70 font-medium">Feasible</span>
|
|
13
|
+
{result.ordered_group_ids && (
|
|
14
|
+
<span className="text-[10px] text-muted-foreground/25 truncate">
|
|
15
|
+
{result.ordered_group_ids.join(" → ")}
|
|
16
|
+
</span>
|
|
17
|
+
)}
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const hasCycleDetails = result.cycle && result.cycle.edge_cycle.length > 0;
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="rounded-md bg-red-500/[0.04] px-3 py-2.5 space-y-2">
|
|
26
|
+
<button
|
|
27
|
+
type="button"
|
|
28
|
+
onClick={() => hasCycleDetails && setExpanded(!expanded)}
|
|
29
|
+
className={`flex items-center gap-2 w-full text-left ${hasCycleDetails ? "cursor-pointer" : "cursor-default"}`}
|
|
30
|
+
>
|
|
31
|
+
<XCircle className="h-3.5 w-3.5 text-red-500/60 shrink-0" />
|
|
32
|
+
<span className="text-[11px] text-red-600/80 dark:text-red-400/80 font-medium flex-1">
|
|
33
|
+
Not feasible — dependency cycle
|
|
34
|
+
</span>
|
|
35
|
+
{hasCycleDetails && (
|
|
36
|
+
<ChevronRight className={`h-3 w-3 text-red-500/30 shrink-0 transition-transform duration-150 ${expanded ? "rotate-90" : ""}`} />
|
|
37
|
+
)}
|
|
38
|
+
</button>
|
|
39
|
+
|
|
40
|
+
{result.cycle && (
|
|
41
|
+
<p className="text-[10px] text-red-500/50 pl-5.5">
|
|
42
|
+
{result.cycle.group_cycle.join(" → ")} → {result.cycle.group_cycle[0]}
|
|
43
|
+
</p>
|
|
44
|
+
)}
|
|
45
|
+
|
|
46
|
+
{expanded && result.cycle && (
|
|
47
|
+
<div className="pl-5.5 space-y-1">
|
|
48
|
+
{result.cycle.edge_cycle.map((edge, i) => (
|
|
49
|
+
<div key={i} className="text-[10px] text-red-500/40">
|
|
50
|
+
{edge.from} → {edge.to}
|
|
51
|
+
<span className="text-red-500/25"> ({edge.kind}{edge.evidence?.path ? `: ${edge.evidence.path}` : ""})</span>
|
|
52
|
+
</div>
|
|
53
|
+
))}
|
|
54
|
+
</div>
|
|
55
|
+
)}
|
|
56
|
+
|
|
57
|
+
{result.unassigned_paths && result.unassigned_paths.length > 0 && (
|
|
58
|
+
<p className="text-[10px] text-red-500/40 pl-5.5">
|
|
59
|
+
{result.unassigned_paths.length} unassigned file(s)
|
|
60
|
+
</p>
|
|
61
|
+
)}
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState, useCallback, useEffect, useRef } from "react";
|
|
2
|
-
import { ArrowLeft, Layers, FolderTree, BookOpen, MessageSquare, GitBranch, Sparkles, Check, ChevronDown, AlertTriangle, RefreshCw, Presentation } from "lucide-react";
|
|
2
|
+
import { ArrowLeft, Layers, FolderTree, BookOpen, MessageSquare, GitBranch, Sparkles, Check, ChevronDown, AlertTriangle, RefreshCw, Presentation, GitPullRequestArrow } from "lucide-react";
|
|
3
3
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "../../components/ui/tabs.tsx";
|
|
4
4
|
import type { NewprOutput } from "../../../types/output.ts";
|
|
5
5
|
import { GroupsPanel } from "../panels/GroupsPanel.tsx";
|
|
@@ -8,10 +8,11 @@ import { StoryPanel } from "../panels/StoryPanel.tsx";
|
|
|
8
8
|
import { DiscussionPanel } from "../panels/DiscussionPanel.tsx";
|
|
9
9
|
import { CartoonPanel } from "../panels/CartoonPanel.tsx";
|
|
10
10
|
import { SlidesPanel } from "../panels/SlidesPanel.tsx";
|
|
11
|
+
import { StackPanel } from "../panels/StackPanel.tsx";
|
|
11
12
|
import { ReviewModal } from "./ReviewModal.tsx";
|
|
12
13
|
import { useOutdatedCheck } from "../hooks/useOutdatedCheck.ts";
|
|
13
14
|
|
|
14
|
-
const VALID_TABS = ["story", "discussion", "groups", "files", "slides", "cartoon"] as const;
|
|
15
|
+
const VALID_TABS = ["story", "discussion", "groups", "files", "stack", "slides", "cartoon"] as const;
|
|
15
16
|
type TabValue = typeof VALID_TABS[number];
|
|
16
17
|
|
|
17
18
|
function getInitialTab(): TabValue {
|
|
@@ -236,6 +237,10 @@ export function ResultsScreen({
|
|
|
236
237
|
<FolderTree className="h-3 w-3 shrink-0" />
|
|
237
238
|
Files
|
|
238
239
|
</TabsTrigger>
|
|
240
|
+
<TabsTrigger value="stack">
|
|
241
|
+
<GitPullRequestArrow className="h-3 w-3 shrink-0" />
|
|
242
|
+
Stack
|
|
243
|
+
</TabsTrigger>
|
|
239
244
|
{(!enabledPlugins || enabledPlugins.includes("slides")) && (
|
|
240
245
|
<TabsTrigger value="slides">
|
|
241
246
|
<Presentation className="h-3 w-3 shrink-0" />
|
|
@@ -268,6 +273,9 @@ export function ResultsScreen({
|
|
|
268
273
|
onFileSelect={(path: string) => onAnchorClick("file", path)}
|
|
269
274
|
/>
|
|
270
275
|
</TabsContent>
|
|
276
|
+
<TabsContent value="stack">
|
|
277
|
+
<StackPanel sessionId={sessionId} />
|
|
278
|
+
</TabsContent>
|
|
271
279
|
<TabsContent value="slides">
|
|
272
280
|
<SlidesPanel data={data} sessionId={sessionId} />
|
|
273
281
|
</TabsContent>
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { ChevronRight, GitBranch, ExternalLink, Plus, Minus } from "lucide-react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import type { StackGroupStats } from "../../../stack/types.ts";
|
|
4
|
+
|
|
5
|
+
const TYPE_COLORS: Record<string, { bg: string; text: string }> = {
|
|
6
|
+
feature: { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400" },
|
|
7
|
+
refactor: { bg: "bg-purple-500/10", text: "text-purple-600 dark:text-purple-400" },
|
|
8
|
+
bugfix: { bg: "bg-red-500/10", text: "text-red-600 dark:text-red-400" },
|
|
9
|
+
chore: { bg: "bg-neutral-500/10", text: "text-neutral-500" },
|
|
10
|
+
docs: { bg: "bg-teal-500/10", text: "text-teal-600 dark:text-teal-400" },
|
|
11
|
+
test: { bg: "bg-yellow-500/10", text: "text-yellow-600 dark:text-yellow-400" },
|
|
12
|
+
config: { bg: "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
|
+
function SizeBar({ stats }: { stats: StackGroupStats }) {
|
|
21
|
+
const total = stats.additions + stats.deletions;
|
|
22
|
+
if (total === 0) return null;
|
|
23
|
+
const addPct = Math.round((stats.additions / total) * 100);
|
|
24
|
+
const segments = 5;
|
|
25
|
+
const addSegs = Math.round((addPct / 100) * segments);
|
|
26
|
+
const delSegs = segments - addSegs;
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className="flex items-center gap-[2px]">
|
|
30
|
+
{Array.from({ length: addSegs }).map((_, i) => (
|
|
31
|
+
<div key={`a${i}`} className="h-[6px] w-[6px] rounded-[1px] bg-green-500/70" />
|
|
32
|
+
))}
|
|
33
|
+
{Array.from({ length: delSegs }).map((_, i) => (
|
|
34
|
+
<div key={`d${i}`} className="h-[6px] w-[6px] rounded-[1px] bg-red-500/70" />
|
|
35
|
+
))}
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface StackGroupCardProps {
|
|
41
|
+
group: {
|
|
42
|
+
id: string;
|
|
43
|
+
name: string;
|
|
44
|
+
type: string;
|
|
45
|
+
description: string;
|
|
46
|
+
files: string[];
|
|
47
|
+
order: number;
|
|
48
|
+
stats?: StackGroupStats;
|
|
49
|
+
pr_title?: string;
|
|
50
|
+
};
|
|
51
|
+
commit?: {
|
|
52
|
+
commit_sha: string;
|
|
53
|
+
branch_name: string;
|
|
54
|
+
};
|
|
55
|
+
pr?: {
|
|
56
|
+
number: number;
|
|
57
|
+
url: string;
|
|
58
|
+
title: string;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function StackGroupCard({ group, commit, pr }: StackGroupCardProps) {
|
|
63
|
+
const [expanded, setExpanded] = useState(false);
|
|
64
|
+
const stats = group.stats;
|
|
65
|
+
const colors = TYPE_COLORS[group.type] ?? TYPE_COLORS.chore!;
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="group/card">
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
onClick={() => setExpanded(!expanded)}
|
|
72
|
+
className="w-full flex items-center gap-2.5 px-3 py-2.5 text-left hover:bg-accent/20 transition-colors rounded-md"
|
|
73
|
+
>
|
|
74
|
+
<span className="text-[10px] text-muted-foreground/20 tabular-nums w-4 shrink-0 text-right">
|
|
75
|
+
{group.order + 1}
|
|
76
|
+
</span>
|
|
77
|
+
|
|
78
|
+
<ChevronRight className={`h-3 w-3 text-muted-foreground/25 shrink-0 transition-transform duration-150 ${expanded ? "rotate-90" : ""}`} />
|
|
79
|
+
|
|
80
|
+
<span className={`text-[9px] font-medium px-1.5 py-px rounded ${colors.bg} ${colors.text} shrink-0`}>
|
|
81
|
+
{group.type}
|
|
82
|
+
</span>
|
|
83
|
+
|
|
84
|
+
<span className="text-[12px] font-medium flex-1 min-w-0 truncate text-foreground/90">
|
|
85
|
+
{group.pr_title ?? group.name}
|
|
86
|
+
</span>
|
|
87
|
+
|
|
88
|
+
{stats ? (
|
|
89
|
+
<span className="flex items-center gap-2 shrink-0">
|
|
90
|
+
<span className="text-[10px] text-green-600/70 dark:text-green-400/70 tabular-nums">
|
|
91
|
+
+{formatStat(stats.additions)}
|
|
92
|
+
</span>
|
|
93
|
+
<span className="text-[10px] text-red-500/70 tabular-nums">
|
|
94
|
+
−{formatStat(stats.deletions)}
|
|
95
|
+
</span>
|
|
96
|
+
<SizeBar stats={stats} />
|
|
97
|
+
</span>
|
|
98
|
+
) : (
|
|
99
|
+
<span className="text-[10px] text-muted-foreground/25 shrink-0 tabular-nums">
|
|
100
|
+
{group.files.length}f
|
|
101
|
+
</span>
|
|
102
|
+
)}
|
|
103
|
+
</button>
|
|
104
|
+
|
|
105
|
+
{expanded && (
|
|
106
|
+
<div className="ml-[26px] pl-5 pb-3 space-y-2.5 border-l border-border/50">
|
|
107
|
+
{group.pr_title && (
|
|
108
|
+
<div className="flex items-center gap-1.5">
|
|
109
|
+
<span className="text-[10px] text-muted-foreground/30">{group.name}</span>
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
|
|
113
|
+
<p className="text-[11px] text-muted-foreground/50 leading-[1.6]">
|
|
114
|
+
{group.description}
|
|
115
|
+
</p>
|
|
116
|
+
|
|
117
|
+
{stats && (
|
|
118
|
+
<div className="flex items-center gap-4 text-[10px]">
|
|
119
|
+
<span className="text-muted-foreground/30 tabular-nums">
|
|
120
|
+
{group.files.length} files
|
|
121
|
+
</span>
|
|
122
|
+
<span className="flex items-center gap-1">
|
|
123
|
+
<Plus className="h-2.5 w-2.5 text-green-600/60 dark:text-green-400/60" />
|
|
124
|
+
<span className="tabular-nums text-green-600/60 dark:text-green-400/60">{stats.additions.toLocaleString()}</span>
|
|
125
|
+
</span>
|
|
126
|
+
<span className="flex items-center gap-1">
|
|
127
|
+
<Minus className="h-2.5 w-2.5 text-red-500/60" />
|
|
128
|
+
<span className="tabular-nums text-red-500/60">{stats.deletions.toLocaleString()}</span>
|
|
129
|
+
</span>
|
|
130
|
+
<span className="text-muted-foreground/20 tabular-nums">
|
|
131
|
+
{stats.files_added > 0 && `${stats.files_added}A `}
|
|
132
|
+
{stats.files_modified > 0 && `${stats.files_modified}M `}
|
|
133
|
+
{stats.files_deleted > 0 && `${stats.files_deleted}D`}
|
|
134
|
+
</span>
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
|
|
138
|
+
{commit && (
|
|
139
|
+
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground/30">
|
|
140
|
+
<GitBranch className="h-3 w-3 shrink-0" />
|
|
141
|
+
<span className="font-mono truncate">{commit.branch_name}</span>
|
|
142
|
+
<span className="text-muted-foreground/15">·</span>
|
|
143
|
+
<span className="font-mono shrink-0">{commit.commit_sha.slice(0, 7)}</span>
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
{pr && (
|
|
148
|
+
<a
|
|
149
|
+
href={pr.url}
|
|
150
|
+
target="_blank"
|
|
151
|
+
rel="noopener noreferrer"
|
|
152
|
+
className="inline-flex items-center gap-1.5 text-[10px] text-foreground/50 hover:text-foreground transition-colors"
|
|
153
|
+
>
|
|
154
|
+
<ExternalLink className="h-3 w-3 shrink-0" />
|
|
155
|
+
<span className="tabular-nums">#{pr.number}</span>
|
|
156
|
+
<span className="truncate">{pr.title}</span>
|
|
157
|
+
</a>
|
|
158
|
+
)}
|
|
159
|
+
|
|
160
|
+
<div className="space-y-0">
|
|
161
|
+
{group.files.map((file) => (
|
|
162
|
+
<div key={file} className="text-[10px] font-mono text-muted-foreground/25 py-[2px] truncate">
|
|
163
|
+
{file}
|
|
164
|
+
</div>
|
|
165
|
+
))}
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { ChevronRight, AlertTriangle, Info } from "lucide-react";
|
|
3
|
+
import type { StackWarning, StackWarningCategory } from "../../../stack/types.ts";
|
|
4
|
+
|
|
5
|
+
const CATEGORY_LABELS: Record<StackWarningCategory, string> = {
|
|
6
|
+
assignment: "File Assignment",
|
|
7
|
+
grouping: "Group Changes",
|
|
8
|
+
coupling: "Coupling Rules",
|
|
9
|
+
"verification.scope": "Scope Check",
|
|
10
|
+
"verification.completeness": "Completeness Check",
|
|
11
|
+
system: "System",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const CATEGORY_ORDER: StackWarningCategory[] = [
|
|
15
|
+
"system",
|
|
16
|
+
"assignment",
|
|
17
|
+
"coupling",
|
|
18
|
+
"grouping",
|
|
19
|
+
"verification.scope",
|
|
20
|
+
"verification.completeness",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function groupByCategory(warnings: StackWarning[]): Map<StackWarningCategory, StackWarning[]> {
|
|
24
|
+
const map = new Map<StackWarningCategory, StackWarning[]>();
|
|
25
|
+
for (const w of warnings) {
|
|
26
|
+
const existing = map.get(w.category) ?? [];
|
|
27
|
+
existing.push(w);
|
|
28
|
+
map.set(w.category, existing);
|
|
29
|
+
}
|
|
30
|
+
return map;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function WarningGroup({ category, items, defaultOpen }: {
|
|
34
|
+
category: StackWarningCategory;
|
|
35
|
+
items: StackWarning[];
|
|
36
|
+
defaultOpen: boolean;
|
|
37
|
+
}) {
|
|
38
|
+
const [expanded, setExpanded] = useState(defaultOpen);
|
|
39
|
+
const hasWarn = items.some((w) => w.severity === "warn");
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div>
|
|
43
|
+
<button
|
|
44
|
+
type="button"
|
|
45
|
+
onClick={() => setExpanded(!expanded)}
|
|
46
|
+
className="w-full flex items-center gap-2 px-2.5 py-2 text-left hover:bg-accent/20 transition-colors rounded-md"
|
|
47
|
+
>
|
|
48
|
+
<ChevronRight className={`h-3 w-3 text-muted-foreground/20 shrink-0 transition-transform duration-150 ${expanded ? "rotate-90" : ""}`} />
|
|
49
|
+
{hasWarn
|
|
50
|
+
? <AlertTriangle className="h-3 w-3 text-yellow-500/60 shrink-0" />
|
|
51
|
+
: <Info className="h-3 w-3 text-muted-foreground/30 shrink-0" />
|
|
52
|
+
}
|
|
53
|
+
<span className="text-[11px] font-medium text-muted-foreground/50 flex-1">
|
|
54
|
+
{CATEGORY_LABELS[category]}
|
|
55
|
+
</span>
|
|
56
|
+
<span className={`text-[9px] font-medium tabular-nums px-1.5 py-0.5 rounded-full ${
|
|
57
|
+
hasWarn
|
|
58
|
+
? "bg-yellow-500/8 text-yellow-600/70 dark:text-yellow-400/70"
|
|
59
|
+
: "bg-foreground/[0.04] text-muted-foreground/30"
|
|
60
|
+
}`}>
|
|
61
|
+
{items.length}
|
|
62
|
+
</span>
|
|
63
|
+
</button>
|
|
64
|
+
|
|
65
|
+
{expanded && (
|
|
66
|
+
<div className="ml-5 pl-3 border-l border-border/40 space-y-1.5 pb-2">
|
|
67
|
+
{items.map((w, i) => (
|
|
68
|
+
<WarningItem key={i} warning={w} />
|
|
69
|
+
))}
|
|
70
|
+
</div>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function WarningItem({ warning }: { warning: StackWarning }) {
|
|
77
|
+
const [detailsOpen, setDetailsOpen] = useState(false);
|
|
78
|
+
const hasDetails = warning.details && warning.details.length > 0;
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div className="py-1">
|
|
82
|
+
<div className="flex items-start gap-1.5">
|
|
83
|
+
<span className={`mt-[5px] h-1 w-1 rounded-full shrink-0 ${
|
|
84
|
+
warning.severity === "warn" ? "bg-yellow-500/60" : "bg-muted-foreground/20"
|
|
85
|
+
}`} />
|
|
86
|
+
<div className="flex-1 min-w-0">
|
|
87
|
+
<div className="text-[11px] font-medium text-foreground/60">{warning.title}</div>
|
|
88
|
+
<div className="text-[10px] text-muted-foreground/35 leading-relaxed">{warning.message}</div>
|
|
89
|
+
{hasDetails && (
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
onClick={() => setDetailsOpen(!detailsOpen)}
|
|
93
|
+
className="text-[10px] text-muted-foreground/25 hover:text-muted-foreground/50 mt-0.5 transition-colors"
|
|
94
|
+
>
|
|
95
|
+
{detailsOpen ? "Hide" : `${warning.details!.length} details`}
|
|
96
|
+
</button>
|
|
97
|
+
)}
|
|
98
|
+
{detailsOpen && hasDetails && (
|
|
99
|
+
<div className="mt-1.5 space-y-0">
|
|
100
|
+
{warning.details!.map((d, i) => (
|
|
101
|
+
<div key={i} className="text-[9px] font-mono text-muted-foreground/25 py-[1px] truncate">
|
|
102
|
+
{d}
|
|
103
|
+
</div>
|
|
104
|
+
))}
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function StackWarnings({ warnings, defaultCollapsed }: {
|
|
114
|
+
warnings: StackWarning[];
|
|
115
|
+
defaultCollapsed?: boolean;
|
|
116
|
+
}) {
|
|
117
|
+
if (warnings.length === 0) return null;
|
|
118
|
+
|
|
119
|
+
const grouped = groupByCategory(warnings);
|
|
120
|
+
const sortedCategories = CATEGORY_ORDER.filter((c) => grouped.has(c));
|
|
121
|
+
const hasAnyWarn = warnings.some((w) => w.severity === "warn");
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div className="space-y-0">
|
|
125
|
+
{sortedCategories.map((category) => (
|
|
126
|
+
<WarningGroup
|
|
127
|
+
key={category}
|
|
128
|
+
category={category}
|
|
129
|
+
items={grouped.get(category)!}
|
|
130
|
+
defaultOpen={!defaultCollapsed && hasAnyWarn}
|
|
131
|
+
/>
|
|
132
|
+
))}
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|