~james/core

core/web/src/lib/api/repoSettings.ts -rw-r--r-- 6.3 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 16 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
import { 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 refuses
export 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 declaration
export 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 first
export 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}`);
};