import {octokit} from './octokit' import {als} from './context' import {feedbackTracker} from './feedback-tracker' import {PatternCategory} from './feedback-types' import { LRUCache } from 'lru-cache' import pino from 'pino' const logger = pino({ level: process.env.LOG_LEVEL || 'info' }) const FEEDBACK_TAG = '' export interface CommentMetadata { commentId?: number pullNumber: number path: string patternType: PatternCategory severity: 'critical' | 'major' | 'minor' | 'info' confidence: number } export class FeedbackCapture { // SECURITY: LRU caches prevent unbounded memory growth (Issue #5) private commentMetadata: LRUCache private pendingFeedback: LRUCache constructor() { // Limit to 1000 entries per PR with 1 hour TTL this.commentMetadata = new LRUCache({ max: 1000, ttl: 1000 * 60 * 60, // 1 hour updateAgeOnGet: true, dispose: (value, key) => { logger.debug({ key, pullNumber: value.pullNumber }, 'Comment metadata evicted') } }) this.pendingFeedback = new LRUCache({ max: 1000, ttl: 1000 * 60 * 60, // 1 hour updateAgeOnGet: true }) } private getCommentKey( pullNumber: number, path: string, startLine: number ): string { return `${pullNumber}:${path}:${startLine}` } trackComment( pullNumber: number, path: string, startLine: number, patternType: PatternCategory, severity: 'critical' | 'major' | 'minor' | 'info', confidence: number, commentId?: number ): void { const key = this.getCommentKey(pullNumber, path, startLine) this.commentMetadata.set(key, { commentId, pullNumber, path, patternType, severity, confidence }) } async checkSuggestionApplied( pullNumber: number, path: string, startLine: number ): Promise { const key = this.getCommentKey(pullNumber, path, startLine) const metadata = this.commentMetadata.get(key) if (!metadata || !metadata.commentId) return false try { const comments = await octokit.rest.pulls.listReviewComments({ owner: als.getStore()?.repo.owner || '', repo: als.getStore()?.repo.repo || '', // eslint-disable-next-line camelcase pull_number: pullNumber }) const originalComment = comments.data.find( (c: any) => c.id === metadata.commentId ) if (!originalComment) return false const diff = await this.getFileDiffAtComment( pullNumber, path, startLine, originalComment.original_line || startLine ) const hasAppliedSuggestion = this.detectSuggestionApplication( originalComment.body || '', diff ) if (hasAppliedSuggestion) { this.recordFeedback(key, 'accepted') } return hasAppliedSuggestion } catch (e) { logger.warn({ error: e }, 'Failed to check suggestion application') return false } } private async getFileDiffAtComment( pullNumber: number, path: string, startLine: number, originalLine: number ): Promise { try { const diff = await octokit.rest.repos.compareCommits({ owner: als.getStore()?.repo.owner || '', repo: als.getStore()?.repo.repo || '', base: `${pullNumber}-base`, head: `${pullNumber}-head` }) const file = diff.data.files?.find((f: any) => f.filename === path) if (!file?.patch) return '' const hunk = file.patch .split('\n') .slice( Math.max(0, originalLine - startLine - 1), originalLine - startLine + 5 ) .join('\n') return hunk } catch (e) { return '' } } private detectSuggestionApplication( originalBody: string, currentDiff: string ): boolean { if (!originalBody.includes('```suggestion')) return false if (!currentDiff) return false const suggestionMatch = originalBody.match(/```suggestion\n([\s\S]*?)```/) if (!suggestionMatch) return false const suggestedCode = suggestionMatch[1].trim() const hasApplied = currentDiff.includes( suggestedCode.substring(0, Math.min(50, suggestedCode.length)) ) return hasApplied } recordFeedback(key: string, type: 'accepted' | 'rejected'): void { const metadata = this.commentMetadata.get(key) if (!metadata) return this.pendingFeedback.set(key, type) const ctx = als.getStore() const teamId = ctx?.repo?.owner || 'default' const repoSlug = ctx?.repo?.repo || 'default' feedbackTracker.recordFeedback({ patternType: metadata.patternType, severity: metadata.severity, filenamePattern: metadata.path, repoSlug, teamId, confidence: metadata.confidence, feedbackType: type }) logger.info({ key, type }, 'Feedback recorded') } async checkReactionFeedback(pullNumber: number): Promise { const ctx = als.getStore() const teamId = ctx?.repo?.owner || 'default' const repoSlug = ctx?.repo?.repo || 'default' try { const comments = await octokit.rest.pulls.listReviewComments({ owner: teamId, repo: repoSlug, // eslint-disable-next-line camelcase pull_number: pullNumber }) for (const comment of comments.data) { if (!comment.body?.includes(FEEDBACK_TAG)) continue const reactions = await octokit.rest.reactions.listForIssueComment({ owner: teamId, repo: repoSlug, // eslint-disable-next-line camelcase comment_id: comment.id }) for (const reaction of reactions.data) { const key = this.getCommentKey( pullNumber, comment.path || '', comment.line || comment.original_line || 0 ) if (reaction.content === '+1' || reaction.content === 'heart') { this.recordFeedback(key, 'accepted') } else if (reaction.content === '-1' || reaction.content === 'eyes') { this.recordFeedback(key, 'rejected') } } } } catch (e) { logger.warn({ error: e }, 'Failed to check reaction feedback') } } async trackReviewSubmission( pullNumber: number, reviewId: number, commitId: string ): Promise { const ctx = als.getStore() const teamId = ctx?.repo?.owner || 'default' const repoSlug = ctx?.repo?.repo || 'default' logger.info( { pullNumber, reviewId }, 'Tracking review submission' ) try { const comments = await octokit.rest.pulls.listReviewComments({ owner: teamId, repo: repoSlug, // eslint-disable-next-line camelcase pull_number: pullNumber }) for (const comment of comments.data) { if (!comment.body?.includes(FEEDBACK_TAG)) continue if (comment.user?.login !== 'github-actions[bot]') continue const metadataMatch = comment.body.match(/data-pattern="([^"]+)"/) const severityMatch = comment.body.match(/data-severity="([^"]+)"/) const confidenceMatch = comment.body.match(/data-confidence="([^"]+)"/) if (metadataMatch && severityMatch && confidenceMatch) { const key = this.getCommentKey( pullNumber, comment.path || '', comment.line || comment.original_line || 0 ) this.commentMetadata.set(key, { commentId: comment.id, pullNumber, path: comment.path || '', patternType: metadataMatch[1] as PatternCategory, severity: severityMatch[1] as | 'critical' | 'major' | 'minor' | 'info', confidence: parseFloat(confidenceMatch[1]) }) } } } catch (e) { logger.warn({ error: e }, 'Failed to track review submission') } } getFeedbackStats(teamId: string, repoSlug: string) { return feedbackTracker.getTeamInsights(teamId, repoSlug) } shouldSuppressFinding( patternType: PatternCategory, filename: string, teamId: string, repoSlug: string ): boolean { return feedbackTracker.shouldSuppressPattern(patternType, teamId, repoSlug) } } export const feedbackCapture = new FeedbackCapture()