From f287f4695f8fa2072c6a736a993d2775ccafdf94 Mon Sep 17 00:00:00 2001 From: orca-bug-scan-bot Date: Tue, 5 May 2026 14:29:21 -0700 Subject: [PATCH] fix: address pr-bug-scan findings from #1444 Threaded linkedPRNumber through all ChecksPanel force-refresh paths; gated useComposerState's PR-URL-from-name recovery on selected repo slug match; wrapped main-process linkedPR fallback gh pr view i --- src/main/github/client.ts | 12 ++++- .../components/right-sidebar/ChecksPanel.tsx | 19 ++++--- src/renderer/src/hooks/useComposerState.ts | 49 ++++++++++++++++++- 3 files changed, 68 insertions(+), 12 deletions(-) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 55bf9035d66..033998d85ac 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -941,8 +941,16 @@ export async function getPRForBranch( '--json', 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' ] - const { stdout } = await ghExecFileAsync(args, { cwd: repoPath }) - data = JSON.parse(stdout) + try { + const { stdout } = await ghExecFileAsync(args, { cwd: repoPath }) + data = JSON.parse(stdout) + } catch { + // Why: a stale linkedPRNumber (PR deleted, wrong repo, …) makes + // `gh pr view ` reject. Treat that as the no-PR case so + // callers see the historical `null` semantics instead of a thrown + // error every poll cycle. + data = null + } } if (!data) { diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index df4205926a8..47a86066972 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -105,8 +105,8 @@ export default function ChecksPanel(): React.JSX.Element { // them so we don't keep rendering cached branch summaries or empty file // lists from an older payload. conflictSummaryRefreshKeyRef.current = refreshKey - void fetchPRForBranch(repo.path, branch, { force: true }) - }, [repo, isFolder, branch, pr, fetchPRForBranch]) + void fetchPRForBranch(repo.path, branch, { force: true, linkedPRNumber: linkedPR }) + }, [repo, isFolder, branch, pr, linkedPR, fetchPRForBranch]) // Fetch checks via cached store method const fetchChecks = useCallback( @@ -235,7 +235,10 @@ export default function ChecksPanel(): React.JSX.Element { } setIsRefreshing(true) try { - const refreshedPR = await fetchPRForBranch(repo.path, branch, { force: true }) + const refreshedPR = await fetchPRForBranch(repo.path, branch, { + force: true, + linkedPRNumber: linkedPR + }) if (refreshedPR) { await Promise.all([ fetchChecks({ force: true, prNumberOverride: refreshedPR.number }), @@ -248,7 +251,7 @@ export default function ChecksPanel(): React.JSX.Element { } finally { setIsRefreshing(false) } - }, [repo, branch, fetchPRForBranch, fetchChecks, fetchComments]) + }, [repo, branch, linkedPR, fetchPRForBranch, fetchChecks, fetchComments]) const handleStartEdit = useCallback(() => { if (!pr) { @@ -278,13 +281,13 @@ export default function ChecksPanel(): React.JSX.Element { }) if (ok) { // Re-fetch PR to get updated title - await fetchPRForBranch(repo.path, branch, { force: true }) + await fetchPRForBranch(repo.path, branch, { force: true, linkedPRNumber: linkedPR }) } } finally { setTitleSaving(false) setEditingTitle(false) } - }, [repo, pr, titleDraft, branch, fetchPRForBranch]) + }, [repo, pr, titleDraft, branch, linkedPR, fetchPRForBranch]) const handleTitleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -318,9 +321,9 @@ export default function ChecksPanel(): React.JSX.Element { // Refresh PR (passed to PRActions) const handleRefreshPR = useCallback(async () => { if (repo && branch) { - await fetchPRForBranch(repo.path, branch, { force: true }) + await fetchPRForBranch(repo.path, branch, { force: true, linkedPRNumber: linkedPR }) } - }, [repo, branch, fetchPRForBranch]) + }, [repo, branch, linkedPR, fetchPRForBranch]) // Open PR in browser const handleOpenPR = useCallback(() => { diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 2da6a45d5c3..87e510454a7 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -358,6 +358,40 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS agentPromptRef.current = agentPrompt const selectedRepo = eligibleRepos.find((repo) => repo.id === repoId) + + // Why: resolves the selected repo's owner/repo slug so a PR URL pasted + // into the workspace name field can be matched against the current repo. + // Pasting a PR URL from a different repo would otherwise recover only the + // PR number, mislinking the worktree to an unrelated PR with the same + // number in the selected repo. + const [selectedRepoSlug, setSelectedRepoSlug] = useState<{ owner: string; repo: string } | null>( + null + ) + useEffect(() => { + if (!selectedRepo) { + setSelectedRepoSlug(null) + return + } + let cancelled = false + void (window.api.gh.repoSlug({ repoPath: selectedRepo.path }) as Promise<{ + owner: string + repo: string + } | null>) + .then((result) => { + if (cancelled) { + return + } + setSelectedRepoSlug(result) + }) + .catch(() => { + if (!cancelled) { + setSelectedRepoSlug(null) + } + }) + return () => { + cancelled = true + } + }, [selectedRepo?.id, selectedRepo?.path]) const sparsePresetsForRepo = sparsePresetsByRepo[repoId] const sparsePresets = sparsePresetsForRepo ?? EMPTY_SPARSE_PRESETS const normalizedSparseDirectories = useMemo( @@ -412,10 +446,21 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } const fromName = parseGitHubIssueOrPRLink(name) if (fromName && fromName.type === 'pr') { - return fromName.number + // Why: only adopt a number when the URL's owner/repo matches the + // selected repo. Pasting `github.com/other/repo/pull/1234` must not + // mislink the worktree to an unrelated PR #1234 in the current repo. + // If the slug hasn't resolved yet, suppress recovery rather than + // risking a cross-repo mislink. + if ( + selectedRepoSlug && + fromName.slug.owner.toLowerCase() === selectedRepoSlug.owner.toLowerCase() && + fromName.slug.repo.toLowerCase() === selectedRepoSlug.repo.toLowerCase() + ) { + return fromName.number + } } return null - }, [linkedPR, name]) + }, [linkedPR, name, selectedRepoSlug]) const setupConfig = useMemo( () => getSetupConfig(selectedRepo, yamlHooks), [selectedRepo, yamlHooks]