import { type BobbinContext, ClientResponseError, createBobbinClient } from "#lib/api/client.ts"; import { resolveEmailToDid } from "#lib/api/emailDid.ts"; import { resolveMiniDoc } from "#lib/api/identity.ts"; import { branches as gitBranches, type GitTarget } from "#lib/api/gitclient.ts"; import { orNull } from "#lib/api/load.ts"; import { branchSummaries, didFromSignature, type ImportState } from "#lib/api/repo.ts"; import { listPubKeys, sshFingerprint } from "#lib/api/settings.ts"; import type { CommitSummary, LanguageSlice } from "#lib/components/repo/types.ts"; import type { Fetch } from "#lib/fetch.ts"; export const COMMIT_LIMIT = 3; export const BRANCH_LIMIT = 3; export const TAG_LIMIT = 5; // a knot only ever lists 100 refs, so any total we get is really a minimum export const REF_LIMIT = 100; export interface Attempt { value: T | null; error: unknown | null; } export const attempt = (promise: Promise): Promise> => promise.then( (value) => ({ value, error: null }), (error) => ({ value: null, error }) ); const isNotSynced = (cause: unknown): boolean => cause instanceof ClientResponseError && cause.error === "RepoNotFound"; export type RepoAvailability = "ok" | "empty" | "not-synced" | "error" | "importing" | "import-failed"; export const classifyRepoAvailability = ( attempts: readonly { value: unknown | null; error: unknown | null }[] ): Exclude => { if (attempts.every((result) => result.value === null && isNotSynced(result.error))) { return "not-synced"; } return attempts.every((result) => result.value === null) ? "error" : "ok"; }; export const withImportAvailability = ( availability: RepoAvailability, status: ImportState | null ): RepoAvailability => { if (availability === "ok" || !status) { return availability; } return status.content === "failed" ? "import-failed" : "importing"; }; export const toLanguageSlices = (languages: { name: string; size: number }[]): LanguageSlice[] => { const sized = languages.filter((language) => language.size > 0); const total = sized.reduce((sum, language) => sum + language.size, 0); if (total === 0) return []; const slices = sized.map((language) => { const share = (language.size / total) * 100; return { name: language.name, size: language.size, share, percentage: Math.floor(share) }; }); const short = 100 - slices.reduce((sum, slice) => sum + slice.percentage, 0); [...slices] .sort((a, b) => (b.share % 1) - (a.share % 1) || b.share - a.share) .slice(0, Math.max(0, short)) .forEach((slice) => { slice.percentage += 1; }); return slices.sort((a, b) => b.share - a.share); }; export const repoBranches = async (git: GitTarget) => branchSummaries(await gitBranches(git, REF_LIMIT)); // for the handle and the row reads like the rest of the site. a plain git // email that belongs to a verified signup resolves to that same did first, so // those rows get the identical treatment. export const withAuthorHandles = async ( commits: CommitSummary[], bobbinUrl: string, fetch: Fetch ): Promise => { const emails = [ ...new Set( commits.flatMap((commit) => [ ...(commit.authorDid ? [] : [commit.authorEmail]), ...(commit.committerEmail && !didFromSignature(commit.committerEmail) ? [commit.committerEmail] : []) ]) ) ]; let emailDids = new Map(); if (emails.length > 0) { // the lookups are best-effort: a kv/route failure keeps plain emails emailDids = (await orNull(resolveEmailToDid(fetch, emails))) ?? new Map(); } const hydrated = commits.map((commit) => { const authorDid = commit.authorDid ?? emailDids.get(commit.authorEmail); const committerDid = commit.committerEmail && (didFromSignature(commit.committerEmail) ?? emailDids.get(commit.committerEmail)); return { ...commit, ...(authorDid && { authorDid }), ...(committerDid && { committerDid }) }; }); const ctx = createBobbinClient({ serviceUrl: bobbinUrl, fetch }); const dids = [ ...new Set( hydrated .flatMap((commit) => [commit.authorDid, commit.committerDid]) .filter((did) => did !== undefined) ) ]; const [docs, verified] = await Promise.all([ Promise.all(dids.map((did) => orNull(resolveMiniDoc(ctx, did)))), withVerifiedSignatures(hydrated, ctx) ]); const handles = new Map(); for (const doc of docs) { if (doc && !doc.handle.endsWith(".invalid")) handles.set(doc.did, doc.handle); } return verified.map((commit) => ({ ...commit, ...(commit.authorDid && { authorHandle: handles.get(commit.authorDid) }), ...(commit.committerDid && { committerHandle: handles.get(commit.committerDid) }) })); }; // knotmirror only says the signature is intact. it counts as the committer's once the // key is one of the signing keys on the committer's account const withVerifiedSignatures = async ( commits: CommitSummary[], ctx: BobbinContext ): Promise => { const dids = [ ...new Set( commits.flatMap((commit) => commit.signatureFingerprint && commit.committerDid ? [commit.committerDid] : [] ) ) ]; const keys = new Map( await Promise.all(dids.map(async (did) => [did, await signingKeys(ctx, did)] as const)) ); return commits.map((commit) => { if (!commit.signatureFingerprint) return commit; const verified = commit.committerDid !== undefined && !!keys.get(commit.committerDid)?.has(commit.signatureFingerprint); return { ...commit, signature: verified ? "verified" : "unverified" }; }); }; const signingKeys = async (ctx: BobbinContext, did: string): Promise> => { const records = (await orNull(listPubKeys(ctx, did))) ?? []; const fingerprints = await Promise.all( records .filter(({ value }) => !value.usages || value.usages.includes("sign")) .map(({ value }) => sshFingerprint(value.publicKey)) ); return new Set(fingerprints.filter((fingerprint) => fingerprint !== null)); };