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<T> {
value: T | null;
error: unknown | null;
}
export const attempt = <T>(promise: Promise<T>): Promise<Attempt<T>> =>
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<RepoAvailability, "empty" | "importing" | "import-failed"> => {
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<CommitSummary[]> => {
const emails = [
...new Set(
commits.flatMap((commit) => [
...(commit.authorDid ? [] : [commit.authorEmail]),
...(commit.committerEmail && !didFromSignature(commit.committerEmail)
? [commit.committerEmail]
: [])
])
)
];
let emailDids = new Map<string, string>();
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<string, string>();
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<CommitSummary[]> => {
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<Set<string>> => {
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));
};