import { afterEach, expect, it, vi } from 'vitest' import { Commands } from '../src/infra/commands.ts' import { testDeployment } from './fixtures/deployment.ts' import { readGithub, pullChecks } from '../src/sources/github-read.ts' const commands = new Commands(() => testDeployment(), {}, new AbortController().signal) afterEach(() => vi.restoreAllMocks()) it('reads a bounded comment page and exposes the next page reference', async () => { const gh = vi.spyOn(commands, 'gh').mockResolvedValue(Array.from({ length: 20 }, (_, id) => ({ id, body: 'comment' }))) expect(await readGithub(commands, 'github/issues/123/comments/2')).toMatchObject({ next_resource: 'github/issues/123/comments/3' }) expect(gh).toHaveBeenCalledWith(['api', 'repos/acme/widgets/issues/123/comments?per_page=20&page=2']) }) it('rejects unsupported or escaping paths without making a request', async () => { const gh = vi.spyOn(commands, 'gh') for (const resource of ['github/../outside', 'github/issues/1/reviews', 'github/pulls/1/checks/2', 'github/issues/1?repo=other']) { await expect(readGithub(commands, resource)).rejects.toThrow() } expect(gh).not.toHaveBeenCalled() }) it('reads checks across pages and canonicalizes the latest status per context', async () => { vi.spyOn(commands, 'gh').mockResolvedValue([{ check_runs: [{ name: 'z', status: 'completed', conclusion: 'success' }] }, { check_runs: [{ name: 'a', status: 'in_progress', conclusion: null }] }]) vi.spyOn(commands, 'pages').mockResolvedValue([{ context: 'build', state: 'success' }, { context: 'build', state: 'failure' }]) const result = await pullChecks(commands, 'abc') expect(result.checks.map((check: { name: string }) => check.name)).toEqual(['a', 'z']) expect(result.statuses).toEqual([{ context: 'build', state: 'success' }]) }) it('reads any requested PR’s CI on demand without restricting the author', async () => { const gh = vi.spyOn(commands, 'gh').mockResolvedValueOnce({ head: { sha: 'abc' }, user: { login: 'another-author' } }).mockResolvedValueOnce([{ check_runs: [] }]) vi.spyOn(commands, 'pages').mockResolvedValue([]) expect(await readGithub(commands, 'github/pulls/123/checks')).toEqual({ head: 'abc', checks: [], statuses: [] }) expect(gh).toHaveBeenCalledTimes(2) }) /** Fake GitHub REST API pull request review comment payload. */ const reviewComment = (id: number, reply?: number) => ({ id, url: `https://api.github.com/repos/acme/widgets/pulls/comments/${id}`, pull_request_review_id: 77, node_id: 'PRRC_x', path: 'src/render.ts', line: 43, original_line: 43, side: 'RIGHT', commit_id: 'abc', diff_hunk: '@@ -40,6 +40,8 @@\n const title = view.title\n const margin = 12\n+ const padding = 8', user: { login: id === 2 ? 'review-bot[bot]' : 'carol', id: 5, type: id === 2 ? 'Bot' : 'User', avatar_url: 'https://avatars/5', html_url: 'https://github.com/carol' }, body: `comment ${id}`, created_at: `2026-09-09T00:00:0${id}Z`, updated_at: `2026-09-09T00:00:0${id}Z`, html_url: `https://github.com/acme/widgets/pull/9#discussion_r${id}`, author_association: 'MEMBER', reactions: { total_count: 0 }, _links: { self: { href: 'x' } }, ...reply ? { in_reply_to_id: reply } : {} }) it('reads a page of review comments as messages: id, author, time, text, the comment each answers, and the file and line', async () => { vi.spyOn(commands, 'gh').mockResolvedValue([reviewComment(1), reviewComment(2, 1)]) expect(await readGithub(commands, 'github/pulls/9/comments/1')).toEqual({ messages: [ { id: '1', from: 'carol', at: '2026-09-09T00:00:01Z', text: 'comment 1', url: 'https://github.com/acme/widgets/pull/9#discussion_r1', path: 'src/render.ts', line: 43 }, { id: '2', from: 'review-bot[bot]', at: '2026-09-09T00:00:02Z', text: 'comment 2', bot: true, reply_to: '1', url: 'https://github.com/acme/widgets/pull/9#discussion_r2', path: 'src/render.ts', line: 43 }] }) }) it('reads a review thread from any of its comments, the first with its file, line and diff lines', async () => { vi.spyOn(commands, 'pages').mockResolvedValue([reviewComment(3), reviewComment(2, 1), reviewComment(1), reviewComment(4, 3)].map(item => ({ ...item, user: { login: item.user.login, type: item.user.type } }))) const thread = await readGithub(commands, 'github/pulls/9/thread/2') as { messages: { id: string; text: string; reply_to?: string }[] } expect(thread.messages.map(message => [message.id, message.reply_to])).toEqual([['1', undefined], ['2', '1']]) expect(thread.messages[0]!.text).toBe('src/render.ts:43\n const title = view.title\n const margin = 12\n+ const padding = 8\n\ncomment 1') await expect(readGithub(commands, 'github/issues/9/thread/2')).rejects.toThrow('require a pull request') }) it('reads an Issue or PR as its author, time, title, state, description and a PR\'s branches', async () => { vi.spyOn(commands, 'gh').mockResolvedValue({ number: 9, id: 1, title: 'Fix the margin', state: 'open', body: 'Details', draft: true, user: { login: 'carol', type: 'User' }, created_at: '2026-09-09T00:00:00Z', html_url: 'https://github.com/acme/widgets/pull/9', head: { ref: 'agent/fix', sha: 'abc', repo: {} }, base: { ref: 'main' }, labels: [{ name: 'ui', color: 'fff' }], node_id: 'x', _links: {} }) expect(await readGithub(commands, 'github/pulls/9')).toEqual({ number: 9, title: 'Fix the margin', state: 'open', draft: true, head: 'agent/fix', base: 'main', labels: ['ui'], id: '9', from: 'carol', at: '2026-09-09T00:00:00Z', text: 'Details', url: 'https://github.com/acme/widgets/pull/9' }) })