feat(github): accept a lazy botName resolver with an appSlug credentials fallback (#2024)

Signed-off-by: Ben Sabic <bensabic@users.noreply.github.com>
Co-authored-by: Ben Sabic <bensabic@users.noreply.github.com>
This commit is contained in:
Ben Sabic
2026-08-14 00:51:09 +10:00
committed by GitHub
parent 044488ce90
commit 02b7b7eb4d
8 changed files with 239 additions and 21 deletions
+5
View File
@@ -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`.
+1 -1
View File
@@ -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://<deployment>/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`.
@@ -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<number | string>);
@@ -20,11 +23,30 @@ export type GitHubWebhookSecret = string | (() => string | Promise<string>);
*/
export type GitHubInstallationToken = string | (() => string | Promise<string>);
/**
* 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<string>);
/** 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<string | undefined>;
/**
* 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");
@@ -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<GitHubInboundResult> {
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"));
},
@@ -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<GitHubChannelConfig["onComment"]>;
readonly from: ChannelFrom<GitHubChannelState>;
}): Promise<void> {
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<GitHubChannelConfig["onComment"]>;
readonly from: ChannelFrom<GitHubChannelState>;
}): Promise<void> {
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)),
@@ -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(
@@ -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<GitHubChannelState, GitHubReceive
/** GitHub channel factory for GitHub App webhooks and proactive comments. */
export function githubChannel(config: GitHubChannelConfig = {}): GitHubChannel {
const botName = config.botName ?? process.env.GITHUB_APP_SLUG;
const botName = createGitHubBotNameResolver({
botName: config.botName,
credentials: config.credentials,
});
const dispatchOptions = { botName };
const mergedEvents: GitHubChannelEvents = {
...createDefaultEvents({
@@ -10,6 +10,7 @@ export {
export { type GitHubHandle, type GitHubThread } from "#public/channels/github/binding.js";
export {
type GitHubAppId,
type GitHubBotName,
type GitHubChannelCredentials,
type GitHubInstallationToken,
type GitHubPrivateKey,