Merge pull request #1722 from Runfusion/feature/import-github-fix

fix(FN-1721): scope GitHub import remotes to project
This commit is contained in:
gsxdsm
2026-06-22 09:45:45 -07:00
committed by GitHub
7 changed files with 87 additions and 7 deletions

View 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.

View File

@@ -86,6 +86,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
const [loadingRemotes, setLoadingRemotes] = useState(false);
const [selectedRemoteName, setSelectedRemoteName] = useState<string>("");
const mountedRef = useRef(false);
const remoteLoadRequestIdRef = useRef(0);
const modalRef = useRef<HTMLDivElement>(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);

View File

@@ -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(<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 () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);

View File

@@ -10,6 +10,7 @@
"paths": {
"node-pty": ["./src/types/node-pty/index.d.ts"],
"@fusion/dashboard/app/components/TaskCard": ["./app/components/TaskCard.tsx"],
"@fusion/dashboard/app/components/ViewHeader": ["./app/components/ViewHeader.tsx"],
"@fusion/dashboard/app/plugins/types": ["./app/plugins/types.ts"],
"@fusion/dashboard/app/utils/projectStorage": ["./app/utils/projectStorage.ts"],
"@fusion/dashboard/app/utils/taskStuck": ["./app/utils/taskStuck.ts"]

View File

@@ -11,6 +11,7 @@
"@fusion/test-utils": ["../core/src/__test-utils__/workspace.ts"],
"node-pty": ["./src/types/node-pty/index.d.ts"],
"@fusion/dashboard/app/components/TaskCard": ["./app/components/TaskCard.tsx"],
"@fusion/dashboard/app/components/ViewHeader": ["./app/components/ViewHeader.tsx"],
"@fusion/dashboard/app/plugins/types": ["./app/plugins/types.ts"],
"@fusion/dashboard/app/utils/projectStorage": ["./app/utils/projectStorage.ts"],
"@fusion/dashboard/app/utils/taskStuck": ["./app/utils/taskStuck.ts"]

View File

@@ -125,6 +125,7 @@ export default defineConfig({
alias: {
"@fusion/core": resolve(__dirname, "../core/src/types.ts"),
"@fusion/dashboard/app/components/TaskCard": resolve(__dirname, "app/components/TaskCard.tsx"),
"@fusion/dashboard/app/components/ViewHeader": resolve(__dirname, "app/components/ViewHeader.tsx"),
"@fusion/dashboard/app/plugins/types": resolve(__dirname, "app/plugins/types.ts"),
"@fusion/dashboard/app/utils/projectStorage": resolve(__dirname, "app/utils/projectStorage.ts"),
"@fusion/dashboard/app/utils/taskStuck": resolve(__dirname, "app/utils/taskStuck.ts"),

View File

@@ -424,6 +424,7 @@ export default defineConfig({
"@fusion/plugin-sdk": resolve(__dirname, "../plugin-sdk/src/index.ts"),
"@fusion/test-utils": resolve(__dirname, "../core/src/__test-utils__/workspace.ts"),
"@fusion/dashboard/app/components/TaskCard": resolve(__dirname, "app/components/TaskCard.tsx"),
"@fusion/dashboard/app/components/ViewHeader": resolve(__dirname, "app/components/ViewHeader.tsx"),
"@fusion/dashboard/app/plugins/types": resolve(__dirname, "app/plugins/types.ts"),
"@fusion/dashboard/app/utils/projectStorage": resolve(__dirname, "app/utils/projectStorage.ts"),
"@fusion/dashboard/app/utils/taskStuck": resolve(__dirname, "app/utils/taskStuck.ts"),