mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
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:
1 parent
9803ef406c
commit
d832e56fcb
9 files changed
+1073
No files matched your search
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ const BOARD_ROUTE_ROOTS = new Set([
|
||||
"agents",
|
||||
"apps",
|
||||
"projects",
|
||||
"repositories",
|
||||
"workspaces",
|
||||
"execution-workspaces",
|
||||
"issues",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user