UNPKG

claude-git-hooks

Version:

Git hooks with Claude CLI for code analysis and automatic commit messages

771 lines (646 loc) â€ĸ 25 kB
/** * File: github-client.js * Purpose: GitHub MCP client wrapper for PR creation and management * * MCP Tools used: * - create_pull_request: Create PRs with full metadata * - get_file_contents: Read CODEOWNERS, package.json, etc. * - search_issues: Find related issues * - create_issue: Create issues from blocking problems * * Graceful degradation: * - If MCP not configured, provides helpful error messages * - Validates inputs before MCP calls * - Includes rate limiting awareness */ import { execSync } from 'child_process'; import logger from './logger.js'; import { getRepoName, getCurrentBranch } from './git-operations.js'; import { executeClaude, executeClaudeInteractive, getClaudeCommand } from './claude-client.js'; import { loadPrompt } from './prompt-builder.js'; import { getConfig } from '../config.js'; import { sanitizePRTitle, sanitizePRBody, sanitizeStringArray, sanitizeBranchName } from './sanitize.js'; /** * Check if GitHub MCP is available * Why: Fail fast with helpful message if MCP not configured * * @returns {boolean} - True if MCP available */ export const isGitHubMCPAvailable = () => { try { // Get the correct Claude command (handles WSL on Windows) const { command, args } = getClaudeCommand(); // Build the full command for mcp list const fullCommand = command === 'wsl' ? `wsl ${args.join(' ')} mcp list` : `${command} ${args.join(' ')} mcp list`.trim(); logger.debug('github-client - isGitHubMCPAvailable', 'Checking MCP', { fullCommand }); const mcpList = execSync(fullCommand, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'], timeout: 10000 }); const hasGitHub = mcpList.toLowerCase().includes('github'); logger.debug('github-client - isGitHubMCPAvailable', 'MCP availability check', { hasGitHub }); return hasGitHub; } catch (error) { logger.debug('github-client - isGitHubMCPAvailable', 'MCP check failed', { error: error.message }); return false; } }; /** * Custom error for GitHub MCP operations */ export class GitHubMCPError extends Error { constructor(message, { cause, context } = {}) { super(message); this.name = 'GitHubMCPError'; this.cause = cause; this.context = context; } } /** * Parse repository from git remote URL * Why: Extract owner/repo from various git URL formats * * @returns {Object} - { owner, repo, fullName } * * Supports formats: * - https://github.com/owner/repo.git * - git@github.com:owner/repo.git * - https://github.com/owner/repo */ export const parseGitHubRepo = () => { try { const remoteUrl = execSync('git config --get remote.origin.url', { encoding: 'utf8' }).trim(); logger.debug('github-client - parseGitHubRepo', 'Parsing remote URL', { remoteUrl }); // Match various GitHub URL formats const httpsMatch = remoteUrl.match(/github\.com[/:]([\w-]+)\/([\w-]+?)(\.git)?$/); if (httpsMatch) { const owner = httpsMatch[1]; const repo = httpsMatch[2]; logger.debug('github-client - parseGitHubRepo', 'Parsed repository', { owner, repo }); return { owner, repo, fullName: `${owner}/${repo}` }; } throw new GitHubMCPError('Could not parse GitHub repository from remote URL', { context: { remoteUrl } }); } catch (error) { if (error instanceof GitHubMCPError) { throw error; } throw new GitHubMCPError('Failed to get git remote URL', { cause: error, context: { command: 'git config --get remote.origin.url' } }); } }; /** * Create a Pull Request on GitHub * Why: Automate PR creation from command line * * @param {Object} options - PR options * @param {string} options.title - PR title (required) * @param {string} options.body - PR description (required) * @param {string} options.head - Head branch (default: current branch) * @param {string} options.base - Base branch (default: 'develop') * @param {boolean} options.draft - Create as draft PR (default: false) * @param {Array<string>} options.labels - Labels to add * @param {Array<string>} options.reviewers - Reviewers to request * @returns {Promise<Object>} - PR data { number, url, html_url } */ export const createPullRequest = async ({ title, body, head = null, base = 'develop', draft = false, labels = [], reviewers = [] }) => { logger.debug('github-client - createPullRequest', 'Creating PR', { title, head, base, draft, labelsCount: labels.length, reviewersCount: reviewers.length }); // Validate required fields if (!title || typeof title !== 'string') { throw new GitHubMCPError('PR title is required'); } if (!body || typeof body !== 'string') { throw new GitHubMCPError('PR body is required'); } // Check MCP availability if (!isGitHubMCPAvailable()) { throw new GitHubMCPError( 'GitHub MCP is not configured. Run: claude mcp add github', { context: { suggestion: 'Setup MCP with: cd /mnt/c/GITHUB/mscope-mcp-script && ./setup-all-mcps.sh' } } ); } // Get repository info const repo = parseGitHubRepo(); // Get head branch (current branch if not specified) const headBranch = head || getCurrentBranch(); if (!headBranch) { throw new GitHubMCPError('Could not determine head branch for PR'); } logger.info(`Creating PR: ${title}`); logger.info(` From: ${headBranch} → To: ${base}`); logger.info(` Repository: ${repo.fullName}`); try { // Load configuration const config = await getConfig(); // Sanitize inputs to prevent prompt injection const sanitizedTitle = sanitizePRTitle(title); const sanitizedBody = sanitizePRBody(body); const sanitizedHead = sanitizeBranchName(headBranch) || headBranch; const sanitizedBase = sanitizeBranchName(base) || base; const sanitizedLabels = sanitizeStringArray(labels); const sanitizedReviewers = sanitizeStringArray(reviewers); logger.debug('github-client - createPullRequest', 'Inputs sanitized', { titleLength: sanitizedTitle.length, bodyLength: sanitizedBody.length }); // Build prompt from template with sanitized values const promptVariables = { OWNER: repo.owner, REPO: repo.repo, TITLE: sanitizedTitle, BODY: sanitizedBody, HEAD: sanitizedHead, BASE: sanitizedBase, DRAFT: draft.toString(), HAS_LABELS: sanitizedLabels.length > 0 ? 'true' : '', LABELS: sanitizedLabels.join(', '), HAS_REVIEWERS: sanitizedReviewers.length > 0 ? 'true' : '', REVIEWERS: sanitizedReviewers.join(', ') }; logger.debug('github-client - createPullRequest', 'Loading PR creation prompt', { repo: repo.fullName, template: config.templates.createGithubPR }); const prompt = await loadPrompt(config.templates.createGithubPR, promptVariables); logger.debug('github-client - createPullRequest', 'Calling Claude interactively with GitHub MCP', { repo: repo.fullName, head: headBranch, base, promptLength: prompt.length }); // Execute Claude interactively so user can approve MCP permissions if needed const response = await executeClaudeInteractive(prompt, { timeout: 300000 // 5 minutes for interactive session }); // In interactive mode, we can't capture the response // The user sees the PR URL directly in the terminal if (response === 'interactive-session-completed') { logger.info('Interactive Claude session completed'); return { number: null, url: null, html_url: null, interactive: true, message: 'PR creation completed in interactive mode. Check the output above for the PR URL.' }; } logger.debug('github-client - createPullRequest', 'Claude response received', { responseLength: response.length }); // Extract PR URL from response (for non-interactive mode fallback) const urlMatch = response.match(/https:\/\/github\.com\/[^\/]+\/[^\/]+\/pull\/\d+/); if (urlMatch) { const prUrl = urlMatch[0]; const prNumber = prUrl.split('/').pop(); logger.info(`✅ Pull request created: ${prUrl}`); return { number: parseInt(prNumber), url: prUrl, html_url: prUrl }; } else { // Claude's response didn't contain a PR URL - might have failed logger.warning('github-client - createPullRequest', 'No PR URL in response', { response }); throw new GitHubMCPError( 'Pull request creation failed - no PR URL returned. Claude response: ' + response.substring(0, 200), { context: { response, title, head: headBranch, base } } ); } } catch (error) { if (error instanceof GitHubMCPError) { throw error; } throw new GitHubMCPError('Failed to create pull request', { cause: error, context: { title, head: headBranch, base } }); } }; /** * Get repository information * Why: Fetch repo metadata for PR creation * * @returns {Promise<Object>} - Repository data */ export const getRepositoryInfo = async () => { logger.debug('github-client - getRepositoryInfo', 'Fetching repository info'); if (!isGitHubMCPAvailable()) { throw new GitHubMCPError('GitHub MCP is not configured'); } const repo = parseGitHubRepo(); try { // TODO: Replace with actual MCP call // const repoInfo = await mcp.github.get_repository({ // owner: repo.owner, // repo: repo.repo // }); // For now, return local git info return { owner: repo.owner, name: repo.repo, fullName: repo.fullName, defaultBranch: 'develop' // Would come from GitHub API }; } catch (error) { throw new GitHubMCPError('Failed to fetch repository info', { cause: error, context: { repo } }); } }; /** * Search for issues in repository * Why: Find related issues to link in PR * * @param {string} query - Search query * @param {Object} options - Search options * @param {number} options.limit - Max results (default: 5) * @returns {Promise<Array<Object>>} - Array of issues */ export const searchIssues = async (query, { limit = 5 } = {}) => { logger.debug('github-client - searchIssues', 'Searching issues', { query, limit }); if (!isGitHubMCPAvailable()) { throw new GitHubMCPError('GitHub MCP is not configured'); } const repo = parseGitHubRepo(); try { // TODO: Replace with actual MCP call // const results = await mcp.github.search_issues({ // query: `${query} repo:${repo.fullName}`, // limit // }); logger.debug('github-client - searchIssues', 'Would search issues', { query, repo: repo.fullName }); return []; // No results for now } catch (error) { throw new GitHubMCPError('Failed to search issues', { cause: error, context: { query, repo } }); } }; /** * Read CODEOWNERS file from repository * Why: Auto-detect reviewers based on file changes * * @returns {Promise<string|null>} - CODEOWNERS content or null if not found */ export const readCodeowners = async () => { logger.debug('github-client - readCodeowners', 'Reading CODEOWNERS file'); const repo = parseGitHubRepo(); try { // Use Octokit-based implementation from github-api.js const { readCodeowners: readCodeownersOctokit } = await import('./github-api.js'); const content = await readCodeownersOctokit({ owner: repo.owner, repo: repo.repo }); return content; } catch (error) { logger.debug('github-client - readCodeowners', 'Could not read CODEOWNERS', { error: error.message }); return null; // Non-critical failure } }; /** * Parse CODEOWNERS file to get reviewers for files * Why: Extract reviewer info from CODEOWNERS content * * @param {string} codeownersContent - CODEOWNERS file content * @param {Array<string>} files - Files changed in PR * @returns {Array<string>} - GitHub usernames of reviewers */ export const parseCodeownersReviewers = (codeownersContent, files = []) => { if (!codeownersContent) { return []; } logger.debug('github-client - parseCodeownersReviewers', 'Parsing CODEOWNERS', { filesCount: files.length }); const reviewers = new Set(); const lines = codeownersContent.split('\n'); // Parse CODEOWNERS format: // pattern @username1 @username2 // Example: *.js @frontend-team @tech-lead for (const line of lines) { const trimmed = line.trim(); // Skip comments and empty lines if (!trimmed || trimmed.startsWith('#')) { continue; } const parts = trimmed.split(/\s+/); if (parts.length < 2) { continue; } const pattern = parts[0]; const owners = parts.slice(1).filter(o => o.startsWith('@')); // Check if any file matches this pattern const patternRegex = new RegExp( pattern .replace(/\./g, '\\.') .replace(/\*/g, '.*') .replace(/\?/g, '.') ); for (const file of files) { if (patternRegex.test(file)) { owners.forEach(owner => reviewers.add(owner.substring(1))); // Remove @ } } } const reviewersList = Array.from(reviewers); logger.debug('github-client - parseCodeownersReviewers', 'Found reviewers', { reviewers: reviewersList }); return reviewersList; }; /** * Get reviewers for PR based on changed files * Why: Auto-detect appropriate reviewers * * @param {Array<string>} files - Files changed in PR * @param {Object} config - GitHub config from .claude/config.json * @returns {Promise<Array<string>>} - Reviewer usernames */ export const getReviewersForFiles = async (files = [], config = {}) => { logger.debug('github-client - getReviewersForFiles', 'Getting reviewers', { filesCount: files.length }); const reviewers = new Set(); // Option 1: Try CODEOWNERS file try { const codeowners = await readCodeowners(); if (codeowners) { const codeownersReviewers = parseCodeownersReviewers(codeowners, files); codeownersReviewers.forEach(r => reviewers.add(r)); } } catch (error) { logger.debug('github-client - getReviewersForFiles', 'Could not read CODEOWNERS', { error: error.message }); } // Option 2: Use config-based reviewers if (config.reviewers) { const configReviewers = Array.isArray(config.reviewers) ? config.reviewers : Object.values(config.reviewers).flat(); configReviewers.forEach(r => reviewers.add(r)); } // Option 3: Use reviewer rules (pattern-based) if (config.reviewerRules && Array.isArray(config.reviewerRules)) { for (const rule of config.reviewerRules) { const patternRegex = new RegExp(rule.pattern); const hasMatch = files.some(file => patternRegex.test(file)); if (hasMatch && rule.reviewers) { rule.reviewers.forEach(r => reviewers.add(r)); } } } const reviewersList = Array.from(reviewers); logger.info(`📋 Found ${reviewersList.length} reviewer(s): ${reviewersList.join(', ') || 'none'}`); return reviewersList; }; /** * Validate GitHub configuration * Why: Check that required config is present before operations * * @param {Object} config - GitHub config * @returns {Object} - Validation result { valid, errors } */ export const validateGitHubConfig = (config = {}) => { const errors = []; if (!config.pr) { errors.push('Missing github.pr configuration'); } if (config.pr && !config.pr.defaultBase) { errors.push('Missing github.pr.defaultBase (e.g., "develop")'); } return { valid: errors.length === 0, errors }; }; /** * Execute a Claude MCP command with proper platform handling * Why: Centralizes WSL vs native command execution for MCP operations * * @param {string} mcpCommand - The MCP subcommand (e.g., 'list', 'add github') * @param {Object} options - Execution options * @param {boolean} options.silent - Suppress output (default: false) * @param {number} options.timeout - Command timeout in ms (default: 30000) * @returns {Object} - { success, output, error } */ export const executeMcpCommand = (mcpCommand, { silent = false, timeout = 30000 } = {}) => { try { const { command, args } = getClaudeCommand(); // Build the full command const fullCommand = command === 'wsl' ? `wsl ${args.join(' ')} mcp ${mcpCommand}` : `${command} ${args.join(' ')} mcp ${mcpCommand}`.trim(); logger.debug('github-client - executeMcpCommand', 'Executing MCP command', { fullCommand }); const output = execSync(fullCommand, { encoding: 'utf8', stdio: silent ? ['pipe', 'pipe', 'pipe'] : ['inherit', 'pipe', 'pipe'], timeout }); return { success: true, output: output?.trim() || '', error: null }; } catch (error) { logger.debug('github-client - executeMcpCommand', 'MCP command failed', { mcpCommand, error: error.message }); return { success: false, output: '', error: error.message }; } }; /** * Setup GitHub MCP for Claude CLI * Why: Automate MCP installation and permission approval * * Steps: * 1. Check if GitHub MCP is already installed * 2. If not, add it with 'claude mcp add github' * 3. Approve necessary permissions * * @param {Object} options - Setup options * @param {boolean} options.force - Force reinstall even if already installed * @param {boolean} options.interactive - Show prompts (default: true) * @returns {Promise<Object>} - { success, message, details } */ export const setupGitHubMcp = async ({ force = false, interactive = true } = {}) => { logger.info('🔧 Setting up GitHub MCP for Claude CLI...'); const results = { alreadyInstalled: false, installed: false, permissionsApproved: false, errors: [] }; try { // Step 1: Check if already installed const isInstalled = isGitHubMCPAvailable(); if (isInstalled && !force) { logger.info('✅ GitHub MCP is already installed'); results.alreadyInstalled = true; // Still try to approve permissions const permResult = await approveGitHubMcpPermissions(); results.permissionsApproved = permResult.success; return { success: true, message: 'GitHub MCP already configured', details: results }; } // Step 2: Add GitHub MCP logger.info('đŸ“Ļ Adding GitHub MCP server...'); const addResult = executeMcpCommand('add github', { silent: false, timeout: 60000 }); if (!addResult.success) { // Check if it failed because it's already added if (addResult.error?.includes('already exists') || addResult.error?.includes('already added')) { logger.info('â„šī¸ GitHub MCP was already added'); results.installed = true; } else { results.errors.push(`Failed to add GitHub MCP: ${addResult.error}`); logger.error('❌ Failed to add GitHub MCP:', addResult.error); } } else { logger.info('✅ GitHub MCP server added successfully'); results.installed = true; } // Step 3: Approve permissions if (results.installed || results.alreadyInstalled) { const permResult = await approveGitHubMcpPermissions(); results.permissionsApproved = permResult.success; if (!permResult.success) { results.errors.push(...permResult.errors); } } // Final status const success = (results.installed || results.alreadyInstalled) && results.permissionsApproved; return { success, message: success ? '✅ GitHub MCP setup complete!' : `âš ī¸ Setup completed with issues: ${results.errors.join(', ')}`, details: results }; } catch (error) { logger.error('❌ GitHub MCP setup failed:', error.message); results.errors.push(error.message); return { success: false, message: `Setup failed: ${error.message}`, details: results }; } }; /** * Approve GitHub MCP permissions for PR creation * Why: MCP tools need explicit permission approval * * @returns {Promise<Object>} - { success, errors } */ export const approveGitHubMcpPermissions = async () => { logger.info('🔐 Approving GitHub MCP permissions...'); const errors = []; // Permissions needed for PR creation workflow const permissions = [ 'create_pull_request', 'get_file_contents', 'list_commits', 'search_repositories' ]; // Try to approve all permissions at once first logger.debug('github-client - approveGitHubMcpPermissions', 'Trying bulk approval'); const bulkResult = executeMcpCommand('approve github --all', { silent: true, timeout: 30000 }); if (bulkResult.success) { logger.info('✅ All GitHub MCP permissions approved'); return { success: true, errors: [] }; } // If bulk approval fails, try individual permissions logger.debug('github-client - approveGitHubMcpPermissions', 'Bulk failed, trying individual', { error: bulkResult.error }); let approvedCount = 0; for (const permission of permissions) { const result = executeMcpCommand(`approve github ${permission}`, { silent: true, timeout: 15000 }); if (result.success) { logger.debug('github-client - approveGitHubMcpPermissions', `Approved: ${permission}`); approvedCount++; } else { // Don't treat as error - permission might already be approved or not exist logger.debug('github-client - approveGitHubMcpPermissions', `Could not approve: ${permission}`, { error: result.error }); } } // Consider success if at least create_pull_request was approved or no errors const success = approvedCount > 0 || errors.length === 0; if (success) { logger.info(`✅ GitHub MCP permissions configured (${approvedCount}/${permissions.length})`); } else { logger.warning('âš ī¸ Could not approve some permissions - you may need to approve manually'); logger.info(' Run: claude mcp approve github --all'); } return { success, errors }; }; /** * Get GitHub MCP status * Why: Diagnostic information for troubleshooting * * @returns {Object} - Status information */ export const getGitHubMcpStatus = () => { const status = { installed: false, command: null, platform: process.platform, usingWsl: false }; try { const { command, args } = getClaudeCommand(); status.command = command === 'wsl' ? `wsl ${args.join(' ')}` : command; status.usingWsl = command === 'wsl'; status.installed = isGitHubMCPAvailable(); // Get MCP list for details const listResult = executeMcpCommand('list', { silent: true }); if (listResult.success) { status.mcpList = listResult.output; } } catch (error) { status.error = error.message; } return status; }; // Note: createPullRequest function has been moved to github-api.js // The new implementation uses Octokit directly instead of MCP for reliable PR creation // Import from '../utils/github-api.js' for PR operations