| import {octokit} from './octokit' |
| import {Bot} from './bot' |
| import {type Prompts} from './prompts' |
| import {getTokenCount} from './tokenizer' |
| import {als} from './context' |
| import {Inputs} from './inputs' |
| import {type Options} from './options' |
| import {PRIX_BRANDING, PLAN_BRANDING} from './commenter' |
|
|
| let info = console.log |
| let warning = console.warn |
|
|
| export const context: any = new Proxy({} as any, { |
| get(target, prop) { |
| return (als.getStore()?.probotContext as any)[prop] |
| } |
| }) |
|
|
| export const repo: any = new Proxy({} as any, { |
| get(target, prop) { |
| return (als.getStore()?.repo as any)[prop] |
| } |
| }) |
|
|
| const ISSUE_COMMAND_TAG = '@prix' |
| const MAX_API_CALLS = 100 |
| const MAX_FILES_RETURNED = 200 |
|
|
| let apiCallCount = 0 |
|
|
| export const handleIssueComment = async ( |
| heavyBot: Bot, |
| options: Options, |
| prompts: Prompts |
| ) => { |
| const inputs: Inputs = new Inputs() |
|
|
| if (context.name !== 'issue_comment') { |
| warning(`Skipped: ${context.name} is not an issue_comment event`) |
| return |
| } |
|
|
| if (!context.payload) { |
| warning(`Skipped: issue_comment event is missing payload`) |
| return |
| } |
|
|
| if (!context.payload.repository) { |
| warning(`Skipped: issue_comment event is missing repository`) |
| return |
| } |
|
|
| const comment = context.payload.comment |
| if (comment == null) { |
| warning(`Skipped: issue_comment event is missing comment`) |
| return |
| } |
|
|
| const issue = context.payload.issue |
| if (issue == null) { |
| warning(`Skipped: issue_comment event is missing issue`) |
| return |
| } |
|
|
| if (context.payload.action !== 'created') { |
| warning(`Skipped: issue_comment event is not created`) |
| return |
| } |
|
|
| const isBotComment = comment.user?.type === 'Bot' |
| if (isBotComment) { |
| info(`Skipped: issue_comment event is from the bot itself`) |
| return |
| } |
|
|
| if (!comment.body.includes(ISSUE_COMMAND_TAG)) { |
| info(`Skipped: comment does not mention @prix`) |
| return |
| } |
|
|
| if (!repo?.owner || !repo?.repo) { |
| warning(`Skipped: repo context is missing`) |
| return |
| } |
|
|
| const issueNumber = issue.number |
| if (!issueNumber || typeof issueNumber !== 'number') { |
| warning(`Skipped: issue number is invalid`) |
| return |
| } |
| const issueTitle = issue.title || '' |
| const issueBody = issue.body || '' |
| const commentBody = comment.body || '' |
| const commentUser = comment.user?.login || 'unknown' |
|
|
| if (issueTitle.length > 500) { |
| await postIssueComment( |
| issueNumber, |
| `@${commentUser} Issue title is too long. Please shorten it to under 500 characters.` |
| ) |
| return |
| } |
|
|
| if (issueBody.length > 10000) { |
| await postIssueComment( |
| issueNumber, |
| `@${commentUser} Issue description is too long. Please simplify it to under 10,000 characters.` |
| ) |
| return |
| } |
|
|
| info(`Processing issue comment for issue #${issueNumber} from @${commentUser}`) |
|
|
| inputs.title = issueTitle |
| inputs.description = issueBody |
| inputs.comment = `${commentUser}: ${commentBody}` |
|
|
| const taskDescription = extractTaskFromComment(commentBody) |
| inputs.taskDescription = taskDescription |
|
|
| apiCallCount = 0 |
| const counter = { fileCount: 0 } |
| const codebaseStructure = await fetchCodebaseStructure( |
| repo.owner, |
| repo.repo, |
| () => counter.fileCount >= MAX_FILES_RETURNED, |
| counter |
| ) |
| inputs.codebaseStructure = codebaseStructure |
|
|
| if (counter.fileCount > MAX_FILES_RETURNED) { |
| info(`Codebase structure truncated: ${counter.fileCount} files found, limit is ${MAX_FILES_RETURNED}`) |
| } |
|
|
| const tokens = getTokenCount(prompts.renderIssueAnalysis(inputs)) |
| if (tokens > options.heavyTokenLimits.requestTokens) { |
| await postIssueComment( |
| issueNumber, |
| `@${commentUser} The issue content is too large to process. Please simplify or split the issue.` |
| ) |
| return |
| } |
|
|
| const [analysis] = await heavyBot.chat( |
| prompts.renderIssueAnalysis(inputs), |
| {} |
| ) |
|
|
| if (!analysis || analysis.trim().length === 0) { |
| warning(`Empty analysis received for issue #${issueNumber}`) |
| return |
| } |
|
|
| const response = formatIssueResponse(commentUser, analysis) |
| await postIssueComment(issueNumber, response) |
|
|
| info(`Successfully processed issue comment for issue #${issueNumber}`) |
| } |
|
|
| const EXCLUDED_DIRS = new Set([ |
| 'node_modules', |
| '.git', |
| 'dist', |
| 'build', |
| 'coverage', |
| '.next', |
| '__pycache__', |
| 'venv', |
| '.venv', |
| 'target', |
| 'bin', |
| 'obj', |
| '.idea', |
| '.vscode', |
| 'vendor', |
| 'packages' |
| ]) |
|
|
| async function fetchCodebaseStructure( |
| owner: string, |
| repoName: string, |
| shouldStop: () => boolean, |
| counter?: { fileCount: number } |
| ): Promise<string> { |
| try { |
| const structure = await fetchDirectoryTree(owner, repoName, '', 3, shouldStop, 0, counter) |
| return structure || 'Unable to fetch codebase structure' |
| } catch (error) { |
| warning(`Failed to fetch codebase structure: ${error}`) |
| return 'Codebase structure unavailable' |
| } |
| } |
|
|
| async function fetchDirectoryTree( |
| owner: string, |
| repo: string, |
| path: string, |
| depth: number, |
| shouldStop: () => boolean, |
| currentDepth = 0, |
| counter?: { fileCount: number } |
| ): Promise<string> { |
| if (currentDepth >= depth) { |
| return '' |
| } |
|
|
| if (apiCallCount >= MAX_API_CALLS) { |
| return '' |
| } |
|
|
| if (shouldStop()) { |
| return '' |
| } |
|
|
| try { |
| apiCallCount++ |
| const response = await octokit.repos.getContent({ |
| owner, |
| repo, |
| path, |
| ref: context.payload.repository?.default_branch || 'main' |
| }) |
|
|
| const items = Array.isArray(response.data) ? response.data : [response.data] |
| let tree = '' |
|
|
| for (const item of items) { |
| if (shouldStop()) { |
| break |
| } |
|
|
| if (item.type === 'dir' && !EXCLUDED_DIRS.has(item.name)) { |
| tree += `${' '.repeat(currentDepth)}📁 ${item.name}/\n` |
| const subTree = await fetchDirectoryTree( |
| owner, |
| repo, |
| item.path, |
| depth, |
| shouldStop, |
| currentDepth + 1, |
| counter |
| ) |
| tree += subTree |
| } else if (item.type === 'file') { |
| const ext = item.name.split('.').pop() || '' |
| if (isRelevantExtension(ext)) { |
| tree += `${' '.repeat(currentDepth)}📄 ${item.name}\n` |
| if (counter) { |
| counter.fileCount++ |
| } |
| } |
| } |
| } |
|
|
| return tree |
| } catch (error) { |
| return '' |
| } |
| } |
|
|
| function isRelevantExtension(ext: string): boolean { |
| const relevantExts = new Set([ |
| 'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', |
| 'py', 'rb', 'go', 'rs', 'java', 'kt', 'scala', |
| 'cpp', 'c', 'h', 'hpp', 'cs', 'swift', |
| 'json', 'yaml', 'yml', 'toml', 'xml', |
| 'md', 'txt', 'sh', 'bash', 'zsh', |
| 'css', 'scss', 'sass', 'less', |
| 'html', 'htm', 'vue', 'svelte', |
| 'sql', 'graphql', 'proto', |
| 'dockerfile', 'env', 'gitignore', 'editorconfig' |
| ]) |
| return relevantExts.has(ext.toLowerCase()) |
| } |
|
|
| function extractTaskFromComment(commentBody: string): string { |
| const mentionPattern = /@prix\s*(.*)$/i |
| const match = commentBody.match(mentionPattern) |
| return match ? match[1].trim() : commentBody |
| } |
|
|
| async function postIssueComment( |
| issueNumber: number, |
| body: string |
| ): Promise<void> { |
| try { |
| await octokit.issues.createComment({ |
| owner: repo.owner, |
| repo: repo.repo, |
| issue_number: issueNumber, |
| body |
| }) |
| } catch (error) { |
| warning(`Failed to post issue comment: ${error}`) |
| } |
| } |
|
|
| function formatIssueResponse(user: string, analysis: string): string { |
| return `## @${user} Issue Analysis |
| |
| ${analysis}${PLAN_BRANDING}` |
| } |
|
|