claude-autopm 2.7.0 → 2.8.1

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,475 @@
1
+ /**
2
+ * GitHub Provider for ClaudeAutoPM
3
+ *
4
+ * Provides bidirectional synchronization with GitHub Issues following 2025 best practices
5
+ *
6
+ * Features:
7
+ * - Full CRUD operations for issues
8
+ * - Comment management
9
+ * - Label management
10
+ * - Advanced search
11
+ * - Rate limiting with exponential backoff
12
+ * - Retry logic for transient failures
13
+ * - GitHub Enterprise support
14
+ *
15
+ * @module lib/providers/GitHubProvider
16
+ */
17
+
18
+ const { Octokit } = require('@octokit/rest');
19
+
20
+ /**
21
+ * GitHub Provider Class
22
+ *
23
+ * Manages integration with GitHub Issues API using Octokit
24
+ *
25
+ * @class GitHubProvider
26
+ */
27
+ class GitHubProvider {
28
+ /**
29
+ * Creates a new GitHub provider instance
30
+ *
31
+ * @param {Object} options - Configuration options
32
+ * @param {string} [options.token] - GitHub Personal Access Token
33
+ * @param {string} [options.owner] - Repository owner
34
+ * @param {string} [options.repo] - Repository name
35
+ * @param {string} [options.baseUrl] - Custom GitHub API URL (for Enterprise)
36
+ * @param {number} [options.timeout=30000] - Request timeout in milliseconds
37
+ * @param {number} [options.maxRetries=3] - Maximum retry attempts
38
+ */
39
+ constructor(options = {}) {
40
+ this.token = options.token || process.env.GITHUB_TOKEN;
41
+ this.owner = options.owner || process.env.GITHUB_OWNER;
42
+ this.repo = options.repo || process.env.GITHUB_REPO;
43
+ this.baseUrl = options.baseUrl;
44
+ this.timeout = options.timeout || 30000;
45
+ this.maxRetries = options.maxRetries || 3;
46
+
47
+ this.octokit = null;
48
+ this.rateLimitRemaining = null;
49
+ this.rateLimitReset = null;
50
+ }
51
+
52
+ /**
53
+ * Authenticates with GitHub API
54
+ *
55
+ * Creates Octokit instance and verifies authentication
56
+ * by fetching the authenticated user's info
57
+ *
58
+ * @async
59
+ * @returns {Promise<Object>} Authenticated user object
60
+ * @throws {Error} If token is missing or authentication fails
61
+ */
62
+ async authenticate() {
63
+ if (!this.token) {
64
+ throw new Error('GitHub token is required');
65
+ }
66
+
67
+ const options = {
68
+ auth: this.token,
69
+ timeout: this.timeout
70
+ };
71
+
72
+ if (this.baseUrl) {
73
+ options.baseUrl = this.baseUrl;
74
+ }
75
+
76
+ this.octokit = new Octokit(options);
77
+
78
+ // Verify authentication
79
+ const { data: user } = await this.octokit.users.getAuthenticated();
80
+
81
+ return user;
82
+ }
83
+
84
+ /**
85
+ * Fetches a single issue by number
86
+ *
87
+ * @async
88
+ * @param {number} number - Issue number
89
+ * @returns {Promise<Object>} Issue object
90
+ * @throws {Error} If issue is not found or request fails
91
+ */
92
+ async getIssue(number) {
93
+ await this._checkRateLimit();
94
+
95
+ const { data } = await this.octokit.rest.issues.get({
96
+ owner: this.owner,
97
+ repo: this.repo,
98
+ issue_number: number
99
+ });
100
+
101
+ return data;
102
+ }
103
+
104
+ /**
105
+ * Lists issues with optional filtering
106
+ *
107
+ * @async
108
+ * @param {Object} [filters={}] - Filter options
109
+ * @param {string} [filters.state] - Issue state (open, closed, all)
110
+ * @param {Array<string>} [filters.labels] - Filter by labels
111
+ * @param {string} [filters.assignee] - Filter by assignee
112
+ * @param {number} [filters.page=1] - Page number
113
+ * @param {number} [filters.perPage=100] - Results per page
114
+ * @returns {Promise<Array>} Array of issue objects
115
+ */
116
+ async listIssues(filters = {}) {
117
+ await this._checkRateLimit();
118
+
119
+ const params = {
120
+ owner: this.owner,
121
+ repo: this.repo,
122
+ per_page: filters.perPage || 100
123
+ };
124
+
125
+ if (filters.state) {
126
+ params.state = filters.state;
127
+ }
128
+
129
+ if (filters.labels && filters.labels.length > 0) {
130
+ params.labels = filters.labels.join(',');
131
+ }
132
+
133
+ if (filters.assignee) {
134
+ params.assignee = filters.assignee;
135
+ }
136
+
137
+ if (filters.page) {
138
+ params.page = filters.page;
139
+ }
140
+
141
+ const { data } = await this.octokit.rest.issues.list(params);
142
+
143
+ return data;
144
+ }
145
+
146
+ /**
147
+ * Creates a new issue
148
+ *
149
+ * @async
150
+ * @param {Object} data - Issue data
151
+ * @param {string} data.title - Issue title (required)
152
+ * @param {string} [data.body] - Issue description
153
+ * @param {Array<string>} [data.labels] - Labels to add
154
+ * @param {Array<string>} [data.assignees] - Assignees
155
+ * @returns {Promise<Object>} Created issue object
156
+ * @throws {Error} If title is missing or creation fails
157
+ */
158
+ async createIssue(data) {
159
+ if (!data.title) {
160
+ throw new Error('Issue title is required');
161
+ }
162
+
163
+ await this._checkRateLimit();
164
+
165
+ const params = {
166
+ owner: this.owner,
167
+ repo: this.repo,
168
+ title: data.title
169
+ };
170
+
171
+ if (data.body) {
172
+ params.body = data.body;
173
+ }
174
+
175
+ if (data.labels) {
176
+ params.labels = data.labels;
177
+ }
178
+
179
+ if (data.assignees) {
180
+ params.assignees = data.assignees;
181
+ }
182
+
183
+ const { data: issue } = await this.octokit.rest.issues.create(params);
184
+
185
+ return issue;
186
+ }
187
+
188
+ /**
189
+ * Updates an existing issue
190
+ *
191
+ * @async
192
+ * @param {number} number - Issue number
193
+ * @param {Object} data - Fields to update
194
+ * @param {string} [data.title] - New title
195
+ * @param {string} [data.body] - New body
196
+ * @param {string} [data.state] - New state (open, closed)
197
+ * @param {Array<string>} [data.labels] - New labels
198
+ * @returns {Promise<Object>} Updated issue object
199
+ */
200
+ async updateIssue(number, data) {
201
+ await this._checkRateLimit();
202
+
203
+ const params = {
204
+ owner: this.owner,
205
+ repo: this.repo,
206
+ issue_number: number,
207
+ ...data
208
+ };
209
+
210
+ const { data: issue } = await this.octokit.rest.issues.update(params);
211
+
212
+ return issue;
213
+ }
214
+
215
+ /**
216
+ * Closes an issue
217
+ *
218
+ * @async
219
+ * @param {number} number - Issue number
220
+ * @param {string} [comment] - Optional closing comment
221
+ * @returns {Promise<Object>} Closed issue object
222
+ */
223
+ async closeIssue(number, comment) {
224
+ if (comment) {
225
+ await this.createComment(number, comment);
226
+ }
227
+
228
+ return await this.updateIssue(number, { state: 'closed' });
229
+ }
230
+
231
+ /**
232
+ * Creates a comment on an issue
233
+ *
234
+ * @async
235
+ * @param {number} number - Issue number
236
+ * @param {string} body - Comment body
237
+ * @returns {Promise<Object>} Created comment object
238
+ * @throws {Error} If body is empty
239
+ */
240
+ async createComment(number, body) {
241
+ if (!body || body.trim() === '') {
242
+ throw new Error('Comment body is required');
243
+ }
244
+
245
+ await this._checkRateLimit();
246
+
247
+ const { data } = await this.octokit.rest.issues.createComment({
248
+ owner: this.owner,
249
+ repo: this.repo,
250
+ issue_number: number,
251
+ body
252
+ });
253
+
254
+ return data;
255
+ }
256
+
257
+ /**
258
+ * Lists comments for an issue
259
+ *
260
+ * @async
261
+ * @param {number} number - Issue number
262
+ * @param {Object} [options={}] - Pagination options
263
+ * @param {number} [options.page] - Page number
264
+ * @param {number} [options.perPage=100] - Results per page
265
+ * @returns {Promise<Array>} Array of comment objects
266
+ */
267
+ async listComments(number, options = {}) {
268
+ await this._checkRateLimit();
269
+
270
+ const params = {
271
+ owner: this.owner,
272
+ repo: this.repo,
273
+ issue_number: number,
274
+ per_page: options.perPage || 100
275
+ };
276
+
277
+ if (options.page) {
278
+ params.page = options.page;
279
+ }
280
+
281
+ const { data } = await this.octokit.rest.issues.listComments(params);
282
+
283
+ return data;
284
+ }
285
+
286
+ /**
287
+ * Adds labels to an issue
288
+ *
289
+ * @async
290
+ * @param {number} number - Issue number
291
+ * @param {Array<string>} labels - Labels to add
292
+ * @returns {Promise<Array>} Array of label objects
293
+ * @throws {Error} If labels array is empty
294
+ */
295
+ async addLabels(number, labels) {
296
+ if (!labels || labels.length === 0) {
297
+ throw new Error('At least one label is required');
298
+ }
299
+
300
+ await this._checkRateLimit();
301
+
302
+ const { data } = await this.octokit.rest.issues.addLabels({
303
+ owner: this.owner,
304
+ repo: this.repo,
305
+ issue_number: number,
306
+ labels
307
+ });
308
+
309
+ return data;
310
+ }
311
+
312
+ /**
313
+ * Searches issues using GitHub search syntax
314
+ *
315
+ * @async
316
+ * @param {string} query - Search query
317
+ * @param {Object} [options={}] - Additional options
318
+ * @param {Array<string>} [options.labels] - Filter by labels
319
+ * @param {string} [options.state] - Filter by state
320
+ * @param {number} [options.page] - Page number
321
+ * @param {number} [options.perPage=100] - Results per page
322
+ * @returns {Promise<Object>} Search results with total_count and items
323
+ */
324
+ async searchIssues(query, options = {}) {
325
+ await this._checkRateLimit();
326
+
327
+ let searchQuery = query;
328
+
329
+ // Build query with filters
330
+ if (options.labels && options.labels.length > 0) {
331
+ searchQuery += ` label:${options.labels[0]}`;
332
+ }
333
+
334
+ if (options.state) {
335
+ searchQuery += ` state:${options.state}`;
336
+ }
337
+
338
+ // Always scope to current repo
339
+ searchQuery += ` repo:${this.owner}/${this.repo}`;
340
+
341
+ const params = {
342
+ q: searchQuery,
343
+ per_page: options.perPage || 100
344
+ };
345
+
346
+ if (options.page) {
347
+ params.page = options.page;
348
+ }
349
+
350
+ const { data } = await this.octokit.rest.search.issuesAndPullRequests(params);
351
+
352
+ return data;
353
+ }
354
+
355
+ /**
356
+ * Checks current rate limit status
357
+ *
358
+ * @async
359
+ * @returns {Promise<Object>} Rate limit information
360
+ */
361
+ async checkRateLimit() {
362
+ const { data } = await this.octokit.rest.rateLimit.get();
363
+
364
+ this.rateLimitRemaining = data.resources.core.remaining;
365
+ this.rateLimitReset = data.resources.core.reset;
366
+
367
+ return data.resources;
368
+ }
369
+
370
+ /**
371
+ * Handles rate limit errors with exponential backoff
372
+ *
373
+ * @async
374
+ * @param {Error} error - Rate limit error
375
+ * @param {number} retryCount - Current retry attempt
376
+ * @returns {Promise<number>} Wait time in milliseconds
377
+ * @throws {Error} If max retries exceeded
378
+ * @private
379
+ */
380
+ async handleRateLimitError(error, retryCount) {
381
+ if (retryCount >= this.maxRetries) {
382
+ throw new Error('Maximum retry attempts exceeded');
383
+ }
384
+
385
+ let waitTime;
386
+
387
+ // Check if we have reset time from headers
388
+ if (error.response && error.response.headers) {
389
+ const resetTime = error.response.headers['x-ratelimit-reset'];
390
+ if (resetTime) {
391
+ const now = Math.floor(Date.now() / 1000);
392
+ const resetTimestamp = parseInt(resetTime, 10);
393
+ waitTime = Math.max(0, (resetTimestamp - now) * 1000);
394
+ }
395
+ }
396
+
397
+ // Fallback to exponential backoff
398
+ if (!waitTime || waitTime === 0) {
399
+ // 2^retryCount * 1000ms (1s, 2s, 4s, 8s...)
400
+ waitTime = Math.pow(2, retryCount) * 1000;
401
+ }
402
+
403
+ return waitTime;
404
+ }
405
+
406
+ /**
407
+ * Makes a request with retry logic
408
+ *
409
+ * @async
410
+ * @param {Function} requestFn - Function that makes the request
411
+ * @param {Object} [options={}] - Options
412
+ * @param {number} [options.maxRetries] - Override max retries
413
+ * @returns {Promise<*>} Response data
414
+ * @throws {Error} If request fails permanently
415
+ * @private
416
+ */
417
+ async _makeRequest(requestFn, options = {}) {
418
+ const maxRetries = options.maxRetries !== undefined ? options.maxRetries : this.maxRetries;
419
+ let lastError;
420
+
421
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
422
+ try {
423
+ const { data } = await requestFn();
424
+ return data;
425
+ } catch (error) {
426
+ lastError = error;
427
+
428
+ // Check if error is rate limit
429
+ if (error.status === 403 && error.message && error.message.includes('rate limit')) {
430
+ const waitTime = await this.handleRateLimitError(error, attempt);
431
+ await new Promise(resolve => setTimeout(resolve, waitTime));
432
+ continue;
433
+ }
434
+
435
+ // Check if error is transient (5xx)
436
+ if (error.status >= 500 && error.status < 600) {
437
+ if (attempt < maxRetries) {
438
+ const waitTime = Math.pow(2, attempt) * 1000;
439
+ await new Promise(resolve => setTimeout(resolve, waitTime));
440
+ continue;
441
+ }
442
+ }
443
+
444
+ // Non-retryable error
445
+ throw error;
446
+ }
447
+ }
448
+
449
+ throw lastError;
450
+ }
451
+
452
+ /**
453
+ * Checks rate limit before making requests
454
+ *
455
+ * @async
456
+ * @private
457
+ */
458
+ async _checkRateLimit() {
459
+ if (!this.octokit) {
460
+ throw new Error('Not authenticated. Call authenticate() first.');
461
+ }
462
+
463
+ // Only check if we haven't checked recently
464
+ if (this.rateLimitRemaining === null) {
465
+ await this.checkRateLimit();
466
+ }
467
+
468
+ // Warn if rate limit is low
469
+ if (this.rateLimitRemaining !== null && this.rateLimitRemaining < 100) {
470
+ console.warn(`GitHub API rate limit low: ${this.rateLimitRemaining} requests remaining`);
471
+ }
472
+ }
473
+ }
474
+
475
+ module.exports = GitHubProvider;