fix: separate GitHub tools and code review bot connections (#14750)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The Connectors catalog lets people give agents tools or connect
agents to conversations.
> - GitHub put these two uses behind one card and an extra choice.
> - People should choose the connection they need from the catalog.
> - This pull request keeps GitHub for tools and adds GitHub Code Review
Bot as a separate card.
> - Each card opens its setup directly. Both use the existing connection
code.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

GitHub connector discovery and setup.

**Current behavior**

With chat connectors enabled, GitHub opens a menu that asks whether to
use tools or create a bot. Saved tools and bots share the same catalog
entry.

**Proposed behavior**

GitHub opens tool account access. GitHub Code Review Bot opens agent
selection. Saved bots and drafts appear under the bot card.
Chat-disabled instances show only GitHub tools.

**Reason and benefit**

The catalog names the two uses and removes an extra setup choice. The
bot keeps the existing GitHub provider, credentials, endpoint IDs, setup
steps, and runtime.

**Additional context**

Related work: https://github.com/paperclipai/paperclip/pull/12843 and
https://github.com/paperclipai/paperclip/pull/14594 established GitHub
account identity. This change preserves that tool flow. No duplicate
catalog split was found.

## What Changed

- Split the generated app definitions into GitHub tools and GitHub Code
Review Bot. Reuse the existing GitHub logo and channel method.
- Open bot setup directly, including old resume and reconnect links.
- Put existing bot endpoints and drafts under the bot card. Hide
duplicate internal chat applications.
- Keep pasted GitHub URLs mapped to the tool connection.
- Add seven Storybook states for the catalog, saved connections,
disabled chat, both setup paths, mobile, and light mode.
- Fix narrow-screen bot rows so the label cannot overlap status and
setup actions.
- Update catalog, route, browser, and API tests, plus the GitHub
connector guide.

## Verification

- [Hosted
Storybook](https://d1p6rlowie26tp.cloudfront.net/storybook/branches/codex~2Fgithub-review-connection/?path=/story/connections-github-and-code-review-bot--catalog):
seven states built from this branch. The deployment passed its
public-file verification.
- All GitHub checks pass on `d13a2cd53561645bb2a15c6f8e75a61a936d6459`.
Two optional Storybook jobs skip under their normal trigger rules; the
manual Storybook deployment passes. The branch has no merge conflicts.
- Greptile: 5/5 on the current head, with no review comments or
unresolved threads.
- `pnpm -r typecheck`, `pnpm build`, `pnpm check:token-gates`, and `pnpm
build-storybook` passed. The final Storybook fixture also passed UI
typecheck and the hosted build.
- Targeted catalog, URL matching, routing, grouping, brand, and chat UI
contract tests passed.
- GitHub provider browser tests: 2 passed. These cover direct tool setup
and the bot setup and management lifecycle with provider responses
mocked.
- Embedded-browser test on an isolated local instance: opened both
cards, selected an agent, saved a bot draft, and resumed the same
endpoint under the bot card after a reload.
- Storybook Tool Setup and Bot Setup assertions pass in the published
preview. Chat Disabled assertions pass locally. Inspected mobile and
light mode, including the draft-row layout and official GitHub marks.
- Local full-suite limitation: `pnpm test:run` was not clean. A
cross-company route assertion failed in the aggregate run and passed in
isolation; a workspace-runtime test reached its 30-second hook timeout.
Some isolated database reruns skipped when the embedded-PostgreSQL
availability probe failed. The local aggregate was stopped after CI
completed. The corresponding full CI suites pass all 360 tool-access
tests and all 162 workspace-runtime tests.
- No live GitHub authorization or installation was performed. The
isolated instance correctly stopped at the cloud enrollment or public
HTTPS prerequisites.

## Risks

- Low scope: catalog presentation and routing change. There is no
database migration or provider credential change.
- Existing GitHub bot URLs now open bot setup directly. The tool route
remains `/apps/connect?source=github`.
- The bot remains behind the existing chat-connectors feature flag.
Existing endpoints retain `provider: github`.
- Channel applications are represented by endpoint rows. Regression
tests cover legacy bot applications, tools, active bots, and drafts
together.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, code execution, and
embedded-browser tools. The exact deployed model ID, context window
size, and reasoning setting are not exposed to this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-09-30 17:15:02 -05:00
1 parent 018993140f
commit 33f2b3a159
20 files changed
+431 -252

No files matched your search

+12
View File
@@ -5,6 +5,18 @@ compatibility method. Cloud owns the fixed public OAuth callback and signed
webhook inbox; provider tokens are sealed to the enrolled instance and stored
only in its existing encrypted secret system.
## Catalog entries
**GitHub** connects an account for repository tools, Git, and `gh`, and opens
Access → Connect directly. **GitHub Code Review Bot** connects one agent to a
GitHub App for pull-request reviews and mentions, and opens Choose agent directly.
The bot entry follows the Chat Connectors experimental setting.
Both entries reuse the existing GitHub integrations. Bot endpoints retain the
`github` provider identity and existing setup, reconnect, and management URLs;
saved bot connections and drafts appear under GitHub Code Review Bot. GitHub
repository and MCP URLs still resolve to the ordinary GitHub tool connection.
## Self-hosted setup
The Access step uses **Continue** to open the local setup screen.
@@ -8,6 +8,7 @@ describe("tool app gallery URL matching", () => {
it("matches pasted links against gallery URL patterns", () => {
expect(getAppDefinitionForUrl("https://mcp.zapier.com/api/mcp")?.slug).toBe("zapier");
expect(getAppDefinitionForUrl("https://api.githubcopilot.com/mcp/")?.slug).toBe("github");
expect(getAppDefinitionForUrl("https://github.com/paperclipai/paperclip/pull/1")?.slug).toBe("github");
expect(getAppDefinitionForUrl("https://docs.google.com/spreadsheets/d/sheet_123/edit")?.slug).toBe("google-sheets");
expect(getAppDefinitionForUrl("https://gmailmcp.googleapis.com/mcp/v1")?.slug).toBe("gmail");
});
@@ -40,8 +41,8 @@ describe("tool app gallery URL matching", () => {
]);
});
it("keeps every gallery entry reachable through at least one pattern", () => {
for (const app of CONNECTABLE_APP_DEFINITIONS) {
it("keeps tool gallery entries reachable through at least one pattern", () => {
for (const app of CONNECTABLE_APP_DEFINITIONS.filter((app) => app.methods.some((method) => method.purpose !== "channel"))) {
const example = app.urlPatterns[0]?.replace("*", "example");
expect(example, `${app.slug} has a pattern`).toBeTruthy();
expect(getAppDefinitionForUrl(example!)?.slug).toBe(app.slug);
@@ -4,79 +4,80 @@ import a2 from "./app-definitions/arcade.json" with { type: "json" };
import a3 from "./app-definitions/executor.json" with { type: "json" };
import a4 from "./app-definitions/railway.json" with { type: "json" };
import a5 from "./app-definitions/github.json" with { type: "json" };
import a6 from "./app-definitions/slack.json" with { type: "json" };
import a7 from "./app-definitions/microsoft-teams.json" with { type: "json" };
import a8 from "./app-definitions/imessage-photon.json" with { type: "json" };
import a9 from "./app-definitions/telegram.json" with { type: "json" };
import a10 from "./app-definitions/discord.json" with { type: "json" };
import a11 from "./app-definitions/notion.json" with { type: "json" };
import a12 from "./app-definitions/posthog.json" with { type: "json" };
import a13 from "./app-definitions/linear.json" with { type: "json" };
import a14 from "./app-definitions/context7.json" with { type: "json" };
import a15 from "./app-definitions/shopify.json" with { type: "json" };
import a16 from "./app-definitions/composio.json" with { type: "json" };
import a17 from "./app-definitions/oauth-generic.json" with { type: "json" };
import a18 from "./app-definitions/api-key-generic.json" with { type: "json" };
import a19 from "./app-definitions/sentry.json" with { type: "json" };
import a20 from "./app-definitions/vercel.json" with { type: "json" };
import a21 from "./app-definitions/anthropic.json" with { type: "json" };
import a22 from "./app-definitions/browser-use-cloud.json" with { type: "json" };
import a23 from "./app-definitions/cognee.json" with { type: "json" };
import a24 from "./app-definitions/jira.json" with { type: "json" };
import a25 from "./app-definitions/airtable.json" with { type: "json" };
import a26 from "./app-definitions/beehiiv.json" with { type: "json" };
import a27 from "./app-definitions/bitly.json" with { type: "json" };
import a28 from "./app-definitions/candid.json" with { type: "json" };
import a29 from "./app-definitions/cloudflare.json" with { type: "json" };
import a30 from "./app-definitions/cloudinary.json" with { type: "json" };
import a31 from "./app-definitions/coda.json" with { type: "json" };
import a32 from "./app-definitions/hugging-face.json" with { type: "json" };
import a33 from "./app-definitions/kernel.json" with { type: "json" };
import a34 from "./app-definitions/local-falcon.json" with { type: "json" };
import a35 from "./app-definitions/make.json" with { type: "json" };
import a36 from "./app-definitions/manufact.json" with { type: "json" };
import a37 from "./app-definitions/miro.json" with { type: "json" };
import a38 from "./app-definitions/netlify.json" with { type: "json" };
import a39 from "./app-definitions/oreilly.json" with { type: "json" };
import a40 from "./app-definitions/planetscale.json" with { type: "json" };
import a41 from "./app-definitions/resend.json" with { type: "json" };
import a42 from "./app-definitions/ticktick.json" with { type: "json" };
import a43 from "./app-definitions/todoist.json" with { type: "json" };
import a44 from "./app-definitions/webflow.json" with { type: "json" };
import a45 from "./app-definitions/wix.json" with { type: "json" };
import a46 from "./app-definitions/brex.json" with { type: "json" };
import a47 from "./app-definitions/clickhouse.json" with { type: "json" };
import a48 from "./app-definitions/egnyte.json" with { type: "json" };
import a49 from "./app-definitions/embat.json" with { type: "json" };
import a50 from "./app-definitions/mixpanel.json" with { type: "json" };
import a51 from "./app-definitions/postman.json" with { type: "json" };
import a52 from "./app-definitions/razorpay.json" with { type: "json" };
import a53 from "./app-definitions/sanity.json" with { type: "json" };
import a54 from "./app-definitions/stripe.json" with { type: "json" };
import a55 from "./app-definitions/supabase.json" with { type: "json" };
import a56 from "./app-definitions/ticket-tailor.json" with { type: "json" };
import a57 from "./app-definitions/asana.json" with { type: "json" };
import a58 from "./app-definitions/box.json" with { type: "json" };
import a59 from "./app-definitions/mem0.json" with { type: "json" };
import a60 from "./app-definitions/pagerduty.json" with { type: "json" };
import a61 from "./app-definitions/similarweb.json" with { type: "json" };
import a62 from "./app-definitions/xero.json" with { type: "json" };
import a63 from "./app-definitions/youcom.json" with { type: "json" };
import a64 from "./app-definitions/fireflies.json" with { type: "json" };
import a65 from "./app-definitions/zep.json" with { type: "json" };
import a66 from "./app-definitions/supermemory.json" with { type: "json" };
import a67 from "./app-definitions/honcho.json" with { type: "json" };
import a68 from "./app-definitions/gmail.json" with { type: "json" };
import a69 from "./app-definitions/google-drive.json" with { type: "json" };
import a70 from "./app-definitions/google-docs.json" with { type: "json" };
import a71 from "./app-definitions/google-sheets.json" with { type: "json" };
import a72 from "./app-definitions/google-slides.json" with { type: "json" };
import a73 from "./app-definitions/google-calendar.json" with { type: "json" };
import a74 from "./app-definitions/google-chat.json" with { type: "json" };
import a75 from "./app-definitions/google-people.json" with { type: "json" };
import a76 from "./app-definitions/google-workspace-search.json" with { type: "json" };
import a77 from "./app-definitions/openai.json" with { type: "json" };
import a78 from "./app-definitions/openrouter.json" with { type: "json" };
import a79 from "./app-definitions/xai.json" with { type: "json" };
import a6 from "./app-definitions/github-code-review-bot.json" with { type: "json" };
import a7 from "./app-definitions/slack.json" with { type: "json" };
import a8 from "./app-definitions/microsoft-teams.json" with { type: "json" };
import a9 from "./app-definitions/imessage-photon.json" with { type: "json" };
import a10 from "./app-definitions/telegram.json" with { type: "json" };
import a11 from "./app-definitions/discord.json" with { type: "json" };
import a12 from "./app-definitions/notion.json" with { type: "json" };
import a13 from "./app-definitions/posthog.json" with { type: "json" };
import a14 from "./app-definitions/linear.json" with { type: "json" };
import a15 from "./app-definitions/context7.json" with { type: "json" };
import a16 from "./app-definitions/shopify.json" with { type: "json" };
import a17 from "./app-definitions/composio.json" with { type: "json" };
import a18 from "./app-definitions/oauth-generic.json" with { type: "json" };
import a19 from "./app-definitions/api-key-generic.json" with { type: "json" };
import a20 from "./app-definitions/sentry.json" with { type: "json" };
import a21 from "./app-definitions/vercel.json" with { type: "json" };
import a22 from "./app-definitions/anthropic.json" with { type: "json" };
import a23 from "./app-definitions/browser-use-cloud.json" with { type: "json" };
import a24 from "./app-definitions/cognee.json" with { type: "json" };
import a25 from "./app-definitions/jira.json" with { type: "json" };
import a26 from "./app-definitions/airtable.json" with { type: "json" };
import a27 from "./app-definitions/beehiiv.json" with { type: "json" };
import a28 from "./app-definitions/bitly.json" with { type: "json" };
import a29 from "./app-definitions/candid.json" with { type: "json" };
import a30 from "./app-definitions/cloudflare.json" with { type: "json" };
import a31 from "./app-definitions/cloudinary.json" with { type: "json" };
import a32 from "./app-definitions/coda.json" with { type: "json" };
import a33 from "./app-definitions/hugging-face.json" with { type: "json" };
import a34 from "./app-definitions/kernel.json" with { type: "json" };
import a35 from "./app-definitions/local-falcon.json" with { type: "json" };
import a36 from "./app-definitions/make.json" with { type: "json" };
import a37 from "./app-definitions/manufact.json" with { type: "json" };
import a38 from "./app-definitions/miro.json" with { type: "json" };
import a39 from "./app-definitions/netlify.json" with { type: "json" };
import a40 from "./app-definitions/oreilly.json" with { type: "json" };
import a41 from "./app-definitions/planetscale.json" with { type: "json" };
import a42 from "./app-definitions/resend.json" with { type: "json" };
import a43 from "./app-definitions/ticktick.json" with { type: "json" };
import a44 from "./app-definitions/todoist.json" with { type: "json" };
import a45 from "./app-definitions/webflow.json" with { type: "json" };
import a46 from "./app-definitions/wix.json" with { type: "json" };
import a47 from "./app-definitions/brex.json" with { type: "json" };
import a48 from "./app-definitions/clickhouse.json" with { type: "json" };
import a49 from "./app-definitions/egnyte.json" with { type: "json" };
import a50 from "./app-definitions/embat.json" with { type: "json" };
import a51 from "./app-definitions/mixpanel.json" with { type: "json" };
import a52 from "./app-definitions/postman.json" with { type: "json" };
import a53 from "./app-definitions/razorpay.json" with { type: "json" };
import a54 from "./app-definitions/sanity.json" with { type: "json" };
import a55 from "./app-definitions/stripe.json" with { type: "json" };
import a56 from "./app-definitions/supabase.json" with { type: "json" };
import a57 from "./app-definitions/ticket-tailor.json" with { type: "json" };
import a58 from "./app-definitions/asana.json" with { type: "json" };
import a59 from "./app-definitions/box.json" with { type: "json" };
import a60 from "./app-definitions/mem0.json" with { type: "json" };
import a61 from "./app-definitions/pagerduty.json" with { type: "json" };
import a62 from "./app-definitions/similarweb.json" with { type: "json" };
import a63 from "./app-definitions/xero.json" with { type: "json" };
import a64 from "./app-definitions/youcom.json" with { type: "json" };
import a65 from "./app-definitions/fireflies.json" with { type: "json" };
import a66 from "./app-definitions/zep.json" with { type: "json" };
import a67 from "./app-definitions/supermemory.json" with { type: "json" };
import a68 from "./app-definitions/honcho.json" with { type: "json" };
import a69 from "./app-definitions/gmail.json" with { type: "json" };
import a70 from "./app-definitions/google-drive.json" with { type: "json" };
import a71 from "./app-definitions/google-docs.json" with { type: "json" };
import a72 from "./app-definitions/google-sheets.json" with { type: "json" };
import a73 from "./app-definitions/google-slides.json" with { type: "json" };
import a74 from "./app-definitions/google-calendar.json" with { type: "json" };
import a75 from "./app-definitions/google-chat.json" with { type: "json" };
import a76 from "./app-definitions/google-people.json" with { type: "json" };
import a77 from "./app-definitions/google-workspace-search.json" with { type: "json" };
import a78 from "./app-definitions/openai.json" with { type: "json" };
import a79 from "./app-definitions/openrouter.json" with { type: "json" };
import a80 from "./app-definitions/xai.json" with { type: "json" };
import type { AppDefinition } from "./types/app-definition.js";
export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70,a71,a72,a73,a74,a75,a76,a77,a78,a79] as AppDefinition[];
export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70,a71,a72,a73,a74,a75,a76,a77,a78,a79,a80] as AppDefinition[];
+22 -10
View File
@@ -10,6 +10,7 @@ import {
appSupportsCatalogSetup,
getAvailableConnectionMethod,
getAppDefinitionForUrl,
getConnectableAppDefinition,
getRecommendedConnectionMethod,
recommendedDefaultsForApp,
resolveConnectionMethodServerUrl,
@@ -284,6 +285,17 @@ describe("AppDefinition catalog", () => {
for (const entry of BLOCKED_MCP_PROVIDERS)
expect(connectableSlugs.has(entry.slug)).toBe(false);
});
it("separates GitHub tools from the review bot without changing the provider identity", () => {
const github = getConnectableAppDefinition("github")!;
const bot = getConnectableAppDefinition("github-code-review-bot")!;
expect(github.methods.map((method) => method.key)).toEqual(["managed", "mcp-key"]);
expect(github.methods.every((method) => method.purpose === "tool")).toBe(true);
expect(bot.name).toBe("GitHub Code Review Bot");
expect(bot.methods).toHaveLength(1);
expect(bot.methods[0]).toMatchObject({ provider: "github", purpose: "channel", transport: "chat_sdk" });
expect(bot.branding).toEqual(github.branding);
expect(bot.urlPatterns).toEqual([]);
});
it("registers the five native chat providers with only required setup credentials", () => {
const expected = {
slack: {
@@ -292,11 +304,11 @@ describe("AppDefinition catalog", () => {
resources: ["workspace", "channel"],
tool: true,
},
github: {
"github-code-review-bot": {
credentials: ["appId", "privateKey"],
publicFields: ["appId"],
resources: ["organization", "repository"],
tool: true,
tool: false,
},
discord: {
credentials: ["botToken", "applicationId", "guildId"],
@@ -329,7 +341,7 @@ describe("AppDefinition catalog", () => {
key: "chat-agent",
label: "Chat with an agent",
purpose: "channel",
provider: slug,
provider: slug === "github-code-review-bot" ? "github" : slug,
transport: "chat_sdk",
auth: "api_key",
ownershipModes: ["customer"],
@@ -374,20 +386,20 @@ describe("AppDefinition catalog", () => {
APP_DEFINITIONS.find((app) => app.slug === slug)?.methods.find(
(method) => method.purpose === "channel",
);
expect(channel("github")?.guidanceMd).toContain("issue_comment");
expect(channel("github-code-review-bot")?.guidanceMd).toContain("issue_comment");
expect(channel("discord")?.guidanceMd).toContain("Message Content intent");
expect(channel("discord")?.guidanceMd).toContain("Discord thread");
expect(channel("github")?.guidanceMd).toContain("pull_request");
expect(channel("github")?.guidanceMd).toContain(
expect(channel("github-code-review-bot")?.guidanceMd).toContain("pull_request");
expect(channel("github-code-review-bot")?.guidanceMd).toContain(
"pull_request_review_comment",
);
expect(channel("github")?.guidanceMd).toContain(
expect(channel("github-code-review-bot")?.guidanceMd).toContain(
"installation_repositories",
);
expect(channel("github")?.guidanceMd).toContain(
expect(channel("github-code-review-bot")?.guidanceMd).toContain(
"Generate the webhook secret in Paperclip",
);
expect(channel("github")?.guidanceMd).toContain("SSL-verified");
expect(channel("github-code-review-bot")?.guidanceMd).toContain("SSL-verified");
expect(channel("microsoft-teams")?.guidanceMd).toContain(
"resource-specific",
);
@@ -707,7 +719,7 @@ describe("AppDefinition catalog", () => {
"ticktick",
"xero",
]);
expect(APP_STORE_DEFINITIONS).toHaveLength(57);
expect(APP_STORE_DEFINITIONS).toHaveLength(58);
const connectableSlugs = new Set(
CONNECTABLE_APP_DEFINITIONS.map((entry) => entry.slug),
);
+1
View File
@@ -30,6 +30,7 @@ export const CONNECTABLE_APP_SLUGS = new Set([
"google-people",
"google-workspace-search",
"github",
"github-code-review-bot",
"discord",
"microsoft-teams",
"telegram",
@@ -0,0 +1,57 @@
{
"schemaVersion": 1,
"slug": "github-code-review-bot",
"name": "GitHub Code Review Bot",
"description": "Have an agent review pull requests and respond to GitHub mentions.",
"categories": [
"developer"
],
"featured": true,
"branding": {
"logoUrl": "/brands/apps/github.svg",
"darkLogoUrl": "/brands/apps/github-dark.svg"
},
"urlPatterns": [],
"methods": [
{
"key": "chat-agent",
"label": "Chat with an agent",
"purpose": "channel",
"provider": "github",
"transport": "chat_sdk",
"auth": "api_key",
"ownershipModes": [
"customer"
],
"whenToUse": "Let people in GitHub start and continue work with one Paperclip agent.",
"credentialFields": [
{
"key": "appId",
"label": "GitHub App ID",
"type": "text",
"required": true,
"placeholder": "123456",
"secret": false
},
{
"key": "privateKey",
"label": "Private key (PEM)",
"type": "textarea",
"required": true,
"placeholder": "-----BEGIN RSA PRIVATE KEY-----",
"secret": true
}
],
"guidanceMd": "Generate the webhook secret in Paperclip, then create one private GitHub App with active SSL-verified webhooks, Issues and Pull requests read/write permission, and the selectable issue_comment and pull_request_review_comment events. GitHub sends installation and installation_repositories automatically. Install the App only on repositories where people may mention the agent.",
"consoleLinks": {
"register": "https://github.com/settings/apps/new",
"docs": "https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app"
},
"riskTier": "S3",
"requiredResourceFilters": [
"organization",
"repository"
]
}
]
}
@@ -2,7 +2,7 @@
"schemaVersion": 1,
"slug": "github",
"name": "GitHub",
"description": "Give agents repository tools or let people work with an agent from GitHub issues and pull requests.",
"description": "Give agents access to GitHub repositories, issues, and pull requests.",
"categories": [
"developer"
],
@@ -29,7 +29,7 @@
},
"guidanceMd": "Authorize Paperclip, then choose selected repositories in GitHub. You can edit repository access later from GitHub's installation settings.",
"riskTier": "S3",
"label": "Use this connection as an agent tool",
"label": "Connect GitHub",
"purpose": "tool",
"oauthStrategy": "paperclip_cloud_connector",
"connectorProfile": "github.code",
@@ -80,46 +80,6 @@
"organization",
"repository"
]
},
{
"key": "chat-agent",
"label": "Chat with an agent",
"purpose": "channel",
"provider": "github",
"transport": "chat_sdk",
"auth": "api_key",
"ownershipModes": [
"customer"
],
"whenToUse": "Let people in GitHub start and continue work with one Paperclip agent.",
"credentialFields": [
{
"key": "appId",
"label": "GitHub App ID",
"type": "text",
"required": true,
"placeholder": "123456",
"secret": false
},
{
"key": "privateKey",
"label": "Private key (PEM)",
"type": "textarea",
"required": true,
"placeholder": "-----BEGIN RSA PRIVATE KEY-----",
"secret": true
}
],
"guidanceMd": "Generate the webhook secret in Paperclip, then create one private GitHub App with active SSL-verified webhooks, Issues and Pull requests read/write permission, and the selectable issue_comment and pull_request_review_comment events. GitHub sends installation and installation_repositories automatically. Install the App only on repositories where people may mention the agent.",
"consoleLinks": {
"register": "https://github.com/settings/apps/new",
"docs": "https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app"
},
"riskTier": "S3",
"requiredResourceFilters": [
"organization",
"repository"
]
}
]
}
+35 -26
View File
@@ -258,7 +258,7 @@ const apps = [
[
"github",
"GitHub",
"Give agents repository tools or let people work with an agent from GitHub issues and pull requests.",
"Give agents access to GitHub repositories, issues, and pull requests.",
"developer",
"github.com",
["https://api.githubcopilot.com/mcp/*", "https://github.com/*"],
@@ -271,7 +271,7 @@ const apps = [
"S3",
"Authorize Paperclip, then choose selected repositories in GitHub. You can edit repository access later from GitHub's installation settings.",
{
label: "Use this connection as an agent tool",
label: "Connect GitHub",
purpose: "tool",
oauthStrategy: "paperclip_cloud_connector",
connectorProfile: "github.code",
@@ -306,32 +306,41 @@ const apps = [
requiredResourceFilters: ["organization", "repository"],
},
),
channelMethod(
"github",
[
{
...field("appId", "GitHub App ID", "123456"),
type: "text",
secret: false,
},
{
...field(
"privateKey",
"Private key (PEM)",
"-----BEGIN RSA PRIVATE KEY-----",
),
type: "textarea",
},
],
["organization", "repository"],
"Generate the webhook secret in Paperclip, then create one private GitHub App with active SSL-verified webhooks, Issues and Pull requests read/write permission, and the selectable issue_comment and pull_request_review_comment events. GitHub sends installation and installation_repositories automatically. Install the App only on repositories where people may mention the agent.",
{
register: "https://github.com/settings/apps/new",
docs: "https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app",
},
),
],
],
[
"github-code-review-bot",
"GitHub Code Review Bot",
"Have an agent review pull requests and respond to GitHub mentions.",
"developer",
"github.com",
[],
channelMethod(
"github",
[
{
...field("appId", "GitHub App ID", "123456"),
type: "text",
secret: false,
},
{
...field(
"privateKey",
"Private key (PEM)",
"-----BEGIN RSA PRIVATE KEY-----",
),
type: "textarea",
},
],
["organization", "repository"],
"Generate the webhook secret in Paperclip, then create one private GitHub App with active SSL-verified webhooks, Issues and Pull requests read/write permission, and the selectable issue_comment and pull_request_review_comment events. GitHub sends installation and installation_repositories automatically. Install the App only on repositories where people may mention the agent.",
{
register: "https://github.com/settings/apps/new",
docs: "https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app",
},
),
{ featured: true },
],
[
"slack",
"Slack",
@@ -5119,10 +5119,11 @@ describeEmbeddedPostgres("tool access service", () => {
"google-people",
"google-workspace-search",
"github",
"github-code-review-bot",
"youcom",
]),
);
expect(res.body.apps).toHaveLength(57);
expect(res.body.apps).toHaveLength(58);
expect(
res.body.apps.find((app: { slug: string }) => app.slug === "gmail")
.ownershipAvailability,
+9 -10
View File
@@ -137,7 +137,7 @@ test.describe.serial("native chat adapter UI", () => {
{ timeout: 30_000 },
);
const connector = page.locator(
`[role="listitem"][data-app-slug="${github.slug}"]`,
'[role="listitem"][data-app-slug="github"]',
);
await expect(connector).toBeVisible();
await connector.getByRole("button", { name: "Connect GitHub" }).click();
@@ -216,8 +216,15 @@ test.describe.serial("native chat adapter UI", () => {
`[role="listitem"][data-app-slug="${provider.slug}"]`,
);
await expect(connector).toBeVisible({ timeout: 30_000 });
if (provider.provider === "github") {
const tools = page.locator('[role="listitem"][data-app-slug="github"]');
await tools.getByRole("button", { name: "Connect GitHub", exact: true }).click();
await expect(page.getByRole("heading", { name: "Connect GitHub as" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Choose how to connect" })).toHaveCount(0);
await page.goto(`/${seed.prefix}/apps`);
}
await connector
.getByRole("button", { name: `Connect ${provider.name}` })
.getByRole("button", { name: `Connect ${provider.provider === "github" ? "GitHub Code Review Bot" : provider.name}` })
.click();
if (provider.chatAndTool) {
@@ -244,14 +251,6 @@ test.describe.serial("native chat adapter UI", () => {
expect(new URL(page.url()).searchParams.get("source")).toBe(
provider.provider,
);
if (provider.provider === "github") {
await expect(
page.getByRole("heading", { name: "Connect GitHub as" }),
).toBeVisible();
await expect(
page.getByText("Chat with an agent", { exact: true }),
).toHaveCount(0);
}
await page.goto(chatSetupUrl);
await expect(
page.getByRole("heading", { name: "Choose how to connect" }),
+2 -2
View File
@@ -59,7 +59,7 @@ export const PROVIDERS: ProviderCase[] = [
},
{
provider: "github",
slug: "github",
slug: "github-code-review-bot",
name: "GitHub",
accountLabel: "paperclip-ai",
botLabel: "Maya",
@@ -72,7 +72,7 @@ export const PROVIDERS: ProviderCase[] = [
externalUrl: "https://github.com/paperclip-ai/paperclip/issues/123",
setupHeading: /Create or connect a GitHub App/i,
setupButton: "Connect and verify",
chatAndTool: true,
chatAndTool: false,
},
{
provider: "microsoft-teams",
+7
View File
@@ -135,6 +135,13 @@
"localAsset": "/brands/apps/github.svg",
"darkAsset": "/brands/apps/github-dark.svg"
},
{
"slug": "github-code-review-bot",
"provider": "GitHub Code Review Bot",
"catalogVisible": true,
"localAsset": "/brands/apps/github.svg",
"darkAsset": "/brands/apps/github-dark.svg"
},
{
"slug": "gmail",
"provider": "Gmail",
@@ -9,7 +9,7 @@ import { useChatConnectorsEnabled } from "@/hooks/useChatConnectorsEnabled";
const providerNames: Record<ChatProvider, string> = {
slack: "Slack",
github: "GitHub",
github: "GitHub Code Review Bot",
discord: "Discord",
"microsoft-teams": "Microsoft Teams",
telegram: "Telegram",
@@ -57,7 +57,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">
Connect AgentMail, Slack, GitHub, Discord, Microsoft Teams, or Telegram from
Connect AgentMail, Slack, GitHub Code Review Bot, Discord, Microsoft Teams, or Telegram from
Connectors.
</p>
<Button asChild className="mt-3" variant="outline" size="sm">
+44 -13
View File
@@ -231,14 +231,14 @@ describe("Connectors landing page", () => {
it("defaults to tools-only GitHub and hides chat-only catalog and existing chat accounts", async () => {
experimentalMock.mockResolvedValue({});
listGalleryMock.mockResolvedValue({ apps: ["github", "discord", "telegram", "microsoft-teams"].map(getAppStoreDefinition) });
listGalleryMock.mockResolvedValue({ apps: ["github", "github-code-review-bot", "discord", "telegram", "microsoft-teams"].map(getAppStoreDefinition) });
listApplicationsMock.mockResolvedValue({ applications: [application({
id: "chat-app", type: "chat", name: "Private bot", applicationKey: "chat:github:endpoint-1", metadata: { purpose: "channel" },
})] });
await renderBrowse();
expect(chatListMock).not.toHaveBeenCalled();
expect(container.querySelector('[data-app-slug="github"]')).not.toBeNull();
for (const slug of ["discord", "telegram", "microsoft-teams", "slack"]) {
for (const slug of ["github-code-review-bot", "discord", "telegram", "microsoft-teams", "slack"]) {
expect(container.querySelector(`[data-app-slug="${slug}"]`)).toBeNull();
}
expect(container.textContent).not.toContain("Private bot");
@@ -247,21 +247,52 @@ describe("Connectors landing page", () => {
expect(navigateMock).toHaveBeenLastCalledWith("/apps/connect?source=github");
});
it("restores the GitHub intent chooser when enabled and hides cached chat rows immediately when disabled", async () => {
listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("github")] });
chatListMock.mockResolvedValue([{ id: "endpoint-1", provider: "github", status: "active", assignedAgentName: "Chat agent", botLabel: "Chat bot", assignedAgentId: "agent-1" }]);
it("separates tools and saved bots and hides only bots when chat connectors are disabled", async () => {
listGalleryMock.mockResolvedValue({ apps: ["github", "github-code-review-bot"].map(getAppStoreDefinition) });
listApplicationsMock.mockResolvedValue({ applications: [
application({ id: "github-tools", name: "GitHub", metadata: { sourceTemplateKey: "github" } }),
application({ id: "chat-app", type: "chat", name: "Legacy bot", metadata: { sourceTemplateKey: "github", purpose: "channel" } }),
] });
listConnectionsMock.mockResolvedValue({ connections: [
connection({ id: "github-account", applicationId: "github-tools", name: "My GitHub" }),
connection({ id: "chat-tools", applicationId: "chat-app", connectionPurpose: "channel" }),
] });
chatListMock.mockResolvedValue([
{ id: "endpoint-1", provider: "github", status: "active", assignedAgentName: "Review agent", botLabel: "Review bot", assignedAgentId: "agent-1" },
{ id: "endpoint-2", provider: "github", status: "draft", assignedAgentName: "Draft agent", assignedAgentId: "agent-2" },
]);
const client = await renderBrowse();
expect(chatListMock).toHaveBeenCalledWith("company-1");
expect(container.querySelector('[data-app-slug="telegram"]')).not.toBeNull();
await act(() => void container.querySelector<HTMLButtonElement>('button[aria-label="Add connection GitHub"]')!.click());
expect(navigateMock).toHaveBeenLastCalledWith("/apps/chat/connect?provider=github&toolHref=%2Fapps%2Fconnect%3Fsource%3Dgithub");
const tools = container.querySelector('[data-app-slug="github"]')!;
const bots = container.querySelector('[data-app-slug="github-code-review-bot"]')!;
expect(tools.textContent).toContain("My GitHub");
expect(tools.textContent).not.toContain("Review agent");
expect(bots.textContent).toContain("Review agent · Code review bot");
expect(bots.textContent).toContain("Draft agent");
expect(bots.textContent).not.toContain("My GitHub");
expect(container.textContent).not.toContain("Legacy bot");
await act(() => void tools.querySelector<HTMLButtonElement>('button[aria-label="Add account GitHub"]')!.click());
expect(navigateMock).toHaveBeenLastCalledWith("/apps/connect?source=github&applicationId=github-tools&name=GitHub&new=1");
await act(() => void bots.querySelector<HTMLButtonElement>('button[aria-label="Add connection GitHub Code Review Bot"]')!.click());
expect(navigateMock).toHaveBeenLastCalledWith("/apps/chat/connect?provider=github&purpose=chat");
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 flushReact();
expect(container.querySelector('[data-app-slug="telegram"]')).toBeNull();
expect(container.textContent).not.toContain("Chat agent");
expect(container.querySelector('a[href*="/apps/chat/"]')).toBeNull();
expect(container.querySelector('[data-app-slug="github-code-review-bot"]')).toBeNull();
expect(container.textContent).not.toContain("Review agent");
expect(container.querySelector('[data-app-slug="github"]')).not.toBeNull();
});
it("keeps the GitHub tool card when the chat catalog needs its local fallback", async () => {
listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("github")] });
await renderBrowse();
expect(container.querySelector('[data-app-slug="github"]')).not.toBeNull();
expect(container.querySelector('[data-app-slug="github-code-review-bot"]')).not.toBeNull();
await act(() => void container.querySelector<HTMLButtonElement>('button[aria-label="Connect GitHub"]')!.click());
expect(navigateMock).toHaveBeenLastCalledWith("/apps/connect?source=github");
await act(() => void container.querySelector<HTMLButtonElement>('button[aria-label="Connect GitHub Code Review Bot"]')!.click());
expect(navigateMock).toHaveBeenLastCalledWith("/apps/chat/connect?provider=github&purpose=chat");
});
it("renders one connector list with the requested header and no gallery sections", async () => {
@@ -294,7 +325,7 @@ describe("Connectors landing page", () => {
).map((row) => row.dataset.appSlug),
).toEqual([
"discord",
"github",
"github-code-review-bot",
"gmail",
"imessage-photon",
"jira",
+23 -29
View File
@@ -388,9 +388,8 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
).filter(
(application) =>
application.status !== "archived" &&
(chatConnectorsEnabled ||
(application.type !== "chat" &&
application.metadata?.purpose !== "channel")),
application.type !== "chat" &&
application.metadata?.purpose !== "channel",
);
const connectionsByApplicationId = new Map<string, ToolConnection[]>();
for (const connection of activeConnections) {
@@ -429,51 +428,46 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
chatEndpoints: [],
});
}
const nativeChatProviders = [
{ provider: "imessage-photon", name: "iMessage Photon", description: "Message agents and share photos from Apple Messages with a dedicated Photon number." },
const nativeChatApps = [
{ slug: "imessage-photon", name: "iMessage Photon", description: "Message agents and share photos from Apple Messages with a dedicated Photon number." },
{
provider: "slack",
slug: "slack",
name: "Slack",
description:
"Chat with agents from Slack channels and direct messages.",
},
{
provider: "github",
name: "GitHub",
slug: "github-code-review-bot",
name: "GitHub Code Review Bot",
description:
"Chat with agents from issues, pull requests, and review threads.",
"Have an agent review pull requests and respond to GitHub mentions.",
},
{
provider: "discord",
slug: "discord",
name: "Discord",
description:
"Chat with agents from Discord channels, threads, and direct messages.",
},
{
provider: "microsoft-teams",
slug: "microsoft-teams",
name: "Microsoft Teams",
description: "Chat with agents from Teams channels and conversations.",
},
{
provider: "telegram",
slug: "telegram",
name: "Telegram",
description:
"Chat with agents from Telegram direct messages, groups, and topics.",
},
] as const;
for (const item of chatConnectorsEnabled ? nativeChatProviders : []) {
if (
[...rowsBySlug.values()].some(
(row) => chatProviderForSlug(row.slug) === item.provider,
)
)
continue;
rowsBySlug.set(item.provider, {
key: `native-chat:${item.provider}`,
slug: item.provider,
for (const item of chatConnectorsEnabled ? nativeChatApps : []) {
if (rowsBySlug.has(item.slug)) continue;
rowsBySlug.set(item.slug, {
key: `native-chat:${item.slug}`,
slug: item.slug,
name: item.name,
description: item.description,
brandKey: item.provider,
brandKey: item.slug,
entry: null,
applications: [],
connections: [],
@@ -857,17 +851,17 @@ export function ConnectorCard({
{row.chatEndpoints.map((endpoint) => (
<div
key={endpoint.id}
className="flex flex-wrap items-center gap-3 px-4 py-3"
className="flex flex-col gap-2 px-4 py-3 sm:flex-row sm:items-center sm:gap-3"
>
<div className="min-w-0 flex-1">
<button
type="button"
className="truncate text-left text-sm font-medium hover:underline"
className="block max-w-full truncate text-left text-sm font-medium hover:underline"
onClick={() =>
onNavigate(`/apps/chat/${endpoint.id}/settings`)
}
>
{endpoint.assignedAgentName} · {endpoint.provider === "agentmail" ? "Email" : "Chat"}
{endpoint.assignedAgentName} · {endpoint.provider === "agentmail" ? "Email" : endpoint.provider === "github" ? "Code review bot" : "Chat"}
</button>
<p className="truncate text-xs text-muted-foreground">
{endpoint.providerAccountLabel ??
@@ -875,10 +869,10 @@ export function ConnectorCard({
"Provider identity"}
</p>
</div>
<span className="text-xs text-muted-foreground">
{endpoint.status.replace(/_/g, " ")}
</span>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{endpoint.status.replace(/_/g, " ")}
</span>
{endpoint.status === "draft" ? (
<Button
size="sm"
@@ -1,5 +1,4 @@
// @vitest-environment jsdom
import { act } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
@@ -27,7 +26,7 @@ function Location() {
);
}
describe("GitHub connection purpose routing", () => {
describe("GitHub code review bot routing", () => {
let root: Root;
let container: HTMLDivElement;
beforeEach(() => {
@@ -52,33 +51,10 @@ describe("GitHub connection purpose routing", () => {
),
);
}
async function click(label: string) {
const button = [...container.querySelectorAll("button")].find((node) =>
node.textContent?.includes(label),
);
expect(button).toBeDefined();
await act(async () => {
button!.click();
});
}
it("offers personal/tool connections from the catalog instead of forcing bot setup", async () => {
render("provider=github&toolHref=%2Fapps%2Fconnect%3Fsource%3Dgithub");
expect(container.textContent).toContain("Choose how to connect");
expect(container.textContent).not.toContain("GitHub bot setup");
await click("Use this connection as an agent tool");
expect(container.querySelector("output")?.textContent).toBe(
"/apps/connect?source=github",
);
});
it("opens the bot wizard when chat is chosen and preserves the agent preselection", async () => {
render("provider=github&agentId=agent-a");
await click("Chat with an agent");
expect(container.textContent).toContain("GitHub bot setup");
expect(container.querySelector("output")?.textContent).toContain(
"agentId=agent-a&purpose=chat",
);
});
it.each([
"",
"agentId=agent-a",
"toolHref=%2Fapps%2Fconnect%3Fsource%3Dgithub",
"purpose=chat",
"resume=endpoint-a",
"resume=endpoint-a&reconnect=1",
@@ -86,5 +62,6 @@ describe("GitHub connection purpose routing", () => {
render(`provider=github&${search}`);
expect(container.textContent).toContain("GitHub bot setup");
expect(container.textContent).not.toContain("Choose how to connect");
expect(container.querySelector("output")?.textContent).toBe(`/apps/chat/connect?provider=github&${search}`);
});
});
+2 -10
View File
@@ -157,16 +157,8 @@ function ChatConnectionPurpose({ provider, onChat, onTools }: {
}
export function ChatEndpointSetup() {
const [params, setParams] = useSearchParams();
const navigate = useNavigate();
if (params.get("provider") === "github") {
if (params.get("purpose") === "chat" || params.get("resume")) return <GitHubChatSetup />;
return <ChatConnectionPurpose provider="github" onChat={() => {
const next = new URLSearchParams(params);
next.set("purpose", "chat");
setParams(next);
}} onTools={() => navigate(params.get("toolHref") || "/apps/connect?source=github")} />;
}
const [params] = useSearchParams();
if (params.get("provider") === "github") return <GitHubChatSetup />;
return params.get("provider") === "agentmail" ? <EmailEndpointSetup /> : <ChatSdkEndpointSetup />;
}
+1 -1
View File
@@ -125,7 +125,7 @@ export function GitHubChatSetup() {
useEffect(() => {
setBreadcrumbs([
{ label: "Connectors", href: "/apps" },
{ label: "Connect GitHub bot" },
{ label: "GitHub Code Review Bot" },
]);
return () => setBreadcrumbs([]);
}, [setBreadcrumbs]);
@@ -72,7 +72,7 @@ describe("chat connector UI contract", () => {
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, Discord, Microsoft Teams, or Telegram from",
"Connect AgentMail, Slack, GitHub Code Review Bot, Discord, Microsoft Teams, or Telegram from",
);
});
@@ -0,0 +1,125 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { getAppStoreDefinition } from "@paperclipai/shared";
import { Link, Route, Routes, useLocation } from "@/lib/router";
import { SetupWizardSidebarProvider } from "@/context/SetupWizardSidebarContext";
import { SetupWizardSidebar } from "@/components/SetupWizard";
import { queryKeys } from "@/lib/queryKeys";
import { Browse } from "@/pages/apps/Browse";
import { AppsConnect } from "@/pages/apps/AppsConnect";
import { ChatEndpointSetup } from "@/pages/apps/chat/ChatEndpointSetup";
import { storybookAgents } from "../fixtures/paperclipData";
const companyId = "company-storybook";
// Hosted Storybooks live below a branch path; reuse their bundled GitHub marks.
const githubBranding = {
logoUrl: "./brands/apps/github.svg",
darkLogoUrl: "./brands/apps/github-dark.svg",
};
function GitHubConnections({ saved = false, chatEnabled = true }) {
const location = useLocation();
const [client] = useState(() => {
const result = new QueryClient({
defaultOptions: { queries: { staleTime: Infinity, retry: false, refetchOnMount: false } },
});
const github = getAppStoreDefinition("github")!;
result.setQueryData(queryKeys.apps.gallery(companyId), {
apps: [
{ ...github, branding: githubBranding, ownershipAvailability: { platform_shared: true, customer: true } },
{ ...getAppStoreDefinition("github-code-review-bot"), branding: githubBranding },
],
capabilities: { canSetCompanyInstall: true, canCreatePersonalConnection: true, canCreateOrganizationConnection: true },
});
result.setQueryData(queryKeys.instance.experimentalSettings, { enableChatConnectors: chatEnabled });
result.setQueryData(queryKeys.tools.applications(companyId), { applications: saved ? [
{ id: "github-tools", companyId, name: "GitHub", status: "active", metadata: { sourceTemplateKey: "github" } },
// Existing bots retain their provider/source identity after the catalog split.
{ id: "github-bot", companyId, name: "Review bot", status: "active", type: "chat", metadata: { sourceTemplateKey: "github", purpose: "channel" } },
] : [] });
result.setQueryData(queryKeys.tools.connections(companyId), { connections: saved ? [{
id: "github-account", applicationId: "github-tools", companyId, name: "octocat",
status: "active", enabled: true, healthStatus: "healthy", transport: "mcp_remote",
authKind: "oauth", config: { sourceTemplateKey: "github" }, transportConfig: {},
}] : [] });
result.setQueryData(queryKeys.chatEndpoints.list(companyId), saved ? [
{ id: "github-review", companyId, provider: "github", status: "active", assignedAgentId: "reviewer", assignedAgentName: "Reviewer", botLabel: "Paperclip Review", allowUnlinkedPeople: false },
{ id: "github-draft", companyId, provider: "github", status: "draft", assignedAgentId: "backup", assignedAgentName: "Backup reviewer", allowUnlinkedPeople: false },
] : []);
result.setQueryData(queryKeys.access.companyUserDirectory(companyId), { users: [] });
result.setQueryData(["github-setup-agents", companyId], storybookAgents);
return result;
});
return (
<QueryClientProvider client={client}>
<SetupWizardSidebarProvider>
<div className="flex min-h-screen">
{location.pathname.endsWith("/apps/chat/connect") && (
<div className="hidden w-64 shrink-0 md:block">
<SetupWizardSidebar />
</div>
)}
<main className="mx-auto min-w-0 w-full max-w-4xl space-y-6 p-6">
<Link to="/apps" className="text-sm text-muted-foreground hover:text-foreground">
Connectors
</Link>
<Routes>
<Route path="/:companyPrefix/apps/connect" element={<AppsConnect />} />
<Route path="/:companyPrefix/apps/chat/connect" element={<ChatEndpointSetup />} />
<Route path="*" element={<Browse />} />
</Routes>
</main>
</div>
</SetupWizardSidebarProvider>
</QueryClientProvider>
);
}
const meta = {
title: "Connections/GitHub and Code Review Bot",
component: GitHubConnections,
play: async ({ canvasElement }) => {
await userEvent.type(await within(canvasElement).findByRole("searchbox", { name: "Search connectors" }), "GitHub");
},
parameters: {
layout: "fullscreen",
docs: { description: { component: "Production catalog cards and setup pages with local API query fixtures. GitHub opens account/tool access; GitHub Code Review Bot opens Choose agent. These stories cover discovery and entry into setup, without live GitHub consent or app installation." } },
},
} satisfies Meta<typeof GitHubConnections>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Catalog: Story = {};
export const SavedConnections: Story = { args: { saved: true } };
export const ChatDisabled: Story = {
args: { chatEnabled: false },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(await canvas.findByRole("button", { name: /^Connect GitHub$/ })).toBeVisible();
await expect(canvas.queryByRole("heading", { name: "GitHub Code Review Bot" })).not.toBeInTheDocument();
},
};
export const ToolSetup: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole("button", { name: /^Connect GitHub$/ }));
await expect(await canvas.findByRole("heading", { name: "Connect GitHub as" })).toBeVisible();
await expect(canvas.queryByRole("heading", { name: "Choose how to connect" })).not.toBeInTheDocument();
},
};
export const BotSetup: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole("button", { name: /^Connect GitHub Code Review Bot$/ }));
await expect(await canvas.findByRole("heading", { name: /^Choose agent$/ })).toBeVisible();
await expect(canvas.queryByRole("heading", { name: "Choose how to connect" })).not.toBeInTheDocument();
await expect(canvas.getByRole("button", { name: /^Continue$/ })).toBeDisabled();
},
};
export const Mobile: Story = {
args: { saved: true },
globals: { viewport: { value: "mobile", isRotated: false } },
};
export const Light: Story = { globals: { theme: "light" } };