newpr 1.0.24 → 1.0.26
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 +2 -1
- package/src/stack/delta.test.ts +5 -3
- package/src/stack/delta.ts +1 -42
- package/src/stack/feasibility.test.ts +111 -0
- package/src/stack/feasibility.ts +40 -4
- package/src/stack/import-deps.test.ts +200 -1
- package/src/stack/import-deps.ts +114 -22
- package/src/stack/quality-gate.ts +10 -18
- package/src/web/client/components/Markdown.tsx +82 -4
- package/src/web/client/components/StackDagView.tsx +51 -35
- package/src/web/client/hooks/useStack.ts +27 -2
- package/src/web/client/panels/StackPanel.tsx +167 -5
- package/src/web/server/routes.ts +3 -2
- package/src/web/server/stack-manager.ts +113 -9
- package/src/web/styles/built.css +1 -1
|
@@ -13,6 +13,7 @@ const LOCKFILES = [
|
|
|
13
13
|
interface QualityGateInput {
|
|
14
14
|
repo_path: string;
|
|
15
15
|
exec_result: StackExecResult;
|
|
16
|
+
custom_env?: Record<string, string>;
|
|
16
17
|
onProgress?: (message: string) => void;
|
|
17
18
|
checkAborted?: () => void;
|
|
18
19
|
}
|
|
@@ -24,12 +25,12 @@ interface PackageJson {
|
|
|
24
25
|
|
|
25
26
|
type PackageManager = "bun" | "pnpm" | "yarn" | "npm";
|
|
26
27
|
|
|
27
|
-
async function runProcess(args: string[], cwd: string): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
|
28
|
+
async function runProcess(args: string[], cwd: string, extraEnv?: Record<string, string>): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
|
28
29
|
const proc = Bun.spawn(args, {
|
|
29
30
|
cwd,
|
|
30
31
|
stdout: "pipe",
|
|
31
32
|
stderr: "pipe",
|
|
32
|
-
env: { ...process.env, CI: "true" },
|
|
33
|
+
env: { ...process.env, CI: "true", ...extraEnv },
|
|
33
34
|
});
|
|
34
35
|
const [exitCode, stdout, stderr] = await Promise.all([
|
|
35
36
|
proc.exited,
|
|
@@ -98,7 +99,9 @@ function runScriptCommand(manager: PackageManager, script: string): string[] {
|
|
|
98
99
|
function selectRequiredScripts(pkg: PackageJson): string[] {
|
|
99
100
|
const scripts = pkg.scripts ?? {};
|
|
100
101
|
const required: string[] = [];
|
|
102
|
+
if (typeof scripts.typecheck === "string" && scripts.typecheck.trim()) required.push("typecheck");
|
|
101
103
|
if (typeof scripts.lint === "string" && scripts.lint.trim()) required.push("lint");
|
|
104
|
+
if (typeof scripts.test === "string" && scripts.test.trim()) required.push("test");
|
|
102
105
|
if (typeof scripts.build === "string" && scripts.build.trim()) required.push("build");
|
|
103
106
|
return required;
|
|
104
107
|
}
|
|
@@ -115,11 +118,11 @@ async function depsChanged(repoPath: string, prevCommit: string, nextCommit: str
|
|
|
115
118
|
return result.stdout.toString().trim().length > 0;
|
|
116
119
|
}
|
|
117
120
|
|
|
118
|
-
async function installDependencies(worktreePath: string, manager: PackageManager): Promise<boolean> {
|
|
121
|
+
async function installDependencies(worktreePath: string, manager: PackageManager, extraEnv?: Record<string, string>): Promise<boolean> {
|
|
119
122
|
const candidates = installCommandCandidates(manager);
|
|
120
123
|
|
|
121
124
|
for (const cmd of candidates) {
|
|
122
|
-
const install = await runProcess(cmd, worktreePath);
|
|
125
|
+
const install = await runProcess(cmd, worktreePath, extraEnv);
|
|
123
126
|
if (install.exitCode === 0) return true;
|
|
124
127
|
}
|
|
125
128
|
|
|
@@ -138,7 +141,7 @@ export interface QualityGateResult {
|
|
|
138
141
|
}
|
|
139
142
|
|
|
140
143
|
export async function runStackQualityGate(input: QualityGateInput): Promise<QualityGateResult> {
|
|
141
|
-
const { repo_path, exec_result, onProgress, checkAborted } = input;
|
|
144
|
+
const { repo_path, exec_result, custom_env, onProgress, checkAborted } = input;
|
|
142
145
|
if (exec_result.group_commits.length === 0) {
|
|
143
146
|
return { ran: false, skippedReason: "No group commits", groupResults: [] };
|
|
144
147
|
}
|
|
@@ -206,7 +209,7 @@ export async function runStackQualityGate(input: QualityGateInput): Promise<Qual
|
|
|
206
209
|
|
|
207
210
|
if (!installReady || await depsChanged(repo_path, prevCommit, commit.commit_sha)) {
|
|
208
211
|
onProgress?.(`${label}: installing dependencies with ${manager}...`);
|
|
209
|
-
const installed = await installDependencies(worktreePath, manager);
|
|
212
|
+
const installed = await installDependencies(worktreePath, manager, custom_env);
|
|
210
213
|
if (!installed) {
|
|
211
214
|
onProgress?.(`${label}: dependency install failed (${manager}) — skipping quality gate for remaining groups`);
|
|
212
215
|
for (let j = i; j < exec_result.group_commits.length; j++) {
|
|
@@ -231,7 +234,7 @@ export async function runStackQualityGate(input: QualityGateInput): Promise<Qual
|
|
|
231
234
|
checkAborted?.();
|
|
232
235
|
const runCmd = runScriptCommand(manager, script);
|
|
233
236
|
onProgress?.(`${label}: ${runCmd.join(" ")}`);
|
|
234
|
-
const run = await runProcess(runCmd, worktreePath);
|
|
237
|
+
const run = await runProcess(runCmd, worktreePath, custom_env);
|
|
235
238
|
if (run.exitCode !== 0) {
|
|
236
239
|
const output = trimOutput(`${run.stdout}\n${run.stderr}`.trim());
|
|
237
240
|
scriptResults.push({ name: script, passed: false, error: output });
|
|
@@ -249,16 +252,5 @@ export async function runStackQualityGate(input: QualityGateInput): Promise<Qual
|
|
|
249
252
|
await Bun.$`rm -rf ${worktreePath}`.quiet().nothrow();
|
|
250
253
|
}
|
|
251
254
|
|
|
252
|
-
const anyFailed = groupResults.some((g) => !g.passed && !g.skipped);
|
|
253
|
-
if (anyFailed) {
|
|
254
|
-
const failures = groupResults
|
|
255
|
-
.filter((g) => !g.passed && !g.skipped)
|
|
256
|
-
.map((g) => {
|
|
257
|
-
const failedScripts = g.scripts.filter((s) => !s.passed).map((s) => s.name).join(", ");
|
|
258
|
-
return `${g.group_id} (${failedScripts})`;
|
|
259
|
-
});
|
|
260
|
-
throw new Error(`Quality gate failed: ${failures.join("; ")}`);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
255
|
return { ran: true, groupResults };
|
|
264
256
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { memo, useState, useEffect, useMemo } from "react";
|
|
1
|
+
import { memo, useState, useEffect, useMemo, isValidElement } from "react";
|
|
2
2
|
import ReactMarkdown from "react-markdown";
|
|
3
3
|
import remarkGfm from "remark-gfm";
|
|
4
4
|
import remarkMath from "remark-math";
|
|
@@ -211,6 +211,75 @@ function preprocess(text: string): string {
|
|
|
211
211
|
return processed.replace(/\x00MATH_BLOCK_(\d+)\x00/g, (_, idx) => mathBlocks[Number(idx)]!);
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
let mermaidCounter = 0;
|
|
215
|
+
let mermaidModule: typeof import("mermaid") | null = null;
|
|
216
|
+
let mermaidLoading: Promise<typeof import("mermaid")> | null = null;
|
|
217
|
+
|
|
218
|
+
function loadMermaid(): Promise<typeof import("mermaid")> {
|
|
219
|
+
if (mermaidModule) return Promise.resolve(mermaidModule);
|
|
220
|
+
if (!mermaidLoading) {
|
|
221
|
+
mermaidLoading = import("mermaid").then((m) => {
|
|
222
|
+
mermaidModule = m;
|
|
223
|
+
return m;
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
return mermaidLoading;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function MermaidBlock({ code, dark }: { code: string; dark: boolean }) {
|
|
230
|
+
const [svg, setSvg] = useState<string>("");
|
|
231
|
+
const [error, setError] = useState(false);
|
|
232
|
+
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
let cancelled = false;
|
|
235
|
+
const id = `mermaid-${++mermaidCounter}`;
|
|
236
|
+
|
|
237
|
+
loadMermaid()
|
|
238
|
+
.then(({ default: mermaid }) => {
|
|
239
|
+
if (cancelled) return;
|
|
240
|
+
mermaid.initialize({
|
|
241
|
+
startOnLoad: false,
|
|
242
|
+
theme: dark ? "dark" : "default",
|
|
243
|
+
securityLevel: "loose",
|
|
244
|
+
fontFamily: "inherit",
|
|
245
|
+
});
|
|
246
|
+
return mermaid.render(id, code);
|
|
247
|
+
})
|
|
248
|
+
.then((result) => {
|
|
249
|
+
if (!cancelled && result) {
|
|
250
|
+
setSvg(result.svg);
|
|
251
|
+
setError(false);
|
|
252
|
+
}
|
|
253
|
+
})
|
|
254
|
+
.catch(() => {
|
|
255
|
+
if (!cancelled) setError(true);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
return () => {
|
|
259
|
+
cancelled = true;
|
|
260
|
+
};
|
|
261
|
+
}, [code, dark]);
|
|
262
|
+
|
|
263
|
+
if (error) {
|
|
264
|
+
return <code className="text-xs font-mono whitespace-pre-wrap">{code}</code>;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (!svg) {
|
|
268
|
+
return (
|
|
269
|
+
<div className="flex items-center justify-center py-6 text-xs text-muted-foreground/40">
|
|
270
|
+
Rendering diagram…
|
|
271
|
+
</div>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return (
|
|
276
|
+
<div
|
|
277
|
+
className="my-2 overflow-x-auto [&>svg]:mx-auto [&>svg]:max-w-full"
|
|
278
|
+
dangerouslySetInnerHTML={{ __html: svg }}
|
|
279
|
+
/>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
214
283
|
export const Markdown = memo(function Markdown({ children, onAnchorClick, activeId, streaming = false }: MarkdownProps) {
|
|
215
284
|
const processed = useMemo(() => preprocess(children), [children]);
|
|
216
285
|
const hl = useHighlighter();
|
|
@@ -234,6 +303,10 @@ export const Markdown = memo(function Markdown({ children, onAnchorClick, active
|
|
|
234
303
|
}
|
|
235
304
|
return <code className="px-1.5 py-0.5 rounded bg-muted text-xs font-mono">{children}</code>;
|
|
236
305
|
}
|
|
306
|
+
if (className?.includes("language-mermaid")) {
|
|
307
|
+
const raw = String(children).replace(/\n$/, "");
|
|
308
|
+
return <MermaidBlock code={raw} dark={dark} />;
|
|
309
|
+
}
|
|
237
310
|
const lang = langFromClassName(className);
|
|
238
311
|
if (lang && hl) {
|
|
239
312
|
const code = String(children).replace(/\n$/, "");
|
|
@@ -250,9 +323,14 @@ export const Markdown = memo(function Markdown({ children, onAnchorClick, active
|
|
|
250
323
|
}
|
|
251
324
|
return <code className="px-1.5 py-0.5 rounded bg-muted text-xs font-mono">{children}</code>;
|
|
252
325
|
},
|
|
253
|
-
pre: ({ children }) =>
|
|
254
|
-
|
|
255
|
-
|
|
326
|
+
pre: ({ children }) => {
|
|
327
|
+
if (isValidElement(children) && children.type === MermaidBlock) {
|
|
328
|
+
return <div className="rounded-lg border border-border/50 bg-muted/30 p-4 mb-3 overflow-x-auto">{children}</div>;
|
|
329
|
+
}
|
|
330
|
+
return (
|
|
331
|
+
<pre className="bg-muted rounded-lg p-4 overflow-x-auto mb-3 whitespace-pre text-xs font-mono [&>span>pre]:!bg-transparent [&>span>pre]:!p-0 [&>span>pre]:!m-0">{children}</pre>
|
|
332
|
+
);
|
|
333
|
+
},
|
|
256
334
|
span: ({ children, ...props }) => {
|
|
257
335
|
const allProps = props as Record<string, unknown>;
|
|
258
336
|
const lineRef = allProps["data-line-ref"] as string | undefined;
|
|
@@ -154,13 +154,11 @@ function DagNodeCard({
|
|
|
154
154
|
node,
|
|
155
155
|
commit,
|
|
156
156
|
pr,
|
|
157
|
-
fileStatsByPath,
|
|
158
157
|
rowRef,
|
|
159
158
|
}: {
|
|
160
159
|
node: DagNode;
|
|
161
160
|
commit?: DagCommit;
|
|
162
161
|
pr?: DagPr;
|
|
163
|
-
fileStatsByPath?: Record<string, { additions: number; deletions: number }>;
|
|
164
162
|
allGroups?: DagGroup[];
|
|
165
163
|
rowRef: (el: HTMLDivElement | null) => void;
|
|
166
164
|
}) {
|
|
@@ -168,34 +166,16 @@ function DagNodeCard({
|
|
|
168
166
|
const { group } = node;
|
|
169
167
|
const resolveFileStats = (file: string): { additions: number; deletions: number } => {
|
|
170
168
|
const normalizedPath = normalizeFilePath(file);
|
|
171
|
-
|
|
172
|
-
??
|
|
169
|
+
return group.file_stats?.[file]
|
|
170
|
+
?? group.file_stats?.[normalizedPath]
|
|
173
171
|
?? { additions: 0, deletions: 0 };
|
|
174
|
-
const fromGroup = group.file_stats?.[file]
|
|
175
|
-
?? group.file_stats?.[normalizedPath];
|
|
176
|
-
if (fromGroup && (fromGroup.additions > 0 || fromGroup.deletions > 0)) return fromGroup;
|
|
177
|
-
if (fallbackStats.additions > 0 || fallbackStats.deletions > 0) return fallbackStats;
|
|
178
|
-
return fromGroup ?? fallbackStats;
|
|
179
172
|
};
|
|
180
173
|
|
|
181
174
|
const fileRows = group.files.map((file) => ({
|
|
182
175
|
path: file,
|
|
183
176
|
stats: resolveFileStats(file),
|
|
184
177
|
}));
|
|
185
|
-
const
|
|
186
|
-
(acc, row) => ({ additions: acc.additions + row.stats.additions, deletions: acc.deletions + row.stats.deletions }),
|
|
187
|
-
{ additions: 0, deletions: 0 },
|
|
188
|
-
);
|
|
189
|
-
const hasFileTotals = fileRows.some((row) => row.stats.additions > 0 || row.stats.deletions > 0);
|
|
190
|
-
const stats = hasFileTotals
|
|
191
|
-
? {
|
|
192
|
-
additions: fileTotals.additions,
|
|
193
|
-
deletions: fileTotals.deletions,
|
|
194
|
-
files_added: group.stats?.files_added ?? 0,
|
|
195
|
-
files_modified: group.stats?.files_modified ?? 0,
|
|
196
|
-
files_deleted: group.stats?.files_deleted ?? 0,
|
|
197
|
-
}
|
|
198
|
-
: group.stats;
|
|
178
|
+
const stats = group.stats;
|
|
199
179
|
const colors = TYPE_COLORS[group.type] ?? TYPE_COLORS.chore!;
|
|
200
180
|
const leftPad = node.indent * INDENT + DOT_CX * 2 + 4;
|
|
201
181
|
|
|
@@ -284,18 +264,55 @@ function DagNodeCard({
|
|
|
284
264
|
);
|
|
285
265
|
}
|
|
286
266
|
|
|
267
|
+
function filterEmptyGroups(groups: DagGroup[]): DagGroup[] {
|
|
268
|
+
const emptyIds = new Set(
|
|
269
|
+
groups.filter((g) => g.stats && g.stats.additions === 0 && g.stats.deletions === 0).map((g) => g.id),
|
|
270
|
+
);
|
|
271
|
+
if (emptyIds.size === 0) return groups;
|
|
272
|
+
|
|
273
|
+
const byId = new Map(groups.map((g) => [g.id, g]));
|
|
274
|
+
|
|
275
|
+
const resolveParents = (id: string, visited = new Set<string>()): string[] => {
|
|
276
|
+
if (visited.has(id)) return [];
|
|
277
|
+
visited.add(id);
|
|
278
|
+
const g = byId.get(id);
|
|
279
|
+
if (!g) return [];
|
|
280
|
+
const parents = g.explicit_deps ?? g.deps ?? [];
|
|
281
|
+
const resolved: string[] = [];
|
|
282
|
+
for (const p of parents) {
|
|
283
|
+
if (emptyIds.has(p)) {
|
|
284
|
+
resolved.push(...resolveParents(p, visited));
|
|
285
|
+
} else {
|
|
286
|
+
resolved.push(p);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return resolved;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
return groups
|
|
293
|
+
.filter((g) => !emptyIds.has(g.id))
|
|
294
|
+
.map((g, i) => {
|
|
295
|
+
const newDeps = resolveParents(g.id);
|
|
296
|
+
return {
|
|
297
|
+
...g,
|
|
298
|
+
deps: [...new Set(newDeps)],
|
|
299
|
+
explicit_deps: g.explicit_deps ? [...new Set(newDeps)] : undefined,
|
|
300
|
+
order: i,
|
|
301
|
+
};
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
287
305
|
export function StackDagView({
|
|
288
306
|
groups,
|
|
289
307
|
groupCommits,
|
|
290
308
|
publishedPrs,
|
|
291
|
-
fileStatsByPath,
|
|
292
309
|
}: {
|
|
293
310
|
groups: DagGroup[];
|
|
294
311
|
groupCommits?: DagCommit[];
|
|
295
312
|
publishedPrs?: DagPr[];
|
|
296
|
-
fileStatsByPath?: Record<string, { additions: number; deletions: number }>;
|
|
297
313
|
}) {
|
|
298
|
-
const
|
|
314
|
+
const filteredGroups = filterEmptyGroups(groups);
|
|
315
|
+
const nodes = buildDagNodes(filteredGroups);
|
|
299
316
|
const isLinear = nodes.every((n) => n.indent === 0);
|
|
300
317
|
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
301
318
|
const [rowHeights, setRowHeights] = useState<number[]>([]);
|
|
@@ -370,15 +387,14 @@ export function StackDagView({
|
|
|
370
387
|
const commit = groupCommits?.find((c) => c.group_id === node.group.id);
|
|
371
388
|
const pr = publishedPrs?.find((p) => p.group_id === node.group.id);
|
|
372
389
|
return (
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
/>
|
|
390
|
+
<DagNodeCard
|
|
391
|
+
key={node.group.id}
|
|
392
|
+
node={node}
|
|
393
|
+
commit={commit}
|
|
394
|
+
pr={pr}
|
|
395
|
+
allGroups={filteredGroups}
|
|
396
|
+
rowRef={(el) => { rowRefs.current[i] = el; }}
|
|
397
|
+
/>
|
|
382
398
|
);
|
|
383
399
|
})}
|
|
384
400
|
</div>
|
|
@@ -105,6 +105,17 @@ interface PublishResultData {
|
|
|
105
105
|
};
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
interface QualityGateResultData {
|
|
109
|
+
ran: boolean;
|
|
110
|
+
skippedReason?: string;
|
|
111
|
+
groupResults: Array<{
|
|
112
|
+
group_id: string;
|
|
113
|
+
passed: boolean;
|
|
114
|
+
skipped: boolean;
|
|
115
|
+
scripts: Array<{ name: string; passed: boolean; error?: string }>;
|
|
116
|
+
}>;
|
|
117
|
+
}
|
|
118
|
+
|
|
108
119
|
interface PublishPreviewData {
|
|
109
120
|
template_path: string | null;
|
|
110
121
|
generatedAt?: number;
|
|
@@ -130,6 +141,7 @@ interface ServerStackState {
|
|
|
130
141
|
plan: PlanData | null;
|
|
131
142
|
execResult: ExecResultData | null;
|
|
132
143
|
verifyResult: VerifyResultData | null;
|
|
144
|
+
qualityGateResult: QualityGateResultData | null;
|
|
133
145
|
publishResult: PublishResultData | null;
|
|
134
146
|
publishPreview: PublishPreviewData | null;
|
|
135
147
|
startedAt: number;
|
|
@@ -146,6 +158,7 @@ export interface StackState {
|
|
|
146
158
|
plan: PlanData | null;
|
|
147
159
|
execResult: ExecResultData | null;
|
|
148
160
|
verifyResult: VerifyResultData | null;
|
|
161
|
+
qualityGateResult: QualityGateResultData | null;
|
|
149
162
|
publishResult: PublishResultData | null;
|
|
150
163
|
publishPreview: PublishPreviewData | null;
|
|
151
164
|
publishPreviewLoading: boolean;
|
|
@@ -154,6 +167,7 @@ export interface StackState {
|
|
|
154
167
|
publishCleanupError: string | null;
|
|
155
168
|
progressMessage: string | null;
|
|
156
169
|
analysisFileStatsByPath: Record<string, { additions: number; deletions: number }>;
|
|
170
|
+
envVars: Record<string, string>;
|
|
157
171
|
}
|
|
158
172
|
|
|
159
173
|
function normalizeFilePath(path: string): string {
|
|
@@ -195,6 +209,7 @@ function applyServerState(server: ServerStackState): Partial<StackState> {
|
|
|
195
209
|
plan: server.plan,
|
|
196
210
|
execResult: server.execResult,
|
|
197
211
|
verifyResult: server.verifyResult,
|
|
212
|
+
qualityGateResult: server.qualityGateResult ?? null,
|
|
198
213
|
publishResult: server.publishResult,
|
|
199
214
|
publishPreview: server.publishPreview,
|
|
200
215
|
};
|
|
@@ -215,6 +230,7 @@ export function useStack(sessionId: string | null | undefined, options?: UseStac
|
|
|
215
230
|
plan: null,
|
|
216
231
|
execResult: null,
|
|
217
232
|
verifyResult: null,
|
|
233
|
+
qualityGateResult: null,
|
|
218
234
|
publishResult: null,
|
|
219
235
|
publishPreview: null,
|
|
220
236
|
publishPreviewLoading: false,
|
|
@@ -223,6 +239,7 @@ export function useStack(sessionId: string | null | undefined, options?: UseStac
|
|
|
223
239
|
publishCleanupError: null,
|
|
224
240
|
progressMessage: null,
|
|
225
241
|
analysisFileStatsByPath: {},
|
|
242
|
+
envVars: {},
|
|
226
243
|
});
|
|
227
244
|
|
|
228
245
|
const eventSourceRef = useRef<EventSource | null>(null);
|
|
@@ -305,15 +322,20 @@ export function useStack(sessionId: string | null | undefined, options?: UseStac
|
|
|
305
322
|
};
|
|
306
323
|
}, []);
|
|
307
324
|
|
|
325
|
+
const setEnvVars = useCallback((envVars: Record<string, string>) => {
|
|
326
|
+
setState((s) => ({ ...s, envVars }));
|
|
327
|
+
}, []);
|
|
328
|
+
|
|
308
329
|
const runFullPipeline = useCallback(async () => {
|
|
309
330
|
if (!sessionId) return;
|
|
310
331
|
|
|
311
332
|
setState((s) => ({ ...s, phase: "partitioning", error: null, progressMessage: "Starting..." }));
|
|
312
333
|
try {
|
|
334
|
+
const envVars = Object.keys(state.envVars).length > 0 ? state.envVars : undefined;
|
|
313
335
|
const res = await fetch("/api/stack/start", {
|
|
314
336
|
method: "POST",
|
|
315
337
|
headers: { "Content-Type": "application/json" },
|
|
316
|
-
body: JSON.stringify({ sessionId, maxGroups: state.maxGroups }),
|
|
338
|
+
body: JSON.stringify({ sessionId, maxGroups: state.maxGroups, envVars }),
|
|
317
339
|
});
|
|
318
340
|
const data = await res.json();
|
|
319
341
|
if (!res.ok) throw new Error(data.error ?? "Failed to start stack pipeline");
|
|
@@ -327,7 +349,7 @@ export function useStack(sessionId: string | null | undefined, options?: UseStac
|
|
|
327
349
|
progressMessage: null,
|
|
328
350
|
}));
|
|
329
351
|
}
|
|
330
|
-
}, [sessionId, state.maxGroups, connectSSE]);
|
|
352
|
+
}, [sessionId, state.maxGroups, state.envVars, connectSSE]);
|
|
331
353
|
|
|
332
354
|
const startPublish = useCallback(async () => {
|
|
333
355
|
if (!sessionId) return;
|
|
@@ -514,6 +536,7 @@ export function useStack(sessionId: string | null | undefined, options?: UseStac
|
|
|
514
536
|
plan: null,
|
|
515
537
|
execResult: null,
|
|
516
538
|
verifyResult: null,
|
|
539
|
+
qualityGateResult: null,
|
|
517
540
|
publishResult: null,
|
|
518
541
|
publishPreview: null,
|
|
519
542
|
publishPreviewLoading: false,
|
|
@@ -522,6 +545,7 @@ export function useStack(sessionId: string | null | undefined, options?: UseStac
|
|
|
522
545
|
publishCleanupError: null,
|
|
523
546
|
progressMessage: null,
|
|
524
547
|
analysisFileStatsByPath: s.analysisFileStatsByPath,
|
|
548
|
+
envVars: s.envVars,
|
|
525
549
|
}));
|
|
526
550
|
}, []);
|
|
527
551
|
|
|
@@ -534,6 +558,7 @@ export function useStack(sessionId: string | null | undefined, options?: UseStac
|
|
|
534
558
|
return {
|
|
535
559
|
...state,
|
|
536
560
|
setMaxGroups,
|
|
561
|
+
setEnvVars,
|
|
537
562
|
runFullPipeline,
|
|
538
563
|
startPublish,
|
|
539
564
|
loadPublishPreview,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import { Loader2, Play, Upload, RotateCcw, CheckCircle2, AlertTriangle, Circle, GitPullRequestArrow, ArrowRight, Layers, FileText, RefreshCw, XCircle, Trash2, Plus, KeyRound } from "lucide-react";
|
|
2
3
|
import { useStack } from "../hooks/useStack.ts";
|
|
3
4
|
import { FeasibilityAlert } from "../components/FeasibilityAlert.tsx";
|
|
4
5
|
import { StackDagView } from "../components/StackDagView.tsx";
|
|
@@ -69,6 +70,162 @@ function PipelineTimeline({ phase }: { phase: StackPhase }) {
|
|
|
69
70
|
);
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
function EnvVarsInput({ envVars, setEnvVars }: { envVars: Record<string, string>; setEnvVars: (v: Record<string, string>) => void }) {
|
|
74
|
+
const [rows, setRows] = useState<Array<{ key: string; value: string }>>(() => {
|
|
75
|
+
const entries = Object.entries(envVars);
|
|
76
|
+
return entries.length > 0 ? entries.map(([key, value]) => ({ key, value })) : [];
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
const record: Record<string, string> = {};
|
|
81
|
+
for (const row of rows) {
|
|
82
|
+
const k = row.key.trim();
|
|
83
|
+
if (k) record[k] = row.value;
|
|
84
|
+
}
|
|
85
|
+
setEnvVars(record);
|
|
86
|
+
}, [rows, setEnvVars]);
|
|
87
|
+
|
|
88
|
+
const addRow = () => setRows((r) => [...r, { key: "", value: "" }]);
|
|
89
|
+
|
|
90
|
+
const updateRow = (idx: number, field: "key" | "value", val: string) => {
|
|
91
|
+
setRows((r) => r.map((row, i) => i === idx ? { ...row, [field]: val } : row));
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const removeRow = (idx: number) => setRows((r) => r.filter((_, i) => i !== idx));
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<details className="mb-5 group">
|
|
98
|
+
<summary className="cursor-pointer list-none flex items-center gap-2 text-[11px] text-muted-foreground/40 hover:text-muted-foreground/60 transition-colors select-none">
|
|
99
|
+
<KeyRound className="h-3 w-3" />
|
|
100
|
+
<span>Environment Variables</span>
|
|
101
|
+
{rows.length > 0 && (
|
|
102
|
+
<span className="text-[10px] tabular-nums text-muted-foreground/25">({rows.length})</span>
|
|
103
|
+
)}
|
|
104
|
+
</summary>
|
|
105
|
+
<div className="mt-2.5 space-y-2">
|
|
106
|
+
<p className="text-[10px] text-muted-foreground/30 leading-relaxed">
|
|
107
|
+
Set env vars for quality gate scripts (e.g. NPM_TOKEN, CI tokens)
|
|
108
|
+
</p>
|
|
109
|
+
{rows.map((row, idx) => (
|
|
110
|
+
<div key={idx} className="flex items-center gap-1.5">
|
|
111
|
+
<input
|
|
112
|
+
type="text"
|
|
113
|
+
placeholder="KEY"
|
|
114
|
+
value={row.key}
|
|
115
|
+
onChange={(e) => updateRow(idx, "key", e.target.value.toUpperCase())}
|
|
116
|
+
className="h-7 flex-1 min-w-0 rounded-md border bg-transparent px-2 text-[10px] font-mono placeholder:text-muted-foreground/20 focus:outline-none focus:ring-1 focus:ring-foreground/20"
|
|
117
|
+
/>
|
|
118
|
+
<input
|
|
119
|
+
type="text"
|
|
120
|
+
placeholder="value"
|
|
121
|
+
value={row.value}
|
|
122
|
+
onChange={(e) => updateRow(idx, "value", e.target.value)}
|
|
123
|
+
className="h-7 flex-[2] min-w-0 rounded-md border bg-transparent px-2 text-[10px] font-mono placeholder:text-muted-foreground/20 focus:outline-none focus:ring-1 focus:ring-foreground/20"
|
|
124
|
+
/>
|
|
125
|
+
<button
|
|
126
|
+
type="button"
|
|
127
|
+
onClick={() => removeRow(idx)}
|
|
128
|
+
className="h-7 w-7 shrink-0 flex items-center justify-center rounded-md text-muted-foreground/25 hover:text-foreground/60 hover:bg-accent/30 transition-colors"
|
|
129
|
+
>
|
|
130
|
+
<XCircle className="h-3 w-3" />
|
|
131
|
+
</button>
|
|
132
|
+
</div>
|
|
133
|
+
))}
|
|
134
|
+
<button
|
|
135
|
+
type="button"
|
|
136
|
+
onClick={addRow}
|
|
137
|
+
className="flex items-center gap-1.5 text-[10px] text-muted-foreground/30 hover:text-muted-foreground/60 transition-colors"
|
|
138
|
+
>
|
|
139
|
+
<Plus className="h-3 w-3" />
|
|
140
|
+
Add variable
|
|
141
|
+
</button>
|
|
142
|
+
</div>
|
|
143
|
+
</details>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
interface QualityGateResultData {
|
|
148
|
+
ran: boolean;
|
|
149
|
+
skippedReason?: string;
|
|
150
|
+
groupResults: Array<{
|
|
151
|
+
group_id: string;
|
|
152
|
+
passed: boolean;
|
|
153
|
+
skipped: boolean;
|
|
154
|
+
scripts: Array<{ name: string; passed: boolean; error?: string }>;
|
|
155
|
+
}>;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function QualityGateResults({ result }: { result: QualityGateResultData }) {
|
|
159
|
+
if (!result.ran && result.skippedReason) {
|
|
160
|
+
return (
|
|
161
|
+
<div className="flex items-center gap-2.5 rounded-lg bg-foreground/[0.03] px-3.5 py-2.5">
|
|
162
|
+
<Circle className="h-3.5 w-3.5 text-muted-foreground/30 shrink-0" />
|
|
163
|
+
<span className="text-[11px] text-muted-foreground/40">
|
|
164
|
+
Quality gate skipped: {result.skippedReason}
|
|
165
|
+
</span>
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!result.ran) return null;
|
|
171
|
+
|
|
172
|
+
const totalGroups = result.groupResults.filter((g) => !g.skipped).length;
|
|
173
|
+
const passedGroups = result.groupResults.filter((g) => g.passed && !g.skipped).length;
|
|
174
|
+
const failedGroups = result.groupResults.filter((g) => !g.passed && !g.skipped);
|
|
175
|
+
const allPassed = failedGroups.length === 0;
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<div className="space-y-2">
|
|
179
|
+
<div className={`flex items-center gap-2.5 rounded-lg px-3.5 py-2.5 ${
|
|
180
|
+
allPassed ? "bg-green-500/[0.04]" : "bg-yellow-500/[0.06]"
|
|
181
|
+
}`}>
|
|
182
|
+
{allPassed
|
|
183
|
+
? <CheckCircle2 className="h-3.5 w-3.5 text-green-600/70 dark:text-green-400/70 shrink-0" />
|
|
184
|
+
: <AlertTriangle className="h-3.5 w-3.5 text-yellow-600/70 dark:text-yellow-400/70 shrink-0" />
|
|
185
|
+
}
|
|
186
|
+
<span className={`text-[11px] ${
|
|
187
|
+
allPassed
|
|
188
|
+
? "text-green-700/70 dark:text-green-300/70"
|
|
189
|
+
: "text-yellow-700/70 dark:text-yellow-300/70"
|
|
190
|
+
}`}>
|
|
191
|
+
Quality gate: {allPassed
|
|
192
|
+
? `all ${totalGroups} groups passed`
|
|
193
|
+
: `${passedGroups}/${totalGroups} groups passed`
|
|
194
|
+
}
|
|
195
|
+
</span>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
{failedGroups.length > 0 && (
|
|
199
|
+
<details className="rounded-lg border border-yellow-500/20 bg-yellow-500/[0.03]">
|
|
200
|
+
<summary className="cursor-pointer list-none px-3.5 py-2.5 text-[11px] text-yellow-700/70 dark:text-yellow-300/70 hover:bg-yellow-500/[0.04] transition-colors select-none">
|
|
201
|
+
{failedGroups.length} group(s) with warnings (non-blocking)
|
|
202
|
+
</summary>
|
|
203
|
+
<div className="px-3.5 pb-3 space-y-2.5">
|
|
204
|
+
{failedGroups.map((group) => (
|
|
205
|
+
<div key={group.group_id} className="space-y-1.5">
|
|
206
|
+
<div className="flex items-center gap-2">
|
|
207
|
+
<AlertTriangle className="h-3 w-3 text-yellow-600/60 dark:text-yellow-400/60 shrink-0" />
|
|
208
|
+
<span className="text-[10px] font-medium text-foreground/70">{group.group_id}</span>
|
|
209
|
+
</div>
|
|
210
|
+
{group.scripts.filter((s) => !s.passed).map((script) => (
|
|
211
|
+
<div key={script.name} className="ml-5 space-y-1">
|
|
212
|
+
<span className="text-[10px] text-yellow-700/60 dark:text-yellow-300/60 font-mono">{script.name}</span>
|
|
213
|
+
{script.error && (
|
|
214
|
+
<pre className="whitespace-pre-wrap break-words text-[9px] leading-relaxed text-foreground/50 bg-foreground/[0.03] rounded-md p-2 overflow-x-auto max-h-32 overflow-y-auto">
|
|
215
|
+
{script.error}
|
|
216
|
+
</pre>
|
|
217
|
+
)}
|
|
218
|
+
</div>
|
|
219
|
+
))}
|
|
220
|
+
</div>
|
|
221
|
+
))}
|
|
222
|
+
</div>
|
|
223
|
+
</details>
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
72
229
|
interface StackPanelProps {
|
|
73
230
|
sessionId?: string | null;
|
|
74
231
|
onTrackAnalysis?: (analysisSessionId: string, prUrl: string) => void;
|
|
@@ -135,6 +292,8 @@ export function StackPanel({ sessionId, onTrackAnalysis }: StackPanelProps) {
|
|
|
135
292
|
</div>
|
|
136
293
|
</div>
|
|
137
294
|
|
|
295
|
+
<EnvVarsInput envVars={stack.envVars} setEnvVars={stack.setEnvVars} />
|
|
296
|
+
|
|
138
297
|
<button
|
|
139
298
|
type="button"
|
|
140
299
|
onClick={stack.runFullPipeline}
|
|
@@ -232,7 +391,6 @@ export function StackPanel({ sessionId, onTrackAnalysis }: StackPanelProps) {
|
|
|
232
391
|
groups={stack.plan.groups}
|
|
233
392
|
groupCommits={stack.execResult?.group_commits}
|
|
234
393
|
publishedPrs={stack.publishResult?.prs}
|
|
235
|
-
fileStatsByPath={stack.analysisFileStatsByPath}
|
|
236
394
|
/>
|
|
237
395
|
</div>
|
|
238
396
|
)}
|
|
@@ -259,12 +417,16 @@ export function StackPanel({ sessionId, onTrackAnalysis }: StackPanelProps) {
|
|
|
259
417
|
}
|
|
260
418
|
</span>
|
|
261
419
|
</div>
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
420
|
+
{stack.verifyResult.structured_warnings.length > 0 && (
|
|
421
|
+
<StackWarnings warnings={stack.verifyResult.structured_warnings} defaultCollapsed={stack.verifyResult.verified} />
|
|
422
|
+
)}
|
|
265
423
|
</div>
|
|
266
424
|
)}
|
|
267
425
|
|
|
426
|
+
{stack.qualityGateResult && (
|
|
427
|
+
<QualityGateResults result={stack.qualityGateResult} />
|
|
428
|
+
)}
|
|
429
|
+
|
|
268
430
|
{stack.phase === "done" && stack.execResult && !stack.publishResult && (
|
|
269
431
|
<div className="space-y-2 rounded-lg border border-border/70 bg-foreground/[0.015] p-2.5">
|
|
270
432
|
<div className="flex items-center justify-between px-1">
|
package/src/web/server/routes.ts
CHANGED
|
@@ -1801,9 +1801,10 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
|
|
|
1801
1801
|
|
|
1802
1802
|
"POST /api/stack/start": async (req: Request) => {
|
|
1803
1803
|
try {
|
|
1804
|
-
const body = await req.json() as { sessionId: string; maxGroups?: number };
|
|
1804
|
+
const body = await req.json() as { sessionId: string; maxGroups?: number; envVars?: Record<string, string> };
|
|
1805
1805
|
if (!body.sessionId) return json({ error: "Missing sessionId" }, 400);
|
|
1806
|
-
const
|
|
1806
|
+
const customEnv = body.envVars && Object.keys(body.envVars).length > 0 ? body.envVars : null;
|
|
1807
|
+
const result = startStack(body.sessionId, body.maxGroups ?? null, token, config, customEnv);
|
|
1807
1808
|
if ("error" in result) return json({ error: result.error }, result.status);
|
|
1808
1809
|
return json({ ok: true });
|
|
1809
1810
|
} catch (err) {
|