11import type { FastifyInstance , FastifyRequest } from 'fastify' ;
2- import { Octokit } from '@octokit/rest' ;
32import { analyzeCode } from '../services/analyzer/index.js' ;
43
4+ // ── Bot payload — bot now sends file content directly ────────────────────────
5+ interface FileContent {
6+ filename : string ;
7+ status : string ;
8+ headContent : string ;
9+ baseContent : string ;
10+ }
11+
512interface BotPayload {
6- owner : string ;
7- repo : string ;
8- prNumber : number ;
9- baseSha : string ;
10- headSha : string ;
11- files : { filename : string ; status : string } [ ] ;
13+ owner : string ;
14+ repo : string ;
15+ prNumber : number ;
16+ baseSha : string ;
17+ headSha : string ;
18+ fileContents : FileContent [ ] ;
1219}
1320
1421function getLanguageFromFilename ( filename : string ) : 'typescript' | 'javascript' | null {
@@ -17,46 +24,45 @@ function getLanguageFromFilename(filename: string): 'typescript' | 'javascript'
1724 return null ;
1825}
1926
20- // ── Inline pricing (no Redis dependency) ────────── ───────────────────────────
27+ // ── Inline pricing (no Redis / no HTTP dependency) ───────────────────────────
2128const OPENAI_PRICING : Record < string , { input : number ; output : number } > = {
22- 'gpt-4o' : { input : 0.005 , output : 0.015 } ,
23- 'gpt-4o-mini' : { input : 0.00015 , output : 0.0006 } ,
24- 'gpt-4-turbo' : { input : 0.01 , output : 0.03 } ,
25- 'gpt-4' : { input : 0.03 , output : 0.06 } ,
26- 'gpt-3.5-turbo' : { input : 0.0005 , output : 0.0015 } ,
29+ 'gpt-4o' : { input : 0.005 , output : 0.015 } ,
30+ 'gpt-4o-mini' : { input : 0.00015 , output : 0.0006 } ,
31+ 'gpt-4-turbo' : { input : 0.01 , output : 0.03 } ,
32+ 'gpt-4' : { input : 0.03 , output : 0.06 } ,
33+ 'gpt-3.5-turbo' : { input : 0.0005 , output : 0.0015 } ,
2734} ;
2835
2936const ANTHROPIC_PRICING : Record < string , { input : number ; output : number } > = {
30- 'claude-3-5-sonnet' : { input : 0.003 , output : 0.015 } ,
31- 'claude-3-opus' : { input : 0.015 , output : 0.075 } ,
32- 'claude-3-sonnet' : { input : 0.003 , output : 0.015 } ,
33- 'claude-3-haiku' : { input : 0.00025 , output : 0.00125 } ,
34- 'claude-2' : { input : 0.008 , output : 0.024 } ,
37+ 'claude-3-5-sonnet' : { input : 0.003 , output : 0.015 } ,
38+ 'claude-3-opus' : { input : 0.015 , output : 0.075 } ,
39+ 'claude-3-sonnet' : { input : 0.003 , output : 0.015 } ,
40+ 'claude-3-haiku' : { input : 0.00025 , output : 0.00125 } ,
41+ 'claude-2' : { input : 0.008 , output : 0.024 } ,
3542} ;
3643
37- function estimateCentsInline ( service : string , model : string | undefined , callsPerMonth : number ,
38- inputTokens : number , outputTokens : number ,
39- storageGB ?: number , operation ?: string ) : number {
44+ function estimateCents (
45+ service : string , model : string | undefined ,
46+ callsPerMonth : number , inputTokens : number , outputTokens : number ,
47+ storageGB ?: number , operation ?: string ,
48+ ) : number {
4049 if ( service === 'openai' && model ) {
4150 const p = OPENAI_PRICING [ model ] ?? OPENAI_PRICING [ 'gpt-4o' ] ;
4251 return Math . round (
43- ( ( inputTokens / 1_000 ) * p . input * callsPerMonth * 100 ) +
44- ( ( outputTokens / 1_000 ) * p . output * callsPerMonth * 100 )
52+ ( inputTokens / 1_000 ) * p . input * callsPerMonth * 100 +
53+ ( outputTokens / 1_000 ) * p . output * callsPerMonth * 100 ,
4554 ) ;
4655 }
4756 if ( service === 'anthropic' && model ) {
4857 const p = ANTHROPIC_PRICING [ model ] ?? ANTHROPIC_PRICING [ 'claude-3-5-sonnet' ] ;
4958 return Math . round (
50- ( ( inputTokens / 1_000 ) * p . input * callsPerMonth * 100 ) +
51- ( ( outputTokens / 1_000 ) * p . output * callsPerMonth * 100 )
59+ ( inputTokens / 1_000 ) * p . input * callsPerMonth * 100 +
60+ ( outputTokens / 1_000 ) * p . output * callsPerMonth * 100 ,
5261 ) ;
5362 }
5463 if ( service === 'aws-lambda' ) {
55- const calls = callsPerMonth ;
56- const memMB = 128 ;
57- const durMs = 200 ;
58- const gbSecs = ( memMB / 1_024 ) * ( durMs / 1_000 ) * calls ;
59- const billableReqs = Math . max ( 0 , calls - 1_000_000 ) ;
64+ const gbSecs = ( 128 / 1_024 ) * ( 200 / 1_000 ) * callsPerMonth ;
65+ const billableReqs = Math . max ( 0 , callsPerMonth - 1_000_000 ) ;
6066 const billableGbSecs = Math . max ( 0 , gbSecs - 400_000 ) ;
6167 return Math . round ( billableReqs * 0.0000002 * 100 + billableGbSecs * 0.0000166667 * 100 ) ;
6268 }
@@ -65,35 +71,31 @@ function estimateCentsInline(service: string, model: string | undefined, callsPe
6571 const isWrite = operation === 'put' ;
6672 const storage = Math . round ( gb * 0.023 * 100 ) ;
6773 const requests = isWrite
68- ? Math . round ( ( callsPerMonth / 1_000 ) * 0.005 * 100 )
74+ ? Math . round ( ( callsPerMonth / 1_000 ) * 0.005 * 100 )
6975 : Math . round ( ( callsPerMonth / 1_000 ) * 0.0004 * 100 ) ;
7076 return storage + requests ;
7177 }
7278 if ( service === 'dynamodb' ) {
7379 const perUnit = operation === 'write' ? 0.00000125 : 0.00000025 ;
7480 return Math . round ( callsPerMonth * perUnit * 100 ) ;
7581 }
76- if ( service === 'api-gateway' ) {
77- return Math . round ( ( callsPerMonth / 1_000_000 ) * 3.5 * 100 ) ;
78- }
79- if ( service === 'redis' ) return 1_224 ; // cache.t3.micro baseline
82+ if ( service === 'api-gateway' ) return Math . round ( ( callsPerMonth / 1_000_000 ) * 3.5 * 100 ) ;
83+ if ( service === 'redis' ) return 1_224 ;
8084 return 0 ;
8185}
8286
83- // ── Route ─────────────────────────────────────────────────────────────────────
87+ // ── Route ────────────────────────────────────────────────────────────────────
8488export async function botRoutes ( app : FastifyInstance ) {
85- app . post ( '/api/bot/analyze-pr' , async ( request : FastifyRequest < { Body : BotPayload } > , reply ) => {
86- const { owner , repo , baseSha , headSha , files } = request . body ;
87-
88- const token = process . env . GITHUB_TOKEN || '' ;
89- const octokit = new Octokit ( { auth : token } ) ;
89+ app . post ( '/api/bot/analyze-pr' , async (
90+ request : FastifyRequest < { Body : BotPayload } > ,
91+ reply ,
92+ ) => {
93+ const { fileContents = [ ] } = request . body ;
9094
9195 let totalHeadCents = 0 ;
9296 let totalBaseCents = 0 ;
9397 let inLoop = 0 ;
9498 let handler = 0 ;
95- const fetchErrors : string [ ] = [ ] ;
96- const debugLines : string [ ] = [ ] ;
9799
98100 interface DetectionRow {
99101 service : string ;
@@ -104,131 +106,109 @@ export async function botRoutes(app: FastifyInstance) {
104106 inLoop : boolean ;
105107 }
106108 const detectionRows : DetectionRow [ ] = [ ] ;
109+ const debugLines : string [ ] = [ ] ;
107110
108- for ( const file of files ) {
109- if ( file . status === 'removed' ) continue ;
111+ for ( const file of fileContents ) {
110112 const language = getLanguageFromFilename ( file . filename ) ;
111113 if ( ! language ) {
112114 debugLines . push ( `skip (not JS/TS): ${ file . filename } ` ) ;
113115 continue ;
114116 }
115117
116- // ── Fetch HEAD ─────────────────────────────────────────────────────────
117- let headContent = '' ;
118- try {
119- const { data } = await octokit . rest . repos . getContent ( {
120- owner, repo, path : file . filename , ref : headSha ,
121- } ) ;
122- if ( 'content' in ( data as any ) && ! Array . isArray ( data ) ) {
123- headContent = Buffer . from ( ( data as any ) . content , 'base64' ) . toString ( 'utf-8' ) ;
124- }
125- } catch ( e : any ) {
126- fetchErrors . push ( `HEAD fetch failed for ${ file . filename } : ${ e . message } ` ) ;
127- debugLines . push ( `HEAD fetch error ${ file . filename } : ${ e . message } ` ) ;
128- continue ;
129- }
118+ const headContent = file . headContent ?? '' ;
119+ const baseContent = file . baseContent ?? '' ;
130120
131121 if ( ! headContent . trim ( ) ) {
132- debugLines . push ( `HEAD content empty for ${ file . filename } ` ) ;
122+ debugLines . push ( `empty head content: ${ file . filename } ` ) ;
133123 continue ;
134124 }
135125
136- // ── Fetch BASE ─────────────────────────────────────────────────────────
137- let baseContent = '' ;
138- if ( file . status !== 'added' ) {
139- try {
140- const { data } = await octokit . rest . repos . getContent ( {
141- owner, repo, path : file . filename , ref : baseSha ,
142- } ) ;
143- if ( 'content' in ( data as any ) && ! Array . isArray ( data ) ) {
144- baseContent = Buffer . from ( ( data as any ) . content , 'base64' ) . toString ( 'utf-8' ) ;
145- }
146- } catch { /* treat as new file */ }
147- }
148-
149- debugLines . push ( `analyzing ${ file . filename } (${ headContent . length } chars, lang=${ language } )` ) ;
126+ debugLines . push ( `analyzing: ${ file . filename } (${ headContent . length } chars, ${ language } )` ) ;
150127
151- // ── AST analysis ───────────────────────────────────────────────────────
152- const headAnalysis = analyzeCode ( headContent , language ) ;
153- const baseAnalysis = analyzeCode ( baseContent || '' , language ) ;
128+ // ── AST analysis ────────────────────────────────────────────────────
129+ const headA = analyzeCode ( headContent , language ) ;
130+ const baseA = analyzeCode ( baseContent , language ) ;
154131
155- debugLines . push ( ` head detections: ${ headAnalysis . detections . length } ` ) ;
156- if ( headAnalysis . errors . length ) debugLines . push ( ` AST errors: ${ headAnalysis . errors . join ( '; ' ) } ` ) ;
132+ debugLines . push ( ` detections head= ${ headA . detections . length } base= ${ baseA . detections . length } ` ) ;
133+ if ( headA . errors . length ) debugLines . push ( ` AST errors: ${ headA . errors . join ( '; ' ) } ` ) ;
157134
158- if ( headAnalysis . detections . length === 0 ) continue ;
135+ if ( headA . detections . length === 0 ) continue ;
159136
160137 const fileHasLoop = / f o r \s * \( | w h i l e \s * \( | \. f o r E a c h \s * \( | \. m a p \s * \( / . test ( headContent ) ;
161138 if ( fileHasLoop ) inLoop += 1 ;
162139 handler += 1 ;
163140
164- // ── Compute costs inline (no Redis) ────────────────────────────────────
141+ // ── Cost calculation ───────────── ────────────────────────────────────
165142 let fileHeadCents = 0 ;
166143 let fileBaseCents = 0 ;
167144
168- for ( const d of headAnalysis . detections ) {
169- const cents = estimateCentsInline (
170- d . service , d . model , d . callsPerMonth ?? 10_000 ,
171- d . inputTokens ?? 500 , d . outputTokens ?? 1_000 ,
145+ for ( const d of headA . detections ) {
146+ const cents = estimateCents (
147+ d . service , d . model ,
148+ d . callsPerMonth ?? 10_000 ,
149+ d . inputTokens ?? 500 ,
150+ d . outputTokens ?? 1_000 ,
172151 d . storageGB , d . operation ,
173152 ) ;
174153 fileHeadCents += cents ;
175-
176154 detectionRows . push ( {
177- service : d . service ,
178- model : d . model ,
179- snippet : d . snippet ,
180- headCents : cents ,
181- deltaCents : 0 , // filled in below
182- inLoop : fileHasLoop ,
155+ service : d . service ,
156+ model : d . model ,
157+ snippet : d . snippet ,
158+ headCents : cents ,
159+ deltaCents : 0 , // filled below
160+ inLoop : fileHasLoop ,
183161 } ) ;
184162 }
185163
186- for ( const d of baseAnalysis . detections ) {
187- fileBaseCents += estimateCentsInline (
188- d . service , d . model , d . callsPerMonth ?? 10_000 ,
189- d . inputTokens ?? 500 , d . outputTokens ?? 1_000 ,
164+ for ( const d of baseA . detections ) {
165+ fileBaseCents += estimateCents (
166+ d . service , d . model ,
167+ d . callsPerMonth ?? 10_000 ,
168+ d . inputTokens ?? 500 ,
169+ d . outputTokens ?? 1_000 ,
190170 d . storageGB , d . operation ,
191171 ) ;
192172 }
193173
194174 totalHeadCents += fileHeadCents ;
195175 totalBaseCents += fileBaseCents ;
196176
197- // Distribute delta proportionally across detections in this file
177+ // Distribute file delta proportionally across detections
198178 const fileDelta = fileHeadCents - fileBaseCents ;
199- const startIdx = detectionRows . length - headAnalysis . detections . length ;
179+ const startIdx = detectionRows . length - headA . detections . length ;
200180 for ( let i = startIdx ; i < detectionRows . length ; i ++ ) {
201181 const share = fileHeadCents > 0 ? detectionRows [ i ] . headCents / fileHeadCents : 0 ;
202182 detectionRows [ i ] . deltaCents = Math . round ( fileDelta * share ) ;
203183 }
204184 }
205185
206186 // ── Build markdown ────────────────────────────────────────────────────────
207- const totalDelta = totalHeadCents - totalBaseCents ;
208- const fmt = ( c : number ) => `$${ ( Math . abs ( c ) / 100 ) . toLocaleString ( 'en-US' , { minimumFractionDigits : 2 } ) } ` ;
209- const sign = ( c : number ) => c > 0 ? '+' : c < 0 ? '-' : '' ;
187+ const totalDelta = totalHeadCents - totalBaseCents ;
188+ const fmt = ( c : number ) => `$${ ( Math . abs ( c ) / 100 ) . toLocaleString ( 'en-US' , { minimumFractionDigits : 2 } ) } ` ;
189+ const sign = ( c : number ) => c > 0 ? '+' : c < 0 ? '-' : '' ;
210190 const uniqueServices = [ ...new Set ( detectionRows . map ( r => r . service ) ) ] ;
211191
212- let criticalityBadge = '🟢 **Criticality: Low**' ;
213- let qualityStatement = '' ;
192+ let critBadge = '🟢 **Criticality: Low**' ;
193+ let qualityNote = '' ;
214194 if ( inLoop > 0 ) {
215- criticalityBadge = '🔴 **Criticality: Major**' ;
216- qualityStatement = '\n> ⚠️ **CRITICAL:** Cloud API calls detected inside a loop. Costs scale with every iteration — consider batching.\n' ;
195+ critBadge = '🔴 **Criticality: Major**' ;
196+ qualityNote = '\n> ⚠️ **CRITICAL:** Cloud API calls inside a loop — costs scale with every iteration. Consider batching.\n' ;
217197 } else if ( totalHeadCents > 5_000 ) {
218- criticalityBadge = '🟡 **Criticality: Minor**' ;
219- qualityStatement = '\n> 💡 Significant cloud costs detected. Ensure these align with your budget.\n' ;
198+ critBadge = '🟡 **Criticality: Minor**' ;
199+ qualityNote = '\n> 💡 Significant cloud costs detected. Verify these align with your budget.\n' ;
220200 }
221201
222202 let md = `## 📊 CloudGauge Cost Impact Analysis\n\n` ;
223- md += `### 💰 ESTIMATED MONTHLY COST DELTA\n` ;
224- md += `# **${ sign ( totalDelta ) } ${ fmt ( totalDelta ) } /mo**\n` ;
225- md += `${ criticalityBadge } \n\n` ;
226- md += `> Total cloud cost **in changed files**: **${ fmt ( totalHeadCents ) } /mo**` ;
203+ md += `### 💰 ESTIMATED MONTHLY COST DELTA\n` ;
204+ md += `# **${ sign ( totalDelta ) } ${ fmt ( totalDelta ) } /mo**\n` ;
205+ md += `${ critBadge } \n\n` ;
206+ md += `> Total cloud cost **in changed files**: **${ fmt ( totalHeadCents ) } /mo**` ;
227207 if ( totalBaseCents > 0 ) md += ` *(was ${ fmt ( totalBaseCents ) } /mo before this PR)*` ;
228- md += `\n\n` ;
229- md += `*Detected **${ detectionRows . length } ** cloud API call(s) across **${ uniqueServices . length } ** service(s).*\n` ;
230- md += qualityStatement + '\n' ;
231- md += `---\n\n` ;
208+ md += `\n\n` ;
209+ md += `*Detected **${ detectionRows . length } ** cloud API call(s) across **${ uniqueServices . length } ** service(s).*\n` ;
210+ md += qualityNote + '\n' ;
211+ md += `---\n\n` ;
232212
233213 md += `### ⚙️ EXECUTION CONTEXT IMPACT\n` ;
234214 md += `| 🔄 In Loop | 🌐 Handler | ⏱️ Scheduled | 📦 Batch | 📌 Direct |\n` ;
@@ -243,18 +223,14 @@ export async function botRoutes(app: FastifyInstance) {
243223 for ( const row of detectionRows ) {
244224 const label = row . model ? `**${ row . service } ** \`${ row . model } \`` : `**${ row . service } **` ;
245225 const badge = row . inLoop ? ' 🔄' : '' ;
246- const delta = row . deltaCents !== 0 ? `**${ sign ( row . deltaCents ) } ${ fmt ( row . deltaCents ) } /mo**` : `*(existing)*` ;
226+ const delta = row . deltaCents !== 0
227+ ? `**${ sign ( row . deltaCents ) } ${ fmt ( row . deltaCents ) } /mo**`
228+ : `*(existing)*` ;
247229 md += `| ${ label } ${ badge } | \`${ row . snippet . slice ( 0 , 70 ) } \` | **${ fmt ( row . headCents ) } /mo** | ${ delta } |\n` ;
248230 }
249231 } else {
250232 md += `*No cloud API calls detected in the changed files.*\n` ;
251- if ( fetchErrors . length > 0 ) {
252- md += `\n> ⚠️ **File fetch errors** (check GITHUB\\_TOKEN permissions):\n` ;
253- for ( const e of fetchErrors ) md += `> - \`${ e } \`\n` ;
254- }
255- if ( debugLines . length > 0 ) {
256- md += `\n<details><summary>🔍 Debug Info</summary>\n\n\`\`\`\n${ debugLines . join ( '\n' ) } \n\`\`\`\n</details>\n` ;
257- }
233+ md += `\n<details><summary>🔍 Debug Info</summary>\n\n\`\`\`\n${ debugLines . join ( '\n' ) } \n\`\`\`\n</details>\n` ;
258234 }
259235
260236 md += `\n\n> *Powered by SentinelEngine CodeReview Bot.*` ;
0 commit comments