PRIX / src /issue-handler.ts
Rachit-Tw's picture
Upload 169 files
9284ad7 verified
Raw
History Blame Contribute Delete
7.94 kB
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'
import {sanitizePrompt, sanitizeHTML} from './utils/sanitizer'
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}`)
// Sanitize user inputs before using in prompts
inputs.title = sanitizeHTML(issueTitle)
inputs.description = sanitizeHTML(issueBody)
inputs.comment = sanitizePrompt(`${commentUser}: ${commentBody}`)
const taskDescription = extractTaskFromComment(sanitizePrompt(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.rest.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.rest.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}`
}