App-GHGen

 view release on metacpan or  search on metacpan

lib/App/GHGen/CostEstimator.pm  view on Meta::CPAN


=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

sub estimate_workflow_cost($workflow, $filename) {
	my $name = $workflow->{name} // $filename;

	# Estimate triggers per month
	my $runs_per_month = estimate_runs_per_month($workflow);

	# Estimate duration per run
	my $minutes_per_run = estimate_duration($workflow);

	# Calculate total
	my $minutes_per_month = $runs_per_month * $minutes_per_run;

    return {
        name => $name,
        file => $filename,
        runs_per_month => $runs_per_month,
        minutes_per_run => $minutes_per_run,
        minutes_per_month => $minutes_per_month,
    };
}

=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

sub estimate_savings($issues, $workflows = []) {
    my %savings = (
        minutes => 0,
        percentage => 0,
        cost => 0,
        details => [],
    );

	# Get current usage if workflows provided
	my $current_usage = @$workflows ? estimate_current_usage($workflows) : undef;

    for my $issue (@$issues) {
        my $saving = 0;
        my $description = '';

        if ($issue->{type} eq 'performance') {
            if ($issue->{message} =~ /caching/) {
                # Caching typically saves 30-60 seconds per run
                # Estimate 100 runs/month affected
                $saving = 100 * 0.75;  # 75 minutes
                $description = 'Adding dependency caching';
            }
        }
        elsif ($issue->{type} eq 'cost') {
            if ($issue->{message} =~ /concurrency/) {
                # Concurrency saves by canceling superseded runs
                # Estimate 10-20% of runs are canceled
                if ($current_usage) {
                    $saving = $current_usage->{total_minutes} * 0.15;
                } else {
                    $saving = 50;  # Conservative estimate
                }
                $description = 'Adding concurrency controls';
            }
            elsif ($issue->{message} =~ /triggers/) {
                # Trigger filters reduce unnecessary runs
                # Estimate 20-30% of runs avoided
                if ($current_usage) {
                    $saving = $current_usage->{total_minutes} * 0.25;
                } else {
                    $saving = 100;  # Conservative estimate
                }
                $description = 'Optimizing workflow triggers';
            }
        }

        if ($saving > 0) {
            $savings{minutes} += $saving;
            push @{$savings{details}}, {
                description => $description,
                minutes => int($saving),
                issue_type => $issue->{type},
            };
        }
    }

    # Calculate percentage and cost
    if ($current_usage && $current_usage->{total_minutes} > 0) {
        $savings{percentage} = int(($savings{minutes} / $current_usage->{total_minutes}) * 100);
    } elsif ($savings{minutes} > 0) {
        $savings{percentage} = 30;  # Estimate 30% savings
    }

	$savings{cost} = sprintf('%.2f', $savings{minutes} * 0.008);
	$savings{minutes} = int($savings{minutes});

	return \%savings;
}

sub estimate_runs_per_month($workflow) {
	my $on = $workflow->{on} or return 50;  # Default estimate

	my $runs = 0;

	# Parse different trigger formats
    if (ref $on eq 'ARRAY') {
        for my $trigger (@$on) {
            $runs += estimate_trigger_frequency($trigger);
        }
    }
    elsif (ref $on eq 'HASH') {
        for my $trigger (keys %$on) {
            $runs += estimate_trigger_frequency($trigger, $on->{$trigger});
        }
    } else {
        $runs += estimate_trigger_frequency($on);
    }

    return $runs || 50;  # Minimum estimate
}

sub estimate_trigger_frequency($trigger, $config = undef) {
    # Estimates based on typical project activity
    my %frequencies = (
        push => 100,           # ~5 pushes/day for active projects
        pull_request => 60,    # ~2-3 PRs/day
        schedule => 30,        # Depends on cron, assume daily
        workflow_dispatch => 10,  # Manual runs
        release => 4,          # ~1 per week
        issues => 20,          # Issue activity
    );

	my $base = $frequencies{$trigger} // 20;

    # Adjust based on configuration
    if ($config && ref $config eq 'HASH') {
        # If it has branches filter, likely fewer runs
        if ($config->{branches}) {
            $base *= 0.6;  # 40% reduction
        }

        # If it has paths filter, significantly fewer runs
        if ($config->{paths}) {
            $base *= 0.3;  # 70% reduction
        }
    }

    return int($base);
}

sub estimate_duration($workflow) {



( run in 1.805 second using v1.01-cache-2.11-cpan-84e82930d8c )