Skip to content

Commit ea3e005

Browse files
Thomas StrombergThomas Stromberg
authored andcommitted
Add merge time modeling for everyone
1 parent 10b067c commit ea3e005

File tree

3 files changed

+236
-15
lines changed

3 files changed

+236
-15
lines changed

cmd/prcost/main.go

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ func main() {
3434
samples := flag.Int("samples", 30, "Number of PRs to sample for extrapolation (30=fast/±18%, 50=slower/±14%)")
3535
days := flag.Int("days", 60, "Number of days to look back for PR modifications")
3636

37+
// Modeling flags
38+
modelMergeTime := flag.Duration("model-merge-time", 1*time.Hour, "Model savings if average merge time was reduced to this duration")
39+
3740
flag.Usage = func() {
3841
fmt.Fprintf(os.Stderr, "Usage: %s [options] <PR_URL>\n", os.Args[0])
3942
fmt.Fprintf(os.Stderr, " %s --org <org> [--repo <repo>] [options]\n\n", os.Args[0])
@@ -119,7 +122,7 @@ func main() {
119122
if *repo != "" {
120123
// Single repository mode
121124

122-
err := analyzeRepository(ctx, *org, *repo, *samples, *days, cfg, token, *dataSource)
125+
err := analyzeRepository(ctx, *org, *repo, *samples, *days, cfg, token, *dataSource, modelMergeTime)
123126
if err != nil {
124127
log.Fatalf("Repository analysis failed: %v", err)
125128
}
@@ -130,7 +133,7 @@ func main() {
130133
"samples", *samples,
131134
"days", *days)
132135

133-
err := analyzeOrganization(ctx, *org, *samples, *days, cfg, token, *dataSource)
136+
err := analyzeOrganization(ctx, *org, *samples, *days, cfg, token, *dataSource, modelMergeTime)
134137
if err != nil {
135138
log.Fatalf("Organization analysis failed: %v", err)
136139
}
@@ -174,7 +177,7 @@ func main() {
174177
// Output in requested format
175178
switch *format {
176179
case "human":
177-
printHumanReadable(&breakdown, prURL)
180+
printHumanReadable(&breakdown, prURL, *modelMergeTime, cfg)
178181
case "json":
179182
encoder := json.NewEncoder(os.Stdout)
180183
encoder.SetIndent("", " ")
@@ -206,7 +209,7 @@ func authToken(ctx context.Context) (string, error) {
206209
}
207210

208211
// printHumanReadable outputs a detailed itemized bill in human-readable format.
209-
func printHumanReadable(breakdown *cost.Breakdown, prURL string) {
212+
func printHumanReadable(breakdown *cost.Breakdown, prURL string, modelMergeTime time.Duration, cfg cost.Config) {
210213
// Helper to format currency with commas
211214
formatCurrency := func(amount float64) string {
212215
return fmt.Sprintf("$%s", formatWithCommas(amount))
@@ -309,6 +312,11 @@ func printHumanReadable(breakdown *cost.Breakdown, prURL string) {
309312

310313
// Print efficiency score
311314
printEfficiency(breakdown)
315+
316+
// Print modeling callout if PR duration exceeds model merge time
317+
if breakdown.PRDuration > modelMergeTime.Hours() {
318+
printMergeTimeModelingCallout(breakdown, modelMergeTime, cfg)
319+
}
312320
}
313321

314322
// printDelayCosts prints delay and future costs section.
@@ -519,6 +527,82 @@ func mergeVelocityGrade(avgOpenDays float64) (grade, message string) {
519527
}
520528
}
521529

530+
// 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+
currentHours := breakdown.PRDuration
534+
535+
// Calculate hourly rate
536+
hourlyRate := (cfg.AnnualSalary * cfg.BenefitsMultiplier) / cfg.HoursPerYear
537+
538+
// Recalculate delivery delay with target merge time
539+
remodelDeliveryDelayCost := hourlyRate * cfg.DeliveryDelayFactor * targetHours
540+
541+
// Code churn: 40min-1h is too short for meaningful code churn (< 1 day)
542+
remodelCodeChurnCost := 0.0
543+
544+
// Automated updates: only applies to PRs open > 1 day
545+
remodelAutomatedUpdatesCost := 0.0
546+
547+
// PR tracking: scales with open time (already minimal for short PRs)
548+
remodelPRTrackingCost := 0.0
549+
if targetHours >= 1.0 { // Only track PRs open >= 1 hour
550+
daysOpen := targetHours / 24.0
551+
remodelPRTrackingHours := (cfg.PRTrackingMinutesPerDay / 60.0) * daysOpen
552+
remodelPRTrackingCost = remodelPRTrackingHours * hourlyRate
553+
}
554+
555+
// Calculate savings for this PR
556+
currentPreventable := breakdown.DelayCostDetail.DeliveryDelayCost +
557+
breakdown.DelayCostDetail.CodeChurnCost +
558+
breakdown.DelayCostDetail.AutomatedUpdatesCost +
559+
breakdown.DelayCostDetail.PRTrackingCost
560+
561+
remodelPreventable := remodelDeliveryDelayCost + remodelCodeChurnCost +
562+
remodelAutomatedUpdatesCost + remodelPRTrackingCost
563+
564+
savingsPerPR := currentPreventable - remodelPreventable
565+
566+
// Calculate efficiency improvement
567+
// Current efficiency: (total hours - preventable hours) / total hours
568+
// Modeled efficiency: (total hours - remodeled preventable hours) / total hours
569+
totalHours := breakdown.Author.TotalHours + breakdown.DelayCostDetail.TotalDelayHours
570+
for _, p := range breakdown.Participants {
571+
totalHours += p.TotalHours
572+
}
573+
574+
var currentEfficiency, modeledEfficiency, efficiencyDelta float64
575+
if totalHours > 0 {
576+
currentEfficiency = 100.0 * (totalHours - (currentPreventable / hourlyRate)) / totalHours
577+
modeledEfficiency = 100.0 * (totalHours - (remodelPreventable / hourlyRate)) / totalHours
578+
efficiencyDelta = modeledEfficiency - currentEfficiency
579+
}
580+
581+
// Estimate annual savings assuming similar PR frequency
582+
// Use a conservative estimate: this PR represents typical overhead
583+
// Extrapolate to 52 weeks based on how long this PR was open
584+
if savingsPerPR > 0 && currentHours > 0 {
585+
// Rough annual extrapolation: (savings per PR) × (52 weeks) / (weeks this PR was open)
586+
weeksOpen := currentHours / (24.0 * 7.0)
587+
if weeksOpen < 0.01 {
588+
weeksOpen = 0.01 // Avoid division by zero, minimum 1% of a week
589+
}
590+
annualSavings := savingsPerPR * (52.0 / weeksOpen)
591+
592+
fmt.Println(" ┌─────────────────────────────────────────────────────────────┐")
593+
fmt.Printf(" │ %-60s│\n", "MERGE TIME MODELING")
594+
fmt.Println(" └─────────────────────────────────────────────────────────────┘")
595+
fmt.Printf(" If you lowered your average merge time to %s, you would save\n", formatTimeUnit(targetHours))
596+
fmt.Printf(" ~$%s/yr in engineering overhead", formatWithCommas(annualSavings))
597+
if efficiencyDelta > 0 {
598+
fmt.Printf(" (+%.1f%% throughput).\n", efficiencyDelta)
599+
} else {
600+
fmt.Println(".")
601+
}
602+
fmt.Println()
603+
}
604+
}
605+
522606
// printEfficiency prints the workflow efficiency section for a single PR.
523607
func printEfficiency(breakdown *cost.Breakdown) {
524608
// Calculate preventable waste: Code Churn + All Delay Costs + Automated Updates + PR Tracking

cmd/prcost/repository.go

Lines changed: 88 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ import (
1414
// analyzeRepository performs repository-wide cost analysis by sampling PRs.
1515
// Uses library functions from pkg/github and pkg/cost for fetching, sampling,
1616
// and extrapolation - all functionality is available to external clients.
17-
func analyzeRepository(ctx context.Context, owner, repo string, sampleSize, days int, cfg cost.Config, token string, dataSource string) error {
17+
//
18+
//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 {
1820
// Calculate since date
1921
since := time.Now().AddDate(0, 0, -days)
2022

@@ -103,15 +105,17 @@ func analyzeRepository(ctx context.Context, owner, repo string, sampleSize, days
103105
extrapolated := cost.ExtrapolateFromSamples(breakdowns, len(prs), totalAuthors, openPRCount, actualDays, cfg)
104106

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

108110
return nil
109111
}
110112

111113
// analyzeOrganization performs organization-wide cost analysis by sampling PRs across all repos.
112114
// Uses library functions from pkg/github and pkg/cost for fetching, sampling,
113115
// and extrapolation - all functionality is available to external clients.
114-
func analyzeOrganization(ctx context.Context, org string, sampleSize, days int, cfg cost.Config, token string, dataSource string) error {
116+
//
117+
//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 {
115119
slog.Info("Fetching PR list from organization")
116120

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

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

208212
return nil
209213
}
@@ -274,7 +278,7 @@ func formatTimeUnit(hours float64) string {
274278
// printExtrapolatedResults displays extrapolated cost breakdown in itemized format.
275279
//
276280
//nolint:maintidx,revive // acceptable complexity/length for comprehensive display function
277-
func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBreakdown, cfg cost.Config) {
281+
func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBreakdown, cfg cost.Config, modelMergeTime time.Duration) {
278282
fmt.Println()
279283
fmt.Printf(" %s\n", title)
280284
avgOpenTime := formatTimeUnit(ext.AvgPRDurationHours)
@@ -594,11 +598,11 @@ func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBrea
594598
fmt.Println()
595599

596600
// Print extrapolated efficiency score + annual waste
597-
printExtrapolatedEfficiency(ext, days, cfg)
601+
printExtrapolatedEfficiency(ext, days, cfg, modelMergeTime)
598602
}
599603

600604
// printExtrapolatedEfficiency prints the workflow efficiency + annual waste section for extrapolated totals.
601-
func printExtrapolatedEfficiency(ext *cost.ExtrapolatedBreakdown, days int, cfg cost.Config) {
605+
func printExtrapolatedEfficiency(ext *cost.ExtrapolatedBreakdown, days int, cfg cost.Config, modelMergeTime time.Duration) {
602606
// Calculate preventable waste: Code Churn + All Delay Costs + Automated Updates + PR Tracking
603607
preventableHours := ext.CodeChurnHours + ext.DeliveryDelayHours + ext.AutomatedUpdatesHours + ext.PRTrackingHours
604608
preventableCost := ext.CodeChurnCost + ext.DeliveryDelayCost + ext.AutomatedUpdatesCost + ext.PRTrackingCost
@@ -654,4 +658,81 @@ func printExtrapolatedEfficiency(ext *cost.ExtrapolatedBreakdown, days int, cfg
654658
fmt.Printf(" If Sustained for 1 Year: $%14s %.1f headcount\n",
655659
formatWithCommas(annualWasteCost), headcount)
656660
fmt.Println()
661+
662+
// Print merge time modeling callout if average PR duration exceeds model merge time
663+
if ext.AvgPRDurationHours > modelMergeTime.Hours() {
664+
printExtrapolatedMergeTimeModelingCallout(ext, days, modelMergeTime, cfg)
665+
}
666+
}
667+
668+
// printExtrapolatedMergeTimeModelingCallout prints a callout showing potential savings from reduced merge time.
669+
func printExtrapolatedMergeTimeModelingCallout(ext *cost.ExtrapolatedBreakdown, days int, targetMergeTime time.Duration, cfg cost.Config) {
670+
targetHours := targetMergeTime.Hours()
671+
672+
// Calculate hourly rate
673+
hourlyRate := (cfg.AnnualSalary * cfg.BenefitsMultiplier) / cfg.HoursPerYear
674+
675+
// Recalculate average preventable costs with target merge time
676+
// This mirrors the logic from ExtrapolateFromSamples but with target merge time
677+
678+
// Average delivery delay per PR at target merge time
679+
remodelDeliveryDelayPerPR := hourlyRate * cfg.DeliveryDelayFactor * targetHours
680+
681+
// Code churn: minimal for short PRs (< 1 day = ~0%)
682+
remodelCodeChurnPerPR := 0.0
683+
684+
// Automated updates: only for PRs open > 1 day
685+
remodelAutomatedUpdatesPerPR := 0.0
686+
687+
// PR tracking: scales with open time
688+
remodelPRTrackingPerPR := 0.0
689+
if targetHours >= 1.0 { // Only track PRs open >= 1 hour
690+
daysOpen := targetHours / 24.0
691+
remodelPRTrackingHours := (cfg.PRTrackingMinutesPerDay / 60.0) * daysOpen
692+
remodelPRTrackingPerPR = remodelPRTrackingHours * hourlyRate
693+
}
694+
695+
// Calculate total remodeled preventable cost for the period
696+
totalPRs := float64(ext.TotalPRs)
697+
remodelPreventablePerPeriod := (remodelDeliveryDelayPerPR + remodelCodeChurnPerPR +
698+
remodelAutomatedUpdatesPerPR + remodelPRTrackingPerPR) * totalPRs
699+
700+
// Current preventable cost for the period
701+
currentPreventablePerPeriod := ext.CodeChurnCost + ext.DeliveryDelayCost +
702+
ext.AutomatedUpdatesCost + ext.PRTrackingCost
703+
704+
// Calculate savings for the period
705+
savingsPerPeriod := currentPreventablePerPeriod - remodelPreventablePerPeriod
706+
707+
// Calculate efficiency improvement
708+
// Current efficiency: (total hours - preventable hours) / total hours
709+
// Modeled efficiency: (total hours - remodeled preventable hours) / total hours
710+
currentPreventableHours := ext.CodeChurnHours + ext.DeliveryDelayHours +
711+
ext.AutomatedUpdatesHours + ext.PRTrackingHours
712+
remodelPreventableHours := remodelPreventablePerPeriod / hourlyRate
713+
714+
var currentEfficiency, modeledEfficiency, efficiencyDelta float64
715+
if ext.TotalHours > 0 {
716+
currentEfficiency = 100.0 * (ext.TotalHours - currentPreventableHours) / ext.TotalHours
717+
modeledEfficiency = 100.0 * (ext.TotalHours - remodelPreventableHours) / ext.TotalHours
718+
efficiencyDelta = modeledEfficiency - currentEfficiency
719+
}
720+
721+
if savingsPerPeriod > 0 {
722+
// Annualize the savings
723+
weeksInPeriod := float64(days) / 7.0
724+
annualSavings := savingsPerPeriod * (52.0 / weeksInPeriod)
725+
726+
fmt.Println(" ┌─────────────────────────────────────────────────────────────┐")
727+
fmt.Printf(" │ %-60s│\n", "MERGE TIME MODELING")
728+
fmt.Println(" └─────────────────────────────────────────────────────────────┘")
729+
fmt.Printf(" If you lowered your average merge time to %s, you would save\n", formatTimeUnit(targetHours))
730+
fmt.Printf(" ~$%s/yr in engineering overhead", formatWithCommas(annualSavings))
731+
if efficiencyDelta > 0 {
732+
fmt.Printf(" (+%.1f%% throughput).\n", efficiencyDelta)
733+
} else {
734+
fmt.Println(".")
735+
}
736+
fmt.Println()
737+
}
657738
}

0 commit comments

Comments
 (0)