claude-git-hooks 2.4.0 → 2.5.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.
@@ -0,0 +1,180 @@
1
+ /**
2
+ * File: sanitize.js
3
+ * Purpose: Input sanitization utilities for security
4
+ *
5
+ * Why: Prevent prompt injection and ensure valid API input
6
+ * Used by: github-client.js, and any module handling external input
7
+ */
8
+
9
+ import logger from './logger.js';
10
+
11
+ /**
12
+ * Sanitize string input for use in prompts and API calls
13
+ * Why: Prevent prompt injection and ensure valid GitHub API input
14
+ *
15
+ * @param {string} input - Raw input string
16
+ * @param {Object} options - Sanitization options
17
+ * @param {number} options.maxLength - Maximum allowed length (default: 65536)
18
+ * @param {boolean} options.allowNewlines - Allow newline characters (default: true)
19
+ * @param {boolean} options.stripControlChars - Remove control characters (default: true)
20
+ * @returns {string} - Sanitized string
21
+ */
22
+ export const sanitizeInput = (input, {
23
+ maxLength = 65536,
24
+ allowNewlines = true,
25
+ stripControlChars = true
26
+ } = {}) => {
27
+ if (typeof input !== 'string') {
28
+ return '';
29
+ }
30
+
31
+ let sanitized = input;
32
+
33
+ // Strip control characters (except newlines/tabs if allowed)
34
+ if (stripControlChars) {
35
+ if (allowNewlines) {
36
+ // Keep \n, \r, \t but remove other control chars
37
+ sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
38
+ } else {
39
+ // Remove all control characters including newlines
40
+ sanitized = sanitized.replace(/[\x00-\x1F\x7F]/g, ' ');
41
+ }
42
+ }
43
+
44
+ // Truncate to max length
45
+ if (sanitized.length > maxLength) {
46
+ sanitized = sanitized.substring(0, maxLength);
47
+ logger.debug('sanitize - sanitizeInput', 'Input truncated', {
48
+ originalLength: input.length,
49
+ maxLength
50
+ });
51
+ }
52
+
53
+ return sanitized.trim();
54
+ };
55
+
56
+ /**
57
+ * Sanitize PR title for GitHub API
58
+ * Why: Titles have specific requirements (single line, reasonable length)
59
+ *
60
+ * @param {string} title - Raw title
61
+ * @returns {string} - Sanitized title
62
+ */
63
+ export const sanitizePRTitle = (title) => {
64
+ return sanitizeInput(title, {
65
+ maxLength: 256,
66
+ allowNewlines: false,
67
+ stripControlChars: true
68
+ });
69
+ };
70
+
71
+ /**
72
+ * Sanitize PR body/description for GitHub API
73
+ * Why: Body can be longer but still needs control char removal
74
+ *
75
+ * @param {string} body - Raw body text
76
+ * @returns {string} - Sanitized body
77
+ */
78
+ export const sanitizePRBody = (body) => {
79
+ return sanitizeInput(body, {
80
+ maxLength: 65536, // GitHub's limit is 65536 chars
81
+ allowNewlines: true,
82
+ stripControlChars: true
83
+ });
84
+ };
85
+
86
+ /**
87
+ * Sanitize array of strings (labels, reviewers)
88
+ * Why: Ensure valid GitHub usernames/label names
89
+ *
90
+ * @param {Array<string>} items - Array of strings to sanitize
91
+ * @param {RegExp} validPattern - Pattern for valid items (default: alphanumeric + hyphen + dot)
92
+ * @returns {Array<string>} - Sanitized array
93
+ */
94
+ export const sanitizeStringArray = (items, validPattern = /^[\w.-]+$/) => {
95
+ if (!Array.isArray(items)) {
96
+ return [];
97
+ }
98
+
99
+ return items
100
+ .filter(item => typeof item === 'string')
101
+ .map(item => item.trim())
102
+ .filter(item => item.length > 0 && item.length <= 100)
103
+ .filter(item => validPattern.test(item));
104
+ };
105
+
106
+ /**
107
+ * Sanitize GitHub username
108
+ * Why: Usernames have specific format requirements
109
+ *
110
+ * @param {string} username - Raw username
111
+ * @returns {string|null} - Sanitized username or null if invalid
112
+ */
113
+ export const sanitizeUsername = (username) => {
114
+ if (typeof username !== 'string') {
115
+ return null;
116
+ }
117
+
118
+ const trimmed = username.trim().replace(/^@/, ''); // Remove leading @
119
+
120
+ // GitHub username rules: alphanumeric + hyphen, 1-39 chars, no consecutive hyphens
121
+ if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/.test(trimmed)) {
122
+ return null;
123
+ }
124
+
125
+ if (trimmed.length > 39 || trimmed.includes('--')) {
126
+ return null;
127
+ }
128
+
129
+ return trimmed;
130
+ };
131
+
132
+ /**
133
+ * Sanitize GitHub label name
134
+ * Why: Labels have format requirements
135
+ *
136
+ * @param {string} label - Raw label name
137
+ * @returns {string|null} - Sanitized label or null if invalid
138
+ */
139
+ export const sanitizeLabel = (label) => {
140
+ if (typeof label !== 'string') {
141
+ return null;
142
+ }
143
+
144
+ const trimmed = label.trim();
145
+
146
+ // Labels can contain most chars except commas, max 50 chars
147
+ if (trimmed.length === 0 || trimmed.length > 50) {
148
+ return null;
149
+ }
150
+
151
+ // Remove problematic characters
152
+ return trimmed.replace(/[,\x00-\x1F\x7F]/g, '');
153
+ };
154
+
155
+ /**
156
+ * Sanitize branch name
157
+ * Why: Branch names have git format requirements
158
+ *
159
+ * @param {string} branch - Raw branch name
160
+ * @returns {string|null} - Sanitized branch or null if invalid
161
+ */
162
+ export const sanitizeBranchName = (branch) => {
163
+ if (typeof branch !== 'string') {
164
+ return null;
165
+ }
166
+
167
+ const trimmed = branch.trim();
168
+
169
+ // Git branch name rules (simplified)
170
+ if (!/^[\w./-]+$/.test(trimmed)) {
171
+ return null;
172
+ }
173
+
174
+ // No consecutive dots, no ending with .lock
175
+ if (trimmed.includes('..') || trimmed.endsWith('.lock')) {
176
+ return null;
177
+ }
178
+
179
+ return trimmed;
180
+ };
@@ -0,0 +1,425 @@
1
+ /**
2
+ * File: task-id.js
3
+ * Purpose: Extract, validate, and format task IDs from various sources
4
+ *
5
+ * Task ID patterns supported:
6
+ * - Jira-style: IX-123, PROJ-456, ABC-789
7
+ * - GitHub issues: #123, GH-456
8
+ * - Linear: LIN-123
9
+ * - Generic: TASK-123, BUG-456
10
+ *
11
+ * Used by:
12
+ * - commit message generation (prepare-commit-msg)
13
+ * - PR analysis (analyze-diff)
14
+ * - PR creation (create-pr)
15
+ */
16
+
17
+ import { execSync } from 'child_process';
18
+ import readline from 'readline';
19
+ import logger from './logger.js';
20
+
21
+ /**
22
+ * Get task ID pattern from config
23
+ * Why: Make pattern configurable to avoid false positives
24
+ *
25
+ * Default pattern: ([A-Z]{1,3}[-\s]\d{3,5})
26
+ * - 1-3 uppercase letters
27
+ * - Dash or space separator
28
+ * - 3-5 digits
29
+ *
30
+ * Examples: ABC-12345, IX-123, DE 4567
31
+ * Non-matches: 471459f, ABCD-123, IX-12, test-123
32
+ *
33
+ * @param {Object} config - Configuration object (optional)
34
+ * @returns {RegExp} - Compiled regex pattern
35
+ */
36
+ const getTaskIdPattern = (config = null) => {
37
+ // Default pattern if no config provided
38
+ const defaultPattern = '([A-Z]{1,3}[-\\s]\\d{3,5})';
39
+
40
+ let patternString = defaultPattern;
41
+
42
+ if (config?.commitMessage?.taskIdPattern) {
43
+ patternString = config.commitMessage.taskIdPattern;
44
+ logger.debug('task-id - getTaskIdPattern', 'Using custom pattern from config', {
45
+ pattern: patternString
46
+ });
47
+ } else {
48
+ logger.debug('task-id - getTaskIdPattern', 'Using default pattern', {
49
+ pattern: patternString
50
+ });
51
+ }
52
+
53
+ // Compile regex with case-insensitive flag
54
+ return new RegExp(patternString, 'i');
55
+ };
56
+
57
+ /**
58
+ * Extract task ID from branch name
59
+ * Why: Branch names often contain task IDs (e.g., feature/IX-123-add-auth)
60
+ *
61
+ * @param {string} branchName - Git branch name
62
+ * @param {Object} config - Configuration object (optional)
63
+ * @returns {string|null} - Extracted task ID or null if not found
64
+ *
65
+ * Examples:
66
+ * extractTaskId('feature/IX-123-add-auth') → 'IX-123'
67
+ * extractTaskId('fix/ABC-12345-bug') → 'ABC-12345'
68
+ * extractTaskId('feature/add-authentication') → null
69
+ * extractTaskId('feature/471459f-test') → null (hash, not task-id)
70
+ */
71
+ export const extractTaskId = (branchName, config = null) => {
72
+ if (!branchName || typeof branchName !== 'string') {
73
+ logger.debug('task-id - extractTaskId', 'Invalid branch name', { branchName });
74
+ return null;
75
+ }
76
+
77
+ logger.debug('task-id - extractTaskId', 'Extracting task ID', { branchName });
78
+
79
+ // Get pattern from config
80
+ const pattern = getTaskIdPattern(config);
81
+ const match = branchName.match(pattern);
82
+
83
+ if (match) {
84
+ // Get first capture group
85
+ const taskId = match[1];
86
+ if (taskId) {
87
+ logger.debug(
88
+ 'task-id - extractTaskId',
89
+ 'Task ID found',
90
+ { branchName, taskId }
91
+ );
92
+ return taskId.toUpperCase(); // Normalize to uppercase
93
+ }
94
+ }
95
+
96
+ logger.debug('task-id - extractTaskId', 'No task ID found', { branchName });
97
+ return null;
98
+ };
99
+
100
+ /**
101
+ * Extract task ID from current git branch
102
+ * Why: Convenience wrapper for most common use case
103
+ *
104
+ * @param {Object} config - Configuration object (optional)
105
+ * @returns {string|null} - Extracted task ID or null if not found
106
+ */
107
+ export const extractTaskIdFromCurrentBranch = (config = null) => {
108
+ try {
109
+ const branchName = execSync('git branch --show-current', {
110
+ encoding: 'utf8'
111
+ }).trim();
112
+
113
+ return extractTaskId(branchName, config);
114
+ } catch (error) {
115
+ logger.debug(
116
+ 'task-id - extractTaskIdFromCurrentBranch',
117
+ 'Failed to get current branch',
118
+ { error: error.message }
119
+ );
120
+ return null;
121
+ }
122
+ };
123
+
124
+ /**
125
+ * Validate task ID format
126
+ * Why: Ensure task IDs are properly formatted before use
127
+ *
128
+ * @param {string} taskId - Task ID to validate
129
+ * @param {Object} config - Configuration object (optional)
130
+ * @returns {boolean} - True if valid format
131
+ *
132
+ * Valid formats (default):
133
+ * - 1-3 uppercase letters + separator + 3-5 digits
134
+ * - Examples: ABC-12345, IX-123, DE 4567
135
+ */
136
+ export const validateTaskId = (taskId, config = null) => {
137
+ if (!taskId || typeof taskId !== 'string') {
138
+ return false;
139
+ }
140
+
141
+ // Check against configured pattern
142
+ const pattern = getTaskIdPattern(config);
143
+ return pattern.test(taskId);
144
+ };
145
+
146
+ /**
147
+ * Format message with task ID prefix
148
+ * Why: Standardize how task IDs appear in messages
149
+ *
150
+ * @param {string} message - Message to format
151
+ * @param {string} taskId - Task ID to prepend
152
+ * @returns {string} - Formatted message
153
+ *
154
+ * Examples:
155
+ * formatWithTaskId('feat: add auth', 'IX-123') → '[IX-123] feat: add auth'
156
+ * formatWithTaskId('[IX-123] feat: add auth', 'IX-123') → '[IX-123] feat: add auth' (idempotent)
157
+ */
158
+ export const formatWithTaskId = (message, taskId) => {
159
+ if (!message || typeof message !== 'string') {
160
+ throw new Error('Message is required');
161
+ }
162
+
163
+ if (!taskId || typeof taskId !== 'string') {
164
+ // No task ID provided, return message unchanged
165
+ return message;
166
+ }
167
+
168
+ // Normalize task ID to uppercase
169
+ const normalizedTaskId = taskId.toUpperCase();
170
+
171
+ // Check if message already has task ID prefix (idempotent)
172
+ const taskIdPrefix = `[${normalizedTaskId}]`;
173
+ if (message.startsWith(taskIdPrefix)) {
174
+ logger.debug(
175
+ 'task-id - formatWithTaskId',
176
+ 'Message already has task ID prefix',
177
+ { message, taskId: normalizedTaskId }
178
+ );
179
+ return message;
180
+ }
181
+
182
+ // Check if message has ANY task ID in brackets at the start
183
+ const existingTaskIdMatch = message.match(/^\[([A-Z0-9#-]+)\]\s*/);
184
+ if (existingTaskIdMatch) {
185
+ // Replace existing task ID with new one
186
+ const formattedMessage = message.replace(existingTaskIdMatch[0], `${taskIdPrefix} `);
187
+ logger.debug(
188
+ 'task-id - formatWithTaskId',
189
+ 'Replaced existing task ID',
190
+ {
191
+ oldTaskId: existingTaskIdMatch[1],
192
+ newTaskId: normalizedTaskId,
193
+ formattedMessage
194
+ }
195
+ );
196
+ return formattedMessage;
197
+ }
198
+
199
+ // Prepend task ID
200
+ const formattedMessage = `${taskIdPrefix} ${message}`;
201
+ logger.debug(
202
+ 'task-id - formatWithTaskId',
203
+ 'Added task ID prefix',
204
+ { message, taskId: normalizedTaskId, formattedMessage }
205
+ );
206
+
207
+ return formattedMessage;
208
+ };
209
+
210
+ /**
211
+ * Remove task ID prefix from message
212
+ * Why: Sometimes need to get clean message without task ID
213
+ *
214
+ * @param {string} message - Message with task ID prefix
215
+ * @returns {string} - Message without task ID prefix
216
+ *
217
+ * Examples:
218
+ * removeTaskIdPrefix('[IX-123] feat: add auth') → 'feat: add auth'
219
+ * removeTaskIdPrefix('feat: add auth') → 'feat: add auth'
220
+ */
221
+ export const removeTaskIdPrefix = (message) => {
222
+ if (!message || typeof message !== 'string') {
223
+ return message;
224
+ }
225
+
226
+ // Remove [TASK-ID] prefix if present
227
+ return message.replace(/^\[([A-Z0-9#-]+)\]\s*/, '');
228
+ };
229
+
230
+ /**
231
+ * Prompt user for task ID interactively
232
+ * Why: When task ID can't be extracted automatically, ask the user
233
+ *
234
+ * @param {Object} options - Prompt options
235
+ * @param {string} options.message - Custom prompt message
236
+ * @param {boolean} options.required - If true, keep prompting until valid ID provided
237
+ * @param {boolean} options.allowSkip - If true, allow user to skip (return null)
238
+ * @returns {Promise<string|null>} - Task ID entered by user or null if skipped
239
+ */
240
+ export const promptForTaskId = async ({
241
+ message = 'Enter task ID (e.g., IX-123, ABC-12345):',
242
+ required = false,
243
+ allowSkip = true,
244
+ config = null
245
+ } = {}) => {
246
+ // Try to use /dev/tty for Unix (git hooks), fallback to stdin for Windows
247
+ let input, output, usingTty = false;
248
+
249
+ // Check platform
250
+ if (process.platform !== 'win32') {
251
+ // Unix-like systems: try /dev/tty
252
+ try {
253
+ const fs = await import('fs');
254
+ // Test if /dev/tty exists and is accessible
255
+ if (fs.existsSync('/dev/tty')) {
256
+ input = fs.createReadStream('/dev/tty');
257
+ output = fs.createWriteStream('/dev/tty');
258
+ usingTty = true;
259
+ logger.debug('task-id - promptForTaskId', 'Using /dev/tty for input');
260
+ }
261
+ } catch (error) {
262
+ logger.debug('task-id - promptForTaskId', '/dev/tty not available, using stdin', {
263
+ error: error.message
264
+ });
265
+ }
266
+ }
267
+
268
+ // Fallback to stdin/stdout (Windows or if /dev/tty failed)
269
+ if (!usingTty) {
270
+ input = process.stdin;
271
+ output = process.stdout;
272
+ logger.debug('task-id - promptForTaskId', 'Using stdin/stdout for input');
273
+ }
274
+
275
+ const rl = readline.createInterface({
276
+ input,
277
+ output
278
+ });
279
+
280
+ return new Promise((resolve) => {
281
+ const askQuestion = () => {
282
+ const promptMessage = allowSkip
283
+ ? `${message} (press Enter to skip): `
284
+ : `${message} `;
285
+
286
+ rl.question(promptMessage, (answer) => {
287
+ const trimmedAnswer = answer.trim();
288
+
289
+ // User pressed Enter without input
290
+ if (!trimmedAnswer) {
291
+ if (allowSkip && !required) {
292
+ logger.debug('task-id - promptForTaskId', 'User skipped task ID');
293
+ rl.close();
294
+ if (usingTty) {
295
+ input.close();
296
+ output.close();
297
+ }
298
+ resolve(null);
299
+ return;
300
+ }
301
+
302
+ if (required) {
303
+ console.log('âš ī¸ Task ID is required. Please try again.');
304
+ askQuestion(); // Ask again
305
+ return;
306
+ }
307
+ }
308
+
309
+ // Validate format
310
+ if (trimmedAnswer && !validateTaskId(trimmedAnswer, config)) {
311
+ console.log('âš ī¸ Invalid task ID format. Examples: ABC-12345, IX-123, DE 4567');
312
+ askQuestion(); // Ask again
313
+ return;
314
+ }
315
+
316
+ // Valid task ID provided
317
+ const normalizedTaskId = trimmedAnswer.toUpperCase();
318
+ logger.debug(
319
+ 'task-id - promptForTaskId',
320
+ 'User provided task ID',
321
+ { taskId: normalizedTaskId }
322
+ );
323
+ rl.close();
324
+ if (usingTty) {
325
+ input.close();
326
+ output.close();
327
+ }
328
+ resolve(normalizedTaskId);
329
+ });
330
+ };
331
+
332
+ askQuestion();
333
+ });
334
+ };
335
+
336
+ /**
337
+ * Get or prompt for task ID
338
+ * Why: Unified workflow - try to extract, if fails, prompt user
339
+ *
340
+ * @param {Object} options - Options
341
+ * @param {string} options.branchName - Branch name to extract from (optional)
342
+ * @param {boolean} options.prompt - If true, prompt user if extraction fails
343
+ * @param {boolean} options.required - If true, keep prompting until valid ID provided
344
+ * @param {Object} options.config - Configuration object (optional)
345
+ * @returns {Promise<string|null>} - Task ID or null if not found/skipped
346
+ */
347
+ export const getOrPromptTaskId = async ({
348
+ branchName = null,
349
+ prompt = true,
350
+ required = false,
351
+ config = null
352
+ } = {}) => {
353
+ // Try to extract from branch name first
354
+ let taskId = null;
355
+
356
+ if (branchName) {
357
+ taskId = extractTaskId(branchName, config);
358
+ } else {
359
+ // Try current branch
360
+ taskId = extractTaskIdFromCurrentBranch(config);
361
+ }
362
+
363
+ if (taskId) {
364
+ logger.info(`📋 Task ID detected: ${taskId}`);
365
+ return taskId;
366
+ }
367
+
368
+ // Couldn't extract, prompt user if allowed
369
+ if (prompt) {
370
+ logger.info('â„šī¸ No task ID found in branch name');
371
+ return await promptForTaskId({ required, config });
372
+ }
373
+
374
+ return null;
375
+ };
376
+
377
+ /**
378
+ * Parse task ID from command-line argument or branch
379
+ * Why: Handle both explicit argument and auto-detection
380
+ *
381
+ * @param {string|null} argTaskId - Task ID from command-line argument
382
+ * @param {Object} options - Options
383
+ * @param {boolean} options.prompt - If true, prompt if not found
384
+ * @param {boolean} options.required - If true, task ID is required
385
+ * @returns {Promise<string|null>} - Resolved task ID
386
+ */
387
+ export const parseTaskIdArg = async (argTaskId, { prompt = true, required = false } = {}) => {
388
+ // If task ID provided as argument, use it
389
+ if (argTaskId) {
390
+ if (!validateTaskId(argTaskId)) {
391
+ throw new Error(`Invalid task ID format: ${argTaskId}`);
392
+ }
393
+ return argTaskId.toUpperCase();
394
+ }
395
+
396
+ // Otherwise, try to get or prompt
397
+ return await getOrPromptTaskId({ prompt, required });
398
+ };
399
+
400
+ /**
401
+ * Get supported task ID patterns info
402
+ * Why: For help messages and documentation
403
+ *
404
+ * @returns {Array<Object>} - Array of pattern info
405
+ */
406
+ export const getSupportedPatterns = () => {
407
+ return TASK_ID_PATTERNS.map(pattern => ({
408
+ name: pattern.name,
409
+ examples: getExamplesForPattern(pattern.name)
410
+ }));
411
+ };
412
+
413
+ /**
414
+ * Get example task IDs for a pattern
415
+ * @private
416
+ */
417
+ const getExamplesForPattern = (patternName) => {
418
+ const examples = {
419
+ 'Jira-style': ['IX-123', 'PROJ-456', 'ABC-789'],
420
+ 'GitHub issue': ['#123', 'GH-456'],
421
+ 'Linear': ['LIN-123', 'LIN-456'],
422
+ 'Generic': ['TASK-123', 'BUG-456', 'FEAT-789']
423
+ };
424
+ return examples[patternName] || [];
425
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-git-hooks",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "Git hooks with Claude CLI for code analysis and automatic commit messages",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,6 +42,9 @@
42
42
  "CHANGELOG.md",
43
43
  "LICENSE"
44
44
  ],
45
+ "dependencies": {
46
+ "@octokit/rest": "^21.0.0"
47
+ },
45
48
  "devDependencies": {
46
49
  "@types/jest": "^29.5.0",
47
50
  "eslint": "^8.57.0",
@@ -0,0 +1,32 @@
1
+ # GitHub Pull Request Creation
2
+
3
+ You have access to the GitHub MCP tool. Use the `create_pull_request` tool to create a pull request with the following parameters:
4
+
5
+ ## Repository Information
6
+ - **Repository**: {{OWNER}}/{{REPO}}
7
+ - **Base Branch**: {{BASE}}
8
+ - **Head Branch**: {{HEAD}}
9
+
10
+ ## Pull Request Details
11
+ - **Title**: {{TITLE}}
12
+ - **Draft**: {{DRAFT}}
13
+
14
+ ## Description
15
+ {{BODY}}
16
+
17
+ ## Additional Metadata
18
+ {{#HAS_LABELS}}- **Labels**: {{LABELS}}{{/HAS_LABELS}}
19
+ {{#HAS_REVIEWERS}}- **Reviewers**: {{REVIEWERS}}{{/HAS_REVIEWERS}}
20
+
21
+ ---
22
+
23
+ **Instructions:**
24
+ 1. Use the `create_pull_request` MCP tool with the exact parameters above
25
+ 2. If labels or reviewers are specified, include them in the request
26
+ 3. Return ONLY the PR URL in your response (format: https://github.com/owner/repo/pull/123)
27
+ 4. If the PR creation fails, explain the error clearly
28
+
29
+ **IMPORTANT**: Return ONLY the PR URL, nothing else. Example response:
30
+ ```
31
+ https://github.com/{{OWNER}}/{{REPO}}/pull/456
32
+ ```