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:
5
.changeset/github-import-remote-dropdown.md
Normal file
5
.changeset/github-import-remote-dropdown.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@kb/dashboard": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
GitHub import now detects git remotes and pre-populates owner/repo fields. Adds a dropdown to select from multiple remotes and auto-fills when only one remote exists.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
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";
|
import type { Task, TaskDetail } from "@kb/core";
|
||||||
|
|
||||||
const FAKE_DETAIL: TaskDetail = {
|
const FAKE_DETAIL: TaskDetail = {
|
||||||
@@ -262,3 +262,39 @@ describe("addSteeringComment", () => {
|
|||||||
await expect(addSteeringComment("KB-001", "Test comment")).rejects.toThrow("Task not found");
|
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 }),
|
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 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";
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
interface GitHubImportModalProps {
|
interface GitHubImportModalProps {
|
||||||
@@ -20,6 +20,12 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks }: GitHubIm
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [importing, setImporting] = useState(false);
|
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
|
// Build set of already imported URLs from existing tasks
|
||||||
const importedUrls = new Set<string>();
|
const importedUrls = new Set<string>();
|
||||||
for (const task of tasks) {
|
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(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
setOwner("");
|
setOwner("");
|
||||||
@@ -39,9 +45,57 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks }: GitHubIm
|
|||||||
setSelectedIssueNumber(null);
|
setSelectedIssueNumber(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
setImporting(false);
|
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]);
|
}, [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
|
// Handle escape key
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
@@ -105,6 +159,10 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks }: GitHubIm
|
|||||||
|
|
||||||
if (!isOpen) return null;
|
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 (
|
return (
|
||||||
<div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
<div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||||
<div className="modal">
|
<div className="modal">
|
||||||
@@ -116,6 +174,33 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks }: GitHubIm
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="modal-body">
|
<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 */}
|
{/* Form Row */}
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
|
|||||||
@@ -15,23 +15,28 @@ const defaultSettings: Settings = {
|
|||||||
buildCommand: "",
|
buildCommand: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock("../../api", () => ({
|
vi.mock("../../api", async (importOriginal) => {
|
||||||
fetchTasks: vi.fn(() => Promise.resolve([])),
|
const actual = await importOriginal<typeof import("../../api")>();
|
||||||
fetchConfig: vi.fn(() => Promise.resolve({ maxConcurrent: 2 })),
|
return {
|
||||||
fetchSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
...actual,
|
||||||
updateSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
fetchTasks: vi.fn(() => Promise.resolve([])),
|
||||||
fetchAuthStatus: vi.fn(() =>
|
fetchConfig: vi.fn(() => Promise.resolve({ maxConcurrent: 2 })),
|
||||||
Promise.resolve({
|
fetchSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
||||||
providers: [
|
updateSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
||||||
{ id: "anthropic", name: "Anthropic", authenticated: false },
|
fetchAuthStatus: vi.fn(() =>
|
||||||
{ id: "github", name: "GitHub", authenticated: false },
|
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([])),
|
),
|
||||||
}));
|
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", () => ({
|
vi.mock("../../hooks/useTasks", () => ({
|
||||||
useTasks: () => ({
|
useTasks: () => ({
|
||||||
|
|||||||
@@ -5,10 +5,15 @@ import { apiFetchGitHubIssues, apiImportGitHubIssue } from "../../api";
|
|||||||
import type { Task } from "@kb/core";
|
import type { Task } from "@kb/core";
|
||||||
|
|
||||||
// Mock the API module
|
// Mock the API module
|
||||||
vi.mock("../../api", () => ({
|
vi.mock("../../api", async (importOriginal) => {
|
||||||
apiFetchGitHubIssues: vi.fn(),
|
const actual = await importOriginal<typeof import("../../api")>();
|
||||||
apiImportGitHubIssue: vi.fn(),
|
return {
|
||||||
}));
|
...actual,
|
||||||
|
apiFetchGitHubIssues: vi.fn(),
|
||||||
|
apiImportGitHubIssue: vi.fn(),
|
||||||
|
fetchGitRemotes: vi.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const mockTask: Task = {
|
const mockTask: Task = {
|
||||||
id: "KB-001",
|
id: "KB-001",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import multer from "multer";
|
import multer from "multer";
|
||||||
import { createReadStream } from "node:fs";
|
import { createReadStream } from "node:fs";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
import type { TaskStore, Column, MergeResult } from "@kb/core";
|
import type { TaskStore, Column, MergeResult } from "@kb/core";
|
||||||
import { COLUMNS } from "@kb/core";
|
import { COLUMNS } from "@kb/core";
|
||||||
import type { ServerOptions } from "./server.js";
|
import type { ServerOptions } from "./server.js";
|
||||||
@@ -41,6 +42,79 @@ const upload = multer({
|
|||||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
|
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 {
|
export function createApiRoutes(store: TaskStore, options?: ServerOptions): Router {
|
||||||
const router = 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
|
* POST /api/github/issues/fetch
|
||||||
|
|||||||
Reference in New Issue
Block a user