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, } 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 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 commentRefs = await getBacklinks(issue.uri, 'sh.tangled.repo.issue.comment:issue'); const comments = await hydrateBacklinks(commentRefs); comments.sort(sortByCreatedAt); return { issue, comments, }; }; const listIssueCommentsFromAppview = async (issueUri: ResourceUri): Promise => { const records = await listAllAppviewRecords( 'sh.tangled.repo.issue.listComments', issueUri, ); const comments = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); 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, body: string, replyTo?: ResourceUri, ): Promise => { const rpc = createAuthRpc(agent); const record: ShTangledRepoIssueComment.Main = { $type: 'sh.tangled.repo.issue.comment', issue: issueUri, body: body.trim(), createdAt: new Date().toISOString(), mentions: extractDids(body), references: extractAtUris(body), ...(replyTo ? { replyTo } : {}), }; const result = await ok( rpc.post('com.atproto.repo.createRecord', { input: { repo: agent.sub, collection: ISSUE_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, }; };