diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 5582747ee3a..947e29f01f4 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index c40d0b27afc..fa4b4d3fc67 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -13,6 +13,7 @@ import { Repeat, Layers, GitBranch, + FolderGit2, Package, Settings, FolderOpen, @@ -247,6 +248,7 @@ export function Sidebar() { {showWorkspacesLink ? ( ) : null} + {streamlined ? ( <> diff --git a/ui/src/components/repositories/GitHubConnectionDialog.test.tsx b/ui/src/components/repositories/GitHubConnectionDialog.test.tsx new file mode 100644 index 00000000000..a086a7c76d9 --- /dev/null +++ b/ui/src/components/repositories/GitHubConnectionDialog.test.tsx @@ -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( + + {}} /> + , + ); + }); +} + +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("#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('[aria-label="Select acme/alpha"]')!; + const betaCheckbox = document.querySelector('[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('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()); + }); +}); diff --git a/ui/src/components/repositories/GitHubConnectionDialog.tsx b/ui/src/components/repositories/GitHubConnectionDialog.tsx new file mode 100644 index 00000000000..c3e4c562f73 --- /dev/null +++ b/ui/src/components/repositories/GitHubConnectionDialog.tsx @@ -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("authorize"); + const [state, setState] = useState(null); + const [installationId, setInstallationId] = useState(""); + const [connection, setConnection] = useState(null); + const [items, setItems] = useState([]); + const [nextCursor, setNextCursor] = useState(null); + const [search, setSearch] = useState(""); + const [selected, setSelected] = useState>(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 ( + + + + + + Connect GitHub + + + Import repositories your GitHub App installation can access. Importing creates reusable + repository objects only — it never attaches projects, grants agents, or creates workspaces. + + + + {step === "authorize" ? ( +
+ + {beginMutation.isError ? ( +

+ {errorMessage(beginMutation.error)} +

+ ) : null} +
+ + setInstallationId(event.target.value)} + placeholder="From the GitHub redirect (installation_id)" + disabled={!state} + /> +

+ After authorizing, paste the installation id from the GitHub redirect to finish connecting. +

+
+ {completeMutation.isError ? ( +

+ {errorMessage(completeMutation.error)} +

+ ) : null} + + + +
+ ) : null} + + {step === "select" ? ( +
+
+ setSearch(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") runSearch(); + }} + placeholder="Search repositories" + /> + +
+ + {discoverMutation.isError ? ( +

+ {errorMessage(discoverMutation.error)} +

+ ) : null} + +
    + {items.length === 0 && !discoverMutation.isPending ? ( +
  • No repositories found.
  • + ) : null} + {items.map((item) => ( +
  • + +
  • + ))} +
+ + {nextCursor ? ( + + ) : null} + + {importMutation.isError ? ( +

+ {errorMessage(importMutation.error)} +

+ ) : null} + + + + +
+ ) : null} + + {step === "done" ? ( +
+

+ Imported {importedCount} {importedCount === 1 ? "repository" : "repositories"} + {skippedCount > 0 ? `, skipped ${skippedCount}` : ""}. +

+ + + +
+ ) : null} +
+
+ ); +} diff --git a/ui/src/lib/company-routes.ts b/ui/src/lib/company-routes.ts index 588f444fc6b..40b3856099c 100644 --- a/ui/src/lib/company-routes.ts +++ b/ui/src/lib/company-routes.ts @@ -8,6 +8,7 @@ const BOARD_ROUTE_ROOTS = new Set([ "agents", "apps", "projects", + "repositories", "workspaces", "execution-workspaces", "issues", diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 9bfaee76a7f..2559df79dd7 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -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, diff --git a/ui/src/pages/Repositories.test.tsx b/ui/src/pages/Repositories.test.tsx new file mode 100644 index 00000000000..346b2bd2c3a --- /dev/null +++ b/ui/src/pages/Repositories.test.tsx @@ -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 ?
: 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> = {}) { + 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( + + + + + , + ); + }); +} + +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('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("#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", + }); + }); + }); +}); diff --git a/ui/src/pages/Repositories.tsx b/ui/src/pages/Repositories.tsx new file mode 100644 index 00000000000..25c74d4755f --- /dev/null +++ b/ui/src/pages/Repositories.tsx @@ -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 ; + } + + if (repositoriesQuery.isError) { + return ( +
+
+ Failed to load repositories. +
+ +
+ ); + } + + const actions = ( +
+ + +
+ ); + + return ( +
+
+
+

Repositories

+

+ Reusable git repositories available to projects and agents in this company. +

+
+ {actions} +
+ + {connections.length > 0 ? ( +
+

Connections

+
+ {connections.map((connection) => { + const tone = connectionHealthTone(connection); + return ( + +
+ +
+

+ {connection.accountName ?? connection.host} +

+

+ {connectionHealthLabel(connection)} + {connection.syncError ? ` — ${connection.syncError}` : ""} +

+
+
+ +
+ ); + })} +
+
+ ) : null} + + {repositories.length === 0 ? ( + setGithubOpen(true)} + /> + ) : ( +
+ setSearch(event.target.value)} + className="max-w-sm" + /> + {filtered.length === 0 ? ( +

+ No repositories match “{search}”. +

+ ) : ( +
    + {filtered.map((repo) => ( +
  • + +
    + +
    +

    + {repo.owner}/{repo.name} +

    +

    {repo.host}

    +
    +
    +
    + {repo.state !== "active" ? ( + {repo.state} + ) : null} + {repo.provider} + + {repo.projectCount} project{repo.projectCount === 1 ? "" : "s"} + + + {repo.directGrantCount} grant{repo.directGrantCount === 1 ? "" : "s"} + +
    + +
  • + ))} +
+ )} +
+ )} + + refresh()} + /> + + + + + Add a repository manually + + Add a repository by its clone URL. This creates a reusable repository object only. + + +
+ + setManualUrl(event.target.value)} + placeholder="https://github.com/owner/repo.git" + /> + {manualMutation.isError ? ( +

+ + {(manualMutation.error as { message?: string } | null)?.message ?? "Failed to add repository."} +

+ ) : null} +
+ + + +
+
+
+ ); +} diff --git a/ui/src/pages/RepositoryDetail.tsx b/ui/src/pages/RepositoryDetail.tsx new file mode 100644 index 00000000000..194d29e1f96 --- /dev/null +++ b/ui/src/pages/RepositoryDetail.tsx @@ -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 ( +
+ {label} + {value} +
+ ); +} + +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 ; + + if (repositoryQuery.isError || !repository) { + return ( +
+
+ Repository not found. +
+
+ ); + } + + return ( +
+
+ +
+

+ {repository.owner}/{repository.name} +

+

{repository.host}

+
+ {repository.provider} +
+ + + {repository.state}} /> + {repository.unavailableReason ? ( + + ) : null} + + + {repository.cloneUrl}} + /> + + {repository.webUrl} + + ) : ( + "—" + ) + } + /> + + +
+ ); +}