Skip to content

Commit bb4e1d8

Browse files
authored
Merge pull request #2 from tstromberg/main
fork sync - improve overhead tracking of open PRs.
2 parents 3a75b40 + bf67c56 commit bb4e1d8

File tree

21 files changed

+2790
-886
lines changed

21 files changed

+2790
-886
lines changed

.golangci.yml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ linters:
122122
struct-method: false
123123

124124
gocognit:
125-
min-complexity: 55
125+
min-complexity: 65
126126

127127
gocritic:
128128
enable-all: true
@@ -143,6 +143,11 @@ linters:
143143
govet:
144144
enable-all: true
145145

146+
maintidx:
147+
# Maintainability Index threshold (default: 20)
148+
# ExtrapolateFromSamples is a straightforward calculation with clear linear logic
149+
under: 16
150+
146151
godot:
147152
scope: toplevel
148153

@@ -177,7 +182,7 @@ linters:
177182
- name: cyclomatic
178183
disabled: true # prefer maintidx
179184
- name: function-length
180-
arguments: [150, 225]
185+
arguments: [150, 300]
181186
- name: line-length-limit
182187
arguments: [150]
183188
- name: nested-structs
@@ -186,6 +191,8 @@ linters:
186191
arguments: [10]
187192
- name: flag-parameter # fixes are difficult
188193
disabled: true
194+
- name: use-waitgroup-go
195+
disabled: true # wg.Add/Done pattern is idiomatic Go
189196

190197
rowserrcheck:
191198
# database/sql is always checked.

README.md

Lines changed: 41 additions & 334 deletions
Large diffs are not rendered by default.

cmd/prcost/main.go

Lines changed: 160 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"log/slog"
1212
"os"
1313
"os/exec"
14+
"strconv"
1415
"strings"
1516
"time"
1617

@@ -30,8 +31,8 @@ func main() {
3031
// Org/Repo sampling flags
3132
org := flag.String("org", "", "GitHub organization to analyze (optionally with --repo for single repo)")
3233
repo := flag.String("repo", "", "GitHub repository to analyze (requires --org)")
33-
samples := flag.Int("samples", 20, "Number of PRs to sample for extrapolation")
34-
days := flag.Int("days", 90, "Number of days to look back for PR modifications")
34+
samples := flag.Int("samples", 25, "Number of PRs to sample for extrapolation (25=fast/±20%, 50=slower/±14%)")
35+
days := flag.Int("days", 60, "Number of days to look back for PR modifications")
3536

3637
flag.Usage = func() {
3738
fmt.Fprintf(os.Stderr, "Usage: %s [options] <PR_URL>\n", os.Args[0])
@@ -52,7 +53,7 @@ func main() {
5253
fmt.Fprintf(os.Stderr, " %s --org myorg --repo myrepo --samples 50 --days 30\n\n", os.Args[0])
5354
fmt.Fprint(os.Stderr, " Organization-wide analysis:\n")
5455
fmt.Fprintf(os.Stderr, " %s --org chainguard-dev\n", os.Args[0])
55-
fmt.Fprintf(os.Stderr, " %s --org myorg --samples 100 --days 60\n", os.Args[0])
56+
fmt.Fprintf(os.Stderr, " %s --org myorg --samples 50 --days 60\n", os.Args[0])
5657
}
5758

5859
flag.Parse()
@@ -101,12 +102,10 @@ func main() {
101102
"salary", cfg.AnnualSalary,
102103
"benefits_multiplier", cfg.BenefitsMultiplier,
103104
"event_minutes", *eventMinutes,
104-
"delivery_delay_factor", cfg.DeliveryDelayFactor,
105-
"coordination_factor", cfg.CoordinationFactor)
105+
"delivery_delay_factor", cfg.DeliveryDelayFactor)
106106

107107
// Retrieve GitHub token from gh CLI
108108
ctx := context.Background()
109-
slog.Info("Retrieving GitHub authentication token")
110109
token, err := authToken(ctx)
111110
if err != nil {
112111
slog.Error("Failed to get GitHub token", "error", err)
@@ -119,11 +118,6 @@ func main() {
119118
// Org/Repo sampling mode
120119
if *repo != "" {
121120
// Single repository mode
122-
slog.Info("Starting repository analysis",
123-
"org", *org,
124-
"repo", *repo,
125-
"samples", *samples,
126-
"days", *days)
127121

128122
err := analyzeRepository(ctx, *org, *repo, *samples, *days, cfg, token, *dataSource)
129123
if err != nil {
@@ -282,7 +276,7 @@ func printHumanReadable(breakdown *cost.Breakdown, prURL string) {
282276
}
283277
// Only show other events if they had non-review events
284278
if p.GitHubHours > 0 {
285-
fmt.Printf(" GitHub Events %12s %d sessions • %s\n",
279+
fmt.Printf(" GitHub Activity %12s %d sessions • %s\n",
286280
formatCurrency(p.GitHubCost), p.Sessions, formatTimeUnit(p.GitHubHours))
287281
}
288282
// Always show context switching if there were sessions
@@ -312,6 +306,9 @@ func printHumanReadable(breakdown *cost.Breakdown, prURL string) {
312306
fmt.Printf(" Total %12s %s\n",
313307
formatCurrency(breakdown.TotalCost), formatTimeUnit(totalHours))
314308
fmt.Println()
309+
310+
// Print efficiency score
311+
printEfficiency(breakdown)
315312
}
316313

317314
// printDelayCosts prints delay and future costs section.
@@ -325,25 +322,22 @@ func printDelayCosts(breakdown *cost.Breakdown, formatCurrency func(float64) str
325322
if breakdown.DelayCapped {
326323
cappedSuffix = " (capped)"
327324
}
328-
fmt.Printf(" Project %12s %s%s\n",
325+
fmt.Printf(" Workstream blockage %12s %s%s\n",
329326
formatCurrency(breakdown.DelayCostDetail.DeliveryDelayCost),
330327
formatTimeUnit(breakdown.DelayCostDetail.DeliveryDelayHours),
331328
cappedSuffix)
332329
}
333330

334-
if breakdown.DelayCostDetail.CoordinationHours > 0 {
335-
cappedSuffix := ""
336-
if breakdown.DelayCapped {
337-
cappedSuffix = " (capped)"
338-
}
339-
fmt.Printf(" Coordination %12s %s%s\n",
340-
formatCurrency(breakdown.DelayCostDetail.CoordinationCost),
341-
formatTimeUnit(breakdown.DelayCostDetail.CoordinationHours),
342-
cappedSuffix)
343-
}
331+
// Calculate merge delay subtotal (all non-future delay costs)
332+
mergeDelayCost := breakdown.DelayCostDetail.DeliveryDelayCost +
333+
breakdown.DelayCostDetail.CodeChurnCost +
334+
breakdown.DelayCostDetail.AutomatedUpdatesCost +
335+
breakdown.DelayCostDetail.PRTrackingCost
336+
mergeDelayHours := breakdown.DelayCostDetail.DeliveryDelayHours +
337+
breakdown.DelayCostDetail.CodeChurnHours +
338+
breakdown.DelayCostDetail.AutomatedUpdatesHours +
339+
breakdown.DelayCostDetail.PRTrackingHours
344340

345-
mergeDelayCost := breakdown.DelayCostDetail.DeliveryDelayCost + breakdown.DelayCostDetail.CoordinationCost
346-
mergeDelayHours := breakdown.DelayCostDetail.DeliveryDelayHours + breakdown.DelayCostDetail.CoordinationHours
347341
fmt.Println(" ────────────")
348342
pct := (mergeDelayCost / breakdown.TotalCost) * 100
349343
fmt.Printf(" Subtotal %12s %s (%.1f%%)\n",
@@ -431,3 +425,144 @@ func formatWithCommas(amount float64) string {
431425

432426
return string(result) + "." + decPart
433427
}
428+
429+
// formatLOC formats lines of code in kilo format with appropriate precision and commas for large values.
430+
func formatLOC(kloc float64) string {
431+
// For values >= 100k, add commas (e.g., "1,517k" instead of "1517k")
432+
if kloc >= 100.0 {
433+
intPart := int(kloc)
434+
fracPart := kloc - float64(intPart)
435+
436+
// Format integer part with commas
437+
intStr := strconv.Itoa(intPart)
438+
var result []rune
439+
for i, r := range intStr {
440+
if i > 0 && (len(intStr)-i)%3 == 0 {
441+
result = append(result, ',')
442+
}
443+
result = append(result, r)
444+
}
445+
446+
// Add fractional part if significant
447+
if kloc < 1000.0 && fracPart >= 0.05 {
448+
return fmt.Sprintf("%s.%dk", string(result), int(fracPart*10))
449+
}
450+
return string(result) + "k"
451+
}
452+
453+
// For values < 100k, use existing precision logic
454+
if kloc < 0.1 && kloc > 0 {
455+
return fmt.Sprintf("%.2fk", kloc)
456+
}
457+
if kloc < 1.0 {
458+
return fmt.Sprintf("%.1fk", kloc)
459+
}
460+
if kloc < 10.0 {
461+
return fmt.Sprintf("%.1fk", kloc)
462+
}
463+
return fmt.Sprintf("%.0fk", kloc)
464+
}
465+
466+
// efficiencyGrade returns a letter grade and message based on efficiency percentage (MIT scale).
467+
func efficiencyGrade(efficiencyPct float64) (grade, message string) {
468+
switch {
469+
case efficiencyPct >= 97:
470+
return "A+", "Impeccable"
471+
case efficiencyPct >= 93:
472+
return "A", "Excellent"
473+
case efficiencyPct >= 90:
474+
return "A-", "Nearly excellent"
475+
case efficiencyPct >= 87:
476+
return "B+", "Acceptable+"
477+
case efficiencyPct >= 83:
478+
return "B", "Acceptable"
479+
case efficiencyPct >= 80:
480+
return "B-", "Nearly acceptable"
481+
case efficiencyPct >= 70:
482+
return "C", "Average"
483+
case efficiencyPct >= 60:
484+
return "D", "Not good my friend."
485+
default:
486+
return "F", "Failing"
487+
}
488+
}
489+
490+
// mergeVelocityGrade returns a grade based on average PR open time in days.
491+
// A+: 4h, A: 8h, A-: 12h, B+: 18h, B: 24h, B-: 36h, C: 100h, D: 120h, F: 120h+.
492+
func mergeVelocityGrade(avgOpenDays float64) (grade, message string) {
493+
switch {
494+
case avgOpenDays <= 0.1667: // 4 hours
495+
return "A+", "Impeccable"
496+
case avgOpenDays <= 0.3333: // 8 hours
497+
return "A", "Excellent"
498+
case avgOpenDays <= 0.5: // 12 hours
499+
return "A-", "Nearly excellent"
500+
case avgOpenDays <= 0.75: // 18 hours
501+
return "B+", "Acceptable+"
502+
case avgOpenDays <= 1.0: // 24 hours
503+
return "B", "Acceptable"
504+
case avgOpenDays <= 1.5: // 36 hours
505+
return "B-", "Nearly acceptable"
506+
case avgOpenDays <= 4.1667: // 100 hours
507+
return "C", "Average"
508+
case avgOpenDays <= 5.0: // 120 hours
509+
return "D", "Not good my friend."
510+
default:
511+
return "F", "Failing"
512+
}
513+
}
514+
515+
// printEfficiency prints the workflow efficiency section for a single PR.
516+
func printEfficiency(breakdown *cost.Breakdown) {
517+
// Calculate preventable waste: Code Churn + All Delay Costs + Automated Updates + PR Tracking
518+
preventableHours := breakdown.DelayCostDetail.CodeChurnHours +
519+
breakdown.DelayCostDetail.DeliveryDelayHours +
520+
breakdown.DelayCostDetail.AutomatedUpdatesHours +
521+
breakdown.DelayCostDetail.PRTrackingHours
522+
preventableCost := breakdown.DelayCostDetail.CodeChurnCost +
523+
breakdown.DelayCostDetail.DeliveryDelayCost +
524+
breakdown.DelayCostDetail.AutomatedUpdatesCost +
525+
breakdown.DelayCostDetail.PRTrackingCost
526+
527+
// Calculate total hours
528+
totalHours := breakdown.Author.TotalHours + breakdown.DelayCostDetail.TotalDelayHours
529+
for _, p := range breakdown.Participants {
530+
totalHours += p.TotalHours
531+
}
532+
533+
// Calculate efficiency
534+
var efficiencyPct float64
535+
if totalHours > 0 {
536+
efficiencyPct = 100.0 * (totalHours - preventableHours) / totalHours
537+
} else {
538+
efficiencyPct = 100.0
539+
}
540+
541+
grade, message := efficiencyGrade(efficiencyPct)
542+
543+
// Calculate merge velocity grade based on PR duration
544+
prDurationDays := breakdown.PRDuration / 24.0
545+
velocityGrade, velocityMessage := mergeVelocityGrade(prDurationDays)
546+
547+
fmt.Println(" ┌─────────────────────────────────────────────────────────────┐")
548+
headerText := fmt.Sprintf("DEVELOPMENT EFFICIENCY: %s (%.1f%%) - %s", grade, efficiencyPct, message)
549+
padding := 60 - len(headerText)
550+
if padding < 0 {
551+
padding = 0
552+
}
553+
fmt.Printf(" │ %s%*s│\n", headerText, padding, "")
554+
fmt.Println(" └─────────────────────────────────────────────────────────────┘")
555+
556+
fmt.Println(" ┌─────────────────────────────────────────────────────────────┐")
557+
velocityHeader := fmt.Sprintf("MERGE VELOCITY: %s (%s) - %s", velocityGrade, formatTimeUnit(breakdown.PRDuration), velocityMessage)
558+
velPadding := 60 - len(velocityHeader)
559+
if velPadding < 0 {
560+
velPadding = 0
561+
}
562+
fmt.Printf(" │ %s%*s│\n", velocityHeader, velPadding, "")
563+
fmt.Println(" └─────────────────────────────────────────────────────────────┘")
564+
565+
fmt.Printf(" Preventable Waste: $%12s %s\n",
566+
formatWithCommas(preventableCost), formatTimeUnit(preventableHours))
567+
fmt.Println()
568+
}

0 commit comments

Comments
 (0)