mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
Simplify GitHub bot completion and hide repository override editing
This commit is contained in:
1 parent
c8b0a1bf3c
commit
91d2a17b78
12 files changed
+302
-203
No files matched your search
@@ -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
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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: () =>
|
||||
|
||||
Reference in new issue
Block a user