Skip to content

Commit 3b89101

Browse files
authored
Merge pull request #4 from tstromberg/main
add projection to callout, improve log msgs
2 parents 3c63f9a + 9f0d6b9 commit 3b89101

File tree

4 files changed

+104
-37
lines changed

4 files changed

+104
-37
lines changed

cmd/server/main.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"net/http"
1010
"os"
1111
"os/signal"
12+
"path/filepath"
1213
"runtime"
1314
"syscall"
1415
"time"
@@ -36,10 +37,19 @@ func main() {
3637
// Create root context
3738
ctx := context.Background()
3839

39-
// Set up logging
40+
// Set up logging with short source paths
4041
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
4142
AddSource: true,
4243
Level: slog.LevelInfo,
44+
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
45+
// Shorten source file paths to show only filename:line
46+
if a.Key == slog.SourceKey {
47+
if src, ok := a.Value.Any().(*slog.Source); ok {
48+
src.File = filepath.Base(src.File)
49+
}
50+
}
51+
return a
52+
},
4353
}))
4454
slog.SetDefault(logger)
4555

internal/server/server.go

Lines changed: 5 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,11 @@ func New() *Server {
196196
logger.InfoContext(ctx, "No fallback token available - requests must provide Authorization header")
197197
}
198198

199-
// Start cache cleanup goroutine.
200-
go server.cleanupCachesPeriodically()
199+
// Note: We don't clear caches periodically because:
200+
// - PR data is immutable (closed PRs don't change)
201+
// - Memory usage is bounded by request patterns
202+
// - Cloud Run instances are ephemeral and restart frequently anyway
203+
// If needed in the future, implement LRU eviction with size limits instead of time-based clearing
201204

202205
return server
203206
}
@@ -304,36 +307,6 @@ func (s *Server) limiter(ctx context.Context, ip string) *rate.Limiter {
304307
return limiter
305308
}
306309

307-
// cleanupCachesPeriodically clears all caches every 30 minutes to prevent unbounded growth.
308-
// Cloud Run instances are ephemeral, so no complex TTL logic is needed.
309-
func (s *Server) cleanupCachesPeriodically() {
310-
ticker := time.NewTicker(30 * time.Minute)
311-
defer ticker.Stop()
312-
313-
for range ticker.C {
314-
s.clearCache(&s.prQueryCacheMu, s.prQueryCache, "pr_query")
315-
s.clearCache(&s.prDataCacheMu, s.prDataCache, "pr_data")
316-
}
317-
}
318-
319-
// clearCache removes all entries from a cache.
320-
func (s *Server) clearCache(mu *sync.RWMutex, cache map[string]*cacheEntry, name string) {
321-
mu.Lock()
322-
defer mu.Unlock()
323-
324-
count := len(cache)
325-
// Clear map by creating new map
326-
for key := range cache {
327-
delete(cache, key)
328-
}
329-
330-
if count > 0 {
331-
s.logger.Info("Cleared cache",
332-
"cache", name,
333-
"cleared", count)
334-
}
335-
}
336-
337310
// cachedPRQuery retrieves cached PR query results.
338311
func (s *Server) cachedPRQuery(key string) ([]github.PRSummary, bool) {
339312
s.prQueryCacheMu.RLock()

internal/server/static/index.html

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1412,15 +1412,26 @@ <h3>Why calculate PR costs?</h3>
14121412
return html;
14131413
}
14141414

1415-
function formatR2RCallout(avgOpenHours) {
1415+
function formatR2RCallout(avgOpenHours, r2rSavings) {
14161416
// Only show if average merge velocity is > 1 hour
14171417
if (avgOpenHours <= 1) {
14181418
return '';
14191419
}
14201420

1421+
// Format savings with appropriate precision
1422+
let savingsText;
1423+
if (r2rSavings >= 1000000) {
1424+
savingsText = '$' + (r2rSavings / 1000000).toFixed(1) + 'M';
1425+
} else if (r2rSavings >= 1000) {
1426+
savingsText = '$' + (r2rSavings / 1000).toFixed(0) + 'K';
1427+
} else {
1428+
savingsText = '$' + r2rSavings.toFixed(0);
1429+
}
1430+
14211431
let html = '<div style="margin: 24px 0; padding: 12px 20px; background: linear-gradient(135deg, #e6f9f0 0%, #ffffff 100%); border-left: 3px solid #00c853; border-radius: 8px; font-size: 14px; color: #1d1d1f; line-height: 1.6;">';
1422-
html += '✓ <a href="https://codegroove.dev/" target="_blank" rel="noopener" style="color: #00c853; font-weight: 600; text-decoration: none;">Ready-to-Review</a>: <strong>$4/mo</strong> to cut merge latency to <strong>≤40 min</strong><br>';
1423-
html += 'Stop losing engineering hours to code review lag. Free for OSS projects. Let\'s chat: <a href="mailto:[email protected]" style="color: #00c853; text-decoration: none;">[email protected]</a>';
1432+
html += '✓ Based on this calculation, <a href="https://codegroove.dev/" target="_blank" rel="noopener" style="color: #00c853; font-weight: 600; text-decoration: none;">Ready-to-Review</a> would save you <strong>~' + savingsText + '/yr</strong> by cutting average merge time to ≤40 min. ';
1433+
html += 'Stop losing engineering hours to code review lag. Free for OSS projects. ';
1434+
html += 'Let\'s chat: <a href="mailto:[email protected]" style="color: #00c853; text-decoration: none;">[email protected]</a>';
14241435
html += '</div>';
14251436
return html;
14261437
}
@@ -2025,7 +2036,8 @@ <h3>Why calculate PR costs?</h3>
20252036

20262037
// Add R2R callout if enabled and merge velocity > 1 hour
20272038
if (data.r2r_callout) {
2028-
html += formatR2RCallout(avgPRDurationHours);
2039+
const r2rSavings = e.r2r_savings || 0;
2040+
html += formatR2RCallout(avgPRDurationHours, r2rSavings);
20292041
}
20302042

20312043
// Calculate average PR efficiency

pkg/cost/extrapolate.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ type ExtrapolatedBreakdown struct {
8282
// Grand totals
8383
TotalCost float64 `json:"total_cost"`
8484
TotalHours float64 `json:"total_hours"`
85+
86+
// R2R cost savings calculation
87+
UniqueNonBotUsers int `json:"unique_non_bot_users"` // Count of unique non-bot users (authors + participants)
88+
R2RSavings float64 `json:"r2r_savings"` // Annual savings if R2R cuts PR time to 40 minutes
8589
}
8690

8791
// ExtrapolateFromSamples calculates extrapolated cost estimates from a sample
@@ -113,6 +117,8 @@ func ExtrapolateFromSamples(breakdowns []Breakdown, totalPRs, totalAuthors, actu
113117

114118
// Track unique PR authors (excluding bots)
115119
uniqueAuthors := make(map[string]bool)
120+
// Track unique non-bot users (authors + participants)
121+
uniqueNonBotUsers := make(map[string]bool)
116122

117123
// Track bot vs human PR metrics
118124
var humanPRCount, botPRCount int
@@ -140,6 +146,7 @@ func ExtrapolateFromSamples(breakdowns []Breakdown, totalPRs, totalAuthors, actu
140146
// Track unique PR authors only (excluding bots)
141147
if !breakdown.AuthorBot {
142148
uniqueAuthors[breakdown.PRAuthor] = true
149+
uniqueNonBotUsers[breakdown.PRAuthor] = true
143150
humanPRCount++
144151
sumHumanPRDuration += breakdown.PRDuration
145152
} else {
@@ -150,6 +157,12 @@ func ExtrapolateFromSamples(breakdowns []Breakdown, totalPRs, totalAuthors, actu
150157
sumBotModifiedLines += breakdown.Author.ModifiedLines
151158
}
152159

160+
// Track unique participants (excluding bots)
161+
for _, p := range breakdown.Participants {
162+
// Participants from the Breakdown struct are already filtered to exclude bots
163+
uniqueNonBotUsers[p.Actor] = true
164+
}
165+
153166
// Accumulate PR duration (all PRs)
154167
sumPRDuration += breakdown.PRDuration
155168

@@ -322,6 +335,62 @@ func ExtrapolateFromSamples(breakdowns []Breakdown, totalPRs, totalAuthors, actu
322335
extHumanPRs := int(float64(humanPRCount) / samples * multiplier)
323336
extBotPRs := int(float64(botPRCount) / samples * multiplier)
324337

338+
// Calculate R2R savings
339+
// Formula: baseline annual waste - (re-modeled waste with 40min PRs) - (R2R subscription cost)
340+
// Baseline annual waste: preventable cost extrapolated to 52 weeks
341+
uniqueUserCount := len(uniqueNonBotUsers)
342+
baselineAnnualWaste := (extCodeChurnCost + extDeliveryDelayCost + extAutomatedUpdatesCost + extPRTrackingCost) * (52.0 / (float64(daysInPeriod) / 7.0))
343+
344+
// Re-model with 40-minute PR merge times
345+
// We need to recalculate delivery delay and future costs assuming all PRs take 40 minutes (2/3 hour)
346+
const targetMergeTimeHours = 40.0 / 60.0 // 40 minutes in hours
347+
348+
// Recalculate delivery delay cost with 40-minute PRs
349+
// Delivery delay formula: hourlyRate × deliveryDelayFactor × PR duration
350+
var remodelDeliveryDelayCost float64
351+
for range breakdowns {
352+
remodelDeliveryDelayCost += hourlyRate * cfg.DeliveryDelayFactor * targetMergeTimeHours
353+
}
354+
extRemodelDeliveryDelayCost := remodelDeliveryDelayCost / samples * multiplier
355+
356+
// Recalculate code churn with 40-minute PRs
357+
// Code churn is proportional to PR duration (rework percentage increases with time)
358+
// For 40 minutes, rework percentage would be minimal (< 1 day, so ~0%)
359+
extRemodelCodeChurnCost := 0.0 // 40 minutes is too short for meaningful code churn
360+
361+
// Recalculate automated updates cost
362+
// Automated updates are calculated based on PR duration
363+
// With 40-minute PRs, no bot updates would be needed (happens after 1 day)
364+
extRemodelAutomatedUpdatesCost := 0.0 // 40 minutes is too short for automated updates
365+
366+
// Recalculate PR tracking cost
367+
// With faster merge times, we'd have fewer open PRs at any given time
368+
// Estimate: if current avg is X hours, and we reduce to 40 min, open PRs would be (40min / X hours) of current
369+
var extRemodelPRTrackingCost float64
370+
var currentAvgOpenTime float64
371+
if successfulSamples > 0 {
372+
currentAvgOpenTime = sumPRDuration / samples
373+
}
374+
if currentAvgOpenTime > 0 {
375+
openPRReductionRatio := targetMergeTimeHours / currentAvgOpenTime
376+
extRemodelPRTrackingCost = extPRTrackingCost * openPRReductionRatio
377+
} else {
378+
extRemodelPRTrackingCost = 0.0
379+
}
380+
381+
// Calculate re-modeled annual waste
382+
remodelPreventablePerPeriod := extRemodelDeliveryDelayCost + extRemodelCodeChurnCost + extRemodelAutomatedUpdatesCost + extRemodelPRTrackingCost
383+
remodelAnnualWaste := remodelPreventablePerPeriod * (52.0 / (float64(daysInPeriod) / 7.0))
384+
385+
// Subtract R2R subscription cost: $4/mo * 12 months * unique user count
386+
r2rAnnualCost := 4.0 * 12.0 * float64(uniqueUserCount)
387+
388+
// Calculate savings
389+
r2rSavings := baselineAnnualWaste - remodelAnnualWaste - r2rAnnualCost
390+
if r2rSavings < 0 {
391+
r2rSavings = 0 // Don't show negative savings
392+
}
393+
325394
return ExtrapolatedBreakdown{
326395
TotalPRs: totalPRs,
327396
HumanPRs: extHumanPRs,
@@ -390,5 +459,8 @@ func ExtrapolateFromSamples(breakdowns []Breakdown, totalPRs, totalAuthors, actu
390459

391460
TotalCost: extTotalCost,
392461
TotalHours: extTotalHours,
462+
463+
UniqueNonBotUsers: uniqueUserCount,
464+
R2RSavings: r2rSavings,
393465
}
394466
}

0 commit comments

Comments
 (0)