#!/usr/bin/env node // biome-ignore-all lint/suspicious/noTemplateCurlyInString: the placeholders // belong to the demo project's own source, not to these strings. /** * Local-dev seed for the collaboration prototype: one repository worked on by * four accounts, each on its own PDS. * * The shape under test is the one the space model already forces. Nobody * writes into anybody else's repo. Alice's copy of `demo-app` is canonical, * every other collaborator pushes branches into their own copy under the same * name, and the reader fans in. A pull request is the branch itself, named by * the account that holds it and the ref: a branch that is not an ancestor of * canonical main is open, and the object graph says so. * * What the seed puts in place, and what each part is there to answer: * * - Five accounts on five servers. Alice holds the canonical repo, Bob and * Carol collaborate, Nova is a review agent that never pushes code, and Dan * is a stranger nobody has named. * - A branch already merged (bob/agent/badge), so the browser can be seen * deriving "merged" from ancestry rather than from stored state. * - A branch rewritten by a rebase (bob/agent/farewell), with one check * against each version. The account and the ref are unchanged by the * rewrite, which is the case a sha-keyed artifact loses. * - A branch force-pushed three times (bob/agent/plural), failing twice and * then passing, once as a rebase across a main that moved. The second * failure is on a commit the branch still carries, so the timeline can say * which commit broke the build. Every tip carries a word, so there is an * answer beside every push and a comment on the first still reads against * the last. * - Two branches that touch the same file (bob/agent/farewell and * carol/agent/errors), so the conflict matrix has something to find. * - A branch taken before main moved (carol/agent/docs), so "behind" is * visible. * - Reviews as records in the reviewer's own repo, from a human and from an * agent, naming the account and the ref they read. * - A pull request from Dan, who is in no collaborator list, plus the * runner's index of it. That is the only path by which a repository hears * from somebody it has never named. * - A draft from Carol on her own agent/docs branch, the same record carrying * the words the graph cannot: a title and the author's own status. * * Every account must already exist. See `just dev-git-collab`, which starts * the five servers and then runs this. * * Usage: node scripts/dev-git-collab-demo.mjs [repo-name] * Env: PDS_DEV_URL alice's PDS (default http://localhost:2471) * PDS_DEV_BOB_URL (default http://localhost:2472) * PDS_DEV_CAROL_URL (default http://localhost:2473) * PDS_DEV_NOVA_URL (default http://localhost:2482) * PDS_DEV_DAN_URL (default http://localhost:2483) * PDS_DEV_PASSWORD (default test-password) * PDS_DEV_PLC_URL (default http://localhost:2582) * PDS_DEV_WORK_DIR (default .dev-pds/collab) */ import { spawn, spawnSync } from 'node:child_process'; import { appendFileSync, mkdirSync, openSync, readFileSync, rmSync, symlinkSync, writeFileSync, } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const PASSWORD = process.env.PDS_DEV_PASSWORD || 'test-password'; const PLC_URL = process.env.PDS_DEV_PLC_URL || 'http://localhost:2582'; const WORK = resolve(process.env.PDS_DEV_WORK_DIR || '.dev-pds/collab'); const NAME = process.argv[2] || 'demo-app'; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const REPO_COLLECTION = 'dev.pdsjs.git.repo'; const CONFIG_COLLECTION = 'dev.pdsjs.git.config'; const CHECK_COLLECTION = 'dev.pdsjs.git.check'; const REVIEW_COLLECTION = 'dev.pdsjs.git.review'; const PULL_COLLECTION = 'dev.pdsjs.git.pull'; const PULLS_COLLECTION = 'dev.pdsjs.git.pulls'; const IDENTITY_COLLECTION = 'dev.pdsjs.git.identity'; const ISSUE_COLLECTION = 'dev.pdsjs.git.issue'; const ISSUES_COLLECTION = 'dev.pdsjs.git.issues'; const LATEST_COLLECTION = 'dev.pdsjs.git.latestCheck'; /** The cast. Each is one server, because a PDS answers for a single DID. */ const PEOPLE = { alice: { base: process.env.PDS_DEV_URL || 'http://localhost:2471', name: 'Alice', email: 'alice@localhost', }, bob: { base: process.env.PDS_DEV_BOB_URL || 'http://localhost:2472', name: 'Bob', email: 'bob@localhost', }, carol: { base: process.env.PDS_DEV_CAROL_URL || 'http://localhost:2473', name: 'Carol', email: 'carol@localhost', }, nova: { base: process.env.PDS_DEV_NOVA_URL || 'http://localhost:2482', name: 'Nova', email: 'nova@localhost', }, dan: { base: process.env.PDS_DEV_DAN_URL || 'http://localhost:2483', name: 'Dan', email: 'dan@localhost', }, }; // ---- plumbing ------------------------------------------------------------- /** @param {string} command @param {string[]} args @param {object} [options] */ function run(command, args, options = {}) { const result = spawnSync(command, args, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8', ...options, env: { ...process.env, PATH: `${WORK}/bin:${process.env.PATH}`, ATPROTO_GIT_PASSWORD: PASSWORD, ATPROTO_GIT_PLC_URL: PLC_URL, ...(options.env ?? {}), }, }); if (result.status !== 0) { throw new Error( `${command} ${args.join(' ')} failed:\n${result.stderr || result.stdout}`, ); } return result.stdout?.trim() ?? ''; } /** Commit times march forward, so a log reads in the order things happened. */ let clock = Date.now() - 6 * 60 * 60 * 1000; function nextTime() { clock += 11 * 60 * 1000; return new Date(clock); } /** * One commit, authored by whoever the checkout belongs to. The branch is the * proposal; nothing is written into the message to make it one. * @param {string} who * @param {string} subject * @param {string[]} [trailers] */ function commit(who, subject, trailers = []) { const person = PEOPLE[who]; const when = nextTime().toISOString(); run('git', ['add', '-A'], { cwd: `${WORK}/${who}` }); run( 'git', [ '-c', `user.email=${person.email}`, '-c', `user.name=${person.name}`, 'commit', '-q', '-m', subject, // One paragraph, so every trailer sits in the same trailer block. ...(trailers.length > 0 ? ['-m', trailers.join('\n')] : []), ], { cwd: `${WORK}/${who}`, env: { GIT_AUTHOR_DATE: when, GIT_COMMITTER_DATE: when }, }, ); return run('git', ['rev-parse', 'HEAD'], { cwd: `${WORK}/${who}` }); } /** @param {string} who @param {string} path @param {string} body */ const write = (who, path, body) => writeFileSync(`${WORK}/${who}/${path}`, body); /** @param {string} who @param {string} path @param {string} body */ const append = (who, path, body) => appendFileSync(`${WORK}/${who}/${path}`, body); /** @param {string} who @param {string} path */ const read = (who, path) => readFileSync(`${WORK}/${who}/${path}`, 'utf8'); const git = (who, ...args) => run('git', args, { cwd: `${WORK}/${who}` }); /** @param {string} base @param {string} path @param {RequestInit} [init] */ async function xrpc(base, path, init) { const res = await fetch(`${base}/xrpc/${path}`, init); if (!res.ok) { throw new Error( `${path.split('?')[0]} ${res.status}: ${(await res.text()).slice(0, 300)}`, ); } return res.json(); } /** @type {Record} */ const sessions = {}; /** @param {string} who @param {string} collection @param {object} value */ function putRecord(who, collection, value, rkey) { const { did, token } = sessions[who]; return xrpc( PEOPLE[who].base, rkey ? 'com.atproto.repo.putRecord' : 'com.atproto.repo.createRecord', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, body: JSON.stringify({ repo: did, collection, ...(rkey ? { rkey } : {}), record: { $type: collection, ...value }, }), }, ); } /** The repo record's current version, which a check or a review names. */ async function repoRef(who) { const params = new URLSearchParams({ repo: sessions[who].did, collection: REPO_COLLECTION, rkey: NAME, }); const body = await xrpc( PEOPLE[who].base, `com.atproto.repo.getRecord?${params}`, ); return { uri: body.uri, cid: body.cid }; } // ---- the cast signs in ---------------------------------------------------- for (const [who, person] of Object.entries(PEOPLE)) { const did = ( await (await fetch(`${person.base}/.well-known/atproto-did`)).text() ) .trim() .replace(/^"|"$/g, ''); if (!did.startsWith('did:')) { throw new Error(`${person.base} serves no account yet`); } const session = await xrpc(person.base, 'com.atproto.server.createSession', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier: did, password: PASSWORD }), }); sessions[who] = { did, token: session.accessJwt }; } const url = (who) => `atproto://${sessions[who].did}/${NAME}`; // ---- what an earlier run left behind ------------------------------------- // // The checkouts below start empty, so a repository record still holding the // last run's history refuses their first push. The config goes first: a // repository with a protected branch refuses deletion. // // Only this repository's records go. These accounts hold others, seeded by // scripts beside this one, and a rerun here must leave them alone. for (const who of Object.keys(PEOPLE)) { const { did, token } = sessions[who]; const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }; const remove = (collection, rkey) => fetch(`${PEOPLE[who].base}/xrpc/com.atproto.repo.deleteRecord`, { method: 'POST', headers, body: JSON.stringify({ repo: did, collection, rkey }), }); await remove(CONFIG_COLLECTION, NAME); await remove(REPO_COLLECTION, NAME); // A check, a review or a pull request names the repository it is about in // its subject, which is what tells this repository's from another's. for (const collection of [ CHECK_COLLECTION, REVIEW_COLLECTION, PULL_COLLECTION, PULLS_COLLECTION, ISSUE_COLLECTION, ISSUES_COLLECTION, ]) { const params = new URLSearchParams({ repo: did, collection, limit: '100' }); const listed = await fetch( `${PEOPLE[who].base}/xrpc/com.atproto.repo.listRecords?${params}`, ); if (!listed.ok) continue; for (const row of (await listed.json()).records ?? []) { if (!String(row.value?.subject?.uri ?? '').endsWith(`/${NAME}`)) continue; await remove(collection, row.uri.split('/').pop()); } } // The latest-check record carries no subject; its derived key names the // repository instead, as ::. const params = new URLSearchParams({ repo: did, collection: LATEST_COLLECTION, limit: '100', }); const listed = await fetch( `${PEOPLE[who].base}/xrpc/com.atproto.repo.listRecords?${params}`, ); if (listed.ok) { for (const row of (await listed.json()).records ?? []) { const rkey = row.uri.split('/').pop(); if (rkey.includes(`:${NAME}:`)) await remove(LATEST_COLLECTION, rkey); } } } rmSync(WORK, { recursive: true, force: true }); mkdirSync(`${WORK}/bin`, { recursive: true }); symlinkSync( `${ROOT}/packages/git/src/cli.js`, `${WORK}/bin/git-remote-atproto`, ); // ---- 0. the runner, before anything moves --------------------------------- // // Nova's daemon watches the three copies, clones each push, runs the workflow // in the tree, and publishes a check record naming the ref it ran against. // The pushes below have to happen while it is connected, which is why it // starts first and the seed waits for its sockets. // // The pid file sits in a directory of its own so `just pds-stop` reaps the // daemon the same way it reaps the member servers. const CI_DIR = resolve('.dev-pds/ci'); // A rerun's fresh state file would make a surviving daemon replay every // event, so the previous daemon dies before its successor starts. try { const previous = Number(readFileSync(`${CI_DIR}/pds.pid`, 'utf8').trim()); if (previous > 0) process.kill(previous); } catch { // No pid file, or the daemon is already gone. } rmSync(CI_DIR, { recursive: true, force: true }); mkdirSync(CI_DIR, { recursive: true }); const ciLog = `${CI_DIR}/daemon.log`; const ciOut = openSync(ciLog, 'a'); const daemon = spawn( process.execPath, [ `${ROOT}/packages/git-ci/src/cli.js`, 'watch', `${sessions.alice.did}/${NAME}`, `${sessions.bob.did}/${NAME}`, `${sessions.carol.did}/${NAME}`, ], { detached: true, stdio: ['ignore', ciOut, ciOut], env: { ...process.env, PATH: `${WORK}/bin:${process.env.PATH}`, PDSJS_CI_IDENTIFIER: sessions.nova.did, PDSJS_CI_PASSWORD: PASSWORD, PDSJS_CI_SERVICE: PEOPLE.nova.base, PDSJS_CI_STATE: `${CI_DIR}/state.json`, ATPROTO_GIT_PLC_URL: PLC_URL, }, }, ); daemon.unref(); writeFileSync(`${CI_DIR}/pds.pid`, `${daemon.pid}\n`); // Three repository firehoses, each logging as its socket opens. for (let i = 0; ; i++) { const opened = (readFileSync(ciLog, 'utf8').match(/ watching http/g) ?? []) .length; if (opened >= 3) break; if (i >= 60) throw new Error(`the runner did not connect; see ${ciLog}`); await new Promise((done) => setTimeout(done, 500)); } console.log(`nova's runner is watching (log: ${ciLog})`); // ---- 1. alice's repository, the canonical one ----------------------------- mkdirSync(`${WORK}/alice`, { recursive: true }); git('alice', 'init', '-q', '-b', 'main'); write( 'alice', 'README.md', `# ${NAME}\n\nA small package published to its own atproto account.\n`, ); write( 'alice', 'index.js', ['export function greet(who) {', ' return `hello, ${who}`;', '}', ''].join( '\n', ), ); write( 'alice', 'package.json', `${JSON.stringify( { name: NAME, version: '1.0.0', main: 'index.js', type: 'module', license: 'MIT', }, null, 2, )}\n`, ); // The workflow the runner finds at every commit. One step, and it really // runs: every check on the work screen is this file's exit code. mkdirSync(`${WORK}/alice/.pdsjs/workflows`, { recursive: true }); write( 'alice', '.pdsjs/workflows/ci.yml', ['name: ci', 'steps:', ' - name: test', ' run: node test.js', ''].join( '\n', ), ); commit('alice', 'the first commit'); write( 'alice', 'test.js', [ "import { greet } from './index.js';", '', "if (greet('world') !== 'hello, world') {", " console.error('greet is wrong');", ' process.exit(1);', '}', "console.log('greet ok');", '', ].join('\n'), ); commit('alice', 'a test file to grow into'); git('alice', 'push', '-q', url('alice'), 'main'); console.log(`alice pushed ${NAME} (canonical)`); // ---- 2. bob clones it and opens two changes ------------------------------- run('git', ['clone', '-q', url('alice'), `${WORK}/bob`], { cwd: WORK }); git('bob', 'remote', 'add', 'mine', url('bob')); git('bob', 'push', '-q', 'mine', 'main'); // A branch that merges during this seed, so the browser has an ancestry-only // "merged" to derive rather than a state field to read. git('bob', 'checkout', '-q', '-b', 'agent/badge'); write( 'bob', 'README.md', `${read('bob', 'README.md')}\nEvery push is [checked](checks).\n`, ); commit('bob', 'link the checks page from the readme'); git('bob', 'push', '-q', 'mine', 'agent/badge'); // The change that gets rewritten. Two commits, so the version history has // something to show. git('bob', 'checkout', '-q', 'main'); git('bob', 'checkout', '-q', '-b', 'agent/farewell'); append( 'bob', 'index.js', [ '', 'export function farewell(who) {', ' return `bye, ${who}`;', '}', '', ].join('\n'), ); commit('bob', 'add farewell()'); // The test asserts "goodbye" while the function says "bye", so the runner's // check on this version fails for real, logs and all. append( 'bob', 'test.js', [ '', "import { farewell } from './index.js';", '', "if (farewell('world') !== 'goodbye, world') {", " console.error('farewell is wrong');", ' process.exit(1);', '}', "console.log('farewell ok');", '', ].join('\n'), ); commit('bob', 'cover farewell() in the test file'); git('bob', 'push', '-q', 'mine', 'agent/farewell'); const farewellV1 = git('bob', 'rev-parse', 'agent/farewell'); const bobV1 = await repoRef('bob'); console.log('bob pushed agent/badge and agent/farewell'); // ---- 3. alice merges the badge ------------------------------------------- git('alice', 'fetch', '-q', url('bob'), 'agent/badge'); git('alice', 'merge', '-q', '--ff-only', 'FETCH_HEAD'); git('alice', 'push', '-q', url('alice'), 'main'); console.log('alice merged agent/badge onto main'); // ---- 4. bob rebases his branch onto the moved main ------------------------ // // New shas, same account and same ref. The runner already checked v1 and // failed it, and that record names a commit no branch points at any more, // which is exactly the artifact a sha-keyed model throws away on every force // push. git('bob', 'fetch', '-q', 'origin', 'main'); git('bob', 'rebase', '-q', 'origin/main'); write( 'bob', 'index.js', read('bob', 'index.js').replace('bye, ${who}', 'goodbye, ${who}'), ); commit('bob', 'say goodbye rather than bye'); git('bob', 'push', '-q', '--force', 'mine', 'agent/farewell'); const farewellV2 = git('bob', 'rev-parse', 'agent/farewell'); const bobV2 = await repoRef('bob'); console.log('bob rebased agent/farewell; the pull request is the same one'); // ---- 4b. dan files an issue ----------------------------------------------- // // Before anyone fixes it: the issue's address exists first, and the fix // names it in a trailer the way a GitHub commit says Fixes #123. Dan is in // nobody's list; his record reaches the page through the runner's index. const danIssue = await putRecord('dan', ISSUE_COLLECTION, { subject: { uri: `at://${sessions.alice.did}/${REPO_COLLECTION}/${NAME}` }, createdAt: nextTime().toISOString(), title: 'greet(null) throws an unhelpful TypeError', body: 'Calling greet(null) blows up with a bare TypeError. A message naming the argument would save the next person the stack trace.', }); console.log('dan filed an issue'); // ---- 5. carol opens two changes ------------------------------------------ run('git', ['clone', '-q', url('alice'), `${WORK}/carol`], { cwd: WORK }); git('carol', 'remote', 'add', 'mine', url('carol')); git('carol', 'push', '-q', 'mine', 'main'); // Touches index.js, which is what bob's change touches too. git('carol', 'checkout', '-q', '-b', 'agent/errors'); write( 'carol', 'index.js', [ 'export function greet(who) {', " if (typeof who !== 'string') throw new TypeError('who must be a string');", ' return `hello, ${who}`;', '}', '', ].join('\n'), ); commit('carol', 'refuse a non-string name', [`Fixes: ${danIssue.uri}`]); git('carol', 'push', '-q', 'mine', 'agent/errors'); // Branched before the badge merged, so this one is behind main. git('carol', 'checkout', '-q', '-b', 'agent/docs', 'main~1'); append('carol', 'README.md', '\n## Usage\n\n```js\ngreet("world");\n```\n'); commit('carol', 'document the usage'); git('carol', 'push', '-q', 'mine', 'agent/docs'); const carolRef = await repoRef('carol'); console.log('carol pushed agent/errors and agent/docs'); // ---- 6. alice opens one of her own --------------------------------------- // // The authority is a collaborator like any other: her branches are open pull // requests, and only her `main` is canonical. git('alice', 'checkout', '-q', '-b', 'agent/license'); write('alice', 'LICENSE', 'MIT License\n\nCopyright (c) 2026\n'); commit('alice', 'add the license file'); git('alice', 'push', '-q', url('alice'), 'agent/license'); console.log('alice pushed agent/license'); // ---- 6b. one branch, four versions --------------------------------------- // // The case a sha-keyed model cannot hold. Bob writes a counter, gets it wrong, // rebases across a main that moved under him, gets it wrong again, and renames // it after review. Four tips under one account and one ref. // // Every one is a real push, so the runner checks each and the log nova keeps // is what says a force-push happened at all. The first two fail because the // test really fails; nothing here is seeded. git('bob', 'checkout', '-q', 'main'); git('bob', 'fetch', '-q', 'origin', 'main'); git('bob', 'reset', '-q', '--hard', 'FETCH_HEAD'); git('bob', 'checkout', '-q', '-b', 'agent/plural'); /** The counter, written whichever way this version got it wrong. */ const counter = (body) => write( 'bob', 'count.js', ['export function items(n) {', ` ${body}`, '}', ''].join('\n'), ); // The test comes first and does not move, so each version is measured // against the same claim. write( 'bob', 'count.test.js', [ "import { items } from './count.js';", '', 'const cases = [', " [0, 'no items'],", " [1, '1 item'],", " [4, '4 items'],", '];', 'for (const [n, want] of cases) {', ' if (items(n) !== want) {', ' console.error(`items(${n}) gave ${items(n)}, wanted ${want}`);', ' process.exit(1);', ' }', '}', "console.log('items ok');", '', ].join('\n'), ); append( 'bob', '.pdsjs/workflows/ci.yml', ' - name: count\n run: node count.test.js\n', ); // v1: the plural is always there. counter('return `${n} items`;'); commit('bob', 'add items()'); git('bob', 'push', '-q', 'mine', 'agent/plural'); const pluralV1 = git('bob', 'rev-parse', 'agent/plural'); // The runner stamps each push with its own wall clock, so the words that // answer a push have to be dated against the same one. The demo's clock runs // hours behind, and a review on it would sort above every push. const pushedAt = [Date.now()]; /** * A pause between the pushes of agent/plural. * * The runner stamps each push with the moment it saw it, and the words that * answer a push are dated between it and the next. Four pushes inside three * seconds leave no room between them, and the whole conversation sorts below * the whole history. Real work has hours here; the demo needs seconds. */ const spaced = () => new Promise((done) => setTimeout(done, 5000)); await spaced(); // Main moves under the branch, so the next push is a rebase rather than // another commit. It happens here, before the branch is finished, so the // commits that fail their checks are ones the rebase carries forward: a // reader looking for what broke the build finds it on a commit the branch // still has, which is the case a rebase at the end would hide. git('alice', 'checkout', '-q', 'main'); append('alice', 'README.md', '\nBuilt in the open.\n'); commit('alice', 'a line about the project'); git('alice', 'push', '-q', url('alice'), 'main'); // v2: rebased onto the moved main. One is singular now, and zero still reads // as a number, so this one fails its check too. git('bob', 'fetch', '-q', 'origin', 'main'); git('bob', 'rebase', '-q', 'FETCH_HEAD'); counter("return n === 1 ? '1 item' : `${n} items`;"); commit('bob', 'say one item, not 1 items'); git('bob', 'push', '-q', '--force', 'mine', 'agent/plural'); const pluralV2 = git('bob', 'rev-parse', 'agent/plural'); pushedAt.push(Date.now()); await spaced(); // v3: zero spelled out, which is what finally passes. counter("return n === 0 ? 'no items' : n === 1 ? '1 item' : `${n} items`;"); commit('bob', 'spell zero out'); git('bob', 'push', '-q', '--force', 'mine', 'agent/plural'); const pluralV3 = git('bob', 'rev-parse', 'agent/plural'); pushedAt.push(Date.now()); await spaced(); // v4: the name the reviewer asked for. The work is the same; the tip is not. write( 'bob', 'count.js', read('bob', 'count.js').replace('items(n)', 'countOf(n)'), ); write( 'bob', 'count.test.js', read('bob', 'count.test.js') .replace('import { items }', 'import { countOf }') .replaceAll('items(', 'countOf('), ); commit('bob', 'call it countOf'); git('bob', 'push', '-q', '--force', 'mine', 'agent/plural'); const pluralV4 = git('bob', 'rev-parse', 'agent/plural'); pushedAt.push(Date.now()); await spaced(); const bobPlural = await repoRef('bob'); console.log( `bob pushed agent/plural four times (${[ pluralV1, pluralV2, pluralV3, pluralV4, ] .map((sha) => sha.slice(0, 8)) .join(' -> ')})`, ); // ---- 7. a stranger opens a pull request ---------------------------------- // // Dan is in nobody's collaborator list. He clones the public repository, // pushes to his own copy, and writes a record in his own repo saying the work // is there. Alice's repository is untouched by any of it. run('git', ['clone', '-q', url('alice'), `${WORK}/dan`], { cwd: WORK }); git('dan', 'remote', 'add', 'mine', url('dan')); git('dan', 'checkout', '-q', '-b', 'fix/contributing'); write( 'dan', 'CONTRIBUTING.md', '# Contributing\n\nPush a branch to your own copy and offer it.\n', ); commit('dan', 'add a contributing guide'); git('dan', 'push', '-q', 'mine', 'main', 'fix/contributing'); const aliceRef = await repoRef('alice'); await putRecord('dan', PULL_COLLECTION, { subject: aliceRef, repo: NAME, ref: 'refs/heads/fix/contributing', sha: git('dan', 'rev-parse', 'fix/contributing'), title: 'Add a contributing guide', note: 'Saw the repo had no contributing guide. Take it or leave it.', createdAt: nextTime().toISOString(), }); // Nothing else happens on dan's side. Nova's daemon saw that write on dan's // firehose and indexed it; the open list reads the index from nova and marks // the row a fork. console.log('dan opened a pull request from outside the collaborator list'); // A collaborator writes the same record for the words alone. The graph says // carol's branch is open; only carol can say it is a draft. await putRecord('carol', PULL_COLLECTION, { subject: aliceRef, repo: NAME, ref: 'refs/heads/agent/docs', sha: git('carol', 'rev-parse', 'agent/docs'), title: 'Document the usage', status: 'draft', note: 'Still writing the usage examples.', createdAt: nextTime().toISOString(), }); console.log('carol marked agent/docs a draft'); // Bob writes a proper description of the counter, so the page has markdown to // render: headings, a list, a table, inline code and a fenced block. A // description comes out of an account this site does not control, so it goes // through the same renderer a README does, which prints raw HTML rather than // parsing it. await putRecord('bob', PULL_COLLECTION, { subject: aliceRef, repo: NAME, ref: 'refs/heads/agent/plural', sha: pluralV4, title: 'Count things without saying "1 items"', note: [ '## What this is', '', 'A counter that reads as English. `items(1)` used to give `1 items`, which', 'is the kind of thing you stop seeing after a week and every reader sees', 'forever.', '', '```js', "import { countOf } from './count.js';", '', "countOf(0); // 'no items'", "countOf(1); // '1 item'", "countOf(4); // '4 items'", '```', '', '## Why it took four goes', '', '| push | what it got wrong |', '| --- | --- |', '| first | the plural was always there |', '| second | one read right, zero did not |', '| third | nothing, but the name was a verb |', '| fourth | nothing |', '', 'The middle one is worth reading on its own: it is rebased across a `main`', "that moved under it, so the diff carries somebody else's README line as", 'well as mine.', '', '## What is left', '', '- [x] zero, one and many each said once', '- [x] a test that asserts all three', "- [ ] a word about it in the readme, which belongs with carol's docs branch", '', '> The name came from carol. `items()` reads as a verb everywhere else in', '> this repository, and a getter should not.', '', 'See [the readme](https://example.invalid/readme) for the rest.', ].join('\n'), createdAt: nextTime().toISOString(), }); console.log('bob described agent/plural at length, in markdown'); // ---- 8. reviews, in the reviewer's own repo ------------------------------ // // Nobody has write access to anybody's repository here. A review is a signed // statement by a DID about an immutable subject, and the reader decides whose // statements to render. const review = (who, subject, sha, author, ref, verdict, note, anchor, at) => putRecord(who, REVIEW_COLLECTION, { subject, sha, author, ref, verdict, ...(anchor ? { anchor } : {}), note, reviewedAt: at ?? nextTime().toISOString(), }); // The farewell branch, as an argument that runs alongside its commits. // // The sections above run in order: every commit is written before any // review, so statements timed by the demo's own clock would all land after // the branch was finished. These carry times taken from the commits they // answer, so a complaint about a missing test comes before the test. // // Author dates, because the rebase stamped its own committer date on // everything it moved: by that clock the commit that caused the rebase // comes before the two it rewrote. const farewellCommits = git( 'bob', 'log', '--reverse', '--format=%H %aI', 'origin/main..agent/farewell', ) .split('\n') .filter(Boolean) .map((line) => { const [sha, at] = line.split(' '); return { sha, at: Date.parse(at) }; }); /** * A clock for the conversation. It starts at the first commit, never goes * backwards, and `afterCommit` carries it past a push so the next word * answers that push rather than preceding it. */ let said = farewellCommits[0].at; // Two minutes a word. The commits are eleven minutes apart on the demo's // clock, and an exchange has to fit between two of them or the next push // lands in the middle of the argument it settles. const soon = (minutes = 2) => { said += minutes * 60 * 1000; return new Date(said).toISOString(); }; const afterCommit = (index) => { said = Math.max(said, farewellCommits[index].at); }; /** * Where a line sits in bob's working copy. Read rather than counted: the * files above are appended to, so a hand-written number goes stale the * moment anything is added before it. * @param {string} path * @param {string} needle */ const lineOf = (path, needle) => read('bob', path) .split('\n') .findIndex((line) => line.includes(needle)) + 1; /** * The line farewell() returns from. The rebase changed the word on it and * nothing else, so both versions anchor to the same line. */ const returnAt = lineOf('index.js', 'goodbye, ${who}'); const returnLine = (word) => ({ path: 'index.js', line: returnAt, side: 'new', snippet: ` return \`${word}, \${who}\`;`, }); const assertLine = { path: 'test.js', line: lineOf('test.js', "!== 'goodbye, world'"), side: 'new', snippet: "if (farewell('world') !== 'goodbye, world') {", }; /** * A word about the farewell branch. Dan is not among the speakers here: the * config names him nowhere, so a reader would not fetch his repo and his * words would not appear. That is the point of him, and it is why nobody * answers him. */ const say = (who, sha, note, extra = {}, minutes) => putRecord(who, REVIEW_COLLECTION, { subject: sha === farewellV1 ? bobV1 : bobV2, sha, author: sessions.bob.did, ref: 'refs/heads/agent/farewell', verdict: 'comment', note, ...extra, reviewedAt: soon(minutes), }); // ---- v1: the branch as bob first pushed it ------------------------------- afterCommit(0); await review( 'nova', bobV1, farewellV1, sessions.bob.did, 'refs/heads/agent/farewell', 'changesRequested', 'farewell() has no test and the greeting is inconsistent with greet().', undefined, soon(), ); const wording = await say( 'nova', farewellV1, 'This returns "bye" but greet() says "hello". Two greetings, two registers.', { anchor: returnLine('bye') }, ); const wordingReply = await say( 'bob', farewellV1, 'I went with the shorter one deliberately. Is that worth a whole revision?', { anchor: returnLine('bye'), replyTo: { uri: wording.uri, cid: wording.cid }, }, ); await say( 'carol', farewellV1, 'It is, yes. A library that says hello should say goodbye. Anything else reads as two authors.', { anchor: returnLine('bye'), replyTo: { uri: wordingReply.uri, cid: wordingReply.cid }, }, ); afterCommit(1); const assertion = await say( 'carol', farewellV1, 'The test asserts "goodbye, world" and the function returns "bye, world". One of the two is wrong and the check will say which.', { anchor: assertLine }, ); await say( 'nova', farewellV1, 'The check has run and it fails here. The log is on the run for this sha.', { anchor: assertLine, replyTo: { uri: assertion.uri, cid: assertion.cid } }, ); await say( 'bob', farewellV1, 'Right. The test is the one that is correct. I will change the function.', { anchor: assertLine, replyTo: { uri: assertion.uri, cid: assertion.cid } }, ); await say( 'carol', farewellV1, 'Reading this from outside: is farewell() meant to be part of the public API, or is it internal for now? The readme does not say.', ); await say( 'alice', farewellV1, 'Public. It goes in the readme with greet() once the wording settles.', ); // ---- v2: the same change, rebased, with the wording fixed ---------------- afterCommit(2); const settled = await say( 'carol', farewellV2, 'This is the wording. Thank you for going round again on it.', { anchor: returnLine('goodbye') }, ); await say( 'bob', farewellV2, 'The branch is what the argument names, so the rebase kept every word above attached to it.', { anchor: returnLine('goodbye'), replyTo: { uri: settled.uri, cid: settled.cid }, }, ); await say( 'carol', farewellV2, 'Every forge I have used loses this on a force push. Here it is still one change.', { anchor: returnLine('goodbye'), replyTo: { uri: settled.uri, cid: settled.cid }, }, ); await review( 'nova', bobV2, farewellV2, sessions.bob.did, 'refs/heads/agent/farewell', 'approve', 'Wording now matches greet(). Test covers the new export.', undefined, soon(), ); await review( 'carol', bobV2, farewellV2, sessions.bob.did, 'refs/heads/agent/farewell', 'approve', 'Reads well.', undefined, soon(), ); await say( 'nova', farewellV2, 'Check passes on this version. Both exports covered.', ); await say( 'alice', farewellV2, 'Good to merge once somebody who is not the author approves it.', ); // ---- the other changes --------------------------------------------------- // Anchored to the line it speaks to, the way the pull request page writes one. const anchored = await review( 'nova', carolRef, git('carol', 'rev-parse', 'agent/errors'), sessions.carol.did, 'refs/heads/agent/errors', 'comment', 'Throwing on a non-string is a breaking change for callers passing numbers.', { path: 'index.js', line: 2, side: 'new', snippet: " if (typeof who !== 'string') throw new TypeError('who must be a string');", }, ); // The author answers on the same line: a reply is a review naming the review // it answers, and still naming the pull request, so a reader that does not // know replyTo shows it beside what it answers. await putRecord('carol', REVIEW_COLLECTION, { subject: carolRef, sha: git('carol', 'rev-parse', 'agent/errors'), author: sessions.carol.did, ref: 'refs/heads/agent/errors', verdict: 'comment', anchor: { path: 'index.js', line: 2, side: 'new', snippet: " if (typeof who !== 'string') throw new TypeError('who must be a string');", }, replyTo: { uri: anchored.uri, cid: anchored.cid }, note: 'Fair. I will coerce numbers and keep the throw for everything else.', reviewedAt: nextTime().toISOString(), }); console.log( `bob's farewell carries ${farewellCommits.length} commits and an argument`, ); // ---- 8c. a word against each tip of agent/plural ------------------------- // // One statement against each tip, so the timeline has a word beside every // force-push. The first still reads on the fourth: it names the account and // the ref, and neither moved. /** * A word about one version, dated just after the push it answers. Seconds * apart is enough: what matters is that each word sorts after its push and * before the next one. * @param {number} which - the push this answers, counting from 1 */ const onPlural = (who, which, verdict, note, anchor, seconds = 2) => putRecord(who, REVIEW_COLLECTION, { subject: bobPlural, sha: [pluralV1, pluralV2, pluralV3, pluralV4][which - 1], author: sessions.bob.did, ref: 'refs/heads/agent/plural', verdict, ...(anchor ? { anchor } : {}), note, reviewedAt: new Date(pushedAt[which - 1] + seconds * 1000).toISOString(), }); await onPlural( 'nova', 1, 'changesRequested', 'items(1) reads "1 items". The test says so and the run failed on it.', { path: 'count.js', line: 2, side: 'new', snippet: ' return `${n} items`;', }, ); await onPlural( 'carol', 2, 'changesRequested', 'Rebased and one reads right now. Zero still says "0 items", which is the same bug one place along, and the run says so.', { path: 'count.js', line: 2, side: 'new', snippet: " return n === 1 ? '1 item' : `${n} items`;", }, ); await onPlural( 'carol', 3, 'comment', [ 'All three cases pass now. One thing left, and it is only a name:', '', '```js', 'items(4) // reads as "do the items"', 'countOf(4) // reads as "the count of"', '```', '', '`items()` is a verb wherever else we use that word in this repository.', ].join('\n'), ); // Anchored to the tip, so the line reads with the sign and the weight the // diff gives it. Every other anchor here is on a commit the branch replaced, // and one of those has only the snippet it recorded. await onPlural( 'carol', 4, 'comment', 'This is the line. Zero, one and many, each said once.', { path: 'count.js', line: 2, side: 'new', snippet: " return n === 0 ? 'no items' : n === 1 ? '1 item' : `${n} items`;", }, ); await onPlural( 'nova', 4, 'approve', 'Renamed, and the run is green on this tip.', ); await onPlural( 'alice', 4, 'approve', 'Three force-pushes and the argument is still attached to the branch. This is the case a sha would have lost twice.', ); console.log('agent/plural was force-pushed three times, each answered'); // ---- 8b. issues, each in its filer's own repo ----------------------------- // // Carol's is answered and resolved by alice, whose disposition record is // what the page shows as the issue's state. const issue = (who, value) => putRecord(who, ISSUE_COLLECTION, { subject: { uri: aliceRef.uri }, createdAt: nextTime().toISOString(), ...value, }); const carolIssue = await issue('carol', { title: 'Document what greet returns', body: 'The readme shows a call but never says the return value is a string.', }); await issue('alice', { body: 'Covered by the usage section once agent/docs merges.', replyTo: { uri: carolIssue.uri, cid: carolIssue.cid }, disposition: 'resolved', }); console.log('carol filed an issue; alice resolved it'); // The other disposition, so both settled states are on the list. Bob answers // after alice has declined, which is the case the model is for: the thread // stays readable and repliable, and the state is one person's word rather // than a bit anybody with push rights flipped. const bobIssue = await issue('bob', { title: 'Ship greet as a CLI', body: 'A `greet` binary on the PATH would let a shell script call this without a node import.', }); await issue('alice', { body: 'Out of scope. This is a library, and a CLI is a package of its own with its own release cadence.', replyTo: { uri: bobIssue.uri, cid: bobIssue.cid }, disposition: 'declined', }); await issue('bob', { body: 'Fair. I will put it in a separate repo and link it from the readme.', replyTo: { uri: bobIssue.uri, cid: bobIssue.cid }, }); console.log('bob filed an issue; alice declined it; bob answered anyway'); // ---- 8d. who wrote which commits ----------------------------------------- // // A commit records an author as a name and an address, and neither is an // atproto identity. This record is alice's statement about which addresses // belong to whom, in her own repo under her own DID. A reader trusts it as // far as it trusts her, which is the bar they already met by opening her // repositories. // // Without it a commit shows a letter and a name from the commit itself, which // is honest and says nothing about who on the network wrote it. await putRecord( 'alice', IDENTITY_COLLECTION, { idents: [ { email: PEOPLE.alice.email }, { email: PEOPLE.bob.email, did: sessions.bob.did }, { email: PEOPLE.carol.email, did: sessions.carol.did }, { email: PEOPLE.dan.email, did: sessions.dan.did }, { email: PEOPLE.nova.email, did: sessions.nova.did }, ], createdAt: new Date().toISOString(), }, 'self', ); console.log('alice claimed the commit addresses she knows'); // ---- 9. the config record, which is the whole fan-in ------------------- // // atproto has no reverse index, so nothing connects alice's repository to the // copies on bob's and carol's servers except this list. await putRecord( 'alice', CONFIG_COLLECTION, { collaborators: [sessions.bob.did, sessions.carol.did], reviewers: [sessions.carol.did, sessions.nova.did], runner: sessions.nova.did, runners: [sessions.nova.did], protectedBranches: ['main'], updatedAt: new Date().toISOString(), }, NAME, ); // ---- 10. wait for the runner to catch up --------------------------------- // // Fifteen pushes crossed the firehoses above: four on alice's copy, eight on // bob's, three on carol's. The runner clones and runs each one, so the seed // is not done until the records exist; without this wait the browser opens // on checks still arriving, which is realistic and confusing. const expectedRuns = 15; process.stdout.write(`waiting for nova's runner (${expectedRuns} checks)`); for (let i = 0; ; i++) { const params = new URLSearchParams({ repo: sessions.nova.did, collection: CHECK_COLLECTION, limit: '100', }); const listed = await fetch( `${PEOPLE.nova.base}/xrpc/com.atproto.repo.listRecords?${params}`, ); const rows = listed.ok ? ((await listed.json()).records ?? []) : []; const mine = rows.filter((row) => String(row.value?.subject?.uri ?? '').endsWith(`/${NAME}`), ); const done = mine.filter((row) => row.value?.status !== 'running').length; if (done >= expectedRuns) { process.stdout.write(` ${done} published\n`); break; } if (i >= 240) { process.stdout.write(`\nstill ${done}/${expectedRuns}; see ${ciLog}\n`); break; } process.stdout.write('.'); await new Promise((finish) => setTimeout(finish, 1000)); } console.log(''); console.log(`Collaborators on ${NAME}:`); for (const who of ['alice', 'bob', 'carol']) { console.log(` ${who.padEnd(6)} ${sessions[who].did} ${PEOPLE[who].base}`); } console.log(` nova ${sessions.nova.did} runner and review agent`); console.log(` dan ${sessions.dan.did} opened a fork pull request`);