Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313import type { Datetime, Did, Nsid, ResourceUri } from "@atcute/lexicons/syntax";import { now as tidNow } from "@atcute/tid";import type { OAuthUserAgent } from "@atcute/oauth-browser-client";import { awaitDeletedRecord, awaitIndexedOrSaved } from "$lib/api/awaitRecord";import { createBobbinClient, type BobbinContext, type XrpcRequestInit } from "$lib/api/client";import type * as ShTangledLabelDefinition from "$lib/api/lexicons/types/sh/tangled/label/definition";import type * as ShTangledLabelOp from "$lib/api/lexicons/types/sh/tangled/label/op";import { collect } from "$lib/api/pagination";import type { RecordView, RepoRecord } from "$lib/api/records";import { saveRepoRecord } from "$lib/api/repoSettings";import { createRecord, deleteRecord, type WrittenRecord } from "$lib/api/write";import { didFromUri, rkeyFromUri } from "$lib/api/uri";
export type LabelDefinition = ShTangledLabelDefinition.Main;export type LabelOp = ShTangledLabelOp.Main;export type LabelOperand = ShTangledLabelOp.Operand;
const LABEL_DEFINITION_COLLECTION = "sh.tangled.label.definition" as Nsid;const LABEL_OP_COLLECTION = "sh.tangled.label.op" as Nsid;const ISSUE_SCOPE = "sh.tangled.repo.issue" as Nsid;const PULL_SCOPE = "sh.tangled.repo.pull" as Nsid;const MAX_COLLABORATORS = 200;
export const BASIC_LABEL_VALUE = "null";
export const DEFAULT_LABEL_OWNER = "did:plc:wshs7t2adsemcrrd4snkeqli";export const DEFAULT_LABELS = [ `at://${DEFAULT_LABEL_OWNER}/sh.tangled.label.definition/wontfix`, `at://${DEFAULT_LABEL_OWNER}/sh.tangled.label.definition/good-first-issue`, `at://${DEFAULT_LABEL_OWNER}/sh.tangled.label.definition/duplicate`, `at://${DEFAULT_LABEL_OWNER}/sh.tangled.label.definition/documentation`, `at://${DEFAULT_LABEL_OWNER}/sh.tangled.label.definition/assignee`] as ResourceUri[];
export interface LabelView { uri: string; value: LabelDefinition;}
export const listLabelDefinitions = async ( ctx: BobbinContext, subject: string, init?: XrpcRequestInit): Promise<LabelView[]> => { const items = await collect( ctx, "sh.tangled.label.listDefinitions", { subject, limit: 100 }, { ...init, maxPages: 5 } ); return items.map((item) => ({ uri: item.uri, value: item.value as LabelDefinition }));};
export const isBasicLabel = (label: LabelDefinition): boolean => label.valueType.type === "null";
export interface CustomLabelInput { name: string; color: string; issues: boolean; pulls: boolean; keyValue: boolean;}
export const createRepoLabel = async ( agent: OAuthUserAgent, bobbinUrl: string, repo: RecordView<RepoRecord>, input: CustomLabelInput): Promise<LabelView> => { const value: LabelDefinition = { $type: "sh.tangled.label.definition", name: input.name, color: input.color, scope: [...(input.issues ? [ISSUE_SCOPE] : []), ...(input.pulls ? [PULL_SCOPE] : [])], valueType: input.keyValue ? { type: "string", format: "any" } : { type: "null", format: "any" }, createdAt: new Date().toISOString() as Datetime }; const written = await createRecord<LabelDefinition>( agent, LABEL_DEFINITION_COLLECTION, value, tidNow() ); await awaitIndexedOrSaved(createBobbinClient({ serviceUrl: bobbinUrl }), written); try { await saveRepoRecord(agent, bobbinUrl, repo, { labels: [...(repo.value.labels ?? []), written.uri] }); } catch (cause) { // roll back the orphaned definition; an unsubscribed label is invisible await deleteLabelDefinition(agent, written.uri).catch(() => {}); throw cause; } return { uri: written.uri, value };};
export const deleteLabelDefinition = (agent: OAuthUserAgent, uri: string): Promise<void> => deleteRecord(agent, LABEL_DEFINITION_COLLECTION, rkeyFromUri(uri));
export const deleteRepoLabel = async ( agent: OAuthUserAgent, bobbinUrl: string, repo: RecordView<RepoRecord>, uri: string): Promise<void> => { const labels: readonly string[] = repo.value.labels ?? []; if (labels.includes(uri)) { await saveRepoRecord(agent, bobbinUrl, repo, { labels: labels.filter((candidate) => candidate !== uri) }); } await deleteLabelDefinition(agent, uri); await awaitDeletedRecord(createBobbinClient({ serviceUrl: bobbinUrl }), uri as ResourceUri);};
export interface OpView { uri: string; value: LabelOp;}
export type AppliedLabels = Record<string, string[]>;
export interface SubjectLabels { definitions: LabelView[]; applied: AppliedLabels;}
export const NO_SUBJECT_LABELS: SubjectLabels = { definitions: [], applied: {} };
interface FoldOp { did: string; rkey: string; at: number; del: boolean; key: string; value: string;}
const collectionOf = (uri: string): string => uri.replace(/^at:\/\//, "").split("/")[1] ?? "";
const inScope = (label: LabelDefinition, subject: string): boolean => label.scope.length === 0 || label.scope.includes(collectionOf(subject) as Nsid);
const validValue = (label: LabelDefinition, value: string): boolean => { if (value === "") return true; const { type, format, enum: allowed } = label.valueType; if (allowed?.length && type !== "null" && !allowed.includes(value)) return false; switch (type) { case "null": return value === BASIC_LABEL_VALUE; case "boolean": return value === "true" || value === "false"; case "integer": return /^[+-]?\d/.test(value); case "string": return format !== "did" || value.startsWith("did:"); default: return false; }};
const performedAt = (value: string): number => { const at = Date.parse(value); return Number.isFinite(at) && at >= 0 ? at : Number.NEGATIVE_INFINITY;};
const compare = (a: string | number, b: string | number): number => (a < b ? -1 : a > b ? 1 : 0);
const opsOf = (view: OpView, definitions: Map<string, LabelDefinition>): FoldOp[] => { const did = didFromUri(view.uri); const rkey = rkeyFromUri(view.uri); const at = performedAt(view.value.performedAt); const toOp = (operand: LabelOperand, del: boolean): FoldOp => ({ did, rkey, at, del: del || operand.value === "", key: operand.key, value: operand.value }); const ops = [ ...(view.value.delete ?? []).map((operand) => toOp(operand, true)), ...(view.value.add ?? []).map((operand) => toOp(operand, false)) ]; const valid = ops.every((op) => { const label = definitions.get(op.key); return !label || validValue(label, op.value); }); return valid ? ops : [];};
export const foldLabelOps = ( subject: string, definitions: LabelView[], ops: OpView[], authors: ReadonlySet<string>): AppliedLabels => { const byUri = new Map(definitions.map((label) => [label.uri, label.value])); const sorted = ops .filter((view) => view.value.subject === subject && authors.has(didFromUri(view.uri))) .flatMap((view) => opsOf(view, byUri)) .sort( (a, b) => compare(a.at, b.at) || compare(a.did, b.did) || compare(a.rkey, b.rkey) || compare(a.del ? 0 : 1, b.del ? 0 : 1) || compare(a.key, b.key) || compare(a.value, b.value) );
const state = new Map<string, Set<string>>(); for (const op of sorted) { const label = byUri.get(op.key); if (!label || !inScope(label, subject)) continue; const values = state.get(op.key); if (op.del) { if (!values?.has(op.value)) continue; if (label.multiple) values.delete(op.value); else state.delete(op.key); } else if (label.multiple) { state.set(op.key, (values ?? new Set()).add(op.value)); } else { state.set(op.key, new Set([op.value])); } }
return Object.fromEntries( [...state] .filter(([, values]) => values.size > 0) .map(([key, values]) => [key, [...values]]) );};
const repoDefinitions = async ( ctx: BobbinContext, subscribed: readonly string[], subject: string, init?: XrpcRequestInit): Promise<LabelView[]> => { const wanted = new Set(subscribed); const owners = [...new Set(subscribed.map(didFromUri))]; const lists = await Promise.all( owners.map((owner) => listLabelDefinitions(ctx, owner, init).catch(() => [])) ); return lists.flat().filter((label) => wanted.has(label.uri) && inScope(label.value, subject));};
const labelAuthors = async ( ctx: BobbinContext, repo: { ownerDid: string; repoDid?: string }, init?: XrpcRequestInit): Promise<Set<string>> => { const authors = new Set([repo.ownerDid]); if (!repo.repoDid) return authors; const collaborators = await collect( ctx, "sh.tangled.repo.listCollaborators", { subject: repo.repoDid as Did, limit: 100 }, { ...init, max: MAX_COLLABORATORS } ); for (const collaborator of collaborators) authors.add(collaborator.subject); return authors;};
export const listLabelOps = async ( ctx: BobbinContext, subject: string, init?: XrpcRequestInit): Promise<OpView[]> => { const items = await collect( ctx, "sh.tangled.label.listOps", { subject, limit: 1000, order: "asc" }, { ...init, maxPages: 5 } ); return items.map((item) => ({ uri: item.uri, value: item.value as LabelOp }));};
export const loadSubjectLabels = async ( ctx: BobbinContext, repo: { ownerDid: string; repoDid?: string; labels?: readonly string[] }, subject: string, init?: XrpcRequestInit): Promise<SubjectLabels> => { const [definitions, ops, authors] = await Promise.all([ repoDefinitions(ctx, repo.labels ?? [], subject, init), listLabelOps(ctx, subject, init), labelAuthors(ctx, repo, init) ]); return { definitions, applied: foldLabelOps(subject, definitions, ops, authors) };};
export const createLabelOp = ( agent: OAuthUserAgent, subject: string, change: { add: LabelOperand[]; delete: LabelOperand[] }): Promise<WrittenRecord> => createRecord<LabelOp>( agent, LABEL_OP_COLLECTION, { $type: "sh.tangled.label.op", subject: subject as ResourceUri, add: change.add, delete: change.delete, performedAt: new Date().toISOString() as Datetime }, tidNow() );