import { describe, expect, it, vi } from 'vitest' import type { RpcResponse } from '../transport/types' import { mobileRepoSelectorFromWorktreeId } from '../source-control/mobile-pr-create' import { buildGithubPrParams, fetchAssignableUsers, fetchGithubRepoSlug, fetchHostedReviewForBranch, fetchPRCheckDetails, fetchPRChecks, fetchPRForBranch, readAssignableUsers, readForBranch, readPRCheckDetails, readPRChecks, readPRForBranch, readWorkItemDetails } from './github-pr-rpc' function okResponse(result: unknown): RpcResponse { return { id: 'x', ok: true, result, _meta: { runtimeId: 'r' } } } function errResponse(message: string): RpcResponse { return { id: 'x', ok: false, error: { code: 'failed', message }, _meta: { runtimeId: 'r' } } } function mockClient(response: RpcResponse) { const sendRequest = vi.fn(async (_method: string, _params?: unknown) => response) return { client: { sendRequest }, sendRequest } } const WORKTREE_ID = 'repo-42::/path/to/wt' describe('readForBranch', () => { it('parses a valid HostedReviewInfo into provider + PR number', () => { const parsed = readForBranch({ provider: 'github', number: 7, title: 'My PR', state: 'open', url: 'https://example/7', status: 'success', updatedAt: '2026-01-01', mergeable: 'MERGEABLE' }) expect(parsed?.provider).toBe('github') expect(parsed?.number).toBe(7) expect(parsed?.state).toBe('open') }) it('returns null for null/non-record input', () => { expect(readForBranch(null)).toBeNull() expect(readForBranch('nope')).toBeNull() }) it('returns null when provider or number is unparseable', () => { expect(readForBranch({ number: 7 })).toBeNull() expect(readForBranch({ provider: 'github' })).toBeNull() }) it('preserves a non-github provider (gate decides, not the parser)', () => { const parsed = readForBranch({ provider: 'gitlab', number: 3 }) expect(parsed?.provider).toBe('gitlab') expect(parsed?.number).toBe(3) }) }) describe('readPRForBranch', () => { it('parses a valid PR record', () => { const parsed = readPRForBranch({ number: 12, title: 'Feature', state: 'open', url: 'u', checksStatus: 'success', updatedAt: 'now', mergeable: 'MERGEABLE' }) expect(parsed?.number).toBe(12) expect(parsed?.state).toBe('open') expect(parsed?.checksStatus).toBe('success') }) it('returns null for null result', () => { expect(readPRForBranch(null)).toBeNull() }) it('narrows without throwing on missing/extra fields, dropping unknowns', () => { const parsed = readPRForBranch({ number: 5, state: 'closed', bogus: { deep: 1 } }) expect(parsed?.number).toBe(5) expect(parsed?.state).toBe('closed') expect(parsed?.title).toBe('') expect('bogus' in (parsed ?? {})).toBe(false) }) it('returns null when number/state is missing', () => { expect(readPRForBranch({ state: 'open' })).toBeNull() expect(readPRForBranch({ number: 1 })).toBeNull() }) it('parses prRepo and mergeMethodSettings when present', () => { const parsed = readPRForBranch({ number: 3, state: 'open', prRepo: { owner: 'forkOwner', repo: 'forkRepo' }, mergeMethodSettings: { defaultMethod: 'squash', allowedMethods: { merge: false, squash: true, rebase: true } } }) expect(parsed?.prRepo).toEqual({ owner: 'forkOwner', repo: 'forkRepo' }) expect(parsed?.mergeMethodSettings).toEqual({ defaultMethod: 'squash', allowedMethods: { merge: false, squash: true, rebase: true } }) }) it('drops a malformed prRepo / mergeMethodSettings without throwing', () => { const parsed = readPRForBranch({ number: 3, state: 'open', prRepo: { owner: 'onlyOwner' }, mergeMethodSettings: { allowedMethods: {} } }) expect(parsed?.prRepo).toBeUndefined() expect(parsed?.mergeMethodSettings).toBeUndefined() }) }) describe('readWorkItemDetails', () => { it('parses state/title/author/base+head/reviewRequests/latestReviews', () => { const parsed = readWorkItemDetails({ item: { id: 'n1', type: 'pr', number: 9, title: 'T', state: 'open', url: 'u', author: 'octo', baseRefName: 'main', branchName: 'feat', reviewRequests: [{ login: 'rev1', name: 'Rev One', avatarUrl: 'a' }], latestReviews: [{ login: 'rev2', state: 'APPROVED' }] }, body: 'body', headSha: 'abc', baseSha: 'def' }) expect(parsed?.item.author).toBe('octo') expect(parsed?.item.baseRefName).toBe('main') expect(parsed?.item.reviewRequests).toEqual([ { login: 'rev1', name: 'Rev One', avatarUrl: 'a' } ]) expect(parsed?.item.latestReviews).toEqual([ { login: 'rev2', state: 'APPROVED', avatarUrl: null } ]) expect(parsed?.headSha).toBe('abc') }) it('skips malformed latestReviews entries without being fatal', () => { const parsed = readWorkItemDetails({ item: { id: 'n', type: 'pr', number: 1, state: 'open', latestReviews: [{ login: 'ok' }, 42, {}] } }) expect(parsed?.item.latestReviews).toEqual([{ login: 'ok', state: null, avatarUrl: null }]) }) it('returns null when item is unparseable', () => { expect(readWorkItemDetails({ item: { number: 1 } })).toBeNull() expect(readWorkItemDetails(null)).toBeNull() }) }) describe('readPRChecks', () => { it('parses an array of mixed pending/completed checks', () => { const parsed = readPRChecks([ { name: 'build', status: 'completed', conclusion: 'success', url: 'u1' }, { name: 'test', status: 'in_progress', conclusion: null, url: null } ]) expect(parsed).toHaveLength(2) expect(parsed[0]).toMatchObject({ name: 'build', status: 'completed', conclusion: 'success' }) expect(parsed[1]).toMatchObject({ name: 'test', status: 'in_progress', conclusion: null }) }) it('returns [] for non-array input', () => { expect(readPRChecks(null)).toEqual([]) expect(readPRChecks({})).toEqual([]) }) it('skips bad entries instead of throwing', () => { const parsed = readPRChecks([ { name: 'ok', status: 'queued', conclusion: null, url: null }, 7, {} ]) expect(parsed).toHaveLength(1) expect(parsed[0]?.name).toBe('ok') }) it('coerces an unknown conclusion to null (pending check renders as pending, not failure)', () => { const parsed = readPRChecks([ { name: 'c', status: 'in_progress', conclusion: 'weird', url: null } ]) expect(parsed[0]?.conclusion).toBeNull() }) }) describe('readPRCheckDetails', () => { it('parses valid details including annotations and jobs', () => { const parsed = readPRCheckDetails({ name: 'CI', status: 'completed', conclusion: 'failure', annotations: [{ message: 'boom', path: 'a.ts', startLine: 1, endLine: 2 }, 'bad'], jobs: [{ name: 'job1', steps: [{ name: 'step1' }, 99] }] }) expect(parsed?.name).toBe('CI') expect(parsed?.annotations).toHaveLength(1) expect(parsed?.jobs[0]?.steps).toHaveLength(1) }) it('returns null for null/garbage', () => { expect(readPRCheckDetails(null)).toBeNull() expect(readPRCheckDetails({ status: 'x' })).toBeNull() }) }) describe('readAssignableUsers', () => { it('parses users and skips entries without a login', () => { const parsed = readAssignableUsers([ { login: 'a', name: 'A', avatarUrl: 'av' }, { name: 'no login' }, 'bad' ]) expect(parsed).toEqual([{ login: 'a', name: 'A', avatarUrl: 'av' }]) }) it('returns [] for non-array (empty list edge)', () => { expect(readAssignableUsers(undefined)).toEqual([]) expect(readAssignableUsers([])).toEqual([]) }) }) describe('buildGithubPrParams — method-aware prRepo / headSha', () => { const fork = { owner: 'forkOwner', repo: 'forkRepo' } it('reuses mobileRepoSelectorFromWorktreeId for the repo selector', () => { const params = buildGithubPrParams('github.prChecks', WORKTREE_ID, { prNumber: 1 }) expect(params.repo).toBe(mobileRepoSelectorFromWorktreeId(WORKTREE_ID)) expect(params.repo).toBe('id:repo-42') }) it('attaches prRepo for methods that accept it', () => { for (const method of [ 'github.prChecks', 'github.prCheckDetails', 'github.mergePR', 'github.setPRAutoMerge', 'github.prComments' ]) { const params = buildGithubPrParams(method, WORKTREE_ID, { prNumber: 1 }, { prRepo: fork }) expect(params.prRepo).toEqual(fork) } }) it('omits prRepo for methods that reject it', () => { for (const method of [ 'github.updatePRState', 'github.requestPRReviewers', 'github.removePRReviewers', 'github.listAssignableUsers', 'github.rerunPRChecks' ]) { const params = buildGithubPrParams(method, WORKTREE_ID, { prNumber: 1 }, { prRepo: fork }) expect('prRepo' in params).toBe(false) } }) it('forwards headSha only to github.prChecks', () => { const checks = buildGithubPrParams( 'github.prChecks', WORKTREE_ID, { prNumber: 1 }, { headSha: 'sha123' } ) expect(checks.headSha).toBe('sha123') const details = buildGithubPrParams( 'github.prCheckDetails', WORKTREE_ID, {}, { headSha: 'sha123' } ) expect('headSha' in details).toBe(false) }) it('does not attach prRepo/headSha when not supplied', () => { const params = buildGithubPrParams('github.prChecks', WORKTREE_ID, { prNumber: 1 }) expect('prRepo' in params).toBe(false) expect('headSha' in params).toBe(false) }) }) describe('fetch wrappers', () => { it('fetchHostedReviewForBranch sends linkedGitHubPR + reuses repo selector', async () => { const { client, sendRequest } = mockClient(okResponse({ provider: 'github', number: 4 })) const out = await fetchHostedReviewForBranch(client, WORKTREE_ID, { branch: 'feat', linkedGitHubPR: 4 }) expect(out.ok).toBe(true) expect(out.ok && out.result).toMatchObject({ provider: 'github', number: 4 }) const [method, params] = sendRequest.mock.calls[0]! expect(method).toBe('hostedReview.forBranch') expect(params).toMatchObject({ repo: 'id:repo-42', branch: 'feat', linkedGitHubPR: 4 }) }) it('fetchPRForBranch threads linkedPRNumber as authoritative resolver', async () => { const { client, sendRequest } = mockClient(okResponse({ number: 4, state: 'open' })) const out = await fetchPRForBranch(client, WORKTREE_ID, { branch: 'feat', linkedPRNumber: 4 }) expect(out.ok).toBe(true) const [method, params] = sendRequest.mock.calls[0]! expect(method).toBe('github.prForBranch') expect(params).toMatchObject({ branch: 'feat', linkedPRNumber: 4 }) expect('prRepo' in (params as object)).toBe(false) }) it('fetchPRChecks forwards headSha + prRepo', async () => { const { client, sendRequest } = mockClient(okResponse([])) await fetchPRChecks(client, WORKTREE_ID, { prNumber: 9, headSha: 'sha1', prRepo: { owner: 'o', repo: 'r' } }) const [method, params] = sendRequest.mock.calls[0]! expect(method).toBe('github.prChecks') expect(params).toMatchObject({ prNumber: 9, headSha: 'sha1', prRepo: { owner: 'o', repo: 'r' } }) }) it('fetchPRCheckDetails attaches prRepo but never headSha', async () => { const { client, sendRequest } = mockClient(okResponse({ name: 'CI' })) await fetchPRCheckDetails(client, WORKTREE_ID, { checkRunId: 3, prRepo: { owner: 'o', repo: 'r' } }) const [, params] = sendRequest.mock.calls[0]! expect(params).toMatchObject({ checkRunId: 3, prRepo: { owner: 'o', repo: 'r' } }) expect('headSha' in (params as object)).toBe(false) }) it('fetchGithubRepoSlug returns the slug for a github repo, null otherwise', async () => { const found = await fetchGithubRepoSlug( mockClient(okResponse({ owner: 'o', repo: 'r' })).client, WORKTREE_ID ) expect(found).toEqual({ ok: true, result: { owner: 'o', repo: 'r' } }) const none = await fetchGithubRepoSlug(mockClient(okResponse(null)).client, WORKTREE_ID) expect(none).toEqual({ ok: true, result: null }) }) it('fetchAssignableUsers returns empty list edge without prRepo', async () => { const { client, sendRequest } = mockClient(okResponse([])) const out = await fetchAssignableUsers(client, WORKTREE_ID) expect(out).toEqual({ ok: true, result: [] }) const [method, params] = sendRequest.mock.calls[0]! expect(method).toBe('github.listAssignableUsers') expect('prRepo' in (params as object)).toBe(false) }) it('surfaces { ok:false, error } on a failed response', async () => { const { client } = mockClient(errResponse('permission denied')) const out = await fetchPRChecks(client, WORKTREE_ID, { prNumber: 1 }) expect(out).toEqual({ ok: false, error: 'permission denied' }) }) it('normalizes a thrown sendRequest into { ok:false, error } (no escaping rejection)', async () => { const client = { sendRequest: vi.fn(async () => { throw new Error('transport closed') }) } const out = await fetchPRChecks(client, WORKTREE_ID, { prNumber: 1 }) expect(out).toEqual({ ok: false, error: 'transport closed' }) }) })