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:
Renzo F
2026-07-21 00:54:20 +02:00
committed by GitHub
co-authored by Renn F
parent 2d210ce6ee
commit 7b84162ae9
26 changed files with 2105 additions and 38 deletions
+51
View File
@@ -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
+8 -1
View File
@@ -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&apos;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-----&#10;...&#10;-----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>
);
}
+61
View File
@@ -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;
},
};
+6
View File
@@ -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,
+7 -1
View File
@@ -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"],
+6
View File
@@ -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;
+9
View File
@@ -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,
+132
View File
@@ -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
]
+1
View File
@@ -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,
+31
View File
@@ -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
+18
View File
@@ -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,
+30
View File
@@ -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
+19
View File
@@ -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
+192
View File
@@ -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
+112
View File
@@ -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
View File
@@ -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"]
+173
View File
@@ -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
+208
View File
@@ -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"