diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 947e29f01f4..771202053b4 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -595,6 +595,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/repositories.ts b/ui/src/api/repositories.ts index e5092843616..ae46c230f3f 100644 --- a/ui/src/api/repositories.ts +++ b/ui/src/api/repositories.ts @@ -11,6 +11,7 @@ import type { RepositoryCatalogItem, RepositoryConnection, RepositoryDiscoveryPage, + RepositoryRelationships, UpdateRepository, } from "@paperclipai/shared"; import { api } from "./client"; @@ -69,6 +70,8 @@ export const repositoriesApi = { `/companies/${encodeURIComponent(companyId)}/repositories${opts.includeArchived ? "?includeArchived=true" : ""}`, ), get: (repositoryId: string) => api.get(`/repositories/${encodeURIComponent(repositoryId)}`), + getRelationships: (repositoryId: string) => + api.get(`/repositories/${encodeURIComponent(repositoryId)}/relationships`), createManual: (companyId: string, input: CreateManualRepository) => api.post(`/companies/${encodeURIComponent(companyId)}/repositories`, input), update: (repositoryId: string, input: UpdateRepository) => diff --git a/ui/src/components/NewProjectDialog.tsx b/ui/src/components/NewProjectDialog.tsx index 7bb3158f7de..8f0a0794947 100644 --- a/ui/src/components/NewProjectDialog.tsx +++ b/ui/src/components/NewProjectDialog.tsx @@ -37,6 +37,8 @@ import { cn } from "../lib/utils"; import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor"; import { StatusBadge } from "./StatusBadge"; import { ChoosePathButton } from "./PathInstructionsModal"; +import { RepositoryPicker } from "./RepositoryPicker"; +import { buildNewProjectRequests } from "../lib/new-project-requests"; const projectStatuses = [ { value: "backlog", label: "Backlog" }, @@ -57,7 +59,7 @@ export function NewProjectDialog() { const [targetDate, setTargetDate] = useState(""); const [expanded, setExpanded] = useState(false); const [workspaceLocalPath, setWorkspaceLocalPath] = useState(""); - const [workspaceRepoUrl, setWorkspaceRepoUrl] = useState(""); + const [repositoryIds, setRepositoryIds] = useState([]); const [workspaceError, setWorkspaceError] = useState(null); const [statusOpen, setStatusOpen] = useState(false); @@ -109,75 +111,36 @@ export function NewProjectDialog() { setTargetDate(""); setExpanded(false); setWorkspaceLocalPath(""); - setWorkspaceRepoUrl(""); + setRepositoryIds([]); setWorkspaceError(null); } const isAbsolutePath = (value: string) => value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value); - const looksLikeRepoUrl = (value: string) => { - try { - const parsed = new URL(value); - if (parsed.protocol !== "https:") return false; - const segments = parsed.pathname.split("/").filter(Boolean); - return segments.length >= 2; - } catch { - return false; - } - }; - - const deriveWorkspaceNameFromPath = (value: string) => { - const normalized = value.trim().replace(/[\\/]+$/, ""); - const segments = normalized.split(/[\\/]/).filter(Boolean); - return segments[segments.length - 1] ?? "Local folder"; - }; - - const deriveWorkspaceNameFromRepo = (value: string) => { - try { - const parsed = new URL(value); - const segments = parsed.pathname.split("/").filter(Boolean); - const repo = segments[segments.length - 1]?.replace(/\.git$/i, "") ?? ""; - return repo || "GitHub repo"; - } catch { - return "GitHub repo"; - } - }; - async function handleSubmit() { if (!selectedCompanyId || !name.trim()) return; const localPath = workspaceLocalPath.trim(); - const repoUrl = workspaceRepoUrl.trim(); if (localPath && !isAbsolutePath(localPath)) { setWorkspaceError("Local folder must be a full absolute path."); return; } - if (repoUrl && !looksLikeRepoUrl(repoUrl)) { - setWorkspaceError("Repo must use a valid GitHub or GitHub Enterprise repo URL."); - return; - } - setWorkspaceError(null); try { - const created = await createProject.mutateAsync({ - name: name.trim(), - description: description.trim() || undefined, + const requests = buildNewProjectRequests({ + name, + description, status, - // No color is sent — new projects persist color = null (neutral gray). See PAP-68. - ...(goalIds.length > 0 ? { goalIds } : {}), - ...(targetDate ? { targetDate } : {}), + goalIds, + targetDate, + repositoryIds, + workspaceLocalPath, }); + const created = await createProject.mutateAsync(requests.project); - if (localPath || repoUrl) { - const workspacePayload: Record = { - name: localPath - ? deriveWorkspaceNameFromPath(localPath) - : deriveWorkspaceNameFromRepo(repoUrl), - ...(localPath ? { cwd: localPath } : {}), - ...(repoUrl ? { repoUrl } : {}), - }; - await projectsApi.createWorkspace(created.id, workspacePayload); + if (requests.workspace) { + await projectsApi.createWorkspace(created.id, requests.workspace); } queryClient.invalidateQueries({ queryKey: queryKeys.projects.all(selectedCompanyId) }); @@ -280,37 +243,28 @@ export function NewProjectDialog() {
-
-
- - optional - - - - - - Link a GitHub repository so agents can clone, read, and push code for this project. - - + {selectedCompanyId ? ( +
+
+

Repository hints

+

+ Add the repositories this project will most likely touch. Agents may use other accessible repositories. +

+
+
- { setWorkspaceRepoUrl(e.target.value); setWorkspaceError(null); }} - placeholder="https://github.com/org/repo" - /> -
+ ) : null} -
+
- + optional - Set an absolute path on this machine where local agents will read and write files for this project. + This local folder is managed separately from repository hints and controls where local agents write files.
diff --git a/ui/src/components/ProjectProperties.tsx b/ui/src/components/ProjectProperties.tsx index 6a181abc16b..3623124f0ee 100644 --- a/ui/src/components/ProjectProperties.tsx +++ b/ui/src/components/ProjectProperties.tsx @@ -24,6 +24,7 @@ import { DraftInput } from "./agent-config-primitives"; import { InlineEditor } from "./InlineEditor"; import { EnvironmentVariablesEditor } from "./environment-variables-editor"; import { Badge } from "@/components/ui/badge"; +import { RepositoryPicker } from "./RepositoryPicker"; const PROJECT_STATUSES = [ { value: "backlog", label: "Backlog" }, @@ -49,6 +50,7 @@ export type ProjectConfigFieldKey = | "status" | "goals" | "env" + | "repository_hints" | "execution_workspace_enabled" | "execution_workspace_default_mode" | "execution_workspace_shared_concurrency" @@ -252,9 +254,8 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa const queryClient = useQueryClient(); const [goalOpen, setGoalOpen] = useState(false); const [executionWorkspaceAdvancedOpen, setExecutionWorkspaceAdvancedOpen] = useState(false); - const [workspaceMode, setWorkspaceMode] = useState<"local" | "repo" | null>(null); + const [workspaceMode, setWorkspaceMode] = useState<"local" | null>(null); const [workspaceCwd, setWorkspaceCwd] = useState(""); - const [workspaceRepoUrl, setWorkspaceRepoUrl] = useState(""); const [workspaceError, setWorkspaceError] = useState(null); const commitField = (field: ProjectConfigFieldKey, data: Record) => { @@ -367,7 +368,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa mutationFn: (data: Record) => projectsApi.createWorkspace(project.id, data), onSuccess: () => { setWorkspaceCwd(""); - setWorkspaceRepoUrl(""); setWorkspaceMode(null); setWorkspaceError(null); invalidateProject(); @@ -378,7 +378,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa mutationFn: (workspaceId: string) => projectsApi.removeWorkspace(project.id, workspaceId), onSuccess: () => { setWorkspaceCwd(""); - setWorkspaceRepoUrl(""); setWorkspaceMode(null); setWorkspaceError(null); invalidateProject(); @@ -389,7 +388,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa projectsApi.updateWorkspace(project.id, workspaceId, data), onSuccess: () => { setWorkspaceCwd(""); - setWorkspaceRepoUrl(""); setWorkspaceMode(null); setWorkspaceError(null); invalidateProject(); @@ -422,17 +420,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa const isAbsolutePath = (value: string) => value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value); - const looksLikeRepoUrl = (value: string) => { - try { - const parsed = new URL(value); - if (parsed.protocol !== "https:") return false; - const segments = parsed.pathname.split("/").filter(Boolean); - return segments.length >= 2; - } catch { - return false; - } - }; - const isSafeExternalUrl = (value: string | null | undefined) => { if (!value) return false; try { @@ -503,21 +490,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa persistCodebase({ cwd }); }; - const submitRepoWorkspace = () => { - const repoUrl = workspaceRepoUrl.trim(); - if (!repoUrl) { - setWorkspaceError(null); - persistCodebase({ repoUrl: null }); - return; - } - if (!looksLikeRepoUrl(repoUrl)) { - setWorkspaceError("Repo must use a valid GitHub or GitHub Enterprise repo URL."); - return; - } - setWorkspaceError(null); - persistCodebase({ repoUrl }); - }; - const clearLocalWorkspace = () => { const confirmed = window.confirm( codebase.repoUrl @@ -528,24 +500,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa persistCodebase({ cwd: null }); }; - const clearRepoWorkspace = () => { - const hasLocalFolder = Boolean(codebase.localFolder); - const confirmed = window.confirm( - hasLocalFolder - ? "Clear repo from this workspace?" - : "Delete this workspace repo?", - ); - if (!confirmed) return; - if (primaryCodebaseWorkspace && hasLocalFolder) { - updateWorkspace.mutate({ - workspaceId: primaryCodebaseWorkspace.id, - data: { repoUrl: null, repoRef: null, defaultRef: null, sourceType: deriveSourceType(codebase.localFolder, null) }, - }); - return; - } - persistCodebase({ repoUrl: null }); - }; - return (
@@ -696,28 +650,48 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa +
+
+ Repository hints + +
+

+ Add the repositories this project will most likely touch. Agents may use other accessible repositories. +

+ {selectedCompanyId ? ( + repository.id)} + onChange={(repositoryIds) => commitField("repository_hints", { repositoryIds })} + disabled={!onUpdate && !onFieldUpdate} + /> + ) : null} +
+ + +
- Codebase + Execution workspace - Repo identifies the source of truth. Local folder is the default place agents write code. + The writable local folder is managed separately from non-exclusive repository hints.
-
Repo
+
Legacy remote
{codebase.repoUrl ? (
{isSafeExternalUrl(codebase.repoUrl) ? ( @@ -737,45 +711,10 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa {codebase.repoUrl}
)} -
- - -
+ Workspace source (read-only)
) : ( -
-
Not set.
- -
+
No legacy remote configured.
)}
@@ -905,39 +844,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
)} - {workspaceMode === "repo" && ( -
- setWorkspaceRepoUrl(e.target.value)} - placeholder="https://github.com/org/repo" - /> -
- - -
-
- )} {workspaceError && (

{workspaceError}

)} diff --git a/ui/src/components/RepositoryPicker.test.tsx b/ui/src/components/RepositoryPicker.test.tsx new file mode 100644 index 00000000000..7f71dd80d20 --- /dev/null +++ b/ui/src/components/RepositoryPicker.test.tsx @@ -0,0 +1,130 @@ +// @vitest-environment jsdom + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { RepositoryPicker } from "./RepositoryPicker"; +import { buildNewProjectRequests } from "../lib/new-project-requests"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const repositoriesApi = vi.hoisted(() => ({ list: vi.fn(), createManual: vi.fn() })); +vi.mock("../api/repositories", () => ({ repositoriesApi })); +vi.mock("@/components/ui/popover", () => ({ + Popover: ({ children }: { children: React.ReactNode }) => <>{children}, + PopoverTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + PopoverContent: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +function repository(id: string, name: string, projectCount = 0) { + return { + id, + companyId: "company-1", + connectionId: null, + provider: "manual", + providerRepositoryId: null, + host: "github.com", + owner: "acme", + name, + cloneUrl: `https://github.com/acme/${name}.git`, + webUrl: `https://github.com/acme/${name}`, + defaultBranch: "main", + visibility: "private", + state: "active", + unavailableReason: null, + providerMetadata: null, + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + projectCount, + directGrantCount: 0, + } as const; +} + +async function act(callback: () => void | Promise) { + let result: void | Promise; + flushSync(() => { result = callback(); }); + await result!; +} + +let root: Root; +let container: HTMLDivElement; +let client: QueryClient; + +beforeEach(() => { + repositoriesApi.list.mockResolvedValue([]); + client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); +}); + +async function render(value: string[], onChange = vi.fn()) { + await act(() => root.render( + + + , + )); + await act(() => new Promise((resolve) => setTimeout(resolve, 0))); + return onChange; +} + +describe("RepositoryPicker", () => { + it("supports non-code projects with zero repository hints", async () => { + await render([]); + expect(container.textContent).toContain("No repository hints"); + }); + + it("selects reusable company repositories for cross-repository projects", async () => { + repositoriesApi.list.mockResolvedValue([ + repository("repo-app", "app", 2), + repository("repo-docs", "docs", 1), + ]); + const onChange = await render(["repo-app"]); + expect(container.textContent).toContain("1 project"); + const docs = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("github.com/acme/docs")); + await act(() => docs!.click()); + expect(onChange).toHaveBeenCalledWith(["repo-app", "repo-docs"]); + }); + + it("returns a nested newly-created repository as selected", async () => { + const created = repository("repo-new", "new"); + repositoriesApi.createManual.mockResolvedValue(created); + const onChange = await render([]); + const addNew = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("Add new repository")); + await act(() => addNew!.click()); + const cloneInput = container.querySelector('input[placeholder="https://github.com/org/repo.git"]')!; + await act(() => { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!; + setter.call(cloneInput, "https://github.com/acme/new.git"); + cloneInput.dispatchEvent(new Event("input", { bubbles: true })); + }); + const submit = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("Add and select")); + await act(() => submit!.click()); + await act(() => new Promise((resolve) => setTimeout(resolve, 0))); + expect(repositoriesApi.createManual).toHaveBeenCalled(); + expect(onChange).toHaveBeenCalledWith(["repo-new"]); + }); + + it("keeps repository hints out of execution workspace creation", () => { + expect(buildNewProjectRequests({ + name: "Research", + description: "", + status: "planned", + goalIds: [], + targetDate: "", + repositoryIds: ["repo-app", "repo-docs"], + workspaceLocalPath: "", + })).toEqual({ + project: { name: "Research", description: undefined, status: "planned", repositoryIds: ["repo-app", "repo-docs"] }, + workspace: null, + }); + }); +}); diff --git a/ui/src/components/RepositoryPicker.tsx b/ui/src/components/RepositoryPicker.tsx new file mode 100644 index 00000000000..20f4b0a0f09 --- /dev/null +++ b/ui/src/components/RepositoryPicker.tsx @@ -0,0 +1,171 @@ +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Check, GitBranch, Plus, Search, X } from "lucide-react"; +import type { RepositoryCatalogItem } from "@paperclipai/shared"; +import { repositoriesApi } from "../api/repositories"; +import { queryKeys } from "../lib/queryKeys"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; + +function repositoryLabel(repository: Pick) { + return `${repository.host}/${repository.owner}/${repository.name}`; +} + +export function RepositoryPicker({ + companyId, + value, + onChange, + disabled = false, +}: { + companyId: string; + value: string[]; + onChange: (repositoryIds: string[]) => void; + disabled?: boolean; +}) { + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const [adding, setAdding] = useState(false); + const [cloneUrl, setCloneUrl] = useState(""); + const [defaultBranch, setDefaultBranch] = useState(""); + const repositoriesQuery = useQuery({ + queryKey: queryKeys.repositories.list(companyId, { includeArchived: true }), + queryFn: () => repositoriesApi.list(companyId, { includeArchived: true }), + enabled: Boolean(companyId), + }); + const repositories = repositoriesQuery.data ?? []; + const selected = value + .map((id) => repositories.find((repository) => repository.id === id)) + .filter((repository): repository is RepositoryCatalogItem => Boolean(repository)); + const filtered = useMemo(() => { + const needle = search.trim().toLowerCase(); + const selectable = repositories.filter((repository) => repository.state === "active" && !value.includes(repository.id)); + if (!needle) return selectable; + return selectable.filter((repository) => repositoryLabel(repository).toLowerCase().includes(needle)); + }, [repositories, search, value]); + const createRepository = useMutation({ + mutationFn: () => repositoriesApi.createManual(companyId, { + cloneUrl: cloneUrl.trim(), + defaultBranch: defaultBranch.trim() || null, + visibility: "unknown", + }), + onSuccess: (repository) => { + queryClient.invalidateQueries({ queryKey: queryKeys.repositories.all(companyId) }); + if (!value.includes(repository.id)) onChange([...value, repository.id]); + setCloneUrl(""); + setDefaultBranch(""); + setAdding(false); + }, + }); + + const toggleRepository = (repositoryId: string) => { + onChange( + value.includes(repositoryId) + ? value.filter((id) => id !== repositoryId) + : [...value, repositoryId], + ); + }; + + return ( +
+ {selected.length > 0 ? ( +
+ {selected.map((repository) => ( +
+
+ + {repositoryLabel(repository)} + {repository.state !== "active" ? ( + {repository.state} + ) : null} +
+ +
+ ))} +
+ ) : ( +

No repository hints. This project can still contain non-code work.

+ )} + + + + + + + {adding ? ( +
+
+

Add company repository

+ +
+ setCloneUrl(event.target.value)} placeholder="https://github.com/org/repo.git" /> + setDefaultBranch(event.target.value)} placeholder="Default branch (optional)" /> + {createRepository.isError ?

Could not add this repository.

: null} + +
+ ) : ( +
+
+ + setSearch(event.target.value)} className="pl-7" placeholder="Search company repositories" /> +
+
+ {filtered.map((repository) => { + const checked = value.includes(repository.id); + return ( + + ); + })} + {filtered.length === 0 ?

No repositories match.

: null} +
+ +
+ )} +
+
+
+ ); +} diff --git a/ui/src/lib/new-project-requests.ts b/ui/src/lib/new-project-requests.ts new file mode 100644 index 00000000000..4026b321cb7 --- /dev/null +++ b/ui/src/lib/new-project-requests.ts @@ -0,0 +1,26 @@ +export function buildNewProjectRequests(input: { + name: string; + description: string; + status: string; + goalIds: string[]; + targetDate: string; + repositoryIds: string[]; + workspaceLocalPath: string; +}) { + const localPath = input.workspaceLocalPath.trim(); + const normalizedPath = localPath.replace(/[\\/]+$/, ""); + const segments = normalizedPath.split(/[\\/]/).filter(Boolean); + return { + project: { + name: input.name.trim(), + description: input.description.trim() || undefined, + status: input.status, + ...(input.goalIds.length > 0 ? { goalIds: input.goalIds } : {}), + repositoryIds: input.repositoryIds, + ...(input.targetDate ? { targetDate: input.targetDate } : {}), + }, + workspace: localPath + ? { name: segments[segments.length - 1] ?? "Local folder", cwd: localPath } + : null, + }; +} diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index fa6dcf277b1..c64043d08eb 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -295,11 +295,13 @@ export const queryKeys = { detail: (id: string) => ["projects", "detail", id] as const, }, repositories: { + all: (companyId: string) => ["repositories", companyId] as const, list: (companyId: string, opts: { includeArchived?: boolean } = {}) => ["repositories", companyId, { includeArchived: opts.includeArchived === true }] as const, connections: (companyId: string) => ["repositories", companyId, "connections"] as const, providers: (companyId: string) => ["repositories", companyId, "providers"] as const, - detail: (id: string) => ["repositories", "detail", id] as const, + detail: (repositoryId: string) => ["repositories", "detail", repositoryId] as const, + relationships: (repositoryId: string) => ["repositories", "relationships", repositoryId] as const, }, cases: { list: (companyId: string) => ["cases", companyId] as const, diff --git a/ui/src/pages/RepositoryDetail.test.tsx b/ui/src/pages/RepositoryDetail.test.tsx new file mode 100644 index 00000000000..479e79f4a03 --- /dev/null +++ b/ui/src/pages/RepositoryDetail.test.tsx @@ -0,0 +1,98 @@ +// @vitest-environment jsdom + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { RepositoryDetail } from "./RepositoryDetail"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const repositoriesApi = vi.hoisted(() => ({ + get: vi.fn(), + getRelationships: vi.fn(), + attachProjectRepository: vi.fn(), + detachProjectRepository: vi.fn(), + grantAgentRepository: vi.fn(), + revokeAgentRepository: vi.fn(), +})); +vi.mock("../api/repositories", () => ({ repositoriesApi })); +vi.mock("../api/projects", () => ({ projectsApi: { list: vi.fn().mockResolvedValue([]) } })); +vi.mock("../api/agents", () => ({ agentsApi: { list: vi.fn().mockResolvedValue([]) } })); +vi.mock("@/lib/router", () => ({ + Link: ({ children, to }: { children: React.ReactNode; to: string }) => {children}, + useParams: () => ({ repositoryId: "repo-1" }), +})); +vi.mock("../context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-1" }) })); +vi.mock("../context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }) })); +vi.mock("@/components/ui/popover", () => ({ + Popover: ({ children }: { children: React.ReactNode }) => <>{children}, + PopoverTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + PopoverContent: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +async function act(callback: () => void | Promise) { + let result: void | Promise; + flushSync(() => { result = callback(); }); + await result!; +} + +describe("RepositoryDetail", () => { + let root: Root; + let container: HTMLDivElement; + + beforeEach(() => { + repositoriesApi.get.mockResolvedValue({ + id: "repo-1", + owner: "acme", + name: "app", + host: "github.com", + defaultBranch: "main", + }); + repositoriesApi.getRelationships.mockResolvedValue({ + projects: [{ id: "project-1", name: "Project One", displayOrder: 0 }], + agents: [ + { + id: "agent-direct", + name: "Direct and inherited", + title: "Engineer", + sources: { direct: true, projects: [{ id: "project-1", name: "Project One" }] }, + }, + { + id: "agent-inherited", + name: "Inherited only", + title: "Engineer", + sources: { direct: false, projects: [{ id: "project-1", name: "Project One" }] }, + }, + ], + }); + repositoriesApi.revokeAgentRepository.mockResolvedValue({ removed: true }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); + }); + + it("distinguishes direct grants from inherited project access and revokes only the direct source", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + await act(() => root.render( + + + , + )); + await act(() => new Promise((resolve) => setTimeout(resolve, 0))); + + expect(container.textContent).toContain("This is guidance, not a boundary"); + expect(container.textContent).toContain("Inherited through Project One · managed on those projects"); + expect(container.textContent).toContain("Inherited access"); + const revoke = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("Revoke direct grant")); + await act(() => revoke!.click()); + await act(() => new Promise((resolve) => setTimeout(resolve, 0))); + expect(repositoriesApi.revokeAgentRepository).toHaveBeenCalledWith("agent-direct", "repo-1"); + }); +}); diff --git a/ui/src/pages/RepositoryDetail.tsx b/ui/src/pages/RepositoryDetail.tsx index 194d29e1f96..984db4c3722 100644 --- a/ui/src/pages/RepositoryDetail.tsx +++ b/ui/src/pages/RepositoryDetail.tsx @@ -1,14 +1,20 @@ -import { useEffect } from "react"; -import { useParams } from "react-router-dom"; -import { useQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Repository } from "@paperclipai/shared"; -import { AlertCircle, GitBranch } from "lucide-react"; +import { AlertCircle, GitBranch, Plus } from "lucide-react"; +import { Link, useParams } from "@/lib/router"; +import { agentsApi } from "../api/agents"; +import { projectsApi } from "../api/projects"; import { repositoriesApi } from "../api/repositories"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { useCompany } from "../context/CompanyContext"; import { queryKeys } from "../lib/queryKeys"; import { PageSkeleton } from "../components/PageSkeleton"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; function MetadataRow({ label, value }: { label: string; value: React.ReactNode }) { return ( @@ -25,28 +31,106 @@ function stateTone(repository: Repository): string { return "text-warning"; } +function SectionPicker({ label, options, onSelect }: { + label: string; + options: Array<{ id: string; label: string }>; + onSelect: (id: string) => void; +}) { + const [open, setOpen] = useState(false); + return ( + + + + + + {options.map((option) => ( + + ))} + + + ); +} + export function RepositoryDetail() { - const { repositoryId } = useParams<{ repositoryId: string }>(); + const { repositoryId = "" } = useParams(); + const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); - + const queryClient = useQueryClient(); const repositoryQuery = useQuery({ - queryKey: queryKeys.repositories.detail(repositoryId!), - queryFn: () => repositoriesApi.get(repositoryId!), - enabled: !!repositoryId, + queryKey: queryKeys.repositories.detail(repositoryId), + queryFn: () => repositoriesApi.get(repositoryId), + enabled: Boolean(repositoryId), + }); + const relationshipsQuery = useQuery({ + queryKey: queryKeys.repositories.relationships(repositoryId), + queryFn: () => repositoriesApi.getRelationships(repositoryId), + enabled: Boolean(repositoryId), + }); + const projectsQuery = useQuery({ + queryKey: queryKeys.projects.list(selectedCompanyId ?? ""), + queryFn: () => projectsApi.list(selectedCompanyId!), + enabled: Boolean(selectedCompanyId), + }); + const agentsQuery = useQuery({ + queryKey: queryKeys.agents.list(selectedCompanyId ?? ""), + queryFn: () => agentsApi.list(selectedCompanyId!), + enabled: Boolean(selectedCompanyId), + }); + const refresh = () => { + queryClient.invalidateQueries({ queryKey: queryKeys.repositories.relationships(repositoryId) }); + if (selectedCompanyId) queryClient.invalidateQueries({ queryKey: queryKeys.repositories.all(selectedCompanyId) }); + }; + const attachProject = useMutation({ + mutationFn: (projectId: string) => repositoriesApi.attachProjectRepository(projectId, repositoryId), + onSuccess: refresh, + }); + const detachProject = useMutation({ + mutationFn: (projectId: string) => repositoriesApi.detachProjectRepository(projectId, repositoryId), + onSuccess: refresh, + }); + const grantAgent = useMutation({ + mutationFn: (agentId: string) => repositoriesApi.grantAgentRepository(agentId, repositoryId), + onSuccess: refresh, + }); + const revokeAgent = useMutation({ + mutationFn: (agentId: string) => repositoriesApi.revokeAgentRepository(agentId, repositoryId), + onSuccess: refresh, }); - const repository = repositoryQuery.data; + const relationships = relationshipsQuery.data; useEffect(() => { setBreadcrumbs([ { label: "Repositories", href: "/repositories" }, { label: repository ? `${repository.owner}/${repository.name}` : "Repository" }, ]); - }, [setBreadcrumbs, repository]); + return () => setBreadcrumbs([]); + }, [repository, setBreadcrumbs]); - if (repositoryQuery.isLoading) return ; + const projectOptions = useMemo(() => { + const linked = new Set(relationships?.projects.map((project) => project.id) ?? []); + return (projectsQuery.data ?? []) + .filter((project) => !linked.has(project.id) && !project.archivedAt) + .map((project) => ({ id: project.id, label: project.name })); + }, [projectsQuery.data, relationships?.projects]); + const directAgentIds = new Set( + relationships?.agents.filter((agent) => agent.sources.direct).map((agent) => agent.id) ?? [], + ); + const agentOptions = (agentsQuery.data ?? []) + .filter((agent) => !directAgentIds.has(agent.id)) + .map((agent) => ({ id: agent.id, label: agent.name })); - if (repositoryQuery.isError || !repository) { + if (repositoryQuery.isLoading || relationshipsQuery.isLoading) return ; + if (repositoryQuery.isError || relationshipsQuery.isError || !repository || !relationships) { return (
@@ -57,7 +141,7 @@ export function RepositoryDetail() { } return ( -
+
@@ -71,32 +155,77 @@ export function RepositoryDetail() { {repository.state}} /> - {repository.unavailableReason ? ( - - ) : null} + {repository.unavailableReason ? : null} - {repository.cloneUrl}} - /> + {repository.cloneUrl}} /> - {repository.webUrl} - - ) : ( - "—" - ) - } - /> - + {repository.webUrl} + + ) : "—"} /> + + + + +
+
+
+

Project hints

+

+ Attach this repository to every project likely to touch it. This is guidance, not a boundary. +

+
+ attachProject.mutate(id)} /> +
+ {relationships.projects.length === 0 ? ( +

No projects currently hint at this repository.

+ ) : relationships.projects.map((project) => ( +
+ {project.name} + +
+ ))} +
+ + + +
+
+
+

Direct agent access

+

These grants are in addition to access inherited through projects.

+
+ grantAgent.mutate(id)} /> +
+ {relationships.agents.length === 0 ? ( +

No agents currently have effective access.

+ ) : relationships.agents.map((agent) => ( +
+
+ {agent.name} +

+ {agent.sources.projects.length > 0 + ? `Inherited through ${agent.sources.projects.map((project) => project.name).join(", ")} · managed on those projects` + : "Direct grant"} +

+
+ {agent.sources.direct ? ( + + ) : ( + Inherited access + )} +
+ ))} +
); }