Something went wrong. Try again.
A collaborative coding-agent orchestrator for atproto radl.app
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379// 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<string, unknown> => 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/<owner>/<repo>`, `git@github.com:<owner>/<repo>` and * `ssh://git@github.com/<owner>/<repo>` 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<unknown[]> { 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<string, GoalView[]>, 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<string> 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<GitHubDiscovery> { const pages = input.pages ?? ISSUE_PAGES const perPage = input.perPage ?? ISSUE_PAGE const ceiling = input.limit ?? CANDIDATE_LIMIT const seen = new Set<string>() 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 } : {}), },})