···11# Twisted App
2233-Ionic Vue client for the Twisted monorepo.
33+Ionic Vue client for Twisted — a Tangled browser and search app for Android & iOS.
44+55+## Requirements
66+77+- Node.js 20+
88+- pnpm
99+- The Twister API running locally (see `packages/api/README.md`)
1010+1111+## Running locally
1212+1313+```sh
1414+# From the repo root, install dependencies
1515+pnpm install
1616+1717+# Copy env file and point it at your local Twister API
1818+cp apps/twisted/.env.example apps/twisted/.env
1919+2020+# Start the Vite dev server
2121+cd apps/twisted
2222+pnpm dev
2323+```
2424+2525+The dev server runs at `http://localhost:5173` by default.
2626+2727+## Environment variables
2828+2929+| Variable | Default | Description |
3030+| --------------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------- |
3131+| `VITE_TWISTER_API_BASE_URL` | `http://localhost:8080` | Base URL of the Twister API. All app requests (AT Protocol, Jetstream, Constellation) are proxied through this. |
3232+3333+> All upstream requests — knot XRPC, PDS records, handle resolution, DID documents,
3434+> Constellation backlink counts, and the Jetstream activity stream — are routed
3535+> through the Twister API. The app makes no direct calls to external services.
3636+3737+## Building for mobile
3838+3939+```sh
4040+# Build the web assets
4141+pnpm build
4242+4343+# Sync to native projects
4444+pnpm cap sync
4545+4646+# Open in Xcode / Android Studio
4747+pnpm cap open ios
4848+pnpm cap open android
4949+```
+9
apps/twisted/src/core/config/project.ts
···10101111 return new URL(path.replace(/^\/+/, ""), `${twisterApiBaseUrl}/`).toString();
1212}
1313+1414+export function getTwisterWsUrl(path: string): string {
1515+ if (!hasTwisterApi) {
1616+ throw new Error("Twister API base URL is not configured.");
1717+ }
1818+1919+ const wsBase = twisterApiBaseUrl.replace(/^http/, (m) => (m === "https" ? "wss" : "ws"));
2020+ return new URL(path.replace(/^\/+/, ""), `${wsBase}/`).toString();
2121+}
···11/**
22- * TanStack Query hooks for the Constellation backlink API.
33- * https://constellation.microcosm.blue
44- *
55- * Constellation is a public AT Protocol backlink index. It answers
66- * "how many records link to this subject?" — star counts, follower
77- * counts, reaction counts — without requiring authentication.
88- *
99- * Calling it directly from the app avoids adding per-resource endpoints
1010- * to the Twister API for every social signal we need.
22+ * TanStack Query hooks for Constellation backlink counts, proxied through the Twister API.
113 */
124import { useQuery } from "@tanstack/vue-query";
135import { computed, toValue } from "vue";
146import type { MaybeRef } from "vue";
1515-1616-const CONSTELLATION_BASE = "https://constellation.microcosm.blue";
77+import { getTwisterApiUrl } from "@/core/config/project.js";
1781818-// AT Protocol collection + field paths used as Constellation "sources".
1919-const SOURCE_STAR = "sh.tangled.feed.star:subject.uri";
2020-const SOURCE_FOLLOW = "sh.tangled.graph.follow:subject";
99+const SOURCE_STAR = "sh.tangled.feed.star:.subject";
1010+const SOURCE_FOLLOW = "sh.tangled.graph.follow:.subject";
21112212const MIN = 60_000;
23132414async function fetchBacklinksCount(subject: string, source: string): Promise<number> {
2525- const url = new URL(`${CONSTELLATION_BASE}/xrpc/blue.microcosm.links.getBacklinksCount`);
1515+ const url = new URL(getTwisterApiUrl("/backlinks/count"));
2616 url.searchParams.set("subject", subject);
2717 url.searchParams.set("source", source);
28182929- const res = await fetch(url.toString(), {
3030- headers: { Accept: "application/json" },
3131- });
1919+ const res = await fetch(url.toString(), { headers: { Accept: "application/json" } });
32203321 if (!res.ok) {
3434- throw new Error(`Constellation request failed: ${res.status}`);
2222+ throw new Error(`Backlinks request failed: ${res.status}`);
3523 }
36243725 const data = (await res.json()) as { count: number };
+5-9
apps/twisted/src/services/jetstream/client.ts
···11/**
22- * JetstreamClient — subscribes to the AT Protocol Jetstream WebSocket firehose
33- * and filters for sh.tangled.* collection events, emitting ActivityItems.
22+ * JetstreamClient — subscribes to the Twister activity stream WebSocket, which
33+ * proxies the AT Protocol Jetstream firehose and filters for sh.tangled.* events.
44 *
55 * Connects on demand, auto-reconnects after disconnection, and tracks the last
66 * event cursor so gap-free resume is possible on reconnect.
77- *
88- * Data source decision: Jetstream is chosen over PDS polling because it provides
99- * a public, real-time stream of all network events without requiring authentication
1010- * or prior knowledge of specific user DIDs. PDS polling would require a known list
1111- * of accounts to follow, and the Twister API does not yet expose an activity feed.
127 */
138import type { ActivityItem } from "@/domain/models/activity.js";
99+import { getTwisterWsUrl } from "@/core/config/project.js";
14101515-const JETSTREAM_URL = "wss://jetstream2.us-east.bsky.network/subscribe";
1111+const ACTIVITY_STREAM_PATH = "/activity/stream";
1612const MAX_ITEMS = 200;
1713const RECONNECT_DELAY_MS = 3_000;
1814···160156 if (this.cursor !== null) {
161157 params.set("cursor", String(this.cursor));
162158 }
163163- return `${JETSTREAM_URL}?${params.toString()}`;
159159+ return `${getTwisterWsUrl(ACTIVITY_STREAM_PATH)}?${params.toString()}`;
164160 }
165161166162 private _open(): void {
apps/twisted/src/services/tangled/.gitkeep
This is a binary file and will not be displayed.
+204-350
apps/twisted/src/services/tangled/endpoints.ts
···11/**
22- * Typed wrappers around XRPC queries to Tangled knots and the AT Protocol PDS.
33- *
44- * Knot endpoints use raw fetch so we can control query serialization for the
55- * `repo=did:.../repoName` parameter. PDS endpoints also use raw fetch because
66- * some `com.atproto.repo.*` calls are not typed in the installed packages.
77- *
88- * --- API Validation Notes (to verify against live endpoints) ---
99- * Knot XRPC base: https://<knot>/xrpc/<nsid> (e.g. us-west.tangled.sh)
1010- * PDS XRPC base: https://bsky.social/xrpc/<nsid> (or user's own PDS)
22+ * Typed wrappers around Twister API endpoints.
113 *
1212- * CORS: knot endpoints need to be confirmed CORS-safe from a browser context.
1313- * Appview (tangled.org) serves HTML/HTMX — not a JSON API. Profile & repo
1414- * metadata must come from PDS records via com.atproto.repo.getRecord.
1515- *
1616- * Data routing:
1717- * - Git data (tree, blob, log, branches, languages) → knot XRPC
1818- * - Repo metadata & profile → PDS com.atproto.repo.getRecord/listRecords
44+ * The app calls Twister for everything — no direct XRPC calls to PDSes or
55+ * knots. Twister resolves handles, routes to the right knot/PDS, and returns
66+ * the raw Lexicon records so the existing normalizers can still apply.
197 */
208219import {
···2311 ShTangledRepoBlob,
2412 ShTangledRepoGetDefaultBranch,
2513 ShTangledRepoLanguages,
2626- ShTangledRepoTags,
2714 ShTangledRepoDiff,
2815 ShTangledRepoCompare,
2916 ShTangledRepo,
3017 ShTangledActorProfile,
3118 ShTangledRepoIssue,
3219 ShTangledRepoIssueComment,
3333- ShTangledRepoIssueState,
3420 ShTangledRepoPull,
3521 ShTangledRepoPullComment,
3636- ShTangledRepoPullStatus,
3722 ShTangledGraphFollow,
3823 ShTangledString,
3924} from "@atcute/tangled";
4025import { throwOnXrpcError } from "@/services/atproto/client.js";
4141-import { MalformedResponseError, NotFoundError } from "@/core/errors/tangled.js";
2626+import { getTwisterApiUrl } from "@/core/config/project.js";
42274343-type KnotParams = Record<string, string | number | boolean | undefined | Array<string | number | boolean>>;
4444-type BlueskyProfileResponse = { did: string; handle: string; displayName?: string; avatar?: string };
2828+export type RecordEntry<T> = { uri: string; cid: string; value: T };
2929+export type IssueEntry = RecordEntry<ShTangledRepoIssue.Main> & { state: "open" | "closed" };
3030+export type PullEntry = RecordEntry<ShTangledRepoPull.Main> & { status: "open" | "merged" | "closed" };
45314646-function encodeKnotQueryParam(key: string, value: string | number | boolean): string {
4747- const encodedValue = encodeURIComponent(String(value));
4848- return `${encodeURIComponent(key)}=${key === "repo" ? encodedValue.replaceAll("%2F", "/") : encodedValue}`;
4949-}
3232+export type ActorResponse = {
3333+ did: string;
3434+ handle: string;
3535+ pds: string;
3636+ profile: RecordEntry<ShTangledActorProfile.Main>;
3737+ bsky?: { displayName?: string; avatar?: string } | null;
3838+};
50395151-function buildKnotQuery(params: KnotParams): string {
5252- const pairs: string[] = [];
4040+export type ActorReposResponse = { did: string; handle: string; records: RecordEntry<ShTangledRepo.Main>[] };
4141+4242+export type ActorRepoResponse = {
4343+ did: string;
4444+ handle: string;
4545+ knot_host: string;
4646+ record: RecordEntry<ShTangledRepo.Main>;
4747+};
4848+4949+export type ActorIssuesResponse = { did: string; handle: string; records: IssueEntry[] };
5050+5151+export type ActorPullsResponse = { did: string; handle: string; records: PullEntry[] };
53525454- for (const [key, rawValue] of Object.entries(params)) {
5555- if (rawValue === undefined) continue;
5353+export type ActorFollowingResponse = { did: string; handle: string; records: RecordEntry<ShTangledGraphFollow.Main>[] };
56545757- if (Array.isArray(rawValue)) {
5858- for (const value of rawValue) {
5959- pairs.push(encodeKnotQueryParam(key, value));
6060- }
6161- continue;
6262- }
5555+export type ActorStringsResponse = { did: string; handle: string; records: RecordEntry<ShTangledString.Main>[] };
63566464- pairs.push(encodeKnotQueryParam(key, rawValue));
6565- }
5757+export type IssueDetailResponse = IssueEntry;
66586767- return pairs.length > 0 ? `?${pairs.join("&")}` : "";
6868-}
5959+export type IssueCommentsResponse = {
6060+ did: string;
6161+ handle: string;
6262+ issueUri: string;
6363+ records: RecordEntry<ShTangledRepoIssueComment.Main>[];
6464+};
69657070-export function buildKnotUrl(knotHost: string, nsid: string, params: KnotParams): string {
7171- return `https://${knotHost}/xrpc/${nsid}${buildKnotQuery(params)}`;
7272-}
6666+export type PullDetailResponse = PullEntry;
73677474-async function readKnotError(res: Response): Promise<never> {
7575- const contentType = res.headers.get("content-type") ?? "";
6868+export type PullCommentsResponse = {
6969+ did: string;
7070+ handle: string;
7171+ pullUri: string;
7272+ records: RecordEntry<ShTangledRepoPullComment.Main>[];
7373+};
76747777- if (contentType.includes("application/json")) {
7575+async function get<T>(path: string, params?: Record<string, string | undefined>): Promise<T> {
7676+ const url = new URL(getTwisterApiUrl(path));
7777+ if (params) {
7878+ for (const [key, value] of Object.entries(params)) {
7979+ if (value !== undefined) url.searchParams.set(key, value);
8080+ }
8181+ }
8282+ const res = await fetch(url.toString());
8383+ if (!res.ok) {
7884 const body = (await res.json().catch(() => ({}))) as { error?: string; message?: string };
7985 throwOnXrpcError(res.status, body.error ?? "Unknown", body.message);
8086 }
8181-8282- const text = await res.text().catch(() => "");
8383- throwOnXrpcError(res.status, "Unknown", text || undefined);
8787+ return res.json() as Promise<T>;
8488}
85898686-async function fetchKnotJson<T>(knotHost: string, nsid: string, params: KnotParams): Promise<T> {
8787- const res = await fetch(buildKnotUrl(knotHost, nsid, params));
8888- if (!res.ok) return readKnotError(res);
8989- return res.json() as Promise<T>;
9090+async function getProxy<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T> {
9191+ const normalized: Record<string, string | undefined> = {};
9292+ if (params) {
9393+ for (const [key, value] of Object.entries(params)) {
9494+ normalized[key] = value === undefined ? undefined : String(value);
9595+ }
9696+ }
9797+ return get<T>(path, normalized);
9098}
91999292-async function fetchKnotBytes(knotHost: string, nsid: string, params: KnotParams): Promise<Uint8Array> {
9393- const res = await fetch(buildKnotUrl(knotHost, nsid, params));
9494- if (!res.ok) return readKnotError(res);
100100+async function getBytes(path: string, params?: Record<string, string | undefined>): Promise<Uint8Array> {
101101+ const url = new URL(getTwisterApiUrl(path));
102102+ if (params) {
103103+ for (const [key, value] of Object.entries(params)) {
104104+ if (value !== undefined) url.searchParams.set(key, value);
105105+ }
106106+ }
107107+ const res = await fetch(url.toString());
108108+ if (!res.ok) {
109109+ const body = (await res.json().catch(() => ({}))) as { error?: string; message?: string };
110110+ throwOnXrpcError(res.status, body.error ?? "Unknown", body.message);
111111+ }
95112 return new Uint8Array(await res.arrayBuffer());
96113}
971149898-export async function fetchRepoTree(
9999- knotHost: string,
100100- params: ShTangledRepoTree.$params,
101101-): Promise<ShTangledRepoTree.$output> {
102102- return fetchKnotJson<ShTangledRepoTree.$output>(knotHost, "sh.tangled.repo.tree", params);
115115+export async function fetchActor(handle: string): Promise<ActorResponse> {
116116+ return get<ActorResponse>(`/actors/${encodeURIComponent(handle)}`);
103117}
104118105105-export async function fetchRepoBlob(
106106- knotHost: string,
107107- params: ShTangledRepoBlob.$params,
108108-): Promise<ShTangledRepoBlob.$output> {
109109- return fetchKnotJson<ShTangledRepoBlob.$output>(knotHost, "sh.tangled.repo.blob", params);
119119+export async function fetchActorRepos(handle: string): Promise<ActorReposResponse> {
120120+ return get<ActorReposResponse>(`/actors/${encodeURIComponent(handle)}/repos`);
110121}
111122112112-export async function fetchDefaultBranch(
113113- knotHost: string,
114114- params: ShTangledRepoGetDefaultBranch.$params,
115115-): Promise<ShTangledRepoGetDefaultBranch.$output> {
116116- return fetchKnotJson<ShTangledRepoGetDefaultBranch.$output>(knotHost, "sh.tangled.repo.getDefaultBranch", params);
123123+export async function fetchActorRepo(handle: string, repo: string): Promise<ActorRepoResponse> {
124124+ return get<ActorRepoResponse>(`/actors/${encodeURIComponent(handle)}/repos/${encodeURIComponent(repo)}`);
117125}
118126119119-export async function fetchLanguages(
120120- knotHost: string,
121121- params: ShTangledRepoLanguages.$params,
122122-): Promise<ShTangledRepoLanguages.$output> {
123123- return fetchKnotJson<ShTangledRepoLanguages.$output>(knotHost, "sh.tangled.repo.languages", params);
127127+export async function fetchActorFollowing(handle: string): Promise<ActorFollowingResponse> {
128128+ return get<ActorFollowingResponse>(`/actors/${encodeURIComponent(handle)}/following`);
124129}
125130126126-/**
127127- * Fetch commit log. The wire format is a raw blob; the decoded text is returned
128128- * as-is so the normalizer can handle it once the format is confirmed against
129129- * the live API. Expected: newline-delimited JSON or git log text.
130130- */
131131-export async function fetchRepoLog(
132132- knotHost: string,
133133- params: { repo: string; ref: string; path?: string; limit?: number; cursor?: string },
134134-): Promise<string> {
135135- return new TextDecoder().decode(await fetchKnotBytes(knotHost, "sh.tangled.repo.log", params));
131131+export async function fetchActorStrings(handle: string): Promise<ActorStringsResponse> {
132132+ return get<ActorStringsResponse>(`/actors/${encodeURIComponent(handle)}/strings`);
136133}
137134138138-/**
139139- * Fetch branch list. The wire format is a raw blob; decoded text is returned
140140- * for the normalizer to parse once the live format is confirmed.
141141- */
142142-export async function fetchRepoBranches(
143143- knotHost: string,
144144- params: { repo: string; limit?: number; cursor?: string },
145145-): Promise<string> {
146146- return new TextDecoder().decode(await fetchKnotBytes(knotHost, "sh.tangled.repo.branches", params));
135135+export async function fetchActorIssues(handle: string): Promise<ActorIssuesResponse> {
136136+ return get<ActorIssuesResponse>(`/actors/${encodeURIComponent(handle)}/issues`);
147137}
148138149149-/** Tag list. Wire format is a raw blob — decoded text returned for normalizer. */
150150-export async function fetchRepoTags(knotHost: string, params: ShTangledRepoTags.$params): Promise<string> {
151151- return new TextDecoder().decode(await fetchKnotBytes(knotHost, "sh.tangled.repo.tags", params));
152152-}
153153-154154-/** Diff for a ref. Wire format is a raw blob — patch text. */
155155-export async function fetchRepoDiff(knotHost: string, params: ShTangledRepoDiff.$params): Promise<string> {
156156- return new TextDecoder().decode(await fetchKnotBytes(knotHost, "sh.tangled.repo.diff", params));
157157-}
158158-159159-/** Comparison between two revisions. Wire format is a raw blob — patch text. */
160160-export async function fetchRepoCompare(knotHost: string, params: ShTangledRepoCompare.$params): Promise<string> {
161161- return new TextDecoder().decode(await fetchKnotBytes(knotHost, "sh.tangled.repo.compare", params));
139139+export async function fetchActorPulls(handle: string): Promise<ActorPullsResponse> {
140140+ return get<ActorPullsResponse>(`/actors/${encodeURIComponent(handle)}/pulls`);
162141}
163142164164-type GetRecordResponse<T> = { uri: string; cid: string; value: T };
143143+type ResolveHandleResponse = { did: string };
165144166166-/**
167167- * Fetch a single record from the AT Protocol PDS.
168168- * Uses raw fetch against /xrpc/com.atproto.repo.getRecord since this NSID
169169- * is not currently typed in the installed @atcute packages.
170170- */
171171-async function getRecord<T>(
172172- pds: string,
173173- repo: string,
174174- collection: string,
175175- rkey: string,
176176-): Promise<GetRecordResponse<T>> {
177177- const url = new URL(`https://${pds}/xrpc/com.atproto.repo.getRecord`);
178178- url.searchParams.set("repo", repo);
179179- url.searchParams.set("collection", collection);
180180- url.searchParams.set("rkey", rkey);
145145+type DidDocument = {
146146+ service?: Array<{ id?: string; type?: string; serviceEndpoint?: string }>;
147147+ alsoKnownAs?: string[];
148148+};
181149182182- const res = await fetch(url.toString());
183183- if (!res.ok) {
184184- const body = (await res.json().catch(() => ({}))) as { error?: string; message?: string };
185185- throwOnXrpcError(res.status, body.error ?? "Unknown", body.message);
186186- }
187187- return res.json() as Promise<GetRecordResponse<T>>;
150150+export async function resolveHandle(handle: string): Promise<string> {
151151+ const data = await getProxy<ResolveHandleResponse>("/identity/resolve", { handle });
152152+ return data.did;
188153}
189154190190-export async function fetchActorProfile(
191191- pds: string,
192192- did: string,
193193-): Promise<GetRecordResponse<ShTangledActorProfile.Main>> {
194194- return getRecord<ShTangledActorProfile.Main>(pds, did, "sh.tangled.actor.profile", "self");
155155+export async function fetchDidDocument(did: string): Promise<DidDocument> {
156156+ return get<DidDocument>(`/identity/did/${encodeURIComponent(did)}`);
195157}
196158197197-export async function fetchBlueskyProfile(actor: string): Promise<BlueskyProfileResponse> {
198198- const url = new URL("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile");
199199- url.searchParams.set("actor", actor);
200200-201201- const res = await fetch(url.toString());
202202- if (!res.ok) {
203203- const body = (await res.json().catch(() => ({}))) as { error?: string; message?: string };
204204- throwOnXrpcError(res.status, body.error ?? "Unknown", body.message);
159159+export async function resolvePds(did: string): Promise<string> {
160160+ const doc = await fetchDidDocument(did);
161161+ const endpoint = doc.service?.find((entry) => entry.id === "#atproto_pds")?.serviceEndpoint;
162162+ if (!endpoint) {
163163+ throw new Error(`No PDS endpoint found in DID document for ${did}`);
205164 }
206206-207207- return res.json() as Promise<BlueskyProfileResponse>;
165165+ return new URL(endpoint).hostname;
208166}
209167210210-/**
211211- * Fetch a repo record by its PDS record key.
212212- * This is distinct from the repo's `name`, which is the identifier used by
213213- * knot endpoints in the `did:.../repoName` format.
214214- */
215215-export async function fetchRepoRecord(
216216- pds: string,
217217- did: string,
218218- rkey: string,
219219-): Promise<GetRecordResponse<ShTangledRepo.Main>> {
220220- return getRecord<ShTangledRepo.Main>(pds, did, "sh.tangled.repo", rkey);
168168+export async function resolveDidIdentity(did: string): Promise<{ did: string; handle: string; pds: string }> {
169169+ const actor = await fetchActor(did);
170170+ return { did: actor.did, handle: actor.handle, pds: new URL(actor.pds).hostname };
221171}
222172223223-/**
224224- * Fetch a repo record by matching on the record's `name` field.
225225- * Use this when the UI route or knot API identifies a repo by repo name rather
226226- * than by the underlying AT Protocol record key.
227227- */
228228-export async function fetchRepoRecordByName(
229229- pds: string,
230230- did: string,
231231- repoName: string,
232232-): Promise<GetRecordResponse<ShTangledRepo.Main>> {
233233- let cursor: string | undefined;
234234-235235- for (;;) {
236236- const response = await listRepoRecords(pds, did, 100, cursor);
237237- const record = response.records.find((entry) => entry.value.name === repoName);
238238- if (record) return record;
239239- if (!response.cursor) break;
240240- cursor = response.cursor;
241241- }
242242-243243- throw new NotFoundError(`Repository ${repoName}`);
173173+export async function fetchBskyXrpc<T>(nsid: string, params?: Record<string, string | number | undefined>): Promise<T> {
174174+ return getProxy<T>(`/xrpc/bsky/${encodeURIComponent(nsid)}`, params);
244175}
245176246246-export async function fetchIssueRecord(
247247- pds: string,
248248- did: string,
249249- rkey: string,
250250-): Promise<GetRecordResponse<ShTangledRepoIssue.Main>> {
251251- return getRecord<ShTangledRepoIssue.Main>(pds, did, "sh.tangled.repo.issue", rkey);
177177+export async function fetchPdsXrpc<T>(
178178+ pdsHost: string,
179179+ nsid: string,
180180+ params?: Record<string, string | number | undefined>,
181181+): Promise<T> {
182182+ return getProxy<T>(`/xrpc/pds/${encodeURIComponent(pdsHost)}/${encodeURIComponent(nsid)}`, params);
252183}
253184254254-export async function fetchPullRecord(
255255- pds: string,
256256- did: string,
257257- rkey: string,
258258-): Promise<GetRecordResponse<ShTangledRepoPull.Main>> {
259259- return getRecord<ShTangledRepoPull.Main>(pds, did, "sh.tangled.repo.pull", rkey);
185185+export async function fetchKnotXrpc<T>(
186186+ knotHost: string,
187187+ nsid: string,
188188+ params?: Record<string, string | number | undefined>,
189189+): Promise<T> {
190190+ return getProxy<T>(`/xrpc/knot/${encodeURIComponent(knotHost)}/${encodeURIComponent(nsid)}`, params);
260191}
261192262262-/**
263263- * Resolve an AT Protocol handle to a DID via bsky.social.
264264- * Returns the DID string (e.g. "did:plc:xxx").
265265- */
266266-export async function resolveHandle(handle: string): Promise<string> {
267267- const url = new URL("https://bsky.social/xrpc/com.atproto.identity.resolveHandle");
268268- url.searchParams.set("handle", handle);
269269- const res = await fetch(url.toString());
270270- if (!res.ok) {
271271- const body = (await res.json().catch(() => ({}))) as { error?: string; message?: string };
272272- throwOnXrpcError(res.status, body.error ?? "Unknown", body.message);
273273- }
274274- const data = (await res.json()) as { did: string };
275275- return data.did;
193193+export async function fetchRepoTree(
194194+ handle: string,
195195+ repo: string,
196196+ params: ShTangledRepoTree.$params,
197197+): Promise<ShTangledRepoTree.$output> {
198198+ return get<ShTangledRepoTree.$output>(
199199+ `/actors/${encodeURIComponent(handle)}/repos/${encodeURIComponent(repo)}/tree`,
200200+ { ref: params.ref, path: params.path },
201201+ );
276202}
277203278278-type DidDocument = { alsoKnownAs?: string[]; service?: Array<{ id: string; type: string; serviceEndpoint: string }> };
279279-280280-async function fetchDidDocument(did: string): Promise<DidDocument> {
281281- let docUrl: string;
282282- if (did.startsWith("did:plc:")) {
283283- docUrl = `https://plc.directory/${did}`;
284284- } else if (did.startsWith("did:web:")) {
285285- const host = did.slice("did:web:".length);
286286- docUrl = `https://${host}/.well-known/did.json`;
287287- } else {
288288- throw new MalformedResponseError("resolveHandle", `Unsupported DID method: ${did}`);
289289- }
290290-291291- const res = await fetch(docUrl);
292292- if (!res.ok) throwOnXrpcError(res.status, "ResolveFailed", `Could not fetch DID document: ${did}`);
293293- return (await res.json()) as DidDocument;
204204+export async function fetchRepoBlob(
205205+ handle: string,
206206+ repo: string,
207207+ params: ShTangledRepoBlob.$params,
208208+): Promise<ShTangledRepoBlob.$output> {
209209+ return get<ShTangledRepoBlob.$output>(
210210+ `/actors/${encodeURIComponent(handle)}/repos/${encodeURIComponent(repo)}/blob`,
211211+ { ref: params.ref, path: params.path },
212212+ );
294213}
295214296296-/**
297297- * Fetch the DID document for a DID and extract the PDS service endpoint hostname.
298298- * Supports did:plc (via plc.directory) and did:web.
299299- */
300300-export async function resolvePds(did: string): Promise<string> {
301301- const doc = await fetchDidDocument(did);
302302- const svc = doc.service?.find((s) => s.id === "#atproto_pds");
303303- if (!svc?.serviceEndpoint) {
304304- throw new MalformedResponseError("resolvePds", `No PDS endpoint in DID document: ${did}`);
305305- }
306306- return new URL(svc.serviceEndpoint).hostname;
215215+export async function fetchDefaultBranch(handle: string, repo: string): Promise<ShTangledRepoGetDefaultBranch.$output> {
216216+ return get<ShTangledRepoGetDefaultBranch.$output>(
217217+ `/actors/${encodeURIComponent(handle)}/repos/${encodeURIComponent(repo)}/default-branch`,
218218+ );
307219}
308220309309-export async function resolveHandleFromDid(did: string): Promise<string> {
310310- if (did.startsWith("did:web:")) return did.slice("did:web:".length);
311311-312312- const doc = await fetchDidDocument(did);
313313- const alias = doc.alsoKnownAs?.find((entry) => entry.startsWith("at://"));
314314- if (!alias) {
315315- throw new MalformedResponseError("resolveHandleFromDid", `No handle alias in DID document: ${did}`);
316316- }
317317-318318- return alias.slice("at://".length);
221221+export async function fetchLanguages(
222222+ handle: string,
223223+ repo: string,
224224+ ref?: string,
225225+): Promise<ShTangledRepoLanguages.$output> {
226226+ return get<ShTangledRepoLanguages.$output>(
227227+ `/actors/${encodeURIComponent(handle)}/repos/${encodeURIComponent(repo)}/languages`,
228228+ ref ? { ref } : undefined,
229229+ );
319230}
320231321321-export async function resolveDidIdentity(did: string): Promise<{ did: string; handle: string; pds: string }> {
322322- const [handle, pds] = await Promise.all([resolveHandleFromDid(did), resolvePds(did)]);
323323- return { did, handle, pds };
232232+export async function fetchRepoLog(
233233+ handle: string,
234234+ repo: string,
235235+ params: { ref: string; path?: string; limit?: number; cursor?: string },
236236+): Promise<string> {
237237+ const p: Record<string, string | undefined> = { ref: params.ref, path: params.path };
238238+ if (params.limit !== undefined) p.limit = String(params.limit);
239239+ if (params.cursor !== undefined) p.cursor = params.cursor;
240240+ return new TextDecoder().decode(
241241+ await getBytes(`/actors/${encodeURIComponent(handle)}/repos/${encodeURIComponent(repo)}/log`, p),
242242+ );
324243}
325244326326-type ListRecordsResponse<T> = { records: Array<{ uri: string; cid: string; value: T }>; cursor?: string };
327327-328328-async function listRecords<T>(
329329- pds: string,
330330- did: string,
331331- collection: string,
332332- limit = 50,
333333- cursor?: string,
334334-): Promise<ListRecordsResponse<T>> {
335335- const url = new URL(`https://${pds}/xrpc/com.atproto.repo.listRecords`);
336336- url.searchParams.set("repo", did);
337337- url.searchParams.set("collection", collection);
338338- url.searchParams.set("limit", String(limit));
339339- if (cursor) url.searchParams.set("cursor", cursor);
340340- const res = await fetch(url.toString());
341341- if (!res.ok) {
342342- const body = (await res.json().catch(() => ({}))) as { error?: string; message?: string };
343343- throwOnXrpcError(res.status, body.error ?? "Unknown", body.message);
344344- }
345345- return res.json() as Promise<ListRecordsResponse<T>>;
245245+export async function fetchRepoBranches(
246246+ handle: string,
247247+ repo: string,
248248+ params?: { limit?: number; cursor?: string },
249249+): Promise<string> {
250250+ const p: Record<string, string | undefined> = {};
251251+ if (params?.limit !== undefined) p.limit = String(params.limit);
252252+ if (params?.cursor !== undefined) p.cursor = params.cursor;
253253+ return new TextDecoder().decode(
254254+ await getBytes(`/actors/${encodeURIComponent(handle)}/repos/${encodeURIComponent(repo)}/branches`, p),
255255+ );
346256}
347257348348-/** List sh.tangled.repo.issue records from a user's PDS. */
349349-export async function listIssueRecords(
350350- pds: string,
351351- did: string,
352352- limit = 50,
353353- cursor?: string,
354354-): Promise<ListRecordsResponse<ShTangledRepoIssue.Main>> {
355355- return listRecords<ShTangledRepoIssue.Main>(pds, did, "sh.tangled.repo.issue", limit, cursor);
258258+export async function fetchRepoTags(handle: string, repo: string): Promise<string> {
259259+ return new TextDecoder().decode(
260260+ await getBytes(`/actors/${encodeURIComponent(handle)}/repos/${encodeURIComponent(repo)}/tags`),
261261+ );
356262}
357263358358-/** List sh.tangled.repo.issue.state records from a user's PDS. */
359359-export async function listIssueStateRecords(
360360- pds: string,
361361- did: string,
362362- limit = 100,
363363- cursor?: string,
364364-): Promise<ListRecordsResponse<ShTangledRepoIssueState.Main>> {
365365- return listRecords<ShTangledRepoIssueState.Main>(pds, did, "sh.tangled.repo.issue.state", limit, cursor);
264264+export async function fetchRepoDiff(handle: string, repo: string, params: ShTangledRepoDiff.$params): Promise<string> {
265265+ return new TextDecoder().decode(
266266+ await getBytes(`/actors/${encodeURIComponent(handle)}/repos/${encodeURIComponent(repo)}/diff`, { ref: params.ref }),
267267+ );
366268}
367269368368-/** List sh.tangled.repo.issue.comment records from a user's PDS. */
369369-export async function listIssueCommentRecords(
370370- pds: string,
371371- did: string,
372372- limit = 100,
373373- cursor?: string,
374374-): Promise<ListRecordsResponse<ShTangledRepoIssueComment.Main>> {
375375- return listRecords<ShTangledRepoIssueComment.Main>(pds, did, "sh.tangled.repo.issue.comment", limit, cursor);
270270+export async function fetchRepoCompare(
271271+ handle: string,
272272+ repo: string,
273273+ params: ShTangledRepoCompare.$params,
274274+): Promise<string> {
275275+ return new TextDecoder().decode(
276276+ await getBytes(`/actors/${encodeURIComponent(handle)}/repos/${encodeURIComponent(repo)}/compare`, {
277277+ from: params.rev1,
278278+ to: params.rev2,
279279+ }),
280280+ );
376281}
377282378378-/** List sh.tangled.repo.pull records from a user's PDS. */
379379-export async function listPullRecords(
380380- pds: string,
381381- did: string,
382382- limit = 50,
383383- cursor?: string,
384384-): Promise<ListRecordsResponse<ShTangledRepoPull.Main>> {
385385- return listRecords<ShTangledRepoPull.Main>(pds, did, "sh.tangled.repo.pull", limit, cursor);
283283+export async function fetchRepoIssues(handle: string, repo: string): Promise<ActorIssuesResponse> {
284284+ return get<ActorIssuesResponse>(`/actors/${encodeURIComponent(handle)}/repos/${encodeURIComponent(repo)}/issues`);
386285}
387286388388-/** List sh.tangled.repo.pull.status records from a user's PDS. */
389389-export async function listPullStatusRecords(
390390- pds: string,
391391- did: string,
392392- limit = 100,
393393- cursor?: string,
394394-): Promise<ListRecordsResponse<ShTangledRepoPullStatus.Main>> {
395395- return listRecords<ShTangledRepoPullStatus.Main>(pds, did, "sh.tangled.repo.pull.status", limit, cursor);
287287+export async function fetchRepoPulls(handle: string, repo: string): Promise<ActorPullsResponse> {
288288+ return get<ActorPullsResponse>(`/actors/${encodeURIComponent(handle)}/repos/${encodeURIComponent(repo)}/pulls`);
396289}
397290398398-/** List sh.tangled.repo.pull.comment records from a user's PDS. */
399399-export async function listPullCommentRecords(
400400- pds: string,
401401- did: string,
402402- limit = 100,
403403- cursor?: string,
404404-): Promise<ListRecordsResponse<ShTangledRepoPullComment.Main>> {
405405- return listRecords<ShTangledRepoPullComment.Main>(pds, did, "sh.tangled.repo.pull.comment", limit, cursor);
291291+export async function fetchIssueDetail(handle: string, rkey: string): Promise<IssueDetailResponse> {
292292+ return get<IssueDetailResponse>(`/issues/${encodeURIComponent(handle)}/${encodeURIComponent(rkey)}`);
406293}
407294408408-export async function listFollowRecords(
409409- pds: string,
410410- did: string,
411411- limit = 100,
412412- cursor?: string,
413413-): Promise<ListRecordsResponse<ShTangledGraphFollow.Main>> {
414414- return listRecords<ShTangledGraphFollow.Main>(pds, did, "sh.tangled.graph.follow", limit, cursor);
295295+export async function fetchIssueComments(handle: string, rkey: string): Promise<IssueCommentsResponse> {
296296+ return get<IssueCommentsResponse>(`/issues/${encodeURIComponent(handle)}/${encodeURIComponent(rkey)}/comments`);
415297}
416298417417-export async function listStringRecords(
418418- pds: string,
419419- did: string,
420420- limit = 100,
421421- cursor?: string,
422422-): Promise<ListRecordsResponse<ShTangledString.Main>> {
423423- return listRecords<ShTangledString.Main>(pds, did, "sh.tangled.string", limit, cursor);
299299+export async function fetchPullDetail(handle: string, rkey: string): Promise<PullDetailResponse> {
300300+ return get<PullDetailResponse>(`/pulls/${encodeURIComponent(handle)}/${encodeURIComponent(rkey)}`);
424301}
425302426426-/**
427427- * List all sh.tangled.repo records from a user's PDS.
428428- * Uses com.atproto.repo.listRecords since it's not in the installed lexicons.
429429- */
430430-export async function listRepoRecords(
431431- pds: string,
432432- did: string,
433433- limit = 50,
434434- cursor?: string,
435435-): Promise<{ records: Array<{ uri: string; cid: string; value: ShTangledRepo.Main }>; cursor?: string }> {
436436- const url = new URL(`https://${pds}/xrpc/com.atproto.repo.listRecords`);
437437- url.searchParams.set("repo", did);
438438- url.searchParams.set("collection", "sh.tangled.repo");
439439- url.searchParams.set("limit", String(limit));
440440- if (cursor) url.searchParams.set("cursor", cursor);
441441-442442- const res = await fetch(url.toString());
443443- if (!res.ok) {
444444- const body = (await res.json().catch(() => ({}))) as { error?: string; message?: string };
445445- throwOnXrpcError(res.status, body.error ?? "Unknown", body.message);
446446- }
447447- return res.json() as Promise<{
448448- records: Array<{ uri: string; cid: string; value: ShTangledRepo.Main }>;
449449- cursor?: string;
450450- }>;
303303+export async function fetchPullComments(handle: string, rkey: string): Promise<PullCommentsResponse> {
304304+ return get<PullCommentsResponse>(`/pulls/${encodeURIComponent(handle)}/${encodeURIComponent(rkey)}/comments`);
451305}
···11# Twister
2233-Tap-based indexing and search service for Tangled.
33+Tap-based indexing and search API for Tangled. Acts as a proxy layer between the Twisted app and all upstream AT Protocol services (knots, PDS, Bluesky, Constellation, Jetstream).
44+55+## Requirements
66+77+- Go 1.25+
88+- A Turso database (or local SQLite for development)
99+1010+## Running locally
1111+1212+```sh
1313+cd packages/api
1414+1515+# Start the API server with a local SQLite database (twister-dev.db)
1616+go run . api --local
1717+```
1818+1919+The server listens on `:8080` by default. Logs are printed as text when `--local` is set.
2020+2121+## Environment variables
2222+2323+Copy `.env.example` to `.env` in the repo root (or `packages/api/`). The server loads `.env`, `../.env`, and `../../.env` automatically.
2424+2525+| Variable | Default | Description |
2626+| -------------------------- | -------------------------------------- | ------------------------------------------------------- |
2727+| `TURSO_DATABASE_URL` | — | Turso/libSQL connection URL (required unless `--local`) |
2828+| `TURSO_AUTH_TOKEN` | — | Auth token (required for non-file URLs) |
2929+| `HTTP_BIND_ADDR` | `:8080` | Address the HTTP server listens on |
3030+| `LOG_LEVEL` | `info` | Log level (`debug`, `info`, `warn`, `error`) |
3131+| `LOG_FORMAT` | `json` | Log format (`json` or `text`) |
3232+| `SEARCH_DEFAULT_LIMIT` | `20` | Default result count for search |
3333+| `SEARCH_MAX_LIMIT` | `100` | Maximum result count for search |
3434+| `ENABLE_ADMIN_ENDPOINTS` | `false` | Expose `/admin/*` endpoints |
3535+| `ADMIN_AUTH_TOKEN` | — | Bearer token required for admin endpoints |
3636+| `CONSTELLATION_URL` | `https://constellation.microcosm.blue` | Constellation API base URL |
3737+| `CONSTELLATION_USER_AGENT` | `twister/1.0 …` | User-Agent sent to Constellation |
3838+| `TAP_URL` | — | Tap firehose URL (indexer only) |
3939+| `TAP_AUTH_PASSWORD` | — | Tap auth password (indexer only) |
4040+| `INDEXED_COLLECTIONS` | — | Comma-separated AT collections to index |
4141+4242+## CLI commands
4343+4444+```sh
4545+twister api # Start the HTTP API server
4646+twister indexer # Start the Tap firehose consumer
4747+twister backfill # Seed the index from upstream APIs
4848+twister reindex # Re-process existing documents
4949+```
5050+5151+## Proxy endpoints
5252+5353+The API proxies all upstream AT Protocol and social-graph requests so the app has a single origin:
5454+5555+| Route | Upstream |
5656+| ------------------------------- | ------------------------------------------------------------- |
5757+| `GET /proxy/knot/{host}/{nsid}` | `https://{host}/xrpc/{nsid}` |
5858+| `GET /proxy/pds/{host}/{nsid}` | `https://{host}/xrpc/{nsid}` |
5959+| `GET /proxy/bsky/{nsid}` | `https://public.api.bsky.app/xrpc/{nsid}` |
6060+| `GET /identity/resolve` | `https://bsky.social/xrpc/com.atproto.identity.resolveHandle` |
6161+| `GET /identity/did/{did}` | `https://plc.directory/{did}` or `/.well-known/did.json` |
6262+| `GET /backlinks/count` | Constellation `getBacklinksCount` (cached) |
6363+| `WS /activity/stream` | `wss://jetstream2.us-east.bsky.network/subscribe` |
+900
packages/api/internal/api/actors.go
···11+package api
22+33+import (
44+ "encoding/json"
55+ "fmt"
66+ "io"
77+ "log/slog"
88+ "net/http"
99+ "net/url"
1010+ "strings"
1111+1212+ "tangled.org/desertthunder.dev/twister/internal/xrpc"
1313+)
1414+1515+// recordEntry is the common shape for a single PDS record returned to the app.
1616+type recordEntry struct {
1717+ URI string `json:"uri"`
1818+ CID string `json:"cid"`
1919+ Value map[string]any `json:"value"`
2020+}
2121+2222+// issueEntry extends recordEntry with pre-joined issue state.
2323+type issueEntry struct {
2424+ recordEntry
2525+ State string `json:"state"` // "open" or "closed"
2626+}
2727+2828+// pullEntry extends recordEntry with pre-joined pull status.
2929+type pullEntry struct {
3030+ recordEntry
3131+ Status string `json:"status"` // "open", "merged", or "closed"
3232+}
3333+3434+// actorContext holds resolved identity for a request.
3535+type actorContext struct {
3636+ DID string `json:"did"`
3737+ Handle string `json:"handle"`
3838+ PDS string `json:"pds"` // full URL, e.g. "https://bsky.social"
3939+}
4040+4141+// repoContext extends actorContext with the repo's knot host and AT URI.
4242+type repoContext struct {
4343+ actorContext
4444+ KnotHost string `json:"knot_host"`
4545+ AtURI string `json:"at_uri"`
4646+ RepoName string `json:"repo_name"`
4747+}
4848+4949+// resolveActor resolves a handle (or DID) to its DID, PDS, and canonical handle.
5050+func (s *Server) resolveActor(r *http.Request, handleOrDID string) (*actorContext, error) {
5151+ ctx := r.Context()
5252+5353+ var did string
5454+ if strings.HasPrefix(handleOrDID, "did:") {
5555+ did = handleOrDID
5656+ } else {
5757+ var err error
5858+ did, err = s.xrpc.ResolveHandle(ctx, handleOrDID)
5959+ if err != nil {
6060+ return nil, fmt.Errorf("resolve handle %q: %w", handleOrDID, err)
6161+ }
6262+ }
6363+6464+ identity, err := s.xrpc.ResolveIdentity(ctx, did)
6565+ if err != nil {
6666+ return nil, fmt.Errorf("resolve identity %q: %w", did, err)
6767+ }
6868+6969+ return &actorContext{
7070+ DID: identity.DID,
7171+ Handle: identity.Handle,
7272+ PDS: identity.PDS,
7373+ }, nil
7474+}
7575+7676+// resolveRepo resolves a handle + repo name to actor + knot host + AT URI.
7777+func (s *Server) resolveRepo(r *http.Request, handleOrDID, repoName string) (*repoContext, error) {
7878+ actor, err := s.resolveActor(r, handleOrDID)
7979+ if err != nil {
8080+ return nil, err
8181+ }
8282+8383+ entries, err := s.xrpc.ListAllRecords(r.Context(), actor.PDS, actor.DID, "sh.tangled.repo")
8484+ if err != nil {
8585+ return nil, fmt.Errorf("list repos for %s: %w", actor.DID, err)
8686+ }
8787+8888+ for _, entry := range entries {
8989+ name, _ := entry.Value["name"].(string)
9090+ if name == repoName {
9191+ knot, _ := entry.Value["knot"].(string)
9292+ return &repoContext{
9393+ actorContext: *actor,
9494+ KnotHost: knot,
9595+ AtURI: entry.URI,
9696+ RepoName: repoName,
9797+ }, nil
9898+ }
9999+ }
100100+101101+ return nil, &xrpc.NotFoundError{Message: fmt.Sprintf("repo %q not found for %s", repoName, handleOrDID)}
102102+}
103103+104104+// knotCall makes a GET request to a knot's XRPC endpoint and streams the response body.
105105+// The caller is responsible for closing the returned ReadCloser.
106106+func (s *Server) knotCall(r *http.Request, knotHost, nsid string, params url.Values) (io.ReadCloser, string, error) {
107107+ if knotHost == "" {
108108+ return nil, "", fmt.Errorf("repo has no knot host")
109109+ }
110110+ knotURL := "https://" + knotHost
111111+ body, err := s.xrpc.Call(r.Context(), knotURL, nsid, params)
112112+ if err != nil {
113113+ return nil, "", err
114114+ }
115115+ return body, knotURL, nil
116116+}
117117+118118+// proxyKnotJSON calls a knot endpoint and writes the JSON response verbatim.
119119+func (s *Server) proxyKnotJSON(w http.ResponseWriter, r *http.Request, repo *repoContext, nsid string, params url.Values) {
120120+ params.Set("repo", repo.DID+"/"+repo.RepoName)
121121+ body, _, err := s.knotCall(r, repo.KnotHost, nsid, params)
122122+ if err != nil {
123123+ s.knotError(w, err)
124124+ return
125125+ }
126126+ defer body.Close()
127127+ w.Header().Set("Content-Type", "application/json")
128128+ w.WriteHeader(http.StatusOK)
129129+ _, _ = io.Copy(w, body)
130130+}
131131+132132+// proxyKnotBytes calls a knot endpoint and writes the raw bytes verbatim.
133133+func (s *Server) proxyKnotBytes(w http.ResponseWriter, r *http.Request, repo *repoContext, nsid string, params url.Values) {
134134+ params.Set("repo", repo.DID+"/"+repo.RepoName)
135135+ body, _, err := s.knotCall(r, repo.KnotHost, nsid, params)
136136+ if err != nil {
137137+ s.knotError(w, err)
138138+ return
139139+ }
140140+ defer body.Close()
141141+ w.Header().Set("Content-Type", "application/octet-stream")
142142+ w.WriteHeader(http.StatusOK)
143143+ _, _ = io.Copy(w, body)
144144+}
145145+146146+func (s *Server) knotError(w http.ResponseWriter, err error) {
147147+ var nfe *xrpc.NotFoundError
148148+ var xe *xrpc.XRPCError
149149+ switch {
150150+ case isError(err, &nfe):
151151+ writeJSON(w, http.StatusNotFound, errorBody("not_found", nfe.Message))
152152+ case isError(err, &xe):
153153+ writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", xe.Message))
154154+ default:
155155+ s.log.Debug("knot call failed", slog.String("error", err.Error()))
156156+ writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", "upstream request failed"))
157157+ }
158158+}
159159+160160+func (s *Server) actorError(w http.ResponseWriter, err error) {
161161+ var nfe *xrpc.NotFoundError
162162+ var xe *xrpc.XRPCError
163163+ switch {
164164+ case isError(err, &nfe):
165165+ writeJSON(w, http.StatusNotFound, errorBody("not_found", nfe.Message))
166166+ case isError(err, &xe):
167167+ writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", xe.Message))
168168+ default:
169169+ s.log.Debug("actor resolve failed", slog.String("error", err.Error()))
170170+ writeJSON(w, http.StatusBadGateway, errorBody("resolve_error", "failed to resolve actor"))
171171+ }
172172+}
173173+174174+// isError is a type-safe errors.As replacement for pointer receiver targets.
175175+func isError[T error](err error, target *T) bool {
176176+ if err == nil {
177177+ return false
178178+ }
179179+180180+ type unwrapper interface{ Unwrap() error }
181181+ for e := err; e != nil; {
182182+ if t, ok := e.(T); ok {
183183+ *target = t
184184+ return true
185185+ }
186186+ if u, ok := e.(unwrapper); ok {
187187+ e = u.Unwrap()
188188+ } else {
189189+ break
190190+ }
191191+ }
192192+ return false
193193+}
194194+195195+// handleGetActor returns the actor's Tangled profile + optional Bluesky info.
196196+// GET /actors/{handle}
197197+func (s *Server) handleGetActor(w http.ResponseWriter, r *http.Request) {
198198+ handle := r.PathValue("handle")
199199+200200+ actor, err := s.resolveActor(r, handle)
201201+ if err != nil {
202202+ s.actorError(w, err)
203203+ return
204204+ }
205205+206206+ rec, err := s.xrpc.GetRecord(r.Context(), actor.PDS, actor.DID, "sh.tangled.actor.profile", "self")
207207+ if err != nil {
208208+ s.actorError(w, err)
209209+ return
210210+ }
211211+212212+ var bsky *bskyProfileResponse
213213+ if linked, _ := rec.Value["bluesky"].(bool); linked {
214214+ bsky = s.fetchBskyProfile(r, actor.DID)
215215+ }
216216+217217+ writeJSON(w, http.StatusOK, map[string]any{
218218+ "did": actor.DID,
219219+ "handle": actor.Handle,
220220+ "pds": actor.PDS,
221221+ "profile": recordEntry{
222222+ URI: rec.URI,
223223+ CID: rec.CID,
224224+ Value: rec.Value,
225225+ },
226226+ "bsky": bsky,
227227+ })
228228+}
229229+230230+// handleListActorRepos returns all sh.tangled.repo records for an actor.
231231+// GET /actors/{handle}/repos
232232+func (s *Server) handleListActorRepos(w http.ResponseWriter, r *http.Request) {
233233+ handle := r.PathValue("handle")
234234+235235+ actor, err := s.resolveActor(r, handle)
236236+ if err != nil {
237237+ s.actorError(w, err)
238238+ return
239239+ }
240240+241241+ entries, err := s.xrpc.ListAllRecords(r.Context(), actor.PDS, actor.DID, "sh.tangled.repo")
242242+ if err != nil {
243243+ s.actorError(w, err)
244244+ return
245245+ }
246246+247247+ records := make([]recordEntry, len(entries))
248248+ for i, e := range entries {
249249+ records[i] = recordEntry{URI: e.URI, CID: e.CID, Value: e.Value}
250250+ }
251251+252252+ writeJSON(w, http.StatusOK, map[string]any{
253253+ "did": actor.DID,
254254+ "handle": actor.Handle,
255255+ "records": records,
256256+ })
257257+}
258258+259259+// handleGetActorRepo returns the repo record for a specific repo by name.
260260+// GET /actors/{handle}/repos/{repo}
261261+func (s *Server) handleGetActorRepo(w http.ResponseWriter, r *http.Request) {
262262+ handle := r.PathValue("handle")
263263+ repoName := r.PathValue("repo")
264264+265265+ repo, err := s.resolveRepo(r, handle, repoName)
266266+ if err != nil {
267267+ s.actorError(w, err)
268268+ return
269269+ }
270270+271271+ did, _, rkey, parseErr := parseATURI(repo.AtURI)
272272+ if parseErr != nil {
273273+ writeJSON(w, http.StatusInternalServerError, errorBody("internal_error", "invalid AT URI"))
274274+ return
275275+ }
276276+277277+ rec, err := s.xrpc.GetRecord(r.Context(), repo.PDS, did, "sh.tangled.repo", rkey)
278278+ if err != nil {
279279+ s.actorError(w, err)
280280+ return
281281+ }
282282+283283+ writeJSON(w, http.StatusOK, map[string]any{
284284+ "did": repo.DID,
285285+ "handle": repo.Handle,
286286+ "knot_host": repo.KnotHost,
287287+ "record": recordEntry{
288288+ URI: rec.URI,
289289+ CID: rec.CID,
290290+ Value: rec.Value,
291291+ },
292292+ })
293293+}
294294+295295+// handleRepoTree proxies sh.tangled.repo.tree to the knot.
296296+// GET /actors/{handle}/repos/{repo}/tree
297297+func (s *Server) handleRepoTree(w http.ResponseWriter, r *http.Request) {
298298+ repo, err := s.resolveRepo(r, r.PathValue("handle"), r.PathValue("repo"))
299299+ if err != nil {
300300+ s.actorError(w, err)
301301+ return
302302+ }
303303+ params := url.Values{}
304304+ for _, k := range []string{"ref", "path"} {
305305+ if v := r.URL.Query().Get(k); v != "" {
306306+ params.Set(k, v)
307307+ }
308308+ }
309309+ s.proxyKnotJSON(w, r, repo, "sh.tangled.repo.tree", params)
310310+}
311311+312312+// handleRepoBlob proxies sh.tangled.repo.blob to the knot.
313313+// GET /actors/{handle}/repos/{repo}/blob
314314+func (s *Server) handleRepoBlob(w http.ResponseWriter, r *http.Request) {
315315+ repo, err := s.resolveRepo(r, r.PathValue("handle"), r.PathValue("repo"))
316316+ if err != nil {
317317+ s.actorError(w, err)
318318+ return
319319+ }
320320+ params := url.Values{}
321321+ for _, k := range []string{"ref", "path"} {
322322+ if v := r.URL.Query().Get(k); v != "" {
323323+ params.Set(k, v)
324324+ }
325325+ }
326326+ s.proxyKnotJSON(w, r, repo, "sh.tangled.repo.blob", params)
327327+}
328328+329329+// handleRepoLog proxies sh.tangled.repo.log (raw bytes) to the knot.
330330+// GET /actors/{handle}/repos/{repo}/log
331331+func (s *Server) handleRepoLog(w http.ResponseWriter, r *http.Request) {
332332+ repo, err := s.resolveRepo(r, r.PathValue("handle"), r.PathValue("repo"))
333333+ if err != nil {
334334+ s.actorError(w, err)
335335+ return
336336+ }
337337+ params := url.Values{}
338338+ for _, k := range []string{"ref", "path", "limit", "cursor"} {
339339+ if v := r.URL.Query().Get(k); v != "" {
340340+ params.Set(k, v)
341341+ }
342342+ }
343343+ s.proxyKnotBytes(w, r, repo, "sh.tangled.repo.log", params)
344344+}
345345+346346+// handleRepoBranches proxies sh.tangled.repo.branches (raw bytes) to the knot.
347347+// GET /actors/{handle}/repos/{repo}/branches
348348+func (s *Server) handleRepoBranches(w http.ResponseWriter, r *http.Request) {
349349+ repo, err := s.resolveRepo(r, r.PathValue("handle"), r.PathValue("repo"))
350350+ if err != nil {
351351+ s.actorError(w, err)
352352+ return
353353+ }
354354+ params := url.Values{}
355355+ for _, k := range []string{"limit", "cursor"} {
356356+ if v := r.URL.Query().Get(k); v != "" {
357357+ params.Set(k, v)
358358+ }
359359+ }
360360+ s.proxyKnotBytes(w, r, repo, "sh.tangled.repo.branches", params)
361361+}
362362+363363+// handleRepoDefaultBranch proxies sh.tangled.repo.getDefaultBranch (JSON) to the knot.
364364+// GET /actors/{handle}/repos/{repo}/default-branch
365365+func (s *Server) handleRepoDefaultBranch(w http.ResponseWriter, r *http.Request) {
366366+ repo, err := s.resolveRepo(r, r.PathValue("handle"), r.PathValue("repo"))
367367+ if err != nil {
368368+ s.actorError(w, err)
369369+ return
370370+ }
371371+ s.proxyKnotJSON(w, r, repo, "sh.tangled.repo.getDefaultBranch", url.Values{})
372372+}
373373+374374+// handleRepoLanguages proxies sh.tangled.repo.languages (JSON) to the knot.
375375+// GET /actors/{handle}/repos/{repo}/languages
376376+func (s *Server) handleRepoLanguages(w http.ResponseWriter, r *http.Request) {
377377+ repo, err := s.resolveRepo(r, r.PathValue("handle"), r.PathValue("repo"))
378378+ if err != nil {
379379+ s.actorError(w, err)
380380+ return
381381+ }
382382+ params := url.Values{}
383383+ if v := r.URL.Query().Get("ref"); v != "" {
384384+ params.Set("ref", v)
385385+ }
386386+ s.proxyKnotJSON(w, r, repo, "sh.tangled.repo.languages", params)
387387+}
388388+389389+// handleRepoTags proxies sh.tangled.repo.tags (raw bytes) to the knot.
390390+// GET /actors/{handle}/repos/{repo}/tags
391391+func (s *Server) handleRepoTags(w http.ResponseWriter, r *http.Request) {
392392+ repo, err := s.resolveRepo(r, r.PathValue("handle"), r.PathValue("repo"))
393393+ if err != nil {
394394+ s.actorError(w, err)
395395+ return
396396+ }
397397+ s.proxyKnotBytes(w, r, repo, "sh.tangled.repo.tags", url.Values{})
398398+}
399399+400400+// handleRepoDiff proxies sh.tangled.repo.diff (raw bytes) to the knot.
401401+// GET /actors/{handle}/repos/{repo}/diff
402402+func (s *Server) handleRepoDiff(w http.ResponseWriter, r *http.Request) {
403403+ repo, err := s.resolveRepo(r, r.PathValue("handle"), r.PathValue("repo"))
404404+ if err != nil {
405405+ s.actorError(w, err)
406406+ return
407407+ }
408408+ params := url.Values{}
409409+ if v := r.URL.Query().Get("ref"); v != "" {
410410+ params.Set("ref", v)
411411+ }
412412+ s.proxyKnotBytes(w, r, repo, "sh.tangled.repo.diff", params)
413413+}
414414+415415+// handleRepoCompare proxies sh.tangled.repo.compare (raw bytes) to the knot.
416416+// GET /actors/{handle}/repos/{repo}/compare
417417+func (s *Server) handleRepoCompare(w http.ResponseWriter, r *http.Request) {
418418+ repo, err := s.resolveRepo(r, r.PathValue("handle"), r.PathValue("repo"))
419419+ if err != nil {
420420+ s.actorError(w, err)
421421+ return
422422+ }
423423+ params := url.Values{}
424424+ for _, k := range []string{"from", "to"} {
425425+ if v := r.URL.Query().Get(k); v != "" {
426426+ params.Set(k, v)
427427+ }
428428+ }
429429+ s.proxyKnotBytes(w, r, repo, "sh.tangled.repo.compare", params)
430430+}
431431+432432+// handleRepoIssues returns issues for a repo, pre-joined with state.
433433+// GET /actors/{handle}/repos/{repo}/issues
434434+func (s *Server) handleRepoIssues(w http.ResponseWriter, r *http.Request) {
435435+ repo, err := s.resolveRepo(r, r.PathValue("handle"), r.PathValue("repo"))
436436+ if err != nil {
437437+ s.actorError(w, err)
438438+ return
439439+ }
440440+441441+ issues, stateMap, err := s.fetchIssuesAndStates(r, repo.PDS, repo.DID)
442442+ if err != nil {
443443+ writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", "failed to fetch issues"))
444444+ return
445445+ }
446446+447447+ var records []issueEntry
448448+ for _, e := range issues {
449449+ repoURI, _ := e.Value["repo"].(string)
450450+ if repo.AtURI != "" && repoURI != repo.AtURI {
451451+ continue
452452+ }
453453+ records = append(records, issueEntry{
454454+ recordEntry: recordEntry{URI: e.URI, CID: e.CID, Value: e.Value},
455455+ State: resolveIssueState(stateMap, e.URI),
456456+ })
457457+ }
458458+ if records == nil {
459459+ records = []issueEntry{}
460460+ }
461461+462462+ writeJSON(w, http.StatusOK, map[string]any{
463463+ "did": repo.DID,
464464+ "handle": repo.Handle,
465465+ "records": records,
466466+ })
467467+}
468468+469469+// handleRepoPulls returns pull requests for a repo, pre-joined with status.
470470+// GET /actors/{handle}/repos/{repo}/pulls
471471+func (s *Server) handleRepoPulls(w http.ResponseWriter, r *http.Request) {
472472+ repo, err := s.resolveRepo(r, r.PathValue("handle"), r.PathValue("repo"))
473473+ if err != nil {
474474+ s.actorError(w, err)
475475+ return
476476+ }
477477+478478+ pulls, statusMap, err := s.fetchPullsAndStatuses(r, repo.PDS, repo.DID)
479479+ if err != nil {
480480+ writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", "failed to fetch pulls"))
481481+ return
482482+ }
483483+484484+ var records []pullEntry
485485+ for _, e := range pulls {
486486+ target, _ := e.Value["target"].(map[string]any)
487487+ targetRepo, _ := target["repo"].(string)
488488+ if repo.AtURI != "" && targetRepo != repo.AtURI {
489489+ continue
490490+ }
491491+ records = append(records, pullEntry{
492492+ recordEntry: recordEntry{URI: e.URI, CID: e.CID, Value: e.Value},
493493+ Status: resolvePullStatus(statusMap, e.URI),
494494+ })
495495+ }
496496+ if records == nil {
497497+ records = []pullEntry{}
498498+ }
499499+500500+ writeJSON(w, http.StatusOK, map[string]any{
501501+ "did": repo.DID,
502502+ "handle": repo.Handle,
503503+ "records": records,
504504+ })
505505+}
506506+507507+// handleActorIssues returns all issues authored by an actor, pre-joined with state.
508508+// GET /actors/{handle}/issues
509509+func (s *Server) handleActorIssues(w http.ResponseWriter, r *http.Request) {
510510+ actor, err := s.resolveActor(r, r.PathValue("handle"))
511511+ if err != nil {
512512+ s.actorError(w, err)
513513+ return
514514+ }
515515+516516+ issues, stateMap, err := s.fetchIssuesAndStates(r, actor.PDS, actor.DID)
517517+ if err != nil {
518518+ writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", "failed to fetch issues"))
519519+ return
520520+ }
521521+522522+ records := make([]issueEntry, len(issues))
523523+ for i, e := range issues {
524524+ records[i] = issueEntry{
525525+ recordEntry: recordEntry{URI: e.URI, CID: e.CID, Value: e.Value},
526526+ State: resolveIssueState(stateMap, e.URI),
527527+ }
528528+ }
529529+530530+ writeJSON(w, http.StatusOK, map[string]any{
531531+ "did": actor.DID,
532532+ "handle": actor.Handle,
533533+ "records": records,
534534+ })
535535+}
536536+537537+// handleActorPulls returns all pull requests authored by an actor, pre-joined with status.
538538+// GET /actors/{handle}/pulls
539539+func (s *Server) handleActorPulls(w http.ResponseWriter, r *http.Request) {
540540+ actor, err := s.resolveActor(r, r.PathValue("handle"))
541541+ if err != nil {
542542+ s.actorError(w, err)
543543+ return
544544+ }
545545+546546+ pulls, statusMap, err := s.fetchPullsAndStatuses(r, actor.PDS, actor.DID)
547547+ if err != nil {
548548+ writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", "failed to fetch pulls"))
549549+ return
550550+ }
551551+552552+ records := make([]pullEntry, len(pulls))
553553+ for i, e := range pulls {
554554+ records[i] = pullEntry{
555555+ recordEntry: recordEntry{URI: e.URI, CID: e.CID, Value: e.Value},
556556+ Status: resolvePullStatus(statusMap, e.URI),
557557+ }
558558+ }
559559+560560+ writeJSON(w, http.StatusOK, map[string]any{
561561+ "did": actor.DID,
562562+ "handle": actor.Handle,
563563+ "records": records,
564564+ })
565565+}
566566+567567+// handleActorFollowing returns sh.tangled.graph.follow records for an actor.
568568+// GET /actors/{handle}/following
569569+func (s *Server) handleActorFollowing(w http.ResponseWriter, r *http.Request) {
570570+ actor, err := s.resolveActor(r, r.PathValue("handle"))
571571+ if err != nil {
572572+ s.actorError(w, err)
573573+ return
574574+ }
575575+576576+ entries, err := s.xrpc.ListAllRecords(r.Context(), actor.PDS, actor.DID, "sh.tangled.graph.follow")
577577+ if err != nil {
578578+ writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", "failed to fetch follows"))
579579+ return
580580+ }
581581+582582+ records := make([]recordEntry, len(entries))
583583+ for i, e := range entries {
584584+ records[i] = recordEntry{URI: e.URI, CID: e.CID, Value: e.Value}
585585+ }
586586+587587+ writeJSON(w, http.StatusOK, map[string]any{
588588+ "did": actor.DID,
589589+ "handle": actor.Handle,
590590+ "records": records,
591591+ })
592592+}
593593+594594+// handleActorStrings returns sh.tangled.string records for an actor.
595595+// GET /actors/{handle}/strings
596596+func (s *Server) handleActorStrings(w http.ResponseWriter, r *http.Request) {
597597+ actor, err := s.resolveActor(r, r.PathValue("handle"))
598598+ if err != nil {
599599+ s.actorError(w, err)
600600+ return
601601+ }
602602+603603+ entries, err := s.xrpc.ListAllRecords(r.Context(), actor.PDS, actor.DID, "sh.tangled.string")
604604+ if err != nil {
605605+ writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", "failed to fetch strings"))
606606+ return
607607+ }
608608+609609+ records := make([]recordEntry, len(entries))
610610+ for i, e := range entries {
611611+ records[i] = recordEntry{URI: e.URI, CID: e.CID, Value: e.Value}
612612+ }
613613+614614+ writeJSON(w, http.StatusOK, map[string]any{
615615+ "did": actor.DID,
616616+ "handle": actor.Handle,
617617+ "records": records,
618618+ })
619619+}
620620+621621+// handleIssueDetail returns a single issue with its state.
622622+// GET /issues/{handle}/{rkey}
623623+func (s *Server) handleIssueDetail(w http.ResponseWriter, r *http.Request) {
624624+ handle := r.PathValue("handle")
625625+ rkey := r.PathValue("rkey")
626626+627627+ actor, err := s.resolveActor(r, handle)
628628+ if err != nil {
629629+ s.actorError(w, err)
630630+ return
631631+ }
632632+633633+ rec, err := s.xrpc.GetRecord(r.Context(), actor.PDS, actor.DID, "sh.tangled.repo.issue", rkey)
634634+ if err != nil {
635635+ s.actorError(w, err)
636636+ return
637637+ }
638638+639639+ _, stateMap, err := s.fetchIssuesAndStates(r, actor.PDS, actor.DID)
640640+ if err != nil {
641641+ writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", "failed to fetch issue states"))
642642+ return
643643+ }
644644+645645+ writeJSON(w, http.StatusOK, issueEntry{
646646+ recordEntry: recordEntry{URI: rec.URI, CID: rec.CID, Value: rec.Value},
647647+ State: resolveIssueState(stateMap, rec.URI),
648648+ })
649649+}
650650+651651+// handleIssueComments returns all comments for a specific issue.
652652+// GET /issues/{handle}/{rkey}/comments
653653+func (s *Server) handleIssueComments(w http.ResponseWriter, r *http.Request) {
654654+ handle := r.PathValue("handle")
655655+ rkey := r.PathValue("rkey")
656656+657657+ actor, err := s.resolveActor(r, handle)
658658+ if err != nil {
659659+ s.actorError(w, err)
660660+ return
661661+ }
662662+663663+ issueURI := fmt.Sprintf("at://%s/sh.tangled.repo.issue/%s", actor.DID, rkey)
664664+665665+ entries, err := s.xrpc.ListAllRecords(r.Context(), actor.PDS, actor.DID, "sh.tangled.repo.issue.comment")
666666+ if err != nil {
667667+ writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", "failed to fetch comments"))
668668+ return
669669+ }
670670+671671+ var records []recordEntry
672672+ for _, e := range entries {
673673+ issue, _ := e.Value["issue"].(string)
674674+ if issue == issueURI {
675675+ records = append(records, recordEntry{URI: e.URI, CID: e.CID, Value: e.Value})
676676+ }
677677+ }
678678+ if records == nil {
679679+ records = []recordEntry{}
680680+ }
681681+682682+ writeJSON(w, http.StatusOK, map[string]any{
683683+ "did": actor.DID,
684684+ "handle": actor.Handle,
685685+ "issueUri": issueURI,
686686+ "records": records,
687687+ })
688688+}
689689+690690+// handlePullDetail returns a single pull request with its status.
691691+// GET /pulls/{handle}/{rkey}
692692+func (s *Server) handlePullDetail(w http.ResponseWriter, r *http.Request) {
693693+ handle := r.PathValue("handle")
694694+ rkey := r.PathValue("rkey")
695695+696696+ actor, err := s.resolveActor(r, handle)
697697+ if err != nil {
698698+ s.actorError(w, err)
699699+ return
700700+ }
701701+702702+ rec, err := s.xrpc.GetRecord(r.Context(), actor.PDS, actor.DID, "sh.tangled.repo.pull", rkey)
703703+ if err != nil {
704704+ s.actorError(w, err)
705705+ return
706706+ }
707707+708708+ _, statusMap, err := s.fetchPullsAndStatuses(r, actor.PDS, actor.DID)
709709+ if err != nil {
710710+ writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", "failed to fetch pull statuses"))
711711+ return
712712+ }
713713+714714+ writeJSON(w, http.StatusOK, pullEntry{
715715+ recordEntry: recordEntry{URI: rec.URI, CID: rec.CID, Value: rec.Value},
716716+ Status: resolvePullStatus(statusMap, rec.URI),
717717+ })
718718+}
719719+720720+// handlePullComments returns all comments for a specific pull request.
721721+// GET /pulls/{handle}/{rkey}/comments
722722+func (s *Server) handlePullComments(w http.ResponseWriter, r *http.Request) {
723723+ handle := r.PathValue("handle")
724724+ rkey := r.PathValue("rkey")
725725+726726+ actor, err := s.resolveActor(r, handle)
727727+ if err != nil {
728728+ s.actorError(w, err)
729729+ return
730730+ }
731731+732732+ pullURI := fmt.Sprintf("at://%s/sh.tangled.repo.pull/%s", actor.DID, rkey)
733733+734734+ entries, err := s.xrpc.ListAllRecords(r.Context(), actor.PDS, actor.DID, "sh.tangled.repo.pull.comment")
735735+ if err != nil {
736736+ writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", "failed to fetch comments"))
737737+ return
738738+ }
739739+740740+ var records []recordEntry
741741+ for _, e := range entries {
742742+ pull, _ := e.Value["pull"].(string)
743743+ if pull == pullURI {
744744+ records = append(records, recordEntry{URI: e.URI, CID: e.CID, Value: e.Value})
745745+ }
746746+ }
747747+ if records == nil {
748748+ records = []recordEntry{}
749749+ }
750750+751751+ writeJSON(w, http.StatusOK, map[string]any{
752752+ "did": actor.DID,
753753+ "handle": actor.Handle,
754754+ "pullUri": pullURI,
755755+ "records": records,
756756+ })
757757+}
758758+759759+func (s *Server) fetchIssuesAndStates(r *http.Request, pds, did string) ([]xrpc.ListRecordEntry, map[string]string, error) {
760760+ issueCh := make(chan []xrpc.ListRecordEntry, 1)
761761+ stateCh := make(chan []xrpc.ListRecordEntry, 1)
762762+ errCh := make(chan error, 2)
763763+764764+ go func() {
765765+ entries, err := s.xrpc.ListAllRecords(r.Context(), pds, did, "sh.tangled.repo.issue")
766766+ if err != nil {
767767+ errCh <- err
768768+ return
769769+ }
770770+ issueCh <- entries
771771+ }()
772772+ go func() {
773773+ entries, err := s.xrpc.ListAllRecords(r.Context(), pds, did, "sh.tangled.repo.issue.state")
774774+ if err != nil {
775775+ errCh <- err
776776+ return
777777+ }
778778+ stateCh <- entries
779779+ }()
780780+781781+ var issues []xrpc.ListRecordEntry
782782+ var states []xrpc.ListRecordEntry
783783+ for i := 0; i < 2; i++ {
784784+ select {
785785+ case e := <-issueCh:
786786+ issues = e
787787+ case e := <-stateCh:
788788+ states = e
789789+ case err := <-errCh:
790790+ return nil, nil, err
791791+ }
792792+ }
793793+794794+ stateMap := make(map[string]string, len(states))
795795+ for _, e := range states {
796796+ issueURI, _ := e.Value["issue"].(string)
797797+ state, _ := e.Value["state"].(string)
798798+ if issueURI != "" {
799799+ stateMap[issueURI] = state
800800+ }
801801+ }
802802+803803+ return issues, stateMap, nil
804804+}
805805+806806+func (s *Server) fetchPullsAndStatuses(r *http.Request, pds, did string) ([]xrpc.ListRecordEntry, map[string]string, error) {
807807+ pullCh := make(chan []xrpc.ListRecordEntry, 1)
808808+ statusCh := make(chan []xrpc.ListRecordEntry, 1)
809809+ errCh := make(chan error, 2)
810810+811811+ go func() {
812812+ entries, err := s.xrpc.ListAllRecords(r.Context(), pds, did, "sh.tangled.repo.pull")
813813+ if err != nil {
814814+ errCh <- err
815815+ return
816816+ }
817817+ pullCh <- entries
818818+ }()
819819+ go func() {
820820+ entries, err := s.xrpc.ListAllRecords(r.Context(), pds, did, "sh.tangled.repo.pull.status")
821821+ if err != nil {
822822+ errCh <- err
823823+ return
824824+ }
825825+ statusCh <- entries
826826+ }()
827827+828828+ var pulls []xrpc.ListRecordEntry
829829+ var statuses []xrpc.ListRecordEntry
830830+ for i := 0; i < 2; i++ {
831831+ select {
832832+ case e := <-pullCh:
833833+ pulls = e
834834+ case e := <-statusCh:
835835+ statuses = e
836836+ case err := <-errCh:
837837+ return nil, nil, err
838838+ }
839839+ }
840840+841841+ statusMap := make(map[string]string, len(statuses))
842842+ for _, e := range statuses {
843843+ pullURI, _ := e.Value["pull"].(string)
844844+ status, _ := e.Value["status"].(string)
845845+ if pullURI != "" {
846846+ statusMap[pullURI] = status
847847+ }
848848+ }
849849+850850+ return pulls, statusMap, nil
851851+}
852852+853853+func resolveIssueState(stateMap map[string]string, issueURI string) string {
854854+ raw := stateMap[issueURI]
855855+ if strings.HasSuffix(raw, ".closed") {
856856+ return "closed"
857857+ }
858858+ return "open"
859859+}
860860+861861+func resolvePullStatus(statusMap map[string]string, pullURI string) string {
862862+ raw := statusMap[pullURI]
863863+ switch {
864864+ case strings.HasSuffix(raw, ".merged"):
865865+ return "merged"
866866+ case strings.HasSuffix(raw, ".closed"):
867867+ return "closed"
868868+ default:
869869+ return "open"
870870+ }
871871+}
872872+873873+type bskyProfileResponse struct {
874874+ DisplayName string `json:"displayName,omitempty"`
875875+ Avatar string `json:"avatar,omitempty"`
876876+}
877877+878878+func (s *Server) fetchBskyProfile(r *http.Request, did string) *bskyProfileResponse {
879879+ body, err := s.xrpc.Call(r.Context(), "https://public.api.bsky.app", "app.bsky.actor.getProfile", url.Values{"actor": {did}})
880880+ if err != nil {
881881+ return nil
882882+ }
883883+ defer body.Close()
884884+885885+ var p bskyProfileResponse
886886+ if err := json.NewDecoder(body).Decode(&p); err != nil {
887887+ return nil
888888+ }
889889+ return &p
890890+}
891891+892892+// parseATURI splits an AT URI (at://did/collection/rkey) into its components.
893893+func parseATURI(uri string) (did, collection, rkey string, err error) {
894894+ trimmed := strings.TrimPrefix(uri, "at://")
895895+ parts := strings.SplitN(trimmed, "/", 3)
896896+ if len(parts) != 3 {
897897+ return "", "", "", fmt.Errorf("invalid AT URI: %q", uri)
898898+ }
899899+ return parts[0], parts[1], parts[2], nil
900900+}