@link-assistant/hive-mind 0.39.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.
Files changed (63) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/LICENSE +24 -0
  3. package/README.md +769 -0
  4. package/package.json +58 -0
  5. package/src/agent.lib.mjs +705 -0
  6. package/src/agent.prompts.lib.mjs +196 -0
  7. package/src/buildUserMention.lib.mjs +71 -0
  8. package/src/claude-limits.lib.mjs +389 -0
  9. package/src/claude.lib.mjs +1445 -0
  10. package/src/claude.prompts.lib.mjs +203 -0
  11. package/src/codex.lib.mjs +552 -0
  12. package/src/codex.prompts.lib.mjs +194 -0
  13. package/src/config.lib.mjs +207 -0
  14. package/src/contributing-guidelines.lib.mjs +268 -0
  15. package/src/exit-handler.lib.mjs +205 -0
  16. package/src/git.lib.mjs +145 -0
  17. package/src/github-issue-creator.lib.mjs +246 -0
  18. package/src/github-linking.lib.mjs +152 -0
  19. package/src/github.batch.lib.mjs +272 -0
  20. package/src/github.graphql.lib.mjs +258 -0
  21. package/src/github.lib.mjs +1479 -0
  22. package/src/hive.config.lib.mjs +254 -0
  23. package/src/hive.mjs +1500 -0
  24. package/src/instrument.mjs +191 -0
  25. package/src/interactive-mode.lib.mjs +1000 -0
  26. package/src/lenv-reader.lib.mjs +206 -0
  27. package/src/lib.mjs +490 -0
  28. package/src/lino.lib.mjs +176 -0
  29. package/src/local-ci-checks.lib.mjs +324 -0
  30. package/src/memory-check.mjs +419 -0
  31. package/src/model-mapping.lib.mjs +145 -0
  32. package/src/model-validation.lib.mjs +278 -0
  33. package/src/opencode.lib.mjs +479 -0
  34. package/src/opencode.prompts.lib.mjs +194 -0
  35. package/src/protect-branch.mjs +159 -0
  36. package/src/review.mjs +433 -0
  37. package/src/reviewers-hive.mjs +643 -0
  38. package/src/sentry.lib.mjs +284 -0
  39. package/src/solve.auto-continue.lib.mjs +568 -0
  40. package/src/solve.auto-pr.lib.mjs +1374 -0
  41. package/src/solve.branch-errors.lib.mjs +341 -0
  42. package/src/solve.branch.lib.mjs +230 -0
  43. package/src/solve.config.lib.mjs +342 -0
  44. package/src/solve.error-handlers.lib.mjs +256 -0
  45. package/src/solve.execution.lib.mjs +291 -0
  46. package/src/solve.feedback.lib.mjs +436 -0
  47. package/src/solve.mjs +1128 -0
  48. package/src/solve.preparation.lib.mjs +210 -0
  49. package/src/solve.repo-setup.lib.mjs +114 -0
  50. package/src/solve.repository.lib.mjs +961 -0
  51. package/src/solve.results.lib.mjs +558 -0
  52. package/src/solve.session.lib.mjs +135 -0
  53. package/src/solve.validation.lib.mjs +325 -0
  54. package/src/solve.watch.lib.mjs +572 -0
  55. package/src/start-screen.mjs +324 -0
  56. package/src/task.mjs +308 -0
  57. package/src/telegram-bot.mjs +1481 -0
  58. package/src/telegram-markdown.lib.mjs +64 -0
  59. package/src/usage-limit.lib.mjs +218 -0
  60. package/src/version.lib.mjs +41 -0
  61. package/src/youtrack/solve.youtrack.lib.mjs +116 -0
  62. package/src/youtrack/youtrack-sync.mjs +219 -0
  63. package/src/youtrack/youtrack.lib.mjs +425 -0
@@ -0,0 +1,1479 @@
1
+ #!/usr/bin/env node
2
+ // GitHub-related utility functions
3
+ // Check if use is already defined (when imported from solve.mjs)
4
+ // If not, fetch it (when running standalone)
5
+ if (typeof globalThis.use === 'undefined') {
6
+ globalThis.use = (await eval(await (await fetch('https://unpkg.com/use-m/use.js')).text())).use;
7
+ }
8
+ const fs = (await use('fs')).promises;
9
+ const os = (await use('os')).default;
10
+ const path = (await use('path')).default;
11
+ // Use command-stream for consistent $ behavior
12
+ const { $ } = await use('command-stream');
13
+ // Import log and maskToken from general lib
14
+ import { log, maskToken, cleanErrorMessage } from './lib.mjs';
15
+ import { reportError } from './sentry.lib.mjs';
16
+ import { githubLimits, timeouts } from './config.lib.mjs';
17
+ // Import batch operations from separate module
18
+ import {
19
+ batchCheckPullRequestsForIssues as batchCheckPRs,
20
+ batchCheckArchivedRepositories as batchCheckArchived
21
+ } from './github.batch.lib.mjs';
22
+
23
+ /**
24
+ * Build cost estimation string for log comments
25
+ * @param {number|null} totalCostUSD - Public pricing estimate
26
+ * @param {number|null} anthropicTotalCostUSD - Cost calculated by Anthropic (Claude-specific)
27
+ * @param {Object|null} pricingInfo - Pricing info from agent tool
28
+ * @returns {string} Formatted cost info string for markdown
29
+ */
30
+ const buildCostInfoString = (totalCostUSD, anthropicTotalCostUSD, pricingInfo) => {
31
+ let costInfo = '\n\nšŸ’° **Cost estimation:**';
32
+ if (pricingInfo && pricingInfo.modelName) {
33
+ costInfo += `\n- Model: ${pricingInfo.modelName}`;
34
+ if (pricingInfo.provider) costInfo += `\n- Provider: ${pricingInfo.provider}`;
35
+ }
36
+ if (totalCostUSD !== null && totalCostUSD !== undefined) {
37
+ costInfo += pricingInfo?.isFreeModel
38
+ ? '\n- Public pricing estimate: $0.00 (Free model)'
39
+ : `\n- Public pricing estimate: $${totalCostUSD.toFixed(6)} USD`;
40
+ } else {
41
+ costInfo += '\n- Public pricing estimate: unknown';
42
+ }
43
+ if (pricingInfo?.tokenUsage) {
44
+ const u = pricingInfo.tokenUsage;
45
+ let tokenInfo = `\n- Token usage: ${u.inputTokens?.toLocaleString() || 0} input, ${u.outputTokens?.toLocaleString() || 0} output`;
46
+ if (u.reasoningTokens > 0) tokenInfo += `, ${u.reasoningTokens.toLocaleString()} reasoning`;
47
+ if (u.cacheReadTokens > 0 || u.cacheWriteTokens > 0) tokenInfo += `, ${u.cacheReadTokens?.toLocaleString() || 0} cache read, ${u.cacheWriteTokens?.toLocaleString() || 0} cache write`;
48
+ costInfo += tokenInfo;
49
+ }
50
+ if (anthropicTotalCostUSD !== null && anthropicTotalCostUSD !== undefined) {
51
+ costInfo += `\n- Calculated by Anthropic: $${anthropicTotalCostUSD.toFixed(6)} USD`;
52
+ if (totalCostUSD !== null) {
53
+ const diff = anthropicTotalCostUSD - totalCostUSD;
54
+ const pct = totalCostUSD > 0 ? ((diff / totalCostUSD) * 100) : 0;
55
+ costInfo += `\n- Difference: $${diff.toFixed(6)} (${pct > 0 ? '+' : ''}${pct.toFixed(2)}%)`;
56
+ } else {
57
+ costInfo += '\n- Difference: unknown';
58
+ }
59
+ } else if (!pricingInfo) {
60
+ costInfo += '\n- Calculated by Anthropic: unknown\n- Difference: unknown';
61
+ }
62
+ return costInfo;
63
+ };
64
+ // Helper function to mask GitHub tokens (alias for backward compatibility)
65
+ export const maskGitHubToken = maskToken;
66
+ // Helper function to get GitHub tokens from local config files
67
+ export const getGitHubTokensFromFiles = async () => {
68
+ const tokens = [];
69
+
70
+ try {
71
+ // Check ~/.config/gh/hosts.yml
72
+ const hostsFile = path.join(os.homedir(), '.config/gh/hosts.yml');
73
+ if (await fs.access(hostsFile).then(() => true).catch(() => false)) {
74
+ const hostsContent = await fs.readFile(hostsFile, 'utf8');
75
+
76
+ // Look for oauth_token and api_token patterns
77
+ const oauthMatches = hostsContent.match(/oauth_token:\s*([^\s\n]+)/g);
78
+ if (oauthMatches) {
79
+ for (const match of oauthMatches) {
80
+ const token = match.split(':')[1].trim();
81
+ if (token && !tokens.includes(token)) {
82
+ tokens.push(token);
83
+ }
84
+ }
85
+ }
86
+
87
+ const apiMatches = hostsContent.match(/api_token:\s*([^\s\n]+)/g);
88
+ if (apiMatches) {
89
+ for (const match of apiMatches) {
90
+ const token = match.split(':')[1].trim();
91
+ if (token && !tokens.includes(token)) {
92
+ tokens.push(token);
93
+ }
94
+ }
95
+ }
96
+ }
97
+ } catch (error) {
98
+ // File access errors are expected when config doesn't exist
99
+ if (global.verboseMode) {
100
+ reportError(error, {
101
+ context: 'github_token_file_access',
102
+ level: 'debug'
103
+ });
104
+ }
105
+ }
106
+
107
+ return tokens;
108
+ };
109
+ // Helper function to get GitHub tokens from gh command output
110
+ export const getGitHubTokensFromCommand = async () => {
111
+ const { $ } = await use('command-stream');
112
+ const tokens = [];
113
+
114
+ try {
115
+ // Run gh auth status to get token info
116
+ const authResult = await $`gh auth status 2>&1`.catch(() => ({ stdout: '', stderr: '' }));
117
+ const authOutput = authResult.stdout?.toString() + authResult.stderr?.toString() || '';
118
+
119
+ // Look for token patterns in the output
120
+ const tokenPatterns = [
121
+ /(?:token|oauth|api)[:\s]*([a-zA-Z0-9_]{20,})/gi,
122
+ /gh[pou]_[a-zA-Z0-9_]{20,}/gi
123
+ ];
124
+
125
+ for (const pattern of tokenPatterns) {
126
+ const matches = authOutput.match(pattern);
127
+ if (matches) {
128
+ for (let match of matches) {
129
+ // Clean up the match
130
+ const token = match.replace(/^(?:token|oauth|api)[:\s]*/, '').trim();
131
+ if (token && token.length >= 20 && !tokens.includes(token)) {
132
+ tokens.push(token);
133
+ }
134
+ }
135
+ }
136
+ }
137
+ } catch (error) {
138
+ // Command errors are expected when gh is not configured
139
+ if (global.verboseMode) {
140
+ reportError(error, {
141
+ context: 'github_token_gh_auth',
142
+ level: 'debug'
143
+ });
144
+ }
145
+ }
146
+
147
+ return tokens;
148
+ };
149
+ // Helper function to escape code blocks in log content for safe embedding in markdown
150
+ // When log content is placed inside a markdown code block, any triple backticks (```)
151
+ // in the content will prematurely close the outer code block, breaking the markdown.
152
+ // This function escapes those backticks by replacing them with \`\`\` (with backslashes).
153
+ export const escapeCodeBlocksInLog = (logContent) => {
154
+ // Replace all occurrences of triple backticks with escaped version
155
+ // We add backslashes before backticks to prevent them from being
156
+ // interpreted as markdown code block delimiters
157
+ return logContent.replace(/```/g, '\\`\\`\\`');
158
+ };
159
+ // Helper function to sanitize log content by masking GitHub tokens
160
+ export const sanitizeLogContent = async (logContent) => {
161
+ let sanitized = logContent;
162
+
163
+ try {
164
+ // Get tokens from both sources
165
+ const fileTokens = await getGitHubTokensFromFiles();
166
+ const commandTokens = await getGitHubTokensFromCommand();
167
+ const allTokens = [...new Set([...fileTokens, ...commandTokens])];
168
+
169
+ // Mask each token found
170
+ for (const token of allTokens) {
171
+ if (token && token.length >= 12) {
172
+ const maskedToken = maskToken(token);
173
+ // Use global replace to mask all occurrences
174
+ sanitized = sanitized.split(token).join(maskedToken);
175
+ }
176
+ }
177
+
178
+ // Also look for and mask common GitHub token patterns directly in the log
179
+ const tokenPatterns = [
180
+ /gh[pou]_[a-zA-Z0-9_]{20,}/g,
181
+ /(?:^|[\s:=])([a-f0-9]{40})(?=[\s\n]|$)/gm, // 40-char hex tokens (like personal access tokens)
182
+ /(?:^|[\s:=])([a-zA-Z0-9_]{20,})(?=[\s\n]|$)/gm // General long tokens
183
+ ];
184
+
185
+ for (const pattern of tokenPatterns) {
186
+ sanitized = sanitized.replace(pattern, (match, token) => {
187
+ if (token && token.length >= 20) {
188
+ return match.replace(token, maskToken(token));
189
+ }
190
+ return match;
191
+ });
192
+ }
193
+
194
+ await log(` šŸ”’ Sanitized ${allTokens.length} detected GitHub tokens in log content`, { verbose: true });
195
+
196
+ } catch (error) {
197
+ reportError(error, {
198
+ context: 'sanitize_log_content',
199
+ level: 'warning'
200
+ });
201
+ await log(` āš ļø Warning: Could not fully sanitize log content: ${error.message}`, { verbose: true });
202
+ }
203
+
204
+ return sanitized;
205
+ };
206
+ // Helper function to check if a file exists in a GitHub branch
207
+ export const checkFileInBranch = async (owner, repo, fileName, branchName) => {
208
+ const { $ } = await use('command-stream');
209
+
210
+ try {
211
+ // Use GitHub CLI to check if file exists in the branch
212
+ const result = await $`gh api repos/${owner}/${repo}/contents/${fileName}?ref=${branchName}`;
213
+ return result.code === 0;
214
+ } catch (error) {
215
+ // File doesn't exist or access error - this is expected behavior
216
+ if (global.verboseMode) {
217
+ reportError(error, {
218
+ context: 'check_file_in_branch',
219
+ level: 'debug',
220
+ owner,
221
+ repo,
222
+ fileName,
223
+ branchName
224
+ });
225
+ }
226
+ return false;
227
+ }
228
+ };
229
+ // Helper function to check GitHub permissions and warn about missing scopes
230
+ export const checkGitHubPermissions = async () => {
231
+ const { $ } = await use('command-stream');
232
+ try {
233
+ await log('\nšŸ” Checking GitHub authentication and permissions...');
234
+ // Get auth status including token scopes
235
+ const authStatusResult = await $`gh auth status 2>&1`;
236
+ const authOutput = authStatusResult.stdout.toString() + authStatusResult.stderr.toString();
237
+ if (authStatusResult.code !== 0 || authOutput.includes('not logged into any GitHub hosts')) {
238
+ await log('āŒ GitHub authentication error: Not logged in', { level: 'error' });
239
+ await log(' To fix this, run: gh auth login', { level: 'error' });
240
+ return false;
241
+ }
242
+ await log('āœ… GitHub authentication: OK');
243
+ // Parse the auth status output to extract token scopes
244
+ const scopeMatch = authOutput.match(/Token scopes:\s*(.+)/);
245
+ if (!scopeMatch) {
246
+ await log('āš ļø Warning: Could not determine token scopes from auth status', { level: 'warning' });
247
+ return true; // Continue despite not being able to check scopes
248
+ }
249
+ // Extract individual scopes from the format: 'scope1', 'scope2', 'scope3'
250
+ const scopeString = scopeMatch[1];
251
+ const scopes = scopeString.match(/'([^']+)'/g)?.map(s => s.replace(/'/g, '')) || [];
252
+ await log(`šŸ“‹ Token scopes: ${scopes.join(', ')}`);
253
+ // Check for important scopes and warn if missing
254
+ const warnings = [];
255
+ if (!scopes.includes('workflow')) {
256
+ warnings.push({
257
+ scope: 'workflow',
258
+ issue: 'Cannot push changes to .github/workflows/ directory',
259
+ solution: 'Run: gh auth refresh -h github.com -s workflow'
260
+ });
261
+ }
262
+ if (!scopes.includes('repo')) {
263
+ warnings.push({
264
+ scope: 'repo',
265
+ issue: 'Limited repository access (may not be able to create PRs or push to private repos)',
266
+ solution: 'Run: gh auth refresh -h github.com -s repo'
267
+ });
268
+ }
269
+ // Display warnings
270
+ if (warnings.length > 0) {
271
+ await log('\nāš ļø Permission warnings detected:', { level: 'warning' });
272
+ for (const warning of warnings) {
273
+ await log(`\n Missing scope: '${warning.scope}'`, { level: 'warning' });
274
+ await log(` Impact: ${warning.issue}`, { level: 'warning' });
275
+ await log(` Solution: ${warning.solution}`, { level: 'warning' });
276
+ }
277
+ await log('\n šŸ’” You can continue, but some operations may fail due to insufficient permissions.', { level: 'warning' });
278
+ await log(' šŸ’” To avoid issues, it\'s recommended to refresh your authentication with the missing scopes.', { level: 'warning' });
279
+ } else {
280
+ await log('āœ… All required permissions: Available');
281
+ }
282
+ return true;
283
+ } catch (error) {
284
+ await log(`āš ļø Warning: Could not check GitHub permissions: ${maskToken(error.message || error.toString())}`, { level: 'warning' });
285
+ await log(' Continuing anyway, but some operations may fail if permissions are insufficient', { level: 'warning' });
286
+ return true; // Continue despite permission check failure
287
+ }
288
+ };
289
+ /**
290
+ * Check if the current user has write (push) permissions to a specific repository
291
+ * This helps fail early before wasting AI tokens when --fork option is not used
292
+ * @param {string} owner - Repository owner
293
+ * @param {string} repo - Repository name
294
+ * @param {Object} options - Configuration options
295
+ * @param {boolean} options.useFork - Whether --fork flag is enabled
296
+ * @param {string} options.issueUrl - Original issue URL for error messages
297
+ * @returns {Promise<boolean>} True if has write access OR fork mode is enabled, false otherwise
298
+ */
299
+ export const checkRepositoryWritePermission = async (owner, repo, options = {}) => {
300
+ const { useFork = false, issueUrl = '' } = options;
301
+ // Skip check if fork mode is enabled - user will work in their own fork
302
+ if (useFork) {
303
+ await log('āœ… Repository access check: Skipped (fork mode enabled)', { verbose: true });
304
+ return true;
305
+ }
306
+ try {
307
+ await log('šŸ” Checking repository write permissions...');
308
+ // Use GitHub API to check repository permissions
309
+ const permResult = await $`gh api repos/${owner}/${repo} --jq .permissions`;
310
+ if (permResult.code !== 0) {
311
+ // API call failed - might be a private repo or network issue
312
+ const errorOutput = (permResult.stderr ? permResult.stderr.toString() : '') +
313
+ (permResult.stdout ? permResult.stdout.toString() : '');
314
+ // If it's a 404, the repo doesn't exist or we don't have read access
315
+ if (errorOutput.includes('404') || errorOutput.includes('Not Found')) {
316
+ await log('āŒ Repository not found or no access', { level: 'error' });
317
+ await log(` Repository: ${owner}/${repo}`, { level: 'error' });
318
+ return false;
319
+ }
320
+ // For other errors, warn but continue (repo might still be accessible)
321
+ await log(`āš ļø Warning: Could not check repository permissions: ${cleanErrorMessage(errorOutput)}`, { level: 'warning' });
322
+ return true;
323
+ }
324
+ // Parse permissions
325
+ const permissions = JSON.parse(permResult.stdout.toString().trim());
326
+ // Check if user has push (write) access
327
+ if (permissions.push === true || permissions.admin === true || permissions.maintain === true) {
328
+ await log('āœ… Repository write access: Confirmed');
329
+ return true;
330
+ }
331
+ // No write access - provide helpful error message
332
+ await log('');
333
+ await log('āŒ NO WRITE ACCESS TO REPOSITORY', { level: 'error' });
334
+ await log('');
335
+ await log(` Repository: ${owner}/${repo}`, { level: 'error' });
336
+ await log(` Your permissions: ${JSON.stringify(permissions)}`, { level: 'error' });
337
+ await log('');
338
+ await log(' āš ļø You cannot push changes to this repository.', { level: 'error' });
339
+ await log(' This would waste AI tokens processing a solution that cannot be uploaded.', { level: 'error' });
340
+ await log('');
341
+ await log(' šŸ“‹ SOLUTIONS:', { level: 'error' });
342
+ await log('');
343
+ await log(' āœ… RECOMMENDED: Use the --fork option', { level: 'error' });
344
+ await log(' This will automatically:', { level: 'error' });
345
+ await log(' • Create or use your existing fork', { level: 'error' });
346
+ await log(' • Push changes to your fork', { level: 'error' });
347
+ await log(' • Create a PR from your fork to the original repository', { level: 'error' });
348
+ await log('');
349
+ // Get current user to suggest their fork
350
+ try {
351
+ const userResult = await $`gh api user --jq .login`;
352
+ if (userResult.code === 0) {
353
+ const currentUser = userResult.stdout.toString().trim();
354
+ await log(' Run this command:', { level: 'error' });
355
+ await log(` solve ${issueUrl} --fork`, { level: 'error' });
356
+ await log('');
357
+ await log(` Your fork will be: ${currentUser}/${repo}`, { level: 'error' });
358
+ }
359
+ } catch {
360
+ // Ignore user lookup errors
361
+ }
362
+ await log('');
363
+ await log(' Alternative: Request collaborator access', { level: 'error' });
364
+ await log(' Ask the repository owner to add you as a collaborator:', { level: 'error' });
365
+ await log(` https://github.com/${owner}/${repo}/settings/access`, { level: 'error' });
366
+ await log('');
367
+ return false;
368
+ } catch (error) {
369
+ reportError(error, {
370
+ context: 'check_repository_write_permission',
371
+ owner,
372
+ repo,
373
+ operation: 'verify_write_access'
374
+ });
375
+ // On unexpected errors, warn but allow to continue (better than blocking)
376
+ await log(`āš ļø Warning: Error checking repository permissions: ${cleanErrorMessage(error)}`, { level: 'warning' });
377
+ await log(' Continuing anyway - will fail later if permissions are insufficient', { level: 'warning' });
378
+ return true;
379
+ }
380
+ };
381
+ /**
382
+ * Check if maintainer can modify (push to) a pull request from a fork
383
+ * This checks the 'maintainer_can_modify' field which indicates if the PR author
384
+ * has enabled "Allow edits by maintainers" checkbox
385
+ * @param {string} owner - Repository owner (upstream repo)
386
+ * @param {string} repo - Repository name
387
+ * @param {number} prNumber - Pull request number
388
+ * @returns {Promise<{canModify: boolean, forkOwner: string|null, forkRepo: string|null}>}
389
+ */
390
+ export const checkMaintainerCanModifyPR = async (owner, repo, prNumber) => {
391
+ try {
392
+ await log('šŸ” Checking if maintainer can modify PR...', { verbose: true });
393
+ // Use GitHub API to check PR details including maintainer_can_modify
394
+ const prResult = await $`gh api repos/${owner}/${repo}/pulls/${prNumber} --jq '{maintainer_can_modify: .maintainer_can_modify, head: .head}'`;
395
+ if (prResult.code !== 0) {
396
+ const errorOutput = (prResult.stderr ? prResult.stderr.toString() : '') +
397
+ (prResult.stdout ? prResult.stdout.toString() : '');
398
+ await log(`āš ļø Warning: Could not check maintainer_can_modify: ${cleanErrorMessage(errorOutput)}`, { level: 'warning' });
399
+ return { canModify: false, forkOwner: null, forkRepo: null };
400
+ }
401
+ // Parse PR data
402
+ const prData = JSON.parse(prResult.stdout.toString().trim());
403
+ const canModify = prData.maintainer_can_modify === true;
404
+ const forkOwner = prData.head?.user?.login || prData.head?.repo?.owner?.login || null;
405
+ const forkRepo = prData.head?.repo?.name || null;
406
+ if (canModify) {
407
+ await log('āœ… Maintainer can modify: YES (contributor enabled "Allow edits by maintainers")', { verbose: true });
408
+ if (forkOwner && forkRepo) {
409
+ await log(` Fork: ${forkOwner}/${forkRepo}`, { verbose: true });
410
+ }
411
+ } else {
412
+ await log('ā„¹ļø Maintainer can modify: NO (contributor has not enabled "Allow edits by maintainers")', { verbose: true });
413
+ }
414
+ return { canModify, forkOwner, forkRepo };
415
+ } catch (error) {
416
+ reportError(error, {
417
+ context: 'check_maintainer_can_modify_pr',
418
+ owner,
419
+ repo,
420
+ prNumber,
421
+ operation: 'check_maintainer_modify_permission'
422
+ });
423
+ await log(`āš ļø Warning: Error checking maintainer_can_modify: ${cleanErrorMessage(error)}`, { level: 'warning' });
424
+ return { canModify: false, forkOwner: null, forkRepo: null };
425
+ }
426
+ };
427
+ /**
428
+ * Post a comment on a PR asking the contributor to enable "Allow edits by maintainers"
429
+ * @param {string} owner - Repository owner
430
+ * @param {string} repo - Repository name
431
+ * @param {number} prNumber - Pull request number
432
+ * @returns {Promise<boolean>} True if comment was posted successfully
433
+ */
434
+ export const requestMaintainerAccess = async (owner, repo, prNumber) => {
435
+ try {
436
+ await log('šŸ“ Posting comment to request maintainer access...', { verbose: true });
437
+ const commentBody = `Hello! šŸ‘‹
438
+ I'm a maintainer trying to help with this PR, but I need access to push changes directly to your fork.
439
+ Could you please enable the **"Allow edits by maintainers"** checkbox? This will let me push updates directly to this PR.
440
+ **How to enable it:**
441
+ 1. Go to the bottom of this PR page
442
+ 2. Find the "Allow edits by maintainers" checkbox in the sidebar (on the right side)
443
+ 3. Check the box āœ…
444
+ Alternatively, you can enable it when creating/editing the PR. See: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork
445
+ Thank you! šŸ™`;
446
+ const commentResult = await $`gh pr comment ${prNumber} --repo ${owner}/${repo} --body ${commentBody}`;
447
+ if (commentResult.code === 0) {
448
+ await log('āœ… Comment posted successfully', { verbose: true });
449
+ return true;
450
+ } else {
451
+ const errorOutput = (commentResult.stderr ? commentResult.stderr.toString() : '') +
452
+ (commentResult.stdout ? commentResult.stdout.toString() : '');
453
+ await log(`āš ļø Warning: Failed to post comment: ${cleanErrorMessage(errorOutput)}`, { level: 'warning' });
454
+ return false;
455
+ }
456
+ } catch (error) {
457
+ reportError(error, {
458
+ context: 'request_maintainer_access',
459
+ owner,
460
+ repo,
461
+ prNumber,
462
+ operation: 'post_comment_request_access'
463
+ });
464
+ await log(`āš ļø Warning: Error posting comment: ${cleanErrorMessage(error)}`, { level: 'warning' });
465
+ return false;
466
+ }
467
+ };
468
+ /**
469
+ * Attaches a log file to a GitHub PR or issue as a comment
470
+ * @param {Object} options - Configuration options
471
+ * @param {string} options.logFile - Path to the log file
472
+ * @param {string} options.targetType - 'pr' or 'issue'
473
+ * @param {number} options.targetNumber - PR or issue number
474
+ * @param {string} options.owner - Repository owner
475
+ * @param {string} options.repo - Repository name
476
+ * @param {Function} options.$ - Command execution function
477
+ * @param {Function} options.log - Logging function
478
+ * @param {Function} options.sanitizeLogContent - Function to sanitize log content
479
+ * @param {boolean} [options.verbose=false] - Enable verbose logging
480
+ * @param {string} [options.errorMessage] - Error message to include in comment (for failure logs)
481
+ * @param {string} [options.customTitle] - Custom title for the comment (defaults to "šŸ¤– Solution Draft Log")
482
+ * @param {boolean} [options.isUsageLimit] - Whether this is a usage limit error
483
+ * @param {string} [options.limitResetTime] - Time when usage limit resets
484
+ * @param {string} [options.toolName] - Name of the tool (claude, codex, opencode)
485
+ * @param {string} [options.resumeCommand] - Command to resume the session
486
+ * @returns {Promise<boolean>} - True if upload succeeded
487
+ */
488
+ export async function attachLogToGitHub(options) {
489
+ const fs = (await use('fs')).promises;
490
+ const {
491
+ logFile,
492
+ targetType,
493
+ targetNumber,
494
+ owner,
495
+ repo,
496
+ $,
497
+ log,
498
+ sanitizeLogContent,
499
+ verbose = false,
500
+ errorMessage,
501
+ customTitle = 'šŸ¤– Solution Draft Log',
502
+ sessionId = null,
503
+ tempDir = null,
504
+ anthropicTotalCostUSD = null,
505
+ isUsageLimit = false,
506
+ limitResetTime = null,
507
+ toolName = 'AI tool',
508
+ resumeCommand = null,
509
+ // New parameters for agent tool pricing support
510
+ publicPricingEstimate = null,
511
+ pricingInfo = null
512
+ } = options;
513
+ const targetName = targetType === 'pr' ? 'Pull Request' : 'Issue';
514
+ const ghCommand = targetType === 'pr' ? 'pr' : 'issue';
515
+ try {
516
+ // Check if log file exists and is not empty
517
+ const logStats = await fs.stat(logFile);
518
+ if (logStats.size === 0) {
519
+ await log(' āš ļø Log file is empty, skipping upload');
520
+ return false;
521
+ } else if (logStats.size > githubLimits.fileMaxSize) {
522
+ await log(` āš ļø Log file too large (${Math.round(logStats.size / 1024 / 1024)}MB), GitHub limit is ${Math.round(githubLimits.fileMaxSize / 1024 / 1024)}MB`);
523
+ return false;
524
+ }
525
+ // Calculate token usage if sessionId and tempDir are provided
526
+ // For agent tool, publicPricingEstimate is already provided, so we skip Claude-specific calculation
527
+ let totalCostUSD = publicPricingEstimate;
528
+ if (totalCostUSD === null && sessionId && tempDir && !errorMessage) {
529
+ try {
530
+ const { calculateSessionTokens } = await import('./claude.lib.mjs');
531
+ const tokenUsage = await calculateSessionTokens(sessionId, tempDir);
532
+ if (tokenUsage) {
533
+ if (tokenUsage.totalCostUSD !== null && tokenUsage.totalCostUSD !== undefined) {
534
+ totalCostUSD = tokenUsage.totalCostUSD;
535
+ if (verbose) {
536
+ await log(` šŸ’° Calculated cost: $${totalCostUSD.toFixed(6)}`, { verbose: true });
537
+ }
538
+ }
539
+ }
540
+ } catch (tokenError) {
541
+ // Don't fail the entire upload if token calculation fails
542
+ if (verbose) {
543
+ await log(` āš ļø Could not calculate token cost: ${tokenError.message}`, { verbose: true });
544
+ }
545
+ }
546
+ }
547
+ // Read and sanitize log content
548
+ const rawLogContent = await fs.readFile(logFile, 'utf8');
549
+ if (verbose) {
550
+ await log(' šŸ” Sanitizing log content to mask GitHub tokens...', { verbose: true });
551
+ }
552
+ let logContent = await sanitizeLogContent(rawLogContent);
553
+
554
+ // Escape code blocks in the log content to prevent them from breaking markdown formatting
555
+ if (verbose) {
556
+ await log(' šŸ”§ Escaping code blocks in log content for safe embedding...', { verbose: true });
557
+ }
558
+ logContent = escapeCodeBlocksInLog(logContent);
559
+ // Create formatted comment
560
+ let logComment;
561
+ // Usage limit comments should be shown whenever isUsageLimit is true,
562
+ // regardless of whether a generic errorMessage is provided.
563
+ if (isUsageLimit) {
564
+ // Usage limit error format - separate from general failures
565
+ logComment = `## ā³ Usage Limit Reached
566
+
567
+ The automated solution draft was interrupted because the ${toolName} usage limit was reached.
568
+
569
+ ### šŸ“Š Limit Information
570
+ - **Tool**: ${toolName}
571
+ - **Limit Type**: Usage limit exceeded`;
572
+
573
+ if (limitResetTime) {
574
+ logComment += `\n- **Reset Time**: ${limitResetTime}`;
575
+ }
576
+
577
+ if (sessionId) {
578
+ logComment += `\n- **Session ID**: ${sessionId}`;
579
+ }
580
+
581
+ logComment += '\n\n### šŸ”„ How to Continue\n';
582
+
583
+ if (limitResetTime) {
584
+ logComment += `Once the limit resets at **${limitResetTime}**, `;
585
+ } else {
586
+ logComment += 'Once the limit resets, ';
587
+ }
588
+
589
+ if (resumeCommand) {
590
+ logComment += `you can resume this session by running:
591
+ \`\`\`bash
592
+ ${resumeCommand}
593
+ \`\`\``;
594
+ } else if (sessionId) {
595
+ logComment += `you can resume this session using session ID: \`${sessionId}\``;
596
+ } else {
597
+ logComment += 'you can retry the operation.';
598
+ }
599
+
600
+ logComment += `
601
+
602
+ <details>
603
+ <summary>Click to expand execution log (${Math.round(logStats.size / 1024)}KB)</summary>
604
+
605
+ \`\`\`
606
+ ${logContent}
607
+ \`\`\`
608
+ </details>
609
+
610
+ ---
611
+ *This session was interrupted due to usage limits. You can resume once the limit resets.*`;
612
+ } else if (errorMessage) {
613
+ // Failure log format (non-usage-limit errors)
614
+ logComment = `## 🚨 Solution Draft Failed
615
+ The automated solution draft encountered an error:
616
+ \`\`\`
617
+ ${errorMessage}
618
+ \`\`\`
619
+ <details>
620
+ <summary>Click to expand failure log (${Math.round(logStats.size / 1024)}KB)</summary>
621
+ \`\`\`
622
+ ${logContent}
623
+ \`\`\`
624
+ </details>
625
+ ---
626
+ *Now working session is ended, feel free to review and add any feedback on the solution draft.*`;
627
+ } else {
628
+ // Success log format - use helper function for cost info
629
+ const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo);
630
+ logComment = `## ${customTitle}
631
+ This log file contains the complete execution trace of the AI ${targetType === 'pr' ? 'solution draft' : 'analysis'} process.${costInfo}
632
+ <details>
633
+ <summary>Click to expand solution draft log (${Math.round(logStats.size / 1024)}KB)</summary>
634
+ \`\`\`
635
+ ${logContent}
636
+ \`\`\`
637
+ </details>
638
+ ---
639
+ *Now working session is ended, feel free to review and add any feedback on the solution draft.*`;
640
+ }
641
+ // Check GitHub comment size limit
642
+ let commentResult;
643
+ if (logComment.length > githubLimits.commentMaxSize) {
644
+ await log(` āš ļø Log comment too long (${logComment.length} chars), GitHub limit is ${githubLimits.commentMaxSize} chars`);
645
+ await log(' šŸ“Ž Uploading log as GitHub Gist instead...');
646
+ try {
647
+ // Check if repository is public or private
648
+ let isPublicRepo = true;
649
+ try {
650
+ const repoVisibilityResult = await $`gh api repos/${owner}/${repo} --jq .visibility`;
651
+ if (repoVisibilityResult.code === 0) {
652
+ const visibility = repoVisibilityResult.stdout.toString().trim();
653
+ isPublicRepo = visibility === 'public';
654
+ if (verbose) {
655
+ await log(` šŸ” Repository visibility: ${visibility}`, { verbose: true });
656
+ }
657
+ }
658
+ } catch (visibilityError) {
659
+ reportError(visibilityError, {
660
+ context: 'check_repo_visibility',
661
+ level: 'warning',
662
+ owner,
663
+ repo
664
+ });
665
+ // Default to public if we can't determine visibility
666
+ await log(' āš ļø Could not determine repository visibility, defaulting to public gist', { verbose: true });
667
+ }
668
+ // Create gist with appropriate visibility
669
+ // Note: Gists don't need escaping because they are uploaded as plain text files
670
+ const tempLogFile = `/tmp/solution-draft-log-${targetType}-${Date.now()}.txt`;
671
+ // Use the original sanitized content (before escaping) for gist since it's a text file
672
+ await fs.writeFile(tempLogFile, await sanitizeLogContent(rawLogContent));
673
+ const gistCommand = isPublicRepo
674
+ ? `gh gist create "${tempLogFile}" --public --desc "Solution draft log for https://github.com/${owner}/${repo}/${targetType === 'pr' ? 'pull' : 'issues'}/${targetNumber}" --filename "solution-draft-log.txt"`
675
+ : `gh gist create "${tempLogFile}" --desc "Solution draft log for https://github.com/${owner}/${repo}/${targetType === 'pr' ? 'pull' : 'issues'}/${targetNumber}" --filename "solution-draft-log.txt"`;
676
+ if (verbose) {
677
+ await log(` šŸ” Creating ${isPublicRepo ? 'public' : 'private'} gist...`, { verbose: true });
678
+ }
679
+ const gistResult = await $(gistCommand);
680
+ await fs.unlink(tempLogFile).catch(() => {});
681
+ if (gistResult.code === 0) {
682
+ const gistPageUrl = gistResult.stdout.toString().trim();
683
+ // Extract gist ID from URL
684
+ const gistId = gistPageUrl.split('/').pop();
685
+ // Construct raw file URL
686
+ // Format: https://gist.githubusercontent.com/{owner}/{gist_id}/raw/{commit_sha}/{filename}
687
+ // We use gh api to get the gist details for owner and commit SHA
688
+ let gistUrl = gistPageUrl; // fallback to page URL if we can't get raw URL
689
+
690
+ const gistDetailsResult = await $`gh api gists/${gistId} --jq '{owner: .owner.login, files: .files, history: .history}'`;
691
+ if (gistDetailsResult.code === 0) {
692
+ const gistDetails = JSON.parse(gistDetailsResult.stdout.toString());
693
+ const commitSha = gistDetails.history && gistDetails.history[0] ? gistDetails.history[0].version : null;
694
+ // Get the actual filename from the gist API response (--filename flag only works with stdin)
695
+ const fileNames = gistDetails.files ? Object.keys(gistDetails.files) : [];
696
+ const fileName = fileNames.length > 0 ? fileNames[0] : 'solution-draft-log.txt';
697
+
698
+ if (commitSha) {
699
+ gistUrl = `https://gist.githubusercontent.com/${gistDetails.owner}/${gistId}/raw/${commitSha}/${fileName}`;
700
+ } else {
701
+ // Fallback: use simpler format without commit SHA (GitHub will redirect to latest)
702
+ gistUrl = `https://gist.githubusercontent.com/${gistDetails.owner}/${gistId}/raw/${fileName}`;
703
+ }
704
+ }
705
+ // Create comment with gist link
706
+ let gistComment;
707
+ // For usage limit cases, always use the dedicated format regardless of errorMessage
708
+ if (isUsageLimit) {
709
+ // Usage limit error gist format
710
+ gistComment = `## ā³ Usage Limit Reached
711
+
712
+ The automated solution draft was interrupted because the ${toolName} usage limit was reached.
713
+
714
+ ### šŸ“Š Limit Information
715
+ - **Tool**: ${toolName}
716
+ - **Limit Type**: Usage limit exceeded`;
717
+
718
+ if (limitResetTime) {
719
+ gistComment += `\n- **Reset Time**: ${limitResetTime}`;
720
+ }
721
+
722
+ if (sessionId) {
723
+ gistComment += `\n- **Session ID**: ${sessionId}`;
724
+ }
725
+
726
+ gistComment += '\n\n### šŸ”„ How to Continue\n';
727
+
728
+ if (limitResetTime) {
729
+ gistComment += `Once the limit resets at **${limitResetTime}**, `;
730
+ } else {
731
+ gistComment += 'Once the limit resets, ';
732
+ }
733
+
734
+ if (resumeCommand) {
735
+ gistComment += `you can resume this session by running:
736
+ \`\`\`bash
737
+ ${resumeCommand}
738
+ \`\`\``;
739
+ } else if (sessionId) {
740
+ gistComment += `you can resume this session using session ID: \`${sessionId}\``;
741
+ } else {
742
+ gistComment += 'you can retry the operation.';
743
+ }
744
+
745
+ gistComment += `
746
+
747
+ šŸ“Ž **Execution log uploaded as GitHub Gist** (${Math.round(logStats.size / 1024)}KB)
748
+ šŸ”— [View complete execution log](${gistUrl})
749
+
750
+ ---
751
+ *This session was interrupted due to usage limits. You can resume once the limit resets.*`;
752
+ } else if (errorMessage) {
753
+ // Failure log gist format (non-usage-limit errors)
754
+ gistComment = `## 🚨 Solution Draft Failed
755
+ The automated solution draft encountered an error:
756
+ \`\`\`
757
+ ${errorMessage}
758
+ \`\`\`
759
+ šŸ“Ž **Failure log uploaded as GitHub Gist** (${Math.round(logStats.size / 1024)}KB)
760
+ šŸ”— [View complete failure log](${gistUrl})
761
+ ---
762
+ *Now working session is ended, feel free to review and add any feedback on the solution draft.*`;
763
+ } else {
764
+ // Success log gist format - use helper function for cost info
765
+ const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo);
766
+ gistComment = `## ${customTitle}
767
+ This log file contains the complete execution trace of the AI ${targetType === 'pr' ? 'solution draft' : 'analysis'} process.${costInfo}
768
+ šŸ“Ž **Log file uploaded as GitHub Gist** (${Math.round(logStats.size / 1024)}KB)
769
+ šŸ”— [View complete solution draft log](${gistUrl})
770
+ ---
771
+ *Now working session is ended, feel free to review and add any feedback on the solution draft.*`;
772
+ }
773
+ const tempGistCommentFile = `/tmp/log-gist-comment-${targetType}-${Date.now()}.md`;
774
+ await fs.writeFile(tempGistCommentFile, gistComment);
775
+ commentResult = await $`gh ${ghCommand} comment ${targetNumber} --repo ${owner}/${repo} --body-file "${tempGistCommentFile}"`;
776
+ await fs.unlink(tempGistCommentFile).catch(() => {});
777
+ if (commentResult.code === 0) {
778
+ await log(` āœ… Solution draft log uploaded to ${targetName} as ${isPublicRepo ? 'public' : 'private'} Gist`);
779
+ await log(` šŸ”— Gist URL: ${gistUrl}`);
780
+ await log(` šŸ“Š Log size: ${Math.round(logStats.size / 1024)}KB`);
781
+ return true;
782
+ } else {
783
+ await log(` āŒ Failed to upload comment with gist link: ${commentResult.stderr ? commentResult.stderr.toString().trim() : 'unknown error'}`);
784
+ return false;
785
+ }
786
+ } else {
787
+ await log(` āŒ Failed to create gist: ${gistResult.stderr ? gistResult.stderr.toString().trim() : 'unknown error'}`);
788
+
789
+ // Fallback to truncated comment
790
+ await log(' šŸ”„ Falling back to truncated comment...');
791
+ return await attachTruncatedLog(options);
792
+ }
793
+ } catch (gistError) {
794
+ reportError(gistError, {
795
+ context: 'create_gist',
796
+ level: 'error'
797
+ });
798
+ await log(` āŒ Error creating gist: ${gistError.message}`);
799
+ // Try regular comment as last resort
800
+ return await attachRegularComment(options, logComment);
801
+ }
802
+ } else {
803
+ // Comment fits within limit
804
+ return await attachRegularComment(options, logComment);
805
+ }
806
+ } catch (uploadError) {
807
+ await log(` āŒ Error uploading log file: ${uploadError.message}`);
808
+ return false;
809
+ }
810
+ }
811
+ /**
812
+ * Helper to attach a truncated log when full log is too large
813
+ */
814
+ async function attachTruncatedLog(options) {
815
+ const fs = (await use('fs')).promises;
816
+ const { logFile, targetType, targetNumber, owner, repo, $, log, sanitizeLogContent } = options;
817
+
818
+ const targetName = targetType === 'pr' ? 'Pull Request' : 'Issue';
819
+ const ghCommand = targetType === 'pr' ? 'pr' : 'issue';
820
+
821
+ const rawLogContent = await fs.readFile(logFile, 'utf8');
822
+ let logContent = await sanitizeLogContent(rawLogContent);
823
+ // Escape code blocks to prevent markdown breaking
824
+ logContent = escapeCodeBlocksInLog(logContent);
825
+ const logStats = await fs.stat(logFile);
826
+
827
+ const GITHUB_COMMENT_LIMIT = 65536;
828
+ const maxContentLength = GITHUB_COMMENT_LIMIT - 500;
829
+ const truncatedContent = logContent.substring(0, maxContentLength) + '\n\n[... Log truncated due to length ...]';
830
+
831
+ const truncatedComment = `## šŸ¤– Solution Draft Log (Truncated)
832
+ This log file contains the complete execution trace of the AI ${targetType === 'pr' ? 'solution draft' : 'analysis'} process.
833
+ āš ļø **Log was truncated** due to GitHub comment size limits.
834
+ <details>
835
+ <summary>Click to expand solution draft log (${Math.round(logStats.size / 1024)}KB, truncated)</summary>
836
+ \`\`\`
837
+ ${truncatedContent}
838
+ \`\`\`
839
+ </details>
840
+ ---
841
+ *Now working session is ended, feel free to review and add any feedback on the solution draft.*`;
842
+ const tempFile = `/tmp/log-truncated-comment-${targetType}-${Date.now()}.md`;
843
+ await fs.writeFile(tempFile, truncatedComment);
844
+
845
+ const result = await $`gh ${ghCommand} comment ${targetNumber} --repo ${owner}/${repo} --body-file "${tempFile}"`;
846
+
847
+ await fs.unlink(tempFile).catch(() => {});
848
+
849
+ if (result.code === 0) {
850
+ await log(` āœ… Truncated solution draft log uploaded to ${targetName}`);
851
+ await log(` šŸ“Š Log size: ${Math.round(logStats.size / 1024)}KB (truncated)`);
852
+ return true;
853
+ } else {
854
+ await log(` āŒ Failed to upload truncated log: ${result.stderr ? result.stderr.toString().trim() : 'unknown error'}`);
855
+ return false;
856
+ }
857
+ }
858
+ /**
859
+ * Helper to attach a regular comment when it fits within limits
860
+ */
861
+ async function attachRegularComment(options, logComment) {
862
+ const fs = (await use('fs')).promises;
863
+ const { targetType, targetNumber, owner, repo, $, log, logFile } = options;
864
+
865
+ const targetName = targetType === 'pr' ? 'Pull Request' : 'Issue';
866
+ const ghCommand = targetType === 'pr' ? 'pr' : 'issue';
867
+ const logStats = await fs.stat(logFile);
868
+
869
+ const tempFile = `/tmp/log-comment-${targetType}-${Date.now()}.md`;
870
+ await fs.writeFile(tempFile, logComment);
871
+
872
+ const result = await $`gh ${ghCommand} comment ${targetNumber} --repo ${owner}/${repo} --body-file "${tempFile}"`;
873
+
874
+ await fs.unlink(tempFile).catch(() => {});
875
+
876
+ if (result.code === 0) {
877
+ await log(` āœ… Solution draft log uploaded to ${targetName} as comment`);
878
+ await log(` šŸ“Š Log size: ${Math.round(logStats.size / 1024)}KB`);
879
+ return true;
880
+ } else {
881
+ await log(` āŒ Failed to upload log to ${targetName}: ${result.stderr ? result.stderr.toString().trim() : 'unknown error'}`);
882
+ return false;
883
+ }
884
+ }
885
+ /**
886
+ * Detects if an error is due to GitHub API rate limiting
887
+ * @param {Error|string} error - The error to check
888
+ * @returns {boolean} True if the error indicates rate limiting
889
+ */
890
+ export function isRateLimitError(error) {
891
+ const errorMessage = (error.message || error.toString()).toLowerCase();
892
+ // Common rate limit error patterns
893
+ const rateLimitPatterns = [
894
+ 'rate limit',
895
+ 'secondary rate limit',
896
+ 'exceeded.*limit',
897
+ 'too many requests',
898
+ 'abuse detection',
899
+ 'wait a few minutes',
900
+ 'http 403.*rate',
901
+ 'api rate limit exceeded'
902
+ ];
903
+ return rateLimitPatterns.some(pattern => {
904
+ return new RegExp(pattern).test(errorMessage);
905
+ });
906
+ }
907
+ /**
908
+ * Helper function to fetch all issues with pagination and rate limiting
909
+ * Note: GitHub Search API has a hard limit of 1000 results total.
910
+ * For user/org queries with >1000 issues, the fetchIssuesFromRepositories fallback should be used.
911
+ * @param {string} baseCommand - The base gh command to execute
912
+ * @returns {Promise<Array>} Array of issues
913
+ */
914
+ export async function fetchAllIssuesWithPagination(baseCommand) {
915
+ const { exec } = await import('child_process');
916
+ const { promisify } = await import('util');
917
+ const execAsync = promisify(exec);
918
+ // Import log and cleanErrorMessage from lib.mjs
919
+ const { log, cleanErrorMessage } = await import('./lib.mjs');
920
+ try {
921
+ // First, try without pagination to see if we get more than the default limit
922
+ await log(' šŸ“Š Fetching issues with improved limits and rate limiting...', { verbose: true });
923
+ // Add a 5-second delay before making the API call to respect rate limits
924
+ await log(' ā° Waiting 5 seconds before API call to respect rate limits...', { verbose: true });
925
+ await new Promise(resolve => setTimeout(resolve, timeouts.githubApiDelay));
926
+ const startTime = Date.now();
927
+ // Use appropriate page sizes: 100 for search API (more restrictive), 1000 for regular listing
928
+ const commandWithoutLimit = baseCommand.replace(/--limit\s+\d+/, '');
929
+ const isSearchCommand = commandWithoutLimit.includes('gh search');
930
+ const maxPageSize = isSearchCommand ? 100 : 1000;
931
+ const improvedCommand = `${commandWithoutLimit} --limit ${maxPageSize}`;
932
+ await log(` šŸ”Ž Executing: ${improvedCommand}`, { verbose: true });
933
+ const { stdout } = await execAsync(improvedCommand, { encoding: 'utf8', env: process.env });
934
+ const endTime = Date.now();
935
+ const issues = JSON.parse(stdout || '[]');
936
+ await log(` āœ… Fetched ${issues.length} issues in ${Math.round((endTime - startTime) / 1000)}s`);
937
+ // If we got exactly the max page size, there might be more - log a warning and throw error to trigger fallback
938
+ if (issues.length === maxPageSize) {
939
+ await log(` āš ļø Hit the ${maxPageSize} issue limit - there may be more issues available`, { level: 'warning' });
940
+ if (isSearchCommand) {
941
+ await log(' šŸ’” GitHub Search API is limited to 1000 results max. Triggering repository fallback for complete results.', { level: 'info' });
942
+ // Throw an error to trigger the fallback to fetchIssuesFromRepositories which uses GraphQL pagination
943
+ throw new Error(`Hit search API limit of ${maxPageSize} issues - need repository-by-repository fallback for complete results`);
944
+ } else if (maxPageSize >= 1000) {
945
+ await log(` šŸ’” Consider filtering by labels or date ranges for repositories with >${maxPageSize} open issues`, { level: 'info' });
946
+ }
947
+ }
948
+ // Add a 5-second delay after the call to be extra safe with rate limits
949
+ await log(' ā° Adding 5-second delay after API call to respect rate limits...', { verbose: true });
950
+ await new Promise(resolve => setTimeout(resolve, timeouts.githubApiDelay));
951
+ return issues;
952
+ } catch (error) {
953
+ await log(` āŒ Enhanced fetch failed: ${cleanErrorMessage(error)}`, { level: 'error' });
954
+ // Check if this is a rate limit error - if so, re-throw immediately
955
+ if (isRateLimitError(error)) {
956
+ await log(' āš ļø Rate limit detected - re-throwing for caller to handle', { verbose: true });
957
+ throw error;
958
+ }
959
+ // Check if this is the "hit search API limit" error - if so, re-throw to trigger repository fallback
960
+ const errorMsg = error.message || error.toString();
961
+ if (errorMsg.includes('Hit search API limit') || errorMsg.includes('repository-by-repository fallback')) {
962
+ await log(' šŸ”„ Re-throwing error to trigger repository-by-repository fallback...', { verbose: true });
963
+ throw error;
964
+ }
965
+ // For other errors, try a simple fallback with default limit
966
+ try {
967
+ await log(' šŸ”„ Falling back to default behavior...', { verbose: true });
968
+ const fallbackCommand = baseCommand.includes('--limit') ? baseCommand : `${baseCommand} --limit 100`;
969
+ await new Promise(resolve => setTimeout(resolve, timeouts.githubRepoDelay)); // Shorter delay for fallback
970
+ const { stdout } = await execAsync(fallbackCommand, { encoding: 'utf8', env: process.env });
971
+ const issues = JSON.parse(stdout || '[]');
972
+ await log(` āš ļø Fallback: fetched ${issues.length} issues (limited to 100)`, { level: 'warning' });
973
+ return issues;
974
+ } catch (fallbackError) {
975
+ await log(` āŒ Fallback also failed: ${cleanErrorMessage(fallbackError)}`, { level: 'error' });
976
+ // Re-throw the error so the caller can handle it appropriately
977
+ throw fallbackError;
978
+ }
979
+ }
980
+ }
981
+ // Function to fetch issues from GitHub Projects v2
982
+ export async function fetchProjectIssues(projectNumber, owner, statusFilter) {
983
+ try {
984
+ await log(`šŸ” Fetching issues from GitHub Project #${projectNumber} (owner: ${owner}, status: ${statusFilter})`);
985
+ // Check for project scope in GitHub CLI authentication
986
+ try {
987
+ const authStatus = await $`gh auth status --show-token`;
988
+ if (!authStatus.stdout.includes('project')) {
989
+ throw new Error('Missing project scope. Run: gh auth refresh -s project');
990
+ }
991
+ } catch (error) {
992
+ reportError(error, {
993
+ context: 'github.lib.mjs - GitHub CLI auth status check',
994
+ level: 'error'
995
+ });
996
+ throw new Error('GitHub CLI authentication failed. Please run: gh auth login');
997
+ }
998
+ // Add delay to respect rate limits
999
+ await log(' ā° Waiting 2 seconds before API call to respect rate limits...', { verbose: true });
1000
+ await new Promise(resolve => setTimeout(resolve, timeouts.githubRepoDelay));
1001
+ const startTime = Date.now();
1002
+ // Fetch all project items
1003
+ await log(` šŸ”Ž Executing: gh project item-list ${projectNumber} --owner ${owner} --format json --limit 100`, { verbose: true });
1004
+ const result = await $`gh project item-list ${projectNumber} --owner ${owner} --format json --limit 100`;
1005
+ const endTime = Date.now();
1006
+ const projectData = JSON.parse(result.stdout || '{"items": []}');
1007
+ const allItems = projectData.items || [];
1008
+ await log(` šŸ“Š Found ${allItems.length} total project items in ${Math.round((endTime - startTime) / 1000)}s`);
1009
+ // Filter by status and item type (only Issues)
1010
+ const filteredIssues = allItems.filter(item => {
1011
+ // Check if it's an Issue (not PR, Discussion, etc.)
1012
+ if (item.content?.type !== 'Issue') {
1013
+ return false;
1014
+ }
1015
+ // Check status field - look for Status field in fieldValueByName
1016
+ const statusField = item.fieldValueByName?.Status;
1017
+ if (!statusField) {
1018
+ // If no status field, skip this item
1019
+ return false;
1020
+ }
1021
+ // Match against configured status value
1022
+ return statusField.name === statusFilter;
1023
+ });
1024
+ // Extract issue information
1025
+ const issues = filteredIssues.map(item => ({
1026
+ url: item.content.url,
1027
+ title: item.content.title,
1028
+ number: item.content.number,
1029
+ repository: item.content.repository,
1030
+ labels: item.content.labels || [],
1031
+ state: item.content.state || 'open'
1032
+ }));
1033
+ await log(` āœ… Found ${issues.length} issues with status "${statusFilter}"`);
1034
+ if (issues.length > 0) {
1035
+ await log(' šŸ“‹ Issues found:', { verbose: true });
1036
+ for (const issue of issues) {
1037
+ await log(` • #${issue.number}: ${issue.title}`, { verbose: true });
1038
+ }
1039
+ }
1040
+ // Add delay after API call
1041
+ await log(' ā° Adding 2-second delay after API call to respect rate limits...', { verbose: true });
1042
+ await new Promise(resolve => setTimeout(resolve, timeouts.githubRepoDelay));
1043
+ return issues;
1044
+ } catch (error) {
1045
+ await log(` āŒ Failed to fetch project issues: ${cleanErrorMessage(error)}`, { level: 'error' });
1046
+ // Provide helpful error messages for common issues
1047
+ if (error.message.includes('project scope')) {
1048
+ await log(' šŸ’” To fix this, run: gh auth refresh -s project', { level: 'info' });
1049
+ } else if (error.message.includes('authentication')) {
1050
+ await log(' šŸ’” To fix this, run: gh auth login', { level: 'info' });
1051
+ } else if (error.message.includes('not found') || error.message.includes('404')) {
1052
+ await log(' šŸ’” Check that the project number and owner are correct', { level: 'info' });
1053
+ await log(' šŸ’” Make sure you have access to the project', { level: 'info' });
1054
+ }
1055
+ return [];
1056
+ }
1057
+ }
1058
+ // Re-export batch operations from separate module
1059
+ export const batchCheckPullRequestsForIssues = batchCheckPRs;
1060
+ /**
1061
+ * Universal GitHub URL parser that handles various formats
1062
+ * @param {string} url - The GitHub URL to parse
1063
+ * @returns {Object} Parsed URL information including:
1064
+ * - valid: boolean indicating if the URL is valid
1065
+ * - normalized: the normalized URL (https://github.com/...)
1066
+ * - type: 'user', 'repo', 'issue', 'pull', 'gist', 'actions', etc.
1067
+ * - owner: repository owner/organization
1068
+ * - repo: repository name (if applicable)
1069
+ * - number: issue/PR number (if applicable)
1070
+ * - path: additional path components
1071
+ * - error: error message if invalid
1072
+ */
1073
+ export function parseGitHubUrl(url) {
1074
+ if (!url || typeof url !== 'string') {
1075
+ return {
1076
+ valid: false,
1077
+ error: 'Invalid input: URL must be a non-empty string'
1078
+ };
1079
+ }
1080
+ // Trim whitespace and remove trailing slashes
1081
+ let normalizedUrl = url.trim().replace(/\/+$/, '');
1082
+ // Check if this looks like a valid GitHub-related input
1083
+ // Reject clearly invalid inputs (spaces in the URL, special chars at the start, etc.)
1084
+ if (/\s/.test(normalizedUrl) || /^[!@#$%^&*()[\]{}|\\:;"'<>,?`~]/.test(normalizedUrl)) {
1085
+ return {
1086
+ valid: false,
1087
+ error: 'Invalid GitHub URL format'
1088
+ };
1089
+ }
1090
+ // Handle protocol normalization
1091
+ if (!normalizedUrl.startsWith('http://') && !normalizedUrl.startsWith('https://')) {
1092
+ // Check if it starts with github.com
1093
+ if (normalizedUrl.startsWith('github.com/')) {
1094
+ normalizedUrl = 'https://' + normalizedUrl;
1095
+ } else if (!normalizedUrl.includes('github.com')) {
1096
+ // Assume it's a shorthand format (owner, owner/repo, owner/repo/issues/123, etc.)
1097
+ normalizedUrl = 'https://github.com/' + normalizedUrl;
1098
+ } else {
1099
+ // Has github.com somewhere but not at the start - likely malformed
1100
+ return {
1101
+ valid: false,
1102
+ error: 'Invalid GitHub URL format'
1103
+ };
1104
+ }
1105
+ }
1106
+ // Convert http to https
1107
+ if (normalizedUrl.startsWith('http://')) {
1108
+ normalizedUrl = normalizedUrl.replace(/^http:\/\//, 'https://');
1109
+ }
1110
+ // Parse the URL
1111
+ let urlObj;
1112
+ try {
1113
+ urlObj = new globalThis.URL(normalizedUrl);
1114
+ } catch (e) {
1115
+ if (global.verboseMode) {
1116
+ reportError(e, {
1117
+ context: 'github.lib.mjs - URL parsing',
1118
+ level: 'debug',
1119
+ url: normalizedUrl
1120
+ });
1121
+ }
1122
+ return {
1123
+ valid: false,
1124
+ error: 'Invalid URL format'
1125
+ };
1126
+ }
1127
+ // Ensure it's a GitHub URL
1128
+ if (urlObj.hostname !== 'github.com' && urlObj.hostname !== 'www.github.com') {
1129
+ return {
1130
+ valid: false,
1131
+ error: 'Not a GitHub URL'
1132
+ };
1133
+ }
1134
+ // Normalize hostname
1135
+ if (urlObj.hostname === 'www.github.com') {
1136
+ normalizedUrl = normalizedUrl.replace('www.github.com', 'github.com');
1137
+ urlObj = new globalThis.URL(normalizedUrl);
1138
+ }
1139
+ // Parse the pathname
1140
+ const pathParts = urlObj.pathname.split('/').filter(p => p);
1141
+ // Handle different GitHub URL patterns
1142
+ const result = {
1143
+ valid: true,
1144
+ normalized: normalizedUrl,
1145
+ hostname: 'github.com',
1146
+ protocol: 'https',
1147
+ path: urlObj.pathname
1148
+ };
1149
+ // No path - just github.com
1150
+ if (pathParts.length === 0) {
1151
+ result.type = 'home';
1152
+ return result;
1153
+ }
1154
+ // User/Organization page: /owner
1155
+ if (pathParts.length === 1) {
1156
+ result.type = 'user';
1157
+ result.owner = pathParts[0];
1158
+ return result;
1159
+ }
1160
+ // Set owner for all other cases
1161
+ result.owner = pathParts[0];
1162
+ // Repository page: /owner/repo
1163
+ if (pathParts.length === 2) {
1164
+ result.type = 'repo';
1165
+ result.repo = pathParts[1];
1166
+ return result;
1167
+ }
1168
+ // Set repo for paths with 3+ parts
1169
+ result.repo = pathParts[1];
1170
+ // Handle specific GitHub paths
1171
+ const thirdPart = pathParts[2];
1172
+ switch (thirdPart) {
1173
+ case 'issues':
1174
+ if (pathParts.length === 3) {
1175
+ // /owner/repo/issues - issues list
1176
+ result.type = 'issues_list';
1177
+ } else if (pathParts.length === 4 && /^\d+$/.test(pathParts[3])) {
1178
+ // /owner/repo/issues/123 - specific issue
1179
+ result.type = 'issue';
1180
+ result.number = parseInt(pathParts[3]);
1181
+ } else {
1182
+ result.type = 'issues_page';
1183
+ result.subpath = pathParts.slice(3).join('/');
1184
+ }
1185
+ break;
1186
+ case 'pull':
1187
+ if (pathParts.length === 4 && /^\d+$/.test(pathParts[3])) {
1188
+ // /owner/repo/pull/456 - specific PR
1189
+ result.type = 'pull';
1190
+ result.number = parseInt(pathParts[3]);
1191
+ } else {
1192
+ result.type = 'pull_page';
1193
+ result.subpath = pathParts.slice(3).join('/');
1194
+ }
1195
+ break;
1196
+ case 'pulls':
1197
+ // /owner/repo/pulls - PR list
1198
+ result.type = 'pulls_list';
1199
+ if (pathParts.length > 3) {
1200
+ result.subpath = pathParts.slice(3).join('/');
1201
+ }
1202
+ break;
1203
+ case 'actions':
1204
+ // /owner/repo/actions - GitHub Actions
1205
+ result.type = 'actions';
1206
+ if (pathParts.length > 3) {
1207
+ result.subpath = pathParts.slice(3).join('/');
1208
+ if (pathParts[3] === 'runs' && pathParts[4] && /^\d+$/.test(pathParts[4])) {
1209
+ result.type = 'action_run';
1210
+ result.runId = parseInt(pathParts[4]);
1211
+ }
1212
+ }
1213
+ break;
1214
+ case 'releases':
1215
+ // /owner/repo/releases
1216
+ result.type = 'releases';
1217
+ if (pathParts.length > 3) {
1218
+ result.subpath = pathParts.slice(3).join('/');
1219
+ if (pathParts[3] === 'tag' && pathParts[4]) {
1220
+ result.type = 'release';
1221
+ result.tag = pathParts[4];
1222
+ }
1223
+ }
1224
+ break;
1225
+ case 'tree':
1226
+ case 'blob':
1227
+ // /owner/repo/tree/branch or /owner/repo/blob/branch/file
1228
+ result.type = thirdPart === 'tree' ? 'tree' : 'file';
1229
+ if (pathParts.length > 3) {
1230
+ result.branch = pathParts[3];
1231
+ if (pathParts.length > 4) {
1232
+ result.filepath = pathParts.slice(4).join('/');
1233
+ }
1234
+ }
1235
+ break;
1236
+ case 'commit':
1237
+ case 'commits':
1238
+ // /owner/repo/commit/sha or /owner/repo/commits/branch
1239
+ result.type = thirdPart === 'commit' ? 'commit' : 'commits';
1240
+ if (pathParts.length > 3) {
1241
+ result.ref = pathParts[3]; // Could be SHA or branch
1242
+ }
1243
+ break;
1244
+ case 'compare':
1245
+ // /owner/repo/compare/base...head
1246
+ result.type = 'compare';
1247
+ if (pathParts.length > 3) {
1248
+ result.comparison = pathParts[3];
1249
+ }
1250
+ break;
1251
+ case 'wiki':
1252
+ // /owner/repo/wiki
1253
+ result.type = 'wiki';
1254
+ if (pathParts.length > 3) {
1255
+ result.subpath = pathParts.slice(3).join('/');
1256
+ }
1257
+ break;
1258
+ case 'settings':
1259
+ // /owner/repo/settings
1260
+ result.type = 'settings';
1261
+ if (pathParts.length > 3) {
1262
+ result.subpath = pathParts.slice(3).join('/');
1263
+ }
1264
+ break;
1265
+ case 'projects':
1266
+ // /owner/repo/projects or /owner/repo/projects/1
1267
+ result.type = 'projects';
1268
+ if (pathParts.length > 3 && /^\d+$/.test(pathParts[3])) {
1269
+ result.type = 'project';
1270
+ result.projectNumber = parseInt(pathParts[3]);
1271
+ }
1272
+ break;
1273
+ default:
1274
+ // Unknown path structure but still valid GitHub URL
1275
+ result.type = 'other';
1276
+ result.subpath = pathParts.slice(2).join('/');
1277
+ }
1278
+ return result;
1279
+ }
1280
+ /**
1281
+ * Normalize a GitHub URL to standard https://github.com format
1282
+ * This is a convenience function that uses parseGitHubUrl
1283
+ * @param {string} url - The URL to normalize
1284
+ * @returns {string|null} The normalized URL or null if invalid
1285
+ */
1286
+ export function normalizeGitHubUrl(url) {
1287
+ const parsed = parseGitHubUrl(url);
1288
+ return parsed.valid ? parsed.normalized : null;
1289
+ }
1290
+ /**
1291
+ * Check if a URL is a valid GitHub URL of a specific type
1292
+ * @param {string} url - The URL to check
1293
+ * @param {string|Array} types - The type(s) to check for ('issue', 'pull', 'repo', etc.)
1294
+ * @returns {boolean} True if the URL matches the specified type(s)
1295
+ */
1296
+ export function isGitHubUrlType(url, types) {
1297
+ const parsed = parseGitHubUrl(url);
1298
+ if (!parsed.valid) return false;
1299
+ const typeArray = Array.isArray(types) ? types : [types];
1300
+ return typeArray.includes(parsed.type);
1301
+ }
1302
+ /**
1303
+ * Universal function to view a pull request using gh pr view
1304
+ * @param {Object} options - Configuration options
1305
+ * @param {number|string} options.prNumber - PR number to view
1306
+ * @param {string} options.owner - Repository owner
1307
+ * @param {string} options.repo - Repository name
1308
+ * @param {string} [options.jsonFields='headRefName,body,number,mergeStateStatus,state,headRepositoryOwner'] - JSON fields to return
1309
+ * @returns {Promise<{code: number, stdout: string, stderr: string, data: Object|null}>}
1310
+ */
1311
+ export async function ghPrView({ prNumber, owner, repo, jsonFields = 'headRefName,body,number,mergeStateStatus,state,headRepositoryOwner' }) {
1312
+ try {
1313
+ const prResult = await $`gh pr view ${prNumber} --repo ${owner}/${repo} --json ${jsonFields}`;
1314
+ const stdout = prResult.stdout.toString();
1315
+ const stderr = prResult.stderr ? prResult.stderr.toString() : '';
1316
+ const code = prResult.code || 0;
1317
+ let data = null;
1318
+ if (code === 0 && stdout && !stdout.includes('Could not resolve')) {
1319
+ try {
1320
+ data = JSON.parse(stdout);
1321
+ } catch {
1322
+ // If JSON parsing fails, data remains null
1323
+ }
1324
+ }
1325
+ return {
1326
+ code,
1327
+ stdout,
1328
+ stderr,
1329
+ data,
1330
+ output: stdout + stderr
1331
+ };
1332
+ } catch (error) {
1333
+ return {
1334
+ code: error.code || 1,
1335
+ stdout: error.stdout?.toString() || '',
1336
+ stderr: error.stderr?.toString() || error.message || '',
1337
+ data: null,
1338
+ output: (error.stdout?.toString() || '') + (error.stderr?.toString() || error.message || '')
1339
+ };
1340
+ }
1341
+ }
1342
+ /**
1343
+ * Universal function to view an issue using gh issue view
1344
+ * @param {Object} options - Configuration options
1345
+ * @param {number|string} options.issueNumber - Issue number to view
1346
+ * @param {string} options.owner - Repository owner
1347
+ * @param {string} options.repo - Repository name
1348
+ * @param {string} [options.jsonFields='number,title'] - JSON fields to return
1349
+ * @returns {Promise<{code: number, stdout: string, stderr: string, data: Object|null}>}
1350
+ */
1351
+ export async function ghIssueView({ issueNumber, owner, repo, jsonFields = 'number,title' }) {
1352
+ try {
1353
+ const issueResult = await $`gh issue view ${issueNumber} --repo ${owner}/${repo} --json ${jsonFields}`;
1354
+ const stdout = issueResult.stdout.toString();
1355
+ const stderr = issueResult.stderr ? issueResult.stderr.toString() : '';
1356
+ const code = issueResult.code || 0;
1357
+ let data = null;
1358
+ if (code === 0 && stdout && !stdout.includes('Could not resolve')) {
1359
+ try {
1360
+ data = JSON.parse(stdout);
1361
+ } catch {
1362
+ // If JSON parsing fails, data remains null
1363
+ }
1364
+ }
1365
+ return {
1366
+ code,
1367
+ stdout,
1368
+ stderr,
1369
+ data,
1370
+ output: stdout + stderr
1371
+ };
1372
+ } catch (error) {
1373
+ return {
1374
+ code: error.code || 1,
1375
+ stdout: error.stdout?.toString() || '',
1376
+ stderr: error.stderr?.toString() || error.message || '',
1377
+ data: null,
1378
+ output: (error.stdout?.toString() || '') + (error.stderr?.toString() || error.message || '')
1379
+ };
1380
+ }
1381
+ }
1382
+ /**
1383
+ * Handle PR not found error and check if an issue exists with the same number
1384
+ * Provides user-friendly error messages and command suggestions
1385
+ * @param {Object} options - Configuration options
1386
+ * @param {number} options.prNumber - PR number that doesn't exist
1387
+ * @param {string} options.owner - Repository owner
1388
+ * @param {string} options.repo - Repository name
1389
+ * @param {Object} options.argv - Command line arguments object (for reconstructing command)
1390
+ * @param {boolean} [options.shouldAttachLogs] - Whether --attach-logs was used
1391
+ * @returns {Promise<void>}
1392
+ */
1393
+ export async function handlePRNotFoundError({ prNumber, owner, repo, argv, shouldAttachLogs }) {
1394
+ await log(`Error: PR #${prNumber} does not exist in ${owner}/${repo}`, { level: 'error' });
1395
+ await log('', { level: 'error' });
1396
+ try {
1397
+ const issueCheckResult = await ghIssueView({ issueNumber: prNumber, owner, repo, jsonFields: 'number,title' });
1398
+ if (issueCheckResult.code === 0 && issueCheckResult.data) {
1399
+ await log(`šŸ’” However, Issue #${prNumber} exists with the same number:`, { level: 'error' });
1400
+ await log(` Title: "${issueCheckResult.data.title}"`, { level: 'error' });
1401
+ await log('', { level: 'error' });
1402
+ await log('šŸ”§ Did you mean to work on the issue instead?', { level: 'error' });
1403
+ await log(' Try this corrected command:', { level: 'error' });
1404
+ await log('', { level: 'error' });
1405
+ const commandParts = [`solve https://github.com/${owner}/${repo}/issues/${prNumber}`];
1406
+ if (argv.autoContinue) commandParts.push('--auto-continue');
1407
+ if (shouldAttachLogs || argv.attachLogs || argv['attach-logs']) commandParts.push('--attach-logs');
1408
+ if (argv.verbose) commandParts.push('--verbose');
1409
+ if (argv.model && argv.model !== 'sonnet') commandParts.push('--model', argv.model);
1410
+ if (argv.think) commandParts.push('--think', argv.think);
1411
+ await log(` ${commandParts.join(' ')}`, { level: 'error' });
1412
+ await log('', { level: 'error' });
1413
+ }
1414
+ } catch {
1415
+ // Silently ignore if issue check fails
1416
+ }
1417
+ }
1418
+ /**
1419
+ * Detect if a repository is public or private
1420
+ * @param {string} owner - Repository owner
1421
+ * @param {string} repo - Repository name
1422
+ * @returns {Promise<{isPublic: boolean, visibility: string|null}>} Repository visibility info
1423
+ */
1424
+ export async function detectRepositoryVisibility(owner, repo) {
1425
+ try {
1426
+ const visibilityResult = await $`gh api repos/${owner}/${repo} --jq .visibility`;
1427
+ if (visibilityResult.code === 0) {
1428
+ const visibility = visibilityResult.stdout.toString().trim();
1429
+ const isPublic = visibility === 'public';
1430
+ if (global.verboseMode) {
1431
+ await log(` Repository visibility: ${visibility}`, { verbose: true });
1432
+ }
1433
+ return { isPublic, visibility };
1434
+ }
1435
+ // If API call failed, default to assuming public (safer to keep temp directories)
1436
+ if (global.verboseMode) {
1437
+ await log(' Warning: Could not detect repository visibility, defaulting to public', { verbose: true });
1438
+ }
1439
+ return { isPublic: true, visibility: null };
1440
+ } catch (error) {
1441
+ reportError(error, {
1442
+ context: 'detect_repository_visibility',
1443
+ owner,
1444
+ repo,
1445
+ operation: 'get_repo_visibility'
1446
+ });
1447
+ // Default to public (safer to keep temp directories on error)
1448
+ if (global.verboseMode) {
1449
+ await log(` Warning: Error detecting visibility: ${cleanErrorMessage(error)}`, { verbose: true });
1450
+ }
1451
+ return { isPublic: true, visibility: null };
1452
+ }
1453
+ }
1454
+ // Re-export batch archived check from separate module
1455
+ export const batchCheckArchivedRepositories = batchCheckArchived;
1456
+ // Export all functions as default object too
1457
+ export default {
1458
+ maskGitHubToken,
1459
+ getGitHubTokensFromFiles,
1460
+ getGitHubTokensFromCommand,
1461
+ escapeCodeBlocksInLog,
1462
+ sanitizeLogContent,
1463
+ checkFileInBranch,
1464
+ checkGitHubPermissions,
1465
+ checkRepositoryWritePermission,
1466
+ attachLogToGitHub,
1467
+ fetchAllIssuesWithPagination,
1468
+ fetchProjectIssues,
1469
+ isRateLimitError,
1470
+ batchCheckPullRequestsForIssues,
1471
+ parseGitHubUrl,
1472
+ normalizeGitHubUrl,
1473
+ isGitHubUrlType,
1474
+ ghPrView,
1475
+ ghIssueView,
1476
+ handlePRNotFoundError,
1477
+ detectRepositoryVisibility,
1478
+ batchCheckArchivedRepositories
1479
+ };