chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// Writing reviews and pull requests as the signed-in visitor.
//
// Both records go into the visitor's own repo on their own PDS, which is the
// whole model: nobody needs write access to the repository they are talking
// about, and the repository's config decides whose statements its readers
// see. The subject names the exact repository record version in view, so a
// statement cannot be re-attributed to a later push.
import { pullRkey } from '@pdsjs/git/pull-requests';
import { authedFetch } from '#/lib/oauth.js';
import { repoRow } from './git.js';
const REVIEW_COLLECTION = 'dev.pdsjs.git.review';
const PULL_COLLECTION = 'dev.pdsjs.git.pull';
const ISSUE_COLLECTION = 'dev.pdsjs.git.issue';
/**
* The review record for one verdict on one commit, ready to write.
* @param {Object} about
* @param {{uri: string, cid: string}} about.subject - the repository record
* @param {string} about.sha - the version reviewed
* @param {string|null} [about.author] - the account whose copy holds the work
* @param {string|null} [about.ref] - the branch holding it, with the author
* what identifies the pull request
* @param {string} about.verdict - approve, changesRequested or comment
* @param {string} about.note
* @param {{path: string, line?: number, endLine?: number, side?: string, snippet?: string, endSnippet?: string}} [about.anchor] -
* the line, or the range, spoken to, for a comment on the code rather than
* on the whole
* @param {{uri: string, cid: string}} [about.replyTo] - the review answered,
* by its exact version
* @returns {Record<string, unknown>}
*/
export function buildReview(about) {
return {
$type: REVIEW_COLLECTION,
subject: { uri: about.subject.uri, cid: about.subject.cid },
sha: about.sha,
...(about.author ? { author: about.author } : {}),
...(about.ref ? { ref: about.ref } : {}),
verdict: about.verdict,
...(about.anchor ? { anchor: about.anchor } : {}),
...(about.replyTo ? { replyTo: about.replyTo } : {}),
...(about.note ? { note: about.note } : {}),
reviewedAt: new Date().toISOString(),
};
}
/**
* The record carrying the author's own words about their pull request.
* @param {Object} about
* @param {{uri: string, cid: string}} about.subject
* @param {string} about.repo - record key of the author's copy
* @param {string} about.ref - full ref name of their branch
* @param {string} about.sha
* @param {string} [about.title]
* @param {string} [about.status] - draft or withdrawn; absent means open
* @param {string} [about.note]
* @returns {Record<string, unknown>}
*/
export function buildPull(about) {
return {
$type: PULL_COLLECTION,
subject: { uri: about.subject.uri, cid: about.subject.cid },
repo: about.repo,
ref: about.ref,
sha: about.sha,
...(about.title ? { title: about.title } : {}),
...(about.status ? { status: about.status } : {}),
...(about.note ? { note: about.note } : {}),
createdAt: new Date().toISOString(),
};
}
/**
* Write one record into the signed-in account's repo, on their PDS.
* @param {import('#/lib/oauth.js').Session} session
* @param {string} collection
* @param {Record<string, unknown>} record
* @returns {Promise<{uri: string, cid: string}>}
*/
async function createRecord(session, collection, record) {
const response = await authedFetch(
session,
`${session.pds}/xrpc/com.atproto.repo.createRecord`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ repo: session.did, collection, record }),
},
);
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.message || `The write failed (${response.status}).`);
}
return response.json();
}
/**
* Write one record at a known key, the same way the CLI's pr create and pr
* update do. A compare-and-swap value guards a concurrent edit.
* @param {import('#/lib/oauth.js').Session} session
* @param {string} collection
* @param {string} rkey
* @param {Record<string, unknown>} record
* @param {string|null} [swapRecord]
* @returns {Promise<{uri: string, cid: string}>}
*/
async function putRecord(session, collection, rkey, record, swapRecord) {
const response = await authedFetch(
session,
`${session.pds}/xrpc/com.atproto.repo.putRecord`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
repo: session.did,
collection,
rkey,
record,
swapRecord,
}),
},
);
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.message || `The write failed (${response.status}).`);
}
return response.json();
}
/**
* Publish a review of the pull request in view.
* @param {import('#/lib/oauth.js').Session} session
* @param {Object} about
* @param {string} about.repo - repository name on this site
* @param {string} about.sha
* @param {string|null} [about.author]
* @param {string|null} [about.ref]
* @param {string} about.verdict
* @param {string} about.note
* @returns {Promise<{uri: string, cid: string}>}
*/
export function publishReview(session, about) {
const row = repoRow(about.repo);
if (!row) throw new Error('The repository record is not in hand.');
return createRecord(
session,
REVIEW_COLLECTION,
buildReview({ ...about, subject: row }),
);
}
/**
* Publish the author's own words about their pull request.
*
* Only the account holding the branch can write this, because the record is in
* that account's own repo and it is about a branch of that account's own copy.
* A reader that reaches the branch and not this record still has the pull
* request; what it loses is the title, the note and the draft word.
* @param {import('#/lib/oauth.js').Session} session
* @param {Object} about
* @param {string} about.repo - repository name on this site, which is also the
* record key of the author's own copy
* @param {string} about.ref - full ref name of their branch
* @param {string} about.sha
* @param {string} [about.title]
* @param {string} [about.status]
* @param {string} [about.note]
* @returns {Promise<{uri: string, cid: string}>}
*/
export function publishPull(session, about) {
const row = repoRow(about.repo);
if (!row) throw new Error('The repository record is not in hand.');
return putRecord(
session,
PULL_COLLECTION,
pullRkey(about.repo, about.ref),
buildPull({ ...about, subject: row }),
);
}
/**
* The record for one word in an issue thread, ready to write: an opener with
* a title, a reply naming its parent, or the owner's disposition on a whole
* thread. All one shape, because they are all the same thing, a statement
* about the repository in its author's own repo.
* @param {Object} about
* @param {{uri: string, cid?: string}} about.subject - the repository record
* @param {string} [about.title] - present on the record that opens a thread
* @param {string} [about.body]
* @param {{uri: string, cid: string}} [about.replyTo]
* @param {string} [about.disposition] - resolved, declined or reopened
* @returns {Record<string, unknown>}
*/
export function buildIssue(about) {
return {
$type: ISSUE_COLLECTION,
subject: {
uri: about.subject.uri,
...(about.subject.cid ? { cid: about.subject.cid } : {}),
},
...(about.title ? { title: about.title } : {}),
...(about.body ? { body: about.body } : {}),
...(about.replyTo ? { replyTo: about.replyTo } : {}),
...(about.disposition ? { disposition: about.disposition } : {}),
createdAt: new Date().toISOString(),
};
}
/**
* Publish a word in an issue thread of this repository.
* @param {import('#/lib/oauth.js').Session} session
* @param {Object} about
* @param {string} about.repo - repository name on this site
* @param {string} [about.title]
* @param {string} [about.body]
* @param {{uri: string, cid: string}} [about.replyTo]
* @param {string} [about.disposition]
* @returns {Promise<{uri: string, cid: string}>}
*/
export function publishIssue(session, about) {
const row = repoRow(about.repo);
if (!row) throw new Error('The repository record is not in hand.');
return createRecord(
session,
ISSUE_COLLECTION,
buildIssue({ ...about, subject: row }),
);
}