diff --git a/panel/src/components/projects/__tests__/edit-project-dialog.test.tsx b/panel/src/components/projects/__tests__/edit-project-dialog.test.tsx new file mode 100644 index 00000000..18d7410d --- /dev/null +++ b/panel/src/components/projects/__tests__/edit-project-dialog.test.tsx @@ -0,0 +1,237 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import React from "react"; +import { Team } from "@/types"; +import type { Project } from "@/types"; + +// jsdom has no ResizeObserver; Radix Switch (the always-rendered "Active" +// toggle) measures its thumb via one on mount — mirrors +// a2a-conversation-list.test.tsx's stub. +if (typeof window !== "undefined" && !window.ResizeObserver) { + window.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; +} + +// Radix Select's SelectValue sizing hook calls ResizeObserver, absent in +// jsdom — mirrors select-repo-picker.test.tsx's functional replacement +// (SelectItem wired to onValueChange via context) so the dialog's Forge / +// Assigned Cell selects mount without crashing. +vi.mock("@/components/ui/select", () => { + const Ctx = React.createContext<(v: string) => void>(() => {}); + return { + Select: ({ + onValueChange, + children, + }: { + onValueChange?: (v: string) => void; + children: React.ReactNode; + }) => ( + {})}> + {children} + + ), + SelectTrigger: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + SelectValue: () => null, + SelectContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + SelectItem: ({ + value, + children, + }: { + value: string; + children: React.ReactNode; + }) => { + const onValueChange = React.useContext(Ctx); + return ( + + ); + }, + }; +}); + +const { useProject, useUpdateProject, mutateAsync } = vi.hoisted(() => ({ + useProject: vi.fn(), + useUpdateProject: vi.fn(), + mutateAsync: vi.fn(), +})); +vi.mock("@/hooks/use-projects", () => ({ useProject, useUpdateProject })); + +const { + getCredentialsStatus, + listInstallations, + listInstallationRepositories, +} = vi.hoisted(() => ({ + getCredentialsStatus: vi.fn(async () => ({ has_credentials: true })), + listInstallations: vi.fn(async () => [{ id: 42, account_login: "acme" }]), + listInstallationRepositories: vi.fn(async () => [ + { + full_name: "acme/widgets", + clone_url: "https://github.com/acme/widgets.git", + private: false, + }, + ]), +})); +vi.mock("@/lib/api", () => ({ + githubAppApi: { + getCredentialsStatus, + listInstallations, + listInstallationRepositories, + }, +})); + +import { EditProjectDialog } from "../edit-project-dialog"; + +function makeProject(overrides: Partial = {}): Project { + return { + id: "proj-1", + name: "RoboCo API", + slug: "roboco-api", + git_url: "https://github.com/org/repo.git", + git_provider: "github", + github_installation_id: null, + default_branch: "main", + environments: null, + protected_branches: ["main"], + assigned_cell: Team.BACKEND, + has_git_token: true, + is_active: true, + test_command: null, + lint_command: null, + format_command: null, + typecheck_command: null, + build_command: null, + quality_command: null, + codegen_command: null, + ci_watch_enabled: false, + ci_watch_workflow: null, + video_engine_enabled: false, + dep_update_command: null, + dep_update_paths: null, + sandbox_services: null, + sandbox_extensions: null, + workspace_path: null, + last_synced_at: null, + head_commit: null, + created_by: "ceo", + created_at: "2026-01-01T00:00:00Z", + updated_at: null, + ...overrides, + }; +} + +function withQueryClient(ui: ReactNode) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return {ui}; +} + +function renderDialog(project: Project) { + useProject.mockReturnValue({ data: project, isLoading: false }); + return render( + withQueryClient( + , + ), + ); +} + +describe("EditProjectDialog — GitHub App binding", () => { + beforeEach(() => { + vi.clearAllMocks(); + getCredentialsStatus.mockResolvedValue({ has_credentials: true }); + listInstallations.mockResolvedValue([{ id: 42, account_login: "acme" }]); + listInstallationRepositories.mockResolvedValue([ + { + full_name: "acme/widgets", + clone_url: "https://github.com/acme/widgets.git", + private: false, + }, + ]); + mutateAsync.mockResolvedValue(makeProject()); + useUpdateProject.mockReturnValue({ mutateAsync, isPending: false }); + }); + + it("shows the PAT state and a repo picker when the App is configured", async () => { + renderDialog(makeProject()); + + expect( + await screen.findByText("Using personal access token"), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /Select repo/i }), + ).toBeInTheDocument(); + }); + + it("shows the current binding and an Unbind button when already bound", async () => { + renderDialog(makeProject({ github_installation_id: 42 })); + + expect( + await screen.findByText("Using GitHub App (installation #42)"), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Unbind/i })).toBeInTheDocument(); + }); + + it("shows a muted note and no picker when the App is not configured", async () => { + getCredentialsStatus.mockResolvedValue({ has_credentials: false }); + renderDialog(makeProject()); + + expect( + await screen.findByText(/Configure the GitHub App/i), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /Select repo/i }), + ).not.toBeInTheDocument(); + }); + + it("hides the picker for a non-GitHub forge provider", async () => { + renderDialog(makeProject({ git_provider: "gitlab" })); + + expect( + await screen.findByText(/App auth is GitHub-only/i), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /Select repo/i }), + ).not.toBeInTheDocument(); + }); + + it("binding via the repo picker sets github_installation_id in the submitted payload", async () => { + renderDialog(makeProject()); + + const pickButton = await screen.findByRole("button", { + name: /Select repo/i, + }); + fireEvent.click(pickButton); + fireEvent.click(await screen.findByText("acme/widgets")); + + fireEvent.click(screen.getByRole("button", { name: /Save Changes/i })); + + await waitFor(() => expect(mutateAsync).toHaveBeenCalled()); + const call = mutateAsync.mock.calls[0][0] as { + updates: { github_installation_id?: number | null }; + }; + expect(call.updates.github_installation_id).toBe(42); + }); + + it("unbinding sends an explicit null so the backend clears the stored installation", async () => { + renderDialog(makeProject({ github_installation_id: 42 })); + + fireEvent.click(await screen.findByRole("button", { name: /Unbind/i })); + fireEvent.click(screen.getByRole("button", { name: /Save Changes/i })); + + await waitFor(() => expect(mutateAsync).toHaveBeenCalled()); + const call = mutateAsync.mock.calls[0][0] as { + updates: { github_installation_id?: number | null }; + }; + expect(call.updates.github_installation_id).toBeNull(); + }); +}); diff --git a/panel/src/components/projects/edit-project-dialog.tsx b/panel/src/components/projects/edit-project-dialog.tsx index 4bb500b9..af450249 100644 --- a/panel/src/components/projects/edit-project-dialog.tsx +++ b/panel/src/components/projects/edit-project-dialog.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useProject, useUpdateProject } from "@/hooks/use-projects"; import { Button } from "@/components/ui/button"; import { @@ -27,8 +28,13 @@ import { ConventionsTab } from "@/components/conventions/conventions-tab"; import { Key, KeyRound } from "lucide-react"; import { toast } from "sonner"; import { Team, type ProjectUpdate, type Project } from "@/types"; +import { githubAppApi } from "@/lib/api"; import { EnvironmentLadderEditor } from "@/components/projects/environment-ladder-editor"; import { validateLadder } from "@/components/projects/ladder-validation"; +import { + SelectRepoDialog, + type SelectedRepo, +} from "@/components/projects/select-repo-picker"; import { HelpTip } from "@/components/ui/help-tip"; const cells: { value: Team; label: string }[] = [ @@ -130,6 +136,11 @@ function EditProjectForm({ const [gitProvider, setGitProvider] = useState( project.git_provider ?? "auto", ); + // Set via the "Select repo" picker (binds to a GitHub App installation) or + // cleared via "Unbind"; null = git ops fall back to the PAT below. + const [githubInstallationId, setGithubInstallationId] = useState< + number | null + >(project.github_installation_id); const [assignedCell, setAssignedCell] = useState(project.assigned_cell); const [defaultBranch, setDefaultBranch] = useState(project.default_branch); const [environments, setEnvironments] = useState( @@ -200,6 +211,21 @@ function EditProjectForm({ const [showAdvanced, setShowAdvanced] = useState(false); const [showAutonomy, setShowAutonomy] = useState(false); + const { data: credStatus } = useQuery({ + queryKey: ["github-app", "credentials"], + queryFn: () => githubAppApi.getCredentialsStatus(), + }); + const appConfigured = !!credStatus?.has_credentials; + // App auth is GitHub-only; a self-hosted Gitea/GitLab project keeps using + // its own token below regardless of any installation id already stored. + const isNonGithubProvider = + gitProvider === "gitea" || gitProvider === "gitlab"; + + const handleRepoSelected = (repo: SelectedRepo) => { + setGitUrl(repo.git_url); + setGithubInstallationId(repo.installation_id); + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -219,6 +245,9 @@ function EditProjectForm({ name, git_url: gitUrl, git_provider: gitProvider === "auto" ? null : gitProvider, + // Sent explicitly (never coerced to undefined) so an unbind (null) + // actually clears the stored installation instead of being dropped. + github_installation_id: githubInstallationId, assigned_cell: assignedCell, default_branch: defaultBranch || "main", environments, @@ -338,6 +367,54 @@ function EditProjectForm({ + {/* GitHub App binding */} +
+ + + + {!appConfigured ? ( +

+ Git operations use this project's personal access token + below. Configure the GitHub App on the Settings page to enable + App-token (bot-attributed) auth. +

+ ) : isNonGithubProvider ? ( +

+ App auth is GitHub-only — this project's forge is{" "} + {gitProvider}, so git operations use its token below. +

+ ) : ( +
+

+ {githubInstallationId !== null ? ( + + Using GitHub App (installation #{githubInstallationId}) + + ) : ( + + Using personal access token + + )} +

+
+ + {githubInstallationId !== null && ( + + + + )} +
+
+ )} +
+ {/* Git Token Section */}