A collaborative coding-agent orchestrator for atproto radl.app

add support for github issues #150

targeting main from radial/impl-4189dbeef111
Open opened by and assigned to claudebot.disnetdev.com
targeting main from radial/impl-4189dbeef111

GitHub issues as goal-import candidates #

Implements the plan step for step: GitHub issues get the tangled feature's shape — a view-time discovery module, a page of unit rows, importing through the ordinary goal composer, and goal.source as the on-protocol record of what a space took up. No lexicon change (source.kind is an open string, so pnpm codegen produces no diff — verified), no materializer or fold change, no daemon change, no ingest change.

Provenance #

Branch radial/impl-4189dbeef111 (from main @ 2470b3e)
Commit d82a850bd3955e92536f147d291bae71d7213ef8
Pull request not opened — tangled has no CLI for it; Radial opens it from the pushed branch

What landed #

core — GOAL_SOURCE_GITHUB_ISSUE = 'github-issue', plus parseGitHubIssueUrl and the isGitHubIssueUrl predicate built on it: parseGitHubPullUrl's strictness (https, no credentials, port, query or fragment, /<owner>/<repo>/issues/<positive n>) over parseGitHubRepo's hosts (github.com, www.github.com). Isomorphic — URL and nothing else. validation.ts checks source.uri against it when the kind is github-issue, beside the tangled check. Unknown kinds stay valid: the rule is per-kind consistency, never an allowlist, so goals signed today survive a build that learns a third forge.

The plan asked for one exported predicate; I exported the parse function beside it because asGitHubIssue needs owner/repo to check that an entry belongs to this repository, and a second hand-rolled parse in the UI is exactly the drift the shared predicate exists to prevent.

sidecar — goalSource() accepts both kinds, each URI checked in its own terms (a collection for tangled, a canonical URL for GitHub, through core's predicate). The unknown-kind error now names both. The all-or-nothing pair rule and the --source-cid dependency are unchanged.

ui lib — import-goal.ts holds what both importers share: GoalSeed, the goal lexicon's ceilings and the truncation at them, and importWarning (whose sentence already named no forge). The seed's filer became SeedFiler — {kind:'did'} resolves through identify() as before, {kind: 'label', href?} is used as-is, because a GitHub login is not an atproto identity and feeding one to identify() would ask the directory a question it cannot answer. importedIssues took a kind parameter, defaulted to the tangled constant.

github-issues.ts mirrors issues.ts section for section: githubRepo (the undefined-returning twin of the daemon's throwing parser), one paged GET /repos/{owner}/{repo}/issues?state=open&sort=created &direction=asc, defensive per-entry decode, the taken-orders-the-budget rule, (createdAt, uri) ordering, and githubGoalSeed.

ui surfaces — githubHref in space.ts; routes/p/[project]/github/+page.svelte, the tangled page's twin with three deltas; a GitHub item in Rail.svelte and on the project page, conditioned on githubRepo(project.gitUrl) and mutually exclusive with Tangled by host.

Docs — packages/ui/README.md (three lib-map rows and a section saying which rule collapsed and why), docs/design.md §13 (a paragraph making "forge issue" hold per forge without a per-forge argument), packages/lexicons/README.md (two minted kinds, each checked in its own terms).

Two deliberate departures from the plan #

Pull requests are not counted as failed. The plan said failed counts everything asGitHubIssue drops. GitHub models a pull as an issue, so the endpoint answers with both by design — a repository with an active branch queue would have announced that a dozen issues went missing. They are dropped through an exported isPullRequest before the count, and asGitHubIssue still refuses one (both the unit test and the component test assert a pull never renders). Genuinely unreadable entries are still counted and still said out loud.

A .mark class rather than a bare glyph. The plan said the row leads with a neutral glyph instead of a Disc. A row's leading element has to live in the 18px gutter that app.css reserves at narrow widths (.row > .dot, .row > .disc), so a loose <span> would have cost the row an extra line on mobile. .mark is drawn in the disc's own box and added to that selector, with the reason — a disc's colour and initials are derived from a DID, and a GitHub login is not one — stated where it is defined.

Verification #

pnpm lint (typecheck, type-strip lint, generate.mjs --check, NUL check) and pnpm typecheck: exit 0. pnpm test: exit 0 — core 428, daemon 596, sidecar 136, ingest 50, atproto 35, transport-iroh 7, transport-iroh-wasm 13, ui 96 node + 945 vitest across 70 files, 0 failures anywhere. That includes materializer.test.mjs unchanged (the fold is untouched), browser-bundle.test.mjs (no node:* on the isomorphic path — the new module is plain fetch plus core imports, nothing to lazily import), and the tangled page's own suite, whose only edit is the seed's filer shape.

New coverage: github-issues.test.ts (34 cases — remote spellings including www., PR filtering, nullable body, wrong-repo and case-insensitive html_url, pagination, taken budget ordering, truncation, ceiling, the rate-limit/404/plain-403 branches, seed shape, the importedIssues kind parameter) and github-page.svelte.test.ts (13 cases, mounted — row furniture, the body as text and never markup, a PR never rendering, the imported badge from the fold including the cold path, import seeding the composer and writing nothing, one look per remote rather than per sync tick, the error and empty and non-GitHub and fixture and read-only states). validation.test.mjs gained a github-issue case; bundle.test.mjs's source-exclusion test now runs once per minted kind, so a forge added later inherits it rather than being incidentally covered.

GitHub's CORS was verified live (2026-08-18, as the plan asked): access-control-allow-origin: *, x-ratelimit-remaining on the exposed-headers list, and x-ratelimit-limit: 60. The exposed header is load-bearing — it is what lets the 403 branch tell a reader "come back in an hour" rather than "nothing could be read".

Remaining risks #

  • Manual verification against a live repository was not run — the plan's step 5, which needs a browser and a project pointed at a real GitHub repo. Everything it would check is covered by the mounted component test against a faked fetch, but the live CORS preflight in a real browser (as opposed to the header check above) is untested here.
  • A repository rename breaks the badge. source.uri is the html_url at import time, so rediscovered issues carry new URLs and old imports lose their badge; the goals are untouched. Accepted and documented, with the note not to "fix" it by joining on issue number.
  • The unauthenticated rate limit is per IP. Several readers behind one NAT share 60 requests an hour. Bounded by design (≤3 requests per deliberate page-open) and named honestly when hit; there is deliberately no token path.
  • Three vitest --project components files (JoinPrivate, NewMember, UnitDetail) fail in a container with no Rust toolchain, because @radial/transport-iroh-wasm has no built pkg/. This is pre-existing and unrelated — confirmed against the tree without these changes — and pnpm test, which builds first, is green.

Radial artifact

Commits 1

GitHub issues as goal-import candidates