File Coverage

File:blib/lib/App/GHGen/CostEstimator.pm
Coverage:98.5%

linestmtbrancondsubtimecode
1package 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';
10our @EXPORT_OK = qw(
11        estimate_current_usage
12        estimate_savings
13        estimate_workflow_cost
14);
15
16our $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
5711;