diff --git a/README.md b/README.md index b140e8f..b76f932 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,8 @@ account. A PR or an issue is a record in its author's account that points at the author, the repo owner, a collaborator or Tangled itself. Records used: `sh.tangled.repo.pull` (with the changes as a gzipped `git format-patch`), -`.pull.status`, `.pull.comment`, `sh.tangled.repo.issue`, `.issue.state`, `.issue.comment` +`.pull.status`, `sh.tangled.repo.issue`, `.issue.state`, `sh.tangled.feed.comment` (comments on both; +the older `.pull.comment` and `.issue.comment` are still read) and `sh.tangled.repo.artifact` (a release file, linked to its tag by the tag's hash). These formats have no official documentation and may change. They follow diff --git a/src/atproto.js b/src/atproto.js index ac30460..8ab4d02 100644 --- a/src/atproto.js +++ b/src/atproto.js @@ -92,7 +92,7 @@ export async function getRecord(uri) { for (const host of [SLINGSHOT, null]) { try { const data = await xrpcQuery(host ?? await cachedPds(did), 'com.atproto.repo.getRecord', params); - return { uri, value: data.value }; + return { uri, cid: data.cid, value: data.value }; } catch (err) { if (host === null) { if (err.xrpcError === 'RecordNotFound') return null; diff --git a/src/commands/issue.js b/src/commands/issue.js index 7728362..a229cce 100644 --- a/src/commands/issue.js +++ b/src/commands/issue.js @@ -3,7 +3,7 @@ import { TglError } from '../errors.js'; import { t } from '../i18n.js'; import { findById, loadItems } from '../repoData.js'; import { - buildIssueCommentRecord, buildIssueRecord, buildIssueStateRecord, NSID, repoWebUrl, resolveRepo, + buildCommentRecord, buildIssueRecord, buildIssueStateRecord, NSID, repoWebUrl, resolveRepo, } from '../tangled.js'; import { openSession } from './auth.js'; import { @@ -27,7 +27,7 @@ async function setIssueState(opts, ref, state) { } const session = await openSession(); if (body?.trim()) { - await session.createRecord(NSID.issueComment, buildIssueCommentRecord({ issueUri: issue.uri, body })); + await session.createRecord(NSID.comment, buildCommentRecord({ subject: issue, body })); } await session.createRecord(NSID.issueState, buildIssueStateRecord({ issueUri: issue.uri, state })); console.log(t(`Issue "${issue.title}" marked as ${STATE_WORDS[state]()}.`, `Issue "${issue.title}" marcada como ${STATE_WORDS[state]()}.`)); @@ -96,7 +96,7 @@ export default { options: { ...repoOption, ...jsonOption }, async run(opts, [ref]) { const issue = await findIssue(opts, ref); - const comments = await loadComments(whoAmI(), issue.uri, NSID.issueComment, '.issue'); + const comments = await loadComments(whoAmI(), issue.uri, { collection: NSID.legacyIssueComment, path: '.issue' }); if (opts.json) { printJson({ id: issue.rkey, ...issue, comments }); return; @@ -118,7 +118,7 @@ export default { const body = readBody(opts, { required: true }); const issue = await findIssue(opts, ref); const session = await openSession(); - await session.createRecord(NSID.issueComment, buildIssueCommentRecord({ issueUri: issue.uri, body })); + await session.createRecord(NSID.comment, buildCommentRecord({ subject: issue, body })); console.log(t(`Comment posted on "${issue.title}".`, `Comentario publicado en "${issue.title}".`)); }, }, diff --git a/src/commands/pr.js b/src/commands/pr.js index b5a7a54..f6fd48e 100644 --- a/src/commands/pr.js +++ b/src/commands/pr.js @@ -7,7 +7,7 @@ import { import { t } from '../i18n.js'; import { isIdOf, loadItems, notFound } from '../repoData.js'; import { - buildPullCommentRecord, buildPullRecord, buildStatusRecord, isFork, NSID, pullPatch, repoGitUrl, repoWebUrl, resolveRepo, + buildCommentRecord, buildPullRecord, buildStatusRecord, isFork, NSID, pullPatch, repoGitUrl, repoWebUrl, resolveRepo, } from '../tangled.js'; import { openSession } from './auth.js'; import { @@ -171,7 +171,7 @@ export default { const { pull, repoDid } = await findPull(opts, ref); const patch = await pullPatch(pull, repoDid); const files = changedFiles(patch); - const comments = await loadComments(whoAmI(), pull.uri, NSID.pullComment, '.pull'); + const comments = await loadComments(whoAmI(), pull.uri, { collection: NSID.legacyPullComment, path: '.pull' }); const revisions = Math.max(pull.rounds?.length ?? 0, 1); if (opts.json) { const { rounds, patch: _inline, ...rest } = pull; @@ -257,7 +257,8 @@ export default { const body = readBody(opts, { required: true }); const { pull } = await findPull(opts, ref); const session = await openSession(); - await session.createRecord(NSID.pullComment, buildPullCommentRecord({ pullUri: pull.uri, body })); + const pullRoundIdx = Math.max((pull.rounds?.length ?? 1) - 1, 0); + await session.createRecord(NSID.comment, buildCommentRecord({ subject: pull, body, pullRoundIdx })); console.log(t(`Comment posted on "${pull.title}".`, `Comentario publicado en "${pull.title}".`)); }, }, diff --git a/src/commands/shared.js b/src/commands/shared.js index b067238..2e32dba 100644 --- a/src/commands/shared.js +++ b/src/commands/shared.js @@ -4,7 +4,7 @@ import { handleOf } from '../atproto.js'; import { recordsLinkingTo } from '../backlinks.js'; import { TglError } from '../errors.js'; import { t } from '../i18n.js'; -import { authorOf } from '../tangled.js'; +import { authorOf, commentText, NSID } from '../tangled.js'; export const repoOption = { repo: { type: 'string', short: 'R' } }; export const jsonOption = { json: { type: 'boolean', default: false } }; @@ -40,10 +40,14 @@ export function day(isoDate) { return (isoDate ?? '').slice(0, 10); } -export async function loadComments(account, subjectUri, collection, path) { - const records = await recordsLinkingTo({ account, collection, links: [{ target: subjectUri, path }] }); - const comments = records - .map((r) => ({ uri: r.uri, author: authorOf(r.uri), body: r.value.body ?? '', createdAt: r.value.createdAt ?? '' })) +// Comments in the current format plus those in the deprecated per-kind collection. +export async function loadComments(account, subjectUri, legacy) { + const [current, old] = await Promise.all([ + recordsLinkingTo({ account, collection: NSID.comment, links: [{ target: subjectUri, path: '.subject.uri' }] }), + recordsLinkingTo({ account, collection: legacy.collection, links: [{ target: subjectUri, path: legacy.path }] }), + ]); + const comments = [...current, ...old] + .map((r) => ({ uri: r.uri, author: authorOf(r.uri), body: commentText(r.value), createdAt: r.value.createdAt ?? '' })) .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); await Promise.all(comments.map(async (c) => { c.authorHandle = await handleOf(c.author); })); return comments; diff --git a/src/repoData.js b/src/repoData.js index 0838e8f..1049705 100644 --- a/src/repoData.js +++ b/src/repoData.js @@ -63,6 +63,7 @@ export async function loadItems(kind, account, repoDid, { state = 'all', limit = return { ...normalize(kind, r.value), uri: r.uri, + cid: r.cid, rkey: r.uri.split('/').pop(), author, state: latestState(states, { field: k.stateField, known: k.known, allowed: new Set([author, ...editors]) }), diff --git a/src/tangled.js b/src/tangled.js index 0f03aa0..274943f 100644 --- a/src/tangled.js +++ b/src/tangled.js @@ -11,12 +11,13 @@ export const NSID = { repo: 'sh.tangled.repo', pull: 'sh.tangled.repo.pull', pullStatus: 'sh.tangled.repo.pull.status', - pullComment: 'sh.tangled.repo.pull.comment', + comment: 'sh.tangled.feed.comment', + legacyPullComment: 'sh.tangled.repo.pull.comment', artifact: 'sh.tangled.repo.artifact', collaborator: 'sh.tangled.repo.collaborator', issue: 'sh.tangled.repo.issue', issueState: 'sh.tangled.repo.issue.state', - issueComment: 'sh.tangled.repo.issue.comment', + legacyIssueComment: 'sh.tangled.repo.issue.comment', }; export const ISSUE_STATES = { @@ -144,8 +145,23 @@ export function buildStatusRecord({ pullUri, state, now = new Date() }) { return { $type: NSID.pullStatus, pull: pullUri, status: PULL_STATES[state], createdAt: now.toISOString() }; } -export function buildPullCommentRecord({ pullUri, body, now = new Date() }) { - return { $type: NSID.pullComment, pull: pullUri, body, createdAt: now.toISOString() }; +// Comments on issues and PRs (lexicons/feed/comment.json). They point at their subject +// with a strong reference (address + content hash); a PR comment also names the +// revision it is about. The older per-kind comment collections are deprecated: Tangled +// no longer shows new ones, but old ones are still read. +export function buildCommentRecord({ subject, body, pullRoundIdx, now = new Date() }) { + const record = { + $type: NSID.comment, + subject: { uri: subject.uri, cid: subject.cid }, + body: { $type: 'sh.tangled.markup.markdown', text: body, original: body }, + createdAt: now.toISOString(), + }; + if (pullRoundIdx !== undefined) record.pullRoundIdx = pullRoundIdx; + return record; +} + +export function commentText(value) { + return typeof value.body === 'string' ? value.body : value.body?.text ?? ''; } export function buildIssueRecord({ repoDid, title, body, now = new Date() }) { @@ -156,10 +172,6 @@ export function buildIssueStateRecord({ issueUri, state, now = new Date() }) { return { $type: NSID.issueState, issue: issueUri, state: ISSUE_STATES[state], createdAt: now.toISOString() }; } -export function buildIssueCommentRecord({ issueUri, body, now = new Date() }) { - return { $type: NSID.issueComment, issue: issueUri, body, createdAt: now.toISOString() }; -} - export function authorOf(uri) { return uri.replace('at://', '').split('/')[0]; } diff --git a/test/tangled.test.js b/test/tangled.test.js index 5c5de91..4c19cb1 100644 --- a/test/tangled.test.js +++ b/test/tangled.test.js @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { - buildArtifactRecord, buildIssueRecord, buildIssueStateRecord, buildPullRecord, buildStatusRecord, + buildArtifactRecord, buildCommentRecord, buildIssueRecord, commentText, buildIssueStateRecord, buildPullRecord, buildStatusRecord, ISSUE_STATES, latestState, PULL_STATES, tagHashFromRecord, } from '../src/tangled.js'; @@ -63,3 +63,16 @@ test('artifact tag is the annotated tag hash as unpadded base64 bytes', () => { assert.equal(r.$type, 'sh.tangled.repo.artifact'); assert.equal(r.repoDid, 'did:plc:repo'); }); + +test('comments use the shared feed.comment format with a strong reference', () => { + const r = buildCommentRecord({ subject: { uri: 'at://did:x/sh.tangled.repo.pull/1', cid: 'bafy', extra: 1 }, body: 'Hi', pullRoundIdx: 0, now }); + assert.deepEqual(r, { + $type: 'sh.tangled.feed.comment', + subject: { uri: 'at://did:x/sh.tangled.repo.pull/1', cid: 'bafy' }, + body: { $type: 'sh.tangled.markup.markdown', text: 'Hi', original: 'Hi' }, + createdAt: now.toISOString(), + pullRoundIdx: 0, + }); + assert.equal(commentText(r), 'Hi'); + assert.equal(commentText({ body: 'old plain text' }), 'old plain text'); +});