SPB Git

Repository Public MIT Selected

insights.mjs 5.6 KB 109 lines javascript

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
Files main
Menu

Theme