mirror of
https://github.com/LukeHagar/usage-statistics.git
synced 2025-12-06 21:07:45 +00:00
351 lines
16 KiB
JavaScript
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 += `\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;
|
|
}
|