chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
/**
* Matching a commit's author against the identities an account claims.
*
* A commit names its author as "Name <address>". 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 <address>", 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 <address>" 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<string, CreditedAuthor>} */
const credited = new Map();
for (const commit of commits ?? []) {
const counted = new Set();
for (const ident of [commit.author, ...coAuthorsOf(commit.message ?? '')]) {
if (typeof ident !== 'string' || !ident) continue;
const { name, email } = splitIdent(ident);
const key = (email || name).toLowerCase();
if (!key || counted.has(key)) continue;
counted.add(key);
const row = credited.get(key) ?? { ident, commits: 0 };
row.commits++;
credited.set(key, row);
}
}
return [...credited.values()].sort((a, b) => b.commits - a.commits);
}