// Issues on the project's GitHub repository, offered as goals somebody could write. // // `issues.ts` is the tangled original and this is its twin, section for section, because the safety // argument is identical and worth being identical: **nothing here enters the fold.** Ingestion polls // member repos for `com.disnetdev.radial.*` records; a GitHub issue is not a record at all, so it is // in no `RecordStore`, no `materialize()`, and no turn bundle. 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 — carrying `source` as a pointer and nothing more. The pointer is in no // bundle (`core/test/bundle.test.mjs` asserts it per kind), 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 a GitHub account. // // The three rules from `issues.ts`, and what GitHub does to each: // // - **Discovery is view-time, and asked for.** Unchanged, and if anything sharper. Nothing runs // until a reader opens the project's GitHub page, and the ask is the navigation. Opening it tells // GitHub that some browser is reading this repository's issues; that is the bargain, it is made // once per look rather than on every project page, and it is the same one the Tangled and // Userinput pages make with somebody else's service. // - **The index is a hint, never an authority.** This one COLLAPSES here, and saying so is the // point — its absence is not a hole. A tangled issue is a record in the repo of whoever filed it, // so Bobbin is only a locator and every candidate is re-read from its author's own PDS. A GitHub // issue lives in GitHub's database and nowhere else: `GET /repos/{owner}/{repo}/issues` *is* the // repository's own copy, so there is no second copy to check the first against and no per-issue // fetch to make. The one thing the rule protected that survives is the repository check // (`asGitHubIssue`), kept because it is one comparison and the property — the words under this // row were filed on THIS repository — is load-bearing. // - **An issue this space took up keeps its row, and says so.** Unchanged: the goal's `source.uri` // says so, on-protocol, so a second member on a cold browser sees what the first took up and which // goal it became. // // Three things about GitHub specifically, each of which the code below turns into a decision: // // - **The filer has no DID.** A `user.login` is not an atproto identity, so there is no `identify()` // round-trip and no membership of this space to check. It travels as a label with its profile URL. // - **There is no version to pin.** GitHub content is not content-addressed, so the goal's optional // `source.cid` is simply absent and "did the issue change after import?" has no GitHub answer. // `source` was always an assertion by the member who signed it rather than a proof. // - **A rename breaks the join.** `source.uri` is the `html_url` at import time, so a renamed or // transferred repository's rediscovered issues carry new URLs and old imports lose their badge. // The goals are untouched. Accepted, and NOT to be "fixed" by joining on issue number: a number is // unique only within a repository identity, which is exactly what a rename changes. // // Calls here are unauthenticated on purpose: 60 requests/hour/IP, which a look costing at most // `ISSUE_PAGES` requests fits inside comfortably, and a browser holding an operator's GitHub token // would be a new trust surface this feature does not need. The two failure modes that follow — the // rate limit, and a repository an anonymous caller cannot see — get sentences of their own, because // they are the two a reader can act on. import { GOAL_SOURCE_GITHUB_ISSUE, parseGitHubIssueUrl, type GoalView } from '@radial/core' import { seedText, type GoalSeed } from './import-goal.js' import { CANDIDATE_LIMIT, ISSUE_PAGE, ISSUE_PAGES, type Fetcher } from './issues.js' /** * GitHub's REST API. A constant rather than a literal at the call site for the `TANGLED_APPVIEW` and * `CONSTELLATION_ORIGIN` reason: one place to point at something else later. GitHub Enterprise is out * of scope here exactly as it is in the daemon's forge adapter — a GHES host needs a credential, and * the whole shape of this module is that it holds none. * * Verified live in August 2026, including `access-control-allow-origin: *` and * `x-ratelimit-remaining` on the exposed-headers list, which is what makes a browser a legal caller * and lets the rate-limit branch below tell a reader something true. */ export const GITHUB_API = 'https://api.github.com' /** The hosts this recognises as GitHub. Mirrors the daemon's `parseGitHubRepo` * (`daemon/src/forge-github.ts`), the way `TANGLED_HOSTS` mirrors `DEFAULT_TANGLED_HOSTS`. */ export const GITHUB_HOSTS = ['github.com', 'www.github.com'] as const 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 // ── which repository ──────────────────────────────────────────────────────────────────────────── /** A repository on GitHub, as the API and the URL both spell it. */ export interface GitHubLocation { owner: string repo: string } /** * `https://github.com//`, `git@github.com:/` and * `ssh://git@github.com//` all name the same repository. * * The undefined-returning twin of the daemon's throwing `parseGitHubRepo`, and `tangledRepo`'s twin on * this side, for `tangledRepo`'s reason: 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 * tangled project's answer to that is no, not an error. Keep it in step with the daemon's parser. */ export function githubRepo( gitUrl: string, hosts: readonly string[] = GITHUB_HOSTS, ): GitHubLocation | 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 { owner, repo } } /** The repository's issue list on the forge. Unlike tangled, this is not the only page nameable: an * issue's own `html_url` is canonical and every row links to it. */ export const issuesPageUrl = (location: GitHubLocation): string => `https://github.com/${location.owner}/${location.repo}/issues` // ── the issues themselves ─────────────────────────────────────────────────────────────────────── /** An issue as GitHub served it. There is no `cid`: GitHub content is not content-addressed. */ export interface GitHubIssueCandidate { /** The canonical page it lives on, which is also what the goal's `source.uri` will be — so the * discovery and the join agree by construction rather than by two spellings of one rule. */ uri: string number: number title: string body: string createdAt: string /** Whoever filed it. A GitHub login, never an atproto identity — the row says so. */ login: string /** Their profile page, when the response named one. */ profileUrl?: string } /** What a look could not do, told apart from what it found. */ export class GitHubIssuesError extends Error {} /** * GitHub models a pull request as an issue, so the issues endpoint answers with both. * * Told apart here as well as inside `asGitHubIssue` — which refuses one too — because the two * refusals mean different things to a reader. A pull request in this response is the endpoint working * as documented and there is nothing to report; an entry `asGitHubIssue` could not read is something * the page says out loud. Counting pulls as unreadable would have a repository with an active branch * queue announce that a dozen issues went missing. */ export const isPullRequest = (entry: unknown): boolean => object(entry) && 'pull_request' in entry /** * One page of `GET /repos/{owner}/{repo}/issues`, open issues only, oldest first. * * Anything unexpected in the response is dropped rather than thrown on, exactly as `listIssues` does: * an entry whose shape we do not recognise is an entry we cannot render, 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. Two statuses * get sentences of their own, because they are the two a reader can act on: the anonymous rate limit * (come back in an hour) and a repository this call cannot see (private, renamed, or gone). */ export async function listGitHubIssues(input: { repo: GitHubLocation page?: number origin?: string fetcher?: Fetcher limit?: number signal?: AbortSignal }): Promise { const query = new URLSearchParams({ state: 'open', sort: 'created', direction: 'asc', per_page: String(input.limit ?? ISSUE_PAGE), page: String(input.page ?? 1), }) const url = `${input.origin ?? GITHUB_API}/repos/${encodeURIComponent(input.repo.owner)}/${encodeURIComponent(input.repo.repo)}/issues?${query}` const response = await (input.fetcher ?? fetch)(url, { headers: { Accept: 'application/vnd.github+json' }, ...(input.signal ? { signal: input.signal } : {}), }) if (!response.ok) { // Rate limiting is the failure this feature will actually meet, and "GitHub answered 403" would // send a reader looking for a permission they do not need. GitHub says which it is in a header it // deliberately exposes to browsers. if (response.status === 403 && response.headers.get('x-ratelimit-remaining') === '0') { throw new GitHubIssuesError( "GitHub's hourly limit for unauthenticated reads is used up on this network — it resets within the hour", ) } if (response.status === 404) { throw new GitHubIssuesError( 'GitHub has no public repository at this address — it may be private, renamed, or gone', ) } throw new GitHubIssuesError(`GitHub answered ${response.status}`) } const payload: unknown = await response.json() return Array.isArray(payload) ? payload : [] } /** * One entry from that page, if it really is an open issue on this repository. * * Three refusals, and none of them is redundant. **Pull requests** come back from the issues endpoint * — GitHub models them as issues — and are told apart by the `pull_request` key they carry; a pull is * work already in flight, not a goal somebody could take up. **The repository check** survives the * collapse of the index-versus-authority rule described at the top: it is one comparison, and a * response echoing a different repository must not be rendered under this one. And **the state check** * is defensive belt-and-braces over `state=open` in the query, the same scepticism `asIssue` applies * to a lexicon it does not own. */ export function asGitHubIssue( entry: unknown, repo: GitHubLocation, ): GitHubIssueCandidate | undefined { if (!object(entry)) return undefined if ('pull_request' in entry) return undefined const uri = string(entry.html_url) if (!uri) return undefined const parsed = parseGitHubIssueUrl(uri) if (!parsed) return undefined if ( parsed.owner.toLowerCase() !== repo.owner.toLowerCase() || parsed.repo.toLowerCase() !== repo.repo.toLowerCase() ) { return undefined } const title = string(entry.title) const createdAt = string(entry.created_at) if (!title || !createdAt) return undefined if (entry.state !== 'open') return undefined const user = object(entry.user) ? entry.user : {} const login = string(user.login) if (!login) return undefined const profileUrl = string(user.html_url) return { uri, number: parsed.number, title, // Nullable on GitHub, unlike tangled's optional field, and the same answer either way: an issue // can be a title and nothing else. body: typeof entry.body === 'string' ? entry.body : '', createdAt, login, ...(profileUrl ? { profileUrl } : {}), } } /** A candidate, plus what this space has already made of it. `IssueRow`'s twin, and the same split for * the same reason: everything in the candidate came off the network, and `imported` is our own fold. */ export interface GitHubIssueRow extends GitHubIssueCandidate { imported: GoalView[] } /** * The candidates a look returned, joined to this space's own account of them at the point of drawing. * * By the issue's URL, which is the only name it has. `issueRows`'s twin. */ export const githubIssueRows = ( taken: ReadonlyMap, found: readonly GitHubIssueCandidate[], ): GitHubIssueRow[] => found.map((candidate) => ({ ...candidate, imported: taken.get(candidate.uri) ?? [] })) export interface GitHubDiscovery { /** 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, and for `issues.ts`'s reason: the fold knows * an imported issue's URL and nothing else, 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: GitHubIssueCandidate[] /** Entries that could not be turned into a candidate — a shape we do not recognise, an entry that * named a different repository. Surfaced rather than swallowed, for the reason `discoverIssues` * surfaces its own. Pull requests are not in this count: see `isPullRequest`. */ failed: number /** The page budget or the ceiling ran out with issues still unread. */ truncated: boolean } /** * The open issues on a GitHub repository. * * One paged list call and no fan-out, which is the whole structural difference from `discoverIssues`: * the list response carries titles and bodies because GitHub's copy IS the repository's copy, so there * is nothing to re-read and nothing an index could have lied about. * * `taken` does not decide WHETHER an issue is offered, only in which order the budget is spent on * them — `discoverIssues`'s rule, kept deliberately even though it costs less here: a page that * dropped already-imported issues could not draw their rows at all on a cold navigation, and to a * reader that reads as "somebody closed it on the forge". */ export async function discoverGitHubIssues(input: { repo: GitHubLocation taken: ReadonlySet origin?: string fetcher?: Fetcher pages?: number limit?: number /** How many the API is asked for per request. Only a test names it. */ perPage?: number signal?: AbortSignal }): Promise { const pages = input.pages ?? ISSUE_PAGES const perPage = input.perPage ?? ISSUE_PAGE const ceiling = input.limit ?? CANDIDATE_LIMIT const seen = new Set() const waiting: GitHubIssueCandidate[] = [] const already: GitHubIssueCandidate[] = [] let failed = 0 let truncated = false for (let page = 1; page <= pages; page += 1) { const entries = await listGitHubIssues({ repo: input.repo, page, limit: perPage, ...(input.origin ? { origin: input.origin } : {}), ...(input.fetcher ? { fetcher: input.fetcher } : {}), ...(input.signal ? { signal: input.signal } : {}), }) for (const entry of entries) { // Not counted, only skipped: the endpoint answering with pulls is documented behaviour, and // `failed` is for what a reader should be told about. if (isPullRequest(entry)) continue const candidate = asGitHubIssue(entry, input.repo) if (!candidate) { failed += 1 continue } if (seen.has(candidate.uri)) continue seen.add(candidate.uri) ;(input.taken.has(candidate.uri) ? already : waiting).push(candidate) } // A short page is the last page; a full one means there may be more. GitHub pages by number // rather than by cursor, so this is the only signal there is. if (entries.length < perPage) break if (page === pages) truncated = true } const found = [...waiting, ...already] if (found.length > ceiling) truncated = true const candidates = found.slice(0, ceiling) // (createdAt, uri) — the order every list in the fold uses. Oldest first, like the tangled page and // for the same reason: the question a backlog asks is what has been waiting longest. GitHub was // asked for that order too, but the sort is here because the answer arrived over several pages. 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 and the warning are `import-goal.ts`'s, shared with the * tangled importer. * * No `cid`, which is the whole of what GitHub has that tangled does not have an answer for: there is * no version of an issue to pin, so the pointer says which issue and not which draft of it. And the * filer is a label rather than a DID — a GitHub login is not an atproto identity, so it is shown as * itself, linked to the profile it came from. */ export const githubGoalSeed = (candidate: GitHubIssueCandidate): GoalSeed => ({ ...seedText(candidate.title, candidate.body), source: { kind: GOAL_SOURCE_GITHUB_ISSUE, uri: candidate.uri }, filer: { kind: 'label', label: candidate.login, ...(candidate.profileUrl ? { href: candidate.profileUrl } : {}), }, })