@@ -6,13 +6,10 @@ import type {
66} from '../api/types'
77
88export function computeAnalytics ( reviews : ReviewSession [ ] ) {
9- const reviewTimestamp = ( value : ReviewSession [ 'started_at' ] ) => toTimestampMs ( value ) ?? 0
10-
11- const completed = reviews
12- . filter ( r => r . status === 'Complete' && r . summary )
13- . sort ( ( a , b ) => reviewTimestamp ( a . started_at ) - reviewTimestamp ( b . started_at ) )
9+ const completed = getCompletedReviews ( reviews )
1410
1511 const scoreOverTime = completed . map ( ( r , i ) => ( {
12+ reviewId : r . id ,
1613 idx : i + 1 ,
1714 label : `#${ i + 1 } ` ,
1815 score : r . summary ! . overall_score ,
@@ -21,6 +18,7 @@ export function computeAnalytics(reviews: ReviewSession[]) {
2118 } ) )
2219
2320 const severityOverTime = completed . map ( ( r , i ) => ( {
21+ reviewId : r . id ,
2422 idx : i + 1 ,
2523 label : `#${ i + 1 } ` ,
2624 Error : r . summary ! . by_severity . Error || 0 ,
@@ -78,6 +76,7 @@ export function computeAnalytics(reviews: ReviewSession[]) {
7876 const labeled = accepted + rejected
7977
8078 return {
79+ reviewId : r . id ,
8180 idx : i + 1 ,
8281 label : `#${ i + 1 } ` ,
8382 coverage : totalComments > 0 ? labeled / totalComments : 0 ,
@@ -136,6 +135,7 @@ export function computeAnalytics(reviews: ReviewSession[]) {
136135 . slice ( 0 , 5 )
137136
138137 const lifecycleSeries = completed . map ( ( r , i ) => ( {
138+ reviewId : r . id ,
139139 idx : i + 1 ,
140140 label : `#${ i + 1 } ` ,
141141 open : r . summary ! . open_comments ,
@@ -149,6 +149,7 @@ export function computeAnalytics(reviews: ReviewSession[]) {
149149 const totalFindings = completeness . total_findings
150150
151151 return {
152+ reviewId : r . id ,
152153 idx : i + 1 ,
153154 label : `#${ i + 1 } ` ,
154155 totalFindings,
@@ -179,6 +180,7 @@ export function computeAnalytics(reviews: ReviewSession[]) {
179180 const totalHours = resolutionHours . reduce ( ( sum , hours ) => sum + hours , 0 )
180181
181182 return {
183+ reviewId : r . id ,
182184 idx : i + 1 ,
183185 label : `#${ i + 1 } ` ,
184186 meanHours : resolutionHours . length > 0 ? totalHours / resolutionHours . length : null ,
@@ -283,6 +285,129 @@ export function computeAnalytics(reviews: ReviewSession[]) {
283285 }
284286}
285287
288+ export type AnalyticsDrilldownSelection =
289+ | { type : 'review' ; reviewId : string }
290+ | { type : 'category' ; category : string }
291+ | { type : 'rule' ; ruleId : string }
292+
293+ export interface AnalyticsDrilldown {
294+ title : string
295+ description : string
296+ reviews : Array < {
297+ id : string
298+ label : string
299+ startedAt : string | number
300+ overallScore ?: number
301+ findingCount : number
302+ } >
303+ comments : Array < {
304+ reviewId : string
305+ reviewLabel : string
306+ id : string
307+ filePath : string
308+ lineNumber : number
309+ content : string
310+ category : string
311+ ruleId ?: string
312+ } >
313+ relatedRules : string [ ]
314+ }
315+
316+ export function buildAnalyticsDrilldown (
317+ reviews : ReviewSession [ ] ,
318+ selection : AnalyticsDrilldownSelection ,
319+ ) : AnalyticsDrilldown | null {
320+ const completed = getCompletedReviews ( reviews )
321+ const labeledReviews = completed . map ( ( review , index ) => ( {
322+ review,
323+ label : `#${ index + 1 } ` ,
324+ } ) )
325+
326+ if ( selection . type === 'review' ) {
327+ const match = labeledReviews . find ( entry => entry . review . id === selection . reviewId )
328+ if ( ! match ) {
329+ return null
330+ }
331+
332+ const relatedRules = Array . from ( new Set (
333+ match . review . comments
334+ . map ( comment => comment . rule_id ?. trim ( ) )
335+ . filter ( ( ruleId ) : ruleId is string => Boolean ( ruleId ) ) ,
336+ ) ) . sort ( )
337+
338+ return {
339+ title : `Review ${ match . label } ` ,
340+ description : `${ match . review . comments . length } finding${ match . review . comments . length === 1 ? '' : 's' } across ${ match . review . files_reviewed } reviewed file${ match . review . files_reviewed === 1 ? '' : 's' } .` ,
341+ reviews : [ {
342+ id : match . review . id ,
343+ label : match . label ,
344+ startedAt : match . review . started_at ,
345+ overallScore : match . review . summary ?. overall_score ,
346+ findingCount : match . review . comments . length ,
347+ } ] ,
348+ comments : match . review . comments . map ( comment => ( {
349+ reviewId : match . review . id ,
350+ reviewLabel : match . label ,
351+ id : comment . id ,
352+ filePath : comment . file_path ,
353+ lineNumber : comment . line_number ,
354+ content : comment . content ,
355+ category : comment . category ,
356+ ruleId : comment . rule_id ?. trim ( ) ,
357+ } ) ) ,
358+ relatedRules,
359+ }
360+ }
361+
362+ const matches = labeledReviews . flatMap ( ( { review, label } ) => review . comments
363+ . filter ( comment => selection . type === 'category'
364+ ? comment . category === selection . category
365+ : comment . rule_id ?. trim ( ) === selection . ruleId )
366+ . map ( comment => ( { review, label, comment } ) ) )
367+
368+ if ( matches . length === 0 ) {
369+ return null
370+ }
371+
372+ const reviewMap = new Map < string , AnalyticsDrilldown [ 'reviews' ] [ number ] > ( )
373+ for ( const { review, label } of matches ) {
374+ if ( ! reviewMap . has ( review . id ) ) {
375+ reviewMap . set ( review . id , {
376+ id : review . id ,
377+ label,
378+ startedAt : review . started_at ,
379+ overallScore : review . summary ?. overall_score ,
380+ findingCount : review . comments . length ,
381+ } )
382+ }
383+ }
384+
385+ const relatedRules = Array . from ( new Set (
386+ matches
387+ . map ( ( { comment } ) => comment . rule_id ?. trim ( ) )
388+ . filter ( ( ruleId ) : ruleId is string => Boolean ( ruleId ) ) ,
389+ ) ) . sort ( )
390+
391+ return {
392+ title : selection . type === 'category'
393+ ? `Category · ${ selection . category } `
394+ : `Rule · ${ selection . ruleId } ` ,
395+ description : `${ matches . length } finding${ matches . length === 1 ? '' : 's' } across ${ reviewMap . size } review${ reviewMap . size === 1 ? '' : 's' } .` ,
396+ reviews : Array . from ( reviewMap . values ( ) ) ,
397+ comments : matches . map ( ( { review, label, comment } ) => ( {
398+ reviewId : review . id ,
399+ reviewLabel : label ,
400+ id : comment . id ,
401+ filePath : comment . file_path ,
402+ lineNumber : comment . line_number ,
403+ content : comment . content ,
404+ category : comment . category ,
405+ ruleId : comment . rule_id ?. trim ( ) ,
406+ } ) ) ,
407+ relatedRules,
408+ }
409+ }
410+
286411export function formatTrendLabel ( timestamp : string , index : number ) : string {
287412 const parsed = new Date ( timestamp )
288413 if ( Number . isNaN ( parsed . getTime ( ) ) ) return `#${ index + 1 } `
@@ -651,6 +776,14 @@ function toTimestampMs(value: string | number | undefined): number | null {
651776 return toDate ( value ) ?. getTime ( ) ?? null
652777}
653778
779+ function getCompletedReviews ( reviews : ReviewSession [ ] ) {
780+ return reviews
781+ . filter ( ( review ) : review is ReviewSession & { summary : NonNullable < ReviewSession [ 'summary' ] > } => (
782+ review . status === 'Complete' && Boolean ( review . summary )
783+ ) )
784+ . sort ( ( left , right ) => ( toTimestampMs ( left . started_at ) ?? 0 ) - ( toTimestampMs ( right . started_at ) ?? 0 ) )
785+ }
786+
654787function getCompletenessSummary ( summary : NonNullable < ReviewSession [ 'summary' ] > ) {
655788 return summary . completeness ?? {
656789 total_findings : summary . total_comments ,
0 commit comments