| File: | blib/lib/App/GHGen/CostEstimator.pm |
| Coverage: | 98.5% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package App::GHGen::CostEstimator; | |||||
| 2 | ||||||
| 3 | 7 7 | 194143 7 | use v5.36; | |||
| 4 | 7 7 7 | 11 4 46 | use strict; | |||
| 5 | 7 7 7 | 7 6 129 | use warnings; | |||
| 6 | 7 7 7 | 193 1893 99 | use YAML::XS qw(LoadFile); | |||
| 7 | 7 7 7 | 10 6 87 | use Path::Tiny; | |||
| 8 | ||||||
| 9 | 7 7 7 | 9 5 3851 | 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 - 109 | =head1 NAME
App::GHGen::CostEstimator - Estimate CI costs and savings
=head1 SYNOPSIS
use App::GHGen::CostEstimator qw(estimate_current_usage);
my $estimate = estimate_current_usage(\@workflows);
=head1 FUNCTIONS
=head2 estimate_current_usage($workflows)
Estimate current monthly CI usage based on workflow configurations.
=head3 Purpose
Load and analyse every workflow file in C<$workflows>, then aggregate
estimated runs, minutes, and cost into a single summary hash.
=head3 Arguments
=over 4
=item C<$workflows> (ArrayRef[Path::Tiny], required)
Array reference of L<Path::Tiny> objects pointing to YAML workflow files.
Each file is loaded with C<YAML::XS::LoadFile>.
=back
=head3 Returns
A hash reference with keys:
{
total_minutes => Num,
billable_minutes => Num, # 0 when within free tier (2 000 min/month)
monthly_cost => Num, # USD; 0 when within free tier
workflows => ArrayRef[ estimate_workflow_cost result ],
}
=head3 Side Effects
Reads each workflow file from disk via C<YAML::XS::LoadFile>.
=head3 Usage Example
use App::GHGen::CostEstimator qw(estimate_current_usage);
use App::GHGen::Analyzer qw(find_workflows);
my @wfs = find_workflows();
my $usage = estimate_current_usage(\@wfs);
printf "Monthly cost: \$%.2f\n", $usage->{monthly_cost};
=head3 API SPECIFICATION
=head4 Input
{ workflows => { type => 'arrayref', required => 1 } }
=head4 Output
{
type => 'hashref',
keys => {
total_minutes => { type => 'scalar' },
billable_minutes => { type => 'scalar' },
monthly_cost => { type => 'scalar' },
workflows => { type => 'arrayref' },
},
}
=head3 FORMAL SPECIFICATION
FREE_TIER â 2000
COST_PER_MIN â 0.008
estimate_current_usage : seq Path â UsageSummary
total â â { estimate_workflow_cost(f).minutes_per_month ⣠f â workflows }
billable â max(0, total â FREE_TIER)
result â {
total_minutes ⦠total,
billable_minutes ⦠billable,
monthly_cost ⦠billable à COST_PER_MIN,
workflows ⦠[ estimate_workflow_cost(f) ⣠f â workflows ],
}
=cut | |||||
| 110 | ||||||
| 111 | 9 9 9 | 11616 9 6 | sub estimate_current_usage($workflows) { | |||
| 112 | 9 | 9 | my $total_minutes = 0; | |||
| 113 | 9 | 6 | my @workflow_costs; | |||
| 114 | ||||||
| 115 | 9 | 11 | for my $wf_file (@$workflows) { | |||
| 116 | 10 | 19 | my $workflow = LoadFile($wf_file); | |||
| 117 | 10 | 493 | my $cost = estimate_workflow_cost($workflow, $wf_file->basename); | |||
| 118 | ||||||
| 119 | 10 | 44 | $total_minutes += $cost->{minutes_per_month}; | |||
| 120 | 10 | 21 | 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 | 9 | 18 | my $cost_per_minute = 0.008; | |||
| 127 | 9 | 6 | my $free_tier = 2000; | |||
| 128 | ||||||
| 129 | 9 | 13 | my $billable_minutes = $total_minutes > $free_tier ? ($total_minutes - $free_tier) : 0; | |||
| 130 | ||||||
| 131 | 9 | 10 | my $monthly_cost = $billable_minutes * $cost_per_minute; | |||
| 132 | ||||||
| 133 | return { | |||||
| 134 | 9 | 19 | total_minutes => $total_minutes, | |||
| 135 | billable_minutes => $billable_minutes, | |||||
| 136 | monthly_cost => $monthly_cost, | |||||
| 137 | workflows => \@workflow_costs, | |||||
| 138 | }; | |||||
| 139 | } | |||||
| 140 | ||||||
| 141 - 223 | =head2 estimate_workflow_cost($workflow, $filename)
Estimate the monthly CI cost of a single parsed workflow.
=head3 Purpose
Compute estimated runs per month, average run duration, and total minute
usage for one workflow, based on its trigger configuration and step complexity.
=head3 Arguments
=over 4
=item C<$workflow> (HashRef, required)
A parsed workflow hash (e.g. from C<YAML::XS::LoadFile>).
=item C<$filename> (Str, required)
Filename string used as a display label when C<$workflow-E<gt>{name}> is absent.
=back
=head3 Returns
A hash reference:
{
name => Str,
file => Str,
runs_per_month => Num,
minutes_per_run => Num,
minutes_per_month => Num, # == runs_per_month * minutes_per_run
}
=head3 Side Effects
None. Pure function.
=head3 Usage Example
my $cost = estimate_workflow_cost($workflow, 'ci.yml');
printf "%s: %d min/month\n", $cost->{name}, $cost->{minutes_per_month};
=head3 API SPECIFICATION
=head4 Input
{
workflow => { type => 'hashref', required => 1 },
filename => { type => 'scalar', required => 1 },
}
=head4 Output
{
type => 'hashref',
keys => {
name => { type => 'scalar' },
file => { type => 'scalar' },
runs_per_month => { type => 'scalar' },
minutes_per_run => { type => 'scalar' },
minutes_per_month => { type => 'scalar' },
},
}
=head3 FORMAL SPECIFICATION
estimate_workflow_cost : Workflow à â¤* â CostRecord
runs â estimate_runs_per_month(w)
dur â estimate_duration(w)
result â {
name ⦠w.name ?? f,
file ⦠f,
runs_per_month ⦠runs,
minutes_per_run ⦠dur,
minutes_per_month ⦠runs à dur,
}
invariant: result.minutes_per_month = result.runs_per_month à result.minutes_per_run
=cut | |||||
| 224 | ||||||
| 225 | 11 11 11 11 | 3619 10 8 7 | sub estimate_workflow_cost($workflow, $filename) { | |||
| 226 | 11 | 21 | my $name = $workflow->{name} // $filename; | |||
| 227 | ||||||
| 228 | # Estimate triggers per month | |||||
| 229 | 11 | 13 | my $runs_per_month = estimate_runs_per_month($workflow); | |||
| 230 | ||||||
| 231 | # Estimate duration per run | |||||
| 232 | 11 | 14 | my $minutes_per_run = estimate_duration($workflow); | |||
| 233 | ||||||
| 234 | # Calculate total | |||||
| 235 | 11 | 9 | my $minutes_per_month = $runs_per_month * $minutes_per_run; | |||
| 236 | ||||||
| 237 | return { | |||||
| 238 | 11 | 22 | 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 - 330 | =head2 estimate_savings($issues, $workflows)
Estimate potential CI-minute and cost savings from resolving a set of issues.
=head3 Purpose
For each issue in C<$issues>, compute how many CI minutes per month would be
saved by fixing it. Optionally uses C<$workflows> to proportion savings
against actual current usage.
=head3 Arguments
=over 4
=item C<$issues> (ArrayRef[HashRef], required)
Array reference of issue hashes, each with at least C<type> and C<message>.
=item C<$workflows> (ArrayRef[Path::Tiny], optional, default C<[]>)
Workflow files used to compute current usage for percentage calculations.
=back
=head3 Returns
A hash reference:
{
minutes => Int, # total minutes saved per month
percentage => Int, # 0â100; 0 when no current usage available
cost => Str, # formatted as "NN.NN" (USD)
details => ArrayRef[{ description => Str, minutes => Int, issue_type => Str }],
}
=head3 Side Effects
May read workflow files from disk when C<$workflows> is non-empty.
=head3 Usage Example
my $savings = estimate_savings(\@issues, \@workflow_paths);
printf "Save %d min/month (\$%s)\n",
$savings->{minutes}, $savings->{cost};
=head3 API SPECIFICATION
=head4 Input
{
issues => { type => 'arrayref', required => 1 },
workflows => { type => 'arrayref', default => [] },
}
=head4 Output
{
type => 'hashref',
keys => {
minutes => { type => 'scalar' },
percentage => { type => 'scalar' },
cost => { type => 'scalar' },
details => { type => 'arrayref' },
},
}
=head3 FORMAL SPECIFICATION
estimate_savings : seq Issue à seq Path â SavingsSummary
savings(i) â
i.type = performance â§ i.message =~ /caching/ â 75
i.type = cost â§ i.message =~ /concurrency/ â 50 | usageÃ0.15
i.type = cost â§ i.message =~ /triggers/ â 100 | usageÃ0.25
otherwise â 0
total â â { savings(i) ⣠i â issues }
result â {
minutes ⦠floor(total),
percentage ⦠floor(total / usage à 100) | 30 (if total > 0, no usage),
cost ⦠sprintf("%.2f", total à 0.008),
details ⦠[ { description, minutes, issue_type } ⣠savings(i) > 0 ],
}
=cut | |||||
| 331 | ||||||
| 332 | 22 22 22 22 | 88562 16 17 14 | sub estimate_savings($issues, $workflows = []) { | |||
| 333 | 22 | 39 | my %savings = ( | |||
| 334 | minutes => 0, | |||||
| 335 | percentage => 0, | |||||
| 336 | cost => 0, | |||||
| 337 | details => [], | |||||
| 338 | ); | |||||
| 339 | ||||||
| 340 | # Get current usage if workflows provided | |||||
| 341 | 22 | 35 | my $current_usage = @$workflows ? estimate_current_usage($workflows) : undef; | |||
| 342 | ||||||
| 343 | 22 | 28 | for my $issue (@$issues) { | |||
| 344 | 27 | 19 | my $saving = 0; | |||
| 345 | 27 | 14 | my $description = ''; | |||
| 346 | ||||||
| 347 | 27 | 86 | if ($issue->{type} eq 'performance') { | |||
| 348 | 13 | 30 | if ($issue->{message} =~ /caching/) { | |||
| 349 | # Caching typically saves 30-60 seconds per run | |||||
| 350 | # Estimate 100 runs/month affected | |||||
| 351 | 11 | 15 | $saving = 100 * 0.75; # 75 minutes | |||
| 352 | 11 | 10 | $description = 'Adding dependency caching'; | |||
| 353 | } | |||||
| 354 | } | |||||
| 355 | elsif ($issue->{type} eq 'cost') { | |||||
| 356 | 11 | 24 | if ($issue->{message} =~ /concurrency/) { | |||
| 357 | # Concurrency saves by canceling superseded runs | |||||
| 358 | # Estimate 10-20% of runs are canceled | |||||
| 359 | 6 | 8 | if ($current_usage) { | |||
| 360 | 2 | 3 | $saving = $current_usage->{total_minutes} * 0.15; | |||
| 361 | } else { | |||||
| 362 | 4 | 4 | $saving = 50; # Conservative estimate | |||
| 363 | } | |||||
| 364 | 6 | 5 | $description = 'Adding concurrency controls'; | |||
| 365 | } | |||||
| 366 | elsif ($issue->{message} =~ /triggers/) { | |||||
| 367 | # Trigger filters reduce unnecessary runs | |||||
| 368 | # Estimate 20-30% of runs avoided | |||||
| 369 | 4 | 4 | if ($current_usage) { | |||
| 370 | 2 | 4 | $saving = $current_usage->{total_minutes} * 0.25; | |||
| 371 | } else { | |||||
| 372 | 2 | 2 | $saving = 100; # Conservative estimate | |||
| 373 | } | |||||
| 374 | 4 | 2 | $description = 'Optimizing workflow triggers'; | |||
| 375 | } | |||||
| 376 | } | |||||
| 377 | ||||||
| 378 | 27 | 30 | if ($saving > 0) { | |||
| 379 | 21 | 15 | $savings{minutes} += $saving; | |||
| 380 | 21 | 38 | push @{$savings{details}}, { | |||
| 381 | description => $description, | |||||
| 382 | minutes => int($saving), | |||||
| 383 | issue_type => $issue->{type}, | |||||
| 384 | 21 | 15 | }; | |||
| 385 | } | |||||
| 386 | } | |||||
| 387 | ||||||
| 388 | # Calculate percentage and cost | |||||
| 389 | 22 | 56 | if ($current_usage && $current_usage->{total_minutes} > 0) { | |||
| 390 | 7 | 14 | $savings{percentage} = int(($savings{minutes} / $current_usage->{total_minutes}) * 100); | |||
| 391 | } elsif ($savings{minutes} > 0) { | |||||
| 392 | 10 | 9 | $savings{percentage} = 30; # Estimate 30% savings | |||
| 393 | } | |||||
| 394 | ||||||
| 395 | 22 | 94 | $savings{cost} = sprintf('%.2f', $savings{minutes} * 0.008); | |||
| 396 | 22 | 22 | $savings{minutes} = int($savings{minutes}); | |||
| 397 | ||||||
| 398 | 22 | 25 | return \%savings; | |||
| 399 | } | |||||
| 400 | ||||||
| 401 | 18 18 18 | 4689 12 11 | sub estimate_runs_per_month($workflow) { | |||
| 402 | 18 | 28 | my $on = $workflow->{on} or return 50; # Default estimate | |||
| 403 | ||||||
| 404 | 15 | 9 | my $runs = 0; | |||
| 405 | ||||||
| 406 | # Parse different trigger formats | |||||
| 407 | 15 | 29 | if (ref $on eq 'ARRAY') { | |||
| 408 | 2 | 2 | for my $trigger (@$on) { | |||
| 409 | 2 | 2 | $runs += estimate_trigger_frequency($trigger); | |||
| 410 | } | |||||
| 411 | } | |||||
| 412 | elsif (ref $on eq 'HASH') { | |||||
| 413 | 8 | 10 | for my $trigger (keys %$on) { | |||
| 414 | 10 | 16 | $runs += estimate_trigger_frequency($trigger, $on->{$trigger}); | |||
| 415 | } | |||||
| 416 | } else { | |||||
| 417 | 5 | 10 | $runs += estimate_trigger_frequency($on); | |||
| 418 | } | |||||
| 419 | ||||||
| 420 | 15 | 56 | return $runs || 50; # Minimum estimate | |||
| 421 | } | |||||
| 422 | ||||||
| 423 | 26 26 26 26 | 6893 20 16 26 | sub estimate_trigger_frequency($trigger, $config = undef) { | |||
| 424 | # Estimates based on typical project activity | |||||
| 425 | 26 | 65 | 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 | 26 | 496 | my $base = $frequencies{$trigger} // 20; | |||
| 435 | ||||||
| 436 | # Adjust based on configuration | |||||
| 437 | 26 | 52 | if ($config && ref $config eq 'HASH') { | |||
| 438 | # If it has branches filter, likely fewer runs | |||||
| 439 | 15 | 21 | if ($config->{branches}) { | |||
| 440 | 8 | 8 | $base *= 0.6; # 40% reduction | |||
| 441 | } | |||||
| 442 | ||||||
| 443 | # If it has paths filter, significantly fewer runs | |||||
| 444 | 15 | 21 | if ($config->{paths}) { | |||
| 445 | 3 | 3 | $base *= 0.3; # 70% reduction | |||
| 446 | } | |||||
| 447 | } | |||||
| 448 | ||||||
| 449 | 26 | 40 | return int($base); | |||
| 450 | } | |||||
| 451 | ||||||
| 452 | 15 15 15 | 4385 13 8 | sub estimate_duration($workflow) { | |||
| 453 | 15 | 18 | my $jobs = $workflow->{jobs} or return 5; # Default 5 minutes | |||
| 454 | ||||||
| 455 | 14 | 13 | my $total_duration = 0; | |||
| 456 | 14 | 7 | my $max_parallel_duration = 0; | |||
| 457 | ||||||
| 458 | # Check if jobs run in parallel or sequence | |||||
| 459 | 14 | 10 | my $has_dependencies = 0; | |||
| 460 | 14 | 18 | for my $job (values %$jobs) { | |||
| 461 | 18 | 20 | $has_dependencies = 1 if $job->{needs}; | |||
| 462 | } | |||||
| 463 | ||||||
| 464 | 14 | 16 | for my $job (values %$jobs) { | |||
| 465 | 18 | 14 | my $duration = estimate_job_duration($job); | |||
| 466 | ||||||
| 467 | 18 | 18 | if ($has_dependencies) { | |||
| 468 | # Sequential - add durations | |||||
| 469 | 4 | 4 | $total_duration += $duration; | |||
| 470 | } else { | |||||
| 471 | # Parallel - track maximum | |||||
| 472 | 14 | 21 | $max_parallel_duration = $duration if $duration > $max_parallel_duration; | |||
| 473 | } | |||||
| 474 | } | |||||
| 475 | ||||||
| 476 | 14 | 16 | my $estimated = $has_dependencies ? $total_duration : $max_parallel_duration; | |||
| 477 | ||||||
| 478 | # Factor in matrix multiplier | |||||
| 479 | 14 | 13 | my $matrix_factor = estimate_matrix_factor($workflow); | |||
| 480 | ||||||
| 481 | 14 | 28 | return int($estimated * $matrix_factor) || 5; | |||
| 482 | } | |||||
| 483 | ||||||
| 484 | 30 30 30 | 11165 18 15 | sub estimate_job_duration($job) { | |||
| 485 | 30 | 33 | my $steps = $job->{steps} or return 3; | |||
| 486 | ||||||
| 487 | 28 | 21 | my $duration = 0; | |||
| 488 | ||||||
| 489 | 28 | 29 | for my $step (@$steps) { | |||
| 490 | # Estimate based on step type | |||||
| 491 | 50 | 83 | if ($step->{uses}) { | |||
| 492 | 16 | 12 | my $uses = $step->{uses}; | |||
| 493 | ||||||
| 494 | # Common actions and their typical durations | |||||
| 495 | 16 | 36 | if ($uses =~ /checkout/) { | |||
| 496 | 11 | 12 | $duration += 0.5; | |||
| 497 | } | |||||
| 498 | elsif ($uses =~ /setup-(?:node|python|go|ruby)/) { | |||||
| 499 | 3 | 5 | $duration += 1; | |||
| 500 | } | |||||
| 501 | elsif ($uses =~ /cache/) { | |||||
| 502 | # Cache hit: ~10s, miss: ~30s | |||||
| 503 | 1 | 1 | $duration += 0.3; | |||
| 504 | } | |||||
| 505 | } | |||||
| 506 | elsif ($step->{run}) { | |||||
| 507 | 31 | 18 | my $run = $step->{run}; | |||
| 508 | ||||||
| 509 | # Estimate based on command | |||||
| 510 | 31 | 80 | if ($run =~ /npm (?:install|ci)/) { | |||
| 511 | 12 | 10 | $duration += 2; # npm install takes time | |||
| 512 | } | |||||
| 513 | elsif ($run =~ /pip install/) { | |||||
| 514 | 1 | 2 | $duration += 1.5; | |||
| 515 | } | |||||
| 516 | elsif ($run =~ /cargo build/) { | |||||
| 517 | 6 | 6 | $duration += 5; # Rust builds are slow | |||
| 518 | } | |||||
| 519 | elsif ($run =~ /(?:npm|pytest|cargo|go) test/) { | |||||
| 520 | 9 | 9 | $duration += 2; # Test suites | |||
| 521 | } | |||||
| 522 | else { | |||||
| 523 | 3 | 46 | $duration += 0.5; # Generic command | |||
| 524 | } | |||||
| 525 | } | |||||
| 526 | } | |||||
| 527 | ||||||
| 528 | 28 | 40 | return $duration || 3; | |||
| 529 | } | |||||
| 530 | ||||||
| 531 | 20 20 20 | 4454 11 26 | sub estimate_matrix_factor($workflow) { | |||
| 532 | 20 | 40 | my $jobs = $workflow->{jobs} or return 1; | |||
| 533 | ||||||
| 534 | 20 | 17 | my $max_matrix_size = 1; | |||
| 535 | ||||||
| 536 | 20 | 23 | for my $job (values %$jobs) { | |||
| 537 | 25 | 25 | next unless $job->{strategy}; | |||
| 538 | 8 | 11 | next unless $job->{strategy}->{matrix}; | |||
| 539 | ||||||
| 540 | 8 | 8 | my $matrix = $job->{strategy}->{matrix}; | |||
| 541 | 8 | 7 | my $size = 1; | |||
| 542 | ||||||
| 543 | # Calculate matrix size | |||||
| 544 | 8 | 10 | for my $key (keys %$matrix) { | |||
| 545 | 13 | 28 | next if $key eq 'include' || $key eq 'exclude'; | |||
| 546 | 10 | 8 | my $values = $matrix->{$key}; | |||
| 547 | 10 | 15 | if (ref $values eq 'ARRAY') { | |||
| 548 | 9 | 8 | $size *= scalar @$values; | |||
| 549 | } | |||||
| 550 | } | |||||
| 551 | ||||||
| 552 | 8 | 12 | $max_matrix_size = $size if $size > $max_matrix_size; | |||
| 553 | } | |||||
| 554 | ||||||
| 555 | 20 | 21 | return $max_matrix_size; | |||
| 556 | } | |||||
| 557 | ||||||
| 558 - 569 | =head1 AUTHOR Nigel Horne E<lt>njh@nigelhorne.comE<gt> L<https://github.com/nigelhorne> =head1 LICENSE This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself. =cut | |||||
| 570 | ||||||
| 571 | 1; | |||||