diff --git a/mobile/src/session/github-pr-rpc.test.ts b/mobile/src/session/github-pr-rpc.test.ts index a287aa4f6..6320d74af 100644 --- a/mobile/src/session/github-pr-rpc.test.ts +++ b/mobile/src/session/github-pr-rpc.test.ts @@ -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, { diff --git a/mobile/src/session/github-pr-rpc.ts b/mobile/src/session/github-pr-rpc.ts index 24baef0a2..a25833766 100644 --- a/mobile/src/session/github-pr-rpc.ts +++ b/mobile/src/session/github-pr-rpc.ts @@ -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 + } ) } diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index 5ebe76f2c..04f0bbaa6 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -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 — 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 = (set, get) => ({ prCache: {}, issueCache: {}, @@ -3077,7 +3066,7 @@ export const createGitHubSlice: StateCreator = (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 = (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 diff --git a/src/shared/github-pr-for-branch-outcome.test.ts b/src/shared/github-pr-for-branch-outcome.test.ts new file mode 100644 index 000000000..3eaec4a99 --- /dev/null +++ b/src/shared/github-pr-for-branch-outcome.test.ts @@ -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) + }) +}) diff --git a/src/shared/github-pr-for-branch-outcome.ts b/src/shared/github-pr-for-branch-outcome.ts new file mode 100644 index 000000000..90b5661f8 --- /dev/null +++ b/src/shared/github-pr-for-branch-outcome.ts @@ -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 } +}