Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 88 additions & 4 deletions cmd/prcost/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ func main() {
samples := flag.Int("samples", 30, "Number of PRs to sample for extrapolation (30=fast/±18%, 50=slower/±14%)")
days := flag.Int("days", 60, "Number of days to look back for PR modifications")

// Modeling flags
modelMergeTime := flag.Duration("model-merge-time", 1*time.Hour, "Model savings if average merge time was reduced to this duration")

flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: %s [options] <PR_URL>\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s --org <org> [--repo <repo>] [options]\n\n", os.Args[0])
Expand Down Expand Up @@ -119,7 +122,7 @@ func main() {
if *repo != "" {
// Single repository mode

err := analyzeRepository(ctx, *org, *repo, *samples, *days, cfg, token, *dataSource)
err := analyzeRepository(ctx, *org, *repo, *samples, *days, cfg, token, *dataSource, modelMergeTime)
if err != nil {
log.Fatalf("Repository analysis failed: %v", err)
}
Expand All @@ -130,7 +133,7 @@ func main() {
"samples", *samples,
"days", *days)

err := analyzeOrganization(ctx, *org, *samples, *days, cfg, token, *dataSource)
err := analyzeOrganization(ctx, *org, *samples, *days, cfg, token, *dataSource, modelMergeTime)
if err != nil {
log.Fatalf("Organization analysis failed: %v", err)
}
Expand Down Expand Up @@ -174,7 +177,7 @@ func main() {
// Output in requested format
switch *format {
case "human":
printHumanReadable(&breakdown, prURL)
printHumanReadable(&breakdown, prURL, *modelMergeTime, cfg)
case "json":
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
Expand Down Expand Up @@ -206,7 +209,7 @@ func authToken(ctx context.Context) (string, error) {
}

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

// Print efficiency score
printEfficiency(breakdown)

// Print modeling callout if PR duration exceeds model merge time
if breakdown.PRDuration > modelMergeTime.Hours() {
printMergeTimeModelingCallout(breakdown, modelMergeTime, cfg)
}
}

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

// printMergeTimeModelingCallout prints a callout showing potential savings from reduced merge time.
func printMergeTimeModelingCallout(breakdown *cost.Breakdown, targetMergeTime time.Duration, cfg cost.Config) {
targetHours := targetMergeTime.Hours()
currentHours := breakdown.PRDuration

// Calculate hourly rate
hourlyRate := (cfg.AnnualSalary * cfg.BenefitsMultiplier) / cfg.HoursPerYear

// Recalculate delivery delay with target merge time
remodelDeliveryDelayCost := hourlyRate * cfg.DeliveryDelayFactor * targetHours

// Code churn: 40min-1h is too short for meaningful code churn (< 1 day)
remodelCodeChurnCost := 0.0

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

// PR tracking: scales with open time (already minimal for short PRs)
remodelPRTrackingCost := 0.0
if targetHours >= 1.0 { // Only track PRs open >= 1 hour
daysOpen := targetHours / 24.0
remodelPRTrackingHours := (cfg.PRTrackingMinutesPerDay / 60.0) * daysOpen
remodelPRTrackingCost = remodelPRTrackingHours * hourlyRate
}

// Calculate savings for this PR
currentPreventable := breakdown.DelayCostDetail.DeliveryDelayCost +
breakdown.DelayCostDetail.CodeChurnCost +
breakdown.DelayCostDetail.AutomatedUpdatesCost +
breakdown.DelayCostDetail.PRTrackingCost

remodelPreventable := remodelDeliveryDelayCost + remodelCodeChurnCost +
remodelAutomatedUpdatesCost + remodelPRTrackingCost

savingsPerPR := currentPreventable - remodelPreventable

// Calculate efficiency improvement
// Current efficiency: (total hours - preventable hours) / total hours
// Modeled efficiency: (total hours - remodeled preventable hours) / total hours
totalHours := breakdown.Author.TotalHours + breakdown.DelayCostDetail.TotalDelayHours
for _, p := range breakdown.Participants {
totalHours += p.TotalHours
}

var currentEfficiency, modeledEfficiency, efficiencyDelta float64
if totalHours > 0 {
currentEfficiency = 100.0 * (totalHours - (currentPreventable / hourlyRate)) / totalHours
modeledEfficiency = 100.0 * (totalHours - (remodelPreventable / hourlyRate)) / totalHours
efficiencyDelta = modeledEfficiency - currentEfficiency
}

// Estimate annual savings assuming similar PR frequency
// Use a conservative estimate: this PR represents typical overhead
// Extrapolate to 52 weeks based on how long this PR was open
if savingsPerPR > 0 && currentHours > 0 {
// Rough annual extrapolation: (savings per PR) × (52 weeks) / (weeks this PR was open)
weeksOpen := currentHours / (24.0 * 7.0)
if weeksOpen < 0.01 {
weeksOpen = 0.01 // Avoid division by zero, minimum 1% of a week
}
annualSavings := savingsPerPR * (52.0 / weeksOpen)

fmt.Println(" ┌─────────────────────────────────────────────────────────────┐")
fmt.Printf(" │ %-60s│\n", "MERGE TIME MODELING")
fmt.Println(" └─────────────────────────────────────────────────────────────┘")
fmt.Printf(" If you lowered your average merge time to %s, you would save\n", formatTimeUnit(targetHours))
fmt.Printf(" ~$%s/yr in engineering overhead", formatWithCommas(annualSavings))
if efficiencyDelta > 0 {
fmt.Printf(" (+%.1f%% throughput).\n", efficiencyDelta)
} else {
fmt.Println(".")
}
fmt.Println()
}
}

// printEfficiency prints the workflow efficiency section for a single PR.
func printEfficiency(breakdown *cost.Breakdown) {
// Calculate preventable waste: Code Churn + All Delay Costs + Automated Updates + PR Tracking
Expand Down
95 changes: 88 additions & 7 deletions cmd/prcost/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import (
// analyzeRepository performs repository-wide cost analysis by sampling PRs.
// Uses library functions from pkg/github and pkg/cost for fetching, sampling,
// and extrapolation - all functionality is available to external clients.
func analyzeRepository(ctx context.Context, owner, repo string, sampleSize, days int, cfg cost.Config, token string, dataSource string) error {
//
//nolint:revive // argument-limit: acceptable for entry point function
func analyzeRepository(ctx context.Context, owner, repo string, sampleSize, days int, cfg cost.Config, token, dataSource string, modelMergeTime *time.Duration) error {
// Calculate since date
since := time.Now().AddDate(0, 0, -days)

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

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

return nil
}

// analyzeOrganization performs organization-wide cost analysis by sampling PRs across all repos.
// Uses library functions from pkg/github and pkg/cost for fetching, sampling,
// and extrapolation - all functionality is available to external clients.
func analyzeOrganization(ctx context.Context, org string, sampleSize, days int, cfg cost.Config, token string, dataSource string) error {
//
//nolint:revive // argument-limit: acceptable for entry point function
func analyzeOrganization(ctx context.Context, org string, sampleSize, days int, cfg cost.Config, token, dataSource string, modelMergeTime *time.Duration) error {
slog.Info("Fetching PR list from organization")

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

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

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

// Print extrapolated efficiency score + annual waste
printExtrapolatedEfficiency(ext, days, cfg)
printExtrapolatedEfficiency(ext, days, cfg, modelMergeTime)
}

// printExtrapolatedEfficiency prints the workflow efficiency + annual waste section for extrapolated totals.
func printExtrapolatedEfficiency(ext *cost.ExtrapolatedBreakdown, days int, cfg cost.Config) {
func printExtrapolatedEfficiency(ext *cost.ExtrapolatedBreakdown, days int, cfg cost.Config, modelMergeTime time.Duration) {
// Calculate preventable waste: Code Churn + All Delay Costs + Automated Updates + PR Tracking
preventableHours := ext.CodeChurnHours + ext.DeliveryDelayHours + ext.AutomatedUpdatesHours + ext.PRTrackingHours
preventableCost := ext.CodeChurnCost + ext.DeliveryDelayCost + ext.AutomatedUpdatesCost + ext.PRTrackingCost
Expand Down Expand Up @@ -654,4 +658,81 @@ func printExtrapolatedEfficiency(ext *cost.ExtrapolatedBreakdown, days int, cfg
fmt.Printf(" If Sustained for 1 Year: $%14s %.1f headcount\n",
formatWithCommas(annualWasteCost), headcount)
fmt.Println()

// Print merge time modeling callout if average PR duration exceeds model merge time
if ext.AvgPRDurationHours > modelMergeTime.Hours() {
printExtrapolatedMergeTimeModelingCallout(ext, days, modelMergeTime, cfg)
}
}

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

// Calculate hourly rate
hourlyRate := (cfg.AnnualSalary * cfg.BenefitsMultiplier) / cfg.HoursPerYear

// Recalculate average preventable costs with target merge time
// This mirrors the logic from ExtrapolateFromSamples but with target merge time

// Average delivery delay per PR at target merge time
remodelDeliveryDelayPerPR := hourlyRate * cfg.DeliveryDelayFactor * targetHours

// Code churn: minimal for short PRs (< 1 day = ~0%)
remodelCodeChurnPerPR := 0.0

// Automated updates: only for PRs open > 1 day
remodelAutomatedUpdatesPerPR := 0.0

// PR tracking: scales with open time
remodelPRTrackingPerPR := 0.0
if targetHours >= 1.0 { // Only track PRs open >= 1 hour
daysOpen := targetHours / 24.0
remodelPRTrackingHours := (cfg.PRTrackingMinutesPerDay / 60.0) * daysOpen
remodelPRTrackingPerPR = remodelPRTrackingHours * hourlyRate
}

// Calculate total remodeled preventable cost for the period
totalPRs := float64(ext.TotalPRs)
remodelPreventablePerPeriod := (remodelDeliveryDelayPerPR + remodelCodeChurnPerPR +
remodelAutomatedUpdatesPerPR + remodelPRTrackingPerPR) * totalPRs

// Current preventable cost for the period
currentPreventablePerPeriod := ext.CodeChurnCost + ext.DeliveryDelayCost +
ext.AutomatedUpdatesCost + ext.PRTrackingCost

// Calculate savings for the period
savingsPerPeriod := currentPreventablePerPeriod - remodelPreventablePerPeriod

// Calculate efficiency improvement
// Current efficiency: (total hours - preventable hours) / total hours
// Modeled efficiency: (total hours - remodeled preventable hours) / total hours
currentPreventableHours := ext.CodeChurnHours + ext.DeliveryDelayHours +
ext.AutomatedUpdatesHours + ext.PRTrackingHours
remodelPreventableHours := remodelPreventablePerPeriod / hourlyRate

var currentEfficiency, modeledEfficiency, efficiencyDelta float64
if ext.TotalHours > 0 {
currentEfficiency = 100.0 * (ext.TotalHours - currentPreventableHours) / ext.TotalHours
modeledEfficiency = 100.0 * (ext.TotalHours - remodelPreventableHours) / ext.TotalHours
efficiencyDelta = modeledEfficiency - currentEfficiency
}

if savingsPerPeriod > 0 {
// Annualize the savings
weeksInPeriod := float64(days) / 7.0
annualSavings := savingsPerPeriod * (52.0 / weeksInPeriod)

fmt.Println(" ┌─────────────────────────────────────────────────────────────┐")
fmt.Printf(" │ %-60s│\n", "MERGE TIME MODELING")
fmt.Println(" └─────────────────────────────────────────────────────────────┘")
fmt.Printf(" If you lowered your average merge time to %s, you would save\n", formatTimeUnit(targetHours))
fmt.Printf(" ~$%s/yr in engineering overhead", formatWithCommas(annualSavings))
if efficiencyDelta > 0 {
fmt.Printf(" (+%.1f%% throughput).\n", efficiencyDelta)
} else {
fmt.Println(".")
}
fmt.Println()
}
}
Loading
Loading