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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,39 @@ Calibrated on Windows Vista development data showing 4% weekly code churn. A PR

**Reference**: Nagappan, N., et al. (2008). Organizational Structure and Software Quality. *ICSE '08*.

### 6. PR Tracking Overhead: Empirical Organizational Studies

Models the cost of managing and triaging open PR backlogs. This captures planning and coordination overhead, **excluding actual code review time** (counted separately in future review costs). Based on research showing developers spend significant time on PR discovery, triage, and project management activities beyond reviewing code.

**Formula**:
```
tracking_hours_per_day = openPRs × log₂(activeContributors + 1) × 0.005
```

**Components**:
- **Linear with PR count**: More open PRs require more organizational scanning/triage overhead
- **Logarithmic with team size**: Larger teams develop specialization, tooling, and distributed ownership that reduce per-capita burden
- **Constant (0.005)**: Calibrated to ~20 seconds per PR per week of planning/coordination time, excluding actual review

**Validation Examples**:
- 20 PRs, 5 contributors: ~15 min/day total (3 min/person/day)
- 200 PRs, 50 contributors: ~6 hours/day total (7 min/person/day)
- 1000 PRs, 100 contributors: ~33 hours/day total (20 min/person/day)

**Activities Captured** (non-review overhead only):
- Sprint/milestone planning discussions about open PRs
- Daily standup mentions and status coordination
- Searching for duplicate work before starting new PRs
- Identifying related PRs that may conflict or depend on each other
- Quarterly/monthly mass triage of stale PRs
- Project/product management tracking of feature delivery
- Estimating and re-prioritizing work based on open PR backlog

**References**:
- Bacchelli, A., & Bird, C. (2013). Expectations, Outcomes, and Challenges of Modern Code Review. *ICSE '13*.
- Rigby, P. C., & Bird, C. (2013). Convergent Contemporary Software Peer Review Practices. *FSE '13*.
- Uwano, H., et al. (2006). Analyzing Individual Performance of Source Code Review Using Reviewers' Eye Movement. *ETRA '06*.

## Model Limitations

**Individual Estimates**: High variance (CV > 1.0) due to developer and task heterogeneity.
Expand Down
25 changes: 21 additions & 4 deletions cmd/prcost/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,23 @@ func mergeVelocityGrade(avgOpenDays float64) (grade, message string) {
}
}

// mergeRateGrade returns a grade based on merge success rate percentage.
// A: >90%, B: >80%, C: >70%, D: >60%, F: ≤60%.
func mergeRateGrade(mergeRatePct float64) (grade, message string) {
switch {
case mergeRatePct > 90:
return "A", "Excellent"
case mergeRatePct > 80:
return "B", "Good"
case mergeRatePct > 70:
return "C", "Acceptable"
case mergeRatePct > 60:
return "D", "Low"
default:
return "F", "Poor"
}
}

// printMergeTimeModelingCallout prints a callout showing potential savings from reduced merge time.
func printMergeTimeModelingCallout(breakdown *cost.Breakdown, cfg cost.Config) {
targetHours := cfg.TargetMergeTimeHours
Expand Down Expand Up @@ -595,12 +612,12 @@ func printMergeTimeModelingCallout(breakdown *cost.Breakdown, cfg cost.Config) {
fmt.Println(" ┌─────────────────────────────────────────────────────────────┐")
fmt.Printf(" │ %-60s│\n", "MERGE TIME MODELING")
fmt.Println(" └─────────────────────────────────────────────────────────────┘")
fmt.Printf(" If you lowered your average merge time to %s, you would save\n", formatTimeUnit(targetHours))
fmt.Printf(" ~$%s/yr in engineering overhead", formatWithCommas(annualSavings))
if efficiencyDelta > 0 {
fmt.Printf(" (+%.1f%% throughput).\n", efficiencyDelta)
fmt.Printf(" Reduce merge time to %s to boost team throughput by %.1f%%\n", formatTimeUnit(targetHours), efficiencyDelta)
fmt.Printf(" and save ~$%s/yr in engineering overhead.\n", formatWithCommas(annualSavings))
} else {
fmt.Println(".")
fmt.Printf(" If you lowered your average merge time to %s, you would save\n", formatTimeUnit(targetHours))
fmt.Printf(" ~$%s/yr in engineering overhead.\n", formatWithCommas(annualSavings))
}
fmt.Println()
}
Expand Down
42 changes: 36 additions & 6 deletions cmd/prcost/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,17 @@ func analyzeRepository(ctx context.Context, owner, repo string, sampleSize, days
openPRCount = 0
}

// Convert PRSummary to PRMergeStatus for merge rate calculation
prStatuses := make([]cost.PRMergeStatus, len(prs))
for i, pr := range prs {
prStatuses[i] = cost.PRMergeStatus{
Merged: pr.Merged,
State: pr.State,
}
}

// Extrapolate costs from samples using library function
extrapolated := cost.ExtrapolateFromSamples(breakdowns, len(prs), totalAuthors, openPRCount, actualDays, cfg)
extrapolated := cost.ExtrapolateFromSamples(breakdowns, len(prs), totalAuthors, openPRCount, actualDays, cfg, prStatuses)

// Display results in itemized format
printExtrapolatedResults(fmt.Sprintf("%s/%s", owner, repo), actualDays, &extrapolated, cfg)
Expand Down Expand Up @@ -199,8 +208,17 @@ func analyzeOrganization(ctx context.Context, org string, sampleSize, days int,
}
slog.Info("Counted total open PRs across organization", "org", org, "open_prs", totalOpenPRs)

// Convert PRSummary to PRMergeStatus for merge rate calculation
prStatuses := make([]cost.PRMergeStatus, len(prs))
for i, pr := range prs {
prStatuses[i] = cost.PRMergeStatus{
Merged: pr.Merged,
State: pr.State,
}
}

// Extrapolate costs from samples using library function
extrapolated := cost.ExtrapolateFromSamples(breakdowns, len(prs), totalAuthors, totalOpenPRs, actualDays, cfg)
extrapolated := cost.ExtrapolateFromSamples(breakdowns, len(prs), totalAuthors, totalOpenPRs, actualDays, cfg, prStatuses)

// Display results in itemized format
printExtrapolatedResults(fmt.Sprintf("%s (organization)", org), actualDays, &extrapolated, cfg)
Expand Down Expand Up @@ -656,6 +674,18 @@ func printExtrapolatedEfficiency(ext *cost.ExtrapolatedBreakdown, days int, cfg
fmt.Printf(" │ %-60s│\n", velocityHeader)
fmt.Println(" └─────────────────────────────────────────────────────────────┘")

// Merge Rate box (if data available)
if ext.MergedPRs+ext.UnmergedPRs > 0 {
mergeRateGradeStr, mergeRateMessage := mergeRateGrade(ext.MergeRate)
fmt.Println(" ┌─────────────────────────────────────────────────────────────┐")
mergeRateHeader := fmt.Sprintf("MERGE RATE: %s (%.1f%%) - %s", mergeRateGradeStr, ext.MergeRate, mergeRateMessage)
if len(mergeRateHeader) > innerWidth {
mergeRateHeader = mergeRateHeader[:innerWidth]
}
fmt.Printf(" │ %-60s│\n", mergeRateHeader)
fmt.Println(" └─────────────────────────────────────────────────────────────┘")
}

// Weekly waste per PR author
if ext.WasteHoursPerAuthorPerWeek > 0 && ext.TotalAuthors > 0 {
fmt.Printf(" Weekly waste per PR author: $%14s %s (%d authors)\n",
Expand Down Expand Up @@ -738,12 +768,12 @@ func printExtrapolatedMergeTimeModelingCallout(ext *cost.ExtrapolatedBreakdown,
fmt.Println(" ┌─────────────────────────────────────────────────────────────┐")
fmt.Printf(" │ %-60s│\n", "MERGE TIME MODELING")
fmt.Println(" └─────────────────────────────────────────────────────────────┘")
fmt.Printf(" If you lowered your average merge time to %s, you would save\n", formatTimeUnit(targetHours))
fmt.Printf(" ~$%s/yr in engineering overhead", formatWithCommas(annualSavings))
if efficiencyDelta > 0 {
fmt.Printf(" (+%.1f%% throughput).\n", efficiencyDelta)
fmt.Printf(" Reduce merge time to %s to boost team throughput by %.1f%%\n", formatTimeUnit(targetHours), efficiencyDelta)
fmt.Printf(" and save ~$%s/yr in engineering overhead.\n", formatWithCommas(annualSavings))
} else {
fmt.Println(".")
fmt.Printf(" If you lowered your average merge time to %s, you would save\n", formatTimeUnit(targetHours))
fmt.Printf(" ~$%s/yr in engineering overhead.\n", formatWithCommas(annualSavings))
}
fmt.Println()
}
Expand Down
44 changes: 40 additions & 4 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -1659,8 +1659,17 @@ func (s *Server) processRepoSample(ctx context.Context, req *RepoSampleRequest,
openPRCount = 0
}

// Convert PRSummary to PRMergeStatus for merge rate calculation
prStatuses := make([]cost.PRMergeStatus, len(prs))
for i, pr := range prs {
prStatuses[i] = cost.PRMergeStatus{
Merged: pr.Merged,
State: pr.State,
}
}

// Extrapolate costs from samples
extrapolated := cost.ExtrapolateFromSamples(breakdowns, len(prs), totalAuthors, openPRCount, actualDays, cfg)
extrapolated := cost.ExtrapolateFromSamples(breakdowns, len(prs), totalAuthors, openPRCount, actualDays, cfg, prStatuses)

// Only include seconds_in_state if we have data (turnserver only)
var secondsInState map[string]int
Expand Down Expand Up @@ -1779,8 +1788,17 @@ func (s *Server) processOrgSample(ctx context.Context, req *OrgSampleRequest, to
}
s.logger.InfoContext(ctx, "Counted total open PRs across organization", "org", req.Org, "open_prs", totalOpenPRs)

// Convert PRSummary to PRMergeStatus for merge rate calculation
prStatuses := make([]cost.PRMergeStatus, len(prs))
for i, pr := range prs {
prStatuses[i] = cost.PRMergeStatus{
Merged: pr.Merged,
State: pr.State,
}
}

// Extrapolate costs from samples
extrapolated := cost.ExtrapolateFromSamples(breakdowns, len(prs), totalAuthors, totalOpenPRs, actualDays, cfg)
extrapolated := cost.ExtrapolateFromSamples(breakdowns, len(prs), totalAuthors, totalOpenPRs, actualDays, cfg, prStatuses)

// Only include seconds_in_state if we have data (turnserver only)
var secondsInState map[string]int
Expand Down Expand Up @@ -2176,8 +2194,17 @@ func (s *Server) processRepoSampleWithProgress(ctx context.Context, req *RepoSam
openPRCount = 0
}

// Convert PRSummary to PRMergeStatus for merge rate calculation
prStatuses := make([]cost.PRMergeStatus, len(prs))
for i, pr := range prs {
prStatuses[i] = cost.PRMergeStatus{
Merged: pr.Merged,
State: pr.State,
}
}

// Extrapolate costs from samples
extrapolated := cost.ExtrapolateFromSamples(breakdowns, len(prs), totalAuthors, openPRCount, actualDays, cfg)
extrapolated := cost.ExtrapolateFromSamples(breakdowns, len(prs), totalAuthors, openPRCount, actualDays, cfg, prStatuses)

// Only include seconds_in_state if we have data (turnserver only)
var secondsInState map[string]int
Expand Down Expand Up @@ -2326,8 +2353,17 @@ func (s *Server) processOrgSampleWithProgress(ctx context.Context, req *OrgSampl
}
s.logger.InfoContext(ctx, "Counted total open PRs across organization", "open_prs", totalOpenPRs, "org", req.Org)

// Convert PRSummary to PRMergeStatus for merge rate calculation
prStatuses := make([]cost.PRMergeStatus, len(prs))
for i, pr := range prs {
prStatuses[i] = cost.PRMergeStatus{
Merged: pr.Merged,
State: pr.State,
}
}

// Extrapolate costs from samples
extrapolated := cost.ExtrapolateFromSamples(breakdowns, len(prs), totalAuthors, totalOpenPRs, actualDays, cfg)
extrapolated := cost.ExtrapolateFromSamples(breakdowns, len(prs), totalAuthors, totalOpenPRs, actualDays, cfg, prStatuses)

// Only include seconds_in_state if we have data (turnserver only)
var secondsInState map[string]int
Expand Down
8 changes: 2 additions & 6 deletions internal/server/static/formatR2RCallout.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,12 @@ function formatR2RCallout(avgOpenHours, r2rSavings, currentEfficiency, modeledEf
}

const efficiencyDelta = modeledEfficiency - currentEfficiency;
let throughputText = '';
if (efficiencyDelta > 0) {
throughputText = ' (+' + efficiencyDelta.toFixed(1) + '% throughput)';
}

// Format target merge time
let targetText = targetMergeHours.toFixed(1) + 'h';

let html = '<div style="margin: 24px 0; padding: 12px 20px; background: linear-gradient(135deg, #e6f9f0 0%, #ffffff 100%); border: 1px solid #00c853; border-radius: 8px; font-size: 14px; color: #1d1d1f; line-height: 1.6;">';
html += 'Pro-Tip: Save <strong>' + savingsText + '/yr</strong> in lost development effort by reducing merge times to &lt;' + targetText + ' with ';
let html = '<div style="margin: 24px 0; padding: 12px 20px; background: linear-gradient(135deg, #e6f9f0 0%, #ffffff 100%); border: 1px solid #00c853; border-radius: 8px; font-size: 16px; color: #1d1d1f; line-height: 1.6; font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Helvetica, Arial, sans-serif, \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Noto Color Emoji\';">';
html += '<span style="font-family: \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Noto Color Emoji\', sans-serif; font-style: normal; font-weight: normal; text-rendering: optimizeLegibility;">\uD83D\uDCA1</span> <strong>Pro-Tip:</strong> Boost team throughput by <strong>' + efficiencyDelta.toFixed(1) + '%</strong> and save <strong>' + savingsText + '/yr</strong> by reducing merge times to &lt;' + targetText + ' with ';
html += '<a href="https://codegroove.dev/products/ready-to-review/" target="_blank" rel="noopener" style="color: #00c853; font-weight: 600; text-decoration: none;">Ready to Review</a>. ';
html += 'Free for open-source repositories, $6/user/org for private repos.';
html += '</div>';
Expand Down
7 changes: 5 additions & 2 deletions internal/server/static/formatR2RCallout.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,13 @@ test('Renders callout for PRs with avgOpenHours > 1.5 (default)', () => {
assert(result.length > 0, 'Should return non-empty HTML');
});

// Test 3: Should contain "Pro-Tip:" text
test('Contains "Pro-Tip:" text', () => {
// Test 3: Should contain "Pro-Tip:" text and throughput boost
test('Contains "Pro-Tip:" text and throughput boost', () => {
const result = formatR2RCallout(10, 50000, 60, 70);
assert(result.includes('💡'), 'Should contain lightbulb emoji');
assert(result.includes('Pro-Tip:'), 'Should contain "Pro-Tip:"');
assert(result.includes('Boost team throughput by'), 'Should contain throughput boost message');
assert(result.includes('10.0%'), 'Should show efficiency delta of 10% (70 - 60)');
});

// Test 4: Should contain "Ready to Review" link
Expand Down
Loading
Loading