claude-git-hooks
Version:
Git hooks with Claude CLI for code analysis and automatic commit messages
642 lines (568 loc) • 21.2 kB
JavaScript
/**
* 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;
}
};