import { 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 does
const 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 state
const 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
};
};