claude-git-hooks
Version:
Git hooks with Claude CLI for code analysis and automatic commit messages
442 lines (398 loc) • 14.2 kB
JavaScript
/**
* 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] || [];
};