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.
@@ -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
- <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>
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
- const fallbackStats = fileStatsByPath?.[file]
172
- ?? fileStatsByPath?.[normalizedPath]
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 fileTotals = fileRows.reduce(
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 nodes = buildDagNodes(groups);
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
- <DagNodeCard
374
- key={node.group.id}
375
- node={node}
376
- commit={commit}
377
- pr={pr}
378
- fileStatsByPath={fileStatsByPath}
379
- allGroups={groups}
380
- rowRef={(el) => { rowRefs.current[i] = el; }}
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 { Loader2, Play, Upload, RotateCcw, CheckCircle2, AlertTriangle, Circle, GitPullRequestArrow, ArrowRight, Layers, FileText, RefreshCw, XCircle, Trash2 } from "lucide-react";
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
- {stack.verifyResult.structured_warnings.length > 0 && (
263
- <StackWarnings warnings={stack.verifyResult.structured_warnings} defaultCollapsed={stack.verifyResult.verified} />
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">
@@ -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 result = startStack(body.sessionId, body.maxGroups ?? null, token, config);
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) {