Files
vercel__eve/packages/eve/src/public/channels/github/dispatch.ts
T

431 lines
14 KiB
TypeScript

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,
formatGitHubContextBlock,
type GitHubCheckRunWebhookEvent,
type GitHubCheckSuiteWebhookEvent,
type GitHubCiPayload,
type GitHubCiWebhookEvent,
type GitHubComment,
type GitHubIssueComment,
type GitHubIssueCommentEvent,
type GitHubIssueWebhookEvent,
type GitHubPullRequestReviewComment,
type GitHubPullRequestReviewCommentEvent,
type GitHubPullRequestWebhookEvent,
type GitHubUser,
type GitHubWorkflowRunWebhookEvent,
} from "#public/channels/github/inbound.js";
import {
buildGitHubPullRequestContext,
mergeGitHubContext,
} from "#public/channels/github/pr-context.js";
import {
continuationTokenFromState,
stateFromCiEvent,
stateFromIssueCommentEvent,
stateFromIssueEvent,
stateFromPullRequestEvent,
stateFromPullRequestReviewCommentEvent,
type GitHubChannelState,
} from "#public/channels/github/state.js";
import type {
GitHubChannelConfig,
GitHubInboundContext,
GitHubInboundResult,
GitHubInboundResultOrPromise,
} from "#public/channels/github/githubChannel.js";
const log = createLogger("github.dispatch");
type GitHubTurnEvent =
| GitHubCiWebhookEvent
| GitHubIssueCommentEvent
| GitHubIssueWebhookEvent
| GitHubPullRequestReviewCommentEvent
| GitHubPullRequestWebhookEvent;
/** Dispatches a bot-directed issue or PR timeline comment into the runtime. */
export async function dispatchIssueComment(input: {
readonly botName: GitHubBotNameResolver;
readonly config: GitHubChannelConfig;
readonly event: GitHubIssueCommentEvent;
readonly handler: NonNullable<GitHubChannelConfig["onComment"]>;
readonly from: ChannelFrom<GitHubChannelState>;
}): Promise<void> {
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,
commentUrl: input.event.comment.htmlUrl,
event: input.event,
handlerResult: () => input.handler(ctx, toGitHubComment(input.event.comment)),
config: input.config,
from: input.from,
state: stateFromIssueCommentEvent(input.event),
});
}
/** Dispatches a bot-directed inline pull-request review comment. */
export async function dispatchPullRequestReviewComment(input: {
readonly botName: GitHubBotNameResolver;
readonly config: GitHubChannelConfig;
readonly event: GitHubPullRequestReviewCommentEvent;
readonly handler: NonNullable<GitHubChannelConfig["onComment"]>;
readonly from: ChannelFrom<GitHubChannelState>;
}): Promise<void> {
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,
commentUrl: input.event.comment.htmlUrl,
event: input.event,
handlerResult: () => input.handler(ctx, toGitHubComment(input.event.comment)),
config: input.config,
from: input.from,
state: stateFromPullRequestReviewCommentEvent(input.event),
});
}
/** Dispatches an opt-in issue webhook event into the runtime. */
export async function dispatchIssue(input: {
readonly config: GitHubChannelConfig;
readonly event: GitHubIssueWebhookEvent;
readonly handler: NonNullable<GitHubChannelConfig["onIssue"]>;
readonly from: ChannelFrom<GitHubChannelState>;
}): Promise<void> {
const ctx = buildInboundContext(input.config, input.event);
await dispatchWebhookEventTurn({
config: input.config,
event: input.event,
handlerResult: () => input.handler(ctx, input.event.issue),
message: formatIssueEventMessage(input.event),
from: input.from,
state: stateFromIssueEvent(input.event),
});
}
/** Dispatches an opt-in pull-request webhook event into the runtime. */
export async function dispatchPullRequest(input: {
readonly config: GitHubChannelConfig;
readonly event: GitHubPullRequestWebhookEvent;
readonly handler: NonNullable<GitHubChannelConfig["onPullRequest"]>;
readonly from: ChannelFrom<GitHubChannelState>;
}): Promise<void> {
const ctx = buildInboundContext(input.config, input.event);
await dispatchWebhookEventTurn({
config: input.config,
event: input.event,
handlerResult: () => input.handler(ctx, input.event.pullRequest),
message: formatPullRequestEventMessage(input.event),
from: input.from,
state: stateFromPullRequestEvent(input.event),
});
}
/** Dispatches an opt-in check-suite webhook event into the runtime. */
export async function dispatchCheckSuite(input: {
readonly config: GitHubChannelConfig;
readonly event: GitHubCheckSuiteWebhookEvent;
readonly handler: NonNullable<GitHubChannelConfig["onCheckSuite"]>;
readonly from: ChannelFrom<GitHubChannelState>;
}): Promise<void> {
await dispatchCiEvent({
...input,
ci: input.event.checkSuite,
handlerResult: (ctx) => input.handler(ctx, input.event.checkSuite),
label: "Check suite",
});
}
/** Dispatches an opt-in check-run webhook event into the runtime. */
export async function dispatchCheckRun(input: {
readonly config: GitHubChannelConfig;
readonly event: GitHubCheckRunWebhookEvent;
readonly handler: NonNullable<GitHubChannelConfig["onCheckRun"]>;
readonly from: ChannelFrom<GitHubChannelState>;
}): Promise<void> {
await dispatchCiEvent({
...input,
ci: input.event.checkRun,
handlerResult: (ctx) => input.handler(ctx, input.event.checkRun),
label: "Check run",
});
}
/** Dispatches an opt-in workflow-run webhook event into the runtime. */
export async function dispatchWorkflowRun(input: {
readonly config: GitHubChannelConfig;
readonly event: GitHubWorkflowRunWebhookEvent;
readonly handler: NonNullable<GitHubChannelConfig["onWorkflowRun"]>;
readonly from: ChannelFrom<GitHubChannelState>;
}): Promise<void> {
await dispatchCiEvent({
...input,
ci: input.event.workflowRun,
handlerResult: (ctx) => input.handler(ctx, input.event.workflowRun),
label: "Workflow run",
});
}
async function dispatchCiEvent(input: {
readonly ci: GitHubCiPayload;
readonly config: GitHubChannelConfig;
readonly event: GitHubCiWebhookEvent;
readonly handlerResult: (ctx: GitHubInboundContext) => GitHubInboundResultOrPromise;
readonly label: string;
readonly from: ChannelFrom<GitHubChannelState>;
}): Promise<void> {
const state = stateFromCiEvent(input.event);
const ctx = buildInboundContext(input.config, input.event);
if (state.pullRequestNumber === null) {
const result = await runInboundHandler({
event: input.event,
handlerResult: () => input.handlerResult(ctx),
});
if (result !== null && result !== undefined) {
log.warn("GitHub CI event cannot dispatch without an associated pull request", {
deliveryId: input.event.delivery.id,
event: input.event.kind,
});
}
return;
}
await dispatchWebhookEventTurn({
config: input.config,
event: input.event,
handlerResult: () => input.handlerResult(ctx),
message: formatCiEventMessage(input.label, input.ci),
from: input.from,
state,
});
}
async function dispatchWebhookEventTurn(input: {
readonly config: GitHubChannelConfig;
readonly event: GitHubCiWebhookEvent | GitHubIssueWebhookEvent | GitHubPullRequestWebhookEvent;
readonly handlerResult: () => GitHubInboundResultOrPromise;
readonly message: string;
readonly from: ChannelFrom<GitHubChannelState>;
readonly state: GitHubChannelState;
}): Promise<void> {
const result = await runInboundHandler({
event: input.event,
handlerResult: input.handlerResult,
});
if (result === null || result === undefined) return;
await sendGitHubTurn({
auth: result.auth,
event: input.event,
message: input.message,
context: mergeGitHubContext({
github: await buildPullRequestContext(input.config, input.state, input.event.delivery.id),
hook: result.context,
}),
from: input.from,
state: input.state,
});
}
async function dispatchCommentTurn(input: {
readonly body: string;
readonly botName: string | undefined;
readonly commentUrl: string | undefined;
readonly config: GitHubChannelConfig;
readonly event: GitHubIssueCommentEvent | GitHubPullRequestReviewCommentEvent;
readonly handlerResult: () => GitHubInboundResultOrPromise;
readonly from: ChannelFrom<GitHubChannelState>;
readonly state: GitHubChannelState;
}): Promise<void> {
const result = await runInboundHandler({
event: input.event,
handlerResult: input.handlerResult,
});
if (result === null || result === undefined) return;
const trigger = extractGitHubCommentTrigger({
body: input.body,
botName: input.botName,
});
const message = trigger?.message ?? input.body.trim();
await sendGitHubTurn({
auth: result.auth,
commentUrl: input.commentUrl,
event: input.event,
message,
context: mergeGitHubContext({
github: await buildPullRequestContext(input.config, input.state, input.event.delivery.id),
hook: result.context,
}),
from: input.from,
state: input.state,
});
}
async function runInboundHandler(input: {
readonly event: GitHubTurnEvent;
readonly handlerResult: () => GitHubInboundResultOrPromise;
}): Promise<GitHubInboundResult | undefined> {
try {
return await input.handlerResult();
} catch (error) {
logError(log, "GitHub inbound handler failed", error, {
deliveryId: input.event.delivery.id,
});
return undefined;
}
}
async function sendGitHubTurn(input: {
readonly auth: SessionAuthContext | null;
readonly commentUrl?: string;
readonly event: GitHubTurnEvent;
readonly logMessage?: string;
readonly message: string;
readonly context: readonly string[] | undefined;
readonly from: ChannelFrom<GitHubChannelState>;
readonly state: GitHubChannelState;
}): Promise<void> {
const contextBlock = formatGitHubContextBlock({
deliveryId: input.event.delivery.id,
commentUrl: input.commentUrl,
headSha: input.state.headSha,
issueNumber: input.state.issueNumber,
pullRequestNumber: input.state.pullRequestNumber,
repository: input.event.repository,
sender: input.event.sender,
});
try {
await input.from(continuationTokenFromState(input.state)).send(input.message, {
auth: input.auth,
context: [contextBlock, ...(input.context ?? [])],
state: input.state,
});
} catch (error) {
logError(log, input.logMessage ?? "GitHub delivery failed", error, {
deliveryId: input.event.delivery.id,
});
}
}
async function buildPullRequestContext(
config: GitHubChannelConfig,
state: GitHubChannelState,
deliveryId: string,
): Promise<readonly string[] | undefined> {
try {
return await buildGitHubPullRequestContext({
api: config.api,
baseSha: state.baseSha,
config: config.pullRequestContext,
credentials: config.credentials,
headSha: state.headSha,
installationId: state.installationId ?? undefined,
owner: state.owner,
pullRequestNumber: state.pullRequestNumber,
repo: state.repo,
});
} catch (error) {
logError(log, "GitHub pull-request context failed — swallowed", error, { deliveryId });
return undefined;
}
}
function buildInboundContext(
config: GitHubChannelConfig,
event:
| GitHubCiWebhookEvent
| GitHubIssueCommentEvent
| GitHubIssueWebhookEvent
| GitHubPullRequestWebhookEvent
| GitHubPullRequestReviewCommentEvent,
): GitHubInboundContext {
const state =
event.kind === "check_suite" || event.kind === "check_run" || event.kind === "workflow_run"
? stateFromCiEvent(event)
: event.kind === "issue_comment"
? stateFromIssueCommentEvent(event)
: event.kind === "issues"
? stateFromIssueEvent(event)
: event.kind === "pull_request"
? stateFromPullRequestEvent(event)
: stateFromPullRequestReviewCommentEvent(event);
const binding = buildGitHubBinding({ config, state });
return {
conversation: event.conversation,
delivery: event.delivery,
github: binding.github,
repository: event.repository,
sender: event.sender,
thread: binding.thread,
};
}
function toGitHubComment(
comment: GitHubIssueComment | GitHubPullRequestReviewComment,
): GitHubComment {
return {
author: comment.author,
body: comment.body,
htmlUrl: comment.htmlUrl,
id: comment.id,
raw: comment.raw,
url: comment.url,
};
}
function formatIssueEventMessage(event: GitHubIssueWebhookEvent): string {
const title = readString(event.issue.raw.title);
return `Issue ${event.issue.action}: #${event.issue.issueNumber}${title ? ` ${title}` : ""}`;
}
function formatPullRequestEventMessage(event: GitHubPullRequestWebhookEvent): string {
const title = readString(event.pullRequest.raw.title);
return `Pull request ${event.pullRequest.action}: #${event.pullRequest.pullRequestNumber}${
title ? ` ${title}` : ""
}`;
}
function formatCiEventMessage(label: string, event: GitHubCiPayload): string {
const outcome = event.conclusion ?? event.status;
return `${label} ${event.action}: ${ciEventId(event)}${outcome ? ` (${outcome})` : ""}`;
}
function ciEventId(event: GitHubCiPayload): number {
if ("checkSuiteId" in event) return event.checkSuiteId;
if ("checkRunId" in event) return event.checkRunId;
return event.workflowRunId;
}
function isIgnoredInboundComment(
body: string,
author: GitHubUser | undefined,
botName: string | undefined,
): boolean {
if (body.includes("<!-- eve:github:")) return true;
if (author?.type === "Bot") return true;
const botLogin = botName ? `${botName}[bot]`.toLowerCase() : "";
return botLogin.length > 0 && author?.login.toLowerCase() === botLogin;
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}