mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(github-app): App credentials, installation tokens, and a Select repo picker (#621)
* feat(github-app): App credentials, installation tokens, and a Select repo picker RoboCo was 100% PAT-based. A singleton Fernet-encrypted github_app_credentials row (migration 077, telegram-credentials pattern) now stores the App id + private key; github_app_auth mints RS256 app JWTs and caches installation tokens until 5 minutes before expiry. Projects can bind an installation (projects.github_installation_id): get_decrypted_token returns a minted installation token for bound projects and falls back to the stored PAT on any minting failure, so all ten token consumers work unchanged. CEO-gated routes expose credentials CRUD plus installation/repo listing, and the New Project dialog gains a Select repo picker (disabled with a HelpTip until the App is configured) that fills the git URL and binds the installation; manual URL + PAT stays the default path. * test(panel): mock the GitHub App credentials card in the settings page test --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
"""Add github_app_credentials + projects.github_installation_id.
|
||||
|
||||
GitHub App integration (Wave H): a singleton ``github_app_credentials`` row
|
||||
holds the App id (plain string — a public identifier, not a secret, like
|
||||
``app_id`` in a GitHub App's own settings page) + the Fernet-encrypted RSA
|
||||
private key used to mint short-lived installation tokens. A project opts a
|
||||
repo into App-token auth by recording which installation covers it
|
||||
(``projects.github_installation_id``, nullable BigInteger — installation ids
|
||||
are large GitHub-assigned integers). Additive and inert: a null installation
|
||||
id or an unset App keeps every project on its existing PAT flow
|
||||
(``ProjectService.get_decrypted_token`` falls back automatically).
|
||||
|
||||
Revision ID: 077_github_app
|
||||
Revises: 076_project_git_provider
|
||||
Create Date: 2026-07-20
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "077_github_app"
|
||||
down_revision = "076_project_git_provider"
|
||||
branch_labels: dict[str, str] | None = None
|
||||
depends_on: dict[str, str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"github_app_credentials",
|
||||
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True, nullable=False),
|
||||
sa.Column("app_id", sa.String(32), nullable=True),
|
||||
sa.Column("private_key_encrypted", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"projects",
|
||||
sa.Column("github_installation_id", sa.BigInteger(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("projects", "github_installation_id")
|
||||
op.drop_table("github_app_credentials")
|
||||
@@ -37,6 +37,10 @@ vi.mock("@/components/settings/feature-flags-card", () => ({
|
||||
FeatureFlagsCard: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/settings/github-app-credentials-card", () => ({
|
||||
GitHubAppCredentialsCard: () => null,
|
||||
}));
|
||||
|
||||
import SettingsPage from "../page";
|
||||
|
||||
// The Label and Switch/Select are siblings inside a flex row, so the label
|
||||
|
||||
@@ -25,6 +25,7 @@ import { Settings, Palette, Bell, Server } from "lucide-react";
|
||||
import { API_URL, WS_URL } from "@/lib/constants";
|
||||
import { UserInfoCard } from "@/components/settings/user-info-card";
|
||||
import { TranscriptRetentionCard } from "@/components/settings/transcript-retention-card";
|
||||
import { GitHubAppCredentialsCard } from "@/components/settings/github-app-credentials-card";
|
||||
import { FeatureFlagsCard } from "@/components/settings/feature-flags-card";
|
||||
|
||||
export default function SettingsPage() {
|
||||
@@ -54,7 +55,8 @@ export default function SettingsPage() {
|
||||
|
||||
{/* Cards grid — two columns on large screens. Order (row,col):
|
||||
User Info (1,1) · Appearance (1,2) · Data & Refresh (2,1) ·
|
||||
Transcript Retention (2,2) · Notifications (3,1) · Connection Info (3,2). */}
|
||||
Transcript Retention (2,2) · Notifications (3,1) · Connection Info (3,2) ·
|
||||
GitHub App (4,1). */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* User Info */}
|
||||
<UserInfoCard />
|
||||
@@ -248,6 +250,11 @@ export default function SettingsPage() {
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* GitHub App — App id + private key powering the New Project
|
||||
dialog's Select repo picker and per-project installation-token
|
||||
auth (no feature flag; a standalone opt-in credential). */}
|
||||
<GitHubAppCredentialsCard />
|
||||
</div>
|
||||
|
||||
{/* Feature Flags — master switches for optional subsystems (full width;
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import React from "react";
|
||||
|
||||
const {
|
||||
getCredentialsStatus,
|
||||
listInstallations,
|
||||
listInstallationRepositories,
|
||||
} = vi.hoisted(() => ({
|
||||
getCredentialsStatus: vi.fn(async () => ({ has_credentials: true })),
|
||||
listInstallations: vi.fn(async () => [{ id: 1, account_login: "acme" }]),
|
||||
listInstallationRepositories: vi.fn(async () => [
|
||||
{
|
||||
full_name: "acme/widgets",
|
||||
clone_url: "https://github.com/acme/widgets.git",
|
||||
private: true,
|
||||
},
|
||||
{
|
||||
full_name: "acme/gizmos",
|
||||
clone_url: "https://github.com/acme/gizmos.git",
|
||||
private: false,
|
||||
},
|
||||
]),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
githubAppApi: {
|
||||
getCredentialsStatus,
|
||||
listInstallations,
|
||||
listInstallationRepositories,
|
||||
},
|
||||
}));
|
||||
|
||||
// Functional Select mock (mirrors a2a-reply-composer.test.tsx): SelectItem
|
||||
// renders as a clickable button wired to onValueChange via context, so a
|
||||
// real "pick an installation" interaction can be simulated without Radix's
|
||||
// portal/pointer machinery.
|
||||
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>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { SelectRepoDialog } from "../select-repo-picker";
|
||||
|
||||
function withQueryClient(ui: ReactNode) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
// Waits for the credentials-status query to resolve (button starts disabled
|
||||
// until `configured` is known) before clicking — otherwise the click lands on
|
||||
// a still-disabled button and silently no-ops.
|
||||
async function openPicker(onSelect = vi.fn()) {
|
||||
render(withQueryClient(<SelectRepoDialog onSelect={onSelect} />));
|
||||
const button = await screen.findByRole("button", { name: /Select repo/i });
|
||||
await waitFor(() => expect(button).not.toBeDisabled());
|
||||
fireEvent.click(button);
|
||||
return onSelect;
|
||||
}
|
||||
|
||||
describe("SelectRepoDialog", () => {
|
||||
beforeEach(() => {
|
||||
getCredentialsStatus.mockClear();
|
||||
listInstallations.mockClear();
|
||||
listInstallationRepositories.mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("disables the button when the GitHub App isn't configured", async () => {
|
||||
getCredentialsStatus.mockResolvedValueOnce({ has_credentials: false });
|
||||
render(withQueryClient(<SelectRepoDialog onSelect={vi.fn()} />));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Select repo/i }),
|
||||
).toBeDisabled(),
|
||||
);
|
||||
expect(listInstallations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("auto-applies the sole installation and lists its repositories", async () => {
|
||||
await openPicker();
|
||||
expect(await screen.findByText("Select a repository")).toBeInTheDocument();
|
||||
expect(await screen.findByText("acme/widgets")).toBeInTheDocument();
|
||||
expect(screen.getByText("acme/gizmos")).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(listInstallationRepositories).toHaveBeenCalledWith(1),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows an installation picker when there is more than one, and only loads repos after picking", async () => {
|
||||
listInstallations.mockResolvedValueOnce([
|
||||
{ id: 1, account_login: "acme" },
|
||||
{ id: 2, account_login: "widgets-inc" },
|
||||
]);
|
||||
await openPicker();
|
||||
await screen.findByText("Select a repository");
|
||||
|
||||
expect(screen.getByText("acme")).toBeInTheDocument();
|
||||
expect(screen.getByText("widgets-inc")).toBeInTheDocument();
|
||||
expect(listInstallationRepositories).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByText("widgets-inc"));
|
||||
await waitFor(() =>
|
||||
expect(listInstallationRepositories).toHaveBeenCalledWith(2),
|
||||
);
|
||||
});
|
||||
|
||||
it("filters the repository list by search text", async () => {
|
||||
await openPicker();
|
||||
await screen.findByText("acme/widgets");
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Search repositories..."), {
|
||||
target: { value: "gizmo" },
|
||||
});
|
||||
|
||||
expect(screen.queryByText("acme/widgets")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("acme/gizmos")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("picking a repo hands back its clone_url + installation id and closes the dialog", async () => {
|
||||
const onSelect = await openPicker();
|
||||
await screen.findByText("acme/widgets");
|
||||
|
||||
fireEvent.click(screen.getByText("acme/widgets"));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith({
|
||||
git_url: "https://github.com/acme/widgets.git",
|
||||
installation_id: 1,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("Select a repository")).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,10 @@ import { toast } from "sonner";
|
||||
import { Team, type ProjectCreate } from "@/types";
|
||||
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 }[] = [
|
||||
@@ -57,6 +61,12 @@ export function CreateProjectDialog() {
|
||||
// the backend auto-detect from the Git URL host at creation time.
|
||||
const [gitProvider, setGitProvider] = useState("auto");
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
// Set only via the "Select repo" picker below; a manually-typed/edited Git
|
||||
// URL clears it so the project never binds to an installation whose repo
|
||||
// no longer matches what's in the field.
|
||||
const [githubInstallationId, setGithubInstallationId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const createProject = useCreateProject();
|
||||
|
||||
@@ -68,6 +78,11 @@ export function CreateProjectDialog() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleRepoSelected = (repo: SelectedRepo) => {
|
||||
setFormData({ ...formData, git_url: repo.git_url });
|
||||
setGithubInstallationId(repo.installation_id);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -93,6 +108,7 @@ export function CreateProjectDialog() {
|
||||
slug: formData.slug,
|
||||
git_url: formData.git_url,
|
||||
git_provider: gitProvider === "auto" ? null : gitProvider,
|
||||
github_installation_id: githubInstallationId ?? undefined,
|
||||
assigned_cell: formData.assigned_cell,
|
||||
git_token: formData.git_token || undefined,
|
||||
default_branch: formData.default_branch || "main",
|
||||
@@ -116,6 +132,7 @@ export function CreateProjectDialog() {
|
||||
environments: null,
|
||||
});
|
||||
setGitProvider("auto");
|
||||
setGithubInstallationId(null);
|
||||
setShowAdvanced(false);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
@@ -177,19 +194,27 @@ export function CreateProjectDialog() {
|
||||
|
||||
{/* Git URL */}
|
||||
<div className="grid gap-2">
|
||||
<HelpTip label="Cloned into each assigned agent's workspace on first access; use HTTPS so the encrypted token below can authenticate clone, push, and PR operations.">
|
||||
<Label htmlFor="git_url">Git URL *</Label>
|
||||
</HelpTip>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<HelpTip label="Cloned into each assigned agent's workspace on first access; use HTTPS so the encrypted token below can authenticate clone, push, and PR operations.">
|
||||
<Label htmlFor="git_url">Git URL *</Label>
|
||||
</HelpTip>
|
||||
<SelectRepoDialog onSelect={handleRepoSelected} />
|
||||
</div>
|
||||
<Input
|
||||
id="git_url"
|
||||
value={formData.git_url}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, git_url: e.target.value })
|
||||
}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, git_url: e.target.value });
|
||||
// A manual edit invalidates any prior "Select repo" pick —
|
||||
// never bind an installation to a URL the user then changed.
|
||||
setGithubInstallationId(null);
|
||||
}}
|
||||
placeholder="https://github.com/org/repo.git"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use HTTPS URL for token-based authentication
|
||||
{githubInstallationId
|
||||
? "Filled from the GitHub App picker — git operations will use a minted installation token."
|
||||
: "Use HTTPS URL for token-based authentication"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { githubAppApi } from "@/lib/api";
|
||||
import type { GitHubAppInstallationRepository } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { FolderGit2, Lock, Search } from "lucide-react";
|
||||
|
||||
export interface SelectedRepo {
|
||||
git_url: string;
|
||||
installation_id: number;
|
||||
}
|
||||
|
||||
interface SelectRepoDialogProps {
|
||||
onSelect: (repo: SelectedRepo) => void;
|
||||
}
|
||||
|
||||
// "Select repo" button + picker for the New Project dialog: lists the repos
|
||||
// a configured GitHub App installation can access and, on pick, hands back
|
||||
// the clone URL + installation id (the caller fills git_url and stashes the
|
||||
// installation id into the create payload). Disabled — with a HelpTip
|
||||
// explaining why — until the App is configured on the Settings page; manual
|
||||
// Git URL + PAT entry stays the default, unaffected fallback either way.
|
||||
export function SelectRepoDialog({ onSelect }: SelectRepoDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [installationId, setInstallationId] = useState<number | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const { data: credStatus } = useQuery({
|
||||
queryKey: ["github-app", "credentials"],
|
||||
queryFn: () => githubAppApi.getCredentialsStatus(),
|
||||
});
|
||||
const configured = !!credStatus?.has_credentials;
|
||||
|
||||
const { data: installations = [], isLoading: loadingInstallations } =
|
||||
useQuery({
|
||||
queryKey: ["github-app", "installations"],
|
||||
queryFn: () => githubAppApi.listInstallations(),
|
||||
enabled: open && configured,
|
||||
});
|
||||
|
||||
// The sole installation applies automatically; a picker only appears when
|
||||
// there's a real choice. Derived (not synced via effect) so there's no
|
||||
// set-state-in-effect to get wrong.
|
||||
const effectiveInstallationId =
|
||||
installations.length === 1 ? installations[0].id : installationId;
|
||||
|
||||
const { data: repos = [], isLoading: loadingRepos } = useQuery({
|
||||
queryKey: [
|
||||
"github-app",
|
||||
"installations",
|
||||
effectiveInstallationId,
|
||||
"repositories",
|
||||
],
|
||||
queryFn: () =>
|
||||
githubAppApi.listInstallationRepositories(
|
||||
effectiveInstallationId as number,
|
||||
),
|
||||
enabled: open && configured && effectiveInstallationId !== null,
|
||||
});
|
||||
|
||||
const filteredRepos = repos.filter((r) =>
|
||||
r.full_name.toLowerCase().includes(search.trim().toLowerCase()),
|
||||
);
|
||||
|
||||
const reset = () => {
|
||||
setInstallationId(null);
|
||||
setSearch("");
|
||||
};
|
||||
|
||||
const handlePick = (repo: GitHubAppInstallationRepository) => {
|
||||
if (effectiveInstallationId === null) return;
|
||||
onSelect({
|
||||
git_url: repo.clone_url,
|
||||
installation_id: effectiveInstallationId,
|
||||
});
|
||||
setOpen(false);
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) reset();
|
||||
}}
|
||||
>
|
||||
<HelpTip
|
||||
label={
|
||||
configured
|
||||
? "Browse repos the GitHub App installation can access — picking one fills the Git URL and binds this project to that installation for token auth."
|
||||
: "Configure the GitHub App (Settings page) first to browse installation repos here."
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!configured}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<FolderGit2 className="mr-2 h-4 w-4" />
|
||||
Select repo
|
||||
</Button>
|
||||
</span>
|
||||
</HelpTip>
|
||||
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Select a repository</DialogTitle>
|
||||
<DialogDescription>
|
||||
Only repositories the GitHub App installation can access are listed.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{installations.length > 1 && (
|
||||
<div className="grid gap-2">
|
||||
<HelpTip label="The App can be installed on more than one account/org — pick which one to browse.">
|
||||
<span className="text-sm font-medium">Installation</span>
|
||||
</HelpTip>
|
||||
<Select
|
||||
value={installationId !== null ? String(installationId) : ""}
|
||||
onValueChange={(v) => setInstallationId(Number(v))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose an installation" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{installations.map((inst) => (
|
||||
<SelectItem key={inst.id} value={String(inst.id)}>
|
||||
{inst.account_login}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectiveInstallationId !== null && (
|
||||
<div className="grid gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search repositories..."
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<ScrollArea className="h-64 rounded-md border">
|
||||
<div className="p-1">
|
||||
{loadingRepos && (
|
||||
<p className="p-3 text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</p>
|
||||
)}
|
||||
{!loadingRepos && filteredRepos.length === 0 && (
|
||||
<p className="p-3 text-sm text-muted-foreground">
|
||||
No matching repositories
|
||||
</p>
|
||||
)}
|
||||
{filteredRepos.map((repo) => (
|
||||
<button
|
||||
key={repo.full_name}
|
||||
type="button"
|
||||
onClick={() => handlePick(repo)}
|
||||
className="flex w-full items-center justify-between gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent"
|
||||
>
|
||||
<span className="truncate">{repo.full_name}</span>
|
||||
{repo.private && (
|
||||
<HelpTip label="Private repository">
|
||||
<Lock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</HelpTip>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open &&
|
||||
configured &&
|
||||
!loadingInstallations &&
|
||||
installations.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No installations found for the configured App.
|
||||
</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const { getCredentialsStatus, setCredentials, clearCredentials } = vi.hoisted(
|
||||
() => ({
|
||||
getCredentialsStatus: vi.fn(async () => ({ has_credentials: false })),
|
||||
setCredentials: vi.fn(async () => ({ has_credentials: true })),
|
||||
clearCredentials: vi.fn(async () => ({ has_credentials: false })),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
githubAppApi: { getCredentialsStatus, setCredentials, clearCredentials },
|
||||
}));
|
||||
|
||||
import { GitHubAppCredentialsCard } from "../github-app-credentials-card";
|
||||
|
||||
function withQueryClient(ui: ReactNode) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
describe("GitHubAppCredentialsCard", () => {
|
||||
beforeEach(() => {
|
||||
getCredentialsStatus.mockClear();
|
||||
setCredentials.mockClear();
|
||||
clearCredentials.mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows 'no credentials configured' by default and no Clear button", async () => {
|
||||
render(withQueryClient(<GitHubAppCredentialsCard />));
|
||||
expect(
|
||||
await screen.findByText("No credentials configured"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Clear" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables Save until both App id and private key are filled", async () => {
|
||||
render(withQueryClient(<GitHubAppCredentialsCard />));
|
||||
await screen.findByText("No credentials configured");
|
||||
const saveButton = screen.getByRole("button", { name: "Save" });
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("App id"), {
|
||||
target: { value: "123456" },
|
||||
});
|
||||
expect(saveButton).toBeDisabled(); // private key still unfilled
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Private key (PEM)"), {
|
||||
target: { value: "-----BEGIN KEY-----" },
|
||||
});
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("saves both fields and clears the inputs on success", async () => {
|
||||
render(withQueryClient(<GitHubAppCredentialsCard />));
|
||||
await screen.findByText("No credentials configured");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("App id"), {
|
||||
target: { value: "123456" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Private key (PEM)"), {
|
||||
target: { value: "-----BEGIN KEY-----" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(setCredentials).toHaveBeenCalledWith({
|
||||
app_id: "123456",
|
||||
private_key: "-----BEGIN KEY-----",
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect((screen.getByLabelText("App id") as HTMLInputElement).value).toBe(
|
||||
"",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a Clear button once credentials are set, gated behind a confirm dialog", async () => {
|
||||
getCredentialsStatus.mockResolvedValueOnce({ has_credentials: true });
|
||||
render(withQueryClient(<GitHubAppCredentialsCard />));
|
||||
await screen.findByText("Credentials are set");
|
||||
|
||||
const clearButton = screen.getByRole("button", { name: "Clear" });
|
||||
fireEvent.click(clearButton);
|
||||
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(clearCredentials).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getAllByRole("button", { name: "Clear" }).slice(-1)[0],
|
||||
);
|
||||
await waitFor(() => expect(clearCredentials).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("cancelling the clear confirm dialog does NOT call clearCredentials", async () => {
|
||||
getCredentialsStatus.mockResolvedValueOnce({ has_credentials: true });
|
||||
render(withQueryClient(<GitHubAppCredentialsCard />));
|
||||
await screen.findByText("Credentials are set");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear" }));
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
expect(dialog).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(clearCredentials).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { githubAppApi } from "@/lib/api";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { FolderGit2, Key, KeyRound, Save, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// The CEO's one-time entry of a GitHub App's id + RSA private key (PEM,
|
||||
// downloaded once from the App's settings page on github.com). Write-only —
|
||||
// the key is never displayed back, only whether it's set (mirrors the
|
||||
// Telegram/X credentials cards). Once set, projects can bind to one of the
|
||||
// App's installations (New Project dialog's "Select repo" picker) and git
|
||||
// operations mint short-lived installation tokens instead of a stored PAT.
|
||||
export function GitHubAppCredentialsCard() {
|
||||
const queryClient = useQueryClient();
|
||||
const [appId, setAppId] = useState("");
|
||||
const [privateKey, setPrivateKey] = useState("");
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
|
||||
const { data: status, isLoading } = useQuery({
|
||||
queryKey: ["github-app", "credentials"],
|
||||
queryFn: () => githubAppApi.getCredentialsStatus(),
|
||||
});
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["github-app", "credentials"] });
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
githubAppApi.setCredentials({ app_id: appId, private_key: privateKey }),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
setAppId("");
|
||||
setPrivateKey("");
|
||||
toast.success("GitHub App credentials saved");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
`Failed to save: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const clearMutation = useMutation({
|
||||
mutationFn: () => githubAppApi.clearCredentials(),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success("GitHub App credentials cleared");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
`Failed to clear: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const canSave = appId.trim().length > 0 && privateKey.trim().length > 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FolderGit2 className="h-5 w-5" />
|
||||
GitHub App
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
The App id + private key from a GitHub App you created on github.com.
|
||||
Once set, a project can bind to one of the App's installations
|
||||
and git operations use a short-lived installation token instead of a
|
||||
personal access token.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-2 rounded-md border p-3">
|
||||
{status?.has_credentials ? (
|
||||
<HelpTip label="App id + private key are both stored — projects can bind to an installation via the New Project dialog's Select repo button.">
|
||||
<Key className="h-4 w-4 text-green-500" />
|
||||
</HelpTip>
|
||||
) : (
|
||||
<HelpTip label="Missing — the Select repo picker stays disabled and every project falls back to its own PAT until both fields below are set.">
|
||||
<KeyRound className="h-4 w-4 text-amber-500" />
|
||||
</HelpTip>
|
||||
)}
|
||||
{status?.has_credentials ? (
|
||||
<span className="text-sm text-green-600 dark:text-green-400">
|
||||
Credentials are set
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-amber-600 dark:text-amber-400">
|
||||
{isLoading ? "Checking..." : "No credentials configured"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<HelpTip label="The numeric App id shown on the App's github.com settings page (General tab). Not a secret, but stored alongside the key.">
|
||||
<Label htmlFor="github-app-id">
|
||||
{status?.has_credentials ? "Replace App id" : "App id"}
|
||||
</Label>
|
||||
</HelpTip>
|
||||
<Input
|
||||
id="github-app-id"
|
||||
value={appId}
|
||||
onChange={(e) => setAppId(e.target.value)}
|
||||
placeholder="123456"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<HelpTip label="Paste the full .pem contents downloaded once from the App's settings page. Stored encrypted server-side; never displayed again once saved.">
|
||||
<Label htmlFor="github-app-private-key">
|
||||
{status?.has_credentials
|
||||
? "Replace private key"
|
||||
: "Private key (PEM)"}
|
||||
</Label>
|
||||
</HelpTip>
|
||||
<Textarea
|
||||
id="github-app-private-key"
|
||||
value={privateKey}
|
||||
onChange={(e) => setPrivateKey(e.target.value)}
|
||||
placeholder="-----BEGIN RSA PRIVATE KEY----- ... -----END RSA PRIVATE KEY-----"
|
||||
className="min-h-32 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Both fields are required together — set both to save (or rotate).
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending || !canSave}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{saveMutation.isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
{status?.has_credentials && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setConfirmClear(true)}
|
||||
disabled={clearMutation.isPending}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<AlertDialog
|
||||
open={confirmClear}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setConfirmClear(false);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Clear GitHub App credentials?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This clears the stored App id and private key. Projects already
|
||||
bound to an installation fall back to their own PAT (if set) until
|
||||
you configure the App again. This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
setConfirmClear(false);
|
||||
clearMutation.mutate();
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import api from "./client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GitHub App integration — the CEO's App id + private key credentials
|
||||
// (write-only; the API never returns the stored key) plus the "Select repo"
|
||||
// picker's installation/repository listing.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface GitHubAppCredentialsStatus {
|
||||
has_credentials: boolean;
|
||||
}
|
||||
|
||||
export interface GitHubAppInstallation {
|
||||
id: number;
|
||||
account_login: string;
|
||||
}
|
||||
|
||||
export interface GitHubAppInstallationRepository {
|
||||
full_name: string;
|
||||
clone_url: string;
|
||||
private: boolean;
|
||||
}
|
||||
|
||||
export const githubAppApi = {
|
||||
getCredentialsStatus: async (): Promise<GitHubAppCredentialsStatus> => {
|
||||
const { data } = await api.get<GitHubAppCredentialsStatus>(
|
||||
"/github-app/credentials",
|
||||
);
|
||||
return data;
|
||||
},
|
||||
setCredentials: async (creds: {
|
||||
app_id: string;
|
||||
private_key: string;
|
||||
}): Promise<GitHubAppCredentialsStatus> => {
|
||||
const { data } = await api.put<GitHubAppCredentialsStatus>(
|
||||
"/github-app/credentials",
|
||||
creds,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
clearCredentials: async (): Promise<GitHubAppCredentialsStatus> => {
|
||||
const { data } = await api.delete<GitHubAppCredentialsStatus>(
|
||||
"/github-app/credentials",
|
||||
);
|
||||
return data;
|
||||
},
|
||||
listInstallations: async (): Promise<GitHubAppInstallation[]> => {
|
||||
const { data } = await api.get<GitHubAppInstallation[]>(
|
||||
"/github-app/installations",
|
||||
);
|
||||
return data;
|
||||
},
|
||||
listInstallationRepositories: async (
|
||||
installationId: number,
|
||||
): Promise<GitHubAppInstallationRepository[]> => {
|
||||
const { data } = await api.get<GitHubAppInstallationRepository[]>(
|
||||
`/github-app/installations/${installationId}/repositories`,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
@@ -33,6 +33,12 @@ export type {
|
||||
} from "./x";
|
||||
export { telegramApi } from "./telegram";
|
||||
export type { TelegramCredentialsStatus } from "./telegram";
|
||||
export { githubAppApi } from "./github-app";
|
||||
export type {
|
||||
GitHubAppCredentialsStatus,
|
||||
GitHubAppInstallation,
|
||||
GitHubAppInstallationRepository,
|
||||
} from "./github-app";
|
||||
export { roadmapApi } from "./roadmap";
|
||||
export type {
|
||||
RoadmapCycle,
|
||||
|
||||
@@ -36,6 +36,7 @@ const mockProjects: Project[] = [
|
||||
slug: "roboco",
|
||||
git_url: "https://github.com/rennf93/roboco.git",
|
||||
git_provider: "github",
|
||||
github_installation_id: null,
|
||||
default_branch: "master",
|
||||
protected_branches: ["master", "slave"],
|
||||
assigned_cell: Team.BACKEND,
|
||||
@@ -55,6 +56,7 @@ const mockProjects: Project[] = [
|
||||
slug: "roboco-website",
|
||||
git_url: "https://github.com/rennf93/roboco-website.git",
|
||||
git_provider: "github",
|
||||
github_installation_id: null,
|
||||
default_branch: "master",
|
||||
protected_branches: ["master"],
|
||||
assigned_cell: Team.FRONTEND,
|
||||
@@ -72,7 +74,10 @@ const mockProjects: Project[] = [
|
||||
|
||||
// Mock task counts per project (mock-mode only — real data comes from the
|
||||
// backend's grouped query). Keyed by project id.
|
||||
const mockTaskCounts: Record<string, { done: number; active: number; blocked: number }> = {
|
||||
const mockTaskCounts: Record<
|
||||
string,
|
||||
{ done: number; active: number; blocked: number }
|
||||
> = {
|
||||
"proj-mock-1": { done: 120, active: 8, blocked: 1 },
|
||||
"proj-mock-2": { done: 34, active: 2, blocked: 0 },
|
||||
};
|
||||
@@ -150,6 +155,7 @@ export const projectsApi = {
|
||||
git_provider:
|
||||
project.git_provider ??
|
||||
(project.git_url.includes("github.com") ? "github" : null),
|
||||
github_installation_id: project.github_installation_id ?? null,
|
||||
default_branch: project.default_branch ?? "main",
|
||||
environments: project.environments ?? null,
|
||||
protected_branches: project.protected_branches ?? ["main", "master"],
|
||||
|
||||
@@ -1034,6 +1034,8 @@ export interface Project {
|
||||
// Forge provider ("github"|"gitlab"|"gitea"); null = auto-detect from
|
||||
// git_url host (github.com -> github, stamped on create). GitHub-only today.
|
||||
git_provider: string | null;
|
||||
// GitHub App installation covering this repo; null = PAT-only auth.
|
||||
github_installation_id: number | null;
|
||||
default_branch: string;
|
||||
// Ordered environment ladder (first=head/PR-target, last=prod/release-target).
|
||||
// Null/empty => degenerate 1-rung ladder synthesized from default_branch.
|
||||
@@ -1081,6 +1083,8 @@ export interface ProjectCreate {
|
||||
assigned_cell: Team;
|
||||
// Git authentication (stored encrypted, never returned)
|
||||
git_token?: string;
|
||||
// GitHub App installation covering this repo (from the Select repo picker).
|
||||
github_installation_id?: number | null;
|
||||
test_command?: string;
|
||||
lint_command?: string;
|
||||
format_command?: string;
|
||||
@@ -1101,6 +1105,8 @@ export interface ProjectUpdate {
|
||||
assigned_cell?: Team;
|
||||
// Git authentication (empty string clears, undefined leaves unchanged)
|
||||
git_token?: string;
|
||||
// null clears the installation binding; omitted leaves unchanged.
|
||||
github_installation_id?: number | null;
|
||||
is_active?: boolean;
|
||||
test_command?: string;
|
||||
lint_command?: string;
|
||||
|
||||
@@ -24,6 +24,7 @@ from roboco.api.routes.company_goals import router as company_goals_router
|
||||
from roboco.api.routes.dashboard import router as dashboard_router
|
||||
from roboco.api.routes.docs import router as docs_router
|
||||
from roboco.api.routes.git import router as git_router
|
||||
from roboco.api.routes.github_app import router as github_app_router
|
||||
from roboco.api.routes.health import router as health_router
|
||||
from roboco.api.routes.journals import router as journals_router
|
||||
from roboco.api.routes.kanban import router as kanban_router
|
||||
@@ -551,6 +552,14 @@ def create_app() -> FastAPI:
|
||||
tags=["Git Operations"],
|
||||
)
|
||||
|
||||
# GitHub App integration — CEO-managed credentials + the "Select repo"
|
||||
# picker's installation/repository listing (see roboco/services/github_app_auth.py).
|
||||
app.include_router(
|
||||
github_app_router,
|
||||
prefix=f"{api_prefix}/github-app",
|
||||
tags=["GitHub App"],
|
||||
)
|
||||
|
||||
# Project Management
|
||||
app.include_router(
|
||||
project_router,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""GitHub App integration API — CEO-managed credentials (write-only) plus the
|
||||
"Select repo" picker's installation/repository listing.
|
||||
|
||||
Mirrors ``roboco.api.routes.telegram``'s credentials surface (CEO-only,
|
||||
write-only — the API never returns the private key, only ``has_credentials``).
|
||||
The two listing routes back the New Project dialog's "Select repo" button:
|
||||
list the App's installations, then list one installation's repositories.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
|
||||
from roboco.api.schemas.github_app import (
|
||||
GitHubAppCredentialsSetRequest,
|
||||
GitHubAppCredentialsStatus,
|
||||
InstallationRepositoryResponse,
|
||||
InstallationResponse,
|
||||
)
|
||||
from roboco.security import guard_deco
|
||||
from roboco.services.github_app_auth import (
|
||||
GitHubAppAPIError,
|
||||
GitHubAppNotConfiguredError,
|
||||
list_installation_repositories,
|
||||
list_installations,
|
||||
)
|
||||
from roboco.services.github_app_credentials import (
|
||||
GitHubAppCredentialsValidationError,
|
||||
get_github_app_credentials_service,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _require_ceo(agent: CurrentAgentContext) -> None:
|
||||
require_ceo_role(agent.role, action="manage the GitHub App integration")
|
||||
|
||||
|
||||
@router.get("/credentials", response_model=GitHubAppCredentialsStatus)
|
||||
async def get_github_app_credentials(
|
||||
db: DbSession, agent: CurrentAgentContext
|
||||
) -> GitHubAppCredentialsStatus:
|
||||
"""Whether the App id + private key are stored. Never the key."""
|
||||
_require_ceo(agent)
|
||||
has_creds = await get_github_app_credentials_service(db).has_credentials()
|
||||
return GitHubAppCredentialsStatus(has_credentials=has_creds)
|
||||
|
||||
|
||||
@router.put("/credentials", response_model=GitHubAppCredentialsStatus)
|
||||
@guard_deco.rate_limit(requests=10, window=60)
|
||||
@guard_deco.max_request_size(size_bytes=16384)
|
||||
@guard_deco.block_clouds()
|
||||
@guard_deco.content_type_filter(["application/json"])
|
||||
@guard_deco.honeypot_detection(["email", "phone", "website"])
|
||||
@guard_deco.usage_monitor(max_calls=30, window=3600)
|
||||
async def set_github_app_credentials(
|
||||
data: GitHubAppCredentialsSetRequest, db: DbSession, agent: CurrentAgentContext
|
||||
) -> GitHubAppCredentialsStatus:
|
||||
"""Set the App id + private key together (PEM paste)."""
|
||||
_require_ceo(agent)
|
||||
svc = get_github_app_credentials_service(db)
|
||||
try:
|
||||
has_creds = await svc.set_credentials(
|
||||
app_id=data.app_id, private_key=data.private_key
|
||||
)
|
||||
except GitHubAppCredentialsValidationError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)
|
||||
) from e
|
||||
await db.commit()
|
||||
return GitHubAppCredentialsStatus(has_credentials=has_creds)
|
||||
|
||||
|
||||
@router.delete("/credentials", response_model=GitHubAppCredentialsStatus)
|
||||
async def clear_github_app_credentials(
|
||||
db: DbSession, agent: CurrentAgentContext
|
||||
) -> GitHubAppCredentialsStatus:
|
||||
"""Clear the App id + private key."""
|
||||
_require_ceo(agent)
|
||||
has_creds = await get_github_app_credentials_service(db).set_credentials(
|
||||
app_id="", private_key=""
|
||||
)
|
||||
await db.commit()
|
||||
return GitHubAppCredentialsStatus(has_credentials=has_creds)
|
||||
|
||||
|
||||
@router.get("/installations", response_model=list[InstallationResponse])
|
||||
@guard_deco.rate_limit(requests=30, window=60)
|
||||
async def get_installations(
|
||||
db: DbSession, agent: CurrentAgentContext
|
||||
) -> list[InstallationResponse]:
|
||||
"""List every installation of the configured App."""
|
||||
_require_ceo(agent)
|
||||
try:
|
||||
installations = await list_installations(db)
|
||||
except GitHubAppNotConfiguredError as e:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) from e
|
||||
except GitHubAppAPIError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)
|
||||
) from e
|
||||
return [
|
||||
InstallationResponse(id=i.id, account_login=i.account_login)
|
||||
for i in installations
|
||||
]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/installations/{installation_id}/repositories",
|
||||
response_model=list[InstallationRepositoryResponse],
|
||||
)
|
||||
@guard_deco.rate_limit(requests=30, window=60)
|
||||
async def get_installation_repositories(
|
||||
installation_id: int, db: DbSession, agent: CurrentAgentContext
|
||||
) -> list[InstallationRepositoryResponse]:
|
||||
"""List every repository the given installation can access."""
|
||||
_require_ceo(agent)
|
||||
try:
|
||||
repos = await list_installation_repositories(db, installation_id)
|
||||
except GitHubAppNotConfiguredError as e:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) from e
|
||||
except GitHubAppAPIError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)
|
||||
) from e
|
||||
return [
|
||||
InstallationRepositoryResponse(
|
||||
full_name=r.full_name, clone_url=r.clone_url, private=r.private
|
||||
)
|
||||
for r in repos
|
||||
]
|
||||
@@ -158,6 +158,7 @@ async def create_project(
|
||||
slug=data.slug,
|
||||
git_url=data.git_url,
|
||||
git_provider=data.git_provider,
|
||||
github_installation_id=data.github_installation_id,
|
||||
default_branch=data.default_branch,
|
||||
protected_branches=protected_branches,
|
||||
environments=data.environments,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Schemas for the GitHub App integration's CEO-managed surface."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class GitHubAppCredentialsStatus(BaseModel):
|
||||
"""Whether the App id + private key are stored. Never the key itself."""
|
||||
|
||||
has_credentials: bool
|
||||
|
||||
|
||||
class GitHubAppCredentialsSetRequest(BaseModel):
|
||||
"""Set (or, if both are empty, clear) the App id + private key together."""
|
||||
|
||||
app_id: str = Field(default="")
|
||||
private_key: str = Field(default="")
|
||||
|
||||
|
||||
class InstallationResponse(BaseModel):
|
||||
"""One App installation — enough for the panel's installation picker."""
|
||||
|
||||
id: int
|
||||
account_login: str
|
||||
|
||||
|
||||
class InstallationRepositoryResponse(BaseModel):
|
||||
"""One repository visible to an installation — the "Select repo" list."""
|
||||
|
||||
full_name: str
|
||||
clone_url: str
|
||||
private: bool
|
||||
@@ -32,6 +32,8 @@ class ProjectResponse(BaseModel):
|
||||
# Forge provider ("github"|"gitlab"|"gitea"); null = auto-detect from
|
||||
# git_url host (github.com -> github, stamped on create).
|
||||
git_provider: str | None = None
|
||||
# GitHub App installation covering this repo; null = PAT-only auth.
|
||||
github_installation_id: int | None = None
|
||||
default_branch: str
|
||||
protected_branches: list[str]
|
||||
environments: list[dict[str, str]] | None = None
|
||||
@@ -144,6 +146,14 @@ class ProjectCreateRequest(BaseModel):
|
||||
default=None,
|
||||
description="GitHub PAT for clone/push/PR (stored encrypted, never returned)",
|
||||
)
|
||||
github_installation_id: int | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"GitHub App installation id covering this repo (from the Select "
|
||||
"repo picker). When set with App credentials configured, git "
|
||||
"operations use a minted installation token instead of a PAT."
|
||||
),
|
||||
)
|
||||
|
||||
# Optional commands
|
||||
test_command: str | None = None
|
||||
@@ -176,6 +186,13 @@ class ProjectUpdateRequest(BaseModel):
|
||||
default=None,
|
||||
description="GitHub PAT (empty string clears, None leaves unchanged)",
|
||||
)
|
||||
github_installation_id: int | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"GitHub App installation id covering this repo. Explicit null "
|
||||
"clears the binding (falls back to PAT-only auth)."
|
||||
),
|
||||
)
|
||||
|
||||
# Commands
|
||||
test_command: str | None = None
|
||||
@@ -265,6 +282,7 @@ def project_to_response(project: "ProjectTable") -> ProjectResponse:
|
||||
slug=str(project.slug),
|
||||
git_url=str(project.git_url),
|
||||
git_provider=project.git_provider,
|
||||
github_installation_id=project.github_installation_id,
|
||||
default_branch=str(default_branch) if default_branch else "master",
|
||||
protected_branches=list(project.protected_branches or []),
|
||||
environments=list(project.environments) if project.environments else None,
|
||||
|
||||
@@ -514,6 +514,13 @@ class ProjectTable(Base):
|
||||
# this explicitly. Validated at the service layer (foundation/policy/
|
||||
# forge.py), not a DB enum — see migration 075.
|
||||
git_provider: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
# GitHub App installation covering this repo. Set → git operations mint a
|
||||
# short-lived installation token (roboco/services/github_app_auth.py)
|
||||
# instead of using git_token_encrypted; null → unchanged PAT flow. See
|
||||
# migration 077.
|
||||
github_installation_id: Mapped[int | None] = mapped_column(
|
||||
BigInteger, nullable=True
|
||||
)
|
||||
|
||||
# CI/CD Commands (optional)
|
||||
test_command: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
@@ -2392,6 +2399,29 @@ class TelegramCredentialsTable(Base):
|
||||
)
|
||||
|
||||
|
||||
class GitHubAppCredentialsTable(Base):
|
||||
"""Singleton row holding the GitHub App id + its Fernet-encrypted RSA
|
||||
private key (mirrors ``TelegramCredentialsTable``). At most one row ever
|
||||
exists; ``GitHubAppCredentialsService`` upserts it. ``app_id`` is a public
|
||||
identifier (not a secret, so stored plain); the private key is decrypted
|
||||
only server-side, by ``github_app_auth`` when minting installation
|
||||
tokens — the API never returns it."""
|
||||
|
||||
__tablename__ = "github_app_credentials"
|
||||
|
||||
id: Mapped[UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid4
|
||||
)
|
||||
app_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
private_key_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
|
||||
)
|
||||
|
||||
|
||||
class XSeenMentionTable(Base):
|
||||
"""Dedup ledger for the mentions poll — one row per mention id the engine
|
||||
has ever turned into a held reply proposal (or decided to skip). Never
|
||||
|
||||
@@ -126,6 +126,17 @@ class Project(TimestampMixin):
|
||||
"from git_url host; RoboCo is GitHub-only today."
|
||||
),
|
||||
)
|
||||
# GitHub App installation covering this repo. Set -> git operations mint a
|
||||
# short-lived installation token instead of the stored PAT (falling back
|
||||
# to the PAT on any minting failure). Null = unchanged PAT-only behavior.
|
||||
github_installation_id: int | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"GitHub App installation id covering this repo. When set (and "
|
||||
"App credentials are configured), git operations use a minted "
|
||||
"installation token instead of the stored PAT."
|
||||
),
|
||||
)
|
||||
default_branch: str = Field(default="master", description="Default branch name")
|
||||
protected_branches: list[str] = Field(
|
||||
default_factory=lambda: ["main", "master"],
|
||||
@@ -286,6 +297,10 @@ class ProjectCreate(RobocoBase):
|
||||
default=None,
|
||||
description="GitHub PAT for clone/push/PR operations (stored encrypted)",
|
||||
)
|
||||
github_installation_id: int | None = Field(
|
||||
default=None,
|
||||
description="GitHub App installation id covering this repo (see Project).",
|
||||
)
|
||||
|
||||
# Optional commands
|
||||
test_command: str | None = None
|
||||
@@ -334,6 +349,10 @@ class ProjectUpdate(RobocoBase):
|
||||
dep_update_paths: list[str] | None = None
|
||||
sandbox_services: list[str] | None = None
|
||||
sandbox_extensions: dict[str, list[str]] | None = None
|
||||
github_installation_id: int | None = Field(
|
||||
default=None,
|
||||
description="GitHub App installation id covering this repo (see Project).",
|
||||
)
|
||||
|
||||
@field_validator("sandbox_services")
|
||||
@classmethod
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""GitHub App installation-token minting — the drop-in replacement for a PAT.
|
||||
|
||||
``GitService``'s plumbing already builds ``Authorization: Basic
|
||||
base64("x-access-token:<token>")`` per call, exactly GitHub's installation-
|
||||
token convention, so a minted token needs no new transport — only a source.
|
||||
Three operations: mint (and cache) an installation token, list the App's
|
||||
installations, and list the repos one installation can access — all signed
|
||||
with an RS256 App JWT built from the singleton ``github_app_credentials`` row.
|
||||
|
||||
The in-memory token cache is process-local (mirrors the sandbox-info cache's
|
||||
known ceiling): an orchestrator restart forgets live tokens and the next call
|
||||
re-mints. That's fine — minting is cheap and has no side effects on GitHub.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
|
||||
from roboco.config import settings
|
||||
from roboco.services.github_app_credentials import get_github_app_credentials_service
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
# GitHub caps an App JWT at 10 minutes; stay comfortably under it. Backdate
|
||||
# `iat` to tolerate clock skew between this host and GitHub's, same margin
|
||||
# GitHub's own docs recommend.
|
||||
_JWT_TTL_SECONDS = 9 * 60
|
||||
_JWT_CLOCK_SKEW_SECONDS = 60
|
||||
# Re-mint an installation token this long before its real expiry so a call
|
||||
# never races a token that expires mid-request.
|
||||
_TOKEN_REFRESH_MARGIN_SECONDS = 5 * 60
|
||||
_ACCEPT = "application/vnd.github+json"
|
||||
_API_VERSION = "2022-11-28"
|
||||
_PER_PAGE = 100
|
||||
# Safety cap on the repositories pagination loop — 5000 repos is far beyond
|
||||
# any real installation; guards against an infinite loop on a malformed reply.
|
||||
_MAX_REPO_PAGES = 50
|
||||
_HTTP_ERROR_STATUS = 400
|
||||
|
||||
|
||||
class GitHubAppError(Exception):
|
||||
"""Base error for GitHub App operations."""
|
||||
|
||||
|
||||
class GitHubAppNotConfiguredError(GitHubAppError):
|
||||
"""No App credentials are stored. Callers map this to a 409/412 response."""
|
||||
|
||||
|
||||
class GitHubAppAPIError(GitHubAppError):
|
||||
"""A GitHub API call returned a non-2xx response."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Installation:
|
||||
"""One App installation (an org or user account that installed the App)."""
|
||||
|
||||
id: int
|
||||
account_login: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InstallationRepo:
|
||||
"""One repository visible to an installation."""
|
||||
|
||||
full_name: str
|
||||
clone_url: str
|
||||
private: bool
|
||||
|
||||
|
||||
# installation_id -> (token, expires_at epoch seconds). Process-local; see
|
||||
# the module docstring's known ceiling.
|
||||
_token_cache: dict[int, tuple[str, float]] = {}
|
||||
|
||||
|
||||
def _api_base() -> str:
|
||||
return settings.github_api_base_url.rstrip("/")
|
||||
|
||||
|
||||
def _headers(token: str) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": _ACCEPT,
|
||||
"X-GitHub-Api-Version": _API_VERSION,
|
||||
}
|
||||
|
||||
|
||||
def _timeout() -> float:
|
||||
return settings.provisioning_timeout_seconds
|
||||
|
||||
|
||||
def _parse_expiry(expires_at: str | None) -> float:
|
||||
"""GitHub returns an ISO-8601 ``expires_at``; fall back to a conservative
|
||||
one-hour horizon if the response is ever missing it (shouldn't happen)."""
|
||||
if not expires_at:
|
||||
return time.time() + 3600
|
||||
return datetime.fromisoformat(expires_at.replace("Z", "+00:00")).timestamp()
|
||||
|
||||
|
||||
async def _app_jwt(session: AsyncSession) -> str:
|
||||
"""Mint a fresh RS256 App JWT from the stored credentials."""
|
||||
creds = await get_github_app_credentials_service(session).get_decrypted()
|
||||
if creds is None:
|
||||
raise GitHubAppNotConfiguredError("GitHub App credentials are not configured")
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"iat": now - _JWT_CLOCK_SKEW_SECONDS,
|
||||
"exp": now + _JWT_TTL_SECONDS,
|
||||
"iss": creds.app_id,
|
||||
}
|
||||
return jwt.encode(payload, creds.private_key, algorithm="RS256")
|
||||
|
||||
|
||||
def _raise_for_status(resp: httpx.Response, action: str) -> None:
|
||||
if resp.status_code >= _HTTP_ERROR_STATUS:
|
||||
raise GitHubAppAPIError(
|
||||
f"{action} failed ({resp.status_code}): {resp.text[:200]}"
|
||||
)
|
||||
|
||||
|
||||
async def mint_installation_token(session: AsyncSession, installation_id: int) -> str:
|
||||
"""Return a live installation token, minting (and caching) as needed."""
|
||||
cached = _token_cache.get(installation_id)
|
||||
if cached is not None:
|
||||
token, expires_at = cached
|
||||
if expires_at - _TOKEN_REFRESH_MARGIN_SECONDS > time.time():
|
||||
return token
|
||||
|
||||
app_jwt = await _app_jwt(session)
|
||||
url = f"{_api_base()}/app/installations/{installation_id}/access_tokens"
|
||||
async with httpx.AsyncClient(timeout=_timeout()) as client:
|
||||
resp = await client.post(url, headers=_headers(app_jwt))
|
||||
_raise_for_status(resp, "Minting installation token")
|
||||
|
||||
data = resp.json()
|
||||
token = cast("str", data["token"])
|
||||
_token_cache[installation_id] = (token, _parse_expiry(data.get("expires_at")))
|
||||
return token
|
||||
|
||||
|
||||
async def list_installations(session: AsyncSession) -> list[Installation]:
|
||||
"""List every installation of the configured App (JWT-authenticated)."""
|
||||
app_jwt = await _app_jwt(session)
|
||||
url = f"{_api_base()}/app/installations"
|
||||
async with httpx.AsyncClient(timeout=_timeout()) as client:
|
||||
resp = await client.get(
|
||||
url, headers=_headers(app_jwt), params={"per_page": _PER_PAGE}
|
||||
)
|
||||
_raise_for_status(resp, "Listing installations")
|
||||
|
||||
return [
|
||||
Installation(
|
||||
id=item["id"], account_login=(item.get("account") or {}).get("login", "")
|
||||
)
|
||||
for item in resp.json()
|
||||
]
|
||||
|
||||
|
||||
async def list_installation_repositories(
|
||||
session: AsyncSession, installation_id: int
|
||||
) -> list[InstallationRepo]:
|
||||
"""List every repository one installation can access (paginated)."""
|
||||
token = await mint_installation_token(session, installation_id)
|
||||
url = f"{_api_base()}/installation/repositories"
|
||||
repos: list[InstallationRepo] = []
|
||||
async with httpx.AsyncClient(timeout=_timeout()) as client:
|
||||
for page in range(1, _MAX_REPO_PAGES + 1):
|
||||
resp = await client.get(
|
||||
url,
|
||||
headers=_headers(token),
|
||||
params={"per_page": _PER_PAGE, "page": page},
|
||||
)
|
||||
_raise_for_status(resp, "Listing installation repositories")
|
||||
|
||||
items = resp.json().get("repositories", [])
|
||||
repos.extend(
|
||||
InstallationRepo(
|
||||
full_name=item["full_name"],
|
||||
clone_url=item["clone_url"],
|
||||
private=bool(item.get("private", False)),
|
||||
)
|
||||
for item in items
|
||||
)
|
||||
if len(items) < _PER_PAGE:
|
||||
break
|
||||
return repos
|
||||
@@ -0,0 +1,112 @@
|
||||
"""GitHub App credentials — a singleton row (mirrors ``telegram_credentials.py``).
|
||||
|
||||
Unlike the Telegram/X pattern, only the private key is a secret worth
|
||||
Fernet-encrypting; ``app_id`` is a public identifier (visible on the App's own
|
||||
GitHub settings page, comparable to an OAuth client id) so it is stored plain.
|
||||
Both fields are still treated all-or-nothing: an App id without its key can't
|
||||
sign a JWT, and vice versa. Decryption is server-side only — ``get_decrypted``
|
||||
is read by ``github_app_auth`` when minting installation tokens; the API never
|
||||
returns the key.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from roboco.db.tables import GitHubAppCredentialsTable
|
||||
from roboco.services.base import BaseService
|
||||
from roboco.utils.crypto import EncryptionError, decrypt_token, encrypt_token
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
class GitHubAppCredentialsValidationError(ValueError):
|
||||
"""Raised when a partial (not all-or-nothing) credential set is given."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GitHubAppCredentialsData:
|
||||
"""The App id + decrypted private key, server-side only."""
|
||||
|
||||
app_id: str
|
||||
private_key: str
|
||||
|
||||
|
||||
class GitHubAppCredentialsService(BaseService):
|
||||
"""CRUD for the single ``github_app_credentials`` row."""
|
||||
|
||||
service_name: ClassVar[str] = "github_app_credentials"
|
||||
|
||||
async def _get_row(self) -> GitHubAppCredentialsTable | None:
|
||||
result = await self.session.execute(select(GitHubAppCredentialsTable).limit(1))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def has_credentials(self) -> bool:
|
||||
"""True iff both the App id and private key are stored."""
|
||||
row = await self._get_row()
|
||||
if row is None:
|
||||
return False
|
||||
return bool(row.app_id and row.private_key_encrypted)
|
||||
|
||||
async def set_credentials(self, *, app_id: str, private_key: str) -> bool:
|
||||
"""Set or clear the App id + private key together.
|
||||
|
||||
Both empty -> clears the row. Both non-empty -> stores (encrypting the
|
||||
key). A mixed set (one empty, one not) raises
|
||||
:class:`GitHubAppCredentialsValidationError` — a partial set can't sign.
|
||||
Returns the resulting ``has_credentials``.
|
||||
"""
|
||||
values = (app_id, private_key)
|
||||
non_empty = sum(1 for v in values if v)
|
||||
if non_empty not in (0, len(values)):
|
||||
raise GitHubAppCredentialsValidationError(
|
||||
"app_id and private_key must be set or cleared together"
|
||||
)
|
||||
|
||||
row = await self._get_row()
|
||||
if non_empty == 0:
|
||||
if row is not None:
|
||||
await self.session.delete(row)
|
||||
await self.session.flush()
|
||||
self.log.info("GitHub App credentials cleared")
|
||||
return False
|
||||
|
||||
try:
|
||||
encrypted_key = encrypt_token(private_key)
|
||||
except EncryptionError as e:
|
||||
self.log.error("Failed to encrypt GitHub App private key", error=str(e))
|
||||
raise
|
||||
|
||||
if row is None:
|
||||
row = GitHubAppCredentialsTable()
|
||||
self.session.add(row)
|
||||
row.app_id = app_id
|
||||
row.private_key_encrypted = encrypted_key
|
||||
await self.session.flush()
|
||||
self.log.info("GitHub App credentials set")
|
||||
return True
|
||||
|
||||
async def get_decrypted(self) -> GitHubAppCredentialsData | None:
|
||||
"""The App id + decrypted private key, or None when unset."""
|
||||
row = await self._get_row()
|
||||
if row is None or not (row.app_id and row.private_key_encrypted):
|
||||
return None
|
||||
try:
|
||||
return GitHubAppCredentialsData(
|
||||
app_id=row.app_id,
|
||||
private_key=decrypt_token(row.private_key_encrypted),
|
||||
)
|
||||
except EncryptionError as e:
|
||||
self.log.error("Failed to decrypt GitHub App private key", error=str(e))
|
||||
raise
|
||||
|
||||
|
||||
def get_github_app_credentials_service(
|
||||
session: AsyncSession,
|
||||
) -> GitHubAppCredentialsService:
|
||||
"""Construct a GitHubAppCredentialsService bound to ``session``."""
|
||||
return GitHubAppCredentialsService(session)
|
||||
+60
-29
@@ -20,6 +20,8 @@ from roboco.models.base import TaskStatus, Team
|
||||
from roboco.models.project import ProjectCreate, ProjectUpdate
|
||||
from roboco.services.base import BaseService, ConflictError, NotFoundError
|
||||
from roboco.services.forge import register_project_forge
|
||||
from roboco.services.github_app_auth import GitHubAppError, mint_installation_token
|
||||
from roboco.services.github_app_credentials import get_github_app_credentials_service
|
||||
from roboco.utils.crypto import EncryptionError, decrypt_token, encrypt_token
|
||||
|
||||
# Statuses that are NOT active progress: completed (done), cancelled
|
||||
@@ -126,6 +128,7 @@ class ProjectService(BaseService):
|
||||
slug=data.slug,
|
||||
git_url=data.git_url,
|
||||
git_provider=git_provider,
|
||||
github_installation_id=data.github_installation_id,
|
||||
default_branch=data.default_branch,
|
||||
protected_branches=data.protected_branches,
|
||||
environments=data.environments,
|
||||
@@ -545,64 +548,92 @@ class ProjectService(BaseService):
|
||||
# GIT TOKEN MANAGEMENT
|
||||
# =========================================================================
|
||||
|
||||
async def _resolve_token(
|
||||
self, project: ProjectTable, *, log_ref: str
|
||||
) -> str | None:
|
||||
"""Resolve the token git operations should use for ``project``.
|
||||
|
||||
An installation-bound project (``github_installation_id`` set) with
|
||||
App credentials stored mints a short-lived installation token instead
|
||||
of using the stored PAT. Any minting failure (App not configured,
|
||||
installation revoked, network hiccup) is logged as a warning and falls
|
||||
back to the PAT path below — a GitHub App hiccup must never brick git
|
||||
operations for a project that also has a PAT on file.
|
||||
"""
|
||||
if project.github_installation_id is not None:
|
||||
has_app_creds = await get_github_app_credentials_service(
|
||||
self.session
|
||||
).has_credentials()
|
||||
if has_app_creds:
|
||||
try:
|
||||
return await mint_installation_token(
|
||||
self.session, project.github_installation_id
|
||||
)
|
||||
except GitHubAppError as e:
|
||||
self.log.warning(
|
||||
"GitHub App token mint failed; falling back to PAT",
|
||||
error=str(e),
|
||||
project=log_ref,
|
||||
)
|
||||
|
||||
if not project.git_token_encrypted:
|
||||
return None
|
||||
try:
|
||||
return decrypt_token(project.git_token_encrypted)
|
||||
except EncryptionError:
|
||||
self.log.error(
|
||||
"Failed to decrypt git token",
|
||||
project=log_ref,
|
||||
error="encryption_key_mismatch_or_corrupted",
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_decrypted_token(self, project_id: UUID) -> str | None:
|
||||
"""
|
||||
Get the decrypted git token for a project.
|
||||
|
||||
Used by WorkspaceService and GitService for git operations.
|
||||
The token is decrypted on-demand and should not be cached.
|
||||
Used by WorkspaceService and GitService for git operations. When the
|
||||
project is bound to a GitHub App installation, this instead returns a
|
||||
minted installation token (see ``_resolve_token``); the returned
|
||||
string is otherwise decrypted on-demand and should not be cached.
|
||||
|
||||
Args:
|
||||
project_id: Project to get token for
|
||||
|
||||
Returns:
|
||||
Decrypted token or None if no token is set
|
||||
Decrypted token, minted installation token, or None if neither
|
||||
is available
|
||||
|
||||
Raises:
|
||||
EncryptionError: If decryption fails (key mismatch, corrupted data)
|
||||
EncryptionError: If PAT decryption fails (key mismatch, corrupted
|
||||
data) — never raised for a GitHub App minting failure, which
|
||||
falls back to the PAT instead.
|
||||
"""
|
||||
project = await self.get(project_id)
|
||||
if not project or not project.git_token_encrypted:
|
||||
if not project:
|
||||
return None
|
||||
|
||||
try:
|
||||
return decrypt_token(project.git_token_encrypted)
|
||||
except EncryptionError:
|
||||
self.log.error(
|
||||
"Failed to decrypt git token",
|
||||
project_id=str(project_id),
|
||||
error="encryption_key_mismatch_or_corrupted",
|
||||
)
|
||||
raise
|
||||
return await self._resolve_token(project, log_ref=str(project_id))
|
||||
|
||||
async def get_decrypted_token_by_slug(self, slug: str) -> str | None:
|
||||
"""
|
||||
Get the decrypted git token for a project by slug.
|
||||
|
||||
Convenience method for services that work with project slugs.
|
||||
Convenience method for services that work with project slugs. Same
|
||||
GitHub App / PAT resolution as ``get_decrypted_token``.
|
||||
|
||||
Args:
|
||||
slug: Project slug
|
||||
|
||||
Returns:
|
||||
Decrypted token or None if no token is set
|
||||
Decrypted token, minted installation token, or None
|
||||
|
||||
Raises:
|
||||
EncryptionError: If decryption fails
|
||||
EncryptionError: If PAT decryption fails
|
||||
"""
|
||||
project = await self.get_by_slug(slug)
|
||||
if not project or not project.git_token_encrypted:
|
||||
if not project:
|
||||
return None
|
||||
|
||||
try:
|
||||
return decrypt_token(project.git_token_encrypted)
|
||||
except EncryptionError:
|
||||
self.log.error(
|
||||
"Failed to decrypt git token",
|
||||
project_slug=slug,
|
||||
error="encryption_key_mismatch_or_corrupted",
|
||||
)
|
||||
raise
|
||||
return await self._resolve_token(project, log_ref=slug)
|
||||
|
||||
# =========================================================================
|
||||
# ACCESS CONTROL
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""GitHubAppCredentialsService coverage — plain app_id, encrypted private key,
|
||||
all-or-nothing set/clear (mirrors ``test_telegram_credentials_service.py``).
|
||||
|
||||
Drives a real ``db_session`` via the project's Postgres-backed conftest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from roboco.db.tables import GitHubAppCredentialsTable
|
||||
from roboco.services.github_app_credentials import (
|
||||
GitHubAppCredentialsService,
|
||||
GitHubAppCredentialsValidationError,
|
||||
get_github_app_credentials_service,
|
||||
)
|
||||
from sqlalchemy import select
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
_CREDS = {
|
||||
"app_id": "123456",
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\nfake-pem\n-----END PRIVATE KEY-----",
|
||||
}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def svc(
|
||||
db_session: AsyncSession,
|
||||
) -> AsyncIterator[GitHubAppCredentialsService]:
|
||||
yield get_github_app_credentials_service(db_session)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unset_has_no_credentials(svc: GitHubAppCredentialsService) -> None:
|
||||
assert await svc.has_credentials() is False
|
||||
assert await svc.get_decrypted() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_both_stores_and_roundtrips(
|
||||
svc: GitHubAppCredentialsService,
|
||||
) -> None:
|
||||
has_creds = await svc.set_credentials(**_CREDS)
|
||||
assert has_creds is True
|
||||
assert await svc.has_credentials() is True
|
||||
|
||||
decrypted = await svc.get_decrypted()
|
||||
assert decrypted is not None
|
||||
assert decrypted.app_id == _CREDS["app_id"]
|
||||
assert decrypted.private_key == _CREDS["private_key"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_app_id_stored_plain_key_encrypted(
|
||||
svc: GitHubAppCredentialsService, db_session: AsyncSession
|
||||
) -> None:
|
||||
await svc.set_credentials(**_CREDS)
|
||||
result = await db_session.execute(select(GitHubAppCredentialsTable).limit(1))
|
||||
row = result.scalar_one_or_none()
|
||||
assert row is not None
|
||||
assert row.app_id == _CREDS["app_id"]
|
||||
assert row.private_key_encrypted != _CREDS["private_key"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clearing_both_removes_row(svc: GitHubAppCredentialsService) -> None:
|
||||
await svc.set_credentials(**_CREDS)
|
||||
has_creds = await svc.set_credentials(app_id="", private_key="")
|
||||
assert has_creds is False
|
||||
assert await svc.has_credentials() is False
|
||||
assert await svc.get_decrypted() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_partial_set_is_rejected(svc: GitHubAppCredentialsService) -> None:
|
||||
with pytest.raises(GitHubAppCredentialsValidationError):
|
||||
await svc.set_credentials(app_id="only-one", private_key="")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rotate_overwrites_previous_values(
|
||||
svc: GitHubAppCredentialsService,
|
||||
) -> None:
|
||||
await svc.set_credentials(**_CREDS)
|
||||
rotated = {"app_id": "654321", "private_key": _CREDS["private_key"] + "-rotated"}
|
||||
await svc.set_credentials(**rotated)
|
||||
decrypted = await svc.get_decrypted()
|
||||
assert decrypted is not None
|
||||
assert decrypted.app_id == rotated["app_id"]
|
||||
assert decrypted.private_key == rotated["private_key"]
|
||||
@@ -0,0 +1,173 @@
|
||||
"""GitHub App route coverage — CEO-only credentials + installation/repo listing.
|
||||
|
||||
Mirrors ``test_x_routes.py``'s credentials section; the two listing routes
|
||||
back the New Project dialog's "Select repo" picker and are covered against a
|
||||
mocked ``github_app_auth`` (network calls are covered directly in
|
||||
``test_github_app_auth.py``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from roboco.api.deps import get_agent_context, get_db
|
||||
from roboco.api.routes.github_app import router as github_app_router
|
||||
from roboco.models import AgentRole
|
||||
from roboco.models.permissions import AgentContext
|
||||
from roboco.services.github_app_auth import (
|
||||
GitHubAppAPIError,
|
||||
GitHubAppNotConfiguredError,
|
||||
Installation,
|
||||
InstallationRepo,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
def _build_app(db_session: AsyncSession, role: AgentRole, agent_id: UUID) -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.include_router(github_app_router, prefix="/api/github-app")
|
||||
|
||||
async def _override_db() -> AsyncIterator[AsyncSession]:
|
||||
yield db_session
|
||||
|
||||
async def _override_agent() -> AgentContext:
|
||||
return AgentContext(agent_id=agent_id, role=role, team=None)
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db
|
||||
app.dependency_overrides[get_agent_context] = _override_agent
|
||||
return app
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def ceo_client(db_session: AsyncSession) -> AsyncIterator[AsyncClient]:
|
||||
app = _build_app(db_session, AgentRole.CEO, uuid4())
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
yield client
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_credentials_default_is_unset(ceo_client: AsyncClient) -> None:
|
||||
resp = await ceo_client.get("/api/github-app/credentials")
|
||||
assert resp.status_code == HTTPStatus.OK
|
||||
assert resp.json()["has_credentials"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_credentials_reports_status_never_plaintext(
|
||||
ceo_client: AsyncClient,
|
||||
) -> None:
|
||||
resp = await ceo_client.put(
|
||||
"/api/github-app/credentials",
|
||||
json={
|
||||
"app_id": "123456",
|
||||
"private_key": "-----BEGIN KEY-----\nsecretpem\n-----END KEY-----",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK
|
||||
assert resp.json() == {"has_credentials": True}
|
||||
assert "secretpem" not in resp.text
|
||||
|
||||
status_resp = await ceo_client.get("/api/github-app/credentials")
|
||||
assert status_resp.json()["has_credentials"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_partial_credentials_is_400(ceo_client: AsyncClient) -> None:
|
||||
resp = await ceo_client.put(
|
||||
"/api/github-app/credentials",
|
||||
json={"app_id": "123456", "private_key": ""},
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_credentials(ceo_client: AsyncClient) -> None:
|
||||
await ceo_client.put(
|
||||
"/api/github-app/credentials",
|
||||
json={"app_id": "123456", "private_key": "pem-body"},
|
||||
)
|
||||
resp = await ceo_client.delete("/api/github-app/credentials")
|
||||
assert resp.status_code == HTTPStatus.OK
|
||||
assert resp.json() == {"has_credentials": False}
|
||||
|
||||
status_resp = await ceo_client.get("/api/github-app/credentials")
|
||||
assert status_resp.json()["has_credentials"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_installations_not_configured_is_409(ceo_client: AsyncClient) -> None:
|
||||
with patch(
|
||||
"roboco.api.routes.github_app.list_installations",
|
||||
AsyncMock(side_effect=GitHubAppNotConfiguredError("nope")),
|
||||
):
|
||||
resp = await ceo_client.get("/api/github-app/installations")
|
||||
assert resp.status_code == HTTPStatus.CONFLICT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_installations_upstream_error_is_502(ceo_client: AsyncClient) -> None:
|
||||
with patch(
|
||||
"roboco.api.routes.github_app.list_installations",
|
||||
AsyncMock(side_effect=GitHubAppAPIError("boom")),
|
||||
):
|
||||
resp = await ceo_client.get("/api/github-app/installations")
|
||||
assert resp.status_code == HTTPStatus.BAD_GATEWAY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_installations_returns_list(ceo_client: AsyncClient) -> None:
|
||||
with patch(
|
||||
"roboco.api.routes.github_app.list_installations",
|
||||
AsyncMock(return_value=[Installation(id=1, account_login="acme")]),
|
||||
):
|
||||
resp = await ceo_client.get("/api/github-app/installations")
|
||||
assert resp.status_code == HTTPStatus.OK
|
||||
assert resp.json() == [{"id": 1, "account_login": "acme"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_installation_repositories_returns_list(ceo_client: AsyncClient) -> None:
|
||||
repos = [
|
||||
InstallationRepo(
|
||||
full_name="acme/widgets",
|
||||
clone_url="https://github.com/acme/widgets.git",
|
||||
private=True,
|
||||
)
|
||||
]
|
||||
with patch(
|
||||
"roboco.api.routes.github_app.list_installation_repositories",
|
||||
AsyncMock(return_value=repos),
|
||||
):
|
||||
resp = await ceo_client.get("/api/github-app/installations/1/repositories")
|
||||
assert resp.status_code == HTTPStatus.OK
|
||||
assert resp.json() == [
|
||||
{
|
||||
"full_name": "acme/widgets",
|
||||
"clone_url": "https://github.com/acme/widgets.git",
|
||||
"private": True,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_ceo_is_forbidden(db_session: AsyncSession) -> None:
|
||||
app = _build_app(db_session, AgentRole.DEVELOPER, uuid4())
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
creds_resp = await client.get("/api/github-app/credentials")
|
||||
installs_resp = await client.get("/api/github-app/installations")
|
||||
assert creds_resp.status_code == HTTPStatus.FORBIDDEN
|
||||
assert installs_resp.status_code == HTTPStatus.FORBIDDEN
|
||||
@@ -0,0 +1,208 @@
|
||||
"""GitHub App installation-token minting — JWT shape, token cache, pagination.
|
||||
|
||||
The App JWT is signed RS256 from the (mocked) stored credentials; every REST
|
||||
call goes through a MockTransport-free ``httpx.AsyncClient`` patch mirroring
|
||||
``test_git_pr_ci_status.py``'s idiom, since the module owns its own client
|
||||
lifecycle (no injection seam, matching the spec's plain-function shape).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from roboco.services import github_app_auth
|
||||
from roboco.services.github_app_auth import (
|
||||
GitHubAppAPIError,
|
||||
GitHubAppNotConfiguredError,
|
||||
list_installation_repositories,
|
||||
list_installations,
|
||||
mint_installation_token,
|
||||
)
|
||||
from roboco.services.github_app_credentials import GitHubAppCredentialsData
|
||||
|
||||
|
||||
def _generate_rsa_keypair() -> str:
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
return private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
).decode()
|
||||
|
||||
|
||||
_PRIVATE_KEY_PEM = _generate_rsa_keypair()
|
||||
_APP_ID = "998877"
|
||||
_SECOND_CALL_COUNT = 2
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_token_cache() -> None:
|
||||
github_app_auth._token_cache.clear()
|
||||
|
||||
|
||||
def _patch_creds(*, configured: bool = True) -> Any:
|
||||
fake_service = MagicMock()
|
||||
creds = (
|
||||
GitHubAppCredentialsData(app_id=_APP_ID, private_key=_PRIVATE_KEY_PEM)
|
||||
if configured
|
||||
else None
|
||||
)
|
||||
fake_service.get_decrypted = AsyncMock(return_value=creds)
|
||||
return patch(
|
||||
"roboco.services.github_app_auth.get_github_app_credentials_service",
|
||||
return_value=fake_service,
|
||||
)
|
||||
|
||||
|
||||
def _client(
|
||||
*, get: list[MagicMock] | None = None, post: list[MagicMock] | None = None
|
||||
) -> MagicMock:
|
||||
client = MagicMock()
|
||||
client.__aenter__ = AsyncMock(return_value=client)
|
||||
client.__aexit__ = AsyncMock(return_value=False)
|
||||
if get is not None:
|
||||
client.get = AsyncMock(side_effect=get)
|
||||
if post is not None:
|
||||
client.post = AsyncMock(side_effect=post)
|
||||
return client
|
||||
|
||||
|
||||
def _resp(status_code: int, json_payload: Any = None, text: str = "") -> MagicMock:
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
resp.json.return_value = json_payload
|
||||
resp.text = text
|
||||
return resp
|
||||
|
||||
|
||||
def _token_resp(token: str, *, expires_in_seconds: int = 3600) -> MagicMock:
|
||||
expires_at = (datetime.now(UTC) + timedelta(seconds=expires_in_seconds)).isoformat()
|
||||
return _resp(201, {"token": token, "expires_at": expires_at})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_credentials_raises_not_configured() -> None:
|
||||
with _patch_creds(configured=False), pytest.raises(GitHubAppNotConfiguredError):
|
||||
await mint_installation_token(MagicMock(), 42)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jwt_claims_shape() -> None:
|
||||
"""The App JWT sent as the POST Authorization header decodes to
|
||||
iss=app_id, an iat backdated ~60s, and an exp within the 9-minute TTL."""
|
||||
client = _client(post=[_token_resp("tok-1")])
|
||||
with (
|
||||
_patch_creds(),
|
||||
patch("roboco.services.github_app_auth.httpx.AsyncClient", return_value=client),
|
||||
):
|
||||
before = int(time.time())
|
||||
await mint_installation_token(MagicMock(), 1)
|
||||
|
||||
_, kwargs = client.post.call_args
|
||||
auth_header = kwargs["headers"]["Authorization"]
|
||||
assert auth_header.startswith("Bearer ")
|
||||
app_jwt = auth_header.removeprefix("Bearer ")
|
||||
|
||||
claims = jwt.decode(app_jwt, options={"verify_signature": False})
|
||||
assert claims["iss"] == _APP_ID
|
||||
assert before - claims["iat"] == pytest.approx(60, abs=2)
|
||||
assert claims["exp"] - claims["iat"] == pytest.approx(9 * 60 + 60, abs=2)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_reuse_skips_a_second_mint() -> None:
|
||||
client = _client(post=[_token_resp("tok-cached", expires_in_seconds=3600)])
|
||||
with (
|
||||
_patch_creds(),
|
||||
patch("roboco.services.github_app_auth.httpx.AsyncClient", return_value=client),
|
||||
):
|
||||
first = await mint_installation_token(MagicMock(), 7)
|
||||
second = await mint_installation_token(MagicMock(), 7)
|
||||
|
||||
assert first == second == "tok-cached"
|
||||
assert client.post.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expiry_re_mints_past_the_refresh_margin() -> None:
|
||||
# Expires in 30s — under the 5-minute refresh margin, so the second call
|
||||
# must not trust the cached entry.
|
||||
client = _client(
|
||||
post=[
|
||||
_token_resp("tok-old", expires_in_seconds=30),
|
||||
_token_resp("tok-fresh", expires_in_seconds=3600),
|
||||
]
|
||||
)
|
||||
with (
|
||||
_patch_creds(),
|
||||
patch("roboco.services.github_app_auth.httpx.AsyncClient", return_value=client),
|
||||
):
|
||||
first = await mint_installation_token(MagicMock(), 9)
|
||||
second = await mint_installation_token(MagicMock(), 9)
|
||||
|
||||
assert first == "tok-old"
|
||||
assert second == "tok-fresh"
|
||||
assert client.post.call_count == _SECOND_CALL_COUNT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mint_failure_raises_api_error() -> None:
|
||||
client = _client(post=[_resp(401, text="Bad credentials")])
|
||||
with (
|
||||
_patch_creds(),
|
||||
patch("roboco.services.github_app_auth.httpx.AsyncClient", return_value=client),
|
||||
pytest.raises(GitHubAppAPIError),
|
||||
):
|
||||
await mint_installation_token(MagicMock(), 5)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_installations_maps_account_login() -> None:
|
||||
payload = [
|
||||
{"id": 1, "account": {"login": "acme"}},
|
||||
{"id": 2, "account": {"login": "widgets"}},
|
||||
]
|
||||
client = _client(get=[_resp(200, payload)])
|
||||
with (
|
||||
_patch_creds(),
|
||||
patch("roboco.services.github_app_auth.httpx.AsyncClient", return_value=client),
|
||||
):
|
||||
installations = await list_installations(MagicMock())
|
||||
|
||||
assert [(i.id, i.account_login) for i in installations] == [
|
||||
(1, "acme"),
|
||||
(2, "widgets"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_installation_repositories_paginates(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(github_app_auth, "_PER_PAGE", 2)
|
||||
|
||||
def _repo(name: str) -> dict[str, Any]:
|
||||
return {
|
||||
"full_name": f"acme/{name}",
|
||||
"clone_url": f"https://github.com/acme/{name}.git",
|
||||
"private": True,
|
||||
}
|
||||
|
||||
page1 = _resp(200, {"repositories": [_repo("a"), _repo("b")]})
|
||||
page2 = _resp(200, {"repositories": [_repo("c")]})
|
||||
client = _client(post=[_token_resp("tok")], get=[page1, page2])
|
||||
with (
|
||||
_patch_creds(),
|
||||
patch("roboco.services.github_app_auth.httpx.AsyncClient", return_value=client),
|
||||
):
|
||||
repos = await list_installation_repositories(MagicMock(), 11)
|
||||
|
||||
assert [r.full_name for r in repos] == ["acme/a", "acme/b", "acme/c"]
|
||||
assert client.get.call_count == _SECOND_CALL_COUNT
|
||||
@@ -0,0 +1,137 @@
|
||||
"""ProjectService.get_decrypted_token{,_by_slug} — the GitHub App installation-
|
||||
token branch + its fall back to the stored PAT.
|
||||
|
||||
An installation-bound project with App credentials mints a token; any minting
|
||||
failure (App unconfigured, revoked installation, network hiccup) falls back
|
||||
to the PAT rather than breaking git operations. Mocks the DB boundary (the
|
||||
project lookup) and the two collaborators (``github_app_credentials``,
|
||||
``github_app_auth``) directly — no real network/DB involved.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.services.github_app_auth import GitHubAppAPIError
|
||||
from roboco.services.project import ProjectService
|
||||
from roboco.utils.crypto import encrypt_token
|
||||
|
||||
|
||||
def _project(*, installation_id: int | None, pat: str | None) -> MagicMock:
|
||||
p = MagicMock()
|
||||
p.id = uuid4()
|
||||
p.github_installation_id = installation_id
|
||||
p.git_token_encrypted = encrypt_token(pat) if pat else None
|
||||
return p
|
||||
|
||||
|
||||
def _svc_with_get(project: MagicMock) -> ProjectService:
|
||||
svc = ProjectService(MagicMock())
|
||||
svc.get = AsyncMock(return_value=project) # type: ignore[method-assign]
|
||||
svc.get_by_slug = AsyncMock(return_value=project) # type: ignore[method-assign]
|
||||
return svc
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_installation_uses_stored_pat() -> None:
|
||||
svc = _svc_with_get(_project(installation_id=None, pat="ghp_plain"))
|
||||
token = await svc.get_decrypted_token(uuid4())
|
||||
assert token == "ghp_plain"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_project_returns_none() -> None:
|
||||
svc = _svc_with_get(None) # type: ignore[arg-type]
|
||||
assert await svc.get_decrypted_token(uuid4()) is None
|
||||
assert await svc.get_decrypted_token_by_slug("nope") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_installation_id_without_app_creds_falls_back_to_pat() -> None:
|
||||
svc = _svc_with_get(_project(installation_id=42, pat="ghp_fallback"))
|
||||
fake_creds_svc = MagicMock()
|
||||
fake_creds_svc.has_credentials = AsyncMock(return_value=False)
|
||||
with patch(
|
||||
"roboco.services.project.get_github_app_credentials_service",
|
||||
return_value=fake_creds_svc,
|
||||
):
|
||||
token = await svc.get_decrypted_token(uuid4())
|
||||
assert token == "ghp_fallback"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_installation_id_with_app_creds_mints_token() -> None:
|
||||
svc = _svc_with_get(_project(installation_id=42, pat="ghp_unused"))
|
||||
fake_creds_svc = MagicMock()
|
||||
fake_creds_svc.has_credentials = AsyncMock(return_value=True)
|
||||
with (
|
||||
patch(
|
||||
"roboco.services.project.get_github_app_credentials_service",
|
||||
return_value=fake_creds_svc,
|
||||
),
|
||||
patch(
|
||||
"roboco.services.project.mint_installation_token",
|
||||
AsyncMock(return_value="ghs_minted"),
|
||||
),
|
||||
):
|
||||
token = await svc.get_decrypted_token(uuid4())
|
||||
assert token == "ghs_minted"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mint_failure_falls_back_to_pat() -> None:
|
||||
svc = _svc_with_get(_project(installation_id=42, pat="ghp_fallback"))
|
||||
fake_creds_svc = MagicMock()
|
||||
fake_creds_svc.has_credentials = AsyncMock(return_value=True)
|
||||
with (
|
||||
patch(
|
||||
"roboco.services.project.get_github_app_credentials_service",
|
||||
return_value=fake_creds_svc,
|
||||
),
|
||||
patch(
|
||||
"roboco.services.project.mint_installation_token",
|
||||
AsyncMock(side_effect=GitHubAppAPIError("revoked")),
|
||||
),
|
||||
):
|
||||
token = await svc.get_decrypted_token(uuid4())
|
||||
assert token == "ghp_fallback"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mint_failure_with_no_pat_returns_none() -> None:
|
||||
svc = _svc_with_get(_project(installation_id=42, pat=None))
|
||||
fake_creds_svc = MagicMock()
|
||||
fake_creds_svc.has_credentials = AsyncMock(return_value=True)
|
||||
with (
|
||||
patch(
|
||||
"roboco.services.project.get_github_app_credentials_service",
|
||||
return_value=fake_creds_svc,
|
||||
),
|
||||
patch(
|
||||
"roboco.services.project.mint_installation_token",
|
||||
AsyncMock(side_effect=GitHubAppAPIError("revoked")),
|
||||
),
|
||||
):
|
||||
token = await svc.get_decrypted_token(uuid4())
|
||||
assert token is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_by_slug_mints_token_too() -> None:
|
||||
svc = _svc_with_get(_project(installation_id=7, pat=None))
|
||||
fake_creds_svc = MagicMock()
|
||||
fake_creds_svc.has_credentials = AsyncMock(return_value=True)
|
||||
with (
|
||||
patch(
|
||||
"roboco.services.project.get_github_app_credentials_service",
|
||||
return_value=fake_creds_svc,
|
||||
),
|
||||
patch(
|
||||
"roboco.services.project.mint_installation_token",
|
||||
AsyncMock(return_value="ghs_minted"),
|
||||
),
|
||||
):
|
||||
token = await svc.get_decrypted_token_by_slug("acme")
|
||||
assert token == "ghs_minted"
|
||||
Reference in New Issue
Block a user