Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248import { ClientResponseError, ok } from "@atcute/client";import type { Datetime, Did } from "@atcute/lexicons/syntax";import { nsidOf, recordKeyOf, type Cid } from "#lib/api/syntax.ts";import type { OAuthUserAgent } from "@atcute/oauth-browser-client";import { awaitIndexedRecord } from "#lib/api/awaitRecord.ts";import { hostForKnot, serviceUrlFor } from "#lib/api/_request.ts";import { createBobbinClient, type BobbinContext } from "#lib/api/client.ts";import { branches } from "#lib/api/knotmirror.ts";import { mainSchema as createRepoSchema } from "#lib/api/lexicons/types/org/tangled/repo/createRepo.ts";import type { Main as DeclarationRecord } from "#lib/api/lexicons/types/org/tangled/repo/declaration.ts";import { mainSchema as deleteRepoSchema } from "#lib/api/lexicons/types/org/tangled/repo/deleteRepo.ts";import { createRecord, deleteRecord, readRecord, type WrittenRecord } from "#lib/api/write.ts";import { pingIndexNow } from "#lib/api/indexnow.ts";import { serviceDidForHost } from "#lib/auth/agent.ts";
const DECLARATION_COLLECTION = nsidOf("org.tangled.repo.declaration");const MIRROR_BUDGET_MS = 30_000;const MIRROR_FIRST_POLL_MS = 250;const MIRROR_LAST_POLL_MS = 2_000;
export interface RepoCreationInput { ownerDid: Did; ownerHandle: string; name: string; description?: string; defaultBranch?: string; knot: string; spindle?: string; forkOf?: Did;}
export interface CreatedRepo { ownerHandle: string; name: string; repoDid: Did;}
export const validateRepoName = (rawName: string): string => { if (rawName.length === 0) throw new Error("Enter a repository name."); if (rawName.length > 100) throw new Error("Use 100 characters or fewer for the repository name."); if (rawName.includes("/") || rawName.includes("\\")) { throw new Error("Remove the slashes from the repository name."); } if (rawName.startsWith(".") || rawName.endsWith(".")) { throw new Error( "Start and end the repository name with a letter, number, hyphen, or underscore." ); } if (!/^[a-zA-Z0-9._-]+$/.test(rawName)) { throw new Error( "Use only letters, numbers, periods, hyphens, and underscores in the repository name." ); } if (rawName.includes("..")) throw new Error("Use single periods in the repository name, not two or more in a row."); if (rawName.toLowerCase() === "self") throw new Error( `${JSON.stringify(rawName)} is reserved. Choose a different repository name.` );
return /\.git$/i.test(rawName) ? rawName.slice(0, -4) : rawName;};
export const BRANCH_NAME_MAX_BYTES = 244;
export const branchNameProblem = (branch: string): string | undefined => { if (branch.length === 0) return "Enter a default branch."; if (new TextEncoder().encode(branch).length > BRANCH_NAME_MAX_BYTES) { return `Use a shorter branch name, up to ${BRANCH_NAME_MAX_BYTES} bytes.`; } const control = [...branch].some((char) => char.charCodeAt(0) <= 0x20 || char === "\x7f"); if (control || /[~^:?*[\\]/.test(branch)) { return "Use a branch name without spaces or any of these characters: ~ ^ : ? * [ \\"; } if (branch === "@" || branch.includes("@{")) return "Use a branch name other than @, without @{."; if (branch.startsWith("-")) return "Start the branch name with a letter or number."; if (branch.includes("..")) return "Use single periods in the branch name, not two in a row."; if (branch.startsWith("/") || branch.endsWith("/") || branch.includes("//")) { return "Separate branch name parts with single slashes, without a slash at the start or end."; } if (branch.endsWith(".")) return "End the branch name with a letter or number."; if (branch.split("/").some((part) => part.startsWith(".") || part.endsWith(".lock"))) { return "Start each part of the branch name without a period, and don't end it in .lock."; } return undefined;};
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;};
// without needBranches this only says the mirror knows the repo, it answers 404 until it doesconst isMirrored = async (mirror: BobbinContext, repo: Did, needBranches: boolean) => { try { const { branches: found } = await branches(mirror, { repo, limit: 1 }); return !needBranches || found.length > 0; } catch { return false; }};
// the page reads git through the mirror, so redirecting before it has the repo lands on an error.// giving up isn't a failure, the page has its own not-synced stateconst awaitMirrored = async (bobbinUrl: string, repoDid: Did, forkOf?: Did): Promise<void> => { const mirror = createBobbinClient({ serviceUrl: bobbinUrl }); const deadline = Date.now() + MIRROR_BUDGET_MS; // the mirror knows a fork before the upstream's commits reach it, and that page reads as empty const needBranches = forkOf ? await isMirrored(mirror, forkOf, true) : false; let delay = MIRROR_FIRST_POLL_MS; while (!(await isMirrored(mirror, repoDid, needBranches)) && Date.now() + delay < deadline) { await sleep(delay); delay = Math.min(delay * 2, MIRROR_LAST_POLL_MS); }};
const deleteDeclaration = async ( agent: OAuthUserAgent, rkey: string, cid: Cid): Promise<string | null> => { try { await deleteRecord(agent, DECLARATION_COLLECTION, recordKeyOf(rkey), cid); return null; } catch (cause) { return `the repository declaration could not be reverted: ${messageOf(cause)}`; }};
const deleteKnotRepo = async ( agent: OAuthUserAgent, input: RepoCreationInput, repoDid: Did): Promise<string | null> => { const knotHost = hostForKnot(input.knot); const ctx = createBobbinClient({ serviceUrl: serviceUrlFor(knotHost), 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 } })); return null; } catch (cause) { lastError = messageOf(cause); } } return `the repository on ${knotHost} could not be reverted: ${lastError}`;};
export 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.defaultBranch !== undefined) { const problem = branchNameProblem(input.defaultBranch); if (problem) throw new Error(problem); } if (!input.knot) throw new Error("Select a knot."); if (agent.sub !== input.ownerDid) throw new Error("The active account changed. Try again.");
if (await readRecord<DeclarationRecord>(agent, DECLARATION_COLLECTION, recordKeyOf(rkey))) throw new Error( `A repository named "${name}" already exists in your account. Choose another name.` );
const createdAt = new Date().toISOString() as Datetime; const knot = createBobbinClient({ serviceUrl: serviceUrlFor(hostForKnot(input.knot)), agent }); const created = await ok( knot.xrpc.call(createRepoSchema, { input: { name: recordKeyOf(name), createdAt, ...(input.defaultBranch ? { defaultBranch: input.defaultBranch } : {}), ...(description ? { description } : {}), ...(spindle ? { spindle: serviceDidForHost(spindle) as Did } : {}), ...(input.forkOf ? { source: { $type: "org.tangled.repo.defs#sourceTangled", did: input.forkOf } } : {}) } }) ).catch((cause: unknown) => { if (cause instanceof ClientResponseError && cause.error === "InvalidRequest") { throw new Error( `${hostForKnot(input.knot)} rejected the repository settings. Check the name and default branch.`, { cause } ); } throw cause; }); const repoDid = created.did; if (!repoDid) { throw new Error("Knot failed to mint a repo DID. The knot may need to be upgraded."); }
let written: WrittenRecord | undefined; try { const declaration: DeclarationRecord = { $type: "org.tangled.repo.declaration", repo: repoDid, createdAt }; written = await createRecord(agent, DECLARATION_COLLECTION, declaration, recordKeyOf(rkey)); await awaitIndexedRecord(createBobbinClient({ serviceUrl: bobbinUrl }), written); } catch (cause) { const recordRollback = written ? await deleteDeclaration(agent, rkey, written.cid) : null; const knotRollback = recordRollback ? null : await deleteKnotRepo(agent, input, repoDid); const rollback = [recordRollback, knotRollback].filter(Boolean).join("; "); throw new Error( `Failed to create repository: ${messageOf(cause)}${rollback ? `. Also, ${rollback}.` : ""}`, { cause } ); }
await awaitMirrored(bobbinUrl, repoDid, input.forkOf); pingIndexNow(`https://tangled.org/${input.ownerHandle}/${name}`);
return { ownerHandle: input.ownerHandle, name, repoDid };};