diff --git a/apps/gittensory-ui/src/components/site/app-panels/ai-review-settings.tsx b/apps/gittensory-ui/src/components/site/app-panels/ai-review-settings.tsx new file mode 100644 index 0000000000..1cd9f16084 --- /dev/null +++ b/apps/gittensory-ui/src/components/site/app-panels/ai-review-settings.tsx @@ -0,0 +1,301 @@ +import { KeyRound, Loader2, Save, Trash2 } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { StatusPill } from "@/components/site/control-primitives"; +import { apiFetch } from "@/lib/api/request"; +import { getApiOrigin } from "@/lib/api/origin"; +import { extractPreviewRepoOptions, splitRepoFullName } from "@/lib/maintainer-settings-preview"; + +type AiReviewMode = "off" | "advisory" | "block"; +type AiProvider = "anthropic" | "openai"; + +type RepoSettingsResponse = { + aiReviewMode?: AiReviewMode; + aiReviewByok?: boolean; + aiReviewProvider?: AiProvider | null; + aiReviewModel?: string | null; +}; + +type AiKeyStatus = { + configured: boolean; + provider?: AiProvider; + last4?: string; + model?: string | null; +}; + +type Message = { kind: "ok" | "err"; text: string }; + +function repoApiBase(repoFullName: string): string | null { + const target = splitRepoFullName(repoFullName); + if (!target) return null; + return `${getApiOrigin().replace(/\/$/, "")}/v1/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}`; +} + +const JSON_HEADERS = { Accept: "application/json", "Content-Type": "application/json" }; + +/** + * Maintainer self-serve AI review + BYOK key config. The provider key is write-only: it POSTs to the + * encrypted key endpoint and only the configured/last4 status is ever read back — the key is never rendered. + */ +export function AiReviewSettings({ reviewability }: { reviewability: Array<{ pr: string }> }) { + const repoOptions = useMemo(() => extractPreviewRepoOptions(reviewability), [reviewability]); + const [repoFullName, setRepoFullName] = useState(repoOptions[0] ?? ""); + const [mode, setMode] = useState("off"); + const [byok, setByok] = useState(false); + const [provider, setProvider] = useState("anthropic"); + const [model, setModel] = useState(""); + const [keyInput, setKeyInput] = useState(""); + const [keyStatus, setKeyStatus] = useState(null); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(null); + + const base = repoApiBase(repoFullName); + + const load = useCallback(async () => { + const apiBase = repoApiBase(repoFullName); + if (!apiBase) return; + setMessage(null); + const [settings, key] = await Promise.all([ + apiFetch(`${apiBase}/settings`, { + label: "AI review settings", + credentials: "include", + silentStatus: true, + }), + apiFetch(`${apiBase}/ai-key`, { + label: "AI key status", + credentials: "include", + silentStatus: true, + }), + ]); + if (settings.ok) { + setMode(settings.data.aiReviewMode ?? "off"); + setByok(settings.data.aiReviewByok ?? false); + setProvider(settings.data.aiReviewProvider ?? "anthropic"); + setModel(settings.data.aiReviewModel ?? ""); + } + setKeyStatus(key.ok ? key.data : null); + }, [repoFullName]); + + useEffect(() => { + void load(); + }, [load]); + + async function saveConfig() { + if (!base) { + setMessage({ kind: "err", text: "Enter a repository as owner/repo." }); + return; + } + setBusy(true); + const result = await apiFetch(`${base}/ai-review`, { + method: "PUT", + label: "Save AI review config", + credentials: "include", + headers: JSON_HEADERS, + body: JSON.stringify({ mode, byok, provider, model: model.trim() || null }), + }); + setBusy(false); + setMessage( + result.ok + ? { kind: "ok", text: "AI review configuration saved." } + : { kind: "err", text: result.message }, + ); + } + + async function saveKey() { + if (!base) return; + if (keyInput.trim().length < 20) { + setMessage({ kind: "err", text: "Enter a valid provider API key." }); + return; + } + setBusy(true); + const result = await apiFetch(`${base}/ai-key`, { + method: "POST", + label: "Save provider key", + credentials: "include", + headers: JSON_HEADERS, + body: JSON.stringify({ provider, key: keyInput.trim(), model: model.trim() || null }), + }); + setBusy(false); + if (result.ok) { + setKeyStatus(result.data); + setKeyInput(""); + setMessage({ kind: "ok", text: "Provider key stored (encrypted)." }); + } else { + setMessage({ kind: "err", text: result.message }); + } + } + + async function removeKey() { + if (!base) return; + setBusy(true); + const result = await apiFetch(`${base}/ai-key`, { + method: "DELETE", + label: "Remove provider key", + credentials: "include", + }); + setBusy(false); + if (result.ok) { + setKeyStatus({ configured: false }); + setMessage({ kind: "ok", text: "Provider key removed." }); + } else { + setMessage({ kind: "err", text: result.message }); + } + } + + const fieldClass = + "mt-1 min-h-10 w-full rounded-token border border-border bg-background/70 px-3 py-2 font-mono text-token-sm text-foreground outline-none transition-colors focus:border-mint"; + const labelClass = "font-mono text-token-2xs uppercase tracking-wider text-muted-foreground"; + + return ( +
+
+
+

+ AI review & BYOK +

+

+ Free Cloudflare Workers AI by default. Bring your own Anthropic/OpenAI key for a + frontier-quality advisory write-up — your key, your provider account. Consensus blocking + always uses the free models and only applies to confirmed contributors. +

+
+ + {mode} + +
+ +
+
+ + + + + + +
+ + +
+ + +
+ +
+
+

+ Provider API key +

+ + {keyStatus?.configured ? `configured ····${keyStatus.last4 ?? ""}` : "not set"} + +
+

+ Stored encrypted at rest and used only to generate the advisory review. It is never + shown again, logged, or posted publicly. +

+ +
+ + {keyStatus?.configured ? ( + + ) : null} +
+
+
+ + {message ? ( +

+ {message.text} +

+ ) : null} +
+ ); +} diff --git a/apps/gittensory-ui/src/components/site/app-panels/maintainer-panel.tsx b/apps/gittensory-ui/src/components/site/app-panels/maintainer-panel.tsx index 9883788c9d..fc5938844f 100644 --- a/apps/gittensory-ui/src/components/site/app-panels/maintainer-panel.tsx +++ b/apps/gittensory-ui/src/components/site/app-panels/maintainer-panel.tsx @@ -17,6 +17,7 @@ import { StatusPill, type Status, } from "@/components/site/control-primitives"; +import { AiReviewSettings } from "@/components/site/app-panels/ai-review-settings"; import { StatCard } from "@/components/site/primitives"; import { StateBoundary } from "@/components/site/state-views"; import { apiFetch } from "@/lib/api/request"; @@ -284,6 +285,8 @@ export function MaintainerPanel() { + + ) : null}