From 7dcdbe7eb6161dbf0b34a00cda720ed32dc60062 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 7 Oct 2026 16:17:14 -0500 Subject: [PATCH] Separate GitHub bot opt-in and add review detail pages Co-authored-by: Paperclip --- doc/connections/GITHUB-REVIEW-BOT.md | 34 ++- .../2026-10-07-github-app-live-test-drive.md | 40 +++ packages/shared/src/feature-catalog.ts | 9 +- packages/shared/src/types/instance.ts | 2 + .../shared/src/validators/instance.test.ts | 4 + packages/shared/src/validators/instance.ts | 1 + .../connection-intents-service.test.ts | 24 ++ .../instance-settings-service.test.ts | 6 + .../src/routes/chat-channels.identity.test.ts | 13 + server/src/routes/chat-channels.ts | 28 +- server/src/services/connection-intents.ts | 3 +- server/src/services/instance-settings.ts | 2 + ui/src/App.tsx | 7 +- ui/src/components/AppsSidebar.production.tsx | 2 +- ui/src/components/AppsSidebar.tsx | 2 +- .../ChatConnectorsExperimentalGate.test.tsx | 25 ++ .../ChatConnectorsExperimentalGate.tsx | 57 +++- ui/src/components/chat/AgentChannelsPanel.tsx | 8 +- ui/src/components/chat/ChatDetailSidebar.tsx | 1 - .../chat/ExternallyConnectedTaskBanner.tsx | 8 +- .../connections/ConnectionSetupFlow.tsx | 8 +- ui/src/hooks/useChatConnectorsEnabled.ts | 21 +- ui/src/pages/AgentDetail.production.tsx | 3 +- .../InstanceExperimentalSettings.test.tsx | 13 + ui/src/pages/InstanceExperimentalSettings.tsx | 16 +- ui/src/pages/apps/Browse.test.tsx | 9 +- ui/src/pages/apps/Browse.tsx | 18 +- ui/src/pages/apps/app-connect-policy.test.ts | 9 +- ui/src/pages/apps/app-connect-policy.ts | 7 +- ui/src/pages/apps/chat/ChatEndpointDetail.tsx | 12 +- .../apps/chat/GitHubBotConfiguration.tsx | 76 ++++- .../apps/chat/GitHubBotManagement.test.tsx | 88 ++++-- .../pages/apps/chat/GitHubBotManagement.tsx | 261 ++++++++++++------ .../pages/apps/chat/chat-ui-contract.test.ts | 7 - .../management.stories.tsx | 25 +- .../stories/github-connections.stories.tsx | 2 +- 36 files changed, 647 insertions(+), 204 deletions(-) diff --git a/doc/connections/GITHUB-REVIEW-BOT.md b/doc/connections/GITHUB-REVIEW-BOT.md index 894de711da9..bc128833192 100644 --- a/doc/connections/GITHUB-REVIEW-BOT.md +++ b/doc/connections/GITHUB-REVIEW-BOT.md @@ -11,6 +11,10 @@ required GitHub checks, read ## Set up a bot +Enable **GitHub review bots** in instance **Experimental** settings, then open +the GitHub Code Review Bot connector. This default-off setting is independent of +**Chat connectors**. Hiding either feature does not pause existing provider delivery. + 1. Choose the bot agent. Prefer a [low-trust review agent](https://docs.paperclip.ing/administration/trust-and-low-trust-review/) with an isolated sandbox and a scoped task boundary. Standard-trust agents @@ -62,13 +66,16 @@ selected environment. - **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. -- **Access:** choose enabled repositories, allowed people, each person’s automatic - events, and the bot’s GitHub tools. Repository switches save immediately. Other +- **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 - access. External contributors still require a sponsor and restricted permissions. -- **Reviews:** see the latest review for each pull request. The result names the - reviewed commit; earlier reviews remain in history. A previous passing result - does not stand in for a pending review of a newer commit. + access. **Add external contributor** still requires a sponsor and restricted + permissions. Bot tools are enabled during setup; a previously disabled connection + exposes an explicit repair action without silently changing its permissions. +- **Reviews:** see every assessment as one row, newest first. Open a row for its + summary, commit, findings, coverage, and task/run/publication links. Each review + has its own URL. A previous passing result does not stand in for a pending review + of a newer commit. - **Conversations:** follow the task title to Paperclip or the repository/thread label to GitHub. @@ -88,7 +95,13 @@ for that person are a separate choice. Guests receive no company membership or sponsor credentials. Authority is checked again before tool calls and publication, so revocation also affects queued or ongoing work. -Automatic events use the configured responsible member. The PR author and +**Run automatically** allows the person's authored PRs and issues to trigger +the automatic events selected in Settings. Turning it off keeps authorized +mentions available while avoiding work on every PR or issue. + +Automatic tasks use the configured responsible Paperclip member for accountability, +not that member's personal GitHub credentials. A member named **Board** in a local +test drive is still the responsible user, not a separate GitHub actor. The PR author and webhook sender are recorded independently. Follow-ups preserve task ownership while checking the current requester's authority. @@ -145,7 +158,12 @@ hostname, or the connector's Reviews page when no task has been created yet. Formal **APPROVE** and **REQUEST_CHANGES** are separate governed tools, each off by default. Enabling either does not automatically perform it. A score of 5/5 -alone never approves a PR. +alone never approves a PR. The agent must finish an assessment and explicitly +choose the allowed action. **Allow approvals** permits a formal ready-to-merge +decision; **Allow request changes** permits a formal change request, which can +prevent merging when the repository's review rules require resolution. Keeping +these off leaves scores, comments, and checks available while making formal +review decisions opt-in. The controls include contextual tooltips. To enforce the rating at merge time, configure GitHub branch protection or a ruleset to require **Paperclip Review**, selecting this bot App as the source diff --git a/doc/plans/2026-10-07-github-app-live-test-drive.md b/doc/plans/2026-10-07-github-app-live-test-drive.md index 454dee4663e..9c47090cb65 100644 --- a/doc/plans/2026-10-07-github-app-live-test-drive.md +++ b/doc/plans/2026-10-07-github-app-live-test-drive.md @@ -285,3 +285,43 @@ head presentation, and GitHub thread labels. The affected management, setup, AgentMail, sidebar, and shared UI-contract group has 47 passing tests. Repository typecheck/build, Storybook build, and token gates pass. The earlier full-suite failure and outstanding current-head CI/review remain as recorded above. + +### Independent GitHub setting and review detail qualification + +GitHub review bots now use their own default-off instance experimental setting. +The shared validator, persisted settings, managed feature catalog, setup and +management routes, catalog, agent channels, task controls, identity-link admission, +and agent connector discovery use that setting independently from chat connectors. +Existing event delivery is not paused merely by hiding the UI. + +The same local test drive was restarted and inspected with **GitHub review bots +on and Chat connectors off**. Connectors showed both GitHub tools and the dedicated +review bot. Opening Animal Bot still reached Settings and Access. Reviews showed +the two real commit assessments as separate rows. Each row opened its own detail +URL with the exact commit, Markdown summary, rationale, coverage, and task/run, +GitHub comment, and check links. Reload retained the detail route; All reviews +returned to the list. The detail kept the Reviews contextual navigation. Desktop +and 390px list/detail layouts were inspected. + +Formal approval and request-changes controls retain their off defaults and have +tooltips describing explicit actions after a completed assessment, independent +of scores and checks. **Run automatically** explains each person's authored +PR/issue scheduling and preserves mention-only access when disabled. The +responsible-member tooltip explains Paperclip accountability without personal +credential delegation. The tooltips were opened in the live UI, including the +approval tooltip at 390px. **Add external contributor** replaces the old label. +The normal bot-tools switch is removed; saved disabled restrictions remain +disabled until an explicit repair is saved. No bot configuration was changed +by this walkthrough. A final read found revision 7 with a separately saved +PR-opened prompt change relative to the previous revision-6 snapshot; that edit +was preserved. Tools, repository access, and formal review permissions remain +unchanged. + +Focused verification: 388 UI tests and 134 backend/shared settings and identity +tests pass. The added connector-discovery integration test passes with a disposable +local PostgreSQL database; the sandbox-only attempt skipped because it could not +open the database port. The management UI group was rerun after the final tooltip +copy edit and passes. Repository typecheck/build, production Storybook build, +and token gates pass; the final discovery change also received focused server +typecheck/build. These checks do not qualify a new provider or model run. The +earlier full-suite failure and outstanding current-head CI/review still apply. diff --git a/packages/shared/src/feature-catalog.ts b/packages/shared/src/feature-catalog.ts index 9cb47d5c2b5..ff83b97fa3f 100644 --- a/packages/shared/src/feature-catalog.ts +++ b/packages/shared/src/feature-catalog.ts @@ -120,10 +120,17 @@ export const INSTANCE_FEATURE_CATALOG: Record { + it("defaults GitHub review bots off independently of chat and accepts explicit changes", () => { + expect(instanceExperimentalSettingsSchema.parse({ enableChatConnectors: true }).enableGitHubReviewBots).toBe(false); + expect(patchInstanceExperimentalSettingsSchema.parse({ enableGitHubReviewBots: true })).toEqual({ enableGitHubReviewBots: true }); + }); it("defaults chat connectors off independently of Apps and accepts only explicit boolean patches", () => { expect(instanceExperimentalSettingsSchema.parse({}).enableChatConnectors).toBe(false); expect(instanceExperimentalSettingsSchema.parse({ enableApps: true }).enableChatConnectors).toBe(false); diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index c340246a648..189f971b184 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -53,6 +53,7 @@ export const instanceExperimentalSettingsSchema = z.object({ enableApps: z.boolean().default(true), enablePublicMcp: z.boolean().default(false), enableChatConnectors: z.boolean().default(false), + enableGitHubReviewBots: z.boolean().default(false), // Compatibility only: old stored and managed values must still parse. enableMcpAggregators: z.boolean().default(true), enableMemoryConnectors: z.boolean().default(false), diff --git a/server/src/__tests__/connection-intents-service.test.ts b/server/src/__tests__/connection-intents-service.test.ts index c6c318c372d..c1c824f3d32 100644 --- a/server/src/__tests__/connection-intents-service.test.ts +++ b/server/src/__tests__/connection-intents-service.test.ts @@ -38,6 +38,7 @@ import { materializeNativeInteractionResponses } from "../services/native-runtim import { toolAccessService } from "../services/tool-access.js"; import { toolAccessPolicyService } from "../services/tool-access-policy.js"; import { connectionIntentService } from "../services/connection-intents.js"; +import { instanceSettingsService } from "../services/instance-settings.js"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, @@ -198,6 +199,29 @@ describeEmbeddedPostgres("connectionIntentService", () => { expect(serialized).not.toContain(claims.sub); }); + it("discovers GitHub review bots independently from chat connectors", async () => { + const settings = instanceSettingsService(db); + const previous = await settings.getExperimental(); + const service = connectionIntentService(db); + try { + await settings.updateExperimental({ enableChatConnectors: false, enableGitHubReviewBots: true }); + expect((await service.search(claims, "github")).results).toEqual(expect.arrayContaining([ + expect.objectContaining({ service: "github-code-review-bot" }), + expect.objectContaining({ service: "github" }), + ])); + expect((await service.search(claims, "slack")).results.some(item => + item.methods.some(method => method.purpose === "channel"))).toBe(false); + await settings.updateExperimental({ enableChatConnectors: true, enableGitHubReviewBots: false }); + expect((await service.search(claims, "github")).results.some(item => + item.service === "github-code-review-bot")).toBe(false); + } finally { + await settings.updateExperimental({ + enableChatConnectors: previous.enableChatConnectors, + enableGitHubReviewBots: previous.enableGitHubReviewBots, + }); + } + }); + it("creates one addressed request, resolves after install, and then reports ready for the responsible user", async () => { const service = connectionIntentService(db); const first = await service.request(claims, "notion"); diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 8ba1de4eb02..ef7a04c7f41 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -16,6 +16,11 @@ describe("instance settings service", () => { const disabled = applyExperimentalSettingsPatch(enabled, { enableChatConnectors: false }); expect(disabled).toMatchObject({ enableApps: true, enableChatConnectors: false }); }); + it("keeps GitHub review bots independent across legacy normalization and persistence", () => { + expect(normalizeExperimentalSettings({ enableChatConnectors: true })).toMatchObject({ enableChatConnectors: true, enableGitHubReviewBots: false }); + const saved = applyExperimentalSettingsPatch({}, { enableGitHubReviewBots: true }); + expect(normalizeExperimentalSettings(JSON.parse(JSON.stringify(saved)))).toMatchObject({ enableChatConnectors: false, enableGitHubReviewBots: true }); + }); it("ignores retired experimental flags without resetting current settings", () => { expect(normalizeExperimentalSettings({ enableEnvironments: true, @@ -48,6 +53,7 @@ describe("instance settings service", () => { enablePublicMcp: false, enableCombinedInboxTasks: false, enableChatConnectors: false, + enableGitHubReviewBots: false, enableMemoryConnectors: false, enableConferenceRoomChat: false, enableClassicTaskInterface: false, diff --git a/server/src/routes/chat-channels.identity.test.ts b/server/src/routes/chat-channels.identity.test.ts index 67aa4d9decf..b50a42396bf 100644 --- a/server/src/routes/chat-channels.identity.test.ts +++ b/server/src/routes/chat-channels.identity.test.ts @@ -65,6 +65,19 @@ describe("chat identity-link preview authority", () => { expect(f.requestIdentityAccess).not.toHaveBeenCalled(); }); + it.each(["github", "slack"])("uses the token’s verified provider when only GitHub bots are enabled (%s)", async (provider) => { + settings.getExperimental.mockResolvedValue({ enableChatConnectors: false, enableGitHubReviewBots: true }); + const f = fixture(); + f.previewIdentityLink.mockResolvedValue({ ...preview, provider } as typeof preview); + await request(f.app).get("/api/chat-identity-links/preview").query({ token }).expect(provider === "github" ? 200 : 403); + await request(f.app).post("/api/chat-identity-links/confirm").send({ token }).expect(provider === "github" ? 200 : 403); + expect(f.confirmIdentityLink.mock.calls.length).toBe(provider === "github" ? 1 : 0); + }); + it("does not admit GitHub links under chat-only opt-in", async () => { + const f = fixture(); + f.previewIdentityLink.mockResolvedValue({ ...preview, provider: "github" } as unknown as typeof preview); + await request(f.app).get("/api/chat-identity-links/preview").query({ token }).expect(403); + }); it("returns the preview to a Board member of its exact company", async () => { const { app, previewIdentityLink } = fixture(); const response = await request(app) diff --git a/server/src/routes/chat-channels.ts b/server/src/routes/chat-channels.ts index 108ecd76034..b5a21f2ddbe 100644 --- a/server/src/routes/chat-channels.ts +++ b/server/src/routes/chat-channels.ts @@ -92,16 +92,27 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) { const access = accessService(db); const github = githubChatManagementService(db, options.fetch); - async function assertIdentityLinkAccess(req: ExpressRequest): Promise { + async function assertIdentityLinkAccess(req: ExpressRequest) { assertBoard(req); const userId = actorUserId(req); if (!userId) throw badRequest("A signed-in Paperclip user is required"); // Enforce rollout here: invited nonmembers cannot read the board's // experimental-settings API. A private token never bypasses this gate. - if (!(await instanceSettingsService(db).getExperimental()).enableChatConnectors) { + const experimental = await instanceSettingsService(db).getExperimental(); + if (!experimental.enableChatConnectors && !experimental.enableGitHubReviewBots) { throw forbidden("Chat connectors are not enabled on this instance"); } - return userId; + return { userId, experimental }; + } + + function assertIdentityProviderEnabled( + provider: string, + experimental: { enableChatConnectors: boolean; enableGitHubReviewBots: boolean }, + ) { + const enabled = provider === "agentmail" || (provider === "github" + ? experimental.enableGitHubReviewBots + : experimental.enableChatConnectors); + if (!enabled) throw forbidden("This connector is not enabled on this instance"); } async function assertConnectionManager( @@ -353,18 +364,22 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) { "/chat-identity-links/confirm", validate(confirmChatIdentityLinkSchema), async (req, res) => { - const userId = await assertIdentityLinkAccess(req); + const { userId, experimental } = await assertIdentityLinkAccess(req); + const preview = await service.previewIdentityLink(req.body.token, userId); + assertIdentityProviderEnabled(preview.provider, experimental); res.json(await service.confirmIdentityLink(req.body.token, userId)); }, ); router.post("/chat-identity-links/request-access", validate(confirmChatIdentityLinkSchema), async (req, res) => { - const userId = await assertIdentityLinkAccess(req); + const { userId, experimental } = await assertIdentityLinkAccess(req); + const preview = await service.previewIdentityLink(req.body.token, userId); + assertIdentityProviderEnabled(preview.provider, experimental); res.json(await service.requestIdentityAccess(req.body.token, userId, req.ip ?? "unknown")); }); router.get("/chat-identity-links/preview", async (req, res) => { - const userId = await assertIdentityLinkAccess(req); + const { userId, experimental } = await assertIdentityLinkAccess(req); const token = typeof req.query.token === "string" ? req.query.token : ""; if (token.length < 32 || token.length > 4096) throw badRequest("A valid identity-link token is required"); @@ -379,6 +394,7 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) { req, res, Promise.resolve(invitation), "Identity-link request not found", ); if (!preview) return; + assertIdentityProviderEnabled(preview.provider, experimental); res.json(preview); }); diff --git a/server/src/services/connection-intents.ts b/server/src/services/connection-intents.ts index c53a42b76f5..531786b3411 100644 --- a/server/src/services/connection-intents.ts +++ b/server/src/services/connection-intents.ts @@ -409,7 +409,8 @@ export function connectionIntentService(db: Db) { const candidates: Array<{ item: ConnectionSearchResultItem; score: number; nameScore: number }> = []; const authorizedCatalogs = new Map>>(); const discoveryMethods = (app: (typeof APP_STORE_DEFINITIONS)[number]) => getAvailableConnectionMethods(app) - .filter(method => method.purpose !== "channel" || app.slug === "agentmail" || settings.enableChatConnectors); + .filter(method => method.purpose !== "channel" || app.slug === "agentmail" + || (method.provider === "github" ? settings.enableGitHubReviewBots : settings.enableChatConnectors)); const services = [...APP_STORE_DEFINITIONS.filter(app => discoveryMethods(app).length).map((app) => app.slug), ...inventory.connections.filter((connection) => sourceSlugForConnection(connection, inventory.applicationsById)?.startsWith("connection:") diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 4954b0dacd3..fbee8d61453 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -235,6 +235,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableMcpAggregators: true, enablePublicMcp: parsed.data.enablePublicMcp ?? false, enableChatConnectors: parsed.data.enableChatConnectors ?? false, + enableGitHubReviewBots: parsed.data.enableGitHubReviewBots ?? false, enableMemoryConnectors: parsed.data.enableMemoryConnectors ?? false, enablePipelines: parsed.data.enablePipelines ?? false, enableCases: parsed.data.enableCases ?? false, @@ -281,6 +282,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableMcpAggregators: true, enablePublicMcp: false, enableChatConnectors: false, + enableGitHubReviewBots: false, enableMemoryConnectors: false, enablePipelines: false, enableCases: false, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 4d3babdf571..e31e4e1b787 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -214,6 +214,9 @@ function boardRoutes(streamlinedUiEnabled: boolean, combinedInboxTasksEnabled: b } /> + + } /> } /> @@ -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 }) ? : ; } diff --git a/ui/src/components/AppsSidebar.production.tsx b/ui/src/components/AppsSidebar.production.tsx index 9aaac873f90..b9859b97a42 100644 --- a/ui/src/components/AppsSidebar.production.tsx +++ b/ui/src/components/AppsSidebar.production.tsx @@ -39,7 +39,7 @@ export function AppsSidebar() { ); if (pathname.endsWith("/apps/chat/connect")) return ; - 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 ; return ( diff --git a/ui/src/components/AppsSidebar.tsx b/ui/src/components/AppsSidebar.tsx index 647d86ef3c6..2cbc7de1be1 100644 --- a/ui/src/components/AppsSidebar.tsx +++ b/ui/src/components/AppsSidebar.tsx @@ -27,7 +27,7 @@ export function AppsSidebar() { }); if (pathname.endsWith("/apps/chat/connect")) return ; - 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 ; return ( diff --git a/ui/src/components/ChatConnectorsExperimentalGate.test.tsx b/ui/src/components/ChatConnectorsExperimentalGate.test.tsx index ce2741da815..440758e3a61 100644 --- a/ui/src/components/ChatConnectorsExperimentalGate.test.tsx +++ b/ui/src/components/ChatConnectorsExperimentalGate.test.tsx @@ -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()); + 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( diff --git a/ui/src/components/ChatConnectorsExperimentalGate.tsx b/ui/src/components/ChatConnectorsExperimentalGate.tsx index e9b081c56f3..ee9e5d61438 100644 --- a/ui/src/components/ChatConnectorsExperimentalGate.tsx +++ b/ui/src/components/ChatConnectorsExperimentalGate.tsx @@ -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 ( -
-

{endpoint.error.message}

- -
- ); + const { enabled, githubEnabled } = useChatConnectorsEnabled(); + if (endpoint.isError) + return ( +
+

+ {endpoint.error.message} +

+ +
+ ); if (!endpoint.data) return null; - return endpoint.data.provider === "agentmail" ? <>{children} : ; + return chatProviderVisible(endpoint.data.provider, enabled, githubEnabled) ? ( + <>{children} + ) : ( + + ); } 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 - ? {children} - : ; - return <>{children}; + if (endpointId) + return ( + + {children} + + ); + return chatProviderVisible(params.get("provider"), enabled, githubEnabled) ? ( + <>{children} + ) : ( + + ); } diff --git a/ui/src/components/chat/AgentChannelsPanel.tsx b/ui/src/components/chat/AgentChannelsPanel.tsx index ed4ce716a26..f040db6b47d 100644 --- a/ui/src/components/chat/AgentChannelsPanel.tsx +++ b/ui/src/components/chat/AgentChannelsPanel.tsx @@ -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 = { 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 (
@@ -56,7 +56,7 @@ export function AgentChannelsPanel({

No channels connected

- {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."}

} {activeTab === "settings" && endpoint.provider !== "github" && } - {activeTab === "reviews" && endpoint.provider === "github" && } + {activeTab === "reviews" && endpoint.provider === "github" && } {activeTab === "access" && endpoint.provider === "agentmail" && } {activeTab === "access" && endpoint.provider !== "github" && endpoint.provider !== "agentmail" && ( + + + + {children} + + ); +} + 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 (
-

{label}

+
+

{label}

+ {help && {help}} +
{description && (

{description}

)} @@ -392,12 +427,13 @@ export function GitHubPolicyEditor({

Formal review actions

set("allowApprove", value)} /> set("allowRequestChanges", value)} /> @@ -597,8 +633,9 @@ export function GitHubAccessEditor({
onChange({ @@ -699,7 +736,7 @@ export function GitHubAccessEditor({ )}
{kind === "guest" && (
@@ -782,26 +819,41 @@ export function GitHubAccessEditor({ }) } > - Allow this account + Add contributor
)}
- Automatic task ownership + Automatic task responsibility
{activeMembers.length === 1 && responsible ? (

- Responsible member:{" "} + + 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. + + + :{" "} {responsible.user?.name ?? responsible.user?.email ?? responsible.principalId}

) : ( <> - +
+ + + 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. + +