import { ClientResponseError, ok } from "@atcute/client"; import type { ResourceUri } from "@atcute/lexicons/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { awaitDeletedRecord, awaitIndexedRecord } from "#lib/api/awaitRecord.ts"; import { serviceClient } from "#lib/api/_request.ts"; import { createBobbinClient } from "#lib/api/client.ts"; import { createGrant, eraseGrant, knotProcedure, updateCob, type CobRef } from "#lib/api/cob.ts"; import { pingIndexNow } from "#lib/api/indexnow.ts"; import { mainSchema as deleteRepoSchema } from "#lib/api/lexicons/types/org/tangled/repo/deleteRepo.ts"; import type { $output as SetDefaultBranchOutput } from "#lib/api/lexicons/types/org/tangled/git/setDefaultBranch.ts"; import type { Main as DeclarationRecord } from "#lib/api/lexicons/types/org/tangled/repo/declaration.ts"; import type { RepoRecord } from "#lib/api/records.ts"; import { knotFor } from "#lib/api/repo.ts"; import { atUriOf, didOf, nsidOf, recordKeyOf } from "#lib/api/syntax.ts"; import { createRecord, deleteRecord, readRecord } from "#lib/api/write.ts"; const DECLARATION_COLLECTION = nsidOf("org.tangled.repo.declaration"); const COLLABORATOR_COLLECTION = nsidOf("org.tangled.graph.collaborator"); const SET_DEFAULT_BRANCH_NSID = nsidOf("org.tangled.git.setDefaultBranch"); export interface ManifestEdit { description?: string; website?: string; topics?: readonly string[]; spindle?: string; } const empty = (value: unknown): boolean => value === undefined || value === "" || (Array.isArray(value) && value.length === 0); // older manifests still carry defaultBranch, which the knot now refuses export const editedManifest = (manifest: RepoRecord, edit: ManifestEdit): RepoRecord => Object.fromEntries( Object.entries({ ...manifest, ...edit, $type: "org.tangled.repo.manifest" }).filter(([key, value]) => key !== "defaultBranch" && !empty(value)) ) as RepoRecord; export interface RepoHome { uri: string; knot: string; } const saveManifest = async ( agent: OAuthUserAgent, bobbinUrl: string, repo: RepoHome, mutate: (manifest: RepoRecord) => RepoRecord ): Promise => { const written = await updateCob( agent, knotFor(repo.knot), atUriOf(repo.uri), mutate ); await awaitIndexedRecord(createBobbinClient({ serviceUrl: bobbinUrl }), written); return written; }; export const editManifest = ( agent: OAuthUserAgent, bobbinUrl: string, repo: RepoHome, edit: ManifestEdit ): Promise => saveManifest(agent, bobbinUrl, repo, (manifest) => editedManifest(manifest, edit)); export interface RenamedRepo extends RepoHome { rkey: string; } // the declaration lives at the lowercased name, so it moves. new one up first, old one down last, // so a rename that dies halfway never leaves the repo without a declaration export const renameRepo = async ( agent: OAuthUserAgent, bobbinUrl: string, repo: RenamedRepo, name: string ): Promise => { const from = recordKeyOf(repo.rkey); const to = recordKeyOf(name.toLowerCase()); const rename = (manifest: RepoRecord): RepoRecord => ({ ...manifest, declaration: { ...manifest.declaration, name } }); if (from === to) { await saveManifest(agent, bobbinUrl, repo, rename); return; } if (await readRecord(agent, DECLARATION_COLLECTION, to)) { throw new Error(`A repository named "${name}" already exists in your account.`); } const current = await readRecord(agent, DECLARATION_COLLECTION, from); if (!current) throw new Error("This repository has no declaration in your account to move."); const moved = await createRecord(agent, DECLARATION_COLLECTION, current.value, to); try { await saveManifest(agent, bobbinUrl, repo, rename); } catch (cause) { await deleteRecord(agent, DECLARATION_COLLECTION, to, moved.cid).catch(() => {}); throw cause; } await awaitIndexedRecord(createBobbinClient({ serviceUrl: bobbinUrl }), moved); await deleteRecord(agent, DECLARATION_COLLECTION, from, current.written?.cid); }; export const setDefaultBranch = async ( agent: OAuthUserAgent, bobbinUrl: string, knot: string, repoDid: string, branch: string ): Promise => { const refs = await knotProcedure( agent, knotFor(knot), didOf(repoDid), SET_DEFAULT_BRANCH_NSID, { branch } ); await awaitIndexedRecord(createBobbinClient({ serviceUrl: bobbinUrl }), refs); }; export const addCollaborator = ( agent: OAuthUserAgent, knot: string, repoDid: string, subject: string ): Promise => createGrant(agent, knotFor(knot), didOf(repoDid), COLLABORATOR_COLLECTION, didOf(subject)); export const removeCollaborator = ( agent: OAuthUserAgent, knot: string, repoDid: string, subject: string ): Promise => eraseGrant(agent, knotFor(knot), didOf(repoDid), COLLABORATOR_COLLECTION, didOf(subject)); const isRecordGone = (cause: unknown): boolean => cause instanceof ClientResponseError && (cause.status === 404 || cause.error === "RecordNotFound"); export interface RepoLocation { rkey: string; knot: string; name: string; ownerDid: string; ownerHandle: string; repoDid?: string; } // the knot refuses to delete a repo while its declaration is still on the owner's pds, so that goes first export const deleteRepo = async ( agent: OAuthUserAgent, bobbinUrl: string, repo: RepoLocation ): Promise => { if (!repo.repoDid) throw new Error("This repository has no DID on its knot yet."); try { await deleteRecord(agent, DECLARATION_COLLECTION, recordKeyOf(repo.rkey)); } catch (cause) { if (!isRecordGone(cause)) throw cause; } try { await ok( serviceClient(agent, repo.knot).xrpc.call(deleteRepoSchema, { input: { repo: didOf(repo.repoDid) } }) ); } catch (cause) { const detail = cause instanceof Error ? cause.message : String(cause); throw new Error( `The declaration is gone, but ${repo.knot} did not delete ${repo.name}: ${detail}`, { cause } ); } // bobbin keeps the manifest of a repo the knot dropped, but it lists repos by their declaration const declaration = `at://${repo.ownerDid}/${DECLARATION_COLLECTION}/${repo.rkey}`; const confirmed = await awaitDeletedRecord( createBobbinClient({ serviceUrl: bobbinUrl }), declaration as ResourceUri ); if (!confirmed) { throw new Error("Deleted, but the appview has not caught up yet."); } pingIndexNow(`https://tangled.org/${repo.ownerHandle}/${repo.name}`); };