import { all, first } from "./db"; const DAY_MS = 24 * 60 * 60 * 1000; const GRAPH_WEEKS = 52; const WEEK_DAYS = 7; const MAX_DAY_REPOS = 3; const MIN_GRAPH_YEAR = 1970; /** * What a viewer may see of private repositories: `full` for the account itself, `counts` for visitors * when the user opted in (numbers only, never repo names or titles), `none` otherwise. */ export type PrivateMode = "full" | "counts" | "none"; export type ContributionBreakdown = { commits: number; pulls: number; issues: number; reviews: number; other: number; /** Anonymized contributions to private repositories (`counts` mode only). */ private: number; }; export type ContributionDay = { date: string; count: number; breakdown: ContributionBreakdown | null; repos: string[]; }; type Category = Exclude; /** Verified addresses of a user (bound as ?1); commits authored with any of them count as theirs. */ export const VERIFIED_EMAILS = `SELECT email FROM users WHERE account_id = ?1 AND email_verified_at IS NOT NULL UNION SELECT email FROM user_emails WHERE user_id = ?1 AND verified_at IS NOT NULL`; /** Activity kinds that count as contributions; pushes are counted per commit from the commit index instead. */ export const COUNTED_ACTIVITY = "ac.kind NOT IN ('view', 'push')"; export function categoryOf(kind: string): Category { if (kind === "commit") return "commits"; if (kind.startsWith("pull_")) return "pulls"; if (kind.startsWith("issue_")) return "issues"; if (kind === "comment" || kind.startsWith("review")) return "reviews"; return "other"; } /** Whether `viewerId` may see private contributions of `accountId`, and how. */ export async function privateModeFor(accountId: string, viewerId: string | null): Promise { if (viewerId === accountId) return "full"; const row = await first<{ show: number }>( "SELECT show_private_contributions AS show FROM users WHERE account_id = ?", accountId, ); return row?.show === 1 ? "counts" : "none"; } function graphRange(year: number | null, now: number): { start: number; end: number } { if (year === null) { const today = new Date(now); const midnight = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()); return { start: midnight - (GRAPH_WEEKS * WEEK_DAYS + today.getUTCDay()) * DAY_MS, end: now }; } const firstDay = Date.UTC(year, 0, 1); const last = Math.min(Date.UTC(year + 1, 0, 1) - 1, now); return { start: firstDay - new Date(firstDay).getUTCDay() * DAY_MS, end: last }; } type GraphRow = { day: string; kind: string; repo: string; private: number; events: number }; function emptyBreakdown(): ContributionBreakdown { return { commits: 0, pulls: 0, issues: 0, reviews: 0, other: 0, private: 0 }; } /** * Daily contributions for the last year or one calendar `year`, oldest first, aligned to whole weeks, with * a per-day breakdown for hover cards. Commits come from each repo's default-branch index, attributed by * author email to the user's verified addresses at query time (so email changes apply immediately and * pushes by someone else still credit the author); forks don't count. Everything else is the user's own * activity. Private repositories follow `mode`. */ export async function contributionGraph( accountId: string, mode: PrivateMode, year: number | null = null, ): Promise { const now = Date.now(); const range = graphRange(year, now); const startDay = new Date(range.start).toISOString().slice(0, 10); const endDay = new Date(range.end).toISOString().slice(0, 10); const includePrivate = mode === "none" ? 0 : 1; const [activity, commits] = await Promise.all([ all( `SELECT strftime('%Y-%m-%d', ac.created_at / 1000, 'unixepoch') AS day, ac.kind, a.name || '/' || r.name AS repo, r.visibility != 'public' AS private, COUNT(*) AS events 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 = ?1 AND ac.created_at >= ?2 AND ac.created_at <= ?3 AND ${COUNTED_ACTIVITY} AND (?4 = 1 OR r.visibility = 'public') GROUP BY day, ac.kind, repo`, accountId, range.start, range.end, includePrivate, ), all( `SELECT d.day, 'commit' AS kind, a.name || '/' || r.name AS repo, r.visibility != 'public' AS private, SUM(d.commits) AS events FROM commit_email_days d JOIN repos r ON r.id = d.repo_id JOIN accounts a ON a.id = r.owner_id WHERE d.email IN (${VERIFIED_EMAILS}) AND d.day >= ?2 AND d.day <= ?3 AND r.fork_of_id IS NULL AND r.status = 'ready' AND (?4 = 1 OR r.visibility = 'public') GROUP BY d.day, repo`, accountId, startDay, endDay, includePrivate, ), ]); const byDay = new Map }>(); for (const row of [...activity, ...commits]) { const entry = byDay.get(row.day) ?? { count: 0, breakdown: emptyBreakdown(), repoEvents: new Map() }; entry.count += row.events; if (row.private === 1 && mode === "counts") { entry.breakdown.private += row.events; } else { entry.breakdown[categoryOf(row.kind)] += 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 = range.start; time <= range.end; 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; } /** * Calendar years, newest first, from the earliest contribution through this year. Commit history may be * backfilled to any date, so this is driven by the data rather than the account's age. */ export async function contributionYears(accountId: string, mode: PrivateMode): Promise { const row = await first<{ commit_day: string | null; activity_at: number | null }>( `SELECT (SELECT MIN(d.day) FROM commit_email_days d JOIN repos r ON r.id = d.repo_id WHERE d.email IN (${VERIFIED_EMAILS}) AND r.fork_of_id IS NULL AND r.status = 'ready' AND (?2 = 1 OR r.visibility = 'public')) AS commit_day, (SELECT MIN(ac.created_at) FROM activity ac JOIN repos r ON r.id = ac.repo_id WHERE ac.actor_id = ?1 AND ${COUNTED_ACTIVITY} AND (?2 = 1 OR r.visibility = 'public')) AS activity_at`, accountId, mode === "none" ? 0 : 1, ); const current = new Date().getUTCFullYear(); const candidates = [ row?.commit_day ? Number(row.commit_day.slice(0, 4)) : current, row?.activity_at ? new Date(row.activity_at).getUTCFullYear() : current, ]; const earliest = Math.max(MIN_GRAPH_YEAR, Math.min(current, ...candidates)); return Array.from({ length: current - earliest + 1 }, (_, index) => current - index); }