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.
- package/CHANGELOG.md +262 -135
- package/README.md +158 -67
- package/bin/claude-hooks +452 -10
- package/lib/config.js +29 -0
- package/lib/hooks/pre-commit.js +2 -6
- package/lib/hooks/prepare-commit-msg.js +27 -4
- package/lib/utils/claude-client.js +148 -16
- package/lib/utils/file-operations.js +0 -102
- package/lib/utils/github-api.js +641 -0
- package/lib/utils/github-client.js +770 -0
- package/lib/utils/interactive-ui.js +314 -0
- package/lib/utils/mcp-setup.js +342 -0
- package/lib/utils/sanitize.js +180 -0
- package/lib/utils/task-id.js +425 -0
- package/package.json +4 -1
- package/templates/CREATE_GITHUB_PR.md +32 -0
- package/templates/config.example.json +41 -41
- package/templates/config.github.example.json +51 -0
- package/templates/presets/ai/PRE_COMMIT_GUIDELINES.md +18 -1
- package/templates/presets/ai/config.json +12 -12
- package/templates/presets/ai/preset.json +37 -42
- package/templates/presets/backend/ANALYSIS_PROMPT.md +23 -28
- package/templates/presets/backend/PRE_COMMIT_GUIDELINES.md +41 -3
- package/templates/presets/backend/config.json +12 -12
- package/templates/presets/database/config.json +12 -12
- package/templates/presets/default/config.json +12 -12
- package/templates/presets/frontend/config.json +12 -12
- package/templates/presets/fullstack/config.json +12 -12
- package/templates/settings.local.example.json +4 -0
|
@@ -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.
|
|
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
|
+
```
|