Repository Public MIT Selected
1/**2 * ─────────────────────────────────────────────3 * SPB Git — Personal Git Platform4 * ─────────────────────────────────────────────5 * Author : Simon-Pierre Boucher6 * Contact : contact@spboucher.ai7 * File : src/insights/insights.mjs8 * Purpose : Repository insights — 52-week activity, contributors, busiest paths, declared dependencies (from manifests)9 * License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { bucketByDay, buildCalendar, calendarSvg } from '../stats/activity.mjs';1415const MANIFEST_BYTES = 512 * 1024;1617/** Parse dependency manifests we can read without executing anything. */18export function parseDependencies(path, text) {19 const base = path.slice(path.lastIndexOf('/') + 1).toLowerCase();20 const deps = [];21 const push = (name, version, kind) => { if (name) deps.push({ name: String(name).trim(), version: version ? String(version).trim() : '', kind }); };22 try {23 if (base === 'package.json') {24 const j = JSON.parse(text);25 for (const [k, v] of Object.entries(j.dependencies ?? {})) push(k, v, 'runtime');26 for (const [k, v] of Object.entries(j.devDependencies ?? {})) push(k, v, 'dev');27 return { ecosystem: 'npm', registry: 'https://www.npmjs.com/package/', deps };28 }29 if (base === 'requirements.txt' || /^requirements[-_.].*\.txt$/.test(base)) {30 for (const raw of text.split('\n')) {31 const line = raw.replace(/#.*$/, '').trim();32 if (!line || line.startsWith('-')) continue;33 const m = /^([A-Za-z0-9_.[\]-]+)\s*(.*)$/.exec(line);34 if (m) push(m[1].replace(/\[.*\]$/, ''), m[2], 'runtime');35 }36 return { ecosystem: 'PyPI', registry: 'https://pypi.org/project/', deps };37 }38 if (base === 'pyproject.toml') {39 const section = /\[project\][\s\S]*?dependencies\s*=\s*\[([\s\S]*?)\]/.exec(text) ?? /\[tool\.poetry\.dependencies\]([\s\S]*?)(\n\[|$)/.exec(text);40 if (section) {41 for (const m of section[1].matchAll(/"([A-Za-z0-9_.-]+)([^"]*)"/g)) push(m[1], m[2], 'runtime');42 for (const m of section[1].matchAll(/^\s*([A-Za-z0-9_.-]+)\s*=\s*"?([^"\n]*)"?/gm)) if (m[1] !== 'python') push(m[1], m[2], 'runtime');43 }44 return { ecosystem: 'PyPI', registry: 'https://pypi.org/project/', deps };45 }46 if (base === 'cargo.toml') {47 const section = /\[dependencies\]([\s\S]*?)(\n\[|$)/.exec(text);48 if (section) for (const m of section[1].matchAll(/^\s*([A-Za-z0-9_-]+)\s*=\s*(?:"([^"]*)"|\{[^}]*version\s*=\s*"([^"]*)")/gm)) push(m[1], m[2] ?? m[3], 'runtime');49 return { ecosystem: 'crates.io', registry: 'https://crates.io/crates/', deps };50 }51 if (base === 'go.mod') {52 for (const m of text.matchAll(/^\s*([^\s]+\/[^\s]+)\s+(v[^\s]+)(?:\s*\/\/\s*indirect)?$/gm)) push(m[1], m[2], /indirect/.test(m[0]) ? 'indirect' : 'runtime');53 return { ecosystem: 'Go', registry: 'https://pkg.go.dev/', deps };54 }55 if (base === 'package.swift') {56 for (const m of text.matchAll(/\.package\(\s*url:\s*"([^"]+)"/g)) push(m[1].replace(/^https?:\/\//, ''), '', 'runtime');57 return { ecosystem: 'Swift PM', registry: 'https://', deps };58 }59 } catch {60 /* malformed manifest → no deps */61 }62 return null;63}6465const MANIFEST_RE = /(^|\/)(package\.json|requirements(?:[-_.][^/]*)?\.txt|pyproject\.toml|Cargo\.toml|go\.mod|Package\.swift)$/;6667/**68 * Compute (uncached) insights for a repository at HEAD.69 * @param {{repos: object}} ctx70 */71export async function repoInsights(ctx, name, sha) {72 const timestamps = await ctx.repos.commitTimestamps(name, sha);73 const calendar = buildCalendar(bucketByDay(timestamps));74 const authorsOut = await ctx.repos.tryGit(name, ['shortlog', '-sne', '--no-merges', sha]);75 const authors = (authorsOut ?? '').split('\n').filter(Boolean).map((line) => {76 const m = /^\s*(\d+)\s+(.*?)\s+<([^>]*)>\s*$/.exec(line);77 return m ? { commits: Number(m[1]), name: m[2], email: m[3] } : null;78 }).filter(Boolean).slice(0, 12);79 const pathsOut = await ctx.repos.tryGit(name, ['log', '--format=', '--name-only', '--max-count=400', sha]);80 const pathCounts = new Map();81 for (const line of (pathsOut ?? '').split('\n')) {82 const p = line.trim();83 if (!p) continue;84 pathCounts.set(p, (pathCounts.get(p) ?? 0) + 1);85 }86 const hotPaths = [...pathCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([path, changes]) => ({ path, changes }));87 const files = await ctx.repos.allFiles(name, sha);88 const manifests = [];89 for (const f of files) {90 if (!MANIFEST_RE.test(f.path) || /(^|\/)node_modules\//.test(f.path) || f.size > MANIFEST_BYTES) continue;91 if (manifests.length >= 8) break;92 const blob = await ctx.repos.blob(name, sha, f.path, { maxBytes: MANIFEST_BYTES });93 if (!blob?.content) continue;94 const parsed = parseDependencies(f.path, blob.content.toString('utf8'));95 if (parsed && parsed.deps.length) manifests.push({ path: f.path, ...parsed, deps: parsed.deps.slice(0, 80) });96 }97 const firstCommit = timestamps.length ? new Date(Math.min(...timestamps) * 1000).toISOString() : null;98 return {99 calendar: { svg: calendarSvg(calendar), total: calendar.total, streak: calendar.streak, longest: calendar.longest, activeDays: calendar.activeDays, busiest: calendar.busiest },100 commitsAllTime: timestamps.length,101 firstCommit,102 authors,103 hotPaths,104 manifests,105 fileCount: files.length,106 totalBytes: files.reduce((n, f) => n + (f.size || 0), 0),107 };108}109