From 2c7dec1a5bd177c3846f4908f276f3e707bd0fc7 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 6 Jul 2026 03:06:52 -0700 Subject: [PATCH] feat(ui): add maintainer activation preview card (#701) The backend for one-step maintainer activation (buildMaintainerActivationPreview, GET/POST /v1/repos/:owner/:repo/activation(-preview)) shipped with no UI surface. Add an ActivationPreview card to the maintainer console that loads the live repo-specific preview on mount (real loading/error/empty states, no mock data) and offers a single "Enable advisory mode" action that posts the activation route and reflects the result inline. --- .../app-panels/activation-preview.test.tsx | 159 +++++++++ .../site/app-panels/activation-preview.tsx | 315 ++++++++++++++++++ .../site/app-panels/maintainer-panel.tsx | 3 + 3 files changed, 477 insertions(+) create mode 100644 apps/gittensory-ui/src/components/site/app-panels/activation-preview.test.tsx create mode 100644 apps/gittensory-ui/src/components/site/app-panels/activation-preview.tsx diff --git a/apps/gittensory-ui/src/components/site/app-panels/activation-preview.test.tsx b/apps/gittensory-ui/src/components/site/app-panels/activation-preview.test.tsx new file mode 100644 index 0000000000..f17747b431 --- /dev/null +++ b/apps/gittensory-ui/src/components/site/app-panels/activation-preview.test.tsx @@ -0,0 +1,159 @@ +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.hoisted(() => ({ apiFetch: vi.fn() })); +vi.mock("@/lib/api/request", () => ({ apiFetch: (...args: unknown[]) => apiFetch(...args) })); +vi.mock("@/lib/api/origin", () => ({ getApiOrigin: () => "https://api.test" })); + +import { ActivationPreview } from "@/components/site/app-panels/activation-preview"; + +const REVIEWABILITY = [{ pr: "acme/widgets#1" }]; + +const BASE_PREVIEW = { + repoFullName: "acme/widgets", + generatedAt: "2026-07-05T00:00:00.000Z", + currentGateMode: "off" as const, + aiReviewConfigured: false, + evaluatedCount: 3, + withFindingsCount: 2, + findingCodeCounts: [{ code: "missing_tests", count: 2 }], + samples: [ + { + number: 12, + title: "Add cursor pagination", + severity: "warning" as const, + findingCount: 1, + findings: [], + }, + { + number: 11, + title: "Fix flaky test", + severity: "info" as const, + findingCount: 0, + findings: [], + }, + ], + recommendedAction: "enable_advisory" as const, + summary: + "Gittensory reviewed your 3 most recent pull request(s) and would have surfaced guidance on 2 of them.", +}; + +describe("ActivationPreview", () => { + beforeEach(() => { + apiFetch.mockReset(); + }); + + it("shows a loading state, then renders the real preview data on load", async () => { + apiFetch.mockResolvedValue({ ok: true, data: BASE_PREVIEW }); + render(); + + expect(screen.getByText(/Building activation preview/i)).toBeTruthy(); + await waitFor(() => expect(screen.getByText(BASE_PREVIEW.summary)).toBeTruthy()); + expect(screen.getByText("Add cursor pagination")).toBeTruthy(); + expect(screen.getByText("missing_tests × 2")).toBeTruthy(); + expect(apiFetch).toHaveBeenCalledWith( + expect.stringContaining("/v1/repos/acme/widgets/activation-preview"), + expect.objectContaining({ label: "Activation preview" }), + ); + }); + + it("renders an error state with the failure message when the preview fails to load", async () => { + apiFetch.mockResolvedValue({ ok: false, message: "503 Service Unavailable" }); + render(); + + await waitFor(() => + expect(screen.getByText(/Couldn't load the activation preview/i)).toBeTruthy(), + ); + expect(screen.getByText("503 Service Unavailable")).toBeTruthy(); + }); + + it("renders an empty state when zero pull requests have been evaluated", async () => { + apiFetch.mockResolvedValue({ + ok: true, + data: { + ...BASE_PREVIEW, + evaluatedCount: 0, + withFindingsCount: 0, + samples: [], + findingCodeCounts: [], + recommendedAction: null, + }, + }); + render(); + + await waitFor(() => expect(screen.getByText(/No recent pull requests yet/i)).toBeTruthy()); + }); + + it("shows the enable-advisory action, posts activation, and reflects the enabled state after the round-trip", async () => { + apiFetch.mockResolvedValueOnce({ ok: true, data: BASE_PREVIEW }); + render(); + await waitFor(() => expect(screen.getByText(BASE_PREVIEW.summary)).toBeTruthy()); + + const activateButton = screen.getByRole("button", { name: /enable advisory mode/i }); + + apiFetch.mockResolvedValueOnce({ + ok: true, + data: { + repoFullName: "acme/widgets", + gateCheckMode: "enabled", + reviewCheckMode: "required", + checkRunMode: "enabled", + linkedIssueGateMode: "advisory", + duplicatePrGateMode: "advisory", + qualityGateMode: "advisory", + }, + }); + // Reload after activation reports the gate is now on — the button should disappear. + apiFetch.mockResolvedValueOnce({ + ok: true, + data: { ...BASE_PREVIEW, currentGateMode: "enabled", recommendedAction: null }, + }); + + fireEvent.click(activateButton); + + await waitFor(() => + expect( + screen.getByText(/Advisory mode enabled\. Gittensory will now surface guidance/i), + ).toBeTruthy(), + ); + await waitFor(() => expect(screen.getByText(/Advisory mode is already enabled/i)).toBeTruthy()); + expect(screen.queryByRole("button", { name: /enable advisory mode/i })).toBeNull(); + + const postCall = apiFetch.mock.calls.find( + ([, opts]) => (opts as { method?: string })?.method === "POST", + ); + expect(postCall?.[0]).toContain("/v1/repos/acme/widgets/activation"); + }); + + it("surfaces the error message inline when activation fails, without touching the preview data", async () => { + apiFetch.mockResolvedValueOnce({ ok: true, data: BASE_PREVIEW }); + render(); + await waitFor(() => expect(screen.getByText(BASE_PREVIEW.summary)).toBeTruthy()); + + apiFetch.mockResolvedValueOnce({ ok: false, message: "403 Forbidden" }); + + fireEvent.click(screen.getByRole("button", { name: /enable advisory mode/i })); + + await waitFor(() => expect(screen.getByText("403 Forbidden")).toBeTruthy()); + // Still showing the previously-loaded preview, unchanged. + expect(screen.getByText(BASE_PREVIEW.summary)).toBeTruthy(); + }); + + it("falls back to a manual owner/repo entry when no repos are registered yet", () => { + render(); + expect(screen.getByText(/No registered repositories detected yet/i)).toBeTruthy(); + expect(screen.getByText(/Enter an installed repository to preview activation\./i)).toBeTruthy(); + }); + + it("shows the 'settings unavailable' copy for a typed repo string that doesn't parse as owner\\/repo", async () => { + apiFetch.mockResolvedValue({ ok: true, data: BASE_PREVIEW }); + render(); + await waitFor(() => expect(screen.getByText(BASE_PREVIEW.summary)).toBeTruthy()); + + fireEvent.change(screen.getByPlaceholderText("owner/repo"), { + target: { value: "not-a-valid-slug" }, + }); + expect(screen.getByText(/Settings are unavailable for this repository\./i)).toBeTruthy(); + }); +}); diff --git a/apps/gittensory-ui/src/components/site/app-panels/activation-preview.tsx b/apps/gittensory-ui/src/components/site/app-panels/activation-preview.tsx new file mode 100644 index 0000000000..b188e20d4e --- /dev/null +++ b/apps/gittensory-ui/src/components/site/app-panels/activation-preview.tsx @@ -0,0 +1,315 @@ +import { CheckCircle2, Loader2, Rocket } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { StatusPill, type Status } from "@/components/site/control-primitives"; +import { StateBoundary } from "@/components/site/state-views"; +import { apiFetch } from "@/lib/api/request"; +import { getApiOrigin } from "@/lib/api/origin"; +import { extractPreviewRepoOptions, splitRepoFullName } from "@/lib/maintainer-settings-preview"; + +type ActivationSeverity = "info" | "warning" | "critical"; + +type ActivationFinding = { code: string; severity: ActivationSeverity; title: string }; + +type ActivationSample = { + number: number; + title: string; + severity: ActivationSeverity; + findingCount: number; + findings: ActivationFinding[]; +}; + +type ActivationPreviewResponse = { + repoFullName: string; + generatedAt: string; + currentGateMode: "off" | "enabled"; + aiReviewConfigured: boolean; + evaluatedCount: number; + withFindingsCount: number; + findingCodeCounts: Array<{ code: string; count: number }>; + samples: ActivationSample[]; + recommendedAction: "enable_advisory" | null; + summary: string; +}; + +type ActivationResponse = { + repoFullName: string; + gateCheckMode: string; + reviewCheckMode: string; + checkRunMode: string; + linkedIssueGateMode: string; + duplicatePrGateMode: string; + qualityGateMode: string; +}; + +type Message = { kind: "ok" | "err"; text: string }; + +const SEVERITY_TONE: Record = { + info: "info", + warning: "warn", + critical: "blocked", +}; + +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)}`; +} + +/** + * One-step maintainer activation demo (#701): loads GET /activation-preview for a repo (deterministic, + * no AI run) so a newly-installed maintainer sees concrete "here's what Gittensory would have surfaced" + * evidence, then a single action button posts /activation to turn on advisory mode. Mirrors the + * AiReviewSettings / MaintainerSettings repo-picker + load/save shape in this same file group. + */ +export function ActivationPreview({ reviewability }: { reviewability: Array<{ pr: string }> }) { + const repoOptions = useMemo(() => extractPreviewRepoOptions(reviewability), [reviewability]); + const [repoFullName, setRepoFullName] = useState(repoOptions[0] ?? ""); + const [preview, setPreview] = useState(null); + const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(null); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(null); + + const base = repoApiBase(repoFullName); + const hasRepos = repoOptions.length > 0; + + const load = useCallback(async () => { + const apiBase = repoApiBase(repoFullName); + if (!apiBase) { + setPreview(null); + setLoadError(null); + return; + } + setMessage(null); + setLoadError(null); + setLoading(true); + const result = await apiFetch(`${apiBase}/activation-preview`, { + label: "Activation preview", + credentials: "include", + silentStatus: true, + }); + if (result.ok) { + setPreview(result.data); + } else { + setPreview(null); + setLoadError(result.message); + } + setLoading(false); + }, [repoFullName]); + + useEffect(() => { + void load(); + }, [load]); + + async function activate() { + if (!base) return; + setBusy(true); + const result = await apiFetch(`${base}/activation`, { + method: "POST", + label: "Enable advisory mode", + credentials: "include", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + }); + setBusy(false); + if (result.ok) { + // Reload first — `load()` clears any prior message, so the success message must be set after it settles. + await load(); + setMessage({ + kind: "ok", + text: "Advisory mode enabled. Gittensory will now surface guidance on new PRs.", + }); + } else { + setMessage({ kind: "err", text: result.message }); + } + } + + return ( +
+
+
+

+ Instant activation preview +

+

+ See what Gittensory would have surfaced on this repo's recent pull requests, then enable + advisory mode in one step. Deterministic — never runs AI, never blocks a merge. +

+
+ {preview ? ( + + gate {preview.currentGateMode} + + ) : null} +
+ + + +
+ + {!base ? ( +

+ {hasRepos + ? "Settings are unavailable for this repository." + : "Enter an installed repository to preview activation."} +

+ ) : preview ? ( + void activate()} + /> + ) : null} +
+
+ + + {message?.text ?? ""} + +
+ ); +} + +function ActivationPreviewBody({ + preview, + busy, + onActivate, +}: { + preview: ActivationPreviewResponse; + busy: boolean; + onActivate: () => void; +}) { + return ( +
+

{preview.summary}

+ +
+ + + +
+ + {preview.findingCodeCounts.length > 0 ? ( +
+
+ Finding types seen +
+
+ {preview.findingCodeCounts.map((entry) => ( + + {entry.code} × {entry.count} + + ))} +
+
+ ) : null} + + {preview.samples.length > 0 ? ( +
+ + + + + + + + + + + {preview.samples.map((sample) => ( + + + + + + + ))} + +
PRTitleSeverityFindings
#{sample.number}{sample.title} + + {sample.severity} + + {sample.findingCount}
+
+ ) : null} + +
+ {preview.recommendedAction === "enable_advisory" ? ( + + ) : ( + + Advisory mode is already enabled + + )} +
+
+ ); +} + +function MetricTile({ label, value }: { label: string; value: string | number }) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} 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 b6de55f6c1..b58617aef0 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 { ActivationPreview } from "@/components/site/app-panels/activation-preview"; import { AiReviewSettings } from "@/components/site/app-panels/ai-review-settings"; import { MaintainerSettings } from "@/components/site/app-panels/maintainer-settings"; import { StatCard } from "@/components/site/primitives"; @@ -351,6 +352,8 @@ function MaintainerDashboardView() { + +