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}`);
};