import { useState, useEffect, useCallback, useRef } from "react"; import type { Task } from "@kb/core"; import { apiFetchGitHubIssues, apiImportGitHubIssue, fetchGitRemotes, type GitHubIssue, type GitRemote } from "../api"; import { Loader2 } from "lucide-react"; interface GitHubImportModalProps { isOpen: boolean; onClose: () => void; onImport: (task: Task) => void; tasks: Task[]; } export function GitHubImportModal({ isOpen, onClose, onImport, tasks }: GitHubImportModalProps) { const [owner, setOwner] = useState(""); const [repo, setRepo] = useState(""); const [labels, setLabels] = useState(""); const [loading, setLoading] = useState(false); const [issues, setIssues] = useState([]); const [selectedIssueNumber, setSelectedIssueNumber] = 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); // Build set of already imported URLs from existing tasks const importedUrls = new Set(); for (const task of tasks) { const match = task.description.match(/Source: (https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/\d+)/); if (match) { importedUrls.add(match[1]); } } // Reset state when modal opens and fetch remotes useEffect(() => { if (isOpen) { setOwner(""); setRepo(""); setLabels(""); setIssues([]); setSelectedIssueNumber(null); setError(null); setImporting(false); setRemotes([]); setLoadingRemotes(true); setSelectedRemoteName(""); 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 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]); 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: any) { setError(err.message || "Failed to fetch issues"); } finally { setLoading(false); } }, [owner, repo, labels]); const handleImport = useCallback(async () => { if (selectedIssueNumber === null) return; setImporting(true); setError(null); try { const task = await apiImportGitHubIssue(owner.trim(), repo.trim(), selectedIssueNumber); onImport(task); onClose(); } catch (err: any) { if (err.message?.includes("already imported")) { setError(err.message); } else { setError(err.message || "Failed to import issue"); } } finally { setImporting(false); } }, [selectedIssueNumber, owner, repo, onImport, onClose]); const selectedIssue = issues.find((i) => i.number === selectedIssueNumber); if (!isOpen) return null; // Determine the repository selection UI state const hasRemotes = remotes.length > 0; const singleRemote = remotes.length === 1; const multipleRemotes = remotes.length > 1; const repositoryName = owner.trim() && repo.trim() ? `${owner.trim()}/${repo.trim()}` : "No repository selected"; const importedIssueCount = issues.filter((issue) => importedUrls.has(issue.html_url)).length; const isEmptyState = error === "No open issues found"; const isResultsError = Boolean(error) && !isEmptyState && issues.length === 0 && !loading; const hasResultsContent = loading || issues.length > 0 || isEmptyState || isResultsError; const showInlineErrorBanner = Boolean(error) && issues.length > 0 && !isEmptyState; return (
e.target === e.currentTarget && onClose()}>

Import from GitHub

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

Repository source

kb reads Git remotes from your current repository so you can load issues without typing owner/repo by hand.

Repository {repositoryName}
{loadingRemotes && (
Detecting Git remotes… Scanning this worktree for GitHub remotes.
)} {!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
)} {!loadingRemotes && singleRemote && (
Auto-detected remote
{remotes[0].name} {remotes[0].owner}/{remotes[0].repo}
Ready
)} {!loadingRemotes && multipleRemotes && (
Pick which remote to query when more than one GitHub origin is available.
)}

Filters & sync

Narrow the issue list with labels, then fetch up to 30 open issues from the selected repository.

setLabels(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleLoad()} disabled={loading || importing} /> Use comma-separated labels to filter the GitHub issue query.
Load issues from the selected repository without changing any board data.
{showInlineErrorBanner && (
{error}
)}

Results

Imported issues stay visible but cannot be selected again.

{issues.length > 0 && (
{issues.length} issue{issues.length === 1 ? "" : "s"} {importedIssueCount} imported
)}
{!hasResultsContent && (
Nothing loaded yet Select a repository and load issues to start reviewing import candidates.
)} {loading && (
Loading open issues… Fetching the latest issue list from GitHub.
)} {isResultsError && (
Could not load issues {error}
)} {isEmptyState && (
No open issues found Try a different label filter or choose another repository.
)} {issues.length > 0 && (
{issues.map((issue) => { const isImported = importedUrls.has(issue.html_url); return (
!isImported && setSelectedIssueNumber(issue.number)} > setSelectedIssueNumber(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}
); })}
)}

Preview

Review the selected issue before importing it as a task.

{selectedIssue ? (
Issue #{selectedIssue.number}
{selectedIssue.title}
{selectedIssue.body ? selectedIssue.body.slice(0, 200) + (selectedIssue.body.length > 200 ? "…" : "") : "(no description)"}
) : (
No issue selected Choose an issue from the results list to inspect its title and description.
)}
); }