mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 16:03:26 +00:00
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:
@@ -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'
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user