Something went wrong. Try again.
csr tangled client in solid-js
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471import { 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, 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 { paginateStatefulRecords, sortStatefulRecordsByNewestRecord, sortStatefulRecordsByUpdatedAt, type StatefulPageOptions,} from './stateful';
export interface IssueSummary extends HydratedRecord<ShTangledRepoIssue.Main> { number: number; state: 'open' | 'closed';}
export interface IssueComment extends HydratedRecord<ShTangledRepoIssueComment.Main> {}
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<HydratedRecord<ShTangledRepoIssue.Main>> => { const record = await appviewJson<AppviewRecordView<ShTangledRepoIssue.Main>>( 'sh.tangled.repo.getIssue', { issue: issueUri }, ); return appviewRecordToHydrated(record);};
const hydrateIssueBacklinks = (refs: BacklinkRecordRef[]): Promise<Array<HydratedRecord<ShTangledRepoIssue.Main>>> => hydrateBacklinksFromAppview<ShTangledRepoIssue.Main>( 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<ShTangledRepoIssueState.Main>(latestBacklinkRefByRkey(refs)!); return normalizeIssueState(state.state);};
const getLatestIssueStates = async (issues: Array<{ uri: ResourceUri }>): Promise<Array<'open' | 'closed'>> => { const states = new Array<'open' | 'closed'>(issues.length); let nextIndex = 0; const worker = async () => { while (nextIndex < issues.length) { const index = nextIndex++; states[index] = await getLatestIssueState(issues[index].uri); } };
await Promise.all(Array.from({ length: Math.min(6, issues.length) }, () => worker())); return states;};
const getIssueUpdatedAt = async ( issue: HydratedRecord<ShTangledRepoIssue.Main>, stateUpdatedAt?: string,): Promise<number> => 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<IssueSummary[]> => { 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 getLatestIssueStates(issues);
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<PaginatedResult<IssueSummary>> => listBacklinkedRecordsPage<ShTangledRepoIssue.Main, 'open' | 'closed'>( repo.repoDid, ['sh.tangled.repo.issue:repoDid', 'sh.tangled.repo.issue:repo'], options, getLatestIssueState, (issue) => getIssueUpdatedAt(issue), hydrateIssueBacklinks, );
const normalizeIssueComment = (hydrated: HydratedRecord<any>, 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<IssueDetail> => { 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<any>(commentRefs); const comments = hydrated.map((record) => normalizeIssueComment(record, issue.uri)); comments.sort(sortByCreatedAt);
return { issue, comments, };};
const listIssueCommentsFromAppview = async (issueUri: ResourceUri): Promise<IssueComment[]> => { const records = await listAllAppviewRecords<any>( 'sh.tangled.feed.listComments', issueUri, ); const comments = await Promise.all(records.map(async (record) => { const hydrated = await appviewRecordToHydrated<any>(record); return normalizeIssueComment(hydrated, issueUri); })); comments.sort(sortByCreatedAt); return comments;};
const listIssuesFromAppview = async (repo: RepoContext): Promise<IssueSummary[]> => { const records = await listAllAppviewRecords<ShTangledRepoIssue.Main>( 'sh.tangled.repo.listIssues', repo.repoDid, ) as Array<AppviewStatefulRecordView<ShTangledRepoIssue.Main>>; const issues = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); const numbers = toNumberMap(issues); const states = await getLatestIssueStates(issues);
const summaries = issues .map((issue, index) => ({ ...issue, number: numbers.get(issue.uri) ?? index + 1, state: states[index], }));
return sortStatefulRecordsByNewestRecord(summaries);};
const listIssuesPageFromAppview = async ( repo: RepoContext, options: StatefulPageOptions<'open' | 'closed'>,): Promise<PaginatedResult<IssueSummary>> => paginateStatefulRecords( await listIssuesFromAppview(repo), options,);
const getIssueFromAppview = async (repo: RepoContext, issueRef: string): Promise<IssueDetail> => { 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<IssueSummary[]> => appviewFallback( () => listIssuesFromAppview(repo), () => listIssuesFromBacklinks(repo), );
// Dashboard surfaces only need the newest few open issues. Skipping the// updated-at sort keeps pagination incremental so large repositories do not// require hydrating and state-resolving every issue.export const listRecentOpenIssues = async ( repo: RepoContext, limit: number,): Promise<IssueSummary[]> => { const page = await listBacklinkedRecordsPage<ShTangledRepoIssue.Main, 'open' | 'closed'>( repo.repoDid, ['sh.tangled.repo.issue:repoDid', 'sh.tangled.repo.issue:repo'], { offset: 0, limit, state: 'open' }, getLatestIssueState, undefined, hydrateIssueBacklinks, ); return page.items;};
export const listIssuesPage = async ( repo: RepoContext, options: StatefulPageOptions<'open' | 'closed'>,): Promise<PaginatedResult<IssueSummary>> => appviewFallback( () => listIssuesPageFromAppview(repo, options), () => listIssuesPageFromBacklinks(repo, options), );
export const getRepoIssueCount = async (repo: RepoContext): Promise<number> => countOpenIssuesFromBacklinks(repo);
export const getAppviewOpenIssueCount = async (repo: RepoContext): Promise<number> => (await listAllAppviewRecords<ShTangledRepoIssue.Main>( 'sh.tangled.repo.listIssues', repo.repoDid, { state: 'open' }, )).length;
const countOpenIssuesFromBacklinks = async (repo: RepoContext): Promise<number> => { const refs = await getUniqueBacklinkRefs(repo.repoDid, [ 'sh.tangled.repo.issue:repoDid', 'sh.tangled.repo.issue:repo', ]); let nextIndex = 0; let count = 0; const worker = async () => { while (nextIndex < refs.length) { const ref = refs[nextIndex++]; if (await getLatestIssueState(`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 getIssue = async (repo: RepoContext, issueRef: string): Promise<IssueDetail> => appviewFallback( () => getIssueFromAppview(repo, issueRef), () => getIssueFromBacklinks(repo, issueRef), );
export const createIssue = async ( agent: OAuthUserAgent, repo: RepoContext, input: CreateIssueInput,): Promise<RecordCreationResult> => { 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<RecordCreationResult> => { 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<RecordCreationResult> => { 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, };};