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
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
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 { OnboardingPreviewCard } from "@/components/site/app-panels/onboarding-preview-card";
import { CheckRunReadinessTable } from "@/components/site/check-run-readiness-table";
import type { CheckRunReadinessTableData } from "@/components/site/check-run-readiness-model";
import { StatCard } from "@/components/site/primitives";
Expand Down Expand Up @@ -119,7 +120,7 @@ type InstallPreview = {
}>;
};

type SettingsPreviewResponse = {
export type SettingsPreviewResponse = {
repoFullName: string;
generatedAt: string;
installation: {
Expand Down Expand Up @@ -224,6 +225,9 @@ function MaintainerDashboardView({
<div className="flex items-center justify-end">
<RefreshMeta loadedAt={dashboard.loadedAt} onRefresh={dashboard.reload} />
</div>

<OnboardingPreviewCard reviewability={data.reviewability} />

<section className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{data.metrics.map((metric) => (
<StatCard
Expand Down Expand Up @@ -610,7 +614,7 @@ function SurfacePreview({
);
}

function PreviewResult({
export function PreviewResult({
preview,
error,
busy,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

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 { OnboardingPreviewCard } from "@/components/site/app-panels/onboarding-preview-card";
import type { SettingsPreviewResponse } from "@/components/site/app-panels/maintainer-panel";

const INSTALL_PREVIEW = {
status: "ready" as const,
summary: "All checks pass.",
readScope: [],
computedContext: [],
previewBehavior: [],
permissions: {
status: "ready" as const,
required: [],
missing: [],
missingEvents: [],
summary: "ok",
},
publicOutputs: [],
privateOnlyContext: [],
commandAuthorization: [],
auditBehavior: [],
sanitizerBoundaries: [],
manualControls: [],
checklist: [],
};

function preview(overrides: Partial<SettingsPreviewResponse> = {}): SettingsPreviewResponse {
return {
repoFullName: "acme/widgets",
generatedAt: "2026-07-10T00:00:00.000Z",
installation: null,
sample: {
authorLogin: "octocat",
authorType: "User",
authorAssociation: "NONE",
minerStatus: "confirmed",
title: "Add cursor pagination",
labels: [],
linkedIssues: [],
},
decision: {
willComment: true,
willLabel: true,
willCheckRun: true,
skipped: false,
skipReason: null,
actions: ["comment", "label", "check_run"],
summary: "Would comment and label this PR.",
},
previewComment: "Thanks for the PR! A couple of notes...",
appliedLabel: "gittensor:reviewed",
checkRun: { willCreate: true, title: "Gittensory review", detailLevel: "full" },
checkRunReadiness: null,
installPreview: INSTALL_PREVIEW,
warnings: [],
summary: "Would comment and label this PR.",
...overrides,
};
}

const REVIEWABILITY = [
{ pr: "acme/widgets#12", title: "Add cursor pagination", reason: "linked issue #7" },
];

describe("OnboardingPreviewCard", () => {
beforeEach(() => {
apiFetch.mockReset();
window.localStorage.clear();
});

it("auto-runs the demo against the most recent PR and shows flagged findings (comment+label+check-run)", async () => {
apiFetch.mockResolvedValue({ ok: true, data: preview() });
render(<OnboardingPreviewCard reviewability={REVIEWABILITY} />);

await waitFor(() => expect(screen.getByText("Would comment and label this PR.")).toBeTruthy());
expect(screen.getByText("acme/widgets#12")).toBeTruthy();
expect(screen.getByText("comment")).toBeTruthy();
expect(screen.getByText("gittensor:reviewed")).toBeTruthy();

// Real title + real linked-issue number scraped from `reason` reach the request.
const [url, options] = apiFetch.mock.calls[0] as [string, { body: string; method: string }];
expect(url).toContain("/v1/repos/acme/widgets/settings-preview");
expect(options.method).toBe("POST");
const body = JSON.parse(options.body) as { sample: { title: string; linkedIssues: number[] } };
expect(body.sample.title).toBe("Add cursor pagination");
expect(body.sample.linkedIssues).toEqual([7]);
});

it("shows a clean/skip verdict for a PR the policy would not act on", async () => {
apiFetch.mockResolvedValue({
ok: true,
data: preview({
decision: {
willComment: false,
willLabel: false,
willCheckRun: false,
skipped: true,
skipReason: "author is a trusted maintainer",
actions: ["skip"],
summary: "Nothing to flag on this PR.",
},
summary: "Nothing to flag on this PR.",
}),
});
render(<OnboardingPreviewCard reviewability={REVIEWABILITY} />);
await waitFor(() => expect(screen.getByText("Nothing to flag on this PR.")).toBeTruthy());
expect(screen.getByText("author is a trusted maintainer")).toBeTruthy();
});

it("renders an EmptyState when the repo has no recent pull requests, without calling the API", async () => {
render(<OnboardingPreviewCard reviewability={[]} />);
await waitFor(() => expect(screen.getByText(/No recent pull requests yet/i)).toBeTruthy());
expect(apiFetch).not.toHaveBeenCalled();
});

it("renders an error state when the demo run fails", async () => {
apiFetch.mockResolvedValue({ ok: false, message: "503 Service Unavailable" });
render(<OnboardingPreviewCard reviewability={REVIEWABILITY} />);
await waitFor(() =>
expect(screen.getByText(/Couldn't run the onboarding preview/i)).toBeTruthy(),
);
expect(screen.getByText("503 Service Unavailable")).toBeTruthy();
});

it("dismisses the card and keeps it hidden (and skips the API call) across a remount", async () => {
apiFetch.mockResolvedValue({ ok: true, data: preview() });
const { unmount } = render(<OnboardingPreviewCard reviewability={REVIEWABILITY} />);
await waitFor(() => expect(screen.getByText("Would comment and label this PR.")).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: "Dismiss onboarding preview" }));
expect(screen.queryByText(/Here's what Gittensory would have flagged/)).toBeNull();

apiFetch.mockClear();
unmount();
// render() flushes the synchronous hydration + load effects within its own act() wrapping (same
// assumption ActivationPreview's tests already rely on for its own initial-load effect), so both of
// these are safe to assert immediately rather than under waitFor.
render(<OnboardingPreviewCard reviewability={REVIEWABILITY} />);
expect(screen.queryByText(/Here's what Gittensory would have flagged/)).toBeNull();
expect(apiFetch).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { useCallback, useEffect, useState } from "react";
import { X } from "lucide-react";

import { StateBoundary } from "@/components/site/state-views";
import {
PreviewResult,
type SettingsPreviewResponse,
} from "@/components/site/app-panels/maintainer-panel";
import { apiFetch } from "@/lib/api/request";
import { getApiOrigin } from "@/lib/api/origin";
import {
buildSettingsPreviewRequest,
splitRepoFullName,
type PreviewFormState,
} from "@/lib/maintainer-settings-preview";
import { useLocalStorage } from "@/lib/use-local-storage";

type ReviewabilityRow = { pr: string; title: string; reason: string };

const DISMISS_KEY = "gittensory_maintainer_onboarding_preview_dismissed";

/** Builds a settings-preview form from a REAL cached PR (title, and a linked-issue number scraped from
* `reason` when present) — everything else (author identity, labels, body) isn't in the reviewability
* projection (see src/api/routes.ts's `reviewability` mapper), so it's filled from a representative
* scenario, same as SurfacePreview's own default. Returns null if the PR string doesn't parse. */
function reviewabilityRowToForm(row: ReviewabilityRow): PreviewFormState | null {
const repoFullName = row.pr.split("#")[0] ?? "";
if (!splitRepoFullName(repoFullName)) return null;
const linkedIssueMatch = row.reason.match(/linked issue #(\d+)/);
return {
repoFullName,
scenarioId: "confirmed-miner",
title: row.title,
labels: "",
linkedIssues: linkedIssueMatch ? linkedIssueMatch[1] : "",
body: "",
};
}

/**
* First-session onboarding preview card (#2217, part of #701): auto-runs the same settings-preview
* simulator SurfacePreview drives manually, against this repo's most recently cached pull request, so a
* maintainer sees "here's what Gittensory would have flagged" without filling out a form. Dismissible via
* localStorage, matching this codebase's established first-visit-card idiom (app.index.tsx's
* OnboardingChecklist). Renders through PreviewResult — the same decision/checklist/comment-preview UI
* SurfacePreview already uses — rather than a new findings UI: the settings-preview response has no
* discrete findings array, so "flagged" here means decision.willComment / willLabel / willCheckRun.
*/
export function OnboardingPreviewCard({ reviewability }: { reviewability: ReviewabilityRow[] }) {
const [state, setState, hydrated] = useLocalStorage<{ dismissed: boolean }>(DISMISS_KEY, {
dismissed: false,
});
const target = reviewability[0] ?? null;
const [preview, setPreview] = useState<SettingsPreviewResponse | null>(null);
const [loading, setLoading] = useState(Boolean(target));
const [error, setError] = useState<string | null>(null);

const load = useCallback(async () => {
if (!target) {
setPreview(null);
setError(null);
return;
}
const form = reviewabilityRowToForm(target);
const repoParts = form ? splitRepoFullName(form.repoFullName) : null;
if (!form || !repoParts) {
setPreview(null);
setError(`Couldn't parse a repository from ${target.pr}.`);
return;
}
setLoading(true);
setError(null);
const result = await apiFetch<SettingsPreviewResponse>(
`${getApiOrigin().replace(/\/$/, "")}/v1/repos/${encodeURIComponent(repoParts.owner)}/${encodeURIComponent(repoParts.repo)}/settings-preview`,
{
method: "POST",
label: "Onboarding preview",
credentials: "include",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify(buildSettingsPreviewRequest(form)),
},
);
setLoading(false);
if (result.ok) {
setPreview(result.data);
} else {
setPreview(null);
setError(result.message);
}
}, [target]);

useEffect(() => {
if (!hydrated || state.dismissed) return;
void load();
}, [load, hydrated, state.dismissed]);

if (!hydrated || state.dismissed) return null;

return (
<section
className="rounded-token border-hairline bg-card p-5"
aria-labelledby="onboarding-preview-title"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 id="onboarding-preview-title" className="font-display text-token-lg font-semibold">
Here's what Gittensory would have flagged
</h2>
<p className="mt-1 text-token-xs text-muted-foreground">
{target ? (
<>
A live demo run of the review policy against{" "}
<span className="font-mono">{target.pr}</span>, your most recently cached pull
request.
</>
) : (
"A live demo run against this repo's most recent pull request."
)}
</p>
</div>
<button
type="button"
onClick={() => setState({ dismissed: true })}
aria-label="Dismiss onboarding preview"
className="shrink-0 rounded-token p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-ring"
>
<X className="size-4" />
</button>
</div>

<div className="mt-4">
<StateBoundary
isLoading={loading}
isError={error !== null}
isEmpty={reviewability.length === 0}
onRetry={load}
onRefresh={load}
loadingTitle="Running the demo preview…"
errorTitle="Couldn't run the onboarding preview"
errorDescription={error ?? undefined}
emptyTitle="No recent pull requests yet"
emptyDescription="This card will run a live demo once this repo has a cached pull request."
>
<PreviewResult preview={preview} error={null} busy={false} />
</StateBoundary>
</div>
</section>
);
}
Loading