csr tangled client in solid-js
15

Configure Feed

Select the types of activity you want to include in your feed.

reorganize api and pages

dawn (May 21, 2026, 8:31 PM +0300) b4729f33 5198786d

+3082 -2915
+11 -7
AGENTS.md
··· 19 19 - `src/App.tsx`: provider wiring only. Keep this small. It should compose app-wide providers and render `AppRoutes`. 20 20 - `src/routes.tsx`: the declarative route table. Repo routes are nested under `/:owner/:repo` and use `RepoLayout`. 21 21 - `src/layout.tsx`: global shell and topbar. Preserve the `.untangled-shell` wrapper so app pages keep the intended width and padding. There is intentionally no footer. 22 - - `src/views/home.tsx`: home page and repository jump/recent/own-repo UI. 23 - - `src/views/oauth-callback.tsx`: OAuth callback completion page. 24 - - `src/views/not-found.tsx`: not-found page. 22 + - `src/pages/home.tsx`: home page and repository jump/recent/own-repo UI. 23 + - `src/pages/oauth-callback.tsx`: OAuth callback completion page. 24 + - `src/pages/not-found.tsx`: not-found page. 25 + - `src/pages/search.tsx`: global search page. 25 26 - `src/pages/repo.tsx`: barrel exports for repo routes only. 26 27 - `src/pages/repo/shared.tsx`: repo layout/context, shared repo frame/header/tabs, repo query keys, and `useRepoQuery`. 27 28 - `src/pages/repo/code.tsx`: repo overview/tree page and blob/file viewer. ··· 33 34 - `src/components/common.tsx`: generic UI primitives such as `Avatar`, `LoadingState`, `StateBadge`, and form/button/card style helpers. 34 35 - `src/components/repo.tsx`: repo presentation components such as tabs, file rows, README/comment cards, `CodeView`, `DiffView`, branch pills, and repo skeletons. 35 36 - `src/lib/api.ts`: compatibility facade that re-exports domain APIs. Do not add implementation here. 36 - - `src/lib/api/core.ts`: current low-level API implementation and shared private helpers. 37 37 - `src/lib/api/constants.ts`: public service URLs and OAuth scope exports. 38 + - `src/lib/api/appview.ts`: appview JSON transport, fallback, and appview response types. 39 + - `src/lib/api/backlinks.ts`: Constellation backlink traversal and backlink pagination helpers. 40 + - `src/lib/api/client.ts`: shared ATProto RPC client creation and service URL normalization. 41 + - `src/lib/api/hydration.ts`: record hydration from appview/PDS records into resolved authors. 38 42 - `src/lib/api/identity.ts`: auth RPC, identity resolver, actor resolution, avatar resolution. 39 43 - `src/lib/api/repos.ts`: repo records, repo metadata, tree/blob/branch/tag/log/language APIs, compare APIs, and related response types. 40 44 - `src/lib/api/issues.ts`: issue list/detail/create/comment/state APIs and issue types. ··· 65 69 import { createIssue } from '../../lib/api/issues'; 66 70 ``` 67 71 68 - The facade `src/lib/api.ts` remains for compatibility with existing imports, but new implementation should go into the relevant domain module or, if it truly must share private internals, into `src/lib/api/core.ts` and be re-exported through the domain module. 72 + The facade `src/lib/api.ts` remains for compatibility with existing imports, but new implementation should go into the relevant domain module. Shared internals should stay in a narrowly scoped helper such as `src/lib/api/appview.ts`, `src/lib/api/backlinks.ts`, `src/lib/api/client.ts`, `src/lib/api/hydration.ts`, `src/lib/api/records.ts`, or `src/lib/api/stateful.ts`. 69 73 70 74 Guidelines: 71 75 ··· 145 149 - Keep `src/App.tsx` small. 146 150 - Keep route declarations in `src/routes.tsx`. 147 151 - Keep global shell/topbar work in `src/layout.tsx`. 148 - - Keep top-level non-repo pages in `src/views`. 152 + - Keep top-level non-repo pages in `src/pages`. 149 153 - Keep repo-route behavior in `src/pages/repo/*.tsx`. 150 154 - Keep repo-route pure helpers in nearby `src/pages/repo/*-helpers.ts` files. 151 155 - Keep generic display components in `src/components/common.tsx`. ··· 165 169 - `src/pages/repo/code.tsx`: largest repo route; split further if code/blob logic grows. 166 170 - `src/pages/repo/pulls.tsx`: pull routes and patch/diff UI. 167 171 - `src/pages/repo/issues.tsx`: issue routes. 168 - - `src/lib/api/core.ts`: compatibility implementation backend; prefer new domain modules for new API surfaces. 172 + - `src/lib/api/*`: domain modules own their public implementations; shared plumbing is split across focused helper modules. 169 173 170 174 171 175 <!-- headroom:rtk-instructions -->
+1 -1
src/components/common.tsx
··· 13 13 import { A } from '@solidjs/router'; 14 14 import { Match, Show, Switch, createEffect, createMemo, createSignal, type Component } from 'solid-js'; 15 15 import { createQuery } from '@tanstack/solid-query'; 16 - import { resolveAvatarUrl } from '../lib/api'; 16 + import { resolveAvatarUrl } from '../lib/api/identity'; 17 17 18 18 export const ToggleButton: Component<{ 19 19 active: boolean;
+1 -1
src/components/repo.tsx
··· 20 20 import { marked } from 'marked'; 21 21 import { A } from '@solidjs/router'; 22 22 import { For, Show, createMemo, createSignal, type Component, type JSX } from 'solid-js'; 23 - import type { TreeEntry } from '../lib/api'; 23 + import type { TreeEntry } from '../lib/api/repos'; 24 24 import { formatRelativeTime } from '../lib/repo-utils'; 25 25 import { Avatar, SkeletonBlock, cardStyles } from './common'; 26 26
+1 -1
src/layout.tsx
··· 5 5 import { For, Show, createEffect, createSignal, onCleanup, onMount, type Component, type JSX } from 'solid-js'; 6 6 7 7 import { Avatar, buttonStyles, inputStyles } from './components/common'; 8 - import { clearApiCaches, resolveActor } from './lib/api'; 8 + import { clearApiCaches, resolveActor } from './lib/api/identity'; 9 9 import { useAuth } from './lib/auth'; 10 10 import { useLiveEvents, type LiveEvent } from './lib/live-events'; 11 11 import { formatRelativeTime } from './lib/repo-utils';
+67
src/lib/api/appview-cache.ts
··· 1 + interface AppviewCursorCheckpoint { 2 + matchingSeen: number; 3 + scannedSeen: number; 4 + cursor?: string; 5 + exhausted: boolean; 6 + } 7 + 8 + const APPVIEW_CURSOR_CACHE_LIMIT = 64; 9 + const appviewPageCursorCache = new Map<string, Map<number, AppviewCursorCheckpoint>>(); 10 + 11 + export const clearAppviewStatefulCursorCache = () => { 12 + appviewPageCursorCache.clear(); 13 + }; 14 + 15 + export const rememberAppviewCursorCheckpoint = (cacheKey: string, checkpoint: AppviewCursorCheckpoint) => { 16 + let checkpoints = appviewPageCursorCache.get(cacheKey); 17 + if (!checkpoints) { 18 + checkpoints = new Map(); 19 + appviewPageCursorCache.set(cacheKey, checkpoints); 20 + } 21 + 22 + const existing = checkpoints.get(checkpoint.matchingSeen); 23 + if (!existing || checkpoint.scannedSeen >= existing.scannedSeen) { 24 + checkpoints.set(checkpoint.matchingSeen, checkpoint); 25 + } 26 + 27 + while (checkpoints.size > APPVIEW_CURSOR_CACHE_LIMIT) { 28 + const first = checkpoints.keys().next().value; 29 + if (first === undefined) break; 30 + checkpoints.delete(first); 31 + } 32 + }; 33 + 34 + export const getNearestAppviewCursorCheckpoint = ( 35 + cacheKey: string, 36 + offset: number, 37 + ): AppviewCursorCheckpoint => { 38 + const checkpoints = appviewPageCursorCache.get(cacheKey); 39 + const start: AppviewCursorCheckpoint = { 40 + matchingSeen: 0, 41 + scannedSeen: 0, 42 + exhausted: false, 43 + }; 44 + 45 + if (!checkpoints) { 46 + return start; 47 + } 48 + 49 + let nearest = start; 50 + for (const checkpoint of checkpoints.values()) { 51 + if (checkpoint.matchingSeen > offset) { 52 + continue; 53 + } 54 + 55 + if ( 56 + checkpoint.matchingSeen > nearest.matchingSeen || 57 + ( 58 + checkpoint.matchingSeen === nearest.matchingSeen && 59 + checkpoint.scannedSeen > nearest.scannedSeen 60 + ) 61 + ) { 62 + nearest = checkpoint; 63 + } 64 + } 65 + 66 + return nearest; 67 + };
+232
src/lib/api/appview.ts
··· 1 + import type { ResourceUri } from '@atcute/lexicons/syntax'; 2 + 3 + import { normalizeServiceUrl } from './client'; 4 + import { 5 + getRouteKnotRequestsThroughAppview, 6 + getTangledAppviewService, 7 + } from '../settings'; 8 + 9 + export interface AppviewRecordView<T> { 10 + uri: ResourceUri; 11 + cid?: string | null; 12 + value: T; 13 + } 14 + 15 + export interface AppviewListResponse<T> { 16 + items: Array<AppviewRecordView<T>>; 17 + cursor?: string | null; 18 + } 19 + 20 + export interface AppviewBulkResponse<T> { 21 + items: Array<AppviewRecordView<T>>; 22 + } 23 + 24 + export interface AppviewStatefulRecordView<T> extends AppviewRecordView<T> { 25 + state?: string; 26 + stateUpdatedAt?: string; 27 + commentCount?: number; 28 + } 29 + 30 + export interface AppviewStatefulListResponse<T> { 31 + items: Array<AppviewStatefulRecordView<T>>; 32 + cursor?: string | null; 33 + } 34 + 35 + export interface AppviewSearchHit<T> extends AppviewRecordView<T> { 36 + nsid: string; 37 + score: number; 38 + } 39 + 40 + export interface AppviewSearchResponse<T> { 41 + hits: Array<AppviewSearchHit<T>>; 42 + cursor?: string | null; 43 + } 44 + 45 + interface AppviewCountResponse { 46 + count: number; 47 + distinctAuthors?: number; 48 + distinct_authors?: number; 49 + } 50 + 51 + export type AppviewParamValue = string | number | boolean | null | undefined; 52 + 53 + export class AppviewResponseError extends Error { 54 + readonly status: number; 55 + 56 + constructor(status: number, message: string) { 57 + super(message); 58 + this.name = 'AppviewResponseError'; 59 + this.status = status; 60 + } 61 + } 62 + 63 + export class AppviewUnavailableError extends Error { 64 + readonly cause: unknown; 65 + 66 + constructor(cause: unknown) { 67 + super(cause instanceof Error ? cause.message : 'Appview unavailable'); 68 + this.name = 'AppviewUnavailableError'; 69 + this.cause = cause; 70 + } 71 + } 72 + 73 + export const appviewJson = async <T>( 74 + nsid: string, 75 + params: Record<string, AppviewParamValue> = {}, 76 + signal?: AbortSignal, 77 + ): Promise<T> => { 78 + const url = new URL(`/xrpc/${nsid}`, normalizeServiceUrl(getTangledAppviewService())); 79 + for (const [key, value] of Object.entries(params)) { 80 + if (value !== undefined && value !== null) { 81 + url.searchParams.set(key, String(value)); 82 + } 83 + } 84 + 85 + let response: Response; 86 + try { 87 + response = await fetch(url, { 88 + headers: { 89 + accept: 'application/json', 90 + }, 91 + signal, 92 + }); 93 + } catch (cause) { 94 + throw new AppviewUnavailableError(cause); 95 + } 96 + 97 + if (!response.ok) { 98 + let message = `${response.status} ${response.statusText}`.trim(); 99 + try { 100 + const body = (await response.json()) as { message?: unknown; error?: unknown }; 101 + const bodyMessage = typeof body.message === 'string' ? body.message : body.error; 102 + if (typeof bodyMessage === 'string') { 103 + message = bodyMessage; 104 + } 105 + } catch { 106 + // Keep the HTTP status message when the appview does not return JSON. 107 + } 108 + 109 + throw new AppviewResponseError(response.status, `Appview ${nsid} failed (${response.status}): ${message}`); 110 + } 111 + 112 + return (await response.json()) as T; 113 + }; 114 + 115 + export const appviewJsonUrl = async <T>(url: URL, signal?: AbortSignal): Promise<T> => { 116 + let response: Response; 117 + try { 118 + response = await fetch(url, { 119 + headers: { 120 + accept: 'application/json', 121 + }, 122 + signal, 123 + }); 124 + } catch (cause) { 125 + throw new AppviewUnavailableError(cause); 126 + } 127 + 128 + if (!response.ok) { 129 + let message = `${response.status} ${response.statusText}`.trim(); 130 + try { 131 + const body = (await response.json()) as { message?: unknown; error?: unknown }; 132 + const bodyMessage = typeof body.message === 'string' ? body.message : body.error; 133 + if (typeof bodyMessage === 'string') { 134 + message = bodyMessage; 135 + } 136 + } catch { 137 + // Keep the HTTP status message when the appview does not return JSON. 138 + } 139 + 140 + throw new AppviewResponseError(response.status, `Appview ${url.pathname.slice('/xrpc/'.length)} failed (${response.status}): ${message}`); 141 + } 142 + 143 + return (await response.json()) as T; 144 + }; 145 + 146 + export const appviewFallback = async <T>(primary: () => Promise<T>, fallback: () => Promise<T>): Promise<T> => { 147 + try { 148 + return await primary(); 149 + } catch (cause) { 150 + if (!(cause instanceof AppviewUnavailableError)) { 151 + throw cause; 152 + } 153 + 154 + return fallback(); 155 + } 156 + }; 157 + 158 + export const listAllAppviewRecords = async <T>( 159 + nsid: string, 160 + subject: string, 161 + extraParams: Record<string, AppviewParamValue> = {}, 162 + ): Promise<Array<AppviewRecordView<T>>> => { 163 + const records: Array<AppviewRecordView<T>> = []; 164 + let cursor: string | undefined; 165 + 166 + do { 167 + const page = await appviewJson<AppviewListResponse<T>>(nsid, { 168 + subject, 169 + limit: 100, 170 + cursor, 171 + ...extraParams, 172 + }); 173 + records.push(...page.items); 174 + cursor = page.cursor ?? undefined; 175 + } while (cursor); 176 + 177 + return records; 178 + }; 179 + 180 + export const appviewBulkRecords = async <T>( 181 + nsid: string, 182 + paramName: string, 183 + uris: ResourceUri[], 184 + ): Promise<Array<AppviewRecordView<T>>> => { 185 + if (uris.length === 0) { 186 + return []; 187 + } 188 + 189 + const records: Array<AppviewRecordView<T>> = []; 190 + for (let index = 0; index < uris.length; index += 50) { 191 + const url = new URL(`/xrpc/${nsid}`, normalizeServiceUrl(getTangledAppviewService())); 192 + for (const uri of uris.slice(index, index + 50)) { 193 + url.searchParams.append(paramName, uri); 194 + } 195 + 196 + const page = await appviewJsonUrl<AppviewBulkResponse<T>>(url); 197 + records.push(...page.items); 198 + } 199 + 200 + return records; 201 + }; 202 + 203 + export const getAppviewRecordCount = async (nsid: string, subject: string): Promise<number> => { 204 + const count = await appviewJson<AppviewCountResponse>(nsid, { subject }); 205 + return count.count; 206 + }; 207 + 208 + export const getDistinctAppviewRecordCount = async (nsid: string, subject: string): Promise<number> => { 209 + const count = await appviewJson<AppviewCountResponse>(nsid, { subject }); 210 + return count.distinctAuthors ?? count.distinct_authors ?? count.count; 211 + }; 212 + 213 + export const getRepoViaAppview = async <T>( 214 + nsid: string, 215 + repo: { record: { uri: string } }, 216 + params: Record<string, AppviewParamValue> = {}, 217 + ): Promise<T> => 218 + appviewJson<T>(nsid, { 219 + repo: repo.record.uri, 220 + ...params, 221 + }); 222 + 223 + export const appviewOptionalKnotRequest = async <T>( 224 + appview: () => Promise<T>, 225 + direct: () => Promise<T>, 226 + ): Promise<T> => { 227 + if (!getRouteKnotRequestsThroughAppview()) { 228 + return direct(); 229 + } 230 + 231 + return appviewFallback(appview, direct); 232 + };
+269
src/lib/api/backlinks.ts
··· 1 + import { ok } from '@atcute/client'; 2 + import type { Did, GenericUri, ResourceUri } from '@atcute/lexicons/syntax'; 3 + 4 + import { CONSTELLATION_SERVICE, STAR_COLLECTION } from './constants'; 5 + import { getRpc } from './client'; 6 + import { hydrateBacklinks } from './hydration'; 7 + import { 8 + type BacklinkRecordRef, 9 + type HydratedRecord, 10 + type PaginatedResult, 11 + sortByCreatedAt, 12 + timestampFromTid, 13 + toNumberMap, 14 + } from './records'; 15 + import { 16 + paginateStatefulRecords, 17 + sortStatefulRecordsByUpdatedAt, 18 + statefulRecordMatchesQuery, 19 + type StatefulHydratedRecord, 20 + type StatefulPageOptions, 21 + } from './stateful'; 22 + 23 + export const getBacklinksPage = async ( 24 + subject: GenericUri, 25 + source: string, 26 + limit: number, 27 + cursor?: string, 28 + ): Promise<{ records: BacklinkRecordRef[]; cursor?: string; total?: number }> => { 29 + const constellation = getRpc(CONSTELLATION_SERVICE); 30 + const page = await ok( 31 + constellation.get('blue.microcosm.links.getBacklinks', { 32 + params: { 33 + subject, 34 + source, 35 + limit, 36 + ...(cursor ? { cursor } : {}), 37 + }, 38 + }), 39 + ); 40 + 41 + return page as { records: BacklinkRecordRef[]; cursor?: string; total?: number }; 42 + }; 43 + 44 + const getBacklinkDidsPage = async ( 45 + subject: GenericUri, 46 + source: string, 47 + limit: number, 48 + cursor?: string, 49 + ): Promise<{ linking_dids: Did[]; cursor?: string | null; total?: number }> => { 50 + const constellation = getRpc(CONSTELLATION_SERVICE); 51 + const page = await ok( 52 + constellation.get('blue.microcosm.links.getBacklinkDids', { 53 + params: { 54 + subject, 55 + source, 56 + limit, 57 + ...(cursor ? { cursor } : {}), 58 + }, 59 + }), 60 + ); 61 + 62 + return page as { linking_dids: Did[]; cursor?: string | null; total?: number }; 63 + }; 64 + 65 + export const getBacklinkDids = async (subject: GenericUri, source: string): Promise<Did[]> => { 66 + const dids: Did[] = []; 67 + let cursor: string | null = null; 68 + 69 + do { 70 + const page = await getBacklinkDidsPage(subject, source, 100, cursor ?? undefined); 71 + dids.push(...page.linking_dids); 72 + cursor = page.cursor ?? null; 73 + } while (cursor); 74 + 75 + return dids; 76 + }; 77 + 78 + const findBacklinkRefByDidForSource = async ( 79 + subject: GenericUri, 80 + source: string, 81 + did: Did, 82 + ): Promise<BacklinkRecordRef | undefined> => { 83 + let cursor: string | null = null; 84 + 85 + do { 86 + const page = await getBacklinksPage(subject, source, 100, cursor ?? undefined); 87 + const ref = page.records.find((record) => record.did === did && record.collection === STAR_COLLECTION); 88 + if (ref) { 89 + return ref; 90 + } 91 + cursor = page.cursor ?? null; 92 + } while (cursor); 93 + }; 94 + 95 + export const findBacklinkRefByDid = async ( 96 + subject: GenericUri, 97 + sources: string[], 98 + did: Did, 99 + ): Promise<BacklinkRecordRef | undefined> => { 100 + const refs = await Promise.all(sources.map((source) => findBacklinkRefByDidForSource(subject, source, did))); 101 + return refs.find((ref) => ref); 102 + }; 103 + 104 + export const getBacklinks = async (subject: GenericUri, source: string): Promise<BacklinkRecordRef[]> => { 105 + const records: BacklinkRecordRef[] = []; 106 + let cursor: string | null = null; 107 + const constellation = getRpc(CONSTELLATION_SERVICE); 108 + 109 + do { 110 + const page: { records: BacklinkRecordRef[]; cursor?: string | null } = await ok( 111 + constellation.get('blue.microcosm.links.getBacklinks', { 112 + params: { 113 + subject, 114 + source, 115 + limit: 100, 116 + ...(cursor ? { cursor } : {}), 117 + }, 118 + }), 119 + ); 120 + records.push(...page.records); 121 + cursor = page.cursor ?? null; 122 + } while (cursor); 123 + 124 + return records; 125 + }; 126 + 127 + export const latestBacklinkRefByRkey = (refs: BacklinkRecordRef[]): BacklinkRecordRef | undefined => 128 + [...refs].sort((left, right) => left.rkey.localeCompare(right.rkey)).at(-1); 129 + 130 + export const getUniqueBacklinkRefs = async ( 131 + subject: GenericUri, 132 + source: string | string[], 133 + ): Promise<BacklinkRecordRef[]> => 134 + Array.from( 135 + new Map( 136 + ( 137 + await Promise.all( 138 + (Array.isArray(source) ? source : [source]).map((entry) => getBacklinks(subject, entry)), 139 + ) 140 + ) 141 + .flat() 142 + .map((ref) => [`${ref.did}/${ref.collection}/${ref.rkey}`, ref] as const), 143 + ).values(), 144 + ); 145 + 146 + export const getLatestBacklinkTimestamp = async ( 147 + subject: GenericUri, 148 + source: string | string[], 149 + ): Promise<number> => { 150 + try { 151 + return timestampFromTid(latestBacklinkRefByRkey(await getUniqueBacklinkRefs(subject, source))?.rkey); 152 + } catch { 153 + return 0; 154 + } 155 + }; 156 + 157 + const BACKLINK_BATCH_LIMIT = 50; 158 + 159 + export const listBacklinkedRecordsPage = async <T extends { createdAt?: string }, State extends string>( 160 + subject: GenericUri, 161 + source: string | string[], 162 + options: StatefulPageOptions<State>, 163 + getState: (uri: ResourceUri) => Promise<State>, 164 + getUpdatedAt?: (record: StatefulHydratedRecord<T, State>) => Promise<number>, 165 + hydrateRefs: (refs: BacklinkRecordRef[]) => Promise<Array<HydratedRecord<T>>> = hydrateBacklinks<T>, 166 + ): Promise<PaginatedResult<StatefulHydratedRecord<T, State>>> => { 167 + if (getUpdatedAt) { 168 + const hydrated = await hydrateRefs(await getUniqueBacklinkRefs(subject, source)); 169 + const numbers = toNumberMap(hydrated); 170 + const states = await Promise.all(hydrated.map((record) => getState(record.uri))); 171 + const items = hydrated.map((record, index) => ({ 172 + ...record, 173 + number: numbers.get(record.uri) ?? index + 1, 174 + state: states[index], 175 + })); 176 + const ordered = await sortStatefulRecordsByUpdatedAt(items, getUpdatedAt); 177 + 178 + return paginateStatefulRecords(ordered, options); 179 + } 180 + 181 + const requestedEnd = options.offset + options.limit; 182 + const items: Array<StatefulHydratedRecord<T, State>> = []; 183 + const sources = Array.isArray(source) ? source : [source]; 184 + const batchLimit = Math.min(BACKLINK_BATCH_LIMIT, Math.max(options.limit + 1, 1)); 185 + const cursors = new Map(sources.map((entry) => [entry, undefined as string | undefined])); 186 + const exhausted = new Set<string>(); 187 + const seenRefs = new Set<string>(); 188 + let matchingSeen = 0; 189 + let scannedSeen = 0; 190 + let total: number | undefined; 191 + 192 + while (items.length <= options.limit) { 193 + const pages = await Promise.all( 194 + sources 195 + .filter((entry) => !exhausted.has(entry)) 196 + .map(async (entry) => ({ 197 + source: entry, 198 + page: await getBacklinksPage(subject, entry, batchLimit, cursors.get(entry)), 199 + })), 200 + ); 201 + 202 + if (pages.length === 0) { 203 + return { items, totalCount: matchingSeen, hasNext: false }; 204 + } 205 + 206 + const refs = pages 207 + .flatMap(({ source: entry, page }) => { 208 + total = sources.length === 1 ? page.total ?? total : undefined; 209 + cursors.set(entry, page.cursor); 210 + if (!page.cursor) { 211 + exhausted.add(entry); 212 + } 213 + return page.records; 214 + }) 215 + .filter((ref) => { 216 + const key = `${ref.did}/${ref.collection}/${ref.rkey}`; 217 + if (seenRefs.has(key)) { 218 + return false; 219 + } 220 + seenRefs.add(key); 221 + return true; 222 + }); 223 + 224 + if (refs.length === 0) { 225 + if (exhausted.size === sources.length) { 226 + return { items, totalCount: matchingSeen, hasNext: false }; 227 + } 228 + continue; 229 + } 230 + 231 + if (pages.every(({ page }) => page.records.length === 0)) { 232 + return { items, totalCount: matchingSeen, hasNext: false }; 233 + } 234 + 235 + const hydrated = await hydrateRefs(refs); 236 + hydrated.sort(sortByCreatedAt).reverse(); 237 + const states = await Promise.all(hydrated.map((record) => getState(record.uri))); 238 + 239 + for (let index = 0; index < hydrated.length; index += 1) { 240 + const record = hydrated[index]; 241 + const state = states[index]; 242 + const number = total ? Math.max(total - scannedSeen, 1) : scannedSeen + 1; 243 + scannedSeen += 1; 244 + 245 + if (state !== options.state) { 246 + continue; 247 + } 248 + 249 + if (!statefulRecordMatchesQuery(record.value, options.query)) { 250 + continue; 251 + } 252 + 253 + if (matchingSeen >= options.offset && matchingSeen < requestedEnd + 1) { 254 + items.push({ ...record, number, state }); 255 + } 256 + 257 + matchingSeen += 1; 258 + if (matchingSeen > requestedEnd) { 259 + return { items: items.slice(0, options.limit), hasNext: true }; 260 + } 261 + } 262 + 263 + if (exhausted.size === sources.length) { 264 + return { items, totalCount: matchingSeen, hasNext: false }; 265 + } 266 + } 267 + 268 + return { items: items.slice(0, options.limit), hasNext: true }; 269 + };
+34
src/lib/api/client.ts
··· 1 + import { Client, simpleFetchHandler } from '@atcute/client'; 2 + import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 3 + 4 + const rpcCache = new Map<string, Client>(); 5 + 6 + export const normalizeServiceUrl = (input: string): string => { 7 + if (input.startsWith('http://') || input.startsWith('https://')) { 8 + return input; 9 + } 10 + 11 + return `https://${input}`; 12 + }; 13 + 14 + export const getRpc = (service: string): Client => { 15 + const normalized = normalizeServiceUrl(service); 16 + const existing = rpcCache.get(normalized); 17 + if (existing) { 18 + return existing; 19 + } 20 + 21 + const client = new Client({ 22 + handler: simpleFetchHandler({ 23 + service: normalized, 24 + }), 25 + }); 26 + 27 + rpcCache.set(normalized, client); 28 + return client; 29 + }; 30 + 31 + export const createAuthRpc = (agent: OAuthUserAgent): Client => 32 + new Client({ 33 + handler: agent, 34 + });
+22 -8
src/lib/api/constants.ts
··· 1 - export { 2 - CONSTELLATION_SERVICE, 3 - PUBLIC_API_SERVICE, 4 - SLINGSHOT_SERVICE, 5 - SPACEDUST_SERVICE, 6 - TANGLED_APPVIEW_SERVICE, 7 - TANGLED_OAUTH_SCOPE, 8 - } from './core'; 1 + import type { Nsid } from '@atcute/lexicons/syntax'; 2 + 3 + import { DEFAULT_TANGLED_APPVIEW_SERVICE } from '../settings'; 4 + 5 + export const PUBLIC_API_SERVICE = 'https://public.api.bsky.app'; 6 + export const CONSTELLATION_SERVICE = 'https://constellation.microcosm.blue'; 7 + export const SLINGSHOT_SERVICE = 'https://slingshot.microcosm.blue'; 8 + export const SPACEDUST_SERVICE = 'wss://spacedust.microcosm.blue/subscribe'; 9 + export const TANGLED_APPVIEW_SERVICE = DEFAULT_TANGLED_APPVIEW_SERVICE; 10 + 11 + export const TANGLED_OAUTH_SCOPE = import.meta.env.VITE_OAUTH_SCOPE; 12 + 13 + export const REPO_COLLECTION: Nsid = 'sh.tangled.repo'; 14 + export const STAR_COLLECTION: Nsid = 'sh.tangled.feed.star'; 15 + export const ACTOR_PROFILE_COLLECTION: Nsid = 'sh.tangled.actor.profile'; 16 + export const APP_BSKY_PROFILE_COLLECTION: Nsid = 'app.bsky.actor.profile'; 17 + export const ISSUE_COLLECTION: Nsid = 'sh.tangled.repo.issue'; 18 + export const ISSUE_COMMENT_COLLECTION: Nsid = 'sh.tangled.repo.issue.comment'; 19 + export const ISSUE_STATE_COLLECTION: Nsid = 'sh.tangled.repo.issue.state'; 20 + export const PULL_COLLECTION: Nsid = 'sh.tangled.repo.pull'; 21 + export const PULL_COMMENT_COLLECTION: Nsid = 'sh.tangled.repo.pull.comment'; 22 + export const PULL_STATUS_COLLECTION: Nsid = 'sh.tangled.repo.pull.status';
-2784
src/lib/api/core.ts
··· 1 - import { Client, ClientResponseError, ok, simpleFetchHandler } from '@atcute/client'; 2 - import { 3 - type ActorResolver, 4 - type ResolveActorOptions, 5 - type ResolvedActor, 6 - } from '@atcute/identity-resolver'; 7 - import type { 8 - ActorIdentifier, 9 - Did, 10 - Handle, 11 - Nsid, 12 - ResourceUri, 13 - } from '@atcute/lexicons/syntax'; 14 - import type { GenericUri } from '@atcute/lexicons/syntax'; 15 - import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 16 - import { 17 - ShTangledActorProfile, 18 - ShTangledFeedStar, 19 - ShTangledLabelDefinition, 20 - ShTangledRepo, 21 - ShTangledRepoIssue, 22 - ShTangledRepoIssueComment, 23 - ShTangledRepoIssueState, 24 - ShTangledRepoPull, 25 - ShTangledRepoPullComment, 26 - ShTangledRepoPullStatus, 27 - ShTangledString, 28 - } from '@atcute/tangled'; 29 - import { gunzipSync, gzipSync, strFromU8, strToU8 } from 'fflate'; 30 - 31 - import type {} from '@atcute/atproto'; 32 - import type {} from '@atcute/microcosm'; 33 - import type {} from '@atcute/tangled'; 34 - 35 - import { 36 - DEFAULT_TANGLED_APPVIEW_SERVICE, 37 - getRouteKnotRequestsThroughAppview, 38 - getTangledAppviewService, 39 - } from '../settings'; 40 - 41 - export const PUBLIC_API_SERVICE = 'https://public.api.bsky.app'; 42 - export const CONSTELLATION_SERVICE = 'https://constellation.microcosm.blue'; 43 - export const SLINGSHOT_SERVICE = 'https://slingshot.microcosm.blue'; 44 - export const SPACEDUST_SERVICE = 'wss://spacedust.microcosm.blue/subscribe'; 45 - export const TANGLED_APPVIEW_SERVICE = DEFAULT_TANGLED_APPVIEW_SERVICE; 46 - 47 - export const TANGLED_OAUTH_SCOPE = import.meta.env.VITE_OAUTH_SCOPE; 48 - 49 - const REPO_COLLECTION: Nsid = 'sh.tangled.repo'; 50 - const STAR_COLLECTION: Nsid = 'sh.tangled.feed.star'; 51 - const ACTOR_PROFILE_COLLECTION: Nsid = 'sh.tangled.actor.profile'; 52 - const APP_BSKY_PROFILE_COLLECTION: Nsid = 'app.bsky.actor.profile'; 53 - const ISSUE_COLLECTION: Nsid = 'sh.tangled.repo.issue'; 54 - const ISSUE_COMMENT_COLLECTION: Nsid = 'sh.tangled.repo.issue.comment'; 55 - const ISSUE_STATE_COLLECTION: Nsid = 'sh.tangled.repo.issue.state'; 56 - const PULL_COLLECTION: Nsid = 'sh.tangled.repo.pull'; 57 - const PULL_COMMENT_COLLECTION: Nsid = 'sh.tangled.repo.pull.comment'; 58 - const PULL_STATUS_COLLECTION: Nsid = 'sh.tangled.repo.pull.status'; 59 - 60 - interface MiniDocResponse { 61 - did: Did; 62 - handle: Handle; 63 - pds: string; 64 - } 65 - 66 - class AppviewFirstActorResolver implements ActorResolver { 67 - async resolve(actor: ActorIdentifier, options?: ResolveActorOptions): Promise<ResolvedActor> { 68 - try { 69 - return normalizeResolvedActor( 70 - await appviewJson<MiniDocResponse>( 71 - 'com.bad-example.identity.resolveMiniDoc', 72 - { identifier: actor }, 73 - options?.signal, 74 - ), 75 - ); 76 - } catch (cause) { 77 - if (!(cause instanceof AppviewUnavailableError)) { 78 - throw cause; 79 - } 80 - 81 - const resolved = await ok( 82 - getRpc(SLINGSHOT_SERVICE).get('blue.microcosm.identity.resolveMiniDoc', { 83 - params: { 84 - identifier: actor, 85 - }, 86 - signal: options?.signal, 87 - }), 88 - ); 89 - 90 - return normalizeResolvedActor(resolved); 91 - } 92 - } 93 - } 94 - 95 - export const identityResolver = new AppviewFirstActorResolver(); 96 - 97 - export interface RepoContext { 98 - owner: ResolvedActor; 99 - record: RepoRecord; 100 - repoDid: Did; 101 - slug: string; 102 - rkey: string; 103 - knot: string; 104 - } 105 - 106 - export interface RepoRecord { 107 - uri: ResourceUri; 108 - cid: string; 109 - rkey: string; 110 - value: ShTangledRepo.Main; 111 - } 112 - 113 - export interface HydratedRecord<T> { 114 - author: ResolvedActor; 115 - collection: Nsid; 116 - rkey: string; 117 - uri: ResourceUri; 118 - cid?: string; 119 - value: T; 120 - } 121 - 122 - export interface IssueSummary extends HydratedRecord<ShTangledRepoIssue.Main> { 123 - number: number; 124 - state: 'open' | 'closed'; 125 - } 126 - 127 - export interface PullSummary extends HydratedRecord<ShTangledRepoPull.Main> { 128 - number: number; 129 - state: 'open' | 'closed' | 'merged'; 130 - } 131 - 132 - type StatefulHydratedRecord<T, State extends string> = HydratedRecord<T> & { 133 - number: number; 134 - state: State; 135 - }; 136 - 137 - type StatefulPageOptions<State extends string> = { 138 - offset: number; 139 - limit: number; 140 - state: State; 141 - author?: Did; 142 - query?: string; 143 - }; 144 - 145 - export interface IssueComment extends HydratedRecord<ShTangledRepoIssueComment.Main> {} 146 - export interface PullComment extends HydratedRecord<ShTangledRepoPullComment.Main> {} 147 - 148 - export interface IssueDetail { 149 - issue: IssueSummary; 150 - comments: IssueComment[]; 151 - } 152 - 153 - export interface PullDetail { 154 - pull: PullSummary; 155 - comments: PullComment[]; 156 - } 157 - 158 - export type SearchCollection = 159 - | 'sh.tangled.actor.profile' 160 - | 'sh.tangled.repo' 161 - | 'sh.tangled.repo.issue' 162 - | 'sh.tangled.repo.issue.comment' 163 - | 'sh.tangled.repo.pull' 164 - | 'sh.tangled.repo.pull.comment' 165 - | 'sh.tangled.string' 166 - | 'sh.tangled.label.definition'; 167 - 168 - export type SearchRecordValue = 169 - | ShTangledActorProfile.Main 170 - | ShTangledRepo.Main 171 - | ShTangledRepoIssue.Main 172 - | ShTangledRepoIssueComment.Main 173 - | ShTangledRepoPull.Main 174 - | ShTangledRepoPullComment.Main 175 - | ShTangledString.Main 176 - | ShTangledLabelDefinition.Main; 177 - 178 - export interface SearchHit extends HydratedRecord<SearchRecordValue> { 179 - nsid: SearchCollection; 180 - score: number; 181 - } 182 - 183 - export interface SearchResponse { 184 - hits: SearchHit[]; 185 - cursor?: string; 186 - } 187 - 188 - export interface SearchRecordsOptions { 189 - q: string; 190 - nsid?: SearchCollection; 191 - author?: Did; 192 - repo?: Did; 193 - since?: string; 194 - until?: string; 195 - cursor?: string; 196 - limit?: number; 197 - } 198 - 199 - export interface PaginatedResult<T> { 200 - items: T[]; 201 - totalCount?: number; 202 - hasNext: boolean; 203 - } 204 - 205 - export interface TreeResponse { 206 - ref: string; 207 - parent?: string; 208 - dotdot?: string; 209 - lastCommit?: CommitHeadline; 210 - readme?: { 211 - contents: string; 212 - filename: string; 213 - }; 214 - files: TreeEntry[]; 215 - } 216 - 217 - export interface TreeEntry { 218 - name: string; 219 - mode: string; 220 - size: number; 221 - last_commit?: CommitHeadline; 222 - } 223 - 224 - export interface CommitHeadline { 225 - hash: string; 226 - message: string; 227 - when: string; 228 - author?: { 229 - email: string; 230 - name: string; 231 - when: string; 232 - }; 233 - } 234 - 235 - export interface BranchResponse { 236 - branches: BranchEntry[]; 237 - } 238 - 239 - export interface DefaultBranchResponse { 240 - name?: string; 241 - branch?: string; 242 - reference?: { 243 - name?: string; 244 - }; 245 - hash?: string; 246 - when?: string; 247 - } 248 - 249 - export interface BranchEntry { 250 - reference: { 251 - name: string; 252 - hash: string; 253 - }; 254 - commit?: { 255 - Message: string; 256 - Author?: { 257 - Name: string; 258 - Email: string; 259 - When: string; 260 - }; 261 - Committer?: { 262 - Name: string; 263 - Email: string; 264 - When: string; 265 - }; 266 - }; 267 - is_default?: boolean; 268 - } 269 - 270 - export interface TagResponse { 271 - tags: Array<{ 272 - name: string; 273 - hash: string; 274 - message?: string; 275 - tag?: { 276 - Name: string; 277 - Message: string; 278 - Tagger?: { 279 - Name: string; 280 - Email: string; 281 - When: string; 282 - }; 283 - }; 284 - }>; 285 - } 286 - 287 - export interface LanguageResponse { 288 - ref: string; 289 - totalFiles?: number; 290 - totalSize?: number; 291 - languages: Array<{ 292 - name: string; 293 - percentage: number; 294 - size: number; 295 - color?: string; 296 - }>; 297 - } 298 - 299 - export interface LogResponse { 300 - ref: string; 301 - total: number; 302 - page: number; 303 - per_page: number; 304 - log: boolean; 305 - commits: Array<{ 306 - this: string; 307 - message: string; 308 - author?: { 309 - Name: string; 310 - Email: string; 311 - When: string; 312 - }; 313 - committer?: { 314 - Name: string; 315 - Email: string; 316 - When: string; 317 - }; 318 - pgp_signature?: string; 319 - parent?: string; 320 - parent_hashes?: number[][]; 321 - }>; 322 - } 323 - 324 - export interface BlobResponse { 325 - path: string; 326 - ref: string; 327 - content?: string; 328 - encoding?: 'base64' | 'utf-8'; 329 - fileTooLarge?: boolean; 330 - isBinary?: boolean; 331 - mimeType?: string; 332 - size?: number; 333 - lastCommit?: CommitHeadline; 334 - submodule?: { 335 - name: string; 336 - url: string; 337 - branch?: string; 338 - }; 339 - } 340 - 341 - export interface ComparePatch { 342 - Title: string; 343 - Body: string; 344 - Raw: string; 345 - SHA: string; 346 - Author?: string; 347 - Committer?: string; 348 - AuthorDate?: string; 349 - CommitterDate?: string; 350 - SubjectPrefix?: string; 351 - RawHeaders?: string; 352 - BodyAppendix?: string; 353 - } 354 - 355 - export interface CompareResponse { 356 - rev1: string; 357 - rev2: string; 358 - patch: string; 359 - format_patch: ComparePatch[]; 360 - } 361 - 362 - export interface CreateIssueInput { 363 - title: string; 364 - body: string; 365 - } 366 - 367 - export interface CreatePullInput { 368 - title: string; 369 - body: string; 370 - targetBranch: string; 371 - sourceBranch: string; 372 - patch: string; 373 - } 374 - 375 - export interface RecordCreationResult { 376 - uri: ResourceUri; 377 - rkey: string; 378 - } 379 - 380 - interface BacklinkRecordRef { 381 - did: Did; 382 - collection: Nsid; 383 - rkey: string; 384 - } 385 - 386 - interface ProfileWithAvatar { 387 - avatar?: unknown; 388 - } 389 - 390 - interface AppviewRecordView<T> { 391 - uri: ResourceUri; 392 - cid?: string | null; 393 - value: T; 394 - } 395 - 396 - interface AppviewListResponse<T> { 397 - items: Array<AppviewRecordView<T>>; 398 - cursor?: string | null; 399 - } 400 - 401 - interface AppviewBulkResponse<T> { 402 - items: Array<AppviewRecordView<T>>; 403 - } 404 - 405 - interface AppviewStatefulRecordView<T> extends AppviewRecordView<T> { 406 - state?: string; 407 - stateUpdatedAt?: string; 408 - commentCount?: number; 409 - } 410 - 411 - interface AppviewStatefulListResponse<T> { 412 - items: Array<AppviewStatefulRecordView<T>>; 413 - cursor?: string | null; 414 - } 415 - 416 - interface AppviewSearchHit<T> extends AppviewRecordView<T> { 417 - nsid: SearchCollection; 418 - score: number; 419 - } 420 - 421 - interface AppviewSearchResponse<T> { 422 - hits: Array<AppviewSearchHit<T>>; 423 - cursor?: string | null; 424 - } 425 - 426 - interface AppviewCountResponse { 427 - count: number; 428 - distinctAuthors?: number; 429 - distinct_authors?: number; 430 - } 431 - 432 - type AppviewParamValue = string | number | boolean | null | undefined; 433 - 434 - const actorCache = new Map<string, Promise<ResolvedActor>>(); 435 - const rpcCache = new Map<string, Client>(); 436 - const SORTABLE_BASE32_ALPHABET = '234567abcdefghijklmnopqrstuvwxyz'; 437 - const TID_PATTERN = /^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/; 438 - 439 - interface AppviewCursorCheckpoint { 440 - matchingSeen: number; 441 - scannedSeen: number; 442 - cursor?: string; 443 - exhausted: boolean; 444 - } 445 - 446 - const APPVIEW_PAGE_BATCH_LIMIT = 100; 447 - const SEARCH_PAGE_BATCH_LIMIT = 100; 448 - const APPVIEW_CURSOR_CACHE_LIMIT = 64; 449 - const appviewPageCursorCache = new Map<string, Map<number, AppviewCursorCheckpoint>>(); 450 - 451 - export const clearApiCaches = () => { 452 - actorCache.clear(); 453 - appviewPageCursorCache.clear(); 454 - }; 455 - 456 - const timestampFromDate = (value?: string): number => { 457 - if (!value) { 458 - return 0; 459 - } 460 - 461 - const timestamp = new Date(value).getTime(); 462 - return Number.isFinite(timestamp) ? timestamp : 0; 463 - }; 464 - 465 - const timestampFromTid = (rkey?: string): number => { 466 - if (!rkey || !TID_PATTERN.test(rkey)) { 467 - return 0; 468 - } 469 - 470 - let micros = 0; 471 - for (const char of rkey.slice(0, 11)) { 472 - const value = SORTABLE_BASE32_ALPHABET.indexOf(char); 473 - if (value < 0) { 474 - return 0; 475 - } 476 - micros = micros * 32 + value; 477 - } 478 - 479 - return Math.floor(micros / 1000); 480 - }; 481 - 482 - const maxTimestamp = (...values: number[]): number => 483 - values.reduce((latest, value) => Math.max(latest, value), 0); 484 - 485 - const getAppviewRecordSortTimestamp = <T extends { createdAt?: string }>( 486 - record: HydratedRecord<T>, 487 - ): number => 488 - maxTimestamp( 489 - timestampFromDate(record.value.createdAt), 490 - timestampFromTid(record.rkey), 491 - ); 492 - 493 - const sortStatefulRecordsByNewestRecord = <T extends { createdAt?: string }, State extends string>( 494 - items: Array<StatefulHydratedRecord<T, State>>, 495 - ): Array<StatefulHydratedRecord<T, State>> => 496 - [...items].sort((left, right) => { 497 - const timestampDiff = getAppviewRecordSortTimestamp(right) - getAppviewRecordSortTimestamp(left); 498 - if (timestampDiff !== 0) { 499 - return timestampDiff; 500 - } 501 - 502 - if (left.number !== right.number) { 503 - return right.number - left.number; 504 - } 505 - 506 - return right.rkey.localeCompare(left.rkey); 507 - }); 508 - 509 - const sortStatefulRecordsByUpdatedAt = async <T, State extends string>( 510 - items: Array<StatefulHydratedRecord<T, State>>, 511 - getUpdatedAt: (item: StatefulHydratedRecord<T, State>) => Promise<number>, 512 - ): Promise<Array<StatefulHydratedRecord<T, State>>> => { 513 - const decorated = await Promise.all( 514 - items.map(async (item) => ({ 515 - item, 516 - updatedAt: await getUpdatedAt(item), 517 - })), 518 - ); 519 - 520 - decorated.sort((left, right) => { 521 - const updatedDiff = right.updatedAt - left.updatedAt; 522 - if (updatedDiff !== 0) { 523 - return updatedDiff; 524 - } 525 - 526 - if (left.item.number !== right.item.number) { 527 - return right.item.number - left.item.number; 528 - } 529 - 530 - return right.item.rkey.localeCompare(left.item.rkey); 531 - }); 532 - 533 - return decorated.map(({ item }) => item); 534 - }; 535 - 536 - const paginateStatefulRecords = <T, State extends string>( 537 - items: Array<StatefulHydratedRecord<T, State>>, 538 - options: StatefulPageOptions<State>, 539 - ): PaginatedResult<StatefulHydratedRecord<T, State>> => { 540 - const offset = Math.max(options.offset, 0); 541 - const limit = Math.max(options.limit, 1); 542 - const matching = items.filter((item) => 543 - item.state === options.state && 544 - (!options.author || item.author.did === options.author) && 545 - statefulRecordMatchesQuery(item.value, options.query), 546 - ); 547 - 548 - return { 549 - items: matching.slice(offset, offset + limit), 550 - totalCount: matching.length, 551 - hasNext: offset + limit < matching.length, 552 - }; 553 - }; 554 - 555 - const statefulRecordMatchesQuery = (value: unknown, query?: string): boolean => { 556 - const normalized = query?.trim().toLowerCase(); 557 - if (!normalized) { 558 - return true; 559 - } 560 - 561 - const record = value as { title?: unknown; body?: unknown }; 562 - return [record.title, record.body] 563 - .filter((part): part is string => typeof part === 'string') 564 - .some((part) => part.toLowerCase().includes(normalized)); 565 - }; 566 - 567 - const normalizeServiceUrl = (input: string): string => { 568 - if (input.startsWith('http://') || input.startsWith('https://')) { 569 - return input; 570 - } 571 - 572 - return `https://${input}`; 573 - }; 574 - 575 - const normalizeResolvedActor = (resolved: MiniDocResponse): ResolvedActor => ({ 576 - did: resolved.did, 577 - handle: resolved.handle, 578 - pds: normalizeServiceUrl(resolved.pds), 579 - }); 580 - 581 - const unresolvedActorFromDid = (did: Did): ResolvedActor => ({ 582 - did, 583 - handle: did as unknown as Handle, 584 - pds: '', 585 - }); 586 - 587 - const resolveActorForDisplay = async (did: Did): Promise<ResolvedActor> => { 588 - try { 589 - return await resolveActor(did); 590 - } catch { 591 - return unresolvedActorFromDid(did); 592 - } 593 - }; 594 - 595 - const getRpc = (service: string): Client => { 596 - const normalized = normalizeServiceUrl(service); 597 - const existing = rpcCache.get(normalized); 598 - if (existing) { 599 - return existing; 600 - } 601 - 602 - const client = new Client({ 603 - handler: simpleFetchHandler({ 604 - service: normalized, 605 - }), 606 - }); 607 - 608 - rpcCache.set(normalized, client); 609 - return client; 610 - }; 611 - 612 - class AppviewResponseError extends Error { 613 - readonly status: number; 614 - 615 - constructor(status: number, message: string) { 616 - super(message); 617 - this.name = 'AppviewResponseError'; 618 - this.status = status; 619 - } 620 - } 621 - 622 - class AppviewUnavailableError extends Error { 623 - readonly cause: unknown; 624 - 625 - constructor(cause: unknown) { 626 - super(cause instanceof Error ? cause.message : 'Appview unavailable'); 627 - this.name = 'AppviewUnavailableError'; 628 - this.cause = cause; 629 - } 630 - } 631 - 632 - const appviewJson = async <T>( 633 - nsid: string, 634 - params: Record<string, AppviewParamValue> = {}, 635 - signal?: AbortSignal, 636 - ): Promise<T> => { 637 - const url = new URL(`/xrpc/${nsid}`, normalizeServiceUrl(getTangledAppviewService())); 638 - for (const [key, value] of Object.entries(params)) { 639 - if (value !== undefined && value !== null) { 640 - url.searchParams.set(key, String(value)); 641 - } 642 - } 643 - 644 - let response: Response; 645 - try { 646 - response = await fetch(url, { 647 - headers: { 648 - accept: 'application/json', 649 - }, 650 - signal, 651 - }); 652 - } catch (cause) { 653 - throw new AppviewUnavailableError(cause); 654 - } 655 - 656 - if (!response.ok) { 657 - let message = `${response.status} ${response.statusText}`.trim(); 658 - try { 659 - const body = (await response.json()) as { message?: unknown; error?: unknown }; 660 - const bodyMessage = typeof body.message === 'string' ? body.message : body.error; 661 - if (typeof bodyMessage === 'string') { 662 - message = bodyMessage; 663 - } 664 - } catch { 665 - // Keep the HTTP status message when the appview does not return JSON. 666 - } 667 - 668 - throw new AppviewResponseError(response.status, `Appview ${nsid} failed (${response.status}): ${message}`); 669 - } 670 - 671 - return (await response.json()) as T; 672 - }; 673 - 674 - const appviewJsonUrl = async <T>(url: URL, signal?: AbortSignal): Promise<T> => { 675 - let response: Response; 676 - try { 677 - response = await fetch(url, { 678 - headers: { 679 - accept: 'application/json', 680 - }, 681 - signal, 682 - }); 683 - } catch (cause) { 684 - throw new AppviewUnavailableError(cause); 685 - } 686 - 687 - if (!response.ok) { 688 - let message = `${response.status} ${response.statusText}`.trim(); 689 - try { 690 - const body = (await response.json()) as { message?: unknown; error?: unknown }; 691 - const bodyMessage = typeof body.message === 'string' ? body.message : body.error; 692 - if (typeof bodyMessage === 'string') { 693 - message = bodyMessage; 694 - } 695 - } catch { 696 - // Keep the HTTP status message when the appview does not return JSON. 697 - } 698 - 699 - throw new AppviewResponseError(response.status, `Appview ${url.pathname.slice('/xrpc/'.length)} failed (${response.status}): ${message}`); 700 - } 701 - 702 - return (await response.json()) as T; 703 - }; 704 - 705 - const appviewFallback = async <T>(primary: () => Promise<T>, fallback: () => Promise<T>): Promise<T> => { 706 - try { 707 - return await primary(); 708 - } catch (cause) { 709 - if (!(cause instanceof AppviewUnavailableError)) { 710 - throw cause; 711 - } 712 - 713 - return fallback(); 714 - } 715 - }; 716 - 717 - const listAllAppviewRecords = async <T>( 718 - nsid: string, 719 - subject: string, 720 - extraParams: Record<string, AppviewParamValue> = {}, 721 - ): Promise<Array<AppviewRecordView<T>>> => { 722 - const records: Array<AppviewRecordView<T>> = []; 723 - let cursor: string | undefined; 724 - 725 - do { 726 - const page = await appviewJson<AppviewListResponse<T>>(nsid, { 727 - subject, 728 - limit: 100, 729 - cursor, 730 - ...extraParams, 731 - }); 732 - records.push(...page.items); 733 - cursor = page.cursor ?? undefined; 734 - } while (cursor); 735 - 736 - return records; 737 - }; 738 - 739 - const appviewBulkRecords = async <T>( 740 - nsid: string, 741 - paramName: string, 742 - uris: ResourceUri[], 743 - ): Promise<Array<AppviewRecordView<T>>> => { 744 - if (uris.length === 0) { 745 - return []; 746 - } 747 - 748 - const records: Array<AppviewRecordView<T>> = []; 749 - for (let index = 0; index < uris.length; index += 50) { 750 - const url = new URL(`/xrpc/${nsid}`, normalizeServiceUrl(getTangledAppviewService())); 751 - for (const uri of uris.slice(index, index + 50)) { 752 - url.searchParams.append(paramName, uri); 753 - } 754 - 755 - const page = await appviewJsonUrl<AppviewBulkResponse<T>>(url); 756 - records.push(...page.items); 757 - } 758 - 759 - return records; 760 - }; 761 - 762 - const appviewRecordToHydrated = async <T>(record: AppviewRecordView<T>): Promise<HydratedRecord<T>> => { 763 - const parsed = parseAtUri(record.uri); 764 - const author = await resolveActorForDisplay(parsed.did); 765 - 766 - return { 767 - author, 768 - collection: parsed.collection, 769 - rkey: parsed.rkey, 770 - uri: record.uri, 771 - cid: record.cid ?? undefined, 772 - value: record.value, 773 - }; 774 - }; 775 - 776 - const appviewCursorCacheKey = ( 777 - nsid: string, 778 - subject: string, 779 - state: string, 780 - extraParams: Record<string, AppviewParamValue> = {}, 781 - ): string => { 782 - const extras = Object.entries(extraParams) 783 - .filter(([, value]) => value !== undefined && value !== null) 784 - .sort(([left], [right]) => left.localeCompare(right)) 785 - .map(([key, value]) => `${key}=${String(value)}`) 786 - .join('&'); 787 - 788 - return [ 789 - normalizeServiceUrl(getTangledAppviewService()), 790 - nsid, 791 - subject, 792 - state, 793 - extras, 794 - ].join('|'); 795 - }; 796 - 797 - const rememberAppviewCursorCheckpoint = (cacheKey: string, checkpoint: AppviewCursorCheckpoint) => { 798 - let checkpoints = appviewPageCursorCache.get(cacheKey); 799 - if (!checkpoints) { 800 - checkpoints = new Map(); 801 - appviewPageCursorCache.set(cacheKey, checkpoints); 802 - } 803 - 804 - const existing = checkpoints.get(checkpoint.matchingSeen); 805 - if (!existing || checkpoint.scannedSeen >= existing.scannedSeen) { 806 - checkpoints.set(checkpoint.matchingSeen, checkpoint); 807 - } 808 - 809 - while (checkpoints.size > APPVIEW_CURSOR_CACHE_LIMIT) { 810 - const first = checkpoints.keys().next().value; 811 - if (first === undefined) break; 812 - checkpoints.delete(first); 813 - } 814 - }; 815 - 816 - const getNearestAppviewCursorCheckpoint = ( 817 - cacheKey: string, 818 - offset: number, 819 - ): AppviewCursorCheckpoint => { 820 - const checkpoints = appviewPageCursorCache.get(cacheKey); 821 - const start: AppviewCursorCheckpoint = { 822 - matchingSeen: 0, 823 - scannedSeen: 0, 824 - exhausted: false, 825 - }; 826 - 827 - if (!checkpoints) { 828 - return start; 829 - } 830 - 831 - let nearest = start; 832 - for (const checkpoint of checkpoints.values()) { 833 - if (checkpoint.matchingSeen > offset) { 834 - continue; 835 - } 836 - 837 - if ( 838 - checkpoint.matchingSeen > nearest.matchingSeen || 839 - ( 840 - checkpoint.matchingSeen === nearest.matchingSeen && 841 - checkpoint.scannedSeen > nearest.scannedSeen 842 - ) 843 - ) { 844 - nearest = checkpoint; 845 - } 846 - } 847 - 848 - return nearest; 849 - }; 850 - 851 - const listAppviewStatefulRecordsPage = async <T extends { createdAt?: string }, State extends string>( 852 - nsid: string, 853 - subject: string, 854 - options: StatefulPageOptions<State>, 855 - normalizeState: (value?: string) => State, 856 - extraParams: Record<string, AppviewParamValue> = {}, 857 - getUpdatedAt?: ( 858 - record: StatefulHydratedRecord<T, State>, 859 - appviewRecord: AppviewStatefulRecordView<T>, 860 - ) => Promise<number>, 861 - ): Promise<PaginatedResult<StatefulHydratedRecord<T, State>>> => { 862 - if (getUpdatedAt) { 863 - const records = await listAllAppviewRecords<T>(nsid, subject, extraParams) as Array<AppviewStatefulRecordView<T>>; 864 - const hydrated = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); 865 - const numbers = toNumberMap(hydrated); 866 - const decorated = await Promise.all( 867 - hydrated.map(async (record, index) => { 868 - const item: StatefulHydratedRecord<T, State> = { 869 - ...record, 870 - number: numbers.get(record.uri) ?? index + 1, 871 - state: normalizeState(records[index].state), 872 - }; 873 - 874 - return { 875 - item, 876 - updatedAt: await getUpdatedAt(item, records[index]), 877 - }; 878 - }), 879 - ); 880 - 881 - decorated.sort((left, right) => { 882 - const updatedDiff = right.updatedAt - left.updatedAt; 883 - if (updatedDiff !== 0) { 884 - return updatedDiff; 885 - } 886 - 887 - if (left.item.number !== right.item.number) { 888 - return right.item.number - left.item.number; 889 - } 890 - 891 - return right.item.rkey.localeCompare(left.item.rkey); 892 - }); 893 - 894 - return paginateStatefulRecords( 895 - decorated.map(({ item }) => item), 896 - options, 897 - ); 898 - } 899 - 900 - // Bobbin exposes cursor pagination, while the UI still uses offsets. Cache 901 - // page-boundary cursors so sequential pages can resume from the prior page. 902 - const offset = Math.max(options.offset, 0); 903 - const limit = Math.max(options.limit, 1); 904 - const requestedEnd = offset + limit; 905 - const cacheKey = appviewCursorCacheKey(nsid, subject, options.state, extraParams); 906 - const start = getNearestAppviewCursorCheckpoint(cacheKey, offset); 907 - const pendingItems: Array<{ 908 - record: AppviewStatefulRecordView<T>; 909 - number: number; 910 - state: State; 911 - }> = []; 912 - 913 - if (start.exhausted) { 914 - return { 915 - items: [], 916 - totalCount: start.matchingSeen, 917 - hasNext: false, 918 - }; 919 - } 920 - 921 - let cursor = start.cursor; 922 - let matchingSeen = start.matchingSeen; 923 - let scannedSeen = start.scannedSeen; 924 - 925 - while (true) { 926 - const pageLimit = Math.min( 927 - APPVIEW_PAGE_BATCH_LIMIT, 928 - Math.max(matchingSeen < requestedEnd ? requestedEnd - matchingSeen : 1, 1), 929 - ); 930 - const page = await appviewJson<AppviewStatefulListResponse<T>>(nsid, { 931 - subject, 932 - limit: pageLimit, 933 - cursor, 934 - ...extraParams, 935 - }); 936 - 937 - for (const record of page.items) { 938 - const state = normalizeState(record.state); 939 - const number = scannedSeen + 1; 940 - scannedSeen += 1; 941 - 942 - if (state !== options.state) { 943 - continue; 944 - } 945 - 946 - if (!statefulRecordMatchesQuery(record.value, options.query)) { 947 - continue; 948 - } 949 - 950 - if (matchingSeen >= offset && matchingSeen < requestedEnd) { 951 - pendingItems.push({ record, number, state }); 952 - } 953 - 954 - matchingSeen += 1; 955 - } 956 - 957 - cursor = page.cursor ?? undefined; 958 - const exhausted = !cursor; 959 - rememberAppviewCursorCheckpoint(cacheKey, { 960 - matchingSeen, 961 - scannedSeen, 962 - cursor, 963 - exhausted, 964 - }); 965 - 966 - if (matchingSeen > requestedEnd) { 967 - const visibleItems = pendingItems.slice(0, limit); 968 - const hydrated = await Promise.all( 969 - visibleItems.map(({ record }) => appviewRecordToHydrated(record)), 970 - ); 971 - 972 - return { 973 - items: hydrated.map((item, index) => ({ 974 - ...item, 975 - number: visibleItems[index].number, 976 - state: visibleItems[index].state, 977 - })), 978 - hasNext: true, 979 - }; 980 - } 981 - 982 - if (exhausted || page.items.length === 0) { 983 - const hydrated = await Promise.all( 984 - pendingItems.map(({ record }) => appviewRecordToHydrated(record)), 985 - ); 986 - 987 - return { 988 - items: hydrated.map((item, index) => ({ 989 - ...item, 990 - number: pendingItems[index].number, 991 - state: pendingItems[index].state, 992 - })), 993 - totalCount: matchingSeen, 994 - hasNext: false, 995 - }; 996 - } 997 - } 998 - }; 999 - 1000 - const listSearchedStatefulRecordsPage = async <T, State extends string>( 1001 - nsid: SearchCollection, 1002 - repo: RepoContext, 1003 - options: StatefulPageOptions<State>, 1004 - getState: (uri: ResourceUri) => Promise<State>, 1005 - ): Promise<PaginatedResult<StatefulHydratedRecord<T, State>>> => { 1006 - const query = options.query?.trim(); 1007 - if (!query) { 1008 - return { items: [], totalCount: 0, hasNext: false }; 1009 - } 1010 - 1011 - const offset = Math.max(options.offset, 0); 1012 - const limit = Math.max(options.limit, 1); 1013 - const requestedEnd = offset + limit; 1014 - const items: Array<StatefulHydratedRecord<T, State>> = []; 1015 - let cursor: string | undefined; 1016 - let matchingSeen = 0; 1017 - 1018 - while (true) { 1019 - const page = await appviewJson<AppviewSearchResponse<T>>('sh.tangled.search.query', { 1020 - q: query, 1021 - nsid, 1022 - author: options.author, 1023 - repo: repo.repoDid, 1024 - cursor, 1025 - limit: SEARCH_PAGE_BATCH_LIMIT, 1026 - }); 1027 - const hydrated = await Promise.all(page.hits.map((hit) => appviewRecordToHydrated(hit))); 1028 - const states = await Promise.all(hydrated.map((record) => getState(record.uri))); 1029 - 1030 - for (const [index, record] of hydrated.entries()) { 1031 - const state = states[index]; 1032 - if (state !== options.state) { 1033 - continue; 1034 - } 1035 - 1036 - if (matchingSeen >= offset && matchingSeen < requestedEnd) { 1037 - items.push({ 1038 - ...record, 1039 - number: 0, 1040 - state, 1041 - }); 1042 - } 1043 - 1044 - matchingSeen += 1; 1045 - if (matchingSeen > requestedEnd) { 1046 - return { 1047 - items: items.slice(0, limit), 1048 - hasNext: true, 1049 - }; 1050 - } 1051 - } 1052 - 1053 - cursor = page.cursor ?? undefined; 1054 - if (!cursor || page.hits.length === 0) { 1055 - return { 1056 - items, 1057 - totalCount: matchingSeen, 1058 - hasNext: false, 1059 - }; 1060 - } 1061 - } 1062 - }; 1063 - 1064 - const getRepoViaAppview = async <T>( 1065 - nsid: string, 1066 - repo: RepoContext, 1067 - params: Record<string, AppviewParamValue> = {}, 1068 - ): Promise<T> => 1069 - appviewJson<T>(nsid, { 1070 - repo: repo.record.uri, 1071 - ...params, 1072 - }); 1073 - 1074 - const appviewOptionalKnotRequest = async <T>( 1075 - appview: () => Promise<T>, 1076 - direct: () => Promise<T>, 1077 - ): Promise<T> => { 1078 - if (!getRouteKnotRequestsThroughAppview()) { 1079 - return direct(); 1080 - } 1081 - 1082 - return appviewFallback(appview, direct); 1083 - }; 1084 - 1085 - export const createAuthRpc = (agent: OAuthUserAgent): Client => 1086 - new Client({ 1087 - handler: agent, 1088 - }); 1089 - 1090 - export const resolveActor = async (identifier: string): Promise<ResolvedActor> => { 1091 - const key = identifier.toLowerCase(); 1092 - const cached = actorCache.get(key); 1093 - if (cached) { 1094 - return cached; 1095 - } 1096 - 1097 - const promise = identityResolver.resolve(asActorIdentifier(identifier)); 1098 - actorCache.set(key, promise); 1099 - return promise; 1100 - }; 1101 - 1102 - const fetchAllRepoRecords = async ( 1103 - actor: ResolvedActor, 1104 - collection: Nsid, 1105 - ): Promise<Array<{ uri: ResourceUri; cid: string; value: unknown }>> => { 1106 - const rpc = getRpc(actor.pds); 1107 - const records: Array<{ uri: ResourceUri; cid: string; value: unknown }> = []; 1108 - let cursor: string | undefined; 1109 - 1110 - do { 1111 - const page = await ok( 1112 - rpc.get('com.atproto.repo.listRecords', { 1113 - params: { 1114 - repo: actor.did, 1115 - collection, 1116 - limit: 100, 1117 - cursor, 1118 - reverse: true, 1119 - }, 1120 - }), 1121 - ); 1122 - 1123 - records.push(...page.records); 1124 - cursor = page.cursor; 1125 - } while (cursor); 1126 - 1127 - return records; 1128 - }; 1129 - 1130 - const listRepoRecordsFromAppview = async (owner: string | ResolvedActor): Promise<RepoRecord[]> => { 1131 - const actor = typeof owner === 'string' ? await resolveActor(owner) : owner; 1132 - const records = await listAllAppviewRecords<ShTangledRepo.Main>('sh.tangled.repo.listRepos', actor.did); 1133 - 1134 - return records.map((record) => ({ 1135 - uri: record.uri, 1136 - cid: record.cid ?? '', 1137 - rkey: parseAtUri(record.uri).rkey, 1138 - value: record.value, 1139 - })); 1140 - }; 1141 - 1142 - const listRepoRecordsFromPds = async (owner: string | ResolvedActor): Promise<RepoRecord[]> => { 1143 - const actor = typeof owner === 'string' ? await resolveActor(owner) : owner; 1144 - const records = await fetchAllRepoRecords(actor, REPO_COLLECTION); 1145 - 1146 - return records.map((record) => ({ 1147 - uri: record.uri, 1148 - cid: record.cid, 1149 - rkey: parseAtUri(record.uri).rkey, 1150 - value: record.value as ShTangledRepo.Main, 1151 - })); 1152 - }; 1153 - 1154 - export const listRepoRecords = async (owner: string | ResolvedActor): Promise<RepoRecord[]> => 1155 - appviewFallback( 1156 - () => listRepoRecordsFromAppview(owner), 1157 - () => listRepoRecordsFromPds(owner), 1158 - ); 1159 - 1160 - export interface RepoStarSummary { 1161 - count: number; 1162 - isStarred: boolean; 1163 - currentUserStarRkey?: string; 1164 - } 1165 - 1166 - const STAR_BACKLINK_SOURCES = [ 1167 - 'sh.tangled.feed.star:subject.did', 1168 - 'sh.tangled.feed.star:subjectDid', 1169 - ]; 1170 - 1171 - const getRepoStarSummaryFromBacklinks = async (repo: RepoContext, viewerDid?: Did | null): Promise<RepoStarSummary> => { 1172 - const starrers = new Set<Did>(); 1173 - const backlinkDidGroups = await Promise.all(STAR_BACKLINK_SOURCES.map((source) => getBacklinkDids(repo.repoDid, source))); 1174 - 1175 - for (const did of backlinkDidGroups.flat()) { 1176 - starrers.add(did); 1177 - } 1178 - 1179 - const currentUserStar = 1180 - viewerDid && starrers.has(viewerDid) 1181 - ? await findBacklinkRefByDid(repo.repoDid, STAR_BACKLINK_SOURCES, viewerDid) 1182 - : undefined; 1183 - 1184 - return { 1185 - count: starrers.size, 1186 - isStarred: !!currentUserStar, 1187 - currentUserStarRkey: currentUserStar?.rkey, 1188 - }; 1189 - }; 1190 - 1191 - const getRepoStarCountFromBacklinks = async (repo: RepoContext): Promise<number> => { 1192 - const summary = await getRepoStarSummaryFromBacklinks(repo); 1193 - return summary.count; 1194 - }; 1195 - 1196 - const appviewStarSubjectDid = (star: ShTangledFeedStar.Main): Did | null => { 1197 - const subject = star.subject as { did?: unknown; subjectDid?: unknown }; 1198 - const did = subject.did ?? subject.subjectDid; 1199 - return typeof did === 'string' && did.startsWith('did:') ? (did as Did) : null; 1200 - }; 1201 - 1202 - const getRepoStarCountFromAppview = async (repo: RepoContext): Promise<number> => { 1203 - const count = await appviewJson<AppviewCountResponse>('sh.tangled.feed.countStars', { 1204 - subject: repo.repoDid, 1205 - }); 1206 - return count.distinctAuthors ?? count.distinct_authors ?? count.count; 1207 - }; 1208 - 1209 - const getAppviewRecordCount = async (nsid: string, subject: string): Promise<number> => { 1210 - const count = await appviewJson<AppviewCountResponse>(nsid, { subject }); 1211 - return count.count; 1212 - }; 1213 - 1214 - const findCurrentUserStarFromAppview = async ( 1215 - repo: RepoContext, 1216 - viewerDid: Did, 1217 - ): Promise<AppviewRecordView<ShTangledFeedStar.Main> | undefined> => { 1218 - const stars = await listAllAppviewRecords<ShTangledFeedStar.Main>('sh.tangled.feed.listStarsBy', viewerDid); 1219 - return stars.find((star) => appviewStarSubjectDid(star.value) === repo.repoDid); 1220 - }; 1221 - 1222 - const getRepoStarSummaryFromAppview = async ( 1223 - repo: RepoContext, 1224 - viewerDid?: Did | null, 1225 - ): Promise<RepoStarSummary> => { 1226 - const [count, currentUserStar] = await Promise.all([ 1227 - getRepoStarCountFromAppview(repo), 1228 - viewerDid ? findCurrentUserStarFromAppview(repo, viewerDid) : undefined, 1229 - ]); 1230 - 1231 - return { 1232 - count, 1233 - isStarred: !!currentUserStar, 1234 - currentUserStarRkey: currentUserStar ? parseAtUri(currentUserStar.uri).rkey : undefined, 1235 - }; 1236 - }; 1237 - 1238 - export const getRepoStarSummary = async (repo: RepoContext, viewerDid?: Did | null): Promise<RepoStarSummary> => 1239 - appviewFallback( 1240 - () => getRepoStarSummaryFromAppview(repo, viewerDid), 1241 - () => getRepoStarSummaryFromBacklinks(repo, viewerDid), 1242 - ); 1243 - 1244 - export const getRepoStarCount = async (repo: RepoContext): Promise<number> => 1245 - appviewFallback( 1246 - () => getRepoStarCountFromAppview(repo), 1247 - () => getRepoStarCountFromBacklinks(repo), 1248 - ); 1249 - 1250 - export const createRepoStar = async (agent: OAuthUserAgent, repo: RepoContext): Promise<RecordCreationResult> => { 1251 - const rpc = createAuthRpc(agent); 1252 - const record: ShTangledFeedStar.Main = { 1253 - $type: 'sh.tangled.feed.star', 1254 - subject: { 1255 - $type: 'sh.tangled.feed.star#repo', 1256 - did: repo.repoDid, 1257 - }, 1258 - createdAt: new Date().toISOString(), 1259 - }; 1260 - const result = await ok( 1261 - rpc.post('com.atproto.repo.createRecord', { 1262 - input: { 1263 - repo: agent.sub, 1264 - collection: STAR_COLLECTION, 1265 - record, 1266 - }, 1267 - }), 1268 - ); 1269 - 1270 - return { 1271 - uri: result.uri, 1272 - rkey: parseAtUri(result.uri).rkey, 1273 - }; 1274 - }; 1275 - 1276 - export const deleteRepoStar = async (agent: OAuthUserAgent, rkey: string): Promise<void> => { 1277 - const rpc = createAuthRpc(agent); 1278 - await ok( 1279 - rpc.post('com.atproto.repo.deleteRecord', { 1280 - input: { 1281 - repo: agent.sub, 1282 - collection: STAR_COLLECTION, 1283 - rkey, 1284 - }, 1285 - }), 1286 - ); 1287 - }; 1288 - 1289 - export const getRepo = async (ownerIdentifier: string, repoSlug: string): Promise<RepoContext> => { 1290 - const owner = await resolveActor(ownerIdentifier); 1291 - const all = await listRepoRecords(owner); 1292 - const wanted = repoSlug.trim().toLowerCase(); 1293 - const match = all.find((record) => { 1294 - const name = record.value.name?.trim().toLowerCase(); 1295 - return name === wanted || record.rkey.toLowerCase() === wanted; 1296 - }); 1297 - 1298 - if (!match) { 1299 - throw new Error(`Repository not found: ${ownerIdentifier}/${repoSlug}`); 1300 - } 1301 - 1302 - if (!match.value.repoDid) { 1303 - throw new Error(`Repository is missing repoDid metadata`); 1304 - } 1305 - 1306 - const slug = match.value.name?.trim() || match.rkey; 1307 - 1308 - return { 1309 - owner, 1310 - record: match, 1311 - repoDid: match.value.repoDid, 1312 - slug, 1313 - rkey: match.rkey, 1314 - knot: normalizeServiceUrl(match.value.knot), 1315 - }; 1316 - }; 1317 - 1318 - export const getRepoByDid = async (repoDid: Did): Promise<RepoContext> => { 1319 - const record = await appviewJson<AppviewRecordView<ShTangledRepo.Main>>( 1320 - 'sh.tangled.repo.getRepoByRepoDid', 1321 - { repoDid }, 1322 - ); 1323 - const parsed = parseAtUri(record.uri); 1324 - const owner = await resolveActor(parsed.did); 1325 - const slug = record.value.name?.trim() || parsed.rkey; 1326 - 1327 - if (!record.value.repoDid) { 1328 - throw new Error(`Repository is missing repoDid metadata`); 1329 - } 1330 - 1331 - return { 1332 - owner, 1333 - record: { 1334 - uri: record.uri, 1335 - cid: record.cid ?? '', 1336 - rkey: parsed.rkey, 1337 - value: record.value, 1338 - }, 1339 - repoDid: record.value.repoDid, 1340 - slug, 1341 - rkey: parsed.rkey, 1342 - knot: normalizeServiceUrl(record.value.knot), 1343 - }; 1344 - }; 1345 - 1346 - export const searchRecords = async (options: SearchRecordsOptions): Promise<SearchResponse> => { 1347 - const q = options.q.trim(); 1348 - if (!q) { 1349 - return { hits: [] }; 1350 - } 1351 - 1352 - const response = await appviewJson<AppviewSearchResponse<SearchRecordValue>>( 1353 - 'sh.tangled.search.query', 1354 - { 1355 - q, 1356 - nsid: options.nsid, 1357 - author: options.author, 1358 - repo: options.repo, 1359 - since: options.since, 1360 - until: options.until, 1361 - cursor: options.cursor, 1362 - limit: options.limit, 1363 - }, 1364 - ); 1365 - const hits = await Promise.all( 1366 - response.hits.map(async (hit) => ({ 1367 - ...(await appviewRecordToHydrated(hit)), 1368 - nsid: hit.nsid, 1369 - score: hit.score, 1370 - })), 1371 - ); 1372 - 1373 - return { 1374 - hits, 1375 - cursor: response.cursor ?? undefined, 1376 - }; 1377 - }; 1378 - 1379 - export const getIssueRecord = async (issueUri: ResourceUri): Promise<HydratedRecord<ShTangledRepoIssue.Main>> => { 1380 - const record = await appviewJson<AppviewRecordView<ShTangledRepoIssue.Main>>( 1381 - 'sh.tangled.repo.getIssue', 1382 - { issue: issueUri }, 1383 - ); 1384 - return appviewRecordToHydrated(record); 1385 - }; 1386 - 1387 - export const getPullRecord = async (pullUri: ResourceUri): Promise<HydratedRecord<ShTangledRepoPull.Main>> => { 1388 - const record = await appviewJson<AppviewRecordView<ShTangledRepoPull.Main>>( 1389 - 'sh.tangled.repo.getPull', 1390 - { pull: pullUri }, 1391 - ); 1392 - return appviewRecordToHydrated(record); 1393 - }; 1394 - 1395 - const getRepoTreeFromKnot = async ( 1396 - repo: RepoContext, 1397 - ref: string, 1398 - path = '', 1399 - ): Promise<TreeResponse> => 1400 - (await ok( 1401 - getRpc(repo.knot).get('sh.tangled.repo.tree', { 1402 - params: { 1403 - repo: repo.repoDid, 1404 - ref, 1405 - path, 1406 - }, 1407 - }), 1408 - )) as TreeResponse; 1409 - 1410 - const getRepoBranchesFromKnot = async (repo: RepoContext): Promise<BranchResponse> => 1411 - (await ok( 1412 - getRpc(repo.knot).get('sh.tangled.repo.branches', { 1413 - params: { 1414 - repo: repo.repoDid, 1415 - }, 1416 - as: 'json', 1417 - }), 1418 - )) as BranchResponse; 1419 - 1420 - const branchUpdatedTimestamp = (branch: BranchEntry): number => 1421 - maxTimestamp( 1422 - timestampFromDate(branch.commit?.Committer?.When), 1423 - timestampFromDate(branch.commit?.Author?.When), 1424 - ); 1425 - 1426 - const sortBranchResponseByUpdatedAt = (response: BranchResponse): BranchResponse => ({ 1427 - ...response, 1428 - branches: [...response.branches].sort((left, right) => { 1429 - const updatedDiff = branchUpdatedTimestamp(right) - branchUpdatedTimestamp(left); 1430 - if (updatedDiff !== 0) { 1431 - return updatedDiff; 1432 - } 1433 - 1434 - if (left.is_default !== right.is_default) { 1435 - return left.is_default ? -1 : 1; 1436 - } 1437 - 1438 - return left.reference.name.localeCompare(right.reference.name); 1439 - }), 1440 - }); 1441 - 1442 - const getRepoDefaultBranchFromKnot = async (repo: RepoContext): Promise<DefaultBranchResponse> => 1443 - (await ok( 1444 - getRpc(repo.knot).get('sh.tangled.repo.getDefaultBranch', { 1445 - params: { 1446 - repo: repo.repoDid, 1447 - }, 1448 - }), 1449 - )) as DefaultBranchResponse; 1450 - 1451 - const getRepoTagsFromKnot = async (repo: RepoContext): Promise<TagResponse> => 1452 - (await ok( 1453 - getRpc(repo.knot).get('sh.tangled.repo.tags', { 1454 - params: { 1455 - repo: repo.repoDid, 1456 - }, 1457 - as: 'json', 1458 - }), 1459 - )) as TagResponse; 1460 - 1461 - const getRepoLanguagesFromKnot = async (repo: RepoContext, ref: string): Promise<LanguageResponse> => 1462 - (await ok( 1463 - getRpc(repo.knot).get('sh.tangled.repo.languages', { 1464 - params: { 1465 - repo: repo.repoDid, 1466 - ref, 1467 - }, 1468 - }), 1469 - )) as LanguageResponse; 1470 - 1471 - const getRepoLogFromKnot = async (repo: RepoContext, ref: string): Promise<LogResponse> => 1472 - (await ok( 1473 - getRpc(repo.knot).get('sh.tangled.repo.log', { 1474 - params: { 1475 - repo: repo.repoDid, 1476 - ref, 1477 - limit: 8, 1478 - }, 1479 - as: 'json', 1480 - }), 1481 - )) as LogResponse; 1482 - 1483 - const getRepoBlobFromKnot = async ( 1484 - repo: RepoContext, 1485 - ref: string, 1486 - path: string, 1487 - ): Promise<BlobResponse> => 1488 - (await ok( 1489 - getRpc(repo.knot).get('sh.tangled.repo.blob', { 1490 - params: { 1491 - repo: repo.repoDid, 1492 - ref, 1493 - path, 1494 - }, 1495 - }), 1496 - )) as BlobResponse; 1497 - 1498 - const compareBranchesFromKnot = async ( 1499 - repo: RepoContext, 1500 - rev1: string, 1501 - rev2: string, 1502 - ): Promise<CompareResponse> => 1503 - (await ok( 1504 - getRpc(repo.knot).get('sh.tangled.repo.compare', { 1505 - params: { 1506 - repo: repo.repoDid, 1507 - rev1, 1508 - rev2, 1509 - }, 1510 - as: 'json', 1511 - }), 1512 - )) as CompareResponse; 1513 - 1514 - export const getRepoTree = async ( 1515 - repo: RepoContext, 1516 - ref: string, 1517 - path = '', 1518 - ): Promise<TreeResponse> => 1519 - appviewOptionalKnotRequest( 1520 - () => getRepoViaAppview<TreeResponse>('sh.tangled.repo.tree', repo, { ref, path }), 1521 - () => getRepoTreeFromKnot(repo, ref, path), 1522 - ); 1523 - 1524 - export const getRepoBranches = async (repo: RepoContext): Promise<BranchResponse> => 1525 - appviewOptionalKnotRequest( 1526 - () => getRepoViaAppview<BranchResponse>('sh.tangled.repo.branches', repo), 1527 - () => getRepoBranchesFromKnot(repo), 1528 - ).then(sortBranchResponseByUpdatedAt); 1529 - 1530 - export const getRepoDefaultBranch = async (repo: RepoContext): Promise<DefaultBranchResponse> => 1531 - appviewOptionalKnotRequest( 1532 - () => getRepoViaAppview<DefaultBranchResponse>('sh.tangled.repo.getDefaultBranch', repo), 1533 - () => getRepoDefaultBranchFromKnot(repo), 1534 - ); 1535 - 1536 - export const getRepoTags = async (repo: RepoContext): Promise<TagResponse> => 1537 - appviewOptionalKnotRequest( 1538 - () => getRepoViaAppview<TagResponse>('sh.tangled.repo.tags', repo), 1539 - () => getRepoTagsFromKnot(repo), 1540 - ); 1541 - 1542 - export const getRepoLanguages = async (repo: RepoContext, ref: string): Promise<LanguageResponse> => 1543 - appviewOptionalKnotRequest( 1544 - () => getRepoViaAppview<LanguageResponse>('sh.tangled.repo.languages', repo, { ref }), 1545 - () => getRepoLanguagesFromKnot(repo, ref), 1546 - ); 1547 - 1548 - export const getRepoLog = async (repo: RepoContext, ref: string): Promise<LogResponse> => 1549 - appviewOptionalKnotRequest( 1550 - () => getRepoViaAppview<LogResponse>('sh.tangled.repo.log', repo, { ref, limit: 8 }), 1551 - () => getRepoLogFromKnot(repo, ref), 1552 - ); 1553 - 1554 - export const getRepoBlob = async ( 1555 - repo: RepoContext, 1556 - ref: string, 1557 - path: string, 1558 - ): Promise<BlobResponse> => 1559 - appviewOptionalKnotRequest( 1560 - () => getRepoViaAppview<BlobResponse>('sh.tangled.repo.blob', repo, { ref, path }), 1561 - () => getRepoBlobFromKnot(repo, ref, path), 1562 - ); 1563 - 1564 - export const compareBranches = async ( 1565 - repo: RepoContext, 1566 - rev1: string, 1567 - rev2: string, 1568 - ): Promise<CompareResponse> => 1569 - appviewOptionalKnotRequest( 1570 - () => getRepoViaAppview<CompareResponse>('sh.tangled.repo.compare', repo, { rev1, rev2 }), 1571 - () => compareBranchesFromKnot(repo, rev1, rev2), 1572 - ); 1573 - 1574 - const getBacklinksPage = async ( 1575 - subject: GenericUri, 1576 - source: string, 1577 - limit: number, 1578 - cursor?: string, 1579 - ): Promise<{ records: BacklinkRecordRef[]; cursor?: string; total?: number }> => { 1580 - const constellation = getRpc(CONSTELLATION_SERVICE); 1581 - const page = await ok( 1582 - constellation.get('blue.microcosm.links.getBacklinks', { 1583 - params: { 1584 - subject, 1585 - source, 1586 - limit, 1587 - ...(cursor ? { cursor } : {}), 1588 - }, 1589 - }), 1590 - ); 1591 - 1592 - return page as { records: BacklinkRecordRef[]; cursor?: string; total?: number }; 1593 - }; 1594 - 1595 - const getBacklinkDidsPage = async ( 1596 - subject: GenericUri, 1597 - source: string, 1598 - limit: number, 1599 - cursor?: string, 1600 - ): Promise<{ linking_dids: Did[]; cursor?: string | null; total?: number }> => { 1601 - const constellation = getRpc(CONSTELLATION_SERVICE); 1602 - const page = await ok( 1603 - constellation.get('blue.microcosm.links.getBacklinkDids', { 1604 - params: { 1605 - subject, 1606 - source, 1607 - limit, 1608 - ...(cursor ? { cursor } : {}), 1609 - }, 1610 - }), 1611 - ); 1612 - 1613 - return page as { linking_dids: Did[]; cursor?: string | null; total?: number }; 1614 - }; 1615 - 1616 - const getBacklinkDids = async (subject: GenericUri, source: string): Promise<Did[]> => { 1617 - const dids: Did[] = []; 1618 - let cursor: string | null = null; 1619 - 1620 - do { 1621 - const page = await getBacklinkDidsPage(subject, source, 100, cursor ?? undefined); 1622 - dids.push(...page.linking_dids); 1623 - cursor = page.cursor ?? null; 1624 - } while (cursor); 1625 - 1626 - return dids; 1627 - }; 1628 - 1629 - const findBacklinkRefByDidForSource = async ( 1630 - subject: GenericUri, 1631 - source: string, 1632 - did: Did, 1633 - ): Promise<BacklinkRecordRef | undefined> => { 1634 - let cursor: string | null = null; 1635 - 1636 - do { 1637 - const page = await getBacklinksPage(subject, source, 100, cursor ?? undefined); 1638 - const ref = page.records.find((record) => record.did === did && record.collection === STAR_COLLECTION); 1639 - if (ref) { 1640 - return ref; 1641 - } 1642 - cursor = page.cursor ?? null; 1643 - } while (cursor); 1644 - }; 1645 - 1646 - const findBacklinkRefByDid = async ( 1647 - subject: GenericUri, 1648 - sources: string[], 1649 - did: Did, 1650 - ): Promise<BacklinkRecordRef | undefined> => { 1651 - const refs = await Promise.all(sources.map((source) => findBacklinkRefByDidForSource(subject, source, did))); 1652 - return refs.find((ref) => ref); 1653 - }; 1654 - 1655 - const getBacklinks = async (subject: GenericUri, source: string): Promise<BacklinkRecordRef[]> => { 1656 - const records: BacklinkRecordRef[] = []; 1657 - let cursor: string | null = null; 1658 - const constellation = getRpc(CONSTELLATION_SERVICE); 1659 - 1660 - do { 1661 - const page: { records: BacklinkRecordRef[]; cursor?: string | null } = await ok( 1662 - constellation.get('blue.microcosm.links.getBacklinks', { 1663 - params: { 1664 - subject, 1665 - source, 1666 - limit: 100, 1667 - ...(cursor ? { cursor } : {}), 1668 - }, 1669 - }), 1670 - ); 1671 - records.push(...page.records); 1672 - cursor = page.cursor ?? null; 1673 - } while (cursor); 1674 - 1675 - return records; 1676 - }; 1677 - 1678 - const hydrateRecord = async <T>( 1679 - ref: BacklinkRecordRef | { did: Did; collection: Nsid; rkey: string }, 1680 - ): Promise<HydratedRecord<T>> => { 1681 - const actor = await resolveActor(ref.did); 1682 - const record = await ok( 1683 - getRpc(actor.pds).get('com.atproto.repo.getRecord', { 1684 - params: { 1685 - repo: ref.did, 1686 - collection: ref.collection, 1687 - rkey: ref.rkey, 1688 - }, 1689 - }), 1690 - ); 1691 - 1692 - return { 1693 - author: actor, 1694 - collection: ref.collection, 1695 - rkey: ref.rkey, 1696 - uri: record.uri, 1697 - cid: record.cid, 1698 - value: record.value as T, 1699 - }; 1700 - }; 1701 - 1702 - const fetchRecordValue = async <T>( 1703 - ref: BacklinkRecordRef | { did: Did; collection: Nsid; rkey: string }, 1704 - ): Promise<T> => { 1705 - const actor = await resolveActor(ref.did); 1706 - const record = await ok( 1707 - getRpc(actor.pds).get('com.atproto.repo.getRecord', { 1708 - params: { 1709 - repo: ref.did, 1710 - collection: ref.collection, 1711 - rkey: ref.rkey, 1712 - }, 1713 - }), 1714 - ); 1715 - 1716 - return record.value as T; 1717 - }; 1718 - 1719 - const hydrateBacklinks = async <T>(refs: BacklinkRecordRef[]): Promise<Array<HydratedRecord<T>>> => 1720 - Promise.all(refs.map((ref) => hydrateRecord<T>(ref))); 1721 - 1722 - const backlinkRefToUri = (ref: BacklinkRecordRef): ResourceUri => 1723 - `at://${ref.did}/${ref.collection}/${ref.rkey}` as ResourceUri; 1724 - 1725 - const hydrateBacklinksFromAppview = async <T>( 1726 - refs: BacklinkRecordRef[], 1727 - nsid: string, 1728 - paramName: string, 1729 - ): Promise<Array<HydratedRecord<T>>> => 1730 - appviewFallback( 1731 - async () => { 1732 - const records = await appviewBulkRecords<T>( 1733 - nsid, 1734 - paramName, 1735 - refs.map(backlinkRefToUri), 1736 - ); 1737 - return Promise.all(records.map((record) => appviewRecordToHydrated(record))); 1738 - }, 1739 - () => hydrateBacklinks<T>(refs), 1740 - ); 1741 - 1742 - const hydrateIssueBacklinks = (refs: BacklinkRecordRef[]): Promise<Array<HydratedRecord<ShTangledRepoIssue.Main>>> => 1743 - hydrateBacklinksFromAppview<ShTangledRepoIssue.Main>( 1744 - refs, 1745 - 'sh.tangled.repo.getIssues', 1746 - 'issues', 1747 - ); 1748 - 1749 - const hydratePullBacklinks = (refs: BacklinkRecordRef[]): Promise<Array<HydratedRecord<ShTangledRepoPull.Main>>> => 1750 - hydrateBacklinksFromAppview<ShTangledRepoPull.Main>( 1751 - refs, 1752 - 'sh.tangled.repo.getPulls', 1753 - 'pulls', 1754 - ); 1755 - 1756 - const getOptionalRecord = async <T>( 1757 - actor: ResolvedActor, 1758 - collection: Nsid, 1759 - rkey: string, 1760 - ): Promise<T | null> => { 1761 - try { 1762 - const record = await ok( 1763 - getRpc(actor.pds).get('com.atproto.repo.getRecord', { 1764 - params: { 1765 - repo: actor.did, 1766 - collection, 1767 - rkey, 1768 - }, 1769 - }), 1770 - ); 1771 - 1772 - return record.value as T; 1773 - } catch (cause) { 1774 - if ( 1775 - cause instanceof ClientResponseError && 1776 - (cause.status === 400 || cause.status === 404) 1777 - ) { 1778 - return null; 1779 - } 1780 - 1781 - throw cause; 1782 - } 1783 - }; 1784 - 1785 - const toNumberMap = <T extends { uri: string; rkey: string; value: { createdAt?: string } }>( 1786 - items: T[], 1787 - ): Map<string, number> => { 1788 - const ordered = [...items].sort((left, right) => { 1789 - const leftTime = timestampFromDate(left.value.createdAt); 1790 - const rightTime = timestampFromDate(right.value.createdAt); 1791 - if (leftTime !== rightTime) { 1792 - return leftTime - rightTime; 1793 - } 1794 - 1795 - return left.rkey.localeCompare(right.rkey); 1796 - }); 1797 - 1798 - return new Map(ordered.map((item, index) => [item.uri, index + 1])); 1799 - }; 1800 - 1801 - const normalizeIssueState = (value?: string): 'open' | 'closed' => 1802 - value === 'closed' || value?.endsWith('.closed') ? 'closed' : 'open'; 1803 - 1804 - const normalizePullStatus = (value?: string): 'open' | 'closed' | 'merged' => { 1805 - if (value === 'merged' || value?.endsWith('.merged')) { 1806 - return 'merged'; 1807 - } 1808 - 1809 - if (value === 'closed' || value?.endsWith('.closed')) { 1810 - return 'closed'; 1811 - } 1812 - 1813 - return 'open'; 1814 - }; 1815 - 1816 - const latestBacklinkRefByRkey = (refs: BacklinkRecordRef[]): BacklinkRecordRef | undefined => 1817 - [...refs].sort((left, right) => left.rkey.localeCompare(right.rkey)).at(-1); 1818 - 1819 - const getUniqueBacklinkRefs = async ( 1820 - subject: GenericUri, 1821 - source: string | string[], 1822 - ): Promise<BacklinkRecordRef[]> => 1823 - Array.from( 1824 - new Map( 1825 - ( 1826 - await Promise.all( 1827 - (Array.isArray(source) ? source : [source]).map((entry) => getBacklinks(subject, entry)), 1828 - ) 1829 - ) 1830 - .flat() 1831 - .map((ref) => [`${ref.did}/${ref.collection}/${ref.rkey}`, ref] as const), 1832 - ).values(), 1833 - ); 1834 - 1835 - const getLatestBacklinkTimestamp = async ( 1836 - subject: GenericUri, 1837 - source: string | string[], 1838 - ): Promise<number> => { 1839 - try { 1840 - return timestampFromTid(latestBacklinkRefByRkey(await getUniqueBacklinkRefs(subject, source))?.rkey); 1841 - } catch { 1842 - return 0; 1843 - } 1844 - }; 1845 - 1846 - const BACKLINK_BATCH_LIMIT = 50; 1847 - 1848 - const listBacklinkedRecordsPage = async <T extends { createdAt?: string }, State extends string>( 1849 - subject: GenericUri, 1850 - source: string | string[], 1851 - options: StatefulPageOptions<State>, 1852 - getState: (uri: ResourceUri) => Promise<State>, 1853 - getUpdatedAt?: (record: StatefulHydratedRecord<T, State>) => Promise<number>, 1854 - hydrateRefs: (refs: BacklinkRecordRef[]) => Promise<Array<HydratedRecord<T>>> = hydrateBacklinks<T>, 1855 - ): Promise<PaginatedResult<StatefulHydratedRecord<T, State>>> => { 1856 - if (getUpdatedAt) { 1857 - const hydrated = await hydrateRefs(await getUniqueBacklinkRefs(subject, source)); 1858 - const numbers = toNumberMap(hydrated); 1859 - const states = await Promise.all(hydrated.map((record) => getState(record.uri))); 1860 - const items = hydrated.map((record, index) => ({ 1861 - ...record, 1862 - number: numbers.get(record.uri) ?? index + 1, 1863 - state: states[index], 1864 - })); 1865 - const ordered = await sortStatefulRecordsByUpdatedAt(items, getUpdatedAt); 1866 - 1867 - return paginateStatefulRecords(ordered, options); 1868 - } 1869 - 1870 - const requestedEnd = options.offset + options.limit; 1871 - const items: Array<StatefulHydratedRecord<T, State>> = []; 1872 - const sources = Array.isArray(source) ? source : [source]; 1873 - const batchLimit = Math.min(BACKLINK_BATCH_LIMIT, Math.max(options.limit + 1, 1)); 1874 - const cursors = new Map(sources.map((entry) => [entry, undefined as string | undefined])); 1875 - const exhausted = new Set<string>(); 1876 - const seenRefs = new Set<string>(); 1877 - let matchingSeen = 0; 1878 - let scannedSeen = 0; 1879 - let total: number | undefined; 1880 - 1881 - while (items.length <= options.limit) { 1882 - const pages = await Promise.all( 1883 - sources 1884 - .filter((entry) => !exhausted.has(entry)) 1885 - .map(async (entry) => ({ 1886 - source: entry, 1887 - page: await getBacklinksPage(subject, entry, batchLimit, cursors.get(entry)), 1888 - })), 1889 - ); 1890 - 1891 - if (pages.length === 0) { 1892 - return { items, totalCount: matchingSeen, hasNext: false }; 1893 - } 1894 - 1895 - const refs = pages 1896 - .flatMap(({ source: entry, page }) => { 1897 - total = sources.length === 1 ? page.total ?? total : undefined; 1898 - cursors.set(entry, page.cursor); 1899 - if (!page.cursor) { 1900 - exhausted.add(entry); 1901 - } 1902 - return page.records; 1903 - }) 1904 - .filter((ref) => { 1905 - const key = `${ref.did}/${ref.collection}/${ref.rkey}`; 1906 - if (seenRefs.has(key)) { 1907 - return false; 1908 - } 1909 - seenRefs.add(key); 1910 - return true; 1911 - }); 1912 - 1913 - if (refs.length === 0) { 1914 - if (exhausted.size === sources.length) { 1915 - return { items, totalCount: matchingSeen, hasNext: false }; 1916 - } 1917 - continue; 1918 - } 1919 - 1920 - if (pages.every(({ page }) => page.records.length === 0)) { 1921 - return { items, totalCount: matchingSeen, hasNext: false }; 1922 - } 1923 - 1924 - const hydrated = await hydrateRefs(refs); 1925 - hydrated.sort(sortByCreatedAt).reverse(); 1926 - const states = await Promise.all(hydrated.map((record) => getState(record.uri))); 1927 - 1928 - for (let index = 0; index < hydrated.length; index += 1) { 1929 - const record = hydrated[index]; 1930 - const state = states[index]; 1931 - const number = total ? Math.max(total - scannedSeen, 1) : scannedSeen + 1; 1932 - scannedSeen += 1; 1933 - 1934 - if (state !== options.state) { 1935 - continue; 1936 - } 1937 - 1938 - if (!statefulRecordMatchesQuery(record.value, options.query)) { 1939 - continue; 1940 - } 1941 - 1942 - if (matchingSeen >= options.offset && matchingSeen < requestedEnd + 1) { 1943 - items.push({ ...record, number, state }); 1944 - } 1945 - 1946 - matchingSeen += 1; 1947 - if (matchingSeen > requestedEnd) { 1948 - return { items: items.slice(0, options.limit), hasNext: true }; 1949 - } 1950 - } 1951 - 1952 - if (exhausted.size === sources.length) { 1953 - return { items, totalCount: matchingSeen, hasNext: false }; 1954 - } 1955 - } 1956 - 1957 - return { items: items.slice(0, options.limit), hasNext: true }; 1958 - }; 1959 - 1960 - const getLatestIssueState = async (issueUri: ResourceUri): Promise<'open' | 'closed'> => { 1961 - const refs = await getBacklinks(issueUri, 'sh.tangled.repo.issue.state:issue'); 1962 - if (refs.length === 0) { 1963 - return 'open'; 1964 - } 1965 - 1966 - const state = await fetchRecordValue<ShTangledRepoIssueState.Main>(latestBacklinkRefByRkey(refs)!); 1967 - return normalizeIssueState(state.state); 1968 - }; 1969 - 1970 - const getLatestPullStatus = async (pullUri: ResourceUri): Promise<'open' | 'closed' | 'merged'> => { 1971 - const refs = await getBacklinks(pullUri, 'sh.tangled.repo.pull.status:pull'); 1972 - if (refs.length === 0) { 1973 - return 'open'; 1974 - } 1975 - 1976 - const status = await fetchRecordValue<ShTangledRepoPullStatus.Main>(latestBacklinkRefByRkey(refs)!); 1977 - return normalizePullStatus(status.status); 1978 - }; 1979 - 1980 - const getIssueUpdatedAt = async ( 1981 - issue: HydratedRecord<ShTangledRepoIssue.Main>, 1982 - stateUpdatedAt?: string, 1983 - ): Promise<number> => 1984 - maxTimestamp( 1985 - timestampFromDate(issue.value.createdAt), 1986 - timestampFromTid(issue.rkey), 1987 - timestampFromDate(stateUpdatedAt), 1988 - await getLatestBacklinkTimestamp(issue.uri, [ 1989 - `${ISSUE_COMMENT_COLLECTION}:issue`, 1990 - `${ISSUE_STATE_COLLECTION}:issue`, 1991 - ]), 1992 - ); 1993 - 1994 - const getPullUpdatedAt = async ( 1995 - pull: HydratedRecord<ShTangledRepoPull.Main>, 1996 - stateUpdatedAt?: string, 1997 - ): Promise<number> => 1998 - maxTimestamp( 1999 - timestampFromDate(pull.value.createdAt), 2000 - timestampFromTid(pull.rkey), 2001 - ...pull.value.rounds.map((round) => timestampFromDate(round.createdAt)), 2002 - timestampFromDate(stateUpdatedAt), 2003 - await getLatestBacklinkTimestamp(pull.uri, [ 2004 - `${PULL_COMMENT_COLLECTION}:pull`, 2005 - `${PULL_STATUS_COLLECTION}:pull`, 2006 - ]), 2007 - ); 2008 - 2009 - const listIssuesFromBacklinks = async (repo: RepoContext): Promise<IssueSummary[]> => { 2010 - const refs = await getUniqueBacklinkRefs(repo.repoDid, [ 2011 - 'sh.tangled.repo.issue:repoDid', 2012 - 'sh.tangled.repo.issue:repo', 2013 - ]); 2014 - const issues = await hydrateIssueBacklinks(refs); 2015 - const numbers = toNumberMap(issues); 2016 - const states = await Promise.all(issues.map((issue) => getLatestIssueState(issue.uri))); 2017 - 2018 - const summaries = issues 2019 - .map((issue, index) => ({ 2020 - ...issue, 2021 - number: numbers.get(issue.uri) ?? index + 1, 2022 - state: states[index], 2023 - })); 2024 - 2025 - return sortStatefulRecordsByUpdatedAt(summaries, (issue) => getIssueUpdatedAt(issue)); 2026 - }; 2027 - 2028 - const listIssuesPageFromBacklinks = async ( 2029 - repo: RepoContext, 2030 - options: StatefulPageOptions<'open' | 'closed'>, 2031 - ): Promise<PaginatedResult<IssueSummary>> => 2032 - listBacklinkedRecordsPage<ShTangledRepoIssue.Main, 'open' | 'closed'>( 2033 - repo.repoDid, 2034 - ['sh.tangled.repo.issue:repoDid', 'sh.tangled.repo.issue:repo'], 2035 - options, 2036 - getLatestIssueState, 2037 - (issue) => getIssueUpdatedAt(issue), 2038 - hydrateIssueBacklinks, 2039 - ); 2040 - 2041 - const getIssueCountFromBacklinks = async (repo: RepoContext): Promise<number> => 2042 - (await getUniqueBacklinkRefs(repo.repoDid, [ 2043 - 'sh.tangled.repo.issue:repoDid', 2044 - 'sh.tangled.repo.issue:repo', 2045 - ])).length; 2046 - 2047 - const getIssueFromBacklinks = async (repo: RepoContext, issueRef: string): Promise<IssueDetail> => { 2048 - const issues = await listIssuesFromBacklinks(repo); 2049 - const issue = findNumberedRecord(issues, issueRef); 2050 - if (!issue) { 2051 - throw new Error(`Issue not found`); 2052 - } 2053 - 2054 - const commentRefs = await getBacklinks(issue.uri, 'sh.tangled.repo.issue.comment:issue'); 2055 - const comments = await hydrateBacklinks<ShTangledRepoIssueComment.Main>(commentRefs); 2056 - comments.sort(sortByCreatedAt); 2057 - 2058 - return { 2059 - issue, 2060 - comments, 2061 - }; 2062 - }; 2063 - 2064 - const listPullsFromBacklinks = async (repo: RepoContext): Promise<PullSummary[]> => { 2065 - const refs = await getBacklinks(repo.repoDid, 'sh.tangled.repo.pull:target.repo'); 2066 - const pulls = await hydratePullBacklinks(refs); 2067 - const numbers = toNumberMap(pulls); 2068 - const states = await Promise.all(pulls.map((pull) => getLatestPullStatus(pull.uri))); 2069 - 2070 - const summaries = pulls 2071 - .map((pull, index) => ({ 2072 - ...pull, 2073 - number: numbers.get(pull.uri) ?? index + 1, 2074 - state: states[index], 2075 - })); 2076 - 2077 - return sortStatefulRecordsByUpdatedAt(summaries, (pull) => getPullUpdatedAt(pull)); 2078 - }; 2079 - 2080 - const listPullsPageFromBacklinks = async ( 2081 - repo: RepoContext, 2082 - options: StatefulPageOptions<'open' | 'closed' | 'merged'>, 2083 - ): Promise<PaginatedResult<PullSummary>> => 2084 - listBacklinkedRecordsPage<ShTangledRepoPull.Main, 'open' | 'closed' | 'merged'>( 2085 - repo.repoDid, 2086 - 'sh.tangled.repo.pull:target.repo', 2087 - options, 2088 - getLatestPullStatus, 2089 - (pull) => getPullUpdatedAt(pull), 2090 - hydratePullBacklinks, 2091 - ); 2092 - 2093 - const getPullCountFromBacklinks = async (repo: RepoContext): Promise<number> => 2094 - (await getBacklinksPage(repo.repoDid, 'sh.tangled.repo.pull:target.repo', 1)).total ?? 2095 - (await getBacklinks(repo.repoDid, 'sh.tangled.repo.pull:target.repo')).length; 2096 - 2097 - const getPullFromBacklinks = async (repo: RepoContext, pullRef: string): Promise<PullDetail> => { 2098 - const pulls = await listPullsFromBacklinks(repo); 2099 - const pull = findNumberedRecord(pulls, pullRef); 2100 - if (!pull) { 2101 - throw new Error(`Pull request not found`); 2102 - } 2103 - 2104 - const commentRefs = await getBacklinks(pull.uri, 'sh.tangled.repo.pull.comment:pull'); 2105 - const comments = await hydrateBacklinks<ShTangledRepoPullComment.Main>(commentRefs); 2106 - comments.sort(sortByCreatedAt); 2107 - 2108 - return { 2109 - pull, 2110 - comments, 2111 - }; 2112 - }; 2113 - 2114 - const listIssueCommentsFromAppview = async (issueUri: ResourceUri): Promise<IssueComment[]> => { 2115 - const records = await listAllAppviewRecords<ShTangledRepoIssueComment.Main>( 2116 - 'sh.tangled.repo.issue.listComments', 2117 - issueUri, 2118 - ); 2119 - const comments = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); 2120 - comments.sort(sortByCreatedAt); 2121 - return comments; 2122 - }; 2123 - 2124 - const listPullCommentsFromAppview = async (pullUri: ResourceUri): Promise<PullComment[]> => { 2125 - const records = await listAllAppviewRecords<ShTangledRepoPullComment.Main>( 2126 - 'sh.tangled.repo.pull.listComments', 2127 - pullUri, 2128 - ); 2129 - const comments = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); 2130 - comments.sort(sortByCreatedAt); 2131 - return comments; 2132 - }; 2133 - 2134 - const listIssuesFromAppview = async (repo: RepoContext): Promise<IssueSummary[]> => { 2135 - const records = await listAllAppviewRecords<ShTangledRepoIssue.Main>( 2136 - 'sh.tangled.repo.listIssues', 2137 - repo.repoDid, 2138 - ) as Array<AppviewStatefulRecordView<ShTangledRepoIssue.Main>>; 2139 - const issues = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); 2140 - const numbers = toNumberMap(issues); 2141 - 2142 - const summaries = issues 2143 - .map((issue, index) => ({ 2144 - ...issue, 2145 - number: numbers.get(issue.uri) ?? index + 1, 2146 - state: normalizeIssueState(records[index].state), 2147 - })); 2148 - 2149 - return sortStatefulRecordsByNewestRecord(summaries); 2150 - }; 2151 - 2152 - const listIssuesPageFromAppview = async ( 2153 - repo: RepoContext, 2154 - options: StatefulPageOptions<'open' | 'closed'>, 2155 - ): Promise<PaginatedResult<IssueSummary>> => { 2156 - if (options.query?.trim()) { 2157 - // Appview search can scope to repo/author/nsid. State is still derived 2158 - // from separate state records, so filter state after each search hit. 2159 - return listSearchedStatefulRecordsPage<ShTangledRepoIssue.Main, 'open' | 'closed'>( 2160 - 'sh.tangled.repo.issue', 2161 - repo, 2162 - options, 2163 - getLatestIssueState, 2164 - ); 2165 - } 2166 - 2167 - // Temporary until Bobbin exposes newest-first/reverse repo issue pagination. 2168 - // This loads all appview issue records, but sorts without per-item backlinks. 2169 - return listAppviewStatefulRecordsPage<ShTangledRepoIssue.Main, 'open' | 'closed'>( 2170 - 'sh.tangled.repo.listIssues', 2171 - repo.repoDid, 2172 - options, 2173 - normalizeIssueState, 2174 - options.author ? { author: options.author } : {}, 2175 - (issue) => Promise.resolve(getAppviewRecordSortTimestamp(issue)), 2176 - ); 2177 - }; 2178 - 2179 - const getIssueFromAppview = async (repo: RepoContext, issueRef: string): Promise<IssueDetail> => { 2180 - const directUri = parseDirectRecordRef(issueRef, ISSUE_COLLECTION); 2181 - if (directUri) { 2182 - const issueRecord = await getIssueRecord(directUri); 2183 - if (issueRecord.value.repo !== repo.repoDid) { 2184 - throw new Error(`Issue not found`); 2185 - } 2186 - 2187 - const issue: IssueSummary = { 2188 - ...issueRecord, 2189 - number: 0, 2190 - state: await getLatestIssueState(issueRecord.uri), 2191 - }; 2192 - 2193 - return { 2194 - issue, 2195 - comments: await listIssueCommentsFromAppview(issue.uri), 2196 - }; 2197 - } 2198 - 2199 - const issues = await listIssuesFromAppview(repo); 2200 - const issue = findNumberedRecord(issues, issueRef); 2201 - if (!issue) { 2202 - throw new Error(`Issue not found`); 2203 - } 2204 - 2205 - return { 2206 - issue, 2207 - comments: await listIssueCommentsFromAppview(issue.uri), 2208 - }; 2209 - }; 2210 - 2211 - const listPullsFromAppview = async (repo: RepoContext): Promise<PullSummary[]> => { 2212 - const records = await listAllAppviewRecords<ShTangledRepoPull.Main>( 2213 - 'sh.tangled.repo.listPulls', 2214 - repo.repoDid, 2215 - ) as Array<AppviewStatefulRecordView<ShTangledRepoPull.Main>>; 2216 - const pulls = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); 2217 - const numbers = toNumberMap(pulls); 2218 - 2219 - const summaries = pulls 2220 - .map((pull, index) => ({ 2221 - ...pull, 2222 - number: numbers.get(pull.uri) ?? index + 1, 2223 - state: normalizePullStatus(records[index].state), 2224 - })); 2225 - 2226 - return sortStatefulRecordsByNewestRecord(summaries); 2227 - }; 2228 - 2229 - const listPullsPageFromAppview = async ( 2230 - repo: RepoContext, 2231 - options: StatefulPageOptions<'open' | 'closed' | 'merged'>, 2232 - ): Promise<PaginatedResult<PullSummary>> => { 2233 - if (options.query?.trim()) { 2234 - // Appview search can scope to repo/author/nsid. State is still derived 2235 - // from separate status records, so filter state after each search hit. 2236 - return listSearchedStatefulRecordsPage<ShTangledRepoPull.Main, 'open' | 'closed' | 'merged'>( 2237 - 'sh.tangled.repo.pull', 2238 - repo, 2239 - options, 2240 - getLatestPullStatus, 2241 - ); 2242 - } 2243 - 2244 - // Temporary until Bobbin exposes newest-first/reverse repo pull pagination. 2245 - // This loads all appview pull records, but sorts without per-item backlinks. 2246 - return listAppviewStatefulRecordsPage<ShTangledRepoPull.Main, 'open' | 'closed' | 'merged'>( 2247 - 'sh.tangled.repo.listPulls', 2248 - repo.repoDid, 2249 - options, 2250 - normalizePullStatus, 2251 - options.author ? { author: options.author } : {}, 2252 - (pull) => Promise.resolve(getAppviewRecordSortTimestamp(pull)), 2253 - ); 2254 - }; 2255 - 2256 - const getPullFromAppview = async (repo: RepoContext, pullRef: string): Promise<PullDetail> => { 2257 - const directUri = parseDirectRecordRef(pullRef, PULL_COLLECTION); 2258 - if (directUri) { 2259 - const pullRecord = await getPullRecord(directUri); 2260 - if (pullRecord.value.target.repo !== repo.repoDid) { 2261 - throw new Error(`Pull request not found`); 2262 - } 2263 - 2264 - const pull: PullSummary = { 2265 - ...pullRecord, 2266 - number: 0, 2267 - state: await getLatestPullStatus(pullRecord.uri), 2268 - }; 2269 - 2270 - return { 2271 - pull, 2272 - comments: await listPullCommentsFromAppview(pull.uri), 2273 - }; 2274 - } 2275 - 2276 - const pulls = await listPullsFromAppview(repo); 2277 - const pull = findNumberedRecord(pulls, pullRef); 2278 - if (!pull) { 2279 - throw new Error(`Pull request not found`); 2280 - } 2281 - 2282 - return { 2283 - pull, 2284 - comments: await listPullCommentsFromAppview(pull.uri), 2285 - }; 2286 - }; 2287 - 2288 - export const listIssues = async (repo: RepoContext): Promise<IssueSummary[]> => 2289 - appviewFallback( 2290 - () => listIssuesFromAppview(repo), 2291 - () => listIssuesFromBacklinks(repo), 2292 - ); 2293 - 2294 - export const listIssuesPage = async ( 2295 - repo: RepoContext, 2296 - options: StatefulPageOptions<'open' | 'closed'>, 2297 - ): Promise<PaginatedResult<IssueSummary>> => 2298 - appviewFallback( 2299 - () => listIssuesPageFromAppview(repo, options), 2300 - () => listIssuesPageFromBacklinks(repo, options), 2301 - ); 2302 - 2303 - export const getRepoIssueCount = async (repo: RepoContext): Promise<number> => 2304 - appviewFallback( 2305 - () => getAppviewRecordCount('sh.tangled.repo.countIssues', repo.repoDid), 2306 - () => getIssueCountFromBacklinks(repo), 2307 - ); 2308 - 2309 - export const getIssue = async (repo: RepoContext, issueRef: string): Promise<IssueDetail> => 2310 - appviewFallback( 2311 - () => getIssueFromAppview(repo, issueRef), 2312 - () => getIssueFromBacklinks(repo, issueRef), 2313 - ); 2314 - 2315 - export const listPulls = async (repo: RepoContext): Promise<PullSummary[]> => 2316 - appviewFallback( 2317 - () => listPullsFromAppview(repo), 2318 - () => listPullsFromBacklinks(repo), 2319 - ); 2320 - 2321 - export const listPullsPage = async ( 2322 - repo: RepoContext, 2323 - options: StatefulPageOptions<'open' | 'closed' | 'merged'>, 2324 - ): Promise<PaginatedResult<PullSummary>> => 2325 - appviewFallback( 2326 - () => listPullsPageFromAppview(repo, options), 2327 - () => listPullsPageFromBacklinks(repo, options), 2328 - ); 2329 - 2330 - export const getRepoPullCount = async (repo: RepoContext): Promise<number> => 2331 - appviewFallback( 2332 - () => getAppviewRecordCount('sh.tangled.repo.countPulls', repo.repoDid), 2333 - () => getPullCountFromBacklinks(repo), 2334 - ); 2335 - 2336 - export const getPull = async (repo: RepoContext, pullRef: string): Promise<PullDetail> => 2337 - appviewFallback( 2338 - () => getPullFromAppview(repo, pullRef), 2339 - () => getPullFromBacklinks(repo, pullRef), 2340 - ); 2341 - 2342 - export const fetchPullRoundPatch = async ( 2343 - pull: PullSummary, 2344 - roundIndex: number, 2345 - ): Promise<string> => { 2346 - const round = pull.value.rounds[roundIndex]; 2347 - if (!round) { 2348 - throw new Error(`Round ${roundIndex + 1} not found`); 2349 - } 2350 - 2351 - const cid = extractBlobCid(round.patchBlob); 2352 - if (!cid) { 2353 - throw new Error(`Missing patch blob CID`); 2354 - } 2355 - 2356 - const author = pull.author.pds ? pull.author : await resolveActor(pull.author.did); 2357 - const rpc = getRpc(author.pds); 2358 - const bytes = await ok( 2359 - rpc.get('com.atproto.sync.getBlob', { 2360 - params: { 2361 - did: author.did, 2362 - cid, 2363 - }, 2364 - as: 'bytes', 2365 - }), 2366 - ); 2367 - 2368 - return strFromU8(gunzipSync(bytes)); 2369 - }; 2370 - 2371 - export const createIssue = async ( 2372 - agent: OAuthUserAgent, 2373 - repo: RepoContext, 2374 - input: CreateIssueInput, 2375 - ): Promise<RecordCreationResult> => { 2376 - const rpc = createAuthRpc(agent); 2377 - const record: ShTangledRepoIssue.Main = { 2378 - $type: 'sh.tangled.repo.issue', 2379 - repo: repo.repoDid, 2380 - title: input.title.trim(), 2381 - body: optionalString(input.body), 2382 - createdAt: new Date().toISOString(), 2383 - mentions: extractDids(input.body), 2384 - references: extractAtUris(input.body), 2385 - }; 2386 - 2387 - const result = await ok( 2388 - rpc.post('com.atproto.repo.createRecord', { 2389 - input: { 2390 - repo: agent.sub, 2391 - collection: ISSUE_COLLECTION, 2392 - record, 2393 - }, 2394 - }), 2395 - ); 2396 - 2397 - return { 2398 - uri: result.uri, 2399 - rkey: parseAtUri(result.uri).rkey, 2400 - }; 2401 - }; 2402 - 2403 - export const createIssueComment = async ( 2404 - agent: OAuthUserAgent, 2405 - issueUri: ResourceUri, 2406 - body: string, 2407 - replyTo?: ResourceUri, 2408 - ): Promise<RecordCreationResult> => { 2409 - const rpc = createAuthRpc(agent); 2410 - const record: ShTangledRepoIssueComment.Main = { 2411 - $type: 'sh.tangled.repo.issue.comment', 2412 - issue: issueUri, 2413 - body: body.trim(), 2414 - createdAt: new Date().toISOString(), 2415 - mentions: extractDids(body), 2416 - references: extractAtUris(body), 2417 - ...(replyTo ? { replyTo } : {}), 2418 - }; 2419 - 2420 - const result = await ok( 2421 - rpc.post('com.atproto.repo.createRecord', { 2422 - input: { 2423 - repo: agent.sub, 2424 - collection: ISSUE_COMMENT_COLLECTION, 2425 - record, 2426 - }, 2427 - }), 2428 - ); 2429 - 2430 - return { 2431 - uri: result.uri, 2432 - rkey: parseAtUri(result.uri).rkey, 2433 - }; 2434 - }; 2435 - 2436 - export const setIssueState = async ( 2437 - agent: OAuthUserAgent, 2438 - issueUri: ResourceUri, 2439 - state: 'open' | 'closed', 2440 - ): Promise<RecordCreationResult> => { 2441 - const rpc = createAuthRpc(agent); 2442 - const record: ShTangledRepoIssueState.Main = { 2443 - $type: 'sh.tangled.repo.issue.state', 2444 - issue: issueUri, 2445 - state: 2446 - state === 'closed' 2447 - ? 'sh.tangled.repo.issue.state.closed' 2448 - : 'sh.tangled.repo.issue.state.open', 2449 - }; 2450 - 2451 - const result = await ok( 2452 - rpc.post('com.atproto.repo.createRecord', { 2453 - input: { 2454 - repo: agent.sub, 2455 - collection: ISSUE_STATE_COLLECTION, 2456 - record, 2457 - }, 2458 - }), 2459 - ); 2460 - 2461 - return { 2462 - uri: result.uri, 2463 - rkey: parseAtUri(result.uri).rkey, 2464 - }; 2465 - }; 2466 - 2467 - export const createPull = async ( 2468 - agent: OAuthUserAgent, 2469 - repo: RepoContext, 2470 - input: CreatePullInput, 2471 - ): Promise<RecordCreationResult> => { 2472 - const rpc = createAuthRpc(agent); 2473 - const patchBytes = new Uint8Array(gzipSync(strToU8(input.patch))); 2474 - const patchBlob = new Blob([patchBytes.buffer], { 2475 - type: 'application/gzip', 2476 - }); 2477 - 2478 - const uploaded = await ok( 2479 - rpc.post('com.atproto.repo.uploadBlob', { 2480 - input: patchBlob, 2481 - }), 2482 - ); 2483 - 2484 - const record: ShTangledRepoPull.Main = { 2485 - $type: 'sh.tangled.repo.pull', 2486 - title: input.title.trim(), 2487 - body: optionalString(input.body), 2488 - target: { 2489 - repo: repo.repoDid, 2490 - branch: input.targetBranch, 2491 - }, 2492 - source: { 2493 - branch: input.sourceBranch, 2494 - }, 2495 - rounds: [ 2496 - { 2497 - createdAt: new Date().toISOString(), 2498 - patchBlob: uploaded.blob, 2499 - }, 2500 - ], 2501 - createdAt: new Date().toISOString(), 2502 - mentions: extractDids(input.body), 2503 - references: extractAtUris(input.body), 2504 - }; 2505 - 2506 - const result = await ok( 2507 - rpc.post('com.atproto.repo.createRecord', { 2508 - input: { 2509 - repo: agent.sub, 2510 - collection: PULL_COLLECTION, 2511 - record, 2512 - }, 2513 - }), 2514 - ); 2515 - 2516 - return { 2517 - uri: result.uri, 2518 - rkey: parseAtUri(result.uri).rkey, 2519 - }; 2520 - }; 2521 - 2522 - export const createPullComment = async ( 2523 - agent: OAuthUserAgent, 2524 - pullUri: ResourceUri, 2525 - body: string, 2526 - ): Promise<RecordCreationResult> => { 2527 - const rpc = createAuthRpc(agent); 2528 - const record: ShTangledRepoPullComment.Main = { 2529 - $type: 'sh.tangled.repo.pull.comment', 2530 - pull: pullUri, 2531 - body: body.trim(), 2532 - createdAt: new Date().toISOString(), 2533 - mentions: extractDids(body), 2534 - references: extractAtUris(body), 2535 - }; 2536 - 2537 - const result = await ok( 2538 - rpc.post('com.atproto.repo.createRecord', { 2539 - input: { 2540 - repo: agent.sub, 2541 - collection: PULL_COMMENT_COLLECTION, 2542 - record, 2543 - }, 2544 - }), 2545 - ); 2546 - 2547 - return { 2548 - uri: result.uri, 2549 - rkey: parseAtUri(result.uri).rkey, 2550 - }; 2551 - }; 2552 - 2553 - export const setPullStatus = async ( 2554 - agent: OAuthUserAgent, 2555 - pullUri: ResourceUri, 2556 - status: 'open' | 'closed' | 'merged', 2557 - ): Promise<RecordCreationResult> => { 2558 - const rpc = createAuthRpc(agent); 2559 - const record: ShTangledRepoPullStatus.Main = { 2560 - $type: 'sh.tangled.repo.pull.status', 2561 - pull: pullUri, 2562 - status: 2563 - status === 'merged' 2564 - ? 'sh.tangled.repo.pull.status.merged' 2565 - : status === 'closed' 2566 - ? 'sh.tangled.repo.pull.status.closed' 2567 - : 'sh.tangled.repo.pull.status.open', 2568 - }; 2569 - 2570 - const result = await ok( 2571 - rpc.post('com.atproto.repo.createRecord', { 2572 - input: { 2573 - repo: agent.sub, 2574 - collection: PULL_STATUS_COLLECTION, 2575 - record, 2576 - }, 2577 - }), 2578 - ); 2579 - 2580 - return { 2581 - uri: result.uri, 2582 - rkey: parseAtUri(result.uri).rkey, 2583 - }; 2584 - }; 2585 - 2586 - export const decodeBlobText = (blob: BlobResponse): string => { 2587 - if (!blob.content) { 2588 - return ''; 2589 - } 2590 - 2591 - if (blob.encoding === 'base64') { 2592 - const buffer = Uint8Array.from(atob(blob.content), (char) => char.charCodeAt(0)); 2593 - return new TextDecoder().decode(buffer); 2594 - } 2595 - 2596 - return blob.content; 2597 - }; 2598 - 2599 - export const buildBlobDataUrl = (blob: BlobResponse): string | null => { 2600 - if (!blob.content || !blob.mimeType) { 2601 - return null; 2602 - } 2603 - 2604 - if (blob.encoding === 'base64') { 2605 - return `data:${blob.mimeType};base64,${blob.content}`; 2606 - } 2607 - 2608 - return `data:${blob.mimeType};charset=utf-8,${encodeURIComponent(blob.content)}`; 2609 - }; 2610 - 2611 - const getActorProfileFromAppview = async (actor: ResolvedActor): Promise<ShTangledActorProfile.Main | null> => { 2612 - const profile = await appviewJson<AppviewRecordView<ShTangledActorProfile.Main>>( 2613 - 'sh.tangled.actor.getProfile', 2614 - { 2615 - actor: `at://${actor.did}/${ACTOR_PROFILE_COLLECTION}/self`, 2616 - }, 2617 - ); 2618 - return profile.value; 2619 - }; 2620 - 2621 - export const resolveAvatarUrl = async (identifier: string): Promise<string | null> => { 2622 - const actor = await resolveActor(identifier); 2623 - let tangledProfile: ShTangledActorProfile.Main | null; 2624 - try { 2625 - tangledProfile = await getActorProfileFromAppview(actor); 2626 - } catch (cause) { 2627 - if (!(cause instanceof AppviewUnavailableError)) { 2628 - throw cause; 2629 - } 2630 - 2631 - tangledProfile = await getOptionalRecord<ShTangledActorProfile.Main>( 2632 - actor, 2633 - ACTOR_PROFILE_COLLECTION, 2634 - 'self', 2635 - ); 2636 - } 2637 - const tangledCid = extractBlobCid(tangledProfile?.avatar); 2638 - if (tangledCid) { 2639 - return buildPdsBlobUrl(actor.pds, actor.did, tangledCid); 2640 - } 2641 - 2642 - const bskyProfile = await getOptionalRecord<ProfileWithAvatar>( 2643 - actor, 2644 - APP_BSKY_PROFILE_COLLECTION, 2645 - 'self', 2646 - ); 2647 - const bskyCid = extractBlobCid(bskyProfile?.avatar); 2648 - if (bskyCid) { 2649 - return buildPdsBlobUrl(actor.pds, actor.did, bskyCid); 2650 - } 2651 - 2652 - return null; 2653 - }; 2654 - 2655 - export const parseAtUri = (uri: string): { did: Did; collection: Nsid; rkey: string } => { 2656 - if (!uri.startsWith('at://')) { 2657 - throw new Error(`Invalid at-uri: ${uri}`); 2658 - } 2659 - 2660 - const parts = uri.slice(5).split('/'); 2661 - const [did, collection, ...rkey] = parts; 2662 - return { 2663 - did: asDid(did), 2664 - collection: asNsid(collection), 2665 - rkey: rkey.join('/'), 2666 - }; 2667 - }; 2668 - 2669 - const parseDirectRecordRef = (ref: string, collection: Nsid): ResourceUri | null => { 2670 - let normalized = ref.trim(); 2671 - try { 2672 - normalized = decodeURIComponent(normalized); 2673 - } catch { 2674 - // Keep the original route param when it is not percent-encoded. 2675 - } 2676 - 2677 - if (!normalized.startsWith('at://')) { 2678 - return null; 2679 - } 2680 - 2681 - try { 2682 - return parseAtUri(normalized).collection === collection ? (normalized as ResourceUri) : null; 2683 - } catch { 2684 - return null; 2685 - } 2686 - }; 2687 - 2688 - export const extractBlobCid = (blob: unknown): string | null => { 2689 - if (!blob || typeof blob !== 'object') { 2690 - return null; 2691 - } 2692 - 2693 - const value = blob as Record<string, unknown>; 2694 - const ref = value.ref; 2695 - 2696 - if (typeof ref === 'string') { 2697 - return ref; 2698 - } 2699 - 2700 - if (ref && typeof ref === 'object') { 2701 - const maybeLink = (ref as Record<string, unknown>).$link; 2702 - if (typeof maybeLink === 'string') { 2703 - return maybeLink; 2704 - } 2705 - } 2706 - 2707 - const maybeCid = value.cid; 2708 - return typeof maybeCid === 'string' ? maybeCid : null; 2709 - }; 2710 - 2711 - const findNumberedRecord = <T extends { number: number; rkey: string; uri: string }>( 2712 - records: T[], 2713 - ref: string, 2714 - ): T | undefined => { 2715 - const trimmed = ref.trim(); 2716 - if (/^\d+$/.test(trimmed)) { 2717 - const number = Number(trimmed); 2718 - return records.find((record) => record.number === number); 2719 - } 2720 - 2721 - return records.find( 2722 - (record) => 2723 - record.rkey === trimmed || 2724 - record.uri === trimmed || 2725 - parseAtUri(record.uri).rkey === trimmed, 2726 - ); 2727 - }; 2728 - 2729 - const optionalString = (value: string): string | undefined => { 2730 - const trimmed = value.trim(); 2731 - return trimmed.length > 0 ? trimmed : undefined; 2732 - }; 2733 - 2734 - const uniq = <T extends string>(values: T[]): T[] | undefined => { 2735 - if (values.length === 0) { 2736 - return undefined; 2737 - } 2738 - 2739 - return [...new Set(values)]; 2740 - }; 2741 - 2742 - const extractDids = (input: string): Did[] | undefined => 2743 - uniq( 2744 - Array.from( 2745 - input.matchAll(/\bdid:[a-z0-9]+:[A-Za-z0-9._:%-]+\b/gi), 2746 - (match) => match[0] as Did, 2747 - ), 2748 - ); 2749 - 2750 - const extractAtUris = (input: string): ResourceUri[] | undefined => 2751 - uniq( 2752 - Array.from( 2753 - input.matchAll(/\bat:\/\/[^\s<>()]+/gi), 2754 - (match) => match[0] as ResourceUri, 2755 - ), 2756 - ); 2757 - 2758 - const sortByCreatedAt = <T extends { value: { createdAt?: string } }>(left: T, right: T): number => 2759 - new Date(left.value.createdAt ?? 0).getTime() - new Date(right.value.createdAt ?? 0).getTime(); 2760 - 2761 - const asActorIdentifier = (value: string): ActorIdentifier => value as ActorIdentifier; 2762 - 2763 - const asDid = (value: string): Did => { 2764 - if (!value.startsWith('did:')) { 2765 - throw new Error(`Invalid DID: ${value}`); 2766 - } 2767 - 2768 - return value as Did; 2769 - }; 2770 - 2771 - const asNsid = (value: string): Nsid => { 2772 - if (value.split('.').length < 3) { 2773 - throw new Error(`Invalid NSID: ${value}`); 2774 - } 2775 - 2776 - return value as Nsid; 2777 - }; 2778 - 2779 - const buildPdsBlobUrl = (pds: string, did: Did, cid: string): string => { 2780 - const url = new URL('/xrpc/com.atproto.sync.getBlob', normalizeServiceUrl(pds)); 2781 - url.searchParams.set('did', did); 2782 - url.searchParams.set('cid', cid); 2783 - return url.toString(); 2784 - };
+93
src/lib/api/hydration.ts
··· 1 + import { ok } from '@atcute/client'; 2 + import type { Did, Nsid, ResourceUri } from '@atcute/lexicons/syntax'; 3 + 4 + import { 5 + appviewBulkRecords, 6 + appviewFallback, 7 + type AppviewRecordView, 8 + } from './appview'; 9 + import { getRpc } from './client'; 10 + import { 11 + type BacklinkRecordRef, 12 + type HydratedRecord, 13 + parseAtUri, 14 + } from './records'; 15 + import { resolveActor, resolveActorForDisplay } from './identity'; 16 + 17 + export const appviewRecordToHydrated = async <T>(record: AppviewRecordView<T>): Promise<HydratedRecord<T>> => { 18 + const parsed = parseAtUri(record.uri); 19 + const author = await resolveActorForDisplay(parsed.did); 20 + 21 + return { 22 + author, 23 + collection: parsed.collection, 24 + rkey: parsed.rkey, 25 + uri: record.uri, 26 + cid: record.cid ?? undefined, 27 + value: record.value, 28 + }; 29 + }; 30 + 31 + export const hydrateRecord = async <T>( 32 + ref: BacklinkRecordRef | { did: Did; collection: Nsid; rkey: string }, 33 + ): Promise<HydratedRecord<T>> => { 34 + const actor = await resolveActor(ref.did); 35 + const record = await ok( 36 + getRpc(actor.pds).get('com.atproto.repo.getRecord', { 37 + params: { 38 + repo: ref.did, 39 + collection: ref.collection, 40 + rkey: ref.rkey, 41 + }, 42 + }), 43 + ); 44 + 45 + return { 46 + author: actor, 47 + collection: ref.collection, 48 + rkey: ref.rkey, 49 + uri: record.uri, 50 + cid: record.cid, 51 + value: record.value as T, 52 + }; 53 + }; 54 + 55 + export const fetchRecordValue = async <T>( 56 + ref: BacklinkRecordRef | { did: Did; collection: Nsid; rkey: string }, 57 + ): Promise<T> => { 58 + const actor = await resolveActor(ref.did); 59 + const record = await ok( 60 + getRpc(actor.pds).get('com.atproto.repo.getRecord', { 61 + params: { 62 + repo: ref.did, 63 + collection: ref.collection, 64 + rkey: ref.rkey, 65 + }, 66 + }), 67 + ); 68 + 69 + return record.value as T; 70 + }; 71 + 72 + export const hydrateBacklinks = async <T>(refs: BacklinkRecordRef[]): Promise<Array<HydratedRecord<T>>> => 73 + Promise.all(refs.map((ref) => hydrateRecord<T>(ref))); 74 + 75 + const backlinkRefToUri = (ref: BacklinkRecordRef): ResourceUri => 76 + `at://${ref.did}/${ref.collection}/${ref.rkey}` as ResourceUri; 77 + 78 + export const hydrateBacklinksFromAppview = async <T>( 79 + refs: BacklinkRecordRef[], 80 + nsid: string, 81 + paramName: string, 82 + ): Promise<Array<HydratedRecord<T>>> => 83 + appviewFallback( 84 + async () => { 85 + const records = await appviewBulkRecords<T>( 86 + nsid, 87 + paramName, 88 + refs.map(backlinkRefToUri), 89 + ); 90 + return Promise.all(records.map((record) => appviewRecordToHydrated(record))); 91 + }, 92 + () => hydrateBacklinks<T>(refs), 93 + );
+164 -7
src/lib/api/identity.ts
··· 1 - export { 2 - clearApiCaches, 3 - createAuthRpc, 4 - identityResolver, 5 - resolveActor, 6 - resolveAvatarUrl, 7 - } from './core'; 1 + import { ok } from '@atcute/client'; 2 + import { 3 + type ActorResolver, 4 + type ResolveActorOptions, 5 + type ResolvedActor, 6 + } from '@atcute/identity-resolver'; 7 + import type { 8 + ActorIdentifier, 9 + Did, 10 + Handle, 11 + } from '@atcute/lexicons/syntax'; 12 + import { ShTangledActorProfile } from '@atcute/tangled'; 13 + 14 + import { 15 + APP_BSKY_PROFILE_COLLECTION, 16 + ACTOR_PROFILE_COLLECTION, 17 + SLINGSHOT_SERVICE, 18 + } from './constants'; 19 + import { appviewJson, AppviewUnavailableError, type AppviewRecordView } from './appview'; 20 + import { clearAppviewStatefulCursorCache } from './appview-cache'; 21 + import { 22 + createAuthRpc as createClientAuthRpc, 23 + getRpc, 24 + normalizeServiceUrl, 25 + } from './client'; 26 + import { 27 + extractBlobCid, 28 + getOptionalRecord, 29 + } from './records'; 30 + 31 + interface MiniDocResponse { 32 + did: Did; 33 + handle: Handle; 34 + pds: string; 35 + } 36 + 37 + interface ProfileWithAvatar { 38 + avatar?: unknown; 39 + } 40 + 41 + const actorCache = new Map<string, Promise<ResolvedActor>>(); 42 + 43 + class AppviewFirstActorResolver implements ActorResolver { 44 + async resolve(actor: ActorIdentifier, options?: ResolveActorOptions): Promise<ResolvedActor> { 45 + try { 46 + return normalizeResolvedActor( 47 + await appviewJson<MiniDocResponse>( 48 + 'com.bad-example.identity.resolveMiniDoc', 49 + { identifier: actor }, 50 + options?.signal, 51 + ), 52 + ); 53 + } catch (cause) { 54 + if (!(cause instanceof AppviewUnavailableError)) { 55 + throw cause; 56 + } 57 + 58 + const resolved = await ok( 59 + getRpc(SLINGSHOT_SERVICE).get('blue.microcosm.identity.resolveMiniDoc', { 60 + params: { 61 + identifier: actor, 62 + }, 63 + signal: options?.signal, 64 + }), 65 + ); 66 + 67 + return normalizeResolvedActor(resolved); 68 + } 69 + } 70 + } 71 + 72 + export const identityResolver = new AppviewFirstActorResolver(); 73 + 74 + export const clearApiCaches = () => { 75 + actorCache.clear(); 76 + clearAppviewStatefulCursorCache(); 77 + }; 78 + 79 + export const createAuthRpc = createClientAuthRpc; 80 + 81 + export const resolveActor = async (identifier: string): Promise<ResolvedActor> => { 82 + const key = identifier.toLowerCase(); 83 + const cached = actorCache.get(key); 84 + if (cached) { 85 + return cached; 86 + } 87 + 88 + const promise = identityResolver.resolve(asActorIdentifier(identifier)); 89 + actorCache.set(key, promise); 90 + return promise; 91 + }; 92 + 93 + export const resolveActorForDisplay = async (did: Did): Promise<ResolvedActor> => { 94 + try { 95 + return await resolveActor(did); 96 + } catch { 97 + return unresolvedActorFromDid(did); 98 + } 99 + }; 100 + 101 + const normalizeResolvedActor = (resolved: MiniDocResponse): ResolvedActor => ({ 102 + did: resolved.did, 103 + handle: resolved.handle, 104 + pds: normalizeServiceUrl(resolved.pds), 105 + }); 106 + 107 + const unresolvedActorFromDid = (did: Did): ResolvedActor => ({ 108 + did, 109 + handle: did as unknown as Handle, 110 + pds: '', 111 + }); 112 + 113 + const getActorProfileFromAppview = async (actor: ResolvedActor): Promise<ShTangledActorProfile.Main | null> => { 114 + const profile = await appviewJson<AppviewRecordView<ShTangledActorProfile.Main>>( 115 + 'sh.tangled.actor.getProfile', 116 + { 117 + actor: `at://${actor.did}/${ACTOR_PROFILE_COLLECTION}/self`, 118 + }, 119 + ); 120 + return profile.value; 121 + }; 122 + 123 + export const resolveAvatarUrl = async (identifier: string): Promise<string | null> => { 124 + const actor = await resolveActor(identifier); 125 + let tangledProfile: ShTangledActorProfile.Main | null; 126 + try { 127 + tangledProfile = await getActorProfileFromAppview(actor); 128 + } catch (cause) { 129 + if (!(cause instanceof AppviewUnavailableError)) { 130 + throw cause; 131 + } 132 + 133 + tangledProfile = await getOptionalRecord<ShTangledActorProfile.Main>( 134 + actor, 135 + ACTOR_PROFILE_COLLECTION, 136 + 'self', 137 + ); 138 + } 139 + const tangledCid = extractBlobCid(tangledProfile?.avatar); 140 + if (tangledCid) { 141 + return buildPdsBlobUrl(actor.pds, actor.did, tangledCid); 142 + } 143 + 144 + const bskyProfile = await getOptionalRecord<ProfileWithAvatar>( 145 + actor, 146 + APP_BSKY_PROFILE_COLLECTION, 147 + 'self', 148 + ); 149 + const bskyCid = extractBlobCid(bskyProfile?.avatar); 150 + if (bskyCid) { 151 + return buildPdsBlobUrl(actor.pds, actor.did, bskyCid); 152 + } 153 + 154 + return null; 155 + }; 156 + 157 + const asActorIdentifier = (value: string): ActorIdentifier => value as ActorIdentifier; 158 + 159 + const buildPdsBlobUrl = (pds: string, did: Did, cid: string): string => { 160 + const url = new URL('/xrpc/com.atproto.sync.getBlob', normalizeServiceUrl(pds)); 161 + url.searchParams.set('did', did); 162 + url.searchParams.set('cid', cid); 163 + return url.toString(); 164 + };
+385 -16
src/lib/api/issues.ts
··· 1 - export { 2 - createIssue, 3 - createIssueComment, 4 - getIssue, 5 - getIssueRecord, 6 - getRepoIssueCount, 7 - listIssues, 8 - listIssuesPage, 9 - setIssueState, 10 - } from './core'; 1 + import { ok } from '@atcute/client'; 2 + import type { ResourceUri } from '@atcute/lexicons/syntax'; 3 + import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 4 + import { 5 + ShTangledRepoIssue, 6 + ShTangledRepoIssueComment, 7 + ShTangledRepoIssueState, 8 + } from '@atcute/tangled'; 9 + 10 + import { 11 + appviewFallback, 12 + appviewJson, 13 + getAppviewRecordCount, 14 + listAllAppviewRecords, 15 + type AppviewRecordView, 16 + type AppviewStatefulRecordView, 17 + } from './appview'; 18 + import { 19 + getBacklinks, 20 + getLatestBacklinkTimestamp, 21 + getUniqueBacklinkRefs, 22 + latestBacklinkRefByRkey, 23 + listBacklinkedRecordsPage, 24 + } from './backlinks'; 25 + import { 26 + ISSUE_COLLECTION, 27 + ISSUE_COMMENT_COLLECTION, 28 + ISSUE_STATE_COLLECTION, 29 + } from './constants'; 30 + import { 31 + appviewRecordToHydrated, 32 + fetchRecordValue, 33 + hydrateBacklinks, 34 + hydrateBacklinksFromAppview, 35 + } from './hydration'; 36 + import { createAuthRpc } from './identity'; 37 + import { 38 + extractAtUris, 39 + extractDids, 40 + findNumberedRecord, 41 + type HydratedRecord, 42 + maxTimestamp, 43 + optionalString, 44 + type PaginatedResult, 45 + parseAtUri, 46 + parseDirectRecordRef, 47 + sortByCreatedAt, 48 + timestampFromDate, 49 + timestampFromTid, 50 + toNumberMap, 51 + type RecordCreationResult, 52 + type BacklinkRecordRef, 53 + } from './records'; 54 + import type { RepoContext } from './repos'; 55 + import { 56 + getAppviewRecordSortTimestamp, 57 + listAppviewStatefulRecordsPage, 58 + listSearchedStatefulRecordsPage, 59 + sortStatefulRecordsByNewestRecord, 60 + sortStatefulRecordsByUpdatedAt, 61 + type StatefulPageOptions, 62 + } from './stateful'; 63 + 64 + export interface IssueSummary extends HydratedRecord<ShTangledRepoIssue.Main> { 65 + number: number; 66 + state: 'open' | 'closed'; 67 + } 68 + 69 + export interface IssueComment extends HydratedRecord<ShTangledRepoIssueComment.Main> {} 70 + 71 + export interface IssueDetail { 72 + issue: IssueSummary; 73 + comments: IssueComment[]; 74 + } 75 + 76 + export interface CreateIssueInput { 77 + title: string; 78 + body: string; 79 + } 80 + 81 + const normalizeIssueState = (value?: string): 'open' | 'closed' => 82 + value === 'closed' || value?.endsWith('.closed') ? 'closed' : 'open'; 83 + 84 + export const getIssueRecord = async (issueUri: ResourceUri): Promise<HydratedRecord<ShTangledRepoIssue.Main>> => { 85 + const record = await appviewJson<AppviewRecordView<ShTangledRepoIssue.Main>>( 86 + 'sh.tangled.repo.getIssue', 87 + { issue: issueUri }, 88 + ); 89 + return appviewRecordToHydrated(record); 90 + }; 91 + 92 + const hydrateIssueBacklinks = (refs: BacklinkRecordRef[]): Promise<Array<HydratedRecord<ShTangledRepoIssue.Main>>> => 93 + hydrateBacklinksFromAppview<ShTangledRepoIssue.Main>( 94 + refs, 95 + 'sh.tangled.repo.getIssues', 96 + 'issues', 97 + ); 98 + 99 + const getLatestIssueState = async (issueUri: ResourceUri): Promise<'open' | 'closed'> => { 100 + const refs = await getBacklinks(issueUri, 'sh.tangled.repo.issue.state:issue'); 101 + if (refs.length === 0) { 102 + return 'open'; 103 + } 104 + 105 + const state = await fetchRecordValue<ShTangledRepoIssueState.Main>(latestBacklinkRefByRkey(refs)!); 106 + return normalizeIssueState(state.state); 107 + }; 108 + 109 + const getIssueUpdatedAt = async ( 110 + issue: HydratedRecord<ShTangledRepoIssue.Main>, 111 + stateUpdatedAt?: string, 112 + ): Promise<number> => 113 + maxTimestamp( 114 + timestampFromDate(issue.value.createdAt), 115 + timestampFromTid(issue.rkey), 116 + timestampFromDate(stateUpdatedAt), 117 + await getLatestBacklinkTimestamp(issue.uri, [ 118 + `${ISSUE_COMMENT_COLLECTION}:issue`, 119 + `${ISSUE_STATE_COLLECTION}:issue`, 120 + ]), 121 + ); 122 + 123 + const listIssuesFromBacklinks = async (repo: RepoContext): Promise<IssueSummary[]> => { 124 + const refs = await getUniqueBacklinkRefs(repo.repoDid, [ 125 + 'sh.tangled.repo.issue:repoDid', 126 + 'sh.tangled.repo.issue:repo', 127 + ]); 128 + const issues = await hydrateIssueBacklinks(refs); 129 + const numbers = toNumberMap(issues); 130 + const states = await Promise.all(issues.map((issue) => getLatestIssueState(issue.uri))); 131 + 132 + const summaries = issues 133 + .map((issue, index) => ({ 134 + ...issue, 135 + number: numbers.get(issue.uri) ?? index + 1, 136 + state: states[index], 137 + })); 138 + 139 + return sortStatefulRecordsByUpdatedAt(summaries, (issue) => getIssueUpdatedAt(issue)); 140 + }; 141 + 142 + const listIssuesPageFromBacklinks = async ( 143 + repo: RepoContext, 144 + options: StatefulPageOptions<'open' | 'closed'>, 145 + ): Promise<PaginatedResult<IssueSummary>> => 146 + listBacklinkedRecordsPage<ShTangledRepoIssue.Main, 'open' | 'closed'>( 147 + repo.repoDid, 148 + ['sh.tangled.repo.issue:repoDid', 'sh.tangled.repo.issue:repo'], 149 + options, 150 + getLatestIssueState, 151 + (issue) => getIssueUpdatedAt(issue), 152 + hydrateIssueBacklinks, 153 + ); 154 + 155 + const getIssueCountFromBacklinks = async (repo: RepoContext): Promise<number> => 156 + (await getUniqueBacklinkRefs(repo.repoDid, [ 157 + 'sh.tangled.repo.issue:repoDid', 158 + 'sh.tangled.repo.issue:repo', 159 + ])).length; 160 + 161 + const getIssueFromBacklinks = async (repo: RepoContext, issueRef: string): Promise<IssueDetail> => { 162 + const issues = await listIssuesFromBacklinks(repo); 163 + const issue = findNumberedRecord(issues, issueRef); 164 + if (!issue) { 165 + throw new Error(`Issue not found`); 166 + } 167 + 168 + const commentRefs = await getBacklinks(issue.uri, 'sh.tangled.repo.issue.comment:issue'); 169 + const comments = await hydrateBacklinks<ShTangledRepoIssueComment.Main>(commentRefs); 170 + comments.sort(sortByCreatedAt); 171 + 172 + return { 173 + issue, 174 + comments, 175 + }; 176 + }; 177 + 178 + const listIssueCommentsFromAppview = async (issueUri: ResourceUri): Promise<IssueComment[]> => { 179 + const records = await listAllAppviewRecords<ShTangledRepoIssueComment.Main>( 180 + 'sh.tangled.repo.issue.listComments', 181 + issueUri, 182 + ); 183 + const comments = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); 184 + comments.sort(sortByCreatedAt); 185 + return comments; 186 + }; 187 + 188 + const listIssuesFromAppview = async (repo: RepoContext): Promise<IssueSummary[]> => { 189 + const records = await listAllAppviewRecords<ShTangledRepoIssue.Main>( 190 + 'sh.tangled.repo.listIssues', 191 + repo.repoDid, 192 + ) as Array<AppviewStatefulRecordView<ShTangledRepoIssue.Main>>; 193 + const issues = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); 194 + const numbers = toNumberMap(issues); 195 + 196 + const summaries = issues 197 + .map((issue, index) => ({ 198 + ...issue, 199 + number: numbers.get(issue.uri) ?? index + 1, 200 + state: normalizeIssueState(records[index].state), 201 + })); 202 + 203 + return sortStatefulRecordsByNewestRecord(summaries); 204 + }; 11 205 12 - export type { 13 - CreateIssueInput, 14 - IssueComment, 15 - IssueDetail, 16 - IssueSummary, 17 - } from './core'; 206 + const listIssuesPageFromAppview = async ( 207 + repo: RepoContext, 208 + options: StatefulPageOptions<'open' | 'closed'>, 209 + ): Promise<PaginatedResult<IssueSummary>> => { 210 + if (options.query?.trim()) { 211 + // Appview search can scope to repo/author/nsid. State is still derived 212 + // from separate state records, so filter state after each search hit. 213 + return listSearchedStatefulRecordsPage<ShTangledRepoIssue.Main, 'open' | 'closed'>( 214 + 'sh.tangled.repo.issue', 215 + repo, 216 + options, 217 + getLatestIssueState, 218 + ); 219 + } 220 + 221 + // Temporary until Bobbin exposes newest-first/reverse repo issue pagination. 222 + // This loads all appview issue records, but sorts without per-item backlinks. 223 + return listAppviewStatefulRecordsPage<ShTangledRepoIssue.Main, 'open' | 'closed'>( 224 + 'sh.tangled.repo.listIssues', 225 + repo.repoDid, 226 + options, 227 + normalizeIssueState, 228 + options.author ? { author: options.author } : {}, 229 + (issue) => Promise.resolve(getAppviewRecordSortTimestamp(issue)), 230 + ); 231 + }; 232 + 233 + const getIssueFromAppview = async (repo: RepoContext, issueRef: string): Promise<IssueDetail> => { 234 + const directUri = parseDirectRecordRef(issueRef, ISSUE_COLLECTION); 235 + if (directUri) { 236 + const issueRecord = await getIssueRecord(directUri); 237 + if (issueRecord.value.repo !== repo.repoDid) { 238 + throw new Error(`Issue not found`); 239 + } 240 + 241 + const issue: IssueSummary = { 242 + ...issueRecord, 243 + number: 0, 244 + state: await getLatestIssueState(issueRecord.uri), 245 + }; 246 + 247 + return { 248 + issue, 249 + comments: await listIssueCommentsFromAppview(issue.uri), 250 + }; 251 + } 252 + 253 + const issues = await listIssuesFromAppview(repo); 254 + const issue = findNumberedRecord(issues, issueRef); 255 + if (!issue) { 256 + throw new Error(`Issue not found`); 257 + } 258 + 259 + return { 260 + issue, 261 + comments: await listIssueCommentsFromAppview(issue.uri), 262 + }; 263 + }; 264 + 265 + export const listIssues = async (repo: RepoContext): Promise<IssueSummary[]> => 266 + appviewFallback( 267 + () => listIssuesFromAppview(repo), 268 + () => listIssuesFromBacklinks(repo), 269 + ); 270 + 271 + export const listIssuesPage = async ( 272 + repo: RepoContext, 273 + options: StatefulPageOptions<'open' | 'closed'>, 274 + ): Promise<PaginatedResult<IssueSummary>> => 275 + appviewFallback( 276 + () => listIssuesPageFromAppview(repo, options), 277 + () => listIssuesPageFromBacklinks(repo, options), 278 + ); 279 + 280 + export const getRepoIssueCount = async (repo: RepoContext): Promise<number> => 281 + appviewFallback( 282 + () => getAppviewRecordCount('sh.tangled.repo.countIssues', repo.repoDid), 283 + () => getIssueCountFromBacklinks(repo), 284 + ); 285 + 286 + export const getIssue = async (repo: RepoContext, issueRef: string): Promise<IssueDetail> => 287 + appviewFallback( 288 + () => getIssueFromAppview(repo, issueRef), 289 + () => getIssueFromBacklinks(repo, issueRef), 290 + ); 291 + 292 + export const createIssue = async ( 293 + agent: OAuthUserAgent, 294 + repo: RepoContext, 295 + input: CreateIssueInput, 296 + ): Promise<RecordCreationResult> => { 297 + const rpc = createAuthRpc(agent); 298 + const record: ShTangledRepoIssue.Main = { 299 + $type: 'sh.tangled.repo.issue', 300 + repo: repo.repoDid, 301 + title: input.title.trim(), 302 + body: optionalString(input.body), 303 + createdAt: new Date().toISOString(), 304 + mentions: extractDids(input.body), 305 + references: extractAtUris(input.body), 306 + }; 307 + 308 + const result = await ok( 309 + rpc.post('com.atproto.repo.createRecord', { 310 + input: { 311 + repo: agent.sub, 312 + collection: ISSUE_COLLECTION, 313 + record, 314 + }, 315 + }), 316 + ); 317 + 318 + return { 319 + uri: result.uri, 320 + rkey: parseAtUri(result.uri).rkey, 321 + }; 322 + }; 323 + 324 + export const createIssueComment = async ( 325 + agent: OAuthUserAgent, 326 + issueUri: ResourceUri, 327 + body: string, 328 + replyTo?: ResourceUri, 329 + ): Promise<RecordCreationResult> => { 330 + const rpc = createAuthRpc(agent); 331 + const record: ShTangledRepoIssueComment.Main = { 332 + $type: 'sh.tangled.repo.issue.comment', 333 + issue: issueUri, 334 + body: body.trim(), 335 + createdAt: new Date().toISOString(), 336 + mentions: extractDids(body), 337 + references: extractAtUris(body), 338 + ...(replyTo ? { replyTo } : {}), 339 + }; 340 + 341 + const result = await ok( 342 + rpc.post('com.atproto.repo.createRecord', { 343 + input: { 344 + repo: agent.sub, 345 + collection: ISSUE_COMMENT_COLLECTION, 346 + record, 347 + }, 348 + }), 349 + ); 350 + 351 + return { 352 + uri: result.uri, 353 + rkey: parseAtUri(result.uri).rkey, 354 + }; 355 + }; 356 + 357 + export const setIssueState = async ( 358 + agent: OAuthUserAgent, 359 + issueUri: ResourceUri, 360 + state: 'open' | 'closed', 361 + ): Promise<RecordCreationResult> => { 362 + const rpc = createAuthRpc(agent); 363 + const record: ShTangledRepoIssueState.Main = { 364 + $type: 'sh.tangled.repo.issue.state', 365 + issue: issueUri, 366 + state: 367 + state === 'closed' 368 + ? 'sh.tangled.repo.issue.state.closed' 369 + : 'sh.tangled.repo.issue.state.open', 370 + }; 371 + 372 + const result = await ok( 373 + rpc.post('com.atproto.repo.createRecord', { 374 + input: { 375 + repo: agent.sub, 376 + collection: ISSUE_STATE_COLLECTION, 377 + record, 378 + }, 379 + }), 380 + ); 381 + 382 + return { 383 + uri: result.uri, 384 + rkey: parseAtUri(result.uri).rkey, 385 + }; 386 + };
+448 -17
src/lib/api/pulls.ts
··· 1 - export { 2 - createPull, 3 - createPullComment, 4 - fetchPullRoundPatch, 5 - getPull, 6 - getPullRecord, 7 - getRepoPullCount, 8 - listPulls, 9 - listPullsPage, 10 - setPullStatus, 11 - } from './core'; 1 + import { ok } from '@atcute/client'; 2 + import type { ResourceUri } from '@atcute/lexicons/syntax'; 3 + import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 4 + import { 5 + ShTangledRepoPull, 6 + ShTangledRepoPullComment, 7 + ShTangledRepoPullStatus, 8 + } from '@atcute/tangled'; 9 + import { gunzipSync, gzipSync, strFromU8, strToU8 } from 'fflate'; 10 + 11 + import { 12 + appviewFallback, 13 + appviewJson, 14 + getAppviewRecordCount, 15 + listAllAppviewRecords, 16 + type AppviewRecordView, 17 + type AppviewStatefulRecordView, 18 + } from './appview'; 19 + import { 20 + getBacklinks, 21 + getBacklinksPage, 22 + getLatestBacklinkTimestamp, 23 + latestBacklinkRefByRkey, 24 + listBacklinkedRecordsPage, 25 + } from './backlinks'; 26 + import { 27 + PULL_COLLECTION, 28 + PULL_COMMENT_COLLECTION, 29 + PULL_STATUS_COLLECTION, 30 + } from './constants'; 31 + import { getRpc } from './client'; 32 + import { 33 + appviewRecordToHydrated, 34 + fetchRecordValue, 35 + hydrateBacklinks, 36 + hydrateBacklinksFromAppview, 37 + } from './hydration'; 38 + import { createAuthRpc, resolveActor } from './identity'; 39 + import { 40 + extractAtUris, 41 + extractBlobCid, 42 + extractDids, 43 + findNumberedRecord, 44 + type HydratedRecord, 45 + maxTimestamp, 46 + optionalString, 47 + type PaginatedResult, 48 + parseAtUri, 49 + parseDirectRecordRef, 50 + sortByCreatedAt, 51 + timestampFromDate, 52 + timestampFromTid, 53 + toNumberMap, 54 + type RecordCreationResult, 55 + type BacklinkRecordRef, 56 + } from './records'; 57 + import type { RepoContext } from './repos'; 58 + import { 59 + getAppviewRecordSortTimestamp, 60 + listAppviewStatefulRecordsPage, 61 + listSearchedStatefulRecordsPage, 62 + sortStatefulRecordsByNewestRecord, 63 + sortStatefulRecordsByUpdatedAt, 64 + type StatefulPageOptions, 65 + } from './stateful'; 66 + 67 + export interface PullSummary extends HydratedRecord<ShTangledRepoPull.Main> { 68 + number: number; 69 + state: 'open' | 'closed' | 'merged'; 70 + } 71 + 72 + export interface PullComment extends HydratedRecord<ShTangledRepoPullComment.Main> {} 73 + 74 + export interface PullDetail { 75 + pull: PullSummary; 76 + comments: PullComment[]; 77 + } 78 + 79 + export interface CreatePullInput { 80 + title: string; 81 + body: string; 82 + targetBranch: string; 83 + sourceBranch: string; 84 + patch: string; 85 + } 86 + 87 + const normalizePullStatus = (value?: string): 'open' | 'closed' | 'merged' => { 88 + if (value === 'merged' || value?.endsWith('.merged')) { 89 + return 'merged'; 90 + } 91 + 92 + if (value === 'closed' || value?.endsWith('.closed')) { 93 + return 'closed'; 94 + } 95 + 96 + return 'open'; 97 + }; 98 + 99 + export const getPullRecord = async (pullUri: ResourceUri): Promise<HydratedRecord<ShTangledRepoPull.Main>> => { 100 + const record = await appviewJson<AppviewRecordView<ShTangledRepoPull.Main>>( 101 + 'sh.tangled.repo.getPull', 102 + { pull: pullUri }, 103 + ); 104 + return appviewRecordToHydrated(record); 105 + }; 106 + 107 + const hydratePullBacklinks = (refs: BacklinkRecordRef[]): Promise<Array<HydratedRecord<ShTangledRepoPull.Main>>> => 108 + hydrateBacklinksFromAppview<ShTangledRepoPull.Main>( 109 + refs, 110 + 'sh.tangled.repo.getPulls', 111 + 'pulls', 112 + ); 113 + 114 + const getLatestPullStatus = async (pullUri: ResourceUri): Promise<'open' | 'closed' | 'merged'> => { 115 + const refs = await getBacklinks(pullUri, 'sh.tangled.repo.pull.status:pull'); 116 + if (refs.length === 0) { 117 + return 'open'; 118 + } 119 + 120 + const status = await fetchRecordValue<ShTangledRepoPullStatus.Main>(latestBacklinkRefByRkey(refs)!); 121 + return normalizePullStatus(status.status); 122 + }; 123 + 124 + const getPullUpdatedAt = async ( 125 + pull: HydratedRecord<ShTangledRepoPull.Main>, 126 + stateUpdatedAt?: string, 127 + ): Promise<number> => 128 + maxTimestamp( 129 + timestampFromDate(pull.value.createdAt), 130 + timestampFromTid(pull.rkey), 131 + ...pull.value.rounds.map((round) => timestampFromDate(round.createdAt)), 132 + timestampFromDate(stateUpdatedAt), 133 + await getLatestBacklinkTimestamp(pull.uri, [ 134 + `${PULL_COMMENT_COLLECTION}:pull`, 135 + `${PULL_STATUS_COLLECTION}:pull`, 136 + ]), 137 + ); 138 + 139 + const listPullsFromBacklinks = async (repo: RepoContext): Promise<PullSummary[]> => { 140 + const refs = await getBacklinks(repo.repoDid, 'sh.tangled.repo.pull:target.repo'); 141 + const pulls = await hydratePullBacklinks(refs); 142 + const numbers = toNumberMap(pulls); 143 + const states = await Promise.all(pulls.map((pull) => getLatestPullStatus(pull.uri))); 144 + 145 + const summaries = pulls 146 + .map((pull, index) => ({ 147 + ...pull, 148 + number: numbers.get(pull.uri) ?? index + 1, 149 + state: states[index], 150 + })); 151 + 152 + return sortStatefulRecordsByUpdatedAt(summaries, (pull) => getPullUpdatedAt(pull)); 153 + }; 154 + 155 + const listPullsPageFromBacklinks = async ( 156 + repo: RepoContext, 157 + options: StatefulPageOptions<'open' | 'closed' | 'merged'>, 158 + ): Promise<PaginatedResult<PullSummary>> => 159 + listBacklinkedRecordsPage<ShTangledRepoPull.Main, 'open' | 'closed' | 'merged'>( 160 + repo.repoDid, 161 + 'sh.tangled.repo.pull:target.repo', 162 + options, 163 + getLatestPullStatus, 164 + (pull) => getPullUpdatedAt(pull), 165 + hydratePullBacklinks, 166 + ); 167 + 168 + const getPullCountFromBacklinks = async (repo: RepoContext): Promise<number> => 169 + (await getBacklinksPage(repo.repoDid, 'sh.tangled.repo.pull:target.repo', 1)).total ?? 170 + (await getBacklinks(repo.repoDid, 'sh.tangled.repo.pull:target.repo')).length; 171 + 172 + const getPullFromBacklinks = async (repo: RepoContext, pullRef: string): Promise<PullDetail> => { 173 + const pulls = await listPullsFromBacklinks(repo); 174 + const pull = findNumberedRecord(pulls, pullRef); 175 + if (!pull) { 176 + throw new Error(`Pull request not found`); 177 + } 178 + 179 + const commentRefs = await getBacklinks(pull.uri, 'sh.tangled.repo.pull.comment:pull'); 180 + const comments = await hydrateBacklinks<ShTangledRepoPullComment.Main>(commentRefs); 181 + comments.sort(sortByCreatedAt); 182 + 183 + return { 184 + pull, 185 + comments, 186 + }; 187 + }; 188 + 189 + const listPullCommentsFromAppview = async (pullUri: ResourceUri): Promise<PullComment[]> => { 190 + const records = await listAllAppviewRecords<ShTangledRepoPullComment.Main>( 191 + 'sh.tangled.repo.pull.listComments', 192 + pullUri, 193 + ); 194 + const comments = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); 195 + comments.sort(sortByCreatedAt); 196 + return comments; 197 + }; 198 + 199 + const listPullsFromAppview = async (repo: RepoContext): Promise<PullSummary[]> => { 200 + const records = await listAllAppviewRecords<ShTangledRepoPull.Main>( 201 + 'sh.tangled.repo.listPulls', 202 + repo.repoDid, 203 + ) as Array<AppviewStatefulRecordView<ShTangledRepoPull.Main>>; 204 + const pulls = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); 205 + const numbers = toNumberMap(pulls); 206 + 207 + const summaries = pulls 208 + .map((pull, index) => ({ 209 + ...pull, 210 + number: numbers.get(pull.uri) ?? index + 1, 211 + state: normalizePullStatus(records[index].state), 212 + })); 213 + 214 + return sortStatefulRecordsByNewestRecord(summaries); 215 + }; 216 + 217 + const listPullsPageFromAppview = async ( 218 + repo: RepoContext, 219 + options: StatefulPageOptions<'open' | 'closed' | 'merged'>, 220 + ): Promise<PaginatedResult<PullSummary>> => { 221 + if (options.query?.trim()) { 222 + // Appview search can scope to repo/author/nsid. State is still derived 223 + // from separate status records, so filter state after each search hit. 224 + return listSearchedStatefulRecordsPage<ShTangledRepoPull.Main, 'open' | 'closed' | 'merged'>( 225 + 'sh.tangled.repo.pull', 226 + repo, 227 + options, 228 + getLatestPullStatus, 229 + ); 230 + } 231 + 232 + // Temporary until Bobbin exposes newest-first/reverse repo pull pagination. 233 + // This loads all appview pull records, but sorts without per-item backlinks. 234 + return listAppviewStatefulRecordsPage<ShTangledRepoPull.Main, 'open' | 'closed' | 'merged'>( 235 + 'sh.tangled.repo.listPulls', 236 + repo.repoDid, 237 + options, 238 + normalizePullStatus, 239 + options.author ? { author: options.author } : {}, 240 + (pull) => Promise.resolve(getAppviewRecordSortTimestamp(pull)), 241 + ); 242 + }; 12 243 13 - export type { 14 - CreatePullInput, 15 - PullComment, 16 - PullDetail, 17 - PullSummary, 18 - } from './core'; 244 + const getPullFromAppview = async (repo: RepoContext, pullRef: string): Promise<PullDetail> => { 245 + const directUri = parseDirectRecordRef(pullRef, PULL_COLLECTION); 246 + if (directUri) { 247 + const pullRecord = await getPullRecord(directUri); 248 + if (pullRecord.value.target.repo !== repo.repoDid) { 249 + throw new Error(`Pull request not found`); 250 + } 251 + 252 + const pull: PullSummary = { 253 + ...pullRecord, 254 + number: 0, 255 + state: await getLatestPullStatus(pullRecord.uri), 256 + }; 257 + 258 + return { 259 + pull, 260 + comments: await listPullCommentsFromAppview(pull.uri), 261 + }; 262 + } 263 + 264 + const pulls = await listPullsFromAppview(repo); 265 + const pull = findNumberedRecord(pulls, pullRef); 266 + if (!pull) { 267 + throw new Error(`Pull request not found`); 268 + } 269 + 270 + return { 271 + pull, 272 + comments: await listPullCommentsFromAppview(pull.uri), 273 + }; 274 + }; 275 + 276 + export const listPulls = async (repo: RepoContext): Promise<PullSummary[]> => 277 + appviewFallback( 278 + () => listPullsFromAppview(repo), 279 + () => listPullsFromBacklinks(repo), 280 + ); 281 + 282 + export const listPullsPage = async ( 283 + repo: RepoContext, 284 + options: StatefulPageOptions<'open' | 'closed' | 'merged'>, 285 + ): Promise<PaginatedResult<PullSummary>> => 286 + appviewFallback( 287 + () => listPullsPageFromAppview(repo, options), 288 + () => listPullsPageFromBacklinks(repo, options), 289 + ); 290 + 291 + export const getRepoPullCount = async (repo: RepoContext): Promise<number> => 292 + appviewFallback( 293 + () => getAppviewRecordCount('sh.tangled.repo.countPulls', repo.repoDid), 294 + () => getPullCountFromBacklinks(repo), 295 + ); 296 + 297 + export const getPull = async (repo: RepoContext, pullRef: string): Promise<PullDetail> => 298 + appviewFallback( 299 + () => getPullFromAppview(repo, pullRef), 300 + () => getPullFromBacklinks(repo, pullRef), 301 + ); 302 + 303 + export const fetchPullRoundPatch = async ( 304 + pull: PullSummary, 305 + roundIndex: number, 306 + ): Promise<string> => { 307 + const round = pull.value.rounds[roundIndex]; 308 + if (!round) { 309 + throw new Error(`Round ${roundIndex + 1} not found`); 310 + } 311 + 312 + const cid = extractBlobCid(round.patchBlob); 313 + if (!cid) { 314 + throw new Error(`Missing patch blob CID`); 315 + } 316 + 317 + const author = pull.author.pds ? pull.author : await resolveActor(pull.author.did); 318 + const rpc = getRpc(author.pds); 319 + const bytes = await ok( 320 + rpc.get('com.atproto.sync.getBlob', { 321 + params: { 322 + did: author.did, 323 + cid, 324 + }, 325 + as: 'bytes', 326 + }), 327 + ); 328 + 329 + return strFromU8(gunzipSync(bytes)); 330 + }; 331 + 332 + export const createPull = async ( 333 + agent: OAuthUserAgent, 334 + repo: RepoContext, 335 + input: CreatePullInput, 336 + ): Promise<RecordCreationResult> => { 337 + const rpc = createAuthRpc(agent); 338 + const patchBytes = new Uint8Array(gzipSync(strToU8(input.patch))); 339 + const patchBlob = new Blob([patchBytes.buffer], { 340 + type: 'application/gzip', 341 + }); 342 + 343 + const uploaded = await ok( 344 + rpc.post('com.atproto.repo.uploadBlob', { 345 + input: patchBlob, 346 + }), 347 + ); 348 + 349 + const record: ShTangledRepoPull.Main = { 350 + $type: 'sh.tangled.repo.pull', 351 + title: input.title.trim(), 352 + body: optionalString(input.body), 353 + target: { 354 + repo: repo.repoDid, 355 + branch: input.targetBranch, 356 + }, 357 + source: { 358 + branch: input.sourceBranch, 359 + }, 360 + rounds: [ 361 + { 362 + createdAt: new Date().toISOString(), 363 + patchBlob: uploaded.blob, 364 + }, 365 + ], 366 + createdAt: new Date().toISOString(), 367 + mentions: extractDids(input.body), 368 + references: extractAtUris(input.body), 369 + }; 370 + 371 + const result = await ok( 372 + rpc.post('com.atproto.repo.createRecord', { 373 + input: { 374 + repo: agent.sub, 375 + collection: PULL_COLLECTION, 376 + record, 377 + }, 378 + }), 379 + ); 380 + 381 + return { 382 + uri: result.uri, 383 + rkey: parseAtUri(result.uri).rkey, 384 + }; 385 + }; 386 + 387 + export const createPullComment = async ( 388 + agent: OAuthUserAgent, 389 + pullUri: ResourceUri, 390 + body: string, 391 + ): Promise<RecordCreationResult> => { 392 + const rpc = createAuthRpc(agent); 393 + const record: ShTangledRepoPullComment.Main = { 394 + $type: 'sh.tangled.repo.pull.comment', 395 + pull: pullUri, 396 + body: body.trim(), 397 + createdAt: new Date().toISOString(), 398 + mentions: extractDids(body), 399 + references: extractAtUris(body), 400 + }; 401 + 402 + const result = await ok( 403 + rpc.post('com.atproto.repo.createRecord', { 404 + input: { 405 + repo: agent.sub, 406 + collection: PULL_COMMENT_COLLECTION, 407 + record, 408 + }, 409 + }), 410 + ); 411 + 412 + return { 413 + uri: result.uri, 414 + rkey: parseAtUri(result.uri).rkey, 415 + }; 416 + }; 417 + 418 + export const setPullStatus = async ( 419 + agent: OAuthUserAgent, 420 + pullUri: ResourceUri, 421 + status: 'open' | 'closed' | 'merged', 422 + ): Promise<RecordCreationResult> => { 423 + const rpc = createAuthRpc(agent); 424 + const record: ShTangledRepoPullStatus.Main = { 425 + $type: 'sh.tangled.repo.pull.status', 426 + pull: pullUri, 427 + status: 428 + status === 'merged' 429 + ? 'sh.tangled.repo.pull.status.merged' 430 + : status === 'closed' 431 + ? 'sh.tangled.repo.pull.status.closed' 432 + : 'sh.tangled.repo.pull.status.open', 433 + }; 434 + 435 + const result = await ok( 436 + rpc.post('com.atproto.repo.createRecord', { 437 + input: { 438 + repo: agent.sub, 439 + collection: PULL_STATUS_COLLECTION, 440 + record, 441 + }, 442 + }), 443 + ); 444 + 445 + return { 446 + uri: result.uri, 447 + rkey: parseAtUri(result.uri).rkey, 448 + }; 449 + };
+229 -9
src/lib/api/records.ts
··· 1 - export { 2 - extractBlobCid, 3 - parseAtUri, 4 - } from './core'; 1 + import { ClientResponseError, ok } from '@atcute/client'; 2 + import type { ResolvedActor } from '@atcute/identity-resolver'; 3 + import type { Did, Nsid, ResourceUri } from '@atcute/lexicons/syntax'; 4 + 5 + import { getRpc } from './client'; 6 + 7 + export interface HydratedRecord<T> { 8 + author: ResolvedActor; 9 + collection: Nsid; 10 + rkey: string; 11 + uri: ResourceUri; 12 + cid?: string; 13 + value: T; 14 + } 15 + 16 + export interface PaginatedResult<T> { 17 + items: T[]; 18 + totalCount?: number; 19 + hasNext: boolean; 20 + } 21 + 22 + export interface RecordCreationResult { 23 + uri: ResourceUri; 24 + rkey: string; 25 + } 26 + 27 + export interface BacklinkRecordRef { 28 + did: Did; 29 + collection: Nsid; 30 + rkey: string; 31 + } 32 + 33 + const SORTABLE_BASE32_ALPHABET = '234567abcdefghijklmnopqrstuvwxyz'; 34 + const TID_PATTERN = /^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/; 35 + 36 + export const timestampFromDate = (value?: string): number => { 37 + if (!value) { 38 + return 0; 39 + } 40 + 41 + const timestamp = new Date(value).getTime(); 42 + return Number.isFinite(timestamp) ? timestamp : 0; 43 + }; 44 + 45 + export const timestampFromTid = (rkey?: string): number => { 46 + if (!rkey || !TID_PATTERN.test(rkey)) { 47 + return 0; 48 + } 49 + 50 + let micros = 0; 51 + for (const char of rkey.slice(0, 11)) { 52 + const value = SORTABLE_BASE32_ALPHABET.indexOf(char); 53 + if (value < 0) { 54 + return 0; 55 + } 56 + micros = micros * 32 + value; 57 + } 58 + 59 + return Math.floor(micros / 1000); 60 + }; 61 + 62 + export const maxTimestamp = (...values: number[]): number => 63 + values.reduce((latest, value) => Math.max(latest, value), 0); 64 + 65 + export const parseAtUri = (uri: string): { did: Did; collection: Nsid; rkey: string } => { 66 + if (!uri.startsWith('at://')) { 67 + throw new Error(`Invalid at-uri: ${uri}`); 68 + } 69 + 70 + const parts = uri.slice(5).split('/'); 71 + const [did, collection, ...rkey] = parts; 72 + return { 73 + did: asDid(did), 74 + collection: asNsid(collection), 75 + rkey: rkey.join('/'), 76 + }; 77 + }; 78 + 79 + export const parseDirectRecordRef = (ref: string, collection: Nsid): ResourceUri | null => { 80 + let normalized = ref.trim(); 81 + try { 82 + normalized = decodeURIComponent(normalized); 83 + } catch { 84 + // Keep the original route param when it is not percent-encoded. 85 + } 86 + 87 + if (!normalized.startsWith('at://')) { 88 + return null; 89 + } 90 + 91 + try { 92 + return parseAtUri(normalized).collection === collection ? (normalized as ResourceUri) : null; 93 + } catch { 94 + return null; 95 + } 96 + }; 97 + 98 + export const extractBlobCid = (blob: unknown): string | null => { 99 + if (!blob || typeof blob !== 'object') { 100 + return null; 101 + } 102 + 103 + const value = blob as Record<string, unknown>; 104 + const ref = value.ref; 105 + 106 + if (typeof ref === 'string') { 107 + return ref; 108 + } 5 109 6 - export type { 7 - HydratedRecord, 8 - PaginatedResult, 9 - RecordCreationResult, 10 - } from './core'; 110 + if (ref && typeof ref === 'object') { 111 + const maybeLink = (ref as Record<string, unknown>).$link; 112 + if (typeof maybeLink === 'string') { 113 + return maybeLink; 114 + } 115 + } 116 + 117 + const maybeCid = value.cid; 118 + return typeof maybeCid === 'string' ? maybeCid : null; 119 + }; 120 + 121 + export const findNumberedRecord = <T extends { number: number; rkey: string; uri: string }>( 122 + records: T[], 123 + ref: string, 124 + ): T | undefined => { 125 + const trimmed = ref.trim(); 126 + if (/^\d+$/.test(trimmed)) { 127 + const number = Number(trimmed); 128 + return records.find((record) => record.number === number); 129 + } 130 + 131 + return records.find( 132 + (record) => 133 + record.rkey === trimmed || 134 + record.uri === trimmed || 135 + parseAtUri(record.uri).rkey === trimmed, 136 + ); 137 + }; 138 + 139 + export const optionalString = (value: string): string | undefined => { 140 + const trimmed = value.trim(); 141 + return trimmed.length > 0 ? trimmed : undefined; 142 + }; 143 + 144 + const uniq = <T extends string>(values: T[]): T[] | undefined => { 145 + if (values.length === 0) { 146 + return undefined; 147 + } 148 + 149 + return [...new Set(values)]; 150 + }; 151 + 152 + export const extractDids = (input: string): Did[] | undefined => 153 + uniq( 154 + Array.from( 155 + input.matchAll(/\bdid:[a-z0-9]+:[A-Za-z0-9._:%-]+\b/gi), 156 + (match) => match[0] as Did, 157 + ), 158 + ); 159 + 160 + export const extractAtUris = (input: string): ResourceUri[] | undefined => 161 + uniq( 162 + Array.from( 163 + input.matchAll(/\bat:\/\/[^\s<>()]+/gi), 164 + (match) => match[0] as ResourceUri, 165 + ), 166 + ); 167 + 168 + export const sortByCreatedAt = <T extends { value: { createdAt?: string } }>(left: T, right: T): number => 169 + new Date(left.value.createdAt ?? 0).getTime() - new Date(right.value.createdAt ?? 0).getTime(); 170 + 171 + export const toNumberMap = <T extends { uri: string; rkey: string; value: { createdAt?: string } }>( 172 + items: T[], 173 + ): Map<string, number> => { 174 + const ordered = [...items].sort((left, right) => { 175 + const leftTime = timestampFromDate(left.value.createdAt); 176 + const rightTime = timestampFromDate(right.value.createdAt); 177 + if (leftTime !== rightTime) { 178 + return leftTime - rightTime; 179 + } 180 + 181 + return left.rkey.localeCompare(right.rkey); 182 + }); 183 + 184 + return new Map(ordered.map((item, index) => [item.uri, index + 1])); 185 + }; 186 + 187 + export const getOptionalRecord = async <T>( 188 + actor: ResolvedActor, 189 + collection: Nsid, 190 + rkey: string, 191 + ): Promise<T | null> => { 192 + try { 193 + const record = await ok( 194 + getRpc(actor.pds).get('com.atproto.repo.getRecord', { 195 + params: { 196 + repo: actor.did, 197 + collection, 198 + rkey, 199 + }, 200 + }), 201 + ); 202 + 203 + return record.value as T; 204 + } catch (cause) { 205 + if ( 206 + cause instanceof ClientResponseError && 207 + (cause.status === 400 || cause.status === 404) 208 + ) { 209 + return null; 210 + } 211 + 212 + throw cause; 213 + } 214 + }; 215 + 216 + export const asDid = (value: string): Did => { 217 + if (!value.startsWith('did:')) { 218 + throw new Error(`Invalid DID: ${value}`); 219 + } 220 + 221 + return value as Did; 222 + }; 223 + 224 + export const asNsid = (value: string): Nsid => { 225 + if (value.split('.').length < 3) { 226 + throw new Error(`Invalid NSID: ${value}`); 227 + } 228 + 229 + return value as Nsid; 230 + };
+512 -31
src/lib/api/repos.ts
··· 1 - export { 2 - buildBlobDataUrl, 3 - compareBranches, 4 - decodeBlobText, 5 - getRepo, 6 - getRepoBlob, 7 - getRepoBranches, 8 - getRepoDefaultBranch, 9 - getRepoByDid, 10 - getRepoLanguages, 11 - getRepoLog, 12 - getRepoTags, 13 - getRepoTree, 14 - listRepoRecords, 15 - } from './core'; 1 + import { ok } from '@atcute/client'; 2 + import type { ResolvedActor } from '@atcute/identity-resolver'; 3 + import type { Did, Nsid, ResourceUri } from '@atcute/lexicons/syntax'; 4 + import { ShTangledRepo } from '@atcute/tangled'; 5 + 6 + import { 7 + appviewFallback, 8 + appviewJson, 9 + appviewOptionalKnotRequest, 10 + getRepoViaAppview, 11 + listAllAppviewRecords, 12 + type AppviewRecordView, 13 + } from './appview'; 14 + import { getRpc, normalizeServiceUrl } from './client'; 15 + import { REPO_COLLECTION } from './constants'; 16 + import { resolveActor } from './identity'; 17 + import { 18 + maxTimestamp, 19 + parseAtUri, 20 + timestampFromDate, 21 + } from './records'; 22 + 23 + export interface RepoContext { 24 + owner: ResolvedActor; 25 + record: RepoRecord; 26 + repoDid: Did; 27 + slug: string; 28 + rkey: string; 29 + knot: string; 30 + } 31 + 32 + export interface RepoRecord { 33 + uri: ResourceUri; 34 + cid: string; 35 + rkey: string; 36 + value: ShTangledRepo.Main; 37 + } 38 + 39 + export interface TreeResponse { 40 + ref: string; 41 + parent?: string; 42 + dotdot?: string; 43 + lastCommit?: CommitHeadline; 44 + readme?: { 45 + contents: string; 46 + filename: string; 47 + }; 48 + files: TreeEntry[]; 49 + } 50 + 51 + export interface TreeEntry { 52 + name: string; 53 + mode: string; 54 + size: number; 55 + last_commit?: CommitHeadline; 56 + } 57 + 58 + export interface CommitHeadline { 59 + hash: string; 60 + message: string; 61 + when: string; 62 + author?: { 63 + email: string; 64 + name: string; 65 + when: string; 66 + }; 67 + } 68 + 69 + export interface BranchResponse { 70 + branches: BranchEntry[]; 71 + } 72 + 73 + export interface DefaultBranchResponse { 74 + name?: string; 75 + branch?: string; 76 + reference?: { 77 + name?: string; 78 + }; 79 + hash?: string; 80 + when?: string; 81 + } 82 + 83 + export interface BranchEntry { 84 + reference: { 85 + name: string; 86 + hash: string; 87 + }; 88 + commit?: { 89 + Message: string; 90 + Author?: { 91 + Name: string; 92 + Email: string; 93 + When: string; 94 + }; 95 + Committer?: { 96 + Name: string; 97 + Email: string; 98 + When: string; 99 + }; 100 + }; 101 + is_default?: boolean; 102 + } 103 + 104 + export interface TagResponse { 105 + tags: Array<{ 106 + name: string; 107 + hash: string; 108 + message?: string; 109 + tag?: { 110 + Name: string; 111 + Message: string; 112 + Tagger?: { 113 + Name: string; 114 + Email: string; 115 + When: string; 116 + }; 117 + }; 118 + }>; 119 + } 120 + 121 + export interface LanguageResponse { 122 + ref: string; 123 + totalFiles?: number; 124 + totalSize?: number; 125 + languages: Array<{ 126 + name: string; 127 + percentage: number; 128 + size: number; 129 + color?: string; 130 + }>; 131 + } 132 + 133 + export interface LogResponse { 134 + ref: string; 135 + total: number; 136 + page: number; 137 + per_page: number; 138 + log: boolean; 139 + commits: Array<{ 140 + this: string; 141 + message: string; 142 + author?: { 143 + Name: string; 144 + Email: string; 145 + When: string; 146 + }; 147 + committer?: { 148 + Name: string; 149 + Email: string; 150 + When: string; 151 + }; 152 + pgp_signature?: string; 153 + parent?: string; 154 + parent_hashes?: number[][]; 155 + }>; 156 + } 157 + 158 + export interface BlobResponse { 159 + path: string; 160 + ref: string; 161 + content?: string; 162 + encoding?: 'base64' | 'utf-8'; 163 + fileTooLarge?: boolean; 164 + isBinary?: boolean; 165 + mimeType?: string; 166 + size?: number; 167 + lastCommit?: CommitHeadline; 168 + submodule?: { 169 + name: string; 170 + url: string; 171 + branch?: string; 172 + }; 173 + } 174 + 175 + export interface ComparePatch { 176 + Title: string; 177 + Body: string; 178 + Raw: string; 179 + SHA: string; 180 + Author?: string; 181 + Committer?: string; 182 + AuthorDate?: string; 183 + CommitterDate?: string; 184 + SubjectPrefix?: string; 185 + RawHeaders?: string; 186 + BodyAppendix?: string; 187 + } 188 + 189 + export interface CompareResponse { 190 + rev1: string; 191 + rev2: string; 192 + patch: string; 193 + format_patch: ComparePatch[]; 194 + } 195 + 196 + const fetchAllRepoRecords = async ( 197 + actor: ResolvedActor, 198 + collection: Nsid, 199 + ): Promise<Array<{ uri: ResourceUri; cid: string; value: unknown }>> => { 200 + const rpc = getRpc(actor.pds); 201 + const records: Array<{ uri: ResourceUri; cid: string; value: unknown }> = []; 202 + let cursor: string | undefined; 203 + 204 + do { 205 + const page = await ok( 206 + rpc.get('com.atproto.repo.listRecords', { 207 + params: { 208 + repo: actor.did, 209 + collection, 210 + limit: 100, 211 + cursor, 212 + reverse: true, 213 + }, 214 + }), 215 + ); 216 + 217 + records.push(...page.records); 218 + cursor = page.cursor; 219 + } while (cursor); 220 + 221 + return records; 222 + }; 223 + 224 + const listRepoRecordsFromAppview = async (owner: string | ResolvedActor): Promise<RepoRecord[]> => { 225 + const actor = typeof owner === 'string' ? await resolveActor(owner) : owner; 226 + const records = await listAllAppviewRecords<ShTangledRepo.Main>('sh.tangled.repo.listRepos', actor.did); 227 + 228 + return records.map((record) => ({ 229 + uri: record.uri, 230 + cid: record.cid ?? '', 231 + rkey: parseAtUri(record.uri).rkey, 232 + value: record.value, 233 + })); 234 + }; 235 + 236 + const listRepoRecordsFromPds = async (owner: string | ResolvedActor): Promise<RepoRecord[]> => { 237 + const actor = typeof owner === 'string' ? await resolveActor(owner) : owner; 238 + const records = await fetchAllRepoRecords(actor, REPO_COLLECTION); 239 + 240 + return records.map((record) => ({ 241 + uri: record.uri, 242 + cid: record.cid, 243 + rkey: parseAtUri(record.uri).rkey, 244 + value: record.value as ShTangledRepo.Main, 245 + })); 246 + }; 247 + 248 + export const listRepoRecords = async (owner: string | ResolvedActor): Promise<RepoRecord[]> => 249 + appviewFallback( 250 + () => listRepoRecordsFromAppview(owner), 251 + () => listRepoRecordsFromPds(owner), 252 + ); 253 + 254 + export const getRepo = async (ownerIdentifier: string, repoSlug: string): Promise<RepoContext> => { 255 + const owner = await resolveActor(ownerIdentifier); 256 + const all = await listRepoRecords(owner); 257 + const wanted = repoSlug.trim().toLowerCase(); 258 + const match = all.find((record) => { 259 + const name = record.value.name?.trim().toLowerCase(); 260 + return name === wanted || record.rkey.toLowerCase() === wanted; 261 + }); 262 + 263 + if (!match) { 264 + throw new Error(`Repository not found: ${ownerIdentifier}/${repoSlug}`); 265 + } 266 + 267 + if (!match.value.repoDid) { 268 + throw new Error(`Repository is missing repoDid metadata`); 269 + } 270 + 271 + const slug = match.value.name?.trim() || match.rkey; 272 + 273 + return { 274 + owner, 275 + record: match, 276 + repoDid: match.value.repoDid, 277 + slug, 278 + rkey: match.rkey, 279 + knot: normalizeServiceUrl(match.value.knot), 280 + }; 281 + }; 282 + 283 + export const getRepoByDid = async (repoDid: Did): Promise<RepoContext> => { 284 + const record = await appviewJson<AppviewRecordView<ShTangledRepo.Main>>( 285 + 'sh.tangled.repo.getRepoByRepoDid', 286 + { repoDid }, 287 + ); 288 + const parsed = parseAtUri(record.uri); 289 + const owner = await resolveActor(parsed.did); 290 + const slug = record.value.name?.trim() || parsed.rkey; 291 + 292 + if (!record.value.repoDid) { 293 + throw new Error(`Repository is missing repoDid metadata`); 294 + } 295 + 296 + return { 297 + owner, 298 + record: { 299 + uri: record.uri, 300 + cid: record.cid ?? '', 301 + rkey: parsed.rkey, 302 + value: record.value, 303 + }, 304 + repoDid: record.value.repoDid, 305 + slug, 306 + rkey: parsed.rkey, 307 + knot: normalizeServiceUrl(record.value.knot), 308 + }; 309 + }; 310 + 311 + const getRepoTreeFromKnot = async ( 312 + repo: RepoContext, 313 + ref: string, 314 + path = '', 315 + ): Promise<TreeResponse> => 316 + (await ok( 317 + getRpc(repo.knot).get('sh.tangled.repo.tree', { 318 + params: { 319 + repo: repo.repoDid, 320 + ref, 321 + path, 322 + }, 323 + }), 324 + )) as TreeResponse; 325 + 326 + const getRepoBranchesFromKnot = async (repo: RepoContext): Promise<BranchResponse> => 327 + (await ok( 328 + getRpc(repo.knot).get('sh.tangled.repo.branches', { 329 + params: { 330 + repo: repo.repoDid, 331 + }, 332 + as: 'json', 333 + }), 334 + )) as BranchResponse; 335 + 336 + const branchUpdatedTimestamp = (branch: BranchEntry): number => 337 + maxTimestamp( 338 + timestampFromDate(branch.commit?.Committer?.When), 339 + timestampFromDate(branch.commit?.Author?.When), 340 + ); 341 + 342 + const sortBranchResponseByUpdatedAt = (response: BranchResponse): BranchResponse => ({ 343 + ...response, 344 + branches: [...response.branches].sort((left, right) => { 345 + const updatedDiff = branchUpdatedTimestamp(right) - branchUpdatedTimestamp(left); 346 + if (updatedDiff !== 0) { 347 + return updatedDiff; 348 + } 349 + 350 + if (left.is_default !== right.is_default) { 351 + return left.is_default ? -1 : 1; 352 + } 353 + 354 + return left.reference.name.localeCompare(right.reference.name); 355 + }), 356 + }); 357 + 358 + const getRepoDefaultBranchFromKnot = async (repo: RepoContext): Promise<DefaultBranchResponse> => 359 + (await ok( 360 + getRpc(repo.knot).get('sh.tangled.repo.getDefaultBranch', { 361 + params: { 362 + repo: repo.repoDid, 363 + }, 364 + }), 365 + )) as DefaultBranchResponse; 366 + 367 + const getRepoTagsFromKnot = async (repo: RepoContext): Promise<TagResponse> => 368 + (await ok( 369 + getRpc(repo.knot).get('sh.tangled.repo.tags', { 370 + params: { 371 + repo: repo.repoDid, 372 + }, 373 + as: 'json', 374 + }), 375 + )) as TagResponse; 376 + 377 + const getRepoLanguagesFromKnot = async (repo: RepoContext, ref: string): Promise<LanguageResponse> => 378 + (await ok( 379 + getRpc(repo.knot).get('sh.tangled.repo.languages', { 380 + params: { 381 + repo: repo.repoDid, 382 + ref, 383 + }, 384 + }), 385 + )) as LanguageResponse; 386 + 387 + const getRepoLogFromKnot = async (repo: RepoContext, ref: string): Promise<LogResponse> => 388 + (await ok( 389 + getRpc(repo.knot).get('sh.tangled.repo.log', { 390 + params: { 391 + repo: repo.repoDid, 392 + ref, 393 + limit: 8, 394 + }, 395 + as: 'json', 396 + }), 397 + )) as LogResponse; 398 + 399 + const getRepoBlobFromKnot = async ( 400 + repo: RepoContext, 401 + ref: string, 402 + path: string, 403 + ): Promise<BlobResponse> => 404 + (await ok( 405 + getRpc(repo.knot).get('sh.tangled.repo.blob', { 406 + params: { 407 + repo: repo.repoDid, 408 + ref, 409 + path, 410 + }, 411 + }), 412 + )) as BlobResponse; 413 + 414 + const compareBranchesFromKnot = async ( 415 + repo: RepoContext, 416 + rev1: string, 417 + rev2: string, 418 + ): Promise<CompareResponse> => 419 + (await ok( 420 + getRpc(repo.knot).get('sh.tangled.repo.compare', { 421 + params: { 422 + repo: repo.repoDid, 423 + rev1, 424 + rev2, 425 + }, 426 + as: 'json', 427 + }), 428 + )) as CompareResponse; 429 + 430 + export const getRepoTree = async ( 431 + repo: RepoContext, 432 + ref: string, 433 + path = '', 434 + ): Promise<TreeResponse> => 435 + appviewOptionalKnotRequest( 436 + () => getRepoViaAppview<TreeResponse>('sh.tangled.repo.tree', repo, { ref, path }), 437 + () => getRepoTreeFromKnot(repo, ref, path), 438 + ); 439 + 440 + export const getRepoBranches = async (repo: RepoContext): Promise<BranchResponse> => 441 + appviewOptionalKnotRequest( 442 + () => getRepoViaAppview<BranchResponse>('sh.tangled.repo.branches', repo), 443 + () => getRepoBranchesFromKnot(repo), 444 + ).then(sortBranchResponseByUpdatedAt); 445 + 446 + export const getRepoDefaultBranch = async (repo: RepoContext): Promise<DefaultBranchResponse> => 447 + appviewOptionalKnotRequest( 448 + () => getRepoViaAppview<DefaultBranchResponse>('sh.tangled.repo.getDefaultBranch', repo), 449 + () => getRepoDefaultBranchFromKnot(repo), 450 + ); 451 + 452 + export const getRepoTags = async (repo: RepoContext): Promise<TagResponse> => 453 + appviewOptionalKnotRequest( 454 + () => getRepoViaAppview<TagResponse>('sh.tangled.repo.tags', repo), 455 + () => getRepoTagsFromKnot(repo), 456 + ); 457 + 458 + export const getRepoLanguages = async (repo: RepoContext, ref: string): Promise<LanguageResponse> => 459 + appviewOptionalKnotRequest( 460 + () => getRepoViaAppview<LanguageResponse>('sh.tangled.repo.languages', repo, { ref }), 461 + () => getRepoLanguagesFromKnot(repo, ref), 462 + ); 463 + 464 + export const getRepoLog = async (repo: RepoContext, ref: string): Promise<LogResponse> => 465 + appviewOptionalKnotRequest( 466 + () => getRepoViaAppview<LogResponse>('sh.tangled.repo.log', repo, { ref, limit: 8 }), 467 + () => getRepoLogFromKnot(repo, ref), 468 + ); 469 + 470 + export const getRepoBlob = async ( 471 + repo: RepoContext, 472 + ref: string, 473 + path: string, 474 + ): Promise<BlobResponse> => 475 + appviewOptionalKnotRequest( 476 + () => getRepoViaAppview<BlobResponse>('sh.tangled.repo.blob', repo, { ref, path }), 477 + () => getRepoBlobFromKnot(repo, ref, path), 478 + ); 479 + 480 + export const compareBranches = async ( 481 + repo: RepoContext, 482 + rev1: string, 483 + rev2: string, 484 + ): Promise<CompareResponse> => 485 + appviewOptionalKnotRequest( 486 + () => getRepoViaAppview<CompareResponse>('sh.tangled.repo.compare', repo, { rev1, rev2 }), 487 + () => compareBranchesFromKnot(repo, rev1, rev2), 488 + ); 489 + 490 + export const decodeBlobText = (blob: BlobResponse): string => { 491 + if (!blob.content) { 492 + return ''; 493 + } 494 + 495 + if (blob.encoding === 'base64') { 496 + const buffer = Uint8Array.from(atob(blob.content), (char) => char.charCodeAt(0)); 497 + return new TextDecoder().decode(buffer); 498 + } 499 + 500 + return blob.content; 501 + }; 502 + 503 + export const buildBlobDataUrl = (blob: BlobResponse): string | null => { 504 + if (!blob.content || !blob.mimeType) { 505 + return null; 506 + } 507 + 508 + if (blob.encoding === 'base64') { 509 + return `data:${blob.mimeType};base64,${blob.content}`; 510 + } 16 511 17 - export type { 18 - BlobResponse, 19 - BranchEntry, 20 - BranchResponse, 21 - CommitHeadline, 22 - ComparePatch, 23 - CompareResponse, 24 - DefaultBranchResponse, 25 - LanguageResponse, 26 - LogResponse, 27 - RepoContext, 28 - RepoRecord, 29 - TagResponse, 30 - TreeEntry, 31 - TreeResponse, 32 - } from './core'; 512 + return `data:${blob.mimeType};charset=utf-8,${encodeURIComponent(blob.content)}`; 513 + };
+91 -10
src/lib/api/search.ts
··· 1 - export { 2 - searchRecords, 3 - } from './core'; 1 + import type { Did } from '@atcute/lexicons/syntax'; 2 + import { 3 + ShTangledActorProfile, 4 + ShTangledLabelDefinition, 5 + ShTangledRepo, 6 + ShTangledRepoIssue, 7 + ShTangledRepoIssueComment, 8 + ShTangledRepoPull, 9 + ShTangledRepoPullComment, 10 + ShTangledString, 11 + } from '@atcute/tangled'; 12 + 13 + import { 14 + appviewJson, 15 + type AppviewSearchResponse, 16 + } from './appview'; 17 + import { appviewRecordToHydrated } from './hydration'; 18 + import type { HydratedRecord } from './records'; 19 + 20 + export type SearchCollection = 21 + | 'sh.tangled.actor.profile' 22 + | 'sh.tangled.repo' 23 + | 'sh.tangled.repo.issue' 24 + | 'sh.tangled.repo.issue.comment' 25 + | 'sh.tangled.repo.pull' 26 + | 'sh.tangled.repo.pull.comment' 27 + | 'sh.tangled.string' 28 + | 'sh.tangled.label.definition'; 29 + 30 + export type SearchRecordValue = 31 + | ShTangledActorProfile.Main 32 + | ShTangledRepo.Main 33 + | ShTangledRepoIssue.Main 34 + | ShTangledRepoIssueComment.Main 35 + | ShTangledRepoPull.Main 36 + | ShTangledRepoPullComment.Main 37 + | ShTangledString.Main 38 + | ShTangledLabelDefinition.Main; 39 + 40 + export interface SearchHit extends HydratedRecord<SearchRecordValue> { 41 + nsid: SearchCollection; 42 + score: number; 43 + } 44 + 45 + export interface SearchResponse { 46 + hits: SearchHit[]; 47 + cursor?: string; 48 + } 49 + 50 + export interface SearchRecordsOptions { 51 + q: string; 52 + nsid?: SearchCollection; 53 + author?: Did; 54 + repo?: Did; 55 + since?: string; 56 + until?: string; 57 + cursor?: string; 58 + limit?: number; 59 + } 60 + 61 + export const searchRecords = async (options: SearchRecordsOptions): Promise<SearchResponse> => { 62 + const q = options.q.trim(); 63 + if (!q) { 64 + return { hits: [] }; 65 + } 4 66 5 - export type { 6 - SearchCollection, 7 - SearchHit, 8 - SearchRecordValue, 9 - SearchRecordsOptions, 10 - SearchResponse, 11 - } from './core'; 67 + const response = await appviewJson<AppviewSearchResponse<SearchRecordValue>>( 68 + 'sh.tangled.search.query', 69 + { 70 + q, 71 + nsid: options.nsid, 72 + author: options.author, 73 + repo: options.repo, 74 + since: options.since, 75 + until: options.until, 76 + cursor: options.cursor, 77 + limit: options.limit, 78 + }, 79 + ); 80 + const hits = await Promise.all( 81 + response.hits.map(async (hit) => ({ 82 + ...(await appviewRecordToHydrated(hit)), 83 + nsid: hit.nsid as SearchCollection, 84 + score: hit.score, 85 + })), 86 + ); 87 + 88 + return { 89 + hits, 90 + cursor: response.cursor ?? undefined, 91 + }; 92 + };
+141 -9
src/lib/api/stars.ts
··· 1 - export { 2 - createRepoStar, 3 - deleteRepoStar, 4 - getRepoStarCount, 5 - getRepoStarSummary, 6 - } from './core'; 1 + import { ok } from '@atcute/client'; 2 + import type { Did } from '@atcute/lexicons/syntax'; 3 + import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 4 + import { ShTangledFeedStar } from '@atcute/tangled'; 5 + 6 + import { 7 + appviewFallback, 8 + getDistinctAppviewRecordCount, 9 + listAllAppviewRecords, 10 + type AppviewRecordView, 11 + } from './appview'; 12 + import { 13 + findBacklinkRefByDid, 14 + getBacklinkDids, 15 + } from './backlinks'; 16 + import { STAR_COLLECTION } from './constants'; 17 + import { createAuthRpc } from './identity'; 18 + import { 19 + parseAtUri, 20 + type RecordCreationResult, 21 + } from './records'; 22 + import type { RepoContext } from './repos'; 23 + 24 + export interface RepoStarSummary { 25 + count: number; 26 + isStarred: boolean; 27 + currentUserStarRkey?: string; 28 + } 29 + 30 + const STAR_BACKLINK_SOURCES = [ 31 + 'sh.tangled.feed.star:subject.did', 32 + 'sh.tangled.feed.star:subjectDid', 33 + ]; 34 + 35 + const getRepoStarSummaryFromBacklinks = async (repo: RepoContext, viewerDid?: Did | null): Promise<RepoStarSummary> => { 36 + const starrers = new Set<Did>(); 37 + const backlinkDidGroups = await Promise.all(STAR_BACKLINK_SOURCES.map((source) => getBacklinkDids(repo.repoDid, source))); 38 + 39 + for (const did of backlinkDidGroups.flat()) { 40 + starrers.add(did); 41 + } 42 + 43 + const currentUserStar = 44 + viewerDid && starrers.has(viewerDid) 45 + ? await findBacklinkRefByDid(repo.repoDid, STAR_BACKLINK_SOURCES, viewerDid) 46 + : undefined; 47 + 48 + return { 49 + count: starrers.size, 50 + isStarred: !!currentUserStar, 51 + currentUserStarRkey: currentUserStar?.rkey, 52 + }; 53 + }; 54 + 55 + const getRepoStarCountFromBacklinks = async (repo: RepoContext): Promise<number> => { 56 + const summary = await getRepoStarSummaryFromBacklinks(repo); 57 + return summary.count; 58 + }; 7 59 8 - export type { 9 - RepoStarSummary, 10 - } from './core'; 60 + const appviewStarSubjectDid = (star: ShTangledFeedStar.Main): Did | null => { 61 + const subject = star.subject as { did?: unknown; subjectDid?: unknown }; 62 + const did = subject.did ?? subject.subjectDid; 63 + return typeof did === 'string' && did.startsWith('did:') ? (did as Did) : null; 64 + }; 65 + 66 + const getRepoStarCountFromAppview = async (repo: RepoContext): Promise<number> => 67 + getDistinctAppviewRecordCount('sh.tangled.feed.countStars', repo.repoDid); 68 + 69 + const findCurrentUserStarFromAppview = async ( 70 + repo: RepoContext, 71 + viewerDid: Did, 72 + ): Promise<AppviewRecordView<ShTangledFeedStar.Main> | undefined> => { 73 + const stars = await listAllAppviewRecords<ShTangledFeedStar.Main>('sh.tangled.feed.listStarsBy', viewerDid); 74 + return stars.find((star) => appviewStarSubjectDid(star.value) === repo.repoDid); 75 + }; 76 + 77 + const getRepoStarSummaryFromAppview = async ( 78 + repo: RepoContext, 79 + viewerDid?: Did | null, 80 + ): Promise<RepoStarSummary> => { 81 + const [count, currentUserStar] = await Promise.all([ 82 + getRepoStarCountFromAppview(repo), 83 + viewerDid ? findCurrentUserStarFromAppview(repo, viewerDid) : undefined, 84 + ]); 85 + 86 + return { 87 + count, 88 + isStarred: !!currentUserStar, 89 + currentUserStarRkey: currentUserStar ? parseAtUri(currentUserStar.uri).rkey : undefined, 90 + }; 91 + }; 92 + 93 + export const getRepoStarSummary = async (repo: RepoContext, viewerDid?: Did | null): Promise<RepoStarSummary> => 94 + appviewFallback( 95 + () => getRepoStarSummaryFromAppview(repo, viewerDid), 96 + () => getRepoStarSummaryFromBacklinks(repo, viewerDid), 97 + ); 98 + 99 + export const getRepoStarCount = async (repo: RepoContext): Promise<number> => 100 + appviewFallback( 101 + () => getRepoStarCountFromAppview(repo), 102 + () => getRepoStarCountFromBacklinks(repo), 103 + ); 104 + 105 + export const createRepoStar = async (agent: OAuthUserAgent, repo: RepoContext): Promise<RecordCreationResult> => { 106 + const rpc = createAuthRpc(agent); 107 + const record: ShTangledFeedStar.Main = { 108 + $type: 'sh.tangled.feed.star', 109 + subject: { 110 + $type: 'sh.tangled.feed.star#repo', 111 + did: repo.repoDid, 112 + }, 113 + createdAt: new Date().toISOString(), 114 + }; 115 + const result = await ok( 116 + rpc.post('com.atproto.repo.createRecord', { 117 + input: { 118 + repo: agent.sub, 119 + collection: STAR_COLLECTION, 120 + record, 121 + }, 122 + }), 123 + ); 124 + 125 + return { 126 + uri: result.uri, 127 + rkey: parseAtUri(result.uri).rkey, 128 + }; 129 + }; 130 + 131 + export const deleteRepoStar = async (agent: OAuthUserAgent, rkey: string): Promise<void> => { 132 + const rpc = createAuthRpc(agent); 133 + await ok( 134 + rpc.post('com.atproto.repo.deleteRecord', { 135 + input: { 136 + repo: agent.sub, 137 + collection: STAR_COLLECTION, 138 + rkey, 139 + }, 140 + }), 141 + ); 142 + };
+357
src/lib/api/stateful.ts
··· 1 + import type { Did, ResourceUri } from '@atcute/lexicons/syntax'; 2 + 3 + import { 4 + appviewJson, 5 + listAllAppviewRecords, 6 + type AppviewParamValue, 7 + type AppviewSearchResponse, 8 + type AppviewStatefulListResponse, 9 + type AppviewStatefulRecordView, 10 + } from './appview'; 11 + import { 12 + getNearestAppviewCursorCheckpoint, 13 + rememberAppviewCursorCheckpoint, 14 + } from './appview-cache'; 15 + import { appviewRecordToHydrated } from './hydration'; 16 + import { 17 + type HydratedRecord, 18 + type PaginatedResult, 19 + maxTimestamp, 20 + timestampFromDate, 21 + timestampFromTid, 22 + toNumberMap, 23 + } from './records'; 24 + import { getTangledAppviewService } from '../settings'; 25 + import { normalizeServiceUrl } from './client'; 26 + 27 + export type StatefulHydratedRecord<T, State extends string> = HydratedRecord<T> & { 28 + number: number; 29 + state: State; 30 + }; 31 + 32 + export type StatefulPageOptions<State extends string> = { 33 + offset: number; 34 + limit: number; 35 + state: State; 36 + author?: Did; 37 + query?: string; 38 + }; 39 + 40 + const APPVIEW_PAGE_BATCH_LIMIT = 100; 41 + const SEARCH_PAGE_BATCH_LIMIT = 100; 42 + 43 + export const getAppviewRecordSortTimestamp = <T extends { createdAt?: string }>( 44 + record: HydratedRecord<T>, 45 + ): number => 46 + maxTimestamp( 47 + timestampFromDate(record.value.createdAt), 48 + timestampFromTid(record.rkey), 49 + ); 50 + 51 + export const sortStatefulRecordsByNewestRecord = <T extends { createdAt?: string }, State extends string>( 52 + items: Array<StatefulHydratedRecord<T, State>>, 53 + ): Array<StatefulHydratedRecord<T, State>> => 54 + [...items].sort((left, right) => { 55 + const timestampDiff = getAppviewRecordSortTimestamp(right) - getAppviewRecordSortTimestamp(left); 56 + if (timestampDiff !== 0) { 57 + return timestampDiff; 58 + } 59 + 60 + if (left.number !== right.number) { 61 + return right.number - left.number; 62 + } 63 + 64 + return right.rkey.localeCompare(left.rkey); 65 + }); 66 + 67 + export const sortStatefulRecordsByUpdatedAt = async <T, State extends string>( 68 + items: Array<StatefulHydratedRecord<T, State>>, 69 + getUpdatedAt: (item: StatefulHydratedRecord<T, State>) => Promise<number>, 70 + ): Promise<Array<StatefulHydratedRecord<T, State>>> => { 71 + const decorated = await Promise.all( 72 + items.map(async (item) => ({ 73 + item, 74 + updatedAt: await getUpdatedAt(item), 75 + })), 76 + ); 77 + 78 + decorated.sort((left, right) => { 79 + const updatedDiff = right.updatedAt - left.updatedAt; 80 + if (updatedDiff !== 0) { 81 + return updatedDiff; 82 + } 83 + 84 + if (left.item.number !== right.item.number) { 85 + return right.item.number - left.item.number; 86 + } 87 + 88 + return right.item.rkey.localeCompare(left.item.rkey); 89 + }); 90 + 91 + return decorated.map(({ item }) => item); 92 + }; 93 + 94 + export const statefulRecordMatchesQuery = (value: unknown, query?: string): boolean => { 95 + const normalized = query?.trim().toLowerCase(); 96 + if (!normalized) { 97 + return true; 98 + } 99 + 100 + const record = value as { title?: unknown; body?: unknown }; 101 + return [record.title, record.body] 102 + .filter((part): part is string => typeof part === 'string') 103 + .some((part) => part.toLowerCase().includes(normalized)); 104 + }; 105 + 106 + export const paginateStatefulRecords = <T, State extends string>( 107 + items: Array<StatefulHydratedRecord<T, State>>, 108 + options: StatefulPageOptions<State>, 109 + ): PaginatedResult<StatefulHydratedRecord<T, State>> => { 110 + const offset = Math.max(options.offset, 0); 111 + const limit = Math.max(options.limit, 1); 112 + const matching = items.filter((item) => 113 + item.state === options.state && 114 + (!options.author || item.author.did === options.author) && 115 + statefulRecordMatchesQuery(item.value, options.query), 116 + ); 117 + 118 + return { 119 + items: matching.slice(offset, offset + limit), 120 + totalCount: matching.length, 121 + hasNext: offset + limit < matching.length, 122 + }; 123 + }; 124 + 125 + const appviewCursorCacheKey = ( 126 + nsid: string, 127 + subject: string, 128 + state: string, 129 + extraParams: Record<string, AppviewParamValue> = {}, 130 + ): string => { 131 + const extras = Object.entries(extraParams) 132 + .filter(([, value]) => value !== undefined && value !== null) 133 + .sort(([left], [right]) => left.localeCompare(right)) 134 + .map(([key, value]) => `${key}=${String(value)}`) 135 + .join('&'); 136 + 137 + return [ 138 + normalizeServiceUrl(getTangledAppviewService()), 139 + nsid, 140 + subject, 141 + state, 142 + extras, 143 + ].join('|'); 144 + }; 145 + 146 + export const listAppviewStatefulRecordsPage = async <T extends { createdAt?: string }, State extends string>( 147 + nsid: string, 148 + subject: string, 149 + options: StatefulPageOptions<State>, 150 + normalizeState: (value?: string) => State, 151 + extraParams: Record<string, AppviewParamValue> = {}, 152 + getUpdatedAt?: ( 153 + record: StatefulHydratedRecord<T, State>, 154 + appviewRecord: AppviewStatefulRecordView<T>, 155 + ) => Promise<number>, 156 + ): Promise<PaginatedResult<StatefulHydratedRecord<T, State>>> => { 157 + if (getUpdatedAt) { 158 + const records = await listAllAppviewRecords<T>(nsid, subject, extraParams) as Array<AppviewStatefulRecordView<T>>; 159 + const hydrated = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); 160 + const numbers = toNumberMap(hydrated); 161 + const decorated = await Promise.all( 162 + hydrated.map(async (record, index) => { 163 + const item: StatefulHydratedRecord<T, State> = { 164 + ...record, 165 + number: numbers.get(record.uri) ?? index + 1, 166 + state: normalizeState(records[index].state), 167 + }; 168 + 169 + return { 170 + item, 171 + updatedAt: await getUpdatedAt(item, records[index]), 172 + }; 173 + }), 174 + ); 175 + 176 + decorated.sort((left, right) => { 177 + const updatedDiff = right.updatedAt - left.updatedAt; 178 + if (updatedDiff !== 0) { 179 + return updatedDiff; 180 + } 181 + 182 + if (left.item.number !== right.item.number) { 183 + return right.item.number - left.item.number; 184 + } 185 + 186 + return right.item.rkey.localeCompare(left.item.rkey); 187 + }); 188 + 189 + return paginateStatefulRecords( 190 + decorated.map(({ item }) => item), 191 + options, 192 + ); 193 + } 194 + 195 + // Bobbin exposes cursor pagination, while the UI still uses offsets. Cache 196 + // page-boundary cursors so sequential pages can resume from the prior page. 197 + const offset = Math.max(options.offset, 0); 198 + const limit = Math.max(options.limit, 1); 199 + const requestedEnd = offset + limit; 200 + const cacheKey = appviewCursorCacheKey(nsid, subject, options.state, extraParams); 201 + const start = getNearestAppviewCursorCheckpoint(cacheKey, offset); 202 + const pendingItems: Array<{ 203 + record: AppviewStatefulRecordView<T>; 204 + number: number; 205 + state: State; 206 + }> = []; 207 + 208 + if (start.exhausted) { 209 + return { 210 + items: [], 211 + totalCount: start.matchingSeen, 212 + hasNext: false, 213 + }; 214 + } 215 + 216 + let cursor = start.cursor; 217 + let matchingSeen = start.matchingSeen; 218 + let scannedSeen = start.scannedSeen; 219 + 220 + while (true) { 221 + const pageLimit = Math.min( 222 + APPVIEW_PAGE_BATCH_LIMIT, 223 + Math.max(matchingSeen < requestedEnd ? requestedEnd - matchingSeen : 1, 1), 224 + ); 225 + const page = await appviewJson<AppviewStatefulListResponse<T>>(nsid, { 226 + subject, 227 + limit: pageLimit, 228 + cursor, 229 + ...extraParams, 230 + }); 231 + 232 + for (const record of page.items) { 233 + const state = normalizeState(record.state); 234 + const number = scannedSeen + 1; 235 + scannedSeen += 1; 236 + 237 + if (state !== options.state) { 238 + continue; 239 + } 240 + 241 + if (!statefulRecordMatchesQuery(record.value, options.query)) { 242 + continue; 243 + } 244 + 245 + if (matchingSeen >= offset && matchingSeen < requestedEnd) { 246 + pendingItems.push({ record, number, state }); 247 + } 248 + 249 + matchingSeen += 1; 250 + } 251 + 252 + cursor = page.cursor ?? undefined; 253 + const exhausted = !cursor; 254 + rememberAppviewCursorCheckpoint(cacheKey, { 255 + matchingSeen, 256 + scannedSeen, 257 + cursor, 258 + exhausted, 259 + }); 260 + 261 + if (matchingSeen > requestedEnd) { 262 + const visibleItems = pendingItems.slice(0, limit); 263 + const hydrated = await Promise.all( 264 + visibleItems.map(({ record }) => appviewRecordToHydrated(record)), 265 + ); 266 + 267 + return { 268 + items: hydrated.map((item, index) => ({ 269 + ...item, 270 + number: visibleItems[index].number, 271 + state: visibleItems[index].state, 272 + })), 273 + hasNext: true, 274 + }; 275 + } 276 + 277 + if (exhausted || page.items.length === 0) { 278 + const hydrated = await Promise.all( 279 + pendingItems.map(({ record }) => appviewRecordToHydrated(record)), 280 + ); 281 + 282 + return { 283 + items: hydrated.map((item, index) => ({ 284 + ...item, 285 + number: pendingItems[index].number, 286 + state: pendingItems[index].state, 287 + })), 288 + totalCount: matchingSeen, 289 + hasNext: false, 290 + }; 291 + } 292 + } 293 + }; 294 + 295 + export const listSearchedStatefulRecordsPage = async <T, State extends string>( 296 + nsid: string, 297 + repo: { repoDid: Did }, 298 + options: StatefulPageOptions<State>, 299 + getState: (uri: ResourceUri) => Promise<State>, 300 + ): Promise<PaginatedResult<StatefulHydratedRecord<T, State>>> => { 301 + const query = options.query?.trim(); 302 + if (!query) { 303 + return { items: [], totalCount: 0, hasNext: false }; 304 + } 305 + 306 + const offset = Math.max(options.offset, 0); 307 + const limit = Math.max(options.limit, 1); 308 + const requestedEnd = offset + limit; 309 + const items: Array<StatefulHydratedRecord<T, State>> = []; 310 + let cursor: string | undefined; 311 + let matchingSeen = 0; 312 + 313 + while (true) { 314 + const page = await appviewJson<AppviewSearchResponse<T>>('sh.tangled.search.query', { 315 + q: query, 316 + nsid, 317 + author: options.author, 318 + repo: repo.repoDid, 319 + cursor, 320 + limit: SEARCH_PAGE_BATCH_LIMIT, 321 + }); 322 + const hydrated = await Promise.all(page.hits.map((hit) => appviewRecordToHydrated(hit))); 323 + const states = await Promise.all(hydrated.map((record) => getState(record.uri))); 324 + 325 + for (const [index, record] of hydrated.entries()) { 326 + const state = states[index]; 327 + if (state !== options.state) { 328 + continue; 329 + } 330 + 331 + if (matchingSeen >= offset && matchingSeen < requestedEnd) { 332 + items.push({ 333 + ...record, 334 + number: 0, 335 + state, 336 + }); 337 + } 338 + 339 + matchingSeen += 1; 340 + if (matchingSeen > requestedEnd) { 341 + return { 342 + items: items.slice(0, limit), 343 + hasNext: true, 344 + }; 345 + } 346 + } 347 + 348 + cursor = page.cursor ?? undefined; 349 + if (!cursor || page.hits.length === 0) { 350 + return { 351 + items, 352 + totalCount: matchingSeen, 353 + hasNext: false, 354 + }; 355 + } 356 + } 357 + };
+1 -1
src/pages/repo/code.tsx
··· 3 3 import { A, useNavigate, useParams } from '@solidjs/router'; 4 4 import { createQuery, keepPreviousData, useQueryClient } from '@tanstack/solid-query'; 5 5 import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component } from 'solid-js'; 6 - import { buildBlobDataUrl, decodeBlobText, getRepoBlob, getRepoBranches, getRepoDefaultBranch, getRepoLanguages, getRepoLog, getRepoTags, getRepoTree } from '../../lib/api'; 6 + import { buildBlobDataUrl, decodeBlobText, getRepoBlob, getRepoBranches, getRepoDefaultBranch, getRepoLanguages, getRepoLog, getRepoTags, getRepoTree } from '../../lib/api/repos'; 7 7 import { Avatar, ErrorState, PlaceholderAvatar, buttonStyles, cardStyles } from '../../components/common'; 8 8 import { CodeView, FileRow, MarkdownBlock, OverviewFileRow, ReadmeCard, RepoTreeSkeleton } from '../../components/repo'; 9 9 import { RepoFrame, useRepoQuery } from './shared';
+1 -1
src/pages/repo/issues-helpers.ts
··· 1 - import type { IssueComment } from '../../lib/api'; 1 + import type { IssueComment } from '../../lib/api/issues'; 2 2 import type { SearchParamValue } from '../../lib/repo-utils'; 3 3 4 4 export type IssueStateFilter = 'open' | 'closed';
+4 -1
src/pages/repo/issues.tsx
··· 4 4 import { createQuery, useQueryClient } from '@tanstack/solid-query'; 5 5 import type { ResourceUri } from '@atcute/lexicons/syntax'; 6 6 import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component } from 'solid-js'; 7 - import { createIssue, createIssueComment, getIssue, listIssuesPage, parseAtUri, resolveActor, setIssueState, type RepoContext } from '../../lib/api'; 7 + import { createIssue, createIssueComment, getIssue, listIssuesPage, setIssueState } from '../../lib/api/issues'; 8 + import { resolveActor } from '../../lib/api/identity'; 9 + import { parseAtUri } from '../../lib/api/records'; 10 + import type { RepoContext } from '../../lib/api/repos'; 8 11 import { useAuth } from '../../lib/auth'; 9 12 import { Avatar, ErrorState, PaginationControls, StateBadge, ToggleButton, buttonStyles, cardStyles, inputStyles, textareaStyles } from '../../components/common'; 10 13 import { AtUriPanel, MarkdownBlock, RepoListSkeleton, RepoThreadSkeleton } from '../../components/repo';
+4 -1
src/pages/repo/pulls.tsx
··· 17 17 import { A, useNavigate, useParams, useSearchParams } from '@solidjs/router'; 18 18 import { createQuery, useQueryClient } from '@tanstack/solid-query'; 19 19 import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component, type JSX } from 'solid-js'; 20 - import { compareBranches, createPull, createPullComment, fetchPullRoundPatch, getPull, getRepoBranches, listPullsPage, parseAtUri, resolveActor, setPullStatus, type RepoContext } from '../../lib/api'; 20 + import { resolveActor } from '../../lib/api/identity'; 21 + import { createPull, createPullComment, fetchPullRoundPatch, getPull, listPullsPage, setPullStatus } from '../../lib/api/pulls'; 22 + import { parseAtUri } from '../../lib/api/records'; 23 + import { compareBranches, getRepoBranches, type RepoContext } from '../../lib/api/repos'; 21 24 import { useAuth } from '../../lib/auth'; 22 25 import { Avatar, ErrorState, LoadingState, PaginationControls, StateBadge, ToggleButton, buttonStyles, cardStyles, textareaStyles } from '../../components/common'; 23 26 import { AtUriPanel, BranchPill, CommentCard, DiffView, MarkdownBlock, PullDiffSkeleton, PullDiffView, RepoListSkeleton, RepoThreadSkeleton } from '../../components/repo';
+4 -1
src/pages/repo/shared.tsx
··· 2 2 import { A, type RouteSectionProps, useParams } from '@solidjs/router'; 3 3 import { createQuery } from '@tanstack/solid-query'; 4 4 import { For, Match, Show, Switch, createContext, createEffect, createMemo, createSignal, onCleanup, useContext, type Component, type JSX } from 'solid-js'; 5 - import { createRepoStar, deleteRepoStar, getRepo, getRepoIssueCount, getRepoPullCount, getRepoStarSummary } from '../../lib/api'; 5 + import { getRepoIssueCount } from '../../lib/api/issues'; 6 + import { getRepoPullCount } from '../../lib/api/pulls'; 7 + import { getRepo } from '../../lib/api/repos'; 8 + import { createRepoStar, deleteRepoStar, getRepoStarSummary } from '../../lib/api/stars'; 6 9 import { useAuth } from '../../lib/auth'; 7 10 import { useLiveEvents } from '../../lib/live-events'; 8 11 import { Avatar, ErrorState, cardStyles } from '../../components/common';
+4 -4
src/routes.tsx
··· 13 13 RepoCodePage, 14 14 } from './pages/repo'; 15 15 import { RepoLayout } from './pages/repo/shared'; 16 - import { HomePage } from './views/home'; 17 - import { NotFoundPage } from './views/not-found'; 18 - import { OAuthCallbackPage } from './views/oauth-callback'; 19 - import { SearchPage } from './views/search'; 16 + import { HomePage } from './pages/home'; 17 + import { NotFoundPage } from './pages/not-found'; 18 + import { OAuthCallbackPage } from './pages/oauth-callback'; 19 + import { SearchPage } from './pages/search'; 20 20 21 21 export const AppRoutes: Component = () => ( 22 22 <Router root={RootShell}>
+2 -1
src/views/home.tsx src/pages/home.tsx
··· 4 4 import { For, Show, createMemo, createSignal, type Component } from 'solid-js'; 5 5 6 6 import { LoadingState, buttonStyles, cardStyles, inputStyles } from '../components/common'; 7 - import { listRepoRecords, resolveActor } from '../lib/api'; 7 + import { resolveActor } from '../lib/api/identity'; 8 + import { listRepoRecords } from '../lib/api/repos'; 8 9 import { useAuth } from '../lib/auth'; 9 10 import { formatRelativeTime, loadRecentRepos, parseRepoJump } from '../lib/repo-utils'; 10 11
src/views/not-found.tsx src/pages/not-found.tsx
src/views/oauth-callback.tsx src/pages/oauth-callback.tsx
+4 -4
src/views/search.tsx src/pages/search.tsx
··· 17 17 import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component, type JSX } from 'solid-js'; 18 18 19 19 import { ErrorState, LoadingState, PaginationControls } from '../components/common'; 20 + import { getIssueRecord } from '../lib/api/issues'; 21 + import { getPullRecord } from '../lib/api/pulls'; 22 + import { getRepoByDid } from '../lib/api/repos'; 20 23 import { 21 - getIssueRecord, 22 - getPullRecord, 23 - getRepoByDid, 24 24 searchRecords, 25 25 type SearchCollection, 26 26 type SearchHit, 27 - } from '../lib/api'; 27 + } from '../lib/api/search'; 28 28 import { formatRelativeTime, getErrorMessage, issueHref, pullHref } from '../lib/repo-utils'; 29 29 30 30 type SearchKind =