import { isDid, type Did } from "@atcute/lexicons/syntax";
import type { BobbinContext, XrpcRequestInit } from "#lib/api/client.ts";
import { resolveEmailToDid } from "#lib/api/emailDid.ts";
import { getBlame } from "#lib/api/knotmirror.ts";
import { resolveMiniDoc } from "#lib/api/identity.ts";
import { didFromSignature, splitMessage } from "#lib/api/repo.ts";
import type * as GetBlame from "#lib/api/lexicons/types/sh/tangled/git/temp2/getBlame.ts";
import type { Blame, BlameCommit } from "#lib/components/repo/blame/types.ts";
type RunTable = { start: number; count: number; commit: string };
// knot and mirror payloads share the run-table contract: commits must be unique
// and runs must be a gap-free, sorted cover of the file starting at line 1
const toBlame = (commits: readonly BlameCommit[], runs: readonly RunTable[]): Blame => {
const byShort = new Map<string, BlameCommit>();
for (const commit of commits) {
if (byShort.has(commit.sha)) throw new Error(`duplicate blame commit: ${commit.sha}`);
byShort.set(commit.sha, commit);
}
const blame: Blame = [];
let nextLine = 1;
for (const run of runs) {
if (run.start !== nextLine || run.count < 1 || !Number.isSafeInteger(run.count)) {
throw new Error(`invalid blame run at line ${run.start}`);
}
const commit = byShort.get(run.commit);
if (!commit) throw new Error(`unknown blame commit: ${run.commit}`);
blame.push({ start: run.start, count: run.count, commit });
nextLine += run.count;
}
return blame;
};
// a did in the author slot is a tangled account signing its own commits
const toAuthor = ({ name, email }: { name: string; email: string }) => ({
name,
email,
did: didFromSignature(email)
});
const fromMirror = (result: GetBlame.$output): Blame =>
toBlame(
result.commits.map((commit) => ({
sha: commit.oid,
subject: splitMessage(commit.message)[0],
when: commit.author.when,
author: toAuthor(commit.author)
})),
result.runs
);
export const blameFor = async (
ctx: BobbinContext,
repo: Did,
ref: string,
path: string,
init?: XrpcRequestInit
): Promise<Blame> => fromMirror(await getBlame(ctx, { repo, ref, path }, init));
const MAX_AUTHOR_HYDRATIONS = 250;
const AUTHOR_HYDRATION_CONCURRENCY = 8;
const mapConcurrent = async <T, U>(
items: T[],
concurrency: number,
fn: (item: T) => Promise<U>
): Promise<U[]> => {
const results = new Array<U>(items.length);
let next = 0;
await Promise.all(
Array.from({ length: Math.min(concurrency, items.length) }, async () => {
while (next < items.length) {
const index = next++;
results[index] = await fn(items[index]);
}
})
);
return results;
};
export const hydrateBlameAuthors = async (blame: Blame, ctx: BobbinContext): Promise<Blame> => {
const emails = [
...new Set(
blame.flatMap(({ commit }) =>
commit.author.did || !commit.author.email ? [] : [commit.author.email]
)
)
].slice(0, MAX_AUTHOR_HYDRATIONS);
const emailDids = await resolveEmailToDid(ctx.fetch, emails);
const didFor = (email: string): string | undefined =>
isDid(email) ? email : emailDids.get(email);
const dids = [
...new Set(
blame.flatMap(({ commit }) => {
const did = commit.author.did ?? didFor(commit.author.email);
return did ? [did] : [];
})
)
].slice(0, MAX_AUTHOR_HYDRATIONS);
const docs = await mapConcurrent(
dids,
AUTHOR_HYDRATION_CONCURRENCY,
async (did) => [did, await resolveMiniDoc(ctx, did).catch(() => null)] as const
);
const handles = new Map(
docs.flatMap(([did, doc]) =>
doc && !doc.handle.endsWith(".invalid") ? [[did, doc.handle] as const] : []
)
);
return blame.map((run) => {
const did = run.commit.author.did ?? didFor(run.commit.author.email);
return {
...run,
commit: {
...run.commit,
author: {
...run.commit.author,
did,
handle: did ? handles.get(did) : undefined
}
}
};
});
};