TER1 (Statement): 100.00%
TER2 (Branch): 100.00%
TER3 (LCSAJ): 100.0% (9/9)
Approximate LCSAJ segments: 31
โ 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.
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) {
137: $report .= "- ðµ **~\$$savings->{cost}/month** (for private repos)\n"; 138: } 139: 140: $report .= "\n"; 141: } 142: 143: return $report;Mutants (Total: 4, Killed: 0, Survived: 4)
- NUM_BOUNDARY_136_30_<: Numeric boundary flip > to <
HIGH: Likely missing edge-case test (boundary value)๐งช Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_136_30_>=: Numeric boundary flip > to >=
HIGH: Likely missing edge-case test (boundary value)๐งช Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_136_30_<=: Numeric boundary flip > to <=
HIGH: Likely missing edge-case test (boundary value)๐งช Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- COND_INV_136_9: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomesMutants (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) {
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: 1, Survived: 3)
- NUM_BOUNDARY_291_29_<: Numeric boundary flip > to <
HIGH: Likely missing edge-case test (boundary value)๐งช Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_291_29_>=: Numeric boundary flip > to >=
HIGH: Likely missing edge-case test (boundary value)๐งช Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_291_29_<=: Numeric boundary flip > to <=
HIGH: Likely missing edge-case test (boundary value)๐งช Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );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: 4, Killed: 0, Survived: 4)
- NUM_BOUNDARY_296_30_<: Numeric boundary flip > to <
HIGH: Likely missing edge-case test (boundary value)๐งช Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_296_30_>=: Numeric boundary flip > to >=
HIGH: Likely missing edge-case test (boundary value)๐งช Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_296_30_<=: Numeric boundary flip > to <=
HIGH: Likely missing edge-case test (boundary value)๐งช Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- COND_INV_296_9: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomesMutants (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;