|
| 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 | +} |
0 commit comments