/** * Matching a commit's author against the identities an account claims. * * A commit names its author as "Name
". That is what the author's * own git client wrote, and nothing signs it, so it identifies a person only * as far as the reader already trusts whoever published the commit. The * claims come from a dev.pdsjs.git.identity record, which an account writes * about itself in its own repo. * * Workers-clean: string handling only. */ /** * @typedef {Object} GitIdent * @property {string} name - author name, empty when the ident carries none * @property {string} email - author address, empty when the ident carries none */ /** * @typedef {Object} IdentClaim * @property {string} name * @property {string} email * @property {string} did - whose identity this is, empty for the account * holding the record * @property {string} label - what to call an identity with no account * @property {string} avatarCid - the face to show for a labelled identity */ /** * Split a commit ident into its name and address. * @param {string} ident - "Name ", or a bare name * @returns {GitIdent} */ export function splitIdent(ident) { const text = String(ident ?? '').trim(); const match = /^(.*?)\s*<([^>]*)>$/.exec(text); if (!match) return { name: text, email: '' }; return { name: match[1].trim(), email: match[2].trim() }; } /** * The people a commit message credits beside its author. * * Git carries them as `Co-authored-by:` trailers, one per line, in the same * "Name " shape the author ident uses. A message may name the same * person twice, or name the author again; each identity is returned once, in * the order the message first gives it. * * Only trailers in the message's last paragraph count, which is where git * itself looks. A line further up quoting a trailer, in a revert or a quoted * patch, credits nobody. * @param {string} message - the whole commit message * @returns {string[]} idents, as written */ export function coAuthorsOf(message) { const text = String(message ?? '').trimEnd(); if (!text) return []; const paragraph = text.slice(text.lastIndexOf('\n\n') + 1); /** @type {string[]} */ const found = []; const seen = new Set(); for (const line of paragraph.split('\n')) { const match = /^\s*co-authored-by:\s*(.+)$/i.exec(line); if (!match) continue; const ident = match[1].trim(); const { name, email } = splitIdent(ident); if (!name && !email) continue; const key = `${name.toLowerCase()}\u0000${email.toLowerCase()}`; if (seen.has(key)) continue; seen.add(key); found.push(ident); } return found; } /** * The claims a dev.pdsjs.git.identity record carries. Anything malformed is * dropped rather than failing the read: the record is advisory, and a reader * that cannot parse an entry should show the commit's own name instead. * @param {unknown} value - a git identity record value * @returns {IdentClaim[]} */ export function parseIdentityRecord(value) { const list = /** @type {{idents?: unknown}} */ (value ?? {}).idents; if (!Array.isArray(list)) return []; /** @type {IdentClaim[]} */ const idents = []; for (const entry of list) { const { email, name, did, label, avatar } = /** @type {{email?: unknown, name?: unknown, did?: unknown, label?: unknown, avatar?: {ref?: {$link?: unknown}}}} */ ( entry ?? {} ); const link = avatar?.ref?.$link; const claim = { name: typeof name === 'string' ? name.trim() : '', email: typeof email === 'string' ? email.trim().toLowerCase() : '', did: typeof did === 'string' && did.startsWith('did:') ? did : '', label: typeof label === 'string' ? label.trim() : '', avatarCid: typeof link === 'string' ? link : '', }; if (claim.email || claim.name) idents.push(claim); } return idents; } /** * The claim a commit ident satisfies, or null when none does. * * An address matches on its own, because it is the part a person controls. * A claim carrying only a name matches only when the commit carries no * address, so claiming a common name cannot capture a commit that names * somebody else's address. An address claim wins over a name claim, since * the address is the stronger signal. * * The claim comes back as the caller's own shape, because a reader that * carries more than the record does still wants what it added. * @template {IdentClaim} T * @param {T[]} claims - from parseIdentityRecord * @param {string} ident - the commit's author ident * @returns {T|null} */ export function matchIdent(claims, ident) { const { name, email } = splitIdent(ident); const address = email.toLowerCase(); return ( claims.find((claim) => claim.email && address && claim.email === address) ?? claims.find((claim) => !claim.email && !address && claim.name === name) ?? null ); } /** * @param {IdentClaim[]} claims * @param {string} ident * @returns {boolean} */ export function identMatches(claims, ident) { return matchIdent(claims, ident) !== null; } /** * @typedef {Object} CreditedAuthor * @property {string} ident - the ident the newest commit writes for them * @property {number} commits - how many commits credit them */ /** * Everyone a run of commits credits, most commits first. * * A commit credits its author and everyone its message names beside them. * One person holds one row, keyed on the address they control, because the * name beside that address is whatever their git client wrote that day. An * ident with no address has only its name to go on. A commit that names the * same person twice credits them once. * @param {Array<{author?: string, message?: string}>} commits - newest first * @returns {CreditedAuthor[]} */ export function creditAuthors(commits) { /** @type {Map