UNPKG

claude-git-hooks

Version:

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

642 lines (568 loc) 21.2 kB
/** * File: github-api.js * Purpose: Direct GitHub API integration via Octokit * * Why Octokit instead of MCP: * - PR creation is deterministic (no AI judgment needed) * - Reliable error handling * - No external process dependencies * - Better debugging and logging * * Token priority: * 1. GITHUB_TOKEN env var (CI/CD friendly) * 2. GITHUB_PERSONAL_ACCESS_TOKEN env var * 3. .claude/settings.local.json → githubToken * 4. Claude Desktop config (cross-platform) */ import { Octokit } from '@octokit/rest'; import { execSync } from 'child_process'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import logger from './logger.js'; import { findGitHubTokenInDesktopConfig } from './mcp-setup.js'; // Get package info for user agent const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const packageJsonPath = path.join(__dirname, '..', '..', 'package.json'); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); const USER_AGENT = `${packageJson.name}/${packageJson.version}`; /** * Custom error for GitHub API operations */ export class GitHubAPIError extends Error { constructor(message, { cause, statusCode, context } = {}) { super(message); this.name = 'GitHubAPIError'; this.cause = cause; this.statusCode = statusCode; this.context = context; } } /** * Get repository root directory * @returns {string} Absolute path to repo root */ const getRepoRoot = () => { try { const repoRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim(); logger.debug('github-api - getRepoRoot', 'Repository root found', { repoRoot }); return repoRoot; } catch (error) { logger.error('github-api - getRepoRoot', 'Not in a git repository', error); throw new GitHubAPIError('Not in a git repository', { cause: error }); } }; /** * Load local settings from .claude/settings.local.json * Why: Gitignored file for sensitive data like tokens * * @returns {Object} Local settings or empty object */ const loadLocalSettings = () => { try { const repoRoot = getRepoRoot(); const settingsPath = path.join(repoRoot, '.claude', 'settings.local.json'); logger.debug('github-api - loadLocalSettings', 'Checking for settings file', { settingsPath }); if (fs.existsSync(settingsPath)) { const content = fs.readFileSync(settingsPath, 'utf8'); const settings = JSON.parse(content); logger.debug('github-api - loadLocalSettings', 'Settings loaded successfully', { hasGithubToken: !!settings.githubToken }); return settings; } else { logger.debug('github-api - loadLocalSettings', 'Settings file not found'); } } catch (error) { logger.debug('github-api - loadLocalSettings', 'Could not load local settings', { error: error.message }); } return {}; }; /** * Get GitHub authentication token * Why: Centralized token resolution with multiple fallback sources * * Priority: * 1. GITHUB_TOKEN env var (standard for CI/CD) * 2. GITHUB_PERSONAL_ACCESS_TOKEN env var (legacy support) * 3. .claude/settings.local.json → githubToken (local dev, gitignored) * 4. Claude Desktop config (cross-platform GUI users) * * @returns {string} GitHub token * @throws {GitHubAPIError} If no token found */ export const getGitHubToken = () => { // Priority 1: Standard env var if (process.env.GITHUB_TOKEN) { logger.debug('github-api - getGitHubToken', 'Using GITHUB_TOKEN env var'); return process.env.GITHUB_TOKEN; } // Priority 2: Legacy env var if (process.env.GITHUB_PERSONAL_ACCESS_TOKEN) { logger.debug('github-api - getGitHubToken', 'Using GITHUB_PERSONAL_ACCESS_TOKEN env var'); return process.env.GITHUB_PERSONAL_ACCESS_TOKEN; } // Priority 3: Local settings file (gitignored) const localSettings = loadLocalSettings(); if (localSettings.githubToken) { logger.debug('github-api - getGitHubToken', 'Using token from .claude/settings.local.json'); return localSettings.githubToken; } // Priority 4: Claude Desktop config const desktopToken = findGitHubTokenInDesktopConfig(); if (desktopToken?.token) { logger.debug('github-api - getGitHubToken', 'Using token from Claude Desktop config', { configPath: desktopToken.configPath }); return desktopToken.token; } // No token found throw new GitHubAPIError( 'GitHub token not found. Please configure authentication.', { context: { searchedLocations: [ 'GITHUB_TOKEN env var', 'GITHUB_PERSONAL_ACCESS_TOKEN env var', '.claude/settings.local.json → githubToken', 'Claude Desktop config' ], suggestion: 'Run: claude-hooks setup-github or create .claude/settings.local.json with {"githubToken": "ghp_..."}' } } ); }; /** * Get configured Octokit instance * @returns {Octokit} Authenticated Octokit instance */ const getOctokit = () => { logger.debug('github-api - getOctokit', 'Getting GitHub token'); const token = getGitHubToken(); logger.debug('github-api - getOctokit', 'Creating Octokit client', { userAgent: USER_AGENT, hasToken: !!token, tokenLength: token ? token.length : 0 }); return new Octokit({ auth: token, userAgent: USER_AGENT, // Add request logging in debug mode log: logger.isDebugMode() ? console : undefined }); }; /** * 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-api - 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-api - parseGitHubRepo', 'Parsed repository', { owner, repo }); return { owner, repo, fullName: `${owner}/${repo}` }; } throw new GitHubAPIError('Could not parse GitHub repository from remote URL', { context: { remoteUrl } }); } catch (error) { if (error instanceof GitHubAPIError) { throw error; } throw new GitHubAPIError('Failed to get git remote URL', { cause: error, context: { command: 'git config --get remote.origin.url' } }); } }; /** * Create a Pull Request on GitHub * Why: Direct API call for deterministic PR creation * * @param {Object} options - PR options * @param {string} options.owner - Repository owner * @param {string} options.repo - Repository name * @param {string} options.title - PR title * @param {string} options.body - PR description * @param {string} options.head - Head branch (source) * @param {string} options.base - Base branch (target) * @param {boolean} options.draft - Create as draft PR (default: false) * @param {Array<string>} options.labels - Labels to add (optional) * @param {Array<string>} options.reviewers - Reviewers to request (optional) * @returns {Promise<Object>} Created PR data * @throws {GitHubAPIError} On failure */ export const createPullRequest = async ({ owner, repo, title, body, head, base, draft = false, labels = [], reviewers = [] }) => { logger.debug('github-api - createPullRequest', 'Starting PR creation', { owner, repo, titlePreview: title.substring(0, 50) + '...', titleLength: title.length, bodyLength: body.length, head, base, draft, labelsCount: labels.length, reviewersCount: reviewers.length }); const octokit = getOctokit(); try { // Step 1: Create the PR logger.info(`Creating PR: ${head} → ${base}`); logger.debug('github-api - createPullRequest', 'Calling GitHub API pulls.create', { owner, repo, head, base }); const { data: pr } = await octokit.pulls.create({ owner, repo, title, body, head, base, draft }); logger.success(`PR #${pr.number} created: ${pr.html_url}`); logger.debug('github-api - createPullRequest', 'PR created successfully', { number: pr.number, id: pr.id, url: pr.html_url, state: pr.state, draft: pr.draft }); // Step 2: Add labels (if any) if (labels.length > 0) { logger.debug('github-api - createPullRequest', 'Adding labels to PR', { prNumber: pr.number, labels }); try { await octokit.issues.addLabels({ owner, repo, issue_number: pr.number, labels }); logger.debug('github-api - createPullRequest', 'Labels added successfully', { labels }); } catch (labelError) { // Non-fatal: PR was created, labels failed logger.warning(`Could not add labels: ${labelError.message}`); logger.debug('github-api - createPullRequest', 'Label addition failed', { error: labelError.message, labels }); } } else { logger.debug('github-api - createPullRequest', 'No labels to add'); } // Step 3: Request reviewers (if any) if (reviewers.length > 0) { logger.debug('github-api - createPullRequest', 'Requesting reviewers', { prNumber: pr.number, reviewers }); try { await octokit.pulls.requestReviewers({ owner, repo, pull_number: pr.number, reviewers }); logger.debug('github-api - createPullRequest', 'Reviewers requested successfully', { reviewers }); } catch (reviewerError) { // Non-fatal: PR was created, reviewer request failed logger.warning(`Could not request reviewers: ${reviewerError.message}`); logger.debug('github-api - createPullRequest', 'Reviewer request failed', { error: reviewerError.message, reviewers }); } } else { logger.debug('github-api - createPullRequest', 'No reviewers to request'); } const result = { number: pr.number, url: pr.html_url, html_url: pr.html_url, state: pr.state, draft: pr.draft, title: pr.title, head: pr.head.ref, base: pr.base.ref, labels: labels, reviewers: reviewers }; logger.debug('github-api - createPullRequest', 'PR creation completed', result); return result; } catch (error) { logger.error('github-api - createPullRequest', 'Failed to create PR', error); // Handle specific GitHub API errors const statusCode = error.status || error.response?.status; logger.debug('github-api - createPullRequest', 'Processing error', { statusCode, errorMessage: error.message, hasResponse: !!error.response }); if (statusCode === 422) { // Validation failed - usually means PR already exists or branch issues const message = error.response?.data?.errors?.[0]?.message || error.message; logger.debug('github-api - createPullRequest', 'Validation error (422)', { message }); if (message.includes('pull request already exists')) { throw new GitHubAPIError( `A pull request already exists for ${head} → ${base}`, { statusCode, context: { head, base, suggestion: 'Check existing PRs on GitHub' } } ); } if (message.includes('No commits between')) { throw new GitHubAPIError( `No commits between ${base} and ${head}. Nothing to merge.`, { statusCode, context: { head, base } } ); } throw new GitHubAPIError( `Validation failed: ${message}`, { statusCode, cause: error, context: { head, base } } ); } if (statusCode === 401) { logger.debug('github-api - createPullRequest', 'Authentication error (401)'); throw new GitHubAPIError( 'GitHub authentication failed. Token may be invalid or expired.', { statusCode, context: { suggestion: 'Check your GitHub token permissions' } } ); } if (statusCode === 403) { logger.debug('github-api - createPullRequest', 'Permission error (403)'); throw new GitHubAPIError( 'GitHub access forbidden. Token may lack required permissions.', { statusCode, context: { requiredPermissions: ['repo', 'read:org'], suggestion: 'Ensure token has "repo" scope' } } ); } if (statusCode === 404) { logger.debug('github-api - createPullRequest', 'Not found error (404)', { owner, repo }); throw new GitHubAPIError( `Repository ${owner}/${repo} not found or not accessible.`, { statusCode, context: { owner, repo } } ); } // Generic error logger.debug('github-api - createPullRequest', 'Generic error', { statusCode, message: error.message }); throw new GitHubAPIError( `Failed to create pull request: ${error.message}`, { cause: error, statusCode, context: { owner, repo, head, base } } ); } }; /** * Read CODEOWNERS file from repository * * @param {Object} params - Parameters * @param {string} params.owner - Repository owner * @param {string} params.repo - Repository name * @param {string} params.branch - Branch name (default: main/master) * @returns {Promise<string|null>} CODEOWNERS content or null if not found */ export const readCodeowners = async ({ owner, repo, branch = null }) => { logger.debug('github-api - readCodeowners', 'Attempting to read CODEOWNERS', { owner, repo }); const octokit = getOctokit(); // Determine default branch if not provided if (!branch) { try { const { data: repoData } = await octokit.repos.get({ owner, repo }); branch = repoData.default_branch; logger.debug('github-api - readCodeowners', 'Using default branch', { branch }); } catch (error) { branch = 'main'; // Fallback logger.debug('github-api - readCodeowners', 'Could not get default branch, using main', { error: error.message }); } } // Try common CODEOWNERS locations const paths = [ 'CODEOWNERS', '.github/CODEOWNERS', 'docs/CODEOWNERS' ]; for (const path of paths) { try { logger.debug('github-api - readCodeowners', 'Trying path', { path }); const { data } = await octokit.repos.getContent({ owner, repo, path, ref: branch }); if (data.type === 'file' && data.content) { // Decode base64 content const content = Buffer.from(data.content, 'base64').toString('utf8'); logger.debug('github-api - readCodeowners', 'CODEOWNERS found', { path, lines: content.split('\n').length }); return content; } } catch (error) { logger.debug('github-api - readCodeowners', 'Path not found', { path, error: error.message }); // Continue to next path } } logger.debug('github-api - readCodeowners', 'CODEOWNERS not found in any location'); return null; }; /** * Check if a PR already exists for the given branches * @param {Object} options * @returns {Promise<Object|null>} Existing PR or null */ export const findExistingPR = async ({ owner, repo, head, base }) => { logger.debug('github-api - findExistingPR', 'Checking for existing PRs', { owner, repo, head, base }); const octokit = getOctokit(); try { const { data: prs } = await octokit.pulls.list({ owner, repo, head: `${owner}:${head}`, base, state: 'open' }); if (prs.length > 0) { logger.debug('github-api - findExistingPR', 'Found existing PR', { number: prs[0].number, url: prs[0].html_url }); return prs[0]; } else { logger.debug('github-api - findExistingPR', 'No existing PR found'); return null; } } catch (error) { logger.error('github-api - findExistingPR', 'Could not check existing PRs', error); logger.debug('github-api - findExistingPR', 'Returning null due to error'); return null; } }; /** * Get repository information * Why: Fetch repo metadata for validation and context * * @returns {Promise<Object>} - Repository data * @throws {GitHubAPIError} - If repo fetch fails */ export const getRepositoryInfo = async () => { logger.debug('github-api - getRepositoryInfo', 'Fetching repository info'); try { const repo = parseGitHubRepo(); const octokit = getOctokit(); const { data } = await octokit.repos.get({ owner: repo.owner, repo: repo.repo }); logger.debug('github-api - getRepositoryInfo', 'Repository info fetched', { owner: data.owner.login, name: data.name, defaultBranch: data.default_branch }); return { owner: data.owner.login, name: data.name, fullName: data.full_name, defaultBranch: data.default_branch, private: data.private, description: data.description }; } catch (error) { if (error.status) { throw new GitHubAPIError( `GitHub API error (${error.status}): ${error.message}`, { cause: error } ); } if (error instanceof GitHubAPIError) { throw error; } throw new GitHubAPIError('Failed to fetch repository info', { cause: error }); } }; /** * Validate GitHub token has required permissions * Why: Fail fast with helpful message instead of cryptic API errors * * @returns {Promise<Object>} Token info including scopes */ export const validateToken = async () => { logger.debug('github-api - validateToken', 'Starting token validation'); const octokit = getOctokit(); try { logger.debug('github-api - validateToken', 'Calling GitHub API users.getAuthenticated'); const { data: user, headers } = await octokit.users.getAuthenticated(); const scopes = headers['x-oauth-scopes']?.split(', ') || []; const result = { valid: true, user: user.login, scopes, hasRepoScope: scopes.includes('repo'), hasOrgReadScope: scopes.includes('read:org') }; logger.debug('github-api - validateToken', 'Token validation successful', { user: user.login, scopes, hasRepoScope: result.hasRepoScope, hasOrgReadScope: result.hasOrgReadScope }); return result; } catch (error) { logger.error('github-api - validateToken', 'Token validation failed', error); const result = { valid: false, error: error.message }; logger.debug('github-api - validateToken', 'Returning invalid token result', result); return result; } };