Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kentcdodds/lineage

src/series.ts

124 lines · 3.3 KB · TypeScript
import { repoHtmlUrl } from './repo.ts'
import {
	getRepoSettings,
	ingestSummary,
	isFileKind,
	listCommentaries,
	loadCommitRows,
} from './storage.ts'
import type { ChartMode, FileKind, SeriesPayload, SeriesPoint } from './types.ts'

/**
 * Build the commit-level series used by the hosted chart.
 * Call when rendering the app or when an agent needs the same numbers the UI graphs.
 *
 * @param input - Optional time bounds and chart mode
 * @returns Points, languages, kinds, and commentaries for the selected window
 *
 * @example
 * import getSeries from 'kody:@kentcdodds/lineage/series'
 *
 * const series = await getSeries({ mode: 'cumulative' })
 */
export default async function getSeries(
	input: {
		from?: string | null
		to?: string | null
		mode?: ChartMode
	} = {},
): Promise<SeriesPayload> {
	const repo = await getRepoSettings()
	const mode: ChartMode = input.mode ?? 'cumulative'
	const { commits, buckets } = await loadCommitRows(input.to)
	const bucketsBySha = new Map<
		string,
		Array<{ language: string; kind: FileKind; additions: number; deletions: number }>
	>()
	const languages = new Set<string>()
	const kinds = new Set<FileKind>()

	for (const row of buckets) {
		if (
			typeof row.sha !== 'string' ||
			typeof row.language !== 'string' ||
			typeof row.kind !== 'string' ||
			!isFileKind(row.kind)
		) {
			continue
		}
		const additions = Number(row.additions ?? 0)
		const deletions = Number(row.deletions ?? 0)
		const list = bucketsBySha.get(row.sha) ?? []
		list.push({ language: row.language, kind: row.kind, additions, deletions })
		bucketsBySha.set(row.sha, list)
		languages.add(row.language)
		kinds.add(row.kind)
	}

	const running = new Map<string, number>()
	const points: SeriesPoint[] = []

	for (const row of commits) {
		if (
			typeof row.sha !== 'string' ||
			typeof row.committed_at !== 'string' ||
			typeof row.message !== 'string' ||
			typeof row.html_url !== 'string'
		) {
			continue
		}
		const commitBuckets = bucketsBySha.get(row.sha) ?? []
		let additions = 0
		let deletions = 0
		const serialized = commitBuckets.map((item) => {
			const key = `${item.language}\t${item.kind}`
			const net = item.additions - item.deletions
			const next = (running.get(key) ?? 0) + net
			running.set(key, next)
			additions += item.additions
			deletions += item.deletions
			return {
				language: item.language,
				kind: item.kind,
				additions: item.additions,
				deletions: item.deletions,
				net,
				cumulative: next,
			}
		})
		let cumulative = 0
		for (const value of running.values()) cumulative += value
		if (input.from && row.committed_at < input.from) continue
		points.push({
			sha: row.sha,
			at: row.committed_at,
			message: row.message.split('\n')[0] ?? row.message,
			htmlUrl: row.html_url,
			additions,
			deletions,
			net: additions - deletions,
			cumulative,
			buckets: serialized,
		})
	}

	const commentaries = (await listCommentaries()).filter((item) =>
		points.some((point) => point.sha === item.sha),
	)

	return {
		repo: { ...repo, htmlUrl: repoHtmlUrl(repo) },
		mode,
		from: input.from ?? null,
		to: input.to ?? null,
		generatedAt: new Date().toISOString(),
		commitCount: points.length,
		languages: [...languages].sort(),
		kinds: (['source', 'tests', 'docs'] as FileKind[]).filter((kind) =>
			kinds.has(kind),
		),
		points,
		commentaries,
		ingest: await ingestSummary(),
	}
}