Skip to content

Commit 9d52a47

Browse files
authored
Merge pull request #19 from tstromberg/main
Improve code churn rates, future costs, and model latency
2 parents 9f711a2 + 0147e44 commit 9d52a47

File tree

11 files changed

+373
-113
lines changed

11 files changed

+373
-113
lines changed

Makefile

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
.PHONY: test
2-
test:
2+
test: test-go test-js
3+
4+
.PHONY: test-go
5+
test-go:
36
go test -race -cover ./...
47

8+
.PHONY: test-js
9+
test-js:
10+
@echo "Running JavaScript tests..."
11+
@node internal/server/static/formatR2RCallout.test.js
12+
513
# BEGIN: lint-install .
614
# http://github.com/codeGROOVE-dev/lint-install
715

cmd/prcost/main.go

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ func main() {
3535
days := flag.Int("days", 60, "Number of days to look back for PR modifications")
3636

3737
// Modeling flags
38-
modelMergeTime := flag.Duration("model-merge-time", 1*time.Hour, "Model savings if average merge time was reduced to this duration")
38+
targetMergeTime := flag.Duration("target-merge-time", 90*time.Minute, "Target merge time for efficiency modeling (default: 90 minutes / 1.5 hours)")
3939

4040
flag.Usage = func() {
4141
fmt.Fprintf(os.Stderr, "Usage: %s [options] <PR_URL>\n", os.Args[0])
@@ -100,11 +100,13 @@ func main() {
100100
cfg.AnnualSalary = *salary
101101
cfg.BenefitsMultiplier = *benefits
102102
cfg.EventDuration = time.Duration(*eventMinutes) * time.Minute
103+
cfg.TargetMergeTimeHours = targetMergeTime.Hours()
103104

104105
slog.Debug("Configuration",
105106
"salary", cfg.AnnualSalary,
106107
"benefits_multiplier", cfg.BenefitsMultiplier,
107108
"event_minutes", *eventMinutes,
109+
"target_merge_time_hours", cfg.TargetMergeTimeHours,
108110
"delivery_delay_factor", cfg.DeliveryDelayFactor)
109111

110112
// Retrieve GitHub token from gh CLI
@@ -122,7 +124,7 @@ func main() {
122124
if *repo != "" {
123125
// Single repository mode
124126

125-
err := analyzeRepository(ctx, *org, *repo, *samples, *days, cfg, token, *dataSource, modelMergeTime)
127+
err := analyzeRepository(ctx, *org, *repo, *samples, *days, cfg, token, *dataSource)
126128
if err != nil {
127129
log.Fatalf("Repository analysis failed: %v", err)
128130
}
@@ -133,7 +135,7 @@ func main() {
133135
"samples", *samples,
134136
"days", *days)
135137

136-
err := analyzeOrganization(ctx, *org, *samples, *days, cfg, token, *dataSource, modelMergeTime)
138+
err := analyzeOrganization(ctx, *org, *samples, *days, cfg, token, *dataSource)
137139
if err != nil {
138140
log.Fatalf("Organization analysis failed: %v", err)
139141
}
@@ -177,7 +179,7 @@ func main() {
177179
// Output in requested format
178180
switch *format {
179181
case "human":
180-
printHumanReadable(&breakdown, prURL, *modelMergeTime, cfg)
182+
printHumanReadable(&breakdown, prURL, cfg)
181183
case "json":
182184
encoder := json.NewEncoder(os.Stdout)
183185
encoder.SetIndent("", " ")
@@ -209,7 +211,7 @@ func authToken(ctx context.Context) (string, error) {
209211
}
210212

211213
// printHumanReadable outputs a detailed itemized bill in human-readable format.
212-
func printHumanReadable(breakdown *cost.Breakdown, prURL string, modelMergeTime time.Duration, cfg cost.Config) {
214+
func printHumanReadable(breakdown *cost.Breakdown, prURL string, cfg cost.Config) {
213215
// Helper to format currency with commas
214216
formatCurrency := func(amount float64) string {
215217
return fmt.Sprintf("$%s", formatWithCommas(amount))
@@ -313,9 +315,9 @@ func printHumanReadable(breakdown *cost.Breakdown, prURL string, modelMergeTime
313315
// Print efficiency score
314316
printEfficiency(breakdown)
315317

316-
// Print modeling callout if PR duration exceeds model merge time
317-
if breakdown.PRDuration > modelMergeTime.Hours() {
318-
printMergeTimeModelingCallout(breakdown, modelMergeTime, cfg)
318+
// Print modeling callout if PR duration exceeds target merge time
319+
if breakdown.PRDuration > cfg.TargetMergeTimeHours {
320+
printMergeTimeModelingCallout(breakdown, cfg)
319321
}
320322
}
321323

@@ -528,8 +530,8 @@ func mergeVelocityGrade(avgOpenDays float64) (grade, message string) {
528530
}
529531

530532
// printMergeTimeModelingCallout prints a callout showing potential savings from reduced merge time.
531-
func printMergeTimeModelingCallout(breakdown *cost.Breakdown, targetMergeTime time.Duration, cfg cost.Config) {
532-
targetHours := targetMergeTime.Hours()
533+
func printMergeTimeModelingCallout(breakdown *cost.Breakdown, cfg cost.Config) {
534+
targetHours := cfg.TargetMergeTimeHours
533535
currentHours := breakdown.PRDuration
534536

535537
// Calculate hourly rate
@@ -538,15 +540,15 @@ func printMergeTimeModelingCallout(breakdown *cost.Breakdown, targetMergeTime ti
538540
// Recalculate delivery delay with target merge time
539541
remodelDeliveryDelayCost := hourlyRate * cfg.DeliveryDelayFactor * targetHours
540542

541-
// Code churn: 40min-1h is too short for meaningful code churn (< 1 day)
543+
// Code churn: target time is too short for meaningful code churn (< 1 day)
542544
remodelCodeChurnCost := 0.0
543545

544546
// Automated updates: only applies to PRs open > 1 day
545547
remodelAutomatedUpdatesCost := 0.0
546548

547549
// PR tracking: scales with open time (already minimal for short PRs)
548550
remodelPRTrackingCost := 0.0
549-
if targetHours >= 1.0 { // Only track PRs open >= 1 hour
551+
if targetHours >= 1.0 { // Minimal tracking for PRs open >= 1 hour
550552
daysOpen := targetHours / 24.0
551553
remodelPRTrackingHours := (cfg.PRTrackingMinutesPerDay / 60.0) * daysOpen
552554
remodelPRTrackingCost = remodelPRTrackingHours * hourlyRate

cmd/prcost/repository.go

Lines changed: 46 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import (
1616
// and extrapolation - all functionality is available to external clients.
1717
//
1818
//nolint:revive // argument-limit: acceptable for entry point function
19-
func analyzeRepository(ctx context.Context, owner, repo string, sampleSize, days int, cfg cost.Config, token, dataSource string, modelMergeTime *time.Duration) error {
19+
func analyzeRepository(ctx context.Context, owner, repo string, sampleSize, days int, cfg cost.Config, token, dataSource string) error {
2020
// Calculate since date
2121
since := time.Now().AddDate(0, 0, -days)
2222

@@ -105,7 +105,7 @@ func analyzeRepository(ctx context.Context, owner, repo string, sampleSize, days
105105
extrapolated := cost.ExtrapolateFromSamples(breakdowns, len(prs), totalAuthors, openPRCount, actualDays, cfg)
106106

107107
// Display results in itemized format
108-
printExtrapolatedResults(fmt.Sprintf("%s/%s", owner, repo), actualDays, &extrapolated, cfg, *modelMergeTime)
108+
printExtrapolatedResults(fmt.Sprintf("%s/%s", owner, repo), actualDays, &extrapolated, cfg)
109109

110110
return nil
111111
}
@@ -115,7 +115,7 @@ func analyzeRepository(ctx context.Context, owner, repo string, sampleSize, days
115115
// and extrapolation - all functionality is available to external clients.
116116
//
117117
//nolint:revive // argument-limit: acceptable for entry point function
118-
func analyzeOrganization(ctx context.Context, org string, sampleSize, days int, cfg cost.Config, token, dataSource string, modelMergeTime *time.Duration) error {
118+
func analyzeOrganization(ctx context.Context, org string, sampleSize, days int, cfg cost.Config, token, dataSource string) error {
119119
slog.Info("Fetching PR list from organization")
120120

121121
// Calculate since date
@@ -207,7 +207,7 @@ func analyzeOrganization(ctx context.Context, org string, sampleSize, days int,
207207
extrapolated := cost.ExtrapolateFromSamples(breakdowns, len(prs), totalAuthors, totalOpenPRs, actualDays, cfg)
208208

209209
// Display results in itemized format
210-
printExtrapolatedResults(fmt.Sprintf("%s (organization)", org), actualDays, &extrapolated, cfg, *modelMergeTime)
210+
printExtrapolatedResults(fmt.Sprintf("%s (organization)", org), actualDays, &extrapolated, cfg)
211211

212212
return nil
213213
}
@@ -278,7 +278,7 @@ func formatTimeUnit(hours float64) string {
278278
// printExtrapolatedResults displays extrapolated cost breakdown in itemized format.
279279
//
280280
//nolint:maintidx,revive // acceptable complexity/length for comprehensive display function
281-
func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBreakdown, cfg cost.Config, modelMergeTime time.Duration) {
281+
func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBreakdown, cfg cost.Config) {
282282
fmt.Println()
283283
fmt.Printf(" %s\n", title)
284284
avgOpenTime := formatTimeUnit(ext.AvgPRDurationHours)
@@ -396,7 +396,7 @@ func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBrea
396396
fmt.Println()
397397
}
398398

399-
// Merge Delay section
399+
// Delay Costs section
400400
avgHumanOpenTime := formatTimeUnit(ext.AvgHumanPRDurationHours)
401401
avgBotOpenTime := formatTimeUnit(ext.AvgBotPRDurationHours)
402402
delayCostsHeader := fmt.Sprintf(" Delay Costs (human PRs avg %s open", avgHumanOpenTime)
@@ -422,6 +422,17 @@ func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBrea
422422
fmt.Print(formatSubtotalLine(avgMergeDelayCost, formatTimeUnit(avgMergeDelayHours), fmt.Sprintf("(%.1f%%)", pct)))
423423
fmt.Println()
424424

425+
// Preventable Future Costs section
426+
if avgCodeChurnCost > 0 {
427+
fmt.Println(" Preventable Future Costs")
428+
fmt.Println(" ────────────────────────")
429+
fmt.Print(formatItemLine("Rework due to churn", avgCodeChurnCost, formatTimeUnit(avgCodeChurnHours), fmt.Sprintf("(%d PRs)", ext.CodeChurnPRCount)))
430+
fmt.Print(formatSectionDivider())
431+
pct = (avgCodeChurnCost / avgTotalCost) * 100
432+
fmt.Print(formatSubtotalLine(avgCodeChurnCost, formatTimeUnit(avgCodeChurnHours), fmt.Sprintf("(%.1f%%)", pct)))
433+
fmt.Println()
434+
}
435+
425436
// Future Costs section
426437
avgFutureReviewCost := ext.FutureReviewCost / float64(ext.TotalPRs)
427438
avgFutureMergeCost := ext.FutureMergeCost / float64(ext.TotalPRs)
@@ -430,15 +441,12 @@ func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBrea
430441
avgFutureMergeHours := ext.FutureMergeHours / float64(ext.TotalPRs)
431442
avgFutureContextHours := ext.FutureContextHours / float64(ext.TotalPRs)
432443

433-
hasFutureCosts := ext.CodeChurnCost > 0.01 || ext.FutureReviewCost > 0.01 ||
444+
hasFutureCosts := ext.FutureReviewCost > 0.01 ||
434445
ext.FutureMergeCost > 0.01 || ext.FutureContextCost > 0.01
435446

436447
if hasFutureCosts {
437448
fmt.Println(" Future Costs")
438449
fmt.Println(" ────────────")
439-
if ext.CodeChurnCost > 0.01 {
440-
fmt.Print(formatItemLine("Code Churn", avgCodeChurnCost, formatTimeUnit(avgCodeChurnHours), fmt.Sprintf("(%d PRs)", ext.CodeChurnPRCount)))
441-
}
442450
if ext.FutureReviewCost > 0.01 {
443451
fmt.Print(formatItemLine("Review", avgFutureReviewCost, formatTimeUnit(avgFutureReviewHours), fmt.Sprintf("(%d PRs)", ext.FutureReviewPRCount)))
444452
}
@@ -449,8 +457,8 @@ func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBrea
449457
avgFutureContextSessions := float64(ext.FutureContextSessions) / float64(ext.TotalPRs)
450458
fmt.Print(formatItemLine("Context Switching", avgFutureContextCost, formatTimeUnit(avgFutureContextHours), fmt.Sprintf("(%.1f sessions)", avgFutureContextSessions)))
451459
}
452-
avgFutureCost := avgCodeChurnCost + avgFutureReviewCost + avgFutureMergeCost + avgFutureContextCost
453-
avgFutureHours := avgCodeChurnHours + avgFutureReviewHours + avgFutureMergeHours + avgFutureContextHours
460+
avgFutureCost := avgFutureReviewCost + avgFutureMergeCost + avgFutureContextCost
461+
avgFutureHours := avgFutureReviewHours + avgFutureMergeHours + avgFutureContextHours
454462
fmt.Print(formatSectionDivider())
455463
pct = (avgFutureCost / avgTotalCost) * 100
456464
fmt.Print(formatSubtotalLine(avgFutureCost, formatTimeUnit(avgFutureHours), fmt.Sprintf("(%.1f%%)", pct)))
@@ -529,7 +537,7 @@ func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBrea
529537
fmt.Println()
530538
}
531539

532-
// Merge Delay section (extrapolated)
540+
// Delay Costs section (extrapolated)
533541
extAvgHumanOpenTime := formatTimeUnit(ext.AvgHumanPRDurationHours)
534542
extAvgBotOpenTime := formatTimeUnit(ext.AvgBotPRDurationHours)
535543
extDelayCostsHeader := fmt.Sprintf(" Delay Costs (human PRs avg %s open", extAvgHumanOpenTime)
@@ -549,25 +557,33 @@ func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBrea
549557
if ext.PRTrackingCost > 0 {
550558
fmt.Print(formatItemLine("PR Tracking", ext.PRTrackingCost, formatTimeUnit(ext.PRTrackingHours), fmt.Sprintf("(%d open PRs)", ext.OpenPRs)))
551559
}
552-
extMergeDelayCost := ext.DeliveryDelayCost + ext.CodeChurnCost + ext.AutomatedUpdatesCost + ext.PRTrackingCost
553-
extMergeDelayHours := ext.DeliveryDelayHours + ext.CodeChurnHours + ext.AutomatedUpdatesHours + ext.PRTrackingHours
560+
extMergeDelayCost := ext.DeliveryDelayCost + ext.AutomatedUpdatesCost + ext.PRTrackingCost
561+
extMergeDelayHours := ext.DeliveryDelayHours + ext.AutomatedUpdatesHours + ext.PRTrackingHours
554562
fmt.Print(formatSectionDivider())
555563
pct = (extMergeDelayCost / ext.TotalCost) * 100
556564
fmt.Print(formatSubtotalLine(extMergeDelayCost, formatTimeUnit(extMergeDelayHours), fmt.Sprintf("(%.1f%%)", pct)))
557565
fmt.Println()
558566

567+
// Preventable Future Costs section (extrapolated)
568+
if ext.CodeChurnCost > 0 {
569+
fmt.Println(" Preventable Future Costs")
570+
fmt.Println(" ────────────────────────")
571+
totalKLOC := float64(ext.TotalNewLines+ext.TotalModifiedLines) / 1000.0
572+
churnLOCStr := formatLOC(totalKLOC)
573+
fmt.Print(formatItemLine("Rework due to churn", ext.CodeChurnCost, formatTimeUnit(ext.CodeChurnHours), fmt.Sprintf("(%d PRs, ~%s)", ext.CodeChurnPRCount, churnLOCStr)))
574+
fmt.Print(formatSectionDivider())
575+
pct = (ext.CodeChurnCost / ext.TotalCost) * 100
576+
fmt.Print(formatSubtotalLine(ext.CodeChurnCost, formatTimeUnit(ext.CodeChurnHours), fmt.Sprintf("(%.1f%%)", pct)))
577+
fmt.Println()
578+
}
579+
559580
// Future Costs section (extrapolated)
560-
extHasFutureCosts := ext.CodeChurnCost > 0.01 || ext.FutureReviewCost > 0.01 ||
581+
extHasFutureCosts := ext.FutureReviewCost > 0.01 ||
561582
ext.FutureMergeCost > 0.01 || ext.FutureContextCost > 0.01
562583

563584
if extHasFutureCosts {
564585
fmt.Println(" Future Costs")
565586
fmt.Println(" ────────────")
566-
if ext.CodeChurnCost > 0.01 {
567-
totalKLOC := float64(ext.TotalNewLines+ext.TotalModifiedLines) / 1000.0
568-
churnLOCStr := formatLOC(totalKLOC)
569-
fmt.Print(formatItemLine("Code Churn", ext.CodeChurnCost, formatTimeUnit(ext.CodeChurnHours), fmt.Sprintf("(%d PRs, ~%s)", ext.CodeChurnPRCount, churnLOCStr)))
570-
}
571587
if ext.FutureReviewCost > 0.01 {
572588
fmt.Print(formatItemLine("Review", ext.FutureReviewCost, formatTimeUnit(ext.FutureReviewHours), fmt.Sprintf("(%d PRs)", ext.FutureReviewPRCount)))
573589
}
@@ -577,8 +593,8 @@ func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBrea
577593
if ext.FutureContextCost > 0.01 {
578594
fmt.Print(formatItemLine("Context Switching", ext.FutureContextCost, formatTimeUnit(ext.FutureContextHours), fmt.Sprintf("(%d sessions)", ext.FutureContextSessions)))
579595
}
580-
extFutureCost := ext.CodeChurnCost + ext.FutureReviewCost + ext.FutureMergeCost + ext.FutureContextCost
581-
extFutureHours := ext.CodeChurnHours + ext.FutureReviewHours + ext.FutureMergeHours + ext.FutureContextHours
596+
extFutureCost := ext.FutureReviewCost + ext.FutureMergeCost + ext.FutureContextCost
597+
extFutureHours := ext.FutureReviewHours + ext.FutureMergeHours + ext.FutureContextHours
582598
fmt.Print(formatSectionDivider())
583599
pct = (extFutureCost / ext.TotalCost) * 100
584600
fmt.Print(formatSubtotalLine(extFutureCost, formatTimeUnit(extFutureHours), fmt.Sprintf("(%.1f%%)", pct)))
@@ -598,11 +614,11 @@ func printExtrapolatedResults(title string, days int, ext *cost.ExtrapolatedBrea
598614
fmt.Println()
599615

600616
// Print extrapolated efficiency score + annual waste
601-
printExtrapolatedEfficiency(ext, days, cfg, modelMergeTime)
617+
printExtrapolatedEfficiency(ext, days, cfg)
602618
}
603619

604620
// printExtrapolatedEfficiency prints the workflow efficiency + annual waste section for extrapolated totals.
605-
func printExtrapolatedEfficiency(ext *cost.ExtrapolatedBreakdown, days int, cfg cost.Config, modelMergeTime time.Duration) {
621+
func printExtrapolatedEfficiency(ext *cost.ExtrapolatedBreakdown, days int, cfg cost.Config) {
606622
// Calculate preventable waste: Code Churn + All Delay Costs + Automated Updates + PR Tracking
607623
preventableHours := ext.CodeChurnHours + ext.DeliveryDelayHours + ext.AutomatedUpdatesHours + ext.PRTrackingHours
608624
preventableCost := ext.CodeChurnCost + ext.DeliveryDelayCost + ext.AutomatedUpdatesCost + ext.PRTrackingCost
@@ -660,14 +676,14 @@ func printExtrapolatedEfficiency(ext *cost.ExtrapolatedBreakdown, days int, cfg
660676
fmt.Println()
661677

662678
// Print merge time modeling callout if average PR duration exceeds model merge time
663-
if ext.AvgPRDurationHours > modelMergeTime.Hours() {
664-
printExtrapolatedMergeTimeModelingCallout(ext, days, modelMergeTime, cfg)
679+
if ext.AvgPRDurationHours > cfg.TargetMergeTimeHours {
680+
printExtrapolatedMergeTimeModelingCallout(ext, days, cfg)
665681
}
666682
}
667683

668684
// printExtrapolatedMergeTimeModelingCallout prints a callout showing potential savings from reduced merge time.
669-
func printExtrapolatedMergeTimeModelingCallout(ext *cost.ExtrapolatedBreakdown, days int, targetMergeTime time.Duration, cfg cost.Config) {
670-
targetHours := targetMergeTime.Hours()
685+
func printExtrapolatedMergeTimeModelingCallout(ext *cost.ExtrapolatedBreakdown, days int, cfg cost.Config) {
686+
targetHours := cfg.TargetMergeTimeHours
671687

672688
// Calculate hourly rate
673689
hourlyRate := (cfg.AnnualSalary * cfg.BenefitsMultiplier) / cfg.HoursPerYear
@@ -686,7 +702,7 @@ func printExtrapolatedMergeTimeModelingCallout(ext *cost.ExtrapolatedBreakdown,
686702

687703
// PR tracking: scales with open time
688704
remodelPRTrackingPerPR := 0.0
689-
if targetHours >= 1.0 { // Only track PRs open >= 1 hour
705+
if targetHours >= 1.0 { // Minimal tracking for PRs open >= 1 hour
690706
daysOpen := targetHours / 24.0
691707
remodelPRTrackingHours := (cfg.PRTrackingMinutesPerDay / 60.0) * daysOpen
692708
remodelPRTrackingPerPR = remodelPRTrackingHours * hourlyRate

internal/server/server.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2348,9 +2348,8 @@ func (s *Server) processOrgSampleWithProgress(ctx context.Context, req *OrgSampl
23482348
// processPRsInParallel processes PRs in parallel and sends progress updates via SSE.
23492349
//
23502350
//nolint:revive // line-length/use-waitgroup-go: long function signature acceptable, standard wg pattern
2351-
func (s *Server) processPRsInParallel(workCtx, reqCtx context.Context, samples []github.PRSummary, defaultOwner, defaultRepo, token string, cfg cost.Config, writer http.ResponseWriter) ([]cost.Breakdown, map[string]int) {
2352-
var breakdowns []cost.Breakdown
2353-
aggregatedSeconds := make(map[string]int)
2351+
func (s *Server) processPRsInParallel(workCtx, reqCtx context.Context, samples []github.PRSummary, defaultOwner, defaultRepo, token string, cfg cost.Config, writer http.ResponseWriter) (breakdowns []cost.Breakdown, aggregatedSeconds map[string]int) {
2352+
aggregatedSeconds = make(map[string]int)
23542353
var mu sync.Mutex
23552354
var sseMu sync.Mutex // Protects SSE writes to prevent corrupted chunked encoding
23562355

internal/server/static/README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Static Assets Testing
2+
3+
This directory contains static assets for the prcost web UI, including JavaScript functions that are tested separately.
4+
5+
## JavaScript Testing
6+
7+
Key functions are extracted into separate `.js` files for testing purposes:
8+
9+
- `formatR2RCallout.js` - Renders the Ready-to-Review savings callout
10+
- `formatR2RCallout.test.js` - Tests for the callout rendering
11+
12+
### Running Tests
13+
14+
```bash
15+
# Run JavaScript tests only
16+
make test-js
17+
18+
# Run all tests (Go + JavaScript)
19+
make test
20+
```
21+
22+
### Test Coverage
23+
24+
The JavaScript tests verify:
25+
- Correct rendering of the savings callout HTML
26+
- Proper formatting of dollar amounts ($50K, $2.5M, etc.)
27+
- Presence of key messaging ("Pro-Tip:", "Ready-to-Review", etc.)
28+
- Correct behavior for fast PRs (no callout for ≤1 hour)
29+
- HTML structure and styling
30+
31+
### Adding New Tests
32+
33+
When modifying `index.html` JavaScript functions:
34+
35+
1. Extract the function to a separate `.js` file (if not already extracted)
36+
2. Add tests to the corresponding `.test.js` file
37+
3. Run `make test-js` to verify
38+
4. Commit both the function and test files together

0 commit comments

Comments
 (0)