Files
2025-08-26 21:36:06 +00:00

351 lines
16 KiB
JavaScript

import { mkdirSync, writeFileSync } from "fs";
import { Chart, registerables } from 'chart.js';
import { Canvas } from 'skia-canvas';
import semver from "semver";
// Register all Chart.js controllers
Chart.register(...registerables);
/**
* Safely compare two version strings using semver
* Falls back to string comparison if semver parsing fails
*/
function safeSemverCompare(a, b) {
try {
const cleanA = a.trim();
const cleanB = b.trim();
if (!semver.valid(cleanA) || !semver.valid(cleanB)) {
return cleanA.localeCompare(cleanB);
}
return semver.compare(cleanA, cleanB);
}
catch {
return a.trim().localeCompare(b.trim());
}
}
/**
* Prettify a normalized asset key for charts/summaries
* Example: "sail|linux|amd64|yaml" -> "Sail / Linux / Amd64 / Yaml"
*/
function formatAssetLabel(key) {
return key
.split("|")
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join(" / ");
}
export function formatGitHubSummary(summary, platformMetrics) {
let totalStars = 0;
let totalForks = 0;
let totalWatchers = 0;
let totalIssues = 0;
let totalOpenIssues = 0;
let totalClosedIssues = 0;
let totalDownloads = 0;
let totalReleases = 0;
summary += `| Repository | Stars | Forks | Watchers | Open Issues | Closed Issues | Total Issues | Release Downloads | Releases | Latest Release | Language |\n`;
summary += `| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n`;
for (const metric of platformMetrics) {
const stars = metric.metrics?.stars || 0;
const forks = metric.metrics?.forks || 0;
const watchers = metric.metrics?.watchers || 0;
const issues = metric.metrics?.totalIssues || 0;
const openIssues = metric.metrics?.openIssues || 0;
const closedIssues = metric.metrics?.closedIssues || 0;
const downloads = metric.metrics?.totalReleaseDownloads || 0;
const releases = metric.metrics?.releaseCount || 0;
const latestRelease = metric.metrics?.latestRelease || 'N/A';
const language = metric.metrics?.language || 'N/A';
totalStars += stars;
totalForks += forks;
totalWatchers += watchers;
totalIssues += issues;
totalOpenIssues += openIssues;
totalClosedIssues += closedIssues;
totalDownloads += downloads;
totalReleases += releases;
summary += `| ${metric.name} | ${stars.toLocaleString()} | ${forks.toLocaleString()} | ${watchers.toLocaleString()} | ${openIssues.toLocaleString()} | ${closedIssues.toLocaleString()} | ${issues.toLocaleString()} | ${downloads.toLocaleString()} | ${releases.toLocaleString()} | ${latestRelease} | ${language} |\n`;
}
summary += `| **Total** | **${totalStars.toLocaleString()}** | **${totalForks.toLocaleString()}** | **${totalWatchers.toLocaleString()}** | **${totalOpenIssues.toLocaleString()}** | **${totalClosedIssues.toLocaleString()}** | **${totalIssues.toLocaleString()}** | **${totalDownloads.toLocaleString()}** | **${totalReleases.toLocaleString()}** | | |\n`;
return summary;
}
export async function addRepoDetails(summary, metrics) {
summary += `#### Repository Details:\n\n`;
for (const metric of metrics) {
summary += `**${metric.name}**:\n`;
summary += `- Last Activity: ${metric.metrics?.lastActivity?.toLocaleString() || 0} days ago\n`;
summary += `- Repository Age: ${metric.metrics?.repositoryAge?.toLocaleString() || 0} days\n`;
summary += `- Release Count: ${metric.metrics?.releaseCount?.toLocaleString() || 0}\n`;
summary += `- Total Release Downloads: ${metric.metrics?.totalReleaseDownloads?.toLocaleString() || 0}\n`;
summary += `- Latest Release: ${metric.metrics?.latestRelease || 'N/A'}\n`;
summary += `- Latest Release Downloads: ${metric.metrics?.latestReleaseDownloads?.toLocaleString() || 0}\n`;
summary += `- Views: ${metric.metrics?.viewsCount?.toLocaleString() || 0}\n`;
summary += `- Unique Visitors: ${metric.metrics?.uniqueVisitors?.toLocaleString() || 0}\n`;
summary += `- Clones: ${metric.metrics?.clonesCount?.toLocaleString() || 0}\n`;
// Include top assets using parsed grouping keys for better aggregation
const assetTotalsByParsedKey = metric.metrics?.assetTotalsByParsedKey;
if (assetTotalsByParsedKey && Object.keys(assetTotalsByParsedKey).length > 0) {
const topAssets = Object.entries(assetTotalsByParsedKey)
.sort((a, b) => b[1] - a[1])
.slice(0, 5);
if (topAssets.length > 0) {
summary += `- Top Assets (by downloads):\n`;
for (const [assetName, count] of topAssets) {
summary += ` - ${assetName}: ${count.toLocaleString()}\n`;
}
}
}
// OS/Arch/Format/Variant breakdowns
const totalsByOs = metric.metrics?.totalsByOs;
const totalsByArch = metric.metrics?.totalsByArch;
const totalsByFormat = metric.metrics?.totalsByFormat;
const totalsByVariant = metric.metrics?.totalsByVariant;
if (totalsByOs && Object.keys(totalsByOs).length > 0) {
const ordered = Object.entries(totalsByOs).sort((a, b) => b[1] - a[1]).slice(0, 5);
summary += `- OS Breakdown:\n`;
for (const [k, v] of ordered)
summary += ` - ${k}: ${v.toLocaleString()}\n`;
}
if (totalsByArch && Object.keys(totalsByArch).length > 0) {
const ordered = Object.entries(totalsByArch).sort((a, b) => b[1] - a[1]).slice(0, 5);
summary += `- Arch Breakdown:\n`;
for (const [k, v] of ordered)
summary += ` - ${k}: ${v.toLocaleString()}\n`;
}
if (totalsByFormat && Object.keys(totalsByFormat).length > 0) {
const ordered = Object.entries(totalsByFormat).sort((a, b) => b[1] - a[1]).slice(0, 5);
summary += `- Format Breakdown:\n`;
for (const [k, v] of ordered)
summary += ` - ${k}: ${v.toLocaleString()}\n`;
}
if (totalsByVariant && Object.keys(totalsByVariant).length > 0) {
const ordered = Object.entries(totalsByVariant).sort((a, b) => b[1] - a[1]).slice(0, 5);
summary += `- Variant Breakdown:\n`;
for (const [k, v] of ordered)
summary += ` - ${k}: ${v.toLocaleString()}\n`;
}
summary += `\n`;
}
summary += `\n\n`;
const chatOutputPath = './charts/github';
mkdirSync(chatOutputPath, { recursive: true });
const svgOutputPathList = await createGitHubReleaseChart(metrics, chatOutputPath);
for (const svgOutputPath of svgOutputPathList) {
summary += `![${svgOutputPath}](${svgOutputPath})\n`;
}
return summary;
}
export async function createGitHubReleaseChart(platformMetrics, outputPath) {
const svgOutputPathList = [];
for (const metric of platformMetrics) {
// Only create charts if there's download data
if (metric.metrics?.downloadRange && metric.metrics.downloadRange.length > 0) {
const svgOutputPath = await createDownloadsPerReleaseChart(metric, outputPath);
svgOutputPathList.push(svgOutputPath);
const svgOutputPathCumulative = await createCumulativeDownloadsChart(metric, outputPath);
svgOutputPathList.push(svgOutputPathCumulative);
// Create trend charts by dimensions if parsed attributes exist
if (metric.metrics?.assetReleaseSeries && metric.metrics?.assetAttributesByKey) {
const trends = await createParsedAttributeTrendCharts(metric, outputPath);
svgOutputPathList.push(...trends);
}
}
}
return svgOutputPathList;
}
function groupByReleaseCumulative(releaseRange) {
const releases = {};
for (const release of releaseRange.sort((a, b) => safeSemverCompare(a.tagName || '0.0.0', b.tagName || '0.0.0'))) {
if (!release.tagName)
continue;
if (!releases[release.tagName]) {
releases[release.tagName] = { downloads: release.downloads, tagName: release.tagName };
}
else {
releases[release.tagName].downloads += release.downloads;
}
}
let cumulativeDownloads = 0;
for (const release of Object.keys(releases).sort((a, b) => safeSemverCompare(a, b))) {
cumulativeDownloads += releases[release].downloads;
releases[release].downloads = cumulativeDownloads;
}
return releases;
}
export async function createDownloadsPerReleaseChart(metric, outputPath) {
const downloadsRange = metric.metrics?.downloadRange || [];
const svgOutputPath = `${outputPath}/${metric.name.replace('/', '-')}-release-downloads.svg`;
const sortedReleases = downloadsRange.sort((a, b) => safeSemverCompare(a.tagName || '0.0.0', b.tagName || '0.0.0'));
const canvas = new Canvas(1000, 800);
const chart = new Chart(canvas, {
type: 'bar',
data: {
labels: sortedReleases.map((r) => r.tagName),
datasets: [{
label: `${metric.name} Release Downloads`,
data: sortedReleases.map((r) => r.downloads),
backgroundColor: 'rgba(54, 162, 235, 0.8)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 1,
}]
},
options: {
responsive: true,
plugins: {
title: { display: true, text: `${metric.name} - Release Downloads`, font: { size: 16 } },
legend: { display: true }
},
scales: {
x: { title: { display: true, text: 'Release' } },
y: { title: { display: true, text: 'Downloads' }, beginAtZero: true }
}
}
});
const svgBuffer = await canvas.toBuffer('svg', { matte: 'white' });
writeFileSync(svgOutputPath, svgBuffer);
chart.destroy();
return svgOutputPath;
}
export async function createCumulativeDownloadsChart(metric, outputPath) {
const downloadsRange = metric.metrics?.downloadRange || [];
const svgOutputPath = `${outputPath}/${metric.name.replace('/', '-')}-cumulative-release-downloads.svg`;
const groupedDownloads = groupByReleaseCumulative(downloadsRange);
const semVerSortedReleases = Object.keys(groupedDownloads).sort((a, b) => safeSemverCompare(a, b));
const canvas = new Canvas(1000, 800);
const chart = new Chart(canvas, {
type: 'line',
data: {
labels: semVerSortedReleases,
datasets: [{
label: `${metric.name} Cumulative Downloads`,
data: semVerSortedReleases.map((release) => groupedDownloads[release].downloads),
backgroundColor: 'rgba(75, 192, 192, 0.2)',
borderColor: 'rgba(75, 192, 192, 1)',
borderWidth: 3,
fill: true,
tension: 0.1
}]
},
options: {
responsive: true,
plugins: {
title: { display: true, text: `${metric.name} - Cumulative Release Downloads`, font: { size: 16 } },
legend: { display: true }
},
scales: {
x: { title: { display: true, text: 'Release' } },
y: { title: { display: true, text: 'Downloads' }, beginAtZero: true }
}
}
});
const svgBuffer = await canvas.toBuffer('svg', { matte: 'white' });
writeFileSync(svgOutputPath, svgBuffer);
chart.destroy();
return svgOutputPath;
}
export async function createParsedAttributeTrendCharts(metric, outputPath) {
const outputPaths = [];
const attrSeries = metric.metrics?.attributeReleaseSeries || { os: {}, arch: {}, format: {}, variant: {}, version: {} };
const dims = ['os', 'arch', 'format', 'variant', 'version'];
for (const dim of dims) {
const groups = attrSeries[dim] || {};
const groupNames = Object.keys(groups);
if (groupNames.length === 0)
continue;
// Build union of release tags for this dim
const allReleaseTags = Array.from(new Set(groupNames.flatMap(name => Object.keys(groups[name]))))
.sort((a, b) => safeSemverCompare(a || '0.0.0', b || '0.0.0'));
const svgOutputPath = `${outputPath}/${metric.name.replace('/', '-')}-trend-${dim}.svg`;
const datasets = groupNames.map((name, idx) => {
const colorHue = (idx * 53) % 360;
const borderColor = `hsl(${colorHue}, 70%, 45%)`;
const backgroundColor = `hsla(${colorHue}, 70%, 45%, 0.2)`;
const byTag = groups[name];
return {
label: name,
data: allReleaseTags.map(tag => byTag[tag] || 0),
borderColor,
backgroundColor,
borderWidth: 2,
fill: false,
tension: 0.1
};
});
const canvas = new Canvas(1200, 800);
const chart = new Chart(canvas, {
type: 'line',
data: { labels: allReleaseTags, datasets },
options: {
responsive: true,
plugins: {
title: { display: true, text: `${metric.name} - ${dim.toUpperCase()} Download Trends`, font: { size: 16 } },
legend: { display: true }
},
scales: {
x: { title: { display: true, text: 'Release Tag' } },
y: {
title: { display: true, text: 'Downloads' },
beginAtZero: true,
ticks: { precision: 0 },
suggestedMin: 0,
suggestedMax: Math.max(10, ...datasets.map((ds) => Math.max(...ds.data)))
}
}
}
});
const svgBuffer = await canvas.toBuffer('svg', { matte: 'white' });
writeFileSync(svgOutputPath, svgBuffer);
chart.destroy();
outputPaths.push(svgOutputPath);
}
return outputPaths;
}
export async function createAssetDownloadsAcrossReleasesChart(metric, outputPath) {
const assetSeries = metric.metrics?.assetReleaseSeries || {};
const svgOutputPath = `${outputPath}/${metric.name.replace('/', '-')}-asset-downloads-over-releases.svg`;
const assetNames = Object.keys(assetSeries);
if (assetNames.length === 0)
return svgOutputPath;
const allReleaseTagsSet = new Set();
for (const name of assetNames) {
for (const point of assetSeries[name]) {
allReleaseTagsSet.add(point.tagName);
}
}
const allReleaseTags = Array.from(allReleaseTagsSet).sort((a, b) => safeSemverCompare(a || '0.0.0', b || '0.0.0'));
const datasets = assetNames.map((name, idx) => {
const points = assetSeries[name];
const byTag = {};
for (const p of points) {
byTag[p.tagName] = (byTag[p.tagName] || 0) + (p.downloads || 0);
}
const colorHue = (idx * 53) % 360;
return {
label: formatAssetLabel(name),
data: allReleaseTags.map(tag => byTag[tag] || 0),
borderColor: `hsl(${colorHue}, 70%, 45%)`,
backgroundColor: `hsla(${colorHue}, 70%, 45%, 0.2)`,
borderWidth: 2,
fill: false,
tension: 0.1
};
});
const canvas = new Canvas(1200, 800);
const chart = new Chart(canvas, {
type: 'line',
data: { labels: allReleaseTags, datasets },
options: {
responsive: true,
plugins: {
title: { display: true, text: `${metric.name} - Asset Downloads Across Releases`, font: { size: 16 } },
legend: { display: true }
},
scales: {
x: { title: { display: true, text: 'Release Tag' } },
y: { title: { display: true, text: 'Downloads' }, beginAtZero: true }
}
}
});
const svgBuffer = await canvas.toBuffer('svg', { matte: 'white' });
writeFileSync(svgOutputPath, svgBuffer);
chart.destroy();
return svgOutputPath;
}