TER1 (Statement): 100.00%
TER2 (Branch): 97.44%
TER3 (LCSAJ): 100.0% (9/9)
Approximate LCSAJ segments: 79
โ 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.
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) {
390: $savings{percentage} = int(($savings{minutes} / $current_usage->{total_minutes}) * 100); 391: } elsif ($savings{minutes} > 0) {Mutants (Total: 4, Killed: 1, Survived: 3)
- NUM_BOUNDARY_389_59_<: Numeric boundary flip > to <
HIGH: Likely missing edge-case test (boundary value)๐งช Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_389_59_>=: Numeric boundary flip > to >=
HIGH: Likely missing edge-case test (boundary value)๐งช Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_389_59_<=: Numeric boundary flip > to <=
HIGH: Likely missing edge-case test (boundary value)๐งช Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );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) {
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: 1, Killed: 0, Survived: 1)
- COND_INV_467_9: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes473: } 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: 3, Killed: 0, Survived: 3)
- NUM_BOUNDARY_472_61_<: Numeric boundary flip > to <
HIGH: Likely missing edge-case test (boundary value)๐งช Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_472_61_>=: Numeric boundary flip > to >=
HIGH: Likely missing edge-case test (boundary value)๐งช Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_472_61_<=: Numeric boundary flip > to <=
HIGH: Likely missing edge-case test (boundary value)๐งช Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );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;