import { env } from "cloudflare:workers"; import { all, first } from "./db"; import { branchSha, listRefs } from "./git/refs"; import { withStore } from "./git/store"; import { renderMarkdown } from "./markdown"; import { REPO_SELECT } from "./repos"; import type { RepoRow } from "~/lib/types"; const DAY_MS = 24 * 60 * 60 * 1000; const GRAPH_WEEKS = 52; const WEEK_DAYS = 7; const README_NAMES = new Set(["readme.md", "readme.markdown"]); const MAX_README_BYTES = 256 * 1024; const README_CACHE_TTL_SECONDS = 60 * 60 * 24 * 7; const decoder = new TextDecoder(); export type ContributionBreakdown = { commits: number; pulls: number; issues: number; reviews: number; other: number }; export type ContributionDay = { date: string; count: number; breakdown: ContributionBreakdown | null; repos: string[]; }; const MAX_DAY_REPOS = 3; type Category = keyof ContributionBreakdown; function categoryOf(kind: string): Category { if (kind === "push" || kind === "ref_created") return "commits"; if (kind.startsWith("pull_")) return "pulls"; if (kind.startsWith("issue_")) return "issues"; if (kind === "comment" || kind.startsWith("review")) return "reviews"; return "other"; } /** Daily repository activity counts (public repos unless `includePrivate`) for roughly the last year, oldest first, aligned to whole weeks, with a per-day breakdown for hover cards. */ export async function contributionGraph(accountId: string, includePrivate: boolean): Promise { const now = Date.now(); const today = new Date(now); const midnight = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()); const start = new Date(midnight - (GRAPH_WEEKS * WEEK_DAYS + today.getUTCDay()) * DAY_MS); const rows = await all<{ day: string; kind: string; repo: string; events: number; commits: number | null }>( `SELECT strftime('%Y-%m-%d', ac.created_at / 1000, 'unixepoch') AS day, ac.kind, a.name || '/' || r.name AS repo, COUNT(*) AS events, SUM(CASE WHEN ac.kind = 'push' THEN COALESCE(json_extract(ac.data, '$.total'), 1) ELSE 0 END) AS commits FROM activity ac JOIN repos r ON r.id = ac.repo_id JOIN accounts a ON a.id = r.owner_id WHERE ac.actor_id = ? AND ac.created_at >= ? AND ac.kind != 'view' AND (? = 1 OR r.visibility = 'public') GROUP BY day, ac.kind, repo`, accountId, start.getTime(), includePrivate ? 1 : 0, ); const byDay = new Map }>(); for (const row of rows) { const entry = byDay.get(row.day) ?? { count: 0, breakdown: { commits: 0, pulls: 0, issues: 0, reviews: 0, other: 0 }, repoEvents: new Map(), }; const category = categoryOf(row.kind); entry.count += row.events; entry.breakdown[category] += category === "commits" && row.kind === "push" ? (row.commits ?? row.events) : row.events; entry.repoEvents.set(row.repo, (entry.repoEvents.get(row.repo) ?? 0) + row.events); byDay.set(row.day, entry); } const days: ContributionDay[] = []; for (let time = start.getTime(); time <= now; time += DAY_MS) { const date = new Date(time).toISOString().slice(0, 10); const entry = byDay.get(date); days.push({ date, count: entry?.count ?? 0, breakdown: entry?.breakdown ?? null, repos: entry ? [...entry.repoEvents.entries()] .sort((left, right) => right[1] - left[1]) .slice(0, MAX_DAY_REPOS) .map(([name]) => name) : [], }); } return days; } /** Renders README.md from the public `/` repo, cached in KV by commit. */ export async function profileReadme(accountName: string): Promise { const repo = await first( `${REPO_SELECT} WHERE a.name = ?1 AND r.name = ?1 AND r.visibility = 'public' AND r.status = 'ready'`, accountName, ); if (!repo) return null; const refs = await listRefs(repo); const sha = branchSha(refs, repo.default_branch); if (!sha) return null; const cacheKey = `profile-readme:${repo.id}:${sha}`; const cached = await env.KV.get(cacheKey); if (cached !== null) return cached || null; const html = await withStore(repo.artifacts_name, async (store) => { const commit = await store.commit(sha); if (!commit) return ""; const entries = await store.listTree(commit.treeSha, ""); const entry = entries.find( (candidate) => candidate.type !== "tree" && README_NAMES.has(candidate.name.toLowerCase()), ); if (!entry) return ""; const bytes = await store.blob(entry.sha); if (!bytes || bytes.byteLength > MAX_README_BYTES) return ""; return renderMarkdown(decoder.decode(bytes), { owner: repo.owner_name, repo: repo.name, ref: repo.default_branch, basePath: "", }); }); await env.KV.put(cacheKey, html, { expirationTtl: README_CACHE_TTL_SECONDS }); return html || null; }