@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.
- package/CHANGELOG.md +20 -0
- package/LICENSE +24 -0
- package/README.md +769 -0
- package/package.json +58 -0
- package/src/agent.lib.mjs +705 -0
- package/src/agent.prompts.lib.mjs +196 -0
- package/src/buildUserMention.lib.mjs +71 -0
- package/src/claude-limits.lib.mjs +389 -0
- package/src/claude.lib.mjs +1445 -0
- package/src/claude.prompts.lib.mjs +203 -0
- package/src/codex.lib.mjs +552 -0
- package/src/codex.prompts.lib.mjs +194 -0
- package/src/config.lib.mjs +207 -0
- package/src/contributing-guidelines.lib.mjs +268 -0
- package/src/exit-handler.lib.mjs +205 -0
- package/src/git.lib.mjs +145 -0
- package/src/github-issue-creator.lib.mjs +246 -0
- package/src/github-linking.lib.mjs +152 -0
- package/src/github.batch.lib.mjs +272 -0
- package/src/github.graphql.lib.mjs +258 -0
- package/src/github.lib.mjs +1479 -0
- package/src/hive.config.lib.mjs +254 -0
- package/src/hive.mjs +1500 -0
- package/src/instrument.mjs +191 -0
- package/src/interactive-mode.lib.mjs +1000 -0
- package/src/lenv-reader.lib.mjs +206 -0
- package/src/lib.mjs +490 -0
- package/src/lino.lib.mjs +176 -0
- package/src/local-ci-checks.lib.mjs +324 -0
- package/src/memory-check.mjs +419 -0
- package/src/model-mapping.lib.mjs +145 -0
- package/src/model-validation.lib.mjs +278 -0
- package/src/opencode.lib.mjs +479 -0
- package/src/opencode.prompts.lib.mjs +194 -0
- package/src/protect-branch.mjs +159 -0
- package/src/review.mjs +433 -0
- package/src/reviewers-hive.mjs +643 -0
- package/src/sentry.lib.mjs +284 -0
- package/src/solve.auto-continue.lib.mjs +568 -0
- package/src/solve.auto-pr.lib.mjs +1374 -0
- package/src/solve.branch-errors.lib.mjs +341 -0
- package/src/solve.branch.lib.mjs +230 -0
- package/src/solve.config.lib.mjs +342 -0
- package/src/solve.error-handlers.lib.mjs +256 -0
- package/src/solve.execution.lib.mjs +291 -0
- package/src/solve.feedback.lib.mjs +436 -0
- package/src/solve.mjs +1128 -0
- package/src/solve.preparation.lib.mjs +210 -0
- package/src/solve.repo-setup.lib.mjs +114 -0
- package/src/solve.repository.lib.mjs +961 -0
- package/src/solve.results.lib.mjs +558 -0
- package/src/solve.session.lib.mjs +135 -0
- package/src/solve.validation.lib.mjs +325 -0
- package/src/solve.watch.lib.mjs +572 -0
- package/src/start-screen.mjs +324 -0
- package/src/task.mjs +308 -0
- package/src/telegram-bot.mjs +1481 -0
- package/src/telegram-markdown.lib.mjs +64 -0
- package/src/usage-limit.lib.mjs +218 -0
- package/src/version.lib.mjs +41 -0
- package/src/youtrack/solve.youtrack.lib.mjs +116 -0
- package/src/youtrack/youtrack-sync.mjs +219 -0
- 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
|
+
};
|