// Labels on issues and PRs (lexicons/label). A repo record lists the label definitions // the repo uses; each change is a sh.tangled.label.op record in the account of whoever // made it, and the current labels come from replaying those changes in order, as // ApplyLabelOps in appview/models/label.go does. import { getRecord, handleOf, mapLimit, resolveHandle } from './atproto.js'; import { recordsLinkingTo } from './backlinks.js'; import { TglError } from './errors.js'; import { t } from './i18n.js'; import { authorOf, NSID } from './tangled.js'; export const LABEL_OP = 'sh.tangled.label.op'; export const LABEL_DEFINITION = 'sh.tangled.label.definition'; // The repo's newest repo record (a rename leaves an older one behind). export async function repoRecord(account, repoDid) { const records = await recordsLinkingTo({ account, collection: NSID.repo, links: [{ target: repoDid, path: '.repoDid' }] }); return records.sort((a, b) => (b.value.createdAt ?? '').localeCompare(a.value.createdAt ?? ''))[0]; } // The label definitions a repo uses. Definitions that can't be read are left out. export async function repoLabelDefs(account, repoDid) { const record = await repoRecord(account, repoDid); const defs = await mapLimit(record?.value.labels ?? [], 8, async (uri) => { const def = await getRecord(uri).catch(() => null); return def && { uri, ...def.value }; }); return defs.filter(Boolean); } // Current labels as a Map of definition address -> Set of values, from label op // records ({ uri, value }). Same order and rules as the website: by date, then author // and record key, deletes before additions; a single-value label keeps only its last // value; ops by people who can't label, or out of the label's scope, are skipped. export function foldLabelOps(ops, defs, { subjectCollection, allowed }) { const byUri = new Map(defs.map((d) => [d.uri, d])); const flat = []; for (const op of ops) { const did = authorOf(op.uri); if (allowed && !allowed.has(did)) continue; const base = { at: op.value.performedAt ?? '', did, rkey: op.uri.split('/').pop() }; for (const o of op.value.delete ?? []) flat.push({ ...base, rank: 0, key: o.key, value: o.value, del: true }); for (const o of op.value.add ?? []) flat.push({ ...base, rank: 1, key: o.key, value: o.value, del: o.value === '' }); } const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0); flat.sort((a, b) => cmp(a.at, b.at) || cmp(a.did, b.did) || cmp(a.rkey, b.rkey) || a.rank - b.rank || cmp(a.key, b.key) || cmp(a.value, b.value)); const state = new Map(); for (const op of flat) { const def = byUri.get(op.key); if (!def) continue; if (subjectCollection && def.scope?.length && !def.scope.includes(subjectCollection)) continue; const values = state.get(op.key); if (op.del) { if (!values?.has(op.value)) continue; if (def.multiple) values.delete(op.value); else state.delete(op.key); if (values?.size === 0) state.delete(op.key); } else if (def.multiple) { state.set(op.key, (values ?? new Set()).add(op.value)); } else { state.set(op.key, new Set([op.value])); } } return state; } // Labels of one issue or PR: [{ uri, name, values }], values shown as handles for // people (e.g. "assignee") and left out for plain labels. export async function labelsOf(account, subjectUri, defs, editors) { const ops = await recordsLinkingTo({ account, collection: LABEL_OP, links: [{ target: subjectUri, path: '.subject' }] }); const state = foldLabelOps(ops, defs, { subjectCollection: subjectUri.split('/')[3], allowed: new Set(editors) }); const labels = []; for (const def of defs) { const values = state.get(def.uri); if (!values) continue; const shown = def.valueType?.type === 'null' ? [] : await Promise.all([...values].map((v) => (v.startsWith('did:') ? handleOf(v) : v))); labels.push({ uri: def.uri, name: def.name, values: shown, raw: [...values] }); } return labels; } export function formatLabels(labels) { return labels.map((l) => (l.values.length ? `${l.name}: ${l.values.join(', ')}` : l.name)).join(' · '); } // "name" or "name=value" given on the command line -> { def, value } ready for a label op. // Plain labels take the value "null"; people can be given by handle. export async function parseLabelArg(arg, defs, subjectCollection, { removing = false } = {}) { const [name, ...rest] = arg.split('='); const def = defs.find((d) => d.name.toLowerCase() === name.toLowerCase() || d.uri.split('/').pop() === name.toLowerCase()); if (!def) { const names = defs.filter((d) => !d.scope?.length || d.scope.includes(subjectCollection)).map((d) => d.name); throw new TglError(t(`This repo has no label "${name}". Its labels: ${names.join(', ')}`, `Este repo no tiene la etiqueta "${name}". Sus etiquetas: ${names.join(', ')}`)); } if (def.scope?.length && !def.scope.includes(subjectCollection)) { throw new TglError(t(`Label "${def.name}" can't be used here.`, `La etiqueta "${def.name}" no se puede usar aquí.`)); } let value = rest.join('='); const type = def.valueType?.type; if (type === 'null') { if (value) throw new TglError(t(`Label "${def.name}" takes no value.`, `La etiqueta "${def.name}" no lleva valor.`)); return { def, value: 'null' }; } if (!value) { if (removing) return { def, value: undefined }; throw new TglError(t(`Label "${def.name}" needs a value: ${def.name}=`, `La etiqueta "${def.name}" necesita un valor: ${def.name}=`)); } if (def.valueType?.format === 'did' && !value.startsWith('did:')) value = await resolveHandle(value.replace(/^@/, '')); if (type === 'integer' && !/^-?\d+$/.test(value)) { throw new TglError(t(`Label "${def.name}" takes a whole number.`, `La etiqueta "${def.name}" lleva un número entero.`)); } if (type === 'boolean' && !['true', 'false'].includes(value)) { throw new TglError(t(`Label "${def.name}" takes true or false.`, `La etiqueta "${def.name}" lleva true o false.`)); } if (def.valueType?.enum?.length && !def.valueType.enum.includes(value)) { throw new TglError(t(`Label "${def.name}" takes one of: ${def.valueType.enum.join(', ')}`, `La etiqueta "${def.name}" lleva uno de: ${def.valueType.enum.join(', ')}`)); } return { def, value }; } // Kinds of label a user can create, as the website's form offers them. export const LABEL_KINDS = { simple: { type: 'null', format: 'any' }, text: { type: 'string', format: 'any' }, person: { type: 'string', format: 'did' }, number: { type: 'integer', format: 'any' }, 'yes-no': { type: 'boolean', format: 'any' }, }; export const LABEL_SCOPES = { issues: [NSID.issue], prs: [NSID.pull], both: [NSID.issue, NSID.pull], }; // Mirrors LabelDefinition.Validate and AsRecord in appview/models/label.go (the // ingester drops definitions that fail these rules). Throws a TglError if invalid. export function buildLabelDefinitionRecord({ name, kind = 'simple', values = [], multiple = false, scope = 'both', color, now = new Date() }) { if (!/^[a-zA-Z0-9]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$/.test(name ?? '') || name.length > 40) { throw new TglError(t( 'A label name has up to 40 letters, numbers, hyphens and underscores, and starts and ends with a letter or number.', 'El nombre de una etiqueta tiene hasta 40 letras, números, guiones y guiones bajos, y empieza y acaba en letra o número.', )); } const valueType = LABEL_KINDS[values.length ? 'text' : kind]; if (!valueType) throw new TglError(t(`--kind must be one of: ${Object.keys(LABEL_KINDS).join(', ')}`, `--kind debe ser uno de: ${Object.keys(LABEL_KINDS).join(', ')}`)); if (values.length && kind !== 'simple' && kind !== 'text') { throw new TglError(t('--values only goes with text labels.', '--values solo va con etiquetas de texto.')); } if (multiple && valueType.type === 'null') { throw new TglError(t('A simple label (no value) cannot be --multiple.', 'Una etiqueta simple (sin valor) no puede ser --multiple.')); } if (!LABEL_SCOPES[scope]) throw new TglError(t('--for must be issues, prs or both.', '--for debe ser issues, prs o both.')); let hex = color?.trim(); if (hex) { if (/^#?[a-fA-F0-9]{3}$/.test(hex)) hex = `#${[...hex.replace('#', '')].map((c) => c + c).join('')}`; if (!/^#?[a-fA-F0-9]{6}$/.test(hex)) throw new TglError(t('--color must be a hex color, e.g. #E11D48.', '--color debe ser un color hexadecimal, p. ej. #E11D48.')); hex = `#${hex.replace('#', '').toUpperCase()}`; } const record = { $type: LABEL_DEFINITION, name, valueType: { ...valueType, ...(values.length ? { enum: values } : {}) }, scope: LABEL_SCOPES[scope], multiple, createdAt: now.toISOString(), }; if (hex) record.color = hex; return record; } // A label's kind in words, for lists: "simple", "person", "text: high, low"… export function labelKind(def) { const vt = def.valueType ?? {}; if (vt.enum?.length) return vt.enum.join('/'); const kind = Object.entries(LABEL_KINDS).find(([, k]) => k.type === vt.type && k.format === (vt.format ?? 'any'))?.[0] ?? vt.type; return def.multiple ? `${kind}, ${t('several', 'varios')}` : kind; } export function buildLabelOpRecord({ subjectUri, add, remove, now = new Date() }) { return { $type: LABEL_OP, subject: subjectUri, add: add.map(({ key, value }) => ({ key, value })), delete: remove.map(({ key, value }) => ({ key, value })), performedAt: now.toISOString(), }; }