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 ca46c019fb
commit 88ca088eda
7 changed files with 265 additions and 26 deletions

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fetchTaskDetail, updateTask, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, addSteeringComment } from "./api";
import { fetchTaskDetail, updateTask, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, addSteeringComment, fetchGitRemotes } from "./api";
import type { Task, TaskDetail } from "@kb/core";
const FAKE_DETAIL: TaskDetail = {
@@ -262,3 +262,39 @@ describe("addSteeringComment", () => {
await expect(addSteeringComment("KB-001", "Test comment")).rejects.toThrow("Task not found");
});
});
describe("fetchGitRemotes", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("returns array of GitHub remotes", async () => {
const remotes = [
{ name: "origin", owner: "dustinbyrne", repo: "kb", url: "https://github.com/dustinbyrne/kb.git" },
];
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, remotes));
const result = await fetchGitRemotes();
expect(result).toEqual(remotes);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/remotes", {
headers: { "Content-Type": "application/json" },
});
});
it("returns empty array when no remotes", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
const result = await fetchGitRemotes();
expect(result).toEqual([]);
});
it("throws on error", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Failed to execute git command" }));
await expect(fetchGitRemotes()).rejects.toThrow("Failed to execute git command");
});
});

View File

@@ -190,3 +190,18 @@ export function apiImportGitHubIssue(owner: string, repo: string, issueNumber: n
body: JSON.stringify({ owner, repo, issueNumber }),
});
}
// --- Git Remote Detection API ---
/** Git remote info returned by the remotes endpoint */
export interface GitRemote {
name: string;
owner: string;
repo: string;
url: string;
}
/** Fetch GitHub remotes from the current git repository */
export function fetchGitRemotes(): Promise<GitRemote[]> {
return api<GitRemote[]>("/git/remotes");
}

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",

View File

@@ -1,6 +1,7 @@
import { Router } from "express";
import multer from "multer";
import { createReadStream } from "node:fs";
import { execSync } from "node:child_process";
import type { TaskStore, Column, MergeResult } from "@kb/core";
import { COLUMNS } from "@kb/core";
import type { ServerOptions } from "./server.js";
@@ -41,6 +42,79 @@ const upload = multer({
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
});
// ── Git Remote Detection ──────────────────────────────────────────
/** Git remote info returned by the remotes endpoint */
export interface GitRemote {
name: string;
owner: string;
repo: string;
url: string;
}
/**
* Parse a GitHub URL to extract owner and repo.
* Handles HTTPS (https://github.com/owner/repo.git) and SSH (git@github.com:owner/repo.git) formats.
*/
function parseGitHubUrl(url: string): { owner: string; repo: string } | null {
// HTTPS format: https://github.com/owner/repo.git or https://github.com/owner/repo
const httpsMatch = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/i);
if (httpsMatch) {
return { owner: httpsMatch[1], repo: httpsMatch[2] };
}
// SSH format: git@github.com:owner/repo.git or git@github.com:owner/repo
const sshMatch = url.match(/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/i);
if (sshMatch) {
return { owner: sshMatch[1], repo: sshMatch[2] };
}
return null;
}
/**
* Get GitHub remotes from the current git repository.
* Executes `git remote -v` and parses the output.
*/
function getGitHubRemotes(): GitRemote[] {
try {
// Execute git remote -v to get all remotes with their URLs
const output = execSync("git remote -v", { encoding: "utf-8", timeout: 5000 });
const remotes: GitRemote[] = [];
const seen = new Set<string>();
for (const line of output.split("\n")) {
// Parse lines like: "origin https://github.com/owner/repo.git (fetch)"
const match = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/);
if (!match) continue;
const [, name, url] = match;
// Skip duplicates (fetch/push entries for same remote)
const key = `${name}-${url}`;
if (seen.has(key)) continue;
seen.add(key);
// Only include GitHub URLs
const parsed = parseGitHubUrl(url);
if (parsed) {
remotes.push({
name,
owner: parsed.owner,
repo: parsed.repo,
url,
});
}
}
return remotes;
} catch {
// Return empty array if not a git repo, git not available, or any error
return [];
}
}
export function createApiRoutes(store: TaskStore, options?: ServerOptions): Router {
const router = Router();
@@ -305,7 +379,21 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
// ── GitHub Import Routes ──────────────────────────────────────────
/**
* GET /api/git/remotes
* Returns GitHub remotes from the current git repository.
* Response: Array of GitRemote objects [{ name: string, owner: string, repo: string, url: string }]
*/
router.get("/git/remotes", (_req, res) => {
try {
const remotes = getGitHubRemotes();
res.json(remotes);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// ── GitHub Import Routes ──────────────────────────────────────────
/**
* POST /api/github/issues/fetch