view release on metacpan or search on metacpan
'auto|a' => \$opts{auto},
'customize|c' => \$opts{customize},
'fix|f' => \$opts{fix},
'estimate|e' => \$opts{estimate},
'timeout=i' => \$opts{timeout},
) or pod2usage(2);
pod2usage(1) if $opts{help};
if ($opts{version}) {
say "ghgen version $VERSION";
exit 0;
}
my $command = shift @ARGV // '';
if ($command eq 'generate') {
cmd_generate();
} elsif ($command eq 'analyze') {
cmd_analyze();
} else {
my $output_file = $opts{output};
unless ($output_file) {
my $workflows_dir = path('.github/workflows');
$workflows_dir->mkpath unless $workflows_dir->exists;
$output_file = $workflows_dir->child("$type-ci.yml");
}
# Write the workflow
path($output_file)->spew_utf8($yaml);
say colored(['green'], "â ") . 'Generated workflow: ' . colored(['bold'], $output_file);
say '';
say colored(['cyan'], "Next steps:");
say " 1. Review the generated workflow";
say " 2. Customize it for your project";
say " 3. Commit and push to trigger the workflow";
say '';
say colored(['yellow'], "ð¡ Tip: ") . "Run 'ghgen analyze' to check for optimizations";
}
sub cmd_analyze() {
my @workflow_files = find_workflows();
unless (@workflow_files) {
die colored(['red'], 'Error: ') .
"No .github/workflows directory found\n" .
"Run this from the root of a repository with GitHub Actions.\n";
}
say colored(['bold cyan'], "GitHub Actions Workflow Analyzer");
if ($opts{fix}) {
say colored(['yellow'], "(Auto-fix mode enabled)");
}
if ($opts{estimate}) {
say colored(['cyan'], "(Cost estimation mode)");
}
say colored(['cyan'], "=" x 50);
say '';
# Get cost estimate first if requested
my $current_usage;
if ($opts{estimate}) {
say colored(['bold'], "ð Estimating current CI usage...");
say '';
$current_usage = estimate_current_usage(\@workflow_files);
say colored(['bold'], "Current Monthly Usage:");
say " Total CI minutes: " . colored(['yellow'], $current_usage->{total_minutes});
say " Billable minutes: " . colored(['yellow'], $current_usage->{billable_minutes}) .
" (after 2,000 free tier)";
say " Estimated cost: " . colored(['yellow'], sprintf("\$%.2f", $current_usage->{monthly_cost}));
say '';
if (@{$current_usage->{workflows}} > 1) {
say colored(['bold'], "Per-Workflow Breakdown:");
for my $wf (sort { $b->{minutes_per_month} <=> $a->{minutes_per_month} }
@{$current_usage->{workflows}}) {
say sprintf(" %-40s %4d min/month (%3d runs à %4.1f min/run)",
$wf->{name},
$wf->{minutes_per_month},
$wf->{runs_per_month},
$wf->{minutes_per_run});
}
say '';
}
}
my @all_issues;
my $total_workflows = 0;
my $total_fixes = 0;
# Analyze each workflow file
for my $file (@workflow_files) {
$total_workflows++;
say colored(['bold'], "ð Analyzing: ") . $file->basename;
my $workflow;
eval { $workflow = LoadFile($file) };
if ($@) {
say colored(['red'], " â Failed to parse YAML: $@");
next;
}
my @issues = analyze_workflow($workflow, $file->basename);
if (@issues) {
# Filter to auto-fixable issues if in fix mode
my @fixable = $opts{fix} ? grep { can_auto_fix($_) } @issues : @issues;
if ($opts{fix} && @fixable) {
say " " . colored(['yellow'], "â ") . "Applying " . scalar(@fixable) . " fix(es)...";
my $fixes = fix_workflow($file, \@fixable);
$total_fixes += $fixes;
if ($fixes > 0) {
say " " . colored(['green'], "â ") . "Applied $fixes fix(es)";
} else {
say " " . colored(['yellow'], "â ") . "Could not apply some fixes automatically";
}
} else {
# Just report issues
for my $issue (@issues) {
my $icon = can_auto_fix($issue) ? "â " : "â¹";
say " " . colored(['yellow'], "$icon ") . $issue->{message};
if ($issue->{fix} && !$opts{fix} && !$opts{estimate}) {
say colored(['cyan'], " ð¡ Fix:");
for my $line (split /\n/, $issue->{fix}) {
say " " . $line;
}
}
}
}
push @all_issues, map { { file => $file->basename, %$_ } } @issues;
} else {
say " " . colored(['green'], "â No issues found");
}
say '';
}
# Summary
say colored(['bold cyan'], "=" x 50);
say colored(['bold'], "Summary:");
say " Workflows analyzed: $total_workflows";
say " Total issues found: " . scalar(@all_issues);
if ($opts{fix}) {
say " Fixes applied: " . colored(['green'], $total_fixes);
}
# Cost savings estimate
if ($opts{estimate} && @all_issues) {
say '';
my $savings = estimate_savings(\@all_issues, \@workflow_files);
if ($savings->{minutes} > 0) {
say colored(['bold'], "ð° Potential Savings:");
say ' With recommended changes: ' . colored(['green'], ($current_usage->{total_minutes} - $savings->{minutes})) .
" CI minutes/month";
say " Reduction: " . colored(['green'], "-$savings->{minutes} minutes") .
" (" . colored(['green'], "$savings->{percentage}%") . ")";
say " Cost savings: " . colored(['green'], "\$$savings->{cost}/month") .
" (for private repos)";
if (@{$savings->{details}}) {
say '';
say " Breakdown:";
for my $detail (@{$savings->{details}}) {
say " ⢠$detail->{description}: ~$detail->{minutes} min/month";
}
}
}
}
if (@all_issues && !$opts{fix}) {
say '';
say colored(['bold yellow'], "Recommendations:");
my %by_type;
push @{$by_type{$_->{type}}}, $_ for @all_issues;
for my $type (sort keys %by_type) {
my $count = scalar @{$by_type{$type}};
my $fixable = grep { can_auto_fix($_) } @{$by_type{$type}};
my $suffix = $fixable > 0 ? " ($fixable auto-fixable)" : '';
say " ⢠" . colored(['yellow'], "$type") . ": $count workflow(s) affected$suffix";
}
say '';
say colored(['cyan'], "ð¡ Tip: ") . "Run " . colored(['bold'], "ghgen analyze --fix") .
" to automatically apply fixes";
if (!$opts{estimate}) {
say colored(['cyan'], "ð¡ Tip: ") . "Run " . colored(['bold'], "ghgen analyze --estimate") .
" to see potential cost savings";
}
}
exit(@all_issues ? 1 : 0);
}
sub list_types() {
say colored(['bold cyan'], "Available workflow templates:");
say '';
my %types = list_workflow_types();
for my $type (sort keys %types) {
say " " . colored(['green'], sprintf("%-10s", $type)) . " - $types{$type}";
}
say '';
say "Usage: ghgen generate --type=<type> [--output=<file>]";
say " or: ghgen generate --interactive";
}
sub interactive_select_type() {
say colored(['bold cyan'], "GitHub Actions Workflow Generator");
say colored(['cyan'], "=" x 50);
say '';
say "Select a project type:";
say '';
my @types = qw(node python rust go ruby perl java cpp php docker static);
my %descriptions = (
node => 'Node.js/npm',
python => 'Python',
rust => 'Rust',
go => 'Go',
ruby => 'Ruby',
perl => 'Perl',
java => 'Java (Maven/Gradle)',
cpp => 'C++ (CMake)',
php => 'PHP (Composer)',
docker => 'Docker',
static => 'Static site (GitHub Pages)',
);
for my $i (0..$#types) {
say " " . colored(['green'], $i + 1) . ". $descriptions{$types[$i]}";
}
say '';
print "Enter number (1-" . scalar(@types) . "): ";
chomp(my $choice = <STDIN>);
if ($choice =~ /^\d+$/ && $choice >= 1 && $choice <= @types) {
return $types[$choice - 1];
}
die colored(['red'], "Invalid choice\n");
}
sub auto_detect_with_prompt() {
say colored(['bold cyan'], "Auto-detecting project type...");
say '';
my @detections = detect_project_type();
unless (@detections) {
say colored(['yellow'], "â ") . "Could not auto-detect project type.";
say '';
say "Try one of these options:";
say " ⢠" . colored(['cyan'], "ghgen generate --list") . " - see all available types";
say " ⢠" . colored(['cyan'], "ghgen generate --interactive") . " - choose interactively";
say " ⢠" . colored(['cyan'], "ghgen generate --type=<type>") . " - specify type directly";
return undef;
}
# Show top detection
my $top = $detections[0];
if(!defined($top->{type})) {
die "Cound't autodetect the project type";
}
say colored(['green'], "â ") . 'Detected project type: ' . colored(['bold'], uc($top->{type}));
# Show evidence
my $indicators = get_project_indicators($top->{type});
my @found_indicators;
for my $indicator (@$indicators) {
# Simple file check (not glob patterns)
if ($indicator !~ /[\*\?]/ && path($indicator)->exists) {
push @found_indicators, $indicator;
last if @found_indicators >= 3; # Show max 3
}
}
if (@found_indicators) {
say colored(['cyan'], " Evidence: ") . join(', ', @found_indicators);
}
say '';
# Show alternatives if any
if (@detections > 1) {
say colored(['yellow'], "Other possibilities:");
for my $i (1 .. min(2, $#detections)) {
say " ⢠$detections[$i]->{type} (confidence: " . int($detections[$i]->{score} / $top->{score} * 100) . "%)";
}
say '';
}
# Prompt for confirmation
print "Generate " . colored(['bold'], uc($top->{type})) . " workflow? [Y/n]: ";
chomp(my $response = <STDIN>);
if ($response =~ /^(y|yes|)$/i) {
return $top->{type};
} elsif ($response =~ /^(n|no)$/i) {
say '';
say "Cancelled. Use " . colored(['cyan'], "--interactive") . " to choose manually.";
return undef;
} else {
say colored(['red'], "Invalid response. Cancelled.");
return undef;
}
}
sub min($a, $b) {
return $a < $b ? $a : $b;
}
__END__
lib/App/GHGen/Analyzer.pm view on Meta::CPAN
=head3 Side Effects
None. Performs read-only filesystem access.
=head3 Usage Example
use App::GHGen::Analyzer qw(find_workflows);
chdir '/path/to/repo';
my @wfs = find_workflows();
say $_->basename for @wfs;
=head3 API SPECIFICATION
=head4 Input
# No parameters.
=head4 Output
{ type => 'array', element => { isa => 'Path::Tiny' } }
lib/App/GHGen/Analyzer.pm view on Meta::CPAN
None. Pure function; does not modify its arguments.
=head3 Usage Example
use App::GHGen::Analyzer qw(analyze_workflow);
use YAML::XS qw(LoadFile);
my $wf = LoadFile('.github/workflows/ci.yml');
my @issues = analyze_workflow($wf, 'ci.yml');
for my $issue (@issues) {
say "$issue->{severity}: $issue->{message}";
}
=head3 API SPECIFICATION
=head4 Input
{
workflow => { type => 'hashref', required => 1 },
filename => { type => 'scalar', required => 1 },
}
lib/App/GHGen/Analyzer.pm view on Meta::CPAN
A non-empty string. When the ecosystem is recognised the string is an
C<actions/cache> YAML step snippet; otherwise a generic guidance message.
=head3 Side Effects
None. Pure function.
=head3 Usage Example
my $suggestion = get_cache_suggestion($workflow);
say $suggestion;
=head3 API SPECIFICATION
=head4 Input
{ workflow => { type => 'hashref', required => 1 } }
=head4 Output
{ type => 'scalar' }
lib/App/GHGen/Detector.pm view on Meta::CPAN
hash references sorted by descending score.
=head3 Side Effects
Reads the filesystem; results depend on the current working directory.
=head3 Usage Example
use App::GHGen::Detector qw(detect_project_type);
my $type = detect_project_type();
say $type // 'unknown';
=head3 API SPECIFICATION
=head4 Input
# No parameters.
=head4 Output (scalar context)
{ type => 'scalar' } # may be undef
lib/App/GHGen/Detector.pm view on Meta::CPAN
When C<$type> is given but unknown: C<undef>.
=head3 Side Effects
None. Pure lookup function.
=head3 Usage Example
my $indicators = get_project_indicators('perl');
say for @$indicators;
my $all = get_project_indicators();
for my $type (sort keys %$all) {
say "$type: " . join(', ', @{$all->{$type}});
}
=head3 API SPECIFICATION
=head4 Input
{ type => { type => 'scalar', optional => 1 } }
=head4 Output
lib/App/GHGen/Fixer.pm view on Meta::CPAN
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 },
}
lib/App/GHGen/Fixer.pm view on Meta::CPAN
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 },
}
lib/App/GHGen/Generator.pm view on Meta::CPAN
Contains exactly eleven entries: C<perl>, C<node>, C<python>, C<rust>,
C<go>, C<ruby>, C<java>, C<cpp>, C<php>, C<docker>, C<static>.
=head3 Side Effects
None. Pure function.
=head3 Usage Example
my %types = list_workflow_types();
say "$_: $types{$_}" for sort keys %types;
=head3 API SPECIFICATION
=head4 Input
# No parameters.
=head4 Output
{ type => 'hashref', keys => { '*' => { type => 'scalar' } } }
lib/App/GHGen/Interactive.pm view on Meta::CPAN
answer â chomp(readline(STDIN))
result â
answer = "" â default
answer â â â§ 1 ⤠answer ⤠|choices| â answer â 1
otherwise â default
=cut
sub prompt_choice($question, $choices, $default = 0) {
say $question;
for my $i (0 .. $#$choices) {
my $marker = $i == $default ? colored(['green'], 'â') : ' ';
say " $marker " . ($i + 1) . ". $choices->[$i]";
}
print "\nEnter number [" . ($default + 1) . "]: ";
chomp(my $answer = <STDIN>);
return $default if $answer eq '';
return $answer - 1 if $answer =~ /^\d+$/ && $answer >= 1 && $answer <= @$choices;
return $default;
}
lib/App/GHGen/Interactive.pm view on Meta::CPAN
answer â chomp(readline(STDIN))
result â
answer = "" â defaults
answer =~ /^all$/i â options
otherwise â [ options[nâ1] ⣠n â split(/[,\s]+/, answer), 1 ⤠n ⤠|options| ]
?? defaults (when result = â
)
=cut
sub prompt_multiselect($question, $options, $defaults = []) {
say $question;
say colored(['cyan'], "(Enter numbers separated by commas, or 'all')");
my %is_default = map { $_ => 1 } @$defaults;
for my $i (0 .. $#$options) {
my $marker = $is_default{$options->[$i]} ? colored(['green'], 'â') : ' ';
say " $marker " . ($i + 1) . ". $options->[$i]";
}
print "\nEnter choices [" . join(',', map { $_+1 } 0..$#$defaults) . "]: ";
chomp(my $answer = <STDIN>);
return $defaults if $answer eq '';
if ($answer =~ /^all$/i) {
return $options;
}
lib/App/GHGen/Interactive.pm view on Meta::CPAN
SupportedCustomTypes â { perl, node, python, rust, go, ruby, docker, static }
customize_workflow : â¤* â Config
t â SupportedCustomTypes â _customize_t()
t â SupportedCustomTypes â {}
=cut
sub customize_workflow($type) {
say '';
say colored(['bold cyan'], "=== Workflow Customization: " . uc($type) . " ===");
say '';
my %dispatch = (
perl => \&_customize_perl,
node => \&_customize_node,
python => \&_customize_python,
rust => \&_customize_rust,
go => \&_customize_go,
ruby => \&_customize_ruby,
docker => \&_customize_docker,
static => \&_customize_static,
lib/App/GHGen/Interactive.pm view on Meta::CPAN
return $dispatch{$type}->();
}
return {};
}
sub _customize_perl() {
my %config;
# Perl versions
say colored(['bold'], "Perl Versions to Test:");
my @all_versions = qw(5.40 5.38 5.36 5.34 5.32 5.30 5.28 5.26 5.24 5.22);
my @default_versions = qw(5.40 5.38 5.36);
$config{perl_versions} = prompt_multiselect(
"Which Perl versions?",
\@all_versions,
\@default_versions
);
say '';
# Operating systems
say colored(['bold'], "Operating Systems:");
my @all_os = ('ubuntu-latest', 'macos-latest', 'windows-latest');
my @default_os = @all_os;
$config{os} = prompt_multiselect(
"Which operating systems?",
\@all_os,
\@default_os
);
say '';
# Code quality
say colored(['bold'], "Code Quality Tools:");
$config{enable_linter} = prompt_yes_no(
"Enable syntax linting (perl -c on all matrix cells)?",
'y'
);
say '';
$config{enable_linter_unused} = prompt_yes_no(
"Enable unused-variable check (warnings::unused, latest+ubuntu only)?",
'n'
);
say '';
$config{enable_critic} = prompt_yes_no(
"Enable Perl::Critic?",
'y'
);
say '';
# Coverage
$config{enable_coverage} = prompt_yes_no(
"Enable test coverage (Devel::Cover)?",
'y'
);
say '';
# Branches
say colored(['bold'], "Branch Configuration:");
my $branches = prompt_text(
"Branches to run on (comma-separated)",
'main,master'
);
$config{branches} = [split /,\s*/, $branches];
say '';
return \%config;
}
sub _customize_node() {
my %config;
# Node versions
say colored(['bold'], "Node.js Versions to Test:");
my @all_versions = qw(18.x 20.x 22.x 23.x);
my @default_versions = qw(20.x 22.x);
$config{node_versions} = prompt_multiselect(
"Which Node.js versions?",
\@all_versions,
\@default_versions
);
say '';
# Package manager
say colored(['bold'], "Package Manager:");
my $pm_choice = prompt_choice(
"Which package manager?",
['npm', 'yarn', 'pnpm'],
0
);
$config{package_manager} = ['npm', 'yarn', 'pnpm']->[$pm_choice];
say '';
# Linting
$config{enable_lint} = prompt_yes_no(
"Enable linting?",
'y'
);
say '';
# Build step
$config{enable_build} = prompt_yes_no(
"Enable build step?",
'y'
);
say '';
# Branches
my $branches = prompt_text(
"Branches to run on (comma-separated)",
'main,develop'
);
$config{branches} = [split /,\s*/, $branches];
say '';
return \%config;
}
sub _customize_python() {
my %config;
# Python versions
say colored(['bold'], "Python Versions to Test:");
my @all_versions = qw(3.9 3.10 3.11 3.12 3.13);
my @default_versions = qw(3.11 3.12);
$config{python_versions} = prompt_multiselect(
"Which Python versions?",
\@all_versions,
\@default_versions
);
say '';
# Linting
say colored(['bold'], "Code Quality:");
$config{enable_flake8} = prompt_yes_no(
"Enable flake8 linting?",
'y'
);
say '';
$config{enable_black} = prompt_yes_no(
"Enable black formatter check?",
'n'
);
say '';
# Coverage
$config{enable_coverage} = prompt_yes_no(
"Enable test coverage?",
'y'
);
say '';
# Branches
my $branches = prompt_text(
"Branches to run on (comma-separated)",
'main,develop'
);
$config{branches} = [split /,\s*/, $branches];
say '';
return \%config;
}
sub _customize_rust() {
my %config;
say colored(['bold'], "Rust Workflow Options:");
$config{enable_fmt} = prompt_yes_no(
"Enable formatting check (cargo fmt)?",
'y'
);
say '';
$config{enable_clippy} = prompt_yes_no(
"Enable clippy linting?",
'y'
);
say '';
$config{enable_release} = prompt_yes_no(
"Build release binary?",
'y'
);
say '';
my $branches = prompt_text(
"Branches to run on (comma-separated)",
'main'
);
$config{branches} = [split /,\s*/, $branches];
say '';
return \%config;
}
sub _customize_go() {
my %config;
say colored(['bold'], "Go Workflow Options:");
my $go_version = prompt_text(
"Go version",
'1.22'
);
$config{go_version} = $go_version;
say '';
$config{enable_vet} = prompt_yes_no(
"Enable go vet?",
'y'
);
say '';
$config{enable_race} = prompt_yes_no(
"Enable race detector?",
'y'
);
say '';
$config{enable_coverage} = prompt_yes_no(
"Enable test coverage?",
'y'
);
say '';
my $branches = prompt_text(
"Branches to run on (comma-separated)",
'main'
);
$config{branches} = [split /,\s*/, $branches];
say '';
return \%config;
}
sub _customize_ruby() {
my %config;
say colored(['bold'], "Ruby Versions to Test:");
my @all_versions = qw(3.1 3.2 3.3);
my @default_versions = qw(3.2 3.3);
$config{ruby_versions} = prompt_multiselect(
"Which Ruby versions?",
\@all_versions,
\@default_versions
);
say '';
my $branches = prompt_text(
"Branches to run on (comma-separated)",
'main'
);
$config{branches} = [split /,\s*/, $branches];
say '';
return \%config;
}
sub _customize_docker() {
my %config;
say colored(['bold'], "Docker Workflow Options:");
my $image_name = prompt_text(
"Docker image name (user/image)",
'your-username/your-image'
);
$config{image_name} = $image_name;
say '';
$config{push_on_pr} = prompt_yes_no(
'Push images on pull requests?',
'n'
);
say '';
my $branches = prompt_text(
"Branches to run on (comma-separated)",
'main'
);
$config{branches} = [split /,\s*/, $branches];
say '';
return \%config;
}
sub _customize_static() {
my %config;
say colored(['bold'], "Static Site Deployment:");
my $build_dir = prompt_text('Build output directory', './public');
$config{build_dir} = $build_dir;
say '';
my $build_command = prompt_text("Build command", 'npm run build');
$config{build_command} = $build_command;
say '';
return \%config;
}
=head1 AUTHOR
Nigel Horne E<lt>njh@nigelhorne.comE<gt>
L<https://github.com/nigelhorne>
lib/App/GHGen/PerlCustomizer.pm view on Meta::CPAN
}
=head3 Side Effects
Reads files from the current working directory.
=head3 Usage Example
use App::GHGen::PerlCustomizer qw(detect_perl_requirements);
my $reqs = detect_perl_requirements();
say $reqs->{min_version} // 'not specified';
=head3 API SPECIFICATION
=head4 Input
# No parameters.
=head4 Output
{
lib/App/GHGen/Reporter.pm view on Meta::CPAN
cost => Int, # floor(minutes * 0.008); 0 when no savings
}
=head3 Side Effects
None. Pure function.
=head3 Usage Example
my $s = estimate_savings(\@issues);
say "Save $s->{minutes} min/month";
=head3 API SPECIFICATION
=head4 Input
{ issues => { type => 'arrayref', required => 1 } }
=head4 Output
{