feat(KB-015): add git remote selection to GitHub import modal

- Add backend API endpoint to list git remotes for a repository
- Add frontend API client and types for git remotes
- Update GitHub import modal with remote dropdown selector
- Add tests for git remotes API and import modal
This commit is contained in:
gsxdsm
2026-03-29 17:57:47 -07:00
parent a7f008f92f
commit e95e50ba68
7 changed files with 265 additions and 26 deletions

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import type { Task } from "@kb/core";
import { apiFetchGitHubIssues, apiImportGitHubIssue, type GitHubIssue } from "../api";
import { apiFetchGitHubIssues, apiImportGitHubIssue, fetchGitRemotes, type GitHubIssue, type GitRemote } from "../api";
import { Loader2 } from "lucide-react";
interface GitHubImportModalProps {
@@ -20,6 +20,12 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks }: GitHubIm
const [error, setError] = useState<string | null>(null);
const [importing, setImporting] = useState(false);
// Git remotes state
const [remotes, setRemotes] = useState<GitRemote[]>([]);
const [loadingRemotes, setLoadingRemotes] = useState(false);
const [selectedRemoteName, setSelectedRemoteName] = useState<string>("");
const mountedRef = useRef(false);
// Build set of already imported URLs from existing tasks
const importedUrls = new Set<string>();
for (const task of tasks) {
@@ -29,7 +35,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks }: GitHubIm
}
}
// Reset state when modal opens
// Reset state when modal opens and fetch remotes
useEffect(() => {
if (isOpen) {
setOwner("");
@@ -39,9 +45,57 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks }: GitHubIm
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);
// Auto-populate if exactly one remote and fields are empty
if (fetchedRemotes.length === 1) {
const remote = fetchedRemotes[0];
setOwner(remote.owner);
setRepo(remote.repo);
setSelectedRemoteName(remote.name);
}
})
.catch(() => {
// Silently fail - manual input remains available
if (mountedRef.current) {
setLoadingRemotes(false);
}
});
return () => {
mountedRef.current = false;
};
}
}, [isOpen]);
// Handle remote selection change
const handleRemoteChange = useCallback((remoteName: string) => {
setSelectedRemoteName(remoteName);
if (remoteName === "") {
// Manual mode - clear fields
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;
@@ -105,6 +159,10 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks }: GitHubIm
if (!isOpen) return null;
// Determine if we should show the remote dropdown
const showRemoteDropdown = remotes.length > 1 || (remotes.length === 1 && !loadingRemotes);
const hasRemotes = remotes.length > 0;
return (
<div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="modal">
@@ -116,6 +174,33 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks }: GitHubIm
</div>
<div className="modal-body">
{/* Remote Selection Dropdown */}
{(showRemoteDropdown || loadingRemotes) && (
<div className="form-row">
<div className="form-group">
<label htmlFor="gh-remote">
Repository
{loadingRemotes && <Loader2 size={12} className="spin" style={{ marginLeft: 8, display: "inline" }} />}
</label>
<select
id="gh-remote"
value={selectedRemoteName}
onChange={(e) => handleRemoteChange(e.target.value)}
disabled={loadingRemotes || loading || importing}
>
{hasRemotes && <option value="">Select a remote...</option>}
{loadingRemotes && <option value="">Loading remotes...</option>}
{!hasRemotes && !loadingRemotes && <option value="">No GitHub remotes detected</option>}
{remotes.map((remote) => (
<option key={remote.name} value={remote.name}>
{remote.name} ({remote.owner}/{remote.repo})
</option>
))}
</select>
</div>
</div>
)}
{/* Form Row */}
<div className="form-row">
<div className="form-group">

View File

@@ -15,23 +15,28 @@ const defaultSettings: Settings = {
buildCommand: "",
};
vi.mock("../../api", () => ({
fetchTasks: vi.fn(() => Promise.resolve([])),
fetchConfig: vi.fn(() => Promise.resolve({ maxConcurrent: 2 })),
fetchSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
updateSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
fetchAuthStatus: vi.fn(() =>
Promise.resolve({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: false },
{ id: "github", name: "GitHub", authenticated: false },
],
}),
),
loginProvider: vi.fn(() => Promise.resolve({ url: "https://auth.example.com/login" })),
logoutProvider: vi.fn(() => Promise.resolve({ success: true })),
fetchModels: vi.fn(() => Promise.resolve([])),
}));
vi.mock("../../api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../api")>();
return {
...actual,
fetchTasks: vi.fn(() => Promise.resolve([])),
fetchConfig: vi.fn(() => Promise.resolve({ maxConcurrent: 2 })),
fetchSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
updateSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
fetchAuthStatus: vi.fn(() =>
Promise.resolve({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: false },
{ id: "github", name: "GitHub", authenticated: false },
],
}),
),
loginProvider: vi.fn(() => Promise.resolve({ url: "https://auth.example.com/login" })),
logoutProvider: vi.fn(() => Promise.resolve({ success: true })),
fetchModels: vi.fn(() => Promise.resolve([])),
fetchGitRemotes: vi.fn(() => Promise.resolve([])),
};
});
vi.mock("../../hooks/useTasks", () => ({
useTasks: () => ({

View File

@@ -5,10 +5,15 @@ import { apiFetchGitHubIssues, apiImportGitHubIssue } from "../../api";
import type { Task } from "@kb/core";
// Mock the API module
vi.mock("../../api", () => ({
apiFetchGitHubIssues: vi.fn(),
apiImportGitHubIssue: vi.fn(),
}));
vi.mock("../../api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../api")>();
return {
...actual,
apiFetchGitHubIssues: vi.fn(),
apiImportGitHubIssue: vi.fn(),
fetchGitRemotes: vi.fn().mockResolvedValue([]),
};
});
const mockTask: Task = {
id: "KB-001",