import { GOAL_SOURCE_GITHUB_ISSUE, GOAL_SOURCE_TANGLED_ISSUE, type GoalView } from '@radial/core' import { describe, expect, it } from 'vitest' import { newGoalArgs } from './goal.js' import { asGitHubIssue, discoverGitHubIssues, githubGoalSeed, githubIssueRows, githubRepo, issuesPageUrl, listGitHubIssues, } from './github-issues.js' import { importedIssues } from './issues.js' // `issues.test.ts`'s twin, and the differences between them are the feature's argument in miniature. // // There is no index-versus-authority pair to test here: GitHub's API IS the repository's copy, so // the fan-out and the re-read that dominate the tangled tests have nothing to attach to. What // replaces them is everything GitHub does that tangled does not — a pull request answered by the // issues endpoint, a nullable body, a login where a DID would be, no version to pin, and the two // failures an unauthenticated caller actually meets. const REPO = { owner: 'acme', repo: 'widget' } const urlOf = (number: number, owner = 'acme', repo = 'widget'): string => `https://github.com/${owner}/${repo}/issues/${number}` const issue = (number: number, over: Record = {}) => ({ html_url: urlOf(number), number, state: 'open', title: `Issue ${number}`, body: `The body of ${number}`, created_at: '2026-07-01T00:00:00Z', user: { login: 'stranger', html_url: 'https://github.com/stranger' }, ...over, }) /** An API that answers with whatever it is handed, one page per call. */ const api = (pages: unknown[][], init: ResponseInit = { status: 200 }) => { const seen: string[] = [] const fetcher = async (url: string): Promise => { seen.push(url) const page = pages[Math.min(seen.length - 1, pages.length - 1)] ?? [] return new Response(JSON.stringify(page), init) } return { fetcher, seen } } describe('reading a project remote', () => { it('takes every spelling of a GitHub repository', () => { expect(githubRepo('https://github.com/acme/widget')).toEqual(REPO) expect(githubRepo('https://github.com/acme/widget.git')).toEqual(REPO) expect(githubRepo('ssh://git@github.com/acme/widget.git')).toEqual(REPO) expect(githubRepo('git@github.com:acme/widget.git')).toEqual(REPO) // The daemon's `parseGitHubRepo` takes this host, so a project whose remote was written with it // must not be told it has no issues. expect(githubRepo('https://www.github.com/acme/widget')).toEqual(REPO) }) it('has no opinion about a remote that is not GitHub', () => { // The mirror image of `tangledRepo`'s own case: not an error, just no. expect(githubRepo('https://tangled.org/disnetdev.com/radial')).toBeUndefined() expect(githubRepo('https://gitlab.com/acme/widget.git')).toBeUndefined() expect(githubRepo('https://github.com/acme')).toBeUndefined() expect(githubRepo('https://github.com/acme/widget/tree/main')).toBeUndefined() expect(githubRepo('not a url')).toBeUndefined() }) it('links the repository issue list', () => { expect(issuesPageUrl(REPO)).toBe('https://github.com/acme/widget/issues') }) }) describe('asking GitHub', () => { it('asks for open issues oldest first, and hands back what it got', async () => { const { fetcher, seen } = api([[issue(1)]]) const entries = await listGitHubIssues({ repo: REPO, fetcher }) expect(entries).toHaveLength(1) expect(seen[0]).toContain('/repos/acme/widget/issues?') expect(seen[0]).toContain('state=open') expect(seen[0]).toContain('sort=created') expect(seen[0]).toContain('direction=asc') }) it('reads a response that is not a list as an empty page rather than throwing', async () => { const fetcher = async () => new Response(JSON.stringify({ message: 'nope' }), { status: 200 }) expect(await listGitHubIssues({ repo: REPO, fetcher })).toEqual([]) }) it('names the rate limit rather than saying nothing could be read', async () => { // The failure this feature will actually meet — 60/hour/IP, unauthenticated by design. "GitHub // answered 403" would send a reader looking for a permission they do not need. const fetcher = async () => new Response('rate limited', { status: 403, headers: { 'x-ratelimit-remaining': '0' } }) await expect(listGitHubIssues({ repo: REPO, fetcher })).rejects.toThrow(/hourly limit/) }) it('keeps a plain 403 as a plain 403', async () => { const fetcher = async () => new Response('forbidden', { status: 403 }) await expect(listGitHubIssues({ repo: REPO, fetcher })).rejects.toThrow(/answered 403/) }) it('says a repository is invisible rather than that it is empty', async () => { const fetcher = async () => new Response('not found', { status: 404 }) await expect(listGitHubIssues({ repo: REPO, fetcher })).rejects.toThrow(/private, renamed, or gone/) }) }) describe('turning an entry into a candidate', () => { it('accepts an open issue on this repository', () => { expect(asGitHubIssue(issue(7), REPO)).toEqual({ uri: urlOf(7), number: 7, title: 'Issue 7', body: 'The body of 7', createdAt: '2026-07-01T00:00:00Z', login: 'stranger', profileUrl: 'https://github.com/stranger', }) }) it('refuses a pull request, which this endpoint answers with too', () => { // GitHub models a pull as an issue. A pull is work already in flight, not a goal somebody could // take up, and importing one would file a goal against a branch that exists. expect(asGitHubIssue(issue(7, { pull_request: { url: 'https://api.github.com/x' } }), REPO)).toBeUndefined() }) it('takes an issue with no body — GitHub nulls it rather than omitting it', () => { expect(asGitHubIssue(issue(7, { body: null }), REPO)?.body).toBe('') expect(asGitHubIssue(issue(7, { body: undefined }), REPO)?.body).toBe('') }) it('refuses an entry whose html_url is not a canonical issue URL', () => { expect(asGitHubIssue(issue(7, { html_url: 'https://github.com/acme/widget/pull/7' }), REPO)).toBeUndefined() expect(asGitHubIssue(issue(7, { html_url: 'http://github.com/acme/widget/issues/7' }), REPO)).toBeUndefined() expect(asGitHubIssue(issue(7, { html_url: undefined }), REPO)).toBeUndefined() }) it('refuses an entry that names a different repository', () => { // The one thing the index-versus-authority rule protected that survives GitHub being its own // authority: whatever is drawn under this repository was filed on it. expect(asGitHubIssue(issue(7, { html_url: urlOf(7, 'acme', 'other') }), REPO)).toBeUndefined() expect(asGitHubIssue(issue(7, { html_url: urlOf(7, 'other', 'widget') }), REPO)).toBeUndefined() // GitHub owner and repository names are case-insensitive, and a project remote written in another // case is the same repository. expect(asGitHubIssue(issue(7, { html_url: urlOf(7, 'Acme', 'Widget') }), REPO)?.number).toBe(7) }) it('refuses an entry missing what a row is drawn from', () => { expect(asGitHubIssue(issue(7, { title: undefined }), REPO)).toBeUndefined() expect(asGitHubIssue(issue(7, { created_at: undefined }), REPO)).toBeUndefined() expect(asGitHubIssue(issue(7, { user: {} }), REPO)).toBeUndefined() expect(asGitHubIssue(issue(7, { state: 'closed' }), REPO)).toBeUndefined() expect(asGitHubIssue('a string', REPO)).toBeUndefined() }) it('takes a filer with no profile URL rather than losing the issue', () => { const candidate = asGitHubIssue(issue(7, { user: { login: 'stranger' } }), REPO) expect(candidate?.login).toBe('stranger') expect(candidate?.profileUrl).toBeUndefined() }) }) describe('discovering candidates', () => { it('orders oldest first and reports nothing dropped', async () => { const { fetcher } = api([ [issue(2, { created_at: '2026-07-01T00:00:00Z' }), issue(1, { created_at: '2026-06-01T00:00:00Z' })], ]) const result = await discoverGitHubIssues({ repo: REPO, taken: new Set(), fetcher }) expect(result.candidates.map((candidate) => candidate.uri)).toEqual([urlOf(1), urlOf(2)]) expect(result).toMatchObject({ failed: 0, truncated: false }) }) it('never counts a pull request as an issue it could not read', async () => { // A repository with an active branch queue would otherwise announce that a dozen issues went // missing. `failed` is for what a reader should be told about. const { fetcher } = api([[issue(1), issue(2, { pull_request: {} }), issue(3, { pull_request: {} })]]) const result = await discoverGitHubIssues({ repo: REPO, taken: new Set(), fetcher }) expect(result.candidates.map((candidate) => candidate.number)).toEqual([1]) expect(result.failed).toBe(0) }) it('counts an entry it could not read', async () => { const { fetcher } = api([[issue(1), { html_url: urlOf(2, 'someone', 'else') }, 'nonsense']]) const result = await discoverGitHubIssues({ repo: REPO, taken: new Set(), fetcher }) expect(result.candidates.map((candidate) => candidate.number)).toEqual([1]) expect(result.failed).toBe(2) }) it('offers an issue the space already took up, so a cold page can still draw its row', async () => { // `discoverIssues`'s regression, and the same one: the fold holds an imported issue's URL and // nothing else, so a look that skipped it could draw the badge, the goal link and "import again" // only in the tab that did the importing, seconds afterwards. const { fetcher } = api([[issue(1), issue(2)]]) const result = await discoverGitHubIssues({ repo: REPO, taken: new Set([urlOf(2)]), fetcher }) expect(result.candidates.map((candidate) => candidate.uri)).toEqual([urlOf(1), urlOf(2)]) }) it('spends a bounded budget on the issues nobody has taken up first', async () => { const { fetcher } = api([[issue(1), issue(2)]]) const result = await discoverGitHubIssues({ repo: REPO, taken: new Set([urlOf(1)]), fetcher, limit: 1, }) expect(result.candidates.map((candidate) => candidate.uri)).toEqual([urlOf(2)]) expect(result.truncated).toBe(true) }) it('walks numbered pages while they come back full, and says when the budget ran out first', async () => { const { fetcher, seen } = api([[issue(1), issue(2)], [issue(3), issue(4)], [issue(5), issue(6)]]) const result = await discoverGitHubIssues({ repo: REPO, taken: new Set(), fetcher, pages: 3, perPage: 2, }) expect(seen).toHaveLength(3) expect(seen[1]).toContain('page=2') expect(seen[2]).toContain('page=3') expect(result.candidates).toHaveLength(6) expect(result.truncated).toBe(true) }) it('stops at a short page — GitHub has no cursor to run out', async () => { const { fetcher, seen } = api([[issue(1)]]) const result = await discoverGitHubIssues({ repo: REPO, taken: new Set(), fetcher, pages: 3, perPage: 2 }) expect(seen).toHaveLength(1) expect(result.truncated).toBe(false) }) it('stops offering at the ceiling and says it did', async () => { const { fetcher } = api([[issue(1), issue(2), issue(3)]]) const result = await discoverGitHubIssues({ repo: REPO, taken: new Set(), fetcher, limit: 2 }) expect(result.candidates).toHaveLength(2) expect(result.truncated).toBe(true) }) it('lets a failed look through as a failure, not as an empty repository', async () => { const fetcher = async () => new Response('nope', { status: 502 }) await expect(discoverGitHubIssues({ repo: REPO, taken: new Set(), fetcher })).rejects.toThrow(/502/) }) }) describe('what the space has already taken up', () => { const goal = (rkey: string, source?: Record): GoalView => ({ target: { uri: `at://did:plc:member/com.disnetdev.radial.goal/${rkey}`, value: { title: rkey, ...(source ? { source } : {}) }, }, }) as unknown as GoalView it('matches on the kind, so one forge never badges the other', () => { // The join is `importedIssues` with a kind rather than a second function, and the kind is what // keeps the two pages apart: neither reads the other's rows, and a forge this build has never // heard of is simply not ours. const goals = [ goal('gh', { kind: GOAL_SOURCE_GITHUB_ISSUE, uri: urlOf(7) }), goal('tangled', { kind: GOAL_SOURCE_TANGLED_ISSUE, uri: 'at://did:plc:stranger/sh.tangled.repo.issue/3mrx', }), goal('plain'), ] expect([...importedIssues(goals, GOAL_SOURCE_GITHUB_ISSUE).keys()]).toEqual([urlOf(7)]) expect([...importedIssues(goals).keys()]).toEqual([ 'at://did:plc:stranger/sh.tangled.repo.issue/3mrx', ]) }) it('keeps every goal that claimed an issue when two members raced', () => { const first = goal('first', { kind: GOAL_SOURCE_GITHUB_ISSUE, uri: urlOf(7) }) const second = goal('second', { kind: GOAL_SOURCE_GITHUB_ISSUE, uri: urlOf(7) }) expect(importedIssues([first, second], GOAL_SOURCE_GITHUB_ISSUE).get(urlOf(7))).toEqual([ first, second, ]) }) it('joins a look to the fold by URL, and answers nothing for an issue nobody took up', () => { const taken = importedIssues( [goal('one', { kind: GOAL_SOURCE_GITHUB_ISSUE, uri: urlOf(7) })], GOAL_SOURCE_GITHUB_ISSUE, ) const candidates = [asGitHubIssue(issue(7), REPO)!, asGitHubIssue(issue(8), REPO)!] expect(githubIssueRows(taken, candidates).map((row) => row.imported.length)).toEqual([1, 0]) expect(githubIssueRows(taken, candidates)[0]?.title).toBe('Issue 7') }) }) describe('importing one', () => { it("seeds the composer with the issue's own words, its URL, and no version to pin", () => { expect(githubGoalSeed(asGitHubIssue(issue(7), REPO)!)).toEqual({ title: 'Issue 7', body: 'The body of 7', filer: { kind: 'label', label: 'stranger', href: 'https://github.com/stranger' }, source: { kind: GOAL_SOURCE_GITHUB_ISSUE, uri: urlOf(7) }, }) }) it("truncates at the goal lexicon's ceilings rather than refusing to open", () => { const seed = githubGoalSeed( asGitHubIssue(issue(7, { title: 'x'.repeat(400), body: 'y'.repeat(100_001) }), REPO)!, ) expect(seed.title).toHaveLength(300) expect(seed.body).toHaveLength(100_000) }) it('writes the pointer through the same `goal create` every other goal goes through', () => { // No `--source-cid`: GitHub content is not content-addressed, so there is no version of the issue // to pin and the pointer says which issue rather than which draft of it. const seed = githubGoalSeed(asGitHubIssue(issue(7), REPO)!) expect( newGoalArgs({ project: 'at://p', title: seed.title, body: seed.body, source: seed.source }), ).toEqual([ 'goal', 'create', '--project', 'at://p', '--title', 'Issue 7', '--body', 'The body of 7', '--source-kind', GOAL_SOURCE_GITHUB_ISSUE, '--source-uri', urlOf(7), ]) }) })