diff --git a/packages/git-ui/src/lib/collab.js b/packages/git-ui/src/lib/collab.js index 3937c8d..085b041 100644 --- a/packages/git-ui/src/lib/collab.js +++ b/packages/git-ui/src/lib/collab.js @@ -16,6 +16,38 @@ // records and bundle blobs from that server directly. import { createGitBrowser } from '@pdsjs/git/browser'; +import { + byPull, + byPushLog, + fileSummary, + fixesOf, + latestIntents, + mergedRun, + overlaps, + place, + pullKey, + replacedTips, + runsForCopies, + shortRef, + subjectOf, +} from '@pdsjs/git/pull-requests'; + +export { + byPull, + byPushLog, + fileSummary, + fixesOf, + latestIntents, + mergedRun, + overlaps, + place, + pullKey, + replacedTips, + runsForCopies, + shortRef, + subjectOf, +}; + import { collaboratorsOf, reviewersOf, runnersOf } from '@pdsjs/git/rules'; import { learnProfiles } from './authors.js'; import { listRunnerChecks, repoRunner } from './checks.js'; @@ -92,31 +124,6 @@ const BRANCH_DEPTH = 100; * @property {string} [noteAt] - when they wrote it, where that is known */ -/** - * What identifies a pull request: the account whose copy holds the work, and - * the branch holding it. A space appears in neither a DID nor a ref name, so - * the two parts cannot run together. - * @param {string} did - * @param {string} branch - short ref name - * @returns {string} - */ -export const pullKey = (did, branch) => `${did} ${branch}`; - -/** - * The short form of a ref name, which is how a branch is named on screen. - * @param {unknown} ref - * @returns {string} - */ -export const shortRef = (ref) => - String(ref || '').replace(/^refs\/heads\//, ''); - -/** - * The DID an AT-URI names. - * @param {unknown} uri - * @returns {string|null} - */ -const didOf = (uri) => /^at:\/\/([^/]+)\//.exec(String(uri || ''))?.[1] ?? null; - /** * The PDS a DID document names. * @param {unknown} doc @@ -209,71 +216,6 @@ function readerFor(who) { return reader; } -/** - * The issues a commit message claims to fix: each a `Fixes:` trailer naming - * an issue record's at-uri. The fixer writes it in the fix. An issue here has - * no number, so its name is its address. - * @param {string} message - * @returns {string[]} - */ -export function fixesOf(message) { - return [ - ...String(message || '').matchAll(/^Fixes:\s*(at:\/\/\S+)\s*$/gm), - ].map((match) => match[1]); -} - -/** - * The first line of a commit message. - * @param {string|undefined} message - */ -export const subjectOf = (message) => - String(message || '') - .split('\n')[0] - .trim(); - -/** - * Place a branch against main by sha alone. Both walks come from different - * servers and neither holds the other's objects, but a commit id is the same - * id everywhere, so the intersection of the two walks is the merge base. - * @param {Array<{sha: string}>} branch - the branch's commits, newest first - * @param {string[]} mainShas - main's commits, newest first - * @returns {{ahead: number, base: string|null, behind: number, - * merged: boolean, rooted: boolean}} - */ -export function place(branch, mainShas) { - const inMain = new Set(mainShas); - const here = branch[0]?.sha; - if (!here || inMain.has(here)) { - return { - ahead: 0, - base: here ?? null, - behind: 0, - merged: true, - rooted: true, - }; - } - let ahead = 0; - let base = null; - for (const entry of branch) { - if (inMain.has(entry.sha)) { - base = entry.sha; - break; - } - ahead++; - } - // No shared commit inside either walk. The branch is not placed against - // main, and counting it as ahead of everything would be a guess. - if (!base) - return { ahead, base: null, behind: 0, merged: false, rooted: false }; - return { - ahead, - base, - behind: mainShas.indexOf(base), - merged: false, - rooted: true, - }; -} - /** * The way back to the pull request list, naming the tab that holds this one. * @@ -402,49 +344,6 @@ export function parsePullPath(rest) { return { did, branch, tab: commit ? 'commit' : tab, commit }; } -/** - * Where a merged branch's work began, read from the merge that brought it in. - * - * Once main reaches a branch there is no divergence left to measure: the - * branch's commits are main's commits. A merge commit still says where they - * came from. Its second parent is the tip that was merged, and its first - * parents are main's own trunk, so the branch's earliest commit outside that - * trunk is where the work began. - * - * A branch fast-forwarded or squashed into main leaves no such commit. The - * honest answer from that history is the tip alone. - * @param {Array<{sha: string, parents: string[]}>} commits - the branch's, - * newest first - * @param {Array<{sha: string, parents: string[]}>} mainCommits - newest first - * @returns {{ahead: number, base: string|null}} - */ -export function mergedRun(commits, mainCommits) { - const tip = commits[0].sha; - const merge = mainCommits.find((entry) => - (entry.parents ?? []).slice(1).includes(tip), - ); - if (!merge) return { ahead: 1, base: commits[0].parents[0] ?? null }; - - const byMain = new Map(mainCommits.map((entry) => [entry.sha, entry])); - /** @type {Set} */ - const trunk = new Set(); - /** @type {string|undefined} */ - let at = merge.parents[0]; - while (at && !trunk.has(at)) { - trunk.add(at); - at = byMain.get(at)?.parents?.[0]; - } - - let ahead = 0; - for (const entry of commits) { - if (trunk.has(entry.sha)) { - return { ahead: Math.max(ahead, 1), base: entry.sha }; - } - ahead++; - } - return { ahead, base: commits[commits.length - 1].parents[0] ?? null }; -} - /** * The pull requests one account's copy carries. * @param {Contributor} who @@ -557,57 +456,6 @@ async function fillDrift(pulls, mainTip, changedPaths) { } } -/** - * A file list too long to read, said in one line. An agent's pull request - * touches tens of files, and printing them all buries everything else on the - * card. - * @param {string[]} paths - * @returns {{count: number, dirs: Array<{dir: string, count: number}>}} - * directories by how many of the paths are in them, most first - */ -export function fileSummary(paths) { - /** @type {Map} */ - const byDir = new Map(); - for (const path of paths) { - const cut = path.lastIndexOf('/'); - const dir = cut === -1 ? '' : path.slice(0, cut); - byDir.set(dir, (byDir.get(dir) ?? 0) + 1); - } - const dirs = [...byDir.entries()] - .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) - .map(([dir, count]) => ({ dir, count })); - return { count: paths.length, dirs }; -} - -/** - * Which pull requests cannot merge together, by the files they touch. - * - * A shared path is not a textual conflict: two branches can edit one file in - * different places and merge cleanly. It is the cheap half of the question, - * answered from the commit list already read, and it is the half that says - * where to look. The expensive half is a three-way merge per pair. - * @param {PullRequest[]} pulls - * @returns {Map>} pull request to - * the ones it overlaps - */ -export function overlaps(pulls) { - /** @type {Map>} */ - const found = new Map(); - for (const pull of pulls) found.set(pull.id, []); - for (let i = 0; i < pulls.length; i++) { - for (let j = i + 1; j < pulls.length; j++) { - const a = pulls[i]; - const b = pulls[j]; - const shared = a.files.filter((path) => b.files.includes(path)); - if (shared.length === 0) continue; - // Every pull request was given an entry above, so both are there. - found.get(a.id)?.push({ id: b.id, files: shared }); - found.get(b.id)?.push({ id: a.id, files: shared }); - } - } - return found; -} - /** * The pull requests the repository's runner has seen from accounts it has not * named, read from those accounts' own copies. @@ -678,56 +526,6 @@ async function forkedPulls( return found.flat(); } -/** - * The newest record per branch among one account's pull request records about - * one repository. What decorates a collaborator's branches with the words only - * the author can say: a title, a draft or withdrawn status, or a note. - * - * Keyed by short branch name, which with the account is what identifies a pull - * request. - * @param {unknown[]} rows - listRecords entries from that account - * @param {string} subjectUri - the canonical repository record's AT-URI - * @returns {Map} - */ -export function latestIntents(rows, subjectUri) { - const records = rows - .map( - (row) => - /** @type {Record} */ ( - /** @type {{value?: unknown}|null} */ (row)?.value ?? {} - ), - ) - .filter( - (value) => - value.subject?.uri === subjectUri && typeof value.ref === 'string', - ) - .sort((a, b) => - String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? '')), - ); - /** @type {Map>} */ - const index = new Map(); - for (const record of records) { - const short = shortRef(record.ref); - if (!index.has(short)) { - index.set(short, { - status: record.status, - note: record.note, - title: record.title, - createdAt: record.createdAt, - }); - } - } - return index; -} - -/** - * Every reviewer's records about this repository, indexed by what they name. - * A reviewer keys reviews by TID in one collection, so the listing is filtered - * here rather than by the server. - * @param {unknown} config - * @returns {Promise>>} newest first - */ /** * Every listed voice's statements about this repository, for a page that * shows one pull request rather than all of them. The same records the list @@ -796,131 +594,6 @@ async function loadReviews(config) { ); } -/** - * The runner's runs about any copy of one repository. The runner checks the - * branch a collaborator pushed to that collaborator's own server, so a run - * names their record rather than the canonical one. - * @param {unknown[]} records - listRecords entries - * @param {string} repo - repository name, the record rkey in every copy - * @param {string[]} dids - the collaborators whose copies count - * @returns {Array>} newest first - */ -export function runsForCopies(records, repo, dids) { - const copies = new Set( - dids.map((did) => `at://${did}/${REPO_COLLECTION}/${repo}`), - ); - const runs = []; - for (const entry of records) { - const row = /** @type {{value?: unknown, uri?: unknown}|null} */ (entry); - const run = /** @type {Record} */ (row?.value ?? {}); - const subject = /** @type {{uri?: unknown}} */ (run.subject ?? {}); - if (typeof subject.uri !== 'string' || !copies.has(subject.uri)) continue; - if (typeof run.sha !== 'string') continue; - // The record key, which is what addresses the run's own page. Without it - // a run reads here and cannot be opened. - const rkey = String(row?.uri ?? '') - .split('/') - .pop(); - runs.push(rkey ? { ...run, rkey } : run); - } - return runs.sort((a, b) => - String(b.startedAt ?? '').localeCompare(String(a.startedAt ?? '')), - ); -} - -/** - * The pull request one statement names: its author DID and ref, or the DID in - * the repository record it read and its ref. A check names the copy it ran - * against rather than an author, and both forms carry the branch. - * @param {Record} statement - * @returns {string|null} - */ -function statementPull(statement) { - const author = - typeof statement.author === 'string' - ? statement.author - : didOf(/** @type {{uri?: string}} */ (statement.subject ?? {}).uri); - const ref = statement.ref; - if (!author || typeof ref !== 'string') return null; - return pullKey(author, shortRef(ref)); -} - -/** - * Index statements by what they name. A statement reaches its pull request - * under two keys, the author and branch pair and the sha it was written - * against, so a reader finds it whether or not the writer named the branch. - * @param {Array>} statements - newest first - * @returns {Map>>} - */ -export function byPull(statements) { - /** @type {Map>>} */ - const index = new Map(); - for (const statement of statements) { - for (const key of [statementPull(statement), statement.sha]) { - if (typeof key !== 'string') continue; - const list = index.get(key) ?? []; - if (!list.includes(statement)) list.push(statement); - index.set(key, list); - } - } - return index; -} - -/** - * The tips one branch has carried that are not the tip now, newest first. - * - * A force-push puts the commit before it out of reach: nothing in the graph - * says it existed, and a review or a check written against it names a sha - * nothing points at. Two things remember, and the union is what a reader can - * say. The runner's push log saw every tip. The checks and reviews name the - * tip they answered. - * - * What this is for is telling an old tip from an unrelated one, so a comment - * on a rewritten commit reads as outdated rather than as lost. - * @param {PullRequest} pull - * @param {Array>} statements - about this pull request - * @param {Array<{sha: string, seenAt: string}>} [log] - pushes to this ref, - * newest first - * @returns {Set} commit ids, the tip excluded - */ -export function replacedTips(pull, statements, log = []) { - /** @type {Set} */ - const tips = new Set(); - for (const statement of statements) { - if (typeof statement.sha === 'string') tips.add(statement.sha); - } - for (const version of log) { - if (typeof version?.sha === 'string') tips.add(version.sha); - } - tips.delete(pull.sha); - return tips; -} - -/** - * The pushes one runner logged, by the pull request they were made to. - * @param {Array>} records - one runner's version - * records, each read for one copy - * @returns {Map>} - */ -export function byPushLog(records) { - /** @type {Map>} */ - const index = new Map(); - for (const record of records) { - const did = didOf(/** @type {{uri?: string}} */ (record.subject ?? {}).uri); - const refs = record.refs; - if (!did || !Array.isArray(refs)) continue; - for (const entry of refs) { - const { ref, versions } = /** @type {Record} */ (entry ?? {}); - if (typeof ref !== 'string' || !Array.isArray(versions)) continue; - index.set( - pullKey(did, shortRef(ref)), - versions.filter((one) => typeof one?.sha === 'string'), - ); - } - } - return index; -} - /** * @typedef {Object} Pulls * @property {PullRequest[]} open - branches canonical main does not reach, diff --git a/packages/git-ui/src/lib/issues.js b/packages/git-ui/src/lib/issues.js index 43a05cb..f26b0ef 100644 --- a/packages/git-ui/src/lib/issues.js +++ b/packages/git-ui/src/lib/issues.js @@ -12,7 +12,11 @@ // each indexed record is then fetched from the server that holds it, so a // lying index can point at nothing but cannot forge a word. +import { issueState, issueThreads } from '@pdsjs/git/issues'; import { collaboratorsOf, reviewersOf, runnersOf } from '@pdsjs/git/rules'; + +export { issueState, issueThreads }; + import { learnProfiles } from './authors.js'; import { resolveContributor } from './collab.js'; import { account, repoConfig } from './git.js'; @@ -32,91 +36,6 @@ const issuesKey = (did, repo) => /** Indexed records fetched per load, so one spammed index stays bounded. */ const MAX_FETCHED = 100; -/** - * The state each disposition names. A Map rather than an object, so a - * statement carrying `constructor` reads as the comment it is. - * @type {Map} - */ -const STATE_OF = new Map([ - ['resolved', 'resolved'], - ['declined', 'declined'], - ['reopened', 'open'], -]); - -/** - * The state an issue thread displays, which is the repository owner's latest - * word on it and nobody else's. Anyone may publish a disposition; anyone may - * read it; only the owner's names the state, because the repository is what - * the issue is about and the owner is who the repository answers to. - * - * The latest wins, so every word the owner says is reversible by saying - * another one. `reopened` is how they get back to the state a thread starts - * in, which no amount of `resolved` and `declined` can reach. - * - * The statement that names the state comes back with it, because when the - * owner said it is a fact a row wants and only this read knows which of the - * statements it was. - * @param {Array>} thread - every statement in one thread - * @param {string} ownerDid - * @returns {{state: 'open'|'resolved'|'declined', - * said?: Record}} - */ -export function issueState(thread, ownerDid) { - let latest = null; - for (const statement of thread) { - if (statement.by?.did !== ownerDid) continue; - if (!STATE_OF.has(statement.disposition)) continue; - if (!latest || String(statement.createdAt) > String(latest.createdAt)) { - latest = statement; - } - } - if (!latest) return { state: 'open' }; - return { state: STATE_OF.get(latest.disposition) ?? 'open', said: latest }; -} - -/** - * Group statements into threads: each opener with everything that answers - * it, however deep the reply chain, ordered oldest first inside a thread and - * newest activity first across threads. - * @param {Array>} statements - * @param {string} ownerDid - * @returns {Array<{root: Record, thread: Array>, - * state: ReturnType, latest: string}>} - */ -export function issueThreads(statements, ownerDid) { - const byUri = new Map(statements.filter((s) => s.uri).map((s) => [s.uri, s])); - /** @param {Record} statement */ - const rootOf = (statement) => { - let here = statement; - for (let step = 0; step < 20; step++) { - const parent = here.replyTo?.uri && byUri.get(here.replyTo.uri); - if (!parent) return here; - here = parent; - } - return here; - }; - - /** @type {Map, Array>>} */ - const groups = new Map(); - for (const statement of statements) { - const root = rootOf(statement); - groups.set(root, [...(groups.get(root) ?? []), statement]); - } - - const threads = [...groups.entries()].map(([root, thread]) => { - thread.sort((a, b) => - String(a.createdAt ?? '').localeCompare(String(b.createdAt ?? '')), - ); - return { - root, - thread, - state: issueState(thread, ownerDid), - latest: String(thread[thread.length - 1]?.createdAt ?? ''), - }; - }); - return threads.sort((a, b) => b.latest.localeCompare(a.latest)); -} - /** * @param {import('./collab.js').Contributor} who * @param {string} subjectUri diff --git a/packages/git-ui/test/collab.test.js b/packages/git-ui/test/collab.test.js index b95fa2c..39db1ec 100644 --- a/packages/git-ui/test/collab.test.js +++ b/packages/git-ui/test/collab.test.js @@ -6,22 +6,7 @@ */ import { describe, expect, it } from 'vitest'; -import { - byPull, - byPushLog, - diffHref, - fileSummary, - fixesOf, - latestIntents, - mergedRun, - overlaps, - parsePullPath, - place, - pullKey, - replacedTips, - runsForCopies, - subjectOf, -} from '../src/lib/collab.js'; +import { diffHref, parsePullPath } from '../src/lib/collab.js'; import { anchorKey, lineKey, @@ -29,24 +14,10 @@ import { threadStack, threadSummaries, } from '../src/lib/comments.js'; -import { issueState, issueThreads } from '../src/lib/issues.js'; /** @param {string} name */ const sha = (name) => name.repeat(40).slice(0, 40); -/** @param {string} name @param {string} [message] */ -const commit = (name, message = '') => ({ sha: sha(name), message }); - -describe('pullKey', () => { - it('names a pull request by the account and the branch', () => { - expect(pullKey('did:plc:bob', 'agent/docs')).toBe('did:plc:bob agent/docs'); - }); - - it('tells two branches of one account apart', () => { - expect(pullKey('did:plc:bob', 'a')).not.toBe(pullKey('did:plc:bob', 'b')); - }); -}); - describe('diffHref and parsePullPath', () => { const DID = 'did:plc:w4i54sx7ci3skbfzcsq3xrbw'; @@ -160,200 +131,6 @@ describe('diffHref and parsePullPath', () => { }); }); -describe('subjectOf', () => { - it('is the first line', () => { - expect(subjectOf('add farewell()\n\nA body.\n')).toBe('add farewell()'); - }); -}); - -describe('place', () => { - const main = [sha('d'), sha('c'), sha('b'), sha('a')]; - - it('counts the commits main does not have, and names the merge base', () => { - const placed = place([commit('f'), commit('e'), commit('c')], main); - expect(placed.ahead).toBe(2); - expect(placed.base).toBe(sha('c')); - expect(placed.merged).toBe(false); - expect(placed.rooted).toBe(true); - }); - - it('counts the commits main has that the branch does not', () => { - // Branched at b, which main has moved two commits past. - expect(place([commit('e'), commit('b')], main).behind).toBe(2); - expect(place([commit('e'), commit('d')], main).behind).toBe(0); - }); - - it('reports a tip main already reaches as merged', () => { - const placed = place([commit('c'), commit('b')], main); - expect(placed.merged).toBe(true); - expect(placed.ahead).toBe(0); - expect(placed.behind).toBe(0); - }); - - it('reports a branch sharing no commit with main as unrooted', () => { - const placed = place([commit('z'), commit('y')], main); - expect(placed.rooted).toBe(false); - expect(placed.merged).toBe(false); - // Nothing places it, so a distance would be a guess. - expect(placed.behind).toBe(0); - }); - - it('treats an empty branch as merged', () => { - expect(place([], main).merged).toBe(true); - }); -}); - -describe('mergedRun', () => { - /** @param {string} name @param {string[]} parents */ - const entry = (name, parents = []) => ({ - sha: sha(name), - parents: parents.map(sha), - }); - - // main: m (merge of t and p) over p over o. The branch is t over s over p. - const branch = [entry('t', ['s']), entry('s', ['p']), entry('p', ['o'])]; - const mainCommits = [entry('m', ['p', 't']), entry('p', ['o']), entry('o')]; - - it('takes the commits the merge brought in, and where they began', () => { - const run = mergedRun(branch, mainCommits); - expect(run.ahead).toBe(2); - expect(run.base).toBe(sha('p')); - }); - - it('takes the tip alone where no merge commit names it', () => { - const run = mergedRun(branch, [entry('p', ['o']), entry('o')]); - expect(run.ahead).toBe(1); - expect(run.base).toBe(sha('s')); - }); - - it('reports no base at the root of a history', () => { - expect(mergedRun([entry('a')], []).base).toBe(null); - }); -}); - -describe('overlaps', () => { - const pulls = [ - { id: 'a', files: ['index.js', 'test.js'] }, - { id: 'b', files: ['index.js'] }, - { id: 'c', files: ['README.md'] }, - ]; - - it('pairs the pull requests touching one file', () => { - const found = overlaps(pulls); - expect(found.get('a')).toEqual([{ id: 'b', files: ['index.js'] }]); - expect(found.get('b')).toEqual([{ id: 'a', files: ['index.js'] }]); - expect(found.get('c')).toEqual([]); - }); - - it('names every one, so a pull request sharing nothing is in the answer', () => { - expect([...overlaps(pulls).keys()]).toEqual(['a', 'b', 'c']); - expect([...overlaps([]).keys()]).toEqual([]); - }); -}); - -describe('fileSummary', () => { - it('counts the paths per directory, most first', () => { - const summary = fileSummary([ - 'packages/core/src/http.js', - 'packages/core/src/auth.js', - 'README.md', - 'packages/git/src/pack.js', - ]); - expect(summary.count).toBe(4); - expect(summary.dirs).toEqual([ - { dir: 'packages/core/src', count: 2 }, - { dir: '', count: 1 }, - { dir: 'packages/git/src', count: 1 }, - ]); - }); - - it('counts nothing for no paths', () => { - expect(fileSummary([])).toEqual({ count: 0, dirs: [] }); - }); -}); - -describe('runsForCopies', () => { - const dids = ['did:plc:alice', 'did:plc:bob']; - /** @param {string} did @param {string} startedAt */ - const run = (did, startedAt, rkey = 'project') => ({ - value: { - subject: { uri: `at://${did}/dev.pdsjs.git.repo/${rkey}` }, - sha: sha('a'), - startedAt, - }, - }); - - it('keeps the runs about any named copy, newest first', () => { - const runs = runsForCopies( - [ - run('did:plc:alice', '2026-08-01T00:00:00Z'), - run('did:plc:bob', '2026-08-02T00:00:00Z'), - ], - 'project', - dids, - ); - expect(runs.map((entry) => entry.startedAt)).toEqual([ - '2026-08-02T00:00:00Z', - '2026-08-01T00:00:00Z', - ]); - }); - - it('drops another repository, and an account nobody named', () => { - const runs = runsForCopies( - [ - run('did:plc:alice', '2026-08-01T00:00:00Z', 'other'), - run('did:plc:mallory', '2026-08-01T00:00:00Z'), - { value: { subject: { uri: 42 } } }, - {}, - ], - 'project', - dids, - ); - expect(runs).toEqual([]); - }); -}); - -describe('latestIntents', () => { - const SUBJECT = 'at://did:plc:owner/dev.pdsjs.git.repo/project'; - /** @param {Partial>} value */ - const row = (value) => ({ value: { subject: { uri: SUBJECT }, ...value } }); - - it('keys the newest record by branch', () => { - const index = latestIntents( - [ - row({ - ref: 'refs/heads/agent/docs', - title: 'Document the reader', - status: 'draft', - createdAt: '2026-08-02T00:00:00Z', - }), - row({ - ref: 'refs/heads/agent/docs', - createdAt: '2026-08-01T00:00:00Z', - }), - ], - SUBJECT, - ); - expect(index.get('agent/docs')?.status).toBe('draft'); - expect(index.get('agent/docs')?.title).toBe('Document the reader'); - }); - - it('drops records about another repository, and malformed rows', () => { - const index = latestIntents( - [ - row({ - subject: { uri: 'at://did:plc:owner/dev.pdsjs.git.repo/other' }, - ref: 'refs/heads/x', - }), - row({ ref: 42 }), - {}, - ], - SUBJECT, - ); - expect(index.size).toBe(0); - }); -}); - describe('threadStack', () => { /** @param {Partial>} value */ const entry = (value) => ({ verdict: 'comment', ...value }); @@ -385,139 +162,6 @@ describe('threadStack', () => { }); }); -describe('issueState and issueThreads', () => { - const OWNER = 'did:plc:owner'; - /** @param {Partial>} value */ - const entry = (value) => ({ - by: { did: 'did:plc:filer', handle: 'filer' }, - ...value, - }); - - it('reads the state from the owner and nobody else', () => { - const thread = [ - entry({ uri: 'at://a', createdAt: '2026-08-01T00:00:00Z' }), - entry({ - uri: 'at://b', - createdAt: '2026-08-02T00:00:00Z', - disposition: 'resolved', - }), - ]; - // The filer marking their own issue resolved is a comment, not a state. - expect(issueState(thread, OWNER).state).toBe('open'); - - thread.push( - entry({ - by: { did: OWNER, handle: 'owner' }, - uri: 'at://c', - createdAt: '2026-08-03T00:00:00Z', - disposition: 'resolved', - }), - ); - expect(issueState(thread, OWNER)).toMatchObject({ state: 'resolved' }); - }); - - it('reads the latest word, so reopened takes back a decline', () => { - const owner = { did: OWNER, handle: 'owner' }; - const thread = [ - entry({ uri: 'at://a', createdAt: '2026-08-01T00:00:00Z' }), - entry({ - by: owner, - uri: 'at://b', - createdAt: '2026-08-02T00:00:00Z', - disposition: 'declined', - }), - ]; - expect(issueState(thread, OWNER)).toMatchObject({ state: 'declined' }); - - thread.push( - entry({ - by: owner, - uri: 'at://c', - createdAt: '2026-08-03T00:00:00Z', - disposition: 'reopened', - }), - ); - expect(issueState(thread, OWNER)).toMatchObject({ - state: 'open', - said: { uri: 'at://c', disposition: 'reopened' }, - }); - }); - - it('leaves the state alone when anybody but the owner reopens', () => { - const thread = [ - entry({ uri: 'at://a', createdAt: '2026-08-01T00:00:00Z' }), - entry({ - by: { did: OWNER, handle: 'owner' }, - uri: 'at://b', - createdAt: '2026-08-02T00:00:00Z', - disposition: 'declined', - }), - entry({ - uri: 'at://c', - createdAt: '2026-08-03T00:00:00Z', - disposition: 'reopened', - }), - ]; - expect(issueState(thread, OWNER)).toMatchObject({ state: 'declined' }); - }); - - it('reads a word off Object.prototype as no disposition at all', () => { - const thread = [ - entry({ - by: { did: OWNER, handle: 'owner' }, - uri: 'at://a', - createdAt: '2026-08-01T00:00:00Z', - disposition: 'constructor', - }), - ]; - expect(issueState(thread, OWNER)).toEqual({ state: 'open' }); - }); - - it('groups replies with their opener, newest activity first', () => { - const threads = issueThreads( - [ - entry({ - uri: 'at://old', - title: 'old', - createdAt: '2026-08-01T00:00:00Z', - }), - entry({ - uri: 'at://young', - title: 'young', - createdAt: '2026-08-02T00:00:00Z', - }), - entry({ - uri: 'at://answer', - createdAt: '2026-08-03T00:00:00Z', - replyTo: { uri: 'at://old' }, - }), - ], - OWNER, - ); - expect(threads.map((t) => t.root.title)).toEqual(['old', 'young']); - expect(threads[0].thread).toHaveLength(2); - }); -}); - -describe('fixesOf', () => { - it('reads each Fixes trailer as an issue address', () => { - const message = [ - 'refuse a non-string name', - '', - 'Fixes: at://did:plc:dan/dev.pdsjs.git.issue/3kissue', - ].join('\n'); - expect(fixesOf(message)).toEqual([ - 'at://did:plc:dan/dev.pdsjs.git.issue/3kissue', - ]); - }); - - it('ignores prose mentioning a fix and messages without one', () => { - expect(fixesOf('this fixes the bug')).toEqual([]); - expect(fixesOf('Fixes: #123')).toEqual([]); - expect(fixesOf('')).toEqual([]); - }); -}); - describe('threadSummaries', () => { /** @param {Partial>} value */ const entry = (value) => ({ verdict: 'comment', ...value }); @@ -636,96 +280,3 @@ describe('placeComments', () => { expect(elsewhere).toBe(0); }); }); - -describe('byPull', () => { - it('indexes a review under its pull request and its sha', () => { - const statement = { - author: 'did:plc:bob', - ref: 'refs/heads/agent/docs', - sha: sha('a'), - }; - const index = byPull([statement]); - expect(index.get(pullKey('did:plc:bob', 'agent/docs'))).toEqual([ - statement, - ]); - expect(index.get(sha('a'))).toEqual([statement]); - }); - - it("reads a check's pull request off the copy it ran against", () => { - const run = { - subject: { uri: 'at://did:plc:bob/dev.pdsjs.git.repo/project' }, - ref: 'refs/heads/agent/docs', - sha: sha('a'), - }; - const index = byPull([run]); - expect(index.get(pullKey('did:plc:bob', 'agent/docs'))).toEqual([run]); - }); - - it('indexes a statement naming no branch under its sha alone', () => { - const index = byPull([{ sha: sha('a') }]); - expect([...index.keys()]).toEqual([sha('a')]); - }); -}); - -describe('replacedTips', () => { - it('names every tip the branch has carried but the one it carries now', () => { - const replaced = replacedTips({ sha: sha('c') }, [ - { sha: sha('b'), startedAt: '2026-08-02T00:00:00Z' }, - { sha: sha('a'), reviewedAt: '2026-08-01T00:00:00Z' }, - { sha: sha('c'), startedAt: '2026-08-03T00:00:00Z' }, - ]); - expect([...replaced].sort()).toEqual([sha('a'), sha('b')]); - // The tip is not something the branch replaced. - expect(replaced.has(sha('c'))).toBe(false); - }); - - it('takes a tip from the push log that nothing else names', () => { - const replaced = replacedTips( - { sha: sha('c') }, - [], - [ - { sha: sha('b'), seenAt: '2026-08-02T00:00:00Z' }, - { sha: sha('a'), seenAt: '2026-08-01T00:00:00Z' }, - ], - ); - expect([...replaced].sort()).toEqual([sha('a'), sha('b')]); - }); - - it('names nothing where the branch has never been rewritten', () => { - expect(replacedTips({ sha: sha('a') }, []).size).toBe(0); - expect( - replacedTips({ sha: sha('a') }, [{ sha: sha('a'), reviewedAt: '' }]).size, - ).toBe(0); - }); -}); - -describe('byPushLog', () => { - const record = { - subject: { uri: 'at://did:plc:bob/dev.pdsjs.git.repo/project' }, - refs: [ - { - ref: 'refs/heads/agent/docs', - versions: [ - { sha: sha('b'), seenAt: '2026-08-02T00:00:00Z' }, - { sha: sha('a'), seenAt: '2026-08-01T00:00:00Z' }, - ], - }, - ], - }; - - it('keys a log by the account the subject names and the branch', () => { - const index = byPushLog([record]); - expect( - index.get(pullKey('did:plc:bob', 'agent/docs'))?.map((one) => one.sha), - ).toEqual([sha('b'), sha('a')]); - }); - - it('drops a record naming no copy, and a malformed ref entry', () => { - const index = byPushLog([ - { refs: [] }, - { subject: { uri: 42 }, refs: [] }, - { ...record, refs: [{ ref: 'refs/heads/x' }, { versions: [] }] }, - ]); - expect(index.size).toBe(0); - }); -}); diff --git a/packages/git/package.json b/packages/git/package.json index 4c5e7c7..9dddaba 100644 --- a/packages/git/package.json +++ b/packages/git/package.json @@ -17,7 +17,9 @@ "./merger": "./src/merger.js", "./merge-commit": "./src/merge-commit.js", "./rules": "./src/rules.js", - "./authors": "./src/authors.js" + "./authors": "./src/authors.js", + "./pull-requests": "./src/pull-requests.js", + "./issues": "./src/issues.js" }, "scripts": { "bench": "node bench/push-costs.mjs", diff --git a/packages/git/src/issues.js b/packages/git/src/issues.js new file mode 100644 index 0000000..0ab6b1c --- /dev/null +++ b/packages/git/src/issues.js @@ -0,0 +1,98 @@ +/** + * Issue threads, read from the statements in every voice's own repo. + * + * An issue is a record in its filer's repo, a reply is a record naming its + * parent, and the repository owner's disposition record is what a reader + * shows as the issue's state. Nothing here is a mutable field on a shared + * object: closed is a statement the owner signed, displayed as such, and the + * filer's disagreement can sit beside it. + * + * These are the parts of that reading that touch no network. + * + * Workers-clean: no node imports. + */ + +/** + * The state each disposition names. A Map rather than an object, so a + * statement carrying `constructor` reads as the comment it is. + * @type {Map} + */ +const STATE_OF = new Map([ + ['resolved', 'resolved'], + ['declined', 'declined'], + ['reopened', 'open'], +]); + +/** + * The state an issue thread displays, which is the repository owner's latest + * word on it and nobody else's. Anyone may publish a disposition; anyone may + * read it; only the owner's names the state, because the repository is what + * the issue is about and the owner is who the repository answers to. + * + * The latest wins, so every word the owner says is reversible by saying + * another one. `reopened` is how they get back to the state a thread starts + * in, which no amount of `resolved` and `declined` can reach. + * + * The statement that names the state comes back with it, because when the + * owner said it is a fact a row wants and only this read knows which of the + * statements it was. + * @param {Array>} thread - every statement in one thread + * @param {string} ownerDid + * @returns {{state: 'open'|'resolved'|'declined', + * said?: Record}} + */ +export function issueState(thread, ownerDid) { + let latest = null; + for (const statement of thread) { + if (statement.by?.did !== ownerDid) continue; + if (!STATE_OF.has(statement.disposition)) continue; + if (!latest || String(statement.createdAt) > String(latest.createdAt)) { + latest = statement; + } + } + if (!latest) return { state: 'open' }; + return { state: STATE_OF.get(latest.disposition) ?? 'open', said: latest }; +} + +/** + * Group statements into threads: each opener with everything that answers + * it, however deep the reply chain, ordered oldest first inside a thread and + * newest activity first across threads. + * @param {Array>} statements + * @param {string} ownerDid + * @returns {Array<{root: Record, thread: Array>, + * state: ReturnType, latest: string}>} + */ +export function issueThreads(statements, ownerDid) { + const byUri = new Map(statements.filter((s) => s.uri).map((s) => [s.uri, s])); + /** @param {Record} statement */ + const rootOf = (statement) => { + let here = statement; + for (let step = 0; step < 20; step++) { + const parent = here.replyTo?.uri && byUri.get(here.replyTo.uri); + if (!parent) return here; + here = parent; + } + return here; + }; + + /** @type {Map, Array>>} */ + const groups = new Map(); + for (const statement of statements) { + const root = rootOf(statement); + groups.set(root, [...(groups.get(root) ?? []), statement]); + } + + const threads = [...groups.entries()].map(([root, thread]) => { + thread.sort((a, b) => + String(a.createdAt ?? '').localeCompare(String(b.createdAt ?? '')), + ); + return { + root, + thread, + state: issueState(thread, ownerDid), + latest: String(thread[thread.length - 1]?.createdAt ?? ''), + }; + }); + return threads.sort((a, b) => b.latest.localeCompare(a.latest)); +} diff --git a/packages/git/src/pull-requests.js b/packages/git/src/pull-requests.js new file mode 100644 index 0000000..0f1dd9f --- /dev/null +++ b/packages/git/src/pull-requests.js @@ -0,0 +1,406 @@ +/** + * Pull requests, read from what the object graph and the records say. + * + * A pull request is a branch in one account's copy, named by the account and + * the branch. Nothing here stores a state: open and merged are where a tip + * stands against canonical main, and every other fact is derived from the + * commits, the statements written against them, and the runner's runs. + * + * These are the parts of that reading that touch no network: the page and + * the server both read pull requests, and each brings its own fetching. + * + * Workers-clean: no node imports. + */ + +const REPO_COLLECTION = 'dev.pdsjs.git.repo'; + +/** + * @typedef {Object} Contributor + * @property {string} did + * @property {string} handle + * @property {string} pds + */ + +/** + * @typedef {Object} PullRequest + * @property {string} id - the author's DID and the branch, which is what + * identifies a pull request + * @property {Contributor} author + * @property {string} branch - short ref name in the author's own copy + * @property {string} title - the author's title, or the tip commit's subject + * @property {string} sha - the tip commit + * @property {number} when - the tip commit's commit date, epoch ms + * @property {number} ahead - commits main does not have + * @property {number} behind - commits main has that this does not + * @property {string|null} base - the merge base with main + * @property {boolean} merged - main already reaches this tip + * @property {boolean} rooted - a merge base was found inside the walk + * @property {string[]} files - paths the pull request touches + * @property {string[]} fixes - issue at-uris its commits claim to fix + * @property {string[]} [drift] - paths main has changed since it branched + * @property {boolean} [fork] - from an account the repository has not named + * @property {string} [status] - the author's word from their own record, such + * as draft or withdrawn + * @property {string} [note] - the author's note from the same record + * @property {string} [noteAt] - when they wrote it, where that is known + */ + +/** + * What identifies a pull request: the account whose copy holds the work, and + * the branch holding it. A space appears in neither a DID nor a ref name, so + * the two parts cannot run together. + * @param {string} did + * @param {string} branch - short ref name + * @returns {string} + */ +export const pullKey = (did, branch) => `${did} ${branch}`; + +/** + * The short form of a ref name, which is how a branch is named on screen. + * @param {unknown} ref + * @returns {string} + */ +export const shortRef = (ref) => + String(ref || '').replace(/^refs\/heads\//, ''); + +/** + * The DID an AT-URI names. + * @param {unknown} uri + * @returns {string|null} + */ +const didOf = (uri) => /^at:\/\/([^/]+)\//.exec(String(uri || ''))?.[1] ?? null; + +/** + * The issues a commit message claims to fix: each a `Fixes:` trailer naming + * an issue record's at-uri. The fixer writes it in the fix. An issue here has + * no number, so its name is its address. + * @param {string} message + * @returns {string[]} + */ +export function fixesOf(message) { + return [ + ...String(message || '').matchAll(/^Fixes:\s*(at:\/\/\S+)\s*$/gm), + ].map((match) => match[1]); +} + +/** + * The first line of a commit message. + * @param {string|undefined} message + */ +export const subjectOf = (message) => + String(message || '') + .split('\n')[0] + .trim(); + +/** + * Place a branch against main by sha alone. Both walks come from different + * servers and neither holds the other's objects, but a commit id is the same + * id everywhere, so the intersection of the two walks is the merge base. + * @param {Array<{sha: string}>} branch - the branch's commits, newest first + * @param {string[]} mainShas - main's commits, newest first + * @returns {{ahead: number, base: string|null, behind: number, + * merged: boolean, rooted: boolean}} + */ +export function place(branch, mainShas) { + const inMain = new Set(mainShas); + const here = branch[0]?.sha; + if (!here || inMain.has(here)) { + return { + ahead: 0, + base: here ?? null, + behind: 0, + merged: true, + rooted: true, + }; + } + let ahead = 0; + let base = null; + for (const entry of branch) { + if (inMain.has(entry.sha)) { + base = entry.sha; + break; + } + ahead++; + } + // No shared commit inside either walk. The branch is not placed against + // main, and counting it as ahead of everything would be a guess. + if (!base) + return { ahead, base: null, behind: 0, merged: false, rooted: false }; + return { + ahead, + base, + behind: mainShas.indexOf(base), + merged: false, + rooted: true, + }; +} + +/** + * Where a merged branch's work began, read from the merge that brought it in. + * + * Once main reaches a branch there is no divergence left to measure: the + * branch's commits are main's commits. A merge commit still says where they + * came from. Its second parent is the tip that was merged, and its first + * parents are main's own trunk, so the branch's earliest commit outside that + * trunk is where the work began. + * + * A branch fast-forwarded or squashed into main leaves no such commit. The + * honest answer from that history is the tip alone. + * @param {Array<{sha: string, parents: string[]}>} commits - the branch's, + * newest first + * @param {Array<{sha: string, parents: string[]}>} mainCommits - newest first + * @returns {{ahead: number, base: string|null}} + */ +export function mergedRun(commits, mainCommits) { + const tip = commits[0].sha; + const merge = mainCommits.find((entry) => + (entry.parents ?? []).slice(1).includes(tip), + ); + if (!merge) return { ahead: 1, base: commits[0].parents[0] ?? null }; + + const byMain = new Map(mainCommits.map((entry) => [entry.sha, entry])); + /** @type {Set} */ + const trunk = new Set(); + /** @type {string|undefined} */ + let at = merge.parents[0]; + while (at && !trunk.has(at)) { + trunk.add(at); + at = byMain.get(at)?.parents?.[0]; + } + + let ahead = 0; + for (const entry of commits) { + if (trunk.has(entry.sha)) { + return { ahead: Math.max(ahead, 1), base: entry.sha }; + } + ahead++; + } + return { ahead, base: commits[commits.length - 1].parents[0] ?? null }; +} + +/** + * A file list too long to read, said in one line. An agent's pull request + * touches tens of files, and printing them all buries everything else on the + * card. + * @param {string[]} paths + * @returns {{count: number, dirs: Array<{dir: string, count: number}>}} + * directories by how many of the paths are in them, most first + */ +export function fileSummary(paths) { + /** @type {Map} */ + const byDir = new Map(); + for (const path of paths) { + const cut = path.lastIndexOf('/'); + const dir = cut === -1 ? '' : path.slice(0, cut); + byDir.set(dir, (byDir.get(dir) ?? 0) + 1); + } + const dirs = [...byDir.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([dir, count]) => ({ dir, count })); + return { count: paths.length, dirs }; +} + +/** + * Which pull requests cannot merge together, by the files they touch. + * + * A shared path is not a textual conflict: two branches can edit one file in + * different places and merge cleanly. It is the cheap half of the question, + * answered from the commit list already read, and it is the half that says + * where to look. The expensive half is a three-way merge per pair. + * @param {PullRequest[]} pulls + * @returns {Map>} pull request to + * the ones it overlaps + */ +export function overlaps(pulls) { + /** @type {Map>} */ + const found = new Map(); + for (const pull of pulls) found.set(pull.id, []); + for (let i = 0; i < pulls.length; i++) { + for (let j = i + 1; j < pulls.length; j++) { + const a = pulls[i]; + const b = pulls[j]; + const shared = a.files.filter((path) => b.files.includes(path)); + if (shared.length === 0) continue; + // Every pull request was given an entry above, so both are there. + found.get(a.id)?.push({ id: b.id, files: shared }); + found.get(b.id)?.push({ id: a.id, files: shared }); + } + } + return found; +} + +/** + * The newest record per branch among one account's pull request records about + * one repository. What decorates a collaborator's branches with the words only + * the author can say: a title, a draft or withdrawn status, or a note. + * + * Keyed by short branch name, which with the account is what identifies a pull + * request. + * @param {unknown[]} rows - listRecords entries from that account + * @param {string} subjectUri - the canonical repository record's AT-URI + * @returns {Map} + */ +export function latestIntents(rows, subjectUri) { + const records = rows + .map( + (row) => + /** @type {Record} */ ( + /** @type {{value?: unknown}|null} */ (row)?.value ?? {} + ), + ) + .filter( + (value) => + value.subject?.uri === subjectUri && typeof value.ref === 'string', + ) + .sort((a, b) => + String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? '')), + ); + /** @type {Map>} */ + const index = new Map(); + for (const record of records) { + const short = shortRef(record.ref); + if (!index.has(short)) { + index.set(short, { + status: record.status, + note: record.note, + title: record.title, + createdAt: record.createdAt, + }); + } + } + return index; +} + +/** + * Every reviewer's records about this repository, indexed by what they name. + * A reviewer keys reviews by TID in one collection, so the listing is filtered + * here rather than by the server. + * @param {unknown} config + * @returns {Promise>>} newest first + */ + +/** + * The runner's runs about any copy of one repository. The runner checks the + * branch a collaborator pushed to that collaborator's own server, so a run + * names their record rather than the canonical one. + * @param {unknown[]} records - listRecords entries + * @param {string} repo - repository name, the record rkey in every copy + * @param {string[]} dids - the collaborators whose copies count + * @returns {Array>} newest first + */ +export function runsForCopies(records, repo, dids) { + const copies = new Set( + dids.map((did) => `at://${did}/${REPO_COLLECTION}/${repo}`), + ); + const runs = []; + for (const entry of records) { + const row = /** @type {{value?: unknown, uri?: unknown}|null} */ (entry); + const run = /** @type {Record} */ (row?.value ?? {}); + const subject = /** @type {{uri?: unknown}} */ (run.subject ?? {}); + if (typeof subject.uri !== 'string' || !copies.has(subject.uri)) continue; + if (typeof run.sha !== 'string') continue; + // The record key, which is what addresses the run's own page. Without it + // a run reads here and cannot be opened. + const rkey = String(row?.uri ?? '') + .split('/') + .pop(); + runs.push(rkey ? { ...run, rkey } : run); + } + return runs.sort((a, b) => + String(b.startedAt ?? '').localeCompare(String(a.startedAt ?? '')), + ); +} + +/** + * The pull request one statement names: its author DID and ref, or the DID in + * the repository record it read and its ref. A check names the copy it ran + * against rather than an author, and both forms carry the branch. + * @param {Record} statement + * @returns {string|null} + */ +function statementPull(statement) { + const author = + typeof statement.author === 'string' + ? statement.author + : didOf(/** @type {{uri?: string}} */ (statement.subject ?? {}).uri); + const ref = statement.ref; + if (!author || typeof ref !== 'string') return null; + return pullKey(author, shortRef(ref)); +} + +/** + * Index statements by what they name. A statement reaches its pull request + * under two keys, the author and branch pair and the sha it was written + * against, so a reader finds it whether or not the writer named the branch. + * @param {Array>} statements - newest first + * @returns {Map>>} + */ +export function byPull(statements) { + /** @type {Map>>} */ + const index = new Map(); + for (const statement of statements) { + for (const key of [statementPull(statement), statement.sha]) { + if (typeof key !== 'string') continue; + const list = index.get(key) ?? []; + if (!list.includes(statement)) list.push(statement); + index.set(key, list); + } + } + return index; +} + +/** + * The tips one branch has carried that are not the tip now, newest first. + * + * A force-push puts the commit before it out of reach: nothing in the graph + * says it existed, and a review or a check written against it names a sha + * nothing points at. Two things remember, and the union is what a reader can + * say. The runner's push log saw every tip. The checks and reviews name the + * tip they answered. + * + * What this is for is telling an old tip from an unrelated one, so a comment + * on a rewritten commit reads as outdated rather than as lost. + * @param {PullRequest} pull + * @param {Array>} statements - about this pull request + * @param {Array<{sha: string, seenAt: string}>} [log] - pushes to this ref, + * newest first + * @returns {Set} commit ids, the tip excluded + */ +export function replacedTips(pull, statements, log = []) { + /** @type {Set} */ + const tips = new Set(); + for (const statement of statements) { + if (typeof statement.sha === 'string') tips.add(statement.sha); + } + for (const version of log) { + if (typeof version?.sha === 'string') tips.add(version.sha); + } + tips.delete(pull.sha); + return tips; +} + +/** + * The pushes one runner logged, by the pull request they were made to. + * @param {Array>} records - one runner's version + * records, each read for one copy + * @returns {Map>} + */ +export function byPushLog(records) { + /** @type {Map>} */ + const index = new Map(); + for (const record of records) { + const did = didOf(/** @type {{uri?: string}} */ (record.subject ?? {}).uri); + const refs = record.refs; + if (!did || !Array.isArray(refs)) continue; + for (const entry of refs) { + const { ref, versions } = /** @type {Record} */ (entry ?? {}); + if (typeof ref !== 'string' || !Array.isArray(versions)) continue; + index.set( + pullKey(did, shortRef(ref)), + versions.filter((one) => typeof one?.sha === 'string'), + ); + } + } + return index; +} diff --git a/packages/git/test/discovery.test.js b/packages/git/test/discovery.test.js index d13d961..ffba7da 100644 --- a/packages/git/test/discovery.test.js +++ b/packages/git/test/discovery.test.js @@ -160,6 +160,26 @@ describe('createConstellationDiscovery', () => { }); }); +describe('createConstellationDiscovery, on the global fetch', () => { + it('carries the request on globalThis.fetch when given no carrier', async () => { + const real = globalThis.fetch; + /** @type {string[]} */ + const asked = []; + globalThis.fetch = async (input) => { + asked.push(String(input)); + return Response.json({ records: [], cursor: null }); + }; + try { + expect(await createConstellationDiscovery().about(SUBJECT, 'c')).toEqual( + [], + ); + expect(asked[0]?.startsWith(`${CONSTELLATION_URL}/xrpc/`)).toBe(true); + } finally { + globalThis.fetch = real; + } + }); +}); + describe('noDiscovery', () => { it('finds nothing', async () => { expect(await noDiscovery().about(SUBJECT, 'dev.pdsjs.git.pull')).toEqual( diff --git a/packages/git/test/issues.test.js b/packages/git/test/issues.test.js new file mode 100644 index 0000000..25b23e0 --- /dev/null +++ b/packages/git/test/issues.test.js @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import { issueState, issueThreads } from '../src/issues.js'; + +describe('issueState and issueThreads', () => { + const OWNER = 'did:plc:owner'; + /** @param {Partial>} value */ + const entry = (value) => ({ + by: { did: 'did:plc:filer', handle: 'filer' }, + ...value, + }); + + it('reads the state from the owner and nobody else', () => { + const thread = [ + entry({ uri: 'at://a', createdAt: '2026-08-01T00:00:00Z' }), + entry({ + uri: 'at://b', + createdAt: '2026-08-02T00:00:00Z', + disposition: 'resolved', + }), + ]; + // The filer marking their own issue resolved is a comment, not a state. + expect(issueState(thread, OWNER).state).toBe('open'); + + thread.push( + entry({ + by: { did: OWNER, handle: 'owner' }, + uri: 'at://c', + createdAt: '2026-08-03T00:00:00Z', + disposition: 'resolved', + }), + ); + expect(issueState(thread, OWNER)).toMatchObject({ state: 'resolved' }); + }); + + it('reads the latest word, so reopened takes back a decline', () => { + const owner = { did: OWNER, handle: 'owner' }; + const thread = [ + entry({ uri: 'at://a', createdAt: '2026-08-01T00:00:00Z' }), + entry({ + by: owner, + uri: 'at://b', + createdAt: '2026-08-02T00:00:00Z', + disposition: 'declined', + }), + ]; + expect(issueState(thread, OWNER)).toMatchObject({ state: 'declined' }); + + thread.push( + entry({ + by: owner, + uri: 'at://c', + createdAt: '2026-08-03T00:00:00Z', + disposition: 'reopened', + }), + ); + expect(issueState(thread, OWNER)).toMatchObject({ + state: 'open', + said: { uri: 'at://c', disposition: 'reopened' }, + }); + }); + + it('leaves the state alone when anybody but the owner reopens', () => { + const thread = [ + entry({ uri: 'at://a', createdAt: '2026-08-01T00:00:00Z' }), + entry({ + by: { did: OWNER, handle: 'owner' }, + uri: 'at://b', + createdAt: '2026-08-02T00:00:00Z', + disposition: 'declined', + }), + entry({ + uri: 'at://c', + createdAt: '2026-08-03T00:00:00Z', + disposition: 'reopened', + }), + ]; + expect(issueState(thread, OWNER)).toMatchObject({ state: 'declined' }); + }); + + it('reads a word off Object.prototype as no disposition at all', () => { + const thread = [ + entry({ + by: { did: OWNER, handle: 'owner' }, + uri: 'at://a', + createdAt: '2026-08-01T00:00:00Z', + disposition: 'constructor', + }), + ]; + expect(issueState(thread, OWNER)).toEqual({ state: 'open' }); + }); + + it('groups replies with their opener, newest activity first', () => { + const threads = issueThreads( + [ + entry({ + uri: 'at://old', + title: 'old', + createdAt: '2026-08-01T00:00:00Z', + }), + entry({ + uri: 'at://young', + title: 'young', + createdAt: '2026-08-02T00:00:00Z', + }), + entry({ + uri: 'at://answer', + createdAt: '2026-08-03T00:00:00Z', + replyTo: { uri: 'at://old' }, + }), + ], + OWNER, + ); + expect(threads.map((t) => t.root.title)).toEqual(['old', 'young']); + expect(threads[0].thread).toHaveLength(2); + }); +}); diff --git a/packages/git/test/pull-requests.test.js b/packages/git/test/pull-requests.test.js new file mode 100644 index 0000000..e5cda8f --- /dev/null +++ b/packages/git/test/pull-requests.test.js @@ -0,0 +1,341 @@ +import { describe, expect, it } from 'vitest'; +import { + byPull, + byPushLog, + fileSummary, + fixesOf, + latestIntents, + mergedRun, + overlaps, + place, + pullKey, + replacedTips, + runsForCopies, + subjectOf, +} from '../src/pull-requests.js'; + +/** @param {string} name */ +const sha = (name) => name.repeat(40).slice(0, 40); + +/** @param {string} name @param {string} [message] */ +const commit = (name, message = '') => ({ sha: sha(name), message }); + +describe('pullKey', () => { + it('names a pull request by the account and the branch', () => { + expect(pullKey('did:plc:bob', 'agent/docs')).toBe('did:plc:bob agent/docs'); + }); + + it('tells two branches of one account apart', () => { + expect(pullKey('did:plc:bob', 'a')).not.toBe(pullKey('did:plc:bob', 'b')); + }); +}); + +describe('subjectOf', () => { + it('is the first line', () => { + expect(subjectOf('add farewell()\n\nA body.\n')).toBe('add farewell()'); + }); +}); + +describe('place', () => { + const main = [sha('d'), sha('c'), sha('b'), sha('a')]; + + it('counts the commits main does not have, and names the merge base', () => { + const placed = place([commit('f'), commit('e'), commit('c')], main); + expect(placed.ahead).toBe(2); + expect(placed.base).toBe(sha('c')); + expect(placed.merged).toBe(false); + expect(placed.rooted).toBe(true); + }); + + it('counts the commits main has that the branch does not', () => { + // Branched at b, which main has moved two commits past. + expect(place([commit('e'), commit('b')], main).behind).toBe(2); + expect(place([commit('e'), commit('d')], main).behind).toBe(0); + }); + + it('reports a tip main already reaches as merged', () => { + const placed = place([commit('c'), commit('b')], main); + expect(placed.merged).toBe(true); + expect(placed.ahead).toBe(0); + expect(placed.behind).toBe(0); + }); + + it('reports a branch sharing no commit with main as unrooted', () => { + const placed = place([commit('z'), commit('y')], main); + expect(placed.rooted).toBe(false); + expect(placed.merged).toBe(false); + // Nothing places it, so a distance would be a guess. + expect(placed.behind).toBe(0); + }); + + it('treats an empty branch as merged', () => { + expect(place([], main).merged).toBe(true); + }); +}); + +describe('mergedRun', () => { + /** @param {string} name @param {string[]} parents */ + const entry = (name, parents = []) => ({ + sha: sha(name), + parents: parents.map(sha), + }); + + // main: m (merge of t and p) over p over o. The branch is t over s over p. + const branch = [entry('t', ['s']), entry('s', ['p']), entry('p', ['o'])]; + const mainCommits = [entry('m', ['p', 't']), entry('p', ['o']), entry('o')]; + + it('takes the commits the merge brought in, and where they began', () => { + const run = mergedRun(branch, mainCommits); + expect(run.ahead).toBe(2); + expect(run.base).toBe(sha('p')); + }); + + it('takes the tip alone where no merge commit names it', () => { + const run = mergedRun(branch, [entry('p', ['o']), entry('o')]); + expect(run.ahead).toBe(1); + expect(run.base).toBe(sha('s')); + }); + + it('reports no base at the root of a history', () => { + expect(mergedRun([entry('a')], []).base).toBe(null); + }); +}); + +describe('overlaps', () => { + /** Only what overlaps reads of a pull request. */ + const pulls = /** @type {any} */ ([ + { id: 'a', files: ['index.js', 'test.js'] }, + { id: 'b', files: ['index.js'] }, + { id: 'c', files: ['README.md'] }, + ]); + + it('pairs the pull requests touching one file', () => { + const found = overlaps(pulls); + expect(found.get('a')).toEqual([{ id: 'b', files: ['index.js'] }]); + expect(found.get('b')).toEqual([{ id: 'a', files: ['index.js'] }]); + expect(found.get('c')).toEqual([]); + }); + + it('names every one, so a pull request sharing nothing is in the answer', () => { + expect([...overlaps(pulls).keys()]).toEqual(['a', 'b', 'c']); + expect([...overlaps([]).keys()]).toEqual([]); + }); +}); + +describe('fileSummary', () => { + it('counts the paths per directory, most first', () => { + const summary = fileSummary([ + 'packages/core/src/http.js', + 'packages/core/src/auth.js', + 'README.md', + 'packages/git/src/pack.js', + ]); + expect(summary.count).toBe(4); + expect(summary.dirs).toEqual([ + { dir: 'packages/core/src', count: 2 }, + { dir: '', count: 1 }, + { dir: 'packages/git/src', count: 1 }, + ]); + }); + + it('counts nothing for no paths', () => { + expect(fileSummary([])).toEqual({ count: 0, dirs: [] }); + }); +}); + +describe('runsForCopies', () => { + const dids = ['did:plc:alice', 'did:plc:bob']; + /** @param {string} did @param {string} startedAt */ + const run = (did, startedAt, rkey = 'project') => ({ + value: { + subject: { uri: `at://${did}/dev.pdsjs.git.repo/${rkey}` }, + sha: sha('a'), + startedAt, + }, + }); + + it('keeps the runs about any named copy, newest first', () => { + const runs = runsForCopies( + [ + run('did:plc:alice', '2026-08-01T00:00:00Z'), + run('did:plc:bob', '2026-08-02T00:00:00Z'), + ], + 'project', + dids, + ); + expect(runs.map((entry) => entry.startedAt)).toEqual([ + '2026-08-02T00:00:00Z', + '2026-08-01T00:00:00Z', + ]); + }); + + it('drops another repository, and an account nobody named', () => { + const runs = runsForCopies( + [ + run('did:plc:alice', '2026-08-01T00:00:00Z', 'other'), + run('did:plc:mallory', '2026-08-01T00:00:00Z'), + { value: { subject: { uri: 42 } } }, + {}, + ], + 'project', + dids, + ); + expect(runs).toEqual([]); + }); +}); + +describe('latestIntents', () => { + const SUBJECT = 'at://did:plc:owner/dev.pdsjs.git.repo/project'; + /** @param {Partial>} value */ + const row = (value) => ({ value: { subject: { uri: SUBJECT }, ...value } }); + + it('keys the newest record by branch', () => { + const index = latestIntents( + [ + row({ + ref: 'refs/heads/agent/docs', + title: 'Document the reader', + status: 'draft', + createdAt: '2026-08-02T00:00:00Z', + }), + row({ + ref: 'refs/heads/agent/docs', + createdAt: '2026-08-01T00:00:00Z', + }), + ], + SUBJECT, + ); + expect(index.get('agent/docs')?.status).toBe('draft'); + expect(index.get('agent/docs')?.title).toBe('Document the reader'); + }); + + it('drops records about another repository, and malformed rows', () => { + const index = latestIntents( + [ + row({ + subject: { uri: 'at://did:plc:owner/dev.pdsjs.git.repo/other' }, + ref: 'refs/heads/x', + }), + row({ ref: 42 }), + {}, + ], + SUBJECT, + ); + expect(index.size).toBe(0); + }); +}); + +describe('fixesOf', () => { + it('reads each Fixes trailer as an issue address', () => { + const message = [ + 'refuse a non-string name', + '', + 'Fixes: at://did:plc:dan/dev.pdsjs.git.issue/3kissue', + ].join('\n'); + expect(fixesOf(message)).toEqual([ + 'at://did:plc:dan/dev.pdsjs.git.issue/3kissue', + ]); + }); + + it('ignores prose mentioning a fix and messages without one', () => { + expect(fixesOf('this fixes the bug')).toEqual([]); + expect(fixesOf('Fixes: #123')).toEqual([]); + expect(fixesOf('')).toEqual([]); + }); +}); + +describe('byPull', () => { + it('indexes a review under its pull request and its sha', () => { + const statement = { + author: 'did:plc:bob', + ref: 'refs/heads/agent/docs', + sha: sha('a'), + }; + const index = byPull([statement]); + expect(index.get(pullKey('did:plc:bob', 'agent/docs'))).toEqual([ + statement, + ]); + expect(index.get(sha('a'))).toEqual([statement]); + }); + + it("reads a check's pull request off the copy it ran against", () => { + const run = { + subject: { uri: 'at://did:plc:bob/dev.pdsjs.git.repo/project' }, + ref: 'refs/heads/agent/docs', + sha: sha('a'), + }; + const index = byPull([run]); + expect(index.get(pullKey('did:plc:bob', 'agent/docs'))).toEqual([run]); + }); + + it('indexes a statement naming no branch under its sha alone', () => { + const index = byPull([{ sha: sha('a') }]); + expect([...index.keys()]).toEqual([sha('a')]); + }); +}); + +describe('replacedTips', () => { + /** Only what replacedTips reads of a pull request. @param {string} name */ + const tip = (name) => /** @type {any} */ ({ sha: sha(name) }); + + it('names every tip the branch has carried but the one it carries now', () => { + const replaced = replacedTips(tip('c'), [ + { sha: sha('b'), startedAt: '2026-08-02T00:00:00Z' }, + { sha: sha('a'), reviewedAt: '2026-08-01T00:00:00Z' }, + { sha: sha('c'), startedAt: '2026-08-03T00:00:00Z' }, + ]); + expect([...replaced].sort()).toEqual([sha('a'), sha('b')]); + // The tip is not something the branch replaced. + expect(replaced.has(sha('c'))).toBe(false); + }); + + it('takes a tip from the push log that nothing else names', () => { + const replaced = replacedTips( + tip('c'), + [], + [ + { sha: sha('b'), seenAt: '2026-08-02T00:00:00Z' }, + { sha: sha('a'), seenAt: '2026-08-01T00:00:00Z' }, + ], + ); + expect([...replaced].sort()).toEqual([sha('a'), sha('b')]); + }); + + it('names nothing where the branch has never been rewritten', () => { + expect(replacedTips(tip('a'), []).size).toBe(0); + expect( + replacedTips(tip('a'), [{ sha: sha('a'), reviewedAt: '' }]).size, + ).toBe(0); + }); +}); + +describe('byPushLog', () => { + const record = { + subject: { uri: 'at://did:plc:bob/dev.pdsjs.git.repo/project' }, + refs: [ + { + ref: 'refs/heads/agent/docs', + versions: [ + { sha: sha('b'), seenAt: '2026-08-02T00:00:00Z' }, + { sha: sha('a'), seenAt: '2026-08-01T00:00:00Z' }, + ], + }, + ], + }; + + it('keys a log by the account the subject names and the branch', () => { + const index = byPushLog([record]); + expect( + index.get(pullKey('did:plc:bob', 'agent/docs'))?.map((one) => one.sha), + ).toEqual([sha('b'), sha('a')]); + }); + + it('drops a record naming no copy, and a malformed ref entry', () => { + const index = byPushLog([ + { refs: [] }, + { subject: { uri: 42 }, refs: [] }, + { ...record, refs: [{ ref: 'refs/heads/x' }, { versions: [] }] }, + ]); + expect(index.size).toBe(0); + }); +}); diff --git a/vitest.config.js b/vitest.config.js index a8475fc..1af1f1d 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -210,6 +210,16 @@ export default defineConfig({ branches: 90, functions: 100, }, + 'packages/git/src/pull-requests.js': { + statements: 95, + branches: 85, + functions: 100, + }, + 'packages/git/src/issues.js': { + statements: 95, + branches: 78, + functions: 100, + }, 'packages/git-ci/src/{issues,pulls,versions}.js': { statements: 94, branches: 87,