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.
- package/CHANGELOG.md +262 -135
- package/README.md +158 -67
- package/bin/claude-hooks +452 -10
- package/lib/config.js +29 -0
- package/lib/hooks/pre-commit.js +2 -6
- package/lib/hooks/prepare-commit-msg.js +27 -4
- package/lib/utils/claude-client.js +148 -16
- package/lib/utils/file-operations.js +0 -102
- package/lib/utils/github-api.js +641 -0
- package/lib/utils/github-client.js +770 -0
- package/lib/utils/interactive-ui.js +314 -0
- package/lib/utils/mcp-setup.js +342 -0
- package/lib/utils/sanitize.js +180 -0
- package/lib/utils/task-id.js +425 -0
- package/package.json +4 -1
- package/templates/CREATE_GITHUB_PR.md +32 -0
- package/templates/config.example.json +41 -41
- package/templates/config.github.example.json +51 -0
- package/templates/presets/ai/PRE_COMMIT_GUIDELINES.md +18 -1
- package/templates/presets/ai/config.json +12 -12
- package/templates/presets/ai/preset.json +37 -42
- package/templates/presets/backend/ANALYSIS_PROMPT.md +23 -28
- package/templates/presets/backend/PRE_COMMIT_GUIDELINES.md +41 -3
- package/templates/presets/backend/config.json +12 -12
- package/templates/presets/database/config.json +12 -12
- package/templates/presets/default/config.json +12 -12
- package/templates/presets/frontend/config.json +12 -12
- package/templates/presets/fullstack/config.json +12 -12
- package/templates/settings.local.example.json +4 -0
|
@@ -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
|
+
};
|