Files
PaperClipAI/packages/shared/src/app-definitions.ts
T
DottaandPaperclip 33f2b3a159 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>
2026-09-30 17:15:02 -05:00

269 lines
9.2 KiB
TypeScript

import { APP_DEFINITIONS } from "./app-definitions.generated.js";
import { SELF_SERVE_MCP_CANDIDATES } from "./self-serve-mcp-research.js";
import type { AppDefinition, ConnectionMethodDef, FieldDef } from "./types/app-definition.js";
import type { ToolConnectionOwnership } from "./types/tool-access.js";
export const CONNECTABLE_APP_SLUGS = new Set([
"anthropic", "openai", "openrouter", "xai",
"agentmail",
"browser-use-cloud",
"cognee",
...SELF_SERVE_MCP_CANDIDATES.map((entry) => entry.slug),
"zapier",
"arcade",
"executor",
"slack",
"notion",
"railway",
"posthog",
"linear",
"google-sheets",
"context7",
"shopify",
"composio",
"gmail",
"google-drive",
"google-docs",
"google-slides",
"google-calendar",
"google-chat",
"google-people",
"google-workspace-search",
"github",
"github-code-review-bot",
"discord",
"microsoft-teams",
"telegram",
"imessage-photon",
]);
export const CONNECTABLE_APP_DEFINITIONS = APP_DEFINITIONS.filter((app) =>
CONNECTABLE_APP_SLUGS.has(app.slug)
);
/**
* Definitions retained for existing connections and later verification, but
* intentionally withheld from the customer-facing store. Keeping visibility
* separate from recognition avoids breaking saved connections when a provider
* is pulled from Browse or reserved for a future first-party experience.
*/
export const APP_STORE_HIDDEN_SLUGS = new Set([
"beehiiv",
"bitly",
"brex",
"candid",
"coda",
"context7",
"egnyte",
"embat",
"kernel",
"local-falcon",
"make",
"manufact",
"oreilly",
"planetscale",
"razorpay",
"sanity",
"similarweb",
"ticket-tailor",
"ticktick",
"xero",
]);
export const APP_STORE_DEFINITIONS = CONNECTABLE_APP_DEFINITIONS.filter((app) =>
!APP_STORE_HIDDEN_SLUGS.has(app.slug)
);
export const DEFAULT_OWNERSHIP_AVAILABILITY: Record<ToolConnectionOwnership, boolean> = {
platform_shared: false,
platform_provisioned: false,
customer: true,
dcr: true,
};
export function getConnectableAppDefinition(slug: string): AppDefinition | null {
return CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === slug) ?? null;
}
export function getAppStoreDefinition(slug: string): AppDefinition | null {
return APP_STORE_DEFINITIONS.find((app) => app.slug === slug) ?? null;
}
export function isAppStoreVisibleSlug(slug: string | null | undefined): boolean {
return Boolean(slug && !APP_STORE_HIDDEN_SLUGS.has(slug) && CONNECTABLE_APP_SLUGS.has(slug));
}
function wildcardPatternToRegExp(pattern: string): RegExp {
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
return new RegExp(`^${escaped}$`, "i");
}
export function getAppDefinitionForUrl(
link: string,
definitions: readonly AppDefinition[] = CONNECTABLE_APP_DEFINITIONS,
): AppDefinition | null {
let normalized: string;
try {
normalized = new URL(link.trim()).toString();
} catch {
return null;
}
return definitions.find((app) =>
app.urlPatterns.some((pattern) => wildcardPatternToRegExp(pattern).test(normalized))
) ?? null;
}
export function getAvailableConnectionMethods(app: AppDefinition): ConnectionMethodDef[] {
const availability = app.ownershipAvailability ?? DEFAULT_OWNERSHIP_AVAILABILITY;
return app.methods.filter((method) =>
method.ownershipModes.some((ownership) => availability[ownership] !== false)
);
}
/**
* Pick the method that gives a new connection the app's useful write surface.
*
* Google Workspace publishes separate read and write capability groups. The
* read method is intentionally listed first for documentation, but treating
* array order as a product default silently created read-only connections and
* left every write action Off. Capability metadata is the durable signal; apps
* without an explicit write/draft capability retain their declared order.
*/
export function getRecommendedConnectionMethod(
methods: readonly ConnectionMethodDef[],
): ConnectionMethodDef | null {
const recommendedCapability = (candidates: readonly ConnectionMethodDef[]) => candidates.find((method) => {
const capabilityKey = method.capabilityProfile?.key;
return capabilityKey === "write" || capabilityKey === "draft";
});
const managedMethods = methods.filter((method) =>
method.oauthStrategy === "paperclip_cloud_connector"
|| method.oauthStrategy === "paperclip_id_connector"
);
// Prefer the permissions needed for agent work, then the simplest sign-in.
// Explicit read-only selections pass their own capability group here.
return recommendedCapability(managedMethods)
?? recommendedCapability(methods)
?? managedMethods[0]
?? methods[0]
?? null;
}
export function getAvailableConnectionMethod(
app: AppDefinition,
methodKey?: string | null,
): ConnectionMethodDef | null {
const methods = getAvailableConnectionMethods(app);
return methodKey
? methods.find((method) => method.key === methodKey) ?? null
: getRecommendedConnectionMethod(methods);
}
export function connectionMethodSupportsAutomaticOAuth(method: ConnectionMethodDef | null | undefined): boolean {
return method?.auth === "oauth" && (
(method.oauthStrategy === "paperclip_cloud_connector" || method.oauthStrategy === "paperclip_id_connector")
|| method.ownershipModes.includes("dcr")
);
}
export function connectionMethodAcceptsCustomerOAuthClient(method: ConnectionMethodDef | null | undefined): boolean {
return method?.auth === "oauth"
&& !method.oauthStrategy
&& method.ownershipModes.includes("customer");
}
export function connectionMethodSupportsCatalogSetup(method: ConnectionMethodDef | null | undefined): boolean {
if (!method) return false;
if (method.transport === "runtime_auth") return Boolean(method.ai);
if (method.auth === "none" || method.auth === "api_key") return true;
return connectionMethodSupportsAutomaticOAuth(method)
|| connectionMethodAcceptsCustomerOAuthClient(method);
}
export function connectionMethodRequiresConfiguration(method: ConnectionMethodDef | null | undefined): boolean {
if (!method) return false;
const visibleTenantFields = method.tenantFields?.filter((field) => !field.hidden) ?? [];
const visibleExtensionFields = method.extensionFields?.filter((field) => !field.hidden) ?? [];
return Boolean(
method.credentialFields?.length
|| visibleTenantFields.length
|| visibleExtensionFields.length
|| method.configRequirements?.atLeastOneOf?.length
// "Use your own OAuth app" is an advanced alternative when DCR/CIMD is
// available, not a required setup field. Only customer-client-only methods
// must stop on the configuration screen.
|| (
connectionMethodAcceptsCustomerOAuthClient(method)
&& !connectionMethodSupportsAutomaticOAuth(method)
),
);
}
export function appSupportsCatalogSetup(app: AppDefinition | null | undefined): boolean {
return Boolean(app && getAvailableConnectionMethods(app).some(connectionMethodSupportsCatalogSetup));
}
export function isConnectableAppSlug(slug: string | null | undefined): boolean {
return Boolean(slug && CONNECTABLE_APP_SLUGS.has(slug));
}
export function appSupportsAutomaticOAuth(app: AppDefinition | null | undefined): boolean {
return Boolean(app && getAvailableConnectionMethods(app).some(connectionMethodSupportsAutomaticOAuth));
}
export function appAcceptsCustomerOAuthClient(app: AppDefinition | null | undefined): boolean {
return Boolean(app && getAvailableConnectionMethods(app).some(connectionMethodAcceptsCustomerOAuthClient));
}
export function credentialConfigPath(field: FieldDef, method?: ConnectionMethodDef | null): string {
if (method?.transport === "local_stdio" && method.keyPlacement?.location === "env") return `env.${field.key}`;
return `credentials.${field.key}`;
}
/** Older credential references used bare names; current references use paths. */
export function connectionCredentialConfigPath(ref: { name: string }): string {
return /^(credentials|headers|oauth|remote)\./.test(ref.name)
? ref.name
: `credentials.${ref.name}`;
}
export function resolveConnectionMethodServerUrl(
method: ConnectionMethodDef,
configValues: Record<string, string | boolean>,
): string | null {
const template = method.defaults?.serverUrlTemplate;
if (!template) return method.defaults?.serverUrl ?? null;
let missingValue = false;
const resolved = template.replace(/\{([a-zA-Z0-9_-]+)\}/g, (_placeholder, key: string) => {
const value = configValues[key];
if (value === undefined || String(value).trim().length === 0) {
missingValue = true;
return "";
}
return encodeURIComponent(String(value).trim());
});
if (missingValue) return null;
try {
return new URL(resolved).toString();
} catch {
return null;
}
}
export function recommendedDefaultsForApp(app: AppDefinition, methodKey?: string | null): Record<string, unknown> {
// Keep the parameters in the public contract: callers resolve defaults for a
// concrete app/method even though the initial policy is now uniform. This is
// an open default, not an approval bypass: connection finalization remains a
// configure-authorized, audited operation, and Ask first stays available as
// an operator-selected policy for any action after the connection is made.
void app;
void methodKey;
return {
access: "all_agents",
askFirstRiskLevels: [],
};
}