// 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} */ 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} */ 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} 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} 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} */ 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 }), ); }