| File: | blib/lib/App/GHGen/Reporter.pm |
| Coverage: | 98.9% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package App::GHGen::Reporter; | |||||
| 2 | ||||||
| 3 | 5 5 | 819 8 | use v5.36; | |||
| 4 | 5 5 5 | 8 2 32 | use strict; | |||
| 5 | 5 5 5 | 5 3 88 | use warnings; | |||
| 6 | ||||||
| 7 | 5 5 5 | 4 3 2578 | use Exporter 'import'; | |||
| 8 | our @EXPORT_OK = qw( | |||||
| 9 | generate_markdown_report | |||||
| 10 | generate_github_comment | |||||
| 11 | estimate_savings | |||||
| 12 | ); | |||||
| 13 | ||||||
| 14 | our $VERSION = '0.10'; | |||||
| 15 | ||||||
| 16 - 89 | =head1 NAME
App::GHGen::Reporter - Generate reports for GitHub integration
=head1 SYNOPSIS
use App::GHGen::Reporter qw(generate_github_comment);
my $comment = generate_github_comment(\@issues, \@fixes);
=head1 FUNCTIONS
=head2 generate_markdown_report($issues, $fixes)
Produce a Markdown-formatted report of workflow issues and applied fixes.
=head3 Purpose
Render a structured Markdown document that summarises detected issues grouped
by category, includes suggested fixes, and appends an estimated savings
section when applicable.
=head3 Arguments
=over 4
=item C<$issues> (ArrayRef[HashRef], required)
Issues to report. Each must have C<type>, C<severity>, C<message>; an
optional C<fix> key renders a collapsible C<E<lt>detailsE<gt>> block.
=item C<$fixes> (ArrayRef, optional, default C<[]>)
List of fixes already applied (used only for the summary count).
=back
=head3 Returns
A non-empty Markdown string beginning with C<# GHGen Workflow Analysis>.
=head3 Side Effects
None. Pure function.
=head3 Usage Example
my $md = generate_markdown_report(\@issues, \@fixes);
path('report.md')->spew_utf8($md);
=head3 API SPECIFICATION
=head4 Input
{
issues => { type => 'arrayref', required => 1 },
fixes => { type => 'arrayref', default => [] },
}
=head4 Output
{ type => 'scalar' } # Markdown string
=head3 FORMAL SPECIFICATION
generate_markdown_report : seq Issue à seq Fix â â¤*
result begins with "# GHGen Workflow Analysis"
|issues| > 0 â result contains "## Issues by Category"
|issues| = 0 â result does not contain "## Issues by Category"
|fixes| > 0 â result contains "Fixes applied"
savings.minutes > 0 â result contains "## ð° Estimated Savings"
=cut | |||||
| 90 | ||||||
| 91 | 12 12 12 12 | 10293 10 8 9 | sub generate_markdown_report($issues, $fixes = []) { | |||
| 92 | 12 | 7 | my $report = "# GHGen Workflow Analysis\n\n"; | |||
| 93 | ||||||
| 94 | 12 | 11 | my $total_issues = scalar @$issues; | |||
| 95 | 12 | 20 | my $total_fixes = scalar @$fixes; | |||
| 96 | ||||||
| 97 | 12 | 11 | $report .= "## Summary\n\n"; | |||
| 98 | 12 | 12 | $report .= "- ð **Issues found:** $total_issues\n"; | |||
| 99 | 12 | 8 | $report .= "- â **Fixes applied:** $total_fixes\n\n"; | |||
| 100 | ||||||
| 101 | 12 | 15 | if (@$issues) { | |||
| 102 | # Group by type | |||||
| 103 | 9 | 6 | my %by_type; | |||
| 104 | 9 18 | 10 23 | push @{$by_type{$_->{type}}}, $_ for @$issues; | |||
| 105 | ||||||
| 106 | 9 | 14 | $report .= "## Issues by Category\n\n"; | |||
| 107 | ||||||
| 108 | 9 | 18 | for my $type (sort keys %by_type) { | |||
| 109 | 16 16 | 6 15 | my $count = scalar @{$by_type{$type}}; | |||
| 110 | 16 | 12 | my $emoji = get_type_emoji($type); | |||
| 111 | 16 | 25 | $report .= "### $emoji " . ucfirst($type) . " ($count)\n\n"; | |||
| 112 | ||||||
| 113 | 16 16 | 12 10 | for my $issue (@{$by_type{$type}}) { | |||
| 114 | 18 | 19 | my $severity_badge = get_severity_badge($issue->{severity}); | |||
| 115 | 18 | 12 | $report .= "**$severity_badge $issue->{message}**\n\n"; | |||
| 116 | ||||||
| 117 | 18 | 23 | if ($issue->{fix}) { | |||
| 118 | 15 | 11 | $report .= "<details>\n"; | |||
| 119 | 15 | 5 | $report .= "<summary>ð¡ Suggested Fix</summary>\n\n"; | |||
| 120 | 15 | 11 | $report .= "```yaml\n"; | |||
| 121 | 15 | 7 | $report .= "$issue->{fix}\n"; | |||
| 122 | 15 | 11 | $report .= "```\n\n"; | |||
| 123 | 15 | 15 | $report .= "</details>\n\n"; | |||
| 124 | } | |||||
| 125 | } | |||||
| 126 | } | |||||
| 127 | } | |||||
| 128 | ||||||
| 129 | # Add savings estimate if available | |||||
| 130 | 12 | 20 | my $savings = estimate_savings($issues); | |||
| 131 | 12 | 14 | if ($savings->{minutes} > 0) { | |||
| 132 | 8 | 17 | $report .= "## ð° Estimated Savings\n\n"; | |||
| 133 | 8 | 21 | $report .= "By fixing these issues, you could save:\n\n"; | |||
| 134 | 8 | 9 | $report .= "- â±ï¸ **~$savings->{minutes} CI minutes/month**\n"; | |||
| 135 | ||||||
| 136 | 8 | 13 | if ($savings->{cost} > 0) { | |||
| 137 | 7 | 7 | $report .= "- ðµ **~\$$savings->{cost}/month** (for private repos)\n"; | |||
| 138 | } | |||||
| 139 | ||||||
| 140 | 8 | 5 | $report .= "\n"; | |||
| 141 | } | |||||
| 142 | ||||||
| 143 | 12 | 21 | return $report; | |||
| 144 | } | |||||
| 145 | ||||||
| 146 - 215 | =head2 generate_github_comment($issues, $fixes, $options)
Generate a GitHub Pull-Request comment summarising workflow issues.
=head3 Purpose
Produce a compact Markdown comment suitable for posting as a PR review
comment. Includes a summary table, a collapsible detail block, a how-to-fix
section when no fixes were applied, and a potential savings estimate.
=head3 Arguments
=over 4
=item C<$issues> (ArrayRef[HashRef], required)
Issues to display. Each must have C<type>, C<severity>, C<message>; an
optional C<file> key adds a file reference line.
=item C<$fixes> (ArrayRef, optional, default C<[]>)
Fixes already applied (used only for the applied-fix count in the header).
=item C<$options> (HashRef, optional, default C<{}>)
Reserved for future use; currently unused.
=back
=head3 Returns
A non-empty Markdown string starting with C<## ð GHGen Workflow Analysis>.
When C<$issues> is empty, the comment contains the phrase
C<No issues found!> and is returned early without a table or details block.
=head3 Side Effects
None. Pure function.
=head3 Usage Example
my $comment = generate_github_comment(\@issues, \@fixes);
# Post $comment via GitHub API
=head3 API SPECIFICATION
=head4 Input
{
issues => { type => 'arrayref', required => 1 },
fixes => { type => 'arrayref', default => [] },
options => { type => 'hashref', default => {} },
}
=head4 Output
{ type => 'scalar' } # Markdown string
=head3 FORMAL SPECIFICATION
generate_github_comment : seq Issue à seq Fix à Options â â¤*
result begins with "## ð GHGen Workflow Analysis"
|issues| = 0 â result contains "No issues found!" â§ early return
|fixes| > 0 â result contains "Applied" â§ fix count
|issues| > 0 â§ |fixes| = 0 â result contains "How to Fix"
â i â issues: i.file defined â result contains i.file
=cut | |||||
| 216 | ||||||
| 217 | 15 15 15 15 15 | 12409 9 10 11 8 | sub generate_github_comment($issues, $fixes = [], $options = {}) { | |||
| 218 | 15 | 10 | my $comment = "## ð GHGen Workflow Analysis\n\n"; | |||
| 219 | ||||||
| 220 | 15 | 9 | my $total_issues = scalar @$issues; | |||
| 221 | 15 | 11 | my $total_fixes = scalar @$fixes; | |||
| 222 | ||||||
| 223 | 15 | 18 | if ($total_fixes > 0) { | |||
| 224 | 3 | 3 | $comment .= "â **Applied $total_fixes automatic fix(es)**\n\n"; | |||
| 225 | } | |||||
| 226 | ||||||
| 227 | 15 | 15 | if ($total_issues == 0) { | |||
| 228 | 3 | 3 | $comment .= "ð **No issues found!** Your workflows look great.\n\n"; | |||
| 229 | 3 | 5 | return $comment; | |||
| 230 | } | |||||
| 231 | ||||||
| 232 | # Summary table | |||||
| 233 | 12 | 11 | $comment .= "| Category | Count | Auto-fixable |\n"; | |||
| 234 | 12 | 8 | $comment .= "|----------|-------|-------------|\n"; | |||
| 235 | ||||||
| 236 | 12 | 9 | my %by_type; | |||
| 237 | 12 17 | 13 20 | push @{$by_type{$_->{type}}}, $_ for @$issues; | |||
| 238 | ||||||
| 239 | 12 | 29 | for my $type (sort keys %by_type) { | |||
| 240 | 15 15 | 8 11 | my $count = scalar @{$by_type{$type}}; | |||
| 241 | 15 17 15 | 11 28 10 | my $fixable = grep { $_->{auto_fixable} // 1 } @{$by_type{$type}}; | |||
| 242 | 15 | 29 | my $emoji = get_type_emoji($type); | |||
| 243 | 15 | 32 | $comment .= "| $emoji " . ucfirst($type) . " | $count | $fixable |\n"; | |||
| 244 | } | |||||
| 245 | ||||||
| 246 | 12 | 9 | $comment .= "\n"; | |||
| 247 | ||||||
| 248 | # Detailed issues | |||||
| 249 | 12 | 8 | $comment .= "<details>\n"; | |||
| 250 | 12 | 9 | $comment .= "<summary>ð View Details</summary>\n\n"; | |||
| 251 | ||||||
| 252 | 12 | 14 | for my $type (sort keys %by_type) { | |||
| 253 | 15 | 14 | $comment .= "### " . get_type_emoji($type) . " " . ucfirst($type) . "\n\n"; | |||
| 254 | ||||||
| 255 | 15 15 | 10 12 | for my $issue (@{$by_type{$type}}) { | |||
| 256 | 17 | 18 | my $badge = get_severity_badge($issue->{severity}); | |||
| 257 | 17 | 15 | $comment .= "- $badge **$issue->{message}**\n"; | |||
| 258 | ||||||
| 259 | 17 | 25 | if ($issue->{file}) { | |||
| 260 | 5 | 7 | $comment .= " - File: `$issue->{file}`\n"; | |||
| 261 | } | |||||
| 262 | } | |||||
| 263 | ||||||
| 264 | 15 | 19 | $comment .= "\n"; | |||
| 265 | } | |||||
| 266 | ||||||
| 267 | 12 | 9 | $comment .= "</details>\n\n"; | |||
| 268 | ||||||
| 269 | # Add recommendations | |||||
| 270 | 12 | 23 | if ($total_fixes == 0 && $total_issues > 0) { | |||
| 271 | 9 | 18 | $comment .= "### ð¡ How to Fix\n\n"; | |||
| 272 | 9 | 7 | $comment .= "Run these commands locally:\n\n"; | |||
| 273 | 9 | 5 | $comment .= "```bash\n"; | |||
| 274 | 9 | 4 | $comment .= "# Install ghgen\n"; | |||
| 275 | 9 | 7 | $comment .= "cpanm App::GHGen\n\n"; | |||
| 276 | 9 | 3 | $comment .= "# Analyze and fix\n"; | |||
| 277 | 9 | 7 | $comment .= "ghgen analyze --fix\n"; | |||
| 278 | 9 | 5 | $comment .= "```\n\n"; | |||
| 279 | ||||||
| 280 | 9 | 7 | $comment .= "Or enable auto-fix in this action:\n\n"; | |||
| 281 | 9 | 3 | $comment .= "```yaml\n"; | |||
| 282 | 9 | 4 | $comment .= "- uses: nigelhorne/ghgen-action\@v1\n"; | |||
| 283 | 9 | 7 | $comment .= " with:\n"; | |||
| 284 | 9 | 5 | $comment .= " auto-fix: true\n"; | |||
| 285 | 9 | 5 | $comment .= " create-pr: true\n"; | |||
| 286 | 9 | 5 | $comment .= "```\n\n"; | |||
| 287 | } | |||||
| 288 | ||||||
| 289 | # Add savings estimate | |||||
| 290 | 12 | 13 | my $savings = estimate_savings($issues); | |||
| 291 | 12 | 14 | if ($savings->{minutes} > 0) { | |||
| 292 | 9 | 6 | $comment .= "### ð° Potential Savings\n\n"; | |||
| 293 | 9 | 5 | $comment .= "By fixing these issues:\n"; | |||
| 294 | 9 | 8 | $comment .= "- â±ï¸ Save **~$savings->{minutes} CI minutes/month**\n"; | |||
| 295 | ||||||
| 296 | 9 | 13 | if ($savings->{cost} > 0) { | |||
| 297 | 8 | 8 | $comment .= "- ðµ Save **~\$$savings->{cost}/month** (private repos)\n"; | |||
| 298 | } | |||||
| 299 | ||||||
| 300 | 9 | 5 | $comment .= "\n"; | |||
| 301 | } | |||||
| 302 | ||||||
| 303 | 12 | 17 | $comment .= "---\n"; | |||
| 304 | 12 | 11 | $comment .= "*Analysis by [GHGen](https://github.com/your-org/ghgen)*\n"; | |||
| 305 | ||||||
| 306 | 12 | 24 | return $comment; | |||
| 307 | } | |||||
| 308 | ||||||
| 309 - 377 | =head2 estimate_savings($issues)
Estimate CI-minute savings and associated cost reduction from fixing a set of issues.
=head3 Purpose
For each C<performance> (caching) or C<cost> (concurrency, triggers) issue,
add a fixed minute estimate to the running total and compute the equivalent
USD saving at the GitHub private-repo rate of $0.008/minute.
=head3 Arguments
=over 4
=item C<$issues> (ArrayRef[HashRef], required)
Issue hashes with at least C<type> and C<message>.
=back
=head3 Returns
A hash reference:
{
minutes => Int, # total estimated minutes saved per month; 0 when no savings
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
{
type => 'hashref',
keys => {
minutes => { type => 'scalar' },
cost => { type => 'scalar' },
},
}
=head3 FORMAL SPECIFICATION
estimate_savings : seq Issue â { minutes: â, cost: â }
RATE â 0.008
savings(i) â
i.type = performance â§ i.message =~ /caching/ â 500
i.type = cost â§ i.message =~ /concurrency/ â 50
i.type = cost â§ i.message =~ /triggers/ â 100
otherwise â 0
total â â { savings(i) ⣠i â issues }
result â { minutes ⦠total, cost ⦠floor(total à RATE) }
=cut | |||||
| 378 | ||||||
| 379 | 36 36 36 | 7943 23 15 | sub estimate_savings($issues) { | |||
| 380 | 36 | 37 | my %savings = ( | |||
| 381 | minutes => 0, | |||||
| 382 | cost => 0, | |||||
| 383 | ); | |||||
| 384 | ||||||
| 385 | 36 | 23 | for my $issue (@$issues) { | |||
| 386 | # Estimate savings by issue type | |||||
| 387 | 48 | 54 | if ($issue->{type} eq 'performance') { | |||
| 388 | # Caching saves ~5 minutes per workflow run | |||||
| 389 | # Assume 100 runs/month | |||||
| 390 | 23 | 45 | $savings{minutes} += 500 if $issue->{message} =~ /caching/; | |||
| 391 | } elsif ($issue->{type} eq 'cost') { | |||||
| 392 | 14 | 23 | if ($issue->{message} =~ /concurrency/) { | |||
| 393 | # Concurrency saves ~50 minutes/month by canceling old runs | |||||
| 394 | 9 | 12 | $savings{minutes} += 50; | |||
| 395 | } | |||||
| 396 | 14 | 18 | if ($issue->{message} =~ /triggers/) { | |||
| 397 | # Trigger filters save ~100 minutes/month | |||||
| 398 | 5 | 4 | $savings{minutes} += 100; | |||
| 399 | } | |||||
| 400 | } | |||||
| 401 | } | |||||
| 402 | ||||||
| 403 | # Private repo pricing: ~$0.008 per minute | |||||
| 404 | 36 | 62 | $savings{cost} = int($savings{minutes} * 0.008); | |||
| 405 | ||||||
| 406 | 36 | 30 | return \%savings; | |||
| 407 | } | |||||
| 408 | ||||||
| 409 | 54 54 54 | 4438 30 24 | sub get_type_emoji($type) { | |||
| 410 | 54 | 64 | my %emojis = ( | |||
| 411 | performance => 'â¡', | |||||
| 412 | security => 'ð', | |||||
| 413 | cost => 'ð°', | |||||
| 414 | maintenance => 'ð§', | |||||
| 415 | ); | |||||
| 416 | ||||||
| 417 | 54 | 85 | return $emojis{$type} // 'ð'; | |||
| 418 | } | |||||
| 419 | ||||||
| 420 | 42 42 42 | 2855 26 27 | sub get_severity_badge($severity) { | |||
| 421 | 42 | 50 | my %badges = ( | |||
| 422 | high => 'ð´', | |||||
| 423 | medium => 'ð¡', | |||||
| 424 | low => 'ð¢', | |||||
| 425 | ); | |||||
| 426 | ||||||
| 427 | 42 | 56 | return $badges{$severity} // 'âª'; | |||
| 428 | } | |||||
| 429 | ||||||
| 430 - 441 | =head1 AUTHOR Nigel Horne E<lt>njh@nigelhorne.comE<gt> L<https://github.com/nigelhorne> =head1 LICENSE This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself. =cut | |||||
| 442 | ||||||
| 443 | 1; | |||||