Skip to content

Commit 4ba99b3

Browse files
Thomas StrombergThomas Stromberg
authored andcommitted
genesis
1 parent 355177e commit 4ba99b3

File tree

8 files changed

+974
-0
lines changed

8 files changed

+974
-0
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,5 @@ go.work.sum
3030
# Editor/IDE
3131
# .idea/
3232
# .vscode/
33+
34+
.claude/

README.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# prcost
2+
3+
Calculate the real-world cost of GitHub pull requests with detailed breakdowns of author effort, participant contributions, and delay costs.
4+
5+
## Installation
6+
7+
```bash
8+
go install github.com/codeGROOVE-dev/prcost/cmd/prcost@latest
9+
```
10+
11+
Or build from source:
12+
13+
```bash
14+
git clone https://github.com/codeGROOVE-dev/prcost
15+
cd prcost
16+
go build -o prcost ./cmd/prcost
17+
```
18+
19+
## Usage
20+
21+
```bash
22+
prcost https://github.com/owner/repo/pull/123
23+
prcost --format json https://github.com/owner/repo/pull/123
24+
prcost --salary 300000 https://github.com/owner/repo/pull/123
25+
```
26+
27+
## Cost Model
28+
29+
This model is based on early research combining COCOMO II effort estimation with session-based time tracking. Individual PR estimates may be under or over-estimated, particularly for delay costs. The recommendation is to use this model across a large pool of PRs and rely on the law of averages for meaningful insights.
30+
31+
### Author Costs
32+
33+
**Code Cost**: Writing effort estimated using COCOMO II: `Effort = 2.94 × (KLOC)^1.0997`. Minimum 20 minutes.
34+
35+
**Code Context Switching**: Cognitive overhead during code writing: `COCOMO hours × 0.2 × sqrt(KLOC)`. Based on Microsoft interruption research (Czerwinski et al., 2004).
36+
37+
**GitHub Time**: Session-based calculation for GitHub interactions (commits, comments).
38+
39+
**GitHub Context Switching**: 20 minutes to context in, 20 minutes to context out per session.
40+
41+
### Participant Costs
42+
43+
**Review Cost**: COCOMO II estimation applied to review comments.
44+
45+
**GitHub Time**: Session-based calculation for GitHub interactions.
46+
47+
**GitHub Context Switching**: Same 20-minute in/out costs per session.
48+
49+
### Delay Costs
50+
51+
**Project Delay (20%)**: Opportunity cost of blocked engineer time: `hourly_rate × duration_hours × 0.20`.
52+
53+
**Code Updates**: Rework cost from code drift. Power-law formula: `driftMultiplier = 1 + (0.03 × days^0.7)`, calibrated to 4% weekly churn. Applies to PRs open 3+ days, capped at 90 days. Based on Windows Vista analysis (Nagappan et al., Microsoft Research, 2008).
54+
55+
**Future GitHub**: Cost for 3 future events (push, review, merge) with full context switching.
56+
57+
External contributors (no write access) receive 50% delay cost reduction.
58+
59+
## Session-Based Time Tracking
60+
61+
Events within 60 minutes are grouped into sessions to model real work patterns. This preserves flow state during continuous work and applies context switching costs only when work is interrupted.
62+
63+
**Example (three events 5 min apart, one session)**:
64+
- Event 1: 20 (in) + 20 (event) + 5 (gap) = 45 min
65+
- Event 2: 5 (gap) + 20 (event) + 5 (gap) = 30 min
66+
- Event 3: 5 (gap) + 20 (event) + 20 (out) = 45 min
67+
- Total: 120 minutes
68+
69+
**Example (two events 90 min apart, two sessions)**:
70+
- Event 1: 20 + 20 + 20 = 60 min
71+
- Event 2: 20 + 20 + 20 = 60 min
72+
- Total: 120 minutes
73+
74+
## License
75+
76+
Apache 2.0

cmd/prcost/main.go

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// Package main implements a CLI tool to calculate the real-world cost of GitHub PRs.
2+
package main
3+
4+
import (
5+
"context"
6+
"encoding/json"
7+
"flag"
8+
"fmt"
9+
"log"
10+
"os"
11+
"os/exec"
12+
"strings"
13+
"time"
14+
15+
"github.com/codeGROOVE-dev/prcost/pkg/cost"
16+
"github.com/codeGROOVE-dev/prcost/pkg/github"
17+
)
18+
19+
func main() {
20+
// Define command-line flags
21+
salary := flag.Float64("salary", 250000, "Annual salary for cost calculation")
22+
benefits := flag.Float64("benefits", 1.3, "Benefits multiplier (1.3 = 30% benefits)")
23+
eventMinutes := flag.Float64("event-minutes", 20, "Minutes per review event")
24+
overheadFactor := flag.Float64("overhead-factor", 0.25, "Delay cost factor (0.25 = 25%)")
25+
format := flag.String("format", "human", "Output format: human or json")
26+
27+
flag.Usage = func() {
28+
fmt.Fprintf(os.Stderr, "Usage: %s [options] <PR_URL>\n\n", os.Args[0])
29+
fmt.Fprintf(os.Stderr, "Calculate the real-world cost of a GitHub pull request.\n\n")
30+
fmt.Fprintf(os.Stderr, "Options:\n")
31+
flag.PrintDefaults()
32+
fmt.Fprintf(os.Stderr, "\nExamples:\n")
33+
fmt.Fprintf(os.Stderr, " %s https://github.com/owner/repo/pull/123\n", os.Args[0])
34+
fmt.Fprintf(os.Stderr, " %s --salary 300000 --benefits 1.4 https://github.com/owner/repo/pull/123\n", os.Args[0])
35+
fmt.Fprintf(os.Stderr, " %s --salary 200000 --benefits 1.25 --event-minutes 30 --format json https://github.com/owner/repo/pull/123\n", os.Args[0])
36+
}
37+
38+
flag.Parse()
39+
40+
// Validate that we have a PR URL
41+
if flag.NArg() != 1 {
42+
flag.Usage()
43+
os.Exit(1)
44+
}
45+
46+
prURL := flag.Arg(0)
47+
48+
// Validate PR URL format
49+
if !strings.HasPrefix(prURL, "https://github.com/") || !strings.Contains(prURL, "/pull/") {
50+
log.Fatalf("Invalid PR URL. Expected format: https://github.com/owner/repo/pull/123")
51+
}
52+
53+
// Create cost configuration from flags
54+
cfg := cost.DefaultConfig()
55+
cfg.AnnualSalary = *salary
56+
cfg.BenefitsMultiplier = *benefits
57+
cfg.MinutesPerEvent = *eventMinutes
58+
cfg.DelayCostFactor = *overheadFactor
59+
60+
// Get GitHub token from gh CLI
61+
ctx := context.Background()
62+
token, err := getGitHubToken(ctx)
63+
if err != nil {
64+
log.Fatalf("Failed to get GitHub token: %v\nPlease ensure 'gh' is installed and authenticated (run 'gh auth login')", err)
65+
}
66+
67+
// Fetch PR data
68+
prData, err := github.FetchPRData(ctx, prURL, token)
69+
if err != nil {
70+
log.Fatalf("Failed to fetch PR data: %v", err)
71+
}
72+
73+
// Calculate costs
74+
breakdown := cost.Calculate(prData, cfg)
75+
76+
// Output in requested format
77+
switch *format {
78+
case "human":
79+
printHumanReadable(breakdown, prURL)
80+
case "json":
81+
printJSON(breakdown)
82+
default:
83+
log.Fatalf("Unknown format: %s (must be human or json)", *format)
84+
}
85+
}
86+
87+
// getGitHubToken retrieves a GitHub token using the gh CLI.
88+
func getGitHubToken(ctx context.Context) (string, error) {
89+
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
90+
defer cancel()
91+
92+
cmd := exec.CommandContext(ctx, "gh", "auth", "token")
93+
output, err := cmd.Output()
94+
if err != nil {
95+
if ctx.Err() == context.DeadlineExceeded {
96+
return "", fmt.Errorf("timeout getting auth token")
97+
}
98+
return "", fmt.Errorf("failed to get auth token (is 'gh' installed and authenticated?): %w", err)
99+
}
100+
101+
token := strings.TrimSpace(string(output))
102+
return token, nil
103+
}
104+
105+
// printHumanReadable outputs a detailed itemized bill in human-readable format.
106+
func printHumanReadable(b cost.Breakdown, prURL string) {
107+
fmt.Printf("PULL REQUEST COST ANALYSIS\n")
108+
fmt.Printf("==========================\n\n")
109+
fmt.Printf("PR URL: %s\n", prURL)
110+
fmt.Printf("Hourly Rate: $%.2f ($%.0f salary * %.1fX total benefits multiplier)\n\n",
111+
b.HourlyRate, b.AnnualSalary, b.BenefitsMultiplier)
112+
113+
// Author Costs
114+
fmt.Printf("AUTHOR COSTS\n")
115+
fmt.Printf(" Code Cost (COCOMO) $%10.2f (%d LOC, %.2f hrs)\n",
116+
b.Author.CodeCost, b.Author.LinesAdded, b.Author.CodeHours)
117+
fmt.Printf(" Code Context Switching $%10.2f (%.2f hrs)\n",
118+
b.Author.CodeContextCost, b.Author.CodeContextHours)
119+
fmt.Printf(" GitHub Time $%10.2f (%d events, %.2f hrs)\n",
120+
b.Author.GitHubCost, b.Author.Events, b.Author.GitHubHours)
121+
fmt.Printf(" GitHub Context Switching $%10.2f (%d sessions, %.2f hrs)\n",
122+
b.Author.GitHubContextCost, b.Author.Sessions, b.Author.GitHubContextHours)
123+
fmt.Printf(" ---\n")
124+
fmt.Printf(" Author Subtotal $%10.2f (%.2f hrs total)\n\n",
125+
b.Author.TotalCost, b.Author.TotalHours)
126+
127+
// Participant Costs
128+
if len(b.Participants) > 0 {
129+
fmt.Printf("PARTICIPANT COSTS\n")
130+
for _, p := range b.Participants {
131+
fmt.Printf(" %s\n", p.Actor)
132+
fmt.Printf(" Event Time $%10.2f (%d events, %.2f hrs)\n",
133+
p.GitHubCost, p.Events, p.GitHubHours)
134+
fmt.Printf(" Context Switching $%10.2f (%d sessions, %.2f hrs)\n",
135+
p.GitHubContextCost, p.Sessions, p.GitHubContextHours)
136+
fmt.Printf(" Subtotal $%10.2f (%.2f hrs total)\n",
137+
p.TotalCost, p.TotalHours)
138+
}
139+
140+
// Sum all participant costs
141+
var totalParticipantCost float64
142+
var totalParticipantHours float64
143+
for _, p := range b.Participants {
144+
totalParticipantCost += p.TotalCost
145+
totalParticipantHours += p.TotalHours
146+
}
147+
fmt.Printf(" ---\n")
148+
fmt.Printf(" Participants Subtotal $%10.2f (%.2f hrs total)\n\n",
149+
totalParticipantCost, totalParticipantHours)
150+
}
151+
152+
// Delay Cost
153+
fmt.Printf("DELAY COST\n")
154+
if b.DelayCapped {
155+
fmt.Printf(" Project Delay (20%%) $%10.2f (%.0f hrs, capped at 90 days)\n",
156+
b.DelayCostDetail.ProjectDelayCost, b.DelayCostDetail.ProjectDelayHours)
157+
} else {
158+
fmt.Printf(" Project Delay (20%%) $%10.2f (%.2f hrs)\n",
159+
b.DelayCostDetail.ProjectDelayCost, b.DelayCostDetail.ProjectDelayHours)
160+
}
161+
162+
if b.DelayCostDetail.ReworkPercentage > 0 {
163+
fmt.Printf(" Code Updates (%.0f%% rework) $%10.2f (%.2f hrs)\n",
164+
b.DelayCostDetail.ReworkPercentage*100, b.DelayCostDetail.CodeUpdatesCost, b.DelayCostDetail.CodeUpdatesHours)
165+
}
166+
167+
fmt.Printf(" Future GitHub (3 events) $%10.2f (%.2f hrs)\n",
168+
b.DelayCostDetail.FutureGitHubCost, b.DelayCostDetail.FutureGitHubHours)
169+
fmt.Printf(" ---\n")
170+
171+
if b.DelayCapped {
172+
fmt.Printf(" Total Delay Cost $%10.2f (actual: %.0f hours open)\n\n",
173+
b.DelayCost, b.DelayHours)
174+
} else {
175+
fmt.Printf(" Total Delay Cost $%10.2f\n\n", b.DelayCost)
176+
}
177+
178+
// Total
179+
fmt.Printf("==========================\n")
180+
fmt.Printf("TOTAL COST $%10.2f\n", b.TotalCost)
181+
fmt.Printf("==========================\n")
182+
}
183+
184+
// printJSON outputs the cost breakdown in JSON format.
185+
func printJSON(b cost.Breakdown) {
186+
encoder := json.NewEncoder(os.Stdout)
187+
encoder.SetIndent("", " ")
188+
if err := encoder.Encode(b); err != nil {
189+
log.Fatalf("Failed to encode JSON: %v", err)
190+
}
191+
}

go.mod

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
module github.com/codeGROOVE-dev/prcost
2+
3+
go 1.25.3
4+
5+
require github.com/codeGROOVE-dev/prx v0.0.0-20251016165946-00c6c6e90c29
6+
7+
require github.com/codeGROOVE-dev/retry v1.2.0 // indirect

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
github.com/codeGROOVE-dev/prx v0.0.0-20251016165946-00c6c6e90c29 h1:MSBy3Ywr3ky/LXhDSFbeJXDdAsfMMrzNdMNehyTvSuA=
2+
github.com/codeGROOVE-dev/prx v0.0.0-20251016165946-00c6c6e90c29/go.mod h1:7qLbi18baOyS8yO/6/64SBIqtyzSzLFdsDST15NPH3w=
3+
github.com/codeGROOVE-dev/retry v1.2.0 h1:xYpYPX2PQZmdHwuiQAGGzsBm392xIMl4nfMEFApQnu8=
4+
github.com/codeGROOVE-dev/retry v1.2.0/go.mod h1:8OgefgV1XP7lzX2PdKlCXILsYKuz6b4ZpHa/20iLi8E=

pkg/cocomo/cocomo.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Package cocomo implements COCOMO II effort estimation for software projects.
2+
// COCOMO (Constructive Cost Model) estimates development effort based on lines of code.
3+
package cocomo
4+
5+
import (
6+
"math"
7+
"time"
8+
)
9+
10+
// Config holds parameters for COCOMO II effort estimation.
11+
// These defaults are based on the COCOMO II model for organic projects.
12+
type Config struct {
13+
// Multiplier is the base effort coefficient (default: 2.94)
14+
Multiplier float64
15+
16+
// Exponent is the scale factor (default: 1.0997)
17+
Exponent float64
18+
19+
// MinimumEffort is the minimum effort in minutes (default: 20)
20+
MinimumEffort time.Duration
21+
}
22+
23+
// DefaultConfig returns COCOMO II configuration with standard values.
24+
func DefaultConfig() Config {
25+
return Config{
26+
Multiplier: 2.94,
27+
Exponent: 1.0997,
28+
MinimumEffort: 20 * time.Minute,
29+
}
30+
}
31+
32+
// EstimateEffort calculates development effort based on lines of code.
33+
//
34+
// The formula used is: Effort = Multiplier × (KLOC)^Exponent
35+
// where KLOC is thousands of lines of code.
36+
//
37+
// The result is in person-months, which we convert to hours by multiplying by 152
38+
// (a standard industry conversion: 1 person-month = 152 hours).
39+
//
40+
// Parameters:
41+
// - linesOfCode: The number of lines of code written
42+
// - cfg: COCOMO configuration parameters
43+
//
44+
// Returns:
45+
// - Effort in hours (never less than config.MinimumEffort)
46+
func EstimateEffort(linesOfCode int, cfg Config) time.Duration {
47+
// Convert lines of code to thousands of lines (KLOC)
48+
kloc := float64(linesOfCode) / 1000.0
49+
50+
// Apply COCOMO II formula: Effort = Multiplier × (KLOC)^Exponent
51+
// Result is in person-months
52+
personMonths := cfg.Multiplier * math.Pow(kloc, cfg.Exponent)
53+
54+
// Convert person-months to hours (1 person-month = 152 hours)
55+
const hoursPerPersonMonth = 152.0
56+
hours := personMonths * hoursPerPersonMonth
57+
58+
// Convert to duration
59+
effort := time.Duration(hours * float64(time.Hour))
60+
61+
// Apply minimum effort floor
62+
if effort < cfg.MinimumEffort {
63+
return cfg.MinimumEffort
64+
}
65+
66+
return effort
67+
}

0 commit comments

Comments
 (0)