// Issues on the project's tangled repository, offered as goals somebody could write. // // A tangled issue is an `sh.tangled.repo.issue` record in the repo of whoever filed it — a stranger, // usually, holding no grant in this space. So the same rule that governs guest comments governs this, // and for the same reason: **nothing here enters the fold.** Ingestion polls member repos for // `com.disnetdev.radial.*` and nothing else, so an issue is not in any `RecordStore`, not in // `materialize()`, and cannot reach a turn bundle. That safety property holds with no code at all, // and this module is careful not to be the thing that breaks it. // // What crosses into the trusted world is a GOAL — a record a member wrote, having read the issue on // screen, in their own words if they changed them, signed by them. The goal carries `source`, a // pointer saying which issue it was taken up from, and that is all it carries: the pointer is not in // any bundle (`core/test/bundle.test.mjs` asserts it), so no turn can follow it to text nobody // reviewed. An agent cannot tell a question from an instruction, and an issue tracker is open to // anyone with an atproto account. // // Three rules, the same three `guests.ts` follows: // // - **Discovery is view-time, and asked for.** Bobbin (tangled's public appview) has no SLA and is // somebody else's service; a tab that queried it on every project page would also be announcing // which repositories this browser is reading. So nothing here runs until a reader opens the // project's Tangled page, which is the same bargain the Userinput section makes with somebody // else's feedback board: the ask is the navigation, and the rail item that offers it costs a // stranger's service nothing. // - **The index is a hint, never an authority.** Bobbin returns locators and its own copy of each // record; every candidate is re-read from its author's own PDS and must still say it belongs to // this repository. An index that could decide which repository somebody's words were filed under // would be an index that could put text in front of a reader under false pretences. // - **An issue this space took up keeps its row, and says so.** The goal's `source.uri` is what // says so, which is why it is on-protocol rather than a note in somebody's browser: a second // member opening this list — on a cold browser, having imported nothing — sees what the first one // took up, which goal it became, and that importing it again is a decision rather than a mistake. import { GOAL_SOURCE_TANGLED_ISSUE, TANGLED_ISSUE_COLLECTION, type GoalView } from '@radial/core' import { seedText, type GoalSeed } from './import-goal.js' /** * Tangled's own read-only appview. A constant rather than a literal at the call site for the reason * `CONSTELLATION_ORIGIN` is one: it is somebody else's public instance, Bobbin is self-hostable, and * an operator who ends up needing to own it should have one place to point this at. * * `sh.tangled.repo.listIssues?subject=` mirrors the `listPulls` the daemon already reads * (`daemon/src/bobbin.ts`), down to the response shape: `{ items: [{ uri, cid, value, state }] }`. * Verified live against api.tangled.org in July 2026, including its `access-control-allow-origin: *`, * which is what makes a browser a legal caller at all. */ export const TANGLED_APPVIEW = 'https://api.tangled.org' /** The hosts this recognises as tangled. Mirrors `DEFAULT_TANGLED_HOSTS` in the daemon's adapter. */ export const TANGLED_HOSTS = ['tangled.org'] as const /** The record a repository's `sh.tangled.repo` metadata lives in — where a repo's own DID is read. */ export const TANGLED_REPO_COLLECTION = 'sh.tangled.repo' /** Per appview call. Bobbin caps a page at 100, like every other atproto list. */ export const ISSUE_PAGE = 100 /** How many pages one look walks. Closed issues are filtered here, so a long-lived repository needs * more than one page to fill a screen of open ones. */ export const ISSUE_PAGES = 3 /** How many candidates a look will offer. A repository with hundreds of open issues is a repository * whose backlog belongs on the forge; this is a way in, not a second issue tracker. */ export const CANDIDATE_LIMIT = 50 export type Fetcher = (input: string, init?: RequestInit) => Promise const object = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) const string = (value: unknown): string | undefined => typeof value === 'string' && value.length > 0 ? value : undefined /** The DID of an `at://` record URI — the repo it came from, which is what makes it provenance. */ export const didOf = (uri: string): string => uri.startsWith('at://') ? (uri.slice('at://'.length).split('/')[0] ?? '') : '' // ── which repository, and whose ───────────────────────────────────────────────────────────────── /** A repository on a tangled host, as its URL spells it. */ export interface TangledLocation { host: string /** A handle, an `@handle`, or a DID — whatever the project's remote was written with. */ owner: string repo: string } /** * `https://tangled.org//`, `git@tangled.org:/` and * `ssh://git@tangled.org//` all name the same repository. * * Returns undefined rather than throwing, unlike the daemon's `parseTangledRepoUrl`: the daemon is * routing a forge operation it has already decided to perform, while the only question here is "does * this project have issues we could offer?", and a GitHub project's answer to that is no, not an * error. */ export function tangledRepo( gitUrl: string, hosts: readonly string[] = TANGLED_HOSTS, ): TangledLocation | undefined { let host: string let path: string if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(gitUrl)) { let url: URL try { url = new URL(gitUrl) } catch { return undefined } host = url.hostname.toLowerCase() path = url.pathname } else { const scp = /^(?:[^@/:]+@)?([^@/:]+):(.+)$/.exec(gitUrl) if (!scp) return undefined host = (scp[1] as string).toLowerCase() path = scp[2] as string } if (!hosts.some((candidate) => candidate.toLowerCase() === host)) return undefined const segments = path.replace(/^\/+/, '').replace(/\.git$/, '').split('/') const [owner, repo] = segments if (segments.length !== 2 || !owner || !repo) return undefined return { host, owner, repo } } /** The repository's issue list on the forge. The only link this section can honestly offer: an * issue's own page is numbered by the appview's database, and the number is in neither the record * nor Bobbin's answer, so a per-issue URL would be a guess. */ export const issuesPageUrl = (location: TangledLocation): string => `https://${location.host}/${location.owner}/${location.repo}/issues` /** The reads resolving a repository needs. `FetchRepoTransport` satisfies all three. */ export interface RepoReader { listForeignRecords(input: { did: string collection: string cursor?: string limit: number signal?: AbortSignal }): Promise<{ records: Array<{ uri: string; cid: string; value: unknown }>; cursor?: string }> getForeignRecord(uri: string, signal?: AbortSignal): Promise<{ uri: string; cid: string; value: unknown }> } /** Handle → DID. The browser has no DNS, so the caller supplies a directory to ask — a PDS this tab * already reads, exactly as `write.ts` resolves an assignee's handle against the writer's own. */ export type HandleResolver = (handle: string) => Promise /** * A tangled repository's own DID, which is NOT its owner's — the fact the daemon's adapter is built * around (`forge-tangled.ts`, THE IDENTITY RULE) and which is silently fatal to get wrong: an issue's * `repo` field names the repository DID, and comparing it against an owner DID is always false. * * Same algorithm as the daemon's, and deliberately so. The owner's handle resolves to a DID; the * `sh.tangled.repo` record lives in the OWNER's repo on a real PDS and its `repoDid` is the answer. * Two rkey conventions live side by side on tangled — older records are keyed by the repo name, newer * ones by a tid with the name in `value.name` — so the owner's records are scanned and matched on * either. A record without `repoDid` predates the field, and its issues can only have named the owner. * * Read from the owner's PDS rather than from Bobbin: this is the fact everything else is checked * against, so it comes from the repo that holds it. */ export async function resolveRepoDid(input: { location: TangledLocation reader: RepoReader resolveHandle: HandleResolver pages?: number signal?: AbortSignal }): Promise { const owner = input.location.owner.replace(/^@/, '') const ownerDid = owner.startsWith('did:') ? owner : await input.resolveHandle(owner) if (!ownerDid.startsWith('did:')) { throw new Error(`${owner} resolved to a malformed DID: ${ownerDid}`) } let cursor: string | undefined for (let page = 0; page < (input.pages ?? ISSUE_PAGES); page += 1) { const result = await input.reader.listForeignRecords({ did: ownerDid, collection: TANGLED_REPO_COLLECTION, limit: ISSUE_PAGE, ...(cursor ? { cursor } : {}), ...(input.signal ? { signal: input.signal } : {}), }) for (const record of result.records) { const value = object(record.value) ? record.value : {} const named = string(value.name) ?? record.uri.slice(record.uri.lastIndexOf('/') + 1) if (named === input.location.repo) return string(value.repoDid) ?? ownerDid } cursor = result.cursor if (!cursor || result.records.length === 0) break } throw new Error( `no ${TANGLED_REPO_COLLECTION} record named "${input.location.repo}" under ${owner}`, ) } // ── the issues themselves ─────────────────────────────────────────────────────────────────────── /** What Bobbin hands out per issue: where it is, and the state it folded from the firehose. */ export interface IssueLocator { uri: string cid: string /** `open` / `closed` today. Read as a string rather than compared to an enum — tangled's lexicons * are `knownValues` and pre-1.0, and an unfamiliar word must not read as "open". */ state?: string } /** * One page of `sh.tangled.repo.listIssues`, newest first. * * Anything unexpected in the response is dropped rather than thrown on, exactly as the guest-comment * backlink page does: an entry whose shape we do not recognise is an entry we cannot fetch, and one * malformed item must not lose the page it arrived in. A failed REQUEST does throw — "we could not * look" and "there is nothing" are different sentences, and only one of them is worth a reader's * attention. */ export async function listIssues(input: { repoDid: string cursor?: string limit?: number origin?: string fetcher?: Fetcher signal?: AbortSignal }): Promise<{ items: IssueLocator[]; cursor?: string }> { const query = new URLSearchParams({ subject: input.repoDid, limit: String(input.limit ?? ISSUE_PAGE), ...(input.cursor ? { cursor: input.cursor } : {}), }) const url = `${input.origin ?? TANGLED_APPVIEW}/xrpc/sh.tangled.repo.listIssues?${query}` const response = await (input.fetcher ?? fetch)(url, input.signal ? { signal: input.signal } : undefined) if (!response.ok) throw new Error(`tangled's appview answered ${response.status}`) const payload: unknown = await response.json() const body = object(payload) ? payload : {} const raw = Array.isArray(body.items) ? body.items : [] const items: IssueLocator[] = [] for (const entry of raw) { if (!object(entry)) continue const uri = string(entry.uri) const cid = string(entry.cid) if (!uri || !cid) continue items.push({ uri, cid, ...(string(entry.state) ? { state: string(entry.state) as string } : {}) }) } return { items, ...(string(body.cursor) ? { cursor: string(body.cursor) as string } : {}) } } /** An issue as this tab read it, from the repo of whoever filed it. */ export interface IssueCandidate { uri: string /** The version that was read. The goal pins exactly this, so "the issue changed after we imported * it" is a question anyone can answer later. */ cid: string /** Whoever filed it. Not a member of this space, in general — the row says so. */ did: string title: string body: string createdAt: string } /** * One fetched record, if it really is an issue on this repository. * * Both checks matter and neither is redundant. The shape check is what stops a record shaped like * anything at all being rendered as an issue; the `repo` check is what stops the INDEX deciding which * repository somebody's words were filed against — the same division of labour `asGuestComment` makes * between the message lexicon and the goal it names. * * There is no Radial lexicon to validate against here, so the shape is checked by hand and defensively * (`forge-tangled.ts` explains why every tangled record is treated as untrusted input: the lexicons * are pre-1.0 and they move). A body is optional — an issue can be a title and nothing else. */ export function asIssue( record: { uri: string; cid: string; value: unknown }, repoDid: string, ): IssueCandidate | undefined { if (!record.uri.includes(`/${TANGLED_ISSUE_COLLECTION}/`)) return undefined const value = object(record.value) ? record.value : undefined if (!value || value.$type !== TANGLED_ISSUE_COLLECTION) return undefined const title = string(value.title) const createdAt = string(value.createdAt) if (!title || !createdAt) return undefined if (string(value.repo) !== repoDid) return undefined return { uri: record.uri, cid: record.cid, did: didOf(record.uri), title, body: typeof value.body === 'string' ? value.body : '', createdAt, } } /** * Every issue this space has already taken up, by the URI its goals pinned. * * Read off the goals rather than kept anywhere: `source` is on-protocol precisely so that the answer * is the same for every member and survives a cold browser. Ended goals count — an issue that was * imported and then dropped has been decided about, and a row that said nothing about it would have * the next reader re-litigate that decision without knowing there had been one. * * Every goal that named an issue, not the first one: the shape `goalsByOrigin` has in the fold, and * for the same reason. Two members racing on one issue is a fact the row should be able to say out * loud rather than a duplicate for it to hide. * * `kind` is what makes this one join rather than one per forge — a goal imported from GitHub is not a * tangled row's business, and matching on the kind rather than on the URI's shape means a forge this * build has never heard of is simply not ours. It defaults to the tangled kind because this module is * tangled's; `github-issues.ts` passes its own. */ export function importedIssues( goals: readonly GoalView[], kind: string = GOAL_SOURCE_TANGLED_ISSUE, ): Map { const taken = new Map() for (const goal of goals) { const source = goal.target.value.source if (source?.kind !== kind) continue const matches = taken.get(source.uri) ?? [] matches.push(goal) taken.set(source.uri, matches) } return taken } /** A candidate, plus what this space has already made of it. `FeedbackRow`'s twin, and the same * split for the same reason: everything in the candidate came off the network, and `imported` is our * own fold, which a member changes by creating a goal and which is already in memory. */ export interface IssueRow extends IssueCandidate { imported: GoalView[] } /** * The candidates a look returned, joined to this space's own account of them at the point of drawing. * * By URI and not by version: a goal written from an earlier draft of an issue was still written from * THIS issue, and a row that hid the goal would invite a second member to import it again. */ export const issueRows = ( taken: ReadonlyMap, found: readonly IssueCandidate[], ): IssueRow[] => found.map((candidate) => ({ ...candidate, imported: taken.get(candidate.uri) ?? [] })) export interface Discovery { /** Every open issue this look could read, oldest first — the order a backlog is worked through. * * Including the ones this space has already taken up. They are what `issueRows` badges, links to * their goals and offers again, and a look is the ONLY thing that can produce them: the fold knows * an issue's URI and nothing else about it, so a page that dropped them here could draw an imported * issue only in the seconds after somebody imported it, and never on a cold navigation. */ candidates: IssueCandidate[] /** Hits that could not be turned into a candidate — a PDS that was down, a record deleted or * rewritten since Bobbin indexed it, an entry that never named this repository. Surfaced rather * than swallowed, for the reason `discoverGuestComments` surfaces its own. */ failed: number /** The page budget ran out with issues still unread. */ truncated: boolean } /** * The open issues on a repository, read from the repos of the people who filed them. * * Order of operations is the point. Bobbin says WHERE issues are and which it folded as open; only * then is each one read from its author's own repo, which is the copy the reader is shown and the * version a goal pins. * * `taken` does not decide WHETHER an issue is read, only in which order the budget is spent on them. * An earlier draft dropped an already-imported issue here, to make a mostly-imported repository cost * one appview call and no PDS reads — and that saving was paid for by the row: an issue somebody took * up last week has a URI in the fold and nothing else, no title, no body and no filer, so a page that * never fetched it could not draw it at all. The badge, the link to the goal and "import again" would * then be reachable only in the seconds after an import, in the one browser that made it. The budget * still goes to what is still waiting, because that is what a backlog is for. */ export async function discoverIssues(input: { repoDid: string taken: ReadonlySet reader: Pick origin?: string fetcher?: Fetcher pages?: number limit?: number signal?: AbortSignal }): Promise { const pages = input.pages ?? ISSUE_PAGES const ceiling = input.limit ?? CANDIDATE_LIMIT const seen = new Set() const waiting: IssueLocator[] = [] const already: IssueLocator[] = [] let cursor: string | undefined let truncated = false for (let page = 0; page < pages; page += 1) { const result = await listIssues({ repoDid: input.repoDid, ...(cursor ? { cursor } : {}), ...(input.origin ? { origin: input.origin } : {}), ...(input.fetcher ? { fetcher: input.fetcher } : {}), ...(input.signal ? { signal: input.signal } : {}), }) for (const item of result.items) { // Only what tangled itself folded as open. A closed issue is a decision the forge already // recorded, and re-proposing it here would be Radial arguing with it. if (item.state !== 'open' || seen.has(item.uri)) continue seen.add(item.uri) ;(input.taken.has(item.uri) ? already : waiting).push(item) } cursor = result.cursor if (!cursor) break if (page === pages - 1) truncated = true } // Both, and the ones nobody has taken up first. A repository with more open issues than this view // loads is one whose backlog belongs on the forge, and the rows worth spending that budget on are // the ones still waiting for somebody — but an imported issue is dropped only when there is no room // left, never on principle. const found = [...waiting, ...already] if (found.length > ceiling) truncated = true const hits = found.slice(0, ceiling) const fetched = await Promise.all( hits.map(async (hit) => { try { return await input.reader.getForeignRecord(hit.uri, input.signal) } catch { return undefined } }), ) const candidates: IssueCandidate[] = [] let failed = 0 for (const record of fetched) { const candidate = record ? asIssue(record, input.repoDid) : undefined if (candidate) candidates.push(candidate) else failed += 1 } // (createdAt, uri) — the order every list in the fold uses. Oldest first here, unlike the goal // list: the question a backlog asks is what has been waiting longest. candidates.sort( (left, right) => left.createdAt.localeCompare(right.createdAt) || left.uri.localeCompare(right.uri), ) return { candidates, failed, truncated } } // ── what importing one writes ─────────────────────────────────────────────────────────────────── /** * The seed a candidate becomes. Truncation, the warning and the seed's own shape are `import-goal.ts`'s * — shared with GitHub's importer, because the composer is one card and the ceilings are one lexicon's. * * The filer is a DID here, which is the whole of what tangled has that GitHub does not: an issue is a * record in the repo of whoever filed it, so the card can resolve them the way it resolves every other * name in the app. */ export const goalSeed = (candidate: IssueCandidate): GoalSeed => ({ ...seedText(candidate.title, candidate.body), source: { kind: GOAL_SOURCE_TANGLED_ISSUE, uri: candidate.uri, cid: candidate.cid }, filer: { kind: 'did', did: candidate.did }, })