import "./GitHubImportModal.css"; import { useState, useEffect, useCallback, useRef } from "react"; import type { Task } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import { apiFetchGitHubIssues, apiImportGitHubIssue, apiFetchGitHubPulls, apiImportGitHubPull, fetchGitRemotes, type GitHubIssue, type GitHubPull, type GitRemote, } from "../api"; import { Loader2, RefreshCw, ArrowLeft, GitPullRequest, CircleDot } from "lucide-react"; import { useModalResizePersist } from "../hooks/useModalResizePersist"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; interface GitHubImportModalProps { isOpen: boolean; onClose: () => void; onImport: (task: Task) => void; tasks: Task[]; projectId?: string; } // Mobile breakpoint in pixels const MOBILE_BREAKPOINT = 640; type TabType = "issues" | "pulls"; export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId }: GitHubImportModalProps) { useMobileScrollLock(isOpen); const [owner, setOwner] = useState(""); const [repo, setRepo] = useState(""); const [labels, setLabels] = useState(""); const [loading, setLoading] = useState(false); // Tab state const [activeTab, setActiveTab] = useState("issues"); // Issues state const [issues, setIssues] = useState([]); const [selectedIssueNumber, setSelectedIssueNumber] = useState(null); // Pulls state const [pulls, setPulls] = useState([]); const [selectedPullNumber, setSelectedPullNumber] = useState(null); const [error, setError] = useState(null); const [importing, setImporting] = useState(false); // Git remotes state const [remotes, setRemotes] = useState([]); const [loadingRemotes, setLoadingRemotes] = useState(false); const [selectedRemoteName, setSelectedRemoteName] = useState(""); const mountedRef = useRef(false); const modalRef = useRef(null); useModalResizePersist(modalRef, isOpen, "fusion:github-modal-size"); const overlayDismissProps = useOverlayDismiss(onClose); // Mobile view state const [isMobile, setIsMobile] = useState(false); const [mobileView, setMobileView] = useState<"list" | "preview">("list"); // Track which owner/repo we've already auto-loaded to prevent duplicate loads const autoLoadedRef = useRef<{ owner: string; repo: string; labels: string; tab: TabType } | null>(null); // Build set of already imported URLs from existing tasks const importedUrls = new Set(); for (const task of tasks) { // Check for issue URLs const issueMatch = task.description.match(/Source: (https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/\d+)/); if (issueMatch) { importedUrls.add(issueMatch[1]); } // Check for PR URLs const prMatch = task.description.match(/PR: (https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+)/); if (prMatch) { importedUrls.add(prMatch[1]); } } // Reset state when modal opens and fetch remotes useEffect(() => { if (isOpen) { setOwner(""); setRepo(""); setLabels(""); setIssues([]); setSelectedIssueNumber(null); setPulls([]); setSelectedPullNumber(null); setActiveTab("issues"); setError(null); setImporting(false); setRemotes([]); setLoadingRemotes(true); setSelectedRemoteName(""); autoLoadedRef.current = null; mountedRef.current = true; // Fetch git remotes fetchGitRemotes() .then((fetchedRemotes) => { if (!mountedRef.current) return; setRemotes(fetchedRemotes); setLoadingRemotes(false); if (fetchedRemotes.length === 1) { // Single remote: auto-select it const remote = fetchedRemotes[0]; setOwner(remote.owner); setRepo(remote.repo); setSelectedRemoteName(remote.name); } else if (fetchedRemotes.length > 1) { // Multiple remotes: don't auto-select, user must choose setOwner(""); setRepo(""); setSelectedRemoteName(""); } // If no remotes, owner/repo remain empty }) .catch(() => { if (mountedRef.current) { setLoadingRemotes(false); } }); return () => { mountedRef.current = false; }; } }, [isOpen]); // Handle remote selection change const handleRemoteChange = useCallback((remoteName: string) => { setSelectedRemoteName(remoteName); if (remoteName === "") { setOwner(""); setRepo(""); } else { const remote = remotes.find((r) => r.name === remoteName); if (remote) { setOwner(remote.owner); setRepo(remote.repo); } } }, [remotes]); // Handle load issues - defined BEFORE the auto-load useEffect const handleLoad = useCallback(async () => { if (!owner.trim() || !repo.trim()) { setError("Repository must be selected"); return; } setLoading(true); setError(null); setIssues([]); setSelectedIssueNumber(null); try { const labelArray = labels .split(",") .map((l) => l.trim()) .filter(Boolean); const fetchedIssues = await apiFetchGitHubIssues(owner.trim(), repo.trim(), 30, labelArray.length > 0 ? labelArray : undefined); setIssues(fetchedIssues); if (fetchedIssues.length === 0) { setError("No open issues found"); } } catch (err) { setError(getErrorMessage(err) || "Failed to fetch issues"); } finally { setLoading(false); } }, [owner, repo, labels]); // Handle load pull requests const handleLoadPulls = useCallback(async () => { if (!owner.trim() || !repo.trim()) { setError("Repository must be selected"); return; } setLoading(true); setError(null); setPulls([]); setSelectedPullNumber(null); try { const fetchedPulls = await apiFetchGitHubPulls(owner.trim(), repo.trim(), 30); setPulls(fetchedPulls); if (fetchedPulls.length === 0) { setError("No open pull requests found"); } } catch (err) { setError(getErrorMessage(err) || "Failed to fetch pull requests"); } finally { setLoading(false); } }, [owner, repo]); // Auto-load data when owner and repo are set and valid useEffect(() => { if (!isOpen) return; if (!owner.trim() || !repo.trim()) return; if (loading || importing) return; // Check if we've already auto-loaded for this exact combination const currentKey = { owner: owner.trim(), repo: repo.trim(), labels: labels.trim(), tab: activeTab }; if ( autoLoadedRef.current?.owner === currentKey.owner && autoLoadedRef.current?.repo === currentKey.repo && autoLoadedRef.current?.labels === currentKey.labels && autoLoadedRef.current?.tab === currentKey.tab ) { return; } // Mark as auto-loaded and trigger the load autoLoadedRef.current = currentKey; if (activeTab === "issues") { handleLoad(); } else { handleLoadPulls(); } }, [owner, repo, labels, activeTab, isOpen, loading, importing, handleLoad, handleLoadPulls]); // Handle escape key useEffect(() => { if (!isOpen) return; const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; document.addEventListener("keydown", handleKey); return () => document.removeEventListener("keydown", handleKey); }, [isOpen, onClose]); // Detect mobile viewport useEffect(() => { if (!isOpen) return; const checkMobile = () => { setIsMobile(window.innerWidth <= MOBILE_BREAKPOINT); }; // Check initially checkMobile(); // Listen for resize window.addEventListener("resize", checkMobile); return () => window.removeEventListener("resize", checkMobile); }, [isOpen]); // Handle issue selection - switch to preview view on mobile const handleIssueSelect = useCallback((issueNumber: number) => { setSelectedIssueNumber(issueNumber); if (isMobile) { setMobileView('preview'); } }, [isMobile]); // Handle pull request selection - switch to preview view on mobile const handlePullSelect = useCallback((pullNumber: number) => { setSelectedPullNumber(pullNumber); if (isMobile) { setMobileView('preview'); } }, [isMobile]); // Handle back button - return to list view on mobile const handleBackToList = useCallback(() => { setMobileView('list'); }, []); const handleImport = useCallback(async () => { if (activeTab === "issues") { if (selectedIssueNumber === null) return; setImporting(true); setError(null); try { const task = await apiImportGitHubIssue(owner.trim(), repo.trim(), selectedIssueNumber, projectId); onImport(task); setSelectedIssueNumber(null); if (isMobile && mobileView === "preview") { setMobileView("list"); } } catch (err) { const msg = getErrorMessage(err); if (msg?.includes("already imported")) { setError(msg); } else { setError(msg || "Failed to import issue"); } } finally { setImporting(false); } } else { if (selectedPullNumber === null) return; setImporting(true); setError(null); try { const task = await apiImportGitHubPull(owner.trim(), repo.trim(), selectedPullNumber, projectId); onImport(task); setSelectedPullNumber(null); if (isMobile && mobileView === "preview") { setMobileView("list"); } } catch (err) { const msg = getErrorMessage(err); if (msg?.includes("already imported")) { setError(msg); } else { setError(msg || "Failed to import pull request"); } } finally { setImporting(false); } } }, [activeTab, selectedIssueNumber, selectedPullNumber, owner, repo, onImport, isMobile, mobileView]); const selectedIssue = issues.find((i) => i.number === selectedIssueNumber); const selectedPull = pulls.find((p) => p.number === selectedPullNumber); if (!isOpen) return null; // Determine state flags const hasRemotes = remotes.length > 0; const singleRemote = remotes.length === 1; // Tab-specific counts const importedIssueCount = issues.filter((issue) => importedUrls.has(issue.html_url)).length; const importedPullCount = pulls.filter((pull) => importedUrls.has(pull.html_url)).length; // Empty states const isIssuesEmpty = error === "No open issues found"; const isPullsEmpty = error === "No open pull requests found"; const isEmptyState = activeTab === "issues" ? isIssuesEmpty : isPullsEmpty; // Results error state const isIssuesError = Boolean(error) && !isIssuesEmpty && issues.length === 0 && !loading; const isPullsError = Boolean(error) && !isPullsEmpty && pulls.length === 0 && !loading; const isResultsError = activeTab === "issues" ? isIssuesError : isPullsError; // Results content const hasIssuesContent = loading || issues.length > 0 || isIssuesEmpty || isIssuesError; const hasPullsContent = loading || pulls.length > 0 || isPullsEmpty || isPullsError; const hasResultsContent = activeTab === "issues" ? hasIssuesContent : hasPullsContent; // Inline error const showIssuesError = Boolean(error) && issues.length > 0 && !isIssuesEmpty; const showPullsError = Boolean(error) && pulls.length > 0 && !isPullsEmpty; const showInlineErrorBanner = activeTab === "issues" ? showIssuesError : showPullsError; return (

Import from GitHub

Choose a detected remote, load open issues or pull requests, and import one into the board.

{/* Tab Navigation */}
{/* Compact Toolbar */}
{/* Left: Remote selector */}
{loadingRemotes ? (
Detecting…
) : !hasRemotes ? ( No remotes ) : singleRemote ? (
{remotes[0].name} {remotes[0].owner}/{remotes[0].repo}
) : (
)}
{/* Center: Labels filter (only for issues) */}
{activeTab === "issues" ? ( <> setLabels(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleLoad()} disabled={loading || importing || !hasRemotes} aria-label="Filter issues by labels" /> ) : ( Open pull requests from {owner || "selected remote"} )}
{/* Right: Load button */}
{/* Warning/Error states below toolbar */} {!loadingRemotes && !hasRemotes && (
No GitHub remotes detected Add a GitHub remote to this repository, then reopen the modal.
git remote add origin https://github.com/owner/repo.git
)} {showInlineErrorBanner && (
{error}
)} {/* Two-pane workspace */}
{/* Left pane: Issue/PR list */}

{activeTab === "issues" ? "Issues" : "Pull Requests"}

{activeTab === "issues" && issues.length > 0 && (
{issues.length} issue{issues.length === 1 ? "" : "s"} {importedIssueCount} imported
)} {activeTab === "pulls" && pulls.length > 0 && (
{pulls.length} pull request{pulls.length === 1 ? "" : "s"} {importedPullCount} imported
)}
{!hasResultsContent && (
Nothing loaded yet Select a repository and click Load to start reviewing import candidates.
)} {loading && (
Loading open {activeTab === "issues" ? "issues" : "pull requests"}… Fetching the latest list from GitHub.
)} {isResultsError && (
Could not load {activeTab === "issues" ? "issues" : "pull requests"} {error}
)} {isEmptyState && (
No open {activeTab === "issues" ? "issues" : "pull requests"} found {activeTab === "issues" ? "Try a different label filter or choose another repository." : "Choose another repository."}
)} {/* Issues list */} {activeTab === "issues" && issues.length > 0 && (
{issues.map((issue) => { const isImported = importedUrls.has(issue.html_url); return (
!isImported && handleIssueSelect(issue.number)} > handleIssueSelect(issue.number)} disabled={isImported} aria-label={`Select issue #${issue.number}`} />
#{issue.number} {issue.title}
{issue.labels.length > 0 && ( {issue.labels.map((l) => ( {l.name} ))} )}
{isImported && Imported}
); })}
)} {/* Pulls list */} {activeTab === "pulls" && pulls.length > 0 && (
{pulls.map((pull) => { const isImported = importedUrls.has(pull.html_url); return (
!isImported && handlePullSelect(pull.number)} > handlePullSelect(pull.number)} disabled={isImported} aria-label={`Select pull request #${pull.number}`} />
#{pull.number} {pull.title}
{pull.headBranch} → {pull.baseBranch}
{isImported && Imported}
); })}
)}
{/* Right pane: Preview */}
{isMobile && ( )}

Preview

{/* Issue preview */} {activeTab === "issues" && selectedIssue ? (
Issue #{selectedIssue.number}
{selectedIssue.title}
{selectedIssue.body ? selectedIssue.body.slice(0, 200) + (selectedIssue.body.length > 200 ? "…" : "") : "(no description)"}
) : activeTab === "issues" ? (
No issue selected Choose an issue from the list to inspect its title and description.
) : null} {/* Pull request preview */} {activeTab === "pulls" && selectedPull ? (
Pull Request #{selectedPull.number}
{selectedPull.title}
Branch: {selectedPull.headBranch} → {selectedPull.baseBranch}
{selectedPull.body ? selectedPull.body.slice(0, 200) + (selectedPull.body.length > 200 ? "…" : "") : "(no description)"}
) : activeTab === "pulls" ? (
No pull request selected Choose a pull request from the list to inspect its details.
) : null}
); }