import { ok } from '@atcute/client'; import type { ResolvedActor } from '@atcute/identity-resolver'; import type { Did, Nsid, ResourceUri } from '@atcute/lexicons/syntax'; import { ShTangledRepo } from '@atcute/tangled'; import { appviewFallback, appviewJson, appviewOptionalKnotRequest, getRepoViaAppview, listAllAppviewRecords, type AppviewRecordView, } from './appview'; import { getRpc, normalizeServiceUrl } from './client'; import { REPO_COLLECTION } from './constants'; import { resolveActor } from './identity'; import { maxTimestamp, parseAtUri, timestampFromDate, } from './records'; export interface RepoContext { owner: ResolvedActor; record: RepoRecord; repoDid: Did; slug: string; rkey: string; knot: string; } export interface RepoRecord { uri: ResourceUri; cid: string; rkey: string; value: ShTangledRepo.Main; } export interface TreeResponse { ref: string; parent?: string; dotdot?: string; lastCommit?: CommitHeadline; readme?: { contents: string; filename: string; }; files: TreeEntry[]; } export interface TreeEntry { name: string; mode: string; size: number; last_commit?: CommitHeadline; } export interface CommitHeadline { hash: string; message: string; when: string; author?: { email: string; name: string; when: string; }; } export interface BranchResponse { branches: BranchEntry[]; } export interface DefaultBranchResponse { name?: string; branch?: string; reference?: { name?: string; }; hash?: string; when?: string; } export interface BranchEntry { reference: { name: string; hash: string; }; commit?: { Message: string; Author?: { Name: string; Email: string; When: string; }; Committer?: { Name: string; Email: string; When: string; }; }; is_default?: boolean; } export interface TagResponse { tags: Array<{ name: string; hash: string; message?: string; tag?: { Name: string; Message: string; Tagger?: { Name: string; Email: string; When: string; }; }; }>; } export interface LanguageResponse { ref: string; totalFiles?: number; totalSize?: number; languages: Array<{ name: string; percentage: number; size: number; color?: string; }>; } export type GitHashValue = | string | number[] | { String?: string; string?: string; }; export interface CommitSignature { Name: string; Email: string; When: string; } export interface CommitEntry { hash?: GitHashValue; this: string; message: string; tree?: string; author?: CommitSignature; committer?: CommitSignature; pgp_signature?: string; parent?: string; parent_hashes?: GitHashValue[]; } export interface LogResponse { ref: string; total: number; page: number; per_page: number; log: boolean; commits: CommitEntry[]; } export interface DiffTextLine { Op: 0 | 1 | 2 | ' ' | '-' | '+'; Line: string; } export interface DiffTextFragment { Comment?: string; OldPosition: number; OldLines: number; NewPosition: number; NewLines: number; LinesAdded: number; LinesDeleted: number; LeadingContext?: number; TrailingContext?: number; Lines: DiffTextLine[]; } export interface RepoDiffFile { name: { old: string; new: string; }; text_fragments: DiffTextFragment[]; is_binary: boolean; is_new: boolean; is_delete: boolean; is_copy: boolean; is_rename: boolean; } export interface RepoDiffStat { insertions: number; deletions: number; files_changed: number; } export interface RepoDiff { commit: CommitEntry; stat: RepoDiffStat; diff: RepoDiffFile[]; } export interface RepoCommitResponse { ref: string; diff?: RepoDiff; } export interface BlobResponse { path: string; ref: string; content?: string; encoding?: 'base64' | 'utf-8'; fileTooLarge?: boolean; isBinary?: boolean; mimeType?: string; size?: number; lastCommit?: CommitHeadline; submodule?: { name: string; url: string; branch?: string; }; } export interface ComparePatch { Title: string; Body: string; Raw: string; SHA: string; Author?: string; Committer?: string; AuthorDate?: string; CommitterDate?: string; SubjectPrefix?: string; RawHeaders?: string; BodyAppendix?: string; } export interface CompareResponse { rev1: string; rev2: string; patch: string; format_patch: ComparePatch[]; } const fetchAllRepoRecords = async ( actor: ResolvedActor, collection: Nsid, ): Promise> => { const rpc = getRpc(actor.pds); const records: Array<{ uri: ResourceUri; cid: string; value: unknown }> = []; let cursor: string | undefined; do { const page = await ok( rpc.get('com.atproto.repo.listRecords', { params: { repo: actor.did, collection, limit: 100, cursor, reverse: true, }, }), ); records.push(...page.records); cursor = page.cursor; } while (cursor); return records; }; const listRepoRecordsFromAppview = async (owner: string | ResolvedActor): Promise => { const actor = typeof owner === 'string' ? await resolveActor(owner) : owner; const records = await listAllAppviewRecords('sh.tangled.repo.listRepos', actor.did); return records.map((record) => ({ uri: record.uri, cid: record.cid ?? '', rkey: parseAtUri(record.uri).rkey, value: record.value, })); }; const listRepoRecordsFromPds = async (owner: string | ResolvedActor): Promise => { const actor = typeof owner === 'string' ? await resolveActor(owner) : owner; const records = await fetchAllRepoRecords(actor, REPO_COLLECTION); return records.map((record) => ({ uri: record.uri, cid: record.cid, rkey: parseAtUri(record.uri).rkey, value: record.value as ShTangledRepo.Main, })); }; export const listRepoRecords = async (owner: string | ResolvedActor): Promise => appviewFallback( () => listRepoRecordsFromAppview(owner), () => listRepoRecordsFromPds(owner), ); export const getRepo = async (ownerIdentifier: string, repoSlug: string): Promise => { const owner = await resolveActor(ownerIdentifier); const all = await listRepoRecords(owner); const wanted = repoSlug.trim().toLowerCase(); const match = all.find((record) => { const name = record.value.name?.trim().toLowerCase(); return name === wanted || record.rkey.toLowerCase() === wanted; }); if (!match) { throw new Error(`Repository not found: ${ownerIdentifier}/${repoSlug}`); } if (!match.value.repoDid) { throw new Error(`Repository is missing repoDid metadata`); } const slug = match.value.name?.trim() || match.rkey; return { owner, record: match, repoDid: match.value.repoDid, slug, rkey: match.rkey, knot: normalizeServiceUrl(match.value.knot), }; }; export const getRepoByDid = async (repoDid: Did): Promise => { const record = await appviewJson>( 'sh.tangled.repo.getRepoByRepoDid', { repoDid }, ); const parsed = parseAtUri(record.uri); const owner = await resolveActor(parsed.did); const slug = record.value.name?.trim() || parsed.rkey; if (!record.value.repoDid) { throw new Error(`Repository is missing repoDid metadata`); } return { owner, record: { uri: record.uri, cid: record.cid ?? '', rkey: parsed.rkey, value: record.value, }, repoDid: record.value.repoDid, slug, rkey: parsed.rkey, knot: normalizeServiceUrl(record.value.knot), }; }; const getRepoTreeFromKnot = async ( repo: RepoContext, ref: string, path = '', ): Promise => (await ok( getRpc(repo.knot).get('sh.tangled.repo.tree', { params: { repo: repo.repoDid, ref, path, }, }), )) as TreeResponse; const getRepoBranchesFromKnot = async (repo: RepoContext): Promise => (await ok( getRpc(repo.knot).get('sh.tangled.repo.branches', { params: { repo: repo.repoDid, }, as: 'json', }), )) as BranchResponse; const branchUpdatedTimestamp = (branch: BranchEntry): number => maxTimestamp( timestampFromDate(branch.commit?.Committer?.When), timestampFromDate(branch.commit?.Author?.When), ); const sortBranchResponseByUpdatedAt = (response: BranchResponse): BranchResponse => ({ ...response, branches: [...response.branches].sort((left, right) => { const updatedDiff = branchUpdatedTimestamp(right) - branchUpdatedTimestamp(left); if (updatedDiff !== 0) { return updatedDiff; } if (left.is_default !== right.is_default) { return left.is_default ? -1 : 1; } return left.reference.name.localeCompare(right.reference.name); }), }); const getRepoDefaultBranchFromKnot = async (repo: RepoContext): Promise => (await ok( getRpc(repo.knot).get('sh.tangled.repo.getDefaultBranch', { params: { repo: repo.repoDid, }, }), )) as DefaultBranchResponse; const getRepoTagsFromKnot = async (repo: RepoContext): Promise => (await ok( getRpc(repo.knot).get('sh.tangled.repo.tags', { params: { repo: repo.repoDid, }, as: 'json', }), )) as TagResponse; const getRepoLanguagesFromKnot = async (repo: RepoContext, ref: string): Promise => (await ok( getRpc(repo.knot).get('sh.tangled.repo.languages', { params: { repo: repo.repoDid, ref, }, }), )) as LanguageResponse; export interface RepoLogOptions { limit?: number; cursor?: string; path?: string; } const getRepoLogFromKnot = async ( repo: RepoContext, ref: string, options: RepoLogOptions = {}, ): Promise => (await ok( getRpc(repo.knot).get('sh.tangled.repo.log', { params: { repo: repo.repoDid, ref, limit: options.limit ?? 8, ...(options.cursor ? { cursor: options.cursor } : {}), ...(options.path ? { path: options.path } : {}), }, as: 'json', }), )) as LogResponse; const getRepoCommitFromKnot = async ( repo: RepoContext, ref: string, ): Promise => (await ok( getRpc(repo.knot).get('sh.tangled.repo.diff', { params: { repo: repo.repoDid, ref, }, as: 'json', }), )) as RepoCommitResponse; const getRepoBlobFromKnot = async ( repo: RepoContext, ref: string, path: string, ): Promise => (await ok( getRpc(repo.knot).get('sh.tangled.repo.blob', { params: { repo: repo.repoDid, ref, path, }, }), )) as BlobResponse; const compareBranchesFromKnot = async ( repo: RepoContext, rev1: string, rev2: string, ): Promise => (await ok( getRpc(repo.knot).get('sh.tangled.repo.compare', { params: { repo: repo.repoDid, rev1, rev2, }, as: 'json', }), )) as CompareResponse; export const getRepoTree = async ( repo: RepoContext, ref: string, path = '', ): Promise => appviewOptionalKnotRequest( () => getRepoViaAppview('sh.tangled.repo.tree', repo, { ref, path }), () => getRepoTreeFromKnot(repo, ref, path), ); export const getRepoBranches = async (repo: RepoContext): Promise => appviewOptionalKnotRequest( () => getRepoViaAppview('sh.tangled.repo.branches', repo), () => getRepoBranchesFromKnot(repo), ).then(sortBranchResponseByUpdatedAt); export const getRepoDefaultBranch = async (repo: RepoContext): Promise => appviewOptionalKnotRequest( () => getRepoViaAppview('sh.tangled.repo.getDefaultBranch', repo), () => getRepoDefaultBranchFromKnot(repo), ); export const getRepoTags = async (repo: RepoContext): Promise => appviewOptionalKnotRequest( () => getRepoViaAppview('sh.tangled.repo.tags', repo), () => getRepoTagsFromKnot(repo), ); export const getRepoLanguages = async (repo: RepoContext, ref: string): Promise => appviewOptionalKnotRequest( () => getRepoViaAppview('sh.tangled.repo.languages', repo, { ref }), () => getRepoLanguagesFromKnot(repo, ref), ); export const getRepoLog = async ( repo: RepoContext, ref: string, options: RepoLogOptions = {}, ): Promise => appviewOptionalKnotRequest( () => getRepoViaAppview('sh.tangled.repo.log', repo, { ref, ...options, limit: options.limit ?? 8 }), () => getRepoLogFromKnot(repo, ref, options), ); export const getRepoCommit = async ( repo: RepoContext, ref: string, ): Promise => appviewOptionalKnotRequest( () => getRepoViaAppview('sh.tangled.repo.diff', repo, { ref }), () => getRepoCommitFromKnot(repo, ref), ); export const getRepoBlob = async ( repo: RepoContext, ref: string, path: string, ): Promise => appviewOptionalKnotRequest( () => getRepoViaAppview('sh.tangled.repo.blob', repo, { ref, path }), () => getRepoBlobFromKnot(repo, ref, path), ); export const compareBranches = async ( repo: RepoContext, rev1: string, rev2: string, ): Promise => appviewOptionalKnotRequest( () => getRepoViaAppview('sh.tangled.repo.compare', repo, { rev1, rev2 }), () => compareBranchesFromKnot(repo, rev1, rev2), ); export const decodeBlobText = (blob: BlobResponse): string => { if (!blob.content) { return ''; } if (blob.encoding === 'base64') { const buffer = Uint8Array.from(atob(blob.content), (char) => char.charCodeAt(0)); return new TextDecoder().decode(buffer); } return blob.content; }; export const buildBlobDataUrl = (blob: BlobResponse): string | null => { if (!blob.content || !blob.mimeType) { return null; } if (blob.encoding === 'base64') { return `data:${blob.mimeType};base64,${blob.content}`; } return `data:${blob.mimeType};charset=utf-8,${encodeURIComponent(blob.content)}`; };