UNPKG

@morodomi/ait3

Version:

AIT³ Development Platform - AI + Ticket + Test + Tool driven development methodology

574 lines (573 loc) 26.1 kB
import { mkdir, writeFile, readFile, access, readdir, rename, rm } from 'fs/promises'; import { join } from 'path'; import matter from 'gray-matter'; import { z } from 'zod'; import { lock } from 'proper-lockfile'; import { TICKET_CONSTANTS, DEFAULT_TICKET_CONFIG, ERROR_MESSAGES } from '../../common/constants.js'; import { ValidationError, FileSystemError, ConfigurationError, LockError, TicketNotFoundError, TicketAlreadyInProgressError, TicketAlreadyCompletedError, TicketNotStartedError } from '../../common/errors.js'; import { TimeUtils, IDUtils, FileUtils } from '../../common/utils.js'; // Zod schema for validation const TicketSchema = z.object({ id: z.string().regex(/^\d{4}$/), title: z.string().min(TICKET_CONSTANTS.VALIDATION.TITLE_MIN_LENGTH).max(TICKET_CONSTANTS.VALIDATION.TITLE_MAX_LENGTH), status: z.enum(['todo', 'doing', 'done']), priority: z.enum(['low', 'medium', 'high', 'critical']), created: z.string().datetime(), updated: z.string().datetime(), started: z.string().datetime().optional(), completed: z.string().datetime().optional(), assignee: z.string().optional(), labels: z.array(z.string()) }); export class LocalTicketService { gitService; basePath; isRepoCache = null; // Git repository check cache constructor(basePath = TICKET_CONSTANTS.DEFAULT_BASE_PATH, gitService) { this.gitService = gitService; this.basePath = basePath; } async createTicket(title, options = {}) { // Validate inputs if (!title || title.trim().length === 0) { throw new ValidationError(ERROR_MESSAGES.EMPTY_TITLE, 'title'); } // Default options const { priority = 'medium', assignee, labels = [], status = 'todo', description } = options; // Use file locking for safe ID generation const lockPath = join(this.basePath, TICKET_CONSTANTS.LOCK_FILE_NAME); await this.ensureDirectoryStructure(); let release; try { release = await lock(this.basePath, { lockfilePath: lockPath, retries: TICKET_CONSTANTS.LOCK_CONFIG.RETRIES, stale: TICKET_CONSTANTS.LOCK_CONFIG.STALE_TIME, realpath: TICKET_CONSTANTS.LOCK_CONFIG.REALPATH }); } catch (error) { throw new LockError(`${ERROR_MESSAGES.FILE_LOCK_ERROR}: ${error instanceof Error ? error.message : String(error)}`); } try { // Generate next ID and create ticket atomically const config = await this.readConfig(); const nextId = IDUtils.formatTicketId(config.numbering.next, TICKET_CONSTANTS.ID_FORMAT.LENGTH); // Create ticket object const now = TimeUtils.now(); const ticket = { id: nextId, title: title.trim(), status, priority, created: now, updated: now, assignee, labels: [...labels] }; // Validate with Zod let validatedTicket; try { validatedTicket = TicketSchema.parse(ticket); } catch (error) { throw new ValidationError(`Validation failed: ${error instanceof Error ? error.message : String(error)}`); } // Generate filename from title const filename = FileUtils.generateTicketFilename(nextId, title); const filePath = join(this.basePath, status, filename); // Create file content const content = this.generateFileContent(validatedTicket, description); // Write file try { await writeFile(filePath, content, 'utf-8'); } catch (error) { throw new FileSystemError(`${ERROR_MESSAGES.FILE_WRITE_ERROR}: ${error instanceof Error ? error.message : String(error)}`, filePath); } // Update config with next ID await this.updateConfig(config, nextId); return validatedTicket; } finally { await release(); } } async ensureDirectoryStructure() { try { // Create base directories await mkdir(this.basePath, { recursive: true }); await mkdir(join(this.basePath, TICKET_CONSTANTS.DIRECTORIES.TODO), { recursive: true }); await mkdir(join(this.basePath, TICKET_CONSTANTS.DIRECTORIES.DOING), { recursive: true }); await mkdir(join(this.basePath, TICKET_CONSTANTS.DIRECTORIES.DONE), { recursive: true }); // Create config if it doesn't exist const configPath = join(this.basePath, TICKET_CONSTANTS.CONFIG_FILE_NAME); try { await access(configPath); } catch { await writeFile(configPath, JSON.stringify(DEFAULT_TICKET_CONFIG, null, 2), 'utf-8'); } } catch (error) { throw new FileSystemError(`${ERROR_MESSAGES.DIRECTORY_CREATE_ERROR}: ${error instanceof Error ? error.message : String(error)}`, this.basePath); } } async readConfig() { const configPath = join(this.basePath, TICKET_CONSTANTS.CONFIG_FILE_NAME); try { const configContent = await readFile(configPath, 'utf-8'); return JSON.parse(configContent); } catch (error) { throw new ConfigurationError(`${ERROR_MESSAGES.CONFIG_READ_ERROR}: ${error instanceof Error ? error.message : String(error)}`); } } async updateConfig(config, currentId) { const configPath = join(this.basePath, TICKET_CONSTANTS.CONFIG_FILE_NAME); try { const currentIdNum = parseInt(currentId, 10); config.numbering.next = currentIdNum + config.numbering.increment; await writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8'); } catch (error) { throw new ConfigurationError(`${ERROR_MESSAGES.CONFIG_WRITE_ERROR}: ${error instanceof Error ? error.message : String(error)}`); } } generateFileContent(ticket, description) { // Type-safe frontmatter without any types const frontmatter = { id: ticket.id, title: ticket.title, status: ticket.status, priority: ticket.priority, created: ticket.created, updated: ticket.updated, labels: ticket.labels }; if (ticket.assignee) { frontmatter.assignee = ticket.assignee; } // Use description if provided, otherwise use default template const markdownContent = description ? `# Ticket #${ticket.id}: ${ticket.title}\n\n## Description\n\n${description}\n` : `# Ticket #${ticket.id}: ${ticket.title}\n\n## Description\n\n[Add ticket description here]\n`; // Use gray-matter to create content with YAML frontmatter const content = matter.stringify(markdownContent, frontmatter); return content; } /** * Cached Git repository check for performance optimization */ async isGitRepository() { if (this.isRepoCache === null && this.gitService) { try { this.isRepoCache = await this.gitService.isRepository(); } catch { this.isRepoCache = false; } } return this.isRepoCache ?? false; } /** * Common file move operation with Git integration and fallback * Reduces code duplication between moveTicketState and undoTicket */ async moveFileWithGitIntegration(currentFilePath, targetPath, updatedContent) { // Try Git move first if Git service is available let gitMoveSucceeded = false; if (this.gitService && await this.isGitRepository()) { try { // Update content in place first await writeFile(currentFilePath, updatedContent, 'utf-8'); // Then use git mv to move the file await this.gitService.moveFile(currentFilePath, targetPath); gitMoveSucceeded = true; } catch (error) { // Git move failed, fall back to file system operation console.warn('Git move failed, falling back to file system operation:', error instanceof Error ? error.message : String(error)); } } // Fallback to file system operation if Git move didn't succeed if (!gitMoveSucceeded) { // Write updated content to new location await writeFile(targetPath, updatedContent, 'utf-8'); // Remove original file - using unlink for atomic operation try { await rename(currentFilePath, `${currentFilePath}.tmp`); await rm(`${currentFilePath}.tmp`, { force: true }); } catch (error) { // If removal fails, try to clean up the target file try { await rm(targetPath, { force: true }); } catch { // Ignore cleanup errors } throw new FileSystemError(`Failed to move ticket file: ${error instanceof Error ? error.message : String(error)}`, currentFilePath); } } } async listTickets(options) { try { await this.ensureDirectoryStructure(); let tickets = []; const directories = [ TICKET_CONSTANTS.DIRECTORIES.TODO, TICKET_CONSTANTS.DIRECTORIES.DOING, TICKET_CONSTANTS.DIRECTORIES.DONE ]; // Process directories in parallel for better performance const ticketPromises = directories.map(async (dir) => { const dirPath = join(this.basePath, dir); try { const files = await readdir(dirPath); // Process files in parallel within each directory const filePromises = files .filter(file => file.endsWith('.md')) .map(async (file) => { try { const filePath = join(dirPath, file); const content = await readFile(filePath, 'utf-8'); const { data } = matter(content); // Validate with Zod const ticket = TicketSchema.parse(data); return ticket; } catch { // Skip invalid ticket files return null; } }); const dirTickets = await Promise.all(filePromises); return dirTickets.filter(ticket => ticket !== null); } catch { // Skip if directory doesn't exist or can't be read return []; } }); const ticketArrays = await Promise.all(ticketPromises); tickets = ticketArrays.flat(); // Apply filters let filtered = tickets; if (options?.status) { filtered = filtered.filter(ticket => ticket.status === options.status); } if (options?.priority) { filtered = filtered.filter(ticket => ticket.priority === options.priority); } return filtered; } catch (error) { throw new FileSystemError(`Failed to list tickets: ${error instanceof Error ? error.message : String(error)}`, this.basePath); } } async getTicket(id) { try { await this.ensureDirectoryStructure(); // Efficient search: Check each status directory for file starting with ID const directories = [ TICKET_CONSTANTS.DIRECTORIES.TODO, TICKET_CONSTANTS.DIRECTORIES.DOING, TICKET_CONSTANTS.DIRECTORIES.DONE ]; // Search directories in parallel for better performance const searchPromises = directories.map(async (dir) => { const dirPath = join(this.basePath, dir); try { const files = await readdir(dirPath); // Find file that starts with the given ID const targetFile = files.find(file => file.endsWith('.md') && file.startsWith(`${id}-`)); if (targetFile) { try { const filePath = join(dirPath, targetFile); const content = await readFile(filePath, 'utf-8'); const { data, content: markdownContent } = matter(content); // Validate with Zod and add description from markdown content const ticket = TicketSchema.parse(data); // Extract full markdown content as description const description = markdownContent.trim(); // Add location information const relativePath = `.tickets/${dir}/${targetFile}`; return { ...ticket, description: description || undefined, location: { type: 'local', path: relativePath } }; } catch { // Skip invalid ticket files return null; } } return null; } catch { // Skip if directory doesn't exist or can't be read return null; } }); const results = await Promise.all(searchPromises); const foundTicket = results.find(ticket => ticket !== null); return foundTicket || null; } catch (error) { throw new FileSystemError(`Failed to get ticket: ${error instanceof Error ? error.message : String(error)}`, this.basePath); } } /** * Generic method to move tickets between states * Reduces code duplication for state transitions */ async moveTicketState(id, fromStatus, toStatus, timestampField) { try { await this.ensureDirectoryStructure(); // Find the ticket in all directories to check current status const directories = [ TICKET_CONSTANTS.DIRECTORIES.TODO, TICKET_CONSTANTS.DIRECTORIES.DOING, TICKET_CONSTANTS.DIRECTORIES.DONE ]; let currentStatus = null; let currentFilePath = null; let targetFilename = null; // Search for the ticket across all status directories for (const dir of directories) { const dirPath = join(this.basePath, dir); try { const files = await readdir(dirPath); const targetFile = files.find(file => file.endsWith('.md') && file.startsWith(`${id}-`)); if (targetFile) { currentStatus = dir; currentFilePath = join(dirPath, targetFile); targetFilename = targetFile; break; } } catch { // Skip if directory doesn't exist or can't be read continue; } } // Check if ticket exists if (!currentStatus || !currentFilePath || !targetFilename) { throw new TicketNotFoundError(id); } // Validate current status matches expected fromStatus if (currentStatus !== fromStatus) { // Throw specific errors based on the transition type if (fromStatus === TICKET_CONSTANTS.DIRECTORIES.TODO && toStatus === 'doing') { if (currentStatus === TICKET_CONSTANTS.DIRECTORIES.DOING) { throw new TicketAlreadyInProgressError(id); } if (currentStatus === TICKET_CONSTANTS.DIRECTORIES.DONE) { throw new TicketAlreadyCompletedError(id); } } else if (fromStatus === TICKET_CONSTANTS.DIRECTORIES.DOING && toStatus === 'done') { if (currentStatus === TICKET_CONSTANTS.DIRECTORIES.TODO) { throw new TicketNotStartedError(id); } if (currentStatus === TICKET_CONSTANTS.DIRECTORIES.DONE) { throw new TicketAlreadyCompletedError(id); } } throw new ValidationError(`Cannot transition ticket from '${currentStatus}' to '${toStatus}'`); } // Read and update ticket content const content = await readFile(currentFilePath, 'utf-8'); const { data, content: markdownContent } = matter(content); // Update metadata with type safety const now = TimeUtils.now(); const updatedData = { ...data, status: toStatus, updated: now, // Type-safe timestamp field addition ...(timestampField === 'started' && { started: now }), ...(timestampField === 'completed' && { completed: now }) }; // Generate updated file content const updatedContent = matter.stringify(markdownContent, updatedData); // Move file to target directory with Git integration const statusToDir = { 'todo': TICKET_CONSTANTS.DIRECTORIES.TODO, 'doing': TICKET_CONSTANTS.DIRECTORIES.DOING, 'done': TICKET_CONSTANTS.DIRECTORIES.DONE }; const targetDir = statusToDir[toStatus]; const targetPath = join(this.basePath, targetDir, targetFilename); // Use common file move method with Git integration await this.moveFileWithGitIntegration(currentFilePath, targetPath, updatedContent); } catch (error) { // Re-throw specific errors as-is if (error instanceof TicketNotFoundError || error instanceof TicketAlreadyInProgressError || error instanceof TicketAlreadyCompletedError || error instanceof TicketNotStartedError || error instanceof ValidationError || error instanceof FileSystemError) { throw error; } // Wrap other errors throw new FileSystemError(`Failed to move ticket state: ${error instanceof Error ? error.message : String(error)}`, this.basePath); } } async startTicket(id) { await this.moveTicketState(id, TICKET_CONSTANTS.DIRECTORIES.TODO, 'doing', 'started'); } async completeTicket(id) { await this.moveTicketState(id, TICKET_CONSTANTS.DIRECTORIES.DOING, 'done', 'completed'); } async undoTicket(id) { try { await this.ensureDirectoryStructure(); // Find the ticket in all directories to check current status const directories = [ TICKET_CONSTANTS.DIRECTORIES.TODO, TICKET_CONSTANTS.DIRECTORIES.DOING, TICKET_CONSTANTS.DIRECTORIES.DONE ]; let currentStatus = null; let currentFilePath = null; let targetFilename = null; // Search for the ticket across all status directories for (const dir of directories) { const dirPath = join(this.basePath, dir); try { const files = await readdir(dirPath); const targetFile = files.find(file => file.endsWith('.md') && file.startsWith(`${id}-`)); if (targetFile) { currentStatus = dir; currentFilePath = join(dirPath, targetFile); targetFilename = targetFile; break; } } catch { // Skip if directory doesn't exist or can't be read continue; } } // Check if ticket exists if (!currentStatus || !currentFilePath || !targetFilename) { throw new TicketNotFoundError(id); } // Determine undo operation based on current status let targetStatus; let fieldToRemove; if (currentStatus === TICKET_CONSTANTS.DIRECTORIES.TODO) { throw new ValidationError(`Cannot undo ticket #${id}: already in 'todo' state`); } else if (currentStatus === TICKET_CONSTANTS.DIRECTORIES.DOING) { // doing → todo (remove started timestamp) targetStatus = 'todo'; fieldToRemove = 'started'; } else if (currentStatus === TICKET_CONSTANTS.DIRECTORIES.DONE) { // done → doing (remove completed timestamp) targetStatus = 'doing'; fieldToRemove = 'completed'; } else { throw new ValidationError(`Invalid ticket status: ${currentStatus}`); } // Read and update ticket content const content = await readFile(currentFilePath, 'utf-8'); const { data, content: markdownContent } = matter(content); // Update metadata with type safety const now = TimeUtils.now(); const { [fieldToRemove || '']: _, ...dataWithoutField } = data; const updatedData = { ...dataWithoutField, status: targetStatus, updated: now }; // Generate updated file content const updatedContent = matter.stringify(markdownContent, updatedData); // Move file to target directory with Git integration const statusToDir = { 'todo': TICKET_CONSTANTS.DIRECTORIES.TODO, 'doing': TICKET_CONSTANTS.DIRECTORIES.DOING }; const targetDir = statusToDir[targetStatus]; const targetPath = join(this.basePath, targetDir, targetFilename); // Use common file move method with Git integration await this.moveFileWithGitIntegration(currentFilePath, targetPath, updatedContent); } catch (error) { // Re-throw specific errors as-is if (error instanceof TicketNotFoundError || error instanceof ValidationError || error instanceof FileSystemError) { throw error; } // Wrap other errors throw new FileSystemError(`Failed to undo ticket: ${error instanceof Error ? error.message : String(error)}`, this.basePath); } } async deleteTicket(id) { try { await this.ensureDirectoryStructure(); // Find the ticket in all directories const directories = [ TICKET_CONSTANTS.DIRECTORIES.TODO, TICKET_CONSTANTS.DIRECTORIES.DOING, TICKET_CONSTANTS.DIRECTORIES.DONE ]; let currentFilePath = null; // Search for the ticket across all status directories for (const dir of directories) { const dirPath = join(this.basePath, dir); try { const files = await readdir(dirPath); const targetFile = files.find(file => file.endsWith('.md') && file.startsWith(`${id}-`)); if (targetFile) { currentFilePath = join(dirPath, targetFile); break; } } catch { // Skip if directory doesn't exist or can't be read continue; } } // Check if ticket exists if (!currentFilePath) { throw new TicketNotFoundError(id); } // Delete the file with Git integration if (this.gitService && await this.isGitRepository()) { try { // Use git rm to remove the file await this.gitService.removeFile(currentFilePath); } catch (error) { // Git removal failed, fall back to file system operation console.warn('Git removal failed, falling back to file system operation:', error instanceof Error ? error.message : String(error)); await rm(currentFilePath, { force: true }); } } else { // Direct file system deletion await rm(currentFilePath, { force: true }); } } catch (error) { // Re-throw specific errors as-is if (error instanceof TicketNotFoundError) { throw error; } // Wrap other errors throw new FileSystemError(`Failed to delete ticket: ${error instanceof Error ? error.message : String(error)}`, this.basePath); } } }