mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
Separate GitHub bot opt-in and add review detail pages
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
7c1d2503db
commit
7dcdbe7eb6
36 files changed
+647
-204
No files matched your search
+5
-2
@@ -214,6 +214,9 @@ function boardRoutes(streamlinedUiEnabled: boolean, combinedInboxTasksEnabled: b
|
||||
<Route path="apps/chat/:endpointId" element={
|
||||
<ChatConnectorsExperimentalGate><Navigate to="settings" replace /></ChatConnectorsExperimentalGate>
|
||||
} />
|
||||
<Route path="apps/chat/:endpointId/reviews/:reviewId" element={
|
||||
<ChatConnectorsExperimentalGate><ChatEndpointDetail /></ChatConnectorsExperimentalGate>
|
||||
} />
|
||||
<Route path="apps/chat/:endpointId/:tab" element={
|
||||
<ChatConnectorsExperimentalGate><ChatEndpointDetail /></ChatConnectorsExperimentalGate>
|
||||
} />
|
||||
@@ -483,8 +486,8 @@ function AppsConnectEntryRoute({
|
||||
} = {}) {
|
||||
const location = useLocation();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const { enabled: chatConnectorsEnabled } = useChatConnectorsEnabled();
|
||||
return canEnterAppsConnect(searchParams, { chatConnectorsEnabled })
|
||||
const { enabled: chatConnectorsEnabled, githubEnabled: githubReviewBotsEnabled } = useChatConnectorsEnabled();
|
||||
return canEnterAppsConnect(searchParams, { chatConnectorsEnabled, githubReviewBotsEnabled })
|
||||
? <AppsConnect credentialSource={credentialSource} />
|
||||
: <Navigate to="/apps" replace />;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function AppsSidebar() {
|
||||
);
|
||||
|
||||
if (pathname.endsWith("/apps/chat/connect")) return <ChatSetupSidebar />;
|
||||
const chatDetail = pathname.match(/\/apps\/chat\/([^/]+)(?:\/(?:settings|access|reviews|conversations|activity))?\/?$/);
|
||||
const chatDetail = pathname.match(/\/apps\/chat\/([^/]+)(?:\/(?:settings|access|conversations|activity)|\/reviews(?:\/[^/]+)?)?\/?$/);
|
||||
if (chatDetail) return <ChatDetailSidebar endpointId={chatDetail[1]} NavItem={SidebarNavItem} />;
|
||||
|
||||
return (
|
||||
|
||||
@@ -27,7 +27,7 @@ export function AppsSidebar() {
|
||||
});
|
||||
|
||||
if (pathname.endsWith("/apps/chat/connect")) return <ChatSetupSidebar />;
|
||||
const chatDetail = pathname.match(/\/apps\/chat\/([^/]+)(?:\/(?:settings|access|reviews|conversations|activity))?\/?$/);
|
||||
const chatDetail = pathname.match(/\/apps\/chat\/([^/]+)(?:\/(?:settings|access|conversations|activity)|\/reviews(?:\/[^/]+)?)?\/?$/);
|
||||
if (chatDetail) return <ChatDetailSidebar endpointId={chatDetail[1]} />;
|
||||
|
||||
return (
|
||||
|
||||
@@ -104,6 +104,31 @@ describe("Chat connectors visibility gate", () => {
|
||||
await vi.waitFor(() => expect(Boolean(container.querySelector("[data-chat-setup]"))).toBe(provider === "agentmail"));
|
||||
if (provider !== "agentmail") expect(container.querySelector("[data-redirect]")).not.toBeNull();
|
||||
});
|
||||
it.each([true, false])("gates GitHub setup by its own flag, not chat (%s)", async (githubEnabled) => {
|
||||
api.provider = "github";
|
||||
api.settings.mockResolvedValue({ enableChatConnectors: !githubEnabled, enableGitHubReviewBots: githubEnabled });
|
||||
await render();
|
||||
expect(Boolean(container.querySelector("[data-chat-setup]"))).toBe(githubEnabled);
|
||||
});
|
||||
it.each(["github", "slack"])("gates each existing endpoint by provider with only GitHub enabled (%s)", async (provider) => {
|
||||
api.endpointId = "endpoint-1";
|
||||
api.get.mockResolvedValue({ provider });
|
||||
api.settings.mockResolvedValue({ enableChatConnectors: false, enableGitHubReviewBots: true });
|
||||
await render();
|
||||
await vi.waitFor(() => expect(api.get).toHaveBeenCalledWith("endpoint-1"));
|
||||
await vi.waitFor(() => expect(Boolean(container.querySelector("[data-chat-setup]"))).toBe(provider === "github"));
|
||||
});
|
||||
it("keeps GitHub agent channels visible while hiding Slack under the independent flag", async () => {
|
||||
api.settings.mockResolvedValue({ enableChatConnectors: false, enableGitHubReviewBots: true });
|
||||
api.list.mockResolvedValue([
|
||||
{ provider: "github", id: "github", assignedAgentId: "agent-1", status: "active" },
|
||||
{ provider: "slack", id: "slack", assignedAgentId: "agent-1", status: "active" },
|
||||
]);
|
||||
flushSync(() => root.render(<QueryClientProvider client={client}><AgentChannelsPanel companyId="company-1" agentId="agent-1" /></QueryClientProvider>));
|
||||
await flushReact();
|
||||
expect(container.querySelector('a[href="/apps/chat/github/settings"]')).not.toBeNull();
|
||||
expect(container.querySelector('a[href="/apps/chat/slack/settings"]')).toBeNull();
|
||||
});
|
||||
it("waits without exposing setup, then enables the route after explicit opt-in", async () => {
|
||||
let resolve!: (value: unknown) => void;
|
||||
api.settings.mockReturnValue(
|
||||
|
||||
@@ -3,22 +3,41 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { chatEndpointsApi } from "@/api/chatEndpoints";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useChatConnectorsEnabled } from "@/hooks/useChatConnectorsEnabled";
|
||||
import {
|
||||
useChatConnectorsEnabled,
|
||||
chatProviderVisible,
|
||||
} from "@/hooks/useChatConnectorsEnabled";
|
||||
import { Navigate, useParams, useSearchParams } from "@/lib/router";
|
||||
|
||||
function DefaultEmailEndpointGate({ endpointId, children }: { endpointId: string; children: ReactNode }) {
|
||||
function ProviderEndpointGate({
|
||||
endpointId,
|
||||
children,
|
||||
}: {
|
||||
endpointId: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const endpoint = useQuery({
|
||||
queryKey: queryKeys.chatEndpoints.detail(endpointId),
|
||||
queryFn: () => chatEndpointsApi.get(endpointId),
|
||||
});
|
||||
if (endpoint.isError) return (
|
||||
<div className="space-y-3 p-6">
|
||||
<p role="alert" className="text-sm text-destructive">{endpoint.error.message}</p>
|
||||
<Button variant="outline" onClick={() => void endpoint.refetch()}>Retry</Button>
|
||||
</div>
|
||||
);
|
||||
const { enabled, githubEnabled } = useChatConnectorsEnabled();
|
||||
if (endpoint.isError)
|
||||
return (
|
||||
<div className="space-y-3 p-6">
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{endpoint.error.message}
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => void endpoint.refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
if (!endpoint.data) return null;
|
||||
return endpoint.data.provider === "agentmail" ? <>{children}</> : <Navigate to="/apps" replace />;
|
||||
return chatProviderVisible(endpoint.data.provider, enabled, githubEnabled) ? (
|
||||
<>{children}</>
|
||||
) : (
|
||||
<Navigate to="/apps" replace />
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatConnectorsExperimentalGate({
|
||||
@@ -26,13 +45,21 @@ export function ChatConnectorsExperimentalGate({
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { enabled, loaded } = useChatConnectorsEnabled();
|
||||
const { enabled, githubEnabled, loaded } = useChatConnectorsEnabled();
|
||||
const [params] = useSearchParams();
|
||||
const { endpointId } = useParams<{ endpointId: string }>();
|
||||
if (!endpointId && params.get("provider") === "agentmail") return <>{children}</>;
|
||||
if (!endpointId && params.get("provider") === "agentmail")
|
||||
return <>{children}</>;
|
||||
if (!loaded) return null;
|
||||
if (!enabled) return endpointId
|
||||
? <DefaultEmailEndpointGate endpointId={endpointId}>{children}</DefaultEmailEndpointGate>
|
||||
: <Navigate to="/apps" replace />;
|
||||
return <>{children}</>;
|
||||
if (endpointId)
|
||||
return (
|
||||
<ProviderEndpointGate endpointId={endpointId}>
|
||||
{children}
|
||||
</ProviderEndpointGate>
|
||||
);
|
||||
return chatProviderVisible(params.get("provider"), enabled, githubEnabled) ? (
|
||||
<>{children}</>
|
||||
) : (
|
||||
<Navigate to="/apps" replace />
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { Link } from "@/lib/router";
|
||||
import { useChatConnectorsEnabled } from "@/hooks/useChatConnectorsEnabled";
|
||||
import { useChatConnectorsEnabled, chatProviderVisible } from "@/hooks/useChatConnectorsEnabled";
|
||||
|
||||
const providerNames: Record<ChatProvider, string> = {
|
||||
slack: "Slack",
|
||||
@@ -24,7 +24,7 @@ export function AgentChannelsPanel({
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
}) {
|
||||
const { enabled } = useChatConnectorsEnabled();
|
||||
const { enabled, githubEnabled } = useChatConnectorsEnabled();
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.chatEndpoints.list(companyId),
|
||||
queryFn: () => chatEndpointsApi.list(companyId),
|
||||
@@ -32,7 +32,7 @@ export function AgentChannelsPanel({
|
||||
});
|
||||
const endpoints = (query.data ?? []).filter(
|
||||
(endpoint) =>
|
||||
endpoint.assignedAgentId === agentId && endpoint.status !== "archived" && (endpoint.provider === "agentmail" || enabled),
|
||||
endpoint.assignedAgentId === agentId && endpoint.status !== "archived" && chatProviderVisible(endpoint.provider, enabled, githubEnabled),
|
||||
);
|
||||
return (
|
||||
<section className="max-w-3xl space-y-5">
|
||||
@@ -56,7 +56,7 @@ export function AgentChannelsPanel({
|
||||
<div className="rounded-lg border border-dashed border-border p-5">
|
||||
<p className="text-sm font-medium">No channels connected</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{enabled ? "Connect AgentMail, Slack, GitHub Code Review Bot, Discord, Microsoft Teams, or Telegram from Connectors." : "Connect AgentMail from Connectors."}
|
||||
{enabled || githubEnabled ? "Connect an agent identity from Connectors." : "Connect AgentMail from Connectors."}
|
||||
</p>
|
||||
<Button asChild className="mt-3" variant="outline" size="sm">
|
||||
<Link to="/apps">Open Connectors</Link>
|
||||
|
||||
@@ -51,7 +51,6 @@ export function ChatDetailSidebar({
|
||||
to={`/apps/chat/${endpointId}/reviews`}
|
||||
label="Reviews"
|
||||
icon={GitPullRequest}
|
||||
end
|
||||
/>
|
||||
)}
|
||||
<NavItem
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { Link } from "@/lib/router";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useChatConnectorsEnabled } from "@/hooks/useChatConnectorsEnabled";
|
||||
import { useChatConnectorsEnabled, chatProviderVisible } from "@/hooks/useChatConnectorsEnabled";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import {
|
||||
boardSendDraftKey,
|
||||
@@ -109,15 +109,15 @@ const filePhaseLabels: Record<ChatFileTransferPhase, string> = {
|
||||
};
|
||||
|
||||
export function useIssueChatBinding(companyId: string, issueId: string) {
|
||||
const { enabled } = useChatConnectorsEnabled();
|
||||
const queryEnabled = enabled && Boolean(companyId && issueId) && !issueId.startsWith("chat:");
|
||||
const { enabled, githubEnabled } = useChatConnectorsEnabled();
|
||||
const queryEnabled = (enabled || githubEnabled) && Boolean(companyId && issueId) && !issueId.startsWith("chat:");
|
||||
const query = useQuery({
|
||||
queryKey: ["issue-chat-binding", companyId, issueId],
|
||||
queryFn: () => chatEndpointsApi.getIssueBinding(issueId),
|
||||
enabled: queryEnabled,
|
||||
});
|
||||
return {
|
||||
binding: queryEnabled ? (query.data ?? null) : null,
|
||||
binding: queryEnabled && chatProviderVisible(query.data?.provider, enabled, githubEnabled) ? (query.data ?? null) : null,
|
||||
isLoading: queryEnabled && query.isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ import { useNavigate, useParams, useSearchParams } from "@/lib/router";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { useChatConnectorsEnabled } from "@/hooks/useChatConnectorsEnabled";
|
||||
import { useChatConnectorsEnabled, chatProviderVisible } from "@/hooks/useChatConnectorsEnabled";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { RadioCardGroup } from "@/components/ui/radio-card";
|
||||
import { ApiError } from "@/api/client";
|
||||
@@ -600,7 +600,7 @@ function StandardConnectionSetupFlow({
|
||||
}, [host, routeNavigate]);
|
||||
const routeParams = useParams<{ appKey?: string }>();
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { enabled: chatConnectorsEnabled } = useChatConnectorsEnabled();
|
||||
const { enabled: chatConnectorsEnabled, githubEnabled } = useChatConnectorsEnabled();
|
||||
const { enabled: memoryConnectorsEnabled } = useMemoryConnectorsEnabled();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const { pushToast } = useToast();
|
||||
@@ -985,11 +985,11 @@ function StandardConnectionSetupFlow({
|
||||
// Generic custom URLs remain usable without selecting a hidden provider.
|
||||
const visibleGalleryApps = useMemo(
|
||||
() => (galleryQuery.data?.apps ?? []).filter((app) => memoryConnectorsEnabled || !isMemoryConnectorId(app.slug)).filter((app) =>
|
||||
app.slug === "agentmail" || chatConnectorsEnabled ||
|
||||
chatProviderVisible(app.slug, chatConnectorsEnabled, githubEnabled) ||
|
||||
!app.methods.some((method) => method.transport === "chat_sdk") ||
|
||||
appSupportsToolCatalogSetup(app),
|
||||
),
|
||||
[galleryQuery.data, chatConnectorsEnabled, memoryConnectorsEnabled],
|
||||
[galleryQuery.data, chatConnectorsEnabled, githubEnabled, memoryConnectorsEnabled],
|
||||
);
|
||||
const fullRequestedDefinition = requestedAppKey
|
||||
? getConnectableAppDefinition(requestedAppKey)
|
||||
|
||||
@@ -9,10 +9,11 @@ import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
let detachedClient: QueryClient | null = null;
|
||||
|
||||
/** One default-off visibility gate; this does not pause existing provider delivery. */
|
||||
/** Independent default-off visibility gates; these do not pause existing provider delivery. */
|
||||
export function useChatConnectorsEnabled(): {
|
||||
enabled: boolean;
|
||||
loaded: boolean;
|
||||
githubEnabled: boolean;
|
||||
} {
|
||||
const contextClient = useContext(QueryClientContext);
|
||||
const { data, isFetched, isError } = useQuery(
|
||||
@@ -23,9 +24,25 @@ export function useChatConnectorsEnabled(): {
|
||||
},
|
||||
contextClient ?? (detachedClient ??= new QueryClient()),
|
||||
);
|
||||
if (!contextClient) return { enabled: false, loaded: true };
|
||||
if (!contextClient)
|
||||
return { enabled: false, githubEnabled: false, loaded: true };
|
||||
return {
|
||||
enabled: !isError && data?.enableChatConnectors === true,
|
||||
githubEnabled: !isError && data?.enableGitHubReviewBots === true,
|
||||
loaded: isFetched,
|
||||
};
|
||||
}
|
||||
|
||||
/** Provider visibility only; never changes an existing connection's permissions. */
|
||||
export function chatProviderVisible(
|
||||
provider: string | null | undefined,
|
||||
chatEnabled: boolean,
|
||||
githubEnabled: boolean,
|
||||
) {
|
||||
return (
|
||||
provider === "agentmail" ||
|
||||
(provider === "github" || provider === "github-code-review-bot"
|
||||
? githubEnabled
|
||||
: chatEnabled)
|
||||
);
|
||||
}
|
||||
@@ -769,7 +769,8 @@ export function AgentDetail() {
|
||||
const navigate = useNavigate();
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [dismissedLeftAgentIds, setDismissedLeftAgentIds] = useState<Set<string>>(() => new Set());
|
||||
const { enabled: chatConnectorsEnabled, loaded: chatConnectorsLoaded } = useChatConnectorsEnabled();
|
||||
const { enabled: chatEnabled, githubEnabled, loaded: chatConnectorsLoaded } = useChatConnectorsEnabled();
|
||||
const chatConnectorsEnabled = chatEnabled || githubEnabled;
|
||||
const activeView = urlRunId ? "runs" as AgentDetailView
|
||||
: urlTab === "channels" && !chatConnectorsEnabled ? "dashboard" : parseAgentDetailView(urlTab ?? null);
|
||||
const needsDashboardData = activeView === "dashboard";
|
||||
|
||||
@@ -87,6 +87,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
|
||||
enableMcpAggregators: true,
|
||||
enablePublicMcp: false,
|
||||
enableChatConnectors: false,
|
||||
enableGitHubReviewBots: false,
|
||||
enableMemoryConnectors: false,
|
||||
enablePipelines: false,
|
||||
enableCases: false,
|
||||
@@ -283,6 +284,18 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
|
||||
}
|
||||
});
|
||||
|
||||
it("toggles GitHub review bots without enabling chat connectors", async () => {
|
||||
await renderPage();
|
||||
const selector = 'button[aria-label="Toggle GitHub review bots experimental setting"]';
|
||||
expect(container.querySelector(selector)?.getAttribute("aria-checked")).toBe("false");
|
||||
for (const enabled of [true, false]) {
|
||||
await act(() => container.querySelector<HTMLButtonElement>(selector)!.click());
|
||||
await flushReact();
|
||||
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenLastCalledWith({ enableGitHubReviewBots: enabled });
|
||||
expect(currentExperimentalSettings.enableChatConnectors).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not render the Conference Room Chat experimental setting for now", async () => {
|
||||
await renderPage();
|
||||
|
||||
|
||||
@@ -354,8 +354,8 @@ export function InstanceExperimentalSettings() {
|
||||
|
||||
<ExperimentalToggleCard
|
||||
title="Chat connectors"
|
||||
description="Connect agents to Slack, GitHub, Discord, Microsoft Teams, and Telegram conversations."
|
||||
footnote="Turning this off hides experimental chat setup and connected-task controls. Existing chat connections keep running. AgentMail, GitHub tools, and other tool connectors stay available."
|
||||
description="Connect agents to Slack, Discord, Microsoft Teams, and Telegram conversations."
|
||||
footnote="Turning this off hides experimental chat setup and connected-task controls. Existing chat connections keep running. AgentMail, GitHub review bots, and tool connectors are controlled separately."
|
||||
checked={enableChatConnectors}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate({ enableChatConnectors: checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
@@ -457,6 +457,18 @@ export function InstanceExperimentalSettings() {
|
||||
ariaLabel="Toggle first task plan proposal experimental setting"
|
||||
/>
|
||||
|
||||
<ExperimentalToggleCard
|
||||
title="GitHub review bots"
|
||||
description="Connect agents to GitHub with their own App identity to review pull requests and respond to issues."
|
||||
footnote="Independent of Chat connectors. Turning this off hides setup and management; existing bots keep running. GitHub tool connections stay available."
|
||||
checked={experimentalQuery.data?.enableGitHubReviewBots === true}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate({ enableGitHubReviewBots: checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="enableGitHubReviewBots"
|
||||
managed={managedKeys.enableGitHubReviewBots}
|
||||
ariaLabel="Toggle GitHub review bots experimental setting"
|
||||
/>
|
||||
|
||||
<ExperimentalToggleCard
|
||||
title="Memory connectors"
|
||||
description="Connect Mem0, Zep, Supermemory, Cognee, and Honcho for long-term memory and context."
|
||||
|
||||
@@ -189,7 +189,7 @@ describe("Connectors landing page", () => {
|
||||
manageComposioAppAccountMock.mockReset();
|
||||
listAgentsMock.mockResolvedValue([{ id: "default-agent", name: "Default agent", role: "ceo", reportsTo: null, status: "active", createdAt: new Date(0) }]);
|
||||
aggregatorCatalogMock.splice(0);
|
||||
experimentalMock.mockResolvedValue({ enableChatConnectors: true });
|
||||
experimentalMock.mockResolvedValue({ enableChatConnectors: true, enableGitHubReviewBots: true });
|
||||
chatListMock.mockResolvedValue([]);
|
||||
chatSetupMock.mockReset().mockResolvedValue({ status: "archived" });
|
||||
emailControlMock.mockReset().mockResolvedValue({ status: "archived" });
|
||||
@@ -993,7 +993,7 @@ describe("Connectors landing page", () => {
|
||||
expect(container.querySelector('button[aria-label="Add key GitHub"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("separates tools and saved bots and hides only bots when chat connectors are disabled", async () => {
|
||||
it("separates tools and saved bots and gates bots independently of chat connectors", async () => {
|
||||
listGalleryMock.mockResolvedValue({ apps: ["github", "github-code-review-bot"].map(getAppStoreDefinition) });
|
||||
listApplicationsMock.mockResolvedValue({ applications: [
|
||||
application({ id: "github-tools", name: "GitHub", metadata: { sourceTemplateKey: "github" } }),
|
||||
@@ -1023,7 +1023,10 @@ describe("Connectors landing page", () => {
|
||||
const finish = [...bots.querySelectorAll("button")].find((button) => button.textContent === "Finish setup")!;
|
||||
await act(() => finish.click());
|
||||
expect(navigateMock).toHaveBeenLastCalledWith("/apps/chat/connect?provider=github&purpose=chat&resume=endpoint-2");
|
||||
await act(() => { client.setQueryData(queryKeys.instance.experimentalSettings, { enableChatConnectors: false }); });
|
||||
await act(() => { client.setQueryData(queryKeys.instance.experimentalSettings, { enableChatConnectors: false, enableGitHubReviewBots: true }); });
|
||||
await flushReact();
|
||||
expect(container.querySelector('[data-app-slug="github-code-review-bot"]')).not.toBeNull();
|
||||
await act(() => { client.setQueryData(queryKeys.instance.experimentalSettings, { enableChatConnectors: true, enableGitHubReviewBots: false }); });
|
||||
await flushReact();
|
||||
expect(container.querySelector('[data-app-slug="github-code-review-bot"]')).toBeNull();
|
||||
expect(container.textContent).not.toContain("Review agent");
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
normalizeConnectionQuery,
|
||||
} from "@paperclipai/shared";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { useChatConnectorsEnabled } from "@/hooks/useChatConnectorsEnabled";
|
||||
import { useChatConnectorsEnabled, chatProviderVisible } from "@/hooks/useChatConnectorsEnabled";
|
||||
import { useMemoryConnectorsEnabled } from "@/hooks/useMemoryConnectorsEnabled";
|
||||
import { appCopyFor } from "@/lib/app-gallery-copy";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
@@ -332,7 +332,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const assistantConnections = useAssistantConnections();
|
||||
const { userId: viewingUserId, settled: identitySettled } = useAccountIdentity();
|
||||
const { enabled: chatConnectorsEnabled } = useChatConnectorsEnabled();
|
||||
const { enabled: chatConnectorsEnabled, githubEnabled } = useChatConnectorsEnabled();
|
||||
const { enabled: memoryConnectorsEnabled } = useMemoryConnectorsEnabled();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -486,7 +486,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
|
||||
if (!memoryConnectorsEnabled && isMemoryConnectorId(appDefinitionSlug(entry))) return false;
|
||||
const definition = getAppStoreDefinition(appDefinitionSlug(entry));
|
||||
return (
|
||||
appDefinitionSlug(entry) === "agentmail" || chatConnectorsEnabled ||
|
||||
chatProviderVisible(appDefinitionSlug(entry), chatConnectorsEnabled, githubEnabled) ||
|
||||
!definition?.methods.some((method) => method.purpose === "channel") ||
|
||||
appSupportsToolCatalogSetup(definition)
|
||||
);
|
||||
@@ -536,7 +536,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
|
||||
slug,
|
||||
name: appDefinitionName(entry),
|
||||
description:
|
||||
!chatConnectorsEnabled && slug !== "agentmail" && chatProviderForSlug(slug)
|
||||
!chatProviderVisible(slug, chatConnectorsEnabled, githubEnabled) && chatProviderForSlug(slug)
|
||||
? appCopyFor(slug).tagline
|
||||
: appDefinitionDescription(entry),
|
||||
brandKey: slug,
|
||||
@@ -581,7 +581,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
|
||||
"Chat with agents from Telegram direct messages, groups, and topics.",
|
||||
},
|
||||
] as const;
|
||||
for (const item of nativeChatApps.filter(item => item.slug === "agentmail" || chatConnectorsEnabled)) {
|
||||
for (const item of nativeChatApps.filter(item => chatProviderVisible(item.slug, chatConnectorsEnabled, githubEnabled))) {
|
||||
if (rowsBySlug.has(item.slug)) continue;
|
||||
rowsBySlug.set(item.slug, {
|
||||
key: `native-chat:${item.slug}`,
|
||||
@@ -678,7 +678,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
|
||||
});
|
||||
}
|
||||
|
||||
for (const endpoint of (chatEndpointsQuery.data ?? []).filter(endpoint => endpoint.provider === "agentmail" || chatConnectorsEnabled)) {
|
||||
for (const endpoint of (chatEndpointsQuery.data ?? []).filter(endpoint => chatProviderVisible(endpoint.provider, chatConnectorsEnabled, githubEnabled))) {
|
||||
if (endpoint.status === "archived") continue;
|
||||
let target = [...rowsBySlug.values()].find(
|
||||
(row) => chatProviderForSlug(row.slug) === endpoint.provider,
|
||||
@@ -754,6 +754,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
|
||||
applicationsQuery.data,
|
||||
chatEndpointsQuery.data,
|
||||
chatConnectorsEnabled,
|
||||
githubEnabled,
|
||||
connectionsQuery.data,
|
||||
gallery,
|
||||
galleryQuery.data,
|
||||
@@ -930,6 +931,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
|
||||
onRequestRemove={target => void requestConnectionRemoval(target)}
|
||||
preselectedAgentId={preselectedChatAgentId}
|
||||
chatConnectorsEnabled={chatConnectorsEnabled}
|
||||
githubReviewBotsEnabled={githubEnabled}
|
||||
onConnectAggregator={connectAggregator}
|
||||
onManageAggregator={(app, connectionId) => setAggregatorToManage({ app, connectionId })}
|
||||
onRefreshAggregator={connectionId => refreshAggregator.mutate(connectionId)}
|
||||
@@ -1026,6 +1028,7 @@ export function ConnectorCard({
|
||||
onRequestRemove,
|
||||
preselectedAgentId,
|
||||
chatConnectorsEnabled,
|
||||
githubReviewBotsEnabled = false,
|
||||
onConnectAggregator,
|
||||
onManageAggregator,
|
||||
onRefreshComposio,
|
||||
@@ -1044,6 +1047,7 @@ export function ConnectorCard({
|
||||
onRequestRemove: (target: ConnectionRemovalTarget) => void;
|
||||
preselectedAgentId?: string | null;
|
||||
chatConnectorsEnabled: boolean;
|
||||
githubReviewBotsEnabled?: boolean;
|
||||
onConnectAggregator?: (app: AggregatorAppCatalogEntry) => void;
|
||||
onManageAggregator?: (app: AggregatorAppCatalogEntry, connectionId?: string) => void;
|
||||
onRefreshComposio?: (connectionId: string) => void;
|
||||
@@ -1056,7 +1060,7 @@ export function ConnectorCard({
|
||||
}) {
|
||||
const action = connectorAction(
|
||||
row,
|
||||
chatConnectorsEnabled,
|
||||
chatProviderVisible(row.slug, chatConnectorsEnabled, githubReviewBotsEnabled),
|
||||
preselectedAgentId,
|
||||
);
|
||||
const upstreamAccounts = (row.upstreamApps ?? []).flatMap(snapshot => {
|
||||
|
||||
@@ -83,7 +83,7 @@ describe("app connect policy", () => {
|
||||
const href = appSourceConnectHref(app.slug);
|
||||
const searchParams = new URL(href, "http://paperclip.test").searchParams;
|
||||
|
||||
expect(canEnterAppsConnect(searchParams, { chatConnectorsEnabled: true }), app.slug).toBe(true);
|
||||
expect(canEnterAppsConnect(searchParams, { chatConnectorsEnabled: true, githubReviewBotsEnabled: true }), app.slug).toBe(true);
|
||||
expect(resolveAppsConnectRouteKey({ sourceSlug: searchParams.get("source") }), app.slug).toBe(app.slug);
|
||||
}
|
||||
});
|
||||
@@ -97,6 +97,13 @@ describe("app connect policy", () => {
|
||||
expect(resolveAppsConnectRouteKey({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("admits the GitHub bot independently of chat setup and keeps tools available", () => {
|
||||
const bot = new URLSearchParams("source=github-code-review-bot");
|
||||
expect(canEnterAppsConnect(bot, { chatConnectorsEnabled: true })).toBe(false);
|
||||
expect(canEnterAppsConnect(bot, { githubReviewBotsEnabled: true })).toBe(true);
|
||||
expect(canEnterAppsConnect(new URLSearchParams("source=discord"), { githubReviewBotsEnabled: true })).toBe(false);
|
||||
expect(canEnterAppsConnect(new URLSearchParams("source=github"))).toBe(true);
|
||||
});
|
||||
it("retains GitHub tools but denies chat-only deep links while chat connectors are disabled", () => {
|
||||
expect(canEnterAppsConnect(new URLSearchParams("source=github"))).toBe(true);
|
||||
expect(canEnterAppsConnect(new URLSearchParams("source=agentmail"))).toBe(true);
|
||||
|
||||
@@ -55,7 +55,7 @@ export function resolveAppsConnectRouteKey(input: {
|
||||
|
||||
export function canEnterAppsConnect(
|
||||
searchParams: URLSearchParams,
|
||||
{ chatConnectorsEnabled = false }: { chatConnectorsEnabled?: boolean } = {},
|
||||
{ chatConnectorsEnabled = false, githubReviewBotsEnabled = false }: { chatConnectorsEnabled?: boolean; githubReviewBotsEnabled?: boolean } = {},
|
||||
): boolean {
|
||||
if (searchParams.get("byo") === "1") {
|
||||
// The old BYO discovery page has moved to the Connectors list. Keep only
|
||||
@@ -74,8 +74,9 @@ export function canEnterAppsConnect(
|
||||
// actionable unavailable state rather than bouncing back to the catalog.
|
||||
if (/^ai-router-(?:[a-f0-9]{2})+$/.test(source)) return true;
|
||||
const entry = getAppStoreDefinition(source);
|
||||
const channelEnabled = source === "github-code-review-bot" ? githubReviewBotsEnabled : chatConnectorsEnabled;
|
||||
if (
|
||||
!chatConnectorsEnabled &&
|
||||
!channelEnabled &&
|
||||
entry?.methods.some((method) => method.transport === "chat_sdk") &&
|
||||
!entry.methods.some((method) => (method.purpose ?? "tool") !== "channel" && method.transport !== "chat_sdk")
|
||||
) return false;
|
||||
@@ -83,5 +84,5 @@ export function canEnterAppsConnect(
|
||||
// setup. Admit only known providers here; the setup flow then proves the
|
||||
// exact reconnect target is visible to the selected company before rendering.
|
||||
if (getConnectableAppDefinition(source) && searchParams.get("reconnect")?.trim()) return true;
|
||||
return source === "agentmail" || chatConnectorsEnabled ? appSupportsCatalogSetup(entry) : appSupportsToolCatalogSetup(entry);
|
||||
return source === "agentmail" || channelEnabled ? appSupportsCatalogSetup(entry) : appSupportsToolCatalogSetup(entry);
|
||||
}
|
||||
@@ -227,10 +227,12 @@ export function connectionHealthPresentation(
|
||||
}
|
||||
|
||||
export function ChatEndpointDetail() {
|
||||
const { endpointId = "", tab = "settings" } = useParams<{
|
||||
const { endpointId = "", tab: routeTab, reviewId } = useParams<{
|
||||
endpointId: string;
|
||||
tab?: string;
|
||||
reviewId?: string;
|
||||
}>();
|
||||
const tab = reviewId ? "reviews" : (routeTab ?? "settings");
|
||||
const activeTab = tabs.includes(tab as ChatTab) ? (tab as ChatTab) : null;
|
||||
const navigate = useNavigate();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
@@ -260,14 +262,14 @@ export function ChatEndpointDetail() {
|
||||
label: `${endpoint.assignedAgentName} · ${providerNames[endpoint.provider]}`,
|
||||
href: `/apps/chat/${endpoint.id}/settings`,
|
||||
},
|
||||
{
|
||||
...(reviewId ? [{ label: "Reviews", href: `/apps/chat/${endpoint.id}/reviews` }, { label: "Review" }] : [{
|
||||
label:
|
||||
tabItems.find((item) => item.value === activeTab)?.label ??
|
||||
"Settings",
|
||||
},
|
||||
}]),
|
||||
]);
|
||||
return () => setBreadcrumbs([]);
|
||||
}, [activeTab, endpoint, setBreadcrumbs]);
|
||||
}, [activeTab, endpoint, reviewId, setBreadcrumbs]);
|
||||
|
||||
if (!activeTab)
|
||||
return <Navigate replace to={`/apps/chat/${endpointId}/settings`} />;
|
||||
@@ -339,7 +341,7 @@ export function ChatEndpointDetail() {
|
||||
<GitHubBotManagement key={endpoint.id} endpoint={endpoint} view={activeTab === "access" ? "access" : "settings"} />
|
||||
</div>}
|
||||
{activeTab === "settings" && endpoint.provider !== "github" && <Settings endpointId={endpoint.id} endpoint={endpoint} />}
|
||||
{activeTab === "reviews" && endpoint.provider === "github" && <GitHubReviews endpointId={endpoint.id} />}
|
||||
{activeTab === "reviews" && endpoint.provider === "github" && <GitHubReviews endpointId={endpoint.id} reviewId={reviewId} />}
|
||||
{activeTab === "access" && endpoint.provider === "agentmail" && <EmailAccess endpoint={endpoint} />}
|
||||
{activeTab === "access" && endpoint.provider !== "github" && endpoint.provider !== "agentmail" && (
|
||||
<Access
|
||||
|
||||
@@ -11,7 +11,7 @@ import { accessApi } from "@/api/access";
|
||||
import { chatEndpointsApi } from "@/api/chatEndpoints";
|
||||
import { githubChatApi } from "@/api/githubChat";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Copy, MoreHorizontal } from "lucide-react";
|
||||
import { Copy, HelpCircle, MoreHorizontal } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -23,6 +23,11 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
import { Link } from "@/lib/router";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
export const githubSelectClass =
|
||||
"w-full rounded-md border border-input bg-background px-3 py-2 text-sm";
|
||||
@@ -34,9 +39,35 @@ const eventLabels = {
|
||||
mention: "Mention",
|
||||
comment: "Follow-up comment",
|
||||
};
|
||||
function GitHubHelp({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Tooltip open={open} onOpenChange={setOpen}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`About ${label}`}
|
||||
className="inline-flex shrink-0 items-center text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<HelpCircle className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">{children}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitHubToggle({
|
||||
label,
|
||||
description,
|
||||
help,
|
||||
checked,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
@@ -44,6 +75,7 @@ export function GitHubToggle({
|
||||
}: {
|
||||
label: string;
|
||||
description?: string;
|
||||
help?: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
ariaLabel?: string;
|
||||
@@ -52,7 +84,10 @@ export function GitHubToggle({
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="break-words text-sm font-medium">{label}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="break-words text-sm font-medium">{label}</p>
|
||||
{help && <GitHubHelp label={label}>{help}</GitHubHelp>}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
|
||||
)}
|
||||
@@ -392,12 +427,13 @@ export function GitHubPolicyEditor({
|
||||
<h3 className="text-sm font-medium">Formal review actions</h3>
|
||||
<GitHubToggle
|
||||
label="Allow approvals"
|
||||
description="The agent must choose this action. A 5/5 score does not approve a PR."
|
||||
help="Lets the agent submit an Approve review on GitHub after a complete assessment. The agent must explicitly choose it; a 5/5 score or passing check does not approve the PR. Off still allows scores, comments, and checks."
|
||||
checked={policy.allowApprove}
|
||||
onChange={(value) => set("allowApprove", value)}
|
||||
/>
|
||||
<GitHubToggle
|
||||
label="Allow request changes"
|
||||
help="Lets the agent submit a Request changes review on GitHub after a complete assessment. This is a formal review decision and may prevent merging under your repository rules. Off still allows findings, comments, and a failing check."
|
||||
checked={policy.allowRequestChanges}
|
||||
onChange={(value) => set("allowRequestChanges", value)}
|
||||
/>
|
||||
@@ -597,8 +633,9 @@ export function GitHubAccessEditor({
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<GitHubToggle
|
||||
label="Automatic events"
|
||||
ariaLabel={`Automatic events for @${person.login}`}
|
||||
label="Run automatically"
|
||||
ariaLabel={`Run automatically for @${person.login}`}
|
||||
help="Lets PRs and issues authored by this person start work without mentioning the bot. Only events enabled in Settings run. Turn this off to keep this person’s use mention-only."
|
||||
checked={person.automaticReviews}
|
||||
onChange={(value) =>
|
||||
onChange({
|
||||
@@ -699,7 +736,7 @@ export function GitHubAccessEditor({
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setKind("guest")}>
|
||||
Allow external contributor
|
||||
Add external contributor
|
||||
</Button>
|
||||
{kind === "guest" && (
|
||||
<div className="space-y-4 rounded-lg border border-border p-4">
|
||||
@@ -782,26 +819,41 @@ export function GitHubAccessEditor({
|
||||
})
|
||||
}
|
||||
>
|
||||
Allow this account
|
||||
Add contributor
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-muted-foreground">
|
||||
Automatic task ownership
|
||||
Automatic task responsibility
|
||||
</summary>
|
||||
<div className="mt-4 space-y-2">
|
||||
{activeMembers.length === 1 && responsible ? (
|
||||
<p>
|
||||
Responsible member:{" "}
|
||||
<span className="inline-flex items-center gap-2">
|
||||
Responsible member{" "}
|
||||
<GitHubHelp label="Responsible member">
|
||||
The Paperclip member accountable for automatically created
|
||||
tasks. This does not change the GitHub author or grant the bot
|
||||
access to the member’s personal credentials.
|
||||
</GitHubHelp>
|
||||
</span>
|
||||
:{" "}
|
||||
{responsible.user?.name ??
|
||||
responsible.user?.email ??
|
||||
responsible.principalId}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<Label htmlFor="github-responsible">Responsible member</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="github-responsible">Responsible member</Label>
|
||||
<GitHubHelp label="Responsible member">
|
||||
The Paperclip member accountable for automatically created
|
||||
tasks. This does not change the GitHub author or grant the bot
|
||||
access to the member’s personal credentials.
|
||||
</GitHubHelp>
|
||||
</div>
|
||||
<select
|
||||
id="github-responsible"
|
||||
className={githubSelectClass}
|
||||
@@ -829,8 +881,8 @@ export function GitHubAccessEditor({
|
||||
</>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Accountable for automatic tasks. GitHub authors remain recorded
|
||||
separately.
|
||||
Automatically created tasks are attributed to this member in
|
||||
Paperclip. The GitHub author is recorded separately.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type GitHubTaskReview,
|
||||
} from "@paperclipai/shared";
|
||||
import { ChatEndpointDetail } from "./ChatEndpointDetail";
|
||||
import { GitHubReviewList, groupGitHubReviews } from "./GitHubBotManagement";
|
||||
import { GitHubReviewList, orderedGitHubReviews } from "./GitHubBotManagement";
|
||||
import { GitHubPolicyEditor } from "./GitHubBotConfiguration";
|
||||
import { conversationDestination } from "./ChatConversationList";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
@@ -17,6 +17,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
tab: "settings",
|
||||
reviewId: undefined as string | undefined,
|
||||
get: vi.fn(),
|
||||
config: vi.fn(),
|
||||
save: vi.fn(),
|
||||
@@ -56,7 +57,11 @@ vi.mock("@/components/MarkdownBody", () => ({
|
||||
),
|
||||
}));
|
||||
vi.mock("@/lib/router", () => ({
|
||||
useParams: () => ({ endpointId: "bot", tab: mocks.tab }),
|
||||
useParams: () => ({
|
||||
endpointId: "bot",
|
||||
tab: mocks.tab,
|
||||
reviewId: mocks.reviewId,
|
||||
}),
|
||||
useNavigate: () => vi.fn(),
|
||||
Link: ({
|
||||
children,
|
||||
@@ -108,6 +113,7 @@ describe("GitHub bot management", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.tab = "settings";
|
||||
mocks.reviewId = undefined;
|
||||
mocks.get.mockResolvedValue(endpoint);
|
||||
mocks.config.mockResolvedValue({
|
||||
revision: 4,
|
||||
@@ -216,7 +222,7 @@ describe("GitHub bot management", () => {
|
||||
await input(container.querySelector("textarea")!, "Edited instructions");
|
||||
await render("access");
|
||||
await vi.waitFor(() => expect(container.textContent).toContain("@maya"));
|
||||
await click("Automatic events for @maya");
|
||||
await click("Run automatically for @maya");
|
||||
await render("reviews");
|
||||
await render("settings");
|
||||
expect(container.querySelector("textarea")?.value).toBe(
|
||||
@@ -312,11 +318,52 @@ describe("GitHub bot management", () => {
|
||||
expect(mocks.save.mock.calls[0][2].memberAccess).toBe("all_linked");
|
||||
expect(mocks.save.mock.calls[0][2].people[0].automaticReviews).toBe(false);
|
||||
});
|
||||
it("does not show an off switch or silently enable a retained disabled bot", async () => {
|
||||
mocks.config.mockResolvedValue({
|
||||
revision: 4,
|
||||
configuration: { ...base, toolsEnabled: false },
|
||||
});
|
||||
await render("access");
|
||||
expect(
|
||||
container.querySelector('[aria-label="Use this bot’s GitHub tools"]'),
|
||||
).toBeNull();
|
||||
expect(container.textContent).toContain("cannot start work or respond");
|
||||
await render("settings");
|
||||
await input(container.querySelector("textarea")!, "Edited instructions");
|
||||
await click("Save changes");
|
||||
await vi.waitFor(() => expect(mocks.save).toHaveBeenCalled());
|
||||
expect(mocks.save.mock.calls[0][2].toolsEnabled).toBe(false);
|
||||
});
|
||||
it("loads a direct review URL and keeps unknown reviews within the current connection", async () => {
|
||||
mocks.reviewId = "review-1";
|
||||
mocks.reviews.mockResolvedValue([
|
||||
review("review-1", "repo", 1, "2026-10-07T10:00:00Z"),
|
||||
]);
|
||||
await render("reviews");
|
||||
await vi.waitFor(() =>
|
||||
expect(container.textContent).toContain(
|
||||
"No assessment has been submitted yet.",
|
||||
),
|
||||
);
|
||||
expect(
|
||||
container.querySelector('a[href="/apps/chat/bot/reviews"]')?.textContent,
|
||||
).toContain("All reviews");
|
||||
mocks.reviewId = "foreign";
|
||||
await render("reviews");
|
||||
await vi.waitFor(() =>
|
||||
expect(container.textContent).toContain("not found in this connection"),
|
||||
);
|
||||
expect(container.textContent).not.toContain(
|
||||
"No assessment has been submitted yet.",
|
||||
);
|
||||
});
|
||||
it("keeps saved automatic triggers when changing to mentions-only", async () => {
|
||||
const change = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<GitHubPolicyEditor policy={base.defaults} onChange={change} />,
|
||||
<TooltipProvider>
|
||||
<GitHubPolicyEditor policy={base.defaults} onChange={change} />
|
||||
</TooltipProvider>,
|
||||
),
|
||||
);
|
||||
await input(
|
||||
@@ -355,7 +402,7 @@ function review(
|
||||
} as GitHubTaskReview;
|
||||
}
|
||||
describe("review history and thread labels", () => {
|
||||
it("groups by repository and PR and orders by creation, not late updates to an older head", () => {
|
||||
it("keeps every review in creation order, independent of late updates to an older head", () => {
|
||||
const old = review(
|
||||
"old",
|
||||
"repo",
|
||||
@@ -365,9 +412,10 @@ describe("review history and thread labels", () => {
|
||||
);
|
||||
const current = review("current", "repo", 1, "2026-10-07T11:00:00Z");
|
||||
const other = review("other", "another-repo", 1, "2026-10-07T12:00:00Z");
|
||||
expect(groupGitHubReviews([old, current, other])).toEqual([
|
||||
[other],
|
||||
[current, old],
|
||||
expect(orderedGitHubReviews([old, current, other])).toEqual([
|
||||
other,
|
||||
current,
|
||||
old,
|
||||
]);
|
||||
});
|
||||
it("does not present a previous passing score as the pending current commit’s result", async () => {
|
||||
@@ -388,18 +436,22 @@ describe("review history and thread labels", () => {
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () =>
|
||||
root.render(<GitHubReviewList reviews={[old, current]} />),
|
||||
root.render(
|
||||
<GitHubReviewList endpointId="bot" reviews={[old, current]} />,
|
||||
),
|
||||
);
|
||||
const article = container.querySelector("article")!;
|
||||
const latest = article.cloneNode(true) as HTMLElement;
|
||||
latest.querySelectorAll("details").forEach((details) => details.remove());
|
||||
expect(latest.textContent).not.toContain("5/5");
|
||||
expect(latest.textContent).toContain("Queued");
|
||||
const history = [...article.querySelectorAll("details")].find((e) =>
|
||||
e.querySelector("summary")?.textContent?.includes("earlier review"),
|
||||
const rows = [...container.querySelectorAll("li")];
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].textContent).toContain("Queued");
|
||||
expect(rows[0].textContent).not.toContain("5/5");
|
||||
expect(rows[0].querySelector("a")?.getAttribute("href")).toBe(
|
||||
"/apps/chat/bot/reviews/current",
|
||||
);
|
||||
expect(history?.textContent).toContain("5/5 · Passed");
|
||||
expect(history?.open).toBe(false);
|
||||
expect(rows[1].textContent).toContain("5/5 · Passed");
|
||||
expect(rows[1].querySelector("a")?.getAttribute("href")).toBe(
|
||||
"/apps/chat/bot/reviews/old",
|
||||
);
|
||||
expect(container.querySelector("details")).toBeNull();
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
}
|
||||
|
||||
@@ -217,23 +217,28 @@ export function GitHubBotManagement({
|
||||
configuration={config}
|
||||
onChange={edit}
|
||||
/>
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-base font-semibold">Agent tools</h2>
|
||||
<GitHubToggle
|
||||
label="Use this bot’s GitHub tools"
|
||||
description="Limited to enabled repositories and this bot’s tasks. Tool policies still apply."
|
||||
checked={config.toolsEnabled}
|
||||
onChange={(toolsEnabled) => edit({ ...config, toolsEnabled })}
|
||||
/>
|
||||
{endpoint.connectionId && (
|
||||
<Link
|
||||
className="text-xs text-muted-foreground underline underline-offset-4"
|
||||
to={`/apps/${endpoint.connectionId}`}
|
||||
{!config.toolsEnabled && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This saved bot has GitHub tools disabled, so it cannot start
|
||||
work or respond.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => edit({ ...config, toolsEnabled: true })}
|
||||
>
|
||||
Manage tool permissions
|
||||
</Link>
|
||||
)}
|
||||
</section>
|
||||
Enable bot tools
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{endpoint.connectionId && (
|
||||
<Link
|
||||
className="text-xs text-muted-foreground underline underline-offset-4"
|
||||
to={`/apps/${endpoint.connectionId}`}
|
||||
>
|
||||
Manage tool permissions
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -360,18 +365,13 @@ export function GitHubBotManagement({
|
||||
);
|
||||
}
|
||||
|
||||
export function groupGitHubReviews(reviews: GitHubTaskReview[]) {
|
||||
const groups = new Map<string, GitHubTaskReview[]>();
|
||||
for (const review of [...reviews].sort(
|
||||
export function orderedGitHubReviews(reviews: GitHubTaskReview[]) {
|
||||
return [...reviews].sort(
|
||||
(a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt),
|
||||
)) {
|
||||
const key = `${review.repositoryId}:${review.pullNumber}`;
|
||||
groups.set(key, [...(groups.get(key) ?? []), review]);
|
||||
}
|
||||
return [...groups.values()];
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewResult({ review }: { review: GitHubTaskReview }) {
|
||||
export function gitHubReviewResultLabel(review: GitHubTaskReview) {
|
||||
const completed = review.state === "completed" && review.assessment?.complete;
|
||||
const conclusion = review.conclusion
|
||||
? {
|
||||
@@ -390,20 +390,30 @@ function ReviewResult({ review }: { review: GitHubTaskReview }) {
|
||||
superseded: "Superseded",
|
||||
manual_required: "Action required",
|
||||
}[review.state];
|
||||
return completed ? `${review.assessment!.score}/5 · ${conclusion}` : state;
|
||||
}
|
||||
|
||||
function ReviewResult({ review }: { review: GitHubTaskReview }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<span className="text-sm font-medium">
|
||||
{completed ? `${review.assessment!.score}/5 · ${conclusion}` : state}
|
||||
{gitHubReviewResultLabel(review)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Commit <code>{review.headSha.slice(0, 7)}</code> ·{" "}
|
||||
{formatDateTime(review.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<MarkdownBody className="text-sm">
|
||||
{review.assessment?.summary ?? review.event.title}
|
||||
</MarkdownBody>
|
||||
{review.assessment ? (
|
||||
<MarkdownBody className="text-sm">
|
||||
{review.assessment.summary}
|
||||
</MarkdownBody>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No assessment has been submitted yet.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
|
||||
<Link
|
||||
className="underline underline-offset-4"
|
||||
@@ -432,11 +442,8 @@ function ReviewResult({ review }: { review: GitHubTaskReview }) {
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-muted-foreground">
|
||||
Review details
|
||||
</summary>
|
||||
<div className="mt-3 space-y-2">
|
||||
<section className="space-y-4 text-sm" aria-label="Review evidence">
|
||||
<div className="space-y-3">
|
||||
{review.runId && (
|
||||
<Link
|
||||
className="text-xs underline underline-offset-4"
|
||||
@@ -447,7 +454,25 @@ function ReviewResult({ review }: { review: GitHubTaskReview }) {
|
||||
)}
|
||||
{review.assessment && (
|
||||
<>
|
||||
<h3 className="font-medium">Rationale</h3>
|
||||
<MarkdownBody>{review.assessment.rationale}</MarkdownBody>
|
||||
{review.assessment.findings.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h3 className="font-medium">Findings</h3>
|
||||
{review.assessment.findings.map((finding) => (
|
||||
<div
|
||||
key={finding.key}
|
||||
className="space-y-2 border-l border-border pl-4"
|
||||
>
|
||||
<p className="break-all font-mono text-xs">
|
||||
{finding.path}:{finding.line} · {finding.severity}
|
||||
</p>
|
||||
<MarkdownBody>{finding.body}</MarkdownBody>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
<h3 className="font-medium">Coverage</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{review.assessment.coverage.reviewedPaths.length} files reviewed
|
||||
· {review.assessment.coverage.omittedPaths.length} omitted
|
||||
@@ -460,12 +485,18 @@ function ReviewResult({ review }: { review: GitHubTaskReview }) {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitHubReviewList({ reviews }: { reviews: GitHubTaskReview[] }) {
|
||||
export function GitHubReviewList({
|
||||
endpointId,
|
||||
reviews,
|
||||
}: {
|
||||
endpointId: string;
|
||||
reviews: GitHubTaskReview[];
|
||||
}) {
|
||||
if (!reviews.length)
|
||||
return (
|
||||
<EmptyState
|
||||
@@ -475,70 +506,118 @@ export function GitHubReviewList({ reviews }: { reviews: GitHubTaskReview[] }) {
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div className="divide-y divide-border border-y border-border">
|
||||
{groupGitHubReviews(reviews).map(([latest, ...history]) => (
|
||||
<article
|
||||
key={`${latest.repositoryId}:${latest.pullNumber}`}
|
||||
className="space-y-4 py-5"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<a
|
||||
className="text-sm font-semibold hover:underline"
|
||||
href={`https://github.com/${latest.repository}/pull/${latest.pullNumber}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{latest.event.title || `Pull request #${latest.pullNumber}`}
|
||||
</a>
|
||||
<p className="break-all text-xs text-muted-foreground">
|
||||
{latest.repository} #{latest.pullNumber}
|
||||
</p>
|
||||
</div>
|
||||
<ReviewResult review={latest} />
|
||||
{history.length > 0 && (
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-muted-foreground">
|
||||
{history.length} earlier{" "}
|
||||
{history.length === 1 ? "review" : "reviews"}
|
||||
</summary>
|
||||
<div className="mt-4 space-y-6 border-l border-border pl-4">
|
||||
{history.map((review) => (
|
||||
<ReviewResult key={review.id} review={review} />
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</article>
|
||||
<ul
|
||||
aria-label="Reviews"
|
||||
className="divide-y divide-border border-y border-border"
|
||||
>
|
||||
{orderedGitHubReviews(reviews).map((review) => (
|
||||
<li key={review.id}>
|
||||
<Link
|
||||
to={`/apps/chat/${endpointId}/reviews/${review.id}`}
|
||||
className="flex flex-wrap items-center gap-x-6 gap-y-2 py-4 text-sm hover:bg-accent/50"
|
||||
>
|
||||
<div className="min-w-0 flex-1 basis-48 space-y-1">
|
||||
<p className="break-words font-medium">
|
||||
{review.event.title || `Pull request #${review.pullNumber}`}
|
||||
</p>
|
||||
<p className="break-all text-xs text-muted-foreground">
|
||||
{review.repository} #{review.pullNumber} ·{" "}
|
||||
<code>{review.headSha.slice(0, 7)}</code>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1 text-right">
|
||||
<p className="font-medium">{gitHubReviewResultLabel(review)}</p>
|
||||
<time
|
||||
className="text-xs text-muted-foreground"
|
||||
dateTime={review.createdAt}
|
||||
>
|
||||
{formatDateTime(review.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitHubReviews({ endpointId }: { endpointId: string }) {
|
||||
export function GitHubReviewDetail({
|
||||
endpointId,
|
||||
review,
|
||||
}: {
|
||||
endpointId: string;
|
||||
review: GitHubTaskReview;
|
||||
}) {
|
||||
return (
|
||||
<article className="max-w-3xl space-y-6">
|
||||
<Link
|
||||
className="text-sm text-muted-foreground hover:underline"
|
||||
to={`/apps/chat/${endpointId}/reviews`}
|
||||
>
|
||||
← All reviews
|
||||
</Link>
|
||||
<header className="space-y-2">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{review.event.title || `Pull request #${review.pullNumber}`}
|
||||
</h2>
|
||||
<a
|
||||
className="break-all text-sm text-muted-foreground hover:underline"
|
||||
href={`https://github.com/${review.repository}/pull/${review.pullNumber}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{review.repository} #{review.pullNumber}
|
||||
</a>
|
||||
</header>
|
||||
<ReviewResult review={review} />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitHubReviews({
|
||||
endpointId,
|
||||
reviewId,
|
||||
}: {
|
||||
endpointId: string;
|
||||
reviewId?: string;
|
||||
}) {
|
||||
const query = useQuery({
|
||||
queryKey: ["github-bot-reviews", endpointId],
|
||||
queryFn: () => githubChatApi.reviews(endpointId),
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Latest review for each pull request. Results apply to the commit shown.
|
||||
if (query.isError)
|
||||
return (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Reviews could not be loaded.{" "}
|
||||
<Button variant="link" onClick={() => void query.refetch()}>
|
||||
Try again
|
||||
</Button>
|
||||
</p>
|
||||
{query.isError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Reviews could not be loaded.{" "}
|
||||
<Button variant="link" onClick={() => void query.refetch()}>
|
||||
Try again
|
||||
</Button>
|
||||
);
|
||||
if (query.isPending)
|
||||
return (
|
||||
<p role="status" className="text-sm text-muted-foreground">
|
||||
Loading reviews…
|
||||
</p>
|
||||
);
|
||||
if (reviewId) {
|
||||
const review = query.data.find((review) => review.id === reviewId);
|
||||
return review ? (
|
||||
<GitHubReviewDetail endpointId={endpointId} review={review} />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p role="alert" className="text-sm text-muted-foreground">
|
||||
This review was not found in this connection.
|
||||
</p>
|
||||
) : query.isPending ? (
|
||||
<p role="status" className="text-sm text-muted-foreground">
|
||||
Loading reviews…
|
||||
</p>
|
||||
) : (
|
||||
<GitHubReviewList reviews={query.data} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
<Link
|
||||
to={`/apps/chat/${endpointId}/reviews`}
|
||||
className="text-sm underline"
|
||||
>
|
||||
All reviews
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <GitHubReviewList endpointId={endpointId} reviews={query.data} />;
|
||||
}
|
||||
@@ -67,13 +67,6 @@ describe("chat connector UI contract", () => {
|
||||
expect(detail).toContain("Not observed");
|
||||
});
|
||||
|
||||
it("lists every supported provider in the agent channel empty state", () => {
|
||||
const panel = source("../../../components/chat/AgentChannelsPanel.tsx");
|
||||
expect(panel).toContain(
|
||||
"Connect AgentMail, Slack, GitHub Code Review Bot, Discord, Microsoft Teams, or Telegram from",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps provider capabilities automatic and settings focused on plausible reach", () => {
|
||||
const detail = source("./ChatEndpointDetail.tsx");
|
||||
const setup = source("./ChatEndpointSetup.tsx");
|
||||
|
||||
Reference in new issue
Block a user