Spaces:
Paused
Paused
| 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 = '<!-- prix_feedback_tracking -->' | |
| 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<string, CommentMetadata> | |
| private pendingFeedback: LRUCache<string, 'accepted' | 'rejected'> | |
| constructor() { | |
| // Limit to 1000 entries per PR with 1 hour TTL | |
| this.commentMetadata = new LRUCache<string, CommentMetadata>({ | |
| 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<string, 'accepted' | 'rejected'>({ | |
| 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<boolean> { | |
| 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<string> { | |
| 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<void> { | |
| 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<void> { | |
| 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() | |