UNPKG

claude-git-hooks

Version:

Git hooks with Claude CLI for code analysis and automatic commit messages

442 lines (398 loc) 14.2 kB
/** * File: task-id.js * Purpose: Extract, validate, and format task IDs from various sources * * Task ID patterns supported: * - Jira-style: IX-123, PROJ-456, ABC-789 * - GitHub issues: #123, GH-456 * - Linear: LIN-123 * - Generic: TASK-123, BUG-456 * * Used by: * - commit message generation (prepare-commit-msg) * - PR analysis (analyze-diff) * - PR creation (create-pr) */ import { execSync } from 'child_process'; import readline from 'readline'; import logger from './logger.js'; /** * Get task ID pattern from config * Why: Make pattern configurable to avoid false positives * * Default pattern: (#\d{1,5}|[A-Z]{1,10}[-\s]\d{1,5}) * - GitHub issue format: #123 * - OR standard format: PROJ-456, IX-123, TASK-789 * * Examples: IX-123, PROJ-456, TASK-123, #123, LIN-123 * Non-matches: 471459f, test-123, very-long-prefix-123 * * @param {Object} config - Configuration object (optional) * @returns {RegExp} - Compiled regex pattern */ const getTaskIdPattern = (config = null) => { // Default pattern if no config provided - supports multiple formats // Matches either #123 (GitHub) or PROJ-456 (standard) const defaultPattern = '(#\\d{1,5}|[A-Z]{1,10}[-\\s]\\d{1,5})'; let patternString = defaultPattern; if (config?.commitMessage?.taskIdPattern) { patternString = config.commitMessage.taskIdPattern; logger.debug('task-id - getTaskIdPattern', 'Using custom pattern from config', { pattern: patternString }); } else { logger.debug('task-id - getTaskIdPattern', 'Using default pattern', { pattern: patternString }); } // Compile regex with case-insensitive flag return new RegExp(patternString, 'i'); }; /** * Extract task ID from branch name * Why: Branch names often contain task IDs (e.g., feature/IX-123-add-auth) * * @param {string} branchName - Git branch name * @param {Object} config - Configuration object (optional) * @returns {string|null} - Extracted task ID or null if not found * * Examples: * extractTaskId('feature/IX-123-add-auth') → 'IX-123' * extractTaskId('fix/PROJ-456-bug') → 'PROJ-456' * extractTaskId('feature/#123-new') → '#123' * extractTaskId('fix/TASK-789-update') → 'TASK-789' * extractTaskId('feature/add-authentication') → null * extractTaskId('feature/471459f-test') → null (hash, not task-id) */ export const extractTaskId = (branchName, config = null) => { if (!branchName || typeof branchName !== 'string') { logger.debug('task-id - extractTaskId', 'Invalid branch name', { branchName }); return null; } logger.debug('task-id - extractTaskId', 'Extracting task ID', { branchName }); // Get pattern from config const pattern = getTaskIdPattern(config); const match = branchName.match(pattern); if (match) { // Get first capture group const taskId = match[1]; if (taskId) { logger.debug( 'task-id - extractTaskId', 'Task ID found', { branchName, taskId } ); return taskId.toUpperCase(); // Normalize to uppercase } } logger.debug('task-id - extractTaskId', 'No task ID found', { branchName }); return null; }; /** * Extract task ID from current git branch * Why: Convenience wrapper for most common use case * * @param {Object} config - Configuration object (optional) * @returns {string|null} - Extracted task ID or null if not found */ export const extractTaskIdFromCurrentBranch = (config = null) => { try { const branchName = execSync('git branch --show-current', { encoding: 'utf8' }).trim(); return extractTaskId(branchName, config); } catch (error) { logger.debug( 'task-id - extractTaskIdFromCurrentBranch', 'Failed to get current branch', { error: error.message } ); return null; } }; /** * Validate task ID format * Why: Ensure task IDs are properly formatted before use * * @param {string} taskId - Task ID to validate * @param {Object} config - Configuration object (optional) * @returns {boolean} - True if valid format * * Valid formats (default): * - Optional # prefix + 1-10 uppercase letters + separator + 1-5 digits * - Examples: IX-123, PROJ-456, TASK-123, #123, LIN-123 */ export const validateTaskId = (taskId, config = null) => { if (!taskId || typeof taskId !== 'string') { return false; } // Check against configured pattern const pattern = getTaskIdPattern(config); return pattern.test(taskId); }; /** * Format message with task ID prefix * Why: Standardize how task IDs appear in messages * * @param {string} message - Message to format * @param {string} taskId - Task ID to prepend * @returns {string} - Formatted message * * Examples: * formatWithTaskId('feat: add auth', 'IX-123') → '[IX-123] feat: add auth' * formatWithTaskId('[IX-123] feat: add auth', 'IX-123') → '[IX-123] feat: add auth' (idempotent) */ export const formatWithTaskId = (message, taskId) => { if (!message || typeof message !== 'string') { throw new Error('Message is required'); } if (!taskId || typeof taskId !== 'string') { // No task ID provided, return message unchanged return message; } // Normalize task ID to uppercase const normalizedTaskId = taskId.toUpperCase(); // Check if message already has task ID prefix (idempotent) const taskIdPrefix = `[${normalizedTaskId}]`; if (message.startsWith(taskIdPrefix)) { logger.debug( 'task-id - formatWithTaskId', 'Message already has task ID prefix', { message, taskId: normalizedTaskId } ); return message; } // Check if message has ANY task ID in brackets at the start const existingTaskIdMatch = message.match(/^\[([A-Z0-9#-]+)\]\s*/); if (existingTaskIdMatch) { // Replace existing task ID with new one const formattedMessage = message.replace(existingTaskIdMatch[0], `${taskIdPrefix} `); logger.debug( 'task-id - formatWithTaskId', 'Replaced existing task ID', { oldTaskId: existingTaskIdMatch[1], newTaskId: normalizedTaskId, formattedMessage } ); return formattedMessage; } // Prepend task ID const formattedMessage = `${taskIdPrefix} ${message}`; logger.debug( 'task-id - formatWithTaskId', 'Added task ID prefix', { message, taskId: normalizedTaskId, formattedMessage } ); return formattedMessage; }; /** * Remove task ID prefix from message * Why: Sometimes need to get clean message without task ID * * @param {string} message - Message with task ID prefix * @returns {string} - Message without task ID prefix * * Examples: * removeTaskIdPrefix('[IX-123] feat: add auth') → 'feat: add auth' * removeTaskIdPrefix('feat: add auth') → 'feat: add auth' */ export const removeTaskIdPrefix = (message) => { if (!message || typeof message !== 'string') { return message; } // Remove [TASK-ID] prefix if present return message.replace(/^\[([A-Z0-9#-]+)\]\s*/, ''); }; /** * Prompt user for task ID interactively * Why: When task ID can't be extracted automatically, ask the user * * @param {Object} options - Prompt options * @param {string} options.message - Custom prompt message * @param {boolean} options.required - If true, keep prompting until valid ID provided * @param {boolean} options.allowSkip - If true, allow user to skip (return null) * @returns {Promise<string|null>} - Task ID entered by user or null if skipped */ export const promptForTaskId = async ({ message = 'Enter task ID (e.g., IX-123, ABC-12345):', required = false, allowSkip = true, config = null } = {}) => { // Try to use /dev/tty for Unix (git hooks), fallback to stdin for Windows let input, output, usingTty = false; // Check platform if (process.platform !== 'win32') { // Unix-like systems: try /dev/tty try { const fs = await import('fs'); // Test if /dev/tty exists and is accessible if (fs.existsSync('/dev/tty')) { input = fs.createReadStream('/dev/tty'); output = fs.createWriteStream('/dev/tty'); usingTty = true; logger.debug('task-id - promptForTaskId', 'Using /dev/tty for input'); } } catch (error) { logger.debug('task-id - promptForTaskId', '/dev/tty not available, using stdin', { error: error.message }); } } // Fallback to stdin/stdout (Windows or if /dev/tty failed) if (!usingTty) { input = process.stdin; output = process.stdout; logger.debug('task-id - promptForTaskId', 'Using stdin/stdout for input'); } const rl = readline.createInterface({ input, output }); return new Promise((resolve) => { const askQuestion = () => { const promptMessage = allowSkip ? `${message} (press Enter to skip): ` : `${message} `; rl.question(promptMessage, (answer) => { const trimmedAnswer = answer.trim(); // User pressed Enter without input if (!trimmedAnswer) { if (allowSkip && !required) { logger.debug('task-id - promptForTaskId', 'User skipped task ID'); rl.close(); if (usingTty) { input.close(); output.close(); } resolve(null); return; } if (required) { console.log('⚠️ Task ID is required. Please try again.'); askQuestion(); // Ask again return; } } // Validate format if (trimmedAnswer && !validateTaskId(trimmedAnswer, config)) { console.log('⚠️ Invalid task ID format. Examples: ABC-12345, IX-123, DE 4567'); askQuestion(); // Ask again return; } // Valid task ID provided const normalizedTaskId = trimmedAnswer.toUpperCase(); logger.debug( 'task-id - promptForTaskId', 'User provided task ID', { taskId: normalizedTaskId } ); rl.close(); if (usingTty) { input.close(); output.close(); } resolve(normalizedTaskId); }); }; askQuestion(); }); }; /** * Get or prompt for task ID * Why: Unified workflow - try to extract, if fails, prompt user * * @param {Object} options - Options * @param {string} options.branchName - Branch name to extract from (optional) * @param {boolean} options.prompt - If true, prompt user if extraction fails * @param {boolean} options.required - If true, keep prompting until valid ID provided * @param {Object} options.config - Configuration object (optional) * @returns {Promise<string|null>} - Task ID or null if not found/skipped */ export const getOrPromptTaskId = async ({ branchName = null, prompt = true, required = false, config = null } = {}) => { // Try to extract from branch name first let taskId = null; if (branchName) { taskId = extractTaskId(branchName, config); } else { // Try current branch taskId = extractTaskIdFromCurrentBranch(config); } if (taskId) { logger.info(`📋 Task ID detected: ${taskId}`); return taskId; } // Couldn't extract, prompt user if allowed if (prompt) { logger.info('ℹ️ No task ID found in branch name'); return await promptForTaskId({ required, config }); } return null; }; /** * Parse task ID from command-line argument or branch * Why: Handle both explicit argument and auto-detection * * @param {string|null} argTaskId - Task ID from command-line argument * @param {Object} options - Options * @param {boolean} options.prompt - If true, prompt if not found * @param {boolean} options.required - If true, task ID is required * @returns {Promise<string|null>} - Resolved task ID */ export const parseTaskIdArg = async (argTaskId, { prompt = true, required = false } = {}) => { // If task ID provided as argument, use it if (argTaskId) { if (!validateTaskId(argTaskId)) { throw new Error(`Invalid task ID format: ${argTaskId}`); } return argTaskId.toUpperCase(); } // Otherwise, try to get or prompt return await getOrPromptTaskId({ prompt, required }); }; /** * Get supported task ID patterns info * Why: For help messages and documentation * * @returns {Array<Object>} - Array of pattern info */ export const getSupportedPatterns = () => { return [ { name: 'Jira-style', examples: getExamplesForPattern('Jira-style') }, { name: 'GitHub issue', examples: getExamplesForPattern('GitHub issue') }, { name: 'Linear', examples: getExamplesForPattern('Linear') }, { name: 'Generic', examples: getExamplesForPattern('Generic') } ]; }; /** * Get example task IDs for a pattern * @private */ const getExamplesForPattern = (patternName) => { const examples = { 'Jira-style': ['IX-123', 'PROJ-456', 'ABC-789'], 'GitHub issue': ['#123', 'GH-456'], 'Linear': ['LIN-123', 'LIN-456'], 'Generic': ['TASK-123', 'BUG-456', 'FEAT-789'] }; return examples[patternName] || []; };