import { ok } from '@atcute/client'; import type { ResourceUri } from '@atcute/lexicons/syntax'; import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; import { ShTangledRepoIssue, ShTangledRepoIssueComment, ShTangledRepoIssueState, } from '@atcute/tangled'; import { appviewFallback, appviewJson, getAppviewRecordCount, listAllAppviewRecords, type AppviewRecordView, type AppviewStatefulRecordView, } from './appview'; import { getBacklinks, getLatestBacklinkTimestamp, getUniqueBacklinkRefs, latestBacklinkRefByRkey, listBacklinkedRecordsPage, } from './backlinks'; import { ISSUE_COLLECTION, ISSUE_COMMENT_COLLECTION, ISSUE_STATE_COLLECTION, FEED_COMMENT_COLLECTION, } from './constants'; import { appviewRecordToHydrated, fetchRecordValue, hydrateBacklinks, hydrateBacklinksFromAppview, } from './hydration'; import { createAuthRpc } from './identity'; import { extractAtUris, 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 IssueSummary extends HydratedRecord { number: number; state: 'open' | 'closed'; } export interface IssueComment extends HydratedRecord {} export interface IssueDetail { issue: IssueSummary; comments: IssueComment[]; } export interface CreateIssueInput { title: string; body: string; } const normalizeIssueState = (value?: string): 'open' | 'closed' => value === 'closed' || value?.endsWith('.closed') ? 'closed' : 'open'; export const getIssueRecord = async (issueUri: ResourceUri): Promise> => { const record = await appviewJson>( 'sh.tangled.repo.getIssue', { issue: issueUri }, ); return appviewRecordToHydrated(record); }; const hydrateIssueBacklinks = (refs: BacklinkRecordRef[]): Promise>> => hydrateBacklinksFromAppview( refs, 'sh.tangled.repo.getIssues', 'issues', ); const getLatestIssueState = async (issueUri: ResourceUri): Promise<'open' | 'closed'> => { const refs = await getBacklinks(issueUri, 'sh.tangled.repo.issue.state:issue'); if (refs.length === 0) { return 'open'; } const state = await fetchRecordValue(latestBacklinkRefByRkey(refs)!); return normalizeIssueState(state.state); }; const getIssueUpdatedAt = async ( issue: HydratedRecord, stateUpdatedAt?: string, ): Promise => maxTimestamp( timestampFromDate(issue.value.createdAt), timestampFromTid(issue.rkey), timestampFromDate(stateUpdatedAt), await getLatestBacklinkTimestamp(issue.uri, [ `${ISSUE_COMMENT_COLLECTION}:issue`, `${ISSUE_STATE_COLLECTION}:issue`, ]), ); const listIssuesFromBacklinks = async (repo: RepoContext): Promise => { const refs = await getUniqueBacklinkRefs(repo.repoDid, [ 'sh.tangled.repo.issue:repoDid', 'sh.tangled.repo.issue:repo', ]); const issues = await hydrateIssueBacklinks(refs); const numbers = toNumberMap(issues); const states = await Promise.all(issues.map((issue) => getLatestIssueState(issue.uri))); const summaries = issues .map((issue, index) => ({ ...issue, number: numbers.get(issue.uri) ?? index + 1, state: states[index], })); return sortStatefulRecordsByUpdatedAt(summaries, (issue) => getIssueUpdatedAt(issue)); }; const listIssuesPageFromBacklinks = async ( repo: RepoContext, options: StatefulPageOptions<'open' | 'closed'>, ): Promise> => listBacklinkedRecordsPage( repo.repoDid, ['sh.tangled.repo.issue:repoDid', 'sh.tangled.repo.issue:repo'], options, getLatestIssueState, (issue) => getIssueUpdatedAt(issue), hydrateIssueBacklinks, ); const getIssueCountFromBacklinks = async (repo: RepoContext): Promise => (await getUniqueBacklinkRefs(repo.repoDid, [ 'sh.tangled.repo.issue:repoDid', 'sh.tangled.repo.issue:repo', ])).length; const normalizeIssueComment = (hydrated: HydratedRecord, issueUri: ResourceUri): IssueComment => { const val = hydrated.value; if (!val || typeof val !== 'object') { return hydrated as IssueComment; } const bodyText = (val.body && typeof val.body === 'object' && 'text' in val.body) ? val.body.text : (typeof val.body === 'string' ? val.body : ''); const replyTo = val.replyTo?.uri ?? val.replyTo; const mappedValue: ShTangledRepoIssueComment.Main = { $type: 'sh.tangled.repo.issue.comment', body: bodyText, createdAt: val.createdAt || new Date().toISOString(), issue: val.issue ?? val.subject?.uri ?? issueUri, replyTo, references: val.references ?? (replyTo ? [replyTo] : []), }; return { ...hydrated, value: mappedValue, }; }; const getIssueFromBacklinks = async (repo: RepoContext, issueRef: string): Promise => { const issues = await listIssuesFromBacklinks(repo); const issue = findNumberedRecord(issues, issueRef); if (!issue) { throw new Error(`Issue not found`); } const [newCommentRefs, legacyCommentRefs] = await Promise.all([ getBacklinks(issue.uri, 'sh.tangled.feed.comment:subject'), getBacklinks(issue.uri, 'sh.tangled.repo.issue.comment:issue'), ]); 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) => normalizeIssueComment(record, issue.uri)); comments.sort(sortByCreatedAt); return { issue, comments, }; }; const listIssueCommentsFromAppview = async (issueUri: ResourceUri): Promise => { const records = await listAllAppviewRecords( 'sh.tangled.feed.listComments', issueUri, ); const comments = await Promise.all(records.map(async (record) => { const hydrated = await appviewRecordToHydrated(record); return normalizeIssueComment(hydrated, issueUri); })); comments.sort(sortByCreatedAt); return comments; }; const listIssuesFromAppview = async (repo: RepoContext): Promise => { const records = await listAllAppviewRecords( 'sh.tangled.repo.listIssues', repo.repoDid, ) as Array>; const issues = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); const numbers = toNumberMap(issues); const summaries = issues .map((issue, index) => ({ ...issue, number: numbers.get(issue.uri) ?? index + 1, state: normalizeIssueState(records[index].state), })); return sortStatefulRecordsByNewestRecord(summaries); }; const listIssuesPageFromAppview = async ( repo: RepoContext, options: StatefulPageOptions<'open' | 'closed'>, ): Promise> => { if (options.query?.trim()) { // Appview search can scope to repo/author/nsid. State is still derived // from separate state records, so filter state after each search hit. return listSearchedStatefulRecordsPage( 'sh.tangled.repo.issue', repo, options, getLatestIssueState, ); } // Temporary until Bobbin exposes newest-first/reverse repo issue pagination. // This loads all appview issue records, but sorts without per-item backlinks. return listAppviewStatefulRecordsPage( 'sh.tangled.repo.listIssues', repo.repoDid, options, normalizeIssueState, options.author ? { author: options.author } : {}, (issue) => Promise.resolve(getAppviewRecordSortTimestamp(issue)), ); }; const getIssueFromAppview = async (repo: RepoContext, issueRef: string): Promise => { const directUri = parseDirectRecordRef(issueRef, ISSUE_COLLECTION); if (directUri) { const issueRecord = await getIssueRecord(directUri); if (issueRecord.value.repo !== repo.repoDid) { throw new Error(`Issue not found`); } const issue: IssueSummary = { ...issueRecord, number: 0, state: await getLatestIssueState(issueRecord.uri), }; return { issue, comments: await listIssueCommentsFromAppview(issue.uri), }; } const issues = await listIssuesFromAppview(repo); const issue = findNumberedRecord(issues, issueRef); if (!issue) { throw new Error(`Issue not found`); } return { issue, comments: await listIssueCommentsFromAppview(issue.uri), }; }; export const listIssues = async (repo: RepoContext): Promise => appviewFallback( () => listIssuesFromAppview(repo), () => listIssuesFromBacklinks(repo), ); export const listIssuesPage = async ( repo: RepoContext, options: StatefulPageOptions<'open' | 'closed'>, ): Promise> => appviewFallback( () => listIssuesPageFromAppview(repo, options), () => listIssuesPageFromBacklinks(repo, options), ); export const getRepoIssueCount = async (repo: RepoContext): Promise => appviewFallback( () => getAppviewRecordCount('sh.tangled.repo.countIssues', repo.repoDid), () => getIssueCountFromBacklinks(repo), ); export const getIssue = async (repo: RepoContext, issueRef: string): Promise => appviewFallback( () => getIssueFromAppview(repo, issueRef), () => getIssueFromBacklinks(repo, issueRef), ); export const createIssue = async ( agent: OAuthUserAgent, repo: RepoContext, input: CreateIssueInput, ): Promise => { const rpc = createAuthRpc(agent); const record: ShTangledRepoIssue.Main = { $type: 'sh.tangled.repo.issue', repo: repo.repoDid, title: input.title.trim(), body: optionalString(input.body), 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: ISSUE_COLLECTION, record, }, }), ); return { uri: result.uri, rkey: parseAtUri(result.uri).rkey, }; }; export const createIssueComment = async ( agent: OAuthUserAgent, issueUri: ResourceUri, issueCid: string, body: string, replyTo?: { uri: ResourceUri; cid: string }, ): Promise => { const rpc = createAuthRpc(agent); const record = { $type: 'sh.tangled.feed.comment', subject: { uri: issueUri, cid: issueCid, }, body: { $type: 'sh.tangled.markup.markdown', text: body.trim(), }, createdAt: new Date().toISOString(), ...(replyTo ? { replyTo: { uri: replyTo.uri, cid: replyTo.cid, } } : {}), }; 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 setIssueState = async ( agent: OAuthUserAgent, issueUri: ResourceUri, state: 'open' | 'closed', ): Promise => { const rpc = createAuthRpc(agent); const record: ShTangledRepoIssueState.Main = { $type: 'sh.tangled.repo.issue.state', issue: issueUri, state: state === 'closed' ? 'sh.tangled.repo.issue.state.closed' : 'sh.tangled.repo.issue.state.open', }; const result = await ok( rpc.post('com.atproto.repo.createRecord', { input: { repo: agent.sub, collection: ISSUE_STATE_COLLECTION, record, }, }), ); return { uri: result.uri, rkey: parseAtUri(result.uri).rkey, }; };