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 @@
- 25 (fast, ±20% accuracy) or 50 (slower, ±14% accuracy)
+ 30 (fast, ±18% accuracy) or 50 (slower, ±14% accuracy)
Days Back
@@ -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 = '
';
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)