Simplify GitHub bot completion and hide repository override editing

This commit is contained in:
Dotta committed 2026-10-08 06:39:44 -05:00
1 parent c8b0a1bf3c
commit 91d2a17b78
12 files changed
+302 -203

No files matched your search

+8 -4
View File
@@ -31,8 +31,10 @@ the GitHub Code Review Bot connector. This default-off setting is independent of
authorize the dedicated App and confirm the observed account once. Bot work
uses installation credentials, never your personal GitHub token.
5. Connection verification and completion happen automatically. The connected
page shows the actual App identity and repositories; a mention test is
optional. Runtime and isolation readiness remain separate prerequisites.
page shows the actual App identity and enabled repository count, with a
copyable optional review mention. Open **Bot settings** to manage the
bot; normal connection navigation is already available. Runtime and isolation
readiness remain separate prerequisites.
Installation may return before GitHub's separately delivered signed webhook
ping. Paperclip keeps the same draft and credentials, shows a waiting state,
@@ -88,8 +90,10 @@ selected environment.
## Manage a connected bot
- **Settings:** edit instructions, choose when the bot runs, and set review output.
Event-specific instructions, filters, formal approvals, and repository overrides
are available in disclosures.
Event-specific instructions, filters, and formal approvals are available in
disclosures. Repository-override editing is temporarily hidden in the UI;
existing overrides and the configuration API remain supported. Changing
defaults preserves saved overrides.
- **Access:** choose enabled repositories, allowed people, and each person's
**Run automatically** setting. Repository switches save immediately. Other
changes use **Save changes**. A linked account alone does not grant selected-member
+1 -1
View File
@@ -38,7 +38,7 @@ export function AppsSidebar() {
(tab) => !isExperimentalToolTab(tab.key) || smokeLabEnabled,
);
if (pathname.endsWith("/apps/chat/connect")) return <ChatSetupSidebar />;
if (pathname.endsWith("/apps/chat/connect")) return <ChatSetupSidebar NavItem={SidebarNavItem} />;
const chatDetail = pathname.match(/\/apps\/chat\/([^/]+)(?:\/(?:settings|access|conversations|activity)|\/reviews(?:\/[^/]+)?)?\/?$/);
if (chatDetail) return <ChatDetailSidebar endpointId={chatDetail[1]} NavItem={SidebarNavItem} />;
@@ -5,6 +5,11 @@ import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { describe, expect, it, vi } from "vitest";
import { ChatDetailSidebar } from "./ChatDetailSidebar";
import { ChatSetupSidebar } from "./ChatSetupNavigation";
import { githubChatApi } from "@/api/githubChat";
import { queryKeys } from "@/lib/queryKeys";
const setup = vi.hoisted(() => ({ params: "provider=github&resume=endpoint-a", advance: vi.fn() }));
vi.mock("@/api/githubChat", () => ({ githubChatApi: { advance: setup.advance } }));
import { SidebarNavItem, SidebarNavExpandedProvider } from "../SidebarNavItem";
import { SidebarNavItem as ProductionNavItem, SidebarNavExpandedProvider as ProductionProvider } from "../SidebarNavItem.production";
import { TooltipProvider } from "../ui/tooltip";
@@ -13,6 +18,7 @@ vi.mock("@/context/SidebarContext", () => ({
useSidebar: () => ({ collapsed: true, peeking: false, isMobile: false, setSidebarOpen: vi.fn() }),
}));
vi.mock("@/lib/router", () => ({
useSearchParams: () => [new URLSearchParams(setup.params)],
NavLink: ({ to, children, className }: { to: string; children: ReactNode; className?: string | ((state: { isActive: boolean }) => string) }) =>
<a href={to} className={typeof className === "function" ? className({ isActive: false }) : className}>{children}</a>,
}));
@@ -34,3 +40,30 @@ describe("chat detail sidebar with collapsed global navigation", () => {
} finally { flushSync(() => root.unmount()); }
});
});
describe("completed GitHub setup navigation", () => {
it.each([
["provider=github&resume=endpoint-a", true],
["provider=github&resume=endpoint-a&reconnect=1", false],
["provider=github&resume=endpoint-a&stage=identity", false],
])("uses the right sidebar for %s without advancing setup", (params, complete) => {
setup.params = params;
setup.advance.mockClear();
const client = new QueryClient({ defaultOptions: { queries: { enabled: false, retry: false } } });
client.setQueryData(["github-wizard", "endpoint-a"], { endpointId: "endpoint-a", state: "connected" });
client.setQueryData(queryKeys.chatEndpoints.detail("endpoint-a"), { provider: "github" });
const container = document.createElement("div");
const root = createRoot(container);
try {
flushSync(() => root.render(<QueryClientProvider client={client}><TooltipProvider><SidebarNavExpandedProvider>
<ChatSetupSidebar />
</SidebarNavExpandedProvider></TooltipProvider></QueryClientProvider>));
expect(container.querySelector('nav[aria-label="Chat connection"]') !== null).toBe(complete);
if (complete) {
expect(container.textContent).toContain("Settings");
expect(container.textContent).toContain("Reviews");
}
expect(githubChatApi.advance).not.toHaveBeenCalled();
} finally { flushSync(() => root.unmount()); client.clear(); }
});
});
+23 -1
View File
@@ -1,5 +1,27 @@
import { useQuery } from "@tanstack/react-query";
import type { GitHubAppWizardState } from "@paperclipai/shared";
import { githubChatApi } from "@/api/githubChat";
import { useSearchParams } from "@/lib/router";
import { SetupWizardNavigation, SetupWizardSidebar } from "../SetupWizard";
export { SetupWizardSidebar as ChatSetupSidebar };
import { SidebarNavItem } from "../SidebarNavItem";
import { ChatDetailSidebar } from "./ChatDetailSidebar";
/** Once setup completes, reuse the connection's normal management navigation. */
export function ChatSetupSidebar({ NavItem = SidebarNavItem }: { NavItem?: typeof SidebarNavItem }) {
const [params] = useSearchParams();
const resume = params.get("resume");
// Observe progress owned and fetched by the wizard; the sidebar never advances setup.
const progress = useQuery<GitHubAppWizardState>({
queryKey: ["github-wizard", resume],
queryFn: () => githubChatApi.advance(resume!),
enabled: false,
});
return params.get("provider") === "github" && resume &&
params.get("stage") !== "identity" && params.get("reconnect") !== "1" &&
progress.data?.state === "connected"
? <ChatDetailSidebar endpointId={resume} NavItem={NavItem} />
: <SetupWizardSidebar />;
}
export function ChatSetupNavigation(props: {
labels?: string[]; step: number; availableStep: number; disabled?: boolean; onSelect: (step: number) => void;
}) {
+21 -39
View File
@@ -1,7 +1,8 @@
import { ExternalLink } from "lucide-react";
import { ChevronRight, ExternalLink } from "lucide-react";
import type { ChatEndpoint } from "@/api/chatEndpoints";
import { AgentAvatarDownload } from "@/components/AgentAvatarDownload";
import { CopyText } from "@/components/CopyText";
import { Button } from "@/components/ui/button";
import { Link } from "@/lib/router";
export function gitHubBotMention(endpoint: Pick<ChatEndpoint, "botUsername">) {
@@ -44,61 +45,42 @@ export function GitHubAppBranding({
avatarUrl?: string;
}) {
return (
<details className="text-sm">
<summary className="cursor-pointer text-muted-foreground">
<details className="group text-sm">
<summary className="flex cursor-pointer list-none items-center gap-2 text-muted-foreground hover:text-foreground">
<ChevronRight className="size-4 shrink-0 transition-transform group-open:rotate-90" />
GitHub App name and logo
</summary>
<div className="mt-4 space-y-4">
<p>
This is your custom GitHub App
{endpoint.botLabel ? (
<>
: <strong>{endpoint.botLabel}</strong>.
</>
) : (
"."
)}
{endpoint.providerAccountLabel && (
<>
{" "}
Owned by <strong>{endpoint.providerAccountLabel}</strong>.
</>
)}
<p className="text-sm text-muted-foreground">
Change the name or upload a logo in the App’s Display information settings on GitHub.
</p>
<p className="text-xs text-muted-foreground">
GitHub derives the @mention from the App name. Upload your logo in the
App’s Display information settings.
</p>
{avatarUrl && (
<div className="flex flex-wrap items-center gap-4">
<div className="flex flex-wrap items-center gap-3">
{avatarUrl && <>
<img
src={avatarUrl}
alt={`${endpoint.assignedAgentName}’s avatar for download`}
className="size-20 rounded-lg bg-muted object-contain"
className="size-12 shrink-0 object-contain"
/>
<AgentAvatarDownload
avatarUrl={avatarUrl}
name={endpoint.botLabel ?? endpoint.assignedAgentName}
/>
</div>
)}
<a
href={gitHubAppSettingsUrl(endpoint)}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 underline underline-offset-4"
>
Edit App name and logo on GitHub <ExternalLink className="size-3" />
</a>
</>}
<Button variant="outline" asChild>
<a href={gitHubAppSettingsUrl(endpoint)} target="_blank" rel="noreferrer"
aria-label="Edit App name and logo on GitHub">
Edit on GitHub <ExternalLink className="size-4" />
</a>
</Button>
</div>
<p className="text-xs text-muted-foreground">
After renaming the App,{" "}
Renaming also changes its @mention.{" "}
<Link
className="underline underline-offset-4"
to={`/apps/chat/connect?provider=github&resume=${endpoint.id}&reconnect=1`}
>
reconnect this App
</Link>{" "}
to refresh its mention in Paperclip.
Reconnect afterward
</Link>{" "}to update it here.
</p>
</div>
</details>
@@ -314,9 +314,9 @@ describe("GitHub bot management", () => {
container.querySelector('button[aria-label="Copy GitHub mention"]')
?.textContent,
).toBe("@maya-reviews");
expect(container.textContent).toContain("This is your custom GitHub App");
expect(container.textContent).toContain("Maya Reviews");
const branding = [...container.querySelectorAll("a")].find((a) =>
a.textContent?.includes("Edit App name and logo"),
a.getAttribute("aria-label") === "Edit App name and logo on GitHub",
);
expect(branding?.href).toBe(
"https://github.com/organizations/acme/settings/apps/maya-reviews",
@@ -404,27 +404,22 @@ describe("GitHub bot management", () => {
await render("settings");
expect(container.querySelector("textarea")?.value).toBe("Unsaved behavior");
});
it("creates repository overrides without overwriting defaults or duplicating field IDs", async () => {
it("hides repository overrides while preserving saved overrides when other settings change", async () => {
const overrides = { "100": { instructions: "Retain repository guidance", events: ["opened"] } };
mocks.config.mockResolvedValue({
revision: 4,
configuration: { ...base, repositories: overrides },
});
await render();
await input(
container.querySelector<HTMLSelectElement>("#github-policy-repository")!,
"100",
);
await click("Use custom settings for this repository");
const areas = [
...container.querySelectorAll<HTMLTextAreaElement>(
'textarea[placeholder="What should this agent do on GitHub?"]',
),
];
expect(areas).toHaveLength(2);
await input(areas[1], "Repository guidance");
const ids = [...container.querySelectorAll("[id]")].map((e) => e.id);
expect(new Set(ids).size).toBe(ids.length);
expect(container.textContent).not.toContain("Repository overrides");
expect(container.querySelector("#github-policy-repository")).toBeNull();
expect(mocks.resources).not.toHaveBeenCalled();
await input(container.querySelector("textarea")!, "Updated default instructions");
await click("Save changes");
await vi.waitFor(() => expect(mocks.save).toHaveBeenCalled());
const saved = mocks.save.mock.calls[0][2];
expect(saved.defaults).toEqual(base.defaults);
expect(saved.repositories["100"].instructions).toBe("Repository guidance");
expect(saved.defaults.instructions).toBe("Updated default instructions");
expect(saved.repositories).toEqual(overrides);
});
it("configures implicit linked-member events without switching to a narrower selected-member list", async () => {
mocks.config.mockResolvedValue({
+2 -82
View File
@@ -35,7 +35,6 @@ import {
GitHubAccessEditor,
GitHubPolicyEditor,
GitHubToggle,
githubSelectClass,
} from "./GitHubBotConfiguration";
import { GitHubAppBranding } from "./GitHubAppIdentity";
@@ -179,13 +178,7 @@ export function GitHubBotManagement({
queryKey: ["github-bot-configuration", endpoint.id],
queryFn: () => githubChatApi.configuration(endpoint.id),
});
const resources = useQuery({
queryKey: ["github-bot-repositories", endpoint.id],
queryFn: () => chatEndpointsApi.listResources(endpoint.id),
enabled: view === "settings",
});
const [draft, setDraft] = useState<GitHubConfigurationRecord | null>(null);
const [repository, setRepository] = useState("");
const [pending, setPending] = useState(false);
const [notice, setNotice] = useState("");
const [error, setError] = useState("");
@@ -210,7 +203,7 @@ export function GitHubBotManagement({
setPending(false);
}
};
if (query.isError || (view === "settings" && resources.isError))
if (query.isError)
return (
<p role="alert" className="text-sm text-destructive">
Could not load the bot configuration.{" "}
@@ -218,24 +211,19 @@ export function GitHubBotManagement({
variant="link"
onClick={() => {
void query.refetch();
void resources.refetch();
}}
>
Try again
</Button>
</p>
);
if (!record || (view === "settings" && resources.isPending))
if (!record)
return (
<p role="status" className="text-sm text-muted-foreground">
Loading configuration…
</p>
);
const config = record.configuration;
const repositories = (resources.data ?? []).filter(
(resource) => resource.type === "repository",
);
const override = repository ? config.repositories[repository] : undefined;
return (
<section className="max-w-3xl space-y-8">
<fieldset disabled={pending} className="min-w-0 space-y-8">
@@ -309,74 +297,6 @@ export function GitHubBotManagement({
policy={config.defaults}
onChange={(defaults) => edit({ ...config, defaults })}
/>
<details className="text-sm">
<summary className="cursor-pointer text-muted-foreground">
Repository overrides
{Object.keys(config.repositories).length > 0
? ` (${Object.keys(config.repositories).length})`
: ""}
</summary>
<div className="mt-4 space-y-5">
<label
htmlFor="github-policy-repository"
className="block text-sm font-medium"
>
Repository
</label>
<select
id="github-policy-repository"
className={githubSelectClass}
value={repository}
onChange={(e) => setRepository(e.target.value)}
>
<option value="">Choose a repository</option>
{repositories
.filter(
(r) => r.enabled && r.metadata?.providerRepositoryId,
)
.map((r) => (
<option
key={r.id}
value={String(r.metadata?.providerRepositoryId)}
>
{r.label ?? r.providerResourceId}
</option>
))}
</select>
{repository && (
<GitHubToggle
label="Use custom settings for this repository"
checked={!!override}
onChange={(enabled) => {
const overrides = { ...config.repositories };
if (enabled)
overrides[repository] = { ...config.defaults };
else delete overrides[repository];
edit({ ...config, repositories: overrides });
}}
/>
)}
{repository && override && (
<GitHubPolicyEditor
policy={{ ...config.defaults, ...override }}
onChange={(policy) =>
edit({
...config,
repositories: {
...config.repositories,
[repository]: policy,
},
})
}
/>
)}
{repository && !override && (
<p className="text-xs text-muted-foreground">
Uses the settings above.
</p>
)}
</div>
</details>
<GitHubAppBranding endpoint={endpoint} avatarUrl={avatarUrl} />
</>
)}
@@ -72,6 +72,7 @@ vi.mock("@/api/chatEndpoints", () => ({
vi.mock("@/api/githubChat", () => ({
githubChatApi: {
advance: vi.fn(),
repositories: vi.fn(),
registration: vi.fn(),
restartRegistration: vi.fn(),
saveDraft: vi.fn(),
@@ -160,6 +161,9 @@ describe("GitHub App wizard", () => {
endpointId: "draft-1",
state: "create",
});
vi.mocked(githubChatApi.repositories).mockResolvedValue({
items: [], nextOffset: null, totalCount: 1, enabledCount: 1, availableCount: 1,
});
vi.mocked(githubChatApi.saveDraft).mockResolvedValue({ saved: true });
vi.mocked(githubChatApi.personalConnections).mockResolvedValue([]);
vi.mocked(copyTextToClipboard).mockResolvedValue();
@@ -586,7 +590,10 @@ describe("GitHub App wizard", () => {
expect(container.querySelector("h1")?.textContent).toBe("GitHub connected");
expect(container.textContent).toContain("Actual GitHub Name");
expect(container.textContent).toContain("GitHub App name and logo");
expect(container.textContent).toContain("acme/repo");
expect(container.textContent).toContain("1 repository enabled");
expect(container.querySelector("footer a")?.getAttribute("href")).toBe("/apps/chat/draft-1/settings");
expect(container.querySelector("details")?.hasAttribute("open")).toBe(false);
expect(container.textContent).toContain("Before your first review");
expect(container.textContent).toContain("Configure a runtime");
expect(container.textContent).not.toContain("Finish");
await click("Copy mention");
@@ -594,4 +601,35 @@ describe("GitHub App wizard", () => {
"@actual-agent review this pull request",
);
});
it("reports copy failure without undoing the completed connection", async () => {
fixture.endpoint = { ...fixture.endpoint, status: "active", botUsername: "actual-agent[bot]" };
vi.mocked(githubChatApi.advance).mockResolvedValue({ endpointId: "draft-1", state: "connected" });
vi.mocked(copyTextToClipboard).mockRejectedValueOnce(new Error("Clipboard unavailable"));
await render("resume=draft-1");
await click("Copy mention");
expect(container.textContent).toContain("Copy failed");
expect(container.querySelector("h1")?.textContent).toBe("GitHub connected");
expect(githubChatApi.registration).not.toHaveBeenCalled();
});
it("shows a useful next step when a connected bot has no enabled repositories", async () => {
vi.mocked(githubChatApi.advance).mockResolvedValue({ endpointId: "draft-1", state: "connected" });
vi.mocked(githubChatApi.repositories).mockResolvedValue({
items: [], nextOffset: null, totalCount: 2, enabledCount: 0, availableCount: 2,
});
await render("resume=draft-1");
expect(container.textContent).toContain("No repositories enabled");
expect(container.textContent).toContain("Enable a repository in Access to try your bot");
expect(container.querySelector('a[href="/apps/chat/draft-1/access"]')).not.toBeNull();
});
it("does not flash App creation controls while checking a resumed connection", async () => {
fixture.endpoint = { ...fixture.endpoint, status: "active", botUsername: "actual-agent[bot]" };
vi.mocked(githubChatApi.advance).mockReturnValue(new Promise(() => {}));
await render("resume=draft-1");
expect(container.textContent).toContain("Checking your GitHub connection");
expect(container.querySelector("#github-app-name")).toBeNull();
expect(container.textContent).not.toContain("I already have an App");
expect(container.textContent).not.toContain("GitHub connected");
});
});
+19 -55
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, ExternalLink, Loader2 } from "lucide-react";
import { ExternalLink, Loader2 } from "lucide-react";
import type { GitHubAppWizardState } from "@paperclipai/shared";
import { agentsApi } from "@/api/agents";
import { chatEndpointsApi } from "@/api/chatEndpoints";
@@ -21,10 +21,7 @@ import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useNavigate, useSearchParams, Link } from "@/lib/router";
import { buildPermissionsForTrustPreset } from "@/lib/trust-policy-ui";
import { copyTextToClipboard } from "@/lib/clipboard";
import { resolveAgentAppearance } from "@paperclipai/shared";
import { agentAvatarUrl } from "@/lib/agent-avatar-url";
import { GitHubAppBranding, gitHubBotMention } from "./GitHubAppIdentity";
import { GitHubConnectionComplete } from "./GitHubConnectionComplete";
/** Restrict native manifest submission to GitHub registration endpoints. */
export function gitHubAppManifestAction(
@@ -98,7 +95,6 @@ export function GitHubChatSetup() {
webhookSecret: "",
});
const [appNotCreated, setAppNotCreated] = useState(false);
const [copied, setCopied] = useState(false);
const [legacyAccount, setLegacyAccount] = useState("");
const [identityLinked, setIdentityLinked] = useState(false);
const [legacyIdentity, setLegacyIdentity] = useState<Awaited<
@@ -242,9 +238,10 @@ export function GitHubChatSetup() {
})}
</div>
);
const mention = `${(bot && gitHubBotMention(bot)) || "@your-bot"} review this pull request`;
return (
<div className="mx-auto w-full max-w-3xl space-y-6 px-4 py-6 sm:px-6">
<div className={connected
? "mx-auto w-full max-w-3xl space-y-6 py-6"
: "mx-auto w-full max-w-3xl space-y-6 px-4 py-6 sm:px-6"}>
{!identityOnly && !connected && (
<SetupWizardNavigation
labels={["Choose agent", "Connect GitHub"]}
@@ -255,15 +252,13 @@ export function GitHubChatSetup() {
takeover
/>
)}
<h1 className="text-2xl font-semibold">
{!connected && <h1 className="text-2xl font-semibold">
{!bot
? "Choose agent"
: connected
? "GitHub connected"
: identityOnly || state?.state === "identity"
: identityOnly || state?.state === "identity"
? "Connect your account"
: "Connect GitHub"}
</h1>
</h1>}
{(error || (!identityOnly && progress.error) || accounts.error) && (
<p role="alert" className="text-sm text-destructive">
{error ||
@@ -329,52 +324,21 @@ export function GitHubChatSetup() {
!agentId || !selectedCompanyId,
)}
</>
) : connected ? (
) : !identityOnly && !existing && progress.isPending ? (
<>
<div className="flex items-center gap-2 text-sm">
<CheckCircle2 className="size-4 text-(--status-task-done)" />
<span>
{bot.botLabel ?? bot.botUsername} · {bot.assignedAgentName}
</span>
</div>
<p className="text-sm">
{bot.resources
?.filter((resource) => resource.enabled)
.map((resource) => resource.label)
.join(", ")}
<p role="status" className="text-sm text-muted-foreground">
Checking your GitHub connection…
</p>
{state.runtimeChecks
?.filter((check) => !check.ok)
.map((check) => (
<p key={check.key} className="text-sm text-muted-foreground">
{check.detail}
</p>
))}
<p className="text-sm">Try a mention on a pull request:</p>
<div className="flex flex-wrap items-center gap-2">
<code className="text-sm">{mention}</code>
<Button
variant="outline"
onClick={() =>
void run(async () => {
await copyTextToClipboard(mention);
setCopied(true);
})
}
>
{copied ? "Copied" : "Copy mention"}
</Button>
</div>
<GitHubAppBranding endpoint={bot}
avatarUrl={selectedAgent ? agentAvatarUrl(resolveAgentAppearance(selectedAgent.appearance, bot.assignedAgentId), 512, 1, "rest") : undefined} />
<Link
className="text-sm underline"
to={`/apps/chat/${bot.id}/settings`}
>
Connection settings
</Link>
{footer()}
</>
) : connected ? (
<GitHubConnectionComplete
endpoint={bot}
agent={selectedAgent}
runtimeChecks={state.runtimeChecks}
onExit={exit}
pending={busy}
/>
) : existing ? (
<>
<p className="text-sm text-muted-foreground">
@@ -0,0 +1,106 @@
import { useQuery } from "@tanstack/react-query";
import { CheckCircle2, Copy } from "lucide-react";
import { resolveAgentAppearance, type GitHubAppWizardState } from "@paperclipai/shared";
import type { ChatEndpoint } from "@/api/chatEndpoints";
import { githubChatApi } from "@/api/githubChat";
import { AgentAvatar, type AvatarAgent } from "@/components/AgentAvatar";
import { AppLogo } from "../AppLogo";
import { CopyText } from "@/components/CopyText";
import { SetupWizardFooter } from "@/components/SetupWizard";
import { Button } from "@/components/ui/button";
import { agentAvatarUrl } from "@/lib/agent-avatar-url";
import { Link } from "@/lib/router";
import { GitHubAppBranding, gitHubBotMention } from "./GitHubAppIdentity";
export function GitHubConnectionComplete({
endpoint,
agent,
runtimeChecks,
onExit,
pending = false,
}: {
endpoint: ChatEndpoint;
agent?: AvatarAgent;
runtimeChecks?: GitHubAppWizardState["runtimeChecks"];
onExit: () => void;
pending?: boolean;
}) {
const repositories = useQuery({
queryKey: ["github-connected-repositories", endpoint.id],
queryFn: () => githubChatApi.repositories(endpoint.id, { limit: 1 }),
});
const avatar = agent ?? { id: endpoint.assignedAgentId, name: endpoint.assignedAgentName };
const mention = `${gitHubBotMention(endpoint) || "@your-bot"} review this pull request`;
const count = repositories.data?.enabledCount;
const incomplete = runtimeChecks?.filter((check) => !check.ok) ?? [];
return (
<div className="@container space-y-8">
<header className="space-y-5">
<div className="flex items-center gap-3">
<CheckCircle2 className="size-6 shrink-0 text-(--status-task-done)" />
<h1 className="text-xl font-semibold @md:text-2xl">GitHub connected</h1>
</div>
<div className="flex items-center gap-3">
<AgentAvatar agent={avatar} size={48} />
<div className="min-w-0 space-y-1">
<h2 className="break-words text-base font-semibold">
{endpoint.botLabel ?? endpoint.botUsername ?? endpoint.assignedAgentName}
</h2>
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground">
<Link to={`/agents/${endpoint.assignedAgentId}`} className="hover:text-foreground hover:underline">
{endpoint.assignedAgentName}
</Link>
{endpoint.providerAccountLabel && <>
<span aria-hidden="true">·</span>
<span className="inline-flex items-center gap-1.5">
<AppLogo name="GitHub" brandKey="github" compact className="size-4! rounded-sm bg-transparent" />
{endpoint.providerAccountLabel}
</span>
</>}
</p>
<Link to={`/apps/chat/${endpoint.id}/access`} className="inline-block text-xs text-muted-foreground underline underline-offset-4">
{repositories.isError ? "Repository access unavailable" : count === undefined ? "Repository access" : count === 0 ? "No repositories enabled" : `${count} ${count === 1 ? "repository" : "repositories"} enabled`}
</Link>
</div>
</div>
</header>
{incomplete.length > 0 && (
<section className="space-y-2">
<h2 className="text-sm font-medium">Before your first review</h2>
{incomplete.map((check) => (
<p key={check.key} className="text-sm text-muted-foreground">{check.detail}</p>
))}
</section>
)}
<section className="space-y-3">
<div className="space-y-1">
<h2 className="text-base font-semibold">Try your bot</h2>
<p className="text-sm text-muted-foreground">
{count === 0
? "Enable a repository in Access to try your bot."
: "Post this on a pull request in an enabled repository."}
</p>
</div>
<CopyText
text={mention}
ariaLabel="Copy mention"
title="Copy mention"
containerClassName="w-full"
className="flex w-full items-center gap-3 rounded-md border border-border bg-muted/30 p-3 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<code className="min-w-0 flex-1 break-words text-sm">{mention}</code>
<Copy className="size-4 shrink-0 text-muted-foreground" />
</CopyText>
</section>
<GitHubAppBranding
endpoint={endpoint}
avatarUrl={agentAvatarUrl(resolveAgentAppearance(avatar.appearance, endpoint.assignedAgentId), 512, 1, "rest")}
/>
<SetupWizardFooter onSaveExit={onExit} disabled={pending}>
<Button asChild>
<Link to={`/apps/chat/${endpoint.id}/settings`}>Bot settings</Link>
</Button>
</SetupWizardFooter>
</div>
);
}
@@ -27,6 +27,8 @@ export function FixtureApi({
});
client.setQueryData(queryKeys.chatEndpoints.detail(endpoint.id), endpoint);
client.setQueryData(queryKeys.agents.detail(agent.id), agent);
client.setQueryData(["github-setup", endpoint.id], endpoint);
client.setQueryData(["github-wizard", endpoint.id], { endpointId: endpoint.id, state: "connected" });
client.setQueryData(["github-members", endpoint.companyId], members);
return client;
});
@@ -65,6 +67,7 @@ export function FixtureApi({
);
}
const body = init?.body ? JSON.parse(String(init.body)) : {};
if (path.endsWith("/github/setup")) return Response.json({ endpointId: endpoint.id, state: "connected" });
if (path.endsWith("/repositories/access")) {
repos = repos.map((r) => (!body.enabled || r.availability === "available") ? { ...r, enabled: body.enabled } : r);
return Response.json({ success: true });
@@ -6,6 +6,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { Routes, Route } from "@/lib/router";
import { PluginLauncherProvider } from "@/plugins/launchers";
import { Layout } from "@/components/Layout";
import { GitHubChatSetup } from "@/pages/apps/chat/GitHubChatSetup";
import { GitHubConnectionComplete } from "@/pages/apps/chat/GitHubConnectionComplete";
import { ChatEndpointDetail } from "@/pages/apps/chat/ChatEndpointDetail";
import {
GitHubPolicyEditor,
@@ -40,7 +42,7 @@ const meta = {
docs: {
description: {
component:
"Production pages and components. Start with **01 Journey / Settings**, then use the actual contextual sidebar to visit Access, Reviews, and Conversations. Try editing instructions, switch tabs, and save or discard the shared draft. **02 States** covers empty, loading, recoverable error, long content, and mobile. **03 Components** isolates the policy editor, people access, repository access, review rows and detail, pending review, and conversations. Provider operations use isolated fixture APIs; these stories are not live GitHub evidence.",
"Production pages and components. Start with **01 Journey / Settings**, then use the actual contextual sidebar to visit Access, Reviews, and Conversations. Try editing instructions, switch tabs, and save or discard the shared draft. **01 Journey / Connected** uses the production completion page, then opens the same bot Settings. **02 States** covers empty, loading, recoverable error, long content, and mobile. **03 Components** isolates the policy editor, people access, repository access, review rows and detail, pending review, and conversations. Provider operations use isolated fixture APIs; these stories are not live GitHub evidence.",
},
},
},
@@ -56,6 +58,7 @@ const meta = {
<PluginLauncherProvider>
<Routes>
<Route path="/:companyPrefix" element={<Layout />}>
<Route path="apps/chat/connect" element={<GitHubChatSetup />} />
<Route
path="apps/chat/:endpointId/reviews/:reviewId"
element={<ChatEndpointDetail />}
@@ -75,6 +78,20 @@ type Story = StoryObj<typeof meta>;
const route = (tab: string) => ({
initialEntries: [`/PAP/apps/chat/${endpoint.id}/${tab}`],
});
export const ConnectedJourney: Story = {
name: "01 Journey / Connected — copy a mention or open settings",
parameters: { initialEntries: [`/PAP/apps/chat/connect?provider=github&resume=${endpoint.id}`] },
};
export const ConnectedMobile: Story = {
...ConnectedJourney,
name: "02 States / Mobile connected",
globals: { viewport: { value: "mobile1", isRotated: false } },
};
export const ConnectedNoRepositories: Story = {
...ConnectedJourney,
name: "02 States / Connected without enabled repositories",
args: { state: "empty" },
};
export const SettingsJourney: Story = { name: "01 Journey / Settings" };
export const AccessJourney: Story = {
name: "01 Journey / Access",
@@ -188,6 +205,21 @@ export const RepositoryAccess: Story = {
name: "03 Components / Repository access",
render: () => component(<Repositories />),
};
export const ConnectedSummary: Story = {
name: "03 Components / Connected summary",
render: () => component(<FixtureApi><GitHubConnectionComplete endpoint={endpoint} agent={agent} onExit={() => {}} /></FixtureApi>),
};
export const ConnectedRuntimeNotReady: Story = {
name: "03 Components / Connected — runtime needs setup",
render: () => component(<FixtureApi><GitHubConnectionComplete endpoint={endpoint} agent={agent} onExit={() => {}}
runtimeChecks={[{ key: "runtime", label: "Runtime", ok: false, detail: "Configure an isolated runtime before the first review." }]} /></FixtureApi>),
};
export const ConnectedLongName: Story = {
name: "03 Components / Connected — long App name",
render: () => component(<FixtureApi><GitHubConnectionComplete endpoint={{ ...endpoint,
botLabel: "Maya Platform Accessibility Reviews", botUsername: "maya-platform-accessibility-reviews[bot]",
providerAccountLabel: "acme-platform-engineering" }} agent={agent} onExit={() => {}} /></FixtureApi>),
};
export const AppIdentityAndLogo: Story = {
name: "03 Components / App identity and logo",
render: () =>