From c83482ad198543c8da2147415001ecd47aa27ae9 Mon Sep 17 00:00:00 2001 From: Renzo F <45401804+rennf93@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:01:18 +0200 Subject: [PATCH] feat(panel): bind an existing project to the GitHub App from Edit (#633) #621 only let a NEW project bind to a GitHub App installation (the create dialog's Select repo picker). An already-imported project on a PAT had no way to re-route to the App. The Edit Project dialog now carries a GitHub App section: when App creds are configured, it shows the current binding (App installation vs PAT), reuses the same SelectRepoPicker to bind, and an Unbind button to revert to PAT (sends explicit null). Hidden/disabled for non-GitHub providers. Once bound, git ops (commits, PR reviews) are attributed to the App bot instead of the operator's account. Co-authored-by: Renn F --- .../__tests__/edit-project-dialog.test.tsx | 237 ++++++++++++++++++ .../projects/edit-project-dialog.tsx | 77 ++++++ 2 files changed, 314 insertions(+) create mode 100644 panel/src/components/projects/__tests__/edit-project-dialog.test.tsx 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 */}