feat(repositories): company catalog UI, GitHub multi-select flow, detail

Add the company Repositories destination (nav + route + BOARD_ROUTE_ROOTS)
with empty/populated/search/error states, per-connection sync health,
linked-project/direct-grant counts, and a manual repository fallback.
Add the guided GitHub connection dialog (authorize -> complete -> searchable
paginated discovery -> partial multi-select import) and a repository detail
metadata/health surface. Token-only styling.

Tests (RTL-free, createRoot): empty/populated/search/error catalog, manual
fallback, and connection success/error/retry + partial multi-select import
+ search/pagination.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-12 02:34:33 +00:00
1 parent 9803ef406c
commit d832e56fcb
9 files changed
+1073

No files matched your search

+4
View File
@@ -20,6 +20,8 @@ import { Companies } from "./pages/Companies";
import { AGENT_FILTER_TABS, Agents } from "./pages/Agents";
import { AgentDetail } from "./pages/AgentDetail";
import { Projects } from "./pages/Projects";
import { Repositories } from "./pages/Repositories";
import { RepositoryDetail } from "./pages/RepositoryDetail";
import { ProjectDetail } from "./pages/ProjectDetail";
import { ProjectWorkspaceDetail } from "./pages/ProjectWorkspaceDetail";
import { Workspaces } from "./pages/Workspaces";
@@ -181,6 +183,8 @@ function boardRoutes() {
<Route path="projects/:projectId/workspaces" element={<ProjectDetail />} />
<Route path="projects/:projectId/configuration" element={<ProjectDetail />} />
<Route path="projects/:projectId/budget" element={<ProjectDetail />} />
<Route path="repositories" element={<Repositories />} />
<Route path="repositories/:repositoryId" element={<RepositoryDetail />} />
<Route path="workspaces" element={<Workspaces />} />
<Route path="issues" element={<Issues />} />
<Route path="search" element={<Search />} />
+2
View File
@@ -13,6 +13,7 @@ import {
Repeat,
Layers,
GitBranch,
FolderGit2,
Package,
Settings,
FolderOpen,
@@ -247,6 +248,7 @@ export function Sidebar() {
{showWorkspacesLink ? (
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
) : null}
<SidebarNavItem to="/repositories" label="Repositories" icon={FolderGit2} />
{streamlined ? (
<>
<SidebarNavItem to="/projects" label="Projects" icon={FolderOpen} />
@@ -0,0 +1,180 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { GitHubConnectionDialog } from "./GitHubConnectionDialog";
const repositoriesApiMock = vi.hoisted(() => ({
beginConnection: vi.fn(),
completeConnection: vi.fn(),
discoverConnectionRepositories: vi.fn(),
importConnectionRepositories: vi.fn(),
}));
vi.mock("../../api/repositories", () => ({ repositoriesApi: repositoriesApiMock }));
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const windowOpen = vi.fn();
vi.stubGlobal("open", windowOpen);
async function flush() {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
async function waitFor(assertion: () => void, attempts = 80) {
let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
assertion();
return;
} catch (error) {
lastError = error;
await flush();
}
}
throw lastError;
}
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
setter.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
function clickByText(text: string) {
const button = [...document.querySelectorAll("button")].find((b) => b.textContent?.includes(text));
if (!button) throw new Error(`button "${text}" not found`);
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
}
function discoveryPage(overrides: Partial<{ items: unknown[]; nextCursor: string | null; total: number | null }> = {}) {
return {
connectionId: "conn-1",
items: [
{ providerRepositoryId: "1", owner: "acme", name: "alpha", cloneUrl: "https://github.com/acme/alpha.git", webUrl: null, defaultBranch: "main", visibility: "private", archived: false, imported: false, importedRepositoryId: null, metadata: null },
{ providerRepositoryId: "2", owner: "acme", name: "beta", cloneUrl: "https://github.com/acme/beta.git", webUrl: null, defaultBranch: "main", visibility: "private", archived: false, imported: true, importedRepositoryId: "repo-2", metadata: null },
],
nextCursor: null,
total: 2,
...overrides,
};
}
let container: HTMLDivElement;
let root: Root;
function render() {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
flushSync(() => {
root.render(
<QueryClientProvider client={queryClient}>
<GitHubConnectionDialog companyId="company-1" open onOpenChange={() => {}} />
</QueryClientProvider>,
);
});
}
describe("GitHubConnectionDialog", () => {
beforeEach(() => {
for (const fn of Object.values(repositoriesApiMock)) fn.mockReset();
windowOpen.mockReset();
});
afterEach(() => {
flushSync(() => root.unmount());
container.remove();
});
async function reachSelectStep() {
repositoriesApiMock.beginConnection.mockResolvedValue({
provider: "github",
installUrl: "https://github.com/apps/paperclip/installations/new?state=abc",
state: "signed-state",
expiresAt: new Date(Date.now() + 60000).toISOString(),
});
repositoriesApiMock.completeConnection.mockResolvedValue({
connection: { id: "conn-1", provider: "github", installationId: "42", accountName: "acme" },
created: true,
repositories: [],
});
repositoriesApiMock.discoverConnectionRepositories.mockResolvedValue(discoveryPage());
render();
clickByText("Authorize on GitHub");
await waitFor(() => expect(windowOpen).toHaveBeenCalled());
setInputValue(document.querySelector<HTMLInputElement>("#github-installation-id")!, "42");
clickByText("Continue");
await waitFor(() => {
expect(repositoriesApiMock.completeConnection).toHaveBeenCalledWith("company-1", "github", {
state: "signed-state",
installationId: "42",
});
expect(document.body.textContent).toContain("acme/alpha");
});
}
it("runs authorize -> complete -> discover and imports a partial multi-select", async () => {
repositoriesApiMock.importConnectionRepositories.mockResolvedValue({
repositories: [{ id: "repo-1" }],
imported: 1,
skipped: [],
});
await reachSelectStep();
// Already-imported repositories are disabled; select only the importable one.
const alphaCheckbox = document.querySelector<HTMLButtonElement>('[aria-label="Select acme/alpha"]')!;
const betaCheckbox = document.querySelector<HTMLButtonElement>('[aria-label="Select acme/beta"]')!;
expect(betaCheckbox.getAttribute("disabled") !== null || betaCheckbox.getAttribute("data-disabled") !== null).toBe(true);
alphaCheckbox.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await waitFor(() => {
const importButton = [...document.querySelectorAll("button")].find((b) => b.textContent?.includes("Import"));
expect(importButton && !(importButton as HTMLButtonElement).disabled).toBe(true);
});
clickByText("Import");
await waitFor(() => {
expect(repositoriesApiMock.importConnectionRepositories).toHaveBeenCalledWith("conn-1", ["1"]);
expect(document.body.textContent).toContain("Imported 1 repository");
});
});
it("supports search and pagination during discovery", async () => {
await reachSelectStep();
repositoriesApiMock.discoverConnectionRepositories.mockClear();
repositoriesApiMock.discoverConnectionRepositories.mockResolvedValue(discoveryPage({ items: [], total: 0 }));
setInputValue(document.querySelector<HTMLInputElement>('input[aria-label="Search repositories"]')!, "zzz");
clickByText("Search");
await waitFor(() => {
expect(repositoriesApiMock.discoverConnectionRepositories).toHaveBeenCalledWith("conn-1", {
query: "zzz",
cursor: null,
pageSize: 20,
});
expect(document.body.textContent).toContain("No repositories found");
});
});
it("shows an error and allows retry when authorization fails", async () => {
repositoriesApiMock.beginConnection.mockRejectedValueOnce(new Error("network down"));
render();
clickByText("Authorize on GitHub");
await waitFor(() => expect(document.body.textContent).toContain("network down"));
repositoriesApiMock.beginConnection.mockResolvedValueOnce({
provider: "github",
installUrl: "https://github.com/apps/paperclip/installations/new?state=abc",
state: "signed-state",
expiresAt: new Date(Date.now() + 60000).toISOString(),
});
clickByText("Authorize on GitHub");
await waitFor(() => expect(windowOpen).toHaveBeenCalled());
});
});
@@ -0,0 +1,307 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type {
RepositoryConnection,
RepositoryDiscoveryItem,
RepositoryDiscoveryPage,
} from "@paperclipai/shared";
import { AlertCircle, ExternalLink, Github, Loader2 } from "lucide-react";
import { repositoriesApi } from "../../api/repositories";
import { queryKeys } from "../../lib/queryKeys";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
type Step = "authorize" | "select" | "done";
const DISCOVERY_PAGE_SIZE = 20;
interface GitHubConnectionDialogProps {
companyId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
/** Called after repositories are imported so the catalog can refresh. */
onImported?: (importedCount: number) => void;
}
function errorMessage(error: unknown): string {
if (error && typeof error === "object" && "message" in error) {
const message = (error as { message?: unknown }).message;
if (typeof message === "string" && message.trim()) return message;
}
return "Something went wrong. Please try again.";
}
export function GitHubConnectionDialog({
companyId,
open,
onOpenChange,
onImported,
}: GitHubConnectionDialogProps) {
const queryClient = useQueryClient();
const [step, setStep] = useState<Step>("authorize");
const [state, setState] = useState<string | null>(null);
const [installationId, setInstallationId] = useState("");
const [connection, setConnection] = useState<RepositoryConnection | null>(null);
const [items, setItems] = useState<RepositoryDiscoveryItem[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [importedCount, setImportedCount] = useState(0);
const [skippedCount, setSkippedCount] = useState(0);
function resetAll() {
setStep("authorize");
setState(null);
setInstallationId("");
setConnection(null);
setItems([]);
setNextCursor(null);
setSearch("");
setSelected(new Set());
setImportedCount(0);
setSkippedCount(0);
}
function handleOpenChange(next: boolean) {
if (!next) resetAll();
onOpenChange(next);
}
const beginMutation = useMutation({
mutationFn: () => repositoriesApi.beginConnection(companyId, "github", {}),
onSuccess: (result) => {
setState(result.state);
window.open(result.installUrl, "_blank", "noopener,noreferrer");
},
});
const applyDiscoveryPage = (page: RepositoryDiscoveryPage, append: boolean) => {
setItems((prev) => (append ? [...prev, ...page.items] : page.items));
setNextCursor(page.nextCursor);
};
const discoverMutation = useMutation({
mutationFn: (input: { connectionId: string; cursor: string | null; query: string }) =>
repositoriesApi.discoverConnectionRepositories(input.connectionId, {
query: input.query || null,
cursor: input.cursor,
pageSize: DISCOVERY_PAGE_SIZE,
}),
onSuccess: (page, variables) => applyDiscoveryPage(page, Boolean(variables.cursor)),
});
const completeMutation = useMutation({
mutationFn: () => {
if (!state) throw new Error("Start the connection before confirming installation");
return repositoriesApi.completeConnection(companyId, "github", {
state,
installationId: installationId.trim(),
});
},
onSuccess: async (result) => {
setConnection(result.connection);
setStep("select");
queryClient.invalidateQueries({ queryKey: queryKeys.repositories.connections(companyId) });
await discoverMutation.mutateAsync({ connectionId: result.connection.id, cursor: null, query: "" });
},
});
const importMutation = useMutation({
mutationFn: (input: { connectionId: string; providerRepositoryIds: string[] }) =>
repositoriesApi.importConnectionRepositories(input.connectionId, input.providerRepositoryIds),
onSuccess: (result) => {
setImportedCount(result.imported);
setSkippedCount(result.skipped.length);
setStep("done");
queryClient.invalidateQueries({ queryKey: queryKeys.repositories.list(companyId) });
onImported?.(result.imported);
},
});
const toggle = (item: RepositoryDiscoveryItem) => {
if (item.imported) return;
setSelected((prev) => {
const next = new Set(prev);
if (next.has(item.providerRepositoryId)) next.delete(item.providerRepositoryId);
else next.add(item.providerRepositoryId);
return next;
});
};
const runSearch = () => {
if (!connection) return;
setSelected(new Set());
discoverMutation.mutate({ connectionId: connection.id, cursor: null, query: search });
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Github className="h-4 w-4" aria-hidden />
Connect GitHub
</DialogTitle>
<DialogDescription>
Import repositories your GitHub App installation can access. Importing creates reusable
repository objects only — it never attaches projects, grants agents, or creates workspaces.
</DialogDescription>
</DialogHeader>
{step === "authorize" ? (
<div className="flex flex-col gap-4">
<Button
type="button"
variant="outline"
onClick={() => beginMutation.mutate()}
disabled={beginMutation.isPending}
>
{beginMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <ExternalLink className="h-4 w-4" />}
Authorize on GitHub
</Button>
{beginMutation.isError ? (
<p role="alert" className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" /> {errorMessage(beginMutation.error)}
</p>
) : null}
<div className="flex flex-col gap-2">
<Label htmlFor="github-installation-id">Installation ID</Label>
<Input
id="github-installation-id"
value={installationId}
onChange={(event) => setInstallationId(event.target.value)}
placeholder="From the GitHub redirect (installation_id)"
disabled={!state}
/>
<p className="text-xs text-muted-foreground">
After authorizing, paste the installation id from the GitHub redirect to finish connecting.
</p>
</div>
{completeMutation.isError ? (
<p role="alert" className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" /> {errorMessage(completeMutation.error)}
</p>
) : null}
<DialogFooter>
<Button
type="button"
onClick={() => completeMutation.mutate()}
disabled={!state || !installationId.trim() || completeMutation.isPending}
>
{completeMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Continue
</Button>
</DialogFooter>
</div>
) : null}
{step === "select" ? (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Input
aria-label="Search repositories"
value={search}
onChange={(event) => setSearch(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") runSearch();
}}
placeholder="Search repositories"
/>
<Button type="button" variant="outline" onClick={runSearch} disabled={discoverMutation.isPending}>
Search
</Button>
</div>
{discoverMutation.isError ? (
<p role="alert" className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" /> {errorMessage(discoverMutation.error)}
</p>
) : null}
<ul className="flex max-h-72 flex-col gap-1 overflow-y-auto">
{items.length === 0 && !discoverMutation.isPending ? (
<li className="py-6 text-center text-sm text-muted-foreground">No repositories found.</li>
) : null}
{items.map((item) => (
<li key={item.providerRepositoryId}>
<label
className={`flex items-center gap-3 px-2 py-2 text-sm ${item.imported ? "opacity-60" : "cursor-pointer hover:bg-muted/50"}`}
>
<Checkbox
checked={item.imported || selected.has(item.providerRepositoryId)}
disabled={item.imported}
onCheckedChange={() => toggle(item)}
aria-label={`Select ${item.owner}/${item.name}`}
/>
<span className="flex-1 truncate">
<span className="font-medium text-foreground">{item.owner}/{item.name}</span>
{item.imported ? (
<span className="ml-2 text-xs text-muted-foreground">Imported</span>
) : null}
</span>
</label>
</li>
))}
</ul>
{nextCursor ? (
<Button
type="button"
variant="ghost"
onClick={() =>
connection && discoverMutation.mutate({ connectionId: connection.id, cursor: nextCursor, query: search })
}
disabled={discoverMutation.isPending}
>
{discoverMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Load more
</Button>
) : null}
{importMutation.isError ? (
<p role="alert" className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" /> {errorMessage(importMutation.error)}
</p>
) : null}
<DialogFooter>
<Button
type="button"
onClick={() =>
connection &&
importMutation.mutate({ connectionId: connection.id, providerRepositoryIds: [...selected] })
}
disabled={selected.size === 0 || importMutation.isPending}
>
{importMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Import {selected.size > 0 ? `${selected.size} ` : ""}selected
</Button>
</DialogFooter>
</div>
) : null}
{step === "done" ? (
<div className="flex flex-col gap-4">
<p className="text-sm text-foreground">
Imported {importedCount} {importedCount === 1 ? "repository" : "repositories"}
{skippedCount > 0 ? `, skipped ${skippedCount}` : ""}.
</p>
<DialogFooter>
<Button type="button" onClick={() => handleOpenChange(false)}>Done</Button>
</DialogFooter>
</div>
) : null}
</DialogContent>
</Dialog>
);
}
+1
View File
@@ -8,6 +8,7 @@ const BOARD_ROUTE_ROOTS = new Set([
"agents",
"apps",
"projects",
"repositories",
"workspaces",
"execution-workspaces",
"issues",
+6
View File
@@ -294,6 +294,12 @@ export const queryKeys = {
["projects", companyId, { includeArchived: opts.includeArchived === true }] as const,
detail: (id: string) => ["projects", "detail", id] as const,
},
repositories: {
list: (companyId: string, opts: { includeArchived?: boolean } = {}) =>
["repositories", companyId, { includeArchived: opts.includeArchived === true }] as const,
connections: (companyId: string) => ["repositories", companyId, "connections"] as const,
detail: (id: string) => ["repositories", "detail", id] as const,
},
cases: {
list: (companyId: string) => ["cases", companyId] as const,
detail: (id: string) => ["cases", "detail", id] as const,
+176
View File
@@ -0,0 +1,176 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Repositories } from "./Repositories";
const companyState = vi.hoisted(() => ({ selectedCompanyId: "company-1" }));
const breadcrumbState = vi.hoisted(() => ({ setBreadcrumbs: vi.fn() }));
const repositoriesApiMock = vi.hoisted(() => ({
list: vi.fn(),
listConnections: vi.fn(),
createManual: vi.fn(),
syncConnection: vi.fn(),
}));
vi.mock("../context/CompanyContext", () => ({ useCompany: () => companyState }));
vi.mock("../context/BreadcrumbContext", () => ({ useBreadcrumbs: () => breadcrumbState }));
vi.mock("../api/repositories", () => ({ repositoriesApi: repositoriesApiMock }));
vi.mock("../components/repositories/GitHubConnectionDialog", () => ({
GitHubConnectionDialog: ({ open }: { open: boolean }) => (open ? <div data-testid="github-dialog" /> : null),
}));
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
async function flush() {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
async function waitFor(assertion: () => void, attempts = 50) {
let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
assertion();
return;
} catch (error) {
lastError = error;
await flush();
}
}
throw lastError;
}
function makeRepo(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: "repo-1",
companyId: "company-1",
connectionId: null,
provider: "manual",
providerRepositoryId: null,
host: "github.com",
owner: "acme",
name: "alpha",
cloneUrl: "https://github.com/acme/alpha.git",
webUrl: "https://github.com/acme/alpha",
defaultBranch: "main",
visibility: "private",
state: "active",
unavailableReason: null,
providerMetadata: null,
archivedAt: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
projectCount: 2,
directGrantCount: 1,
...overrides,
};
}
let container: HTMLDivElement;
let root: Root;
function render() {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
flushSync(() => {
root.render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["/repositories"]}>
<Repositories />
</MemoryRouter>
</QueryClientProvider>,
);
});
}
describe("Repositories catalog", () => {
beforeEach(() => {
repositoriesApiMock.list.mockReset();
repositoriesApiMock.listConnections.mockReset();
repositoriesApiMock.createManual.mockReset();
repositoriesApiMock.syncConnection.mockReset();
repositoriesApiMock.listConnections.mockResolvedValue([]);
});
afterEach(() => {
flushSync(() => root.unmount());
container.remove();
});
it("shows an empty state with a connect action", async () => {
repositoriesApiMock.list.mockResolvedValue([]);
render();
await waitFor(() => {
expect(document.body.textContent).toContain("No repositories yet");
expect(document.body.textContent).toContain("Connect GitHub");
});
});
it("renders a populated catalog with project and grant counts and filters on search", async () => {
repositoriesApiMock.list.mockResolvedValue([
makeRepo(),
makeRepo({ id: "repo-2", name: "beta", projectCount: 0, directGrantCount: 0 }),
]);
render();
await waitFor(() => {
expect(document.body.textContent).toContain("acme/alpha");
expect(document.body.textContent).toContain("acme/beta");
expect(document.body.textContent).toContain("2 projects");
expect(document.body.textContent).toContain("1 grant");
});
const searchInput = container.querySelector<HTMLInputElement>('input[aria-label="Search repositories"]')!;
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
setter.call(searchInput, "beta");
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
await waitFor(() => {
expect(document.body.textContent).toContain("acme/beta");
expect(document.body.textContent).not.toContain("acme/alpha");
});
});
it("shows an error state with retry", async () => {
repositoriesApiMock.list.mockRejectedValue(new Error("boom"));
render();
await waitFor(() => {
expect(document.body.textContent).toContain("Failed to load repositories");
expect(document.body.textContent).toContain("Retry");
});
});
it("supports the manual repository fallback", async () => {
repositoriesApiMock.list.mockResolvedValue([makeRepo()]);
repositoriesApiMock.createManual.mockResolvedValue(makeRepo({ id: "repo-manual", name: "manual" }));
render();
await waitFor(() => expect(document.body.textContent).toContain("acme/alpha"));
const addButtons = [...document.querySelectorAll("button")].filter((b) => b.textContent?.includes("Add manually"));
addButtons[0]!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await waitFor(() => expect(document.body.textContent).toContain("Add a repository manually"));
const urlInput = document.querySelector<HTMLInputElement>("#manual-clone-url")!;
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
setter.call(urlInput, "https://github.com/acme/manual.git");
urlInput.dispatchEvent(new Event("input", { bubbles: true }));
await waitFor(() => {
const submit = [...document.querySelectorAll("button")].find((b) => b.textContent?.includes("Add repository"));
expect(submit && !(submit as HTMLButtonElement).disabled).toBe(true);
});
const submit = [...document.querySelectorAll("button")].find((b) => b.textContent?.includes("Add repository"))!;
submit.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await waitFor(() => {
expect(repositoriesApiMock.createManual).toHaveBeenCalledWith("company-1", {
cloneUrl: "https://github.com/acme/manual.git",
visibility: "unknown",
});
});
});
});
+295
View File
@@ -0,0 +1,295 @@
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { RepositoryCatalogItem, RepositoryConnection } from "@paperclipai/shared";
import { AlertCircle, GitBranch, Github, Loader2, Plus, RefreshCw } from "lucide-react";
import { repositoriesApi } from "../api/repositories";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { GitHubConnectionDialog } from "../components/repositories/GitHubConnectionDialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
function connectionHealthTone(connection: RepositoryConnection): "success" | "warning" | "danger" | "neutral" {
if (connection.status === "error" || connection.syncStatus === "failed") return "danger";
if (connection.status === "disconnected") return "warning";
if (connection.syncStatus === "syncing") return "neutral";
return "success";
}
function connectionHealthLabel(connection: RepositoryConnection): string {
if (connection.status === "disconnected") return "Disconnected";
if (connection.status === "error" || connection.syncStatus === "failed") return "Sync failed";
if (connection.syncStatus === "syncing") return "Syncing";
if (connection.syncStatus === "succeeded") return "Healthy";
return "Idle";
}
function toneClass(tone: "success" | "warning" | "danger" | "neutral"): string {
switch (tone) {
case "success":
return "text-success";
case "warning":
return "text-warning";
case "danger":
return "text-destructive";
default:
return "text-muted-foreground";
}
}
export function Repositories() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const [search, setSearch] = useState("");
const [githubOpen, setGithubOpen] = useState(false);
const [manualOpen, setManualOpen] = useState(false);
const [manualUrl, setManualUrl] = useState("");
useEffect(() => {
setBreadcrumbs([{ label: "Repositories" }]);
}, [setBreadcrumbs]);
const repositoriesQuery = useQuery({
queryKey: queryKeys.repositories.list(selectedCompanyId!),
queryFn: () => repositoriesApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const connectionsQuery = useQuery({
queryKey: queryKeys.repositories.connections(selectedCompanyId!),
queryFn: () => repositoriesApi.listConnections(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const manualMutation = useMutation({
mutationFn: (cloneUrl: string) =>
repositoriesApi.createManual(selectedCompanyId!, { cloneUrl, visibility: "unknown" }),
onSuccess: () => {
setManualOpen(false);
setManualUrl("");
queryClient.invalidateQueries({ queryKey: queryKeys.repositories.list(selectedCompanyId!) });
},
});
const syncMutation = useMutation({
mutationFn: (connectionId: string) => repositoriesApi.syncConnection(connectionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.repositories.list(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.repositories.connections(selectedCompanyId!) });
},
});
const repositories = repositoriesQuery.data ?? [];
const connections = connectionsQuery.data ?? [];
const filtered = useMemo(() => {
const term = search.trim().toLowerCase();
if (!term) return repositories;
return repositories.filter((repo) =>
`${repo.host}/${repo.owner}/${repo.name}`.toLowerCase().includes(term),
);
}, [repositories, search]);
const refresh = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.repositories.list(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.repositories.connections(selectedCompanyId!) });
};
if (repositoriesQuery.isLoading) {
return <PageSkeleton />;
}
if (repositoriesQuery.isError) {
return (
<div className="p-6">
<div role="alert" className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" /> Failed to load repositories.
</div>
<Button className="mt-4" variant="outline" onClick={refresh}>
<RefreshCw className="h-4 w-4" /> Retry
</Button>
</div>
);
}
const actions = (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setManualOpen(true)}>
<Plus className="h-4 w-4" /> Add manually
</Button>
<Button onClick={() => setGithubOpen(true)}>
<Github className="h-4 w-4" /> Connect GitHub
</Button>
</div>
);
return (
<div className="flex flex-col gap-6 p-6">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-lg font-semibold text-foreground">Repositories</h1>
<p className="text-sm text-muted-foreground">
Reusable git repositories available to projects and agents in this company.
</p>
</div>
{actions}
</div>
{connections.length > 0 ? (
<div className="flex flex-col gap-2">
<h2 className="text-sm font-medium text-muted-foreground">Connections</h2>
<div className="flex flex-col gap-2">
{connections.map((connection) => {
const tone = connectionHealthTone(connection);
return (
<Card key={connection.id} className="flex items-center justify-between gap-3 px-4 py-3">
<div className="flex items-center gap-3">
<Github className="h-4 w-4 text-muted-foreground" aria-hidden />
<div>
<p className="text-sm font-medium text-foreground">
{connection.accountName ?? connection.host}
</p>
<p className={`text-xs ${toneClass(tone)}`}>
{connectionHealthLabel(connection)}
{connection.syncError ? ` — ${connection.syncError}` : ""}
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => syncMutation.mutate(connection.id)}
disabled={connection.status === "disconnected" || syncMutation.isPending}
>
{syncMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
Sync
</Button>
</Card>
);
})}
</div>
</div>
) : null}
{repositories.length === 0 ? (
<EmptyState
icon={GitBranch}
title="No repositories yet"
message="Connect GitHub to import repositories, or add one manually by its clone URL."
action="Connect GitHub"
hideActionIcon
onAction={() => setGithubOpen(true)}
/>
) : (
<div className="flex flex-col gap-3">
<Input
aria-label="Search repositories"
placeholder="Search repositories"
value={search}
onChange={(event) => setSearch(event.target.value)}
className="max-w-sm"
/>
{filtered.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No repositories match “{search}”.
</p>
) : (
<ul className="flex flex-col gap-1">
{filtered.map((repo) => (
<li key={repo.id}>
<Link
to={repo.id}
className="flex items-center justify-between gap-3 px-3 py-2.5 hover:bg-muted/50"
>
<div className="flex min-w-0 items-center gap-3">
<GitBranch className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{repo.owner}/{repo.name}
</p>
<p className="truncate text-xs text-muted-foreground">{repo.host}</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{repo.state !== "active" ? (
<Badge variant="outline" className="text-warning">{repo.state}</Badge>
) : null}
<Badge variant="secondary">{repo.provider}</Badge>
<span className="text-xs text-muted-foreground">
{repo.projectCount} project{repo.projectCount === 1 ? "" : "s"}
</span>
<span className="text-xs text-muted-foreground">
{repo.directGrantCount} grant{repo.directGrantCount === 1 ? "" : "s"}
</span>
</div>
</Link>
</li>
))}
</ul>
)}
</div>
)}
<GitHubConnectionDialog
companyId={selectedCompanyId!}
open={githubOpen}
onOpenChange={setGithubOpen}
onImported={() => refresh()}
/>
<Dialog open={manualOpen} onOpenChange={setManualOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add a repository manually</DialogTitle>
<DialogDescription>
Add a repository by its clone URL. This creates a reusable repository object only.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<Label htmlFor="manual-clone-url">Clone URL</Label>
<Input
id="manual-clone-url"
value={manualUrl}
onChange={(event) => setManualUrl(event.target.value)}
placeholder="https://github.com/owner/repo.git"
/>
{manualMutation.isError ? (
<p role="alert" className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
{(manualMutation.error as { message?: string } | null)?.message ?? "Failed to add repository."}
</p>
) : null}
</div>
<DialogFooter>
<Button
onClick={() => manualMutation.mutate(manualUrl.trim())}
disabled={!manualUrl.trim() || manualMutation.isPending}
>
{manualMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Add repository
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { useEffect } from "react";
import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import type { Repository } from "@paperclipai/shared";
import { AlertCircle, GitBranch } from "lucide-react";
import { repositoriesApi } from "../api/repositories";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { PageSkeleton } from "../components/PageSkeleton";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
function MetadataRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-start justify-between gap-4 py-2">
<span className="text-sm text-muted-foreground">{label}</span>
<span className="text-right text-sm text-foreground">{value}</span>
</div>
);
}
function stateTone(repository: Repository): string {
if (repository.state === "active") return "text-success";
if (repository.state === "archived") return "text-muted-foreground";
return "text-warning";
}
export function RepositoryDetail() {
const { repositoryId } = useParams<{ repositoryId: string }>();
const { setBreadcrumbs } = useBreadcrumbs();
const repositoryQuery = useQuery({
queryKey: queryKeys.repositories.detail(repositoryId!),
queryFn: () => repositoriesApi.get(repositoryId!),
enabled: !!repositoryId,
});
const repository = repositoryQuery.data;
useEffect(() => {
setBreadcrumbs([
{ label: "Repositories", href: "/repositories" },
{ label: repository ? `${repository.owner}/${repository.name}` : "Repository" },
]);
}, [setBreadcrumbs, repository]);
if (repositoryQuery.isLoading) return <PageSkeleton />;
if (repositoryQuery.isError || !repository) {
return (
<div className="p-6">
<div role="alert" className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" /> Repository not found.
</div>
</div>
);
}
return (
<div className="flex flex-col gap-6 p-6">
<div className="flex items-center gap-3">
<GitBranch className="h-5 w-5 text-muted-foreground" aria-hidden />
<div>
<h1 className="text-lg font-semibold text-foreground">
{repository.owner}/{repository.name}
</h1>
<p className="text-sm text-muted-foreground">{repository.host}</p>
</div>
<Badge variant="secondary" className="ml-auto">{repository.provider}</Badge>
</div>
<Card className="px-4 py-2">
<MetadataRow label="State" value={<span className={stateTone(repository)}>{repository.state}</span>} />
{repository.unavailableReason ? (
<MetadataRow label="Reason" value={repository.unavailableReason} />
) : null}
<MetadataRow label="Visibility" value={repository.visibility} />
<MetadataRow label="Default branch" value={repository.defaultBranch ?? "—"} />
<MetadataRow
label="Clone URL"
value={<code className="text-xs">{repository.cloneUrl}</code>}
/>
<MetadataRow
label="Web URL"
value={
repository.webUrl ? (
<a href={repository.webUrl} target="_blank" rel="noopener noreferrer" className="text-primary underline">
{repository.webUrl}
</a>
) : (
"—"
)
}
/>
<MetadataRow
label="Source"
value={repository.connectionId ? "Provider connection" : "Manual"}
/>
</Card>
</div>
);
}