diff --git a/packages/plugins/examples/plugin-github-webhook-dispatcher/.gitignore b/packages/plugins/examples/plugin-github-webhook-dispatcher/.gitignore new file mode 100644 index 00000000000..0430cc35608 --- /dev/null +++ b/packages/plugins/examples/plugin-github-webhook-dispatcher/.gitignore @@ -0,0 +1,3 @@ +dist +node_modules +.paperclip-sdk diff --git a/packages/plugins/examples/plugin-github-webhook-dispatcher/README.md b/packages/plugins/examples/plugin-github-webhook-dispatcher/README.md new file mode 100644 index 00000000000..1865962bd89 --- /dev/null +++ b/packages/plugins/examples/plugin-github-webhook-dispatcher/README.md @@ -0,0 +1,27 @@ +# Plugin Github Webhook Dispatcher + +A Paperclip plugin + +## Development + +```bash +pnpm install +pnpm dev # watch builds +pnpm dev:ui # local dev server with hot-reload events +pnpm test +``` + + + +## Install Into Paperclip + +```bash +curl -X POST http://127.0.0.1:3100/api/plugins/install \ + -H "Content-Type: application/json" \ + -d '{"packageName":"/home/ubuntu/paperclip-data/trial/paperclip-ten-334-github-webhook-dispatcher/packages/plugins/examples/plugin-github-webhook-dispatcher","isLocalPath":true}' +``` + +## Build Options + +- `pnpm build` uses esbuild presets from `@paperclipai/plugin-sdk/bundlers`. +- `pnpm build:rollup` uses rollup presets from the same SDK. diff --git a/packages/plugins/examples/plugin-github-webhook-dispatcher/esbuild.config.mjs b/packages/plugins/examples/plugin-github-webhook-dispatcher/esbuild.config.mjs new file mode 100644 index 00000000000..348116ee52b --- /dev/null +++ b/packages/plugins/examples/plugin-github-webhook-dispatcher/esbuild.config.mjs @@ -0,0 +1,16 @@ +import esbuild from "esbuild"; +import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers"; + +const presets = createPluginBundlerPresets({}); +const watch = process.argv.includes("--watch"); + +const workerCtx = await esbuild.context(presets.esbuild.worker); +const manifestCtx = await esbuild.context(presets.esbuild.manifest); + +if (watch) { + await Promise.all([workerCtx.watch(), manifestCtx.watch()]); + console.log("esbuild watch mode enabled for worker and manifest"); +} else { + await Promise.all([workerCtx.rebuild(), manifestCtx.rebuild()]); + await Promise.all([workerCtx.dispose(), manifestCtx.dispose()]); +} diff --git a/packages/plugins/examples/plugin-github-webhook-dispatcher/package.json b/packages/plugins/examples/plugin-github-webhook-dispatcher/package.json new file mode 100644 index 00000000000..3ab3831af43 --- /dev/null +++ b/packages/plugins/examples/plugin-github-webhook-dispatcher/package.json @@ -0,0 +1,36 @@ +{ + "name": "@tensorleap/plugin-github-webhook-dispatcher", + "version": "0.1.0", + "type": "module", + "private": true, + "description": "GitHub webhook dispatcher that routes PR, issue, and CI events to matching TEN issues across tensorleap repos", + "scripts": { + "prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps", + "build": "node ./esbuild.config.mjs", + "dev": "node ./esbuild.config.mjs --watch", + "test": "vitest run --config ./vitest.config.ts", + "typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit" + }, + "paperclipPlugin": { + "manifest": "./dist/manifest.js", + "worker": "./dist/worker.js" + }, + "keywords": [ + "paperclip", + "plugin", + "github", + "webhook" + ], + "author": "Tensorleap", + "license": "MIT", + "dependencies": { + "@paperclipai/plugin-sdk": "workspace:*" + }, + "devDependencies": { + "@paperclipai/shared": "workspace:*", + "@types/node": "^24.6.0", + "esbuild": "^0.27.3", + "typescript": "^5.7.3", + "vitest": "^3.2.4" + } +} diff --git a/packages/plugins/examples/plugin-github-webhook-dispatcher/rollup.config.mjs b/packages/plugins/examples/plugin-github-webhook-dispatcher/rollup.config.mjs new file mode 100644 index 00000000000..ccee40a7443 --- /dev/null +++ b/packages/plugins/examples/plugin-github-webhook-dispatcher/rollup.config.mjs @@ -0,0 +1,28 @@ +import { nodeResolve } from "@rollup/plugin-node-resolve"; +import typescript from "@rollup/plugin-typescript"; +import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers"; + +const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" }); + +function withPlugins(config) { + if (!config) return null; + return { + ...config, + plugins: [ + nodeResolve({ + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"], + }), + typescript({ + tsconfig: "./tsconfig.json", + declaration: false, + declarationMap: false, + }), + ], + }; +} + +export default [ + withPlugins(presets.rollup.manifest), + withPlugins(presets.rollup.worker), + withPlugins(presets.rollup.ui), +].filter(Boolean); diff --git a/packages/plugins/examples/plugin-github-webhook-dispatcher/src/manifest.ts b/packages/plugins/examples/plugin-github-webhook-dispatcher/src/manifest.ts new file mode 100644 index 00000000000..57a31430571 --- /dev/null +++ b/packages/plugins/examples/plugin-github-webhook-dispatcher/src/manifest.ts @@ -0,0 +1,51 @@ +import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk"; + +const manifest: PaperclipPluginManifestV1 = { + id: "tensorleap.plugin-github-webhook-dispatcher", + apiVersion: 1, + version: "0.1.0", + displayName: "GitHub Webhook Dispatcher", + description: "Receives GitHub webhook events and dispatches wake comments to matching TEN issues. Handles pull_request, pull_request_review, issues, issue_comment, and check_suite events across tensorleap repos.", + author: "Tensorleap", + categories: ["connector", "automation"], + capabilities: [ + "webhooks.receive", + "issues.read", + "issues.create", + "issue.comments.create", + "plugin.state.read", + "plugin.state.write", + "secrets.read-ref", + ], + instanceConfigSchema: { + type: "object", + required: ["companyId", "webhookSecretRef"], + properties: { + companyId: { + type: "string", + title: "Company ID", + description: "Paperclip company whose issues should receive GitHub event comments.", + default: "", + }, + webhookSecretRef: { + type: "string", + title: "Webhook Secret Ref", + description: "Company secret UUID used to verify GitHub's X-Hub-Signature-256 header.", + format: "secret-ref", + default: "", + }, + }, + }, + entrypoints: { + worker: "./dist/worker.js", + }, + webhooks: [ + { + endpointKey: "github", + displayName: "GitHub Events", + description: "Receives PR, issue, and CI events from tensorleap GitHub repos", + }, + ], +}; + +export default manifest; diff --git a/packages/plugins/examples/plugin-github-webhook-dispatcher/src/worker.ts b/packages/plugins/examples/plugin-github-webhook-dispatcher/src/worker.ts new file mode 100644 index 00000000000..651ed5ce690 --- /dev/null +++ b/packages/plugins/examples/plugin-github-webhook-dispatcher/src/worker.ts @@ -0,0 +1,419 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { definePlugin, runWorker, type PluginContext, type PluginWebhookInput } from "@paperclipai/plugin-sdk"; + +const WEBHOOK_ENDPOINT_KEY = "github"; +const LAST_DELIVERY_KEY = "gh-dispatcher:last-delivery"; + +type DeliveryState = { + deliveryId: string; + requestId: string; + eventType: string; + action: string; + processedAt: string; + processed: boolean; + reason: string; + matchedIssueId: string | null; +}; + +type PluginConfig = { + companyId: string; + webhookSecretRef: string; +}; + +// ────────────────────────────────────────────── +// Utilities +// ────────────────────────────────────────────── + +function str(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function getHeader(headers: Record, name: string): string | null { + const target = name.toLowerCase(); + for (const [k, v] of Object.entries(headers)) { + if (k.toLowerCase() !== target) continue; + if (typeof v === "string") return str(v); + if (Array.isArray(v)) { + const first = v.find((e): e is string => typeof e === "string" && e.trim().length > 0); + return first ? str(first) : null; + } + } + return null; +} + +function computeGitHubSignature(secret: string, rawBody: string): string { + return `sha256=${createHmac("sha256", secret).update(rawBody).digest("hex")}`; +} + +function verifyGitHubSignature(secret: string, rawBody: string, signatureHeader: string): void { + const expected = Buffer.from(computeGitHubSignature(secret, rawBody), "utf8"); + const actual = Buffer.from(signatureHeader, "utf8"); + if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) { + throw new Error("Invalid GitHub webhook signature"); + } +} + +function deliveryStateKey(deliveryId: string): string { + return `gh-dispatcher:delivery:${deliveryId}`; +} + +function extractGitHubRef(payload: Record): string | null { + const repo = payload.repository as Record | null | undefined; + const repoFullName = str(repo?.full_name); + + // PR events: pull_request.number + const pr = payload.pull_request as Record | null | undefined; + if (pr && repoFullName) { + const num = typeof pr.number === "number" ? pr.number : null; + if (num != null) return `${repoFullName}#${num}`; + } + + // Issue events: issue.number + const issue = payload.issue as Record | null | undefined; + if (issue && repoFullName) { + const num = typeof issue.number === "number" ? issue.number : null; + if (num != null) return `${repoFullName}#${num}`; + } + + // check_suite: first pull_request in array + const suite = payload.check_suite as Record | null | undefined; + if (suite && repoFullName) { + const prs = suite.pull_requests as Array> | null | undefined; + if (Array.isArray(prs) && prs.length > 0) { + const firstPr = prs[0]; + if (firstPr) { + const num = typeof firstPr.number === "number" ? firstPr.number : null; + if (num != null) return `${repoFullName}#${num}`; + } + } + } + + return null; +} + +function isActionable(eventType: string, action: string | null, payload: Record): boolean { + switch (eventType) { + case "check_suite": { + const suite = payload.check_suite as Record | null | undefined; + if (!suite) return false; + if (str(suite.status) !== "completed") return false; + const conclusion = str(suite.conclusion); + if (!conclusion || !["failure", "timed_out", "action_required"].includes(conclusion)) return false; + const prs = suite.pull_requests as Array | null | undefined; + return Array.isArray(prs) && prs.length > 0; + } + case "pull_request": + return ["opened", "closed", "synchronize", "reopened"].includes(action ?? ""); + case "pull_request_review": + return action === "submitted"; + case "issues": + return ["opened", "assigned", "closed"].includes(action ?? ""); + case "issue_comment": + return action === "created"; + default: + return false; + } +} + +function buildSummary(eventType: string, action: string | null, payload: Record): string { + const sender = (payload.sender as Record | null | undefined)?.login; + const actor = sender ? `\`${str(sender)}\`` : "unknown actor"; + + switch (eventType) { + case "pull_request": { + const pr = payload.pull_request as Record | null | undefined; + const title = str(pr?.title as unknown) ?? "(untitled)"; + const url = str(pr?.html_url as unknown); + const prRef = url ? `[PR](${url})` : "PR"; + if (action === "closed" && pr?.merged === true) return `${actor} merged ${prRef}: ${title}`; + if (action === "closed") return `${actor} closed ${prRef}: ${title}`; + return `${actor} ${action} ${prRef}: ${title}`; + } + case "pull_request_review": { + const review = payload.review as Record | null | undefined; + const state = str(review?.state as unknown) ?? "unknown"; + const url = str(review?.html_url as unknown); + const reviewRef = url ? `[review](${url})` : "review"; + return `${actor} submitted a \`${state}\` ${reviewRef}`; + } + case "issues": { + const issue = payload.issue as Record | null | undefined; + const title = str(issue?.title as unknown) ?? "(untitled)"; + return `${actor} ${action} issue: ${title}`; + } + case "issue_comment": { + const comment = payload.comment as Record | null | undefined; + const body = str(comment?.body as unknown); + const url = str(comment?.html_url as unknown); + const excerpt = body ? body.slice(0, 120) + (body.length > 120 ? "…" : "") : "(empty)"; + return `${actor} commented${url ? ` [→](${url})` : ""}: ${excerpt}`; + } + case "check_suite": { + const suite = payload.check_suite as Record | null | undefined; + const conclusion = str(suite?.conclusion as unknown) ?? "unknown"; + const appName = str((suite?.app as Record | null | undefined)?.name) ?? "CI"; + return `${appName} check suite completed with conclusion: \`${conclusion}\``; + } + default: + return `${eventType}.${action ?? "unknown"} event received`; + } +} + +function buildWakeComment( + eventType: string, + action: string | null, + githubRef: string, + summary: string, + rawPayload: string, +): string { + const eventLabel = action ? `${eventType}.${action}` : eventType; + const payloadPreview = rawPayload.length > 4000 + ? rawPayload.slice(0, 4000) + "\n… (truncated)" + : rawPayload; + + return [ + `## GitHub Event: ${eventLabel}`, + ``, + `**Ref:** \`${githubRef}\``, + ``, + summary, + ``, + `
`, + `Raw payload`, + ``, + "```json", + payloadPreview, + "```", + `
`, + ].join("\n"); +} + +// ────────────────────────────────────────────── +// Config +// ────────────────────────────────────────────── + +let currentContext: PluginContext | null = null; + +function normalizeConfig(raw: Record): PluginConfig { + return { + companyId: str(raw.companyId) ?? "", + webhookSecretRef: str(raw.webhookSecretRef) ?? "", + }; +} + +function configReady(config: PluginConfig): boolean { + return config.companyId.length > 0 && config.webhookSecretRef.length > 0; +} + +async function getConfig(ctx: PluginContext): Promise { + const raw = await ctx.config.get(); + return normalizeConfig(raw as Record); +} + +// ────────────────────────────────────────────── +// Issue matching +// ────────────────────────────────────────────── + +async function findTenIssue( + ctx: PluginContext, + companyId: string, + githubRef: string, +): Promise<{ id: string; identifier: string } | null> { + const results = await ctx.issues.list({ companyId, q: githubRef, limit: 5 }); + if (!results || results.length === 0) return null; + const issue = results[0]; + if (!issue) return null; + return { id: issue.id, identifier: issue.identifier ?? issue.id }; +} + +// ────────────────────────────────────────────── +// Plugin definition +// ────────────────────────────────────────────── + +const plugin = definePlugin({ + async setup(ctx) { + currentContext = ctx; + ctx.logger.info("github-webhook-dispatcher plugin setup complete"); + }, + + async onValidateConfig(config) { + const errors: string[] = []; + const normalized = normalizeConfig(config as Record); + + if (!normalized.companyId) errors.push("companyId is required"); + if (!normalized.webhookSecretRef) errors.push("webhookSecretRef is required"); + + if (currentContext && normalized.webhookSecretRef) { + try { + await currentContext.secrets.resolve(normalized.webhookSecretRef); + } catch (err) { + errors.push(err instanceof Error ? err.message : String(err)); + } + } + + return { ok: errors.length === 0, errors, warnings: [] }; + }, + + async onWebhook(input: PluginWebhookInput) { + if (!currentContext) throw new Error("Plugin context not initialized"); + if (input.endpointKey !== WEBHOOK_ENDPOINT_KEY) { + throw new Error(`Unsupported webhook endpoint: ${input.endpointKey}`); + } + + const config = await getConfig(currentContext); + if (!configReady(config)) { + throw new Error("Plugin config incomplete: companyId and webhookSecretRef are required"); + } + + const deliveryId = getHeader(input.headers, "x-github-delivery"); + const eventType = getHeader(input.headers, "x-github-event"); + const signature = getHeader(input.headers, "x-hub-signature-256"); + + if (!deliveryId) throw new Error("Missing X-GitHub-Delivery header"); + if (!eventType) throw new Error("Missing X-GitHub-Event header"); + if (!signature) throw new Error("Missing X-Hub-Signature-256 header"); + + // Deduplication check + const existing = await currentContext.state.get({ + scopeKind: "instance", + stateKey: deliveryStateKey(deliveryId), + }); + if (existing) { + currentContext.logger.info("Skipping duplicate delivery", { deliveryId, eventType }); + return; + } + + // Signature verification + const secret = await currentContext.secrets.resolve(config.webhookSecretRef); + verifyGitHubSignature(secret, input.rawBody, signature); + + const payload = input.parsedBody as Record; + const action = str(payload.action) ?? null; + + // Persist delivery record and skip if not actionable + const baseState = { + deliveryId, + requestId: input.requestId, + eventType, + action: action ?? "unknown", + processedAt: new Date().toISOString(), + }; + + if (!isActionable(eventType, action, payload)) { + currentContext.logger.info("Skipping non-actionable event", { eventType, action, deliveryId }); + const skipState: DeliveryState = { + ...baseState, + processed: false, + reason: `filtered:${eventType}.${action ?? "unknown"}`, + matchedIssueId: null, + }; + await currentContext.state.set({ scopeKind: "instance", stateKey: deliveryStateKey(deliveryId) }, skipState); + await currentContext.state.set({ scopeKind: "instance", stateKey: LAST_DELIVERY_KEY }, skipState); + return; + } + + // Extract canonical GitHub ref + const githubRef = extractGitHubRef(payload); + if (!githubRef) { + currentContext.logger.info("Cannot extract GitHub ref from payload", { eventType, action, deliveryId }); + const noRefState: DeliveryState = { + ...baseState, + processed: false, + reason: "no-github-ref", + matchedIssueId: null, + }; + await currentContext.state.set({ scopeKind: "instance", stateKey: deliveryStateKey(deliveryId) }, noRefState); + await currentContext.state.set({ scopeKind: "instance", stateKey: LAST_DELIVERY_KEY }, noRefState); + return; + } + + const summary = buildSummary(eventType, action, payload); + const commentBody = buildWakeComment(eventType, action, githubRef, summary, input.rawBody); + + // Search for matching TEN issue + const matchedIssue = await findTenIssue(currentContext, config.companyId, githubRef); + + let matchedIssueId: string | null = null; + let reason: string; + + if (matchedIssue) { + await currentContext.issues.createComment(matchedIssue.id, commentBody, config.companyId); + matchedIssueId = matchedIssue.id; + reason = `dispatched:${matchedIssue.identifier}`; + currentContext.logger.info("Dispatched wake comment", { + githubRef, + issueId: matchedIssue.id, + identifier: matchedIssue.identifier, + eventType, + action, + deliveryId, + }); + } else { + // Create triage issue for unmapped ref + const triageTitle = `triage: unmapped ${githubRef}`; + const triageDesc = [ + `GitHub event \`${eventType}.${action ?? "unknown"}\` received for \`${githubRef}\` but no matching TEN issue was found.`, + ``, + `Auto-created by the GitHub Webhook Dispatcher plugin for triage.`, + ``, + commentBody, + ].join("\n"); + + const triageIssue = await currentContext.issues.create({ + companyId: config.companyId, + title: triageTitle, + description: triageDesc, + }); + reason = `triage-created:${triageIssue?.id ?? "unknown"}`; + currentContext.logger.info("Created triage issue for unmapped ref", { + githubRef, + triageIssueId: triageIssue?.id, + eventType, + action, + deliveryId, + }); + } + + const deliveryState: DeliveryState = { + ...baseState, + processed: true, + reason, + matchedIssueId, + }; + + await currentContext.state.set( + { scopeKind: "instance", stateKey: deliveryStateKey(deliveryId) }, + deliveryState, + ); + await currentContext.state.set( + { scopeKind: "instance", stateKey: LAST_DELIVERY_KEY }, + deliveryState, + ); + + currentContext.logger.info("GitHub webhook delivery processed", deliveryState); + }, + + async onHealth() { + if (!currentContext) { + return { status: "degraded", message: "github-webhook-dispatcher plugin not initialized" }; + } + const config = await getConfig(currentContext); + const ready = configReady(config); + const lastDelivery = await currentContext.state.get({ + scopeKind: "instance", + stateKey: LAST_DELIVERY_KEY, + }); + return { + status: ready ? "ok" : "degraded", + message: ready ? "github-webhook-dispatcher plugin is running" : "Plugin config incomplete", + details: { + companyId: config.companyId || null, + webhookConfigured: ready, + lastDelivery: lastDelivery ?? null, + }, + }; + }, +}); + +export default plugin; +runWorker(plugin, import.meta.url); diff --git a/packages/plugins/examples/plugin-github-webhook-dispatcher/tests/plugin.spec.ts b/packages/plugins/examples/plugin-github-webhook-dispatcher/tests/plugin.spec.ts new file mode 100644 index 00000000000..2907f8d6482 --- /dev/null +++ b/packages/plugins/examples/plugin-github-webhook-dispatcher/tests/plugin.spec.ts @@ -0,0 +1,543 @@ +import { createHmac, randomUUID } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import type { Issue } from "@paperclipai/shared"; +import { createTestHarness } from "@paperclipai/plugin-sdk/testing"; +import manifest from "../src/manifest.js"; +import plugin from "../src/worker.js"; + +// ────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────── + +function signPayload(secretRef: string, body: string): string { + return `sha256=${createHmac("sha256", `resolved:${secretRef}`).update(body).digest("hex")}`; +} + +function makeIssue(partial: { + id?: string; + companyId: string; + identifier: string; + title?: string; + /** GitHub ref (e.g. "tensorleap/fsd#414") stored in description for search */ + githubRef?: string; + status?: Issue["status"]; +}): Issue { + const now = new Date(); + return { + id: partial.id ?? randomUUID(), + companyId: partial.companyId, + projectId: null, + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: partial.title ?? `TEN issue for ${partial.identifier}`, + description: partial.githubRef + ? `Tracks ${partial.githubRef}` + : null, + status: partial.status ?? "in_progress", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + createdByAgentId: null, + createdByUserId: null, + issueNumber: null, + identifier: partial.identifier, + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + createdAt: now, + updatedAt: now, + }; +} + +function buildHarness(companyId: string, secretRef: string) { + return createTestHarness({ + manifest, + capabilities: [...manifest.capabilities, "issue.comments.read"], + config: { companyId, webhookSecretRef: secretRef }, + }); +} + +function webhookInput(opts: { + secretRef: string; + payload: object; + eventType: string; + deliveryId?: string; + overrideSignature?: string; +}) { + const body = JSON.stringify(opts.payload); + return { + endpointKey: "github", + requestId: `req-${randomUUID()}`, + headers: { + "x-github-delivery": opts.deliveryId ?? randomUUID(), + "x-github-event": opts.eventType, + "x-hub-signature-256": opts.overrideSignature ?? signPayload(opts.secretRef, body), + } as Record, + rawBody: body, + parsedBody: opts.payload, + }; +} + +// ────────────────────────────────────────────── +// Manifest +// ────────────────────────────────────────────── + +describe("manifest", () => { + it("declares required capabilities", () => { + expect(manifest.capabilities).toEqual( + expect.arrayContaining([ + "webhooks.receive", + "issues.read", + "issues.create", + "issue.comments.create", + "plugin.state.read", + "plugin.state.write", + "secrets.read-ref", + ]), + ); + }); + + it("declares github webhook endpoint", () => { + expect(manifest.webhooks).toEqual([ + expect.objectContaining({ endpointKey: "github" }), + ]); + }); +}); + +// ────────────────────────────────────────────── +// HMAC signature validation +// ────────────────────────────────────────────── + +describe("signature validation", () => { + it("rejects missing X-Hub-Signature-256 header", async () => { + const companyId = randomUUID(); + const secretRef = randomUUID(); + const harness = buildHarness(companyId, secretRef); + await plugin.definition.setup(harness.ctx); + + const body = JSON.stringify({ action: "opened" }); + await expect( + plugin.definition.onWebhook?.({ + endpointKey: "github", + requestId: "req-1", + headers: { + "x-github-delivery": randomUUID(), + "x-github-event": "pull_request", + }, + rawBody: body, + parsedBody: { action: "opened" }, + }), + ).rejects.toThrow("Missing X-Hub-Signature-256 header"); + }); + + it("rejects an invalid signature", async () => { + const companyId = randomUUID(); + const secretRef = randomUUID(); + const harness = buildHarness(companyId, secretRef); + await plugin.definition.setup(harness.ctx); + + const body = JSON.stringify({ action: "opened" }); + await expect( + plugin.definition.onWebhook?.({ + endpointKey: "github", + requestId: "req-2", + headers: { + "x-github-delivery": randomUUID(), + "x-github-event": "pull_request", + "x-hub-signature-256": "sha256=deadbeefdeadbeef", + }, + rawBody: body, + parsedBody: { action: "opened" }, + }), + ).rejects.toThrow("Invalid GitHub webhook signature"); + }); +}); + +// ────────────────────────────────────────────── +// pull_request events +// ────────────────────────────────────────────── + +describe("pull_request events", () => { + it("posts wake comment on matched TEN issue for PR opened", async () => { + const companyId = randomUUID(); + const secretRef = randomUUID(); + const issue = makeIssue({ companyId, identifier: "TEN-73", githubRef: "tensorleap/fsd#414" }); + const harness = buildHarness(companyId, secretRef); + harness.seed({ issues: [issue] }); + await plugin.definition.setup(harness.ctx); + + const payload = { + action: "opened", + pull_request: { + number: 414, + title: "Fix auth regression", + html_url: "https://github.com/tensorleap/fsd/pull/414", + merged: false, + user: { login: "devuser" }, + }, + repository: { full_name: "tensorleap/fsd" }, + sender: { login: "devuser" }, + }; + const input = webhookInput({ secretRef, payload, eventType: "pull_request" }); + + await plugin.definition.onWebhook?.(input); + + const comments = await harness.ctx.issues.listComments(issue.id, companyId); + expect(comments).toHaveLength(1); + expect(comments[0]?.body).toContain("## GitHub Event: pull_request.opened"); + expect(comments[0]?.body).toContain("`tensorleap/fsd#414`"); + expect(comments[0]?.body).toContain("`devuser`"); + }); + + it("posts wake comment for PR synchronize", async () => { + const companyId = randomUUID(); + const secretRef = randomUUID(); + const issue = makeIssue({ companyId, identifier: "TEN-73", githubRef: "tensorleap/fsd#414" }); + const harness = buildHarness(companyId, secretRef); + harness.seed({ issues: [issue] }); + await plugin.definition.setup(harness.ctx); + + const payload = { + action: "synchronize", + pull_request: { number: 414, title: "Fix auth" }, + repository: { full_name: "tensorleap/fsd" }, + sender: { login: "devuser" }, + }; + const input = webhookInput({ secretRef, payload, eventType: "pull_request" }); + await plugin.definition.onWebhook?.(input); + + const comments = await harness.ctx.issues.listComments(issue.id, companyId); + expect(comments).toHaveLength(1); + expect(comments[0]?.body).toContain("pull_request.synchronize"); + }); + + it("silently drops pull_request.labeled", async () => { + const companyId = randomUUID(); + const secretRef = randomUUID(); + const issue = makeIssue({ companyId, identifier: "TEN-73", githubRef: "tensorleap/fsd#414" }); + const harness = buildHarness(companyId, secretRef); + harness.seed({ issues: [issue] }); + await plugin.definition.setup(harness.ctx); + + const payload = { + action: "labeled", + pull_request: { number: 414 }, + repository: { full_name: "tensorleap/fsd" }, + }; + const input = webhookInput({ secretRef, payload, eventType: "pull_request" }); + await plugin.definition.onWebhook?.(input); + + const comments = await harness.ctx.issues.listComments(issue.id, companyId); + expect(comments).toHaveLength(0); + }); +}); + +// ────────────────────────────────────────────── +// pull_request_review events +// ────────────────────────────────────────────── + +describe("pull_request_review events", () => { + it("posts comment for review submitted (changes_requested)", async () => { + const companyId = randomUUID(); + const secretRef = randomUUID(); + const issue = makeIssue({ companyId, identifier: "TEN-73", githubRef: "tensorleap/fsd#414" }); + const harness = buildHarness(companyId, secretRef); + harness.seed({ issues: [issue] }); + await plugin.definition.setup(harness.ctx); + + const payload = { + action: "submitted", + review: { + state: "changes_requested", + html_url: "https://github.com/tensorleap/fsd/pull/414#pullrequestreview-1", + user: { login: "reviewer" }, + }, + pull_request: { number: 414, title: "Fix auth" }, + repository: { full_name: "tensorleap/fsd" }, + sender: { login: "reviewer" }, + }; + const input = webhookInput({ secretRef, payload, eventType: "pull_request_review" }); + await plugin.definition.onWebhook?.(input); + + const comments = await harness.ctx.issues.listComments(issue.id, companyId); + expect(comments).toHaveLength(1); + expect(comments[0]?.body).toContain("## GitHub Event: pull_request_review.submitted"); + expect(comments[0]?.body).toContain("changes_requested"); + }); + + it("silently drops pull_request_review.dismissed", async () => { + const companyId = randomUUID(); + const secretRef = randomUUID(); + const issue = makeIssue({ companyId, identifier: "TEN-73", githubRef: "tensorleap/fsd#414" }); + const harness = buildHarness(companyId, secretRef); + harness.seed({ issues: [issue] }); + await plugin.definition.setup(harness.ctx); + + const payload = { + action: "dismissed", + review: { state: "dismissed" }, + pull_request: { number: 414 }, + repository: { full_name: "tensorleap/fsd" }, + }; + const input = webhookInput({ secretRef, payload, eventType: "pull_request_review" }); + await plugin.definition.onWebhook?.(input); + + const comments = await harness.ctx.issues.listComments(issue.id, companyId); + expect(comments).toHaveLength(0); + }); +}); + +// ────────────────────────────────────────────── +// issues events +// ────────────────────────────────────────────── + +describe("issues events", () => { + it("posts comment for issues.opened", async () => { + const companyId = randomUUID(); + const secretRef = randomUUID(); + const issue = makeIssue({ companyId, identifier: "TEN-73", githubRef: "tensorleap/fsd#10" }); + const harness = buildHarness(companyId, secretRef); + harness.seed({ issues: [issue] }); + await plugin.definition.setup(harness.ctx); + + const payload = { + action: "opened", + issue: { number: 10, title: "Bug report" }, + repository: { full_name: "tensorleap/fsd" }, + sender: { login: "user1" }, + }; + const input = webhookInput({ secretRef, payload, eventType: "issues" }); + await plugin.definition.onWebhook?.(input); + + const comments = await harness.ctx.issues.listComments(issue.id, companyId); + expect(comments).toHaveLength(1); + expect(comments[0]?.body).toContain("## GitHub Event: issues.opened"); + }); + + it("silently drops issues.labeled", async () => { + const companyId = randomUUID(); + const secretRef = randomUUID(); + const issue = makeIssue({ companyId, identifier: "TEN-73", githubRef: "tensorleap/fsd#10" }); + const harness = buildHarness(companyId, secretRef); + harness.seed({ issues: [issue] }); + await plugin.definition.setup(harness.ctx); + + const payload = { + action: "labeled", + issue: { number: 10 }, + repository: { full_name: "tensorleap/fsd" }, + }; + const input = webhookInput({ secretRef, payload, eventType: "issues" }); + await plugin.definition.onWebhook?.(input); + + const comments = await harness.ctx.issues.listComments(issue.id, companyId); + expect(comments).toHaveLength(0); + }); +}); + +// ────────────────────────────────────────────── +// issue_comment events +// ────────────────────────────────────────────── + +describe("issue_comment events", () => { + it("posts comment for issue_comment.created", async () => { + const companyId = randomUUID(); + const secretRef = randomUUID(); + const issue = makeIssue({ companyId, identifier: "TEN-73", githubRef: "tensorleap/fsd#10" }); + const harness = buildHarness(companyId, secretRef); + harness.seed({ issues: [issue] }); + await plugin.definition.setup(harness.ctx); + + const payload = { + action: "created", + issue: { number: 10 }, + comment: { body: "LGTM!", html_url: "https://github.com/tensorleap/fsd/issues/10#issuecomment-1" }, + repository: { full_name: "tensorleap/fsd" }, + sender: { login: "commenter" }, + }; + const input = webhookInput({ secretRef, payload, eventType: "issue_comment" }); + await plugin.definition.onWebhook?.(input); + + const comments = await harness.ctx.issues.listComments(issue.id, companyId); + expect(comments).toHaveLength(1); + expect(comments[0]?.body).toContain("## GitHub Event: issue_comment.created"); + expect(comments[0]?.body).toContain("`commenter`"); + }); +}); + +// ────────────────────────────────────────────── +// check_suite events +// ────────────────────────────────────────────── + +describe("check_suite events", () => { + it("posts comment for failed completed check_suite with PRs", async () => { + const companyId = randomUUID(); + const secretRef = randomUUID(); + const issue = makeIssue({ companyId, identifier: "TEN-73", githubRef: "tensorleap/fsd#414" }); + const harness = buildHarness(companyId, secretRef); + harness.seed({ issues: [issue] }); + await plugin.definition.setup(harness.ctx); + + const payload = { + action: "completed", + check_suite: { + status: "completed", + conclusion: "failure", + pull_requests: [{ number: 414 }], + app: { name: "GitHub Actions" }, + }, + repository: { full_name: "tensorleap/fsd" }, + sender: { login: "github-actions[bot]" }, + }; + const input = webhookInput({ secretRef, payload, eventType: "check_suite" }); + await plugin.definition.onWebhook?.(input); + + const comments = await harness.ctx.issues.listComments(issue.id, companyId); + expect(comments).toHaveLength(1); + expect(comments[0]?.body).toContain("## GitHub Event: check_suite"); + expect(comments[0]?.body).toContain("`failure`"); + }); + + it("silently drops successful check_suite", async () => { + const companyId = randomUUID(); + const secretRef = randomUUID(); + const issue = makeIssue({ companyId, identifier: "TEN-73", githubRef: "tensorleap/fsd#414" }); + const harness = buildHarness(companyId, secretRef); + harness.seed({ issues: [issue] }); + await plugin.definition.setup(harness.ctx); + + const payload = { + action: "completed", + check_suite: { + status: "completed", + conclusion: "success", + pull_requests: [{ number: 414 }], + }, + repository: { full_name: "tensorleap/fsd" }, + }; + const input = webhookInput({ secretRef, payload, eventType: "check_suite" }); + await plugin.definition.onWebhook?.(input); + + const comments = await harness.ctx.issues.listComments(issue.id, companyId); + expect(comments).toHaveLength(0); + }); + + it("silently drops check_suite with empty pull_requests", async () => { + const companyId = randomUUID(); + const secretRef = randomUUID(); + const harness = buildHarness(companyId, secretRef); + await plugin.definition.setup(harness.ctx); + + const payload = { + action: "completed", + check_suite: { + status: "completed", + conclusion: "failure", + pull_requests: [], + }, + repository: { full_name: "tensorleap/fsd" }, + }; + const input = webhookInput({ secretRef, payload, eventType: "check_suite" }); + await expect(plugin.definition.onWebhook?.(input)).resolves.not.toThrow(); + }); +}); + +// ────────────────────────────────────────────── +// Unmapped refs — triage issue creation +// ────────────────────────────────────────────── + +describe("unmapped refs", () => { + it("creates a triage issue when no TEN issue matches", async () => { + const companyId = randomUUID(); + const secretRef = randomUUID(); + const harness = buildHarness(companyId, secretRef); + // No issues seeded — no match + await plugin.definition.setup(harness.ctx); + + const payload = { + action: "opened", + pull_request: { number: 999, title: "Unknown PR" }, + repository: { full_name: "tensorleap/fsd" }, + sender: { login: "devuser" }, + }; + const input = webhookInput({ secretRef, payload, eventType: "pull_request" }); + await plugin.definition.onWebhook?.(input); + + const allIssues = await harness.ctx.issues.list({ companyId }); + const triageIssue = allIssues.find((i) => i.title.includes("triage: unmapped tensorleap/fsd#999")); + expect(triageIssue).toBeDefined(); + expect(triageIssue?.description).toContain("## GitHub Event: pull_request.opened"); + }); +}); + +// ────────────────────────────────────────────── +// Deduplication +// ────────────────────────────────────────────── + +describe("deduplication", () => { + it("skips duplicate delivery IDs", async () => { + const companyId = randomUUID(); + const secretRef = randomUUID(); + const issue = makeIssue({ companyId, identifier: "TEN-73", githubRef: "tensorleap/fsd#414" }); + const harness = buildHarness(companyId, secretRef); + harness.seed({ issues: [issue] }); + await plugin.definition.setup(harness.ctx); + + const deliveryId = randomUUID(); + const payload = { + action: "opened", + pull_request: { number: 414, title: "Fix" }, + repository: { full_name: "tensorleap/fsd" }, + sender: { login: "dev" }, + }; + + const input1 = webhookInput({ secretRef, payload, eventType: "pull_request", deliveryId }); + const input2 = webhookInput({ secretRef, payload, eventType: "pull_request", deliveryId }); + + await plugin.definition.onWebhook?.(input1); + await plugin.definition.onWebhook?.(input2); + + const comments = await harness.ctx.issues.listComments(issue.id, companyId); + expect(comments).toHaveLength(1); + }); +}); + +// ────────────────────────────────────────────── +// Ignored event types +// ────────────────────────────────────────────── + +describe("ignored event types", () => { + it("silently drops push events", async () => { + const companyId = randomUUID(); + const secretRef = randomUUID(); + const harness = buildHarness(companyId, secretRef); + await plugin.definition.setup(harness.ctx); + + const payload = { ref: "refs/heads/main" }; + const input = webhookInput({ secretRef, payload, eventType: "push" }); + await expect(plugin.definition.onWebhook?.(input)).resolves.not.toThrow(); + }); + + it("silently drops star events", async () => { + const companyId = randomUUID(); + const secretRef = randomUUID(); + const harness = buildHarness(companyId, secretRef); + await plugin.definition.setup(harness.ctx); + + const payload = { action: "created" }; + const input = webhookInput({ secretRef, payload, eventType: "star" }); + await expect(plugin.definition.onWebhook?.(input)).resolves.not.toThrow(); + }); +}); diff --git a/packages/plugins/examples/plugin-github-webhook-dispatcher/tsconfig.json b/packages/plugins/examples/plugin-github-webhook-dispatcher/tsconfig.json new file mode 100644 index 00000000000..b3d207fee6b --- /dev/null +++ b/packages/plugins/examples/plugin-github-webhook-dispatcher/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "strict": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "." + }, + "include": [ + "src", + "tests" + ], + "exclude": [ + "dist", + "node_modules" + ] +} diff --git a/packages/plugins/examples/plugin-github-webhook-dispatcher/vitest.config.ts b/packages/plugins/examples/plugin-github-webhook-dispatcher/vitest.config.ts new file mode 100644 index 00000000000..649a293ef95 --- /dev/null +++ b/packages/plugins/examples/plugin-github-webhook-dispatcher/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.spec.ts"], + environment: "node", + }, +}); diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index 19566570c15..992ad59bb5f 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -734,6 +734,7 @@ export interface WorkerToHostMethods { originKind?: string; originId?: string; status?: string; + q?: string; limit?: number; offset?: number; }, diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index 16efee05d02..445ab81da88 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -675,6 +675,14 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { } if (input?.originId) out = out.filter((issue) => issue.originId === input.originId); if (input?.status) out = out.filter((issue) => issue.status === input.status); + if (input?.q) { + const q = input.q.toLowerCase(); + out = out.filter((issue) => + (issue.title?.toLowerCase().includes(q) ?? false) || + (issue.description?.toLowerCase().includes(q) ?? false) || + (issue.identifier?.toLowerCase().includes(q) ?? false), + ); + } if (input?.offset) out = out.slice(input.offset); if (input?.limit) out = out.slice(0, input.limit); return out; diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index 599a06ecac9..0e5aa9c6c14 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -1101,6 +1101,8 @@ export interface PluginIssuesClient { originKind?: PluginIssueOriginKind; originId?: string; status?: Issue["status"]; + /** Full-text search query applied across issue title, description, and comments. */ + q?: string; limit?: number; offset?: number; }): Promise;