claude-git-hooks
Version:
Git hooks with Claude CLI for code analysis and automatic commit messages
771 lines (646 loc) âĸ 25 kB
JavaScript
/**
* 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