File Coverage

File:blib/lib/App/GHGen/Reporter.pm
Coverage:98.9%

linestmtbrancondsubtimecode
1package 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';
8our @EXPORT_OK = qw(
9        generate_markdown_report
10        generate_github_comment
11        estimate_savings
12);
13
14our $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
4431;