claude-git-hooks 2.4.0 → 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,641 @@
1
+ /**
2
+ * File: github-api.js
3
+ * Purpose: Direct GitHub API integration via Octokit
4
+ *
5
+ * Why Octokit instead of MCP:
6
+ * - PR creation is deterministic (no AI judgment needed)
7
+ * - Reliable error handling
8
+ * - No external process dependencies
9
+ * - Better debugging and logging
10
+ *
11
+ * Token priority:
12
+ * 1. GITHUB_TOKEN env var (CI/CD friendly)
13
+ * 2. GITHUB_PERSONAL_ACCESS_TOKEN env var
14
+ * 3. .claude/settings.local.json → githubToken
15
+ * 4. Claude Desktop config (cross-platform)
16
+ */
17
+
18
+ import { Octokit } from '@octokit/rest';
19
+ import { execSync } from 'child_process';
20
+ import fs from 'fs';
21
+ import path from 'path';
22
+ import { fileURLToPath } from 'url';
23
+ import logger from './logger.js';
24
+ import { findGitHubTokenInDesktopConfig } from './mcp-setup.js';
25
+
26
+ // Get package info for user agent
27
+ const __filename = fileURLToPath(import.meta.url);
28
+ const __dirname = path.dirname(__filename);
29
+ const packageJsonPath = path.join(__dirname, '..', '..', 'package.json');
30
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
31
+ const USER_AGENT = `${packageJson.name}/${packageJson.version}`;
32
+
33
+ /**
34
+ * Custom error for GitHub API operations
35
+ */
36
+ export class GitHubAPIError extends Error {
37
+ constructor(message, { cause, statusCode, context } = {}) {
38
+ super(message);
39
+ this.name = 'GitHubAPIError';
40
+ this.cause = cause;
41
+ this.statusCode = statusCode;
42
+ this.context = context;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Get repository root directory
48
+ * @returns {string} Absolute path to repo root
49
+ */
50
+ const getRepoRoot = () => {
51
+ try {
52
+ const repoRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim();
53
+ logger.debug('github-api - getRepoRoot', 'Repository root found', { repoRoot });
54
+ return repoRoot;
55
+ } catch (error) {
56
+ logger.error('github-api - getRepoRoot', 'Not in a git repository', error);
57
+ throw new GitHubAPIError('Not in a git repository', { cause: error });
58
+ }
59
+ };
60
+
61
+ /**
62
+ * Load local settings from .claude/settings.local.json
63
+ * Why: Gitignored file for sensitive data like tokens
64
+ *
65
+ * @returns {Object} Local settings or empty object
66
+ */
67
+ const loadLocalSettings = () => {
68
+ try {
69
+ const repoRoot = getRepoRoot();
70
+ const settingsPath = path.join(repoRoot, '.claude', 'settings.local.json');
71
+
72
+ logger.debug('github-api - loadLocalSettings', 'Checking for settings file', { settingsPath });
73
+
74
+ if (fs.existsSync(settingsPath)) {
75
+ const content = fs.readFileSync(settingsPath, 'utf8');
76
+ const settings = JSON.parse(content);
77
+ logger.debug('github-api - loadLocalSettings', 'Settings loaded successfully', {
78
+ hasGithubToken: !!settings.githubToken
79
+ });
80
+ return settings;
81
+ } else {
82
+ logger.debug('github-api - loadLocalSettings', 'Settings file not found');
83
+ }
84
+ } catch (error) {
85
+ logger.debug('github-api - loadLocalSettings', 'Could not load local settings', {
86
+ error: error.message
87
+ });
88
+ }
89
+ return {};
90
+ };
91
+
92
+ /**
93
+ * Get GitHub authentication token
94
+ * Why: Centralized token resolution with multiple fallback sources
95
+ *
96
+ * Priority:
97
+ * 1. GITHUB_TOKEN env var (standard for CI/CD)
98
+ * 2. GITHUB_PERSONAL_ACCESS_TOKEN env var (legacy support)
99
+ * 3. .claude/settings.local.json → githubToken (local dev, gitignored)
100
+ * 4. Claude Desktop config (cross-platform GUI users)
101
+ *
102
+ * @returns {string} GitHub token
103
+ * @throws {GitHubAPIError} If no token found
104
+ */
105
+ export const getGitHubToken = () => {
106
+ // Priority 1: Standard env var
107
+ if (process.env.GITHUB_TOKEN) {
108
+ logger.debug('github-api - getGitHubToken', 'Using GITHUB_TOKEN env var');
109
+ return process.env.GITHUB_TOKEN;
110
+ }
111
+
112
+ // Priority 2: Legacy env var
113
+ if (process.env.GITHUB_PERSONAL_ACCESS_TOKEN) {
114
+ logger.debug('github-api - getGitHubToken', 'Using GITHUB_PERSONAL_ACCESS_TOKEN env var');
115
+ return process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
116
+ }
117
+
118
+ // Priority 3: Local settings file (gitignored)
119
+ const localSettings = loadLocalSettings();
120
+ if (localSettings.githubToken) {
121
+ logger.debug('github-api - getGitHubToken', 'Using token from .claude/settings.local.json');
122
+ return localSettings.githubToken;
123
+ }
124
+
125
+ // Priority 4: Claude Desktop config
126
+ const desktopToken = findGitHubTokenInDesktopConfig();
127
+ if (desktopToken?.token) {
128
+ logger.debug('github-api - getGitHubToken', 'Using token from Claude Desktop config', {
129
+ configPath: desktopToken.configPath
130
+ });
131
+ return desktopToken.token;
132
+ }
133
+
134
+ // No token found
135
+ throw new GitHubAPIError(
136
+ 'GitHub token not found. Please configure authentication.',
137
+ {
138
+ context: {
139
+ searchedLocations: [
140
+ 'GITHUB_TOKEN env var',
141
+ 'GITHUB_PERSONAL_ACCESS_TOKEN env var',
142
+ '.claude/settings.local.json → githubToken',
143
+ 'Claude Desktop config'
144
+ ],
145
+ suggestion: 'Run: claude-hooks setup-github or create .claude/settings.local.json with {"githubToken": "ghp_..."}'
146
+ }
147
+ }
148
+ );
149
+ };
150
+
151
+ /**
152
+ * Get configured Octokit instance
153
+ * @returns {Octokit} Authenticated Octokit instance
154
+ */
155
+ const getOctokit = () => {
156
+ logger.debug('github-api - getOctokit', 'Getting GitHub token');
157
+ const token = getGitHubToken();
158
+
159
+ logger.debug('github-api - getOctokit', 'Creating Octokit client', {
160
+ userAgent: USER_AGENT,
161
+ hasToken: !!token,
162
+ tokenLength: token ? token.length : 0
163
+ });
164
+
165
+ return new Octokit({
166
+ auth: token,
167
+ userAgent: USER_AGENT,
168
+ // Add request logging in debug mode
169
+ log: logger.isDebugMode() ? console : undefined
170
+ });
171
+ };
172
+
173
+ /**
174
+ * Parse repository from git remote URL
175
+ * Why: Extract owner/repo from various git URL formats
176
+ *
177
+ * @returns {Object} - { owner, repo, fullName }
178
+ *
179
+ * Supports formats:
180
+ * - https://github.com/owner/repo.git
181
+ * - git@github.com:owner/repo.git
182
+ * - https://github.com/owner/repo
183
+ */
184
+ export const parseGitHubRepo = () => {
185
+ try {
186
+ const remoteUrl = execSync('git config --get remote.origin.url', {
187
+ encoding: 'utf8'
188
+ }).trim();
189
+
190
+ logger.debug('github-api - parseGitHubRepo', 'Parsing remote URL', { remoteUrl });
191
+
192
+ // Match various GitHub URL formats
193
+ const httpsMatch = remoteUrl.match(/github\.com[/:]([\w-]+)\/([\w-]+?)(\.git)?$/);
194
+
195
+ if (httpsMatch) {
196
+ const owner = httpsMatch[1];
197
+ const repo = httpsMatch[2];
198
+
199
+ logger.debug('github-api - parseGitHubRepo', 'Parsed repository', { owner, repo });
200
+
201
+ return {
202
+ owner,
203
+ repo,
204
+ fullName: `${owner}/${repo}`
205
+ };
206
+ }
207
+
208
+ throw new GitHubAPIError('Could not parse GitHub repository from remote URL', {
209
+ context: { remoteUrl }
210
+ });
211
+
212
+ } catch (error) {
213
+ if (error instanceof GitHubAPIError) {
214
+ throw error;
215
+ }
216
+
217
+ throw new GitHubAPIError('Failed to get git remote URL', {
218
+ cause: error,
219
+ context: { command: 'git config --get remote.origin.url' }
220
+ });
221
+ }
222
+ };
223
+
224
+ /**
225
+ * Create a Pull Request on GitHub
226
+ * Why: Direct API call for deterministic PR creation
227
+ *
228
+ * @param {Object} options - PR options
229
+ * @param {string} options.owner - Repository owner
230
+ * @param {string} options.repo - Repository name
231
+ * @param {string} options.title - PR title
232
+ * @param {string} options.body - PR description
233
+ * @param {string} options.head - Head branch (source)
234
+ * @param {string} options.base - Base branch (target)
235
+ * @param {boolean} options.draft - Create as draft PR (default: false)
236
+ * @param {Array<string>} options.labels - Labels to add (optional)
237
+ * @param {Array<string>} options.reviewers - Reviewers to request (optional)
238
+ * @returns {Promise<Object>} Created PR data
239
+ * @throws {GitHubAPIError} On failure
240
+ */
241
+ export const createPullRequest = async ({
242
+ owner,
243
+ repo,
244
+ title,
245
+ body,
246
+ head,
247
+ base,
248
+ draft = false,
249
+ labels = [],
250
+ reviewers = []
251
+ }) => {
252
+ logger.debug('github-api - createPullRequest', 'Starting PR creation', {
253
+ owner,
254
+ repo,
255
+ titlePreview: title.substring(0, 50) + '...',
256
+ titleLength: title.length,
257
+ bodyLength: body.length,
258
+ head,
259
+ base,
260
+ draft,
261
+ labelsCount: labels.length,
262
+ reviewersCount: reviewers.length
263
+ });
264
+
265
+ const octokit = getOctokit();
266
+
267
+ try {
268
+ // Step 1: Create the PR
269
+ logger.info(`Creating PR: ${head} → ${base}`);
270
+ logger.debug('github-api - createPullRequest', 'Calling GitHub API pulls.create', {
271
+ owner,
272
+ repo,
273
+ head,
274
+ base
275
+ });
276
+
277
+ const { data: pr } = await octokit.pulls.create({
278
+ owner,
279
+ repo,
280
+ title,
281
+ body,
282
+ head,
283
+ base,
284
+ draft
285
+ });
286
+
287
+ logger.success(`PR #${pr.number} created: ${pr.html_url}`);
288
+ logger.debug('github-api - createPullRequest', 'PR created successfully', {
289
+ number: pr.number,
290
+ id: pr.id,
291
+ url: pr.html_url,
292
+ state: pr.state,
293
+ draft: pr.draft
294
+ });
295
+
296
+ // Step 2: Add labels (if any)
297
+ if (labels.length > 0) {
298
+ logger.debug('github-api - createPullRequest', 'Adding labels to PR', {
299
+ prNumber: pr.number,
300
+ labels
301
+ });
302
+ try {
303
+ await octokit.issues.addLabels({
304
+ owner,
305
+ repo,
306
+ issue_number: pr.number,
307
+ labels
308
+ });
309
+ logger.debug('github-api - createPullRequest', 'Labels added successfully', { labels });
310
+ } catch (labelError) {
311
+ // Non-fatal: PR was created, labels failed
312
+ logger.warning(`Could not add labels: ${labelError.message}`);
313
+ logger.debug('github-api - createPullRequest', 'Label addition failed', {
314
+ error: labelError.message,
315
+ labels
316
+ });
317
+ }
318
+ } else {
319
+ logger.debug('github-api - createPullRequest', 'No labels to add');
320
+ }
321
+
322
+ // Step 3: Request reviewers (if any)
323
+ if (reviewers.length > 0) {
324
+ logger.debug('github-api - createPullRequest', 'Requesting reviewers', {
325
+ prNumber: pr.number,
326
+ reviewers
327
+ });
328
+ try {
329
+ await octokit.pulls.requestReviewers({
330
+ owner,
331
+ repo,
332
+ pull_number: pr.number,
333
+ reviewers
334
+ });
335
+ logger.debug('github-api - createPullRequest', 'Reviewers requested successfully', { reviewers });
336
+ } catch (reviewerError) {
337
+ // Non-fatal: PR was created, reviewer request failed
338
+ logger.warning(`Could not request reviewers: ${reviewerError.message}`);
339
+ logger.debug('github-api - createPullRequest', 'Reviewer request failed', {
340
+ error: reviewerError.message,
341
+ reviewers
342
+ });
343
+ }
344
+ } else {
345
+ logger.debug('github-api - createPullRequest', 'No reviewers to request');
346
+ }
347
+
348
+ const result = {
349
+ number: pr.number,
350
+ url: pr.html_url,
351
+ html_url: pr.html_url,
352
+ state: pr.state,
353
+ draft: pr.draft,
354
+ title: pr.title,
355
+ head: pr.head.ref,
356
+ base: pr.base.ref,
357
+ labels: labels,
358
+ reviewers: reviewers
359
+ };
360
+
361
+ logger.debug('github-api - createPullRequest', 'PR creation completed', result);
362
+ return result;
363
+
364
+ } catch (error) {
365
+ logger.error('github-api - createPullRequest', 'Failed to create PR', error);
366
+
367
+ // Handle specific GitHub API errors
368
+ const statusCode = error.status || error.response?.status;
369
+
370
+ logger.debug('github-api - createPullRequest', 'Processing error', {
371
+ statusCode,
372
+ errorMessage: error.message,
373
+ hasResponse: !!error.response
374
+ });
375
+
376
+ if (statusCode === 422) {
377
+ // Validation failed - usually means PR already exists or branch issues
378
+ const message = error.response?.data?.errors?.[0]?.message || error.message;
379
+ logger.debug('github-api - createPullRequest', 'Validation error (422)', { message });
380
+
381
+ if (message.includes('pull request already exists')) {
382
+ throw new GitHubAPIError(
383
+ `A pull request already exists for ${head} → ${base}`,
384
+ { statusCode, context: { head, base, suggestion: 'Check existing PRs on GitHub' } }
385
+ );
386
+ }
387
+
388
+ if (message.includes('No commits between')) {
389
+ throw new GitHubAPIError(
390
+ `No commits between ${base} and ${head}. Nothing to merge.`,
391
+ { statusCode, context: { head, base } }
392
+ );
393
+ }
394
+
395
+ throw new GitHubAPIError(
396
+ `Validation failed: ${message}`,
397
+ { statusCode, cause: error, context: { head, base } }
398
+ );
399
+ }
400
+
401
+ if (statusCode === 401) {
402
+ logger.debug('github-api - createPullRequest', 'Authentication error (401)');
403
+ throw new GitHubAPIError(
404
+ 'GitHub authentication failed. Token may be invalid or expired.',
405
+ { statusCode, context: { suggestion: 'Check your GitHub token permissions' } }
406
+ );
407
+ }
408
+
409
+ if (statusCode === 403) {
410
+ logger.debug('github-api - createPullRequest', 'Permission error (403)');
411
+ throw new GitHubAPIError(
412
+ 'GitHub access forbidden. Token may lack required permissions.',
413
+ {
414
+ statusCode,
415
+ context: {
416
+ requiredPermissions: ['repo', 'read:org'],
417
+ suggestion: 'Ensure token has "repo" scope'
418
+ }
419
+ }
420
+ );
421
+ }
422
+
423
+ if (statusCode === 404) {
424
+ logger.debug('github-api - createPullRequest', 'Not found error (404)', { owner, repo });
425
+ throw new GitHubAPIError(
426
+ `Repository ${owner}/${repo} not found or not accessible.`,
427
+ { statusCode, context: { owner, repo } }
428
+ );
429
+ }
430
+
431
+ // Generic error
432
+ logger.debug('github-api - createPullRequest', 'Generic error', {
433
+ statusCode,
434
+ message: error.message
435
+ });
436
+ throw new GitHubAPIError(
437
+ `Failed to create pull request: ${error.message}`,
438
+ { cause: error, statusCode, context: { owner, repo, head, base } }
439
+ );
440
+ }
441
+ };
442
+
443
+ /**
444
+ * Read CODEOWNERS file from repository
445
+ *
446
+ * @param {Object} params - Parameters
447
+ * @param {string} params.owner - Repository owner
448
+ * @param {string} params.repo - Repository name
449
+ * @param {string} params.branch - Branch name (default: main/master)
450
+ * @returns {Promise<string|null>} CODEOWNERS content or null if not found
451
+ */
452
+ export const readCodeowners = async ({ owner, repo, branch = null }) => {
453
+ logger.debug('github-api - readCodeowners', 'Attempting to read CODEOWNERS', { owner, repo });
454
+
455
+ const octokit = getOctokit();
456
+
457
+ // Determine default branch if not provided
458
+ if (!branch) {
459
+ try {
460
+ const { data: repoData } = await octokit.repos.get({ owner, repo });
461
+ branch = repoData.default_branch;
462
+ logger.debug('github-api - readCodeowners', 'Using default branch', { branch });
463
+ } catch (error) {
464
+ branch = 'main'; // Fallback
465
+ logger.debug('github-api - readCodeowners', 'Could not get default branch, using main', { error: error.message });
466
+ }
467
+ }
468
+
469
+ // Try common CODEOWNERS locations
470
+ const paths = [
471
+ 'CODEOWNERS',
472
+ '.github/CODEOWNERS',
473
+ 'docs/CODEOWNERS'
474
+ ];
475
+
476
+ for (const path of paths) {
477
+ try {
478
+ logger.debug('github-api - readCodeowners', 'Trying path', { path });
479
+
480
+ const { data } = await octokit.repos.getContent({
481
+ owner,
482
+ repo,
483
+ path,
484
+ ref: branch
485
+ });
486
+
487
+ if (data.type === 'file' && data.content) {
488
+ // Decode base64 content
489
+ const content = Buffer.from(data.content, 'base64').toString('utf8');
490
+ logger.debug('github-api - readCodeowners', 'CODEOWNERS found', {
491
+ path,
492
+ lines: content.split('\n').length
493
+ });
494
+ return content;
495
+ }
496
+ } catch (error) {
497
+ logger.debug('github-api - readCodeowners', 'Path not found', { path, error: error.message });
498
+ // Continue to next path
499
+ }
500
+ }
501
+
502
+ logger.debug('github-api - readCodeowners', 'CODEOWNERS not found in any location');
503
+ return null;
504
+ };
505
+
506
+ /**
507
+ * Check if a PR already exists for the given branches
508
+ * @param {Object} options
509
+ * @returns {Promise<Object|null>} Existing PR or null
510
+ */
511
+ export const findExistingPR = async ({ owner, repo, head, base }) => {
512
+ logger.debug('github-api - findExistingPR', 'Checking for existing PRs', {
513
+ owner,
514
+ repo,
515
+ head,
516
+ base
517
+ });
518
+
519
+ const octokit = getOctokit();
520
+
521
+ try {
522
+ const { data: prs } = await octokit.pulls.list({
523
+ owner,
524
+ repo,
525
+ head: `${owner}:${head}`,
526
+ base,
527
+ state: 'open'
528
+ });
529
+
530
+ if (prs.length > 0) {
531
+ logger.debug('github-api - findExistingPR', 'Found existing PR', {
532
+ number: prs[0].number,
533
+ url: prs[0].html_url
534
+ });
535
+ return prs[0];
536
+ } else {
537
+ logger.debug('github-api - findExistingPR', 'No existing PR found');
538
+ return null;
539
+ }
540
+ } catch (error) {
541
+ logger.error('github-api - findExistingPR', 'Could not check existing PRs', error);
542
+ logger.debug('github-api - findExistingPR', 'Returning null due to error');
543
+ return null;
544
+ }
545
+ };
546
+
547
+ /**
548
+ * Get repository information
549
+ * Why: Fetch repo metadata for validation and context
550
+ *
551
+ * @returns {Promise<Object>} - Repository data
552
+ * @throws {GitHubAPIError} - If repo fetch fails
553
+ */
554
+ export const getRepositoryInfo = async () => {
555
+ logger.debug('github-api - getRepositoryInfo', 'Fetching repository info');
556
+
557
+ try {
558
+ const repo = parseGitHubRepo();
559
+ const octokit = getOctokit();
560
+
561
+ const { data } = await octokit.repos.get({
562
+ owner: repo.owner,
563
+ repo: repo.repo
564
+ });
565
+
566
+ logger.debug('github-api - getRepositoryInfo', 'Repository info fetched', {
567
+ owner: data.owner.login,
568
+ name: data.name,
569
+ defaultBranch: data.default_branch
570
+ });
571
+
572
+ return {
573
+ owner: data.owner.login,
574
+ name: data.name,
575
+ fullName: data.full_name,
576
+ defaultBranch: data.default_branch,
577
+ private: data.private,
578
+ description: data.description
579
+ };
580
+
581
+ } catch (error) {
582
+ if (error.status) {
583
+ throw new GitHubAPIError(
584
+ `GitHub API error (${error.status}): ${error.message}`,
585
+ { cause: error }
586
+ );
587
+ }
588
+
589
+ if (error instanceof GitHubAPIError) {
590
+ throw error;
591
+ }
592
+
593
+ throw new GitHubAPIError('Failed to fetch repository info', { cause: error });
594
+ }
595
+ };
596
+
597
+ /**
598
+ * Validate GitHub token has required permissions
599
+ * Why: Fail fast with helpful message instead of cryptic API errors
600
+ *
601
+ * @returns {Promise<Object>} Token info including scopes
602
+ */
603
+ export const validateToken = async () => {
604
+ logger.debug('github-api - validateToken', 'Starting token validation');
605
+
606
+ const octokit = getOctokit();
607
+
608
+ try {
609
+ logger.debug('github-api - validateToken', 'Calling GitHub API users.getAuthenticated');
610
+ const { data: user, headers } = await octokit.users.getAuthenticated();
611
+
612
+ const scopes = headers['x-oauth-scopes']?.split(', ') || [];
613
+
614
+ const result = {
615
+ valid: true,
616
+ user: user.login,
617
+ scopes,
618
+ hasRepoScope: scopes.includes('repo'),
619
+ hasOrgReadScope: scopes.includes('read:org')
620
+ };
621
+
622
+ logger.debug('github-api - validateToken', 'Token validation successful', {
623
+ user: user.login,
624
+ scopes,
625
+ hasRepoScope: result.hasRepoScope,
626
+ hasOrgReadScope: result.hasOrgReadScope
627
+ });
628
+
629
+ return result;
630
+ } catch (error) {
631
+ logger.error('github-api - validateToken', 'Token validation failed', error);
632
+
633
+ const result = {
634
+ valid: false,
635
+ error: error.message
636
+ };
637
+
638
+ logger.debug('github-api - validateToken', 'Returning invalid token result', result);
639
+ return result;
640
+ }
641
+ };