import 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 => { 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, input: CustomLabelInput ): Promise => { 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( 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 => deleteRecord(agent, LABEL_DEFINITION_COLLECTION, rkeyFromUri(uri)); export const deleteRepoLabel = async ( agent: OAuthUserAgent, bobbinUrl: string, repo: RecordView, uri: string ): Promise => { 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; 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): 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 ): 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>(); 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 => { 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> => { 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 => { 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 => { 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 => createRecord( 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() );