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<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 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 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<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 getIssueCountFromBacklinks = async (repo: RepoContext): Promise<number> =>
(await getUniqueBacklinkRefs(repo.repoDid, [
'sh.tangled.repo.issue:repoDid',
'sh.tangled.repo.issue:repo',
])).length;
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 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<PaginatedResult<IssueSummary>> => {
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<ShTangledRepoIssue.Main, 'open' | 'closed'>(
'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<ShTangledRepoIssue.Main, 'open' | 'closed'>(
'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<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),
);
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> =>
appviewFallback(
() => getAppviewRecordCount('sh.tangled.repo.countIssues', repo.repoDid),
() => getIssueCountFromBacklinks(repo),
);
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,
};
};