Handle GitHub mentions in descriptions and edited comments

This commit is contained in:
Dotta committed 2026-10-08 07:38:15 -05:00
1 parent 1d9152ace4
commit f2d89304fc
8 files changed
+388 -17

No files matched your search

+85 -15
View File
@@ -14,7 +14,7 @@ import { buildChatCommunicationGuidance } from "./chat-communication-guidance.js
function githubPolicyRecord(value: unknown): Record<string, unknown> { return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
import { githubChatManagementService } from "./chat-github-management.js";
import { githubReviewCheckService } from "./chat-github-checks.js";
import { githubAutomaticReviewEvent, githubAutomaticIssueEvent, githubAutomaticAdmission, githubPreviousAssessment } from "./chat-github-events.js";
import { githubAutomaticReviewEvent, githubAutomaticIssueEvent, githubAutomaticAdmission, githubPreviousAssessment, githubBodyMentionsBot, githubExplicitMentionEvent } from "./chat-github-events.js";
import { githubReviewPrompt } from "./chat-github-review-policy.js";
import { chatGitHubConfigurations, chatGitHubRegistrations, chatGitHubReviews } from "@paperclipai/db";
import type { GitHubReviewEventContext, GitHubIssueEventContext, GitHubAutomaticEventContext, GitHubReviewPolicy } from "@paperclipai/shared";
@@ -812,6 +812,7 @@ const UNAVOIDABLE_GITHUB_EVENTS = [
const SUPPORTED_GITHUB_WEBHOOK_EVENTS = new Set<string>([
"issues",
"pull_request",
"pull_request_review",
"ping",
...REQUIRED_GITHUB_EVENTS,
...UNAVOIDABLE_GITHUB_EVENTS,
@@ -1098,6 +1099,7 @@ type LiveInboundMessage = {
endpoint: EndpointRow;
message: Message;
receiptReactionSupported: boolean;
providerUrl: string | null;
runtimeContext?: InboundRuntimeContext;
thread: Thread;
trigger: ChatSdkMessageCallbackEvent["trigger"];
@@ -6315,6 +6317,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
...REQUIRED_GITHUB_EVENTS,
...UNAVOIDABLE_GITHUB_EVENTS,
"pull_request",
"pull_request_review",
"issues",
]);
const excessiveEvents = [...configuredEvents].filter(
@@ -14537,7 +14540,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
if (endpoint.provider === "github" && !githubAutomatic && !githubIssue && !githubManual) {
const [config] = await db.select().from(chatGitHubConfigurations).where(eq(chatGitHubConfigurations.endpointId, endpoint.id));
if (config) {
const repository = thread.channelId.replace(/^github:/, "");
const repository = thread.channelId.replace(/^github:/, "").toLowerCase();
const [repo] = await db.select().from(chatEndpointResources).where(and(eq(chatEndpointResources.endpointId, endpoint.id), eq(chatEndpointResources.providerResourceId, repository)));
const repositoryId = String(repo?.metadata?.providerRepositoryId ?? "");
githubManual = { policy: { ...config.configuration.defaults, ...config.configuration.repositories[repositoryId] }, revision: config.revision, event: trigger === "mention" || message.isMention ? "mention" : "comment" };
@@ -14632,6 +14635,10 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
(githubAutomatic
? `https://github.com/${githubAutomatic.context.repository}/pull/${githubAutomatic.context.pullNumber}`
: githubIssue ? `https://github.com/${githubIssue.context.repository}/issues/${githubIssue.context.issueNumber}` : null) ??
// Description/review requests and edits have synthetic ledger IDs, not
// native comment IDs. Their verified source URL must not acquire a fake
// issuecomment anchor from that ledger identity.
(endpoint.provider === "github" && githubManual && !receiptReactionSupported ? recoveredProviderUrl : null) ??
chatProviderConversationUrl({
provider: endpoint.provider,
providerAccountId: endpoint.providerAccountId,
@@ -15277,6 +15284,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
message,
trigger,
receiptReactionSupported,
providerUrl,
runtimeContext,
});
if (!deferDrainUntilFollowup) {
@@ -16853,8 +16861,12 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
)
return null;
const normalized = delivery.normalizedEvent as {
message?: { targetProviderEventId?: unknown };
message?: { targetProviderEventId?: unknown; admissionDependencyEventId?: unknown };
};
// An edit can also introduce a new addressed request. Process that durable
// admission before waiting for the old, potentially unadmitted source.
if (typeof normalized.message?.admissionDependencyEventId === "string")
return normalized.message.admissionDependencyEventId;
return typeof normalized.message?.targetProviderEventId === "string"
? normalized.message.targetProviderEventId
: null;
@@ -17458,7 +17470,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
live.message,
live.trigger,
false,
null,
live.providerUrl,
live.runtimeContext,
undefined,
delivery.id,
@@ -17688,6 +17700,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
providerUpdateId?: number | null;
raw?: unknown;
revision?: string | null;
admissionDependencyEventId?: string;
},
runtimeContext?: RuntimeContext,
) {
@@ -17751,6 +17764,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
message: {
providerMessageId: input.messageId,
targetProviderEventId,
...(input.admissionDependencyEventId ? { admissionDependencyEventId: input.admissionDependencyEventId } : {}),
...(endpoint.provider === "github" && input.isBotMessage === true
? { isBotMessage: true }
: {}),
@@ -24715,6 +24729,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
eventType === "issue_comment" ||
eventType === "issues" ||
eventType === "pull_request" ||
eventType === "pull_request_review" ||
eventType === "pull_request_review_comment"
);
}
@@ -24728,15 +24743,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
if (!comment || typeof comment !== "object") return false;
const body = (comment as { body?: unknown }).body;
if (typeof body !== "string") return false;
const identities = new Set([
botUsername,
botUsername.replace(/\[bot\]$/i, ""),
]);
return [...identities].some((identity) => {
if (!identity) return false;
const escaped = identity.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`(?<!\\w)@${escaped}(?![\\w-])`, "i").test(body);
});
return githubBodyMentionsBot(body, botUsername);
}
function githubIngressAllowedInEndpointState(
@@ -27239,11 +27246,44 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
// their normal transactional fences if a pause wins after this check.
if (!matchesGitHubIngressFence(runtimeContext))
return ignoreSupersededIngress();
if (provider === "github") {
const mention = githubExplicitMentionEvent(
request.headers.get("x-github-event") ?? "",
await request.clone().json(),
request.headers.get("x-github-delivery") ?? "",
endpoint.botUsername,
);
if (mention) {
const thread = endpointRuntime.thread(mention.threadId);
const message = {
id: mention.messageId, threadId: thread.id, text: mention.body,
formatted: { type: "root", children: [] }, raw: mention.raw,
author: { userId: mention.sender.id, userName: mention.sender.login, fullName: mention.sender.login, isBot: false, isMe: false, isSystem: false },
metadata: { dateSent: new Date(), edited: mention.edited }, attachments: [], links: [], isMention: true,
} as unknown as Message;
await processMessage(endpoint, thread, message, "mention", true, mention.url, { ...runtimeContext, endpointRuntime }, undefined, null, mention.receiptReactionSupported);
if (mention.edited) {
// Admit the new explicit request before its supplemental edit
// record: an orphan lifecycle receipt must not wait for the old
// unmentioned comment and block this newly addressed root.
const lifecycle = await githubLifecycleEventFromRequest(request.clone());
if (lifecycle) await recordLifecycleDelivery({
endpointId: endpoint.id, ...lifecycle,
admissionDependencyEventId: `${durableExternalThreadIdentity(thread.id)}:${message.id}`,
}, runtimeContext);
}
return new Response("accepted", { status: 202 });
}
if (request.headers.get("x-github-event") === "pull_request_review") return new Response("ignored", { status: 200 });
}
if (provider === "github" && request.headers.get("x-github-event") === "issues") {
const event = githubAutomaticIssueEvent(await request.clone().json(), request.headers.get("x-github-delivery") ?? "");
if (!event) return new Response("ignored", { status: 200 });
const admission = await githubAutomaticAdmission(db, endpoint, event);
if (!admission?.allowed) return new Response("ignored", { status: 200 });
if (!admission?.allowed) {
await recordGitHubAutomaticSkip(endpoint, event.deliveryId, "issue opened", admission?.reason, runtimeContext);
return new Response("ignored", { status: 200 });
}
const thread = endpointRuntime.thread(`github:${event.repository}:issue:${event.issueNumber}`);
const message = {
id: `issue-event:${event.deliveryId}`, threadId: thread.id,
@@ -27267,7 +27307,10 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
if (!event) return new Response("ignored", { status: 200 });
const admission = await githubAutomaticAdmission(db, endpoint, event);
if (admission) await githubReviewCheckService(db, fetchImpl).enqueue(endpoint, event, admission.allowed, admission.reason);
if (!admission?.allowed) return new Response("ignored", { status: 200 });
if (!admission?.allowed) {
await recordGitHubAutomaticSkip(endpoint, event.deliveryId, "pull request event", admission?.reason, runtimeContext);
return new Response("ignored", { status: 200 });
}
const previousAssessment = await githubPreviousAssessment(db, endpoint, event.repositoryId, event.pullNumber);
if (previousAssessment) event.priorReviewedHeadSha = previousAssessment.headSha;
const thread = endpointRuntime.thread(`github:${event.repository}:${event.pullNumber}`);
@@ -28706,6 +28749,33 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
});
}
async function recordGitHubAutomaticSkip(
endpoint: EndpointRow, deliveryId: string, event: string, reason: string | undefined, runtimeContext: LifecycleRuntimeFence,
) {
const details: Record<string, string> = {
mentions_only: "This bot runs only when mentioned. No new mention was found in this event.",
event_disabled: "Automatic runs are off for this GitHub event.",
person_not_authorized: "The GitHub author is not authorized to start automatic work.",
identity_revoked: "The GitHub author's identity link was revoked.",
automatic_reviews_disabled_for_person: "Automatic runs are off for this GitHub author.",
guest_automatic_reviews_disabled: "Automatic runs are off for external contributors.",
responsible_user_unavailable: "The member responsible for automatic tasks is unavailable.",
repository_disabled: "Destination is not enabled in Paperclip",
bot_tools_disabled: "This bot's GitHub tools are disabled.",
};
await db.transaction(async (tx) => {
const current = await runtimeCallbackEndpoint(tx, endpoint.id, runtimeContext, ["verifying", "active"]);
if (!current) return;
const providerEventId = `github:policy_ingress:${createHash("sha256").update(deliveryId).digest("hex")}`;
await tx.insert(chatDeliveries).values({
companyId: current.companyId, endpointId: current.id,
providerEventId, deduplicationKey: providerEventId, eventKind: "message", state: "filtered", processedAt: new Date(),
redactedError: `GitHub ${event} ignored. ${details[reason ?? ""] ?? "Automatic runs are restricted by this bot's review policy."}`,
normalizedEvent: { providerEventId, kind: "message", filtering: { contentRetained: false, reason: reason ?? "automatic_policy_unavailable" } },
}).onConflictDoNothing();
});
}
async function listActivity(endpointId: string, page?: { limit: number; before?: { createdAt: string; id: string } }) {
const limit = page ? page.limit + 1 : 100;
// Match the millisecond precision of the public timestamp and use a UUID
+50 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { githubAutomaticIssueEvent } from "./chat-github-events.js";
import { githubAutomaticIssueEvent, githubBodyMentionsBot, githubExplicitMentionEvent } from "./chat-github-events.js";
function payload() {
return {
@@ -26,3 +26,52 @@ describe("GitHub issue event parsing", () => {
expect(githubAutomaticIssueEvent({ ...input, issue: { ...input.issue, number: 0 } }, "delivery")).toBeNull();
});
});
describe("explicit GitHub mentions", () => {
it.each(["@maya help", "@MAYA[bot] help", "Hi, @maya."])("recognizes the bot's exact bare and bot identity: %s", (body) => {
expect(githubBodyMentionsBot(body, "maya[bot]")).toBe(true);
});
it.each(["@maya-other", "@mayabot", "x@maya", "@someone"])("does not match a different identity: %s", (body) => {
expect(githubBodyMentionsBot(body, "maya[bot]")).toBe(false);
});
it.each(["issues", "pull_request", "issue_comment", "pull_request_review_comment", "pull_request_review"])("normalizes a %s request into the matching issue or PR thread", (event) => {
const issue = { ...payload().issue, body: "@maya help" };
const comment = { id: 123, body: "@maya[bot] help", in_reply_to_id: 99 };
const input = {
...payload(), action: event === "pull_request_review" ? "submitted" : event.includes("comment") ? "created" : "opened",
issue, pull_request: issue, comment, review: comment,
};
const mention = githubExplicitMentionEvent(event, input, "delivery", "maya[bot]");
expect(mention).toMatchObject({ sender: { id: "77", login: "sender" }, edited: false });
expect(mention!.threadId).toBe(`github:Test/Repo:${event === "issues" || event === "issue_comment" ? "issue:" : ""}2${event === "pull_request_review_comment" ? ":rc:99" : ""}`);
});
it("puts PR conversation comments in the PR thread, and preserves the SDK comment identity", () => {
const input = { ...payload(), action: "created", issue: { ...payload().issue, pull_request: {} }, comment: { id: 123, body: "@maya help" } };
expect(githubExplicitMentionEvent("issue_comment", input, "delivery", "maya")).toMatchObject({ threadId: "github:Test/Repo:2", messageId: "123", url: "https://github.com/test/repo/pull/2#issuecomment-123" });
});
it.each(["issues", "pull_request", "issue_comment", "pull_request_review_comment", "pull_request_review"])("recognizes a newly added mention in an edited %s body", (event) => {
const target = { ...payload().issue, body: "@maya help" };
const content = { id: 123, body: "@maya help" };
const input = { ...payload(), action: "edited", issue: target, pull_request: target, comment: content, review: content, changes: { body: { from: null } } };
expect(githubExplicitMentionEvent(event, input, "edit", "maya")).toMatchObject({ edited: true, messageId: "mention-event:edit", receiptReactionSupported: false });
});
it("admits an edit adding a mention under the editor's identity, but not a persistent mention", () => {
const input = { ...payload(), action: "edited", issue: { ...payload().issue, body: "@maya help" }, changes: { body: { from: "Old description" } } };
expect(githubExplicitMentionEvent("issues", input, "edit-delivery", "maya")).toMatchObject({ edited: true, messageId: "mention-event:edit-delivery", sender: { id: "77" } });
expect(githubExplicitMentionEvent("issues", { ...input, changes: { body: { from: "@maya[bot] old request" } } }, "delivery", "maya")).toBeNull();
expect(githubExplicitMentionEvent("issues", { ...input, changes: { title: { from: "Old title" } } }, "delivery", "maya")).toBeNull();
expect(githubExplicitMentionEvent("issues", { ...input, changes: undefined }, "delivery", "maya")).toBeNull();
});
it.each(["synchronize", "reopened", "ready_for_review", "closed", "deleted"])("does not replay a description mention on %s", (action) => {
const input = { ...payload(), action, pull_request: { ...payload().issue, body: "@maya help" } };
expect(githubExplicitMentionEvent("pull_request", input, "delivery", "maya")).toBeNull();
});
it("ignores bot senders, title-only mentions, malformed payloads and unsafe IDs", () => {
const input = { ...payload(), issue: { ...payload().issue, body: "@maya help" } };
expect(githubExplicitMentionEvent("issues", { ...input, sender: { ...input.sender, type: "Bot" } }, "delivery", "maya")).toBeNull();
expect(githubExplicitMentionEvent("issues", { ...input, issue: { ...input.issue, body: null, title: "@maya" } }, "delivery", "maya")).toBeNull();
expect(githubExplicitMentionEvent("issues", { ...input, repository: { id: 1, full_name: "wrong/repo/extra" } }, "delivery", "maya")).toBeNull();
expect(githubExplicitMentionEvent("issues", { ...input, sender: { id: Number.MAX_SAFE_INTEGER + 1, login: "sender" } }, "delivery", "maya")).toBeNull();
expect(githubExplicitMentionEvent("issues", input, "", "maya")).toBeNull();
});
});
+81
View File
@@ -31,6 +31,87 @@ const person = z.object({
login: z.string().min(1).max(100),
type: z.string().optional(),
});
export function githubBodyMentionsBot(body: string, username: string | null): boolean {
if (!username) return false;
const name = username.replace(/\[bot\]$/i, "");
if (!name) return false;
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`(?<![\\w-])@${escaped}(?:\\[bot\\])?(?![\\w-])`, "i").test(body);
}
const mentionPayloadSchema = z.object({
action: z.string(),
repository: z.object({ id, full_name: z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/) }),
sender: person,
changes: z.object({ body: z.object({ from: z.string().nullable() }).optional() }).optional(),
});
const descriptionSchema = z.object({
number: z.number().int().positive().safe(),
body: z.string().nullable(),
pull_request: z.unknown().optional(),
});
const commentSchema = z.object({
id,
body: z.string().nullable(),
in_reply_to_id: id.optional(),
});
/** Explicit requests use the ordinary message admission path, including the
* actor who made an edit, rather than the automatic event's original author. */
export function githubExplicitMentionEvent(
eventType: string,
payload: unknown,
deliveryId: string,
botUsername: string | null,
) {
const parsed = mentionPayloadSchema.safeParse(payload);
if (!parsed.success || !deliveryId) return null;
const envelope = parsed.data;
const source = payload as Record<string, unknown>;
const isIssue = eventType === "issues" || eventType === "issue_comment";
const target = descriptionSchema.safeParse(source[isIssue ? "issue" : "pull_request"]);
if (!target.success) return null;
const description = eventType === "issues" || eventType === "pull_request";
const review = eventType === "pull_request_review";
if (!description && !review && !["issue_comment", "pull_request_review_comment"].includes(eventType)) return null;
if (eventType === "issues" && target.data.pull_request !== undefined) return null;
const content = description ? target : commentSchema.safeParse(source[review ? "review" : "comment"]);
if (!content.success) return null;
const body = content.data.body ?? "";
if (!githubBodyMentionsBot(body, botUsername)) return null;
const edited = envelope.action === "edited";
if (edited) {
// A title/label edit, push, or unrelated edit retaining an old mention
// must not rerun the request. Missing previous content is not evidence
// that the editor added a mention.
if (!envelope.changes?.body || githubBodyMentionsBot(envelope.changes.body.from ?? "", botUsername)) return null;
} else if (envelope.action !== (description ? "opened" : review ? "submitted" : "created")) return null;
if (envelope.sender.type === "Bot") return null;
const repository = envelope.repository.full_name.toLowerCase();
const number = target.data.number;
const issueThread = isIssue && target.data.pull_request === undefined;
const comment = !description && !review ? commentSchema.parse(source.comment) : null;
const reviewId = review ? commentSchema.parse(source.review).id : null;
// Match the native adapter's repository casing so addressed descriptions
// and ordinary follow-up comments share the same existing conversation.
const threadId = `github:${envelope.repository.full_name}:${issueThread ? "issue:" : ""}${number}${eventType === "pull_request_review_comment" ? `:rc:${comment!.in_reply_to_id ?? comment!.id}` : ""}`;
return {
threadId,
// Created comments retain the SDK's identity so alternate delivery paths
// cannot duplicate a turn. Edits are separate, delivery-deduplicated asks.
messageId: edited || description ? `mention-event:${deliveryId}` : review ? `review:${reviewId}` : comment!.id,
body: body.slice(0, 65536),
sender: envelope.sender,
edited,
receiptReactionSupported: !description && !review && !edited,
url: `https://github.com/${repository}/${issueThread ? "issues" : "pull"}/${number}${description ? "" : review ? `#pullrequestreview-${reviewId}` : eventType === "pull_request_review_comment" ? `#discussion_r${comment!.id}` : `#issuecomment-${comment!.id}`}`,
raw: comment ? {
type: eventType === "pull_request_review_comment" ? "review_comment" : "issue_comment",
comment: source.comment, repository: source.repository, prNumber: number, threadType: issueThread ? "issue" : "pr",
} : {},
};
}
const payloadSchema = z.object({
action: z.enum(["opened", "synchronize", "reopened", "ready_for_review"]),
repository: z.object({
@@ -228,6 +228,7 @@ export function githubChatRegistrationService(
"issues",
"issue_comment",
"pull_request_review_comment",
"pull_request_review",
"pull_request",
],
},
@@ -393,6 +393,7 @@ export function githubChatWizardService(
"issues",
"issue_comment",
"pull_request_review_comment",
"pull_request_review",
"pull_request",
],
},