Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514import { isDid } from "@atcute/lexicons/syntax";import { ClientResponseError, type BobbinContext, type XrpcRequestInit } from "$lib/api/client";import type { LineStat, NiceCommit } from "$lib/api/diff";import { REPO_OWNER_DOCS } from "$lib/api/descriptors";import { authorOf, enrich, target } from "$lib/api/enrich";import { getRepoByName, type RecordList, type RecordView, type RepoRecord } from "$lib/api/records";import { branches as knotBranches, describeRepo as knotDescribeRepo, log as knotLog, tag as knotTag, tags as knotTags} from "$lib/api/knot";import type * as DescribeRepo from "$lib/api/lexicons/types/sh/tangled/repo/describeRepo";import { httpStatusFor } from "$lib/api/load";import { didFromUri, rkeyFromUri } from "$lib/api/uri";import type * as Tree from "$lib/api/lexicons/types/sh/tangled/repo/tree";
// log, branches and tags are `*/*` in the lexicons, so these shapes are copied// from core/types by hand. anything go-git touches keeps its go field names and// writes hashes as byte arrays, so the hex comes from a sibling field
export interface GitSignature { Name: string; Email: string; When: string;}
// go-git's IsHash accepts uppercase hex too (hex.DecodeString), both sha1// and sha256 match case-insensitivelyexport const FULL_HASH_RE = /^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/;
export const parseRawCommit = (spec: string): { ref: string; format: "patch" | "diff" } | null => { const dot = spec.lastIndexOf("."); if (dot === -1) return null; const format = spec.slice(dot + 1); if (format !== "patch" && format !== "diff") return null; const ref = spec.slice(0, dot); return FULL_HASH_RE.test(ref) ? { ref, format } : null;};
export interface GitCommit { Author?: GitSignature; Committer?: GitSignature; Message?: string;}
// `this` and `parent` are the hex hashes, `hash` is the byte arrayexport interface LogCommit { this?: string; parent?: string; author?: GitSignature; committer?: GitSignature; message?: string; tree?: string; change_id?: string;}
export interface LogResponse { commits?: LogCommit[]; ref?: string; total?: number; page?: number;}
export interface GitReference { name: string; hash: string;}
export interface BranchEntry { reference: GitReference; commit?: GitCommit; is_default?: boolean;}
export interface BranchesResponse { branches?: BranchEntry[]; total?: number;}
export interface TagEntry { name: string; hash: string; message?: string; tag?: { Tagger?: GitSignature; Message?: string; // the commit an annotated tag points at, bytes like every go-git hash Target?: number[]; };}
export interface TagsResponse { tags?: TagEntry[]; total?: number;}
export interface RepoTagResponse { tag?: TagEntry;}
// newer repos get tid rkeys and keep their display name in the recordexport const repoNameOf = (view: RecordView<RepoRecord>): string => view.value.name ?? rkeyFromUri(view.uri);
// a repo bobbin has never indexed is a miss, not an errorexport const resolveRepoByName = async ( ctx: BobbinContext, ownerDid: string, name: string, init?: XrpcRequestInit): Promise<RecordView<RepoRecord> | null> => { try { return await getRepoByName(ctx, ownerDid, name, init); } catch (cause) { if (cause instanceof ClientResponseError && httpStatusFor(cause) === 404) return null; throw cause; }};
// 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 branchexport 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: "sh.tangled.repo.getReposByRepoDids", params: { dids: repoDids }, enrich: [target(REPO_OWNER_DOCS, ["items[].uri"])] }, init ); const labels: Record<string, string> = {}; for (const item of page.output.items ?? []) { if (!item.value.repoDid) continue; const owner = authorOf(page.data, item.uri, REPO_OWNER_DOCS); labels[item.value.repoDid] = `${owner.handle}/${repoNameOf(item)}`; } return labels;};
export interface ResolvedRepoLocation { owner: string; name: string;}
// resolve a repoDid to its owner handle/did and repository nameexport const resolveRepoByRepoDid = async ( ctx: BobbinContext, repoDid: string, init?: XrpcRequestInit): Promise<ResolvedRepoLocation | null> => { try { const page = await enrich<RecordView<RepoRecord>>( ctx, { xrpc: "sh.tangled.repo.getRepoByRepoDid", params: { repoDid }, enrich: [target(REPO_OWNER_DOCS, ["uri"])] }, init ); const view = page.output; if (!view?.uri) return null; const owner = authorOf(page.data, view.uri, REPO_OWNER_DOCS); const ownerIdentifier = owner.handle && !owner.handle.endsWith(".invalid") ? owner.handle : didFromUri(view.uri); return { owner: ownerIdentifier, name: repoNameOf(view) }; } catch (cause) { if (cause instanceof ClientResponseError && httpStatusFor(cause) === 404) return null; return null; }};
// org.tangled.review.patch recordexport 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; 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 Whenexport 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 SignOff { line: string; name: string; email: string; did?: string;}
const signOffLine = /^Signed-off-by:\s*(.+?)\s*<([^>]+)>$/i;
export const signOffsFrom = (body: string): SignOff[] => { const signOffs = new Map<string, SignOff>(); for (const raw of body.split("\n")) { const line = raw.trim(); const match = signOffLine.exec(line); if (!match) continue; if (signOffs.has(line)) continue; const email = match[2].trim(); signOffs.set(line, { line, name: match[1].trim(), email, did: didFromSignature(email) }); } return [...signOffs.values()];};
export const hasBodyBeyondSignOffs = (body: string): boolean => body.split("\n").some((raw) => raw.trim() !== "" && !signOffLine.test(raw.trim()));
export const toCommitSummary = (commit: LogCommit): CommitSummary => { const [subject, body] = splitMessage(commit.message ?? ""); const hash = commit.this ?? ""; const authorEmail = commit.author?.Email ?? ""; return { hash, shortHash: hash.slice(0, 8), subject, body, authorName: commit.author?.Name ?? "", authorEmail, authorDid: didFromSignature(authorEmail), when: commit.committer?.When ?? commit.author?.When ?? "", changeId: commit.change_id };};
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 }[]; // filled in by the loader when the author's email resolves to an account authorDid?: string; authorHandle?: string;}
// the diff endpoint's commit carries both signatures, unlike the log's// summary shapeexport const toCommitDetail = (commit: NiceCommit): CommitDetail => { const [subject, body] = splitMessage(commit.message ?? ""); const hash = commit.this ?? ""; const committerWhen = commit.committer?.When ?? ""; return { hash, shortHash: hash.slice(0, 8), subject, body, authorName: commit.author?.Name ?? "", authorEmail: commit.author?.Email ?? "", authorDid: didFromSignature(commit.author?.Email ?? ""), authorWhen: commit.author?.When ?? "", committerName: commit.committer?.Name ?? "", committerEmail: commit.committer?.Email ?? "", committerWhen, coAuthors: coAuthorsFrom(commit.message ?? "", committerWhen).map(({ Name, Email }) => ({ name: Name, email: Email })) };};
// the tree endpoint uses lexicon casing where the log ones pass through go field// names, so this cannot share `toCommitSummary`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: BranchEntry): BranchSummary => ({ name: branch.reference.name, hash: branch.reference.hash, when: branch.commit?.Committer?.When ?? branch.commit?.Author?.When, isDefault: branch.is_default === true, message: branch.commit?.Message});
// canonical order for branch lists: default branch first, then newest commit firstexport 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 interface TagSummary { name: string; hash: string; // an annotated tag has its own hash, this is the commit it points at commitHash: string; when?: string; message?: string; // annotated tags carry their tagger taggerName?: string;}
export const hexFromBytes = (bytes: number[]): string => bytes.map((byte) => byte.toString(16).padStart(2, "0")).join("");
export const toTagSummary = (tag: TagEntry): TagSummary => { const target = tag.tag?.Target; return { name: tag.name, hash: tag.hash, commitHash: target?.length ? hexFromBytes(target) : tag.hash, when: tag.tag?.Tagger?.When, message: tag.message ?? tag.tag?.Message, taggerName: tag.tag?.Tagger?.Name };};
export type TreeEntryKind = "file" | "directory" | "symlink" | "submodule";
// modes come back octal and zero paddedexport 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: Tree.TreeEntry): TreeEntrySummary => ({ name: entry.name, kind: treeEntryKind(entry.mode), size: entry.size, lastCommitHash: entry.last_commit?.hash, lastCommitWhen: entry.last_commit?.when, lastCommitMessage: entry.last_commit?.message});
export const tagsByCommitHash = ( commits: CommitSummary[], 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 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); });
// the log cursor is a numeric offset encoded as a stringexport const logFor = ( ctx: BobbinContext, repo: string, ref: string, limit: number, cursor?: string, init?: XrpcRequestInit) => knotLog<LogResponse>(ctx, { repo, ref, limit, cursor }, init);
export const branchesFor = ( ctx: BobbinContext, repo: string, limit: number, cursor?: string, init?: XrpcRequestInit) => knotBranches<BranchesResponse>(ctx, { repo, limit, cursor }, init);
export const tagsFor = ( ctx: BobbinContext, repo: string, limit: number, cursor?: string, init?: XrpcRequestInit) => knotTags<TagsResponse>(ctx, { repo, limit, cursor }, init);
export const tagFor = (ctx: BobbinContext, repo: string, tag: string, init?: XrpcRequestInit) => knotTag<RepoTagResponse>(ctx, { repo, tag }, init);
type ImportContent = "fetching" | "partial" | "failed";
export interface ImportState { content: ImportContent; reason?: string; source?: string;}
// bobbin proxy resolves knot from at-uri, knot keys on repoDid; both requiredconst describeRepo = ( ctx: BobbinContext, repo: { uri: string; repoDid?: string }, init?: XrpcRequestInit): Promise<DescribeRepo.$output> => knotDescribeRepo( ctx, { repo: repo.uri, repoDid: repo.repoDid ?? "" } as DescribeRepo.$params, init );
export const importState = async ( ctx: BobbinContext, repo: { uri: string; repoDid?: string }, init?: XrpcRequestInit): Promise<ImportState | null> => { if (!repo.repoDid) return null; const out = await describeRepo(ctx, repo, init); const { content, reason } = out; if (!content || content === "present") return null; if (content === "failed") return { content: "failed", reason }; return { content: content === "fetching" ? "fetching" : "partial" };};