lib/App/GHGen/Reporter.pm

Structural Coverage (Approximate)

TER1 (Statement): 100.00%
TER2 (Branch): 100.00%
TER3 (LCSAJ): 100.0% (9/9)
Approximate LCSAJ segments: 31

LCSAJ Legend

โ— Covered โ€” this LCSAJ path was executed during testing.

โ— Not covered โ€” this LCSAJ path was never executed. These are the paths to focus on.

Multiple dots on a line indicate that multiple control-flow paths begin at that line. Hovering over any dot shows:

        start โ†’ end โ†’ jump
        

Uncovered paths show [NOT COVERED] in the tooltip.

Mutant Testing Legend

Survived (tests missed this) Killed (tests detected this) No mutation
    1: package App::GHGen::Reporter;
    2: 
    3: use v5.36;
    4: use strict;
    5: use warnings;
    6: 
    7: 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: =head1 NAME
   17: 
   18: App::GHGen::Reporter - Generate reports for GitHub integration
   19: 
   20: =head1 SYNOPSIS
   21: 
   22:     use App::GHGen::Reporter qw(generate_github_comment);
   23: 
   24:     my $comment = generate_github_comment(\@issues, \@fixes);
   25: 
   26: =head1 FUNCTIONS
   27: 
   28: =head2 generate_markdown_report($issues, $fixes)
   29: 
   30: Produce a Markdown-formatted report of workflow issues and applied fixes.
   31: 
   32: =head3 Purpose
   33: 
   34: Render a structured Markdown document that summarises detected issues grouped
   35: by category, includes suggested fixes, and appends an estimated savings
   36: section when applicable.
   37: 
   38: =head3 Arguments
   39: 
   40: =over 4
   41: 
   42: =item C<$issues> (ArrayRef[HashRef], required)
   43: 
   44: Issues to report.  Each must have C<type>, C<severity>, C<message>; an
   45: optional C<fix> key renders a collapsible C<E<lt>detailsE<gt>> block.
   46: 
   47: =item C<$fixes> (ArrayRef, optional, default C<[]>)
   48: 
   49: List of fixes already applied (used only for the summary count).
   50: 
   51: =back
   52: 
   53: =head3 Returns
   54: 
   55: A non-empty Markdown string beginning with C<# GHGen Workflow Analysis>.
   56: 
   57: =head3 Side Effects
   58: 
   59: None.  Pure function.
   60: 
   61: =head3 Usage Example
   62: 
   63:     my $md = generate_markdown_report(\@issues, \@fixes);
   64:     path('report.md')->spew_utf8($md);
   65: 
   66: =head3 API SPECIFICATION
   67: 
   68: =head4 Input
   69: 
   70:     {
   71:         issues => { type => 'arrayref', required => 1 },
   72:         fixes  => { type => 'arrayref', default  => [] },
   73:     }
   74: 
   75: =head4 Output
   76: 
   77:     { type => 'scalar' }   # Markdown string
   78: 
   79: =head3 FORMAL SPECIFICATION
   80: 
   81:     generate_markdown_report : seq Issue × seq Fix → ℤ*
   82: 
   83:     result begins with "# GHGen Workflow Analysis"
   84:     |issues| > 0 ⇒ result contains "## Issues by Category"
   85:     |issues| = 0 ⇒ result does not contain "## Issues by Category"
   86:     |fixes|  > 0 ⇒ result contains "Fixes applied"
   87:     savings.minutes > 0 ⇒ result contains "## 💰 Estimated Savings"
   88: 
   89: =cut
   90: 
   91: sub generate_markdown_report($issues, $fixes = []) {
โ—92 โ†’ 101 โ†’ 130   92: 	my $report = "# GHGen Workflow Analysis\n\n";
   93: 
   94: 	my $total_issues = scalar @$issues;
   95: 	my $total_fixes = scalar @$fixes;
   96: 
   97: 	$report .= "## Summary\n\n";
   98: 	$report .= "- 📊 **Issues found:** $total_issues\n";
   99: 	$report .= "- ✅ **Fixes applied:** $total_fixes\n\n";
  100: 
  101:     if (@$issues) {

Mutants (Total: 1, Killed: 1, Survived: 0)

102: # Group by type 103: my %by_type; 104: push @{$by_type{$_->{type}}}, $_ for @$issues; 105: 106: $report .= "## Issues by Category\n\n"; 107: 108: for my $type (sort keys %by_type) { 109: my $count = scalar @{$by_type{$type}}; 110: my $emoji = get_type_emoji($type); 111: $report .= "### $emoji " . ucfirst($type) . " ($count)\n\n"; 112: 113: for my $issue (@{$by_type{$type}}) { 114: my $severity_badge = get_severity_badge($issue->{severity}); 115: $report .= "**$severity_badge $issue->{message}**\n\n"; 116: 117: if ($issue->{fix}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

118: $report .= "<details>\n"; 119: $report .= "<summary>💡 Suggested Fix</summary>\n\n"; 120: $report .= "```yaml\n"; 121: $report .= "$issue->{fix}\n"; 122: $report .= "```\n\n"; 123: $report .= "</details>\n\n"; 124: } 125: } 126: } 127: } 128: 129: # Add savings estimate if available โ—130 โ†’ 131 โ†’ 143 130: my $savings = estimate_savings($issues); 131: if ($savings->{minutes} > 0) {

Mutants (Total: 4, Killed: 4, Survived: 0)

132: $report .= "## 💰 Estimated Savings\n\n"; 133: $report .= "By fixing these issues, you could save:\n\n"; 134: $report .= "- ⏱️ **~$savings->{minutes} CI minutes/month**\n"; 135: 136: if ($savings->{cost} > 0) {

Mutants (Total: 4, Killed: 0, Survived: 4)
137: $report .= "- 💵 **~\$$savings->{cost}/month** (for private repos)\n"; 138: } 139: 140: $report .= "\n"; 141: } 142: 143: return $report;

Mutants (Total: 2, Killed: 2, Survived: 0)

144: } 145: 146: =head2 generate_github_comment($issues, $fixes, $options) 147: 148: Generate a GitHub Pull-Request comment summarising workflow issues. 149: 150: =head3 Purpose 151: 152: Produce a compact Markdown comment suitable for posting as a PR review 153: comment. Includes a summary table, a collapsible detail block, a how-to-fix 154: section when no fixes were applied, and a potential savings estimate. 155: 156: =head3 Arguments 157: 158: =over 4 159: 160: =item C<$issues> (ArrayRef[HashRef], required) 161: 162: Issues to display. Each must have C<type>, C<severity>, C<message>; an 163: optional C<file> key adds a file reference line. 164: 165: =item C<$fixes> (ArrayRef, optional, default C<[]>) 166: 167: Fixes already applied (used only for the applied-fix count in the header). 168: 169: =item C<$options> (HashRef, optional, default C<{}>) 170: 171: Reserved for future use; currently unused. 172: 173: =back 174: 175: =head3 Returns 176: 177: A non-empty Markdown string starting with C<## 🔍 GHGen Workflow Analysis>. 178: 179: When C<$issues> is empty, the comment contains the phrase 180: C<No issues found!> and is returned early without a table or details block. 181: 182: =head3 Side Effects 183: 184: None. Pure function. 185: 186: =head3 Usage Example 187: 188: my $comment = generate_github_comment(\@issues, \@fixes); 189: # Post $comment via GitHub API 190: 191: =head3 API SPECIFICATION 192: 193: =head4 Input 194: 195: { 196: issues => { type => 'arrayref', required => 1 }, 197: fixes => { type => 'arrayref', default => [] }, 198: options => { type => 'hashref', default => {} }, 199: } 200: 201: =head4 Output 202: 203: { type => 'scalar' } # Markdown string 204: 205: =head3 FORMAL SPECIFICATION 206: 207: generate_github_comment : seq Issue × seq Fix × Options → ℤ* 208: 209: result begins with "## 🔍 GHGen Workflow Analysis" 210: |issues| = 0 ⇒ result contains "No issues found!" ∧ early return 211: |fixes| > 0 ⇒ result contains "Applied" ∧ fix count 212: |issues| > 0 ∧ |fixes| = 0 ⇒ result contains "How to Fix" 213: ∃ i ∈ issues: i.file defined ⇒ result contains i.file 214: 215: =cut 216: 217: sub generate_github_comment($issues, $fixes = [], $options = {}) { โ—218 โ†’ 223 โ†’ 227 218: my $comment = "## 🔍 GHGen Workflow Analysis\n\n"; 219: 220: my $total_issues = scalar @$issues; 221: my $total_fixes = scalar @$fixes; 222: 223: if ($total_fixes > 0) {

Mutants (Total: 4, Killed: 4, Survived: 0)

224: $comment .= "✅ **Applied $total_fixes automatic fix(es)**\n\n"; 225: } 226: โ—227 โ†’ 227 โ†’ 233 227: if ($total_issues == 0) {

Mutants (Total: 2, Killed: 2, Survived: 0)

228: $comment .= "🎉 **No issues found!** Your workflows look great.\n\n"; 229: return $comment;

Mutants (Total: 2, Killed: 2, Survived: 0)

230: } 231: 232: # Summary table โ—233 โ†’ 239 โ†’ 246 233: $comment .= "| Category | Count | Auto-fixable |\n"; 234: $comment .= "|----------|-------|-------------|\n"; 235: 236: my %by_type; 237: push @{$by_type{$_->{type}}}, $_ for @$issues; 238: 239: for my $type (sort keys %by_type) { 240: my $count = scalar @{$by_type{$type}}; 241: my $fixable = grep { $_->{auto_fixable} // 1 } @{$by_type{$type}}; 242: my $emoji = get_type_emoji($type); 243: $comment .= "| $emoji " . ucfirst($type) . " | $count | $fixable |\n"; 244: } 245: โ—246 โ†’ 252 โ†’ 267 246: $comment .= "\n"; 247: 248: # Detailed issues 249: $comment .= "<details>\n"; 250: $comment .= "<summary>📋 View Details</summary>\n\n"; 251: 252: for my $type (sort keys %by_type) { 253: $comment .= "### " . get_type_emoji($type) . " " . ucfirst($type) . "\n\n"; 254: 255: for my $issue (@{$by_type{$type}}) { 256: my $badge = get_severity_badge($issue->{severity}); 257: $comment .= "- $badge **$issue->{message}**\n"; 258: 259: if ($issue->{file}) {

Mutants (Total: 1, Killed: 1, Survived: 0)

260: $comment .= " - File: `$issue->{file}`\n"; 261: } 262: } 263: 264: $comment .= "\n"; 265: } 266: โ—267 โ†’ 270 โ†’ 290 267: $comment .= "</details>\n\n"; 268: 269: # Add recommendations 270: if ($total_fixes == 0 && $total_issues > 0) {

Mutants (Total: 5, Killed: 5, Survived: 0)

271: $comment .= "### 💡 How to Fix\n\n"; 272: $comment .= "Run these commands locally:\n\n"; 273: $comment .= "```bash\n"; 274: $comment .= "# Install ghgen\n"; 275: $comment .= "cpanm App::GHGen\n\n"; 276: $comment .= "# Analyze and fix\n"; 277: $comment .= "ghgen analyze --fix\n"; 278: $comment .= "```\n\n"; 279: 280: $comment .= "Or enable auto-fix in this action:\n\n"; 281: $comment .= "```yaml\n"; 282: $comment .= "- uses: nigelhorne/ghgen-action\@v1\n"; 283: $comment .= " with:\n"; 284: $comment .= " auto-fix: true\n"; 285: $comment .= " create-pr: true\n"; 286: $comment .= "```\n\n"; 287: } 288: 289: # Add savings estimate โ—290 โ†’ 291 โ†’ 303 290: my $savings = estimate_savings($issues); 291: if ($savings->{minutes} > 0) {

Mutants (Total: 4, Killed: 1, Survived: 3)
292: $comment .= "### 💰 Potential Savings\n\n"; 293: $comment .= "By fixing these issues:\n"; 294: $comment .= "- ⏱️ Save **~$savings->{minutes} CI minutes/month**\n"; 295: 296: if ($savings->{cost} > 0) {
Mutants (Total: 4, Killed: 0, Survived: 4)
297: $comment .= "- 💵 Save **~\$$savings->{cost}/month** (private repos)\n"; 298: } 299: 300: $comment .= "\n"; 301: } 302: 303: $comment .= "---\n"; 304: $comment .= "*Analysis by [GHGen](https://github.com/your-org/ghgen)*\n"; 305: 306: return $comment;

Mutants (Total: 2, Killed: 2, Survived: 0)

307: } 308: 309: =head2 estimate_savings($issues) 310: 311: Estimate CI-minute savings and associated cost reduction from fixing a set of issues. 312: 313: =head3 Purpose 314: 315: For each C<performance> (caching) or C<cost> (concurrency, triggers) issue, 316: add a fixed minute estimate to the running total and compute the equivalent 317: USD saving at the GitHub private-repo rate of $0.008/minute. 318: 319: =head3 Arguments 320: 321: =over 4 322: 323: =item C<$issues> (ArrayRef[HashRef], required) 324: 325: Issue hashes with at least C<type> and C<message>. 326: 327: =back 328: 329: =head3 Returns 330: 331: A hash reference: 332: 333: { 334: minutes => Int, # total estimated minutes saved per month; 0 when no savings 335: cost => Int, # floor(minutes * 0.008); 0 when no savings 336: } 337: 338: =head3 Side Effects 339: 340: None. Pure function. 341: 342: =head3 Usage Example 343: 344: my $s = estimate_savings(\@issues); 345: say "Save $s->{minutes} min/month"; 346: 347: =head3 API SPECIFICATION 348: 349: =head4 Input 350: 351: { issues => { type => 'arrayref', required => 1 } } 352: 353: =head4 Output 354: 355: { 356: type => 'hashref', 357: keys => { 358: minutes => { type => 'scalar' }, 359: cost => { type => 'scalar' }, 360: }, 361: } 362: 363: =head3 FORMAL SPECIFICATION 364: 365: estimate_savings : seq Issue → { minutes: â„•, cost: â„• } 366: 367: RATE ≔ 0.008 368: savings(i) ≔ 369: i.type = performance ∧ i.message =~ /caching/ → 500 370: i.type = cost ∧ i.message =~ /concurrency/ → 50 371: i.type = cost ∧ i.message =~ /triggers/ → 100 372: otherwise → 0 373: 374: total ≔ ∑ { savings(i) ∣ i ∈ issues } 375: result ≔ { minutes ↦ total, cost ↦ floor(total × RATE) } 376: 377: =cut 378: 379: sub estimate_savings($issues) { โ—380 โ†’ 385 โ†’ 404 380: my %savings = ( 381: minutes => 0, 382: cost => 0, 383: ); 384: 385: for my $issue (@$issues) { 386: # Estimate savings by issue type 387: if ($issue->{type} eq 'performance') {

Mutants (Total: 1, Killed: 1, Survived: 0)

388: # Caching saves ~5 minutes per workflow run 389: # Assume 100 runs/month 390: $savings{minutes} += 500 if $issue->{message} =~ /caching/; 391: } elsif ($issue->{type} eq 'cost') { 392: if ($issue->{message} =~ /concurrency/) {

Mutants (Total: 1, Killed: 1, Survived: 0)

393: # Concurrency saves ~50 minutes/month by canceling old runs 394: $savings{minutes} += 50; 395: } 396: if ($issue->{message} =~ /triggers/) {

Mutants (Total: 1, Killed: 1, Survived: 0)

397: # Trigger filters save ~100 minutes/month 398: $savings{minutes} += 100; 399: } 400: } 401: } 402: 403: # Private repo pricing: ~$0.008 per minute 404: $savings{cost} = int($savings{minutes} * 0.008); 405: 406: return \%savings;

Mutants (Total: 2, Killed: 2, Survived: 0)

407: } 408: 409: sub get_type_emoji($type) { 410: my %emojis = ( 411: performance => '⚡', 412: security => '🔒', 413: cost => '💰', 414: maintenance => '🔧', 415: ); 416: 417: return $emojis{$type} // '📌';

Mutants (Total: 2, Killed: 2, Survived: 0)

418: } 419: 420: sub get_severity_badge($severity) { 421: my %badges = ( 422: high => '🔴', 423: medium => '🟡', 424: low => '🟢', 425: ); 426: 427: return $badges{$severity} // '⚪';

Mutants (Total: 2, Killed: 2, Survived: 0)

428: } 429: 430: =head1 AUTHOR 431: 432: Nigel Horne E<lt>njh@nigelhorne.comE<gt> 433: 434: L<https://github.com/nigelhorne> 435: 436: =head1 LICENSE 437: 438: This is free software; you can redistribute it and/or modify it under 439: the same terms as the Perl 5 programming language system itself. 440: 441: =cut 442: 443: 1;