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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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: () => ({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user