App-GHGen
view release on metacpan or search on metacpan
scripts/generate_index.pl view on Meta::CPAN
#!/usr/bin/env perl
# Generates the HTML for use as a testing dashboard on GitHub
# The location will be https://nigelhorne.github.io/$config{github_repo}/coverage/
# The script is automatically run by each 'git push' by the script .github/workflows/dashboard.yml
use strict;
use warnings;
use autodie qw(:all);
use File::Glob ':glob';
use File::Slurp;
use File::stat;
use IPC::Run3;
use JSON::MaybeXS;
use List::Util;
use POSIX qw(strftime);
use Readonly;
use HTTP::Tiny;
use Time::HiRes qw(sleep);
use URI::Escape qw(uri_escape);
use version;
use WWW::RT::CPAN;
my ($github_user, $github_repo);
if (my $repo = $ENV{GITHUB_REPOSITORY}) {
($github_user, $github_repo) = split m{/}, $repo, 2;
} else {
die 'What repo are you?';
}
my $package_name = $github_repo;
$package_name =~ s/\-/::/g;
Readonly my %config => (
github_user => 'nigelhorne',
github_repo => $github_repo,
package_name => $package_name,
low_threshold => 70,
med_threshold => 90,
max_points => 10, # Only display the last 10 commits in the coverage trend graph
cover_db => 'cover_db/cover.json',
output => 'cover_html/index.html',
max_retry => 3,
min_locale_samples => 3,
);
# -------------------------------
# Dependency correlation analysis
# -------------------------------
my $MAX_REPORTS_PER_GRADE = 20; # safety rail
my $ENABLE_DEP_ANALYSIS = 1;
# Read and decode coverage data
my $data = eval { decode_json(read_file($config{cover_db})) };
my $coverage_pct = 0;
my $badge_color = 'red';
if(my $total_info = $data->{summary}{Total}) {
$coverage_pct = int($total_info->{total}{percentage} // 0);
$badge_color = $coverage_pct > $config{med_threshold} ? 'brightgreen' : $coverage_pct > $config{low_threshold} ? 'yellow' : 'red';
}
Readonly my $coverage_badge_url => "https://img.shields.io/badge/coverage-${coverage_pct}%25-${badge_color}";
# Start HTML
my @html; # build in array, join later
push @html, <<"HTML";
<!DOCTYPE html>
<html>
<head>
<title>$config{package_name} Coverage Report</title>
<style>
body { font-family: sans-serif; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ccc; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; }
.low { background-color: #fdd; }
.med { background-color: #ffd; }
scripts/generate_index.pl view on Meta::CPAN
}
}
}
# Collect data points from non-merge commits
my @data_points_with_time;
my $processed_count = 0;
foreach my $file (reverse sort @history_files) {
last if $processed_count >= $config{max_points};
my $json = $historical_cache{$file};
next unless $json->{summary}{Total};
my ($sha) = $file =~ /-(\w{7})\.json$/;
next unless $commit_messages{$sha}; # Skip merge commits
my $timestamp = $commit_times{$sha} // strftime('%Y-%m-%dT%H:%M:%S', localtime((stat($file))->mtime));
# Git log returns format like: "2024-01-15 14:30:45 -0500" or "2024-01-15 14:30:45 +0000"
# We need ISO 8601 format: "2024-01-15T14:30:45-05:00"
# Replace space between date and time with 'T'
$timestamp =~ s/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})/$1T$2/;
# Fix timezone format: convert "-0500" to "-05:00" or " -05:00" to "-05:00"
$timestamp =~ s/\s*([+-])(\d{2}):?(\d{2})$/$1$2:$3/;
# Remove any remaining spaces (safety cleanup)
$timestamp =~ s/\s+//g;
my $pct = $json->{summary}{Total}{total}{percentage} // 0;
my $color = 'gray'; # Will be set properly after sorting
my $url = "https://github.com/$config{github_user}/$config{github_repo}/commit/$sha";
my $comment = $commit_messages{$sha};
# Store with timestamp for sorting
push @data_points_with_time, {
timestamp => $timestamp,
pct => $pct,
url => $url,
comment => $comment
};
$processed_count++;
}
# Sort by timestamp to ensure chronological order
@data_points_with_time = sort { $a->{timestamp} cmp $b->{timestamp} } @data_points_with_time;
# Now calculate deltas and create JavaScript data points
my @data_points;
my $prev_pct;
foreach my $point (@data_points_with_time) {
my $delta = defined $prev_pct ? sprintf('%.1f', $point->{pct} - $prev_pct) : 0;
$prev_pct = $point->{pct};
my $color = $delta > 0 ? 'green' : $delta < 0 ? 'red' : 'gray';
my $comment = js_escape($point->{comment});
push @data_points, qq{{ x: "$point->{timestamp}", y: $point->{pct}, delta: $delta, url: "$point->{url}", label: "$point->{timestamp}", pointBackgroundColor: "$color", comment: "$comment" }};
}
my $js_data = join(",\n", @data_points);
if(scalar(@data_points)) {
push @html, <<'HTML';
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1em;">
<div>
<h2>Coverage Trend</h2>
<label>
<input type="checkbox" id="toggleTrend" checked>
Show regression trend
</label>
<div>
</div>
</div>
<div id="zoomControls" style="margin-top:8px;">
<input type="button" value="Refresh" onClick="refresh(this)">
<button id="resetZoomBtn" type="button">Reset Zoom</button>
</div>
</div>
HTML
}
push @html, <<"HTML";
<canvas id="coverageTrend" width="600" height="300"></canvas>
<!-- Zoom controls for the trend chart -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- Add chartjs-plugin-zoom (required for wheel/pinch/drag zoom & pan) -->
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom\@2.1.1/dist/chartjs-plugin-zoom.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns"></script>
<script>
function linearRegression(data) {
const xs = data.map(p => new Date(p.x).getTime());
const ys = data.map(p => p.y);
const n = xs.length;
const sumX = xs.reduce((a, b) => a + b, 0);
const sumY = ys.reduce((a, b) => a + b, 0);
const sumXY = xs.reduce((acc, val, i) => acc + val * ys[i], 0);
const sumX2 = xs.reduce((acc, val) => acc + val * val, 0);
if (n < 2 || (n * sumX2 - sumX * sumX) === 0) {
return [];
}
const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
const intercept = (sumY - slope * sumX) / n;
return xs.map(x => ({
x: new Date(x).toISOString(),
y: slope * x + intercept
}));
}
const dataPoints = [ $js_data ];
HTML
push @html, <<'HTML';
const regressionPoints = linearRegression(dataPoints);
scripts/generate_index.pl view on Meta::CPAN
pointRadius: 0
}]
}, options: {
responsive: false,
maintainAspectRatio: false,
elements: { line: { borderJoinStyle: 'round' } },
plugins: {
legend: { display: false },
tooltip: { enabled: false },
zoom: { // Enable zoom and pan
pan: {
enabled: true,
mode: 'x',
}, zoom: {
wheel: {
enabled: true,
},
pinch: {
enabled: true
},
mode: 'x',
}
}
}, scales: { x: { display: false }, y: { display: false } }
}
});
});
});
function refresh(){
window.location.reload("Refresh")
}
</script>
HTML
push @html, '<p><center>Use mouse wheel or pinch to zoom; drag to pan</center></p>';
# -------------------------------
# Issues flagged on RT
# -------------------------------
{
my $rt_count = fetch_open_rt_ticket_count($config{github_repo});
my $rt_url = "https://rt.cpan.org/Public/Dist/Display.html?Name=$config{github_repo}";
if(defined $rt_count && $rt_count > 0) {
push @html, '<p class="notice rt-issues">',
'<strong>RT issues:</strong>',
"<a href=\"$rt_url\" target=\"_blank\" rel=\"noopener\">",
"$rt_count open ticket" . @{[ $rt_count == 1 ? '' : 's' ]},
'</a>',
'</p>';
} else {
push @html, "<p>No issues active on <a href=\"$rt_url\">RT</a></p>";
}
}
# -------------------------------
# CPAN Testers failing reports table
# -------------------------------
my $dist_name = $config{github_repo};
my $cpan_api = "https://api.cpantesters.org/v3/summary/" . uri_escape($dist_name);
my $http = HTTP::Tiny->new(agent => 'cpan-coverage-html/1.0', timeout => 15);
my $retry = 0;
my $success = 0;
my $res;
# Try a number of times because the cpantesters website can get overloaded
while($retry < $config{max_retry}) {
$res = $http->get($cpan_api);
if($res->{success}) {
$success = 1;
last;
}
$retry++;
sleep(2 ** $retry);
}
my $version; # current version
my $prev_version; # may be undef
if($success) {
my $releases = eval { decode_json($res->{content}) };
my @versions;
foreach my $release (@{$releases}) {
next unless defined $release->{version};
push @versions, $release->{version};
}
@versions = sort { parse_version($b) <=> parse_version($a) } @versions;
$version = $versions[0]; # current
$prev_version = $versions[1]; # previous (may be undef)
# push @html, "<p>CPAN Release: $version</p>";
} else {
push @html, "<p><a href=\"$cpan_api\">$cpan_api</a>: $res->{status} $res->{reason}</p>";
}
# $version ||= 'latest';
my @fail_reports;
my @pass_reports;
if($version) {
@fail_reports = fetch_reports_by_grades(
$dist_name,
$version,
'fail',
'unknown',
'na',
);
# warn 'Fetched ', scalar(@fail_reports), ' rows from API';
# use Data::Dumper;
# warn Dumper($fail_reports[0]) if scalar(@fail_reports);
@pass_reports = fetch_reports_by_grades(
$dist_name,
$version,
scripts/generate_index.pl view on Meta::CPAN
$reporter =~ s/\s+$//g;
my $guid = $r->{guid} // '';
my $url = $guid ? "https://www.cpantesters.org/cpan/report/$guid" : '#';
my $is_new = !$prev_fail_set{make_key($r)};
my $new_html = $is_new ? '<span class="new-failure">NEW</span>' : '';
push @html, sprintf(
qq{<tr class="%s"><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td>
<td><a href="%s" target="_blank">View</a></td></tr>},
$row_class,
$date,
$os,
$perl,
$reporter,
$new_html,
$url
);
}
push @html, '</tbody></table>';
} else {
# @fail_reports is empty
push @html, "<p>No <A HREF=\"https://fast2-matrix.cpantesters.org/?dist=$dist_name+$version\">CPAN Testers</A> failures reported for $dist_name $version</p>";
}
} elsif($res->{status} == 404) { # 404 means no fail reports
# push @html, "<A HREF=\"$cpan_api\">$cpan_api</A>";
push @html, "<p>No CPAN Testers failures reported for $dist_name $version.</p>";
} else {
push @html, "<a href=\"$cpan_api\">$cpan_api</a>: $res->{status} $res->{reason}";
}
my $timestamp = 'Unknown';
if (my $stat = stat($config{cover_db})) {
$timestamp = strftime('%Y-%m-%d %H:%M:%S', localtime($stat->mtime));
}
push @html, <<"HTML";
<footer>
<p>Project: <a href="https://github.com/$config{github_user}/$config{github_repo}">$config{github_repo}</a></p>
<p><em>Last updated: $timestamp - <a href="$commit_url">commit <code>$short_sha</code></a></em></p>
</footer>
</body>
</html>
HTML
# Write to index.html
write_file($config{output}, join("\n", @html));
# Safe git command execution
sub run_git {
my @cmd = @_;
my ($out, $err);
run3 ['git', @cmd], \undef, \$out, \$err;
return unless $? == 0;
chomp $out;
return $out;
}
sub js_escape {
my $str = $_[0];
$str =~ s/\\/\\\\/g;
$str =~ s/"/\\"/g;
$str =~ s/\n/\\n/g;
return $str;
}
sub fetch_reports_by_grades {
my ($dist, $version, @grades) = @_;
my %seen;
my @reports;
for my $grade (@grades) {
my $url = 'https://api.cpantesters.org/v3/summary/'
. uri_escape($dist)
. '/' . uri_escape($version)
. "?grade=$grade";
my $res = $http->get($url);
next unless $res->{success};
my $arr = eval { decode_json($res->{content}) };
next unless ref $arr eq 'ARRAY';
for my $r (@$arr) {
my $key = make_key($r);
next if $seen{$key}++;
push @reports, $r;
}
}
return @reports;
}
sub aggregate_dependency_stats {
my (%args) = @_;
my $guids = $args{guids} || [];
my $grade = $args{grade} || 'fail';
my $stats = $args{stats_ref} || {};
my $count = 0;
for my $guid (@$guids) {
last if $count++ >= $MAX_REPORTS_PER_GRADE;
my $html = fetch_report_html($guid) or next;
my $mods = extract_installed_modules($html);
for my $m (keys %$mods) {
$stats->{$m}{$grade}++;
$stats->{$m}{versions}{ $mods->{$m} }{$grade}++;
}
}
return $stats;
}
sub fetch_report_html {
my $guid = $_[0];
return unless $guid;
my $url = "https://www.cpantesters.org/cpan/report/$guid";
# print "fetching report HTML $url\n";
my $res = $http->get($url);
return unless $res->{success};
return $res->{content};
}
sub extract_installed_modules {
my ($html) = @_;
my %mods;
( run in 0.640 second using v1.01-cache-2.11-cpan-2398b32b56e )