File Coverage

blib/lib/App/GHGen/Fixer.pm
Criterion Covered Total %
statement 82 164 50.0
branch 28 76 36.8
condition 28 60 46.6
subroutine 13 19 68.4
pod 3 13 23.0
total 154 332 46.3


line stmt bran cond sub pod time code
1             package App::GHGen::Fixer;
2              
3 1     1   1993 use v5.36;
  1         4  
4 1     1   6 use strict;
  1         2  
  1         49  
5 1     1   5 use warnings;
  1         2  
  1         75  
6 1     1   6 use YAML::XS qw(LoadFile DumpFile);
  1         2  
  1         75  
7 1     1   7 use Path::Tiny;
  1         1  
  1         53  
8              
9 1     1   5 use Exporter 'import';
  1         2  
  1         2717  
10             our @EXPORT_OK = qw(
11             apply_fixes
12             can_auto_fix
13             fix_workflow
14             );
15              
16             our $VERSION = '0.05';
17              
18             =head1 NAME
19              
20             App::GHGen::Fixer - Auto-fix workflow issues
21              
22             =head1 SYNOPSIS
23              
24             use App::GHGen::Fixer qw(apply_fixes);
25              
26             my $fixed = apply_fixes($workflow, \@issues);
27              
28             =head1 FUNCTIONS
29              
30             =head2 can_auto_fix($issue)
31              
32             Check if an issue can be automatically fixed.
33              
34             =cut
35              
36 4     4 1 7 sub can_auto_fix($issue) {
  4         7  
  4         23  
37 4         20 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   50     24 return $fixable{$issue->{type}} // 0;
45             }
46              
47             =head2 apply_fixes($workflow, $issues)
48              
49             Apply automatic fixes to a workflow. Returns modified workflow hashref.
50              
51             =cut
52              
53 2     2 1 12 sub apply_fixes($workflow, $issues) {
  2         3  
  2         4  
  2         774  
54 2         6 my $modified = 0;
55              
56 2         8 for my $issue (@$issues) {
57 4 50       12 next unless can_auto_fix($issue);
58              
59 4 100 100     89 if ($issue->{type} eq 'performance' && $issue->{message} =~ /caching/) {
    50 33        
    50 33        
    50 33        
    100 100        
    100 66        
    50 33        
    50 33        
60 1         4 $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         4 $modified += add_concurrency($workflow);
73             }
74             elsif ($issue->{type} eq 'cost' && $issue->{message} =~ /triggers/) {
75 1         4 $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         4 $modified += add_missing_timeout($workflow);
81             }
82             }
83              
84 2         13 return $modified;
85             }
86              
87             =head2 fix_workflow($file, $issues)
88              
89             Fix a workflow file in place. Returns number of fixes applied.
90              
91             =cut
92              
93 0     0 1 0 sub fix_workflow($file, $issues) {
  0         0  
  0         0  
  0         0  
94 0         0 my $workflow = LoadFile($file);
95 0         0 my $fixes = apply_fixes($workflow, $issues);
96              
97 0 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 0 2 sub add_caching($workflow) {
  1         2  
  1         3  
107 1 50       4 my $jobs = $workflow->{jobs} or return 0;
108 1         2 my $modified = 0;
109              
110 1         4 for my $job (values %$jobs) {
111 1 50       3 my $steps = $job->{steps} or next;
112              
113             # Check if already has caching
114 1 100       3 my $has_cache = grep { $_->{uses} && $_->{uses} =~ /actions\/cache/ } @$steps;
  2         34  
115 1 50       4 next if $has_cache;
116              
117             # Detect project type and add appropriate cache
118 1         3 my $cache_step = detect_and_create_cache_step($steps);
119 1 50       4 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 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         20 return $modified;
135             }
136              
137 1     1 0 2 sub detect_and_create_cache_step($steps) {
  1         1  
  1         2  
138             # Detect project type from steps
139 1         3 for my $step (@$steps) {
140 2   100     8 my $run = $step->{run} // '';
141              
142             # Node.js
143 2 50 66     14 if ($run =~ /npm (install|ci)/ || ($step->{uses} && $step->{uses} =~ /setup-node/)) {
      33        
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 50 66     12 if ($run =~ /pip install/ || ($step->{uses} && $step->{uses} =~ /setup-python/)) {
      33        
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 50       7 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 50 66     15 if ($run =~ /go (build|test)/ || ($step->{uses} && $step->{uses} =~ /setup-go/)) {
      33        
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         2 return undef;
195             }
196              
197 0     0 0 0 sub fix_unpinned_actions($workflow) {
  0         0  
  0         0  
198 0 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       0 my $steps = $job->{steps} or next;
203 0         0 for my $step (@$steps) {
204 0 0       0 next unless $step->{uses};
205              
206 0 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 sub add_permissions($workflow) {
  0         0  
  0         0  
220 0 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 sub update_actions($workflow) {
  0         0  
  0         0  
227 0 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       0 my $steps = $job->{steps} or next;
243 0         0 for my $step (@$steps) {
244 0 0       0 next unless $step->{uses};
245              
246 0         0 for my $old (keys %updates) {
247 0 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 0 2 sub add_concurrency($workflow) {
  1         2  
  1         2  
259 1 50       3 return 0 if $workflow->{concurrency};
260              
261             $workflow->{concurrency} = {
262 1         6 group => '${{ github.workflow }}-${{ github.ref }}',
263             'cancel-in-progress' => 'true',
264             };
265 1         3 return 1;
266             }
267              
268 1     1 0 2 sub add_trigger_filters($workflow) {
  1         2  
  1         2  
269 1 50       21 my $on = $workflow->{on} or return 0;
270 1         3 my $modified = 0;
271              
272             # If 'on' is just 'push', expand it
273 1 50 33     20 if (ref $on eq 'ARRAY' && grep { $_ eq 'push' } @$on) {
  0 50 33     0  
      33        
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         4 return $modified;
293             }
294              
295 1     1 0 14 sub add_missing_timeout($workflow) {
  1         2  
  1         2  
296 1 50       4 my $jobs = $workflow->{jobs} or return 0;
297 1         2 my $modified = 0;
298              
299 1         4 for my $job_name (keys %$jobs) {
300 1         2 my $job = $jobs->{$job_name};
301              
302             # Skip if timeout already exists
303 1 50       3 next if exists $job->{'timeout-minutes'};
304              
305             # Insert default timeout
306 1         3 $job->{'timeout-minutes'} = 30;
307 1         2 $modified++;
308             }
309              
310 1         3 return $modified;
311             }
312              
313 0     0 0   sub update_runners($workflow) {
  0            
  0            
314 0 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 0         my $runs_on = $job->{'runs-on'} or next;
326              
327 0 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) {
  0            
  0            
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   0       return $versions{$action} // 'v4'; # Default fallback
348             }
349              
350             =head1 AUTHOR
351              
352             Nigel Horne Enjh@nigelhorne.comE
353              
354             L
355              
356             =head1 COPYRIGHT AND LICENSE
357              
358             Copyright 2025-2026 Nigel Horne.
359              
360             Usage is subject to license terms.
361              
362             The license terms of this software are as follows:
363              
364             =cut
365              
366             1;