Something went wrong. Try again.
Monorepo for Tangled forked from tangled.org/core
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190import { 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 refusesexport 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<CobRef> => { const written = await updateCob<RepoRecord>( 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<CobRef> => 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 declarationexport const renameRepo = async ( agent: OAuthUserAgent, bobbinUrl: string, repo: RenamedRepo, name: string): Promise<void> => { 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<DeclarationRecord>(agent, DECLARATION_COLLECTION, to)) { throw new Error(`A repository named "${name}" already exists in your account.`); } const current = await readRecord<DeclarationRecord>(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<void> => { const refs = await knotProcedure<SetDefaultBranchOutput>( 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<void> => createGrant(agent, knotFor(knot), didOf(repoDid), COLLABORATOR_COLLECTION, didOf(subject));
export const removeCollaborator = ( agent: OAuthUserAgent, knot: string, repoDid: string, subject: string): Promise<void> => 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 firstexport const deleteRepo = async ( agent: OAuthUserAgent, bobbinUrl: string, repo: RepoLocation): Promise<void> => { 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}`);};