@@ -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}
)}
-
- {
- setWorkspaceMode("repo");
- setWorkspaceRepoUrl(codebase.repoUrl ?? "");
- setWorkspaceError(null);
- }}
- >
- Change repo
-
-
-
-
-
+
Workspace source (read-only)
) : (
-
-
Not set.
-
{
- setWorkspaceMode("repo");
- setWorkspaceRepoUrl(codebase.repoUrl ?? "");
- setWorkspaceError(null);
- }}
- >
- Set repo
-
-
+
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"
- />
-
-
- Save
-
- {
- setWorkspaceMode(null);
- setWorkspaceRepoUrl("");
- setWorkspaceError(null);
- }}
- >
- Cancel
-
-
-
- )}
{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}
+
+
toggleRepository(repository.id)}
+ disabled={disabled}
+ aria-label={`Remove project hint ${repositoryLabel(repository)}`}
+ >
+ Remove
+
+
+ ))}
+
+ ) : (
+
No repository hints. This project can still contain non-code work.
+ )}
+
+
+
+
+
+ Add repository
+
+
+
+ {adding ? (
+
+
+
Add company repository
+
setAdding(false)} aria-label="Back to repositories">
+
+
+
+
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}
+
createRepository.mutate()}
+ >
+ {createRepository.isPending ? "Adding…" : "Add and select"}
+
+
+ ) : (
+
+
+
+ setSearch(event.target.value)} className="pl-7" placeholder="Search company repositories" />
+
+
+ {filtered.map((repository) => {
+ const checked = value.includes(repository.id);
+ return (
+
toggleRepository(repository.id)}
+ >
+
+ {checked ? : null}
+
+ {repositoryLabel(repository)}
+ {repository.projectCount > 0 ? (
+
+ {repository.projectCount} {repository.projectCount === 1 ? "project" : "projects"}
+
+ ) : null}
+
+ );
+ })}
+ {filtered.length === 0 ?
No repositories match.
: null}
+
+
setAdding(true)}>
+
+ Add new repository
+
+
+ )}
+
+
+
+ );
+}
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 (
+
+
+
+ {label}
+
+
+
+ {options.map((option) => (
+ { onSelect(option.id); setOpen(false); }}
+ >
+ {option.label}
+
+ ))}
+
+
+ );
+}
+
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}
+ detachProject.mutate(project.id)}>
+ Remove project hint
+
+
+ ))}
+
+
+
+
+
+
+
+
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 ? (
+
revokeAgent.mutate(agent.id)}>
+ Revoke direct grant
+
+ ) : (
+
Inherited access
+ )}
+
+ ))}
+
);
}