Skip to content

Commit c8f0488

Browse files
committed
use 1.5h as model average PR latency
1 parent 563bbb1 commit c8f0488

6 files changed

Lines changed: 48 additions & 40 deletions

File tree

cmd/prcost/main.go

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ func main() {
3535
days := flag.Int("days", 60, "Number of days to look back for PR modifications")
3636

3737
// Modeling flags
38-
modelMergeTime := flag.Duration("model-merge-time", 1*time.Hour, "Model savings if average merge time was reduced to this duration")
38+
targetMergeTime := flag.Duration("target-merge-time", 90*time.Minute, "Target merge time for efficiency modeling (default: 90 minutes / 1.5 hours)")
3939

4040
flag.Usage = func() {
4141
fmt.Fprintf(os.Stderr, "Usage: %s [options] <PR_URL>\n", os.Args[0])
@@ -100,11 +100,13 @@ func main() {
100100
cfg.AnnualSalary = *salary
101101
cfg.BenefitsMultiplier = *benefits
102102
cfg.EventDuration = time.Duration(*eventMinutes) * time.Minute
103+
cfg.TargetMergeTimeHours = targetMergeTime.Hours()
103104

104105
slog.Debug("Configuration",
105106
"salary", cfg.AnnualSalary,
106107
"benefits_multiplier", cfg.BenefitsMultiplier,
107108
"event_minutes", *eventMinutes,
109+
"target_merge_time_hours", cfg.TargetMergeTimeHours,
108110
"delivery_delay_factor", cfg.DeliveryDelayFactor)
109111

110112
// Retrieve GitHub token from gh CLI
@@ -122,7 +124,7 @@ func main() {
122124
if *repo != "" {
123125
// Single repository mode
124126

125-
err := analyzeRepository(ctx, *org, *repo, *samples, *days, cfg, token, *dataSource, modelMergeTime)
127+
err := analyzeRepository(ctx, *org, *repo, *samples, *days, cfg, token, *dataSource)
126128
if err != nil {
127129
log.Fatalf("Repository analysis failed: %v", err)
128130
}
@@ -133,7 +135,7 @@ func main() {
133135
"samples", *samples,
134136
"days", *days)
135137

136-
err := analyzeOrganization(ctx, *org, *samples, *days, cfg, token, *dataSource, modelMergeTime)
138+
err := analyzeOrganization(ctx, *org, *samples, *days, cfg, token, *dataSource)
137139
if err != nil {
138140
log.Fatalf("Organization analysis failed: %v", err)
139141
}
@@ -177,7 +179,7 @@ func main() {
177179
// Output in requested format
178180
switch *format {
179181
case "human":
180-
printHumanReadable(&breakdown, prURL, *modelMergeTime, cfg)
182+
printHumanReadable(&breakdown, prURL, cfg)
181183
case "json":
182184
encoder := json.NewEncoder(os.Stdout)
183185
encoder.SetIndent("", " ")
@@ -209,7 +211,7 @@ func authToken(ctx context.Context) (string, error) {
209211
}
210212

211213
// printHumanReadable outputs a detailed itemized bill in human-readable format.
212-
func printHumanReadable(breakdown *cost.Breakdown, prURL string, modelMergeTime time.Duration, cfg cost.Config) {
214+
func printHumanReadable(breakdown *cost.Breakdown, prURL string, cfg cost.Config) {
213215
// Helper to format currency with commas
214216
formatCurrency := func(amount float64) string {
215217
return fmt.Sprintf("$%s", formatWithCommas(amount))
@@ -313,9 +315,9 @@ func printHumanReadable(breakdown *cost.Breakdown, prURL string, modelMergeTime
313315
// Print efficiency score
314316
printEfficiency(breakdown)
315317

316-
// Print modeling callout if PR duration exceeds model merge time
317-
if breakdown.PRDuration > modelMergeTime.Hours() {
318-
printMergeTimeModelingCallout(breakdown, modelMergeTime, cfg)
318+
// Print modeling callout if PR duration exceeds target merge time
319+
if breakdown.PRDuration > cfg.TargetMergeTimeHours {
320+
printMergeTimeModelingCallout(breakdown, cfg)
319321
}
320322
}
321323

@@ -528,8 +530,8 @@ func mergeVelocityGrade(avgOpenDays float64) (grade, message string) {
528530
}
529531

530532
// printMergeTimeModelingCallout prints a callout showing potential savings from reduced merge time.
531-
func printMergeTimeModelingCallout(breakdown *cost.Breakdown, targetMergeTime time.Duration, cfg cost.Config) {
532-
targetHours := targetMergeTime.Hours()
533+
func printMergeTimeModelingCallout(breakdown *cost.Breakdown, cfg cost.Config) {
534+
targetHours := cfg.TargetMergeTimeHours
533535
currentHours := breakdown.PRDuration
534536

535537
// Calculate hourly rate
@@ -538,15 +540,15 @@ func printMergeTimeModelingCallout(breakdown *cost.Breakdown, targetMergeTime ti
538540
// Recalculate delivery delay with target merge time
539541
remodelDeliveryDelayCost := hourlyRate * cfg.DeliveryDelayFactor * targetHours
540542

541-
// Code churn: 40min-1h is too short for meaningful code churn (< 1 day)
543+
// Code churn: target time is too short for meaningful code churn (< 1 day)
542544
remodelCodeChurnCost := 0.0
543545

544546
// Automated updates: only applies to PRs open > 1 day
545547
remodelAutomatedUpdatesCost := 0.0
546548

547549
// PR tracking: scales with open time (already minimal for short PRs)
548550
remodelPRTrackingCost := 0.0
549-
if targetHours >= 1.0 { // Only track PRs open >= 1 hour
551+
if targetHours >= 1.0 { // Minimal tracking for PRs open >= 1 hour
550552
daysOpen := targetHours / 24.0
551553
remodelPRTrackingHours := (cfg.PRTrackingMinutesPerDay / 60.0) * daysOpen
552554
remodelPRTrackingCost = remodelPRTrackingHours * hourlyRate

cmd/prcost/repository.go

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import (
1616
// and extrapolation - all functionality is available to external clients.
1717
//
1818
//nolint:revive // argument-limit: acceptable for entry point function
19-
func analyzeRepository(ctx context.Context, owner, repo string, sampleSize, days int, cfg cost.Config, token, dataSource string, modelMergeTime *time.Duration) error {
19+
func analyzeRepository(ctx context.Context, owner, repo string, sampleSize, days int, cfg cost.Config, token, dataSource string) error {
2020
// Calculate since date
2121
since := time.Now().AddDate(0, 0, -days)
2222

@@ -105,7 +105,7 @@ func analyzeRepository(ctx context.Context, owner, repo string, sampleSize, days
105105
extrapolated := cost.ExtrapolateFromSamples(breakdowns, len(prs), totalAuthors, openPRCount, actualDays, cfg)
106106

107107
// Display results in itemized format
108-
printExtrapolatedResults(fmt.Sprintf("%s/%s", owner, repo), actualDays, &extrapolated, cfg, *modelMergeTime)
108+
printExtrapolatedResults(fmt.Sprintf("%s/%s", owner, repo), actualDays, &extrapolated, cfg)
109109

110110
return nil
111111
}
@@ -115,7 +115,7 @@ func analyzeRepository(ctx context.Context, owner, repo string, sampleSize, days
115115
// and extrapolation - all functionality is available to external clients.
116116
//
117117
//nolint:revive // argument-limit: acceptable for entry point function
118-
func analyzeOrganization(ctx context.Context, org string, sampleSize, days int, cfg cost.Config, token, dataSource string, modelMergeTime *time.Duration) error {
118+
func analyzeOrganization(ctx context.Context, org string, sampleSize, days int, cfg cost.Config, token, dataSource string) error {
119119
slog.Info("Fetching PR list from organization")
120120

121121
// Calculate since date
@@ -207,7 +207,7 @@ func analyzeOrganization(ctx context.Context, org string, sampleSize, days int,
207207
extrapolated := cost.ExtrapolateFromSamples(breakdowns, len(prs), totalAuthors, totalOpenPRs, actualDays, cfg)
208208

209209
// Display results in itemized format
210-
printExtrapolatedResults(fmt.Sprintf("%s (organization)", org), actualDays, &extrapolated, cfg, *modelMergeTime)
210+
printExtrapolatedResults(fmt.Sprintf("%s (organization)", org), actualDays, &extrapolated, cfg)
211211

212212
return nil
213213
}
@@ -278,7 +278,7 @@ func formatTimeUnit(hours float64) string {
278278
// printExtrapolatedResults displays extrapolated cost breakdown in itemized format.
279279
//
280280
//nolint:maintidx,revive // acceptable complexity/length for comprehensive display function
281-
func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBreakdown, cfg cost.Config, modelMergeTime time.Duration) {
281+
func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBreakdown, cfg cost.Config) {
282282
fmt.Println()
283283
fmt.Printf(" %s\n", title)
284284
avgOpenTime := formatTimeUnit(ext.AvgPRDurationHours)
@@ -614,11 +614,11 @@ func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBrea
614614
fmt.Println()
615615

616616
// Print extrapolated efficiency score + annual waste
617-
printExtrapolatedEfficiency(ext, days, cfg, modelMergeTime)
617+
printExtrapolatedEfficiency(ext, days, cfg)
618618
}
619619

620620
// printExtrapolatedEfficiency prints the workflow efficiency + annual waste section for extrapolated totals.
621-
func printExtrapolatedEfficiency(ext *cost.ExtrapolatedBreakdown, days int, cfg cost.Config, modelMergeTime time.Duration) {
621+
func printExtrapolatedEfficiency(ext *cost.ExtrapolatedBreakdown, days int, cfg cost.Config) {
622622
// Calculate preventable waste: Code Churn + All Delay Costs + Automated Updates + PR Tracking
623623
preventableHours := ext.CodeChurnHours + ext.DeliveryDelayHours + ext.AutomatedUpdatesHours + ext.PRTrackingHours
624624
preventableCost := ext.CodeChurnCost + ext.DeliveryDelayCost + ext.AutomatedUpdatesCost + ext.PRTrackingCost
@@ -676,14 +676,14 @@ func printExtrapolatedEfficiency(ext *cost.ExtrapolatedBreakdown, days int, cfg
676676
fmt.Println()
677677

678678
// Print merge time modeling callout if average PR duration exceeds model merge time
679-
if ext.AvgPRDurationHours > modelMergeTime.Hours() {
680-
printExtrapolatedMergeTimeModelingCallout(ext, days, modelMergeTime, cfg)
679+
if ext.AvgPRDurationHours > cfg.TargetMergeTimeHours {
680+
printExtrapolatedMergeTimeModelingCallout(ext, days, cfg)
681681
}
682682
}
683683

684684
// printExtrapolatedMergeTimeModelingCallout prints a callout showing potential savings from reduced merge time.
685-
func printExtrapolatedMergeTimeModelingCallout(ext *cost.ExtrapolatedBreakdown, days int, targetMergeTime time.Duration, cfg cost.Config) {
686-
targetHours := targetMergeTime.Hours()
685+
func printExtrapolatedMergeTimeModelingCallout(ext *cost.ExtrapolatedBreakdown, days int, cfg cost.Config) {
686+
targetHours := cfg.TargetMergeTimeHours
687687

688688
// Calculate hourly rate
689689
hourlyRate := (cfg.AnnualSalary * cfg.BenefitsMultiplier) / cfg.HoursPerYear
@@ -702,7 +702,7 @@ func printExtrapolatedMergeTimeModelingCallout(ext *cost.ExtrapolatedBreakdown,
702702

703703
// PR tracking: scales with open time
704704
remodelPRTrackingPerPR := 0.0
705-
if targetHours >= 1.0 { // Only track PRs open >= 1 hour
705+
if targetHours >= 1.0 { // Minimal tracking for PRs open >= 1 hour
706706
daysOpen := targetHours / 24.0
707707
remodelPRTrackingHours := (cfg.PRTrackingMinutesPerDay / 60.0) * daysOpen
708708
remodelPRTrackingPerPR = remodelPRTrackingHours * hourlyRate

internal/server/static/index.html

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1510,7 +1510,7 @@ <h3>Why calculate PR costs?</h3>
15101510
}
15111511

15121512
let html = '<div style="margin: 24px 0; padding: 12px 20px; background: linear-gradient(135deg, #f0f4f9 0%, #ffffff 100%); border: 1px solid #0066cc; border-radius: 8px; font-size: 14px; color: #1d1d1f; line-height: 1.6;">';
1513-
html += '<strong>💡 Merge Time Modeling:</strong> If you lowered your average merge time to 1h, you would save <strong>~' + savingsText + '/yr</strong> in engineering overhead' + throughputText + '.';
1513+
html += '<strong>💡 Merge Time Modeling:</strong> If you lowered your average merge time to 1.5h, you would save <strong>~' + savingsText + '/yr</strong> in engineering overhead' + throughputText + '.';
15141514
html += '</div>';
15151515
return html;
15161516
}
@@ -2271,16 +2271,16 @@ <h3>Why calculate PR costs?</h3>
22712271
html += formatEfficiencyHTML(extEfficiencyPct, extEfficiency.grade, extEfficiency.message, extPreventableCost, extPreventableHours, e.total_cost, e.total_hours, avgPRDurationHours, true, annualWasteCost, annualWasteHours, wasteHoursPerWeek, wasteCostPerWeek, wasteHoursPerAuthorPerWeek, wasteCostPerAuthorPerWeek, totalAuthors, salary, benefitsMultiplier, analysisType, sourceName);
22722272

22732273
// Add R2R callout if enabled, otherwise generic merge time callout
2274-
// Calculate modeled efficiency (with 40min/1h merge time)
2275-
const targetMergeHours = 40 / 60; // 40 minutes in hours
2274+
// Calculate modeled efficiency (with 1.5h merge time)
2275+
const targetMergeHours = 1.5; // 1.5 hours (90 minutes)
22762276
const hourlyRate = (salary * benefitsMultiplier) / 2080;
22772277

22782278
// Remodel preventable costs with target merge time
22792279
const deliveryDelayFactor = 0.20; // From DefaultConfig
22802280
const remodelDeliveryDelayPerPR = hourlyRate * deliveryDelayFactor * targetMergeHours;
22812281
const remodelCodeChurnPerPR = 0; // < 1 day, no churn
22822282
const remodelAutomatedUpdatesPerPR = 0; // < 1 day, no automated updates
2283-
const remodelPRTrackingPerPR = 0; // < 1 hour, no tracking
2283+
const remodelPRTrackingPerPR = 0; // < 2 hours, minimal tracking
22842284

22852285
const totalPRs = e.total_prs;
22862286
const remodelPreventableCost = (remodelDeliveryDelayPerPR + remodelCodeChurnPerPR +

pkg/cost/cost.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,11 @@ type Config struct {
109109
// - 0.080+ (8%+/week) → 99%+ annual churn - extremely fast-moving, younger companies
110110
WeeklyChurnRate float64
111111

112+
// TargetMergeTimeHours is the target merge time in hours for efficiency modeling (default: 1.5 hours / 90 minutes)
113+
// Used to calculate potential savings if merge times were reduced to this target.
114+
// This represents a realistic goal for well-optimized PR workflows.
115+
TargetMergeTimeHours float64
116+
112117
// COCOMO configuration for estimating code writing effort
113118
COCOMO cocomo.Config
114119
}
@@ -132,6 +137,7 @@ func DefaultConfig() Config {
132137
ReviewInspectionRate: 275.0, // 275 LOC/hour (average of optimal 150-400 range)
133138
ModificationCostFactor: 0.4, // Modified code costs 40% of new code
134139
WeeklyChurnRate: 0.0229, // 2.29% per week (70% annual, 60th percentile empirical)
140+
TargetMergeTimeHours: 1.5, // 1.5 hours (90 minutes) target for efficiency modeling
135141
COCOMO: cocomo.DefaultConfig(),
136142
}
137143
}

pkg/cost/cost_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1537,7 +1537,7 @@ func TestExtrapolateFromSamplesR2RSavings(t *testing.T) {
15371537
}
15381538

15391539
// For a 3-day PR, there should be significant savings
1540-
// (R2R targets 40-minute PRs, which would eliminate most delay costs)
1540+
// (R2R targets 1.5-hour PRs, which would eliminate most delay costs)
15411541
if result.R2RSavings == 0 {
15421542
t.Error("Expected positive R2R savings for long-duration PRs")
15431543
}

pkg/cost/extrapolate.go

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ type ExtrapolatedBreakdown struct {
9696

9797
// R2R cost savings calculation
9898
UniqueNonBotUsers int `json:"unique_non_bot_users"` // Count of unique non-bot users (authors + participants)
99-
R2RSavings float64 `json:"r2r_savings"` // Annual savings if R2R cuts PR time to 40 minutes
99+
R2RSavings float64 `json:"r2r_savings"` // Annual savings if R2R cuts PR time to target merge time
100100
}
101101

102102
// ExtrapolateFromSamples calculates extrapolated cost estimates from a sample
@@ -389,31 +389,31 @@ func ExtrapolateFromSamples(breakdowns []Breakdown, totalPRs, totalAuthors, actu
389389
preventableCost := extCodeChurnCost + extDeliveryDelayCost + extAutomatedUpdatesCost + extPRTrackingCost
390390
baselineAnnualWaste := preventableCost * (52.0 / (float64(daysInPeriod) / 7.0))
391391

392-
// Re-model with 40-minute PR merge times
393-
// We need to recalculate delivery delay and future costs assuming all PRs take 40 minutes (2/3 hour)
394-
const targetMergeTimeHours = 40.0 / 60.0 // 40 minutes in hours
392+
// Re-model with target PR merge time from config
393+
// We need to recalculate delivery delay and future costs assuming all PRs take the target merge time
394+
targetMergeTimeHours := cfg.TargetMergeTimeHours
395395

396-
// Recalculate delivery delay cost with 40-minute PRs
396+
// Recalculate delivery delay cost with target merge time PRs
397397
// Delivery delay formula: hourlyRate × deliveryDelayFactor × PR duration
398398
var remodelDeliveryDelayCost float64
399399
for range breakdowns {
400400
remodelDeliveryDelayCost += hourlyRate * cfg.DeliveryDelayFactor * targetMergeTimeHours
401401
}
402402
extRemodelDeliveryDelayCost := remodelDeliveryDelayCost / samples * multiplier
403403

404-
// Recalculate code churn with 40-minute PRs
404+
// Recalculate code churn with target merge time PRs
405405
// Code churn is proportional to PR duration (rework percentage increases with time)
406-
// For 40 minutes, rework percentage would be minimal (< 1 day, so ~0%)
407-
extRemodelCodeChurnCost := 0.0 // 40 minutes is too short for meaningful code churn
406+
// For target merge times < 1 day, rework percentage would be minimal (~0%)
407+
extRemodelCodeChurnCost := 0.0 // Target merge time is too short for meaningful code churn
408408

409409
// Recalculate automated updates cost
410410
// Automated updates are calculated based on PR duration
411-
// With 40-minute PRs, no bot updates would be needed (happens after 1 day)
412-
extRemodelAutomatedUpdatesCost := 0.0 // 40 minutes is too short for automated updates
411+
// With target merge time PRs, no bot updates would be needed (happens after 1 day)
412+
extRemodelAutomatedUpdatesCost := 0.0 // Target merge time is too short for automated updates
413413

414414
// Recalculate PR tracking cost
415415
// With faster merge times, we'd have fewer open PRs at any given time
416-
// Estimate: if current avg is X hours, and we reduce to 40 min, open PRs would be (40min / X hours) of current
416+
// Estimate: if current avg is X hours, and we reduce to target, open PRs would be (target / X hours) of current
417417
var extRemodelPRTrackingCost float64
418418
var currentAvgOpenTime float64
419419
if successfulSamples > 0 {

0 commit comments

Comments
 (0)