Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216import { ok } from "@atcute/client";import type { Cid, Did, Nsid, ResourceUri } from "@atcute/lexicons/syntax";import type { OAuthUserAgent } from "@atcute/oauth-browser-client";import { awaitIndexedRecord } from "./awaitRecord";import { createBobbinClient, type BobbinContext } from "./client";import { mainSchema as createRepoSchema } from "./lexicons/types/sh/tangled/repo/create";import { mainSchema as deleteRepoSchema } from "./lexicons/types/sh/tangled/repo/delete";import type { RepoRecord } from "./records";import { createRecord, deleteRecord, putRecord, type WrittenRecord } from "./write";
const REPO_COLLECTION = "sh.tangled.repo" as Nsid;const DEFAULT_LABELS = [ "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/wontfix", "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue", "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/duplicate", "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/documentation", "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/assignee"] as ResourceUri[];
export interface RepoCreationInput { ownerDid: Did; ownerHandle: string; name: string; description?: string; defaultBranch?: string; knot: string; spindle?: string; source?: { record: string; cloneUrl: string; };}
export interface CreatedRepo { ownerHandle: string; name: string; repoDid: Did;}
export const validateRepoName = (rawName: string): string => { if (rawName.length === 0) throw new Error("Repository name cannot be empty."); if (rawName.length > 100) throw new Error("Repository name must be 100 characters or fewer."); if (rawName.includes("/") || rawName.includes("\\")) { throw new Error("Repository name contains invalid path characters."); } if (rawName.startsWith(".") || rawName.endsWith(".")) { throw new Error("Repository name contains an invalid path sequence."); } if (!/^[a-zA-Z0-9._-]+$/.test(rawName)) { throw new Error( "Repository name can only contain alphanumeric characters, periods, hyphens, and underscores." ); } if (rawName.includes("..")) throw new Error("Repository name cannot contain sequential dots."); if (rawName.toLowerCase() === "self") throw new Error(`Repository name ${JSON.stringify(rawName)} is reserved.`);
return rawName.endsWith(".git") ? rawName.slice(0, -4) : rawName;};
export const knotServiceUrl = (knot: string): string => /^[a-z][a-z0-9+.-]*:\/\//i.test(knot) ? knot.replace(/\/+$/, "") : `https://${knot}`;
const messageOf = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause);
const sleep = (milliseconds: number): Promise<void> => { const { promise, resolve } = Promise.withResolvers<void>(); setTimeout(resolve, milliseconds); return promise;};
const deleteOptimisticRecord = async ( agent: OAuthUserAgent, rkey: string, cid: Cid): Promise<string | null> => { try { await deleteRecord(agent, REPO_COLLECTION, rkey, cid); return null; } catch (cause) { return `the repository record could not be reverted: ${messageOf(cause)}`; }};
const deleteKnotRepo = async ( agent: OAuthUserAgent, input: RepoCreationInput, rkey: string, repoDid: Did): Promise<string | null> => { const ctx = createBobbinClient({ serviceUrl: knotServiceUrl(input.knot), agent }); let lastError = "unknown error"; for (const delay of [0, 2_000, 5_000]) { if (delay > 0) await sleep(delay); try { await ok( ctx.xrpc.call(deleteRepoSchema, { input: { repo: repoDid, did: input.ownerDid, name: rkey, rkey: rkey as never } }) ); return null; } catch (cause) { lastError = messageOf(cause); } } return `the repository on ${input.knot} could not be reverted: ${lastError}`;};
const completeCreation = async ( agent: OAuthUserAgent, bobbin: BobbinContext, input: RepoCreationInput, rkey: string, record: RepoRecord, initial: WrittenRecord): Promise<{ repoDid: Did }> => { let repoDid: Did | undefined; let currentCid = initial.cid; try { const knot = createBobbinClient({ serviceUrl: knotServiceUrl(input.knot), agent }); const created = await ok( knot.xrpc.call(createRepoSchema, { input: { rkey: rkey as never, name: rkey, ...(input.defaultBranch ? { defaultBranch: input.defaultBranch } : {}), ...(input.source ? { source: input.source.cloneUrl } : {}) } }) ); repoDid = created.repoDid; if (!repoDid) { throw new Error("Knot failed to mint a repo DID. The knot may need to be upgraded."); }
const committed = await putRecord( agent, REPO_COLLECTION, rkey, { ...record, repoDid }, initial.cid ); currentCid = committed.cid; await awaitIndexedRecord(bobbin, committed); return { repoDid }; } catch (cause) { const recordRollback = await deleteOptimisticRecord(agent, rkey, currentCid); const knotRollback = repoDid && !recordRollback ? await deleteKnotRepo(agent, input, rkey, repoDid) : null; const rollback = [recordRollback, knotRollback].filter(Boolean).join("; "); throw new Error( `Failed to create repository: ${messageOf(cause)}${rollback ? `. Also, ${rollback}.` : ""}`, { cause } ); }};
// announce first so bobbin observes each side of the transaction in orderexport const createRepo = async ( agent: OAuthUserAgent, bobbinUrl: string, input: RepoCreationInput): Promise<CreatedRepo> => { const name = validateRepoName(input.name); const rkey = name.toLowerCase(); const description = input.description?.trim() || undefined; const spindle = input.spindle?.trim() || undefined; if ([...(description ?? "")].length > 140) { throw new Error("Description must be 140 characters or fewer."); } if (!input.knot) throw new Error("Select a knot."); if (agent.sub !== input.ownerDid) throw new Error("The active account changed. Try again.");
const record: RepoRecord = { $type: "sh.tangled.repo", knot: input.knot, createdAt: new Date().toISOString() as never, labels: DEFAULT_LABELS, ...(name !== rkey ? { name } : {}), ...(description ? { description } : {}), ...(spindle ? { spindle } : {}), ...(input.source ? { source: input.source.record as never } : {}) };
const bobbin = createBobbinClient({ serviceUrl: bobbinUrl }); const initial = await createRecord(agent, REPO_COLLECTION, record, rkey); try { await awaitIndexedRecord(bobbin, initial); } catch (cause) { const rollback = await deleteOptimisticRecord(agent, rkey, initial.cid); throw new Error( `Failed to announce repository creation: ${messageOf(cause)}${rollback ? `. Also, ${rollback}.` : ""}`, { cause } ); }
const { repoDid } = await completeCreation(agent, bobbin, input, rkey, record, initial); return { ownerHandle: input.ownerHandle, name, repoDid };};