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 (
+ onValueChange(value)}>
+ {children}
+
+ );
+ },
+ };
+});
+
+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 */}
+
+
+ GitHub App
+
+ {!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 && (
+
+ setGithubInstallationId(null)}
+ >
+ Unbind
+
+
+ )}
+
+
+ )}
+
+
{/* Git Token Section */}