PRIX / src /feedback-capture.ts
Rachit-Tw's picture
Upload 169 files
9284ad7 verified
Raw
History Blame Contribute Delete
8.8 kB
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()