Persist linkedPR when branch-name discovery finds a PR

A worktree only got linked to its PR via the terminal-output URL scanner
(observeTerminalGitHubPullRequestLink). When a PR is created outside Orca
(e.g. raw `gh pr create`) and then squash-merged with the remote branch
deleted in the same session, that scanner loses the race and linkedPR is
never set. Later refreshes look up by branch name, find nothing (branch is
gone), and the checks panel shows "No pull request found".

Persist the discovered PR number to linkedPR after a successful branch-name
lookup so subsequent refreshes use `gh pr view <number>`, which stays
authoritative after the branch is deleted. Guarded to only fill an empty
link, never overwrite an explicit one.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing
2026-06-28 14:43:41 -07:00
co-authored by Orca
parent 7608c955bb
commit 7274fbe92e
2 changed files with 148 additions and 4 deletions
+119 -4
View File
@@ -82,11 +82,36 @@ function resetRemoteRuntimeMocks() {
function createTestStore() {
return create<AppState>()(
(...a) =>
(set, get, store) =>
({
...createGitHubSlice(...a),
...createHostedReviewSlice(...a)
}) as AppState
...createGitHubSlice(set, get, store),
...createHostedReviewSlice(set, get, store),
// Minimal stand-in for the worktrees slice: applies linkedPR updates so
// tests can assert branch-name discovery persists the worktree↔PR link.
updateWorktreeMeta: async (
worktreeId: string,
updates: Partial<Worktree>,
options?: { shouldApply?: (worktree: Worktree | undefined) => boolean }
) => {
set((s) => {
const next = { ...s.worktreesByRepo }
for (const [repoId, worktrees] of Object.entries(next)) {
const index = worktrees.findIndex((w) => w.id === worktreeId)
if (index === -1) {
continue
}
if (options?.shouldApply && !options.shouldApply(worktrees[index])) {
return s
}
const updated = [...worktrees]
updated[index] = { ...updated[index], ...updates }
next[repoId] = updated
return { worktreesByRepo: next }
}
return s
})
}
}) as unknown as AppState
)
}
@@ -2133,6 +2158,96 @@ describe('createGitHubSlice.fetchPRForBranch', () => {
expect(store.getState().hostedReviewCache[hostedReviewCacheKey]).toBeUndefined()
})
it('persists linkedPR when branch-name discovery finds a PR for an unlinked worktree', async () => {
const store = createTestStore()
const repoPath = '/repo'
const repoId = 'repo-1'
const branch = 'feature/auto-link'
const worktreeId = 'wt-auto-link'
store.setState({
repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }],
worktreesByRepo: {
[repoId]: [makePRRefreshWorktree({ id: worktreeId, repoId, branch, linkedPR: null })]
}
} as unknown as Partial<AppState>)
mockApi.gh.refreshPRNow.mockResolvedValueOnce({
kind: 'found',
pr: makePR({ number: 6599, title: 'Improve failed CI check details panel' }),
fetchedAt: Date.now()
})
await store.getState().fetchPRForBranch(repoPath, branch, {
force: true,
repoId,
worktreeId
})
expect(
store.getState().worktreesByRepo[repoId]?.find((w) => w.id === worktreeId)?.linkedPR
).toBe(6599)
})
it('persists linkedPR even when the discovered PR is already merged', async () => {
const store = createTestStore()
const repoPath = '/repo'
const repoId = 'repo-1'
const branch = 'feature/auto-link-merged'
const worktreeId = 'wt-auto-link-merged'
store.setState({
repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }],
worktreesByRepo: {
[repoId]: [makePRRefreshWorktree({ id: worktreeId, repoId, branch, linkedPR: null })]
}
} as unknown as Partial<AppState>)
mockApi.gh.refreshPRNow.mockResolvedValueOnce({
kind: 'found',
pr: makePR({ number: 6599, state: 'merged' }),
fetchedAt: Date.now()
})
await store.getState().fetchPRForBranch(repoPath, branch, {
force: true,
repoId,
worktreeId
})
expect(
store.getState().worktreesByRepo[repoId]?.find((w) => w.id === worktreeId)?.linkedPR
).toBe(6599)
})
it('does not overwrite an existing linkedPR on branch-name discovery', async () => {
const store = createTestStore()
const repoPath = '/repo'
const repoId = 'repo-1'
const branch = 'feature/already-linked'
const worktreeId = 'wt-already-linked'
store.setState({
repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }],
worktreesByRepo: {
[repoId]: [makePRRefreshWorktree({ id: worktreeId, repoId, branch, linkedPR: 42 })]
}
} as unknown as Partial<AppState>)
mockApi.gh.refreshPRNow.mockResolvedValueOnce({
kind: 'found',
pr: makePR({ number: 99 }),
fetchedAt: Date.now()
})
await store.getState().fetchPRForBranch(repoPath, branch, {
force: true,
repoId,
worktreeId
})
expect(
store.getState().worktreesByRepo[repoId]?.find((w) => w.id === worktreeId)?.linkedPR
).toBe(42)
})
it('preserves cached PR data when a forced coordinator refresh errors', async () => {
const store = createTestStore()
const repoPath = '/repo'
+29
View File
@@ -3034,6 +3034,35 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
if (didUpdatePRCache) {
debouncedSaveCache(get())
}
// Why: branch-name auto-discovery is the only reliable way Orca finds a
// PR created outside its own flow (e.g. raw `gh pr create`). Persisting
// the number to linkedPR keeps the worktree linked after the remote
// branch is deleted (e.g. on squash-merge), when branch lookups can no
// longer find it but `gh pr view <number>` still can.
const updateWorktreeMeta = get().updateWorktreeMeta
if (
outcome.kind === 'found' &&
pr &&
linkedPRNumber == null &&
options?.worktreeId &&
typeof updateWorktreeMeta === 'function'
) {
const worktreeId = options.worktreeId
const prNumber = pr.number
void updateWorktreeMeta(
worktreeId,
{ linkedPR: prNumber },
{
shouldApply: (currentWorktree) =>
Boolean(
currentWorktree &&
!currentWorktree.isBare &&
!currentWorktree.isArchived &&
currentWorktree.linkedPR == null
)
}
)
}
}
if (
shouldPreserveExistingPRForFallbackMiss({