Skip to content

Commit 91321ad

Browse files
Thomas StrombergThomas Stromberg
authored andcommitted
Improve project delay calculations
1 parent a16c324 commit 91321ad

File tree

4 files changed

+68
-23
lines changed

4 files changed

+68
-23
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ This model is based on early research combining COCOMO II effort estimation with
4848

4949
### Delay Costs
5050

51-
**Project Delay (20%)**: Opportunity cost of blocked engineer time: `hourly_rate × duration_hours × 0.20`. Capped at 60 days (2 months).
51+
**Project Delay (20%)**: Opportunity cost of blocked engineer time: `hourly_rate × duration_hours × 0.20`. Capped at 2 weeks after the last event, with an absolute maximum of 90 days.
5252

5353
**Code Updates**: Rework cost from code drift. Probability-based formula: `drift = 1 - (0.96)^(days/7)`, modeling the cumulative probability that code becomes stale with 4% weekly churn. Applies to PRs open 3+ days, capped at 90 days (~41% max drift). Based on Windows Vista analysis (Nagappan et al., Microsoft Research, 2008).
5454

cmd/prcost/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ func printHumanReadable(breakdown *cost.Breakdown, prURL string) {
184184
// Delay Cost
185185
fmt.Println("DELAY COST")
186186
if breakdown.DelayCapped {
187-
fmt.Printf(" %-32s $%10.2f (%.0f hrs, capped at 60 days)\n",
187+
fmt.Printf(" %-32s $%10.2f (%.0f hrs, capped)\n",
188188
"Project Delay (20%)", breakdown.DelayCostDetail.ProjectDelayCost, breakdown.DelayCostDetail.ProjectDelayHours)
189189
} else {
190190
fmt.Printf(" %-32s $%10.2f (%.2f hrs)\n",

pkg/cost/cost.go

Lines changed: 60 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,12 @@ type Config struct {
3636
// This represents the opportunity cost of having a PR open
3737
DelayCostFactor float64
3838

39-
// Maximum duration for project delay cost calculation (default: 60 days / 2 months)
40-
// Project delay is capped at this duration to avoid unrealistic costs
39+
// Maximum time after last event to count for project delay (default: 14 days / 2 weeks)
40+
// Only counts delay costs up to this many days after the last event on the PR
41+
MaxDelayAfterLastEvent time.Duration
42+
43+
// Maximum total project delay duration (default: 90 days / 3 months)
44+
// Absolute cap on project delay costs regardless of PR age
4145
MaxProjectDelay time.Duration
4246

4347
// Maximum duration for code drift calculation (default: 90 days / 3 months)
@@ -51,16 +55,17 @@ type Config struct {
5155
// DefaultConfig returns reasonable defaults for cost calculation.
5256
func DefaultConfig() Config {
5357
return Config{
54-
AnnualSalary: 250000.0,
55-
BenefitsMultiplier: 1.3,
56-
HoursPerYear: 2080.0,
57-
EventDuration: 20 * time.Minute,
58-
ContextSwitchDuration: 20 * time.Minute,
59-
SessionGapThreshold: 60 * time.Minute,
60-
DelayCostFactor: 0.20,
61-
MaxProjectDelay: 60 * 24 * time.Hour, // 60 days
62-
MaxCodeDrift: 90 * 24 * time.Hour, // 90 days
63-
COCOMO: cocomo.DefaultConfig(),
58+
AnnualSalary: 250000.0,
59+
BenefitsMultiplier: 1.3,
60+
HoursPerYear: 2080.0,
61+
EventDuration: 20 * time.Minute,
62+
ContextSwitchDuration: 20 * time.Minute,
63+
SessionGapThreshold: 60 * time.Minute,
64+
DelayCostFactor: 0.20,
65+
MaxDelayAfterLastEvent: 14 * 24 * time.Hour, // 14 days (2 weeks) after last event
66+
MaxProjectDelay: 90 * 24 * time.Hour, // 90 days absolute max
67+
MaxCodeDrift: 90 * 24 * time.Hour, // 90 days
68+
COCOMO: cocomo.DefaultConfig(),
6469
}
6570
}
6671

@@ -159,7 +164,7 @@ type Breakdown struct {
159164
// Total cost (sum of all components)
160165
TotalCost float64
161166

162-
// True if project delay was capped at 60 days (2 months)
167+
// True if project delay was capped (either by 2 weeks after last event or 90 days total)
163168
DelayCapped bool
164169
}
165170

@@ -185,16 +190,52 @@ func Calculate(data PRData, cfg Config) Breakdown {
185190
}
186191
delayDays := delayHours / 24.0
187192

188-
// Cap Project Delay at configured maximum (default: 60 days / 2 months)
189-
maxHrs := cfg.MaxProjectDelay.Hours()
193+
// Find the last event timestamp to determine time since last activity
194+
var lastEventTime time.Time
195+
if len(data.Events) > 0 {
196+
// Find the most recent event
197+
lastEventTime = data.Events[0].Timestamp
198+
for _, event := range data.Events {
199+
if event.Timestamp.After(lastEventTime) {
200+
lastEventTime = event.Timestamp
201+
}
202+
}
203+
} else {
204+
// No events, use CreatedAt
205+
lastEventTime = data.CreatedAt
206+
}
207+
208+
// Calculate time since last event
209+
timeSinceLastEvent := data.UpdatedAt.Sub(lastEventTime).Hours()
210+
if timeSinceLastEvent < 0 {
211+
timeSinceLastEvent = 0
212+
}
213+
214+
// Cap Project Delay in two ways:
215+
// 1. Only count up to MaxDelayAfterLastEvent (default: 14 days) after the last event
216+
// 2. Absolute maximum of MaxProjectDelay (default: 90 days) total
190217
var capped bool
191218
var cappedHrs float64
192219

193-
if delayHours > maxHrs {
220+
cappedHrs = delayHours
221+
222+
// First, apply the "2 weeks after last event" cap
223+
maxAfterEvent := cfg.MaxDelayAfterLastEvent.Hours()
224+
if timeSinceLastEvent > maxAfterEvent {
225+
// Reduce delay by the excess time since last event
226+
excessHours := timeSinceLastEvent - maxAfterEvent
227+
cappedHrs = delayHours - excessHours
228+
if cappedHrs < 0 {
229+
cappedHrs = 0
230+
}
231+
capped = true
232+
}
233+
234+
// Second, apply the absolute maximum cap
235+
maxTotal := cfg.MaxProjectDelay.Hours()
236+
if cappedHrs > maxTotal {
237+
cappedHrs = maxTotal
194238
capped = true
195-
cappedHrs = maxHrs
196-
} else {
197-
cappedHrs = delayHours
198239
}
199240

200241
// 1. Project Delay: Configured percentage (default 20%) of engineer time

pkg/cost/cost_test.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,12 @@ func TestDefaultConfig(t *testing.T) {
3131
t.Errorf("Expected delay cost factor 0.20, got %.2f", cfg.DelayCostFactor)
3232
}
3333

34-
if cfg.MaxProjectDelay != 60*24*time.Hour {
35-
t.Errorf("Expected 60 days max project delay, got %v", cfg.MaxProjectDelay)
34+
if cfg.MaxDelayAfterLastEvent != 14*24*time.Hour {
35+
t.Errorf("Expected 14 days max delay after last event, got %v", cfg.MaxDelayAfterLastEvent)
36+
}
37+
38+
if cfg.MaxProjectDelay != 90*24*time.Hour {
39+
t.Errorf("Expected 90 days max project delay, got %v", cfg.MaxProjectDelay)
3640
}
3741

3842
if cfg.MaxCodeDrift != 90*24*time.Hour {

0 commit comments

Comments
 (0)