From 5cf3629bc8a5301e059e269f0621643eb1ab5a70 Mon Sep 17 00:00:00 2001 From: Luke Hagar Date: Thu, 14 Aug 2025 22:29:38 -0500 Subject: [PATCH] chore: streamline release workflows by adding version bump and built files commit step; remove redundant asset upload steps --- .github/workflows/auto-release.yml | 66 +---- .github/workflows/release.yml | 66 +---- .gitignore | 1 - dist/action.js | 94 +++++++ dist/collectors/github.js | 268 ++++++++++++++++++++ dist/collectors/npm.js | 112 +++++++++ dist/collectors/powershell.js | 176 +++++++++++++ dist/collectors/pypi.js | 151 +++++++++++ dist/collectors/types.js | 4 + dist/summaries/github.js | 280 +++++++++++++++++++++ dist/summaries/npm.js | 171 +++++++++++++ dist/summaries/powershell.js | 238 ++++++++++++++++++ dist/summaries/pypi.js | 388 +++++++++++++++++++++++++++++ dist/utils.js | 123 +++++++++ 14 files changed, 2031 insertions(+), 107 deletions(-) create mode 100644 dist/action.js create mode 100644 dist/collectors/github.js create mode 100644 dist/collectors/npm.js create mode 100644 dist/collectors/powershell.js create mode 100644 dist/collectors/pypi.js create mode 100644 dist/collectors/types.js create mode 100644 dist/summaries/github.js create mode 100644 dist/summaries/npm.js create mode 100644 dist/summaries/powershell.js create mode 100644 dist/summaries/pypi.js create mode 100644 dist/utils.js diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 80154bf..89360ca 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -112,6 +112,19 @@ jobs: echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + - name: Commit version bump and built files + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add package.json dist/ + git commit -m "chore: bump version to ${{ steps.bump.outputs.new_version }} and build action" + git push + + - name: Create and push tag + run: | + git tag v${{ steps.bump.outputs.new_version }} + git push origin v${{ steps.bump.outputs.new_version }} + - name: Create Release id: create_release uses: actions/create-release@v1 @@ -133,56 +146,3 @@ jobs: ``` draft: false prerelease: false - - - name: Upload Release Assets - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./dist/action.js - asset_name: action.js - asset_content_type: application/javascript - - - name: Upload Release Assets - Collectors - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./dist/collectors/ - asset_name: collectors.zip - asset_content_type: application/zip - - - name: Upload Release Assets - Summaries - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./dist/summaries/ - asset_name: summaries.zip - asset_content_type: application/zip - - - name: Upload Release Assets - Utils - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./dist/utils.js - asset_name: utils.js - asset_content_type: application/javascript - - - name: Commit version bump - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add package.json - git commit -m "chore: bump version to ${{ steps.bump.outputs.new_version }}" - git push - - - name: Create and push tag - run: | - git tag v${{ steps.bump.outputs.new_version }} - git push origin v${{ steps.bump.outputs.new_version }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 516ac26..27e4462 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -95,6 +95,19 @@ jobs: echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + - name: Commit version bump and built files + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add package.json dist/ + git commit -m "chore: bump version to ${{ steps.bump.outputs.new_version }} and build action" + git push + + - name: Create and push tag + run: | + git tag v${{ steps.bump.outputs.new_version }} + git push origin v${{ steps.bump.outputs.new_version }} + - name: Create Release id: create_release uses: actions/create-release@v1 @@ -106,59 +119,6 @@ jobs: draft: false prerelease: false - - name: Upload Release Assets - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./dist/action.js - asset_name: action.js - asset_content_type: application/javascript - - - name: Upload Release Assets - Collectors - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./dist/collectors/ - asset_name: collectors.zip - asset_content_type: application/zip - - - name: Upload Release Assets - Summaries - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./dist/summaries/ - asset_name: summaries.zip - asset_content_type: application/zip - - - name: Upload Release Assets - Utils - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./dist/utils.js - asset_name: utils.js - asset_content_type: application/javascript - - - name: Commit version bump - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add package.json - git commit -m "chore: bump version to ${{ steps.bump.outputs.new_version }}" - git push - - - name: Create and push tag - run: | - git tag v${{ steps.bump.outputs.new_version }} - git push origin v${{ steps.bump.outputs.new_version }} - - name: Update action.yml version reference run: | # Update the action.yml to reference the new version diff --git a/.gitignore b/.gitignore index bb5d54a..e5a9f98 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,6 @@ yarn-error.log* # Build outputs build/ *.tsbuildinfo -dist/ # Environment variables .env diff --git a/dist/action.js b/dist/action.js new file mode 100644 index 0000000..bd31970 --- /dev/null +++ b/dist/action.js @@ -0,0 +1,94 @@ +import * as core from '@actions/core'; +import { collectNpmBatch } from './collectors/npm.js'; +import { collectGithubBatch } from './collectors/github.js'; +import { collectPowerShellBatch } from './collectors/powershell.js'; +import { collectPypiBatch } from './collectors/pypi.js'; +import { getInputs, updateRepositoryReadme } from './utils.js'; +import { writeFile } from 'fs/promises'; +try { + const { npmPackages, githubRepositories, pypiPackages, powershellModules, jsonOutputPath, updateReadme, commitMessage, readmePath, } = getInputs(); + // Debug logs are only output if the `ACTIONS_STEP_DEBUG` secret is true + core.debug(`NPM Packages: ${npmPackages.join(', ')}`); + core.debug(`GitHub Repositories: ${githubRepositories.join(', ')}`); + core.debug(`PyPI Packages: ${pypiPackages.join(', ')}`); + core.debug(`PowerShell Modules: ${powershellModules.join(', ')}`); + core.debug(``); + core.debug(`JSON Output Path: ${jsonOutputPath}`); + core.debug(`Update README: ${updateReadme}`); + core.debug(`Commit Message: ${commitMessage}`); + // Track which platforms are being used + const platformsTracked = []; + if (npmPackages.length > 0) + platformsTracked.push('NPM'); + if (githubRepositories.length > 0) + platformsTracked.push('GitHub'); + if (pypiPackages.length > 0) + platformsTracked.push('PyPI'); + if (powershellModules.length > 0) + platformsTracked.push('PowerShell'); + core.debug(`Platforms to track: ${platformsTracked.join(', ')}`); + core.info(`Successfully configured usage statistics tracker for ${platformsTracked.length} platforms`); + const metricPromises = []; + const metrics = []; + for (const platform of platformsTracked) { + core.info(`Collecting ${platform} metrics...`); + switch (platform) { + case 'NPM': + console.log(`Collecting NPM metrics for ${npmPackages.join(', ')}`); + console.time(`Collecting NPM metrics`); + metricPromises.push(collectNpmBatch(npmPackages).then(results => { + console.timeEnd(`Collecting NPM metrics`); + return results; + })); + break; + case 'GitHub': + console.log(`Collecting GitHub metrics for ${githubRepositories.join(', ')}`); + console.time(`Collecting GitHub metrics`); + metricPromises.push(collectGithubBatch(githubRepositories).then(results => { + console.timeEnd(`Collecting GitHub metrics`); + return results; + })); + break; + case 'PyPI': + console.log(`Collecting PyPI metrics for ${pypiPackages.join(', ')}`); + console.time(`Collecting PyPI metrics`); + metricPromises.push(collectPypiBatch(pypiPackages).then(results => { + console.timeEnd(`Collecting PyPI metrics`); + return results; + })); + break; + case 'PowerShell': + console.log(`Collecting PowerShell metrics for ${powershellModules.join(', ')}`); + console.time(`Collecting PowerShell metrics`); + metricPromises.push(collectPowerShellBatch(powershellModules).then(results => { + console.timeEnd(`Collecting PowerShell metrics`); + return results; + })); + break; + } + } + console.log('All metrics collecting started'); + const metricResults = await Promise.all(metricPromises); + metrics.push(...metricResults.flat()); + console.log('All metrics collecting completed'); + if (updateReadme) { + console.log('Updating repository readme...'); + await updateRepositoryReadme(metrics, readmePath); + } + console.log('Repository readme updated'); + // Persist full result set to JSON for downstream consumption + try { + await writeFile(jsonOutputPath, JSON.stringify(metrics, null, 2), 'utf8'); + core.setOutput('json-output', jsonOutputPath); + console.log(`Wrote metrics JSON to ${jsonOutputPath}`); + } + catch (writeErr) { + console.warn(`Failed to write metrics JSON to ${jsonOutputPath}:`, writeErr); + } + core.setOutput('commit-message', commitMessage); +} +catch (error) { + // Fail the workflow run if an error occurs + if (error instanceof Error) + core.setFailed(error.message); +} diff --git a/dist/collectors/github.js b/dist/collectors/github.js new file mode 100644 index 0000000..aba210c --- /dev/null +++ b/dist/collectors/github.js @@ -0,0 +1,268 @@ +/** + * GitHub repository statistics collector with enhanced metrics using Octokit SDK and GraphQL + */ +import { Octokit } from '@octokit/rest'; +import { graphql } from '@octokit/graphql'; +const PlatformSettings = { + name: 'GitHub', +}; +// GraphQL query for basic repository data (without releases) +const REPOSITORY_BASIC_QUERY = ` + query RepositoryBasicData($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + id + name + description + homepageUrl + stargazerCount + forkCount + watchers { + totalCount + } + openIssues: issues(states: OPEN) { + totalCount + } + closedIssues: issues(states: CLOSED) { + totalCount + } + primaryLanguage { + name + } + diskUsage + createdAt + updatedAt + pushedAt + defaultBranchRef { + name + } + repositoryTopics(first: 10) { + nodes { + topic { + name + } + } + } + licenseInfo { + name + spdxId + } + } + } +`; +// GraphQL query for releases with download data +const RELEASES_QUERY = ` + query RepositoryReleases($owner: String!, $name: String!, $first: Int!) { + repository(owner: $owner, name: $name) { + releases(first: $first, orderBy: {field: CREATED_AT, direction: DESC}) { + nodes { + id + tagName + name + description + createdAt + publishedAt + releaseAssets(first: 100) { + nodes { + id + name + size + downloadCount + downloadUrl + } + } + } + } + } + } +`; +export async function collectGithub(repository) { + try { + const [owner, repo] = repository.split('/'); + if (!owner || !repo) { + throw new Error(`Invalid repository format: ${repository}. Expected "owner/repo"`); + } + // Initialize Octokit for REST API calls + const token = process.env.GITHUB_TOKEN || process.env.INPUT_GITHUB_TOKEN || ''; + const octokit = new Octokit({ + auth: token, + userAgent: 'usage-statistics-tracker' + }); + if (!token) { + console.warn('No GitHub token provided. Using unauthenticated requests (rate limited).'); + } + // Step 1: Fetch basic repository data using GraphQL + let graphqlData = null; + try { + const graphqlClient = graphql.defaults({ + headers: { + authorization: token ? `token ${token}` : undefined, + }, + }); + // Fetch basic repository data (without releases) + const basicResponse = await graphqlClient(REPOSITORY_BASIC_QUERY, { + owner, + name: repo + }); + if (basicResponse.repository) { + graphqlData = basicResponse.repository; + } + } + catch (error) { + console.warn(`Could not fetch GitHub GraphQL basic data for ${repository}:`, error); + } + // Step 2: Fetch releases data separately using GraphQL + let totalReleaseDownloads = 0; + let latestReleaseDownloads = 0; + let releaseCount = 0; + let downloadRange = []; + let latestRelease = null; + try { + const graphqlClient = graphql.defaults({ + headers: { + authorization: token ? `token ${token}` : undefined, + }, + }); + // Fetch releases data + const releasesResponse = await graphqlClient(RELEASES_QUERY, { + owner, + name: repo, + first: 100 + }); + if (releasesResponse.repository?.releases?.nodes) { + const releases = releasesResponse.repository.releases.nodes.filter(Boolean); + releaseCount = releases.length; + for (const release of releases) { + let releaseDownloads = 0; + if (release?.releaseAssets?.nodes) { + for (const asset of release.releaseAssets.nodes) { + if (asset) { + releaseDownloads += asset.downloadCount || 0; + } + } + } + totalReleaseDownloads += releaseDownloads; + // Latest release is the first one in the list + if (release && release === releases[0]) { + latestReleaseDownloads = releaseDownloads; + latestRelease = release.tagName; + } + // Add to download range with proper date format for charts + if (release?.publishedAt) { + downloadRange.push({ + day: release.publishedAt, + downloads: releaseDownloads, + tagName: release.tagName + }); + } + } + } + } + catch (error) { + console.warn(`Could not fetch GitHub GraphQL releases data for ${repository}:`, error); + } + // Fallback to REST API if GraphQL fails or for additional data + let restData = null; + try { + const { data: repoData } = await octokit.repos.get({ + owner, + repo + }); + restData = repoData; + } + catch (error) { + console.warn(`Could not fetch GitHub REST data for ${repository}:`, error); + } + // Use the best available data (GraphQL preferred, REST as fallback) + const finalData = graphqlData || restData; + if (!finalData) { + throw new Error('Could not fetch repository data from either GraphQL or REST API'); + } + // Get traffic statistics using REST API (requires authentication) + let viewsCount = 0; + let uniqueVisitors = 0; + let clonesCount = 0; + if (token) { + try { + // Get views data + const { data: viewsData } = await octokit.repos.getViews({ + owner, + repo + }); + if (viewsData) { + viewsCount = viewsData.count || 0; + uniqueVisitors = viewsData.uniques || 0; + } + // Get clones data + const { data: clonesData } = await octokit.repos.getClones({ + owner, + repo + }); + if (clonesData) { + clonesCount = clonesData.count || 0; + } + } + catch (error) { + console.warn(`Could not fetch GitHub traffic data for ${repository}:`, error); + } + } + // Calculate repository age + let repositoryAge = 0; + if (finalData.createdAt) { + const created = new Date(finalData.createdAt); + const now = new Date(); + repositoryAge = Math.floor((now.getTime() - created.getTime()) / (1000 * 60 * 60 * 24)); // days + } + // Calculate activity metrics + let lastActivity = 0; + if (finalData.pushedAt) { + const pushed = new Date(finalData.pushedAt); + const now = new Date(); + lastActivity = Math.floor((now.getTime() - pushed.getTime()) / (1000 * 60 * 60 * 24)); // days + } + return { + platform: PlatformSettings.name, + name: repository, + timestamp: new Date().toISOString(), + metrics: { + stars: finalData.stargazerCount || finalData.stargazers_count || 0, + forks: finalData.forkCount || finalData.forks_count || 0, + watchers: finalData.watchers?.totalCount || finalData.watchers_count || 0, + totalIssues: finalData.openIssues?.totalCount + finalData.closedIssues?.totalCount || 0, + openIssues: finalData.openIssues?.totalCount || 0, + closedIssues: finalData.closedIssues?.totalCount || 0, + language: finalData.primaryLanguage?.name || finalData.language || null, + size: finalData.diskUsage || finalData.size || null, + repositoryAge, + lastActivity, + releaseCount, + totalReleaseDownloads, + latestReleaseDownloads, + viewsCount, + uniqueVisitors, + latestRelease, + clonesCount, + topics: finalData.repositoryTopics?.nodes?.length || finalData.topics?.length || 0, + license: finalData.licenseInfo?.name || finalData.license?.name || null, + defaultBranch: finalData.defaultBranchRef?.name || finalData.default_branch || null, + downloadsTotal: totalReleaseDownloads || 0, + downloadRange, + } + }; + } + catch (error) { + return { + platform: PlatformSettings.name, + name: repository, + timestamp: new Date().toISOString(), + metrics: {}, + error: error instanceof Error ? error.message : String(error) + }; + } +} +export async function collectGithubBatch(repositories) { + const results = []; + for (const repo of repositories) { + results.push(collectGithub(repo)); + } + return Promise.all(results); +} diff --git a/dist/collectors/npm.js b/dist/collectors/npm.js new file mode 100644 index 0000000..001a042 --- /dev/null +++ b/dist/collectors/npm.js @@ -0,0 +1,112 @@ +/** + * NPM package statistics collector with enhanced metrics + */ +const PlatformSettings = { + name: 'NPM', +}; +const BASE_URL = 'https://api.npmjs.org/downloads/range'; +const CHUNK_DAYS = 540; // 18 months max per request +const START_DATE = new Date('2015-01-10'); // Earliest NPM data +function formatDate(date) { + return date.toISOString().split('T')[0]; +} +function addDays(date, days) { + const result = new Date(date); + result.setDate(result.getDate() + days); + return result; +} +async function fetchChunk(start, end, packageName) { + const url = `${BASE_URL}/${formatDate(start)}:${formatDate(end)}/${packageName}`; + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Failed to fetch data: ${res.status} ${res.statusText}`); + } + const json = await res.json(); + return json.downloads; +} +async function getFullDownloadHistory(packageName, startDate) { + const today = new Date(); + let currentStart = new Date(startDate); + let allDownloads = []; + while (currentStart < today) { + const currentEnd = addDays(currentStart, CHUNK_DAYS - 1); + const end = currentEnd > today ? today : currentEnd; + console.log(`Fetching ${formatDate(currentStart)} to ${formatDate(end)}...`); + const chunk = await fetchChunk(currentStart, end, packageName); + allDownloads = allDownloads.concat(chunk); + currentStart = addDays(end, 1); // move to next chunk + } + return Array.from(new Set(allDownloads)); +} +export async function collectNpm(packageName) { + try { + // Get package info from npm registry + const packageUrl = `https://registry.npmjs.org/${packageName}`; + const packageResponse = await fetch(packageUrl); + const packageData = await packageResponse.json(); + // Get download statistics + let downloadsMonthly; + let downloadsWeekly; + let downloadsDaily; + try { + // Monthly downloads + const monthlyUrl = `https://api.npmjs.org/downloads/point/last-month/${packageName}`; + const monthlyResponse = await fetch(monthlyUrl); + const monthlyData = await monthlyResponse.json(); + downloadsMonthly = monthlyData.downloads || null; + } + catch (error) { + console.warn(`Could not fetch NPM monthly downloads for ${packageName}:`, error); + } + try { + // Weekly downloads + const weeklyUrl = `https://api.npmjs.org/downloads/point/last-week/${packageName}`; + const weeklyResponse = await fetch(weeklyUrl); + const weeklyData = await weeklyResponse.json(); + downloadsWeekly = weeklyData.downloads || null; + } + catch (error) { + console.warn(`Could not fetch NPM weekly downloads for ${packageName}:`, error); + } + try { + // Daily downloads + const dailyUrl = `https://api.npmjs.org/downloads/point/last-day/${packageName}`; + const dailyResponse = await fetch(dailyUrl); + const dailyData = await dailyResponse.json(); + downloadsDaily = dailyData.downloads || null; + } + catch (error) { + console.warn(`Could not fetch NPM daily downloads for ${packageName}:`, error); + } + const downloadsRange = await getFullDownloadHistory(packageName, new Date(packageData.time?.created || START_DATE)); + const downloadsTotal = downloadsRange.reduce((acc, curr) => acc + curr.downloads, 0); + return { + platform: PlatformSettings.name, + name: packageName, + timestamp: new Date().toISOString(), + metrics: { + downloadsTotal, + downloadsMonthly, + downloadsWeekly, + downloadsDaily, + downloadsRange, + } + }; + } + catch (error) { + return { + platform: PlatformSettings.name, + name: packageName, + timestamp: new Date().toISOString(), + metrics: {}, + error: error instanceof Error ? error.message : String(error) + }; + } +} +export async function collectNpmBatch(packageNames) { + const resultPromises = []; + for (const packageName of packageNames) { + resultPromises.push(collectNpm(packageName)); + } + return Promise.all(resultPromises); +} diff --git a/dist/collectors/powershell.js b/dist/collectors/powershell.js new file mode 100644 index 0000000..56f78ae --- /dev/null +++ b/dist/collectors/powershell.js @@ -0,0 +1,176 @@ +/** + * PowerShell Gallery module statistics collector with enhanced metrics + */ +import { XMLParser } from 'fast-xml-parser'; +const PlatformSettings = { + name: 'PowerShell', +}; +const BASE_URL = 'https://www.powershellgallery.com/api/v2/'; +const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '', +}); +function parsePowerShellGalleryEntry(entry) { + const props = entry["m:properties"]; + const getText = (field) => field?.["#text"]; + const isTrue = (field) => field?.["#text"] === true; + const getNumber = (field) => field?.["#text"]; + const getDate = (field) => { + const dateText = getText(field); + if (!dateText || dateText === '') { + return new Date(0); // Return epoch date for invalid dates + } + return new Date(dateText); + }; + return { + id: entry.id, + name: props["d:Id"], + version: props["d:Version"], + normalizedVersion: props["d:NormalizedVersion"], + authors: props["d:Authors"], + description: props["d:Description"], + downloadCount: getNumber(props["d:DownloadCount"]), + versionDownloadCount: getNumber(props["d:VersionDownloadCount"]), + published: getDate(props["d:Published"]), + lastUpdated: getDate(props["d:LastUpdated"]), + created: getDate(props["d:Created"]), + isLatest: isTrue(props["d:IsLatestVersion"]), + isPrerelease: isTrue(props["d:IsPrerelease"]), + projectUrl: getText(props["d:ProjectUrl"]) ?? undefined, + reportAbuseUrl: props["d:ReportAbuseUrl"], + galleryDetailsUrl: props["d:GalleryDetailsUrl"], + packageSize: getNumber(props["d:PackageSize"]), + companyName: props["d:CompanyName"], + owners: props["d:Owners"], + }; +} +/** + * Fetches all versions of a package. + * Equivalent to: FindPackagesById()?id='PackageName' + */ +export async function findPackagesById(id) { + const url = `${BASE_URL}FindPackagesById()?id='${encodeURIComponent(id)}'`; + const res = await fetch(url); + const xml = await res.text(); + const json = parser.parse(xml); + return json.feed.entry ?? []; +} +/** + * Fetches metadata for a specific version of a package. + * Equivalent to: Packages(Id='Name',Version='x.y.z') + */ +export async function getPackageVersionInfo(id, version) { + const url = `${BASE_URL}Packages(Id='${encodeURIComponent(id)}',Version='${encodeURIComponent(version)}')`; + const res = await fetch(url); + const xml = await res.text(); + const json = parser.parse(xml); + return json.entry; +} +/** + * Searches the PowerShell Gallery with a search term. + * Equivalent to: Search()?searchTerm='term'&includePrerelease=false + */ +export async function searchPackages(searchTerm, includePrerelease = false) { + const url = `${BASE_URL}Search()?searchTerm='${encodeURIComponent(searchTerm)}'&includePrerelease=${includePrerelease.toString()}`; + const res = await fetch(url); + const xml = await res.text(); + const json = parser.parse(xml); + return json.feed?.entry ?? []; +} +/** + * Sum total download count for all versions of a package. + */ +export async function getTotalDownloadCount(id) { + const entries = await findPackagesById(id); + const versions = Array.isArray(entries) ? entries : [entries]; + return versions.reduce((sum, entry) => { + const count = entry['m:properties']?.['d:DownloadCount']?.['#text']; + return sum + count; + }, 0); +} +export async function collectPowerShell(moduleName) { + try { + // Get all versions of the package + const allVersions = await findPackagesById(moduleName); + if (!allVersions || allVersions.length === 0) { + throw new Error(`Module ${moduleName} not found`); + } + const versions = []; + for (const version of allVersions) { + const parsedVersion = parsePowerShellGalleryEntry(version); + versions.push(parsedVersion); + } + // Sort versions by published date (newest first) + const sortedVersions = versions.sort((a, b) => b.published.getTime() - a.published.getTime()); + let downloadsTotal = 0; + let latestVersionDownloads = 0; + let downloadRange = []; + let latestVersion = ''; + let latestVersionDate = ''; + // Process each version + for (const version of sortedVersions) { + // Use Created date if Published date is invalid (1900-01-01) + const effectiveDate = version.published.getTime() === new Date('1900-01-01T00:00:00').getTime() + ? version.created + : version.published; + // Skip versions with invalid dates + if (effectiveDate.getTime() === 0) { + continue; + } + downloadsTotal += version.versionDownloadCount; + // Track latest version downloads + if (version.isLatest) { + latestVersionDownloads = version.versionDownloadCount; + latestVersion = version.version; + latestVersionDate = effectiveDate.toISOString(); + } + const rangeEntry = { + day: effectiveDate.toISOString(), + downloads: version.versionDownloadCount, + version: version.version + }; + // Add to download range for charts + downloadRange.push(rangeEntry); + } + // Get latest version metadata + const latestModuleData = sortedVersions[0]; + const result = { + platform: PlatformSettings.name, + name: moduleName, + timestamp: new Date().toISOString(), + metrics: { + downloadsTotal, + downloadsRange: downloadRange, + latestVersionDownloads, + latestVersion, + latestVersionDate, + versionCount: versions.length, + lastUpdated: latestModuleData.lastUpdated.toISOString(), + // Additional metadata + authors: latestModuleData.authors, + description: latestModuleData.description, + projectUrl: latestModuleData.projectUrl, + packageSize: latestModuleData.packageSize, + companyName: latestModuleData.companyName, + owners: latestModuleData.owners, + } + }; + return result; + } + catch (error) { + console.warn('Error collecting PowerShell module:', error); + return { + platform: PlatformSettings.name, + name: moduleName, + timestamp: new Date().toISOString(), + error: error instanceof Error ? error.message : String(error), + }; + } +} +export async function collectPowerShellBatch(moduleNames) { + const resultPromises = []; + for (const moduleName of moduleNames) { + resultPromises.push(collectPowerShell(moduleName)); + } + return Promise.all(resultPromises); +} diff --git a/dist/collectors/pypi.js b/dist/collectors/pypi.js new file mode 100644 index 0000000..5c57016 --- /dev/null +++ b/dist/collectors/pypi.js @@ -0,0 +1,151 @@ +/** + * PyPI package statistics collector using external PyPI Stats API + */ +const PlatformSettings = { + name: 'PyPI', +}; +// (no BigQuery historical metrics; all data comes from the external API) +// External PyPI Stats API base URL +const PYPI_STATS_BASE_URL = process.env.PYPI_STATS_BASE_URL || 'https://pypistats.dev'; +function normalizePackageName(name) { + return name.replace(/[._]/g, '-').toLowerCase(); +} +async function fetchJson(url) { + const res = await fetch(url); + if (!res.ok) + throw new Error(`Request failed ${res.status}: ${url}`); + return res.json(); +} +export async function collectPypi(packageName) { + const normalized = normalizePackageName(packageName); + try { + // Package metadata + const packageDataPromise = fetchJson(`https://pypi.org/pypi/${normalized}/json`); + const recentPromise = fetchJson(`${PYPI_STATS_BASE_URL}/api/packages/${normalized}/recent`); + const summaryPromise = fetchJson(`${PYPI_STATS_BASE_URL}/api/packages/${normalized}/summary`); + const overallPromise = fetchJson(`${PYPI_STATS_BASE_URL}/api/packages/${normalized}/overall`); + const pythonMajorPromise = fetchJson(`${PYPI_STATS_BASE_URL}/api/packages/${normalized}/python_major`); + const pythonMinorPromise = fetchJson(`${PYPI_STATS_BASE_URL}/api/packages/${normalized}/python_minor`); + const systemPromise = fetchJson(`${PYPI_STATS_BASE_URL}/api/packages/${normalized}/system`); + const installerPromise = fetchJson(`${PYPI_STATS_BASE_URL}/api/packages/${normalized}/installer`); + const overallChartPromise = fetchJson(`${PYPI_STATS_BASE_URL}/api/packages/${normalized}/chart/overall?format=json`); + const pythonMajorChartPromise = fetchJson(`${PYPI_STATS_BASE_URL}/api/packages/${normalized}/chart/python_major?format=json`); + const pythonMinorChartPromise = fetchJson(`${PYPI_STATS_BASE_URL}/api/packages/${normalized}/chart/python_minor?format=json`); + const systemChartPromise = fetchJson(`${PYPI_STATS_BASE_URL}/api/packages/${normalized}/chart/system?format=json`); + const installerChartPromise = fetchJson(`${PYPI_STATS_BASE_URL}/api/packages/${normalized}/chart/installer?format=json`); + const [packageData, recent, summary, overall, pythonMajor, pythonMinor, system, installer, overallChart, pythonMajorChart, pythonMinorChart, systemChart, installerChart] = await Promise.all([ + packageDataPromise, + recentPromise, + summaryPromise, + overallPromise, + pythonMajorPromise, + pythonMinorPromise, + systemPromise, + installerPromise, + overallChartPromise, + pythonMajorChartPromise, + pythonMinorChartPromise, + systemChartPromise, + installerChartPromise, + ]); + // All time-series and breakdowns are provided by the external API + const overallSeries = (overall.data || []).filter(p => p.category === 'without_mirrors'); + const systemBreakdown = summary.totals?.system || null; + const pythonVersionBreakdown = summary.totals?.python_major + ? Object.fromEntries(Object.entries(summary.totals.python_major).filter(([k]) => /^\d+$/.test(k)).map(([k, v]) => [`python${k}`, v])) + : null; + const pythonMinorBreakdown = summary.totals?.python_minor + ? Object.fromEntries(Object.entries(summary.totals.python_minor).filter(([k]) => /^\d+(?:\.\d+)?$/.test(k)).map(([k, v]) => [`python${k}`, v])) + : null; + // Derive popular system and installer from totals/series + let popularSystem; + if (systemBreakdown && Object.keys(systemBreakdown).length > 0) { + popularSystem = Object.entries(systemBreakdown).sort((a, b) => b[1] - a[1])[0]?.[0]; + } + else if (system.data && system.data.length > 0) { + const totals = {}; + for (const p of system.data) + totals[p.category] = (totals[p.category] || 0) + p.downloads; + popularSystem = Object.entries(totals).sort((a, b) => b[1] - a[1])[0]?.[0]; + } + let popularInstaller; + if (installer.data && installer.data.length > 0) { + const totals = {}; + for (const p of installer.data) + totals[p.category] = (totals[p.category] || 0) + p.downloads; + popularInstaller = Object.entries(totals).sort((a, b) => b[1] - a[1])[0]?.[0]; + } + // Latest release date for current version + let latestReleaseDate; + try { + const currentVersion = packageData.info?.version; + const files = currentVersion ? (packageData.releases?.[currentVersion] || []) : []; + const latestUpload = files.reduce((max, f) => { + const t = f.upload_time; + if (!t) + return max; + if (!max) + return t; + return new Date(t) > new Date(max) ? t : max; + }, undefined); + if (latestUpload) { + const d = new Date(latestUpload); + if (!isNaN(d.getTime())) + latestReleaseDate = d.toISOString().slice(0, 10); + } + } + catch { } + return { + platform: PlatformSettings.name, + name: packageName, + timestamp: new Date().toISOString(), + metrics: { + downloadsTotal: summary.totals?.overall, + downloadsMonthly: recent.data?.last_month, + downloadsWeekly: recent.data?.last_week, + downloadsDaily: recent.data?.last_day, + version: packageData.info?.version, + latestReleaseDate, + description: packageData.info?.summary, + homepage: packageData.info?.home_page, + author: packageData.info?.author, + license: packageData.info?.license, + requiresPython: packageData.info?.requires_python, + releases: Object.keys(packageData.releases || {}).length, + downloadsRange: overallSeries.map(p => ({ day: p.date, downloads: p.downloads })), + overallSeries, + pythonMajorSeries: (pythonMajor.data || []).filter(p => p.category?.toLowerCase?.() !== 'unknown'), + pythonMinorSeries: (pythonMinor.data || []).filter(p => p.category?.toLowerCase?.() !== 'unknown'), + systemSeries: system.data || [], + installerSeries: installer.data || [], + popularSystem, + popularInstaller, + // Server-prepared chart JSON (preferred for rendering) + overallChart, + pythonMajorChart: { ...pythonMajorChart, datasets: (pythonMajorChart.datasets || []).filter(ds => !/unknown/i.test(ds.label)) }, + pythonMinorChart: { ...pythonMinorChart, datasets: (pythonMinorChart.datasets || []).filter(ds => !/unknown/i.test(ds.label)) }, + systemChart, + installerChart, + pythonVersionBreakdown, + pythonMinorBreakdown, + systemBreakdown, + } + }; + } + catch (error) { + return { + platform: PlatformSettings.name, + name: packageName, + timestamp: new Date().toISOString(), + metrics: {}, + error: error instanceof Error ? error.message : String(error) + }; + } +} +export async function collectPypiBatch(packageNames) { + const results = []; + for (const packageName of packageNames) { + results.push(collectPypi(packageName)); + } + return Promise.all(results); +} diff --git a/dist/collectors/types.js b/dist/collectors/types.js new file mode 100644 index 0000000..6ca66d4 --- /dev/null +++ b/dist/collectors/types.js @@ -0,0 +1,4 @@ +/** + * Core types for the simplified usage statistics system + */ +export {}; diff --git a/dist/summaries/github.js b/dist/summaries/github.js new file mode 100644 index 0000000..3e5676e --- /dev/null +++ b/dist/summaries/github.js @@ -0,0 +1,280 @@ +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); +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`; + 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); + const svgOutputPathReleases = await createReleaseDownloadsChart(metric, outputPath); + svgOutputPathList.push(svgOutputPathReleases); + } + } + return svgOutputPathList; +} +function groupByReleaseCumulative(releaseRange) { + const releases = {}; + for (const release of releaseRange.sort((a, b) => { + return semver.compare(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) => { + return semver.compare(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) => { + return semver.compare(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((release) => release.tagName), + datasets: [{ + label: `${metric.name} Release Downloads`, + data: sortedReleases.map((release) => release.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); + // Sort months chronologically + const semVerSortedReleases = Object.keys(groupedDownloads).sort((a, b) => { + return semver.compare(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 createReleaseDownloadsChart(metric, outputPath) { + const downloadsRange = metric.metrics?.downloadRange || []; + const svgOutputPath = `${outputPath}/${metric.name.replace('/', '-')}-top-release-downloads.svg`; + // Sort releases by date (newest first for display) + const sortedReleases = downloadsRange + .filter((release) => release.tagName && release.downloads > 0) + .sort((a, b) => b.downloads - a.downloads) + .slice(0, 10) // Show top 10 releases + .sort((a, b) => semver.compare(a.tagName || '0.0.0', b.tagName || '0.0.0')); + if (sortedReleases.length === 0) { + // Return empty chart if no releases + return svgOutputPath; + } + const canvas = new Canvas(1200, 800); + const chart = new Chart(canvas, { + type: 'bar', + data: { + labels: sortedReleases.map((release) => release.tagName), + datasets: [{ + label: `${metric.name} Release Downloads`, + data: sortedReleases.map((release) => release.downloads), + backgroundColor: 'rgba(255, 99, 132, 0.8)', + borderColor: 'rgba(255, 99, 132, 1)', + borderWidth: 1, + }] + }, + options: { + responsive: true, + plugins: { + title: { + display: true, + text: `${metric.name} - Top Release Downloads`, + 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; +} diff --git a/dist/summaries/npm.js b/dist/summaries/npm.js new file mode 100644 index 0000000..3b8e8ef --- /dev/null +++ b/dist/summaries/npm.js @@ -0,0 +1,171 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { Chart, registerables } from 'chart.js'; +import { Canvas } from 'skia-canvas'; +// Register all Chart.js controllers +Chart.register(...registerables); +export function formatNpmSummary(summary, platformMetrics) { + let totalDownloads = 0; + let totalMonthlyDownloads = 0; + let totalWeeklyDownloads = 0; + let totalDailyDownloads = 0; + summary += `| Package | Downloads | Monthly Downloads | Weekly Downloads | Daily Downloads |\n`; + summary += `| --- | --- | --- | --- | --- |\n`; + for (const metric of platformMetrics) { + const downloads = metric.metrics?.downloadsTotal || 0; + const monthlyDownloads = metric.metrics?.downloadsMonthly || 0; + const weeklyDownloads = metric.metrics?.downloadsWeekly || 0; + const dailyDownloads = metric.metrics?.downloadsDaily || 0; + totalDownloads += downloads; + totalMonthlyDownloads += monthlyDownloads; + totalWeeklyDownloads += weeklyDownloads; + totalDailyDownloads += dailyDownloads; + summary += `| ${metric.name} | ${downloads.toLocaleString()} | ${monthlyDownloads.toLocaleString()} | ${weeklyDownloads.toLocaleString()} | ${dailyDownloads.toLocaleString()} |\n`; + } + summary += `| **Total** | **${totalDownloads.toLocaleString()}** | **${totalMonthlyDownloads.toLocaleString()}** | **${totalWeeklyDownloads.toLocaleString()}** | **${totalDailyDownloads.toLocaleString()}** | | | | |\n`; + return summary; +} +// Convert a list of dates into a list of Months +function groupByMonth(dateRange) { + const months = {}; + for (const range of dateRange) { + const month = new Date(range.day).toLocaleDateString('en-US', { month: 'short', year: '2-digit' }); + if (!months[month]) { + months[month] = range.downloads; + } + else { + months[month] += range.downloads; + } + } + return months; +} +function groupByMonthCumulative(dateRange) { + const months = {}; + for (const range of dateRange) { + const month = new Date(range.day).toLocaleDateString('en-US', { month: 'short', year: '2-digit' }); + if (!months[month]) { + months[month] = range.downloads; + } + else { + months[month] += range.downloads; + } + } + let cumulativeDownloads = 0; + for (const month in months) { + cumulativeDownloads += months[month]; + months[month] = cumulativeDownloads; + } + return months; +} +export async function createDownloadsPerMonthChart(metric, outputPath) { + const downloadsRange = metric.metrics?.downloadsRange || []; + const svgOutputPath = `${outputPath}/${metric.name}-new-downloads-by-month.svg`; + const groupedDownloads = groupByMonth(downloadsRange); + const canvas = new Canvas(1000, 800); + const chart = new Chart(canvas, { + type: 'line', + data: { + labels: Object.keys(groupedDownloads), + datasets: [{ + label: metric.name, + data: Object.values(groupedDownloads), + backgroundColor: 'rgba(75, 192, 192, 0.2)', + borderColor: 'rgba(75, 192, 192, 1)', + borderWidth: 3, + fill: true, + tension: 0.1 + }] + }, + options: { + scales: { + x: { + time: { + unit: 'month', + displayFormats: { + month: 'MMM DD' + } + }, + title: { + display: true, + text: 'Date' + } + }, + y: { + title: { + display: true, + text: 'Downloads per month' + }, + } + } + } + }); + 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?.downloadsRange || []; + const svgOutputPath = `${outputPath}/${metric.name}-cumulative-downloads.svg`; + const groupedDownloads = groupByMonthCumulative(downloadsRange); + const canvas = new Canvas(1000, 800); + const chart = new Chart(canvas, { + type: 'line', + data: { + labels: Object.keys(groupedDownloads), + datasets: [{ + label: metric.name, + data: Object.values(groupedDownloads), + backgroundColor: 'rgba(75, 192, 192, 0.2)', + borderColor: 'rgba(75, 192, 192, 1)', + borderWidth: 3, + fill: true, + tension: 0.1 + }] + }, + options: { + scales: { + x: { + time: { + unit: 'month', + displayFormats: { + month: 'MMM DD' + } + }, + title: { + display: true, + text: 'Date' + } + }, + y: { + title: { + display: true, + text: 'Downloads per month' + }, + } + } + } + }); + const svgBuffer = await canvas.toBuffer('svg', { matte: 'white' }); + writeFileSync(svgOutputPath, svgBuffer); + chart.destroy(); + return svgOutputPath; +} +export async function createNpmChart(platformMetrics, outputPath) { + const svgOutputPathList = []; + for (const metric of platformMetrics) { + const svgOutputPath = await createDownloadsPerMonthChart(metric, outputPath); + svgOutputPathList.push(svgOutputPath); + const svgOutputPathCumulative = await createCumulativeDownloadsChart(metric, outputPath); + svgOutputPathList.push(svgOutputPathCumulative); + } + return svgOutputPathList; +} +export async function addNpmDetails(summary, platformMetrics) { + const outputPath = './charts/npm'; + mkdirSync(outputPath, { recursive: true }); + const svgOutputPathList = await createNpmChart(platformMetrics, outputPath); + for (const svgOutputPath of svgOutputPathList) { + summary += `![${svgOutputPath}](${svgOutputPath})\n`; + } + return summary; +} diff --git a/dist/summaries/powershell.js b/dist/summaries/powershell.js new file mode 100644 index 0000000..581712d --- /dev/null +++ b/dist/summaries/powershell.js @@ -0,0 +1,238 @@ +import { mkdirSync, writeFileSync } from "fs"; +import { Chart, registerables } from 'chart.js'; +import { Canvas } from 'skia-canvas'; +// Register all Chart.js controllers +Chart.register(...registerables); +export function formatPowerShellSummary(summary, platformMetrics) { + let platformDownloadTotal = 0; + let totalVersions = 0; + summary += `| Module | Total Downloads | Latest Version | Version Downloads | Versions | Last Updated |\n`; + summary += `| --- | --- | --- | --- | --- | --- |\n`; + for (const metric of platformMetrics) { + const lastUpdated = metric.metrics?.lastUpdated ? new Date(metric.metrics.lastUpdated).toLocaleDateString() : 'N/A'; + const latestVersion = metric.metrics?.latestVersion || 'N/A'; + const latestVersionDownloads = metric.metrics?.latestVersionDownloads || 0; + const versionCount = metric.metrics?.versionCount || 0; + summary += `| ${metric.name} | ${metric.metrics?.downloadsTotal?.toLocaleString() || 0} | ${latestVersion} | ${latestVersionDownloads.toLocaleString()} | ${versionCount} | ${lastUpdated} |\n`; + platformDownloadTotal += metric.metrics?.downloadsTotal || 0; + totalVersions += versionCount; + } + summary += `| **Total** | **${platformDownloadTotal.toLocaleString()}** | | | **${totalVersions}** | |\n`; + return summary; +} +export async function addPowerShellDetails(summary, platformMetrics) { + summary += `#### PowerShell Module Details:\n\n`; + for (const metric of platformMetrics) { + summary += `**${metric.name}**:\n`; + summary += `- Total Downloads: ${metric.metrics?.downloadsTotal?.toLocaleString() || 0}\n`; + summary += `- Latest Version: ${metric.metrics?.latestVersion || 'N/A'}\n`; + summary += `- Latest Version Downloads: ${metric.metrics?.latestVersionDownloads?.toLocaleString() || 0}\n`; + summary += `- Version Count: ${metric.metrics?.versionCount || 0}\n`; + summary += `- Last Updated: ${metric.metrics?.lastUpdated ? new Date(metric.metrics.lastUpdated).toLocaleDateString() : 'N/A'}\n`; + summary += `- Package Size: ${metric.metrics?.packageSize ? `${Math.round(metric.metrics.packageSize / 1024)} KB` : 'N/A'}\n`; + summary += `\n`; + } + summary += `\n\n`; + const chartOutputPath = './charts/powershell'; + mkdirSync(chartOutputPath, { recursive: true }); + const svgOutputPathList = await createPowerShellCharts(platformMetrics, chartOutputPath); + for (const svgOutputPath of svgOutputPathList) { + summary += `![${svgOutputPath}](${svgOutputPath})\n`; + } + return summary; +} +export async function createPowerShellCharts(platformMetrics, outputPath) { + const svgOutputPathList = []; + // Only create charts if there's download data + const metricsWithData = platformMetrics.filter(metric => metric.metrics?.downloadsRange && metric.metrics.downloadsRange.length > 0); + if (metricsWithData.length > 0) { + const svgOutputPath = await createCombinedDownloadsChart(metricsWithData, outputPath); + svgOutputPathList.push(svgOutputPath); + const svgOutputPathCumulative = await createCombinedCumulativeDownloadsChart(metricsWithData, outputPath); + svgOutputPathList.push(svgOutputPathCumulative); + } + return svgOutputPathList; +} +// Color palette for different modules +const colors = [ + 'rgba(54, 162, 235, 0.8)', + 'rgba(255, 99, 132, 0.8)', + 'rgba(75, 192, 192, 0.8)', + 'rgba(255, 205, 86, 0.8)', + 'rgba(153, 102, 255, 0.8)', + 'rgba(255, 159, 64, 0.8)', + 'rgba(199, 199, 199, 0.8)', + 'rgba(83, 102, 255, 0.8)', + 'rgba(255, 99, 132, 0.8)', + 'rgba(54, 162, 235, 0.8)' +]; +const borderColors = [ + 'rgba(54, 162, 235, 1)', + 'rgba(255, 99, 132, 1)', + 'rgba(75, 192, 192, 1)', + 'rgba(255, 205, 86, 1)', + 'rgba(153, 102, 255, 1)', + 'rgba(255, 159, 64, 1)', + 'rgba(199, 199, 199, 1)', + 'rgba(83, 102, 255, 1)', + 'rgba(255, 99, 132, 1)', + 'rgba(54, 162, 235, 1)' +]; +export async function createCombinedDownloadsChart(metrics, outputPath) { + const svgOutputPath = `${outputPath}/powershell-combined-downloads.svg`; + // Get all unique dates across all modules for the x-axis + const allDates = new Set(); + for (const metric of metrics) { + const downloadsRange = metric.metrics?.downloadsRange || []; + for (const download of downloadsRange) { + allDates.add(download.day); + } + } + // Sort all dates chronologically + const sortedAllDates = Array.from(allDates).sort((a, b) => new Date(a).getTime() - new Date(b).getTime()); + // Create datasets for each module (one line per module) + const data = []; + for (const metric of metrics) { + const downloadsRange = metric.metrics?.downloadsRange || []; + for (const date of sortedAllDates) { + const downloads = downloadsRange.filter(d => d.day === date).reduce((sum, d) => sum + d.downloads, 0); + data.push(downloads); + } + } + const labels = sortedAllDates.map(date => new Date(date).toLocaleDateString('en-US', { + month: 'short', + year: '2-digit', + day: 'numeric' + })); + const canvas = new Canvas(1200, 800); + const chart = new Chart(canvas, { + type: 'line', + data: { + labels: labels, + datasets: [{ + data, + backgroundColor: 'rgba(54, 162, 235, 0.2)', + borderColor: 'rgba(54, 162, 235, 1)', + borderWidth: 3, + fill: true, + tension: 0.1 + }] + }, + options: { + responsive: true, + plugins: { + title: { + display: true, + text: 'PowerShell Modules - Downloads Over Time', + font: { + size: 16 + } + }, + legend: { + display: true + } + }, + scales: { + x: { + title: { + display: true, + text: 'Release Date' + } + }, + y: { + type: 'linear', + title: { + display: true, + text: 'Downloads' + }, + beginAtZero: true, + ticks: { + stepSize: 1000 + } + } + } + } + }); + const svgBuffer = await canvas.toBuffer('svg', { matte: 'white' }); + writeFileSync(svgOutputPath, svgBuffer); + chart.destroy(); + return svgOutputPath; +} +export async function createCombinedCumulativeDownloadsChart(metrics, outputPath) { + const svgOutputPath = `${outputPath}/powershell-cumulative-downloads.svg`; + // Get all unique dates across all modules for the x-axis + const allDates = new Set(); + for (const metric of metrics) { + const downloadsRange = metric.metrics?.downloadsRange || []; + for (const download of downloadsRange) { + allDates.add(download.day); + } + } + // Sort all dates chronologically + const sortedAllDates = Array.from(allDates).sort((a, b) => new Date(a).getTime() - new Date(b).getTime()); + const labels = sortedAllDates.map(date => new Date(date).toLocaleDateString('en-US', { + month: 'short', + year: '2-digit', + day: 'numeric' + })); + const data = []; + let runningTotal = 0; + for (const date of sortedAllDates) { + const downloads = metrics.reduce((sum, metric) => sum + (metric.metrics?.downloadsRange?.find(d => d.day === date)?.downloads || 0), 0); + runningTotal += downloads; + data.push(runningTotal); + } + const canvas = new Canvas(1200, 800); + const chart = new Chart(canvas, { + type: 'line', + data: { + labels: labels, + datasets: [{ + label: 'Cumulative Downloads', + data, + backgroundColor: 'rgba(54, 162, 235, 0.2)', + borderColor: 'rgba(54, 162, 235, 1)', + borderWidth: 3, + fill: true, + tension: 0.1 + }] + }, + options: { + responsive: true, + plugins: { + title: { + display: true, + text: 'PowerShell Modules - Cumulative Downloads', + font: { + size: 16 + } + }, + legend: { + display: true + } + }, + scales: { + x: { + title: { + display: true, + text: 'Release Date' + } + }, + y: { + title: { + display: true, + text: 'Cumulative Downloads' + }, + beginAtZero: true, + ticks: { + stepSize: 5000 + } + } + } + } + }); + const svgBuffer = await canvas.toBuffer('svg', { matte: 'white' }); + writeFileSync(svgOutputPath, svgBuffer); + chart.destroy(); + return svgOutputPath; +} diff --git a/dist/summaries/pypi.js b/dist/summaries/pypi.js new file mode 100644 index 0000000..416b875 --- /dev/null +++ b/dist/summaries/pypi.js @@ -0,0 +1,388 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { Chart, registerables } from 'chart.js'; +import { Canvas } from 'skia-canvas'; +Chart.register(...registerables); +export function formatPypiSummary(summary, platformMetrics) { + summary += `| Package | Total Downloads | Monthly Downloads | Weekly Downloads | Daily Downloads | Version |\n`; + summary += `| --- | --- | --- | --- | --- | --- |\n`; + for (const metric of platformMetrics) { + summary += `| ${metric.name} | ${metric.metrics?.downloadsTotal?.toLocaleString() || 0} | ${metric.metrics?.downloadsMonthly?.toLocaleString() || 0} | ${metric.metrics?.downloadsWeekly?.toLocaleString() || 0} | ${metric.metrics?.downloadsDaily?.toLocaleString() || 0} | ${metric.metrics?.version || 'N/A'} |\n`; + } + summary += `| **Total** | **${platformMetrics.reduce((sum, m) => sum + (m.metrics?.downloadsTotal || 0), 0).toLocaleString()}** | **${platformMetrics.reduce((sum, m) => sum + (m.metrics?.downloadsMonthly || 0), 0).toLocaleString()}** | **${platformMetrics.reduce((sum, m) => sum + (m.metrics?.downloadsWeekly || 0), 0).toLocaleString()}** | **${platformMetrics.reduce((sum, m) => sum + (m.metrics?.downloadsDaily || 0), 0).toLocaleString()}** | | |\n`; + return summary; +} +function toIsoMonth(dateStr) { + // input expected YYYY-MM-DD; fallback to Date parse if needed + const iso = dateStr?.slice(0, 7); + if (iso && /\d{4}-\d{2}/.test(iso)) + return iso; + const d = new Date(dateStr); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + return `${y}-${m}`; +} +function displayMonthLabel(isoMonth) { + const [y, m] = isoMonth.split('-'); + const d = new Date(Number(y), Number(m) - 1, 1); + return d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' }); +} +function aggregateMonthlyTotals(points) { + const totals = {}; + for (const p of points) { + const iso = toIsoMonth(p.date); + totals[iso] = (totals[iso] || 0) + p.downloads; + } + const labelsIso = Object.keys(totals).sort(); + const labels = labelsIso.map(displayMonthLabel); + const data = labelsIso.map(l => totals[l]); + return { labels, data }; +} +function aggregateMonthlyByCategory(points) { + const labelIsoSet = new Set(); + const categoryMap = {}; + for (const p of points) { + const iso = toIsoMonth(p.date); + labelIsoSet.add(iso); + if (!categoryMap[p.category]) + categoryMap[p.category] = {}; + categoryMap[p.category][iso] = (categoryMap[p.category][iso] || 0) + p.downloads; + } + const labelsIso = Array.from(labelIsoSet).sort(); + const labels = labelsIso.map(displayMonthLabel); + return { labelsIso, labels, categoryMap }; +} +async function createOverallDownloadsChart(metric, outputPath) { + // Prefer server-prepared chart JSON if present + const server = metric.metrics?.overallChart; + let labels; + let datasets; + if (server && server.labels && server.labels.length && server.datasets && server.datasets.length) { + labels = server.labels; + const colorFor = (label, idx) => { + const l = (label || '').toLowerCase(); + if (l.includes('without')) + return { stroke: '#2563eb', fill: '#2563eb33' }; // blue + if (l.includes('with')) + return { stroke: '#64748b', fill: '#64748b33' }; // slate + const palette = ['#2563eb', '#16a34a', '#f59e0b', '#ef4444', '#7c3aed']; + const i = idx ?? 0; + return { stroke: palette[i % palette.length], fill: palette[i % palette.length] + '33' }; + }; + datasets = server.datasets.map((ds, i) => { + const c = colorFor(ds.label, i); + return { + ...ds, + borderColor: c.stroke, + backgroundColor: c.fill, + borderWidth: 3, + fill: true, + tension: 0.1, + }; + }); + } + else { + const series = metric.metrics?.overallSeries || []; + const agg = aggregateMonthlyTotals(series.map(p => ({ date: p.date, downloads: p.downloads }))); + labels = agg.labels; + datasets = [{ + label: `${metric.name} downloads per month`, + data: agg.data, + backgroundColor: 'rgba(54, 162, 235, 0.2)', + borderColor: 'rgba(54, 162, 235, 1)', + borderWidth: 3, + fill: true, + tension: 0.1 + }]; + } + const canvas = new Canvas(1000, 800); + const chart = new Chart(canvas, { + type: 'line', + data: { labels, datasets }, + options: { + plugins: { + legend: { display: true, position: 'bottom' }, + title: { display: true, text: `${metric.name} overall downloads` } + }, + scales: { + x: { title: { display: true, text: 'Month' } }, + y: { title: { display: true, text: 'Downloads' } } + } + } + }); + const svgBuffer = await canvas.toBuffer('svg', { matte: 'white' }); + const svgPath = `${outputPath}/${metric.name}-pypi-overall.svg`; + writeFileSync(svgPath, svgBuffer); + chart.destroy(); + return svgPath; +} +// Time-series: Python major over time (line) +async function createPythonMajorChart(metric, outputPath) { + // Prefer server chart JSON if present + const server = metric.metrics?.pythonMajorChart; + let labels; + let datasets; + if (server && server.labels && server.labels.length && server.datasets && server.datasets.length) { + const palette = ['#2563eb', '#16a34a', '#f59e0b', '#ef4444', '#7c3aed', '#0891b2', '#dc2626', '#0ea5e9']; + labels = server.labels; + datasets = server.datasets + .filter(ds => !/unknown/i.test(ds.label)) + .map((ds, idx) => ({ + ...ds, + borderColor: palette[idx % palette.length], + backgroundColor: palette[idx % palette.length] + '33', + borderWidth: 2, + fill: false, + })); + } + else { + const points = metric.metrics?.pythonMajorSeries || []; + const { labelsIso, labels: lbls, categoryMap } = aggregateMonthlyByCategory(points); + labels = lbls; + const sortedCategories = Object.keys(categoryMap).filter(k => !/unknown/i.test(k)).sort((a, b) => Number(a) - Number(b)); + const palette = ['#2563eb', '#16a34a', '#f59e0b', '#ef4444', '#7c3aed', '#0891b2', '#dc2626', '#0ea5e9']; + datasets = sortedCategories.map((category, idx) => ({ + label: `Python ${category}`, + data: labelsIso.map(l => categoryMap[category][l] || 0), + borderColor: palette[idx % palette.length], + backgroundColor: palette[idx % palette.length] + '33', + borderWidth: 2, + fill: false, + })); + } + const canvas = new Canvas(1000, 800); + const chart = new Chart(canvas, { + type: 'line', + data: { labels, datasets }, + options: { + plugins: { + legend: { position: 'bottom' }, + title: { display: true, text: `${metric.name} downloads by Python major version` } + }, + scales: { + x: { title: { display: true, text: 'Month' } }, + y: { title: { display: true, text: 'Downloads' } } + } + } + }); + const svgBuffer = await canvas.toBuffer('svg', { matte: 'white' }); + const svgPath = `${outputPath}/${metric.name}-pypi-python-major.svg`; + writeFileSync(svgPath, svgBuffer); + chart.destroy(); + return svgPath; +} +// Time-series: Python minor over time (line) +async function createPythonMinorChart(metric, outputPath) { + // Prefer server chart JSON if present + const server = metric.metrics?.pythonMinorChart; + let labels; + let datasets; + if (server && server.labels && server.labels.length && server.datasets && server.datasets.length) { + const palette = ['#1d4ed8', '#059669', '#d97706', '#dc2626', '#6d28d9', '#0e7490', '#b91c1c', '#0284c7']; + labels = server.labels; + datasets = server.datasets + .filter(ds => !/unknown/i.test(ds.label)) + .map((ds, idx) => ({ + ...ds, + borderColor: palette[idx % palette.length], + backgroundColor: palette[idx % palette.length] + '33', + borderWidth: 2, + fill: false, + })); + } + else { + const points = metric.metrics?.pythonMinorSeries || []; + const { labelsIso, labels: lbls, categoryMap } = aggregateMonthlyByCategory(points); + labels = lbls; + const sortedCategories = Object.keys(categoryMap).filter(k => !/unknown/i.test(k)).sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); + const palette = ['#1d4ed8', '#059669', '#d97706', '#dc2626', '#6d28d9', '#0e7490', '#b91c1c', '#0284c7']; + datasets = sortedCategories.map((category, idx) => ({ + label: `Python ${category}`, + data: labelsIso.map(l => categoryMap[category][l] || 0), + borderColor: palette[idx % palette.length], + backgroundColor: palette[idx % palette.length] + '33', + borderWidth: 2, + fill: false, + })); + } + const canvas = new Canvas(1000, 800); + const chart = new Chart(canvas, { + type: 'line', + data: { labels, datasets }, + options: { + plugins: { + legend: { position: 'bottom' }, + title: { display: true, text: `${metric.name} downloads by Python minor version` } + }, + scales: { + x: { title: { display: true, text: 'Month' } }, + y: { title: { display: true, text: 'Downloads' } } + } + } + }); + const svgBuffer = await canvas.toBuffer('svg', { matte: 'white' }); + const svgPath = `${outputPath}/${metric.name}-pypi-python-minor.svg`; + writeFileSync(svgPath, svgBuffer); + chart.destroy(); + return svgPath; +} +// Time-series: Installer over time (line) - prefer server JSON +async function createInstallerChart(metric, outputPath) { + const server = metric.metrics?.installerChart; + let labels; + let datasets; + if (server && server.labels && server.labels.length && server.datasets && server.datasets.length) { + const palette = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#a78bfa', '#22d3ee']; + labels = server.labels; + datasets = server.datasets.map((ds, idx) => ({ + ...ds, + borderColor: palette[idx % palette.length], + backgroundColor: palette[idx % palette.length] + '33', + borderWidth: 2, + fill: false, + })); + } + else { + const points = metric.metrics?.installerSeries || []; + const { labelsIso, labels: lbls, categoryMap } = aggregateMonthlyByCategory(points); + labels = lbls; + const categories = Object.keys(categoryMap); + const palette = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#a78bfa', '#22d3ee']; + datasets = categories.map((category, idx) => ({ + label: category, + data: labelsIso.map(l => categoryMap[category][l] || 0), + borderColor: palette[idx % palette.length], + backgroundColor: palette[idx % palette.length] + '33', + borderWidth: 2, + fill: false, + })); + } + const canvas = new Canvas(1000, 800); + const chart = new Chart(canvas, { + type: 'line', + data: { labels, datasets }, + options: { + plugins: { + legend: { position: 'bottom' }, + title: { display: true, text: `${metric.name} downloads by installer` } + }, + scales: { + x: { title: { display: true, text: 'Month' } }, + y: { title: { display: true, text: 'Downloads' } } + } + } + }); + const svgBuffer = await canvas.toBuffer('svg', { matte: 'white' }); + const svgPath = `${outputPath}/${metric.name}-pypi-installer.svg`; + writeFileSync(svgPath, svgBuffer); + chart.destroy(); + return svgPath; +} +// Time-series: System over time (line) - prefer server JSON +async function createSystemChart(metric, outputPath) { + const server = metric.metrics?.systemChart; + let labels; + let datasets; + if (server && server.labels && server.labels.length && server.datasets && server.datasets.length) { + const palette = ['#0ea5e9', '#22c55e', '#f97316', '#e11d48', '#8b5cf6', '#06b6d4']; + labels = server.labels; + datasets = server.datasets.map((ds, idx) => ({ + ...ds, + borderColor: palette[idx % palette.length], + backgroundColor: palette[idx % palette.length] + '33', + borderWidth: 2, + fill: false, + })); + } + else { + const points = metric.metrics?.systemSeries || []; + const { labelsIso, labels: lbls, categoryMap } = aggregateMonthlyByCategory(points); + labels = lbls; + const sortedCategories = Object.keys(categoryMap).sort(); + const palette = ['#0ea5e9', '#22c55e', '#f97316', '#e11d48', '#8b5cf6', '#06b6d4']; + datasets = sortedCategories.map((category, idx) => ({ + label: category, + data: labelsIso.map(l => categoryMap[category][l] || 0), + borderColor: palette[idx % palette.length], + backgroundColor: palette[idx % palette.length] + '33', + borderWidth: 2, + fill: false, + })); + } + const canvas = new Canvas(1000, 800); + const chart = new Chart(canvas, { + type: 'line', + data: { labels, datasets }, + options: { + plugins: { + legend: { position: 'bottom' }, + title: { display: true, text: `${metric.name} downloads by OS` } + }, + scales: { + x: { title: { display: true, text: 'Month' } }, + y: { title: { display: true, text: 'Downloads' } } + } + } + }); + const svgBuffer = await canvas.toBuffer('svg', { matte: 'white' }); + const svgPath = `${outputPath}/${metric.name}-pypi-system.svg`; + writeFileSync(svgPath, svgBuffer); + chart.destroy(); + return svgPath; +} +// Removed static bar chart generators per request +async function createPypiCharts(metrics, basePath) { + const outputPaths = []; + for (const metric of metrics) { + const packagePath = `${basePath}`; + mkdirSync(packagePath, { recursive: true }); + const overall = await createOverallDownloadsChart(metric, packagePath); + outputPaths.push(overall); + const pythonMajor = await createPythonMajorChart(metric, packagePath); + outputPaths.push(pythonMajor); + const pythonMinor = await createPythonMinorChart(metric, packagePath); + outputPaths.push(pythonMinor); + const installer = await createInstallerChart(metric, packagePath); + outputPaths.push(installer); + const system = await createSystemChart(metric, packagePath); + outputPaths.push(system); + // static bar charts removed + } + return outputPaths; +} +export function addPypiDetails(summary, metrics) { + summary += `#### Package Details:\n\n`; + for (const metric of metrics) { + summary += `**${metric.name}**:\n`; + summary += `- Version: ${metric.metrics?.version || 'N/A'}\n`; + if (metric.metrics?.latestReleaseDate) + summary += `- Released: ${metric.metrics.latestReleaseDate}\n`; + if (metric.metrics?.popularSystem) + summary += `- Popular system: ${metric.metrics.popularSystem}\n`; + if (metric.metrics?.popularInstaller) + summary += `- Popular installer: ${metric.metrics.popularInstaller}\n`; + summary += `- Releases: ${metric.metrics?.releases || 0}\n`; + if (metric.metrics?.systemBreakdown) { + summary += `- OS Usage Breakdown \n`; + for (const [key, value] of Object.entries(metric.metrics?.systemBreakdown)) { + summary += ` - ${key}: ${value}\n`; + } + } + if (metric.metrics?.pythonVersionBreakdown) { + summary += `- Python Version Breakdown \n`; + for (const [key, value] of Object.entries(metric.metrics?.pythonVersionBreakdown)) { + summary += ` - ${key}: ${value}\n`; + } + } + } + return summary; +} +export async function addPypiCharts(summary, platformMetrics) { + const outputPath = './charts/pypi'; + mkdirSync(outputPath, { recursive: true }); + summary += `\n\n`; + const svgPaths = await createPypiCharts(platformMetrics, outputPath); + for (const p of svgPaths) { + summary += `![${p}](${p})\n`; + } + return summary; +} diff --git a/dist/utils.js b/dist/utils.js new file mode 100644 index 0000000..396f7d3 --- /dev/null +++ b/dist/utils.js @@ -0,0 +1,123 @@ +import * as core from '@actions/core'; +import { CategoryScale, Chart, LinearScale, LineController, LineElement, PointElement, BarController, BarElement } from 'chart.js'; +import { readFile, writeFile } from 'fs/promises'; +import { addRepoDetails, formatGitHubSummary } from './summaries/github.js'; +import { addNpmDetails, formatNpmSummary } from './summaries/npm.js'; +import { formatPowerShellSummary, addPowerShellDetails } from './summaries/powershell.js'; +import { addPypiDetails, addPypiCharts, formatPypiSummary } from './summaries/pypi.js'; +Chart.register([ + CategoryScale, + LineController, + LineElement, + LinearScale, + PointElement, + BarController, + BarElement +]); +/** + * Parse comma-separated inputs into arrays + * @param input - The input string to parse + * @returns An array of trimmed, non-empty items + */ +function parseCommaSeparatedInputs(input) { + return input ? input.split(',').map(item => item.trim()).filter(item => item) : []; +} +export function getInputs() { + // Get all inputs from action.yml + const npmPackages = core.getInput('npm-packages'); + const githubRepositories = core.getInput('github-repositories'); + const pypiPackages = core.getInput('pypi-packages'); + const powershellModules = core.getInput('powershell-modules'); + const jsonOutputPath = core.getInput('json-output-path'); + const updateReadme = core.getBooleanInput('update-readme'); + const commitMessage = core.getInput('commit-message'); + const readmePath = core.getInput('readme-path'); + return { + npmPackages: parseCommaSeparatedInputs(npmPackages), + githubRepositories: parseCommaSeparatedInputs(githubRepositories), + pypiPackages: parseCommaSeparatedInputs(pypiPackages), + powershellModules: parseCommaSeparatedInputs(powershellModules), + jsonOutputPath, + updateReadme, + commitMessage, + readmePath, + }; +} +const MetricsPlaceHolderRegex = /[\s\S]*/; +function formatSummary(summary) { + return `\n${summary}\n`; +} +const PlatformMap = { + "NPM": "JavaScript/TypeScript", + "PyPI": "Python", + "PowerShell Gallery": undefined, + "GitHub": undefined, +}; +export async function createSummary(metrics) { + const platforms = metrics.map(metric => metric.platform).filter((value, index, self) => self.indexOf(value) === index); + console.log(platforms); + console.log(metrics); + let summary = `# Usage Statistics + +Last updated: ${new Date().toLocaleString()} + +Below are stats from artifacts tracked across ${platforms.slice(0, -1).join(', ')} and ${platforms.slice(-1)}. + +`; + for (const platform of platforms) { + const platformMetrics = metrics.filter(metric => metric.platform === platform); + const platformLanguage = PlatformMap[platform]; + summary += `### ${platform}${platformLanguage ? ` (${platformLanguage})` : ''}: \n\n`; + switch (platform) { + case "NPM": + summary = formatNpmSummary(summary, platformMetrics); + break; + case "GitHub": + summary = formatGitHubSummary(summary, platformMetrics); + break; + case "PyPI": + summary = formatPypiSummary(summary, platformMetrics); + break; + case "PowerShell": + summary = formatPowerShellSummary(summary, platformMetrics); + break; + default: + let platformDownloadTotal = 0; + summary += `| Package | Downloads |\n`; + summary += `| --- | --- |\n`; + for (const metric of platformMetrics) { + summary += `| ${metric.name} | ${metric.metrics?.downloadCount?.toLocaleString() || 0} |\n`; + platformDownloadTotal += metric.metrics?.downloadCount || 0; + } + summary += `| **Total** | **${platformDownloadTotal.toLocaleString()}** |\n`; + break; + } + summary += `\n`; + // Add detailed information for each platform + switch (platform) { + case "GitHub": + summary = await addRepoDetails(summary, platformMetrics); + break; + case "PyPI": + summary = addPypiDetails(summary, platformMetrics); + summary = await addPypiCharts(summary, platformMetrics); + break; + case "NPM": + summary = await addNpmDetails(summary, platformMetrics); + break; + case "PowerShell": + summary = await addPowerShellDetails(summary, platformMetrics); + break; + default: + break; + } + summary += '\n'; + } + return summary; +} +export async function updateRepositoryReadme(metrics, readmePath) { + const currentReadme = await readFile(readmePath, 'utf8'); + const summary = await createSummary(metrics); + const updatedReadme = currentReadme.replace(MetricsPlaceHolderRegex, formatSummary(summary)); + await writeFile(readmePath, updatedReadme); +}