jettypod 4.4.98 → 4.4.100
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/apps/dashboard/app/api/claude/[workItemId]/route.ts +127 -0
- package/apps/dashboard/app/page.tsx +10 -0
- package/apps/dashboard/app/tests/page.tsx +73 -0
- package/apps/dashboard/components/CardMenu.tsx +19 -2
- package/apps/dashboard/components/ClaudePanel.tsx +271 -0
- package/apps/dashboard/components/KanbanBoard.tsx +11 -4
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +62 -3
- package/apps/dashboard/components/TestTree.tsx +208 -0
- package/apps/dashboard/hooks/useClaudeSessions.ts +265 -0
- package/apps/dashboard/hooks/useClaudeStream.ts +205 -0
- package/apps/dashboard/lib/tests.ts +201 -0
- package/apps/dashboard/next.config.ts +31 -1
- package/apps/dashboard/package.json +1 -1
- package/cucumber-results.json +12970 -0
- package/lib/git-hooks/pre-commit +6 -0
- package/lib/work-commands/index.js +20 -10
- package/package.json +1 -1
- package/skills-templates/chore-mode/SKILL.md +14 -1
- package/skills-templates/chore-planning/SKILL.md +35 -3
- package/skills-templates/epic-planning/SKILL.md +148 -9
- package/skills-templates/feature-planning/SKILL.md +6 -2
- package/skills-templates/request-routing/SKILL.md +24 -0
- package/skills-templates/simple-improvement/SKILL.md +30 -4
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
export interface ClaudeMessage {
|
|
6
|
+
type: 'text' | 'tool_use' | 'tool_result' | 'error' | 'done' | 'assistant';
|
|
7
|
+
content?: string;
|
|
8
|
+
tool_name?: string;
|
|
9
|
+
tool_input?: Record<string, unknown>;
|
|
10
|
+
result?: string;
|
|
11
|
+
exitCode?: number;
|
|
12
|
+
timestamp: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type ClaudeStreamStatus = 'idle' | 'connecting' | 'streaming' | 'done' | 'error';
|
|
16
|
+
|
|
17
|
+
interface UseClaudeStreamOptions {
|
|
18
|
+
workItemId: string;
|
|
19
|
+
title?: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
type?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface UseClaudeStreamReturn {
|
|
25
|
+
messages: ClaudeMessage[];
|
|
26
|
+
status: ClaudeStreamStatus;
|
|
27
|
+
error: string | null;
|
|
28
|
+
exitCode: number | null;
|
|
29
|
+
canRetry: boolean;
|
|
30
|
+
start: () => void;
|
|
31
|
+
stop: () => void;
|
|
32
|
+
clear: () => void;
|
|
33
|
+
retry: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function useClaudeStream({
|
|
37
|
+
workItemId,
|
|
38
|
+
title,
|
|
39
|
+
description,
|
|
40
|
+
type,
|
|
41
|
+
}: UseClaudeStreamOptions): UseClaudeStreamReturn {
|
|
42
|
+
const [messages, setMessages] = useState<ClaudeMessage[]>([]);
|
|
43
|
+
const [status, setStatus] = useState<ClaudeStreamStatus>('idle');
|
|
44
|
+
const [error, setError] = useState<string | null>(null);
|
|
45
|
+
const [exitCode, setExitCode] = useState<number | null>(null);
|
|
46
|
+
const [canRetry, setCanRetry] = useState(false);
|
|
47
|
+
const abortControllerRef = useRef<AbortController | null>(null);
|
|
48
|
+
|
|
49
|
+
const start = useCallback(async () => {
|
|
50
|
+
// Clean up any existing connection
|
|
51
|
+
if (abortControllerRef.current) {
|
|
52
|
+
abortControllerRef.current.abort();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
setStatus('connecting');
|
|
56
|
+
setMessages([]);
|
|
57
|
+
setError(null);
|
|
58
|
+
setExitCode(null);
|
|
59
|
+
setCanRetry(false);
|
|
60
|
+
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
abortControllerRef.current = controller;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const response = await fetch(`/api/claude/${workItemId}`, {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: {
|
|
68
|
+
'Content-Type': 'application/json',
|
|
69
|
+
},
|
|
70
|
+
body: JSON.stringify({ title, description, type }),
|
|
71
|
+
signal: controller.signal,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
// Try to parse JSON error response from API
|
|
76
|
+
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
|
77
|
+
try {
|
|
78
|
+
const errorData = await response.json();
|
|
79
|
+
if (errorData.message) {
|
|
80
|
+
errorMessage = errorData.message;
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
// Response wasn't JSON, use default message
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Determine if retryable based on status code
|
|
87
|
+
const retryable = response.status >= 500 || response.status === 503;
|
|
88
|
+
setCanRetry(retryable);
|
|
89
|
+
throw new Error(errorMessage);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!response.body) {
|
|
93
|
+
throw new Error('Response body is null');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
setStatus('streaming');
|
|
97
|
+
|
|
98
|
+
const reader = response.body.getReader();
|
|
99
|
+
const decoder = new TextDecoder();
|
|
100
|
+
let buffer = '';
|
|
101
|
+
|
|
102
|
+
while (true) {
|
|
103
|
+
const { done, value } = await reader.read();
|
|
104
|
+
|
|
105
|
+
if (done) {
|
|
106
|
+
setStatus('done');
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
buffer += decoder.decode(value, { stream: true });
|
|
111
|
+
|
|
112
|
+
// Process SSE data lines
|
|
113
|
+
const lines = buffer.split('\n');
|
|
114
|
+
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
|
115
|
+
|
|
116
|
+
for (const line of lines) {
|
|
117
|
+
if (line.startsWith('data: ')) {
|
|
118
|
+
const data = line.slice(6);
|
|
119
|
+
try {
|
|
120
|
+
const parsed = JSON.parse(data);
|
|
121
|
+
const message: ClaudeMessage = {
|
|
122
|
+
...parsed,
|
|
123
|
+
timestamp: Date.now(),
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
setMessages((prev) => [...prev, message]);
|
|
127
|
+
|
|
128
|
+
if (parsed.type === 'done') {
|
|
129
|
+
if (parsed.exitCode !== undefined && parsed.exitCode !== 0) {
|
|
130
|
+
// Process crashed with non-zero exit
|
|
131
|
+
setStatus('error');
|
|
132
|
+
setExitCode(parsed.exitCode);
|
|
133
|
+
setError(`Process exited with code ${parsed.exitCode}`);
|
|
134
|
+
setCanRetry(true);
|
|
135
|
+
} else {
|
|
136
|
+
setStatus('done');
|
|
137
|
+
}
|
|
138
|
+
} else if (parsed.type === 'error') {
|
|
139
|
+
setStatus('error');
|
|
140
|
+
setError(parsed.content || 'Unknown error');
|
|
141
|
+
setCanRetry(true);
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
// Non-JSON line, skip
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
} catch (err) {
|
|
150
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
151
|
+
// User cancelled, don't set error status
|
|
152
|
+
setStatus('idle');
|
|
153
|
+
} else {
|
|
154
|
+
setStatus('error');
|
|
155
|
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
156
|
+
|
|
157
|
+
// Detect connection lost vs other errors
|
|
158
|
+
const isNetworkError = err instanceof TypeError ||
|
|
159
|
+
(err instanceof Error && (
|
|
160
|
+
err.message.includes('network') ||
|
|
161
|
+
err.message.includes('fetch') ||
|
|
162
|
+
err.message.includes('Failed to fetch')
|
|
163
|
+
));
|
|
164
|
+
|
|
165
|
+
if (isNetworkError) {
|
|
166
|
+
setError('Connection lost');
|
|
167
|
+
} else {
|
|
168
|
+
setError(errorMessage);
|
|
169
|
+
}
|
|
170
|
+
setCanRetry(true);
|
|
171
|
+
|
|
172
|
+
setMessages((prev) => [
|
|
173
|
+
...prev,
|
|
174
|
+
{
|
|
175
|
+
type: 'error',
|
|
176
|
+
content: errorMessage,
|
|
177
|
+
timestamp: Date.now(),
|
|
178
|
+
},
|
|
179
|
+
]);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}, [workItemId, title, description, type]);
|
|
183
|
+
|
|
184
|
+
const stop = useCallback(() => {
|
|
185
|
+
if (abortControllerRef.current) {
|
|
186
|
+
abortControllerRef.current.abort();
|
|
187
|
+
abortControllerRef.current = null;
|
|
188
|
+
}
|
|
189
|
+
setStatus('idle');
|
|
190
|
+
}, []);
|
|
191
|
+
|
|
192
|
+
const clear = useCallback(() => {
|
|
193
|
+
setMessages([]);
|
|
194
|
+
setStatus('idle');
|
|
195
|
+
setError(null);
|
|
196
|
+
setExitCode(null);
|
|
197
|
+
setCanRetry(false);
|
|
198
|
+
}, []);
|
|
199
|
+
|
|
200
|
+
const retry = useCallback(() => {
|
|
201
|
+
start();
|
|
202
|
+
}, [start]);
|
|
203
|
+
|
|
204
|
+
return { messages, status, error, exitCode, canRetry, start, stop, clear, retry };
|
|
205
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Test data layer for the test dashboard
|
|
2
|
+
// Reads BDD test results and feature file structure
|
|
3
|
+
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import { execSync } from 'child_process';
|
|
7
|
+
|
|
8
|
+
// Types for test data structure
|
|
9
|
+
export interface TestScenario {
|
|
10
|
+
id: string;
|
|
11
|
+
title: string;
|
|
12
|
+
status: 'pass' | 'fail' | 'pending';
|
|
13
|
+
duration: string;
|
|
14
|
+
error?: string;
|
|
15
|
+
failedStep?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TestFeature {
|
|
19
|
+
id: string;
|
|
20
|
+
title: string;
|
|
21
|
+
featureFile: string;
|
|
22
|
+
scenarios: TestScenario[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TestEpic {
|
|
26
|
+
id: string;
|
|
27
|
+
title: string;
|
|
28
|
+
healthBadge: { passing: number; failing: number };
|
|
29
|
+
features: TestFeature[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface TestSummary {
|
|
33
|
+
total: number;
|
|
34
|
+
passing: number;
|
|
35
|
+
failing: number;
|
|
36
|
+
pending: number;
|
|
37
|
+
lastRun: string | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface TestDashboardData {
|
|
41
|
+
summary: TestSummary;
|
|
42
|
+
epics: TestEpic[];
|
|
43
|
+
standaloneFeatures: TestFeature[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getProjectRoot(): string {
|
|
47
|
+
if (process.env.JETTYPOD_PROJECT_PATH) {
|
|
48
|
+
return process.env.JETTYPOD_PROJECT_PATH;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
return execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim();
|
|
52
|
+
} catch {
|
|
53
|
+
throw new Error('Not in a git repository and JETTYPOD_PROJECT_PATH not set');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Parse a .feature file to extract scenarios
|
|
58
|
+
function parseFeatureFile(filePath: string): { title: string; scenarios: string[] } {
|
|
59
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
60
|
+
const lines = content.split('\n');
|
|
61
|
+
|
|
62
|
+
let title = '';
|
|
63
|
+
const scenarios: string[] = [];
|
|
64
|
+
|
|
65
|
+
for (const line of lines) {
|
|
66
|
+
const trimmed = line.trim();
|
|
67
|
+
if (trimmed.startsWith('Feature:')) {
|
|
68
|
+
title = trimmed.replace('Feature:', '').trim();
|
|
69
|
+
} else if (trimmed.startsWith('Scenario:')) {
|
|
70
|
+
scenarios.push(trimmed.replace('Scenario:', '').trim());
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { title, scenarios };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Try to read Cucumber JSON results if available
|
|
78
|
+
function getCucumberResults(projectRoot: string): Map<string, { status: string; duration: number; error?: string }> | null {
|
|
79
|
+
const resultsPath = path.join(projectRoot, 'cucumber-results.json');
|
|
80
|
+
if (!fs.existsSync(resultsPath)) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const results = JSON.parse(fs.readFileSync(resultsPath, 'utf-8'));
|
|
86
|
+
const scenarioResults = new Map<string, { status: string; duration: number; error?: string }>();
|
|
87
|
+
|
|
88
|
+
for (const feature of results) {
|
|
89
|
+
for (const element of feature.elements || []) {
|
|
90
|
+
if (element.type === 'scenario') {
|
|
91
|
+
const steps = element.steps || [];
|
|
92
|
+
const failed = steps.some((s: { result?: { status: string } }) => s.result?.status === 'failed');
|
|
93
|
+
const pending = steps.some((s: { result?: { status: string } }) => s.result?.status === 'pending' || s.result?.status === 'undefined');
|
|
94
|
+
const totalDuration = steps.reduce((sum: number, s: { result?: { duration: number } }) => sum + (s.result?.duration || 0), 0);
|
|
95
|
+
|
|
96
|
+
let error: string | undefined;
|
|
97
|
+
if (failed) {
|
|
98
|
+
const failedStep = steps.find((s: { result?: { status: string; error_message?: string } }) => s.result?.status === 'failed');
|
|
99
|
+
error = failedStep?.result?.error_message;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
scenarioResults.set(element.name, {
|
|
103
|
+
status: failed ? 'failed' : pending ? 'pending' : 'passed',
|
|
104
|
+
duration: totalDuration / 1000000, // Convert nanoseconds to milliseconds
|
|
105
|
+
error,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return scenarioResults;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Get all feature files from the project
|
|
118
|
+
function getFeatureFiles(projectRoot: string): string[] {
|
|
119
|
+
const featuresDir = path.join(projectRoot, 'features');
|
|
120
|
+
if (!fs.existsSync(featuresDir)) {
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const files: string[] = [];
|
|
125
|
+
const entries = fs.readdirSync(featuresDir, { withFileTypes: true });
|
|
126
|
+
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
if (entry.isFile() && entry.name.endsWith('.feature')) {
|
|
129
|
+
files.push(path.join(featuresDir, entry.name));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return files;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Main function to get test dashboard data
|
|
137
|
+
export function getTestDashboardData(): TestDashboardData {
|
|
138
|
+
const projectRoot = getProjectRoot();
|
|
139
|
+
const featureFiles = getFeatureFiles(projectRoot);
|
|
140
|
+
const cucumberResults = getCucumberResults(projectRoot);
|
|
141
|
+
|
|
142
|
+
const features: TestFeature[] = [];
|
|
143
|
+
let totalTests = 0;
|
|
144
|
+
let passingTests = 0;
|
|
145
|
+
let failingTests = 0;
|
|
146
|
+
let pendingTests = 0;
|
|
147
|
+
|
|
148
|
+
for (const filePath of featureFiles) {
|
|
149
|
+
const { title, scenarios: scenarioNames } = parseFeatureFile(filePath);
|
|
150
|
+
const featureId = path.basename(filePath, '.feature');
|
|
151
|
+
|
|
152
|
+
const scenarios: TestScenario[] = scenarioNames.map((name, index) => {
|
|
153
|
+
const result = cucumberResults?.get(name);
|
|
154
|
+
let status: 'pass' | 'fail' | 'pending' = 'pending';
|
|
155
|
+
let duration = '0s';
|
|
156
|
+
let error: string | undefined;
|
|
157
|
+
|
|
158
|
+
if (result) {
|
|
159
|
+
status = result.status === 'passed' ? 'pass' : result.status === 'failed' ? 'fail' : 'pending';
|
|
160
|
+
duration = result.duration < 1000 ? `${Math.round(result.duration)}ms` : `${(result.duration / 1000).toFixed(1)}s`;
|
|
161
|
+
error = result.error;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
totalTests++;
|
|
165
|
+
if (status === 'pass') passingTests++;
|
|
166
|
+
else if (status === 'fail') failingTests++;
|
|
167
|
+
else pendingTests++;
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
id: `${featureId}-${index}`,
|
|
171
|
+
title: name,
|
|
172
|
+
status,
|
|
173
|
+
duration,
|
|
174
|
+
error,
|
|
175
|
+
};
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
features.push({
|
|
179
|
+
id: featureId,
|
|
180
|
+
title,
|
|
181
|
+
featureFile: path.relative(projectRoot, filePath),
|
|
182
|
+
scenarios,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// For now, all features are standalone (no epic grouping from test results)
|
|
187
|
+
// Epic grouping would require mapping features to work items
|
|
188
|
+
const summary: TestSummary = {
|
|
189
|
+
total: totalTests,
|
|
190
|
+
passing: passingTests,
|
|
191
|
+
failing: failingTests,
|
|
192
|
+
pending: pendingTests,
|
|
193
|
+
lastRun: cucumberResults ? new Date().toISOString() : null,
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
summary,
|
|
198
|
+
epics: [], // Epic grouping can be added later by mapping to work items
|
|
199
|
+
standaloneFeatures: features,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
@@ -1,7 +1,37 @@
|
|
|
1
1
|
import type { NextConfig } from "next";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
// Dashboard is at apps/dashboard, jettypod lib is at ../../lib relative to that
|
|
5
|
+
// process.cwd() is the dashboard directory during build
|
|
6
|
+
const jettypodLibPath = path.resolve(process.cwd(), '../../lib');
|
|
2
7
|
|
|
3
8
|
const nextConfig: NextConfig = {
|
|
4
|
-
|
|
9
|
+
// Externalize modules with native bindings or dynamic requires
|
|
10
|
+
serverExternalPackages: ['better-sqlite3'],
|
|
11
|
+
|
|
12
|
+
// Set monorepo root for correct file tracing
|
|
13
|
+
outputFileTracingRoot: path.resolve(process.cwd(), '../..'),
|
|
14
|
+
|
|
15
|
+
// Acknowledge we're using webpack config (required for Next.js 16+)
|
|
16
|
+
turbopack: {},
|
|
17
|
+
|
|
18
|
+
webpack: (config, { isServer }) => {
|
|
19
|
+
if (isServer) {
|
|
20
|
+
// Externalize the jettypod lib using absolute path resolution
|
|
21
|
+
config.externals = config.externals || [];
|
|
22
|
+
config.externals.push(({ request }: { request: string }, callback: (err: Error | null | undefined, result?: string) => void) => {
|
|
23
|
+
// Externalize any require that goes to jettypod lib (dynamic requires)
|
|
24
|
+
if (request && request.includes('lib/worktree-facade')) {
|
|
25
|
+
// Use absolute path to jettypod lib
|
|
26
|
+
const moduleName = request.split('lib/')[1];
|
|
27
|
+
const absolutePath = path.join(jettypodLibPath, moduleName);
|
|
28
|
+
return callback(null, `commonjs ${absolutePath}`);
|
|
29
|
+
}
|
|
30
|
+
callback(undefined);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return config;
|
|
34
|
+
},
|
|
5
35
|
};
|
|
6
36
|
|
|
7
37
|
export default nextConfig;
|