mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
fix: resolve TypeScript build errors (#177)
- Add RunMode type and QaGateState interface to shared task.types.ts - Add runMode and qaGate optional fields to Task and UpdateTaskInput interfaces - Mirror changes in shared/src/types/task.types.d.ts (used by web bundler) - Add RunModeGateSection.tsx component (was untracked, causing web build failure) - Add qa-gate.test.ts and dependency-cycle.test.ts (untracked test files)
This commit is contained in:
parent
0b23cf83e5
commit
c4c21459d4
4 changed files with 450 additions and 0 deletions
146
server/src/__tests__/routes/qa-gate.test.ts
Normal file
146
server/src/__tests__/routes/qa-gate.test.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
/**
|
||||
* QA Gate & Run Mode — Unit tests for the blocking logic
|
||||
*
|
||||
* These tests verify that:
|
||||
* 1. runMode and qaGate fields are accepted in the updateTaskSchema
|
||||
* 2. The QA gate check correctly identifies when a task is blocked from done
|
||||
*
|
||||
* We test the logic layer directly (not via HTTP) to avoid the
|
||||
* singleton service wiring complexity of the route layer.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import type { QaGateState } from '@veritas-kanban/shared';
|
||||
|
||||
// ─── Re-create the minimal schemas from the route (mirrors tasks.ts) ──────────
|
||||
|
||||
const runModeSchema = z
|
||||
.enum(['strategy', 'eng-review', 'paranoid-review', 'qa'])
|
||||
.optional()
|
||||
.nullable();
|
||||
|
||||
const qaGateSchema = z
|
||||
.object({
|
||||
required: z.boolean(),
|
||||
passed: z.boolean(),
|
||||
passedAt: z.string().optional(),
|
||||
passedBy: z.string().optional(),
|
||||
})
|
||||
.optional()
|
||||
.nullable();
|
||||
|
||||
const patchSchema = z.object({
|
||||
status: z.enum(['todo', 'in-progress', 'blocked', 'done']).optional(),
|
||||
runMode: runModeSchema,
|
||||
qaGate: qaGateSchema,
|
||||
});
|
||||
|
||||
// ─── QA gate blocking logic (mirrors tasks.ts) ────────────────────────────────
|
||||
|
||||
function wouldBeBlockedByQaGate(
|
||||
existingGate: QaGateState | null | undefined,
|
||||
incomingGate: QaGateState | null | undefined,
|
||||
newStatus: string | undefined
|
||||
): boolean {
|
||||
if (newStatus !== 'done') return false;
|
||||
const merged = incomingGate !== undefined ? incomingGate : existingGate;
|
||||
return !!(merged?.required && !merged?.passed);
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('runMode schema', () => {
|
||||
it('accepts valid run modes', () => {
|
||||
for (const mode of ['strategy', 'eng-review', 'paranoid-review', 'qa']) {
|
||||
const result = runModeSchema.safeParse(mode);
|
||||
expect(result.success, `mode: ${mode}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid run modes', () => {
|
||||
const result = runModeSchema.safeParse('auto-ship');
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts null to clear', () => {
|
||||
expect(runModeSchema.safeParse(null).success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts undefined (no-op)', () => {
|
||||
expect(runModeSchema.safeParse(undefined).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('qaGate schema', () => {
|
||||
it('accepts required+not-passed gate', () => {
|
||||
const result = qaGateSchema.safeParse({ required: true, passed: false });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts required+passed gate with metadata', () => {
|
||||
const result = qaGateSchema.safeParse({
|
||||
required: true,
|
||||
passed: true,
|
||||
passedAt: '2026-03-12T21:00:00Z',
|
||||
passedBy: 'brad',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts null to clear', () => {
|
||||
expect(qaGateSchema.safeParse(null).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects missing required field', () => {
|
||||
const result = qaGateSchema.safeParse({ passed: false });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('QA gate blocking logic', () => {
|
||||
it('blocks done when required=true, passed=false on existing gate', () => {
|
||||
expect(wouldBeBlockedByQaGate({ required: true, passed: false }, undefined, 'done')).toBe(true);
|
||||
});
|
||||
|
||||
it('allows done when required=true, passed=true on existing gate', () => {
|
||||
expect(wouldBeBlockedByQaGate({ required: true, passed: true }, undefined, 'done')).toBe(false);
|
||||
});
|
||||
|
||||
it('allows done when no gate is set', () => {
|
||||
expect(wouldBeBlockedByQaGate(undefined, undefined, 'done')).toBe(false);
|
||||
});
|
||||
|
||||
it('allows done when required=false', () => {
|
||||
expect(wouldBeBlockedByQaGate({ required: false, passed: false }, undefined, 'done')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('allows passing QA and moving to done in same PATCH', () => {
|
||||
// Existing: required, not passed. Incoming: required, passed.
|
||||
expect(
|
||||
wouldBeBlockedByQaGate(
|
||||
{ required: true, passed: false },
|
||||
{ required: true, passed: true },
|
||||
'done'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not block non-done transitions', () => {
|
||||
expect(
|
||||
wouldBeBlockedByQaGate({ required: true, passed: false }, undefined, 'in-progress')
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('uses incoming gate over existing when both present', () => {
|
||||
// Incoming says passed=false, should block
|
||||
expect(
|
||||
wouldBeBlockedByQaGate(
|
||||
{ required: true, passed: true }, // existing (passed)
|
||||
{ required: true, passed: false }, // incoming (revoked)
|
||||
'done'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
13
shared/src/types/task.types.d.ts
vendored
13
shared/src/types/task.types.d.ts
vendored
|
|
@ -1,6 +1,15 @@
|
|||
export type TaskType = string;
|
||||
export type TaskStatus = 'todo' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||
export type TaskPriority = 'low' | 'medium' | 'high' | 'critical';
|
||||
/** Run mode controls the review/QA strategy for a task. */
|
||||
export type RunMode = 'strategy' | 'eng-review' | 'paranoid-review' | 'qa';
|
||||
/** QA gate state — tracks whether a QA review is required and whether it has passed. */
|
||||
export interface QaGateState {
|
||||
required: boolean;
|
||||
passed: boolean;
|
||||
passedAt?: string;
|
||||
passedBy?: string;
|
||||
}
|
||||
/** Built-in agent types. Custom agents use any string slug. */
|
||||
export type BuiltInAgentType = 'claude-code' | 'amp' | 'copilot' | 'gemini' | 'veritas';
|
||||
export type AgentType = BuiltInAgentType | (string & {});
|
||||
|
|
@ -140,6 +149,8 @@ export interface Task {
|
|||
actualCost?: number;
|
||||
lessonsLearned?: string;
|
||||
lessonTags?: string[];
|
||||
runMode?: RunMode | null;
|
||||
qaGate?: QaGateState | null;
|
||||
}
|
||||
export interface ReviewComment {
|
||||
id: string;
|
||||
|
|
@ -199,6 +210,8 @@ export interface UpdateTaskInput {
|
|||
position?: number;
|
||||
lessonsLearned?: string;
|
||||
lessonTags?: string[];
|
||||
runMode?: RunMode | null;
|
||||
qaGate?: QaGateState | null;
|
||||
}
|
||||
export interface TaskFilters {
|
||||
status?: TaskStatus | TaskStatus[];
|
||||
|
|
|
|||
|
|
@ -3,6 +3,17 @@
|
|||
export type TaskType = string;
|
||||
export type TaskStatus = 'todo' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||
export type TaskPriority = 'low' | 'medium' | 'high' | 'critical';
|
||||
|
||||
/** Run mode controls the review/QA strategy for a task. */
|
||||
export type RunMode = 'strategy' | 'eng-review' | 'paranoid-review' | 'qa';
|
||||
|
||||
/** QA gate state — tracks whether a QA review is required and whether it has passed. */
|
||||
export interface QaGateState {
|
||||
required: boolean;
|
||||
passed: boolean;
|
||||
passedAt?: string; // ISO timestamp when QA was approved
|
||||
passedBy?: string; // Who approved (e.g. 'human', agent slug, user name)
|
||||
}
|
||||
/** Built-in agent types. Custom agents use any string slug. */
|
||||
export type BuiltInAgentType = 'claude-code' | 'amp' | 'copilot' | 'gemini' | 'veritas';
|
||||
export type AgentType = BuiltInAgentType | (string & {});
|
||||
|
|
@ -263,6 +274,12 @@ export interface Task {
|
|||
timestamp: string; // ISO timestamp when checkpoint was saved
|
||||
resumeCount?: number; // How many times this task has been resumed
|
||||
};
|
||||
|
||||
// Run mode — controls the review/QA strategy for the task
|
||||
runMode?: RunMode | null;
|
||||
|
||||
// QA gate — requires a QA pass before the task can move to Done
|
||||
qaGate?: QaGateState | null;
|
||||
}
|
||||
|
||||
export interface ReviewComment {
|
||||
|
|
@ -343,6 +360,8 @@ export interface UpdateTaskInput {
|
|||
timestamp: string;
|
||||
resumeCount?: number;
|
||||
};
|
||||
runMode?: RunMode | null;
|
||||
qaGate?: QaGateState | null;
|
||||
}
|
||||
|
||||
export interface TaskFilters {
|
||||
|
|
|
|||
272
web/src/components/task/RunModeGateSection.tsx
Normal file
272
web/src/components/task/RunModeGateSection.tsx
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
/**
|
||||
* RunModeGateSection — v1
|
||||
*
|
||||
* Displays and manages:
|
||||
* - Run mode (strategy / eng-review / paranoid-review / qa)
|
||||
* - QA gate state (required + passed/not passed)
|
||||
*
|
||||
* Kept intentionally simple: no workflow engine, just a couple of selects
|
||||
* and a toggle button that PATCHes the task.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { CheckCircle2, XCircle, ShieldCheck, Loader2 } from 'lucide-react';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import type { Task, RunMode, QaGateState } from '@veritas-kanban/shared';
|
||||
|
||||
interface RunModeGateSectionProps {
|
||||
task: Task;
|
||||
onUpdate: <K extends keyof Task>(field: K, value: Task[K]) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const RUN_MODE_LABELS: Record<RunMode, string> = {
|
||||
strategy: '📋 Strategy',
|
||||
'eng-review': '🔧 Eng Review',
|
||||
'paranoid-review': '🔍 Paranoid Review',
|
||||
qa: '✅ QA',
|
||||
};
|
||||
|
||||
const RUN_MODE_DESCRIPTIONS: Record<RunMode, string> = {
|
||||
strategy: 'High-level strategic review required before completion',
|
||||
'eng-review': 'Engineering review (PR / code quality) required',
|
||||
'paranoid-review': 'Extra-thorough review — security-sensitive or critical infra',
|
||||
qa: 'QA pass required before marking done',
|
||||
};
|
||||
|
||||
export function RunModeGateSection({ task, onUpdate, readOnly = false }: RunModeGateSectionProps) {
|
||||
const { toast } = useToast();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const runMode = task.runMode ?? null;
|
||||
const qaGate = task.qaGate ?? null;
|
||||
|
||||
const handleRunModeChange = async (value: string) => {
|
||||
const newMode = value === 'none' ? null : (value as RunMode);
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/tasks/${task.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ runMode: newMode }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
onUpdate('runMode', newMode ?? undefined);
|
||||
toast({ title: newMode ? `Run mode set: ${RUN_MODE_LABELS[newMode]}` : 'Run mode cleared' });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: '❌ Failed to update run mode',
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQaRequiredToggle = async (checked: boolean) => {
|
||||
const newGate: QaGateState = {
|
||||
required: checked,
|
||||
passed: checked ? (qaGate?.passed ?? false) : false,
|
||||
passedAt: checked ? qaGate?.passedAt : undefined,
|
||||
passedBy: checked ? qaGate?.passedBy : undefined,
|
||||
};
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/tasks/${task.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ qaGate: newGate }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
onUpdate('qaGate', newGate);
|
||||
toast({ title: checked ? 'QA gate enabled' : 'QA gate disabled' });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: '❌ Failed to update QA gate',
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQaPassToggle = async () => {
|
||||
if (!qaGate?.required) return;
|
||||
const nowPassed = !qaGate.passed;
|
||||
const newGate: QaGateState = {
|
||||
required: true,
|
||||
passed: nowPassed,
|
||||
passedAt: nowPassed ? new Date().toISOString() : undefined,
|
||||
passedBy: nowPassed ? 'human' : undefined,
|
||||
};
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/tasks/${task.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ qaGate: newGate }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
onUpdate('qaGate', newGate);
|
||||
toast({ title: nowPassed ? '✅ QA passed' : 'QA pass revoked' });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: '❌ Failed to update QA gate',
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Run Mode */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground flex items-center gap-1">
|
||||
<ShieldCheck className="h-3.5 w-3.5" />
|
||||
Run Mode
|
||||
</Label>
|
||||
{readOnly ? (
|
||||
<div>
|
||||
{runMode ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{RUN_MODE_LABELS[runMode]}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">None</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Select value={runMode ?? 'none'} onValueChange={handleRunModeChange} disabled={isSaving}>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="Select run mode…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
<span className="text-muted-foreground">None</span>
|
||||
</SelectItem>
|
||||
{(Object.keys(RUN_MODE_LABELS) as RunMode[]).map((mode) => (
|
||||
<SelectItem key={mode} value={mode}>
|
||||
<span className="font-medium">{RUN_MODE_LABELS[mode]}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground hidden sm:inline">
|
||||
— {RUN_MODE_DESCRIPTIONS[mode]}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{runMode && (
|
||||
<p className="text-xs text-muted-foreground">{RUN_MODE_DESCRIPTIONS[runMode]}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* QA Gate */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">QA Gate</Label>
|
||||
|
||||
{readOnly ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{qaGate?.required ? (
|
||||
qaGate.passed ? (
|
||||
<Badge className="bg-green-100 text-green-800 border-green-300 text-xs flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
QA Passed
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="text-xs flex items-center gap-1">
|
||||
<XCircle className="h-3 w-3" />
|
||||
QA Required — Not Passed
|
||||
</Badge>
|
||||
)
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">No QA gate</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* Required toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Require QA before done</span>
|
||||
<Switch
|
||||
checked={qaGate?.required ?? false}
|
||||
onCheckedChange={handleQaRequiredToggle}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pass/fail button — only visible when required */}
|
||||
{qaGate?.required && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-md border bg-muted/30">
|
||||
{qaGate.passed ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-green-700">QA Passed</p>
|
||||
{qaGate.passedAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(qaGate.passedAt).toLocaleString()}
|
||||
{qaGate.passedBy ? ` · ${qaGate.passedBy}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleQaPassToggle}
|
||||
disabled={isSaving}
|
||||
className="text-xs h-7"
|
||||
>
|
||||
{isSaving ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Revoke'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="h-4 w-4 text-destructive flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-destructive">QA Not Passed</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Task cannot be moved to Done until QA is approved
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleQaPassToggle}
|
||||
disabled={isSaving}
|
||||
className="text-xs h-7 bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
Pass QA
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue