lib/App/GHGen/CostEstimator.pm

Structural Coverage (Approximate)

TER1 (Statement): 100.00%
TER2 (Branch): 97.44%
TER3 (LCSAJ): 100.0% (9/9)
Approximate LCSAJ segments: 79

LCSAJ Legend

โ— Covered โ€” this LCSAJ path was executed during testing.

โ— Not covered โ€” this LCSAJ path was never executed. These are the paths to focus on.

Multiple dots on a line indicate that multiple control-flow paths begin at that line. Hovering over any dot shows:

        start โ†’ end โ†’ jump
        

Uncovered paths show [NOT COVERED] in the tooltip.

Mutant Testing Legend

Survived (tests missed this) Killed (tests detected this) No mutation
    1: package App::GHGen::CostEstimator;
    2: 
    3: use v5.36;
    4: use strict;
    5: use warnings;
    6: use YAML::XS qw(LoadFile);
    7: use Path::Tiny;
    8: 
    9: use Exporter 'import';
   10: our @EXPORT_OK = qw(
   11: 	estimate_current_usage
   12: 	estimate_savings
   13: 	estimate_workflow_cost
   14: );
   15: 
   16: our $VERSION = '0.10';
   17: 
   18: =head1 NAME
   19: 
   20: App::GHGen::CostEstimator - Estimate CI costs and savings
   21: 
   22: =head1 SYNOPSIS
   23: 
   24:     use App::GHGen::CostEstimator qw(estimate_current_usage);
   25: 
   26:     my $estimate = estimate_current_usage(\@workflows);
   27: 
   28: =head1 FUNCTIONS
   29: 
   30: =head2 estimate_current_usage($workflows)
   31: 
   32: Estimate current monthly CI usage based on workflow configurations.
   33: 
   34: =head3 Purpose
   35: 
   36: Load and analyse every workflow file in C<$workflows>, then aggregate
   37: estimated runs, minutes, and cost into a single summary hash.
   38: 
   39: =head3 Arguments
   40: 
   41: =over 4
   42: 
   43: =item C<$workflows> (ArrayRef[Path::Tiny], required)
   44: 
   45: Array reference of L<Path::Tiny> objects pointing to YAML workflow files.
   46: Each file is loaded with C<YAML::XS::LoadFile>.
   47: 
   48: =back
   49: 
   50: =head3 Returns
   51: 
   52: A hash reference with keys:
   53: 
   54:     {
   55:         total_minutes    => Num,
   56:         billable_minutes => Num,   # 0 when within free tier (2 000 min/month)
   57:         monthly_cost     => Num,   # USD; 0 when within free tier
   58:         workflows        => ArrayRef[ estimate_workflow_cost result ],
   59:     }
   60: 
   61: =head3 Side Effects
   62: 
   63: Reads each workflow file from disk via C<YAML::XS::LoadFile>.
   64: 
   65: =head3 Usage Example
   66: 
   67:     use App::GHGen::CostEstimator qw(estimate_current_usage);
   68:     use App::GHGen::Analyzer      qw(find_workflows);
   69: 
   70:     my @wfs   = find_workflows();
   71:     my $usage = estimate_current_usage(\@wfs);
   72:     printf "Monthly cost: \$%.2f\n", $usage->{monthly_cost};
   73: 
   74: =head3 API SPECIFICATION
   75: 
   76: =head4 Input
   77: 
   78:     { workflows => { type => 'arrayref', required => 1 } }
   79: 
   80: =head4 Output
   81: 
   82:     {
   83:         type => 'hashref',
   84:         keys => {
   85:             total_minutes    => { type => 'scalar' },
   86:             billable_minutes => { type => 'scalar' },
   87:             monthly_cost     => { type => 'scalar' },
   88:             workflows        => { type => 'arrayref' },
   89:         },
   90:     }
   91: 
   92: =head3 FORMAL SPECIFICATION
   93: 
   94:     FREE_TIER      ≔ 2000
   95:     COST_PER_MIN   ≔ 0.008
   96: 
   97:     estimate_current_usage : seq Path → UsageSummary
   98: 
   99:     total    ≔ ∑ { estimate_workflow_cost(f).minutes_per_month ∣ f ∈ workflows }
  100:     billable ≔ max(0, total − FREE_TIER)
  101: 
  102:     result ≔ {
  103:         total_minutes    ↦ total,
  104:         billable_minutes ↦ billable,
  105:         monthly_cost     ↦ billable × COST_PER_MIN,
  106:         workflows        ↦ [ estimate_workflow_cost(f) ∣ f ∈ workflows ],
  107:     }
  108: 
  109: =cut
  110: 
  111: sub estimate_current_usage($workflows) {
โ—112 โ†’ 115 โ†’ 126  112: 	my $total_minutes = 0;
  113: 	my @workflow_costs;
  114: 
  115: 	for my $wf_file (@$workflows) {
  116: 		my $workflow = LoadFile($wf_file);
  117: 		my $cost = estimate_workflow_cost($workflow, $wf_file->basename);
  118: 
  119: 		$total_minutes += $cost->{minutes_per_month};
  120: 		push @workflow_costs, $cost;
  121: 	}
  122: 
  123: 	# GitHub pricing (approximate)
  124: 	# Free tier: 2,000 minutes/month for private repos
  125: 	# Additional: $0.008 per minute
  126: 	my $cost_per_minute = 0.008;
  127: 	my $free_tier = 2000;
  128: 
  129: 	my $billable_minutes = $total_minutes > $free_tier ? ($total_minutes - $free_tier) : 0;

Mutants (Total: 3, Killed: 3, Survived: 0)

130: 131: my $monthly_cost = $billable_minutes * $cost_per_minute; 132: 133: return { 134: total_minutes => $total_minutes, 135: billable_minutes => $billable_minutes, 136: monthly_cost => $monthly_cost, 137: workflows => \@workflow_costs, 138: }; 139: } 140: 141: =head2 estimate_workflow_cost($workflow, $filename) 142: 143: Estimate the monthly CI cost of a single parsed workflow. 144: 145: =head3 Purpose 146: 147: Compute estimated runs per month, average run duration, and total minute 148: usage for one workflow, based on its trigger configuration and step complexity. 149: 150: =head3 Arguments 151: 152: =over 4 153: 154: =item C<$workflow> (HashRef, required) 155: 156: A parsed workflow hash (e.g. from C<YAML::XS::LoadFile>). 157: 158: =item C<$filename> (Str, required) 159: 160: Filename string used as a display label when C<$workflow-E<gt>{name}> is absent. 161: 162: =back 163: 164: =head3 Returns 165: 166: A hash reference: 167: 168: { 169: name => Str, 170: file => Str, 171: runs_per_month => Num, 172: minutes_per_run => Num, 173: minutes_per_month => Num, # == runs_per_month * minutes_per_run 174: } 175: 176: =head3 Side Effects 177: 178: None. Pure function. 179: 180: =head3 Usage Example 181: 182: my $cost = estimate_workflow_cost($workflow, 'ci.yml'); 183: printf "%s: %d min/month\n", $cost->{name}, $cost->{minutes_per_month}; 184: 185: =head3 API SPECIFICATION 186: 187: =head4 Input 188: 189: { 190: workflow => { type => 'hashref', required => 1 }, 191: filename => { type => 'scalar', required => 1 }, 192: } 193: 194: =head4 Output 195: 196: { 197: type => 'hashref', 198: keys => { 199: name => { type => 'scalar' }, 200: file => { type => 'scalar' }, 201: runs_per_month => { type => 'scalar' }, 202: minutes_per_run => { type => 'scalar' }, 203: minutes_per_month => { type => 'scalar' }, 204: }, 205: } 206: 207: =head3 FORMAL SPECIFICATION 208: 209: estimate_workflow_cost : Workflow × ℤ* → CostRecord 210: 211: runs ≔ estimate_runs_per_month(w) 212: dur ≔ estimate_duration(w) 213: result ≔ { 214: name ↦ w.name ?? f, 215: file ↦ f, 216: runs_per_month ↦ runs, 217: minutes_per_run ↦ dur, 218: minutes_per_month ↦ runs × dur, 219: } 220: 221: invariant: result.minutes_per_month = result.runs_per_month × result.minutes_per_run 222: 223: =cut 224: 225: sub estimate_workflow_cost($workflow, $filename) { 226: my $name = $workflow->{name} // $filename; 227: 228: # Estimate triggers per month 229: my $runs_per_month = estimate_runs_per_month($workflow); 230: 231: # Estimate duration per run 232: my $minutes_per_run = estimate_duration($workflow); 233: 234: # Calculate total 235: my $minutes_per_month = $runs_per_month * $minutes_per_run; 236: 237: return { 238: name => $name, 239: file => $filename, 240: runs_per_month => $runs_per_month, 241: minutes_per_run => $minutes_per_run, 242: minutes_per_month => $minutes_per_month, 243: }; 244: } 245: 246: =head2 estimate_savings($issues, $workflows) 247: 248: Estimate potential CI-minute and cost savings from resolving a set of issues. 249: 250: =head3 Purpose 251: 252: For each issue in C<$issues>, compute how many CI minutes per month would be 253: saved by fixing it. Optionally uses C<$workflows> to proportion savings 254: against actual current usage. 255: 256: =head3 Arguments 257: 258: =over 4 259: 260: =item C<$issues> (ArrayRef[HashRef], required) 261: 262: Array reference of issue hashes, each with at least C<type> and C<message>. 263: 264: =item C<$workflows> (ArrayRef[Path::Tiny], optional, default C<[]>) 265: 266: Workflow files used to compute current usage for percentage calculations. 267: 268: =back 269: 270: =head3 Returns 271: 272: A hash reference: 273: 274: { 275: minutes => Int, # total minutes saved per month 276: percentage => Int, # 0–100; 0 when no current usage available 277: cost => Str, # formatted as "NN.NN" (USD) 278: details => ArrayRef[{ description => Str, minutes => Int, issue_type => Str }], 279: } 280: 281: =head3 Side Effects 282: 283: May read workflow files from disk when C<$workflows> is non-empty. 284: 285: =head3 Usage Example 286: 287: my $savings = estimate_savings(\@issues, \@workflow_paths); 288: printf "Save %d min/month (\$%s)\n", 289: $savings->{minutes}, $savings->{cost}; 290: 291: =head3 API SPECIFICATION 292: 293: =head4 Input 294: 295: { 296: issues => { type => 'arrayref', required => 1 }, 297: workflows => { type => 'arrayref', default => [] }, 298: } 299: 300: =head4 Output 301: 302: { 303: type => 'hashref', 304: keys => { 305: minutes => { type => 'scalar' }, 306: percentage => { type => 'scalar' }, 307: cost => { type => 'scalar' }, 308: details => { type => 'arrayref' }, 309: }, 310: } 311: 312: =head3 FORMAL SPECIFICATION 313: 314: estimate_savings : seq Issue × seq Path → SavingsSummary 315: 316: savings(i) ≔ 317: i.type = performance ∧ i.message =~ /caching/ → 75 318: i.type = cost ∧ i.message =~ /concurrency/ → 50 | usage×0.15 319: i.type = cost ∧ i.message =~ /triggers/ → 100 | usage×0.25 320: otherwise → 0 321: 322: total ≔ ∑ { savings(i) ∣ i ∈ issues } 323: result ≔ { 324: minutes ↦ floor(total), 325: percentage ↦ floor(total / usage × 100) | 30 (if total > 0, no usage), 326: cost ↦ sprintf("%.2f", total × 0.008), 327: details ↦ [ { description, minutes, issue_type } ∣ savings(i) > 0 ], 328: } 329: 330: =cut 331: 332: sub estimate_savings($issues, $workflows = []) { โ—333 โ†’ 343 โ†’ 389 333: my %savings = ( 334: minutes => 0, 335: percentage => 0, 336: cost => 0, 337: details => [], 338: ); 339: 340: # Get current usage if workflows provided 341: my $current_usage = @$workflows ? estimate_current_usage($workflows) : undef; 342: 343: for my $issue (@$issues) { 344: my $saving = 0; 345: my $description = ''; 346: 347: if ($issue->{type} eq 'performance') {

Mutants (Total: 1, Killed: 1, Survived: 0)

348: if ($issue->{message} =~ /caching/) {

Mutants (Total: 1, Killed: 1, Survived: 0)

349: # Caching typically saves 30-60 seconds per run 350: # Estimate 100 runs/month affected 351: $saving = 100 * 0.75; # 75 minutes 352: $description = 'Adding dependency caching'; 353: } 354: } 355: elsif ($issue->{type} eq 'cost') { 356: if ($issue->{message} =~ /concurrency/) {

Mutants (Total: 1, Killed: 1, Survived: 0)

357: # Concurrency saves by canceling superseded runs 358: # Estimate 10-20% of runs are canceled 359: if ($current_usage) {

Mutants (Total: 1, Killed: 1, Survived: 0)

360: $saving = $current_usage->{total_minutes} * 0.15; 361: } else { 362: $saving = 50; # Conservative estimate 363: } 364: $description = 'Adding concurrency controls'; 365: } 366: elsif ($issue->{message} =~ /triggers/) { 367: # Trigger filters reduce unnecessary runs 368: # Estimate 20-30% of runs avoided 369: if ($current_usage) {

Mutants (Total: 1, Killed: 1, Survived: 0)

370: $saving = $current_usage->{total_minutes} * 0.25; 371: } else { 372: $saving = 100; # Conservative estimate 373: } 374: $description = 'Optimizing workflow triggers'; 375: } 376: } 377: 378: if ($saving > 0) {

Mutants (Total: 4, Killed: 4, Survived: 0)

379: $savings{minutes} += $saving; 380: push @{$savings{details}}, { 381: description => $description, 382: minutes => int($saving), 383: issue_type => $issue->{type}, 384: }; 385: } 386: } 387: 388: # Calculate percentage and cost โ—389 โ†’ 389 โ†’ 395 389: if ($current_usage && $current_usage->{total_minutes} > 0) {

Mutants (Total: 4, Killed: 1, Survived: 3)
390: $savings{percentage} = int(($savings{minutes} / $current_usage->{total_minutes}) * 100); 391: } elsif ($savings{minutes} > 0) {

Mutants (Total: 3, Killed: 3, Survived: 0)

392: $savings{percentage} = 30; # Estimate 30% savings 393: } 394: 395: $savings{cost} = sprintf('%.2f', $savings{minutes} * 0.008); 396: $savings{minutes} = int($savings{minutes}); 397: 398: return \%savings;

Mutants (Total: 2, Killed: 2, Survived: 0)

399: } 400: 401: sub estimate_runs_per_month($workflow) { โ—402 โ†’ 407 โ†’ 420 402: my $on = $workflow->{on} or return 50; # Default estimate 403: 404: my $runs = 0; 405: 406: # Parse different trigger formats 407: if (ref $on eq 'ARRAY') {

Mutants (Total: 1, Killed: 1, Survived: 0)

408: for my $trigger (@$on) { 409: $runs += estimate_trigger_frequency($trigger); 410: } 411: } 412: elsif (ref $on eq 'HASH') { 413: for my $trigger (keys %$on) { 414: $runs += estimate_trigger_frequency($trigger, $on->{$trigger}); 415: } 416: } else { 417: $runs += estimate_trigger_frequency($on); 418: } 419: 420: return $runs || 50; # Minimum estimate

Mutants (Total: 2, Killed: 2, Survived: 0)

421: } 422: 423: sub estimate_trigger_frequency($trigger, $config = undef) { 424: # Estimates based on typical project activity โ—425 โ†’ 437 โ†’ 449 425: my %frequencies = ( 426: push => 100, # ~5 pushes/day for active projects 427: pull_request => 60, # ~2-3 PRs/day 428: schedule => 30, # Depends on cron, assume daily 429: workflow_dispatch => 10, # Manual runs 430: release => 4, # ~1 per week 431: issues => 20, # Issue activity 432: ); 433: 434: my $base = $frequencies{$trigger} // 20; 435: 436: # Adjust based on configuration 437: if ($config && ref $config eq 'HASH') {

Mutants (Total: 1, Killed: 1, Survived: 0)

438: # If it has branches filter, likely fewer runs 439: if ($config->{branches}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

440: $base *= 0.6; # 40% reduction 441: } 442: 443: # If it has paths filter, significantly fewer runs 444: if ($config->{paths}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

445: $base *= 0.3; # 70% reduction 446: } 447: } 448: 449: return int($base);

Mutants (Total: 2, Killed: 2, Survived: 0)

450: } 451: 452: sub estimate_duration($workflow) { โ—453 โ†’ 460 โ†’ 464 453: my $jobs = $workflow->{jobs} or return 5; # Default 5 minutes 454: 455: my $total_duration = 0; 456: my $max_parallel_duration = 0; 457: 458: # Check if jobs run in parallel or sequence 459: my $has_dependencies = 0; 460: for my $job (values %$jobs) { 461: $has_dependencies = 1 if $job->{needs}; 462: } 463: โ—464 โ†’ 464 โ†’ 476 464: for my $job (values %$jobs) { 465: my $duration = estimate_job_duration($job); 466: 467: if ($has_dependencies) {

Mutants (Total: 1, Killed: 0, Survived: 1)
468: # Sequential - add durations 469: $total_duration += $duration; 470: } else { 471: # Parallel - track maximum 472: $max_parallel_duration = $duration if $duration > $max_parallel_duration;
Mutants (Total: 3, Killed: 0, Survived: 3)
473: } 474: } 475: 476: my $estimated = $has_dependencies ? $total_duration : $max_parallel_duration; 477: 478: # Factor in matrix multiplier 479: my $matrix_factor = estimate_matrix_factor($workflow); 480: 481: return int($estimated * $matrix_factor) || 5;

Mutants (Total: 2, Killed: 2, Survived: 0)

482: } 483: 484: sub estimate_job_duration($job) { โ—485 โ†’ 489 โ†’ 528 485: my $steps = $job->{steps} or return 3; 486: 487: my $duration = 0; 488: 489: for my $step (@$steps) { 490: # Estimate based on step type 491: if ($step->{uses}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

492: my $uses = $step->{uses}; 493: 494: # Common actions and their typical durations 495: if ($uses =~ /checkout/) {

Mutants (Total: 1, Killed: 1, Survived: 0)

496: $duration += 0.5; 497: } 498: elsif ($uses =~ /setup-(?:node|python|go|ruby)/) { 499: $duration += 1; 500: } 501: elsif ($uses =~ /cache/) { 502: # Cache hit: ~10s, miss: ~30s 503: $duration += 0.3; 504: } 505: } 506: elsif ($step->{run}) { 507: my $run = $step->{run}; 508: 509: # Estimate based on command 510: if ($run =~ /npm (?:install|ci)/) {

Mutants (Total: 1, Killed: 1, Survived: 0)

511: $duration += 2; # npm install takes time 512: } 513: elsif ($run =~ /pip install/) { 514: $duration += 1.5; 515: } 516: elsif ($run =~ /cargo build/) { 517: $duration += 5; # Rust builds are slow 518: } 519: elsif ($run =~ /(?:npm|pytest|cargo|go) test/) { 520: $duration += 2; # Test suites 521: } 522: else { 523: $duration += 0.5; # Generic command 524: } 525: } 526: } 527: 528: return $duration || 3;

Mutants (Total: 2, Killed: 2, Survived: 0)

529: } 530: 531: sub estimate_matrix_factor($workflow) { โ—532 โ†’ 536 โ†’ 555 532: my $jobs = $workflow->{jobs} or return 1; 533: 534: my $max_matrix_size = 1; 535: 536: for my $job (values %$jobs) { 537: next unless $job->{strategy}; 538: next unless $job->{strategy}->{matrix}; 539: 540: my $matrix = $job->{strategy}->{matrix}; 541: my $size = 1; 542: 543: # Calculate matrix size 544: for my $key (keys %$matrix) { 545: next if $key eq 'include' || $key eq 'exclude'; 546: my $values = $matrix->{$key}; 547: if (ref $values eq 'ARRAY') {

Mutants (Total: 1, Killed: 1, Survived: 0)

548: $size *= scalar @$values; 549: } 550: } 551: 552: $max_matrix_size = $size if $size > $max_matrix_size;

Mutants (Total: 3, Killed: 3, Survived: 0)

553: } 554: 555: return $max_matrix_size;

Mutants (Total: 2, Killed: 2, Survived: 0)

556: } 557: 558: =head1 AUTHOR 559: 560: Nigel Horne E<lt>njh@nigelhorne.comE<gt> 561: 562: L<https://github.com/nigelhorne> 563: 564: =head1 LICENSE 565: 566: This is free software; you can redistribute it and/or modify it under 567: the same terms as the Perl 5 programming language system itself. 568: 569: =cut 570: 571: 1;