mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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 <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -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;
|
||||
}) => (
|
||||
<Ctx.Provider value={onValueChange ?? (() => {})}>
|
||||
{children}
|
||||
</Ctx.Provider>
|
||||
),
|
||||
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectValue: () => null,
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectItem: ({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: string;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const onValueChange = React.useContext(Ctx);
|
||||
return (
|
||||
<button type="button" onClick={() => onValueChange(value)}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
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> = {}): 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 <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
function renderDialog(project: Project) {
|
||||
useProject.mockReturnValue({ data: project, isLoading: false });
|
||||
return render(
|
||||
withQueryClient(
|
||||
<EditProjectDialog projectId={project.id} open onOpenChange={vi.fn()} />,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* GitHub App binding */}
|
||||
<div className="grid gap-2 p-3 border rounded-lg bg-muted/30">
|
||||
<HelpTip label="Binding routes commits and PR reviews through a short-lived GitHub App installation token attributed to the App bot, instead of this project's personal access token; unbinding reverts to the PAT. Requires the App to be installed on this repository.">
|
||||
<Label>GitHub App</Label>
|
||||
</HelpTip>
|
||||
{!appConfigured ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
) : isNonGithubProvider ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
App auth is GitHub-only — this project's forge is{" "}
|
||||
{gitProvider}, so git operations use its token below.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm">
|
||||
{githubInstallationId !== null ? (
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
Using GitHub App (installation #{githubInstallationId})
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
Using personal access token
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<SelectRepoDialog onSelect={handleRepoSelected} />
|
||||
{githubInstallationId !== null && (
|
||||
<HelpTip label="Clears the installation binding on save; git operations fall back to the personal access token below.">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setGithubInstallationId(null)}
|
||||
>
|
||||
Unbind
|
||||
</Button>
|
||||
</HelpTip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Git Token Section */}
|
||||
<div className="grid gap-2 p-3 border rounded-lg bg-muted/30">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
Reference in New Issue
Block a user