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
This commit is contained in:
5
.changeset/github-import-project-remotes.md
Normal file
5
.changeset/github-import-project-remotes.md
Normal file
@@ -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.
|
||||||
@@ -86,6 +86,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
|||||||
const [loadingRemotes, setLoadingRemotes] = useState(false);
|
const [loadingRemotes, setLoadingRemotes] = useState(false);
|
||||||
const [selectedRemoteName, setSelectedRemoteName] = useState<string>("");
|
const [selectedRemoteName, setSelectedRemoteName] = useState<string>("");
|
||||||
const mountedRef = useRef(false);
|
const mountedRef = useRef(false);
|
||||||
|
const remoteLoadRequestIdRef = useRef(0);
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
useModalResizePersist(modalRef, isOpen && resizePersistEnabled, "fusion:github-modal-size");
|
useModalResizePersist(modalRef, isOpen && resizePersistEnabled, "fusion:github-modal-size");
|
||||||
const overlayDismissProps = useOverlayDismiss(onClose);
|
const overlayDismissProps = useOverlayDismiss(onClose);
|
||||||
@@ -153,11 +154,22 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
|||||||
autoLoadedRef.current = null;
|
autoLoadedRef.current = null;
|
||||||
|
|
||||||
mountedRef.current = true;
|
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) => {
|
.then((fetchedRemotes) => {
|
||||||
if (!mountedRef.current) return;
|
if (cancelled || !mountedRef.current || remoteLoadRequestId !== remoteLoadRequestIdRef.current) return;
|
||||||
|
|
||||||
setRemotes(fetchedRemotes);
|
setRemotes(fetchedRemotes);
|
||||||
setLoadingRemotes(false);
|
setLoadingRemotes(false);
|
||||||
@@ -179,16 +191,17 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
|||||||
// If no remotes, owner/repo remain empty
|
// If no remotes, owner/repo remain empty
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (mountedRef.current) {
|
if (!cancelled && mountedRef.current && remoteLoadRequestId === remoteLoadRequestIdRef.current) {
|
||||||
setLoadingRemotes(false);
|
setLoadingRemotes(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
mountedRef.current = false;
|
mountedRef.current = false;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen, projectId]);
|
||||||
|
|
||||||
// Handle remote selection change
|
// Handle remote selection change
|
||||||
const handleRemoteChange = useCallback((remoteName: string) => {
|
const handleRemoteChange = useCallback((remoteName: string) => {
|
||||||
@@ -453,7 +466,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
|||||||
setImporting(false);
|
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 selectedIssue = issues.find((i) => i.number === selectedIssueNumber);
|
||||||
const selectedPull = pulls.find((p) => p.number === selectedPullNumber);
|
const selectedPull = pulls.find((p) => p.number === selectedPullNumber);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
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 { GitHubImportModal } from "../GitHubImportModal";
|
||||||
import {
|
import {
|
||||||
apiFetchGitHubIssues,
|
apiFetchGitHubIssues,
|
||||||
@@ -294,6 +294,64 @@ describe("GitHubImportModal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("with single remote", () => {
|
describe("with single remote", () => {
|
||||||
|
it("loads remotes using the active project id", async () => {
|
||||||
|
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||||
|
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" />);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-a" />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchGitRemotes).toHaveBeenCalledWith("project-a");
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-b" />);
|
||||||
|
|
||||||
|
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 () => {
|
it("auto-selects the remote and shows compact pill", async () => {
|
||||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||||
|
|||||||
Reference in New Issue
Block a user