diff --git a/cmd/prcost/main.go b/cmd/prcost/main.go index 6f08d93..3a2c7b9 100644 --- a/cmd/prcost/main.go +++ b/cmd/prcost/main.go @@ -428,6 +428,13 @@ func formatWithCommas(amount float64) string { // formatLOC formats lines of code in kilo format with appropriate precision and commas for large values. func formatLOC(kloc float64) string { + loc := kloc * 1000 // Convert to actual lines + + // For values < 1k LOC, just show LOC count without 'k' suffix + if loc < 1000 { + return fmt.Sprintf("%d LOC", int(loc)) + } + // For values >= 100k, add commas (e.g., "1,517k" instead of "1517k") if kloc >= 100.0 { intPart := int(kloc) diff --git a/cmd/prcost/repository.go b/cmd/prcost/repository.go index 59c98aa..b8f076a 100644 --- a/cmd/prcost/repository.go +++ b/cmd/prcost/repository.go @@ -60,7 +60,6 @@ func isBotAuthor(author string) bool { // Uses library functions from pkg/github and pkg/cost for fetching, sampling, // and extrapolation - all functionality is available to external clients. func analyzeRepository(ctx context.Context, owner, repo string, sampleSize, days int, cfg cost.Config, token string, dataSource string) error { - // Calculate since date since := time.Now().AddDate(0, 0, -days) @@ -283,28 +282,13 @@ func analyzeOrganization(ctx context.Context, org string, sampleSize, days int, // Count unique authors across all PRs (not just samples) totalAuthors := github.CountUniqueAuthors(prs) - // Count open PRs across all unique repos in the organization - uniqueRepos := make(map[string]bool) - for _, pr := range prs { - repoKey := pr.Owner + "/" + pr.Repo - uniqueRepos[repoKey] = true - } - - totalOpenPRs := 0 - for repoKey := range uniqueRepos { - parts := strings.SplitN(repoKey, "/", 2) - if len(parts) != 2 { - continue - } - owner, repo := parts[0], parts[1] - openCount, err := github.CountOpenPRsInRepo(ctx, owner, repo, token) - if err != nil { - slog.Warn("Failed to count open PRs for repo", "repo", repoKey, "error", err) - continue - } - totalOpenPRs += openCount + // Count open PRs across the entire organization with a single query + totalOpenPRs, err := github.CountOpenPRsInOrg(ctx, org, token) + if err != nil { + slog.Warn("Failed to count open PRs in organization, using 0", "error", err) + totalOpenPRs = 0 } - slog.Info("Counted total open PRs across organization", "open_prs", totalOpenPRs, "repos", len(uniqueRepos)) + slog.Info("Counted total open PRs across organization", "org", org, "open_prs", totalOpenPRs) // Extrapolate costs from samples using library function extrapolated := cost.ExtrapolateFromSamples(breakdowns, len(prs), totalAuthors, totalOpenPRs, actualDays, cfg) @@ -433,14 +417,18 @@ func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBrea fmt.Printf(" Development Costs (%d PRs, %s total LOC)\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\n", - formatWithCommas(avgAuthorGitHubCost), formatTimeUnit(avgAuthorGitHubHours)) - fmt.Printf(" Context Switching $%10s %s\n", - formatWithCommas(avgAuthorGitHubContextCost), formatTimeUnit(avgAuthorGitHubContextHours)) + 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) // Show bot PR LOC even though cost is $0 if ext.BotPRs > 0 { @@ -457,18 +445,23 @@ func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBrea // Participants section (if any participants) if ext.ParticipantTotalCost > 0 { + avgParticipantEvents := float64(ext.ParticipantEvents) / float64(ext.TotalPRs) + avgParticipantSessions := float64(ext.ParticipantSessions) / float64(ext.TotalPRs) + + avgParticipantReviews := float64(ext.ParticipantReviews) / float64(ext.TotalPRs) + fmt.Println(" Participant Costs") fmt.Println(" ─────────────────") if avgParticipantReviewCost > 0 { - fmt.Printf(" Review Activity $%10s %s\n", - formatWithCommas(avgParticipantReviewCost), formatTimeUnit(avgParticipantReviewHours)) + fmt.Printf(" Review Activity $%10s %s (%.1f reviews)\n", + formatWithCommas(avgParticipantReviewCost), formatTimeUnit(avgParticipantReviewHours), avgParticipantReviews) } if avgParticipantGitHubCost > 0 { - fmt.Printf(" GitHub Activity $%10s %s\n", - formatWithCommas(avgParticipantGitHubCost), formatTimeUnit(avgParticipantGitHubHours)) + fmt.Printf(" GitHub Activity $%10s %s (%.1f events)\n", + formatWithCommas(avgParticipantGitHubCost), formatTimeUnit(avgParticipantGitHubHours), avgParticipantEvents) } - fmt.Printf(" Context Switching $%10s %s\n", - formatWithCommas(avgParticipantContextCost), formatTimeUnit(avgParticipantContextHours)) + fmt.Printf(" Context Switching $%10s %s (%.1f sessions)\n", + formatWithCommas(avgParticipantContextCost), formatTimeUnit(avgParticipantContextHours), avgParticipantSessions) fmt.Println(" ──────────") participantPct := (avgParticipantTotalCost / avgTotalCost) * 100 fmt.Printf(" Subtotal $%10s %s (%.1f%%)\n", @@ -533,8 +526,9 @@ func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBrea formatWithCommas(avgFutureMergeCost), formatTimeUnit(avgFutureMergeHours), ext.FutureMergePRCount) } if ext.FutureContextCost > 0.01 { - fmt.Printf(" Context Switching $%10s %s\n", - formatWithCommas(avgFutureContextCost), formatTimeUnit(avgFutureContextHours)) + avgFutureContextSessions := float64(ext.FutureContextSessions) / float64(ext.TotalPRs) + fmt.Printf(" Context Switching $%10s %s (%.1f sessions)\n", + formatWithCommas(avgFutureContextCost), formatTimeUnit(avgFutureContextHours), avgFutureContextSessions) } avgFutureCost := avgCodeChurnCost + avgFutureReviewCost + avgFutureMergeCost + avgFutureContextCost avgFutureHours := avgCodeChurnHours + avgFutureReviewHours + avgFutureMergeHours + avgFutureContextHours @@ -589,10 +583,10 @@ func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBrea 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\n", - formatWithCommas(ext.AuthorGitHubCost), formatTimeUnit(ext.AuthorGitHubHours)) - fmt.Printf(" Context Switching $%10s %s\n", - formatWithCommas(ext.AuthorGitHubContextCost), formatTimeUnit(ext.AuthorGitHubContextHours)) + 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) // Show bot PR LOC even though cost is $0 if ext.BotPRs > 0 { @@ -612,15 +606,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\n", - formatWithCommas(ext.ParticipantReviewCost), formatTimeUnit(ext.ParticipantReviewHours)) + fmt.Printf(" Review Activity $%10s %s (%d reviews)\n", + formatWithCommas(ext.ParticipantReviewCost), formatTimeUnit(ext.ParticipantReviewHours), ext.ParticipantReviews) } if ext.ParticipantGitHubCost > 0 { - fmt.Printf(" GitHub Activity $%10s %s\n", - formatWithCommas(ext.ParticipantGitHubCost), formatTimeUnit(ext.ParticipantGitHubHours)) + fmt.Printf(" GitHub Activity $%10s %s (%d events)\n", + formatWithCommas(ext.ParticipantGitHubCost), formatTimeUnit(ext.ParticipantGitHubHours), ext.ParticipantEvents) } - fmt.Printf(" Context Switching $%10s %s\n", - formatWithCommas(ext.ParticipantContextCost), formatTimeUnit(ext.ParticipantContextHours)) + fmt.Printf(" Context Switching $%10s %s (%d sessions)\n", + formatWithCommas(ext.ParticipantContextCost), formatTimeUnit(ext.ParticipantContextHours), ext.ParticipantSessions) fmt.Println(" ──────────") pct = (ext.ParticipantTotalCost / ext.TotalCost) * 100 fmt.Printf(" Subtotal $%10s %s (%.1f%%)\n", @@ -681,8 +675,8 @@ func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBrea formatWithCommas(ext.FutureMergeCost), formatTimeUnit(ext.FutureMergeHours), ext.FutureMergePRCount) } if ext.FutureContextCost > 0.01 { - fmt.Printf(" Context Switching $%10s %s\n", - formatWithCommas(ext.FutureContextCost), formatTimeUnit(ext.FutureContextHours)) + fmt.Printf(" Context Switching $%10s %s (%d sessions)\n", + formatWithCommas(ext.FutureContextCost), formatTimeUnit(ext.FutureContextHours), ext.FutureContextSessions) } extFutureCost := ext.CodeChurnCost + ext.FutureReviewCost + ext.FutureMergeCost + ext.FutureContextCost extFutureHours := ext.CodeChurnHours + ext.FutureReviewHours + ext.FutureMergeHours + ext.FutureContextHours diff --git a/internal/server/server.go b/internal/server/server.go index 736fb80..b8095e5 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1412,28 +1412,13 @@ func (s *Server) processOrgSample(ctx context.Context, req *OrgSampleRequest, to // Count unique authors across all PRs (not just samples) totalAuthors := github.CountUniqueAuthors(prs) - // Count open PRs across all unique repos in the organization - uniqueRepos := make(map[string]bool) - for _, pr := range prs { - repoKey := pr.Owner + "/" + pr.Repo - uniqueRepos[repoKey] = true - } - - totalOpenPRs := 0 - for repoKey := range uniqueRepos { - parts := strings.SplitN(repoKey, "/", 2) - if len(parts) != 2 { - continue - } - owner, repo := parts[0], parts[1] - openCount, err := github.CountOpenPRsInRepo(ctx, owner, repo, token) - if err != nil { - s.logger.WarnContext(ctx, "Failed to count open PRs for repo", "repo", repoKey, errorKey, err) - continue - } - totalOpenPRs += openCount + // Count open PRs across the entire organization with a single query + totalOpenPRs, err := github.CountOpenPRsInOrg(ctx, req.Org, token) + if err != nil { + s.logger.WarnContext(ctx, "Failed to count open PRs in organization, using 0", errorKey, err) + totalOpenPRs = 0 } - s.logger.InfoContext(ctx, "Counted total open PRs across organization", "open_prs", totalOpenPRs, "repos", len(uniqueRepos)) + s.logger.InfoContext(ctx, "Counted total open PRs across organization", "org", req.Org, "open_prs", totalOpenPRs) // Extrapolate costs from samples extrapolated := cost.ExtrapolateFromSamples(breakdowns, len(prs), totalAuthors, totalOpenPRs, actualDays, cfg) diff --git a/internal/server/static/index.html b/internal/server/static/index.html index 6d47638..edff0c5 100644 --- a/internal/server/static/index.html +++ b/internal/server/static/index.html @@ -1245,6 +1245,13 @@

Why calculate PR costs?

} function formatLOC(kloc) { + const loc = kloc * 1000; // Convert to actual lines + + // For values < 1k LOC, just show LOC count without 'k' suffix + if (loc < 1000) { + return Math.floor(loc) + ' LOC'; + } + // For values >= 100k, add commas (e.g., "1,517k" instead of "1517k") if (kloc >= 100.0) { const intPart = Math.floor(kloc); @@ -1404,7 +1411,7 @@

Why calculate PR costs?

html += `
${annualWasteFormatted}
`; const annualCostPerHead = salary * benefitsMultiplier; const headcount = annualWasteCost / annualCostPerHead; - html += `
${headcount.toFixed(1)} headcount • ${formatTimeUnit(annualWasteHours)}
`; + html += `
${headcount.toFixed(1)} headcount
`; html += ''; // Close efficiency-box } @@ -1601,23 +1608,37 @@

Why calculate PR costs?

let output = ''; + // Calculate LOC averages + const avgNewLines = e.total_new_lines / totalPRs; + const avgModifiedLines = e.total_modified_lines / totalPRs; + const avgTotalLOC = (avgNewLines + avgModifiedLines) / 1000; + const newLOC = avgNewLines / 1000; + const modifiedLOC = avgModifiedLines / 1000; + // Development Costs - output += ' Development Costs\n'; - output += ' ─────────────────\n'; - output += ` New Development ${formatCurrency(avgAuthorNewCodeCost).padStart(10)} ${formatTimeUnit(avgAuthorNewCodeHours)}\n`; - output += ` Adaptation ${formatCurrency(avgAuthorAdaptationCost).padStart(10)} ${formatTimeUnit(avgAuthorAdaptationHours)}\n`; - output += ` GitHub Activity ${formatCurrency(avgAuthorGitHubCost).padStart(10)} ${formatTimeUnit(avgAuthorGitHubHours)}\n`; - output += ` Context Switching ${formatCurrency(avgAuthorGitHubContextCost).padStart(10)} ${formatTimeUnit(avgAuthorGitHubContextHours)}\n`; + const avgAuthorEvents = e.author_events / totalPRs; + 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 += ' ──────────\n'; let pct = (avgAuthorTotalCost / avgTotalCost) * 100; output += ` Subtotal ${formatCurrency(avgAuthorTotalCost).padStart(10)} ${formatTimeUnit(avgAuthorTotalHours)} (${pct.toFixed(1)}%)\n\n`; // Participants if (e.participant_total_cost > 0) { - output += ' Participant Costs\n'; + const avgParticipantEvents = e.participant_events / totalPRs; + const avgParticipantSessions = e.participant_sessions / totalPRs; + const avgParticipantReviews = e.participant_reviews / totalPRs; + + output += ` Participant Costs\n`; output += ' ─────────────────\n'; - output += ` Review Activity ${formatCurrency(avgParticipantReviewCost).padStart(10)} ${formatTimeUnit(avgParticipantReviewHours)}\n`; - output += ` GitHub Activity ${formatCurrency(avgParticipantGitHubCost).padStart(10)} ${formatTimeUnit(avgParticipantGitHubHours)}\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 += ' ──────────\n'; pct = (avgParticipantTotalCost / avgTotalCost) * 100; output += ` Subtotal ${formatCurrency(avgParticipantTotalCost).padStart(10)} ${formatTimeUnit(avgParticipantTotalHours)} (${pct.toFixed(1)}%)\n\n`; @@ -1649,16 +1670,19 @@

Why calculate PR costs?

output += ' Future Costs\n'; output += ' ────────────\n'; if (e.code_churn_cost > 0.01) { - output += ` Code Churn ${formatCurrency(avgCodeChurnCost).padStart(10)} ${formatTimeUnit(avgCodeChurnHours)}\n`; + 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`; } if (e.future_review_cost > 0.01) { - output += ` Review ${formatCurrency(avgFutureReviewCost).padStart(10)} ${formatTimeUnit(avgFutureReviewHours)}\n`; + output += ` Review ${formatCurrency(avgFutureReviewCost).padStart(10)} ${formatTimeUnit(avgFutureReviewHours)} (${e.future_review_pr_count} PRs)\n`; } if (e.future_merge_cost > 0.01) { - output += ` Merge ${formatCurrency(avgFutureMergeCost).padStart(10)} ${formatTimeUnit(avgFutureMergeHours)}\n`; + output += ` Merge ${formatCurrency(avgFutureMergeCost).padStart(10)} ${formatTimeUnit(avgFutureMergeHours)} (${e.future_merge_pr_count} PRs)\n`; } if (e.future_context_cost > 0.01) { - output += ` Context Switching ${formatCurrency(avgFutureContextCost).padStart(10)} ${formatTimeUnit(avgFutureContextHours)}\n`; + const avgFutureContextSessions = e.future_context_sessions / totalPRs; + output += ` Context Switching ${formatCurrency(avgFutureContextCost).padStart(10)} ${formatTimeUnit(avgFutureContextHours)} (${avgFutureContextSessions.toFixed(1)} sessions)\n`; } const avgFutureCost = avgCodeChurnCost + avgFutureReviewCost + avgFutureMergeCost + avgFutureContextCost; const avgFutureHours = avgCodeChurnHours + avgFutureReviewHours + avgFutureMergeHours + avgFutureContextHours; @@ -1684,22 +1708,22 @@

Why calculate PR costs?

let output = ''; // Calculate LOC for header and lines - const totalLOC = ((e.new_lines || 0) + (e.modified_lines || 0) + (e.bot_new_lines || 0) + (e.bot_modified_lines || 0)) / 1000; - const newLOC = (e.new_lines || 0) / 1000; - const modifiedLOC = (e.modified_lines || 0) / 1000; + const totalLOC = ((e.total_new_lines || 0) + (e.total_modified_lines || 0) + (e.bot_new_lines || 0) + (e.bot_modified_lines || 0)) / 1000; + const newLOC = (e.total_new_lines || 0) / 1000; + const modifiedLOC = (e.total_modified_lines || 0) / 1000; const botLOC = ((e.bot_new_lines || 0) + (e.bot_modified_lines || 0)) / 1000; // Development Costs - output += ` Development Costs (${e.human_prs || e.total_prs || 0} PRs, ${totalLOC.toFixed(1)}k total LOC)\n`; - output += ' ────────────────────────────────────────\n'; - output += ` New Development ${formatCurrency(e.author_new_code_cost).padStart(10)} ${formatTimeUnit(e.author_new_code_hours)} (${newLOC.toFixed(1)}k LOC)\n`; - output += ` Adaptation ${formatCurrency(e.author_adaptation_cost).padStart(10)} ${formatTimeUnit(e.author_adaptation_hours)} (${modifiedLOC.toFixed(1)}k LOC)\n`; - output += ` GitHub Activity ${formatCurrency(e.author_github_cost).padStart(10)} ${formatTimeUnit(e.author_github_hours)}\n`; - output += ` Context Switching ${formatCurrency(e.author_github_context_cost).padStart(10)} ${formatTimeUnit(e.author_github_context_hours)}\n`; + 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`; // Show bot PR LOC even though cost is $0 if ((e.bot_prs || 0) > 0) { - output += ` Automated Updates — ${formatTimeUnit(0)} (${e.bot_prs} PRs, ${botLOC.toFixed(1)}k LOC)\n`; + output += ` Automated Updates — ${formatTimeUnit(0)} (${e.bot_prs} PRs, ${formatLOC(botLOC)})\n`; } output += ' ──────────\n'; @@ -1708,15 +1732,15 @@

Why calculate PR costs?

// Participants if (e.participant_total_cost > 0) { - output += ' Participant Costs\n'; + 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)}\n`; + output += ` Review Activity ${formatCurrency(e.participant_review_cost).padStart(10)} ${formatTimeUnit(e.participant_review_hours)} (${e.participant_reviews || 0} reviews)\n`; } if ((e.participant_github_cost || 0) > 0) { - output += ` GitHub Activity ${formatCurrency(e.participant_github_cost).padStart(10)} ${formatTimeUnit(e.participant_github_hours)}\n`; + output += ` GitHub Activity ${formatCurrency(e.participant_github_cost).padStart(10)} ${formatTimeUnit(e.participant_github_hours)} (${e.participant_events || 0} events)\n`; } - output += ` Context Switching ${formatCurrency(e.participant_context_cost).padStart(10)} ${formatTimeUnit(e.participant_context_hours)}\n`; + output += ` Context Switching ${formatCurrency(e.participant_context_cost).padStart(10)} ${formatTimeUnit(e.participant_context_hours)} (${e.participant_sessions || 0} sessions)\n`; 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`; @@ -1764,7 +1788,7 @@

Why calculate PR costs?

output += ` Merge ${formatCurrency(e.future_merge_cost).padStart(10)} ${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)}\n`; + output += ` Context Switching ${formatCurrency(e.future_context_cost).padStart(10)} ${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); diff --git a/pkg/cost/cost.go b/pkg/cost/cost.go index 4e6c56a..ef733bc 100644 --- a/pkg/cost/cost.go +++ b/pkg/cost/cost.go @@ -93,22 +93,22 @@ type Config struct { // DefaultConfig returns reasonable defaults for cost calculation. func DefaultConfig() Config { return Config{ - AnnualSalary: 249000.0, // Average Staff Software Engineer salary (2025, Glassdoor) - BenefitsMultiplier: 1.3, // 30% benefits overhead - HoursPerYear: 2080.0, // Standard full-time hours - EventDuration: 10 * time.Minute, // 10 minutes per GitHub event - 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 - AutomatedUpdatesFactor: 0.01, // 1% overhead for bot PRs - PRTrackingMinutesPerDay: 1.0, // 1 minute per day for PRs open >24 hours - 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 - ReviewInspectionRate: 275.0, // 275 LOC/hour (average of optimal 150-400 range) - ModificationCostFactor: 0.4, // Modified code costs 40% of new code - COCOMO: cocomo.DefaultConfig(), + AnnualSalary: 249000.0, // Average Staff Software Engineer salary (2025, Glassdoor) + BenefitsMultiplier: 1.3, // 30% benefits overhead + HoursPerYear: 2080.0, // Standard full-time hours + EventDuration: 10 * time.Minute, // 10 minutes per GitHub event + 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 + AutomatedUpdatesFactor: 0.01, // 1% overhead for bot PRs + PRTrackingMinutesPerDay: 1.0, // 1 minute per day for PRs open >24 hours + 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 + ReviewInspectionRate: 275.0, // 275 LOC/hour (average of optimal 150-400 range) + ModificationCostFactor: 0.4, // Modified code costs 40% of new code + COCOMO: cocomo.DefaultConfig(), } } diff --git a/pkg/cost/cost_test.go b/pkg/cost/cost_test.go index c629876..7be45fb 100644 --- a/pkg/cost/cost_test.go +++ b/pkg/cost/cost_test.go @@ -31,10 +31,6 @@ func TestDefaultConfig(t *testing.T) { t.Errorf("Expected delivery delay factor 0.15, got %.2f", cfg.DeliveryDelayFactor) } - if cfg.CoordinationFactor != 0.05 { - t.Errorf("Expected coordination factor 0.05, got %.2f", cfg.CoordinationFactor) - } - if cfg.MaxDelayAfterLastEvent != 14*24*time.Hour { t.Errorf("Expected 14 days max delay after last event, got %v", cfg.MaxDelayAfterLastEvent) } @@ -354,11 +350,6 @@ func TestCalculateDelayComponents(t *testing.T) { t.Error("Delivery delay cost should be positive for 7-day old PR") } - // Should have coordination cost - if breakdown.DelayCostDetail.CoordinationCost <= 0 { - t.Error("Coordination cost should be positive for 7-day old PR") - } - // Should have code churn cost (7 days = ~7% drift) if breakdown.DelayCostDetail.CodeChurnCost <= 0 { t.Error("Code churn cost should be positive for 7-day old PR") @@ -383,7 +374,6 @@ func TestCalculateDelayComponents(t *testing.T) { // Total delay should equal sum of components expectedDelay := breakdown.DelayCostDetail.DeliveryDelayCost + - breakdown.DelayCostDetail.CoordinationCost + breakdown.DelayCostDetail.CodeChurnCost + breakdown.DelayCostDetail.AutomatedUpdatesCost + breakdown.DelayCostDetail.PRTrackingCost + @@ -418,15 +408,11 @@ func TestCalculateShortPRNoRework(t *testing.T) { t.Error("Rework percentage should be zero for 2-day old PR") } - // Should still have delivery delay and coordination costs + // Should still have delivery delay cost if breakdown.DelayCostDetail.DeliveryDelayCost <= 0 { t.Error("Delivery delay cost should be positive even for short PR") } - if breakdown.DelayCostDetail.CoordinationCost <= 0 { - t.Error("Coordination cost should be positive even for short PR") - } - futureTotalCost := breakdown.DelayCostDetail.FutureReviewCost + breakdown.DelayCostDetail.FutureMergeCost + breakdown.DelayCostDetail.FutureContextCost @@ -511,20 +497,13 @@ func TestCalculateWithRealPR13(t *testing.T) { // 90 days absolute cap = 2160 hours // Delivery: 2160 * 0.15 = 324 hours - // Coordination: 2160 * 0.05 = 108 hours - expectedDeliveryHours := 90.0 * 24.0 * 0.15 // 324 hours - expectedCoordinationHours := 90.0 * 24.0 * 0.05 // 108 hours + expectedDeliveryHours := 90.0 * 24.0 * 0.15 // 324 hours if breakdown.DelayCostDetail.DeliveryDelayHours != expectedDeliveryHours { t.Errorf("Expected %.0f delivery delay hours (15%% of 90 day cap), got %.2f", expectedDeliveryHours, breakdown.DelayCostDetail.DeliveryDelayHours) } - if breakdown.DelayCostDetail.CoordinationHours != expectedCoordinationHours { - t.Errorf("Expected %.0f coordination hours (5%% of 90 day cap), got %.2f", - expectedCoordinationHours, breakdown.DelayCostDetail.CoordinationHours) - } - // Code drift should be capped at 90 days (not unlimited) // At 90 days, drift is ~35%, so we should never see >100% rework if breakdown.DelayCostDetail.ReworkPercentage > 100.0 { @@ -543,8 +522,6 @@ func TestCalculateWithRealPR13(t *testing.T) { t.Logf(" Author cost: $%.2f", breakdown.Author.TotalCost) t.Logf(" Delivery Delay (15%%): $%.2f (%.0f hrs, capped at 90 days)", breakdown.DelayCostDetail.DeliveryDelayCost, breakdown.DelayCostDetail.DeliveryDelayHours) - t.Logf(" Coordination (5%%): $%.2f (%.0f hrs, capped at 90 days)", - breakdown.DelayCostDetail.CoordinationCost, breakdown.DelayCostDetail.CoordinationHours) t.Logf(" Code Churn: $%.2f (%.1f%% rework, capped at 90 days drift)", breakdown.DelayCostDetail.CodeChurnCost, breakdown.DelayCostDetail.ReworkPercentage) futureTotalCost := breakdown.DelayCostDetail.FutureReviewCost + @@ -579,30 +556,23 @@ func TestCalculateLongPRCapped(t *testing.T) { // Last event was 120 days ago, so we only count 14 days after it // Capped hours: 14 days = 336 hours // Delivery delay: 336 * 0.15 = 50.4 hours - // Coordination: 336 * 0.05 = 16.8 hours expectedDeliveryHours := 14.0 * 24.0 * 0.15 - expectedCoordinationHours := 14.0 * 24.0 * 0.05 if breakdown.DelayCostDetail.DeliveryDelayHours != expectedDeliveryHours { t.Errorf("Expected %.1f delivery delay hours (15%% of 14 days), got %.2f", expectedDeliveryHours, breakdown.DelayCostDetail.DeliveryDelayHours) } - - if breakdown.DelayCostDetail.CoordinationHours != expectedCoordinationHours { - t.Errorf("Expected %.1f coordination hours (5%% of 14 days), got %.2f", - expectedCoordinationHours, breakdown.DelayCostDetail.CoordinationHours) - } } func TestDelayHoursNeverExceedPRAge(t *testing.T) { // Test that delay hours (as productivity-equivalent time) are reasonable - // Delivery (15%) + Coordination (5%) should equal 20% of PR age + // Delivery (15%) should be proportional to PR age now := time.Now() testCases := []struct { name string ageHrs float64 prData PRData - wantMax float64 // Maximum acceptable delay hours (delivery + coordination) + wantMax float64 // Maximum acceptable delay hours (delivery) }{ { name: "1 day old PR", @@ -615,7 +585,7 @@ func TestDelayHoursNeverExceedPRAge(t *testing.T) { }, CreatedAt: now.Add(-24 * time.Hour), }, - wantMax: 24.0 * 0.20, // 20% of 24 hours = 4.8 hours + wantMax: 24.0 * 0.15, // 15% of 24 hours = 3.6 hours }, { name: "7 day old PR", @@ -628,7 +598,7 @@ func TestDelayHoursNeverExceedPRAge(t *testing.T) { }, CreatedAt: now.Add(-7 * 24 * time.Hour), }, - wantMax: 168.0 * 0.20, // 20% of 168 hours = 33.6 hours + wantMax: 168.0 * 0.15, // 15% of 168 hours = 25.2 hours }, } @@ -638,28 +608,19 @@ func TestDelayHoursNeverExceedPRAge(t *testing.T) { t.Run(tc.name, func(t *testing.T) { breakdown := Calculate(tc.prData, cfg) - totalDelayHours := breakdown.DelayCostDetail.DeliveryDelayHours + - breakdown.DelayCostDetail.CoordinationHours - - // Total delay hours should not exceed 20% of PR age - if totalDelayHours > tc.wantMax+0.1 { // Allow 0.1 hr tolerance for floating point - t.Errorf("Delay hours exceed 20%% of PR age: got %.2f hours, want <= %.2f hours (PR age: %.1f hrs)", - totalDelayHours, tc.wantMax, tc.ageHrs) + // Delivery delay hours should not exceed 15% 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)", + breakdown.DelayCostDetail.DeliveryDelayHours, tc.wantMax, tc.ageHrs) } - // Verify the split: delivery should be 15%, coordination should be 5% + // Verify delivery should be 15% expectedDelivery := tc.ageHrs * 0.15 - expectedCoordination := tc.ageHrs * 0.05 if breakdown.DelayCostDetail.DeliveryDelayHours > expectedDelivery+0.1 { t.Errorf("Delivery delay hours too high: got %.2f, want %.2f (15%% of %.1f)", breakdown.DelayCostDetail.DeliveryDelayHours, expectedDelivery, tc.ageHrs) } - - if breakdown.DelayCostDetail.CoordinationHours > expectedCoordination+0.1 { - t.Errorf("Coordination hours too high: got %.2f, want %.2f (5%% of %.1f)", - breakdown.DelayCostDetail.CoordinationHours, expectedCoordination, tc.ageHrs) - } }) } } @@ -727,10 +688,6 @@ func TestCalculateFastTurnaroundNoDelay(t *testing.T) { t.Errorf("Expected 0 delivery delay cost for %v minute PR, got $%.2f", tc.openMinutes, breakdown.DelayCostDetail.DeliveryDelayCost) } - if breakdown.DelayCostDetail.CoordinationCost != 0 { - t.Errorf("Expected 0 coordination cost for %v minute PR, got $%.2f", - tc.openMinutes, breakdown.DelayCostDetail.CoordinationCost) - } } else if breakdown.DelayCost == 0 { // For PRs >= 30 minutes, delay cost should be > 0 t.Errorf("Expected non-zero delay cost for %v minute PR, got $0", diff --git a/pkg/cost/extrapolate.go b/pkg/cost/extrapolate.go index 0ddc1e9..4d4f70a 100644 --- a/pkg/cost/extrapolate.go +++ b/pkg/cost/extrapolate.go @@ -35,6 +35,10 @@ type ExtrapolatedBreakdown struct { AuthorGitHubContextHours float64 `json:"author_github_context_hours"` AuthorTotalHours float64 `json:"author_total_hours"` + // Author activity metrics (extrapolated) + AuthorEvents int `json:"author_events"` // Total GitHub events by authors + AuthorSessions int `json:"author_sessions"` // Total GitHub work sessions by authors + // LOC metrics (extrapolated totals) TotalNewLines int `json:"total_new_lines"` // Total net new lines across all PRs TotalModifiedLines int `json:"total_modified_lines"` // Total modified lines across all PRs @@ -54,6 +58,11 @@ type ExtrapolatedBreakdown struct { ParticipantContextHours float64 `json:"participant_context_hours"` ParticipantTotalHours float64 `json:"participant_total_hours"` + // Participant activity metrics (extrapolated) + ParticipantEvents int `json:"participant_events"` // Total GitHub events by participants + ParticipantSessions int `json:"participant_sessions"` // Total GitHub work sessions by participants + ParticipantReviews int `json:"participant_reviews"` // Total number of reviews performed + // Delay costs (extrapolated) DeliveryDelayCost float64 `json:"delivery_delay_cost"` CodeChurnCost float64 `json:"code_churn_cost"` @@ -75,9 +84,11 @@ type ExtrapolatedBreakdown struct { DelayTotalHours float64 `json:"delay_total_hours"` // Counts for future costs (extrapolated) - CodeChurnPRCount int `json:"code_churn_pr_count"` // Number of PRs with code churn - FutureReviewPRCount int `json:"future_review_pr_count"` // Number of PRs with future review costs - FutureMergePRCount int `json:"future_merge_pr_count"` // Number of PRs with future merge costs + CodeChurnPRCount int `json:"code_churn_pr_count"` // Number of PRs with code churn + FutureReviewPRCount int `json:"future_review_pr_count"` // Number of PRs with future review costs + FutureMergePRCount int `json:"future_merge_pr_count"` // Number of PRs with future merge costs + FutureContextSessions int `json:"future_context_sessions"` // Estimated future context switching sessions + AvgReworkPercentage float64 `json:"avg_rework_percentage"` // Average code drift/rework percentage // Grand totals TotalCost float64 `json:"total_cost"` @@ -138,6 +149,10 @@ func ExtrapolateFromSamples(breakdowns []Breakdown, totalPRs, totalAuthors, actu var sumPRDuration float64 var sumNewLines, sumModifiedLines int var sumBotNewLines, sumBotModifiedLines int + var sumAuthorEvents, sumAuthorSessions int + var sumParticipantEvents, sumParticipantSessions, sumParticipantReviews int + var sumFutureContextSessions int + var sumReworkPercentage float64 var countCodeChurn, countFutureReview, countFutureMerge int for i := range breakdowns { @@ -180,6 +195,8 @@ func ExtrapolateFromSamples(breakdowns []Breakdown, totalPRs, totalAuthors, actu sumAuthorGitHubHours += breakdown.Author.GitHubHours sumAuthorGitHubContextHours += breakdown.Author.GitHubContextHours sumAuthorHours += breakdown.Author.TotalHours + sumAuthorEvents += breakdown.Author.Events + sumAuthorSessions += breakdown.Author.Sessions // Accumulate participant costs (combined across all participants) for _, p := range breakdown.Participants { @@ -191,6 +208,11 @@ func ExtrapolateFromSamples(breakdowns []Breakdown, totalPRs, totalAuthors, actu sumParticipantGitHubHours += p.GitHubHours sumParticipantContextHours += p.GitHubContextHours sumParticipantHours += p.TotalHours + sumParticipantEvents += p.Events + sumParticipantSessions += p.Sessions + if p.ReviewCost > 0 { + sumParticipantReviews++ // Count reviewers (participants who performed reviews) + } } // Accumulate delay costs @@ -202,9 +224,10 @@ func ExtrapolateFromSamples(breakdowns []Breakdown, totalPRs, totalAuthors, actu sumFutureMergeCost += breakdown.DelayCostDetail.FutureMergeCost sumFutureContextCost += breakdown.DelayCostDetail.FutureContextCost - // Count PRs with each future cost type + // Count PRs with each future cost type and accumulate rework percentage if breakdown.DelayCostDetail.CodeChurnCost > 0.01 { countCodeChurn++ + sumReworkPercentage += breakdown.DelayCostDetail.ReworkPercentage } if breakdown.DelayCostDetail.FutureReviewCost > 0.01 { countFutureReview++ @@ -212,6 +235,10 @@ func ExtrapolateFromSamples(breakdowns []Breakdown, totalPRs, totalAuthors, actu if breakdown.DelayCostDetail.FutureMergeCost > 0.01 { countFutureMerge++ } + if breakdown.DelayCostDetail.FutureContextCost > 0.01 { + // Future context cost assumes 3 sessions per open PR (review request, review, merge) + sumFutureContextSessions += 3 + } sumDeliveryDelayHours += breakdown.DelayCostDetail.DeliveryDelayHours sumCodeChurnHours += breakdown.DelayCostDetail.CodeChurnHours sumAutomatedUpdatesHours += breakdown.DelayCostDetail.AutomatedUpdatesHours @@ -244,6 +271,8 @@ func ExtrapolateFromSamples(breakdowns []Breakdown, totalPRs, totalAuthors, actu extAuthorGitHubContextHours := sumAuthorGitHubContextHours / samples * multiplier extAuthorTotal := extAuthorNewCodeCost + extAuthorAdaptationCost + extAuthorGitHubCost + extAuthorGitHubContextCost extAuthorHours := sumAuthorHours / samples * multiplier + extAuthorEvents := int(float64(sumAuthorEvents) / samples * multiplier) + extAuthorSessions := int(float64(sumAuthorSessions) / samples * multiplier) extParticipantReviewCost := sumParticipantReviewCost / samples * multiplier extParticipantGitHubCost := sumParticipantGitHubCost / samples * multiplier @@ -253,6 +282,9 @@ func ExtrapolateFromSamples(breakdowns []Breakdown, totalPRs, totalAuthors, actu extParticipantGitHubHours := sumParticipantGitHubHours / samples * multiplier extParticipantContextHours := sumParticipantContextHours / samples * multiplier extParticipantHours := sumParticipantHours / samples * multiplier + extParticipantEvents := int(float64(sumParticipantEvents) / samples * multiplier) + extParticipantSessions := int(float64(sumParticipantSessions) / samples * multiplier) + extParticipantReviews := int(float64(sumParticipantReviews) / samples * multiplier) extDeliveryDelayCost := sumDeliveryDelayCost / samples * multiplier extCodeChurnCost := sumCodeChurnCost / samples * multiplier @@ -278,9 +310,16 @@ func ExtrapolateFromSamples(breakdowns []Breakdown, totalPRs, totalAuthors, actu extCodeChurnPRCount := int(float64(countCodeChurn) / samples * multiplier) extFutureReviewPRCount := int(float64(countFutureReview) / samples * multiplier) extFutureMergePRCount := int(float64(countFutureMerge) / samples * multiplier) + extFutureContextSessions := int(float64(sumFutureContextSessions) / samples * multiplier) // Use actual open PR count from repository query, not extrapolated from sample extOpenPRs := actualOpenPRs + // Calculate average rework percentage (only for PRs with code churn) + var avgReworkPercentage float64 + if countCodeChurn > 0 { + avgReworkPercentage = sumReworkPercentage / float64(countCodeChurn) + } + extTotalCost := sumTotalCost / samples * multiplier extTotalHours := extAuthorHours + extParticipantHours + extDelayHours @@ -419,6 +458,9 @@ func ExtrapolateFromSamples(breakdowns []Breakdown, totalPRs, totalAuthors, actu AuthorGitHubContextHours: extAuthorGitHubContextHours, AuthorTotalHours: extAuthorHours, + AuthorEvents: extAuthorEvents, + AuthorSessions: extAuthorSessions, + TotalNewLines: extTotalNewLines, TotalModifiedLines: extTotalModifiedLines, BotNewLines: extBotNewLines, @@ -435,10 +477,14 @@ func ExtrapolateFromSamples(breakdowns []Breakdown, totalPRs, totalAuthors, actu ParticipantContextHours: extParticipantContextHours, ParticipantTotalHours: extParticipantHours, + ParticipantEvents: extParticipantEvents, + ParticipantSessions: extParticipantSessions, + ParticipantReviews: extParticipantReviews, + DeliveryDelayCost: extDeliveryDelayCost, CodeChurnCost: extCodeChurnCost, AutomatedUpdatesCost: extAutomatedUpdatesCost, - PRTrackingCost: extPRTrackingCost, + PRTrackingCost: extPRTrackingCost, FutureReviewCost: extFutureReviewCost, FutureMergeCost: extFutureMergeCost, FutureContextCost: extFutureContextCost, @@ -447,15 +493,17 @@ func ExtrapolateFromSamples(breakdowns []Breakdown, totalPRs, totalAuthors, actu DeliveryDelayHours: extDeliveryDelayHours, CodeChurnHours: extCodeChurnHours, AutomatedUpdatesHours: extAutomatedUpdatesHours, - PRTrackingHours: extPRTrackingHours, + PRTrackingHours: extPRTrackingHours, FutureReviewHours: extFutureReviewHours, FutureMergeHours: extFutureMergeHours, FutureContextHours: extFutureContextHours, DelayTotalHours: extDelayHours, - CodeChurnPRCount: extCodeChurnPRCount, - FutureReviewPRCount: extFutureReviewPRCount, - FutureMergePRCount: extFutureMergePRCount, + CodeChurnPRCount: extCodeChurnPRCount, + FutureReviewPRCount: extFutureReviewPRCount, + FutureMergePRCount: extFutureMergePRCount, + FutureContextSessions: extFutureContextSessions, + AvgReworkPercentage: avgReworkPercentage, TotalCost: extTotalCost, TotalHours: extTotalHours, diff --git a/pkg/github/query.go b/pkg/github/query.go index 43cbc8c..24eb366 100644 --- a/pkg/github/query.go +++ b/pkg/github/query.go @@ -48,7 +48,6 @@ func FetchPRsFromRepo(ctx context.Context, owner, repo string, since time.Time, return nil, err } - // If we didn't hit the limit, we got all PRs within the period - done! if !hitLimit { return recent, nil