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.uriis thehtml_urlat 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 componentsfiles (JoinPrivate,NewMember,UnitDetail) fail in a container with no Rust toolchain, because@radial/transport-iroh-wasmhas no builtpkg/. This is pre-existing and unrelated — confirmed against the tree without these changes — andpnpm test, which builds first, is green.