From 67281fe4dde9c75f671b9edc933110602dc413b2 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 09:25:26 -0700 Subject: [PATCH] fix(FN-1721): scope GitHub import remotes to project Pass the active projectId when the GitHub import modal detects remotes so multi-project dashboards do not show a false no-remotes state. Ignore stale remote responses when projectId changes while the modal remains open. References: https://github.com/Runfusion/Fusion/issues/1721 --- .changeset/github-import-project-remotes.md | 5 ++ .../app/components/GitHubImportModal.tsx | 25 ++++++-- .../__tests__/GitHubImportModal.test.tsx | 60 ++++++++++++++++++- 3 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 .changeset/github-import-project-remotes.md diff --git a/.changeset/github-import-project-remotes.md b/.changeset/github-import-project-remotes.md new file mode 100644 index 0000000000..b960e8fc67 --- /dev/null +++ b/.changeset/github-import-project-remotes.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix Import from GitHub remote detection in multi-project dashboards by passing the active `projectId` to the `/api/git/remotes` lookup. The dialog now lists configured GitHub remotes instead of showing "No GitHub remotes detected" when the backend requires project scope. diff --git a/packages/dashboard/app/components/GitHubImportModal.tsx b/packages/dashboard/app/components/GitHubImportModal.tsx index 34fe32d204..4b4dd1e72f 100644 --- a/packages/dashboard/app/components/GitHubImportModal.tsx +++ b/packages/dashboard/app/components/GitHubImportModal.tsx @@ -86,6 +86,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, const [loadingRemotes, setLoadingRemotes] = useState(false); const [selectedRemoteName, setSelectedRemoteName] = useState(""); const mountedRef = useRef(false); + const remoteLoadRequestIdRef = useRef(0); const modalRef = useRef(null); useModalResizePersist(modalRef, isOpen && resizePersistEnabled, "fusion:github-modal-size"); const overlayDismissProps = useOverlayDismiss(onClose); @@ -153,11 +154,22 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, autoLoadedRef.current = null; mountedRef.current = true; + const remoteLoadRequestId = remoteLoadRequestIdRef.current + 1; + remoteLoadRequestIdRef.current = remoteLoadRequestId; + let cancelled = false; - // Fetch git remotes - fetchGitRemotes() + /* + FNXC:GitHubImport 2026-06-22-09:08: + Import from GitHub must detect remotes for the active project, not the dashboard process fallback. + The remotes API returns an empty list without projectId in multi-project mode, which incorrectly shows "No GitHub remotes detected" for configured repositories. + + FNXC:GitHubImport 2026-06-22-09:22: + Project changes can happen while the modal stays open, so remote discovery must ignore stale responses from earlier projectId requests. + A mounted-only guard is insufficient because the next effect marks the component mounted again before the older request resolves. + */ + fetchGitRemotes(projectId) .then((fetchedRemotes) => { - if (!mountedRef.current) return; + if (cancelled || !mountedRef.current || remoteLoadRequestId !== remoteLoadRequestIdRef.current) return; setRemotes(fetchedRemotes); setLoadingRemotes(false); @@ -179,16 +191,17 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, // If no remotes, owner/repo remain empty }) .catch(() => { - if (mountedRef.current) { + if (!cancelled && mountedRef.current && remoteLoadRequestId === remoteLoadRequestIdRef.current) { setLoadingRemotes(false); } }); return () => { + cancelled = true; mountedRef.current = false; }; } - }, [isOpen]); + }, [isOpen, projectId]); // Handle remote selection change const handleRemoteChange = useCallback((remoteName: string) => { @@ -453,7 +466,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, setImporting(false); } } - }, [activeTab, selectedIssueNumber, selectedPullNumber, owner, repo, onImport, isMobile, mobileView]); + }, [activeTab, selectedIssueNumber, selectedPullNumber, owner, repo, projectId, onImport, isMobile, mobileView]); const selectedIssue = issues.find((i) => i.number === selectedIssueNumber); const selectedPull = pulls.find((p) => p.number === selectedPullNumber); diff --git a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx index e86db036ed..3b4b09f2f2 100644 --- a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; +import { act, render, screen, fireEvent, waitFor, within } from "@testing-library/react"; import { GitHubImportModal } from "../GitHubImportModal"; import { apiFetchGitHubIssues, @@ -294,6 +294,64 @@ describe("GitHubImportModal", () => { }); describe("with single remote", () => { + it("loads remotes using the active project id", async () => { + vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote); + render(); + + await waitFor(() => { + expect(fetchGitRemotes).toHaveBeenCalledWith("project-1"); + }); + }); + + it("ignores stale remote responses after the active project changes", async () => { + const projectARemote: GitRemote[] = [ + { name: "origin", owner: "project-a", repo: "old-repo", url: "https://github.com/project-a/old-repo.git" }, + ]; + const projectBRemote: GitRemote[] = [ + { name: "origin", owner: "project-b", repo: "new-repo", url: "https://github.com/project-b/new-repo.git" }, + ]; + let resolveProjectA!: (value: GitRemote[]) => void; + let resolveProjectB!: (value: GitRemote[]) => void; + vi.mocked(fetchGitRemotes) + .mockImplementationOnce(() => new Promise((resolve) => { + resolveProjectA = resolve; + })) + .mockImplementationOnce(() => new Promise((resolve) => { + resolveProjectB = resolve; + })); + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(fetchGitRemotes).toHaveBeenCalledWith("project-a"); + }); + + rerender(); + + await waitFor(() => { + expect(fetchGitRemotes).toHaveBeenCalledWith("project-b"); + }); + + await act(async () => { + resolveProjectB(projectBRemote); + }); + + await waitFor(() => { + expect(screen.getByText("project-b/new-repo")).toBeTruthy(); + }); + + await act(async () => { + resolveProjectA(projectARemote); + }); + + await waitFor(() => { + expect(screen.getByText("project-b/new-repo")).toBeTruthy(); + expect(screen.queryByText("project-a/old-repo")).toBeNull(); + }); + }); + it("auto-selects the remote and shows compact pill", async () => { vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote); render();