~james/core

core/web/src/lib/api/repoCreate.ts -rw-r--r-- 8.9 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
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
	};
};