// The pull request list as the server reads it: the owner's own copy through // its own browser, every listed collaborator's copy through theirs, and the // strangers the discovery port names, each read from their own server. import { describe, expect, it } from 'vitest'; import { createPullRequestReader } from '../src/pull-request-reader.js'; const ALICE = 'did:plc:alice'; const BOB = 'did:plc:bob'; const CAROL = 'did:plc:carol'; const DAN = 'did:plc:dan'; const EVE = 'did:plc:eve'; const RUNNER = 'did:plc:runner'; const REPO = 'demo'; const SUBJECT = `at://${ALICE}/dev.pdsjs.git.repo/${REPO}`; /** @param {string} name */ const sha = (name) => name.repeat(40).slice(0, 40); /** * @param {string} name * @param {string[]} parents * @param {string} message * @param {number} at */ const commit = (name, parents, message, at) => ({ sha: sha(name), parents: parents.map(sha), message, committedAt: at, author: 'Someone ', authoredAt: at, }); /** @type {Record} */ const PEOPLE = { [ALICE]: { did: ALICE, handle: 'alice.test', pds: 'https://alice.test' }, [BOB]: { did: BOB, handle: 'bob.test', pds: 'https://bob.test' }, [CAROL]: { did: CAROL, handle: 'carol.test', pds: 'https://carol.test' }, [DAN]: { did: DAN, handle: 'dan.test', pds: 'https://dan.test' }, [EVE]: { did: EVE, handle: 'eve.test', pds: 'https://eve.test' }, [RUNNER]: { did: RUNNER, handle: 'runner.test', pds: 'https://runner.test' }, }; // main: m3 -> m2 -> m1. Bob's feature: f2 -> f1 -> m2. Dan's fix: d1 -> m3. const MAIN = [ commit('3', ['2'], 'third', 3000), commit('2', ['1'], 'second', 2000), commit('1', [], 'first', 1000), ]; const FEATURE = [ commit( 'f', ['e'], 'feature done\n\nFixes: at://did:plc:x/dev.pdsjs.git.issue/1', 5000, ), commit('e', ['2'], 'feature started', 4000), ...MAIN.slice(1), ]; const FIX = [commit('d', ['3'], 'fix it', 6000), ...MAIN]; /** * A world of records and copies, answered by URL. * @param {Object} [options] * @param {Array<{did: string, collection: string, rkey: string}>} [options.about] - what discovery answers for every collection * @param {string[]} [options.unreachable] - DIDs whose servers refuse * @param {string[]} [options.refusing] - DIDs whose servers answer 500 * @param {Record} [options.config] - the repository's config record * @param {{commits: unknown[]}|null} [options.mainLog] - what the owner's browser answers for main * @param {string[]} [options.unresolvable] - DIDs with no document * @param {string} [options.branchReadme] - README.md at bob's tip * @param {string} [options.baseReadme] - README.md at the base * @param {string} [options.mainReadme] - README.md at main's tip * @param {string[]} [options.drift] - paths main changed since the base */ function world({ about = [], unreachable = [], refusing = [], config = /** @type {Record} */ ({ collaborators: [BOB], reviewers: [CAROL], runner: RUNNER, }), mainLog = /** @type {{commits: unknown[]}|null} */ ({ commits: MAIN }), unresolvable = /** @type {string[]} */ ([]), // README.md on bob's branch. Main changed the first line since the base; // a change to the last line merges, one to the first does not. Lines apart, // because git reads a change to the line beside one as touching it. branchReadme = 'a\nb\nc\nD\n', baseReadme = 'a\nb\nc\nd\n', mainReadme = 'A\nb\nc\nd\n', drift = ['README.md'], } = {}) { /** @type {string[]} */ const asked = []; /** @type {Array<{did: string, branch: string}>} */ const walked = []; /** @type {Record>} records per did per collection */ const records = { [BOB]: { // A collaborator's copy is their own record, found by the name the two // copies share. 'dev.pdsjs.git.repo': [ { uri: `at://${BOB}/dev.pdsjs.git.repo/${REPO}`, cid: 'br1', value: { name: REPO, refs: [], bundles: [], createdAt: '2026-08-01T00:00:00Z', }, }, ], 'dev.pdsjs.git.pull': [ { uri: `at://${BOB}/dev.pdsjs.git.pull/demo:refs_heads_feature`, cid: 'b1', value: { subject: { uri: SUBJECT }, repo: REPO, ref: 'refs/heads/feature', sha: sha('f'), title: 'Bob named it', note: 'a note', createdAt: '2026-08-02T00:00:00Z', }, }, ], 'dev.pdsjs.git.review': [], }, [CAROL]: { 'dev.pdsjs.git.review': [ { uri: `at://${CAROL}/dev.pdsjs.git.review/1`, cid: 'c1', value: { subject: { uri: SUBJECT }, author: BOB, ref: 'refs/heads/feature', sha: sha('f'), verdict: 'approve', reviewedAt: '2026-08-03T00:00:00Z', }, }, { uri: `at://${CAROL}/dev.pdsjs.git.review/2`, cid: 'c2', value: { subject: { uri: `at://${ALICE}/dev.pdsjs.git.repo/other` }, author: BOB, ref: 'refs/heads/feature', sha: sha('f'), verdict: 'approve', reviewedAt: '2026-08-03T00:00:00Z', }, }, // Written against bob's own copy, which is where the branch is. { uri: `at://${CAROL}/dev.pdsjs.git.review/3`, cid: 'c3', value: { subject: { uri: `at://${BOB}/dev.pdsjs.git.repo/${REPO}` }, author: BOB, ref: 'refs/heads/feature', sha: sha('f'), verdict: 'comment', note: 'on the copy', reviewedAt: '2026-08-04T00:00:00Z', }, }, ], 'dev.pdsjs.git.pull': [], }, [DAN]: { 'dev.pdsjs.git.repo': [ { uri: `at://${DAN}/dev.pdsjs.git.repo/${REPO}`, cid: 'dr1', value: { name: REPO, upstream: SUBJECT, refs: [], bundles: [], createdAt: '2026-08-01T00:00:00Z', }, }, ], 'dev.pdsjs.git.pull': [ { // The current word, at the key the branch derives. Legacy records // with server-generated keys are stale. uri: `at://${DAN}/dev.pdsjs.git.pull/demo:refs_heads_fix`, cid: 'd4', value: { subject: { uri: SUBJECT }, repo: REPO, ref: 'refs/heads/fix', sha: sha('d'), title: 'Dan renamed it', note: 'the update', createdAt: '2026-08-05T00:00:00Z', }, }, { uri: `at://${DAN}/dev.pdsjs.git.pull/3d`, cid: 'd1', value: { subject: { uri: SUBJECT }, repo: REPO, ref: 'refs/heads/fix', sha: sha('d'), title: 'Dan named it', status: 'draft', createdAt: '2026-08-01T00:00:00Z', }, }, { uri: `at://${DAN}/dev.pdsjs.git.pull/elsewhere`, cid: 'd2', value: { subject: { uri: `at://${ALICE}/dev.pdsjs.git.repo/other` }, repo: 'other', ref: 'refs/heads/fix', sha: sha('d'), }, }, ], }, [EVE]: { 'dev.pdsjs.git.review': [ { uri: `at://${EVE}/dev.pdsjs.git.review/1`, cid: 'e1', value: { subject: { uri: SUBJECT }, author: DAN, ref: 'refs/heads/fix', sha: sha('d'), verdict: 'changesRequested', reviewedAt: '2026-08-04T00:00:00Z', }, }, ], }, [RUNNER]: { 'dev.pdsjs.git.check': [ { uri: `at://${RUNNER}/dev.pdsjs.git.check/r1`, cid: 'r1', value: { subject: { uri: `at://${BOB}/dev.pdsjs.git.repo/${REPO}` }, ref: 'refs/heads/feature', sha: sha('f'), status: 'success', startedAt: '2026-08-05T00:00:00Z', }, }, { // A copy the config does not name, which runsForCopies drops. uri: `at://${RUNNER}/dev.pdsjs.git.check/r2`, cid: 'r2', value: { subject: { uri: `at://${DAN}/dev.pdsjs.git.repo/${REPO}` }, ref: 'refs/heads/fix', sha: sha('d'), status: 'failure', startedAt: '2026-08-06T00:00:00Z', }, }, ], }, }; /** @type {typeof fetch} */ const send = async (input) => { const url = new URL(String(input)); asked.push(url.href); const who = Object.values(PEOPLE).find((one) => one.pds === url.origin); if (!who || unreachable.includes(who.did)) { throw new TypeError('fetch failed'); } if (refusing.includes(who.did)) return new Response('no', { status: 500 }); const collection = url.searchParams.get('collection') ?? ''; const held = records[who.did]?.[collection] ?? []; if (url.pathname.endsWith('listRecords')) { // The runner's runs come in two pages, to prove the cursor is followed. if (who.did === RUNNER) { const cursor = url.searchParams.get('cursor'); return cursor ? Response.json({ records: held.slice(1), cursor: null }) : Response.json({ records: held.slice(0, 1), cursor: 'more' }); } return Response.json({ records: held, cursor: null }); } if (url.pathname.endsWith('getRecord')) { const rkey = url.searchParams.get('rkey'); const row = /** @type {{uri: string}[]} */ (held).find((one) => one.uri.endsWith(`/${rkey}`), ); return row ? Response.json(row) : new Response('no', { status: 404 }); } return new Response('no', { status: 404 }); }; /** @type {Record>} branches per did */ const copies = { [BOB]: { main: MAIN, feature: FEATURE }, [DAN]: { fix: FIX, other: [commit('o', ['3'], 'not asked for', 7000)] }, }; /** @param {{did: string}} who */ const openCopy = (who) => ({ refNames: async () => Object.keys(copies[who.did] ?? {}).map((name) => `refs/heads/${name}`), /** @param {string} _did @param {string} _repo @param {string} branch */ listCommits: async (_did, _repo, branch) => { walked.push({ did: who.did, branch }); const log = copies[who.did]?.[branch]; return log ? { commits: log } : null; }, /** * @param {string} _did @param {string} _repo @param {string} _base * @param {string} tip */ changedPaths: async (_did, _repo, _base, tip) => tip === sha('f') ? [...new Set(['README.md', 'src/a.js', ...drift])] : ['src/a.js'], /** @param {string} _did @param {string} _repo @param {string} _sha @param {string} _path */ readFile: async (_did, _repo, _sha, _path) => ({ text: branchReadme, binary: false, }), }); const reader = createPullRequestReader({ resolveContributor: async (did) => unresolvable.includes(did) ? null : (PEOPLE[did] ?? null), readRecord: async (_did, collection, rkey) => { if (rkey !== REPO) return null; if (collection === 'dev.pdsjs.git.repo') { return { name: REPO, defaultBranch: 'main', refs: [{ name: 'refs/heads/main', sha: sha('3') }], }; } return config; }, browser: /** @type {any} */ ({ listCommits: async () => mainLog, // What main changed since the merge base, for the drift. changedPaths: async () => drift, // Every drifted path reads the same base/main text here. /** @param {string} _did @param {string} _repo @param {string} at */ readFile: async (_did, _repo, at) => ({ text: at === sha('3') ? mainReadme : baseReadme, binary: false, }), }), discovery: { about: async (_subject, collection) => about.filter((entry) => entry.collection === collection), }, fetch: send, openCopy: /** @type {any} */ (openCopy), }); return { reader, asked, walked }; } describe('createPullRequestReader', () => { it("places a collaborator's branch against main and takes their own word on it", async () => { const { reader } = world(); const list = await reader.listPulls(ALICE, REPO); expect(list).not.toBeNull(); const pulls = /** @type {NonNullable} */ (list); expect(pulls.open.map((pull) => pull.id)).toEqual([`${BOB} feature`]); expect(pulls.merged).toEqual([]); const [feature] = pulls.open; expect(feature).toMatchObject({ author: PEOPLE[BOB], branch: 'feature', sha: sha('f'), ahead: 2, behind: 1, base: sha('2'), merged: false, rooted: true, files: ['README.md', 'src/a.js'], fixes: ['at://did:plc:x/dev.pdsjs.git.issue/1'], // The author's record renames it and carries their note. title: 'Bob named it', note: 'a note', noteAt: '2026-08-02T00:00:00Z', // Main changed README.md since the branch point, and so did the branch, // on different lines, so the two go together. drift: ['README.md'], conflicts: [], }); expect(feature.fork).toBeUndefined(); expect(pulls.collaborators.map((who) => who.did)).toEqual([ALICE, BOB]); expect(pulls.configured).toBe(true); }); it('leaves a branch drifting more files than it reads for conflicts as unknown, not clean', async () => { // Main and the branch both change 40 files, the same lines, but the list // reads only the first 25 for conflicts. Here the two change different // lines, so none of the read subset conflicts; the rest are unread. const paths = Array.from({ length: 40 }, (_, at) => `f${at}.txt`); const { reader } = world({ drift: paths, // The branch's version differs from main's on a line apart, so a read // path merges; whether the unread ones do is unknown. branchReadme: 'a\nb\nc\nD\n', mainReadme: 'A\nb\nc\nd\n', baseReadme: 'a\nb\nc\nd\n', }); const pulls = /** @type {NonNullable>>} */ ( await reader.listPulls(ALICE, REPO) ); const feature = pulls.open.find((pull) => pull.author.did === BOB); expect(feature?.drift).toHaveLength(40); expect(feature?.conflicts).toEqual([]); expect(feature?.conflictsUnknown).toBe(true); }); it('names the path where the branch and main changed the same line', async () => { const { reader } = world({ branchReadme: 'x\nb\nc\nd\n' }); const pulls = /** @type {NonNullable>>} */ ( await reader.listPulls(ALICE, REPO) ); expect(pulls.open[0].conflicts).toEqual(['README.md']); }); it('reads a stranger the discovery port names, and only the branch their record names', async () => { const { reader, walked } = world({ about: [ { did: DAN, collection: 'dev.pdsjs.git.pull', rkey: 'demo:refs_heads_fix', }, // A record about another repository, which the record itself refutes. { did: DAN, collection: 'dev.pdsjs.git.pull', rkey: 'elsewhere' }, // A collaborator, already read whole, is not read twice. { did: BOB, collection: 'dev.pdsjs.git.pull', rkey: 'demo:refs_heads_feature', }, ], }); const pulls = /** @type {NonNullable>>} */ ( await reader.listPulls(ALICE, REPO) ); const fix = pulls.open.find((pull) => pull.author.did === DAN); expect(fix).toMatchObject({ id: `${DAN} fix`, fork: true, title: 'Dan renamed it', note: 'the update', ahead: 1, behind: 0, base: sha('3'), files: ['src/a.js'], }); // Dan's other branch is not this repository's business. expect(walked.filter((one) => one.did === DAN)).toEqual([ { did: DAN, branch: 'fix' }, ]); // Newest tip first. expect(pulls.open.map((pull) => pull.branch)).toEqual(['fix', 'feature']); }); it("prefers the record at the branch's derived key over another discovery names", async () => { // Discovery names the derived-key record and an older one. Either order, // the record the branch's key derives is the current word. const { reader } = world({ about: [ { did: DAN, collection: 'dev.pdsjs.git.pull', rkey: '3d' }, { did: DAN, collection: 'dev.pdsjs.git.pull', rkey: 'demo:refs_heads_fix', }, ], }); const pulls = /** @type {NonNullable>>} */ ( await reader.listPulls(ALICE, REPO) ); const fix = pulls.open.find((pull) => pull.author.did === DAN); expect(fix).toMatchObject({ title: 'Dan renamed it', note: 'the update', status: undefined, }); }); it('reads every branch of a copy that names this repository as its upstream', async () => { const { reader, walked } = world({ about: [{ did: DAN, collection: 'dev.pdsjs.git.repo', rkey: REPO }], }); const pulls = /** @type {NonNullable>>} */ ( await reader.listPulls(ALICE, REPO) ); const fromDan = pulls.open.filter((pull) => pull.author.did === DAN); expect(fromDan.map((pull) => pull.branch).sort()).toEqual(['fix', 'other']); expect(fromDan.every((pull) => pull.fork)).toBe(true); // No pull request record, so the tip's own subject names it. expect(fromDan.find((pull) => pull.branch === 'fix')?.title).toBe('fix it'); expect(walked.filter((one) => one.did === DAN)).toHaveLength(2); }); it('gathers the reviews from the listed voices and from whoever the port names', async () => { const { reader } = world({ about: [{ did: EVE, collection: 'dev.pdsjs.git.review', rkey: '1' }], }); const pulls = /** @type {NonNullable>>} */ ( await reader.listPulls(ALICE, REPO) ); const reviews = new Map(pulls.reviews); // Carol's reviews of bob's branch, whether they name alice's record or // bob's copy, and not her review of another repository. const onFeature = reviews.get(`${BOB} feature`) ?? []; expect(onFeature.map((one) => one.uri)).toEqual([ `at://${CAROL}/dev.pdsjs.git.review/1`, `at://${CAROL}/dev.pdsjs.git.review/3`, ]); expect(onFeature[0].by).toEqual(PEOPLE[CAROL]); // Eve is nobody the config names; her word arrives through the port, // read from her own server, and says so. const onFix = reviews.get(`${DAN} fix`) ?? []; expect(onFix).toHaveLength(1); expect(onFix[0]).toMatchObject({ uri: `at://${EVE}/dev.pdsjs.git.review/1`, verdict: 'changesRequested', by: PEOPLE[EVE], indexed: true, }); }); it("reads the runner's runs across pages, and keeps only the copies the config names", async () => { const { reader, asked } = world(); const pulls = /** @type {NonNullable>>} */ ( await reader.listPulls(ALICE, REPO) ); const checks = new Map(pulls.checks); expect(checks.get(`${BOB} feature`)?.map((run) => run.status)).toEqual([ 'success', ]); expect(checks.get(`${BOB} feature`)?.[0].rkey).toBe('r1'); // Dan's copy is nobody's collaborator here, so the run on it is not shown. expect(checks.has(`${DAN} fix`)).toBe(false); const runnerPages = asked.filter( (url) => url.startsWith('https://runner.test/') && url.includes('listRecords'), ); expect(runnerPages).toHaveLength(2); expect(runnerPages[1]).toContain('cursor=more'); }); it('costs an unreachable collaborator their branches and nothing else', async () => { const { reader } = world({ unreachable: [BOB], about: [{ did: DAN, collection: 'dev.pdsjs.git.pull', rkey: '3d' }], }); const pulls = /** @type {NonNullable>>} */ ( await reader.listPulls(ALICE, REPO) ); // Where a collaborator's copy is, is on their own server: the record is // theirs and carries their own key. A server that cannot be reached // costs their branches, and costs nobody else anything. expect(pulls.open.find((pull) => pull.author.did === BOB)).toBeUndefined(); expect(pulls.open.find((pull) => pull.author.did === DAN)).toBeDefined(); expect(pulls.reviews.length).toBeGreaterThan(0); }); it('answers null for a repository this account does not hold', async () => { const { reader } = world(); expect(await reader.listPulls(ALICE, 'nothing')).toBeNull(); }); }); describe('createPullRequestReader, when a server does not answer', () => { /** @param {ReturnType} made */ const list = async ({ reader }) => /** @type {NonNullable>>} */ ( await reader.listPulls(ALICE, REPO) ); it('shows no checks for a repository naming no runner, or one that does not resolve', async () => { expect( (await list(world({ config: { collaborators: [BOB] } }))).checks, ).toEqual([]); expect((await list(world({ unresolvable: [RUNNER] }))).checks).toEqual([]); }); it('lists nothing from a server that refuses, and reads no record from one', async () => { const pulls = await list( world({ refusing: [CAROL, DAN], about: [{ did: DAN, collection: 'dev.pdsjs.git.pull', rkey: '3d' }], }), ); expect(pulls.reviews).toEqual([]); expect(pulls.open.map((pull) => pull.author.did)).toEqual([BOB]); }); it('skips a stranger the port names whose account does not resolve', async () => { const pulls = await list( world({ unresolvable: [DAN], about: [{ did: DAN, collection: 'dev.pdsjs.git.pull', rkey: '3d' }], }), ); expect(pulls.open.map((pull) => pull.author.did)).toEqual([BOB]); }); it('reads a repository whose main has no commits as unrooted work, with nothing to drift from', async () => { const pulls = await list( world({ mainLog: null, config: { collaborators: [BOB] } }), ); const [feature] = pulls.open; expect(feature.rooted).toBe(false); expect(feature.files).toEqual([]); expect(feature.drift).toEqual([]); expect(pulls.configured).toBe(true); }); it('says so when the config names nobody', async () => { const pulls = await list(world({ config: {} })); expect(pulls.configured).toBe(false); expect(pulls.collaborators.map((who) => who.did)).toEqual([ALICE]); }); });