orca/mobile/src/session/github-pr-rpc.test.ts

401 lines
13 KiB
TypeScript

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' })
})
})