import { all, first } from "./db"; import { repoDb } from "./scoped-db"; import { NOT_TAKEN_DOWN_SQL, repoReadableSql } from "./repos"; import { activityRepoIds, filterRepoIds } from "./activity-index"; 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`; /** * Repositories (aliased `r`) whose contributions may count for the account bound as `accountExpr`: public ones, and * private ones only while the account can still read them. Commit authorship alone (an imported history, a * revoked collaborator) never reveals a private repository's name, commits or activity, not even as a count. */ export function countableRepoSql(accountExpr: string): string { return `(r.hidden_at IS NULL AND ${NOT_TAKEN_DOWN_SQL} AND (r.visibility = 'public' OR ${repoReadableSql("r", accountExpr)}))`; } /** 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 type ActivityRow = { id: string; kind: string; data: string; created_at: number; owner: string; name: string; private: number; }; /** The repositories the account's activity index lists (for `range`), that `includePrivate` and access allow. */ async function activityRepos(accountId: string, range: { start: number; end: number } | null, includePrivate: boolean) { const candidates = await activityRepoIds(accountId, range); const privacy = includePrivate ? "1 = 1" : "r.visibility = 'public'"; return filterRepoIds(candidates, `${privacy} AND ${countableRepoSql("?1")}`, accountId); } /** * A user's counted activity in `[start, end)` (each repository keeps its own `activity` table, and the account's * activity index says which to ask), with the repository's owner, name and whether it is private. * `includePrivate` false leaves private repositories out. */ export async function actorActivity( accountId: string, range: { start: number; end: number }, includePrivate: boolean, ): Promise { const repos = await activityRepos(accountId, range, includePrivate); const perRepo = await Promise.all( repos.map(async (repo) => { const rows = await repoDb(repo.id).all<{ id: string; kind: string; data: string; created_at: number; }>( `SELECT ac.id, ac.kind, ac.data, ac.created_at FROM activity ac WHERE ac.actor_id = ? AND ac.created_at >= ? AND ac.created_at < ? AND ${COUNTED_ACTIVITY}`, accountId, range.start, range.end, ); return rows.map((row) => ({ ...row, owner: repo.owner, name: repo.name, private: repo.private, })); }), ); return perRepo.flat().sort((left, right) => left.created_at - right.created_at || left.id.localeCompare(right.id)); } function dayOf(time: number): string { return new Date(time).toISOString().slice(0, 10); } 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 [events, commits] = await Promise.all([ actorActivity(accountId, { start: range.start, end: range.end + 1 }, includePrivate === 1), 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') AND ${countableRepoSql("?1")} GROUP BY d.day, repo`, accountId, startDay, endDay, includePrivate, ), ]); const activity: GraphRow[] = events.map((row) => ({ day: dayOf(row.created_at), kind: row.kind, repo: `${row.owner}/${row.name}`, private: row.private, events: 1, })); const byDay = new Map< string, { count: number; breakdown: ContributionBreakdown; repoEvents: 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 includePrivate = mode === "none" ? 0 : 1; const [commitRow, repos] = await Promise.all([ first<{ commit_day: string | null }>( `SELECT MIN(d.day) AS commit_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') AND ${countableRepoSql("?1")}`, accountId, includePrivate, ), activityRepos(accountId, null, includePrivate === 1), ]); const earliestActivity = await Promise.all( repos.map((repo) => repoDb(repo.id).first<{ activity_at: number | null }>( `SELECT MIN(ac.created_at) AS activity_at FROM activity ac WHERE ac.actor_id = ? AND ${COUNTED_ACTIVITY}`, accountId, ), ), ); const activityTimes = earliestActivity.flatMap((entry) => (entry?.activity_at ? [entry.activity_at] : [])); const row = { commit_day: commitRow?.commit_day ?? null, activity_at: activityTimes.length ? Math.min(...activityTimes) : null, }; 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); }