Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ jobs:
- name: UI check
env:
VITE_GITTENSORY_API_ORIGIN: https://gittensory-api.aethereal.dev
run: npm run ui:openapi:check && npm run ui:lint && npm run ui:typecheck && npm run ui:build
run: npm run ui:openapi:check && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build

- name: Audit dependencies
run: npm audit --audit-level=moderate
4 changes: 4 additions & 0 deletions apps/gittensory-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"version:built": "wrangler versions upload --config dist/server/wrangler.json",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"test": "vitest run",
"format": "prettier --write ."
},
"dependencies": {
Expand Down Expand Up @@ -74,6 +75,8 @@
"devDependencies": {
"@eslint/js": "^9.32.0",
"@lovable.dev/vite-tanstack-config": "^2.1.1",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.1.0",
"@types/node": "^22.16.5",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
Expand All @@ -84,6 +87,7 @@
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^15.15.0",
"jsdom": "^25.0.1",
"nitro": "3.0.260429-beta",
"prettier": "^3.7.3",
"typescript": "^5.8.3",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

// Mock the API layer so the component never touches the network.
const apiFetch = vi.fn();
vi.mock("@/lib/api/request", () => ({ apiFetch: (...args: unknown[]) => apiFetch(...args) }));
vi.mock("@/lib/api/origin", () => ({ getApiOrigin: () => "https://api.test" }));

import { AiReviewSettings } from "@/components/site/app-panels/ai-review-settings";

const REVIEWABILITY = [{ pr: "acme/widgets#1" }];

describe("AiReviewSettings", () => {
beforeEach(() => {
apiFetch.mockReset();
apiFetch.mockResolvedValue({ ok: true, data: { configured: false } });
});

it("renders the provider key field as write-only (password) and never hydrates a stored key", async () => {
// GET settings + GET ai-key both report a configured key, but only the last4 status comes back.
apiFetch.mockResolvedValue({
ok: true,
data: { configured: true, last4: "7890", provider: "anthropic" },
});
render(<AiReviewSettings reviewability={REVIEWABILITY} />);

const keyInput = (await screen.findByPlaceholderText("sk-ant-…")) as HTMLInputElement;
expect(keyInput.type).toBe("password");
expect(keyInput.value).toBe(""); // the stored key is NEVER written back into the field
await waitFor(() => expect(screen.getByText(/configured/)).toBeTruthy());
// The raw key never appears anywhere in the rendered DOM.
expect(document.body.textContent).not.toContain("sk-ant-");
});

it("rejects a provider/key mismatch client-side without calling the key endpoint", async () => {
render(<AiReviewSettings reviewability={REVIEWABILITY} />);
await screen.findByPlaceholderText("sk-ant-…");
await waitFor(() => expect(apiFetch).toHaveBeenCalled()); // initial load (GETs) settled
apiFetch.mockClear();

// Provider defaults to anthropic; paste an OpenAI-shaped key.
fireEvent.change(screen.getByPlaceholderText("sk-ant-…"), {
target: { value: "sk-openai-not-anthropic-123456" },
});
fireEvent.click(screen.getByRole("button", { name: /save key/i }));

expect(await screen.findByText(/Anthropic keys start with sk-ant-/)).toBeTruthy();
// No write request was attempted.
expect(apiFetch).not.toHaveBeenCalled();
});

it("posts a valid key, clears the input, and surfaces only the returned last4 status", async () => {
render(<AiReviewSettings reviewability={REVIEWABILITY} />);
await screen.findByPlaceholderText("sk-ant-…");
await waitFor(() => expect(apiFetch).toHaveBeenCalled());
apiFetch.mockClear();
apiFetch.mockResolvedValue({
ok: true,
data: { configured: true, last4: "4242", provider: "anthropic" },
});

const keyInput = screen.getByPlaceholderText("sk-ant-…") as HTMLInputElement;
fireEvent.change(keyInput, { target: { value: "sk-ant-valid-key-123456789" } });
fireEvent.click(screen.getByRole("button", { name: /save key/i }));

await waitFor(() => expect(screen.getByText(/Provider key stored/)).toBeTruthy());
const post = apiFetch.mock.calls.find(
([, opts]) => (opts as { method?: string })?.method === "POST",
);
expect(post?.[0]).toContain("/ai-key");
expect(keyInput.value).toBe(""); // input cleared after a successful save
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,17 @@ export function AiReviewSettings({ reviewability }: { reviewability: Array<{ pr:
const [keyInput, setKeyInput] = useState("");
const [keyStatus, setKeyStatus] = useState<AiKeyStatus | null>(null);
const [busy, setBusy] = useState(false);
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<Message | null>(null);

const base = repoApiBase(repoFullName);
const hasRepos = repoOptions.length > 0;

const load = useCallback(async () => {
const apiBase = repoApiBase(repoFullName);
if (!apiBase) return;
setMessage(null);
setLoading(true);
const [settings, key] = await Promise.all([
apiFetch<RepoSettingsResponse>(`${apiBase}/settings`, {
label: "AI review settings",
Expand All @@ -73,6 +76,7 @@ export function AiReviewSettings({ reviewability }: { reviewability: Array<{ pr:
setModel(settings.data.aiReviewModel ?? "");
}
setKeyStatus(key.ok ? key.data : null);
setLoading(false);
}, [repoFullName]);

useEffect(() => {
Expand Down Expand Up @@ -198,6 +202,12 @@ export function AiReviewSettings({ reviewability }: { reviewability: Array<{ pr:
<option key={repo} value={repo} />
))}
</datalist>
{!hasRepos ? (
<span className="mt-1 block text-token-2xs text-muted-foreground">
No registered repositories detected yet — type an installed{" "}
<code className="font-mono">owner/repo</code> to configure it.
</span>
) : null}
</label>

<label className="block">
Expand Down Expand Up @@ -248,12 +258,17 @@ export function AiReviewSettings({ reviewability }: { reviewability: Array<{ pr:

<button
type="button"
disabled={busy || !base}
disabled={busy || loading || !base}
aria-busy={busy}
onClick={() => void saveConfig()}
className="inline-flex items-center gap-2 rounded-token border border-mint/40 bg-mint px-3 py-2 text-token-xs font-medium text-primary-foreground transition-all hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Save className="size-3.5" />}
Save configuration
{busy || loading ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Save className="size-3.5" />
)}
{loading ? "Loading…" : "Save configuration"}
</button>
</div>

Expand Down Expand Up @@ -286,7 +301,8 @@ export function AiReviewSettings({ reviewability }: { reviewability: Array<{ pr:
<div className="flex gap-2">
<button
type="button"
disabled={busy || !base}
disabled={busy || loading || !base}
aria-busy={busy}
onClick={() => void saveKey()}
className="inline-flex items-center gap-2 rounded-token border border-mint/40 bg-mint px-3 py-2 text-token-xs font-medium text-primary-foreground transition-all hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
>
Expand All @@ -296,7 +312,8 @@ export function AiReviewSettings({ reviewability }: { reviewability: Array<{ pr:
{keyStatus?.configured ? (
<button
type="button"
disabled={busy || !base}
disabled={busy || loading || !base}
aria-busy={busy}
onClick={() => void removeKey()}
className="inline-flex items-center gap-2 rounded-token border border-border px-3 py-2 text-token-xs font-medium text-foreground transition-colors hover:border-warning/50 hover:text-warning disabled:cursor-not-allowed disabled:opacity-50"
>
Expand All @@ -307,11 +324,13 @@ export function AiReviewSettings({ reviewability }: { reviewability: Array<{ pr:
</div>
</div>

{message ? (
<p className={`mt-4 text-token-xs ${message.kind === "ok" ? "text-mint" : "text-warning"}`}>
{message.text}
</p>
) : null}
<p
role="status"
aria-live="polite"
className={`mt-4 text-token-xs ${message ? (message.kind === "ok" ? "text-mint" : "text-warning") : "sr-only"}`}
>
{message?.text ?? ""}
</p>
</section>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";

// Control the session role + stub the data hook so the dashboard branch never hits the network.
const useSession = vi.fn();
vi.mock("@/lib/api/session", () => ({ useSession: () => useSession() }));
vi.mock("@/lib/api/use-api-resource", () => ({
useApiResource: () => ({ status: "loading", data: null, reload: () => {}, error: null }),
}));

import { MaintainerPanel } from "@/components/site/app-panels/maintainer-panel";

describe("MaintainerPanel role gate", () => {
it("shows a loading state until the session is hydrated", () => {
useSession.mockReturnValue({ session: null, hydrated: false });
render(<MaintainerPanel />);
expect(screen.getByText(/Checking maintainer access/i)).toBeTruthy();
});

it("blocks a non-maintainer: shows 'Maintainer access required' and never mounts the BYOK panel", () => {
useSession.mockReturnValue({ session: { login: "miner", roles: ["miner"] }, hydrated: true });
render(<MaintainerPanel />);
expect(screen.getByText(/Maintainer access required/i)).toBeTruthy();
// The BYOK key field (the only sk-ant- placeholder) must not exist for a non-maintainer.
expect(screen.queryByPlaceholderText("sk-ant-…")).toBeNull();
});

it("admits a maintainer (no access-required message) and proceeds to the dashboard", () => {
useSession.mockReturnValue({
session: { login: "maint", roles: ["maintainer"] },
hydrated: true,
});
render(<MaintainerPanel />);
expect(screen.queryByText(/Maintainer access required/i)).toBeNull();
});
});
16 changes: 16 additions & 0 deletions apps/gittensory-ui/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
import { defineConfig } from "vitest/config";

// Standalone vitest config for component tests — intentionally NOT the full TanStack Start build config
// (which pulls in nitro/cloudflare wiring that has no place in a jsdom unit test). Only the React JSX
// transform and the `@/` path alias are needed.
export default defineConfig({
plugins: [react(), tsconfigPaths()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./vitest.setup.ts"],
include: ["src/**/*.test.{ts,tsx}"],
},
});
7 changes: 7 additions & 0 deletions apps/gittensory-ui/vitest.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";

// Unmount React trees between tests so jsdom state never leaks across cases.
afterEach(() => {
cleanup();
});
77 changes: 77 additions & 0 deletions docs/maintainer-byok-ai-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# AI review & BYOK (maintainer guide)

Gittensory can post an AI maintainer review on pull requests. It runs on **free Cloudflare Workers AI
by default**, and maintainers can optionally **bring their own (BYOK) Anthropic or OpenAI key** for a
higher-quality advisory write-up. This page explains how it works and how to configure it.

## What the AI review does

There are two independent layers:

1. **Advisory write-up** (non-blocking) — a maintainer-style summary, suggestions, and risks. This is the
layer your BYOK key powers when you supply one; otherwise it uses the free Workers-AI model.
2. **Consensus blocker** (opt-in, blocking) — only fires when **two free Workers-AI models independently
agree** on a high-confidence critical defect. **This always uses the free models and never your BYOK
key.** It only ever applies to *confirmed Gittensor contributors* — it will never block an outside
contributor's PR.

Modes (`off` / `advisory` / `block`):

| Mode | Advisory write-up | Can block the Gate |
| ---------- | ----------------- | ------------------ |
| `off` | no | no |
| `advisory` | yes | no |
| `block` | yes | yes — only on a dual-model consensus defect, only for confirmed contributors |

## How BYOK works

When BYOK is enabled and a key is configured, the **advisory write-up** is generated by a direct request
to your provider:

- **Anthropic** → `POST https://api.anthropic.com/v1/messages` (your key in the `x-api-key` header)
- **OpenAI** → `POST https://api.openai.com/v1/chat/completions` (your key as a `Bearer` token)

These calls bill **your** provider account, do not run through Cloudflare Workers AI, and are **not**
counted against Gittensory's free daily budget. If a BYOK call fails (bad key, rate limit, timeout) the
review fails safe — you simply get no advisory note for that PR; nothing is ever blocked because of it.

### Key handling & security

- The key is **encrypted at rest** (AES-256-GCM, per-record salt) and is **write-only**: it is never
returned by any API, never logged, and never included in a public PR comment.
- The dashboard only ever shows a status: `configured ····<last4>`, plus who set it and when.
- Setting, replacing, or deleting a key is recorded in the audit log (actor + timestamp + last4 only —
never the key).
- Removing the key (or setting mode back to `off`/disabling BYOK) immediately stops using it.

## Configuring it

### Option A — the maintainer dashboard (recommended)

Open the **Maintainer console → AI review & BYOK** panel (visible only to verified repository
maintainers/owners/operators). Pick the repository, choose the mode, optionally enable BYOK and select a
provider/model, then paste your provider key. The panel validates the key shape before saving
(Anthropic keys start with `sk-ant-`; OpenAI keys start with `sk-`).

### Option B — config-as-code in `.gittensory.yml`

The non-secret choices can also live in your repo's `.gittensory.yml` (which takes precedence over the
dashboard settings). **The secret key is never put in the file** — set it via the dashboard.

```yaml
gate:
aiReview:
mode: advisory # off | advisory | block
byok: true # use your provider key for the advisory write-up
provider: anthropic # anthropic | openai
model: claude-3-5-sonnet-latest # optional model override
```

Precedence: `.gittensory.yml` > dashboard settings > safe defaults.

## Notes

- The feature is **dormant by default** — it only runs when the operator has enabled the AI flags for the
deployment **and** the repository sets a non-`off` mode.
- If you declare a `provider` that doesn't match your stored key's provider, BYOK is skipped and the
review falls back to the free Workers-AI model (no error, no block).
Loading
Loading