diff --git a/.changeset/lazy-github-bot-name.md b/.changeset/lazy-github-bot-name.md new file mode 100644 index 000000000..410a0c57b --- /dev/null +++ b/.changeset/lazy-github-bot-name.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +`githubChannel({ botName })` now also accepts a lazy resolver function, resolved on first use inside request handling, cached on success, and retried after a failure, so resolvers that depend on request-scoped credentials work in production. When `botName` is omitted, the channel falls back to the new `appSlug` field on `GitHubChannelCredentials`, then to `GITHUB_APP_SLUG`. diff --git a/docs/channels/github.mdx b/docs/channels/github.mdx index baf2d55b2..38568782c 100644 --- a/docs/channels/github.mdx +++ b/docs/channels/github.mdx @@ -58,7 +58,7 @@ GITHUB_WEBHOOK_SECRET=... # verifies the webhook signature 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. +`appId`/`privateKey`/`webhookSecret` also take a lazy resolver function if you'd rather fetch them on demand, and so does `botName`: it resolves on first use inside request handling, caches on success, and retries on the next event after a failure, so a resolver that depends on request-scoped credentials works in production. When `botName` is not configured, the channel falls back to the credentials' `appSlug`, then to `GITHUB_APP_SLUG`. 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`. diff --git a/packages/eve/src/public/channels/github/auth.ts b/packages/eve/src/public/channels/github/auth.ts index 13f851e84..b01be4d61 100644 --- a/packages/eve/src/public/channels/github/auth.ts +++ b/packages/eve/src/public/channels/github/auth.ts @@ -1,8 +1,11 @@ import { createSign } from "node:crypto"; +import { createLogger } from "#internal/logging.js"; import { isObject } from "#shared/guards.js"; import type { GitHubWebhookVerifier } from "#public/channels/github/verify.js"; +const log = createLogger("github.auth"); + /** GitHub App id, supplied directly or resolved lazily from a secret manager. */ export type GitHubAppId = number | string | (() => number | string | Promise); @@ -20,11 +23,30 @@ export type GitHubWebhookSecret = string | (() => string | Promise); */ export type GitHubInstallationToken = string | (() => string | Promise); +/** + * The name the channel answers to in `@mentions` (the GitHub App slug, + * without the `[bot]` suffix), supplied directly or resolved lazily. + * + * A lazy resolver runs on first use inside request handling, where + * credentials that only exist per request (such as the Vercel OIDC token + * behind Connect metadata lookups) are available; a fulfilled value is + * cached, and a rejection is retried on the next event instead of pinned. + */ +export type GitHubBotName = string | (() => string | Promise); + /** Credentials used by the native GitHub channel. */ export interface GitHubChannelCredentials { readonly appId?: GitHubAppId; readonly privateKey?: GitHubPrivateKey; readonly webhookSecret?: GitHubWebhookSecret; + /** + * The GitHub App's slug (its `@mention` handle, without the `[bot]` + * suffix), supplied directly or resolved lazily. Used as the channel's + * `botName` when the config does not set one, so integrations that broker + * credentials can make mention dispatch work with no explicit + * configuration. + */ + readonly appSlug?: GitHubBotName; /** * Pre-resolved GitHub installation access token. When supplied, eve uses * it directly for authenticated GitHub API calls and skips the native @@ -87,6 +109,47 @@ export async function resolveGitHubWebhookSecret( return typeof source === "function" ? await source() : source; } +/** The channel's lazily resolved bot name, shared by dispatch and defaults. */ +export type GitHubBotNameResolver = () => Promise; + +/** + * Creates the channel's `botName` resolver: explicit config first, then the + * credentials' `appSlug`, then `GITHUB_APP_SLUG`. A fulfilled name is cached + * for the channel's lifetime; a rejection is logged and retried on the next + * event, so one failed delivery cannot pin the channel to a missing name. + */ +export function createGitHubBotNameResolver(input: { + readonly botName?: GitHubBotName; + readonly credentials?: GitHubChannelCredentials; +}): GitHubBotNameResolver { + let cached: string | undefined; + return async () => { + if (cached !== undefined) { + return cached; + } + const source = input.botName ?? input.credentials?.appSlug ?? process.env.GITHUB_APP_SLUG; + if (source === undefined) { + return undefined; + } + if (typeof source === "string") { + cached = normalizeBotName(source); + return cached; + } + try { + cached = normalizeBotName(await source()); + return cached; + } catch (error) { + log.warn("githubChannel: botName resolver failed; retrying on the next event", { error }); + return undefined; + } + }; +} + +function normalizeBotName(value: string): string | undefined { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + /** Converts hosted-platform escaped newlines back into PEM newlines. */ export function normalizeGitHubPrivateKey(privateKey: string): string { return privateKey.replace(/\\n/gu, "\n"); diff --git a/packages/eve/src/public/channels/github/defaults.ts b/packages/eve/src/public/channels/github/defaults.ts index 35d472b49..eacd670a6 100644 --- a/packages/eve/src/public/channels/github/defaults.ts +++ b/packages/eve/src/public/channels/github/defaults.ts @@ -2,7 +2,10 @@ import type { SessionAuthContext } from "#channel/types.js"; import { createLogger, extractErrorId, formatErrorHint, logError } from "#internal/logging.js"; import type { GitHubApiOptions } from "#public/channels/github/api.js"; -import type { GitHubChannelCredentials } from "#public/channels/github/auth.js"; +import type { + GitHubBotNameResolver, + GitHubChannelCredentials, +} from "#public/channels/github/auth.js"; import { checkoutGitHubRepository } from "#public/channels/github/checkout.js"; import { shouldDispatchGitHubComment, @@ -50,20 +53,20 @@ export function defaultGitHubAuth(ctx: GitHubInboundContext): SessionAuthContext /** Options used by the built-in GitHub comment dispatch hook. */ export interface GitHubDefaultDispatchOptions { - readonly botName?: string; + readonly botName?: GitHubBotNameResolver; } /** Default comment hook: dispatch only when the comment `@mention`s the bot. */ -export function defaultOnComment( +export async function defaultOnComment( ctx: GitHubInboundContext, comment: GitHubComment, options: GitHubDefaultDispatchOptions, -): GitHubInboundResult { +): Promise { if ( !shouldDispatchGitHubComment({ author: comment.author, body: comment.body, - botName: options.botName, + botName: await options.botName?.(), }) ) { return null; @@ -74,7 +77,7 @@ export function defaultOnComment( /** Options used by built-in GitHub event handlers. */ export interface GitHubDefaultEventOptions { readonly api?: GitHubApiOptions; - readonly botName?: string; + readonly botName?: GitHubBotNameResolver; readonly credentials?: GitHubChannelCredentials; readonly progress?: GitHubProgressConfig; } @@ -102,7 +105,7 @@ export function createDefaultEvents(options: GitHubDefaultEventOptions = {}): Gi async "input.requested"(event, channel, _ctx) { if (event.requests.length === 0) return; const sections = event.requests.map(renderInputRequest); - const replyInstruction = renderReplyInstruction(event.requests, options.botName); + const replyInstruction = renderReplyInstruction(event.requests, await options.botName?.()); if (replyInstruction !== undefined) sections.push(replyInstruction); await postCommentChunks(channel, sections.join("\n\n")); }, diff --git a/packages/eve/src/public/channels/github/dispatch.ts b/packages/eve/src/public/channels/github/dispatch.ts index fb24fb7fc..6284e4e85 100644 --- a/packages/eve/src/public/channels/github/dispatch.ts +++ b/packages/eve/src/public/channels/github/dispatch.ts @@ -2,6 +2,7 @@ import type { SessionAuthContext } from "#channel/types.js"; import type { ChannelFrom } from "#channel/channel-operations.js"; import { createLogger, logError } from "#internal/logging.js"; +import type { GitHubBotNameResolver } from "#public/channels/github/auth.js"; import { buildGitHubBinding } from "#public/channels/github/binding.js"; import { extractGitHubCommentTrigger, @@ -51,21 +52,20 @@ type GitHubTurnEvent = /** Dispatches a bot-directed issue or PR timeline comment into the runtime. */ export async function dispatchIssueComment(input: { - readonly botName: string | undefined; + readonly botName: GitHubBotNameResolver; readonly config: GitHubChannelConfig; readonly event: GitHubIssueCommentEvent; readonly handler: NonNullable; readonly from: ChannelFrom; }): Promise { - if ( - isIgnoredInboundComment(input.event.comment.body, input.event.comment.author, input.botName) - ) { + const botName = await input.botName(); + if (isIgnoredInboundComment(input.event.comment.body, input.event.comment.author, botName)) { return; } const ctx = buildInboundContext(input.config, input.event); await dispatchCommentTurn({ body: input.event.comment.body, - botName: input.botName, + botName, commentUrl: input.event.comment.htmlUrl, event: input.event, handlerResult: () => input.handler(ctx, toGitHubComment(input.event.comment)), @@ -77,21 +77,20 @@ export async function dispatchIssueComment(input: { /** Dispatches a bot-directed inline pull-request review comment. */ export async function dispatchPullRequestReviewComment(input: { - readonly botName: string | undefined; + readonly botName: GitHubBotNameResolver; readonly config: GitHubChannelConfig; readonly event: GitHubPullRequestReviewCommentEvent; readonly handler: NonNullable; readonly from: ChannelFrom; }): Promise { - if ( - isIgnoredInboundComment(input.event.comment.body, input.event.comment.author, input.botName) - ) { + const botName = await input.botName(); + if (isIgnoredInboundComment(input.event.comment.body, input.event.comment.author, botName)) { return; } const ctx = buildInboundContext(input.config, input.event); await dispatchCommentTurn({ body: input.event.comment.body, - botName: input.botName, + botName, commentUrl: input.event.comment.htmlUrl, event: input.event, handlerResult: () => input.handler(ctx, toGitHubComment(input.event.comment)), diff --git a/packages/eve/src/public/channels/github/githubChannel.test.ts b/packages/eve/src/public/channels/github/githubChannel.test.ts index 2be0bc52d..fa1db4f6d 100644 --- a/packages/eve/src/public/channels/github/githubChannel.test.ts +++ b/packages/eve/src/public/channels/github/githubChannel.test.ts @@ -257,6 +257,93 @@ describe("githubChannel", () => { }); }); + it("resolves a lazy botName on first dispatch and caches it", async () => { + const resolveBotName = vi.fn().mockResolvedValue("testbot"); + const channel = githubChannel({ + botName: resolveBotName, + credentials: { webhookSecret: SECRET }, + }); + const commentRequest = () => + signedRequest( + "issue_comment", + basePayload({ + action: "created", + comment: { + body: "@testbot help me", + html_url: "https://github.test/vercel/eve/issues/5#issuecomment-10", + id: 10, + user: { id: 1, login: "octocat", type: "User" }, + }, + issue: { number: 5 }, + }), + ); + + const first = await firePost(channel, commentRequest()); + const second = await firePost(channel, commentRequest()); + + expect(first.send).toHaveBeenCalledTimes(1); + expect(second.send).toHaveBeenCalledTimes(1); + expect(first.send.mock.calls[0]![1].message).toBe("help me"); + expect(resolveBotName).toHaveBeenCalledTimes(1); + }); + + it("retries a failed botName resolver on the next delivery instead of pinning", async () => { + const resolveBotName = vi + .fn() + .mockRejectedValueOnce(new Error("no request context")) + .mockRejectedValueOnce(new Error("no request context")) + .mockResolvedValue("testbot"); + const channel = githubChannel({ + botName: resolveBotName, + credentials: { webhookSecret: SECRET }, + }); + const commentRequest = () => + signedRequest( + "issue_comment", + basePayload({ + action: "created", + comment: { + body: "@testbot help me", + html_url: "https://github.test/vercel/eve/issues/5#issuecomment-10", + id: 10, + user: { id: 1, login: "octocat", type: "User" }, + }, + issue: { number: 5 }, + }), + ); + + const first = await firePost(channel, commentRequest()); + const second = await firePost(channel, commentRequest()); + + expect(first.send).not.toHaveBeenCalled(); + expect(second.send).toHaveBeenCalledTimes(1); + }); + + it("falls back to the credentials' appSlug when botName is not configured", async () => { + const channel = githubChannel({ + credentials: { appSlug: "testbot", webhookSecret: SECRET }, + }); + const { send } = await firePost( + channel, + signedRequest( + "issue_comment", + basePayload({ + action: "created", + comment: { + body: "@testbot help me", + html_url: "https://github.test/vercel/eve/issues/5#issuecomment-10", + id: 10, + user: { id: 1, login: "octocat", type: "User" }, + }, + issue: { number: 5 }, + }), + ), + ); + + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0]![1].message).toBe("help me"); + }); + it("keeps GitHub metadata separate from the comment text", async () => { const channel = githubChannel({ botName: "testbot", @@ -854,6 +941,54 @@ describe("githubChannel", () => { }); }); + it("renders the mention instruction from a lazy botName resolver", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: 79 }))); + const adapter = withState( + getAdapter( + githubChannel({ + api: { apiBaseUrl: "https://github.test", fetch: fetchMock }, + botName: () => Promise.resolve("testbot"), + credentials: { appId: "test-app", webhookSecret: SECRET }, + }), + ), + { + conversationKind: "issue", + installationId: 55, + issueNumber: 5, + owner: "vercel", + repo: "eve", + repositoryId: 123, + }, + ); + const ctx = buildAdapterContext(adapter, stubAccessor()); + + await callEvent( + adapter, + makeEvent("input.requested", { + requests: [ + { + action: { callId: "call_1", input: {}, kind: "tool-call", toolName: "deploy" }, + options: [ + { id: "approve", label: "Yes" }, + { id: "deny", label: "No" }, + ], + prompt: "Approve this change?", + requestId: "call_1", + }, + ], + sequence: 0, + stepIndex: 0, + turnId: "t1", + }), + ctx, + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(JSON.parse(String((fetchMock.mock.calls[0]![1] as RequestInit).body))).toEqual({ + body: "Approve this change?\n\n1. Yes\n2. No\n\nAnswer by mentioning me in a reply, e.g. `@testbot Yes`.", + }); + }); + it("omits the mention instruction when no bot name is configured", async () => { const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: 78 }))); const adapter = withState( diff --git a/packages/eve/src/public/channels/github/githubChannel.ts b/packages/eve/src/public/channels/github/githubChannel.ts index 9c1b4b2e8..cf84bce8b 100644 --- a/packages/eve/src/public/channels/github/githubChannel.ts +++ b/packages/eve/src/public/channels/github/githubChannel.ts @@ -11,7 +11,11 @@ import { type GitHubThread, } from "#public/channels/github/binding.js"; import { getGitHubRepository, type GitHubApiOptions } from "#public/channels/github/api.js"; -import type { GitHubChannelCredentials } from "#public/channels/github/auth.js"; +import { + createGitHubBotNameResolver, + type GitHubBotName, + type GitHubChannelCredentials, +} from "#public/channels/github/auth.js"; import { GITHUB_CHANNEL_DEFAULT_ROUTE } from "#public/channels/github/constants.js"; import { createDefaultEvents, defaultOnComment } from "#public/channels/github/defaults.js"; import { @@ -152,7 +156,12 @@ export interface GitHubChannelEvents { /** Configuration for {@link githubChannel}. */ export interface GitHubChannelConfig { readonly api?: GitHubApiOptions; - readonly botName?: string; + /** + * The name the channel answers to in `@mentions`, supplied directly or + * resolved lazily on first use inside request handling. Falls back to the + * credentials' `appSlug`, then `GITHUB_APP_SLUG`. + */ + readonly botName?: GitHubBotName; readonly credentials?: GitHubChannelCredentials; readonly events?: GitHubChannelEvents; readonly progress?: GitHubProgressConfig; @@ -220,7 +229,10 @@ export interface GitHubChannel extends Channel