import { ok } from '@atcute/client'; import type { Did, 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, listAllAppviewRecords, type AppviewRecordView, type AppviewStatefulRecordView, } from './appview'; import { getBacklinks, getLatestBacklinkTimestamp, latestBacklinkRefByRkey, listBacklinkedRecordsPage, } from './backlinks'; import { PULL_COLLECTION, PULL_COMMENT_COLLECTION, PULL_STATUS_COLLECTION, FEED_COMMENT_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 { paginateStatefulRecords, sortStatefulRecordsByNewestRecord, sortStatefulRecordsByUpdatedAt, type StatefulPageOptions, } from './stateful'; export interface PullSummary extends HydratedRecord { number: number; state: 'open' | 'closed' | 'merged'; } export interface PullComment extends HydratedRecord {} export interface PullDetail { pull: PullSummary; comments: PullComment[]; } export interface CreatePullInput { title: string; body: string; targetBranch: string; sourceBranch: string; patch: string; sourceRepoDid?: Did; } 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> => { const record = await appviewJson>( 'sh.tangled.repo.getPull', { pull: pullUri }, ); return appviewRecordToHydrated(record); }; const hydratePullBacklinks = (refs: BacklinkRecordRef[]): Promise>> => hydrateBacklinksFromAppview( 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(latestBacklinkRefByRkey(refs)!); return normalizePullStatus(status.status); }; const getLatestPullStatuses = async (pulls: Array<{ uri: ResourceUri }>): Promise> => { const statuses = new Array<'open' | 'closed' | 'merged'>(pulls.length); let nextIndex = 0; const worker = async () => { while (nextIndex < pulls.length) { const index = nextIndex++; statuses[index] = await getLatestPullStatus(pulls[index].uri); } }; await Promise.all(Array.from({ length: Math.min(6, pulls.length) }, () => worker())); return statuses; }; const getPullUpdatedAt = async ( pull: HydratedRecord, stateUpdatedAt?: string, ): Promise => 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 => { const refs = await getBacklinks(repo.repoDid, 'sh.tangled.repo.pull:target.repo'); const pulls = await hydratePullBacklinks(refs); const numbers = toNumberMap(pulls); const states = await getLatestPullStatuses(pulls); 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> => listBacklinkedRecordsPage( repo.repoDid, 'sh.tangled.repo.pull:target.repo', options, getLatestPullStatus, (pull) => getPullUpdatedAt(pull), hydratePullBacklinks, ); const getPullFromBacklinks = async (repo: RepoContext, pullRef: string): Promise => { const pulls = await listPullsFromBacklinks(repo); const pull = findNumberedRecord(pulls, pullRef); if (!pull) { throw new Error(`Pull request not found`); } const [newCommentRefs, legacyCommentRefs] = await Promise.all([ getBacklinks(pull.uri, 'sh.tangled.feed.comment:subject'), getBacklinks(pull.uri, 'sh.tangled.repo.pull.comment:pull'), ]); const commentRefs = [...newCommentRefs]; const seenUris = new Set(commentRefs.map(r => `at://${r.did}/${r.collection}/${r.rkey}`)); for (const ref of legacyCommentRefs) { const refUri = `at://${ref.did}/${ref.collection}/${ref.rkey}`; if (!seenUris.has(refUri)) { commentRefs.push(ref); seenUris.add(refUri); } } const hydrated = await hydrateBacklinks(commentRefs); const comments = hydrated.map((record) => normalizePullComment(record, pull.uri)); comments.sort(sortByCreatedAt); return { pull, comments, }; }; const normalizePullComment = (hydrated: HydratedRecord, pullUri: ResourceUri): PullComment => { const val = hydrated.value; if (!val || typeof val !== 'object') { return hydrated as PullComment; } const bodyText = (val.body && typeof val.body === 'object' && 'text' in val.body) ? val.body.text : (typeof val.body === 'string' ? val.body : ''); const pullRoundIdx = val.pullRoundIdx !== undefined ? val.pullRoundIdx : val.round; const replyTo = val.replyTo?.uri ?? val.replyTo; const mappedValue: ShTangledRepoPullComment.Main = { $type: 'sh.tangled.repo.pull.comment', body: bodyText, createdAt: val.createdAt || new Date().toISOString(), pull: val.pull ?? val.subject?.uri ?? pullUri, references: val.references ?? (replyTo ? [replyTo] : []), }; return { ...hydrated, value: { ...mappedValue, replyTo, pullRoundIdx, } as any, }; }; const listPullCommentsFromAppview = async (pullUri: ResourceUri): Promise => { const records = await listAllAppviewRecords( 'sh.tangled.feed.listComments', pullUri, ); const comments = await Promise.all(records.map(async (record) => { const hydrated = await appviewRecordToHydrated(record); return normalizePullComment(hydrated, pullUri); })); comments.sort(sortByCreatedAt); return comments; }; const listPullsFromAppview = async (repo: RepoContext): Promise => { const records = await listAllAppviewRecords( 'sh.tangled.repo.listPulls', repo.repoDid, ) as Array>; const pulls = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); const numbers = toNumberMap(pulls); const statuses = await getLatestPullStatuses(pulls); const summaries = pulls .map((pull, index) => ({ ...pull, number: numbers.get(pull.uri) ?? index + 1, state: statuses[index], })); return sortStatefulRecordsByNewestRecord(summaries); }; const listPullsPageFromAppview = async ( repo: RepoContext, options: StatefulPageOptions<'open' | 'closed' | 'merged'>, ): Promise> => paginateStatefulRecords( await listPullsFromAppview(repo), options, ); const getPullFromAppview = async (repo: RepoContext, pullRef: string): Promise => { 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 => appviewFallback( () => listPullsFromAppview(repo), () => listPullsFromBacklinks(repo), ); // See listRecentOpenIssues: incremental pagination for dashboard summaries. export const listRecentOpenPulls = async ( repo: RepoContext, limit: number, ): Promise => { const page = await listBacklinkedRecordsPage( repo.repoDid, 'sh.tangled.repo.pull:target.repo', { offset: 0, limit, state: 'open' }, getLatestPullStatus, undefined, hydratePullBacklinks, ); return page.items; }; export const listPullsPage = async ( repo: RepoContext, options: StatefulPageOptions<'open' | 'closed' | 'merged'>, ): Promise> => appviewFallback( () => listPullsPageFromAppview(repo, options), () => listPullsPageFromBacklinks(repo, options), ); export const getRepoPullCount = async (repo: RepoContext): Promise => countOpenPullsFromBacklinks(repo); export const getAppviewOpenPullCount = async (repo: RepoContext): Promise => (await listAllAppviewRecords( 'sh.tangled.repo.listPulls', repo.repoDid, { state: 'open' }, )).length; const countOpenPullsFromBacklinks = async (repo: RepoContext): Promise => { const refs = await getBacklinks(repo.repoDid, 'sh.tangled.repo.pull:target.repo'); let nextIndex = 0; let count = 0; const worker = async () => { while (nextIndex < refs.length) { const ref = refs[nextIndex++]; if (await getLatestPullStatus(`at://${ref.did}/${ref.collection}/${ref.rkey}` as ResourceUri) === 'open') { count += 1; } } }; await Promise.all(Array.from({ length: Math.min(6, refs.length) }, () => worker())); return count; }; export const getPull = async (repo: RepoContext, pullRef: string): Promise => appviewFallback( () => getPullFromAppview(repo, pullRef), () => getPullFromBacklinks(repo, pullRef), ); export const fetchPullRoundPatch = async ( pull: PullSummary, roundIndex: number, ): Promise => { 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 => { 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, repo: input.sourceRepoDid, }, 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, pullCid: string, body: string, pullRoundIdx: number, ): Promise => { const rpc = createAuthRpc(agent); const record = { $type: 'sh.tangled.feed.comment', subject: { uri: pullUri, cid: pullCid, }, body: { $type: 'sh.tangled.markup.markdown', text: body.trim(), }, createdAt: new Date().toISOString(), pullRoundIdx, }; const result = await ok( rpc.post('com.atproto.repo.createRecord', { input: { repo: agent.sub, collection: FEED_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 => { 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 => { 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'); };