csr tangled client in solid-js
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483import { ok } from '@atcute/client';import type { ResourceUri } from '@atcute/lexicons/syntax';import type { OAuthUserAgent } from '@atcute/oauth-browser-client';import { ShTangledRepoMerge, ShTangledRepoPull, ShTangledRepoPullComment, ShTangledRepoPullStatus,} from '@atcute/tangled';import { gunzipSync, gzipSync, strFromU8, strToU8 } from 'fflate';
import { appviewFallback, appviewJson, getAppviewRecordCount, listAllAppviewRecords, type AppviewRecordView, type AppviewStatefulRecordView,} from './appview';import { getBacklinks, getBacklinksPage, getLatestBacklinkTimestamp, latestBacklinkRefByRkey, listBacklinkedRecordsPage,} from './backlinks';import { PULL_COLLECTION, PULL_COMMENT_COLLECTION, PULL_STATUS_COLLECTION,} from './constants';import { createServiceAuthRpc, getRpc } from './client';import { appviewRecordToHydrated, fetchRecordValue, hydrateBacklinks, hydrateBacklinksFromAppview,} from './hydration';import { createAuthRpc, resolveActor } from './identity';import { extractAtUris, extractBlobCid, extractDids, findNumberedRecord, type HydratedRecord, maxTimestamp, optionalString, type PaginatedResult, parseAtUri, parseDirectRecordRef, sortByCreatedAt, timestampFromDate, timestampFromTid, toNumberMap, type RecordCreationResult, type BacklinkRecordRef,} from './records';import type { RepoContext } from './repos';import { getAppviewRecordSortTimestamp, listAppviewStatefulRecordsPage, listSearchedStatefulRecordsPage, sortStatefulRecordsByNewestRecord, sortStatefulRecordsByUpdatedAt, type StatefulPageOptions,} from './stateful';
export interface PullSummary extends HydratedRecord<ShTangledRepoPull.Main> { number: number; state: 'open' | 'closed' | 'merged';}
export interface PullComment extends HydratedRecord<ShTangledRepoPullComment.Main> {}
export interface PullDetail { pull: PullSummary; comments: PullComment[];}
export interface CreatePullInput { title: string; body: string; targetBranch: string; sourceBranch: string; patch: string;}
export interface MergePullInput { pull: PullSummary; patch: string;}
const normalizePullStatus = (value?: string): 'open' | 'closed' | 'merged' => { if (value === 'merged' || value?.endsWith('.merged')) { return 'merged'; }
if (value === 'closed' || value?.endsWith('.closed')) { return 'closed'; }
return 'open';};
export const getPullRecord = async (pullUri: ResourceUri): Promise<HydratedRecord<ShTangledRepoPull.Main>> => { const record = await appviewJson<AppviewRecordView<ShTangledRepoPull.Main>>( 'sh.tangled.repo.getPull', { pull: pullUri }, ); return appviewRecordToHydrated(record);};
const hydratePullBacklinks = (refs: BacklinkRecordRef[]): Promise<Array<HydratedRecord<ShTangledRepoPull.Main>>> => hydrateBacklinksFromAppview<ShTangledRepoPull.Main>( refs, 'sh.tangled.repo.getPulls', 'pulls', );
const getLatestPullStatus = async (pullUri: ResourceUri): Promise<'open' | 'closed' | 'merged'> => { const refs = await getBacklinks(pullUri, 'sh.tangled.repo.pull.status:pull'); if (refs.length === 0) { return 'open'; }
const status = await fetchRecordValue<ShTangledRepoPullStatus.Main>(latestBacklinkRefByRkey(refs)!); return normalizePullStatus(status.status);};
const getPullUpdatedAt = async ( pull: HydratedRecord<ShTangledRepoPull.Main>, stateUpdatedAt?: string,): Promise<number> => maxTimestamp( timestampFromDate(pull.value.createdAt), timestampFromTid(pull.rkey), ...pull.value.rounds.map((round) => timestampFromDate(round.createdAt)), timestampFromDate(stateUpdatedAt), await getLatestBacklinkTimestamp(pull.uri, [ `${PULL_COMMENT_COLLECTION}:pull`, `${PULL_STATUS_COLLECTION}:pull`, ]), );
const listPullsFromBacklinks = async (repo: RepoContext): Promise<PullSummary[]> => { const refs = await getBacklinks(repo.repoDid, 'sh.tangled.repo.pull:target.repo'); const pulls = await hydratePullBacklinks(refs); const numbers = toNumberMap(pulls); const states = await Promise.all(pulls.map((pull) => getLatestPullStatus(pull.uri)));
const summaries = pulls .map((pull, index) => ({ ...pull, number: numbers.get(pull.uri) ?? index + 1, state: states[index], }));
return sortStatefulRecordsByUpdatedAt(summaries, (pull) => getPullUpdatedAt(pull));};
const listPullsPageFromBacklinks = async ( repo: RepoContext, options: StatefulPageOptions<'open' | 'closed' | 'merged'>,): Promise<PaginatedResult<PullSummary>> => listBacklinkedRecordsPage<ShTangledRepoPull.Main, 'open' | 'closed' | 'merged'>( repo.repoDid, 'sh.tangled.repo.pull:target.repo', options, getLatestPullStatus, (pull) => getPullUpdatedAt(pull), hydratePullBacklinks, );
const getPullCountFromBacklinks = async (repo: RepoContext): Promise<number> => (await getBacklinksPage(repo.repoDid, 'sh.tangled.repo.pull:target.repo', 1)).total ?? (await getBacklinks(repo.repoDid, 'sh.tangled.repo.pull:target.repo')).length;
const getPullFromBacklinks = async (repo: RepoContext, pullRef: string): Promise<PullDetail> => { const pulls = await listPullsFromBacklinks(repo); const pull = findNumberedRecord(pulls, pullRef); if (!pull) { throw new Error(`Pull request not found`); }
const commentRefs = await getBacklinks(pull.uri, 'sh.tangled.repo.pull.comment:pull'); const comments = await hydrateBacklinks<ShTangledRepoPullComment.Main>(commentRefs); comments.sort(sortByCreatedAt);
return { pull, comments, };};
const listPullCommentsFromAppview = async (pullUri: ResourceUri): Promise<PullComment[]> => { const records = await listAllAppviewRecords<ShTangledRepoPullComment.Main>( 'sh.tangled.repo.pull.listComments', pullUri, ); const comments = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); comments.sort(sortByCreatedAt); return comments;};
const listPullsFromAppview = async (repo: RepoContext): Promise<PullSummary[]> => { const records = await listAllAppviewRecords<ShTangledRepoPull.Main>( 'sh.tangled.repo.listPulls', repo.repoDid, ) as Array<AppviewStatefulRecordView<ShTangledRepoPull.Main>>; const pulls = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); const numbers = toNumberMap(pulls);
const summaries = pulls .map((pull, index) => ({ ...pull, number: numbers.get(pull.uri) ?? index + 1, state: normalizePullStatus(records[index].state), }));
return sortStatefulRecordsByNewestRecord(summaries);};
const listPullsPageFromAppview = async ( repo: RepoContext, options: StatefulPageOptions<'open' | 'closed' | 'merged'>,): Promise<PaginatedResult<PullSummary>> => { if (options.query?.trim()) { // Appview search can scope to repo/author/nsid. State is still derived // from separate status records, so filter state after each search hit. return listSearchedStatefulRecordsPage<ShTangledRepoPull.Main, 'open' | 'closed' | 'merged'>( 'sh.tangled.repo.pull', repo, options, getLatestPullStatus, ); }
// Temporary until Bobbin exposes newest-first/reverse repo pull pagination. // This loads all appview pull records, but sorts without per-item backlinks. return listAppviewStatefulRecordsPage<ShTangledRepoPull.Main, 'open' | 'closed' | 'merged'>( 'sh.tangled.repo.listPulls', repo.repoDid, options, normalizePullStatus, options.author ? { author: options.author } : {}, (pull) => Promise.resolve(getAppviewRecordSortTimestamp(pull)), );};
const getPullFromAppview = async (repo: RepoContext, pullRef: string): Promise<PullDetail> => { const directUri = parseDirectRecordRef(pullRef, PULL_COLLECTION); if (directUri) { const pullRecord = await getPullRecord(directUri); if (pullRecord.value.target.repo !== repo.repoDid) { throw new Error(`Pull request not found`); }
const pull: PullSummary = { ...pullRecord, number: 0, state: await getLatestPullStatus(pullRecord.uri), };
return { pull, comments: await listPullCommentsFromAppview(pull.uri), }; }
const pulls = await listPullsFromAppview(repo); const pull = findNumberedRecord(pulls, pullRef); if (!pull) { throw new Error(`Pull request not found`); }
return { pull, comments: await listPullCommentsFromAppview(pull.uri), };};
export const listPulls = async (repo: RepoContext): Promise<PullSummary[]> => appviewFallback( () => listPullsFromAppview(repo), () => listPullsFromBacklinks(repo), );
export const listPullsPage = async ( repo: RepoContext, options: StatefulPageOptions<'open' | 'closed' | 'merged'>,): Promise<PaginatedResult<PullSummary>> => appviewFallback( () => listPullsPageFromAppview(repo, options), () => listPullsPageFromBacklinks(repo, options), );
export const getRepoPullCount = async (repo: RepoContext): Promise<number> => appviewFallback( () => getAppviewRecordCount('sh.tangled.repo.countPulls', repo.repoDid), () => getPullCountFromBacklinks(repo), );
export const getPull = async (repo: RepoContext, pullRef: string): Promise<PullDetail> => appviewFallback( () => getPullFromAppview(repo, pullRef), () => getPullFromBacklinks(repo, pullRef), );
export const fetchPullRoundPatch = async ( pull: PullSummary, roundIndex: number,): Promise<string> => { const round = pull.value.rounds[roundIndex]; if (!round) { throw new Error(`Round ${roundIndex + 1} not found`); }
const cid = extractBlobCid(round.patchBlob); if (!cid) { throw new Error(`Missing patch blob CID`); }
const author = pull.author.pds ? pull.author : await resolveActor(pull.author.did); const rpc = getRpc(author.pds); const bytes = await ok( rpc.get('com.atproto.sync.getBlob', { params: { did: author.did, cid, }, as: 'bytes', }), );
return strFromU8(gunzipSync(bytes));};
export const createPull = async ( agent: OAuthUserAgent, repo: RepoContext, input: CreatePullInput,): Promise<RecordCreationResult> => { const rpc = createAuthRpc(agent); const patchBytes = new Uint8Array(gzipSync(strToU8(input.patch))); const patchBlob = new Blob([patchBytes.buffer], { type: 'application/gzip', });
const uploaded = await ok( rpc.post('com.atproto.repo.uploadBlob', { input: patchBlob, }), );
const record: ShTangledRepoPull.Main = { $type: 'sh.tangled.repo.pull', title: input.title.trim(), body: optionalString(input.body), target: { repo: repo.repoDid, branch: input.targetBranch, }, source: { branch: input.sourceBranch, }, rounds: [ { createdAt: new Date().toISOString(), patchBlob: uploaded.blob, }, ], createdAt: new Date().toISOString(), mentions: extractDids(input.body), references: extractAtUris(input.body), };
const result = await ok( rpc.post('com.atproto.repo.createRecord', { input: { repo: agent.sub, collection: PULL_COLLECTION, record, }, }), );
return { uri: result.uri, rkey: parseAtUri(result.uri).rkey, };};
export const createPullComment = async ( agent: OAuthUserAgent, pullUri: ResourceUri, body: string,): Promise<RecordCreationResult> => { const rpc = createAuthRpc(agent); const record: ShTangledRepoPullComment.Main = { $type: 'sh.tangled.repo.pull.comment', pull: pullUri, body: body.trim(), createdAt: new Date().toISOString(), mentions: extractDids(body), references: extractAtUris(body), };
const result = await ok( rpc.post('com.atproto.repo.createRecord', { input: { repo: agent.sub, collection: PULL_COMMENT_COLLECTION, record, }, }), );
return { uri: result.uri, rkey: parseAtUri(result.uri).rkey, };};
export const setPullStatus = async ( agent: OAuthUserAgent, pullUri: ResourceUri, status: 'open' | 'closed' | 'merged',): Promise<RecordCreationResult> => { const rpc = createAuthRpc(agent); const record: ShTangledRepoPullStatus.Main = { $type: 'sh.tangled.repo.pull.status', pull: pullUri, status: status === 'merged' ? 'sh.tangled.repo.pull.status.merged' : status === 'closed' ? 'sh.tangled.repo.pull.status.closed' : 'sh.tangled.repo.pull.status.open', };
const result = await ok( rpc.post('com.atproto.repo.createRecord', { input: { repo: agent.sub, collection: PULL_STATUS_COLLECTION, record, }, }), );
return { uri: result.uri, rkey: parseAtUri(result.uri).rkey, };};
export const mergePull = async ( agent: OAuthUserAgent, repo: RepoContext, input: MergePullInput,): Promise<RecordCreationResult> => { const rpc = await createServiceAuthRpc(agent, repo.knot, 'sh.tangled.repo.merge'); const pull = input.pull; const mergeInput: ShTangledRepoMerge.$input = { did: repo.owner.did, name: repo.slug, branch: pull.value.target.branch, patch: input.patch, commitMessage: pull.value.title, authorName: pull.author.handle, ...(pull.value.body ? { commitBody: pull.value.body } : {}), };
await ok( rpc.post('sh.tangled.repo.merge', { input: mergeInput, as: null, }), );
return setPullStatus(agent, pull.uri, 'merged');};