Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376import { isDid } from "@atcute/lexicons/syntax";import { ClientResponseError, type BobbinContext, type XrpcRequestInit } from "$lib/api/client";import type { LineStat } 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 { ok } from "@atcute/client";import { mainSchema as describeRepoSchema, type $output as DescribeRepoOutput, type $params as DescribeRepoParams} 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";import type * as ListBranches from "$lib/api/lexicons/types/org/tangled/temp/git/listBranches";import type * as ListTags from "$lib/api/lexicons/types/org/tangled/temp/git/listTags";
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;};
// 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 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;}
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 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 const branchSummaries = (response: ListBranches.$output): BranchSummary[] => sortBranches( response.branches.map((branch) => toBranchSummary(branch, response.defaultBranch)) );
export interface TagSummary { name: string; commitHash: string; when?: string; message?: string; // annotated tags carry their tagger taggerName?: string;}
export const toTagSummary = (tag: ListTags.TagInfo): TagSummary => ({ name: tag.name.replace(/^refs\/tags\//, ""), commitHash: tag.oid, when: tag.annotation?.tagger?.when, message: tag.annotation?.message, taggerName: tag.annotation?.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); });
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<DescribeRepoOutput> => ok( ctx.xrpc.call(describeRepoSchema, { params: { repo: repo.uri, repoDid: repo.repoDid ?? "" } as DescribeRepoParams, ...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" };};