@vibedx/vibekit 0.1.0
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/LICENSE +21 -0
- package/README.md +368 -0
- package/assets/config.yml +35 -0
- package/assets/default.md +47 -0
- package/assets/instructions/README.md +46 -0
- package/assets/instructions/claude.md +83 -0
- package/assets/instructions/codex.md +19 -0
- package/index.js +106 -0
- package/package.json +90 -0
- package/src/commands/close/index.js +66 -0
- package/src/commands/close/index.test.js +235 -0
- package/src/commands/get-started/index.js +138 -0
- package/src/commands/get-started/index.test.js +246 -0
- package/src/commands/init/index.js +51 -0
- package/src/commands/init/index.test.js +159 -0
- package/src/commands/link/index.js +395 -0
- package/src/commands/link/index.test.js +28 -0
- package/src/commands/lint/index.js +657 -0
- package/src/commands/lint/index.test.js +569 -0
- package/src/commands/list/index.js +131 -0
- package/src/commands/list/index.test.js +153 -0
- package/src/commands/new/index.js +305 -0
- package/src/commands/new/index.test.js +256 -0
- package/src/commands/refine/index.js +741 -0
- package/src/commands/refine/index.test.js +28 -0
- package/src/commands/review/index.js +957 -0
- package/src/commands/review/index.test.js +193 -0
- package/src/commands/start/index.js +180 -0
- package/src/commands/start/index.test.js +88 -0
- package/src/commands/unlink/index.js +123 -0
- package/src/commands/unlink/index.test.js +22 -0
- package/src/utils/arrow-select.js +233 -0
- package/src/utils/cli.js +489 -0
- package/src/utils/cli.test.js +9 -0
- package/src/utils/git.js +146 -0
- package/src/utils/git.test.js +330 -0
- package/src/utils/index.js +193 -0
- package/src/utils/index.test.js +375 -0
- package/src/utils/prompts.js +47 -0
- package/src/utils/prompts.test.js +165 -0
- package/src/utils/test-helpers.js +492 -0
- package/src/utils/ticket.js +423 -0
- package/src/utils/ticket.test.js +190 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Normalize ticket ID to find the actual ticket file
|
|
6
|
+
* Handles formats: 9, 009, TKT-9, TKT-009
|
|
7
|
+
* @param {string|number} input - The ticket identifier
|
|
8
|
+
* @returns {Object|null} Ticket info object or null if not found
|
|
9
|
+
* @throws {Error} If input is invalid or tickets directory is inaccessible
|
|
10
|
+
*/
|
|
11
|
+
export function resolveTicketId(input) {
|
|
12
|
+
// Validate input
|
|
13
|
+
if (!input && input !== 0) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ticketsDir = path.join(process.cwd(), '.vibe', 'tickets');
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
if (!fs.existsSync(ticketsDir)) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const files = fs.readdirSync(ticketsDir).filter(f => f.endsWith('.md'));
|
|
25
|
+
|
|
26
|
+
// Clean and validate input
|
|
27
|
+
let cleanInput = input.toString().trim().toUpperCase();
|
|
28
|
+
|
|
29
|
+
// Remove TKT- prefix if present
|
|
30
|
+
if (cleanInput.startsWith('TKT-')) {
|
|
31
|
+
cleanInput = cleanInput.replace('TKT-', '');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Validate numeric part
|
|
35
|
+
if (!/^\d+$/.test(cleanInput)) {
|
|
36
|
+
throw new Error(`Invalid ticket ID format: ${input}. Expected numeric ID or TKT-XXX format.`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Pad with zeros to make it 3 digits
|
|
40
|
+
const paddedNumber = cleanInput.padStart(3, '0');
|
|
41
|
+
const fullId = `TKT-${paddedNumber}`;
|
|
42
|
+
|
|
43
|
+
// Find file that starts with this ID
|
|
44
|
+
const matchingFile = files.find(file => file.startsWith(fullId));
|
|
45
|
+
|
|
46
|
+
if (matchingFile) {
|
|
47
|
+
return {
|
|
48
|
+
id: fullId,
|
|
49
|
+
file: matchingFile,
|
|
50
|
+
path: path.join(ticketsDir, matchingFile)
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return null;
|
|
55
|
+
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error.code === 'ENOENT') {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
if (error.code === 'EACCES') {
|
|
61
|
+
throw new Error(`Permission denied accessing tickets directory: ${ticketsDir}`);
|
|
62
|
+
}
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parse ticket markdown file while preserving exact format
|
|
69
|
+
* @param {string} filePath - Path to the ticket file
|
|
70
|
+
* @returns {Object} Parsed ticket data with metadata and content
|
|
71
|
+
* @throws {Error} If file cannot be read or parsed
|
|
72
|
+
*/
|
|
73
|
+
export function parseTicket(filePath) {
|
|
74
|
+
// Validate input
|
|
75
|
+
if (typeof filePath !== 'string') {
|
|
76
|
+
throw new Error('File path must be a string');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
// Check if file exists
|
|
81
|
+
if (!fs.existsSync(filePath)) {
|
|
82
|
+
throw new Error(`Ticket file not found: ${filePath}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Check if file is readable
|
|
86
|
+
try {
|
|
87
|
+
fs.accessSync(filePath, fs.constants.R_OK);
|
|
88
|
+
} catch (accessError) {
|
|
89
|
+
throw new Error(`Cannot read ticket file: ${filePath}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
93
|
+
|
|
94
|
+
if (!content.trim()) {
|
|
95
|
+
throw new Error('Ticket file is empty');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const lines = content.split('\n');
|
|
99
|
+
|
|
100
|
+
// Validate YAML frontmatter structure
|
|
101
|
+
if (lines.length < 3) {
|
|
102
|
+
throw new Error('Invalid ticket format: file too short to contain valid frontmatter');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (lines[0] !== '---') {
|
|
106
|
+
throw new Error('Invalid ticket format: missing opening YAML frontmatter delimiter (---)');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const yamlEndIndex = lines.findIndex((line, index) => index > 0 && line === '---');
|
|
110
|
+
if (yamlEndIndex === -1) {
|
|
111
|
+
throw new Error('Invalid ticket format: missing closing YAML frontmatter delimiter (---)');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const yamlLines = lines.slice(1, yamlEndIndex);
|
|
115
|
+
const contentLines = lines.slice(yamlEndIndex + 1);
|
|
116
|
+
|
|
117
|
+
// Parse YAML manually to preserve exact formatting
|
|
118
|
+
const metadata = {};
|
|
119
|
+
const invalidLines = [];
|
|
120
|
+
|
|
121
|
+
for (let i = 0; i < yamlLines.length; i++) {
|
|
122
|
+
const line = yamlLines[i];
|
|
123
|
+
if (line.trim() === '') continue; // Skip empty lines
|
|
124
|
+
|
|
125
|
+
const colonIndex = line.indexOf(':');
|
|
126
|
+
if (colonIndex > -1) {
|
|
127
|
+
const key = line.substring(0, colonIndex).trim();
|
|
128
|
+
const value = line.substring(colonIndex + 1).trim();
|
|
129
|
+
|
|
130
|
+
if (key) {
|
|
131
|
+
metadata[key] = value;
|
|
132
|
+
} else {
|
|
133
|
+
invalidLines.push(i + 2); // +2 for 0-based index and skipping first ---
|
|
134
|
+
}
|
|
135
|
+
} else if (line.trim() !== '') {
|
|
136
|
+
invalidLines.push(i + 2);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (invalidLines.length > 0) {
|
|
141
|
+
console.warn(`⚠️ Warning: Invalid YAML lines found at line(s) ${invalidLines.join(', ')} in ${path.basename(filePath)}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Validate required metadata fields
|
|
145
|
+
if (!metadata.id) {
|
|
146
|
+
console.warn(`⚠️ Warning: Missing 'id' field in ticket metadata`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!metadata.title) {
|
|
150
|
+
console.warn(`⚠️ Warning: Missing 'title' field in ticket metadata`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
metadata,
|
|
155
|
+
yamlLines,
|
|
156
|
+
contentLines,
|
|
157
|
+
fullContent: content,
|
|
158
|
+
filePath
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (error.code === 'ENOENT') {
|
|
163
|
+
throw new Error(`Ticket file not found: ${filePath}`);
|
|
164
|
+
}
|
|
165
|
+
if (error.code === 'EACCES') {
|
|
166
|
+
throw new Error(`Permission denied reading ticket file: ${filePath}`);
|
|
167
|
+
}
|
|
168
|
+
if (error.code === 'EISDIR') {
|
|
169
|
+
throw new Error(`Expected file but found directory: ${filePath}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
throw new Error(`Failed to parse ticket ${path.basename(filePath)}: ${error.message}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Update ticket file while preserving exact format
|
|
178
|
+
* @param {string} filePath - Path to the ticket file
|
|
179
|
+
* @param {Object} ticketData - Parsed ticket data
|
|
180
|
+
* @param {Object} updates - Updates to apply to the ticket
|
|
181
|
+
* @returns {Object} Update result with success status and new path
|
|
182
|
+
* @throws {Error} If update operation fails
|
|
183
|
+
*/
|
|
184
|
+
export function updateTicket(filePath, ticketData, updates) {
|
|
185
|
+
// Validate inputs
|
|
186
|
+
if (typeof filePath !== 'string') {
|
|
187
|
+
throw new Error('File path must be a string');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!ticketData || typeof ticketData !== 'object') {
|
|
191
|
+
throw new Error('Ticket data must be a valid object');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!updates || typeof updates !== 'object') {
|
|
195
|
+
throw new Error('Updates must be a valid object');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (Object.keys(updates).length === 0) {
|
|
199
|
+
return { success: true, newPath: filePath, message: 'No updates to apply' };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
// Check if original file exists and is writable
|
|
204
|
+
if (!fs.existsSync(filePath)) {
|
|
205
|
+
throw new Error(`Original ticket file not found: ${filePath}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
fs.accessSync(filePath, fs.constants.W_OK);
|
|
210
|
+
} catch (accessError) {
|
|
211
|
+
throw new Error(`Cannot write to ticket file: ${filePath}`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let newFilePath = filePath;
|
|
215
|
+
|
|
216
|
+
// Determine new file path if slug is being updated
|
|
217
|
+
if (updates.slug) {
|
|
218
|
+
const ticketId = ticketData.metadata.id || 'TKT-000';
|
|
219
|
+
const cleanSlug = updates.slug.toString().trim();
|
|
220
|
+
|
|
221
|
+
if (!cleanSlug) {
|
|
222
|
+
throw new Error('Slug cannot be empty');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const newSlug = `${ticketId}-${cleanSlug}`;
|
|
226
|
+
const newFileName = `${newSlug}.md`;
|
|
227
|
+
const ticketsDir = path.dirname(filePath);
|
|
228
|
+
newFilePath = path.join(ticketsDir, newFileName);
|
|
229
|
+
|
|
230
|
+
// Check if target file already exists (unless it's the same file)
|
|
231
|
+
if (newFilePath !== filePath && fs.existsSync(newFilePath)) {
|
|
232
|
+
throw new Error(`Target file already exists: ${path.basename(newFilePath)}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Create updated YAML lines with timestamp
|
|
237
|
+
const timestamp = new Date().toISOString();
|
|
238
|
+
const updatedYamlLines = [...ticketData.yamlLines];
|
|
239
|
+
|
|
240
|
+
// Update existing fields or add new ones
|
|
241
|
+
let updatedTimestamp = false;
|
|
242
|
+
let updatedSlug = false;
|
|
243
|
+
let updatedTitle = false;
|
|
244
|
+
|
|
245
|
+
for (let i = 0; i < updatedYamlLines.length; i++) {
|
|
246
|
+
const line = updatedYamlLines[i];
|
|
247
|
+
|
|
248
|
+
if (line.startsWith('updated_at:')) {
|
|
249
|
+
updatedYamlLines[i] = `updated_at: ${timestamp}`;
|
|
250
|
+
updatedTimestamp = true;
|
|
251
|
+
} else if (line.startsWith('slug:') && updates.slug) {
|
|
252
|
+
const ticketId = ticketData.metadata.id || 'TKT-000';
|
|
253
|
+
const fullSlug = `${ticketId}-${updates.slug}`;
|
|
254
|
+
updatedYamlLines[i] = `slug: ${fullSlug}`;
|
|
255
|
+
updatedSlug = true;
|
|
256
|
+
} else if (line.startsWith('title:') && updates.title) {
|
|
257
|
+
// Ensure title is properly quoted if it contains special characters
|
|
258
|
+
const cleanTitle = updates.title.trim();
|
|
259
|
+
const needsQuotes = /[:\[\]{}|>]/.test(cleanTitle) || cleanTitle.includes('#');
|
|
260
|
+
const formattedTitle = needsQuotes ? `"${cleanTitle.replace(/"/g, '\\"')}"` : cleanTitle;
|
|
261
|
+
updatedYamlLines[i] = `title: ${formattedTitle}`;
|
|
262
|
+
updatedTitle = true;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Add missing fields
|
|
267
|
+
if (!updatedTimestamp) {
|
|
268
|
+
updatedYamlLines.push(`updated_at: ${timestamp}`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (updates.slug && !updatedSlug) {
|
|
272
|
+
const ticketId = ticketData.metadata.id || 'TKT-000';
|
|
273
|
+
const fullSlug = `${ticketId}-${updates.slug}`;
|
|
274
|
+
updatedYamlLines.push(`slug: ${fullSlug}`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (updates.title && !updatedTitle) {
|
|
278
|
+
const cleanTitle = updates.title.trim();
|
|
279
|
+
const needsQuotes = /[:\[\]{}|>]/.test(cleanTitle) || cleanTitle.includes('#');
|
|
280
|
+
const formattedTitle = needsQuotes ? `"${cleanTitle.replace(/"/g, '\\"')}"` : cleanTitle;
|
|
281
|
+
updatedYamlLines.push(`title: ${formattedTitle}`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Update content sections
|
|
285
|
+
let newContentLines = [...ticketData.contentLines];
|
|
286
|
+
const updatedSections = [];
|
|
287
|
+
|
|
288
|
+
Object.keys(updates).forEach(key => {
|
|
289
|
+
if (key !== 'slug' && updates[key]) {
|
|
290
|
+
const sectionHeader = `## ${key}`;
|
|
291
|
+
if (hasSectionContent(newContentLines, sectionHeader)) {
|
|
292
|
+
newContentLines = replaceSectionContent(newContentLines, sectionHeader, updates[key]);
|
|
293
|
+
updatedSections.push(key);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Reconstruct file content
|
|
299
|
+
const reconstructed = [
|
|
300
|
+
'---',
|
|
301
|
+
...updatedYamlLines,
|
|
302
|
+
'---',
|
|
303
|
+
...newContentLines
|
|
304
|
+
].join('\n');
|
|
305
|
+
|
|
306
|
+
// Write to new file path
|
|
307
|
+
try {
|
|
308
|
+
fs.writeFileSync(newFilePath, reconstructed, 'utf8');
|
|
309
|
+
} catch (writeError) {
|
|
310
|
+
if (writeError.code === 'ENOSPC') {
|
|
311
|
+
throw new Error('Not enough disk space to update ticket');
|
|
312
|
+
}
|
|
313
|
+
if (writeError.code === 'EACCES') {
|
|
314
|
+
throw new Error(`Permission denied writing to: ${newFilePath}`);
|
|
315
|
+
}
|
|
316
|
+
throw new Error(`Failed to write updated ticket: ${writeError.message}`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Handle file rename if necessary
|
|
320
|
+
if (newFilePath !== filePath) {
|
|
321
|
+
try {
|
|
322
|
+
if (fs.existsSync(filePath)) {
|
|
323
|
+
fs.unlinkSync(filePath);
|
|
324
|
+
}
|
|
325
|
+
} catch (deleteError) {
|
|
326
|
+
console.warn(`⚠️ Warning: Could not remove old file ${path.basename(filePath)}: ${deleteError.message}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const result = {
|
|
331
|
+
success: true,
|
|
332
|
+
newPath: newFilePath,
|
|
333
|
+
updatedSections,
|
|
334
|
+
renamed: newFilePath !== filePath
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
if (result.renamed) {
|
|
338
|
+
result.message = `Renamed ticket file to: ${path.basename(newFilePath)}`;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return result;
|
|
342
|
+
|
|
343
|
+
} catch (error) {
|
|
344
|
+
if (error.code === 'ENOENT') {
|
|
345
|
+
throw new Error(`Ticket file not found: ${filePath}`);
|
|
346
|
+
}
|
|
347
|
+
if (error.code === 'EACCES') {
|
|
348
|
+
throw new Error(`Permission denied updating ticket: ${filePath}`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
throw new Error(`Failed to update ticket ${path.basename(filePath)}: ${error.message}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Replace content of a specific section
|
|
357
|
+
* @param {string[]} lines - Array of content lines
|
|
358
|
+
* @param {string} sectionHeader - Section header to find (e.g., "## Description")
|
|
359
|
+
* @param {string} newContent - New content for the section
|
|
360
|
+
* @returns {string[]} Updated lines array
|
|
361
|
+
*/
|
|
362
|
+
function replaceSectionContent(lines, sectionHeader, newContent) {
|
|
363
|
+
if (!Array.isArray(lines)) {
|
|
364
|
+
throw new Error('Lines must be an array');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (typeof sectionHeader !== 'string' || !sectionHeader.trim()) {
|
|
368
|
+
throw new Error('Section header must be a non-empty string');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (typeof newContent !== 'string') {
|
|
372
|
+
throw new Error('New content must be a string');
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const sectionIndex = lines.findIndex(line =>
|
|
376
|
+
line && typeof line === 'string' && line.trim() === sectionHeader.trim()
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
if (sectionIndex === -1) {
|
|
380
|
+
return lines; // Section not found, return unchanged
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Find next section or end of content
|
|
384
|
+
let nextSectionIndex = lines.length;
|
|
385
|
+
for (let i = sectionIndex + 1; i < lines.length; i++) {
|
|
386
|
+
if (lines[i] && typeof lines[i] === 'string' && lines[i].match(/^##\s+/)) {
|
|
387
|
+
nextSectionIndex = i;
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Replace section content
|
|
393
|
+
const before = lines.slice(0, sectionIndex + 1);
|
|
394
|
+
const after = lines.slice(nextSectionIndex);
|
|
395
|
+
|
|
396
|
+
// Format new content with proper spacing
|
|
397
|
+
const newSectionContent = newContent.trim() ? ['', newContent.trim(), ''] : ['', ''];
|
|
398
|
+
|
|
399
|
+
return [...before, ...newSectionContent, ...after];
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Check if section exists in content
|
|
404
|
+
* @param {string[]} lines - Array of content lines
|
|
405
|
+
* @param {string} sectionHeader - Section header to find
|
|
406
|
+
* @returns {boolean} True if section exists
|
|
407
|
+
*/
|
|
408
|
+
function hasSectionContent(lines, sectionHeader) {
|
|
409
|
+
if (!Array.isArray(lines)) {
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (typeof sectionHeader !== 'string' || !sectionHeader.trim()) {
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const sectionIndex = lines.findIndex(line =>
|
|
418
|
+
line && typeof line === 'string' && line.trim() === sectionHeader.trim()
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
return sectionIndex !== -1;
|
|
422
|
+
}
|
|
423
|
+
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import {
|
|
5
|
+
createTempDir,
|
|
6
|
+
cleanupTempDir,
|
|
7
|
+
mockProcessCwd,
|
|
8
|
+
createMockVibeProject
|
|
9
|
+
} from './test-helpers.js';
|
|
10
|
+
import { resolveTicketId } from './ticket.js';
|
|
11
|
+
|
|
12
|
+
describe('ticket utilities', () => {
|
|
13
|
+
let tempDir;
|
|
14
|
+
let restoreCwd;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
tempDir = createTempDir('ticket-utils-test');
|
|
18
|
+
restoreCwd = mockProcessCwd(tempDir);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
restoreCwd();
|
|
23
|
+
cleanupTempDir(tempDir);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('resolveTicketId', () => {
|
|
27
|
+
it('should return null for invalid input', () => {
|
|
28
|
+
expect(resolveTicketId(null)).toBe(null);
|
|
29
|
+
expect(resolveTicketId(undefined)).toBe(null);
|
|
30
|
+
expect(resolveTicketId('')).toBe(null);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should return null when tickets directory does not exist', () => {
|
|
34
|
+
// No vibe project created
|
|
35
|
+
expect(resolveTicketId('1')).toBe(null);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should resolve numeric ticket ID', () => {
|
|
39
|
+
// Arrange
|
|
40
|
+
createMockVibeProject(tempDir, {
|
|
41
|
+
withTickets: [
|
|
42
|
+
{ id: 'TKT-001', title: 'Test ticket', slug: 'test-ticket' }
|
|
43
|
+
]
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Act
|
|
47
|
+
const result = resolveTicketId('1');
|
|
48
|
+
|
|
49
|
+
// Assert
|
|
50
|
+
expect(result).toBeDefined();
|
|
51
|
+
expect(result.id).toBe('TKT-001');
|
|
52
|
+
expect(result.file).toBe('TKT-001-test-ticket.md');
|
|
53
|
+
expect(result.path).toContain('TKT-001-test-ticket.md');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should resolve padded numeric ticket ID', () => {
|
|
57
|
+
// Arrange
|
|
58
|
+
createMockVibeProject(tempDir, {
|
|
59
|
+
withTickets: [
|
|
60
|
+
{ id: 'TKT-001', title: 'Test ticket', slug: 'test-ticket' }
|
|
61
|
+
]
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Act
|
|
65
|
+
const result = resolveTicketId('001');
|
|
66
|
+
|
|
67
|
+
// Assert
|
|
68
|
+
expect(result).toBeDefined();
|
|
69
|
+
expect(result.id).toBe('TKT-001');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should resolve full TKT-XXX format', () => {
|
|
73
|
+
// Arrange
|
|
74
|
+
createMockVibeProject(tempDir, {
|
|
75
|
+
withTickets: [
|
|
76
|
+
{ id: 'TKT-001', title: 'Test ticket', slug: 'test-ticket' }
|
|
77
|
+
]
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Act
|
|
81
|
+
const result = resolveTicketId('TKT-001');
|
|
82
|
+
|
|
83
|
+
// Assert
|
|
84
|
+
expect(result).toBeDefined();
|
|
85
|
+
expect(result.id).toBe('TKT-001');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should handle case insensitive input', () => {
|
|
89
|
+
// Arrange
|
|
90
|
+
createMockVibeProject(tempDir, {
|
|
91
|
+
withTickets: [
|
|
92
|
+
{ id: 'TKT-001', title: 'Test ticket', slug: 'test-ticket' }
|
|
93
|
+
]
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Act
|
|
97
|
+
const result = resolveTicketId('tkt-001');
|
|
98
|
+
|
|
99
|
+
// Assert
|
|
100
|
+
expect(result).toBeDefined();
|
|
101
|
+
expect(result.id).toBe('TKT-001');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should return null for non-existent ticket', () => {
|
|
105
|
+
// Arrange
|
|
106
|
+
createMockVibeProject(tempDir, {
|
|
107
|
+
withTickets: [
|
|
108
|
+
{ id: 'TKT-001', title: 'Test ticket', slug: 'test-ticket' }
|
|
109
|
+
]
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Act
|
|
113
|
+
const result = resolveTicketId('999');
|
|
114
|
+
|
|
115
|
+
// Assert
|
|
116
|
+
expect(result).toBe(null);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should throw error for invalid format', () => {
|
|
120
|
+
// Arrange
|
|
121
|
+
createMockVibeProject(tempDir);
|
|
122
|
+
|
|
123
|
+
// Act & Assert
|
|
124
|
+
expect(() => resolveTicketId('invalid-id')).toThrow('Invalid ticket ID format');
|
|
125
|
+
expect(() => resolveTicketId('TKT-abc')).toThrow('Invalid ticket ID format');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should handle zero as input', () => {
|
|
129
|
+
// Arrange
|
|
130
|
+
createMockVibeProject(tempDir, {
|
|
131
|
+
withTickets: [
|
|
132
|
+
{ id: 'TKT-000', title: 'Zero ticket', slug: 'zero-ticket' }
|
|
133
|
+
]
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Act
|
|
137
|
+
const result = resolveTicketId(0);
|
|
138
|
+
|
|
139
|
+
// Assert
|
|
140
|
+
expect(result).toBeDefined();
|
|
141
|
+
expect(result.id).toBe('TKT-000');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should handle multiple tickets and find correct one', () => {
|
|
145
|
+
// Arrange
|
|
146
|
+
createMockVibeProject(tempDir, {
|
|
147
|
+
withTickets: [
|
|
148
|
+
{ id: 'TKT-001', title: 'First ticket', slug: 'first-ticket' },
|
|
149
|
+
{ id: 'TKT-002', title: 'Second ticket', slug: 'second-ticket' },
|
|
150
|
+
{ id: 'TKT-010', title: 'Tenth ticket', slug: 'tenth-ticket' }
|
|
151
|
+
]
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Act
|
|
155
|
+
const result1 = resolveTicketId('2');
|
|
156
|
+
const result10 = resolveTicketId('10');
|
|
157
|
+
|
|
158
|
+
// Assert
|
|
159
|
+
expect(result1).toBeDefined();
|
|
160
|
+
expect(result1.id).toBe('TKT-002');
|
|
161
|
+
expect(result10).toBeDefined();
|
|
162
|
+
expect(result10.id).toBe('TKT-010');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should validate return object structure', () => {
|
|
166
|
+
// Arrange
|
|
167
|
+
createMockVibeProject(tempDir, {
|
|
168
|
+
withTickets: [
|
|
169
|
+
{ id: 'TKT-001', title: 'Test ticket', slug: 'test-ticket' }
|
|
170
|
+
]
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Act
|
|
174
|
+
const result = resolveTicketId('1');
|
|
175
|
+
|
|
176
|
+
// Assert
|
|
177
|
+
expect(result).toHaveProperty('id');
|
|
178
|
+
expect(result).toHaveProperty('file');
|
|
179
|
+
expect(result).toHaveProperty('path');
|
|
180
|
+
expect(typeof result.id).toBe('string');
|
|
181
|
+
expect(typeof result.file).toBe('string');
|
|
182
|
+
expect(typeof result.path).toBe('string');
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Note: parseTicket and updateTicket have complex error handling and validation
|
|
187
|
+
// that would require extensive mocking of file system operations and YAML parsing.
|
|
188
|
+
// These functions are better tested through integration tests that test the
|
|
189
|
+
// commands that use them (like close, start, etc.)
|
|
190
|
+
});
|