From 4cb622dca82e9002d8e17268314e6223bf5c2be3 Mon Sep 17 00:00:00 2001 From: Thomas Stromberg Date: Mon, 27 Oct 2025 22:23:56 -0400 Subject: [PATCH] more samples, more progress, more simplification --- README.md | 6 +- cmd/prcost/main.go | 14 +- cmd/prcost/repository.go | 195 +++++++++++++--------------- cmd/server/main.go | 2 +- internal/server/integration_test.go | 4 +- internal/server/server.go | 64 +++++---- internal/server/server_test.go | 6 + internal/server/static/index.html | 189 ++++++++++++++++----------- pkg/cost/cost.go | 4 +- pkg/cost/cost_test.go | 18 +-- pkg/cost/extrapolate.go | 8 +- pkg/github/query.go | 40 ++++-- 12 files changed, 307 insertions(+), 243 deletions(-) diff --git a/README.md b/README.md index b54dbb8..dd3ba48 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ go install github.com/codeGROOVE-dev/prcost/cmd/prcost@latest prcost https://github.com/owner/repo/pull/123 prcost --salary 300000 https://github.com/owner/repo/pull/123 -# Repository analysis (samples 25 PRs from last 90 days) +# Repository analysis (samples 30 PRs from last 90 days) prcost --org kubernetes --repo kubernetes prcost --org myorg --repo myrepo --samples 50 --days 30 @@ -85,8 +85,8 @@ go run ./cmd/server Repository and organization modes use time-bucket sampling to ensure even distribution across the time period, avoiding temporal clustering that would bias estimates. -- **25 samples** (default): Fast analysis with ±20% confidence interval -- **50 samples**: More accurate with ±14% confidence interval (1.4× better precision) +- **30 samples** (default): Fast analysis with ±18% confidence interval +- **50 samples**: More accurate with ±14% confidence interval (1.3× better precision) ## Cost Model: Scientific Foundations diff --git a/cmd/prcost/main.go b/cmd/prcost/main.go index 3a2c7b9..656e35d 100644 --- a/cmd/prcost/main.go +++ b/cmd/prcost/main.go @@ -31,7 +31,7 @@ func main() { // Org/Repo sampling flags org := flag.String("org", "", "GitHub organization to analyze (optionally with --repo for single repo)") repo := flag.String("repo", "", "GitHub repository to analyze (requires --org)") - samples := flag.Int("samples", 25, "Number of PRs to sample for extrapolation (25=fast/±20%, 50=slower/±14%)") + 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") flag.Usage = func() { @@ -452,22 +452,22 @@ func formatLOC(kloc float64) string { // Add fractional part if significant if kloc < 1000.0 && fracPart >= 0.05 { - return fmt.Sprintf("%s.%dk", string(result), int(fracPart*10)) + return fmt.Sprintf("%s.%dk LOC", string(result), int(fracPart*10)) } - return string(result) + "k" + return string(result) + "k LOC" } // For values < 100k, use existing precision logic if kloc < 0.1 && kloc > 0 { - return fmt.Sprintf("%.2fk", kloc) + return fmt.Sprintf("%.2fk LOC", kloc) } if kloc < 1.0 { - return fmt.Sprintf("%.1fk", kloc) + return fmt.Sprintf("%.1fk LOC", kloc) } if kloc < 10.0 { - return fmt.Sprintf("%.1fk", kloc) + return fmt.Sprintf("%.1fk LOC", kloc) } - return fmt.Sprintf("%.0fk", kloc) + return fmt.Sprintf("%.0fk LOC", kloc) } // efficiencyGrade returns a letter grade and message based on efficiency percentage (MIT scale). diff --git a/cmd/prcost/repository.go b/cmd/prcost/repository.go index fdedd68..898ae7b 100644 --- a/cmd/prcost/repository.go +++ b/cmd/prcost/repository.go @@ -19,7 +19,7 @@ func analyzeRepository(ctx context.Context, owner, repo string, sampleSize, days since := time.Now().AddDate(0, 0, -days) // Fetch all PRs modified since the date using library function - prs, err := github.FetchPRsFromRepo(ctx, owner, repo, since, token) + prs, err := github.FetchPRsFromRepo(ctx, owner, repo, since, token, nil) if err != nil { return fmt.Errorf("failed to fetch PRs: %w", err) } @@ -118,7 +118,7 @@ func analyzeOrganization(ctx context.Context, org string, sampleSize, days int, since := time.Now().AddDate(0, 0, -days) // Fetch all PRs across the org modified since the date using library function - prs, err := github.FetchPRsFromOrg(ctx, org, since, token) + prs, err := github.FetchPRsFromOrg(ctx, org, since, token, nil) if err != nil { return fmt.Errorf("failed to fetch PRs: %w", err) } @@ -208,6 +208,36 @@ func analyzeOrganization(ctx context.Context, org string, sampleSize, days int, return nil } +// Ledger formatting functions - all output must use these for consistency + +// formatItemLine formats a cost breakdown line item with 4-space indent. +func formatItemLine(label string, cost float64, timeUnit string, detail string) string { + if cost == 0 { + return fmt.Sprintf(" %-30s %15s %-6s %s\n", label, "—", timeUnit, detail) + } + return fmt.Sprintf(" %-30s $%14s %-6s %s\n", label, formatWithCommas(cost), timeUnit, detail) +} + +// formatSubtotalLine formats a subtotal line with 4-space indent. +func formatSubtotalLine(label string, cost float64, timeUnit string, detail string) string { + return fmt.Sprintf(" %-30s $%14s %-6s %s\n", label, formatWithCommas(cost), timeUnit, detail) +} + +// formatSummaryLine formats a summary line (like Preventable Loss Total) with 2-space indent. +func formatSummaryLine(label string, cost float64, timeUnit string, detail string) string { + return fmt.Sprintf(" %-30s $%14s %-6s %s\n", label, formatWithCommas(cost), timeUnit, detail) +} + +// formatTotalLine formats a total line with 2-space indent. +func formatTotalLine(label string, cost float64, timeUnit string) string { + return fmt.Sprintf(" %-30s $%14s %-6s\n", label, formatWithCommas(cost), timeUnit) +} + +// formatSectionDivider formats the divider line under subtotals (4-space indent, 32 chars + 14 dashes). +func formatSectionDivider() string { + return " ──────────────\n" +} + // formatTimeUnit intelligently scales time units based on magnitude. // Once a value exceeds 2x a unit, it scales to the next unit: // - < 1 hour: show as minutes @@ -220,31 +250,30 @@ func formatTimeUnit(hours float64) string { // Show minutes for values less than 1 hour if hours < 1.0 { minutes := hours * 60.0 - // Use 1 decimal place for better precision and clearer addition - return fmt.Sprintf("%.1f min", minutes) + return fmt.Sprintf("%.1fm", minutes) } if hours < 48 { - return fmt.Sprintf("%.1f hrs", hours) + return fmt.Sprintf("%.1fh", hours) } days := hours / 24.0 if days < 14 { - return fmt.Sprintf("%.1f days", days) + return fmt.Sprintf("%.1fd", days) } weeks := days / 7.0 if weeks < 8 { - return fmt.Sprintf("%.1f weeks", weeks) + return fmt.Sprintf("%.1fw", weeks) } months := days / 30.0 if months < 24 { - return fmt.Sprintf("%.1f months", months) + return fmt.Sprintf("%.1fmo", months) } years := days / 365.0 - return fmt.Sprintf("%.1f years", years) + return fmt.Sprintf("%.1fy", years) } // printExtrapolatedResults displays extrapolated cost breakdown in itemized format. @@ -323,33 +352,27 @@ func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBrea newLOCStr := formatLOC(avgNewLOC) modifiedLOCStr := formatLOC(avgModifiedLOC) - fmt.Printf(" Development Costs (%d PRs, %s total LOC)\n", ext.HumanPRs, totalLOCStr) + fmt.Printf(" Development Costs (%d PRs, %s)\n", ext.HumanPRs, totalLOCStr) fmt.Println(" ────────────────────────────────────────") // Calculate average events and sessions avgAuthorEvents := float64(ext.AuthorEvents) / float64(ext.TotalPRs) avgAuthorSessions := float64(ext.AuthorSessions) / float64(ext.TotalPRs) - fmt.Printf(" New Development $%10s %s (%s LOC)\n", - formatWithCommas(avgAuthorNewCodeCost), formatTimeUnit(avgAuthorNewCodeHours), newLOCStr) - fmt.Printf(" Adaptation $%10s %s (%s LOC)\n", - formatWithCommas(avgAuthorAdaptationCost), formatTimeUnit(avgAuthorAdaptationHours), modifiedLOCStr) - fmt.Printf(" GitHub Activity $%10s %s (%.1f events)\n", - formatWithCommas(avgAuthorGitHubCost), formatTimeUnit(avgAuthorGitHubHours), avgAuthorEvents) - fmt.Printf(" Context Switching $%10s %s (%.1f sessions)\n", - formatWithCommas(avgAuthorGitHubContextCost), formatTimeUnit(avgAuthorGitHubContextHours), avgAuthorSessions) + fmt.Print(formatItemLine("New Development", avgAuthorNewCodeCost, formatTimeUnit(avgAuthorNewCodeHours), fmt.Sprintf("(%s)", newLOCStr))) + fmt.Print(formatItemLine("Adaptation", avgAuthorAdaptationCost, formatTimeUnit(avgAuthorAdaptationHours), fmt.Sprintf("(%s)", modifiedLOCStr))) + fmt.Print(formatItemLine("GitHub Activity", avgAuthorGitHubCost, formatTimeUnit(avgAuthorGitHubHours), fmt.Sprintf("(%.1f events)", avgAuthorEvents))) + fmt.Print(formatItemLine("Context Switching", avgAuthorGitHubContextCost, formatTimeUnit(avgAuthorGitHubContextHours), fmt.Sprintf("(%.1f sessions)", avgAuthorSessions))) // Show bot PR LOC even though cost is $0 if ext.BotPRs > 0 { avgBotTotalLOC := float64(ext.BotNewLines+ext.BotModifiedLines) / float64(ext.TotalPRs) / 1000.0 botLOCStr := formatLOC(avgBotTotalLOC) - fmt.Printf(" Automated Updates — %s (%d PRs, %s LOC)\n", - formatTimeUnit(0.0), ext.BotPRs, botLOCStr) + fmt.Print(formatItemLine("Automated Updates", 0, formatTimeUnit(0.0), fmt.Sprintf("(%d PRs, %s)", ext.BotPRs, botLOCStr))) } - fmt.Println(" ──────────") + fmt.Print(formatSectionDivider()) pct := (avgAuthorTotalCost / avgTotalCost) * 100 - fmt.Printf(" Subtotal $%10s %s (%.1f%%)\n", - formatWithCommas(avgAuthorTotalCost), formatTimeUnit(avgAuthorTotalHours), pct) + fmt.Print(formatSubtotalLine("Subtotal", avgAuthorTotalCost, formatTimeUnit(avgAuthorTotalHours), fmt.Sprintf("(%.1f%%)", pct))) fmt.Println() // Participants section (if any participants) @@ -362,19 +385,15 @@ func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBrea fmt.Println(" Participant Costs") fmt.Println(" ─────────────────") if avgParticipantReviewCost > 0 { - fmt.Printf(" Review Activity $%10s %s (%.1f reviews)\n", - formatWithCommas(avgParticipantReviewCost), formatTimeUnit(avgParticipantReviewHours), avgParticipantReviews) + fmt.Print(formatItemLine("Review Activity", avgParticipantReviewCost, formatTimeUnit(avgParticipantReviewHours), fmt.Sprintf("(%.1f reviews)", avgParticipantReviews))) } if avgParticipantGitHubCost > 0 { - fmt.Printf(" GitHub Activity $%10s %s (%.1f events)\n", - formatWithCommas(avgParticipantGitHubCost), formatTimeUnit(avgParticipantGitHubHours), avgParticipantEvents) + fmt.Print(formatItemLine("GitHub Activity", avgParticipantGitHubCost, formatTimeUnit(avgParticipantGitHubHours), fmt.Sprintf("(%.1f events)", avgParticipantEvents))) } - fmt.Printf(" Context Switching $%10s %s (%.1f sessions)\n", - formatWithCommas(avgParticipantContextCost), formatTimeUnit(avgParticipantContextHours), avgParticipantSessions) - fmt.Println(" ──────────") + fmt.Print(formatItemLine("Context Switching", avgParticipantContextCost, formatTimeUnit(avgParticipantContextHours), fmt.Sprintf("(%.1f sessions)", avgParticipantSessions))) + fmt.Print(formatSectionDivider()) participantPct := (avgParticipantTotalCost / avgTotalCost) * 100 - fmt.Printf(" Subtotal $%10s %s (%.1f%%)\n", - formatWithCommas(avgParticipantTotalCost), formatTimeUnit(avgParticipantTotalHours), participantPct) + fmt.Print(formatSubtotalLine("Subtotal", avgParticipantTotalCost, formatTimeUnit(avgParticipantTotalHours), fmt.Sprintf("(%.1f%%)", participantPct))) fmt.Println() } @@ -389,23 +408,19 @@ func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBrea fmt.Println(delayCostsHeader) fmt.Println(" " + strings.Repeat("─", len(delayCostsHeader)-2)) if avgDeliveryDelayCost > 0 { - fmt.Printf(" Workstream blockage $%10s %s (%d PRs)\n", - formatWithCommas(avgDeliveryDelayCost), formatTimeUnit(avgDeliveryDelayHours), ext.HumanPRs) + fmt.Print(formatItemLine("Workstream blockage", avgDeliveryDelayCost, formatTimeUnit(avgDeliveryDelayHours), fmt.Sprintf("(%d PRs)", ext.HumanPRs))) } if avgAutomatedUpdatesCost > 0 { - fmt.Printf(" Automated Updates $%10s %s (%d PRs)\n", - formatWithCommas(avgAutomatedUpdatesCost), formatTimeUnit(avgAutomatedUpdatesHours), ext.BotPRs) + fmt.Print(formatItemLine("Automated Updates", avgAutomatedUpdatesCost, formatTimeUnit(avgAutomatedUpdatesHours), fmt.Sprintf("(%d PRs)", ext.BotPRs))) } if avgPRTrackingCost > 0 { - fmt.Printf(" PR Tracking $%10s %s (%d open PRs)\n", - formatWithCommas(avgPRTrackingCost), formatTimeUnit(avgPRTrackingHours), ext.OpenPRs) + fmt.Print(formatItemLine("PR Tracking", avgPRTrackingCost, formatTimeUnit(avgPRTrackingHours), fmt.Sprintf("(%d open PRs)", ext.OpenPRs))) } avgMergeDelayCost := avgDeliveryDelayCost + avgAutomatedUpdatesCost + avgPRTrackingCost avgMergeDelayHours := avgDeliveryDelayHours + avgAutomatedUpdatesHours + avgPRTrackingHours - fmt.Println(" ──────────") + fmt.Print(formatSectionDivider()) pct = (avgMergeDelayCost / avgTotalCost) * 100 - fmt.Printf(" Subtotal $%10s %s (%.1f%%)\n", - formatWithCommas(avgMergeDelayCost), formatTimeUnit(avgMergeDelayHours), pct) + fmt.Print(formatSubtotalLine("Subtotal", avgMergeDelayCost, formatTimeUnit(avgMergeDelayHours), fmt.Sprintf("(%.1f%%)", pct))) fmt.Println() // Future Costs section @@ -423,28 +438,23 @@ func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBrea fmt.Println(" Future Costs") fmt.Println(" ────────────") if ext.CodeChurnCost > 0.01 { - fmt.Printf(" Code Churn $%10s %s (%d PRs)\n", - formatWithCommas(avgCodeChurnCost), formatTimeUnit(avgCodeChurnHours), ext.CodeChurnPRCount) + fmt.Print(formatItemLine("Code Churn", avgCodeChurnCost, formatTimeUnit(avgCodeChurnHours), fmt.Sprintf("(%d PRs)", ext.CodeChurnPRCount))) } if ext.FutureReviewCost > 0.01 { - fmt.Printf(" Review $%10s %s (%d PRs)\n", - formatWithCommas(avgFutureReviewCost), formatTimeUnit(avgFutureReviewHours), ext.FutureReviewPRCount) + fmt.Print(formatItemLine("Review", avgFutureReviewCost, formatTimeUnit(avgFutureReviewHours), fmt.Sprintf("(%d PRs)", ext.FutureReviewPRCount))) } if ext.FutureMergeCost > 0.01 { - fmt.Printf(" Merge $%10s %s (%d PRs)\n", - formatWithCommas(avgFutureMergeCost), formatTimeUnit(avgFutureMergeHours), ext.FutureMergePRCount) + fmt.Print(formatItemLine("Merge", avgFutureMergeCost, formatTimeUnit(avgFutureMergeHours), fmt.Sprintf("(%d PRs)", ext.FutureMergePRCount))) } if ext.FutureContextCost > 0.01 { avgFutureContextSessions := float64(ext.FutureContextSessions) / float64(ext.TotalPRs) - fmt.Printf(" Context Switching $%10s %s (%.1f sessions)\n", - formatWithCommas(avgFutureContextCost), formatTimeUnit(avgFutureContextHours), avgFutureContextSessions) + fmt.Print(formatItemLine("Context Switching", avgFutureContextCost, formatTimeUnit(avgFutureContextHours), fmt.Sprintf("(%.1f sessions)", avgFutureContextSessions))) } avgFutureCost := avgCodeChurnCost + avgFutureReviewCost + avgFutureMergeCost + avgFutureContextCost avgFutureHours := avgCodeChurnHours + avgFutureReviewHours + avgFutureMergeHours + avgFutureContextHours - fmt.Println(" ──────────") + fmt.Print(formatSectionDivider()) pct = (avgFutureCost / avgTotalCost) * 100 - fmt.Printf(" Subtotal $%10s %s (%.1f%%)\n", - formatWithCommas(avgFutureCost), formatTimeUnit(avgFutureHours), pct) + fmt.Print(formatSubtotalLine("Subtotal", avgFutureCost, formatTimeUnit(avgFutureHours), fmt.Sprintf("(%.1f%%)", pct))) fmt.Println() } @@ -452,12 +462,11 @@ func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBrea avgPreventableCost := avgCodeChurnCost + avgDeliveryDelayCost + avgAutomatedUpdatesCost + avgPRTrackingCost avgPreventableHours := avgCodeChurnHours + avgDeliveryDelayHours + avgAutomatedUpdatesHours + avgPRTrackingHours avgPreventablePct := (avgPreventableCost / avgTotalCost) * 100 - fmt.Printf(" Preventable Loss Total $%10s %s (%.1f%%)\n", - formatWithCommas(avgPreventableCost), formatTimeUnit(avgPreventableHours), avgPreventablePct) + fmt.Print(formatSummaryLine("Preventable Loss Total", avgPreventableCost, formatTimeUnit(avgPreventableHours), fmt.Sprintf("(%.1f%%)", avgPreventablePct))) // Average total fmt.Println(" ════════════════════════════════════════════════════") - fmt.Printf(" Average Total $%10s %s\n", + fmt.Printf(" Average Total $%14s %s\n", formatWithCommas(avgTotalCost), formatTimeUnit(avgTotalHours)) fmt.Println() fmt.Println() @@ -485,29 +494,23 @@ func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBrea totalTotalLOC := totalNewLOC + totalModifiedLOC totalTotalLOCStr := formatLOC(totalTotalLOC) - fmt.Printf(" Development Costs (%d PRs, %s total LOC)\n", ext.HumanPRs, totalTotalLOCStr) + fmt.Printf(" Development Costs (%d PRs, %s)\n", ext.HumanPRs, totalTotalLOCStr) fmt.Println(" ────────────────────────────────────────") - fmt.Printf(" New Development $%10s %s (%s LOC)\n", - formatWithCommas(ext.AuthorNewCodeCost), formatTimeUnit(ext.AuthorNewCodeHours), totalNewLOCStr) - fmt.Printf(" Adaptation $%10s %s (%s LOC)\n", - formatWithCommas(ext.AuthorAdaptationCost), formatTimeUnit(ext.AuthorAdaptationHours), totalModifiedLOCStr) - fmt.Printf(" GitHub Activity $%10s %s (%d events)\n", - formatWithCommas(ext.AuthorGitHubCost), formatTimeUnit(ext.AuthorGitHubHours), ext.AuthorEvents) - fmt.Printf(" Context Switching $%10s %s (%d sessions)\n", - formatWithCommas(ext.AuthorGitHubContextCost), formatTimeUnit(ext.AuthorGitHubContextHours), ext.AuthorSessions) + fmt.Print(formatItemLine("New Development", ext.AuthorNewCodeCost, formatTimeUnit(ext.AuthorNewCodeHours), fmt.Sprintf("(%s)", totalNewLOCStr))) + fmt.Print(formatItemLine("Adaptation", ext.AuthorAdaptationCost, formatTimeUnit(ext.AuthorAdaptationHours), fmt.Sprintf("(%s)", totalModifiedLOCStr))) + fmt.Print(formatItemLine("GitHub Activity", ext.AuthorGitHubCost, formatTimeUnit(ext.AuthorGitHubHours), fmt.Sprintf("(%d events)", ext.AuthorEvents))) + fmt.Print(formatItemLine("Context Switching", ext.AuthorGitHubContextCost, formatTimeUnit(ext.AuthorGitHubContextHours), fmt.Sprintf("(%d sessions)", ext.AuthorSessions))) // Show bot PR LOC even though cost is $0 if ext.BotPRs > 0 { totalBotLOC := float64(ext.BotNewLines+ext.BotModifiedLines) / 1000.0 botTotalLOCStr := formatLOC(totalBotLOC) - fmt.Printf(" Automated Updates — %s (%d PRs, %s LOC)\n", - formatTimeUnit(0.0), ext.BotPRs, botTotalLOCStr) + fmt.Print(formatItemLine("Automated Updates", 0, formatTimeUnit(0.0), fmt.Sprintf("(%d PRs, %s)", ext.BotPRs, botTotalLOCStr))) } - fmt.Println(" ──────────") + fmt.Print(formatSectionDivider()) pct = (ext.AuthorTotalCost / ext.TotalCost) * 100 - fmt.Printf(" Subtotal $%10s %s (%.1f%%)\n", - formatWithCommas(ext.AuthorTotalCost), formatTimeUnit(ext.AuthorTotalHours), pct) + fmt.Print(formatSubtotalLine("Subtotal", ext.AuthorTotalCost, formatTimeUnit(ext.AuthorTotalHours), fmt.Sprintf("(%.1f%%)", pct))) fmt.Println() // Participants section (extrapolated, if any participants) @@ -515,19 +518,15 @@ func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBrea fmt.Println(" Participant Costs") fmt.Println(" ─────────────────") if ext.ParticipantReviewCost > 0 { - fmt.Printf(" Review Activity $%10s %s (%d reviews)\n", - formatWithCommas(ext.ParticipantReviewCost), formatTimeUnit(ext.ParticipantReviewHours), ext.ParticipantReviews) + fmt.Print(formatItemLine("Review Activity", ext.ParticipantReviewCost, formatTimeUnit(ext.ParticipantReviewHours), fmt.Sprintf("(%d reviews)", ext.ParticipantReviews))) } if ext.ParticipantGitHubCost > 0 { - fmt.Printf(" GitHub Activity $%10s %s (%d events)\n", - formatWithCommas(ext.ParticipantGitHubCost), formatTimeUnit(ext.ParticipantGitHubHours), ext.ParticipantEvents) + fmt.Print(formatItemLine("GitHub Activity", ext.ParticipantGitHubCost, formatTimeUnit(ext.ParticipantGitHubHours), fmt.Sprintf("(%d events)", ext.ParticipantEvents))) } - fmt.Printf(" Context Switching $%10s %s (%d sessions)\n", - formatWithCommas(ext.ParticipantContextCost), formatTimeUnit(ext.ParticipantContextHours), ext.ParticipantSessions) - fmt.Println(" ──────────") + fmt.Print(formatItemLine("Context Switching", ext.ParticipantContextCost, formatTimeUnit(ext.ParticipantContextHours), fmt.Sprintf("(%d sessions)", ext.ParticipantSessions))) + fmt.Print(formatSectionDivider()) pct = (ext.ParticipantTotalCost / ext.TotalCost) * 100 - fmt.Printf(" Subtotal $%10s %s (%.1f%%)\n", - formatWithCommas(ext.ParticipantTotalCost), formatTimeUnit(ext.ParticipantTotalHours), pct) + fmt.Print(formatSubtotalLine("Subtotal", ext.ParticipantTotalCost, formatTimeUnit(ext.ParticipantTotalHours), fmt.Sprintf("(%.1f%%)", pct))) fmt.Println() } @@ -543,23 +542,19 @@ func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBrea fmt.Println(" " + strings.Repeat("─", len(extDelayCostsHeader)-2)) if ext.DeliveryDelayCost > 0 { - fmt.Printf(" Workstream blockage $%10s %s (%d PRs)\n", - formatWithCommas(ext.DeliveryDelayCost), formatTimeUnit(ext.DeliveryDelayHours), ext.HumanPRs) + fmt.Print(formatItemLine("Workstream blockage", ext.DeliveryDelayCost, formatTimeUnit(ext.DeliveryDelayHours), fmt.Sprintf("(%d PRs)", ext.HumanPRs))) } if ext.AutomatedUpdatesCost > 0 { - fmt.Printf(" Automated Updates $%10s %s (%d PRs)\n", - formatWithCommas(ext.AutomatedUpdatesCost), formatTimeUnit(ext.AutomatedUpdatesHours), ext.BotPRs) + fmt.Print(formatItemLine("Automated Updates", ext.AutomatedUpdatesCost, formatTimeUnit(ext.AutomatedUpdatesHours), fmt.Sprintf("(%d PRs)", ext.BotPRs))) } if ext.PRTrackingCost > 0 { - fmt.Printf(" PR Tracking $%10s %s (%d open PRs)\n", - formatWithCommas(ext.PRTrackingCost), formatTimeUnit(ext.PRTrackingHours), ext.OpenPRs) + fmt.Print(formatItemLine("PR Tracking", ext.PRTrackingCost, formatTimeUnit(ext.PRTrackingHours), fmt.Sprintf("(%d open PRs)", ext.OpenPRs))) } extMergeDelayCost := ext.DeliveryDelayCost + ext.CodeChurnCost + ext.AutomatedUpdatesCost + ext.PRTrackingCost extMergeDelayHours := ext.DeliveryDelayHours + ext.CodeChurnHours + ext.AutomatedUpdatesHours + ext.PRTrackingHours - fmt.Println(" ──────────") + fmt.Print(formatSectionDivider()) pct = (extMergeDelayCost / ext.TotalCost) * 100 - fmt.Printf(" Subtotal $%10s %s (%.1f%%)\n", - formatWithCommas(extMergeDelayCost), formatTimeUnit(extMergeDelayHours), pct) + fmt.Print(formatSubtotalLine("Subtotal", extMergeDelayCost, formatTimeUnit(extMergeDelayHours), fmt.Sprintf("(%.1f%%)", pct))) fmt.Println() // Future Costs section (extrapolated) @@ -572,27 +567,22 @@ func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBrea if ext.CodeChurnCost > 0.01 { totalKLOC := float64(ext.TotalNewLines+ext.TotalModifiedLines) / 1000.0 churnLOCStr := formatLOC(totalKLOC) - fmt.Printf(" Code Churn $%10s %s (%d PRs, ~%s LOC)\n", - formatWithCommas(ext.CodeChurnCost), formatTimeUnit(ext.CodeChurnHours), ext.CodeChurnPRCount, churnLOCStr) + fmt.Print(formatItemLine("Code Churn", ext.CodeChurnCost, formatTimeUnit(ext.CodeChurnHours), fmt.Sprintf("(%d PRs, ~%s)", ext.CodeChurnPRCount, churnLOCStr))) } if ext.FutureReviewCost > 0.01 { - fmt.Printf(" Review $%10s %s (%d PRs)\n", - formatWithCommas(ext.FutureReviewCost), formatTimeUnit(ext.FutureReviewHours), ext.FutureReviewPRCount) + fmt.Print(formatItemLine("Review", ext.FutureReviewCost, formatTimeUnit(ext.FutureReviewHours), fmt.Sprintf("(%d PRs)", ext.FutureReviewPRCount))) } if ext.FutureMergeCost > 0.01 { - fmt.Printf(" Merge $%10s %s (%d PRs)\n", - formatWithCommas(ext.FutureMergeCost), formatTimeUnit(ext.FutureMergeHours), ext.FutureMergePRCount) + fmt.Print(formatItemLine("Merge", ext.FutureMergeCost, formatTimeUnit(ext.FutureMergeHours), fmt.Sprintf("(%d PRs)", ext.FutureMergePRCount))) } if ext.FutureContextCost > 0.01 { - fmt.Printf(" Context Switching $%10s %s (%d sessions)\n", - formatWithCommas(ext.FutureContextCost), formatTimeUnit(ext.FutureContextHours), ext.FutureContextSessions) + fmt.Print(formatItemLine("Context Switching", ext.FutureContextCost, formatTimeUnit(ext.FutureContextHours), fmt.Sprintf("(%d sessions)", ext.FutureContextSessions))) } extFutureCost := ext.CodeChurnCost + ext.FutureReviewCost + ext.FutureMergeCost + ext.FutureContextCost extFutureHours := ext.CodeChurnHours + ext.FutureReviewHours + ext.FutureMergeHours + ext.FutureContextHours - fmt.Println(" ──────────") + fmt.Print(formatSectionDivider()) pct = (extFutureCost / ext.TotalCost) * 100 - fmt.Printf(" Subtotal $%10s %s (%.1f%%)\n", - formatWithCommas(extFutureCost), formatTimeUnit(extFutureHours), pct) + fmt.Print(formatSubtotalLine("Subtotal", extFutureCost, formatTimeUnit(extFutureHours), fmt.Sprintf("(%.1f%%)", pct))) fmt.Println() } @@ -600,12 +590,11 @@ func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBrea preventableCost := ext.CodeChurnCost + ext.DeliveryDelayCost + ext.AutomatedUpdatesCost + ext.PRTrackingCost preventableHours := ext.CodeChurnHours + ext.DeliveryDelayHours + ext.AutomatedUpdatesHours + ext.PRTrackingHours preventablePct := (preventableCost / ext.TotalCost) * 100 - fmt.Printf(" Preventable Loss Total $%10s %s (%.1f%%)\n", - formatWithCommas(preventableCost), formatTimeUnit(preventableHours), preventablePct) + fmt.Print(formatSummaryLine("Preventable Loss Total", preventableCost, formatTimeUnit(preventableHours), fmt.Sprintf("(%.1f%%)", preventablePct))) // Extrapolated grand total fmt.Println(" ════════════════════════════════════════════════════") - fmt.Printf(" Total $%10s %s\n", + fmt.Printf(" Total $%14s %s\n", formatWithCommas(ext.TotalCost), formatTimeUnit(ext.TotalHours)) fmt.Println() @@ -658,7 +647,7 @@ func printExtrapolatedEfficiency(ext *cost.ExtrapolatedBreakdown, days int, cfg // Weekly waste per PR author if ext.WasteHoursPerAuthorPerWeek > 0 && ext.TotalAuthors > 0 { - fmt.Printf(" Weekly waste per PR author: $%12s %s (%d authors)\n", + fmt.Printf(" Weekly waste per PR author: $%14s %s (%d authors)\n", formatWithCommas(ext.WasteCostPerAuthorPerWeek), formatTimeUnit(ext.WasteHoursPerAuthorPerWeek), ext.TotalAuthors) @@ -667,7 +656,7 @@ func printExtrapolatedEfficiency(ext *cost.ExtrapolatedBreakdown, days int, cfg // Calculate headcount from annual waste annualCostPerHead := cfg.AnnualSalary * cfg.BenefitsMultiplier headcount := annualWasteCost / annualCostPerHead - fmt.Printf(" If Sustained for 1 Year: $%12s %.1f headcount\n", + fmt.Printf(" If Sustained for 1 Year: $%14s %.1f headcount\n", formatWithCommas(annualWasteCost), headcount) fmt.Println() } diff --git a/cmd/server/main.go b/cmd/server/main.go index fb9cd14..ad41a04 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -41,7 +41,7 @@ func main() { logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ AddSource: true, Level: slog.LevelInfo, - ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { + ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { // Shorten source file paths to show only filename:line if a.Key == slog.SourceKey { if src, ok := a.Value.Any().(*slog.Source); ok { diff --git a/internal/server/integration_test.go b/internal/server/integration_test.go index af12091..775f863 100644 --- a/internal/server/integration_test.go +++ b/internal/server/integration_test.go @@ -33,7 +33,7 @@ func TestOrgSampleStreamIntegration(t *testing.T) { // Create request reqBody := OrgSampleRequest{ Org: "codeGROOVE-dev", - SampleSize: 25, + SampleSize: 30, Days: 90, } body, err := json.Marshal(reqBody) @@ -195,7 +195,7 @@ func TestOrgSampleStreamNoTimeout(t *testing.T) { // Create request with larger sample size to ensure longer operation reqBody := OrgSampleRequest{ Org: "codeGROOVE-dev", - SampleSize: 25, + SampleSize: 30, Days: 90, } body, err := json.Marshal(reqBody) diff --git a/internal/server/server.go b/internal/server/server.go index b8095e5..3884349 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -112,7 +112,7 @@ type CalculateResponse struct { type RepoSampleRequest struct { Owner string `json:"owner"` Repo string `json:"repo"` - SampleSize int `json:"sample_size,omitempty"` // Default: 25 + SampleSize int `json:"sample_size,omitempty"` // Default: 30 Days int `json:"days,omitempty"` // Default: 90 Config *cost.Config `json:"config,omitempty"` } @@ -122,7 +122,7 @@ type RepoSampleRequest struct { //nolint:govet // fieldalignment: API struct field order optimized for readability type OrgSampleRequest struct { Org string `json:"org"` - SampleSize int `json:"sample_size,omitempty"` // Default: 25 + SampleSize int `json:"sample_size,omitempty"` // Default: 30 Days int `json:"days,omitempty"` // Default: 90 Config *cost.Config `json:"config,omitempty"` } @@ -1150,18 +1150,18 @@ func (s *Server) parseRepoSampleRequest(ctx context.Context, r *http.Request) (* // Set defaults if req.SampleSize == 0 { - req.SampleSize = 25 + req.SampleSize = 30 } if req.Days == 0 { req.Days = 90 } - // Validate reasonable limits (silently cap at 25) + // Validate reasonable limits (silently cap at 50) if req.SampleSize < 1 { return nil, errors.New("sample_size must be at least 1") } - if req.SampleSize > 25 { - req.SampleSize = 25 + if req.SampleSize > 50 { + req.SampleSize = 50 } if req.Days < 1 || req.Days > 365 { return nil, errors.New("days must be between 1 and 365") @@ -1208,18 +1208,18 @@ func (s *Server) parseOrgSampleRequest(ctx context.Context, r *http.Request) (*O // Set defaults if req.SampleSize == 0 { - req.SampleSize = 25 + req.SampleSize = 30 } if req.Days == 0 { req.Days = 90 } - // Validate reasonable limits (silently cap at 25) + // Validate reasonable limits (silently cap at 50) if req.SampleSize < 1 { return nil, errors.New("sample_size must be at least 1") } - if req.SampleSize > 25 { - req.SampleSize = 25 + if req.SampleSize > 50 { + req.SampleSize = 50 } if req.Days < 1 || req.Days > 365 { return nil, errors.New("days must be between 1 and 365") @@ -1249,7 +1249,7 @@ func (s *Server) processRepoSample(ctx context.Context, req *RepoSampleRequest, } else { // Fetch all PRs modified since the date var err error - prs, err = github.FetchPRsFromRepo(ctx, req.Owner, req.Repo, since, token) + prs, err = github.FetchPRsFromRepo(ctx, req.Owner, req.Repo, since, token, nil) if err != nil { return nil, fmt.Errorf("failed to fetch PRs: %w", err) } @@ -1350,7 +1350,7 @@ func (s *Server) processOrgSample(ctx context.Context, req *OrgSampleRequest, to } else { // Fetch all PRs across the org modified since the date var err error - prs, err = github.FetchPRsFromOrg(ctx, req.Org, since, token) + prs, err = github.FetchPRsFromOrg(ctx, req.Org, since, token, nil) if err != nil { return nil, fmt.Errorf("failed to fetch PRs: %w", err) } @@ -1648,30 +1648,30 @@ func sendSSE(w http.ResponseWriter, update ProgressUpdate) error { // startKeepAlive starts a goroutine that sends SSE keep-alive comments every 2 seconds. // This prevents client-side timeouts during long operations. // Returns a stop channel (to stop keep-alive) and an error channel (signals connection failure). -func startKeepAlive(w http.ResponseWriter) (chan struct{}, <-chan error) { - stop := make(chan struct{}) - connErr := make(chan error, 1) +func startKeepAlive(w http.ResponseWriter) (stop chan struct{}, connErr <-chan error) { + stopChan := make(chan struct{}) + errChan := make(chan error, 1) go func() { ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() - defer close(connErr) + defer close(errChan) for { select { case <-ticker.C: // Send SSE comment (keeps connection alive, ignored by client) if _, err := fmt.Fprint(w, ": keepalive\n\n"); err != nil { - connErr <- fmt.Errorf("keepalive write failed: %w", err) + errChan <- fmt.Errorf("keepalive write failed: %w", err) return } if flusher, ok := w.(http.Flusher); ok { flusher.Flush() } - case <-stop: + case <-stopChan: return } } }() - return stop, connErr + return stopChan, errChan } // logSSEError logs an error from sendSSE if it occurs. @@ -1737,10 +1737,19 @@ func (s *Server) processRepoSampleWithProgress(ctx context.Context, req *RepoSam } }() - // Fetch all PRs modified since the date + // Fetch all PRs modified since the date with progress updates var err error + progressCallback := func(queryName string, page int, prCount int) { + logSSEError(ctx, s.logger, sendSSE(writer, ProgressUpdate{ + Type: "fetching", + PR: 0, + Owner: req.Owner, + Repo: req.Repo, + Progress: fmt.Sprintf("Fetching %s PRs (page %d, %d PRs found)...", queryName, page, prCount), + })) + } //nolint:contextcheck // Using background context intentionally to prevent client timeout from canceling work - prs, err = github.FetchPRsFromRepo(workCtx, req.Owner, req.Repo, since, token) + prs, err = github.FetchPRsFromRepo(workCtx, req.Owner, req.Repo, since, token, progressCallback) if err != nil { logSSEError(ctx, s.logger, sendSSE(writer, ProgressUpdate{ Type: "error", @@ -1862,10 +1871,19 @@ func (s *Server) processOrgSampleWithProgress(ctx context.Context, req *OrgSampl } }() - // Fetch all PRs across the org modified since the date + // Fetch all PRs across the org modified since the date with progress updates var err error + progressCallback := func(queryName string, page int, prCount int) { + logSSEError(ctx, s.logger, sendSSE(writer, ProgressUpdate{ + Type: "fetching", + PR: 0, + Owner: req.Org, + Repo: "", + Progress: fmt.Sprintf("Fetching %s PRs (page %d, %d PRs found)...", queryName, page, prCount), + })) + } //nolint:contextcheck // Using background context intentionally to prevent client timeout from canceling work - prs, err = github.FetchPRsFromOrg(workCtx, req.Org, since, token) + prs, err = github.FetchPRsFromOrg(workCtx, req.Org, since, token, progressCallback) if err != nil { logSSEError(ctx, s.logger, sendSSE(writer, ProgressUpdate{ Type: "error", diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 06d5dc2..d97c30f 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -460,6 +460,12 @@ func TestParseRequest(t *testing.T) { } func TestHandleCalculateNoToken(t *testing.T) { + // Clear environment variables that could provide a fallback token + // t.Setenv automatically restores the original value after the test + t.Setenv("GITHUB_TOKEN", "") + // Clear PATH to prevent gh CLI lookup + t.Setenv("PATH", "") + s := New() reqBody := CalculateRequest{ diff --git a/internal/server/static/index.html b/internal/server/static/index.html index edff0c5..536e0a0 100644 --- a/internal/server/static/index.html +++ b/internal/server/static/index.html @@ -1059,11 +1059,11 @@

PR Cost Calculator

-
25 (fast, ±20% accuracy) or 50 (slower, ±14% accuracy)
+
30 (fast, ±18% accuracy) or 50 (slower, ±14% accuracy)
@@ -1099,11 +1099,11 @@

PR Cost Calculator

-
25 (fast, ±20% accuracy) or 50 (slower, ±14% accuracy)
+
30 (fast, ±18% accuracy) or 50 (slower, ±14% accuracy)
@@ -1262,76 +1262,114 @@

Why calculate PR costs?

// Add fractional part if significant if (kloc < 1000.0 && fracPart >= 0.05) { - return `${intStr}.${Math.floor(fracPart * 10)}k`; + return `${intStr}.${Math.floor(fracPart * 10)}k LOC`; } - return intStr + 'k'; + return intStr + 'k LOC'; } // For values < 100k, use existing precision logic if (kloc < 0.1 && kloc > 0) { - return kloc.toFixed(2) + 'k'; + return kloc.toFixed(2) + 'k LOC'; } if (kloc < 1.0) { - return kloc.toFixed(1) + 'k'; + return kloc.toFixed(1) + 'k LOC'; } if (kloc < 10.0) { - return kloc.toFixed(1) + 'k'; + return kloc.toFixed(1) + 'k LOC'; } - return Math.floor(kloc) + 'k'; + return Math.floor(kloc) + 'k LOC'; } function formatTimeUnit(hours) { if (hours < 1) { - return (hours * 60).toFixed(1) + ' min'; + return (hours * 60).toFixed(1) + 'm'; } if (hours < 48) { - return hours.toFixed(1) + ' hrs'; + return hours.toFixed(1) + 'h'; } const days = hours / 24; if (days < 14) { - return days.toFixed(1) + ' days'; + return days.toFixed(1) + 'd'; } const weeks = days / 7; if (weeks < 8) { - return weeks.toFixed(1) + ' weeks'; + return weeks.toFixed(1) + 'w'; } const months = days / 30; if (months < 24) { - return months.toFixed(1) + ' months'; + return months.toFixed(1) + 'mo'; } const years = days / 365; - return years.toFixed(1) + ' years'; + return years.toFixed(1) + 'y'; + } + + // Ledger formatting functions - all output must use these for consistency + + // formatItemLine formats a cost breakdown line item with 4-space indent + function formatItemLine(label, cost, timeUnit, detail) { + const paddedLabel = label.padEnd(30); + const paddedTimeUnit = timeUnit.padEnd(6); + if (cost === 0) { + return ` ${paddedLabel} ${'—'.padStart(15)} ${paddedTimeUnit} ${detail}\n`; + } + return ` ${paddedLabel} ${formatCurrency(cost).padStart(15)} ${paddedTimeUnit} ${detail}\n`; + } + + // formatSubtotalLine formats a subtotal line with 4-space indent + function formatSubtotalLine(label, cost, timeUnit, detail) { + const paddedLabel = label.padEnd(30); + const paddedTimeUnit = timeUnit.padEnd(6); + return ` ${paddedLabel} ${formatCurrency(cost).padStart(15)} ${paddedTimeUnit} ${detail}\n`; + } + + // formatSummaryLine formats a summary line (like Preventable Loss Total) with 2-space indent + function formatSummaryLine(label, cost, timeUnit, detail) { + const paddedLabel = label.padEnd(30); + const paddedTimeUnit = timeUnit.padEnd(6); + return ` ${paddedLabel} ${formatCurrency(cost).padStart(15)} ${paddedTimeUnit} ${detail}\n`; + } + + // formatTotalLine formats a total line with 2-space indent + function formatTotalLine(label, cost, timeUnit) { + const paddedLabel = label.padEnd(30); + const paddedTimeUnit = timeUnit.padEnd(6); + return ` ${paddedLabel} ${formatCurrency(cost).padStart(15)} ${paddedTimeUnit}\n`; + } + + // formatSectionDivider formats the divider line under subtotals + function formatSectionDivider() { + return ' ──────────────\n'; } function formatEngTimeUnit(hours) { if (hours < 1) { - return (hours * 60).toFixed(1) + ' min'; + return (hours * 60).toFixed(1) + 'm'; } if (hours < 48) { - return hours.toFixed(1) + ' hrs'; + return hours.toFixed(1) + 'h'; } const days = hours / 24; if (days < 14) { - return days.toFixed(1) + ' days'; + return days.toFixed(1) + 'd'; } const weeks = days / 7; if (weeks < 8) { - return weeks.toFixed(1) + ' weeks'; + return weeks.toFixed(1) + 'w'; } const months = days / 30; if (months < 24) { - return months.toFixed(1) + ' months'; + return months.toFixed(1) + 'mo'; } const years = days / 365; - return years.toFixed(1) + ' years'; + return years.toFixed(1) + 'y'; } function efficiencyGrade(efficiencyPct) { @@ -1435,10 +1473,10 @@

Why calculate PR costs?

savingsText = '$' + r2rSavings.toFixed(0); } - let html = '
'; - html += '✓ Based on this calculation, Ready-to-Review would save you ~' + savingsText + '/yr by cutting average merge time to ≤40 min. '; - html += 'Stop losing engineering hours to code review lag. Free for OSS projects. '; - html += 'Let\'s chat: go-faster@codeGROOVE.dev'; + let html = '
'; + html += '✓ You\'re losing ' + savingsText + '/yr to code review lag. '; + html += 'Ready-to-Review fixes it: <40min merges, free for OSS. '; + html += 'go-faster@codeGROOVE.dev'; html += '
'; return html; } @@ -1620,13 +1658,13 @@

Why calculate PR costs?

const avgAuthorSessions = e.author_sessions / totalPRs; output += ` Development Costs (${formatLOC(avgTotalLOC)} total)\n`; output += ' ──────────────────────────────────\n'; - output += ` New Development ${formatCurrency(avgAuthorNewCodeCost).padStart(10)} ${formatTimeUnit(avgAuthorNewCodeHours)} (${formatLOC(newLOC)})\n`; - output += ` Adaptation ${formatCurrency(avgAuthorAdaptationCost).padStart(10)} ${formatTimeUnit(avgAuthorAdaptationHours)} (${formatLOC(modifiedLOC)})\n`; - output += ` GitHub Activity ${formatCurrency(avgAuthorGitHubCost).padStart(10)} ${formatTimeUnit(avgAuthorGitHubHours)} (${avgAuthorEvents.toFixed(1)} events)\n`; - output += ` Context Switching ${formatCurrency(avgAuthorGitHubContextCost).padStart(10)} ${formatTimeUnit(avgAuthorGitHubContextHours)} (${avgAuthorSessions.toFixed(1)} sessions)\n`; + output += formatItemLine("New Development", avgAuthorNewCodeCost, formatTimeUnit(avgAuthorNewCodeHours), `(${formatLOC(newLOC)})`); + output += formatItemLine("Adaptation", avgAuthorAdaptationCost, formatTimeUnit(avgAuthorAdaptationHours), `(${formatLOC(modifiedLOC)})`); + output += formatItemLine("GitHub Activity", avgAuthorGitHubCost, formatTimeUnit(avgAuthorGitHubHours), `(${avgAuthorEvents.toFixed(1)} events)`); + output += formatItemLine("Context Switching", avgAuthorGitHubContextCost, formatTimeUnit(avgAuthorGitHubContextHours), `(${avgAuthorSessions.toFixed(1)} sessions)`); output += ' ──────────\n'; let pct = (avgAuthorTotalCost / avgTotalCost) * 100; - output += ` Subtotal ${formatCurrency(avgAuthorTotalCost).padStart(10)} ${formatTimeUnit(avgAuthorTotalHours)} (${pct.toFixed(1)}%)\n\n`; + output += ` Subtotal ${formatCurrency(avgAuthorTotalCost).padStart(15)} ${formatTimeUnit(avgAuthorTotalHours)} (${pct.toFixed(1)}%)\n\n`; // Participants if (e.participant_total_cost > 0) { @@ -1636,33 +1674,33 @@

Why calculate PR costs?

output += ` Participant Costs\n`; output += ' ─────────────────\n'; - output += ` Review Activity ${formatCurrency(avgParticipantReviewCost).padStart(10)} ${formatTimeUnit(avgParticipantReviewHours)} (${avgParticipantReviews.toFixed(1)} reviews)\n`; - output += ` GitHub Activity ${formatCurrency(avgParticipantGitHubCost).padStart(10)} ${formatTimeUnit(avgParticipantGitHubHours)} (${avgParticipantEvents.toFixed(1)} events)\n`; - output += ` Context Switching ${formatCurrency(avgParticipantContextCost).padStart(10)} ${formatTimeUnit(avgParticipantContextHours)} (${avgParticipantSessions.toFixed(1)} sessions)\n`; + output += formatItemLine("Review Activity", avgParticipantReviewCost, formatTimeUnit(avgParticipantReviewHours), `(${avgParticipantReviews.toFixed(1)} reviews)`); + output += formatItemLine("GitHub Activity", avgParticipantGitHubCost, formatTimeUnit(avgParticipantGitHubHours), `(${avgParticipantEvents.toFixed(1)} events)`); + output += formatItemLine("Context Switching", avgParticipantContextCost, formatTimeUnit(avgParticipantContextHours), `(${avgParticipantSessions.toFixed(1)} sessions)`); output += ' ──────────\n'; pct = (avgParticipantTotalCost / avgTotalCost) * 100; - output += ` Subtotal ${formatCurrency(avgParticipantTotalCost).padStart(10)} ${formatTimeUnit(avgParticipantTotalHours)} (${pct.toFixed(1)}%)\n\n`; + output += ` Subtotal ${formatCurrency(avgParticipantTotalCost).padStart(15)} ${formatTimeUnit(avgParticipantTotalHours)} (${pct.toFixed(1)}%)\n\n`; } // Delay Costs output += ' Delay Costs\n'; output += ' ───────────\n'; - output += ` Workstream blockage ${formatCurrency(avgDeliveryDelayCost).padStart(10)} ${formatTimeUnit(avgDeliveryDelayHours)} (${e.human_prs} PRs)\n`; + output += ` Workstream blockage ${formatCurrency(avgDeliveryDelayCost).padStart(15)} ${formatTimeUnit(avgDeliveryDelayHours)} (${e.human_prs} PRs)\n`; const avgAutomatedUpdatesCost = e.automated_updates_cost / totalPRs; const avgAutomatedUpdatesHours = e.automated_updates_hours / totalPRs; const avgPRTrackingCost = e.pr_tracking_cost / totalPRs; const avgPRTrackingHours = e.pr_tracking_hours / totalPRs; if (avgAutomatedUpdatesCost > 0.01) { - output += ` Automated Updates ${formatCurrency(avgAutomatedUpdatesCost).padStart(10)} ${formatTimeUnit(avgAutomatedUpdatesHours)} (${e.bot_prs} PRs)\n`; + output += ` Automated Updates ${formatCurrency(avgAutomatedUpdatesCost).padStart(15)} ${formatTimeUnit(avgAutomatedUpdatesHours)} (${e.bot_prs} PRs)\n`; } if (avgPRTrackingCost > 0.01) { - output += ` PR Tracking ${formatCurrency(avgPRTrackingCost).padStart(10)} ${formatTimeUnit(avgPRTrackingHours)} (${e.open_prs} open PRs)\n`; + output += ` PR Tracking ${formatCurrency(avgPRTrackingCost).padStart(15)} ${formatTimeUnit(avgPRTrackingHours)} (${e.open_prs} open PRs)\n`; } const avgMergeDelayCost = avgDeliveryDelayCost + avgCodeChurnCost + avgAutomatedUpdatesCost + avgPRTrackingCost; const avgMergeDelayHours = avgDeliveryDelayHours + avgCodeChurnHours + avgAutomatedUpdatesHours + avgPRTrackingHours; output += ' ──────────\n'; pct = (avgMergeDelayCost / avgTotalCost) * 100; - output += ` Subtotal ${formatCurrency(avgMergeDelayCost).padStart(10)} ${formatTimeUnit(avgMergeDelayHours)} (${pct.toFixed(1)}%)\n\n`; + output += ` Subtotal ${formatCurrency(avgMergeDelayCost).padStart(15)} ${formatTimeUnit(avgMergeDelayHours)} (${pct.toFixed(1)}%)\n\n`; // Future Costs const hasFuture = e.code_churn_cost > 0.01 || e.future_review_cost > 0.01 || e.future_merge_cost > 0.01 || e.future_context_cost > 0.01; @@ -1672,34 +1710,34 @@

Why calculate PR costs?

if (e.code_churn_cost > 0.01) { const avgReworkPct = e.avg_rework_percentage || 0; const label = avgReworkPct > 0 ? `Code Churn (${avgReworkPct.toFixed(0)}% drift)` : 'Code Churn'; - output += ` ${label.padEnd(26)} ${formatCurrency(avgCodeChurnCost).padStart(10)} ${formatTimeUnit(avgCodeChurnHours)} (${e.code_churn_pr_count} PRs)\n`; + output += ` ${label.padEnd(26)} ${formatCurrency(avgCodeChurnCost).padStart(15)} ${formatTimeUnit(avgCodeChurnHours)} (${e.code_churn_pr_count} PRs)\n`; } if (e.future_review_cost > 0.01) { - output += ` Review ${formatCurrency(avgFutureReviewCost).padStart(10)} ${formatTimeUnit(avgFutureReviewHours)} (${e.future_review_pr_count} PRs)\n`; + output += ` Review ${formatCurrency(avgFutureReviewCost).padStart(15)} ${formatTimeUnit(avgFutureReviewHours)} (${e.future_review_pr_count} PRs)\n`; } if (e.future_merge_cost > 0.01) { - output += ` Merge ${formatCurrency(avgFutureMergeCost).padStart(10)} ${formatTimeUnit(avgFutureMergeHours)} (${e.future_merge_pr_count} PRs)\n`; + output += ` Merge ${formatCurrency(avgFutureMergeCost).padStart(15)} ${formatTimeUnit(avgFutureMergeHours)} (${e.future_merge_pr_count} PRs)\n`; } if (e.future_context_cost > 0.01) { const avgFutureContextSessions = e.future_context_sessions / totalPRs; - output += ` Context Switching ${formatCurrency(avgFutureContextCost).padStart(10)} ${formatTimeUnit(avgFutureContextHours)} (${avgFutureContextSessions.toFixed(1)} sessions)\n`; + output += ` Context Switching ${formatCurrency(avgFutureContextCost).padStart(15)} ${formatTimeUnit(avgFutureContextHours)} (${avgFutureContextSessions.toFixed(1)} sessions)\n`; } const avgFutureCost = avgCodeChurnCost + avgFutureReviewCost + avgFutureMergeCost + avgFutureContextCost; const avgFutureHours = avgCodeChurnHours + avgFutureReviewHours + avgFutureMergeHours + avgFutureContextHours; output += ' ──────────\n'; pct = (avgFutureCost / avgTotalCost) * 100; - output += ` Subtotal ${formatCurrency(avgFutureCost).padStart(10)} ${formatTimeUnit(avgFutureHours)} (${pct.toFixed(1)}%)\n\n`; + output += ` Subtotal ${formatCurrency(avgFutureCost).padStart(15)} ${formatTimeUnit(avgFutureHours)} (${pct.toFixed(1)}%)\n\n`; } // Average Preventable Loss Total (before grand total) const avgPreventableCost = avgCodeChurnCost + avgDeliveryDelayCost + avgCodeChurnCost + (e.automated_updates_cost / totalPRs) + (e.pr_tracking_cost / totalPRs); const avgPreventableHours = avgCodeChurnHours + avgDeliveryDelayHours + avgCodeChurnHours + (e.automated_updates_hours / totalPRs) + (e.pr_tracking_hours / totalPRs); const avgPreventablePct = (avgPreventableCost / avgTotalCost) * 100; - output += ` Preventable Loss Total ${formatCurrency(avgPreventableCost).padStart(10)} ${formatTimeUnit(avgPreventableHours)} (${avgPreventablePct.toFixed(1)}%)\n`; + output += ` Preventable Loss Total ${formatCurrency(avgPreventableCost).padStart(15)} ${formatTimeUnit(avgPreventableHours)} (${avgPreventablePct.toFixed(1)}%)\n`; // Average Total output += ' ════════════════════════════════════════════════════\n'; - output += ` Average Total ${formatCurrency(avgTotalCost).padStart(10)} ${formatTimeUnit(avgTotalHours)}\n\n`; + output += ` Average Total ${formatCurrency(avgTotalCost).padStart(15)} ${formatTimeUnit(avgTotalHours)}\n\n`; return output; } @@ -1716,34 +1754,34 @@

Why calculate PR costs?

// Development Costs output += ` Development Costs (${e.human_prs || e.total_prs || 0} PRs, ${formatLOC(totalLOC)} total)\n`; output += ' ─────────────────────────────────────────────────────────────────────\n'; - output += ` New Development ${formatCurrency(e.author_new_code_cost).padStart(10)} ${formatTimeUnit(e.author_new_code_hours)} (${formatLOC(newLOC)})\n`; - output += ` Adaptation ${formatCurrency(e.author_adaptation_cost).padStart(10)} ${formatTimeUnit(e.author_adaptation_hours)} (${formatLOC(modifiedLOC)})\n`; - output += ` GitHub Activity ${formatCurrency(e.author_github_cost).padStart(10)} ${formatTimeUnit(e.author_github_hours)} (${e.author_events || 0} events)\n`; - output += ` Context Switching ${formatCurrency(e.author_github_context_cost).padStart(10)} ${formatTimeUnit(e.author_github_context_hours)} (${e.author_sessions || 0} sessions)\n`; + output += formatItemLine("New Development", e.author_new_code_cost, formatTimeUnit(e.author_new_code_hours), `(${formatLOC(newLOC)})`); + output += formatItemLine("Adaptation", e.author_adaptation_cost, formatTimeUnit(e.author_adaptation_hours), `(${formatLOC(modifiedLOC)})`); + output += formatItemLine("GitHub Activity", e.author_github_cost, formatTimeUnit(e.author_github_hours), `(${e.author_events || 0} events)`); + output += formatItemLine("Context Switching", e.author_github_context_cost, formatTimeUnit(e.author_github_context_hours), `(${e.author_sessions || 0} sessions)`); // Show bot PR LOC even though cost is $0 if ((e.bot_prs || 0) > 0) { - output += ` Automated Updates — ${formatTimeUnit(0)} (${e.bot_prs} PRs, ${formatLOC(botLOC)})\n`; + output += formatItemLine("Automated Updates", 0, formatTimeUnit(0), `(${e.bot_prs} PRs, ${formatLOC(botLOC)})`); } output += ' ──────────\n'; let pct = (e.author_total_cost / e.total_cost) * 100; - output += ` Subtotal ${formatCurrency(e.author_total_cost).padStart(10)} ${formatTimeUnit(e.author_total_hours)} (${pct.toFixed(1)}%)\n\n`; + output += ` Subtotal ${formatCurrency(e.author_total_cost).padStart(15)} ${formatTimeUnit(e.author_total_hours)} (${pct.toFixed(1)}%)\n\n`; // Participants if (e.participant_total_cost > 0) { output += ` Participant Costs\n`; output += ' ─────────────────\n'; if ((e.participant_review_cost || 0) > 0) { - output += ` Review Activity ${formatCurrency(e.participant_review_cost).padStart(10)} ${formatTimeUnit(e.participant_review_hours)} (${e.participant_reviews || 0} reviews)\n`; + output += formatItemLine("Review Activity", e.participant_review_cost, formatTimeUnit(e.participant_review_hours), `(${e.participant_reviews || 0} reviews)`); } if ((e.participant_github_cost || 0) > 0) { - output += ` GitHub Activity ${formatCurrency(e.participant_github_cost).padStart(10)} ${formatTimeUnit(e.participant_github_hours)} (${e.participant_events || 0} events)\n`; + output += formatItemLine("GitHub Activity", e.participant_github_cost, formatTimeUnit(e.participant_github_hours), `(${e.participant_events || 0} events)`); } - output += ` Context Switching ${formatCurrency(e.participant_context_cost).padStart(10)} ${formatTimeUnit(e.participant_context_hours)} (${e.participant_sessions || 0} sessions)\n`; + output += formatItemLine("Context Switching", e.participant_context_cost, formatTimeUnit(e.participant_context_hours), `(${e.participant_sessions || 0} sessions)`); output += ' ──────────\n'; pct = (e.participant_total_cost / e.total_cost) * 100; - output += ` Subtotal ${formatCurrency(e.participant_total_cost).padStart(10)} ${formatTimeUnit(e.participant_total_hours)} (${pct.toFixed(1)}%)\n\n`; + output += ` Subtotal ${formatCurrency(e.participant_total_cost).padStart(15)} ${formatTimeUnit(e.participant_total_hours)} (${pct.toFixed(1)}%)\n\n`; } // Delay Costs @@ -1756,20 +1794,20 @@

Why calculate PR costs?

output += ' ' + '─'.repeat(delayCostsHeader.length - 2) + '\n'; if ((e.delivery_delay_cost || 0) > 0) { - output += ` Workstream blockage ${formatCurrency(e.delivery_delay_cost).padStart(10)} ${formatTimeUnit(e.delivery_delay_hours)} (${e.human_prs || 0} PRs)\n`; + output += ` Workstream blockage ${formatCurrency(e.delivery_delay_cost).padStart(15)} ${formatTimeUnit(e.delivery_delay_hours)} (${e.human_prs || 0} PRs)\n`; } if ((e.automated_updates_cost || 0) > 0) { - output += ` Automated Updates ${formatCurrency(e.automated_updates_cost).padStart(10)} ${formatTimeUnit(e.automated_updates_hours)} (${e.bot_prs || 0} PRs)\n`; + output += ` Automated Updates ${formatCurrency(e.automated_updates_cost).padStart(15)} ${formatTimeUnit(e.automated_updates_hours)} (${e.bot_prs || 0} PRs)\n`; } if ((e.pr_tracking_cost || 0) > 0) { - output += ` PR Tracking ${formatCurrency(e.pr_tracking_cost).padStart(10)} ${formatTimeUnit(e.pr_tracking_hours)} (${e.open_prs || 0} open PRs)\n`; + output += ` PR Tracking ${formatCurrency(e.pr_tracking_cost).padStart(15)} ${formatTimeUnit(e.pr_tracking_hours)} (${e.open_prs || 0} open PRs)\n`; } const mergeDelayCost = (e.delivery_delay_cost || 0) + (e.code_churn_cost || 0) + (e.automated_updates_cost || 0) + (e.pr_tracking_cost || 0); const mergeDelayHours = (e.delivery_delay_hours || 0) + (e.code_churn_hours || 0) + (e.automated_updates_hours || 0) + (e.pr_tracking_hours || 0); output += ' ──────────\n'; pct = (mergeDelayCost / e.total_cost) * 100; - output += ` Subtotal ${formatCurrency(mergeDelayCost).padStart(10)} ${formatTimeUnit(mergeDelayHours)} (${pct.toFixed(1)}%)\n\n`; + output += ` Subtotal ${formatCurrency(mergeDelayCost).padStart(15)} ${formatTimeUnit(mergeDelayHours)} (${pct.toFixed(1)}%)\n\n`; // Future Costs const hasFuture = (e.code_churn_cost || 0) > 0.01 || (e.future_review_cost || 0) > 0.01 || (e.future_merge_cost || 0) > 0.01 || (e.future_context_cost || 0) > 0.01; @@ -1777,40 +1815,35 @@

Why calculate PR costs?

output += ' Future Costs\n'; output += ' ────────────\n'; if ((e.code_churn_cost || 0) > 0.01) { - output += ` Code Churn ${formatCurrency(e.code_churn_cost).padStart(10)} ${formatTimeUnit(e.code_churn_hours)}\n`; + output += ` Code Churn ${formatCurrency(e.code_churn_cost).padStart(15)} ${formatTimeUnit(e.code_churn_hours)}\n`; } if ((e.future_review_cost || 0) > 0.01) { const openPRs = e.open_prs || 0; - output += ` Review ${formatCurrency(e.future_review_cost).padStart(10)} ${formatTimeUnit(e.future_review_hours)} (${openPRs} PRs)\n`; + output += ` Review ${formatCurrency(e.future_review_cost).padStart(15)} ${formatTimeUnit(e.future_review_hours)} (${openPRs} PRs)\n`; } if ((e.future_merge_cost || 0) > 0.01) { const openPRs = e.open_prs || 0; - output += ` Merge ${formatCurrency(e.future_merge_cost).padStart(10)} ${formatTimeUnit(e.future_merge_hours)} (${openPRs} PRs)\n`; + output += ` Merge ${formatCurrency(e.future_merge_cost).padStart(15)} ${formatTimeUnit(e.future_merge_hours)} (${openPRs} PRs)\n`; } if ((e.future_context_cost || 0) > 0.01) { - output += ` Context Switching ${formatCurrency(e.future_context_cost).padStart(10)} ${formatTimeUnit(e.future_context_hours)} (${e.future_context_sessions || 0} sessions)\n`; + output += ` Context Switching ${formatCurrency(e.future_context_cost).padStart(15)} ${formatTimeUnit(e.future_context_hours)} (${e.future_context_sessions || 0} sessions)\n`; } const futureCost = (e.code_churn_cost || 0) + (e.future_review_cost || 0) + (e.future_merge_cost || 0) + (e.future_context_cost || 0); const futureHours = (e.code_churn_hours || 0) + (e.future_review_hours || 0) + (e.future_merge_hours || 0) + (e.future_context_hours || 0); output += ' ──────────\n'; pct = (futureCost / e.total_cost) * 100; - output += ` Subtotal ${formatCurrency(futureCost).padStart(10)} ${formatTimeUnit(futureHours)} (${pct.toFixed(1)}%)\n\n`; - } - - // Weekly waste per PR author - if ((e.waste_hours_per_author_per_week || 0) > 0 && (e.total_authors || 0) > 0) { - output += ` Weekly waste per PR author: ${formatCurrency(e.waste_cost_per_author_per_week).padStart(12)} ${formatTimeUnit(e.waste_hours_per_author_per_week)} (${e.total_authors} authors)\n`; + output += ` Subtotal ${formatCurrency(futureCost).padStart(15)} ${formatTimeUnit(futureHours)} (${pct.toFixed(1)}%)\n\n`; } // Preventable Loss Total (before grand total) const preventableCost = (e.code_churn_cost || 0) + (e.delivery_delay_cost || 0) + (e.automated_updates_cost || 0) + (e.pr_tracking_cost || 0); const preventableHours = (e.code_churn_hours || 0) + (e.delivery_delay_hours || 0) + (e.automated_updates_hours || 0) + (e.pr_tracking_hours || 0); const preventablePct = (preventableCost / e.total_cost) * 100; - output += ` Preventable Loss Total ${formatCurrency(preventableCost).padStart(10)} ${formatTimeUnit(preventableHours)} (${preventablePct.toFixed(1)}%)\n`; + output += ` Preventable Loss Total ${formatCurrency(preventableCost).padStart(15)} ${formatTimeUnit(preventableHours)} (${preventablePct.toFixed(1)}%)\n`; // Total output += ' ════════════════════════════════════════════════════\n'; - output += ` Total ${formatCurrency(e.total_cost).padStart(10)} ${formatTimeUnit(e.total_hours)}\n\n`; + output += ` Total ${formatCurrency(e.total_cost).padStart(15)} ${formatTimeUnit(e.total_hours)}\n\n`; return output; } @@ -2077,18 +2110,18 @@

Why calculate PR costs?

const avgEfficiencyPct = avgTotalHours > 0 ? 100.0 * (avgTotalHours - avgPreventableHours) / avgTotalHours : 100.0; const avgEfficiency = efficiencyGrade(avgEfficiencyPct); - // Average PR section - html += '
'; - html += `

Average PR (sampled over ${days} day period)

`; - html += '
' + formatAveragePR(e) + '
'; - html += '
'; - // Extrapolated total section html += '
'; html += `

${days}-day Estimated Costs

`; html += '
' + formatExtrapolatedTotal(e, days) + '
'; html += '
'; + // Average PR section + html += '
'; + html += `

Average PR (sampled over ${days} day period)

`; + html += '
' + formatAveragePR(e) + '
'; + html += '
'; + resultDiv.innerHTML = html; resolve(); return; diff --git a/pkg/cost/cost.go b/pkg/cost/cost.go index ef733bc..eac5456 100644 --- a/pkg/cost/cost.go +++ b/pkg/cost/cost.go @@ -100,9 +100,9 @@ func DefaultConfig() Config { ContextSwitchInDuration: 3 * time.Minute, // 3 min to context switch in (Microsoft Research) ContextSwitchOutDuration: 16*time.Minute + 33*time.Second, // 16m33s to context switch out (Microsoft Research) SessionGapThreshold: 20 * time.Minute, // Events within 20 min are same session - DeliveryDelayFactor: 0.15, // 15% opportunity cost + DeliveryDelayFactor: 0.20, // 20% opportunity cost AutomatedUpdatesFactor: 0.01, // 1% overhead for bot PRs - PRTrackingMinutesPerDay: 1.0, // 1 minute per day for PRs open >24 hours + PRTrackingMinutesPerDay: 10.0 / 60.0, // 10 seconds/person/day per open PR MaxDelayAfterLastEvent: 14 * 24 * time.Hour, // 14 days (2 weeks) after last event MaxProjectDelay: 90 * 24 * time.Hour, // 90 days absolute max MaxCodeDrift: 90 * 24 * time.Hour, // 90 days diff --git a/pkg/cost/cost_test.go b/pkg/cost/cost_test.go index 7be45fb..01d3e99 100644 --- a/pkg/cost/cost_test.go +++ b/pkg/cost/cost_test.go @@ -27,8 +27,8 @@ func TestDefaultConfig(t *testing.T) { t.Errorf("Expected 20 minute session gap, got %v", cfg.SessionGapThreshold) } - if cfg.DeliveryDelayFactor != 0.15 { - t.Errorf("Expected delivery delay factor 0.15, got %.2f", cfg.DeliveryDelayFactor) + if cfg.DeliveryDelayFactor != 0.20 { + t.Errorf("Expected delivery delay factor 0.20, got %.2f", cfg.DeliveryDelayFactor) } if cfg.MaxDelayAfterLastEvent != 14*24*time.Hour { @@ -585,7 +585,7 @@ func TestDelayHoursNeverExceedPRAge(t *testing.T) { }, CreatedAt: now.Add(-24 * time.Hour), }, - wantMax: 24.0 * 0.15, // 15% of 24 hours = 3.6 hours + wantMax: 24.0 * 0.20, // 20% of 24 hours = 4.8 hours }, { name: "7 day old PR", @@ -598,7 +598,7 @@ func TestDelayHoursNeverExceedPRAge(t *testing.T) { }, CreatedAt: now.Add(-7 * 24 * time.Hour), }, - wantMax: 168.0 * 0.15, // 15% of 168 hours = 25.2 hours + wantMax: 168.0 * 0.20, // 20% of 168 hours = 33.6 hours }, } @@ -608,17 +608,17 @@ func TestDelayHoursNeverExceedPRAge(t *testing.T) { t.Run(tc.name, func(t *testing.T) { breakdown := Calculate(tc.prData, cfg) - // Delivery delay hours should not exceed 15% of PR age + // Delivery delay hours should not exceed 20% of PR age if breakdown.DelayCostDetail.DeliveryDelayHours > tc.wantMax+0.1 { // Allow 0.1 hr tolerance for floating point - t.Errorf("Delay hours exceed 15%% of PR age: got %.2f hours, want <= %.2f hours (PR age: %.1f hrs)", + t.Errorf("Delay hours exceed 20%% of PR age: got %.2f hours, want <= %.2f hours (PR age: %.1f hrs)", breakdown.DelayCostDetail.DeliveryDelayHours, tc.wantMax, tc.ageHrs) } - // Verify delivery should be 15% - expectedDelivery := tc.ageHrs * 0.15 + // Verify delivery should be 20% + expectedDelivery := tc.ageHrs * 0.20 if breakdown.DelayCostDetail.DeliveryDelayHours > expectedDelivery+0.1 { - t.Errorf("Delivery delay hours too high: got %.2f, want %.2f (15%% of %.1f)", + t.Errorf("Delivery delay hours too high: got %.2f, want %.2f (20%% of %.1f)", breakdown.DelayCostDetail.DeliveryDelayHours, expectedDelivery, tc.ageHrs) } }) diff --git a/pkg/cost/extrapolate.go b/pkg/cost/extrapolate.go index 4d4f70a..d119f6e 100644 --- a/pkg/cost/extrapolate.go +++ b/pkg/cost/extrapolate.go @@ -290,9 +290,11 @@ func ExtrapolateFromSamples(breakdowns []Breakdown, totalPRs, totalAuthors, actu extCodeChurnCost := sumCodeChurnCost / samples * multiplier extAutomatedUpdatesCost := sumAutomatedUpdatesCost / samples * multiplier // Calculate Open PR Tracking cost based on actual open PRs (not from samples) - // Formula: actualOpenPRs × (tracking_minutes_per_day / 60) × daysInPeriod × hourlyRate + // Formula: actualOpenPRs × uniqueUsers × (tracking_minutes_per_day_per_person / 60) × daysInPeriod × hourlyRate + // This scales with team size: larger teams spend more total time tracking open PRs hourlyRate := cfg.AnnualSalary * cfg.BenefitsMultiplier / cfg.HoursPerYear - extPRTrackingHours := float64(actualOpenPRs) * (cfg.PRTrackingMinutesPerDay / 60.0) * float64(daysInPeriod) + uniqueUserCount := len(uniqueNonBotUsers) + extPRTrackingHours := float64(actualOpenPRs) * float64(uniqueUserCount) * (cfg.PRTrackingMinutesPerDay / 60.0) * float64(daysInPeriod) extPRTrackingCost := extPRTrackingHours * hourlyRate extFutureReviewCost := sumFutureReviewCost / samples * multiplier extFutureMergeCost := sumFutureMergeCost / samples * multiplier @@ -377,7 +379,7 @@ func ExtrapolateFromSamples(breakdowns []Breakdown, totalPRs, totalAuthors, actu // Calculate R2R savings // Formula: baseline annual waste - (re-modeled waste with 40min PRs) - (R2R subscription cost) // Baseline annual waste: preventable cost extrapolated to 52 weeks - uniqueUserCount := len(uniqueNonBotUsers) + // uniqueUserCount already defined above for PR tracking calculation baselineAnnualWaste := (extCodeChurnCost + extDeliveryDelayCost + extAutomatedUpdatesCost + extPRTrackingCost) * (52.0 / (float64(daysInPeriod) / 7.0)) // Re-model with 40-minute PR merge times diff --git a/pkg/github/query.go b/pkg/github/query.go index 5ce6b9a..bd10993 100644 --- a/pkg/github/query.go +++ b/pkg/github/query.go @@ -23,6 +23,10 @@ type PRSummary struct { UpdatedAt time.Time // Last update time } +// ProgressCallback is called during PR fetching to report progress. +// Parameters: queryName (e.g., "recent", "old", "early"), currentPage, totalPRsSoFar +type ProgressCallback func(queryName string, page int, prCount int) + // FetchPRsFromRepo queries GitHub GraphQL API for all PRs in a repository // modified since the specified date. // @@ -38,12 +42,13 @@ type PRSummary struct { // - repo: GitHub repository name // - since: Only include PRs updated after this time // - token: GitHub authentication token +// - progress: Optional callback for progress updates (can be nil) // // Returns: // - Slice of PRSummary for all matching PRs (deduplicated) -func FetchPRsFromRepo(ctx context.Context, owner, repo string, since time.Time, token string) ([]PRSummary, error) { +func FetchPRsFromRepo(ctx context.Context, owner, repo string, since time.Time, token string, progress ProgressCallback) ([]PRSummary, error) { // Query 1: Recent activity (updated DESC) - get up to 1000 PRs - recent, hitLimit, err := fetchPRsFromRepoWithSort(ctx, owner, repo, since, token, "UPDATED_AT", "DESC", 1000) + recent, hitLimit, err := fetchPRsFromRepoWithSort(ctx, owner, repo, since, token, "UPDATED_AT", "DESC", 1000, "recent", progress) if err != nil { return nil, err } @@ -55,7 +60,7 @@ func FetchPRsFromRepo(ctx context.Context, owner, repo string, since time.Time, // Hit limit - need more coverage for earlier periods // Query 2: Old activity (updated ASC) - get ~500 more - old, _, err := fetchPRsFromRepoWithSort(ctx, owner, repo, since, token, "UPDATED_AT", "ASC", 500) + old, _, err := fetchPRsFromRepoWithSort(ctx, owner, repo, since, token, "UPDATED_AT", "ASC", 500, "old", progress) if err != nil { slog.Warn("Failed to fetch old PRs, falling back to recent only", "error", err) return recent, nil @@ -81,7 +86,7 @@ func FetchPRsFromRepo(ctx context.Context, owner, repo string, since time.Time, slog.Info("Gap > 1 week detected, fetching early period PRs to fill coverage hole") // Query 3: Early period (created ASC) - get ~250 more - early, _, err := fetchPRsFromRepoWithSort(ctx, owner, repo, since, token, "CREATED_AT", "ASC", 250) + early, _, err := fetchPRsFromRepoWithSort(ctx, owner, repo, since, token, "CREATED_AT", "ASC", 250, "early", progress) if err != nil { slog.Warn("Failed to fetch early PRs, proceeding with recent+old", "error", err) return deduplicatePRs(append(recent, old...)), nil @@ -102,7 +107,7 @@ func FetchPRsFromRepo(ctx context.Context, owner, repo string, since time.Time, // Returns PRs and a boolean indicating if the API limit (1000) was hit. func fetchPRsFromRepoWithSort( ctx context.Context, owner, repo string, since time.Time, - token, field, direction string, maxPRs int, + token, field, direction string, maxPRs int, queryName string, progress ProgressCallback, ) ([]PRSummary, bool, error) { query := fmt.Sprintf(` query($owner: String!, $name: String!, $cursor: String) { @@ -254,6 +259,11 @@ func fetchPRsFromRepoWithSort( } } + // Call progress callback after processing each page + if progress != nil { + progress(queryName, pageNum, len(allPRs)) + } + // Check if we need to fetch more pages if !result.Data.Repository.PullRequests.PageInfo.HasNextPage { break @@ -298,14 +308,15 @@ func deduplicatePRs(prs []PRSummary) []PRSummary { // - org: GitHub organization name // - since: Only include PRs updated after this time // - token: GitHub authentication token +// - progress: Optional callback for progress updates (can be nil) // // Returns: // - Slice of PRSummary for all matching PRs (deduplicated) -func FetchPRsFromOrg(ctx context.Context, org string, since time.Time, token string) ([]PRSummary, error) { +func FetchPRsFromOrg(ctx context.Context, org string, since time.Time, token string, progress ProgressCallback) ([]PRSummary, error) { sinceStr := since.Format("2006-01-02") // Query 1: Recent activity (updated desc) - get up to 1000 PRs - recent, hitLimit, err := fetchPRsFromOrgWithSort(ctx, org, sinceStr, token, "updated", "desc", 1000) + recent, hitLimit, err := fetchPRsFromOrgWithSort(ctx, org, sinceStr, token, "updated", "desc", 1000, "recent", progress) if err != nil { return nil, err } @@ -321,7 +332,7 @@ func FetchPRsFromOrg(ctx context.Context, org string, since time.Time, token str // Hit limit - need more coverage for earlier periods // Query 2: Old activity (updated asc) - get ~500 more - old, _, err := fetchPRsFromOrgWithSort(ctx, org, sinceStr, token, "updated", "asc", 500) + old, _, err := fetchPRsFromOrgWithSort(ctx, org, sinceStr, token, "updated", "asc", 500, "old", progress) if err != nil { slog.Warn("Failed to fetch old PRs from org, falling back to recent only", "error", err) return recent, nil @@ -347,7 +358,7 @@ func FetchPRsFromOrg(ctx context.Context, org string, since time.Time, token str slog.Info("Gap > 1 week detected, fetching early period PRs to fill coverage hole (org)") // Query 3: Early period (created asc) - get ~250 more - early, _, err := fetchPRsFromOrgWithSort(ctx, org, sinceStr, token, "created", "asc", 250) + early, _, err := fetchPRsFromOrgWithSort(ctx, org, sinceStr, token, "created", "asc", 250, "early", progress) if err != nil { slog.Warn("Failed to fetch early PRs from org, proceeding with recent+old", "error", err) return deduplicatePRsByOwnerRepoNumber(append(recent, old...)), nil @@ -367,7 +378,7 @@ func FetchPRsFromOrg(ctx context.Context, org string, since time.Time, token str // fetchPRsFromOrgWithSort queries GitHub Search API with configurable sort order. // Returns PRs and a boolean indicating if the API limit (1000) was hit. func fetchPRsFromOrgWithSort( - ctx context.Context, org, sinceStr, token, field, direction string, maxPRs int, + ctx context.Context, org, sinceStr, token, field, direction string, maxPRs int, queryName string, progress ProgressCallback, ) ([]PRSummary, bool, error) { // Build search query with sort // Query format: org:myorg is:pr updated:>2025-07-25 sort:updated-desc @@ -519,6 +530,11 @@ func fetchPRsFromOrgWithSort( } } + // Call progress callback after processing each page + if progress != nil { + progress(queryName, pageNum, len(allPRs)) + } + // Check if we need to fetch more pages if !result.Data.Search.PageInfo.HasNextPage { break @@ -800,7 +816,7 @@ func CountOpenPRsInRepo(ctx context.Context, owner, repo, token string) (int, er if err != nil { return 0, fmt.Errorf("request failed: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() //nolint:errcheck // best effort close if resp.StatusCode != http.StatusOK { return 0, fmt.Errorf("unexpected status code: %d", resp.StatusCode) @@ -881,7 +897,7 @@ func CountOpenPRsInOrg(ctx context.Context, org, token string) (int, error) { if err != nil { return 0, fmt.Errorf("request failed: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() //nolint:errcheck // best effort close if resp.StatusCode != http.StatusOK { return 0, fmt.Errorf("unexpected status code: %d", resp.StatusCode)