| File: | blib/lib/App/GHGen/Fixer.pm |
| Coverage: | 95.7% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package App::GHGen::Fixer; | |||||
| 2 | ||||||
| 3 | 9 9 | 48296 11 | use v5.36; | |||
| 4 | 9 9 9 | 12 8 84 | use strict; | |||
| 5 | 9 9 9 | 12 7 159 | use warnings; | |||
| 6 | 9 8 8 | 10 5 105 | use YAML::XS qw(LoadFile DumpFile); | |||
| 7 | 8 8 8 | 9 7 90 | use Path::Tiny; | |||
| 8 | ||||||
| 9 | 8 8 8 | 8 5 5461 | use Exporter 'import'; | |||
| 10 | our @EXPORT_OK = qw( | |||||
| 11 | apply_fixes | |||||
| 12 | can_auto_fix | |||||
| 13 | fix_workflow | |||||
| 14 | %ACTION_UPDATES | |||||
| 15 | ); | |||||
| 16 | ||||||
| 17 | our $VERSION = '0.10'; | |||||
| 18 | ||||||
| 19 - 36 | =head1 PACKAGE DATA =head2 %ACTION_UPDATES Canonical mapping of outdated action strings to their current replacements. 'old/action@vN' => 'old/action@vM' This hash is the B<single source of truth> for action version knowledge. Both C<find_outdated_actions> (detection) and C<update_actions> (mutation) consume it so that detecting an outdated action always implies the ability to fix it â and vice versa. Premise 1: the Analyzer reports every key in this hash as "outdated". Premise 2: the Fixer replaces every key with its value. Conclusion: no detected issue is unfixable, and no fix targets an undetected issue. =cut | |||||
| 37 | ||||||
| 38 | our %ACTION_UPDATES = ( | |||||
| 39 | 'actions/cache@v4' => 'actions/cache@v5', | |||||
| 40 | 'actions/cache@v3' => 'actions/cache@v5', | |||||
| 41 | 'actions/checkout@v5' => 'actions/checkout@v6', | |||||
| 42 | 'actions/checkout@v4' => 'actions/checkout@v6', | |||||
| 43 | 'actions/checkout@v2' => 'actions/checkout@v6', | |||||
| 44 | 'actions/checkout@v3' => 'actions/checkout@v6', | |||||
| 45 | 'actions/setup-node@v3' => 'actions/setup-node@v4', | |||||
| 46 | 'actions/setup-python@v4' => 'actions/setup-python@v5', | |||||
| 47 | 'actions/setup-go@v4' => 'actions/setup-go@v5', | |||||
| 48 | ); | |||||
| 49 | ||||||
| 50 - 114 | =head1 NAME
App::GHGen::Fixer - Auto-fix workflow issues
=head1 SYNOPSIS
use App::GHGen::Fixer qw(apply_fixes);
my $fixed = apply_fixes($workflow, \@issues);
=head1 FUNCTIONS
=head2 can_auto_fix($issue)
Determine whether a given issue can be automatically resolved.
=head3 Purpose
Act as a capability gate before calling C<apply_fixes>. Returns true only
for the four issue types that the Fixer knows how to handle.
=head3 Arguments
=over 4
=item C<$issue> (HashRef, required)
An issue hash with at least a C<type> key. Recognised types are
C<performance>, C<security>, C<cost>, and C<maintenance>.
=back
=head3 Returns
C<1> (true) when the issue type is auto-fixable; C<0> (false) otherwise.
=head3 Side Effects
None. Pure predicate.
=head3 Usage Example
if (can_auto_fix($issue)) {
apply_fixes($workflow, [$issue]);
}
=head3 API SPECIFICATION
=head4 Input
{ issue => { type => 'hashref', required => 1 } }
=head4 Output
{ type => 'scalar' } # boolean: 1 or 0
=head3 FORMAL SPECIFICATION
can_auto_fix : Issue â ð¹
FixableTypes â { performance, security, cost, maintenance }
can_auto_fix(i) â¡ i.type â FixableTypes
=cut | |||||
| 115 | ||||||
| 116 | 43 43 43 | 3247 24 16 | sub can_auto_fix($issue) { | |||
| 117 | 43 | 54 | my %fixable = ( | |||
| 118 | 'performance' => 1, # Can add caching | |||||
| 119 | 'security' => 1, # Can update action versions and add permissions | |||||
| 120 | 'cost' => 1, # Can add concurrency, filters | |||||
| 121 | 'maintenance' => 1, # Can update runners | |||||
| 122 | ); | |||||
| 123 | ||||||
| 124 | 43 | 80 | return $fixable{$issue->{type}} // 0; | |||
| 125 | } | |||||
| 126 | ||||||
| 127 - 186 | =head2 apply_fixes($workflow, $issues)
Apply all auto-fixable changes from C<$issues> directly to C<$workflow>.
=head3 Purpose
Iterate over C<$issues>, skip issues that are not auto-fixable, and call
the appropriate internal fix routine for each fixable type/message
combination. Modifies C<$workflow> in place.
=head3 Arguments
=over 4
=item C<$workflow> (HashRef, required)
The parsed workflow hash to be mutated.
=item C<$issues> (ArrayRef[HashRef], required)
The issues to process. Each must have C<type> and C<message> keys.
=back
=head3 Returns
The number of individual fix operations applied (an integer ⥠0).
=head3 Side Effects
Modifies C<$workflow> in place.
=head3 Usage Example
my $n = apply_fixes($workflow, \@issues);
say "$n fix(es) applied.";
=head3 API SPECIFICATION
=head4 Input
{
workflow => { type => 'hashref', required => 1 },
issues => { type => 'arrayref', required => 1 },
}
=head4 Output
{ type => 'scalar' } # non-negative integer
=head3 FORMAL SPECIFICATION
apply_fixes : Workflow à seq Issue â â
applied â â { fix(w, i) ⣠i â issues, can_auto_fix(i) }
result â applied
Mutates w by applying each fix in sequence.
=cut | |||||
| 187 | ||||||
| 188 | # File-scoped dispatch table for apply_fixes. | |||||
| 189 | # Premise: can_auto_fix guarantees type â {performance,security,cost,maintenance}. | |||||
| 190 | # Conclusion: we only need to match on (type, message pattern) â no further type | |||||
| 191 | # validation is required inside the loop. | |||||
| 192 | # Each rule: [ type_string, compiled_pattern, handler_sub ]. | |||||
| 193 | # Using anonymous delegates (not direct coderefs) so symbol-table mocks work. | |||||
| 194 | my @_APPLY_RULES = ( | |||||
| 195 | [ 'performance', qr/caching/, sub($wf) { add_caching($wf) } ], | |||||
| 196 | [ 'security', qr/unpinned/, sub($wf) { fix_unpinned_actions($wf) } ], | |||||
| 197 | [ 'security', qr/permissions/, sub($wf) { add_permissions($wf) } ], | |||||
| 198 | [ 'maintenance', qr/outdated action/, sub($wf) { update_actions($wf) } ], | |||||
| 199 | [ 'cost', qr/concurrency/, sub($wf) { add_concurrency($wf) } ], | |||||
| 200 | [ 'cost', qr/triggers/, sub($wf) { add_trigger_filters($wf) } ], | |||||
| 201 | [ 'maintenance', qr/runner/, sub($wf) { update_runners($wf) } ], | |||||
| 202 | [ 'performance', qr/missing timeout-minutes/, sub($wf) { add_missing_timeout($wf) } ], | |||||
| 203 | ); | |||||
| 204 | ||||||
| 205 | 25 25 25 25 | 11512 19 12 15 | sub apply_fixes($workflow, $issues) { | |||
| 206 | 25 | 15 | my $modified = 0; | |||
| 207 | ||||||
| 208 | 25 | 21 | for my $issue (@$issues) { | |||
| 209 | # Guard: can_auto_fix is a complete predicate â if it returns false the | |||||
| 210 | # type is guaranteed not in the dispatch table, so skip immediately. | |||||
| 211 | 33 | 30 | next unless can_auto_fix($issue); | |||
| 212 | ||||||
| 213 | 30 | 20 | for my $rule (@_APPLY_RULES) { | |||
| 214 | 161 | 106 | my ($type, $pattern, $handler) = @$rule; | |||
| 215 | 161 | 205 | if ($issue->{type} eq $type && $issue->{message} =~ $pattern) { | |||
| 216 | 27 | 31 | $modified += $handler->($workflow); | |||
| 217 | 27 | 27 | last; # first-match-wins; one issue maps to exactly one fix | |||
| 218 | } | |||||
| 219 | } | |||||
| 220 | } | |||||
| 221 | ||||||
| 222 | 25 | 24 | return $modified; | |||
| 223 | } | |||||
| 224 | ||||||
| 225 - 286 | =head2 fix_workflow($file, $issues)
Load a workflow YAML file, apply fixes, and write it back to disk.
=head3 Purpose
Persist the results of C<apply_fixes> by reading the workflow from C<$file>
with C<YAML::XS::LoadFile>, calling C<apply_fixes>, and rewriting the file
with C<YAML::XS::DumpFile> when at least one fix was applied.
=head3 Arguments
=over 4
=item C<$file> (Str, required)
Path to a YAML workflow file. Passed directly to C<YAML::XS::LoadFile>.
=item C<$issues> (ArrayRef[HashRef], required)
Issues to fix, each with C<type> and C<message> keys.
=back
=head3 Returns
The number of fixes applied (an integer ⥠0). The file is only rewritten
when the count is greater than zero.
=head3 Side Effects
Reads C<$file> from disk; rewrites C<$file> in place when fixes are applied.
=head3 Usage Example
my $n = fix_workflow('.github/workflows/ci.yml', \@issues);
say "$n fix(es) written to ci.yml.";
=head3 API SPECIFICATION
=head4 Input
{
file => { type => 'scalar', required => 1 },
issues => { type => 'arrayref', required => 1 },
}
=head4 Output
{ type => 'scalar' } # non-negative integer
=head3 FORMAL SPECIFICATION
fix_workflow : Path à seq Issue â â
w â LoadFile(file)
fixes â apply_fixes(w, issues)
fixes > 0 â DumpFile(file, w)
result â fixes
=cut | |||||
| 287 | ||||||
| 288 | 5 5 5 5 | 9080 5 4 34 | sub fix_workflow($file, $issues) { | |||
| 289 | 5 | 9 | my $workflow = LoadFile($file); | |||
| 290 | 5 | 180 | my $fixes = apply_fixes($workflow, $issues); | |||
| 291 | ||||||
| 292 | 5 | 9 | if ($fixes > 0) { | |||
| 293 | 3 | 5 | DumpFile($file, $workflow); | |||
| 294 | } | |||||
| 295 | ||||||
| 296 | 5 | 5476 | return $fixes; | |||
| 297 | } | |||||
| 298 | ||||||
| 299 | # Fix implementations | |||||
| 300 | ||||||
| 301 | 11 11 11 | 8505 7 7 | sub add_caching($workflow) { | |||
| 302 | 11 | 21 | my $jobs = $workflow->{jobs} or return 0; | |||
| 303 | 11 | 5 | my $modified = 0; | |||
| 304 | ||||||
| 305 | 11 | 15 | for my $job (values %$jobs) { | |||
| 306 | 11 | 15 | my $steps = $job->{steps} or next; | |||
| 307 | ||||||
| 308 | # Check if already has caching | |||||
| 309 | 11 29 | 43 51 | my $has_cache = grep { $_->{uses} && $_->{uses} =~ /actions\/cache/ } @$steps; | |||
| 310 | 11 | 14 | next if $has_cache; | |||
| 311 | ||||||
| 312 | # Detect project type and add appropriate cache | |||||
| 313 | 8 | 17 | my $cache_step = detect_and_create_cache_step($steps); | |||
| 314 | 8 | 12 | next unless $cache_step; | |||
| 315 | ||||||
| 316 | # Insert cache step after checkout | |||||
| 317 | 6 | 3 | my $insert_at = 0; | |||
| 318 | 6 | 10 | for my $i (0 .. $#$steps) { | |||
| 319 | 8 | 17 | if ($steps->[$i]->{uses} && $steps->[$i]->{uses} =~ /actions\/checkout/) { | |||
| 320 | 5 | 4 | $insert_at = $i + 1; | |||
| 321 | 5 | 5 | last; | |||
| 322 | } | |||||
| 323 | } | |||||
| 324 | ||||||
| 325 | 6 | 9 | splice @$steps, $insert_at, 0, $cache_step; | |||
| 326 | 6 | 6 | $modified++; | |||
| 327 | } | |||||
| 328 | ||||||
| 329 | 11 | 12 | return $modified; | |||
| 330 | } | |||||
| 331 | ||||||
| 332 | 17 17 17 | 8751 9 11 | sub detect_and_create_cache_step($steps) { | |||
| 333 | # Detect project type from steps | |||||
| 334 | 17 | 22 | for my $step (@$steps) { | |||
| 335 | 25 | 38 | my $run = $step->{run} // ''; | |||
| 336 | ||||||
| 337 | # Node.js | |||||
| 338 | 25 | 68 | if ($run =~ /npm (?:install|ci)/ || ($step->{uses} && $step->{uses} =~ /setup-node/)) { | |||
| 339 | return { | |||||
| 340 | 8 | 22 | name => 'Cache dependencies', | |||
| 341 | uses => 'actions/cache@v5', | |||||
| 342 | with => { | |||||
| 343 | path => '~/.npm', | |||||
| 344 | key => '${{ runner.os }}-node-${{ hashFiles(\'**/package-lock.json\') }}', | |||||
| 345 | 'restore-keys' => '${{ runner.os }}-node-', | |||||
| 346 | }, | |||||
| 347 | }; | |||||
| 348 | } | |||||
| 349 | ||||||
| 350 | # Python | |||||
| 351 | 17 | 40 | if ($run =~ /pip install/ || ($step->{uses} && $step->{uses} =~ /setup-python/)) { | |||
| 352 | return { | |||||
| 353 | 2 | 5 | name => 'Cache pip packages', | |||
| 354 | uses => 'actions/cache@v5', | |||||
| 355 | with => { | |||||
| 356 | path => '~/.cache/pip', | |||||
| 357 | key => '${{ runner.os }}-pip-${{ hashFiles(\'**/requirements.txt\') }}', | |||||
| 358 | 'restore-keys' => '${{ runner.os }}-pip-', | |||||
| 359 | }, | |||||
| 360 | }; | |||||
| 361 | } | |||||
| 362 | ||||||
| 363 | # Rust | |||||
| 364 | 15 | 15 | if ($run =~ /cargo (?:build|test)/) { | |||
| 365 | return { | |||||
| 366 | 1 | 2 | name => 'Cache cargo', | |||
| 367 | uses => 'actions/cache@v5', | |||||
| 368 | with => { | |||||
| 369 | path => "~/.cargo/bin/\n~/.cargo/registry/index/\n~/.cargo/registry/cache/\n~/.cargo/git/db/\ntarget/", | |||||
| 370 | key => '${{ runner.os }}-cargo-${{ hashFiles(\'**/Cargo.lock\') }}', | |||||
| 371 | }, | |||||
| 372 | }; | |||||
| 373 | } | |||||
| 374 | ||||||
| 375 | # Go | |||||
| 376 | 14 | 36 | if ($run =~ /go (?:build|test)/ || ($step->{uses} && $step->{uses} =~ /setup-go/)) { | |||
| 377 | return { | |||||
| 378 | 2 | 7 | name => 'Cache Go modules', | |||
| 379 | uses => 'actions/cache@v5', | |||||
| 380 | with => { | |||||
| 381 | path => '~/go/pkg/mod', | |||||
| 382 | key => '${{ runner.os }}-go-${{ hashFiles(\'**/go.sum\') }}', | |||||
| 383 | 'restore-keys' => '${{ runner.os }}-go-', | |||||
| 384 | }, | |||||
| 385 | }; | |||||
| 386 | } | |||||
| 387 | } | |||||
| 388 | ||||||
| 389 | 4 | 5 | return undef; | |||
| 390 | } | |||||
| 391 | ||||||
| 392 | 2 2 2 | 2693 2 2 | sub fix_unpinned_actions($workflow) { | |||
| 393 | 2 | 4 | my $jobs = $workflow->{jobs} or return 0; | |||
| 394 | 2 | 1 | my $modified = 0; | |||
| 395 | ||||||
| 396 | 2 | 3 | for my $job (values %$jobs) { | |||
| 397 | 2 | 4 | my $steps = $job->{steps} or next; | |||
| 398 | 2 | 2 | for my $step (@$steps) { | |||
| 399 | 6 | 6 | next unless $step->{uses}; | |||
| 400 | ||||||
| 401 | 6 | 14 | if ($step->{uses} =~ /^(.+?)\@(?:master|main)$/) { | |||
| 402 | 4 | 3 | my $action = $1; | |||
| 403 | # Map to appropriate version | |||||
| 404 | 4 | 4 | my $version = get_latest_version($action); | |||
| 405 | 4 | 3 | $step->{uses} = "$action\@$version"; | |||
| 406 | 4 | 4 | $modified++; | |||
| 407 | } | |||||
| 408 | } | |||||
| 409 | } | |||||
| 410 | ||||||
| 411 | 2 | 2 | return $modified; | |||
| 412 | } | |||||
| 413 | ||||||
| 414 | 4 4 4 | 1632 4 4 | sub add_permissions($workflow) { | |||
| 415 | 4 | 17 | return 0 if $workflow->{permissions}; | |||
| 416 | ||||||
| 417 | 3 | 5 | $workflow->{permissions} = { contents => 'read' }; | |||
| 418 | 3 | 3 | return 1; | |||
| 419 | } | |||||
| 420 | ||||||
| 421 | 4 4 4 | 2702 4 2 | sub update_actions($workflow) { | |||
| 422 | 4 | 6 | my $jobs = $workflow->{jobs} or return 0; | |||
| 423 | 4 | 5 | my $modified = 0; | |||
| 424 | ||||||
| 425 | # Premise: %ACTION_UPDATES is the canonical version table (defined above). | |||||
| 426 | # Conclusion: this function and find_outdated_actions (Analyzer) are always in sync. | |||||
| 427 | 4 | 5 | for my $job (values %$jobs) { | |||
| 428 | 4 | 7 | my $steps = $job->{steps} or next; | |||
| 429 | 4 | 4 | for my $step (@$steps) { | |||
| 430 | 17 | 14 | next unless $step->{uses}; | |||
| 431 | 16 | 16 | for my $old (keys %ACTION_UPDATES) { | |||
| 432 | 144 | 385 | if ($step->{uses} =~ /^\Q$old\E/) { | |||
| 433 | 13 | 10 | $step->{uses} = $ACTION_UPDATES{$old}; | |||
| 434 | 13 | 18 | $modified++; | |||
| 435 | } | |||||
| 436 | } | |||||
| 437 | } | |||||
| 438 | } | |||||
| 439 | ||||||
| 440 | 4 | 4 | return $modified; | |||
| 441 | } | |||||
| 442 | ||||||
| 443 | 10 10 10 | 1132 9 2 | sub add_concurrency($workflow) { | |||
| 444 | 10 | 15 | return 0 if $workflow->{concurrency}; | |||
| 445 | ||||||
| 446 | $workflow->{concurrency} = { | |||||
| 447 | 9 | 16 | group => '${{ github.workflow }}-${{ github.ref }}', | |||
| 448 | 'cancel-in-progress' => 'true', | |||||
| 449 | }; | |||||
| 450 | 9 | 8 | return 1; | |||
| 451 | } | |||||
| 452 | ||||||
| 453 | 9 9 9 | 6435 8 4 | sub add_trigger_filters($workflow) { | |||
| 454 | 9 | 15 | my $on = $workflow->{on} or return 0; | |||
| 455 | 8 | 8 | my $modified = 0; | |||
| 456 | ||||||
| 457 | # If 'on' is just 'push', expand it | |||||
| 458 | 8 6 | 44 9 | if (ref $on eq 'ARRAY' && grep { $_ eq 'push' } @$on) { | |||
| 459 | $workflow->{on} = { | |||||
| 460 | 3 | 10 | push => { | |||
| 461 | branches => ['main', 'master'], | |||||
| 462 | }, | |||||
| 463 | pull_request => { | |||||
| 464 | branches => ['main', 'master'], | |||||
| 465 | }, | |||||
| 466 | }; | |||||
| 467 | 3 | 2 | $modified++; | |||
| 468 | } | |||||
| 469 | elsif (ref $on eq 'HASH' && $on->{push} && ref $on->{push} eq '') { | |||||
| 470 | # 'push' with no filters | |||||
| 471 | $on->{push} = { | |||||
| 472 | 1 | 2 | branches => ['main', 'master'], | |||
| 473 | }; | |||||
| 474 | 1 | 1 | $modified++; | |||
| 475 | } | |||||
| 476 | ||||||
| 477 | 8 | 9 | return $modified; | |||
| 478 | } | |||||
| 479 | ||||||
| 480 | 6 6 6 | 1529 6 4 | sub add_missing_timeout($workflow) { | |||
| 481 | 6 | 9 | my $jobs = $workflow->{jobs} or return 0; | |||
| 482 | 6 | 6 | my $modified = 0; | |||
| 483 | ||||||
| 484 | 6 | 10 | for my $job_name (keys %$jobs) { | |||
| 485 | 7 | 4 | my $job = $jobs->{$job_name}; | |||
| 486 | ||||||
| 487 | # Skip if timeout already exists | |||||
| 488 | 7 | 13 | next if exists $job->{'timeout-minutes'}; | |||
| 489 | ||||||
| 490 | # Insert default timeout | |||||
| 491 | 6 | 5 | $job->{'timeout-minutes'} = 30; | |||
| 492 | 6 | 5 | $modified++; | |||
| 493 | } | |||||
| 494 | ||||||
| 495 | 6 | 6 | return $modified; | |||
| 496 | } | |||||
| 497 | ||||||
| 498 | 8 8 8 | 6285 5 6 | sub update_runners($workflow) { | |||
| 499 | 8 | 11 | my $jobs = $workflow->{jobs} or return 0; | |||
| 500 | 8 | 7 | my $modified = 0; | |||
| 501 | ||||||
| 502 | 8 | 17 | my %runner_updates = ( | |||
| 503 | 'ubuntu-18.04' => 'ubuntu-latest', | |||||
| 504 | 'ubuntu-16.04' => 'ubuntu-latest', | |||||
| 505 | 'macos-10.15' => 'macos-latest', | |||||
| 506 | 'windows-2016' => 'windows-latest', | |||||
| 507 | ); | |||||
| 508 | ||||||
| 509 | 8 | 13 | for my $job (values %$jobs) { | |||
| 510 | 8 | 9 | my $runs_on = $job->{'runs-on'} or next; | |||
| 511 | ||||||
| 512 | 7 | 14 | if (exists $runner_updates{$runs_on}) { | |||
| 513 | 5 | 4 | $job->{'runs-on'} = $runner_updates{$runs_on}; | |||
| 514 | 5 | 5 | $modified++; | |||
| 515 | } | |||||
| 516 | } | |||||
| 517 | ||||||
| 518 | 8 | 13 | return $modified; | |||
| 519 | } | |||||
| 520 | ||||||
| 521 | 8 8 8 | 2276 9 3 | sub get_latest_version($action) { | |||
| 522 | 8 | 22 | my %versions = ( | |||
| 523 | 'actions/checkout' => 'v6', | |||||
| 524 | 'actions/cache' => 'v5', | |||||
| 525 | 'actions/setup-node' => 'v4', | |||||
| 526 | 'actions/setup-python' => 'v5', | |||||
| 527 | 'actions/setup-go' => 'v5', | |||||
| 528 | 'actions/upload-artifact' => 'v4', | |||||
| 529 | 'actions/download-artifact' => 'v4', | |||||
| 530 | ); | |||||
| 531 | ||||||
| 532 | 8 | 22 | return $versions{$action} // 'v4'; # Default fallback | |||
| 533 | } | |||||
| 534 | ||||||
| 535 - 549 | =head1 AUTHOR Nigel Horne E<lt>njh@nigelhorne.comE<gt> L<https://github.com/nigelhorne> =head1 COPYRIGHT AND LICENSE Copyright 2025-2026 Nigel Horne. Usage is subject to license terms. The license terms of this software are as follows: =cut | |||||
| 550 | ||||||
| 551 | 1; | |||||