~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
230 lines · 8.2 KB Raw History
// 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 }),
  );
}