mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 16:03:26 +00:00
@@ -840,9 +840,7 @@ describe('getPRForBranch', () => {
|
||||
|
||||
it('returns a merged PR by exact merge commit when branch lookup misses', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
gitExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'feature/test\0\n', stderr: '' })
|
||||
.mockResolvedValueOnce({ stdout: 'aaaaaaaa\n', stderr: '' })
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'feature/test\0\n', stderr: '' })
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
||||
.mockResolvedValueOnce({
|
||||
@@ -890,7 +888,9 @@ describe('getPRForBranch', () => {
|
||||
})
|
||||
})
|
||||
|
||||
const pr = await getPRForBranch('/repo-root', 'feature/test')
|
||||
const pr = await getPRForBranch('/repo-root', 'feature/test', null, null, null, {
|
||||
currentHeadOid: 'aaaaaaaa'
|
||||
})
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
@@ -920,9 +920,7 @@ describe('getPRForBranch', () => {
|
||||
|
||||
it('rejects open and merge-commit mismatched associated PRs', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
gitExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'feature/test\0\n', stderr: '' })
|
||||
.mockResolvedValueOnce({ stdout: 'aaaaaaaa\n', stderr: '' })
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'feature/test\0\n', stderr: '' })
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
||||
.mockResolvedValueOnce({
|
||||
@@ -953,10 +951,45 @@ describe('getPRForBranch', () => {
|
||||
])
|
||||
})
|
||||
|
||||
await expect(getPRForBranch('/repo-root', 'feature/test')).resolves.toBeNull()
|
||||
await expect(
|
||||
getPRForBranch('/repo-root', 'feature/test', null, null, null, {
|
||||
currentHeadOid: 'aaaaaaaa'
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('treats a missing GitHub commit association as no PR for local-only commits', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'feature/test\0\n', stderr: '' })
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
||||
.mockRejectedValueOnce(new Error('gh: No commit found for SHA: aaaaaaaa (HTTP 422)'))
|
||||
|
||||
await expect(
|
||||
getPRForBranchOutcome('/repo-root', 'feature/test', null, null, null, {
|
||||
currentHeadOid: 'aaaaaaaa'
|
||||
})
|
||||
).resolves.toMatchObject({ kind: 'no-pr' })
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not discover merge-commit PRs from repo-root HEAD without an explicit head hint', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'feature/test\0\n', stderr: '' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
||||
|
||||
await expect(getPRForBranchOutcome('/repo-root', 'feature/test')).resolves.toMatchObject({
|
||||
kind: 'no-pr'
|
||||
})
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['api', 'repos/acme/widgets/pulls?head=acme%3Afeature%2Ftest&state=all&per_page=1'],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
})
|
||||
|
||||
it('reuses an unavailable HEAD lookup after hiding a stale merged branch PR', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' })
|
||||
@@ -1002,7 +1035,6 @@ describe('getPRForBranch', () => {
|
||||
|
||||
it('runs the merge-commit fallback for detached HEAD', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'dddddddd\n', stderr: '' })
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
@@ -1037,7 +1069,9 @@ describe('getPRForBranch', () => {
|
||||
})
|
||||
})
|
||||
|
||||
const pr = await getPRForBranch('/repo-root', '')
|
||||
const pr = await getPRForBranch('/repo-root', '', null, null, null, {
|
||||
currentHeadOid: 'dddddddd'
|
||||
})
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
@@ -1160,7 +1194,6 @@ describe('getPRForBranch', () => {
|
||||
|
||||
it('does not use invalid current HEAD hints for merge-commit lookup', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' })
|
||||
|
||||
await expect(
|
||||
getPRForBranch('/repo-root', '', null, null, null, {
|
||||
@@ -1168,16 +1201,17 @@ describe('getPRForBranch', () => {
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['rev-parse', 'HEAD'], { cwd: '/repo-root' })
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps unproven merge-commit 404s distinct from no-pr misses', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'dddddddd\n', stderr: '' })
|
||||
ghExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 404: Not Found'))
|
||||
|
||||
const outcome = await getPRForBranchOutcome('/repo-root', '')
|
||||
const outcome = await getPRForBranchOutcome('/repo-root', '', null, null, null, {
|
||||
currentHeadOid: 'dddddddd'
|
||||
})
|
||||
|
||||
expect(outcome).toMatchObject({
|
||||
kind: 'upstream-error',
|
||||
@@ -1568,13 +1602,11 @@ describe('getPRForBranch', () => {
|
||||
getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
gitExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'existing\0\n', stderr: '' })
|
||||
.mockResolvedValueOnce({ stdout: '1111111\n', stderr: '' })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: 'existing\0\nnew-feature\0origin/contributor/original\n',
|
||||
stderr: ''
|
||||
})
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
||||
.mockResolvedValueOnce({
|
||||
@@ -1750,10 +1782,8 @@ describe('getPRForBranch', () => {
|
||||
.mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-b\u0000120')
|
||||
gitExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'feature\0\n', stderr: '' })
|
||||
.mockResolvedValueOnce({ stdout: '2222222\n', stderr: '' })
|
||||
.mockResolvedValueOnce({ stdout: 'feature\0origin/contributor/original\n', stderr: '' })
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
||||
.mockResolvedValueOnce({
|
||||
@@ -1814,13 +1844,11 @@ describe('getPRForBranch', () => {
|
||||
.mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-b\u0000120')
|
||||
gitExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'feature\0origin/old-upstream\n', stderr: '' })
|
||||
.mockResolvedValueOnce({ stdout: '2222222\n', stderr: '' })
|
||||
.mockResolvedValueOnce({ stdout: 'feature\0origin/contributor/original\n', stderr: '' })
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
@@ -1863,10 +1891,8 @@ describe('getPRForBranch', () => {
|
||||
.mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-b\u0000120')
|
||||
gitExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'feature\0\n', stderr: '' })
|
||||
.mockResolvedValueOnce({ stdout: '2222222\n', stderr: '' })
|
||||
.mockResolvedValueOnce({ stdout: 'feature\0origin/contributor/original\n', stderr: '' })
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
||||
.mockResolvedValueOnce({
|
||||
@@ -2330,7 +2356,6 @@ describe('getPRForBranch', () => {
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
|
||||
@@ -2460,6 +2460,13 @@ async function probeCommitAssociatedPRForRepo(args: {
|
||||
cacheCommitAssociatedPRResult(args.cacheKey, result)
|
||||
return result
|
||||
} catch (err) {
|
||||
if (isMissingCommitAssociatedPRError(err)) {
|
||||
// Why: local/unpushed commits commonly do not exist in GitHub yet; that is
|
||||
// an empty association, not a PR refresh failure.
|
||||
const result: Extract<CommitAssociatedPRProbeResult, { kind: 'empty' }> = { kind: 'empty' }
|
||||
cacheCommitAssociatedPRResult(args.cacheKey, result)
|
||||
return result
|
||||
}
|
||||
if (!isNotFoundGhError(err)) {
|
||||
throw err
|
||||
}
|
||||
@@ -2932,6 +2939,11 @@ function isNotFoundGhError(err: unknown): boolean {
|
||||
return classifyGhError(stderr).type === 'not_found'
|
||||
}
|
||||
|
||||
function isMissingCommitAssociatedPRError(err: unknown): boolean {
|
||||
const stderr = err instanceof Error ? err.message : String(err)
|
||||
return /no commit found for sha/i.test(stderr) && /http 422/i.test(stderr)
|
||||
}
|
||||
|
||||
function shouldStopAfterExactLookupError(err: unknown): boolean {
|
||||
const stderr = err instanceof Error ? err.message : String(err)
|
||||
const type = classifyGhError(stderr).type
|
||||
@@ -2997,12 +3009,15 @@ export async function getPRForBranchOutcome(
|
||||
let currentHeadOidResolved = false
|
||||
let acceptedByExactMergeCommit = false
|
||||
const accessibleRepos = new Set<string>()
|
||||
const explicitCurrentHeadOid = isUsableHeadOid(options.currentHeadOid ?? null)
|
||||
? (options.currentHeadOid ?? null)
|
||||
: null
|
||||
|
||||
const getCurrentHeadOidForLookup = async (): Promise<string | null> => {
|
||||
if (!currentHeadOidResolved) {
|
||||
currentHeadOidForMergedImplicit = isUsableHeadOid(options.currentHeadOid ?? null)
|
||||
? (options.currentHeadOid ?? null)
|
||||
: await getCurrentHeadOid(repoPath, connectionId, localGitOptions)
|
||||
currentHeadOidForMergedImplicit =
|
||||
explicitCurrentHeadOid ??
|
||||
(await getCurrentHeadOid(repoPath, connectionId, localGitOptions))
|
||||
currentHeadOidResolved = true
|
||||
}
|
||||
return currentHeadOidForMergedImplicit ?? null
|
||||
@@ -3106,11 +3121,10 @@ export async function getPRForBranchOutcome(
|
||||
dataHeadRepo = headRepo
|
||||
}
|
||||
if (!data && typeof linkedPRNumber !== 'number') {
|
||||
currentHeadOidForMergedImplicit = await getCurrentHeadOidForLookup()
|
||||
if (isUsableHeadOid(currentHeadOidForMergedImplicit) && candidates.length > 0) {
|
||||
if (explicitCurrentHeadOid && candidates.length > 0) {
|
||||
const mergeCommitLookup = await lookupPRByMergeCommit({
|
||||
candidates,
|
||||
headOid: currentHeadOidForMergedImplicit,
|
||||
headOid: explicitCurrentHeadOid,
|
||||
ghOptions,
|
||||
repoPath,
|
||||
connectionId,
|
||||
|
||||
@@ -790,6 +790,32 @@ describe('pr-refresh-coordinator', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not coalesce detached refreshes for different worktree HEADs', async () => {
|
||||
const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator')
|
||||
getPRForBranchOutcomeMock
|
||||
.mockResolvedValueOnce({ kind: 'no-pr', fetchedAt: Date.now() })
|
||||
.mockResolvedValueOnce({ kind: 'no-pr', fetchedAt: Date.now() })
|
||||
|
||||
reportVisiblePRRefreshCandidates(
|
||||
[
|
||||
makeCandidate({ branch: '', worktreeHead: '1111111', cacheKey: '/repo::head::1111111' }),
|
||||
makeCandidate({ branch: '', worktreeHead: '2222222', cacheKey: '/repo::head::2222222' })
|
||||
],
|
||||
1,
|
||||
1
|
||||
)
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
|
||||
expect(getPRForBranchOutcomeMock).toHaveBeenNthCalledWith(1, '/repo', '', null, null, null, {
|
||||
currentHeadOid: '1111111'
|
||||
})
|
||||
expect(getPRForBranchOutcomeMock).toHaveBeenNthCalledWith(2, '/repo', '', null, null, null, {
|
||||
currentHeadOid: '2222222'
|
||||
})
|
||||
})
|
||||
|
||||
it('accounts for the extra core probe on background PR refreshes', async () => {
|
||||
const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator')
|
||||
getPRForBranchOutcomeMock.mockResolvedValueOnce({
|
||||
|
||||
@@ -178,6 +178,10 @@ function refreshKey(candidate: GitHubPRRefreshCandidate): string {
|
||||
if (typeof candidate.linkedPRNumber === 'number') {
|
||||
return `${connectionScope}::${runtimeScope}::${candidate.repoPath}::pr::${candidate.linkedPRNumber}`
|
||||
}
|
||||
const detachedHead = candidate.branch ? null : candidate.worktreeHead?.trim()
|
||||
if (detachedHead) {
|
||||
return `${connectionScope}::${runtimeScope}::${candidate.repoPath}::head::${detachedHead}`
|
||||
}
|
||||
return `${connectionScope}::${runtimeScope}::${candidate.repoPath}::branch::${candidate.branch}`
|
||||
}
|
||||
|
||||
@@ -236,6 +240,7 @@ function validateCandidate(
|
||||
if (candidate.connectionId && candidate.connectionState === 'disconnected') {
|
||||
return 'disconnected'
|
||||
}
|
||||
// Why: detached worktrees can still resolve a PR from the checked-out merge commit.
|
||||
if (
|
||||
!candidate.branch &&
|
||||
typeof candidate.linkedPRNumber !== 'number' &&
|
||||
@@ -281,6 +286,7 @@ function aliasFromCandidate(candidate: GitHubPRRefreshCandidate): GitHubPRRefres
|
||||
repoPath: candidate.repoPath,
|
||||
branch: candidate.branch,
|
||||
worktreeId: candidate.worktreeId,
|
||||
worktreeHead: candidate.worktreeHead ?? null,
|
||||
connectionId: candidate.connectionId ?? null,
|
||||
linkedPRNumber: candidate.linkedPRNumber ?? null,
|
||||
fallbackPRNumber:
|
||||
|
||||
@@ -278,23 +278,24 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
|
||||
linkedPRNumber?: number | null
|
||||
fallbackPRNumber?: number | null
|
||||
acceptMergedFallbackPR?: boolean
|
||||
currentHeadOid?: string | null
|
||||
}
|
||||
) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
const localGitOptions = localGitOptionArgs(store, repo)[0]
|
||||
const hostedReviewOptionArgs: [] | [{ localGitExecOptions: { wslDistro?: string } }] =
|
||||
localGitOptions ? [{ localGitExecOptions: localGitOptions }] : []
|
||||
const lookupOptions: GitHubPRBranchLookupOptions | undefined = hostedReviewOptionArgs[0]
|
||||
const lookupOptions: GitHubPRBranchLookupOptions = hostedReviewOptionArgs[0]
|
||||
? { ...hostedReviewOptionArgs[0] }
|
||||
: args.acceptMergedFallbackPR === true
|
||||
? {}
|
||||
: undefined
|
||||
if (lookupOptions && args.acceptMergedFallbackPR === true) {
|
||||
: {}
|
||||
if (args.acceptMergedFallbackPR === true) {
|
||||
lookupOptions.acceptMergedFallbackPR = true
|
||||
}
|
||||
const lookupOptionArgs: [] | [GitHubPRBranchLookupOptions] = lookupOptions
|
||||
? [lookupOptions]
|
||||
: []
|
||||
if (typeof args.currentHeadOid === 'string') {
|
||||
lookupOptions.currentHeadOid = args.currentHeadOid
|
||||
}
|
||||
const lookupOptionArgs: [] | [GitHubPRBranchLookupOptions] =
|
||||
Object.keys(lookupOptions).length > 0 ? [lookupOptions] : []
|
||||
const pr = await getPRForBranch(
|
||||
repo.path,
|
||||
args.branch,
|
||||
|
||||
@@ -4244,6 +4244,16 @@ describe('OrcaRuntimeService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('passes runtime PR branch current HEAD hints through to GitHub lookup', async () => {
|
||||
const runtime = new OrcaRuntimeService(store as never)
|
||||
|
||||
await runtime.getRepoPRForBranch('id:repo-1', '', null, null, false, 'abc1234')
|
||||
|
||||
expect(getPRForBranchMock).toHaveBeenCalledWith(TEST_REPO_PATH, '', null, null, null, {
|
||||
currentHeadOid: 'abc1234'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects hosted review worktree selectors outside the selected repo', async () => {
|
||||
vi.mocked(listWorktrees).mockImplementation(async (repoPath: string) => {
|
||||
if (repoPath === '/tmp/repo-b') {
|
||||
|
||||
@@ -10239,7 +10239,8 @@ export class OrcaRuntimeService {
|
||||
branch: string,
|
||||
linkedPRNumber?: number | null,
|
||||
fallbackPRNumber?: number | null,
|
||||
acceptMergedFallbackPR?: boolean
|
||||
acceptMergedFallbackPR?: boolean,
|
||||
currentHeadOid?: string | null
|
||||
): Promise<Awaited<ReturnType<typeof getPRForBranch>>> {
|
||||
const repo = await this.resolveRepoSelector(repoSelector)
|
||||
const options: GitHubPRBranchLookupOptions = this.getHostedReviewExecutionOptions(repo) ?? {}
|
||||
@@ -10247,6 +10248,9 @@ export class OrcaRuntimeService {
|
||||
if (acceptMergedFallbackPR === true) {
|
||||
lookupOptions.acceptMergedFallbackPR = true
|
||||
}
|
||||
if (typeof currentHeadOid === 'string') {
|
||||
lookupOptions.currentHeadOid = currentHeadOid
|
||||
}
|
||||
const lookupOptionArgs: [] | [GitHubPRBranchLookupOptions] =
|
||||
Object.keys(lookupOptions).length > 0 ? [lookupOptions] : []
|
||||
return getPRForBranch(
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
/* eslint-disable max-lines -- Why: GitHub runtime RPC keeps related repo, project, and mutation schemas beside their handlers so the method contract stays reviewable in one place. */
|
||||
import { z } from 'zod'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
|
||||
import {
|
||||
OptionalFiniteNumber,
|
||||
OptionalString,
|
||||
requiredString,
|
||||
requiredStringAllowingEmpty
|
||||
} from '../schemas'
|
||||
|
||||
const RepoSelector = z.object({
|
||||
repo: requiredString('Missing repo selector')
|
||||
@@ -50,10 +55,11 @@ const SlugAssignableUsers = SlugRepo.extend({
|
||||
})
|
||||
|
||||
const PrForBranch = RepoSelector.extend({
|
||||
branch: requiredString('Missing branch'),
|
||||
branch: requiredStringAllowingEmpty('Missing branch'),
|
||||
linkedPRNumber: z.number().int().positive().nullable().optional(),
|
||||
fallbackPRNumber: z.number().int().positive().nullable().optional(),
|
||||
acceptMergedFallbackPR: z.boolean().optional()
|
||||
acceptMergedFallbackPR: z.boolean().optional(),
|
||||
currentHeadOid: z.string().nullable().optional()
|
||||
})
|
||||
|
||||
const Issue = RepoSelector.extend({
|
||||
@@ -361,7 +367,8 @@ export const GITHUB_METHODS: RpcMethod[] = [
|
||||
params.branch,
|
||||
params.linkedPRNumber,
|
||||
params.fallbackPRNumber,
|
||||
params.acceptMergedFallbackPR
|
||||
params.acceptMergedFallbackPR,
|
||||
params.currentHeadOid
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
|
||||
@@ -1151,6 +1151,7 @@ export type PreloadApi = {
|
||||
linkedPRNumber?: number | null
|
||||
fallbackPRNumber?: number | null
|
||||
acceptMergedFallbackPR?: boolean
|
||||
currentHeadOid?: string | null
|
||||
}) => Promise<PRInfo | null>
|
||||
refreshPRNow: (args: { candidate: GitHubPRRefreshCandidate }) => Promise<PRRefreshOutcome>
|
||||
enqueuePRRefresh: (args: {
|
||||
|
||||
@@ -950,6 +950,7 @@ const api = {
|
||||
linkedPRNumber?: number | null
|
||||
fallbackPRNumber?: number | null
|
||||
acceptMergedFallbackPR?: boolean
|
||||
currentHeadOid?: string | null
|
||||
}): Promise<unknown> => ipcRenderer.invoke('gh:prForBranch', args),
|
||||
|
||||
refreshPRNow: (args: { candidate: GitHubPRRefreshCandidate }): Promise<unknown> =>
|
||||
|
||||
@@ -20,7 +20,11 @@ import {
|
||||
prChecksCacheSuffix,
|
||||
prCommentsCacheSuffix
|
||||
} from '@/store/slices/github'
|
||||
import { getGitHubPRCacheKey, getGitHubRepoCacheKey } from '@/store/slices/github-cache-key'
|
||||
import {
|
||||
getGitHubPRCacheBranch,
|
||||
getGitHubPRCacheKey,
|
||||
getGitHubRepoCacheKey
|
||||
} from '@/store/slices/github-cache-key'
|
||||
import { useActiveWorktree, useRepoById } from '@/store/selectors'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { openHttpLink } from '@/lib/http-link-routing'
|
||||
@@ -599,12 +603,14 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
|
||||
// Find active worktree and repo
|
||||
const isFolder = repo ? isFolderRepo(repo) : false
|
||||
const prCacheBranch = getGitHubPRCacheBranch(branch, activeWorktree?.head)
|
||||
const hasReviewLookupIdentity = Boolean(branch || activeWorktree?.head)
|
||||
const prCacheKey =
|
||||
repo && branch
|
||||
repo && hasReviewLookupIdentity
|
||||
? getGitHubPRCacheKey(
|
||||
repo.path,
|
||||
repo.id,
|
||||
branch,
|
||||
prCacheBranch,
|
||||
settings,
|
||||
repo.connectionId,
|
||||
repo.executionHostId,
|
||||
@@ -612,10 +618,10 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
)
|
||||
: ''
|
||||
const hostedReviewCacheKey =
|
||||
repo && branch
|
||||
repo && hasReviewLookupIdentity
|
||||
? getHostedReviewCacheKey(
|
||||
repo.path,
|
||||
branch,
|
||||
prCacheBranch,
|
||||
settings,
|
||||
repo.id,
|
||||
repo.connectionId,
|
||||
@@ -1186,17 +1192,19 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
}, [agentComposerState?.commentResolution, stateRequestKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (isPanelVisible && repo && !isFolder && branch) {
|
||||
void fetchHostedReviewForBranch(repo.path, branch, {
|
||||
repoId: repo.id,
|
||||
linkedGitHubPR: linkedPR,
|
||||
fallbackGitHubPR: fallbackGitHubPRNumber,
|
||||
linkedGitLabMR,
|
||||
linkedBitbucketPR,
|
||||
linkedAzureDevOpsPR,
|
||||
linkedGiteaPR,
|
||||
staleWhileRevalidate: true
|
||||
})
|
||||
if (isPanelVisible && repo && !isFolder && hasReviewLookupIdentity) {
|
||||
if (branch) {
|
||||
void fetchHostedReviewForBranch(repo.path, branch, {
|
||||
repoId: repo.id,
|
||||
linkedGitHubPR: linkedPR,
|
||||
fallbackGitHubPR: fallbackGitHubPRNumber,
|
||||
linkedGitLabMR,
|
||||
linkedBitbucketPR,
|
||||
linkedAzureDevOpsPR,
|
||||
linkedGiteaPR,
|
||||
staleWhileRevalidate: true
|
||||
})
|
||||
}
|
||||
if (activeWorktreeId && !isGitLabReviewContext) {
|
||||
enqueueGitHubPRRefresh(activeWorktreeId, 'swr', 30)
|
||||
}
|
||||
@@ -1207,6 +1215,7 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
enqueueGitHubPRRefresh,
|
||||
fallbackGitHubPRNumber,
|
||||
fetchHostedReviewForBranch,
|
||||
hasReviewLookupIdentity,
|
||||
isFolder,
|
||||
isGitLabReviewContext,
|
||||
isPanelVisible,
|
||||
@@ -1834,7 +1843,7 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
}, [activeGitLabReview, fetchComments, isPanelVisible, prNumber, repo])
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (!repo || !branch) {
|
||||
if (!repo || !hasReviewLookupIdentity || (isGitLabReviewContext && !branch)) {
|
||||
return
|
||||
}
|
||||
const initialRequestKey = checksPanelAsyncResultKey(
|
||||
@@ -1920,17 +1929,19 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
if (!isCurrentRequest()) {
|
||||
return
|
||||
}
|
||||
await refreshHostedReviewCard(fetchHostedReviewForBranch, {
|
||||
repoPath: repo.path,
|
||||
repoId: repo.id,
|
||||
branch,
|
||||
linkedGitHubPR: linkedPR,
|
||||
fallbackGitHubPR: refreshedPR?.number ?? fallbackGitHubPRNumber,
|
||||
linkedGitLabMR,
|
||||
linkedBitbucketPR,
|
||||
linkedAzureDevOpsPR,
|
||||
linkedGiteaPR
|
||||
})
|
||||
if (branch) {
|
||||
await refreshHostedReviewCard(fetchHostedReviewForBranch, {
|
||||
repoPath: repo.path,
|
||||
repoId: repo.id,
|
||||
branch,
|
||||
linkedGitHubPR: linkedPR,
|
||||
fallbackGitHubPR: refreshedPR?.number ?? fallbackGitHubPRNumber,
|
||||
linkedGitLabMR,
|
||||
linkedBitbucketPR,
|
||||
linkedAzureDevOpsPR,
|
||||
linkedGiteaPR
|
||||
})
|
||||
}
|
||||
if (!isCurrentRequest()) {
|
||||
return
|
||||
}
|
||||
@@ -2057,6 +2068,7 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
linkedPR,
|
||||
fallbackGitHubPRNumber,
|
||||
fetchGitLabDetails,
|
||||
hasReviewLookupIdentity,
|
||||
linkedAzureDevOpsPR,
|
||||
linkedBitbucketPR,
|
||||
linkedGiteaPR,
|
||||
|
||||
@@ -38,6 +38,29 @@ describe('getActiveChecksStatus', () => {
|
||||
expect(getActiveChecksStatus(state)).toBe('success')
|
||||
})
|
||||
|
||||
it('uses detached-head PR cache entries for active checks status', () => {
|
||||
const state = {
|
||||
activeWorktreeId: 'wt-1',
|
||||
repos: [{ id: 'repo-1', path: '/repo' }],
|
||||
worktreesByRepo: {
|
||||
'repo-1': [
|
||||
{
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
branch: '',
|
||||
head: 'abc1234'
|
||||
}
|
||||
]
|
||||
},
|
||||
prCache: {
|
||||
'repo-1::__detached_head__:abc1234': { data: makePR('failure'), fetchedAt: 2 },
|
||||
'repo-1::': { data: makePR('success'), fetchedAt: 999 }
|
||||
}
|
||||
} as unknown as Pick<AppState, 'activeWorktreeId' | 'repos' | 'worktreesByRepo' | 'prCache'>
|
||||
|
||||
expect(getActiveChecksStatus(state)).toBe('failure')
|
||||
})
|
||||
|
||||
it('uses GitLab MR pipeline status when the active branch has no GitHub PR cache entry', () => {
|
||||
const state = {
|
||||
activeWorktreeId: 'wt-1',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AppState } from '../../store/types'
|
||||
import { getRepoMapFromState, getWorktreeMapFromState } from '../../store/selectors'
|
||||
import type { CheckStatus } from '../../../../shared/types'
|
||||
import { getGitHubPRCacheKey } from '../../store/slices/github-cache-key'
|
||||
import { getGitHubPRCacheBranch, getGitHubPRCacheKey } from '../../store/slices/github-cache-key'
|
||||
import { getHostedReviewCacheKey } from '../../store/slices/hosted-review-cache-identity'
|
||||
|
||||
type ActiveChecksStatusState = Pick<
|
||||
@@ -28,16 +28,17 @@ export function getActiveChecksStatus(state: ActiveChecksStatusState): CheckStat
|
||||
}
|
||||
|
||||
const branch = branchDisplayName(activeWorktree.branch)
|
||||
if (!branch) {
|
||||
if (!branch && !activeWorktree.head) {
|
||||
return null
|
||||
}
|
||||
const prCacheBranch = getGitHubPRCacheBranch(branch, activeWorktree.head)
|
||||
|
||||
// Why: PR refreshes are written under repo-id scoped keys so repo path
|
||||
// changes and legacy duplicates cannot leave the activity indicator stale.
|
||||
const prCacheKey = getGitHubPRCacheKey(
|
||||
activeRepo.path,
|
||||
activeRepo.id,
|
||||
branch,
|
||||
prCacheBranch,
|
||||
state.settings,
|
||||
activeRepo.connectionId,
|
||||
activeRepo.executionHostId,
|
||||
@@ -45,7 +46,7 @@ export function getActiveChecksStatus(state: ActiveChecksStatusState): CheckStat
|
||||
)
|
||||
const hostedReviewCacheKey = getHostedReviewCacheKey(
|
||||
activeRepo.path,
|
||||
branch,
|
||||
prCacheBranch,
|
||||
state.settings,
|
||||
activeRepo.id,
|
||||
activeRepo.connectionId,
|
||||
|
||||
@@ -155,4 +155,16 @@ describe('WorktreeCard branch review lookup pending state', () => {
|
||||
expect(markup).toContain('Branch')
|
||||
expect(markup).toContain('lucide-git-branch')
|
||||
})
|
||||
|
||||
it('keeps branch status on pathname-detected paired web clients', async () => {
|
||||
vi.stubGlobal('window', { location: { pathname: '/web-index.html' } })
|
||||
const { default: WorktreeCard } = await import('./WorktreeCard')
|
||||
|
||||
const markup = renderWorktreeCardMarkup(
|
||||
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={false} />
|
||||
)
|
||||
|
||||
expect(markup).toContain('Branch')
|
||||
expect(markup).toContain('lucide-git-branch')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useEffect, useCallback, useState } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review'
|
||||
import { issueCacheKey as getIssueCacheKey } from '@/store/slices/github'
|
||||
import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key'
|
||||
import { getGitHubPRCacheBranch, getGitHubPRCacheKey } from '@/store/slices/github-cache-key'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||
@@ -153,7 +153,10 @@ function isWebClient(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
return Boolean((window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__)
|
||||
return (
|
||||
Boolean((window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) ||
|
||||
window.location.pathname.endsWith('/web-index.html')
|
||||
)
|
||||
}
|
||||
|
||||
function isCachedMergedBranchPRCurrentForWorktree(
|
||||
@@ -407,11 +410,12 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
const folderMetaRowContent = newCardStyle
|
||||
? hasPathIdentityEnabled && Boolean(folderPathIdentityDisplay)
|
||||
: isFolder
|
||||
const prCacheBranch = getGitHubPRCacheBranch(branch, worktree.head)
|
||||
const hostedReviewCacheKey =
|
||||
repo && branch
|
||||
repo && prCacheBranch
|
||||
? getHostedReviewCacheKey(
|
||||
repo.path,
|
||||
branch,
|
||||
prCacheBranch,
|
||||
settings,
|
||||
repo.id,
|
||||
repo.connectionId,
|
||||
@@ -420,11 +424,11 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
)
|
||||
: ''
|
||||
const prCacheKey =
|
||||
repo && branch
|
||||
repo && prCacheBranch
|
||||
? getGitHubPRCacheKey(
|
||||
repo.path,
|
||||
repo.id,
|
||||
branch,
|
||||
prCacheBranch,
|
||||
settings,
|
||||
repo.connectionId,
|
||||
repo.executionHostId,
|
||||
|
||||
@@ -80,6 +80,16 @@ export function getGitHubPRCacheKey(
|
||||
)
|
||||
}
|
||||
|
||||
export function getGitHubPRCacheBranch(branch: string, worktreeHead?: string | null): string {
|
||||
const trimmedHead = worktreeHead?.trim()
|
||||
if (branch || !trimmedHead) {
|
||||
return branch
|
||||
}
|
||||
// Why: detached worktrees have no branch, so PR lookup results are keyed by
|
||||
// the commit whose GitHub merge association was probed.
|
||||
return `__detached_head__:${trimmedHead}`
|
||||
}
|
||||
|
||||
export function getLegacyGitHubPRCacheKey(
|
||||
repoPath: string,
|
||||
repoId: string | undefined,
|
||||
|
||||
@@ -155,7 +155,7 @@ describe('GitHub PR refresh owner-host routing', () => {
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
method: 'github.prForBranch',
|
||||
params: { repo: 'repo-runtime', branch, linkedPRNumber: null },
|
||||
params: { repo: 'repo-runtime', branch, linkedPRNumber: null, currentHeadOid: 'head-oid' },
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
})
|
||||
@@ -228,7 +228,45 @@ describe('GitHub PR refresh owner-host routing', () => {
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
method: 'github.prForBranch',
|
||||
params: { repo: 'repo-runtime', branch, linkedPRNumber: null },
|
||||
params: { repo: 'repo-runtime', branch, linkedPRNumber: null, currentHeadOid: 'head-oid' },
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
})
|
||||
|
||||
it('routes detached post-push refreshes with the current HEAD hint', async () => {
|
||||
runtimeEnvironmentCall.mockResolvedValueOnce({
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
result: makePR({ number: 27 }),
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
})
|
||||
const store = createTestStore()
|
||||
seed(store, {
|
||||
repos: [
|
||||
makeRepo({
|
||||
id: 'repo-runtime',
|
||||
path: '/runtime/repo',
|
||||
executionHostId: 'runtime:env-1'
|
||||
})
|
||||
],
|
||||
worktreesByRepo: {
|
||||
'repo-runtime': [makeWorktree('repo-runtime', '', 'wt-runtime-detached')]
|
||||
}
|
||||
})
|
||||
|
||||
store.getState().refreshGitHubForWorktree('wt-runtime-detached')
|
||||
|
||||
await vi.waitFor(() => expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1))
|
||||
expect(enqueuePRRefresh).not.toHaveBeenCalled()
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
method: 'github.prForBranch',
|
||||
params: {
|
||||
repo: 'repo-runtime',
|
||||
branch: '',
|
||||
linkedPRNumber: null,
|
||||
currentHeadOid: 'head-oid'
|
||||
},
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
})
|
||||
@@ -272,7 +310,52 @@ describe('GitHub PR refresh owner-host routing', () => {
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
method: 'github.prForBranch',
|
||||
params: { repo: 'repo-runtime', branch: 'feature/runtime', linkedPRNumber: null },
|
||||
params: {
|
||||
repo: 'repo-runtime',
|
||||
branch: 'feature/runtime',
|
||||
linkedPRNumber: null,
|
||||
currentHeadOid: 'head-oid'
|
||||
},
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
})
|
||||
|
||||
it('routes detached runtime PR refreshes with the current HEAD hint', async () => {
|
||||
runtimeEnvironmentCall.mockResolvedValueOnce({
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
result: makePR({ number: 26 }),
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
})
|
||||
const store = createTestStore()
|
||||
seed(store, {
|
||||
settings: { activeRuntimeEnvironmentId: null } as AppState['settings'],
|
||||
repos: [
|
||||
makeRepo({
|
||||
id: 'repo-runtime',
|
||||
path: '/runtime/repo',
|
||||
executionHostId: 'runtime:env-1'
|
||||
})
|
||||
],
|
||||
worktreesByRepo: {
|
||||
'repo-runtime': [makeWorktree('repo-runtime', '', 'wt-runtime-detached')]
|
||||
},
|
||||
groupBy: 'pr-status'
|
||||
})
|
||||
|
||||
store.getState().refreshGitHubForWorktreeIfStale('wt-runtime-detached')
|
||||
|
||||
await vi.waitFor(() => expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1))
|
||||
expect(enqueuePRRefresh).not.toHaveBeenCalled()
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
method: 'github.prForBranch',
|
||||
params: {
|
||||
repo: 'repo-runtime',
|
||||
branch: '',
|
||||
linkedPRNumber: null,
|
||||
currentHeadOid: 'head-oid'
|
||||
},
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1755,6 +1755,149 @@ describe('createGitHubSlice.fetchPRForBranch', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keys detached-head PR refresh cache entries by worktree head', async () => {
|
||||
const store = createTestStore()
|
||||
const repoPath = '/repo'
|
||||
const repoId = 'repo-1'
|
||||
const firstPR = makePR({ number: 101, title: 'Detached first' })
|
||||
|
||||
store.setState({
|
||||
repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }],
|
||||
worktreesByRepo: {
|
||||
[repoId]: [
|
||||
makePRRefreshWorktree({
|
||||
id: 'wt-detached-one',
|
||||
repoId,
|
||||
branch: '',
|
||||
head: 'detached-head-one'
|
||||
}),
|
||||
makePRRefreshWorktree({
|
||||
id: 'wt-detached-two',
|
||||
repoId,
|
||||
branch: '',
|
||||
head: 'detached-head-two'
|
||||
})
|
||||
]
|
||||
}
|
||||
} as unknown as Partial<AppState>)
|
||||
mockApi.gh.refreshPRNow
|
||||
.mockResolvedValueOnce({ kind: 'found', pr: firstPR, fetchedAt: 10 })
|
||||
.mockResolvedValueOnce({ kind: 'no-pr', fetchedAt: 20 })
|
||||
|
||||
await expect(
|
||||
store.getState().fetchPRForBranch(repoPath, '', {
|
||||
force: true,
|
||||
repoId,
|
||||
worktreeId: 'wt-detached-one'
|
||||
})
|
||||
).resolves.toMatchObject({ number: 101 })
|
||||
await expect(
|
||||
store.getState().fetchPRForBranch(repoPath, '', {
|
||||
force: true,
|
||||
repoId,
|
||||
worktreeId: 'wt-detached-two'
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
|
||||
expect(mockApi.gh.refreshPRNow).toHaveBeenNthCalledWith(1, {
|
||||
candidate: expect.objectContaining({
|
||||
branch: '',
|
||||
cacheKey: `${repoId}::__detached_head__:detached-head-one`,
|
||||
worktreeHead: 'detached-head-one'
|
||||
})
|
||||
})
|
||||
expect(mockApi.gh.refreshPRNow).toHaveBeenNthCalledWith(2, {
|
||||
candidate: expect.objectContaining({
|
||||
branch: '',
|
||||
cacheKey: `${repoId}::__detached_head__:detached-head-two`,
|
||||
worktreeHead: 'detached-head-two'
|
||||
})
|
||||
})
|
||||
expect(
|
||||
store.getState().prCache[`${repoId}::__detached_head__:detached-head-one`]
|
||||
).toMatchObject({
|
||||
data: expect.objectContaining({ number: 101 }),
|
||||
fetchedAt: 10
|
||||
})
|
||||
expect(store.getState().prCache[`${repoId}::__detached_head__:detached-head-two`]).toEqual({
|
||||
data: null,
|
||||
fetchedAt: 20
|
||||
})
|
||||
})
|
||||
|
||||
it('does not reuse empty-branch hosted-review fallback across detached HEADs', async () => {
|
||||
const store = createTestStore()
|
||||
const repoPath = '/repo'
|
||||
const repoId = 'repo-1'
|
||||
const staleEmptyBranchHostedKey = getHostedReviewCacheKey(repoPath, '', null, repoId)
|
||||
const detachedHostedKey = getHostedReviewCacheKey(
|
||||
repoPath,
|
||||
'__detached_head__:detached-head-two',
|
||||
null,
|
||||
repoId
|
||||
)
|
||||
const freshPR = makePR({ number: 202, title: 'Detached second' })
|
||||
|
||||
store.setState({
|
||||
repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }],
|
||||
hostedReviewCache: {
|
||||
[staleEmptyBranchHostedKey]: {
|
||||
data: {
|
||||
provider: 'github',
|
||||
number: 101,
|
||||
title: 'Detached first',
|
||||
state: 'merged',
|
||||
url: 'https://github.com/acme/repo/pull/101',
|
||||
status: 'success',
|
||||
updatedAt: '2026-05-20T00:00:00Z',
|
||||
mergeable: 'UNKNOWN'
|
||||
},
|
||||
fetchedAt: Date.now(),
|
||||
linkedReviewHintKey: 'github:101'
|
||||
}
|
||||
},
|
||||
worktreesByRepo: {
|
||||
[repoId]: [
|
||||
makePRRefreshWorktree({
|
||||
id: 'wt-detached-two',
|
||||
repoId,
|
||||
branch: '',
|
||||
head: 'detached-head-two'
|
||||
})
|
||||
]
|
||||
}
|
||||
} as unknown as Partial<AppState>)
|
||||
mockApi.gh.refreshPRNow.mockResolvedValueOnce({
|
||||
kind: 'found',
|
||||
pr: freshPR,
|
||||
fetchedAt: 30
|
||||
})
|
||||
|
||||
await expect(
|
||||
store.getState().fetchPRForBranch(repoPath, '', {
|
||||
force: true,
|
||||
repoId,
|
||||
worktreeId: 'wt-detached-two'
|
||||
})
|
||||
).resolves.toMatchObject({ number: 202 })
|
||||
|
||||
expect(mockApi.gh.refreshPRNow).toHaveBeenCalledWith({
|
||||
candidate: expect.objectContaining({
|
||||
branch: '',
|
||||
cacheKey: `${repoId}::__detached_head__:detached-head-two`,
|
||||
fallbackPRNumber: null,
|
||||
fallbackPRSource: null,
|
||||
worktreeHead: 'detached-head-two'
|
||||
})
|
||||
})
|
||||
expect(store.getState().hostedReviewCache[staleEmptyBranchHostedKey]?.data).toMatchObject({
|
||||
number: 101
|
||||
})
|
||||
expect(store.getState().hostedReviewCache[detachedHostedKey]?.data).toMatchObject({
|
||||
number: 202
|
||||
})
|
||||
})
|
||||
|
||||
it('does not reuse local fresh PR cache for SSH-backed repos', async () => {
|
||||
const store = createTestStore()
|
||||
const repoPath = '/repo'
|
||||
@@ -3917,18 +4060,38 @@ describe('createGitHubSlice.refreshGitHubForWorktreeIfStale', () => {
|
||||
connectionId: null,
|
||||
executionHostId: 'runtime:env-1'
|
||||
}
|
||||
]
|
||||
],
|
||||
worktreesByRepo: {
|
||||
'repo-runtime': [
|
||||
makePRRefreshWorktree({
|
||||
id: 'wt-runtime-owner',
|
||||
repoId: 'repo-runtime',
|
||||
branch,
|
||||
head: 'runtime-head-oid'
|
||||
})
|
||||
]
|
||||
}
|
||||
} as unknown as Partial<AppState>)
|
||||
|
||||
await expect(
|
||||
store.getState().fetchPRForBranch(repoPath, branch, { repoId: 'repo-runtime' })
|
||||
store
|
||||
.getState()
|
||||
.fetchPRForBranch(repoPath, branch, {
|
||||
repoId: 'repo-runtime',
|
||||
worktreeId: 'wt-runtime-owner'
|
||||
})
|
||||
).resolves.toMatchObject({ number: 23 })
|
||||
|
||||
expect(mockApi.gh.refreshPRNow).not.toHaveBeenCalled()
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
method: 'github.prForBranch',
|
||||
params: { repo: 'repo-runtime', branch, linkedPRNumber: null },
|
||||
params: {
|
||||
repo: 'repo-runtime',
|
||||
branch,
|
||||
linkedPRNumber: null,
|
||||
currentHeadOid: 'runtime-head-oid'
|
||||
},
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
expect(store.getState().prCache[`runtime:env-1::repo-runtime::${branch}`]?.data).toMatchObject({
|
||||
|
||||
@@ -44,7 +44,11 @@ import { settingsForProjectRowOwner } from './github-project-row-owner'
|
||||
import { rightSidebarShowsPullRequestData } from '@/lib/right-sidebar-visibility'
|
||||
import { hostedReviewInfoFromGitHubPRInfo } from '../../../../shared/hosted-review-github'
|
||||
import { getHostedReviewCacheKey, linkedReviewHintKey } from './hosted-review-cache-identity'
|
||||
import { getGitHubPRCacheKey, getGitHubRepoCacheKey } from './github-cache-key'
|
||||
import {
|
||||
getGitHubPRCacheBranch,
|
||||
getGitHubPRCacheKey,
|
||||
getGitHubRepoCacheKey
|
||||
} from './github-cache-key'
|
||||
import { isGitHubWorkItemsQueryTooLarge } from './github-work-items-query-bounds'
|
||||
import { isMacAppDataPath } from '@/lib/passive-macos-app-data-access'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -1018,10 +1022,11 @@ function buildPRRefreshCandidate(
|
||||
return null
|
||||
}
|
||||
const branch = worktree.branch.replace(/^refs\/heads\//, '')
|
||||
const cacheBranch = getGitHubPRCacheBranch(branch, worktree.head)
|
||||
const cacheKey = prCacheKey(
|
||||
repoPath ?? repo.path,
|
||||
repo.id,
|
||||
branch,
|
||||
cacheBranch,
|
||||
settingsForGitHubRepoOwner(state.settings, repo),
|
||||
repo.connectionId,
|
||||
repo.executionHostId,
|
||||
@@ -1032,7 +1037,7 @@ function buildPRRefreshCandidate(
|
||||
state,
|
||||
repoPath ?? repo.path,
|
||||
repo.id,
|
||||
branch,
|
||||
cacheBranch,
|
||||
repo.connectionId,
|
||||
repo.executionHostId,
|
||||
true
|
||||
@@ -1156,6 +1161,7 @@ function syncHostedReviewCacheFromGitHubPRResult(args: {
|
||||
cache: AppState['hostedReviewCache']
|
||||
repoPath: string
|
||||
branch: string
|
||||
hostedReviewBranch?: string
|
||||
settings: AppState['settings']
|
||||
repoId?: string
|
||||
connectionId?: string | null
|
||||
@@ -1171,7 +1177,7 @@ function syncHostedReviewCacheFromGitHubPRResult(args: {
|
||||
}): { cache: AppState['hostedReviewCache']; accepted: boolean } {
|
||||
const hostedReviewCacheKey = getHostedReviewCacheKey(
|
||||
args.repoPath,
|
||||
args.branch,
|
||||
args.hostedReviewBranch ?? args.branch,
|
||||
args.settings,
|
||||
args.repoId,
|
||||
args.connectionId,
|
||||
@@ -1343,6 +1349,7 @@ function setGitHubPRResultCaches(
|
||||
prCacheKey: string
|
||||
repoPath: string
|
||||
branch: string
|
||||
hostedReviewBranch?: string
|
||||
settings: AppState['settings']
|
||||
repoId?: string
|
||||
connectionId?: string | null
|
||||
@@ -1362,6 +1369,7 @@ function setGitHubPRResultCaches(
|
||||
cache: state.hostedReviewCache,
|
||||
repoPath: args.repoPath,
|
||||
branch: args.branch,
|
||||
hostedReviewBranch: args.hostedReviewBranch,
|
||||
settings: args.settings,
|
||||
repoId: args.repoId,
|
||||
connectionId: args.connectionId,
|
||||
@@ -1377,7 +1385,7 @@ function setGitHubPRResultCaches(
|
||||
})
|
||||
const hostedReviewCacheKey = getHostedReviewCacheKey(
|
||||
args.repoPath,
|
||||
args.branch,
|
||||
args.hostedReviewBranch ?? args.branch,
|
||||
args.settings,
|
||||
args.repoId,
|
||||
args.connectionId,
|
||||
@@ -1420,6 +1428,7 @@ function applyGitHubPRResultToCaches(args: {
|
||||
prCacheKey: string
|
||||
repoPath: string
|
||||
branch: string
|
||||
hostedReviewBranch?: string
|
||||
settings: AppState['settings']
|
||||
repoId?: string
|
||||
connectionId?: string | null
|
||||
@@ -1442,6 +1451,7 @@ function applyGitHubPRResultToCaches(args: {
|
||||
cache: args.hostedReviewCache,
|
||||
repoPath: args.repoPath,
|
||||
branch: args.branch,
|
||||
hostedReviewBranch: args.hostedReviewBranch,
|
||||
settings: args.settings,
|
||||
repoId: args.repoId,
|
||||
connectionId: args.connectionId,
|
||||
@@ -1457,7 +1467,7 @@ function applyGitHubPRResultToCaches(args: {
|
||||
})
|
||||
const hostedReviewCacheKey = getHostedReviewCacheKey(
|
||||
args.repoPath,
|
||||
args.branch,
|
||||
args.hostedReviewBranch ?? args.branch,
|
||||
args.settings,
|
||||
args.repoId,
|
||||
args.connectionId,
|
||||
@@ -2865,11 +2875,13 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
||||
options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath
|
||||
)
|
||||
const repoId = options?.repoId ?? repo?.id
|
||||
const requestWorktree = options?.worktreeId ? findWorktreeById(get(), options.worktreeId) : null
|
||||
const requestSettings = settingsForGitHubRepoOwner(get().settings, repo)
|
||||
const cacheBranch = getGitHubPRCacheBranch(branch, requestWorktree?.head)
|
||||
const cacheKey = prCacheKey(
|
||||
repoPath,
|
||||
repoId,
|
||||
branch,
|
||||
cacheBranch,
|
||||
requestSettings,
|
||||
repo?.connectionId,
|
||||
repo?.executionHostId,
|
||||
@@ -2878,7 +2890,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
||||
const cached = get().prCache[cacheKey]
|
||||
const hostedReviewCacheKey = getHostedReviewCacheKey(
|
||||
repoPath,
|
||||
branch,
|
||||
cacheBranch,
|
||||
requestSettings,
|
||||
repoId,
|
||||
repo?.connectionId,
|
||||
@@ -2895,7 +2907,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
||||
get(),
|
||||
repoPath,
|
||||
repoId,
|
||||
branch,
|
||||
cacheBranch,
|
||||
repo?.connectionId,
|
||||
repo?.executionHostId,
|
||||
repo !== undefined
|
||||
@@ -2946,6 +2958,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
||||
repo: runtimeRepo.repo.id,
|
||||
branch,
|
||||
linkedPRNumber,
|
||||
...(requestWorktree?.head ? { currentHeadOid: requestWorktree.head } : {}),
|
||||
...(fallbackPRNumber !== null
|
||||
? { fallbackPRNumber, acceptMergedFallbackPR: fallbackPRSource !== null }
|
||||
: {})
|
||||
@@ -2957,9 +2970,6 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
||||
: ({ kind: 'no-pr', fetchedAt: Date.now() } as const)
|
||||
)
|
||||
: await (async () => {
|
||||
const requestWorktree = options?.worktreeId
|
||||
? findWorktreeById(get(), options.worktreeId)
|
||||
: null
|
||||
const candidate: GitHubPRRefreshCandidate = {
|
||||
repoId: repoId ?? '',
|
||||
repoPath,
|
||||
@@ -2989,6 +2999,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
||||
branch,
|
||||
linkedPRNumber,
|
||||
fallbackPRNumber,
|
||||
currentHeadOid: requestWorktree?.head ?? null,
|
||||
acceptMergedFallbackPR: fallbackPRNumber !== null && fallbackPRSource !== null
|
||||
})
|
||||
.then((pr) =>
|
||||
@@ -3016,6 +3027,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
||||
prCacheKey: cacheKey,
|
||||
repoPath,
|
||||
branch,
|
||||
hostedReviewBranch: cacheBranch,
|
||||
settings: requestSettings,
|
||||
repoId,
|
||||
connectionId: repo?.connectionId,
|
||||
@@ -3877,12 +3889,14 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
||||
if (isStaleExactLinkedPRLookup(s, alias.worktreeId, alias.linkedPRNumber)) {
|
||||
continue
|
||||
}
|
||||
const hostedReviewBranch = getGitHubPRCacheBranch(alias.branch, alias.worktreeHead)
|
||||
const nextCaches = applyGitHubPRResultToCaches({
|
||||
prCache: nextPRCache,
|
||||
hostedReviewCache: nextHostedReviewCache,
|
||||
prCacheKey: alias.cacheKey,
|
||||
repoPath: alias.repoPath,
|
||||
branch: alias.branch,
|
||||
hostedReviewBranch,
|
||||
settings: s.settings,
|
||||
repoId: alias.repoId,
|
||||
connectionId: alias.connectionId,
|
||||
@@ -3909,9 +3923,10 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
||||
deletePRRefreshStartedEntry(previousSequence, alias.cacheKey)
|
||||
}
|
||||
if (event.status === 'in-flight' && event.requestStartedAt !== undefined) {
|
||||
const hostedReviewBranch = getGitHubPRCacheBranch(alias.branch, alias.worktreeHead)
|
||||
const hostedReviewCacheKey = getHostedReviewCacheKey(
|
||||
alias.repoPath,
|
||||
alias.branch,
|
||||
hostedReviewBranch,
|
||||
s.settings,
|
||||
alias.repoId,
|
||||
alias.connectionId,
|
||||
@@ -3998,12 +4013,13 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
||||
}
|
||||
|
||||
const branch = wt.branch.replace(/^refs\/heads\//, '')
|
||||
if (shouldRefreshPRs && !wt.isBare && branch) {
|
||||
if (shouldRefreshPRs && !wt.isBare && (branch || wt.head)) {
|
||||
const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo)
|
||||
const cacheBranch = getGitHubPRCacheBranch(branch, wt.head)
|
||||
const prKey = prCacheKey(
|
||||
repo.path,
|
||||
repo.id,
|
||||
branch,
|
||||
cacheBranch,
|
||||
ownerSettings,
|
||||
repo.connectionId,
|
||||
repo.executionHostId
|
||||
@@ -4081,11 +4097,12 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
||||
|
||||
// Invalidate this worktree's cache entries
|
||||
const branch = worktree.branch.replace(/^refs\/heads\//, '')
|
||||
const cacheBranch = getGitHubPRCacheBranch(branch, worktree.head)
|
||||
const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo)
|
||||
const prKey = prCacheKey(
|
||||
repo.path,
|
||||
repo.id,
|
||||
branch,
|
||||
cacheBranch,
|
||||
ownerSettings,
|
||||
repo.connectionId,
|
||||
repo.executionHostId
|
||||
@@ -4116,8 +4133,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
||||
return updates
|
||||
})
|
||||
|
||||
// Re-fetch (skip when branch is empty — detached HEAD during rebase)
|
||||
if (!worktree.isBare && branch) {
|
||||
// Re-fetch; detached merged worktrees resolve PRs from the checked-out HEAD.
|
||||
if (!worktree.isBare && (branch || worktree.head)) {
|
||||
const candidate = buildPRRefreshCandidate(get(), worktree)
|
||||
if (candidate) {
|
||||
if (getPRRefreshRuntimeRepoTarget(get(), candidate)) {
|
||||
@@ -4315,7 +4332,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
||||
: cardProps.includes('pr') || rawCardProps.includes('ci')) ||
|
||||
rightSidebarShowsPullRequestData(state)
|
||||
|
||||
if (shouldRefreshPR && !worktree.isBare && branch) {
|
||||
if (shouldRefreshPR && !worktree.isBare && (branch || worktree.head)) {
|
||||
const candidate = buildPRRefreshCandidate(state, worktree)
|
||||
if (candidate) {
|
||||
if (getPRRefreshRuntimeRepoTarget(state, candidate)) {
|
||||
|
||||
@@ -1718,6 +1718,7 @@ function createGitHubApi(): WebGitHubApi {
|
||||
branch: candidate.branch,
|
||||
linkedPRNumber: candidate.linkedPRNumber ?? null,
|
||||
fallbackPRNumber: candidate.fallbackPRNumber ?? null,
|
||||
...(candidate.worktreeHead ? { currentHeadOid: candidate.worktreeHead } : {}),
|
||||
...(acceptMergedFallbackPR ? { acceptMergedFallbackPR: true } : {})
|
||||
})
|
||||
return pr
|
||||
|
||||
Reference in New Issue
Block a user