Skip to content

Commit 73edd32

Browse files
committed
Improve COCOMO floor
1 parent a66cca9 commit 73edd32

13 files changed

Lines changed: 503 additions & 305 deletions

File tree

Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,7 @@ fix:
6767
exit $$exit_code
6868

6969
# END: lint-install .
70+
71+
.PHONY: deploy
72+
deploy:
73+
./hacks/deploy.sh cmd/server/

README.md

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Calculate the real-world cost of GitHub pull requests with detailed breakdowns o
88
$ prcost https://github.com/chainguard-dev/apko/pull/1860
99
1010
https://github.com/chainguard-dev/apko/pull/1860
11-
Rate: $156.25/hr • Salary: $250,000.00 • Benefits: 1.3x
11+
Rate: $155.71/hr • Salary: $249,000.00 • Benefits: 1.3x
1212
1313
Author
1414
──────
@@ -31,8 +31,8 @@ $ prcost https://github.com/chainguard-dev/apko/pull/1860
3131
3232
Merge Delay
3333
───────────
34-
Cost of Delay $9,481.36 2.5 days (capped)
35-
Cognitive Load $3,160.45 20.2 hrs (capped)
34+
Delivery $9,481.36 2.5 days (capped)
35+
Coordination $3,160.45 20.2 hrs (capped)
3636
────────────
3737
Subtotal $12,641.81 3.4 days
3838
@@ -170,6 +170,8 @@ func main() {
170170

171171
This model synthesizes empirical research from software engineering economics, cognitive psychology, and organizational behavior to estimate the comprehensive cost of pull request workflows. Individual PR estimates exhibit variance due to developer heterogeneity and project characteristics; statistical validity improves with aggregate analysis across larger samples (n ≥ 25).
172172

173+
**Default Salary**: The model uses $249,000 as the default annual salary, based on the 2025 average for Staff Software Engineers per [Glassdoor](https://www.glassdoor.com/Salaries/staff-software-engineer-salary-SRCH_KO0,23.htm). This can be customized via the `--salary` flag or API configuration.
174+
173175
### 1. Development Effort
174176

175177
**Method**: COCOMO II (COnstructive COst MOdel) effort estimation [1]
@@ -273,15 +275,11 @@ Where:
273275

274276
**Scientific Justification**:
275277

276-
**Cognitive Load**: Each unmerged PR consumes working memory capacity. Cognitive Load Theory shows that human working memory is limited to 7±2 items [7]. Developers must mentally track:
277-
1. The PR's current state and review status
278-
2. Potential merge conflicts with ongoing work
279-
3. Dependencies on or from the unmerged code
280-
4. Communication overhead coordinating with reviewers
278+
**Working Memory Burden**: Each unmerged PR consumes limited working memory capacity. Developers must mentally track the PR's current state, potential merge conflicts, dependencies, and communication with reviewers. Cognitive Load Theory shows human working memory is limited to 7±2 items [7], making this tracking overhead measurable.
281279

282280
**Context Retention Costs**: Weinberg's research on programmer productivity shows 20-25% productivity loss from maintaining multiple mental contexts [8]. The 5% coordination factor captures the cost of "keeping tabs" on pending work rather than the cost of full context switches (which are accounted separately).
283281

284-
**Team Communication Overhead**: Brooks' Law demonstrates that communication overhead grows with project complexity [9]. Unmerged PRs increase coordination burden as team members must track dependencies and potential conflicts.
282+
**Team Communication Overhead**: Brooks' Law demonstrates that communication overhead grows with project complexity [9]. Unmerged PRs increase coordination burden as team members must track dependencies and potential conflicts across the team.
285283

286284
**References**:
287285
[7] Sweller, J., van Merriënboer, J. J., & Paas, F. (1998). Cognitive Architecture and Instructional Design. *Educational Psychology Review*, 10(3), 251-296. DOI: 10.1023/A:1022193728205

cmd/prcost/main.go

Lines changed: 86 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,16 @@ import (
2020

2121
func main() {
2222
// Define command-line flags
23-
salary := flag.Float64("salary", 250000, "Annual salary for cost calculation")
23+
salary := flag.Float64("salary", 249000, "Annual salary for cost calculation")
2424
benefits := flag.Float64("benefits", 1.3, "Benefits multiplier (1.3 = 30% benefits)")
25-
eventMinutes := flag.Float64("event-minutes", 20, "Minutes per review event")
25+
eventMinutes := flag.Float64("event-minutes", 10, "Minutes per GitHub event (commits, comments, etc.)")
2626
format := flag.String("format", "human", "Output format: human or json")
2727
verbose := flag.Bool("verbose", false, "Show verbose logging output")
2828

2929
// Org/Repo sampling flags
3030
org := flag.String("org", "", "GitHub organization to analyze (optionally with --repo for single repo)")
3131
repo := flag.String("repo", "", "GitHub repository to analyze (requires --org)")
32-
samples := flag.Int("samples", 25, "Number of PRs to sample for extrapolation")
32+
samples := flag.Int("samples", 20, "Number of PRs to sample for extrapolation")
3333
days := flag.Int("days", 90, "Number of days to look back for PR modifications")
3434

3535
flag.Usage = func() {
@@ -43,13 +43,13 @@ func main() {
4343
fmt.Fprint(os.Stderr, "Options:\n")
4444
flag.PrintDefaults()
4545
fmt.Fprint(os.Stderr, "\nExamples:\n")
46-
fmt.Fprintf(os.Stderr, " Single PR:\n")
46+
fmt.Fprint(os.Stderr, " Single PR:\n")
4747
fmt.Fprintf(os.Stderr, " %s https://github.com/owner/repo/pull/123\n", os.Args[0])
4848
fmt.Fprintf(os.Stderr, " %s --salary 300000 https://github.com/owner/repo/pull/123\n\n", os.Args[0])
49-
fmt.Fprintf(os.Stderr, " Repository analysis:\n")
49+
fmt.Fprint(os.Stderr, " Repository analysis:\n")
5050
fmt.Fprintf(os.Stderr, " %s --org kubernetes --repo kubernetes\n", os.Args[0])
5151
fmt.Fprintf(os.Stderr, " %s --org myorg --repo myrepo --samples 50 --days 30\n\n", os.Args[0])
52-
fmt.Fprintf(os.Stderr, " Organization-wide analysis:\n")
52+
fmt.Fprint(os.Stderr, " Organization-wide analysis:\n")
5353
fmt.Fprintf(os.Stderr, " %s --org chainguard-dev\n", os.Args[0])
5454
fmt.Fprintf(os.Stderr, " %s --org myorg --samples 100 --days 60\n", os.Args[0])
5555
}
@@ -74,13 +74,13 @@ func main() {
7474
// Validate mode selection
7575
// First check if --repo is specified without --org
7676
if *repo != "" && *org == "" {
77-
fmt.Fprintf(os.Stderr, "Error: --repo requires --org to be specified\n\n")
77+
fmt.Fprint(os.Stderr, "Error: --repo requires --org to be specified\n\n")
7878
flag.Usage()
7979
os.Exit(1)
8080
}
8181

8282
if orgMode && singlePRMode {
83-
fmt.Fprintf(os.Stderr, "Error: Cannot use both --org and PR URL. Choose one mode.\n\n")
83+
fmt.Fprint(os.Stderr, "Error: Cannot use both --org and PR URL. Choose one mode.\n\n")
8484
flag.Usage()
8585
os.Exit(1)
8686
}
@@ -210,25 +210,45 @@ func printHumanReadable(breakdown *cost.Breakdown, prURL string) {
210210
// Header with PR info
211211
fmt.Println()
212212
fmt.Printf(" %s\n", prURL)
213+
214+
// Show author and duration
215+
authorLabel := breakdown.PRAuthor
216+
if breakdown.AuthorBot {
217+
authorLabel += " (bot)"
218+
}
219+
fmt.Printf(" Author: %s • Open: %s\n", authorLabel, formatTimeUnit(breakdown.PRDuration))
213220
fmt.Printf(" Rate: %s/hr • Salary: %s • Benefits: %.1fx\n",
214221
formatCurrency(breakdown.HourlyRate),
215222
formatCurrency(breakdown.AnnualSalary),
216223
breakdown.BenefitsMultiplier)
217224
fmt.Println()
218225

219-
// Author Costs
220-
fmt.Println(" Author")
221-
fmt.Println(" ──────")
222-
fmt.Printf(" Development Effort %12s %d LOC • %s\n",
223-
formatCurrency(breakdown.Author.CodeCost), breakdown.Author.LinesAdded, formatTimeUnit(breakdown.Author.CodeHours))
224-
fmt.Printf(" GitHub Activity %12s %d sessions • %s\n",
225-
formatCurrency(breakdown.Author.GitHubCost), breakdown.Author.Sessions, formatTimeUnit(breakdown.Author.GitHubHours))
226-
fmt.Printf(" GitHub Context Switching %12s %s\n",
227-
formatCurrency(breakdown.Author.GitHubContextCost), formatTimeUnit(breakdown.Author.GitHubContextHours))
228-
fmt.Println(" ────────────")
229-
fmt.Printf(" Subtotal %12s %s\n",
230-
formatCurrency(breakdown.Author.TotalCost), formatTimeUnit(breakdown.Author.TotalHours))
231-
fmt.Println()
226+
// Author Costs (skip entire section if no costs)
227+
if breakdown.Author.TotalCost > 0 {
228+
fmt.Println(" Development Cost")
229+
fmt.Println(" ────────────────")
230+
// Show development and adaptation separately (only if there are actual lines of code)
231+
if breakdown.Author.NewLines > 0 {
232+
fmt.Printf(" New Development %12s %d LOC • %s\n",
233+
formatCurrency(breakdown.Author.NewCodeCost), breakdown.Author.NewLines, formatTimeUnit(breakdown.Author.NewCodeHours))
234+
}
235+
if breakdown.Author.ModifiedLines > 0 {
236+
fmt.Printf(" Adaptation %12s %d LOC • %s\n",
237+
formatCurrency(breakdown.Author.AdaptationCost), breakdown.Author.ModifiedLines, formatTimeUnit(breakdown.Author.AdaptationHours))
238+
}
239+
if breakdown.Author.GitHubHours > 0 {
240+
fmt.Printf(" GitHub Activity %12s %d sessions • %s\n",
241+
formatCurrency(breakdown.Author.GitHubCost), breakdown.Author.Sessions, formatTimeUnit(breakdown.Author.GitHubHours))
242+
}
243+
if breakdown.Author.GitHubContextHours > 0 {
244+
fmt.Printf(" GitHub Context Switching %12s %s\n",
245+
formatCurrency(breakdown.Author.GitHubContextCost), formatTimeUnit(breakdown.Author.GitHubContextHours))
246+
}
247+
fmt.Println(" ────────────")
248+
fmt.Printf(" Subtotal %12s %s\n",
249+
formatCurrency(breakdown.Author.TotalCost), formatTimeUnit(breakdown.Author.TotalHours))
250+
fmt.Println()
251+
}
232252

233253
// Participant Costs
234254
if len(breakdown.Participants) > 0 {
@@ -240,14 +260,25 @@ func printHumanReadable(breakdown *cost.Breakdown, prURL string) {
240260
totalParticipantHours += p.TotalHours
241261
}
242262

243-
fmt.Println(" Participants")
244-
fmt.Println(" ────────────")
263+
fmt.Println(" Participant Cost")
264+
fmt.Println(" ────────────────")
245265
for _, p := range breakdown.Participants {
246266
fmt.Printf(" %s\n", p.Actor)
247-
fmt.Printf(" Review Activity %12s %d sessions • %s\n",
248-
formatCurrency(p.GitHubCost), p.Sessions, formatTimeUnit(p.GitHubHours))
249-
fmt.Printf(" Context Switching %12s %s\n",
250-
formatCurrency(p.GitHubContextCost), formatTimeUnit(p.GitHubContextHours))
267+
// Only show review activity if they reviewed (LOC-based)
268+
if p.ReviewHours > 0 {
269+
fmt.Printf(" Review Activity %12s %s\n",
270+
formatCurrency(p.ReviewCost), formatTimeUnit(p.ReviewHours))
271+
}
272+
// Only show other events if they had non-review events
273+
if p.GitHubHours > 0 {
274+
fmt.Printf(" GitHub Events %12s %d sessions • %s\n",
275+
formatCurrency(p.GitHubCost), p.Sessions, formatTimeUnit(p.GitHubHours))
276+
}
277+
// Always show context switching if there were sessions
278+
if p.Sessions > 0 {
279+
fmt.Printf(" Context Switching %12s %s\n",
280+
formatCurrency(p.GitHubContextCost), formatTimeUnit(p.GitHubContextHours))
281+
}
251282
}
252283
fmt.Println(" ────────────")
253284
fmt.Printf(" Subtotal %12s %s\n",
@@ -256,28 +287,32 @@ func printHumanReadable(breakdown *cost.Breakdown, prURL string) {
256287
}
257288

258289
// Merge Delay Costs
259-
fmt.Println(" Merge Delay")
290+
fmt.Println(" Delay Costs")
260291
fmt.Println(" ───────────")
261-
if breakdown.DelayCapped {
262-
fmt.Printf(" Cost of Delay %12s %s (capped)\n",
263-
formatCurrency(breakdown.DelayCostDetail.DeliveryDelayCost), formatTimeUnit(breakdown.DelayCostDetail.DeliveryDelayHours))
264-
} else {
265-
fmt.Printf(" Cost of Delay %12s %s\n",
266-
formatCurrency(breakdown.DelayCostDetail.DeliveryDelayCost), formatTimeUnit(breakdown.DelayCostDetail.DeliveryDelayHours))
292+
if breakdown.DelayCostDetail.DeliveryDelayHours > 0 {
293+
if breakdown.DelayCapped {
294+
fmt.Printf(" Delivery %12s %s (capped)\n",
295+
formatCurrency(breakdown.DelayCostDetail.DeliveryDelayCost), formatTimeUnit(breakdown.DelayCostDetail.DeliveryDelayHours))
296+
} else {
297+
fmt.Printf(" Delivery %12s %s\n",
298+
formatCurrency(breakdown.DelayCostDetail.DeliveryDelayCost), formatTimeUnit(breakdown.DelayCostDetail.DeliveryDelayHours))
299+
}
267300
}
268301

269-
if breakdown.DelayCapped {
270-
fmt.Printf(" Cognitive Load %12s %s (capped)\n",
271-
formatCurrency(breakdown.DelayCostDetail.CoordinationCost), formatTimeUnit(breakdown.DelayCostDetail.CoordinationHours))
272-
} else {
273-
fmt.Printf(" Cognitive Load %12s %s\n",
274-
formatCurrency(breakdown.DelayCostDetail.CoordinationCost), formatTimeUnit(breakdown.DelayCostDetail.CoordinationHours))
302+
if breakdown.DelayCostDetail.CoordinationHours > 0 {
303+
if breakdown.DelayCapped {
304+
fmt.Printf(" Coordination %12s %s (capped)\n",
305+
formatCurrency(breakdown.DelayCostDetail.CoordinationCost), formatTimeUnit(breakdown.DelayCostDetail.CoordinationHours))
306+
} else {
307+
fmt.Printf(" Coordination %12s %s\n",
308+
formatCurrency(breakdown.DelayCostDetail.CoordinationCost), formatTimeUnit(breakdown.DelayCostDetail.CoordinationHours))
309+
}
275310
}
276311

277312
mergeDelayCost := breakdown.DelayCostDetail.DeliveryDelayCost + breakdown.DelayCostDetail.CoordinationCost
278313
mergeDelayHours := breakdown.DelayCostDetail.DeliveryDelayHours + breakdown.DelayCostDetail.CoordinationHours
279314
fmt.Println(" ────────────")
280-
fmt.Printf(" Subtotal %12s %s\n",
315+
fmt.Printf(" Subtotal %12s %s\n",
281316
formatCurrency(mergeDelayCost), formatTimeUnit(mergeDelayHours))
282317
fmt.Println()
283318

@@ -292,24 +327,28 @@ func printHumanReadable(breakdown *cost.Breakdown, prURL string) {
292327
fmt.Println(" ────────────")
293328

294329
if breakdown.DelayCostDetail.ReworkPercentage > 0 {
295-
fmt.Printf(" Code Churn (%.0f%% drift) %12s %s\n",
296-
breakdown.DelayCostDetail.ReworkPercentage,
330+
label := fmt.Sprintf("Code Churn (%.0f%% drift)", breakdown.DelayCostDetail.ReworkPercentage)
331+
fmt.Printf(" %-24s%12s %s\n",
332+
label,
297333
formatCurrency(breakdown.DelayCostDetail.CodeChurnCost),
298334
formatTimeUnit(breakdown.DelayCostDetail.CodeChurnHours))
299335
}
300336

301337
if breakdown.DelayCostDetail.FutureReviewCost > 0 {
302-
fmt.Printf(" Review %12s %s\n",
338+
fmt.Printf(" %-24s%12s %s\n",
339+
"Review",
303340
formatCurrency(breakdown.DelayCostDetail.FutureReviewCost), formatTimeUnit(breakdown.DelayCostDetail.FutureReviewHours))
304341
}
305342

306343
if breakdown.DelayCostDetail.FutureMergeCost > 0 {
307-
fmt.Printf(" Merge %12s %s\n",
344+
fmt.Printf(" %-24s%12s %s\n",
345+
"Merge",
308346
formatCurrency(breakdown.DelayCostDetail.FutureMergeCost), formatTimeUnit(breakdown.DelayCostDetail.FutureMergeHours))
309347
}
310348

311349
if breakdown.DelayCostDetail.FutureContextCost > 0 {
312-
fmt.Printf(" Context Switching %12s %s\n",
350+
fmt.Printf(" %-24s%12s %s\n",
351+
"Context Switching",
313352
formatCurrency(breakdown.DelayCostDetail.FutureContextCost), formatTimeUnit(breakdown.DelayCostDetail.FutureContextHours))
314353
}
315354

@@ -322,7 +361,7 @@ func printHumanReadable(breakdown *cost.Breakdown, prURL string) {
322361
breakdown.DelayCostDetail.FutureMergeHours +
323362
breakdown.DelayCostDetail.FutureContextHours
324363
fmt.Println(" ────────────")
325-
fmt.Printf(" Subtotal %12s %s\n",
364+
fmt.Printf(" Subtotal %12s %s\n",
326365
formatCurrency(futureCost), formatTimeUnit(futureHours))
327366
fmt.Println()
328367
}

0 commit comments

Comments
 (0)