File Coverage

File:blib/lib/App/GHGen/Fixer.pm
Coverage:47.3%

linestmtbrancondsubtimecode
1package App::GHGen::Fixer;
2
3
1
1
479
1
use v5.36;
4
1
1
1
1
1
8
use strict;
5
1
1
1
1
0
21
use warnings;
6
1
1
1
2
0
20
use YAML::XS qw(LoadFile DumpFile);
7
1
1
1
2
0
14
use Path::Tiny;
8
9
1
1
1
1
1
795
use Exporter 'import';
10our @EXPORT_OK = qw(
11        apply_fixes
12        can_auto_fix
13        fix_workflow
14);
15
16our $VERSION = '0.05';
17
18 - 34
=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)

Check if an issue can be automatically fixed.

=cut
35
36
4
4
4
2
1
3
sub can_auto_fix($issue) {
37
4
5
        my %fixable = (
38                'performance' => 1,  # Can add caching
39                'security'    => 1,  # Can update action versions and add permissions
40                'cost'        => 1,  # Can add concurrency, filters
41                'maintenance' => 1,  # Can update runners
42        );
43
44
4
5
        return $fixable{$issue->{type}} // 0;
45}
46
47 - 51
=head2 apply_fixes($workflow, $issues)

Apply automatic fixes to a workflow. Returns modified workflow hashref.

=cut
52
53
2
2
2
2
2
2
1
1
sub apply_fixes($workflow, $issues) {
54
2
1
        my $modified = 0;
55
56
2
2
        for my $issue (@$issues) {
57
4
3
                next unless can_auto_fix($issue);
58
59
4
21
        if ($issue->{type} eq 'performance' && $issue->{message} =~ /caching/) {
60
1
1
            $modified += add_caching($workflow);
61        }
62        elsif ($issue->{type} eq 'security' && $issue->{message} =~ /unpinned/) {
63
0
0
            $modified += fix_unpinned_actions($workflow);
64        }
65        elsif ($issue->{type} eq 'security' && $issue->{message} =~ /permissions/) {
66
0
0
            $modified += add_permissions($workflow);
67        }
68        elsif ($issue->{type} eq 'maintenance' && $issue->{message} =~ /outdated action/) {
69
0
0
            $modified += update_actions($workflow);
70        }
71        elsif ($issue->{type} eq 'cost' && $issue->{message} =~ /concurrency/) {
72
1
1
            $modified += add_concurrency($workflow);
73        }
74        elsif ($issue->{type} eq 'cost' && $issue->{message} =~ /triggers/) {
75
1
7
            $modified += add_trigger_filters($workflow);
76        }
77        elsif ($issue->{type} eq 'maintenance' && $issue->{message} =~ /runner/) {
78
0
0
            $modified += update_runners($workflow);
79        } elsif ($issue->{type} eq 'performance' && $issue->{message} =~ /missing timeout-minutes/) {
80
1
3
                $modified += add_missing_timeout($workflow);
81        }
82    }
83
84
2
2
        return $modified;
85}
86
87 - 91
=head2 fix_workflow($file, $issues)

Fix a workflow file in place. Returns number of fixes applied.

=cut
92
93
0
0
0
0
0
0
0
0
sub fix_workflow($file, $issues) {
94
0
0
        my $workflow = LoadFile($file);
95
0
0
        my $fixes = apply_fixes($workflow, $issues);
96
97
0
0
        if ($fixes > 0) {
98
0
0
                DumpFile($file, $workflow);
99        }
100
101
0
0
    return $fixes;
102}
103
104# Fix implementations
105
106
1
1
1
1
1
0
sub add_caching($workflow) {
107
1
1
        my $jobs = $workflow->{jobs} or return 0;
108
1
1
        my $modified = 0;
109
110
1
1
    for my $job (values %$jobs) {
111
1
1
        my $steps = $job->{steps} or next;
112
113        # Check if already has caching
114
1
2
0
4
        my $has_cache = grep { $_->{uses} && $_->{uses} =~ /actions\/cache/ } @$steps;
115
1
3
        next if $has_cache;
116
117        # Detect project type and add appropriate cache
118
1
1
        my $cache_step = detect_and_create_cache_step($steps);
119
1
2
        next unless $cache_step;
120
121        # Insert cache step after checkout
122
0
0
        my $insert_at = 0;
123
0
0
        for my $i (0 .. $#$steps) {
124
0
0
            if ($steps->[$i]->{uses} && $steps->[$i]->{uses} =~ /actions\/checkout/) {
125
0
0
                $insert_at = $i + 1;
126
0
0
                last;
127            }
128        }
129
130
0
0
        splice @$steps, $insert_at, 0, $cache_step;
131
0
0
        $modified++;
132    }
133
134
1
0
    return $modified;
135}
136
137
1
1
1
1
0
1
sub detect_and_create_cache_step($steps) {
138    # Detect project type from steps
139
1
1
    for my $step (@$steps) {
140
2
2
        my $run = $step->{run} // '';
141
142        # Node.js
143
2
4
        if ($run =~ /npm (install|ci)/ || ($step->{uses} && $step->{uses} =~ /setup-node/)) {
144            return {
145
0
0
                name => 'Cache dependencies',
146                uses => 'actions/cache@v5',
147                with => {
148                    path => '~/.npm',
149                    key => '${{ runner.os }}-node-${{ hashFiles(\'**/package-lock.json\') }}',
150                    'restore-keys' => '${{ runner.os }}-node-',
151                },
152            };
153        }
154
155        # Python
156
2
3
        if ($run =~ /pip install/ || ($step->{uses} && $step->{uses} =~ /setup-python/)) {
157            return {
158
0
0
                name => 'Cache pip packages',
159                uses => 'actions/cache@v5',
160                with => {
161                    path => '~/.cache/pip',
162                    key => '${{ runner.os }}-pip-${{ hashFiles(\'**/requirements.txt\') }}',
163                    'restore-keys' => '${{ runner.os }}-pip-',
164                },
165            };
166        }
167
168        # Rust
169
2
2
        if ($run =~ /cargo (build|test)/) {
170            return {
171
0
0
                name => 'Cache cargo',
172                uses => 'actions/cache@v5',
173                with => {
174                    path => "~/.cargo/bin/\n~/.cargo/registry/index/\n~/.cargo/registry/cache/\n~/.cargo/git/db/\ntarget/",
175                    key => '${{ runner.os }}-cargo-${{ hashFiles(\'**/Cargo.lock\') }}',
176                },
177            };
178        }
179
180        # Go
181
2
6
        if ($run =~ /go (build|test)/ || ($step->{uses} && $step->{uses} =~ /setup-go/)) {
182            return {
183
0
0
                name => 'Cache Go modules',
184                uses => 'actions/cache@v5',
185                with => {
186                    path => '~/go/pkg/mod',
187                    key => '${{ runner.os }}-go-${{ hashFiles(\'**/go.sum\') }}',
188                    'restore-keys' => '${{ runner.os }}-go-',
189                },
190            };
191        }
192    }
193
194
1
0
    return undef;
195}
196
197
0
0
0
0
0
0
sub fix_unpinned_actions($workflow) {
198
0
0
        my $jobs = $workflow->{jobs} or return 0;
199
0
0
        my $modified = 0;
200
201
0
0
    for my $job (values %$jobs) {
202
0
0
        my $steps = $job->{steps} or next;
203
0
0
        for my $step (@$steps) {
204
0
0
            next unless $step->{uses};
205
206
0
0
            if ($step->{uses} =~ /^(.+)\@(master|main)$/) {
207
0
0
                my $action = $1;
208                # Map to appropriate version
209
0
0
                my $version = get_latest_version($action);
210
0
0
                $step->{uses} = "$action\@$version";
211
0
0
                $modified++;
212            }
213        }
214    }
215
216
0
0
    return $modified;
217}
218
219
0
0
0
0
0
0
sub add_permissions($workflow) {
220
0
0
        return 0 if $workflow->{permissions};
221
222
0
0
        $workflow->{permissions} = { contents => 'read' };
223
0
0
        return 1;
224}
225
226
0
0
0
0
0
0
sub update_actions($workflow) {
227
0
0
        my $jobs = $workflow->{jobs} or return 0;
228
0
0
        my $modified = 0;
229
230
0
0
    my %updates = (
231        'actions/cache@v4' => 'actions/cache@v5',
232        'actions/cache@v3' => 'actions/cache@v5',
233        'actions/checkout@v5' => 'actions/checkout@v6',
234        'actions/checkout@v4' => 'actions/checkout@v6',
235        'actions/checkout@v3' => 'actions/checkout@v6',
236        'actions/setup-node@v3' => 'actions/setup-node@v4',
237        'actions/setup-python@v4' => 'actions/setup-python@v5',
238        'actions/setup-go@v4' => 'actions/setup-go@v5',
239    );
240
241
0
0
    for my $job (values %$jobs) {
242
0
0
        my $steps = $job->{steps} or next;
243
0
0
        for my $step (@$steps) {
244
0
0
            next unless $step->{uses};
245
246
0
0
            for my $old (keys %updates) {
247
0
0
                if ($step->{uses} =~ /^\Q$old\E/) {
248
0
0
                    $step->{uses} = $updates{$old};
249
0
0
                    $modified++;
250                }
251            }
252        }
253    }
254
255
0
0
    return $modified;
256}
257
258
1
1
1
1
1
0
sub add_concurrency($workflow) {
259
1
1
        return 0 if $workflow->{concurrency};
260
261    $workflow->{concurrency} = {
262
1
2
        group => '${{ github.workflow }}-${{ github.ref }}',
263        'cancel-in-progress' => 'true',
264    };
265
1
3
    return 1;
266}
267
268
1
1
1
1
0
1
sub add_trigger_filters($workflow) {
269
1
1
        my $on = $workflow->{on} or return 0;
270
1
0
        my $modified = 0;
271
272    # If 'on' is just 'push', expand it
273
1
0
4
0
    if (ref $on eq 'ARRAY' && grep { $_ eq 'push' } @$on) {
274        $workflow->{on} = {
275
0
0
            push => {
276                branches => ['main', 'master'],
277            },
278            pull_request => {
279                branches => ['main', 'master'],
280            },
281        };
282
0
0
        $modified++;
283    }
284    elsif (ref $on eq 'HASH' && $on->{push} && ref $on->{push} eq '') {
285        # 'push' with no filters
286        $on->{push} = {
287
0
0
            branches => ['main', 'master'],
288        };
289
0
0
        $modified++;
290    }
291
292
1
1
    return $modified;
293}
294
295
1
1
1
1
0
1
sub add_missing_timeout($workflow) {
296
1
1
    my $jobs = $workflow->{jobs} or return 0;
297
1
0
    my $modified = 0;
298
299
1
1
    for my $job_name (keys %$jobs) {
300
1
1
        my $job = $jobs->{$job_name};
301
302        # Skip if timeout already exists
303
1
3
        next if exists $job->{'timeout-minutes'};
304
305        # Insert default timeout
306
1
1
        $job->{'timeout-minutes'} = 30;
307
1
0
        $modified++;
308    }
309
310
1
1
        return $modified;
311}
312
313
0
0
0
sub update_runners($workflow) {
314
0
        my $jobs = $workflow->{jobs} or return 0;
315
0
        my $modified = 0;
316
317
0
    my %runner_updates = (
318        'ubuntu-18.04' => 'ubuntu-latest',
319        'ubuntu-16.04' => 'ubuntu-latest',
320        'macos-10.15'  => 'macos-latest',
321        'windows-2016' => 'windows-latest',
322    );
323
324
0
    for my $job (values %$jobs) {
325
0
        my $runs_on = $job->{'runs-on'} or next;
326
327
0
        if (exists $runner_updates{$runs_on}) {
328
0
            $job->{'runs-on'} = $runner_updates{$runs_on};
329
0
            $modified++;
330        }
331    }
332
333
0
    return $modified;
334}
335
336
0
0
0
sub get_latest_version($action) {
337
0
    my %versions = (
338        'actions/checkout' => 'v6',
339        'actions/cache' => 'v5',
340        'actions/setup-node' => 'v4',
341        'actions/setup-python' => 'v5',
342        'actions/setup-go' => 'v5',
343        'actions/upload-artifact' => 'v4',
344        'actions/download-artifact' => 'v4',
345    );
346
347
0
    return $versions{$action} // 'v4';  # Default fallback
348}
349
350 - 364
=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
365
3661;