mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
feat(ui): add repository hint and grant editors
Replace project Repo URL ownership with a reusable zero-to-many repository picker and nested add flow, keep execution workspace controls separate, and add source-aware repository relationship editing with focused UI coverage. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
01bd270119
commit
358b2863f2
10 files changed
+651
-231
No files matched your search
@@ -595,6 +595,7 @@ export function App() {
|
||||
<Route path="projects/:projectId/workspaces" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="projects/:projectId/workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="repositories/:repositoryId" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="workspaces" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId/services" element={<UnprefixedBoardRedirect />} />
|
||||
|
||||
@@ -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<Repository>(`/repositories/${encodeURIComponent(repositoryId)}`),
|
||||
getRelationships: (repositoryId: string) =>
|
||||
api.get<RepositoryRelationships>(`/repositories/${encodeURIComponent(repositoryId)}/relationships`),
|
||||
createManual: (companyId: string, input: CreateManualRepository) =>
|
||||
api.post<Repository>(`/companies/${encodeURIComponent(companyId)}/repositories`, input),
|
||||
update: (repositoryId: string, input: UpdateRepository) =>
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
const [workspaceError, setWorkspaceError] = useState<string | null>(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<string, unknown> = {
|
||||
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() {
|
||||
</div>
|
||||
|
||||
<div className="px-4 pt-3 pb-3 space-y-3 border-t border-border">
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<label className="block text-xs text-muted-foreground">Repo URL</label>
|
||||
<span className="text-xs text-muted-foreground/50">optional</span>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="h-3 w-3 text-muted-foreground/50 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-(--sz-240px) text-xs">
|
||||
Link a GitHub repository so agents can clone, read, and push code for this project.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{selectedCompanyId ? (
|
||||
<div className="space-y-1.5">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Repository hints</p>
|
||||
<p className="text-(length:--text-micro) text-muted-foreground">
|
||||
Add the repositories this project will most likely touch. Agents may use other accessible repositories.
|
||||
</p>
|
||||
</div>
|
||||
<RepositoryPicker companyId={selectedCompanyId} value={repositoryIds} onChange={setRepositoryIds} />
|
||||
</div>
|
||||
<input
|
||||
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
|
||||
value={workspaceRepoUrl}
|
||||
onChange={(e) => { setWorkspaceRepoUrl(e.target.value); setWorkspaceError(null); }}
|
||||
placeholder="https://github.com/org/repo"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<label className="block text-xs text-muted-foreground">Local folder</label>
|
||||
<label className="block text-xs text-muted-foreground">Execution workspace</label>
|
||||
<span className="text-xs text-muted-foreground/50">optional</span>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="h-3 w-3 text-muted-foreground/50 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-(--sz-240px) text-xs">
|
||||
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.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
const commitField = (field: ProjectConfigFieldKey, data: Record<string, unknown>) => {
|
||||
@@ -367,7 +368,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
||||
mutationFn: (data: Record<string, unknown>) => 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 (
|
||||
<div>
|
||||
<div className="space-y-1 pb-4">
|
||||
@@ -696,28 +650,48 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="space-y-2 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">Repository hints</span>
|
||||
<SaveIndicator state={fieldState("repository_hints")} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add the repositories this project will most likely touch. Agents may use other accessible repositories.
|
||||
</p>
|
||||
{selectedCompanyId ? (
|
||||
<RepositoryPicker
|
||||
companyId={selectedCompanyId}
|
||||
value={(project.repositoryHints ?? []).map((repository) => repository.id)}
|
||||
onChange={(repositoryIds) => commitField("repository_hints", { repositoryIds })}
|
||||
disabled={!onUpdate && !onFieldUpdate}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="space-y-1 py-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>Codebase</span>
|
||||
<span>Execution workspace</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded-full border border-border text-(length:--text-nano) text-muted-foreground hover:text-foreground"
|
||||
aria-label="Codebase help"
|
||||
aria-label="Execution workspace help"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
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.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="space-y-2 rounded-md border border-border/70 p-3">
|
||||
<div className="space-y-1">
|
||||
<div className="text-(length:--text-micro) uppercase tracking-wide text-muted-foreground">Repo</div>
|
||||
<div className="text-(length:--text-micro) uppercase tracking-wide text-muted-foreground">Legacy remote</div>
|
||||
{codebase.repoUrl ? (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{isSafeExternalUrl(codebase.repoUrl) ? (
|
||||
@@ -737,45 +711,10 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
||||
<span className="break-all min-w-0">{codebase.repoUrl}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="h-6 px-2"
|
||||
onClick={() => {
|
||||
setWorkspaceMode("repo");
|
||||
setWorkspaceRepoUrl(codebase.repoUrl ?? "");
|
||||
setWorkspaceError(null);
|
||||
}}
|
||||
>
|
||||
Change repo
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={clearRepoWorkspace}
|
||||
aria-label="Clear repo"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="text-(length:--text-micro) text-muted-foreground">Workspace source (read-only)</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-xs text-muted-foreground">Not set.</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="h-6 px-2"
|
||||
onClick={() => {
|
||||
setWorkspaceMode("repo");
|
||||
setWorkspaceRepoUrl(codebase.repoUrl ?? "");
|
||||
setWorkspaceError(null);
|
||||
}}
|
||||
>
|
||||
Set repo
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">No legacy remote configured.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -905,39 +844,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{workspaceMode === "repo" && (
|
||||
<div className="space-y-1.5 rounded-md border border-border p-2">
|
||||
<input
|
||||
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
|
||||
value={workspaceRepoUrl}
|
||||
onChange={(e) => setWorkspaceRepoUrl(e.target.value)}
|
||||
placeholder="https://github.com/org/repo"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="h-6 px-2"
|
||||
disabled={(!workspaceRepoUrl.trim() && !primaryCodebaseWorkspace) || createWorkspace.isPending || updateWorkspace.isPending}
|
||||
onClick={submitRepoWorkspace}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-6 px-2"
|
||||
onClick={() => {
|
||||
setWorkspaceMode(null);
|
||||
setWorkspaceRepoUrl("");
|
||||
setWorkspaceError(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{workspaceError && (
|
||||
<p className="text-xs text-destructive">{workspaceError}</p>
|
||||
)}
|
||||
|
||||
@@ -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 }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
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<void>) {
|
||||
let result: void | Promise<void>;
|
||||
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(
|
||||
<QueryClientProvider client={client}>
|
||||
<RepositoryPicker companyId="company-1" value={value} onChange={onChange} />
|
||||
</QueryClientProvider>,
|
||||
));
|
||||
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<HTMLInputElement>('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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<RepositoryCatalogItem, "host" | "owner" | "name">) {
|
||||
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 (
|
||||
<div className="space-y-2">
|
||||
{selected.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{selected.map((repository) => (
|
||||
<div key={repository.id} className="flex items-center justify-between gap-2 rounded-md border border-border px-2 py-1.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<GitBranch className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-xs">{repositoryLabel(repository)}</span>
|
||||
{repository.state !== "active" ? (
|
||||
<span className="text-(length:--text-micro) text-muted-foreground">{repository.state}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-6 px-2 text-muted-foreground"
|
||||
onClick={() => toggleRepository(repository.id)}
|
||||
disabled={disabled}
|
||||
aria-label={`Remove project hint ${repositoryLabel(repository)}`}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No repository hints. This project can still contain non-code work.</p>
|
||||
)}
|
||||
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="outline" size="xs" disabled={disabled || !companyId}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
Add repository
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-2" align="start">
|
||||
{adding ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium">Add company repository</p>
|
||||
<Button type="button" variant="ghost" size="icon-xs" onClick={() => setAdding(false)} aria-label="Back to repositories">
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<Input value={cloneUrl} onChange={(event) => setCloneUrl(event.target.value)} placeholder="https://github.com/org/repo.git" />
|
||||
<Input value={defaultBranch} onChange={(event) => setDefaultBranch(event.target.value)} placeholder="Default branch (optional)" />
|
||||
{createRepository.isError ? <p className="text-xs text-destructive">Could not add this repository.</p> : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={!cloneUrl.trim() || createRepository.isPending}
|
||||
onClick={() => createRepository.mutate()}
|
||||
>
|
||||
{createRepository.isPending ? "Adding…" : "Add and select"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input value={search} onChange={(event) => setSearch(event.target.value)} className="pl-7" placeholder="Search company repositories" />
|
||||
</div>
|
||||
<div className="max-h-56 space-y-0.5 overflow-y-auto">
|
||||
{filtered.map((repository) => {
|
||||
const checked = value.includes(repository.id);
|
||||
return (
|
||||
<button
|
||||
key={repository.id}
|
||||
type="button"
|
||||
className={cn("flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-accent", checked && "bg-accent")}
|
||||
onClick={() => toggleRepository(repository.id)}
|
||||
>
|
||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded border border-border">
|
||||
{checked ? <Check className="h-3 w-3" /> : null}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs">{repositoryLabel(repository)}</span>
|
||||
{repository.projectCount > 0 ? (
|
||||
<span className="text-(length:--text-micro) text-muted-foreground">
|
||||
{repository.projectCount} {repository.projectCount === 1 ? "project" : "projects"}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 ? <p className="px-2 py-3 text-center text-xs text-muted-foreground">No repositories match.</p> : null}
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="sm" className="w-full justify-start" onClick={() => setAdding(true)}>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
Add new repository
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }) => <a href={to}>{children}</a>,
|
||||
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 }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void>;
|
||||
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(
|
||||
<QueryClientProvider client={client}>
|
||||
<RepositoryDetail />
|
||||
</QueryClientProvider>,
|
||||
));
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="outline" size="xs" disabled={options.length === 0}>
|
||||
<Plus className="mr-1 h-3 w-3" />{label}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-1" align="end">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
className="w-full truncate rounded px-2 py-1.5 text-left text-xs hover:bg-accent"
|
||||
onClick={() => { onSelect(option.id); setOpen(false); }}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
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 <PageSkeleton />;
|
||||
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 <PageSkeleton />;
|
||||
if (repositoryQuery.isError || relationshipsQuery.isError || !repository || !relationships) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div role="alert" className="flex items-center gap-2 text-sm text-destructive">
|
||||
@@ -57,7 +141,7 @@ export function RepositoryDetail() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<div className="mx-auto flex w-full max-w-4xl 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>
|
||||
@@ -71,32 +155,77 @@ export function RepositoryDetail() {
|
||||
|
||||
<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}
|
||||
{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="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"}
|
||||
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>
|
||||
|
||||
<Separator />
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium">Project hints</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Attach this repository to every project likely to touch it. This is guidance, not a boundary.
|
||||
</p>
|
||||
</div>
|
||||
<SectionPicker label="Add to project" options={projectOptions} onSelect={(id) => attachProject.mutate(id)} />
|
||||
</div>
|
||||
{relationships.projects.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No projects currently hint at this repository.</p>
|
||||
) : relationships.projects.map((project) => (
|
||||
<div key={project.id} className="flex items-center justify-between gap-3 border-b border-border py-2 last:border-b-0">
|
||||
<Link to={`/projects/${project.id}/configuration`} className="text-sm hover:underline">{project.name}</Link>
|
||||
<Button type="button" variant="ghost" size="xs" onClick={() => detachProject.mutate(project.id)}>
|
||||
Remove project hint
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium">Direct agent access</h2>
|
||||
<p className="text-xs text-muted-foreground">These grants are in addition to access inherited through projects.</p>
|
||||
</div>
|
||||
<SectionPicker label="Grant agent" options={agentOptions} onSelect={(id) => grantAgent.mutate(id)} />
|
||||
</div>
|
||||
{relationships.agents.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No agents currently have effective access.</p>
|
||||
) : relationships.agents.map((agent) => (
|
||||
<div key={agent.id} className="flex items-center justify-between gap-3 border-b border-border py-2 last:border-b-0">
|
||||
<div className="min-w-0">
|
||||
<Link to={`/agents/${agent.id}`} className="text-sm hover:underline">{agent.name}</Link>
|
||||
<p className="text-(length:--text-micro) text-muted-foreground">
|
||||
{agent.sources.projects.length > 0
|
||||
? `Inherited through ${agent.sources.projects.map((project) => project.name).join(", ")} · managed on those projects`
|
||||
: "Direct grant"}
|
||||
</p>
|
||||
</div>
|
||||
{agent.sources.direct ? (
|
||||
<Button type="button" variant="ghost" size="xs" onClick={() => revokeAgent.mutate(agent.id)}>
|
||||
Revoke direct grant
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-(length:--text-micro) text-muted-foreground">Inherited access</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user