claude-git-hooks 2.4.1 â 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 +250 -150
- package/README.md +126 -40
- package/bin/claude-hooks +436 -2
- 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 +108 -5
- 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.github.example.json +51 -0
- package/templates/presets/ai/PRE_COMMIT_GUIDELINES.md +18 -1
- package/templates/presets/ai/preset.json +37 -37
- package/templates/settings.local.example.json +4 -0
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: github-client.js
|
|
3
|
+
* Purpose: GitHub MCP client wrapper for PR creation and management
|
|
4
|
+
*
|
|
5
|
+
* MCP Tools used:
|
|
6
|
+
* - create_pull_request: Create PRs with full metadata
|
|
7
|
+
* - get_file_contents: Read CODEOWNERS, package.json, etc.
|
|
8
|
+
* - search_issues: Find related issues
|
|
9
|
+
* - create_issue: Create issues from blocking problems
|
|
10
|
+
*
|
|
11
|
+
* Graceful degradation:
|
|
12
|
+
* - If MCP not configured, provides helpful error messages
|
|
13
|
+
* - Validates inputs before MCP calls
|
|
14
|
+
* - Includes rate limiting awareness
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { execSync } from 'child_process';
|
|
18
|
+
import logger from './logger.js';
|
|
19
|
+
import { getRepoName, getCurrentBranch } from './git-operations.js';
|
|
20
|
+
import { executeClaude, executeClaudeInteractive, getClaudeCommand } from './claude-client.js';
|
|
21
|
+
import { loadPrompt } from './prompt-builder.js';
|
|
22
|
+
import { getConfig } from '../config.js';
|
|
23
|
+
import {
|
|
24
|
+
sanitizePRTitle,
|
|
25
|
+
sanitizePRBody,
|
|
26
|
+
sanitizeStringArray,
|
|
27
|
+
sanitizeBranchName
|
|
28
|
+
} from './sanitize.js';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Check if GitHub MCP is available
|
|
32
|
+
* Why: Fail fast with helpful message if MCP not configured
|
|
33
|
+
*
|
|
34
|
+
* @returns {boolean} - True if MCP available
|
|
35
|
+
*/
|
|
36
|
+
export const isGitHubMCPAvailable = () => {
|
|
37
|
+
try {
|
|
38
|
+
// Get the correct Claude command (handles WSL on Windows)
|
|
39
|
+
const { command, args } = getClaudeCommand();
|
|
40
|
+
|
|
41
|
+
// Build the full command for mcp list
|
|
42
|
+
const fullCommand = command === 'wsl'
|
|
43
|
+
? `wsl ${args.join(' ')} mcp list`
|
|
44
|
+
: `${command} ${args.join(' ')} mcp list`.trim();
|
|
45
|
+
|
|
46
|
+
logger.debug('github-client - isGitHubMCPAvailable', 'Checking MCP', { fullCommand });
|
|
47
|
+
|
|
48
|
+
const mcpList = execSync(fullCommand, {
|
|
49
|
+
encoding: 'utf8',
|
|
50
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
51
|
+
timeout: 10000
|
|
52
|
+
});
|
|
53
|
+
const hasGitHub = mcpList.toLowerCase().includes('github');
|
|
54
|
+
|
|
55
|
+
logger.debug('github-client - isGitHubMCPAvailable', 'MCP availability check', { hasGitHub });
|
|
56
|
+
return hasGitHub;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
logger.debug('github-client - isGitHubMCPAvailable', 'MCP check failed', { error: error.message });
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Custom error for GitHub MCP operations
|
|
65
|
+
*/
|
|
66
|
+
export class GitHubMCPError extends Error {
|
|
67
|
+
constructor(message, { cause, context } = {}) {
|
|
68
|
+
super(message);
|
|
69
|
+
this.name = 'GitHubMCPError';
|
|
70
|
+
this.cause = cause;
|
|
71
|
+
this.context = context;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Parse repository from git remote URL
|
|
77
|
+
* Why: Extract owner/repo from various git URL formats
|
|
78
|
+
*
|
|
79
|
+
* @returns {Object} - { owner, repo, fullName }
|
|
80
|
+
*
|
|
81
|
+
* Supports formats:
|
|
82
|
+
* - https://github.com/owner/repo.git
|
|
83
|
+
* - git@github.com:owner/repo.git
|
|
84
|
+
* - https://github.com/owner/repo
|
|
85
|
+
*/
|
|
86
|
+
export const parseGitHubRepo = () => {
|
|
87
|
+
try {
|
|
88
|
+
const remoteUrl = execSync('git config --get remote.origin.url', {
|
|
89
|
+
encoding: 'utf8'
|
|
90
|
+
}).trim();
|
|
91
|
+
|
|
92
|
+
logger.debug('github-client - parseGitHubRepo', 'Parsing remote URL', { remoteUrl });
|
|
93
|
+
|
|
94
|
+
// Match various GitHub URL formats
|
|
95
|
+
const httpsMatch = remoteUrl.match(/github\.com[/:]([\w-]+)\/([\w-]+?)(\.git)?$/);
|
|
96
|
+
|
|
97
|
+
if (httpsMatch) {
|
|
98
|
+
const owner = httpsMatch[1];
|
|
99
|
+
const repo = httpsMatch[2];
|
|
100
|
+
|
|
101
|
+
logger.debug('github-client - parseGitHubRepo', 'Parsed repository', { owner, repo });
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
owner,
|
|
105
|
+
repo,
|
|
106
|
+
fullName: `${owner}/${repo}`
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
throw new GitHubMCPError('Could not parse GitHub repository from remote URL', {
|
|
111
|
+
context: { remoteUrl }
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (error instanceof GitHubMCPError) {
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
throw new GitHubMCPError('Failed to get git remote URL', {
|
|
120
|
+
cause: error,
|
|
121
|
+
context: { command: 'git config --get remote.origin.url' }
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Create a Pull Request on GitHub
|
|
128
|
+
* Why: Automate PR creation from command line
|
|
129
|
+
*
|
|
130
|
+
* @param {Object} options - PR options
|
|
131
|
+
* @param {string} options.title - PR title (required)
|
|
132
|
+
* @param {string} options.body - PR description (required)
|
|
133
|
+
* @param {string} options.head - Head branch (default: current branch)
|
|
134
|
+
* @param {string} options.base - Base branch (default: 'develop')
|
|
135
|
+
* @param {boolean} options.draft - Create as draft PR (default: false)
|
|
136
|
+
* @param {Array<string>} options.labels - Labels to add
|
|
137
|
+
* @param {Array<string>} options.reviewers - Reviewers to request
|
|
138
|
+
* @returns {Promise<Object>} - PR data { number, url, html_url }
|
|
139
|
+
*/
|
|
140
|
+
export const createPullRequest = async ({
|
|
141
|
+
title,
|
|
142
|
+
body,
|
|
143
|
+
head = null,
|
|
144
|
+
base = 'develop',
|
|
145
|
+
draft = false,
|
|
146
|
+
labels = [],
|
|
147
|
+
reviewers = []
|
|
148
|
+
}) => {
|
|
149
|
+
logger.debug('github-client - createPullRequest', 'Creating PR', {
|
|
150
|
+
title,
|
|
151
|
+
head,
|
|
152
|
+
base,
|
|
153
|
+
draft,
|
|
154
|
+
labelsCount: labels.length,
|
|
155
|
+
reviewersCount: reviewers.length
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Validate required fields
|
|
159
|
+
if (!title || typeof title !== 'string') {
|
|
160
|
+
throw new GitHubMCPError('PR title is required');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!body || typeof body !== 'string') {
|
|
164
|
+
throw new GitHubMCPError('PR body is required');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Check MCP availability
|
|
168
|
+
if (!isGitHubMCPAvailable()) {
|
|
169
|
+
throw new GitHubMCPError(
|
|
170
|
+
'GitHub MCP is not configured. Run: claude mcp add github',
|
|
171
|
+
{ context: { suggestion: 'Setup MCP with: cd /mnt/c/GITHUB/mscope-mcp-script && ./setup-all-mcps.sh' } }
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Get repository info
|
|
176
|
+
const repo = parseGitHubRepo();
|
|
177
|
+
|
|
178
|
+
// Get head branch (current branch if not specified)
|
|
179
|
+
const headBranch = head || getCurrentBranch();
|
|
180
|
+
|
|
181
|
+
if (!headBranch) {
|
|
182
|
+
throw new GitHubMCPError('Could not determine head branch for PR');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
logger.info(`Creating PR: ${title}`);
|
|
186
|
+
logger.info(` From: ${headBranch} â To: ${base}`);
|
|
187
|
+
logger.info(` Repository: ${repo.fullName}`);
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
// Load configuration
|
|
191
|
+
const config = await getConfig();
|
|
192
|
+
|
|
193
|
+
// Sanitize inputs to prevent prompt injection
|
|
194
|
+
const sanitizedTitle = sanitizePRTitle(title);
|
|
195
|
+
const sanitizedBody = sanitizePRBody(body);
|
|
196
|
+
const sanitizedHead = sanitizeBranchName(headBranch) || headBranch;
|
|
197
|
+
const sanitizedBase = sanitizeBranchName(base) || base;
|
|
198
|
+
const sanitizedLabels = sanitizeStringArray(labels);
|
|
199
|
+
const sanitizedReviewers = sanitizeStringArray(reviewers);
|
|
200
|
+
|
|
201
|
+
logger.debug('github-client - createPullRequest', 'Inputs sanitized', {
|
|
202
|
+
titleLength: sanitizedTitle.length,
|
|
203
|
+
bodyLength: sanitizedBody.length
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Build prompt from template with sanitized values
|
|
207
|
+
const promptVariables = {
|
|
208
|
+
OWNER: repo.owner,
|
|
209
|
+
REPO: repo.repo,
|
|
210
|
+
TITLE: sanitizedTitle,
|
|
211
|
+
BODY: sanitizedBody,
|
|
212
|
+
HEAD: sanitizedHead,
|
|
213
|
+
BASE: sanitizedBase,
|
|
214
|
+
DRAFT: draft.toString(),
|
|
215
|
+
HAS_LABELS: sanitizedLabels.length > 0 ? 'true' : '',
|
|
216
|
+
LABELS: sanitizedLabels.join(', '),
|
|
217
|
+
HAS_REVIEWERS: sanitizedReviewers.length > 0 ? 'true' : '',
|
|
218
|
+
REVIEWERS: sanitizedReviewers.join(', ')
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
logger.debug('github-client - createPullRequest', 'Loading PR creation prompt', {
|
|
222
|
+
repo: repo.fullName,
|
|
223
|
+
template: config.templates.createGithubPR
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const prompt = await loadPrompt(config.templates.createGithubPR, promptVariables);
|
|
227
|
+
|
|
228
|
+
logger.debug('github-client - createPullRequest', 'Calling Claude interactively with GitHub MCP', {
|
|
229
|
+
repo: repo.fullName,
|
|
230
|
+
head: headBranch,
|
|
231
|
+
base,
|
|
232
|
+
promptLength: prompt.length
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// Execute Claude interactively so user can approve MCP permissions if needed
|
|
236
|
+
const response = await executeClaudeInteractive(prompt, {
|
|
237
|
+
timeout: 300000 // 5 minutes for interactive session
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// In interactive mode, we can't capture the response
|
|
241
|
+
// The user sees the PR URL directly in the terminal
|
|
242
|
+
if (response === 'interactive-session-completed') {
|
|
243
|
+
logger.info('Interactive Claude session completed');
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
number: null,
|
|
247
|
+
url: null,
|
|
248
|
+
html_url: null,
|
|
249
|
+
interactive: true,
|
|
250
|
+
message: 'PR creation completed in interactive mode. Check the output above for the PR URL.'
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
logger.debug('github-client - createPullRequest', 'Claude response received', {
|
|
255
|
+
responseLength: response.length
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// Extract PR URL from response (for non-interactive mode fallback)
|
|
259
|
+
const urlMatch = response.match(/https:\/\/github\.com\/[^\/]+\/[^\/]+\/pull\/\d+/);
|
|
260
|
+
|
|
261
|
+
if (urlMatch) {
|
|
262
|
+
const prUrl = urlMatch[0];
|
|
263
|
+
const prNumber = prUrl.split('/').pop();
|
|
264
|
+
|
|
265
|
+
logger.info(`â
Pull request created: ${prUrl}`);
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
number: parseInt(prNumber),
|
|
269
|
+
url: prUrl,
|
|
270
|
+
html_url: prUrl
|
|
271
|
+
};
|
|
272
|
+
} else {
|
|
273
|
+
// Claude's response didn't contain a PR URL - might have failed
|
|
274
|
+
logger.warning('github-client - createPullRequest', 'No PR URL in response', { response });
|
|
275
|
+
|
|
276
|
+
throw new GitHubMCPError(
|
|
277
|
+
'Pull request creation failed - no PR URL returned. Claude response: ' + response.substring(0, 200),
|
|
278
|
+
{ context: { response, title, head: headBranch, base } }
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
} catch (error) {
|
|
283
|
+
if (error instanceof GitHubMCPError) {
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
throw new GitHubMCPError('Failed to create pull request', {
|
|
288
|
+
cause: error,
|
|
289
|
+
context: { title, head: headBranch, base }
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Get repository information
|
|
296
|
+
* Why: Fetch repo metadata for PR creation
|
|
297
|
+
*
|
|
298
|
+
* @returns {Promise<Object>} - Repository data
|
|
299
|
+
*/
|
|
300
|
+
export const getRepositoryInfo = async () => {
|
|
301
|
+
logger.debug('github-client - getRepositoryInfo', 'Fetching repository info');
|
|
302
|
+
|
|
303
|
+
if (!isGitHubMCPAvailable()) {
|
|
304
|
+
throw new GitHubMCPError('GitHub MCP is not configured');
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const repo = parseGitHubRepo();
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
// TODO: Replace with actual MCP call
|
|
311
|
+
// const repoInfo = await mcp.github.get_repository({
|
|
312
|
+
// owner: repo.owner,
|
|
313
|
+
// repo: repo.repo
|
|
314
|
+
// });
|
|
315
|
+
|
|
316
|
+
// For now, return local git info
|
|
317
|
+
return {
|
|
318
|
+
owner: repo.owner,
|
|
319
|
+
name: repo.repo,
|
|
320
|
+
fullName: repo.fullName,
|
|
321
|
+
defaultBranch: 'develop' // Would come from GitHub API
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
} catch (error) {
|
|
325
|
+
throw new GitHubMCPError('Failed to fetch repository info', {
|
|
326
|
+
cause: error,
|
|
327
|
+
context: { repo }
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Search for issues in repository
|
|
334
|
+
* Why: Find related issues to link in PR
|
|
335
|
+
*
|
|
336
|
+
* @param {string} query - Search query
|
|
337
|
+
* @param {Object} options - Search options
|
|
338
|
+
* @param {number} options.limit - Max results (default: 5)
|
|
339
|
+
* @returns {Promise<Array<Object>>} - Array of issues
|
|
340
|
+
*/
|
|
341
|
+
export const searchIssues = async (query, { limit = 5 } = {}) => {
|
|
342
|
+
logger.debug('github-client - searchIssues', 'Searching issues', { query, limit });
|
|
343
|
+
|
|
344
|
+
if (!isGitHubMCPAvailable()) {
|
|
345
|
+
throw new GitHubMCPError('GitHub MCP is not configured');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const repo = parseGitHubRepo();
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
// TODO: Replace with actual MCP call
|
|
352
|
+
// const results = await mcp.github.search_issues({
|
|
353
|
+
// query: `${query} repo:${repo.fullName}`,
|
|
354
|
+
// limit
|
|
355
|
+
// });
|
|
356
|
+
|
|
357
|
+
logger.debug('github-client - searchIssues', 'Would search issues', { query, repo: repo.fullName });
|
|
358
|
+
|
|
359
|
+
return []; // No results for now
|
|
360
|
+
|
|
361
|
+
} catch (error) {
|
|
362
|
+
throw new GitHubMCPError('Failed to search issues', {
|
|
363
|
+
cause: error,
|
|
364
|
+
context: { query, repo }
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Read CODEOWNERS file from repository
|
|
371
|
+
* Why: Auto-detect reviewers based on file changes
|
|
372
|
+
*
|
|
373
|
+
* @returns {Promise<string|null>} - CODEOWNERS content or null if not found
|
|
374
|
+
*/
|
|
375
|
+
export const readCodeowners = async () => {
|
|
376
|
+
logger.debug('github-client - readCodeowners', 'Reading CODEOWNERS file');
|
|
377
|
+
|
|
378
|
+
const repo = parseGitHubRepo();
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
// Use Octokit-based implementation from github-api.js
|
|
382
|
+
const { readCodeowners: readCodeownersOctokit } = await import('./github-api.js');
|
|
383
|
+
const content = await readCodeownersOctokit({
|
|
384
|
+
owner: repo.owner,
|
|
385
|
+
repo: repo.repo
|
|
386
|
+
});
|
|
387
|
+
return content;
|
|
388
|
+
} catch (error) {
|
|
389
|
+
logger.debug('github-client - readCodeowners', 'Could not read CODEOWNERS', {
|
|
390
|
+
error: error.message
|
|
391
|
+
});
|
|
392
|
+
return null; // Non-critical failure
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Parse CODEOWNERS file to get reviewers for files
|
|
398
|
+
* Why: Extract reviewer info from CODEOWNERS content
|
|
399
|
+
*
|
|
400
|
+
* @param {string} codeownersContent - CODEOWNERS file content
|
|
401
|
+
* @param {Array<string>} files - Files changed in PR
|
|
402
|
+
* @returns {Array<string>} - GitHub usernames of reviewers
|
|
403
|
+
*/
|
|
404
|
+
export const parseCodeownersReviewers = (codeownersContent, files = []) => {
|
|
405
|
+
if (!codeownersContent) {
|
|
406
|
+
return [];
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
logger.debug('github-client - parseCodeownersReviewers', 'Parsing CODEOWNERS', {
|
|
410
|
+
filesCount: files.length
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
const reviewers = new Set();
|
|
414
|
+
const lines = codeownersContent.split('\n');
|
|
415
|
+
|
|
416
|
+
// Parse CODEOWNERS format:
|
|
417
|
+
// pattern @username1 @username2
|
|
418
|
+
// Example: *.js @frontend-team @tech-lead
|
|
419
|
+
|
|
420
|
+
for (const line of lines) {
|
|
421
|
+
const trimmed = line.trim();
|
|
422
|
+
|
|
423
|
+
// Skip comments and empty lines
|
|
424
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const parts = trimmed.split(/\s+/);
|
|
429
|
+
if (parts.length < 2) {
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const pattern = parts[0];
|
|
434
|
+
const owners = parts.slice(1).filter(o => o.startsWith('@'));
|
|
435
|
+
|
|
436
|
+
// Check if any file matches this pattern
|
|
437
|
+
const patternRegex = new RegExp(
|
|
438
|
+
pattern
|
|
439
|
+
.replace(/\./g, '\\.')
|
|
440
|
+
.replace(/\*/g, '.*')
|
|
441
|
+
.replace(/\?/g, '.')
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
for (const file of files) {
|
|
445
|
+
if (patternRegex.test(file)) {
|
|
446
|
+
owners.forEach(owner => reviewers.add(owner.substring(1))); // Remove @
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const reviewersList = Array.from(reviewers);
|
|
452
|
+
logger.debug('github-client - parseCodeownersReviewers', 'Found reviewers', {
|
|
453
|
+
reviewers: reviewersList
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
return reviewersList;
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Get reviewers for PR based on changed files
|
|
461
|
+
* Why: Auto-detect appropriate reviewers
|
|
462
|
+
*
|
|
463
|
+
* @param {Array<string>} files - Files changed in PR
|
|
464
|
+
* @param {Object} config - GitHub config from .claude/config.json
|
|
465
|
+
* @returns {Promise<Array<string>>} - Reviewer usernames
|
|
466
|
+
*/
|
|
467
|
+
export const getReviewersForFiles = async (files = [], config = {}) => {
|
|
468
|
+
logger.debug('github-client - getReviewersForFiles', 'Getting reviewers', {
|
|
469
|
+
filesCount: files.length
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
const reviewers = new Set();
|
|
473
|
+
|
|
474
|
+
// Option 1: Try CODEOWNERS file
|
|
475
|
+
try {
|
|
476
|
+
const codeowners = await readCodeowners();
|
|
477
|
+
if (codeowners) {
|
|
478
|
+
const codeownersReviewers = parseCodeownersReviewers(codeowners, files);
|
|
479
|
+
codeownersReviewers.forEach(r => reviewers.add(r));
|
|
480
|
+
}
|
|
481
|
+
} catch (error) {
|
|
482
|
+
logger.debug('github-client - getReviewersForFiles', 'Could not read CODEOWNERS', {
|
|
483
|
+
error: error.message
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Option 2: Use config-based reviewers
|
|
488
|
+
if (config.reviewers) {
|
|
489
|
+
const configReviewers = Array.isArray(config.reviewers)
|
|
490
|
+
? config.reviewers
|
|
491
|
+
: Object.values(config.reviewers).flat();
|
|
492
|
+
|
|
493
|
+
configReviewers.forEach(r => reviewers.add(r));
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Option 3: Use reviewer rules (pattern-based)
|
|
497
|
+
if (config.reviewerRules && Array.isArray(config.reviewerRules)) {
|
|
498
|
+
for (const rule of config.reviewerRules) {
|
|
499
|
+
const patternRegex = new RegExp(rule.pattern);
|
|
500
|
+
const hasMatch = files.some(file => patternRegex.test(file));
|
|
501
|
+
|
|
502
|
+
if (hasMatch && rule.reviewers) {
|
|
503
|
+
rule.reviewers.forEach(r => reviewers.add(r));
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const reviewersList = Array.from(reviewers);
|
|
509
|
+
|
|
510
|
+
logger.info(`đ Found ${reviewersList.length} reviewer(s): ${reviewersList.join(', ') || 'none'}`);
|
|
511
|
+
|
|
512
|
+
return reviewersList;
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Validate GitHub configuration
|
|
517
|
+
* Why: Check that required config is present before operations
|
|
518
|
+
*
|
|
519
|
+
* @param {Object} config - GitHub config
|
|
520
|
+
* @returns {Object} - Validation result { valid, errors }
|
|
521
|
+
*/
|
|
522
|
+
export const validateGitHubConfig = (config = {}) => {
|
|
523
|
+
const errors = [];
|
|
524
|
+
|
|
525
|
+
if (!config.pr) {
|
|
526
|
+
errors.push('Missing github.pr configuration');
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (config.pr && !config.pr.defaultBase) {
|
|
530
|
+
errors.push('Missing github.pr.defaultBase (e.g., "develop")');
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return {
|
|
534
|
+
valid: errors.length === 0,
|
|
535
|
+
errors
|
|
536
|
+
};
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Execute a Claude MCP command with proper platform handling
|
|
541
|
+
* Why: Centralizes WSL vs native command execution for MCP operations
|
|
542
|
+
*
|
|
543
|
+
* @param {string} mcpCommand - The MCP subcommand (e.g., 'list', 'add github')
|
|
544
|
+
* @param {Object} options - Execution options
|
|
545
|
+
* @param {boolean} options.silent - Suppress output (default: false)
|
|
546
|
+
* @param {number} options.timeout - Command timeout in ms (default: 30000)
|
|
547
|
+
* @returns {Object} - { success, output, error }
|
|
548
|
+
*/
|
|
549
|
+
export const executeMcpCommand = (mcpCommand, { silent = false, timeout = 30000 } = {}) => {
|
|
550
|
+
try {
|
|
551
|
+
const { command, args } = getClaudeCommand();
|
|
552
|
+
|
|
553
|
+
// Build the full command
|
|
554
|
+
const fullCommand = command === 'wsl'
|
|
555
|
+
? `wsl ${args.join(' ')} mcp ${mcpCommand}`
|
|
556
|
+
: `${command} ${args.join(' ')} mcp ${mcpCommand}`.trim();
|
|
557
|
+
|
|
558
|
+
logger.debug('github-client - executeMcpCommand', 'Executing MCP command', { fullCommand });
|
|
559
|
+
|
|
560
|
+
const output = execSync(fullCommand, {
|
|
561
|
+
encoding: 'utf8',
|
|
562
|
+
stdio: silent ? ['pipe', 'pipe', 'pipe'] : ['inherit', 'pipe', 'pipe'],
|
|
563
|
+
timeout
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
return { success: true, output: output?.trim() || '', error: null };
|
|
567
|
+
} catch (error) {
|
|
568
|
+
logger.debug('github-client - executeMcpCommand', 'MCP command failed', {
|
|
569
|
+
mcpCommand,
|
|
570
|
+
error: error.message
|
|
571
|
+
});
|
|
572
|
+
return { success: false, output: '', error: error.message };
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Setup GitHub MCP for Claude CLI
|
|
578
|
+
* Why: Automate MCP installation and permission approval
|
|
579
|
+
*
|
|
580
|
+
* Steps:
|
|
581
|
+
* 1. Check if GitHub MCP is already installed
|
|
582
|
+
* 2. If not, add it with 'claude mcp add github'
|
|
583
|
+
* 3. Approve necessary permissions
|
|
584
|
+
*
|
|
585
|
+
* @param {Object} options - Setup options
|
|
586
|
+
* @param {boolean} options.force - Force reinstall even if already installed
|
|
587
|
+
* @param {boolean} options.interactive - Show prompts (default: true)
|
|
588
|
+
* @returns {Promise<Object>} - { success, message, details }
|
|
589
|
+
*/
|
|
590
|
+
export const setupGitHubMcp = async ({ force = false, interactive = true } = {}) => {
|
|
591
|
+
logger.info('đ§ Setting up GitHub MCP for Claude CLI...');
|
|
592
|
+
|
|
593
|
+
const results = {
|
|
594
|
+
alreadyInstalled: false,
|
|
595
|
+
installed: false,
|
|
596
|
+
permissionsApproved: false,
|
|
597
|
+
errors: []
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
try {
|
|
601
|
+
// Step 1: Check if already installed
|
|
602
|
+
const isInstalled = isGitHubMCPAvailable();
|
|
603
|
+
|
|
604
|
+
if (isInstalled && !force) {
|
|
605
|
+
logger.info('â
GitHub MCP is already installed');
|
|
606
|
+
results.alreadyInstalled = true;
|
|
607
|
+
|
|
608
|
+
// Still try to approve permissions
|
|
609
|
+
const permResult = await approveGitHubMcpPermissions();
|
|
610
|
+
results.permissionsApproved = permResult.success;
|
|
611
|
+
|
|
612
|
+
return {
|
|
613
|
+
success: true,
|
|
614
|
+
message: 'GitHub MCP already configured',
|
|
615
|
+
details: results
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Step 2: Add GitHub MCP
|
|
620
|
+
logger.info('đĻ Adding GitHub MCP server...');
|
|
621
|
+
|
|
622
|
+
const addResult = executeMcpCommand('add github', { silent: false, timeout: 60000 });
|
|
623
|
+
|
|
624
|
+
if (!addResult.success) {
|
|
625
|
+
// Check if it failed because it's already added
|
|
626
|
+
if (addResult.error?.includes('already exists') || addResult.error?.includes('already added')) {
|
|
627
|
+
logger.info('âšī¸ GitHub MCP was already added');
|
|
628
|
+
results.installed = true;
|
|
629
|
+
} else {
|
|
630
|
+
results.errors.push(`Failed to add GitHub MCP: ${addResult.error}`);
|
|
631
|
+
logger.error('â Failed to add GitHub MCP:', addResult.error);
|
|
632
|
+
}
|
|
633
|
+
} else {
|
|
634
|
+
logger.info('â
GitHub MCP server added successfully');
|
|
635
|
+
results.installed = true;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Step 3: Approve permissions
|
|
639
|
+
if (results.installed || results.alreadyInstalled) {
|
|
640
|
+
const permResult = await approveGitHubMcpPermissions();
|
|
641
|
+
results.permissionsApproved = permResult.success;
|
|
642
|
+
|
|
643
|
+
if (!permResult.success) {
|
|
644
|
+
results.errors.push(...permResult.errors);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Final status
|
|
649
|
+
const success = (results.installed || results.alreadyInstalled) && results.permissionsApproved;
|
|
650
|
+
|
|
651
|
+
return {
|
|
652
|
+
success,
|
|
653
|
+
message: success
|
|
654
|
+
? 'â
GitHub MCP setup complete!'
|
|
655
|
+
: `â ī¸ Setup completed with issues: ${results.errors.join(', ')}`,
|
|
656
|
+
details: results
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
} catch (error) {
|
|
660
|
+
logger.error('â GitHub MCP setup failed:', error.message);
|
|
661
|
+
results.errors.push(error.message);
|
|
662
|
+
|
|
663
|
+
return {
|
|
664
|
+
success: false,
|
|
665
|
+
message: `Setup failed: ${error.message}`,
|
|
666
|
+
details: results
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Approve GitHub MCP permissions for PR creation
|
|
673
|
+
* Why: MCP tools need explicit permission approval
|
|
674
|
+
*
|
|
675
|
+
* @returns {Promise<Object>} - { success, errors }
|
|
676
|
+
*/
|
|
677
|
+
export const approveGitHubMcpPermissions = async () => {
|
|
678
|
+
logger.info('đ Approving GitHub MCP permissions...');
|
|
679
|
+
|
|
680
|
+
const errors = [];
|
|
681
|
+
|
|
682
|
+
// Permissions needed for PR creation workflow
|
|
683
|
+
const permissions = [
|
|
684
|
+
'create_pull_request',
|
|
685
|
+
'get_file_contents',
|
|
686
|
+
'list_commits',
|
|
687
|
+
'search_repositories'
|
|
688
|
+
];
|
|
689
|
+
|
|
690
|
+
// Try to approve all permissions at once first
|
|
691
|
+
logger.debug('github-client - approveGitHubMcpPermissions', 'Trying bulk approval');
|
|
692
|
+
|
|
693
|
+
const bulkResult = executeMcpCommand('approve github --all', { silent: true, timeout: 30000 });
|
|
694
|
+
|
|
695
|
+
if (bulkResult.success) {
|
|
696
|
+
logger.info('â
All GitHub MCP permissions approved');
|
|
697
|
+
return { success: true, errors: [] };
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// If bulk approval fails, try individual permissions
|
|
701
|
+
logger.debug('github-client - approveGitHubMcpPermissions', 'Bulk failed, trying individual', {
|
|
702
|
+
error: bulkResult.error
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
let approvedCount = 0;
|
|
706
|
+
|
|
707
|
+
for (const permission of permissions) {
|
|
708
|
+
const result = executeMcpCommand(`approve github ${permission}`, { silent: true, timeout: 15000 });
|
|
709
|
+
|
|
710
|
+
if (result.success) {
|
|
711
|
+
logger.debug('github-client - approveGitHubMcpPermissions', `Approved: ${permission}`);
|
|
712
|
+
approvedCount++;
|
|
713
|
+
} else {
|
|
714
|
+
// Don't treat as error - permission might already be approved or not exist
|
|
715
|
+
logger.debug('github-client - approveGitHubMcpPermissions', `Could not approve: ${permission}`, {
|
|
716
|
+
error: result.error
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Consider success if at least create_pull_request was approved or no errors
|
|
722
|
+
const success = approvedCount > 0 || errors.length === 0;
|
|
723
|
+
|
|
724
|
+
if (success) {
|
|
725
|
+
logger.info(`â
GitHub MCP permissions configured (${approvedCount}/${permissions.length})`);
|
|
726
|
+
} else {
|
|
727
|
+
logger.warning('â ī¸ Could not approve some permissions - you may need to approve manually');
|
|
728
|
+
logger.info(' Run: claude mcp approve github --all');
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
return { success, errors };
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Get GitHub MCP status
|
|
736
|
+
* Why: Diagnostic information for troubleshooting
|
|
737
|
+
*
|
|
738
|
+
* @returns {Object} - Status information
|
|
739
|
+
*/
|
|
740
|
+
export const getGitHubMcpStatus = () => {
|
|
741
|
+
const status = {
|
|
742
|
+
installed: false,
|
|
743
|
+
command: null,
|
|
744
|
+
platform: process.platform,
|
|
745
|
+
usingWsl: false
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
try {
|
|
749
|
+
const { command, args } = getClaudeCommand();
|
|
750
|
+
status.command = command === 'wsl' ? `wsl ${args.join(' ')}` : command;
|
|
751
|
+
status.usingWsl = command === 'wsl';
|
|
752
|
+
|
|
753
|
+
status.installed = isGitHubMCPAvailable();
|
|
754
|
+
|
|
755
|
+
// Get MCP list for details
|
|
756
|
+
const listResult = executeMcpCommand('list', { silent: true });
|
|
757
|
+
if (listResult.success) {
|
|
758
|
+
status.mcpList = listResult.output;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
} catch (error) {
|
|
762
|
+
status.error = error.message;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
return status;
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
// Note: createPullRequest function has been moved to github-api.js
|
|
769
|
+
// The new implementation uses Octokit directly instead of MCP for reliable PR creation
|
|
770
|
+
// Import from '../utils/github-api.js' for PR operations
|