App-GHGen

 view release on metacpan or  search on metacpan

bin/ghgen  view on Meta::CPAN

	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);

bin/ghgen  view on Meta::CPAN

			}

			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',

bin/ghgen  view on Meta::CPAN

	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
		}

bin/ghgen  view on Meta::CPAN

	# 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.");

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


=head2 customize_workflow($type)

Interactive customization for a specific workflow type.
Returns hash of configuration options.

=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,

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_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(

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

    $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'
    );

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

    );
    $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'

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

    );
    $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?",

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

    );
    $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 '';

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

    );
    $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?',

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

	);
	$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;

scripts/generate_index.pl  view on Meta::CPAN

		.icon-link {
			text-decoration: none;
		}
		.icon-link:hover {
			opacity: 0.7;
			cursor: pointer;
		}
		.coverage-badge {
			padding: 2px 6px;
			border-radius: 4px;
			font-weight: bold;
			color: white;
			font-size: 0.9em;
		}
		.badge-good { background-color: #4CAF50; }
		.badge-warn { background-color: #FFC107; }
		.badge-bad { background-color: #F44336; }
		.summary-row {
			font-weight: bold;
			background-color: #f0f0f0;
		}
		td.positive { color: green; font-weight: bold; }
		td.negative { color: red; font-weight: bold; }
		td.neutral { color: gray; }
		/* Show cursor points on the headers to show that they are clickable */
		th { background-color: #f2f2f2; cursor: pointer; }
		th.sortable {
			cursor: pointer;
			user-select: none;
			white-space: nowrap;
		}
		th .arrow {
			color: #aaa;	/* dimmed for inactive */
			font-weight: normal;
		}
		th .arrow.active {
			color: #000;	/* dark for active */
			font-weight: bold;
		}
		.sparkline {
			display: inline-block;
			vertical-align: middle;
		}
		tr.cpan-fail td {
			background-color: #fdd;
		}
		tr.cpan-unknown td {
			background-color: #eee;
			color: #666;
		}
		tr.cpan-na td {
			background-color: #ffffde;
			color: #666;
		}
		.new-failure {
			background: #c00;
			color: #fff;
			font-weight: bold;
			padding: 2px 6px;
			border-radius: 4px;
			font-size: 0.85em;
		}
		.notice {
			padding: 8px 12px;
			margin: 10px 0;
			border-radius: 4px;
			font-size: 0.95em;
		}
		.notice strong {
			font-weight: bold;
		}
		.notice.perl-version-cliff {
			background-color: #fff3cd; /* soft amber */
			border: 1px solid #ffeeba;
			color: #856404;
		}
		.notice.perl-version-cliff a {
			color: #533f03;
			text-decoration: underline;
		}

scripts/generate_index.pl  view on Meta::CPAN

			y: { beginAtZero: true, max: 100, title: { display: true, text: 'Coverage (%)' } }
		}, plugins: {
			legend: {
				display: true,
				position: 'top', // You can also use 'bottom', 'left', or 'right'
				labels: {
					boxWidth: 12,
					padding: 10,
					font: {
						size: 12,
						weight: 'bold'
					}
				}
			}, tooltip: {
				callbacks: {
					label: function(context) {
						const raw = context.raw;
						const coverage = raw.y.toFixed(1);
						const delta = raw.delta?.toFixed(1) ?? '0.0';
						const sign = delta > 0 ? '+' : delta < 0 ? '-' : '±';
						// const baseLine = `${raw.label}: ${coverage}% (${sign}${Math.abs(delta)}%)`;



( run in 0.573 second using v1.01-cache-2.11-cpan-cdf2f3d4e48 )