diff --git a/.changeset/guided-github-setup.md b/.changeset/guided-github-setup.md new file mode 100644 index 000000000..b8c8ad491 --- /dev/null +++ b/.changeset/guided-github-setup.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Add guided GitHub channel setup through `eve add channel/github`. The flow provisions a Vercel Connect GitHub App, routes verified webhooks, scaffolds the channel, and explains how to install and use the app. diff --git a/apps/docs/lib/integrations/data.ts b/apps/docs/lib/integrations/data.ts index 060d4663c..582a353af 100644 --- a/apps/docs/lib/integrations/data.ts +++ b/apps/docs/lib/integrations/data.ts @@ -271,26 +271,23 @@ TWILIO_AUTH_TOKEN=... # required for inbound signature verification logo: "github", docsHref: "/docs/channels/github", keywords: ["issues", "pull requests", "app", "webhook", "code"], - install: `Add this channel from eve's registry. This writes \`agent/channels/github.ts\`: + install: `Add this channel from eve's registry to create a Vercel Connect GitHub App, route verified webhooks, and write \`agent/channels/github.ts\`: \`\`\`bash eve add channel/github \`\`\``, - quickStart: `Create \`agent/channels/github.ts\`: + quickStart: `The guided setup writes \`agent/channels/github.ts\`: \`\`\`ts // agent/channels/github.ts +import { connectGitHubCredentials } from "@vercel/connect/eve"; import { githubChannel } from "eve/channels/github"; export default githubChannel({ - credentials: { - appId: () => process.env.GITHUB_APP_ID!, - privateKey: () => process.env.GITHUB_APP_PRIVATE_KEY!, - webhookSecret: () => process.env.GITHUB_WEBHOOK_SECRET!, - }, + credentials: connectGitHubCredentials("github/my-agent"), }); \`\`\``, - configure: `Create a GitHub App, subscribe to issue and pull-request events, and set the webhook URL to eve's route (\`/eve/v1/github\`). Provide the app ID, private key, and webhook secret through environment variables. See the [GitHub channel docs](/docs/channels/github) for required permissions.`, + configure: `Sign in to Vercel, then let the guided flow create or link a project, provision the GitHub App, and attach its verified webhook trigger to \`/eve/v1/github\`. Deploy, install the app from Vercel Connect, then add its \`@handle\` invocation token to a new issue, pull request, or review comment. GitHub may not autocomplete or render the token as a linked mention. See the [GitHub channel docs](/docs/channels/github) for permissions and events.`, }, "linear-agent": { logo: "linear", diff --git a/apps/docs/registry.json b/apps/docs/registry.json index 621c1285c..5277aa3e0 100644 --- a/apps/docs/registry.json +++ b/apps/docs/registry.json @@ -1135,14 +1135,14 @@ "requires": ">=0.27.8" } }, - "dependencies": ["@vercel/connect@0.4.2"] + "dependencies": ["@vercel/connect@>=0.4.2"] }, { "name": "channel/discord", "type": "registry:item", "title": "Discord", "description": "Connect an eve agent to Discord with guided connector and slash-command setup.", - "dependencies": ["@vercel/connect@0.6.0"], + "dependencies": ["@vercel/connect@>=0.6.0"], "meta": { "eve": { "setup": { @@ -1209,19 +1209,19 @@ "name": "channel/github", "type": "registry:item", "title": "GitHub", - "description": "Drive your agent from issues, pull requests, and comments.", - "envVars": { - "GITHUB_APP_ID": "", - "GITHUB_APP_PRIVATE_KEY": "", - "GITHUB_WEBHOOK_SECRET": "" - }, - "files": [ - { - "path": "registry/channels/github.ts", - "type": "registry:file", - "target": "agent/channels/github.ts" + "description": "Drive your agent from issues, pull requests, and comments, with guided Connect setup.", + "meta": { + "eve": { + "setup": { + "command": "eve", + "package": "eve", + "bin": "eve", + "args": ["integration", "setup", "github"] + }, + "requires": ">=0.30.7" } - ] + }, + "dependencies": ["@vercel/connect@>=0.6.0"] }, { "name": "channel/linear-agent", @@ -1681,7 +1681,7 @@ "type": "registry:item", "title": "Photon iMessage", "description": "Connect an eve agent to iMessage through Photon with guided project and phone setup.", - "dependencies": ["@vercel/connect@0.5.0"], + "dependencies": ["@vercel/connect@>=0.5.0"], "meta": { "eve": { "setup": { diff --git a/apps/docs/registry/channels/github.ts b/apps/docs/registry/channels/github.ts index 736d53f92..5b7b7d5d2 100644 --- a/apps/docs/registry/channels/github.ts +++ b/apps/docs/registry/channels/github.ts @@ -1,9 +1,6 @@ +import { connectGitHubCredentials } from "@vercel/connect/eve"; import { githubChannel } from "eve/channels/github"; export default githubChannel({ - credentials: { - appId: () => process.env.GITHUB_APP_ID!, - privateKey: () => process.env.GITHUB_APP_PRIVATE_KEY!, - webhookSecret: () => process.env.GITHUB_WEBHOOK_SECRET!, - }, + credentials: connectGitHubCredentials("github/my-agent"), }); diff --git a/apps/docs/scripts/validate-channel-registry.ts b/apps/docs/scripts/validate-channel-registry.ts index b7570950e..5d16f9ecc 100644 --- a/apps/docs/scripts/validate-channel-registry.ts +++ b/apps/docs/scripts/validate-channel-registry.ts @@ -35,6 +35,7 @@ const registrySlugsByCatalogSlug: Readonly> = { const setupKindsByCatalogSlug: Readonly> = { discord: "discord", + github: "github", "linear-agent": "linear", eve: "web", photon: "photon", @@ -115,6 +116,7 @@ for (const [index, item] of items.entries()) { if ( entry.slug === "slack" || entry.slug === "discord" || + entry.slug === "github" || entry.slug === "linear-agent" || entry.slug === "eve" || entry.slug === "photon" diff --git a/docs/channels/github.mdx b/docs/channels/github.mdx index d82b641e3..de0400160 100644 --- a/docs/channels/github.mdx +++ b/docs/channels/github.mdx @@ -1,29 +1,24 @@ --- title: "GitHub" -description: "Reach your agent from GitHub App webhooks, with @mention dispatch, PR diff context, sandbox checkout, and Vercel Connect credentials." +description: "Reach your agent from GitHub App webhooks, with comment invocation, PR diff context, sandbox checkout, and Vercel Connect credentials." type: integration --- -The GitHub channel lets the agent work directly on a repository. Someone `@mentions` it in an issue, PR, or review comment, and the agent answers right there in the thread, with the PR diff already in context and the repo checked out into the sandbox. It takes GitHub App webhooks at `/eve/v1/github`, checks the signature, derives auth from whoever triggered the event, and replies on the native surface. Credentials can run through [Vercel Connect](../guides/auth-and-route-protection), which manages the GitHub App, the installation token, and inbound webhook verification, so there's no app private key or webhook secret for you to hold. See [Channels](./overview) for the contract this builds on. +The GitHub channel lets the agent work directly on a repository. Add its invocation token, such as `@my-agent`, to a new issue, PR, or review comment and the agent answers in that thread, with the PR diff already in context and the repo checked out into the sandbox. The token is an eve convention: GitHub may not autocomplete it or render it as a linked mention. The channel takes GitHub App webhooks at `/eve/v1/github`, checks the signature, derives auth from whoever triggered the event, and replies on the native surface. Credentials can run through [Vercel Connect](../guides/auth-and-route-protection), which manages the GitHub App, the installation token, and inbound webhook verification, so there's no app private key or webhook secret for you to hold. See [Channels](./overview) for the contract this builds on. -## Set up Connect +## Guided Connect setup -Create a GitHub Connect client and copy its UID (e.g. `github/my-agent`), then attach this project as the trigger destination at eve's GitHub route: +Run the registry setup from the agent directory: ```bash -npm install -g vercel@latest -vercel connect create github --triggers -vercel connect detach --yes -vercel connect attach --triggers --trigger-path /eve/v1/github --yes +eve add channel/github ``` -The `create` step provisions the GitHub App and a trigger destination at the default Connect path. `detach` then `attach --trigger-path /eve/v1/github` re-points the trigger at the eve GitHub route, since eve does not serve the default Connect path. `--triggers` makes Connect receive the App's webhooks, verify them, and forward them to your deployment. During registration, subscribe to `issue_comment` and `pull_request_review_comment` for mention-driven turns — the managed App defaults to `pull_request` only — and add `issues`, `pull_request`, `check_suite`, `check_run`, or `workflow_run` if you wire up their opt-in hooks. You can also create the client from the [Connect dashboard](https://vercel.com/d?to=/%5Bteam%5D/~/connect&title=Go+to+Connect). +The flow checks that the Vercel CLI is authenticated, creates or links a Vercel project when needed, provisions an app-scoped GitHub Connect client, and replaces its default trigger with `/eve/v1/github`. It then installs `@vercel/connect` and writes `agent/channels/github.ts` with the connector UID. -## Add the channel +Vercel Connect creates the GitHub App, receives and verifies its webhooks, and forwards them to the deployed agent. After deploying, open the GitHub App in the Connect dashboard and install it in the organization or account where you want to use it. Add the generated invocation token (for example, `@my-agent`) to a new issue, pull request, or review comment to start a conversation. GitHub may not autocomplete the token or render it as a linked mention. -```bash -npm install @vercel/connect -``` +The generated channel uses Connect-managed credentials: ```ts title="agent/channels/github.ts" import { connectGitHubCredentials } from "@vercel/connect/eve"; @@ -65,7 +60,7 @@ GITHUB_APP_SLUG=... # supplies botName when it is not set in config `appId`/`privateKey`/`webhookSecret` also take a lazy resolver function if you'd rather fetch them on demand. -Point the GitHub App webhook URL at `https:///eve/v1/github`. For mention-driven turns, subscribe to `issue_comment` and `pull_request_review_comment`; add `issues`, `pull_request`, `check_suite`, `check_run`, or `workflow_run` if you wire up their opt-in hooks. A comment that `@mention`s `botName` starts a turn. +Point the GitHub App webhook URL at `https:///eve/v1/github`. For comment-invoked turns, subscribe to `issue_comment` and `pull_request_review_comment`; add `issues`, `pull_request`, `check_suite`, `check_run`, or `workflow_run` if you wire up their opt-in hooks. After installing the App for the repository, a new comment that includes `@botName` starts a turn. This is a text invocation token, not a GitHub-native mention: GitHub may display the App as `botName[bot]`, but it may not autocomplete or link `@botName`. ## How the channel handles messages @@ -78,7 +73,7 @@ import { defaultGitHubAuth, githubChannel } from "eve/channels/github"; export default githubChannel({ botName: "my-agent", - // Replaces the @mention gate. ctx.conversation.kind is "issue", "pull_request", or "review_thread". + // Replaces the default invocation-token gate. ctx.conversation.kind is "issue", "pull_request", or "review_thread". onComment: (ctx, comment) => ({ auth: defaultGitHubAuth(ctx) }), // Opt in; no default dispatch on these events. onIssue: (ctx, issue) => (issue.action === "opened" ? { auth: defaultGitHubAuth(ctx) } : null), @@ -108,7 +103,7 @@ GitHub comments have no interactive button or card affordance. A human-in-the-lo ### Proactive sessions -Start a session without an inbound mention through `receive(github, { message, target, auth })` from a schedule `run` handler, or `args.receive(github, ...)` from another channel. The target requires `owner`, `repo`, and exactly one of `issueNumber` or `pullRequestNumber`. +Start a session without an inbound comment invocation through `receive(github, { message, target, auth })` from a schedule `run` handler, or `args.receive(github, ...)` from another channel. The target requires `owner`, `repo`, and exactly one of `issueNumber` or `pullRequestNumber`. ### Attachments diff --git a/packages/eve-catalog/src/index.ts b/packages/eve-catalog/src/index.ts index 2def2d4f7..19f0c38ed 100644 --- a/packages/eve-catalog/src/index.ts +++ b/packages/eve-catalog/src/index.ts @@ -122,7 +122,8 @@ export const INTEGRATIONS: readonly IntegrationEntry[] = [ slug: "github", name: "GitHub", kind: "channel", - tagline: "Drive your agent from issues, pull requests, and comments.", + tagline: + "Drive your agent from issues, pull requests, and comments, with guided Connect setup.", surfaces: { scaffoldable: false, gallery: true }, }, { diff --git a/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts b/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts index 4175d7f9a..97d57005a 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts @@ -1490,6 +1490,24 @@ describe("TerminalRenderer (inline scrollback)", () => { expect(screen.snapshot()).toContain("⎿ ✓ Registry items added: connection/linear."); }); + it("keeps a setup command result tight after its flow closes", async () => { + const { screen, input, renderer } = makeRenderer(); + + const prompt = renderer.readPrompt(); + input.type("/add"); + input.enter(); + await prompt; + renderer.setupFlow.begin("/add"); + renderer.setupFlow.end({ preserveDiagnostics: false }); + renderer.renderCommandResult("Registry items added: channel/github.", "success"); + renderer.shutdown(); + + const lines = screen.snapshot().split("\n"); + const command = lines.findIndex((line) => line.includes("│ /add")); + const result = lines.findIndex((line) => line.includes("⎿ ✓ Registry items added")); + expect(result).toBe(command + 1); + }); + it("marks a failed automatic command and keeps its multiline outcome in one result block", () => { const { screen, renderer } = makeRenderer(); renderer.renderCommandInvocation("/vc:login", "failed"); diff --git a/packages/eve/src/cli/dev/tui/terminal-renderer.ts b/packages/eve/src/cli/dev/tui/terminal-renderer.ts index 9f26316cb..11b279204 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.ts @@ -1648,6 +1648,8 @@ export class TerminalRenderer implements AgentTUIRenderer { const content = stripTerminalControls(text); if (content.trim().length === 0) return; this.#start(); + const latest = this.#blocks.at(-1); + if (latest?.kind === "command") latest.live = false; this.#pushBlock( tone === "success" ? { kind: "result", body: content, live: false, status: "done" } @@ -1695,6 +1697,10 @@ export class TerminalRenderer implements AgentTUIRenderer { if (flow === undefined) return; this.#setupFlow = undefined; this.#stopTicker(); + // The result belongs to the slash command that opened this flow. Its + // elbow should stay adjacent even though closing the panel repaints first. + const latest = this.#blocks.at(-1); + if (latest?.kind === "command") latest.live = true; if (preserveDiagnostics) { let evidence: string[] = []; diff --git a/packages/eve/src/setup/integrations/github/connect.test.ts b/packages/eve/src/setup/integrations/github/connect.test.ts new file mode 100644 index 000000000..4f046e334 --- /dev/null +++ b/packages/eve/src/setup/integrations/github/connect.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { ChannelSetupLog } from "#setup/cli/index.js"; +import { parseCreatedGitHubConnector, provisionGitHubConnector } from "./connect.js"; + +function log(): ChannelSetupLog { + return { + message: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + commandOutput: vi.fn(), + }; +} + +describe("GitHub Connect provisioning", () => { + it("parses an app-scoped GitHub connector", () => { + expect( + parseCreatedGitHubConnector( + JSON.stringify({ + id: "scl_github", + uid: "github/agent", + supportedSubjectTypes: ["app"], + }), + ), + ).toEqual({ id: "scl_github", uid: "github/agent" }); + }); + + it("creates the connector and replaces its trigger", async () => { + const runVercelCaptureStdout = vi + .fn() + .mockResolvedValueOnce({ + ok: true as const, + stdout: JSON.stringify({ + id: "scl_github", + uid: "github/agent", + supportedSubjectTypes: ["app"], + }), + stderr: "", + }) + .mockResolvedValueOnce({ + ok: true as const, + stdout: JSON.stringify({ + data: { appSlug: "agent" }, + id: "scl_github", + type: "github", + uid: "github/agent", + }), + stderr: "", + }); + const runVercel = vi.fn(async () => true); + + await expect( + provisionGitHubConnector({ + events: ["issue_comment", "pull_request_review_comment"], + log: log(), + project: { orgId: "team_123", projectId: "prj_123" }, + projectRoot: "/project", + slug: "agent", + deps: { runVercel, runVercelCaptureStdout }, + }), + ).resolves.toEqual({ appSlug: "agent", id: "scl_github", uid: "github/agent" }); + + expect(runVercelCaptureStdout).toHaveBeenCalledWith( + [ + "connect", + "create", + "github", + "--name", + "agent", + "--triggers", + "--trigger-event", + "issue_comment", + "--trigger-event", + "pull_request_review_comment", + "-F", + "json", + "--scope", + "team_123", + ], + expect.objectContaining({ cwd: "/project", nonInteractive: true }), + ); + expect(runVercelCaptureStdout).toHaveBeenNthCalledWith( + 2, + ["api", "/v1/connect/connectors/scl_github", "--scope", "team_123", "--raw"], + expect.objectContaining({ cwd: "/project", nonInteractive: true }), + ); + expect(runVercel).toHaveBeenNthCalledWith( + 1, + ["connect", "detach", "github/agent", "--project", "prj_123", "--yes", "--scope", "team_123"], + expect.objectContaining({ cwd: "/project", nonInteractive: true }), + ); + expect(runVercel).toHaveBeenNthCalledWith( + 2, + [ + "connect", + "attach", + "github/agent", + "--project", + "prj_123", + "--environment", + "production", + "--triggers", + "--trigger-path", + "/eve/v1/github", + "--yes", + "--scope", + "team_123", + ], + expect.objectContaining({ cwd: "/project", nonInteractive: true }), + ); + }); +}); diff --git a/packages/eve/src/setup/integrations/github/connect.ts b/packages/eve/src/setup/integrations/github/connect.ts new file mode 100644 index 000000000..03be247e0 --- /dev/null +++ b/packages/eve/src/setup/integrations/github/connect.ts @@ -0,0 +1,155 @@ +import { createPromptCommandOutput, withPhase, type ChannelSetupLog } from "#setup/cli/index.js"; +import { replaceConnectTrigger } from "#setup/connect-provisioning.js"; +import type { VercelProjectReference } from "#setup/project-resolution.js"; +import { runVercel, runVercelCaptureStdout } from "#setup/primitives/run-vercel.js"; +import { z } from "zod"; + +export const GITHUB_TRIGGER_PATH = "/eve/v1/github"; + +/** Identity of the GitHub connector provisioned for an agent channel. */ +export interface GitHubConnectorRef { + /** GitHub's actual @mention handle, without the `[bot]` suffix. */ + appSlug: string; + id: string; + uid: string; +} + +/** Effects used to provision a GitHub Connect connector. */ +export interface ProvisionGitHubConnectorDeps { + runVercel: typeof runVercel; + runVercelCaptureStdout: typeof runVercelCaptureStdout; +} + +const GitHubConnectorRefSchema = z.object({ + id: z.string().min(1), + uid: z.string().min(1), + supportedSubjectTypes: z.array(z.string()).refine((types) => types.includes("app")), +}); + +const GitHubConnectorDetailsSchema = z.object({ + data: z.object({ + appSlug: z.string().min(1), + }), + id: z.string().min(1), + type: z.literal("github"), + uid: z.string().min(1), +}); + +/** Parses `vercel connect create -F json` output for an app-scoped GitHub connector. */ +export function parseCreatedGitHubConnector( + stdout: string, +): Omit | undefined { + try { + const parsed = GitHubConnectorRefSchema.safeParse(JSON.parse(stdout)); + return parsed.success ? { id: parsed.data.id, uid: parsed.data.uid } : undefined; + } catch { + return undefined; + } +} + +function parseGitHubConnectorDetails( + stdout: string, + connector: Omit, +): GitHubConnectorRef | undefined { + try { + const parsed = GitHubConnectorDetailsSchema.safeParse(JSON.parse(stdout)); + if (!parsed.success || parsed.data.id !== connector.id || parsed.data.uid !== connector.uid) { + return undefined; + } + return { ...connector, appSlug: parsed.data.data.appSlug }; + } catch { + return undefined; + } +} + +/** Creates a GitHub connector and routes its verified webhooks to eve. */ +export async function provisionGitHubConnector(input: { + events: readonly string[]; + log: ChannelSetupLog; + project: VercelProjectReference; + projectRoot: string; + slug: string; + signal?: AbortSignal; + deps?: ProvisionGitHubConnectorDeps; +}): Promise { + const deps = input.deps ?? { runVercel, runVercelCaptureStdout }; + const onOutput = createPromptCommandOutput(input.log); + const result = await withPhase(input.log, "Creating GitHub connector...", () => + deps.runVercelCaptureStdout( + [ + "connect", + "create", + "github", + "--name", + input.slug, + "--triggers", + ...input.events.flatMap((event) => ["--trigger-event", event]), + "-F", + "json", + "--scope", + input.project.orgId, + ], + { + cwd: input.projectRoot, + nonInteractive: true, + onOutput, + signal: input.signal, + }, + ), + ); + input.signal?.throwIfAborted(); + if (!result.ok) { + const detail = [result.stderr, result.stdout].find( + (value): value is string => value !== undefined && value.trim().length > 0, + ); + throw new Error( + detail ? `GitHub connector creation failed:\n${detail}` : "GitHub connector creation failed.", + ); + } + const created = parseCreatedGitHubConnector(result.stdout); + if (created === undefined) throw new Error("Vercel returned an invalid GitHub connector."); + + const details = await withPhase(input.log, "Reading GitHub App details...", () => + deps.runVercelCaptureStdout( + [ + "api", + `/v1/connect/connectors/${encodeURIComponent(created.id)}`, + "--scope", + input.project.orgId, + "--raw", + ], + { + cwd: input.projectRoot, + nonInteractive: true, + onOutput, + signal: input.signal, + }, + ), + ); + input.signal?.throwIfAborted(); + if (!details.ok) throw new Error("Could not read the created GitHub connector."); + const connector = parseGitHubConnectorDetails(details.stdout, created); + if (connector === undefined) + throw new Error("Vercel returned invalid details for the GitHub connector."); + + const attachment = await withPhase(input.log, "Connecting GitHub webhooks...", () => + replaceConnectTrigger({ + connectorUid: connector.uid, + projectRoot: input.projectRoot, + projectId: input.project.projectId, + orgId: input.project.orgId, + environment: "production", + triggerPath: GITHUB_TRIGGER_PATH, + onOutput, + signal: input.signal, + deps, + }), + ); + input.signal?.throwIfAborted(); + if (attachment.state !== "attached") { + throw new Error( + `GitHub connector was created, but its trigger could not be attached. Run \`vercel connect attach ${connector.uid} --project ${input.project.projectId} --environment production --triggers --trigger-path ${GITHUB_TRIGGER_PATH} --yes --scope ${input.project.orgId}\`.`, + ); + } + return connector; +} diff --git a/packages/eve/src/setup/integrations/github/setup.test.ts b/packages/eve/src/setup/integrations/github/setup.test.ts new file mode 100644 index 000000000..c36b1e9b3 --- /dev/null +++ b/packages/eve/src/setup/integrations/github/setup.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createFakePrompter } from "#internal/testing/fake-prompter.js"; +import type { Asker } from "#setup/ask.js"; + +import { integrationSetupEnvironment } from "../shared/environment.js"; +import { createIntegrationSetupUi } from "../shared/ui.js"; +import { setupGitHub, type GitHubSetupDeps } from "./setup.js"; + +function deps(): GitHubSetupDeps { + return { + deriveConnectorSlug: vi.fn(async () => "agent" as never), + ensureVercelProject: vi.fn(async () => ({ orgId: "team-id", projectId: "project-id" })), + provisionConnector: vi.fn(async () => ({ + appSlug: "agent", + id: "scl_github", + uid: "github/agent", + })), + writeTextFile: vi.fn(async () => {}), + }; +} + +describe("GitHub setup", () => { + function asker(events = ["issue_comment", "pull_request_review_comment"]): Asker { + return { + ask: vi.fn(), + askMany: vi.fn(async () => events) as Asker["askMany"], + }; + } + + it("provisions Connect, routes the selected webhooks, and scaffolds matching handlers", async () => { + const fake = createFakePrompter(); + const effects = deps(); + const selectedEvents = ["issue_comment", "issues", "pull_request"]; + + await expect( + setupGitHub( + { + appRoot: "/project", + environment: integrationSetupEnvironment("authenticated", { kind: "unresolved" }), + ui: createIntegrationSetupUi({ asker: asker(selectedEvents), prompter: fake.prompter }), + }, + effects, + ), + ).resolves.toMatchObject({ kind: "done" }); + + expect(effects.provisionConnector).toHaveBeenCalledWith( + expect.objectContaining({ events: selectedEvents }), + ); + expect(effects.writeTextFile).toHaveBeenCalledWith( + "/project/agent/channels/github.ts", + expect.stringContaining('connectGitHubCredentials("github/agent")'), + { force: undefined }, + ); + const scaffold = vi.mocked(effects.writeTextFile).mock.calls[0]?.[1] ?? ""; + expect(scaffold).toContain('botName: "agent"'); + expect(scaffold).toContain("onIssue(ctx, issue)"); + expect(scaffold).toContain("onPullRequest(ctx, pullRequest)"); + }); + + it("recommends comment events that the default scaffold handles", async () => { + const fake = createFakePrompter(); + const askMany = vi.fn(async () => [ + "issue_comment", + "pull_request_review_comment", + ]) as Asker["askMany"]; + + await setupGitHub( + { + appRoot: "/project", + environment: integrationSetupEnvironment("authenticated", { kind: "unresolved" }), + ui: createIntegrationSetupUi({ asker: { ask: vi.fn(), askMany }, prompter: fake.prompter }), + }, + deps(), + ); + + expect(askMany).toHaveBeenCalledWith( + expect.objectContaining({ + key: "github-events", + recommended: ["issue_comment", "pull_request_review_comment"], + }), + ); + }); + + it("requires an authenticated Vercel CLI", async () => { + const fake = createFakePrompter(); + await expect( + setupGitHub({ + appRoot: "/project", + environment: integrationSetupEnvironment("logged-out", { kind: "unresolved" }), + ui: createIntegrationSetupUi({ + asker: asker(), + prompter: fake.prompter, + }), + }), + ).rejects.toThrow("vercel login"); + }); +}); diff --git a/packages/eve/src/setup/integrations/github/setup.ts b/packages/eve/src/setup/integrations/github/setup.ts new file mode 100644 index 000000000..6badc0d0a --- /dev/null +++ b/packages/eve/src/setup/integrations/github/setup.ts @@ -0,0 +1,159 @@ +import { join } from "node:path"; + +import type { MultiSelectQuestion } from "#setup/ask.js"; +import { ensureVercelProject } from "#setup/flows/ensure-vercel-project.js"; +import { deriveSlackConnectorSlug } from "#setup/scaffold/index.js"; +import { writeTextFile } from "#setup/scaffold/files.js"; +import { WizardCancelledError } from "#setup/step.js"; + +import type { + IntegrationSetupContext, + IntegrationSetupResult, + SetupIntegration, +} from "../types.js"; +import { provisionGitHubConnector } from "./connect.js"; + +export interface GitHubSetupDeps { + deriveConnectorSlug: typeof deriveSlackConnectorSlug; + ensureVercelProject: typeof ensureVercelProject; + provisionConnector: typeof provisionGitHubConnector; + writeTextFile: typeof writeTextFile; +} + +const defaultDeps: GitHubSetupDeps = { + deriveConnectorSlug: deriveSlackConnectorSlug, + ensureVercelProject, + provisionConnector: provisionGitHubConnector, + writeTextFile, +}; + +const GITHUB_EVENT_OPTIONS = [ + { + id: "issue_comment", + label: "New issue and PR comments", + value: "issue_comment", + hint: "Reply when a new timeline comment includes `@`.", + }, + { + id: "pull_request_review_comment", + label: "New inline PR review comments", + value: "pull_request_review_comment", + hint: "Reply when a new inline review comment includes `@`.", + }, + { + id: "issues", + label: "New issues", + value: "issues", + hint: "Add comments to new issues.", + }, + { + id: "pull_request", + label: "New PRs", + value: "pull_request", + hint: "Add comments to new pull requests.", + }, +] as const; + +type GitHubWebhookEvent = (typeof GITHUB_EVENT_OPTIONS)[number]["value"]; + +const DEFAULT_GITHUB_EVENTS: readonly GitHubWebhookEvent[] = [ + "issue_comment", + "pull_request_review_comment", +]; + +const githubEventsQuestion: MultiSelectQuestion = { + key: "github-events", + message: "What should this GitHub App respond to?", + options: GITHUB_EVENT_OPTIONS, + recommended: DEFAULT_GITHUB_EVENTS, + requireSelection: true, +}; + +function connectTemplate( + uid: string, + appSlug: string, + events: readonly GitHubWebhookEvent[], +): string { + const handlers = [ + events.includes("issues") + ? ` onIssue(ctx, issue) { + if (issue.action !== "opened") return null; + return { auth: defaultGitHubAuth(ctx) }; + },` + : undefined, + events.includes("pull_request") + ? ` onPullRequest(ctx, pullRequest) { + if (pullRequest.action !== "opened") return null; + return { auth: defaultGitHubAuth(ctx) }; + },` + : undefined, + ].filter((handler): handler is string => handler !== undefined); + const defaultAuthImport = handlers.length > 0 ? ", defaultGitHubAuth" : ""; + const handlerBlock = handlers.length > 0 ? `\n${handlers.join("\n")}` : ""; + + return `import { connectGitHubCredentials } from "@vercel/connect/eve"; +import { githubChannel${defaultAuthImport} } from "eve/channels/github"; + +export default githubChannel({ + botName: ${JSON.stringify(appSlug)}, + credentials: connectGitHubCredentials(${JSON.stringify(uid)}),${handlerBlock} +}); +`; +} + +/** Runs guided GitHub App connector and channel setup. */ +export async function setupGitHub( + context: IntegrationSetupContext, + deps: GitHubSetupDeps = defaultDeps, +): Promise { + if (context.environment.vercel.kind === "unavailable") { + throw new Error( + "GitHub setup requires an authenticated Vercel CLI. Run `vercel login`, then retry.", + ); + } + try { + context.ui.prompter.log.info("GitHub App"); + context.ui.prompter.log.info( + "Vercel Connect creates a GitHub App and routes verified webhooks to your deployed agent.", + ); + const events = await context.ui.asker.askMany(githubEventsQuestion); + const project = await deps.ensureVercelProject({ + appRoot: context.appRoot, + prompter: context.ui.prompter, + signal: context.signal, + }); + const connector = await deps.provisionConnector({ + log: context.ui.prompter.log, + events, + project, + projectRoot: context.appRoot, + slug: await deps.deriveConnectorSlug(context.appRoot), + signal: context.signal, + }); + await deps.writeTextFile( + join(context.appRoot, "agent/channels/github.ts"), + connectTemplate(connector.uid, connector.appSlug, events), + { force: context.force }, + ); + const dashboardUrl = "https://vercel.com/d?to=/%5Bteam%5D/~/connect&title=Open+Vercel+Connect"; + context.ui.nextSteps([ + "Deploy the agent, then open the GitHub App in Vercel Connect and install it in the organization or account where you want to use it.", + `Add @${connector.appSlug} to a new issue, pull request, or review comment to invoke the agent. GitHub may not autocomplete or render the token as a linked mention.`, + ]); + return { + kind: "done", + facts: [{ label: "Vercel Connect", value: dashboardUrl, kind: "url" }], + }; + } catch (error) { + if (error instanceof WizardCancelledError) return { kind: "cancelled" }; + throw error; + } +} + +/** GitHub App setup registration. */ +export const GITHUB_SETUP: SetupIntegration = { + kind: "github", + label: "GitHub", + hint: "Respond to issues, pull requests, and comments", + setup: setupGitHub, +}; diff --git a/packages/eve/src/setup/integrations/registry.test.ts b/packages/eve/src/setup/integrations/registry.test.ts index f2a42ffa1..07b0d4644 100644 --- a/packages/eve/src/setup/integrations/registry.test.ts +++ b/packages/eve/src/setup/integrations/registry.test.ts @@ -38,6 +38,10 @@ describe("setup integrations", () => { }); }); + it("registers guided GitHub setup", () => { + expect(setupIntegration("github")).toMatchObject({ kind: "github", label: "GitHub" }); + }); + it("rejects an unknown integration", () => { expect(() => setupIntegration("unknown")).toThrow( 'Integration setup "unknown" is not available', diff --git a/packages/eve/src/setup/integrations/registry.ts b/packages/eve/src/setup/integrations/registry.ts index 32d6bfd0f..eb3a46821 100644 --- a/packages/eve/src/setup/integrations/registry.ts +++ b/packages/eve/src/setup/integrations/registry.ts @@ -1,4 +1,5 @@ import { DISCORD_SETUP } from "./discord/setup.js"; +import { GITHUB_SETUP } from "./github/setup.js"; import { LINEAR_SETUP } from "./linear/setup.js"; import { PHOTON_SETUP } from "./photon/setup.js"; import { SLACK_SETUP } from "./slack/setup.js"; @@ -10,6 +11,7 @@ export const SETUP_INTEGRATIONS: readonly SetupIntegration[] = [ WEB_SETUP, SLACK_SETUP, DISCORD_SETUP, + GITHUB_SETUP, LINEAR_SETUP, PHOTON_SETUP, ];