import { isDid, type Handle } from "@atcute/lexicons/syntax";
import { ClientResponseError, type BobbinContext, type XrpcRequestInit } from "#lib/api/client.ts";
import type { LineStat } from "#lib/api/diff.ts";
import { REPO_OWNER_DOCS } from "#lib/api/descriptors.ts";
import { enrich, handleOf, target, type Sidecar } from "#lib/api/enrich.ts";
import { buildUrl, hostForKnot, toResponseError } from "#lib/api/_request.ts";
import { didOf, knotOf, nsidOf, type Did, type KnotOrigin } from "#lib/api/syntax.ts";
import {
getRepoByName,
getRepoByRepoDid,
type RecordList,
type RecordView,
type RepoRecord,
type RepoView
} from "#lib/api/records.ts";
import type { $output as ImportStatusOutput } from "#lib/api/lexicons/types/org/tangled/repo/getImportStatus.ts";
import { httpStatusFor } from "#lib/api/load.ts";
import { profileIdentifier } from "#lib/api/profileIdentifier.ts";
import { didFromUri } from "#lib/api/uri.ts";
import { didTag, lookups } from "#lib/api/lookupCache.ts";
import type { RepoTreeEntry } from "#lib/api/knotmirror.ts";
import type * as ListBranches from "#lib/api/lexicons/types/org/tangled/temp/git/listBranches.ts";
import type * as ListTags from "#lib/api/lexicons/types/org/tangled/temp/git/listTags.ts";
import { toArtifactSummary, type ArtifactSummary } from "#lib/api/artifact.ts";
import type { Fetch } from "#lib/fetch.ts";
export interface GitSignature {
Name: string;
Email: string;
When: string;
}
export { FULL_HASH_RE, parseRawCommit } from "#lib/api/rawCommit.ts";
export const repoOwnerOf = (
data: Sidecar,
view: RecordView<RepoRecord>
): { did: Did; handle: Handle } => {
const did = didOf(view.value.declaration.owner);
return { did, handle: handleOf(data, did, REPO_OWNER_DOCS) };
};
export const knotFor = (knot: string): KnotOrigin => {
if (!knot) throw new Error("This repository doesn't have a knot yet.");
return knotOf(hostForKnot(knot));
};
export const resolveRepoKnot = async (
ctx: BobbinContext,
repoDid: Did,
init?: XrpcRequestInit
): Promise<KnotOrigin | undefined> => {
const repo = await getRepoByRepoDid(ctx, repoDid, init).catch(() => null);
return repo?.knot ? knotFor(repo.knot) : undefined;
};
// a repo bobbin has never indexed is a miss, not an error
const fetchRepoByName = async (
ctx: BobbinContext,
ownerDid: string,
name: string,
init?: XrpcRequestInit
): Promise<RepoView | null> => {
try {
return await getRepoByName(ctx, ownerDid, name, init);
} catch (cause) {
if (cause instanceof ClientResponseError && httpStatusFor(cause) === 404) return null;
throw cause;
}
};
// a miss isn't kept. a repo created a moment later has to show up
export const resolveRepoByName = (
ctx: BobbinContext,
ownerDid: string,
name: string,
init?: XrpcRequestInit
): Promise<RepoView | null> =>
lookups.lookup(
`repo:${ctx.serviceUrl}:${ownerDid}:${name.toLowerCase()}`,
[didTag(ownerDid)],
() => fetchRepoByName(ctx, ownerDid, name, init),
(view) => (view ? [didTag(didFromUri(view.uri))] : undefined)
);
// bulk did -> `owner/name` for pipeline runs that came from a fork. repos that
// resolve to nothing are left out, the card falls back to the bare branch
export const resolveForkRepoLabels = async (
ctx: BobbinContext,
repoDids: readonly string[],
init?: XrpcRequestInit
): Promise<Record<string, string>> => {
if (repoDids.length === 0) return {};
const page = await enrich<RecordList<RepoRecord>>(
ctx,
{
xrpc: "org.tangled.repo.getReposByRepoDids",
params: { dids: repoDids },
enrich: [target(REPO_OWNER_DOCS, ["items[].value.declaration.owner"])]
},
init
);
const labels: Record<string, string> = {};
for (const item of page.output.items ?? []) {
const owner = repoOwnerOf(page.data, item);
const slug = item.value.declaration.name.toLowerCase();
labels[didFromUri(item.uri)] = `${profileIdentifier(owner.handle, owner.did)}/${slug}`;
}
return labels;
};
export interface ResolvedRepoLocation {
owner: string;
name: string;
}
// resolve a repoDid to its owner handle/did and repository name
export const resolveRepoByRepoDid = async (
ctx: BobbinContext,
repoDid: string,
init?: XrpcRequestInit
): Promise<ResolvedRepoLocation | null> => {
try {
const page = await enrich<RecordView<RepoRecord>>(
ctx,
{
xrpc: "org.tangled.repo.getRepoByRepoDid",
params: { repoDid },
enrich: [target(REPO_OWNER_DOCS, ["value.declaration.owner"])]
},
init
);
const view = page.output;
if (!view?.uri) return null;
const owner = repoOwnerOf(page.data, view);
const ownerIdentifier = profileIdentifier(owner.handle, owner.did);
return {
owner: ownerIdentifier,
name: view.value.declaration.name
};
} catch (cause) {
if (cause instanceof ClientResponseError && httpStatusFor(cause) === 404) return null;
return null;
}
};
// org.tangled.review.patch record
export interface PullSubmission {
head: string;
base: string;
createdAt: string;
}
export interface CommitSummary {
hash: string;
shortHash: string;
subject: string;
body: string;
authorName: string;
authorEmail: string;
authorDid?: string;
authorHandle?: string;
committerEmail?: string; // TODO: make these required after refining the km lexicons to always include 'committer'
committerDid?: string;
committerHandle?: string;
signatureFingerprint?: string;
signature?: "verified" | "unverified";
when: string;
changeId?: string;
// first parent, only filled in where a caller needs to link a commit range
parent?: string;
// pinned comments referencing this commit
commentCount?: number;
stat?: LineStat;
}
export const didFromSignature = (email: string): string | undefined =>
isDid(email) ? email : undefined;
export const splitMessage = (message: string): [string, string] => {
const separator = message.indexOf("\n\n");
if (separator === -1) return [message.trim(), ""];
return [message.slice(0, separator).trim(), message.slice(separator + 2).trim()];
};
const coAuthorPattern = /^Co-authored-by:\s*(.+?)\s*<([^>]+)>/gim;
// trailers carry no date of their own, like types/commit.go every
// co-author gets stamped with the committer's When
export const coAuthorsFrom = (message: string, when: string): GitSignature[] => {
const seen = new Set<string>();
const coAuthors: GitSignature[] = [];
for (const match of message.matchAll(coAuthorPattern)) {
const name = match[1].trim();
const email = match[2].trim();
if (seen.has(email)) continue;
seen.add(email);
coAuthors.push({ Name: name, Email: email, When: when });
}
return coAuthors;
};
export interface CommitDetail {
hash: string;
shortHash: string;
subject: string;
body: string;
authorName: string;
authorEmail: string;
authorWhen: string;
committerName: string;
committerEmail: string;
committerWhen: string;
coAuthors: { name: string; email: string }[];
signatureFingerprint?: string;
// filled in by the loader when the author's email resolves to an account
authorDid?: string;
authorHandle?: string;
}
export const toTreeCommitSummary = (commit: {
hash: string;
message?: string;
when?: string;
author?: { name?: string; email?: string; when?: string };
}): CommitSummary => {
const [subject, body] = splitMessage(commit.message ?? "");
return {
hash: commit.hash,
shortHash: commit.hash.slice(0, 8),
subject,
body,
authorName: commit.author?.name ?? "",
authorEmail: commit.author?.email ?? "",
when: commit.when ?? commit.author?.when ?? ""
};
};
export interface BranchSummary {
name: string;
hash: string;
when?: string;
isDefault: boolean;
// full commit message
message?: string;
}
export const toBranchSummary = (
branch: ListBranches.BranchInfo,
defaultBranch?: string
): BranchSummary => ({
name: branch.name,
hash: branch.oid,
when: branch.committer.when,
isDefault: branch.name === defaultBranch,
message: branch.message
});
// canonical order for branch lists: default branch first, then newest commit first
export const sortBranches = <T extends { isDefault: boolean; when?: string }>(
branches: readonly T[]
): T[] =>
branches
.map((branch, index) => ({ branch, index }))
.sort((a, b) => {
if (a.branch.isDefault !== b.branch.isDefault) return a.branch.isDefault ? -1 : 1;
const at = a.branch.when ? Date.parse(a.branch.when) : NaN;
const bt = b.branch.when ? Date.parse(b.branch.when) : NaN;
const av = Number.isNaN(at) ? -Infinity : at;
const bv = Number.isNaN(bt) ? -Infinity : bt;
return bv - av || a.index - b.index;
})
.map(({ branch }) => branch);
export const branchSummaries = (response: ListBranches.$output): BranchSummary[] =>
sortBranches(
response.branches.map((branch) => toBranchSummary(branch, response.defaultBranch))
);
export interface TagSummary {
name: string;
commitHash: string;
tagHash?: string;
when?: string;
message?: string;
// annotated tags carry their tagger
taggerName?: string;
artifacts?: ArtifactSummary[];
}
export const toTagSummary = (tag: ListTags.TagInfo): TagSummary => ({
name: tag.name.replace(/^refs\/tags\//, ""),
commitHash: tag.oid,
tagHash: tag.annotation?.oid,
when: tag.annotation?.tagger?.when,
message: tag.annotation?.message,
taggerName: tag.annotation?.tagger?.name,
artifacts: tag.artifacts?.flatMap(toArtifactSummary)
});
export type TreeEntryKind = "file" | "directory" | "symlink" | "submodule";
// modes come back octal and zero padded
export const treeEntryKind = (mode: string): TreeEntryKind => {
switch (mode.replace(/^0+/, "").padStart(6, "0")) {
case "040000":
return "directory";
case "120000":
return "symlink";
case "160000":
return "submodule";
default:
return "file";
}
};
export interface TreeEntrySummary {
name: string;
kind: TreeEntryKind;
size: number;
lastCommitHash?: string;
lastCommitWhen?: string;
lastCommitMessage?: string;
}
export const toTreeEntrySummary = (entry: RepoTreeEntry): TreeEntrySummary => ({
name: entry.name,
kind: treeEntryKind(entry.mode),
size: entry.size,
lastCommitHash: entry.lastCommit?.hash,
lastCommitWhen: entry.lastCommit?.when,
lastCommitMessage: entry.lastCommit?.message
});
export const tagsByCommitHash = (
commits: { hash: string }[],
tags: TagSummary[]
): Record<string, string[]> => {
const shown = new Set(commits.map((commit) => commit.hash));
return tags.reduce<Record<string, string[]>>((acc, tag) => {
if (shown.has(tag.commitHash)) (acc[tag.commitHash] ??= []).push(tag.name);
return acc;
}, {});
};
export const refNamesByCommitHash = (
commits: { hash: string }[],
tags: TagSummary[],
branches: BranchSummary[]
): Record<string, string[]> => {
const map = tagsByCommitHash(commits, tags);
const shown = new Set(commits.map((commit) => commit.hash));
for (const branch of branches) {
if (!shown.has(branch.hash)) continue;
const names = (map[branch.hash] ??= []);
if (!names.includes(branch.name)) names.push(branch.name);
}
return map;
};
export const sortTreeEntries = (entries: TreeEntrySummary[]): TreeEntrySummary[] =>
[...entries].sort((a, b) => {
const aDir = a.kind === "directory" || a.kind === "submodule";
const bDir = b.kind === "directory" || b.kind === "submodule";
if (aDir !== bDir) return aDir ? -1 : 1;
return a.name.localeCompare(b.name);
});
type ImportContent = "fetching" | "partial" | "failed";
export interface ImportState {
content: ImportContent;
source?: string;
}
const IMPORT_STATUS_NSID = nsidOf("org.tangled.repo.getImportStatus");
export interface ImportStateInit {
fetch?: Fetch;
signal?: AbortSignal;
}
// not bobbin, its mirror lags behind an import that is still running
export const importState = async (
repo: { knot: string; repoDid?: string },
{ fetch = globalThis.fetch, signal }: ImportStateInit = {}
): Promise<ImportState | null> => {
if (!repo.repoDid) return null;
const repoDid = didOf(repo.repoDid);
const knot = knotFor(repo.knot);
const response = await fetch(
buildUrl(`https://${knot}`, IMPORT_STATUS_NSID, { repo: repoDid }),
{ headers: { accept: "application/json" }, signal }
);
if (!response.ok) throw await toResponseError(response);
const { status } = (await response.json()) as ImportStatusOutput;
switch (status) {
case "fetching":
return { content: "fetching" };
case "partial":
return { content: "partial" };
case "failed":
return { content: "failed" };
default:
return null;
}
};