import { 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 { enrich, handleOf, target, type Sidecar } from "$lib/api/enrich"; import { resolveMiniDoc } from "$lib/api/identity"; 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 } 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-insensitively export 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 const repoOwnerOf = ( data: Sidecar, view: RecordView ): { did: string; handle: string } => { const did = view.value.declaration.owner; return { did, handle: handleOf(data, did, REPO_OWNER_DOCS) }; }; // TODO(pre-1.0): resolve knot from #tangled_knot service. Don't do PDS fallback. export const resolveRepoKnot = async ( ctx: BobbinContext, repoDid: string, init?: XrpcRequestInit ): Promise => { const doc = await resolveMiniDoc(ctx, repoDid, init).catch(() => null); return (doc?.pds && URL.parse(doc.pds)?.host) || undefined; }; // a repo bobbin has never indexed is a miss, not an error export const resolveRepoByName = async ( ctx: BobbinContext, ownerDid: string, name: string, init?: XrpcRequestInit ): Promise | 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 branch export const resolveForkRepoLabels = async ( ctx: BobbinContext, repoDids: readonly string[], init?: XrpcRequestInit ): Promise> => { if (repoDids.length === 0) return {}; const page = await enrich>( ctx, { xrpc: "sh.tangled.repo.getReposByRepoDids", params: { dids: repoDids }, enrich: [target(REPO_OWNER_DOCS, ["items[].value.declaration.owner"])] }, init ); const labels: Record = {}; for (const item of page.output.items ?? []) { const owner = repoOwnerOf(page.data, item); const slug = item.value.declaration.slug.toLowerCase(); labels[didFromUri(item.uri)] = `${owner.handle}/${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 => { try { const page = await enrich>( ctx, { xrpc: "sh.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 = owner.handle && !owner.handle.endsWith(".invalid") ? owner.handle : owner.did; return { owner: ownerIdentifier, name: view.value.declaration.slug, }; } 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; 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(); 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(); 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 first export const sortBranches = ( 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 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: 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 => { const shown = new Set(commits.map((commit) => commit.hash)); return tags.reduce>((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 required const describeRepo = ( ctx: BobbinContext, repo: { uri: string; repoDid?: string }, init?: XrpcRequestInit ): Promise => 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 => { 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" }; };