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.
@@ -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