fix(mobile): parse classified PR lookup outcomes (#12659)

This commit is contained in:
Brennan Benson 2026-08-05 11:31:11 -07:00 committed by GitHub
parent 2539889197
commit 86b878cfd6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 124 additions and 32 deletions

View File

@ -353,15 +353,51 @@ describe('fetch wrappers', () => {
})
it('fetchPRForBranch threads linkedPRNumber as authoritative resolver', async () => {
const { client, sendRequest } = mockClient(okResponse({ number: 4, state: 'open' }))
const { client, sendRequest } = mockClient(
okResponse({
kind: 'found',
pr: { number: 4, state: 'merged' },
fetchedAt: 1
})
)
const out = await fetchPRForBranch(client, WORKTREE_ID, { branch: 'feat', linkedPRNumber: 4 })
expect(out.ok).toBe(true)
expect(out.ok && out.result).toMatchObject({ number: 4, state: 'merged' })
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('fetchPRForBranch preserves legacy flat responses', async () => {
const { client } = mockClient(okResponse({ number: 4, state: 'open' }))
const out = await fetchPRForBranch(client, WORKTREE_ID, { branch: 'feat' })
expect(out.ok && out.result).toMatchObject({ number: 4, state: 'open' })
})
it('fetchPRForBranch maps a classified no-pr response to null', async () => {
const { client } = mockClient(okResponse({ kind: 'no-pr', fetchedAt: 1 }))
await expect(fetchPRForBranch(client, WORKTREE_ID, { branch: 'feat' })).resolves.toEqual({
ok: true,
result: null
})
})
it('fetchPRForBranch propagates classified upstream errors', async () => {
const { client } = mockClient(
okResponse({
kind: 'upstream-error',
errorType: 'network',
message: 'network unavailable',
fetchedAt: 1
})
)
await expect(fetchPRForBranch(client, WORKTREE_ID, { branch: 'feat' })).resolves.toEqual({
ok: false,
error: 'network unavailable'
})
})
it('fetchPRChecks forwards headSha + prRepo', async () => {
const { client, sendRequest } = mockClient(okResponse([]))
await fetchPRChecks(client, WORKTREE_ID, {

View File

@ -6,6 +6,10 @@ import type {
PRInfo
} from '../../../src/shared/types'
import type { HostedReviewInfo } from '../../../src/shared/hosted-review'
import {
normalizeGitHubPRForBranchOutcome,
type GitHubPRForBranchResponse
} from '../../../src/shared/github-pr-for-branch-outcome'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import { mobileRepoSelectorFromWorktreeId } from '../source-control/mobile-pr-create'
@ -162,7 +166,20 @@ export async function fetchPRForBranch(
branch: args.branch,
linkedPRNumber: args.linkedPRNumber ?? null
}),
readPRForBranch
(value) => {
const outcome = normalizeGitHubPRForBranchOutcome(value as GitHubPRForBranchResponse)
if (outcome.kind === 'upstream-error') {
throw new Error(outcome.message)
}
if (outcome.kind === 'no-pr') {
return null
}
const pr = readPRForBranch(outcome.pr)
if (!pr) {
throw new Error('GitHub returned an invalid pull request response.')
}
return pr
}
)
}

View File

@ -73,6 +73,7 @@ import {
getTaskSourceRuntimeSettings,
type TaskSourceContext
} from '../../../../shared/task-source-context'
import { normalizeGitHubPRForBranchOutcome } from '../../../../shared/github-pr-for-branch-outcome'
// ─── ProjectV2 cache types ────────────────────────────────────────────
// Why: separate from CacheEntry<T> — project-view has a single GraphQL source (no issue/PR fallback) and a distinct error union.
@ -2040,18 +2041,6 @@ export type GitHubSlice = {
patchProjectRowContent: (cacheKey: string, rowId: string, patch: ProjectRowContentPatch) => void
}
/** Normalizes `github.prForBranch` into a {@link PRRefreshOutcome}: preserves a runtime `upstream-error` instead of collapsing to a false "no PR"; a legacy host returning `PRInfo | null` maps to `found`/`no-pr`. */
function normalizeRuntimePRForBranchOutcome(
result: PRRefreshOutcome | PRInfo | null
): PRRefreshOutcome {
if (result && typeof result === 'object' && 'kind' in result) {
return result
}
return result
? { kind: 'found', pr: result, fetchedAt: Date.now() }
: { kind: 'no-pr', fetchedAt: Date.now() }
}
export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (set, get) => ({
prCache: {},
issueCache: {},
@ -3077,7 +3066,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
: {})
},
{ timeoutMs: 30_000 }
).then((result) => normalizeRuntimePRForBranchOutcome(result))
).then((result) => normalizeGitHubPRForBranchOutcome(result))
: await (async () => {
const candidate: GitHubPRRefreshCandidate = {
repoId: repoId ?? '',
@ -3099,24 +3088,18 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
cachedMergeable: cached?.data?.mergeable ?? null,
cachedMergeStateStatus: cached?.data?.mergeStateStatus ?? null
}
return window.api.gh.refreshPRNow
const response = window.api.gh.refreshPRNow
? await window.api.gh.refreshPRNow({ candidate })
: await window.api.gh
.prForBranch({
repoPath,
repoId,
branch,
linkedPRNumber,
fallbackPRNumber,
acceptMergedFallbackPR:
fallbackPRNumber !== null && fallbackPRSource !== null,
currentHeadOid: requestHeadOid
})
.then((pr) =>
pr
? ({ kind: 'found', pr, fetchedAt: Date.now() } as const)
: ({ kind: 'no-pr', fetchedAt: Date.now() } as const)
)
: await window.api.gh.prForBranch({
repoPath,
repoId,
branch,
linkedPRNumber,
fallbackPRNumber,
acceptMergedFallbackPR: fallbackPRNumber !== null && fallbackPRSource !== null,
currentHeadOid: requestHeadOid
})
return normalizeGitHubPRForBranchOutcome(response)
})()
const pr: PRInfo | null =
outcome.kind === 'found' ? outcome.pr : outcome.kind === 'no-pr' ? null : null

View File

@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import type { PRInfo, PRRefreshOutcome } from './types'
import { normalizeGitHubPRForBranchOutcome } from './github-pr-for-branch-outcome'
const PR = {
number: 42,
title: 'Feature',
state: 'merged',
url: 'https://github.com/acme/orca/pull/42',
checksStatus: 'success',
updatedAt: '2026-08-04T22:46:08Z',
mergeable: 'UNKNOWN'
} as PRInfo
describe('normalizeGitHubPRForBranchOutcome', () => {
it('preserves current classified outcomes', () => {
const outcome: PRRefreshOutcome = { kind: 'found', pr: PR, fetchedAt: 10 }
expect(normalizeGitHubPRForBranchOutcome(outcome, 20)).toBe(outcome)
})
it('normalizes legacy PRInfo and null responses', () => {
expect(normalizeGitHubPRForBranchOutcome(PR, 20)).toEqual({
kind: 'found',
pr: PR,
fetchedAt: 20
})
expect(normalizeGitHubPRForBranchOutcome(null, 20)).toEqual({
kind: 'no-pr',
fetchedAt: 20
})
})
it('preserves classified upstream errors', () => {
const outcome: PRRefreshOutcome = {
kind: 'upstream-error',
errorType: 'network',
message: 'network unavailable',
fetchedAt: 10
}
expect(normalizeGitHubPRForBranchOutcome(outcome, 20)).toBe(outcome)
})
})

View File

@ -0,0 +1,14 @@
import type { PRInfo, PRRefreshOutcome } from './types'
export type GitHubPRForBranchResponse = PRRefreshOutcome | PRInfo | null
// Legacy hosts return PRInfo|null; current hosts return a classified refresh outcome.
export function normalizeGitHubPRForBranchOutcome(
response: GitHubPRForBranchResponse,
fetchedAt = Date.now()
): PRRefreshOutcome {
if (response && typeof response === 'object' && 'kind' in response) {
return response
}
return response ? { kind: 'found', pr: response, fetchedAt } : { kind: 'no-pr', fetchedAt }
}