diff --git a/packages/host/app/commands/create-listing-pr.ts b/packages/host/app/commands/create-listing-pr.ts index 9c5bd7bff4..ac3e182308 100644 --- a/packages/host/app/commands/create-listing-pr.ts +++ b/packages/host/app/commands/create-listing-pr.ts @@ -187,7 +187,24 @@ export default class CreateListingPRCommand extends HostBaseCommand< log.debug('PR created successfully:', prResult); - // Open room and send PR status message with full details + // Register webhook for PR status updates + const webhookData = await this.registerPRWebhook(prResult.prNumber); + + if (webhookData) { + manifest.webhook = { + id: webhookData.id, + path: webhookData.path, + signingSecret: webhookData.signingSecret, + }; + + log.debug('Webhook registered for PR:', { + webhookUrl: `${this.realmServer.url.href}_webhooks/${webhookData.path}`, + signingSecret: webhookData.signingSecret, + prNumber: prResult.prNumber, + }); + } + + // Open room and send PR status message await new UseAiAssistantCommand(this.commandContext).execute({ roomId, prompt: `I just submitted a PR for my listing "${listing.name ?? listing.id}". @@ -208,6 +225,47 @@ PR Details: return await this.makeResult(manifest); } + private async registerPRWebhook( + prNumber: number, + ): Promise<{ id: string; path: string; signingSecret: string } | null> { + try { + const webhook = await this.realmServer.createIncomingWebhook({ + verificationType: 'HMAC_SHA256_HEADER', + verificationConfig: { + header: 'x-hub-signature-256', + encoding: 'hex', + }, + }); + + log.debug('Created incoming webhook:', { + id: webhook.id, + path: webhook.webhookPath, + }); + + await this.realmServer.createWebhookCommand({ + incomingWebhookId: webhook.id, + command: `${this.realmServer.url.href}catalog-realm/commands/process-github-webhook`, + filter: { + eventType: 'pull_request', + prNumber: prNumber, + }, + }); + + log.debug('Registered webhook command for PR', { prNumber }); + + return { + id: webhook.id, + path: webhook.webhookPath, + signingSecret: webhook.signingSecret, + }; + } catch (error: any) { + log.error('Failed to register PR webhook:', error); + // Don't fail the entire PR creation if webhook registration fails + // Just log and continue + return null; + } + } + private async collectAndFetchFiles( listing: Listing, plan: ReturnType, diff --git a/packages/host/app/services/realm-server.ts b/packages/host/app/services/realm-server.ts index 9f2f871ab4..0b3e1cee3a 100644 --- a/packages/host/app/services/realm-server.ts +++ b/packages/host/app/services/realm-server.ts @@ -1022,6 +1022,130 @@ export default class RealmServerService extends Service { return data.attributes; } + async createIncomingWebhook(params: { + verificationType: 'HMAC_SHA256_HEADER'; + verificationConfig: { + header: string; + encoding: 'hex' | 'base64'; + }; + }): Promise<{ + id: string; + webhookPath: string; + signingSecret: string; + username: string; + createdAt: string; + }> { + await this.login(); + + const response = await this.authedFetch( + `${this.url.href}_incoming-webhooks`, + { + method: 'POST', + headers: { + Accept: SupportedMimeType.JSONAPI, + 'Content-Type': 'application/vnd.api+json', + }, + body: JSON.stringify({ + data: { + type: 'incoming-webhook', + attributes: params, + }, + }), + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Failed to create incoming webhook: ${response.status} - ${errorText}`, + ); + } + + const { data } = (await response.json()) as { + data: { + type: string; + id: string; + attributes: { + username: string; + webhookPath: string; + verificationType: string; + verificationConfig: Record; + signingSecret: string; + createdAt: string; + updatedAt: string; + }; + }; + }; + + return { + id: data.id, + webhookPath: data.attributes.webhookPath, + signingSecret: data.attributes.signingSecret, + username: data.attributes.username, + createdAt: data.attributes.createdAt, + }; + } + + async createWebhookCommand(params: { + incomingWebhookId: string; + command: string; + filter?: Record | null; + }): Promise<{ + id: string; + incomingWebhookId: string; + command: string; + filter: Record | null; + createdAt: string; + }> { + await this.login(); + + const response = await this.authedFetch( + `${this.url.href}_webhook-commands`, + { + method: 'POST', + headers: { + Accept: SupportedMimeType.JSONAPI, + 'Content-Type': 'application/vnd.api+json', + }, + body: JSON.stringify({ + data: { + type: 'webhook-command', + attributes: params, + }, + }), + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Failed to create webhook command: ${response.status} - ${errorText}`, + ); + } + + const { data } = (await response.json()) as { + data: { + type: string; + id: string; + attributes: { + incomingWebhookId: string; + command: string; + filter: Record | null; + createdAt: string; + updatedAt: string; + }; + }; + }; + + return { + id: data.id, + incomingWebhookId: data.attributes.incomingWebhookId, + command: data.attributes.command, + filter: data.attributes.filter, + createdAt: data.attributes.createdAt, + }; + } + private async getToken() { if (!this.token) { await this.login(); diff --git a/packages/matrix/scripts/register-github-webhook.ts b/packages/matrix/scripts/register-github-webhook.ts new file mode 100755 index 0000000000..b2239e790d --- /dev/null +++ b/packages/matrix/scripts/register-github-webhook.ts @@ -0,0 +1,283 @@ +#!/usr/bin/env ts-node +import { registerRealmUser } from './register-realm-user-using-api'; + +const realmServerURL = process.env.REALM_SERVER_URL || 'http://localhost:4201'; + +// Parse command line arguments +// Usage: register-github-webhook.ts [publicURL] [commandURL] [eventType] +const args = process.argv.slice(2); +const publicURL = process.env.PUBLIC_URL || args[0]; // ngrok URL or public URL +const commandURL = args[1] || `${realmServerURL}/catalog-realm/commands/process-github-webhook`; // Command code ref +const filterEventType = args[2] || 'pull_request'; + +// Default GitHub webhook config +const webhookConfig = { + verificationType: 'HMAC_SHA256_HEADER' as const, + verificationConfig: { + header: 'x-hub-signature-256', + encoding: 'hex' as const, + }, +}; + +// Fetch all webhooks for authenticated user +async function fetchIncomingWebhooks(jwt: string) { + const response = await fetch(`${realmServerURL}/_incoming-webhooks`, { + method: 'GET', + headers: { + Authorization: jwt, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error( + `Failed to list incoming webhooks: ${response.status} ${text}`, + ); + } + + const json = await response.json(); + return json?.data ?? []; +} + +// Create incoming webhook +async function createIncomingWebhook( + jwt: string, + config: typeof webhookConfig, +) { + const response = await fetch(`${realmServerURL}/_incoming-webhooks`, { + method: 'POST', + headers: { + Authorization: jwt, + 'Content-Type': 'application/vnd.api+json', + Accept: 'application/vnd.api+json', + }, + body: JSON.stringify({ + data: { + type: 'incoming-webhook', + attributes: config, + }, + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error( + `Failed to create incoming webhook: ${response.status} ${text}`, + ); + } + + const json = await response.json(); + return json?.data; +} + +// Fetch webhook commands for a specific webhook +async function fetchWebhookCommands( + jwt: string, + incomingWebhookId?: string, +) { + const url = new URL(`${realmServerURL}/_webhook-commands`); + if (incomingWebhookId) { + url.searchParams.set('incomingWebhookId', incomingWebhookId); + } + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + Authorization: jwt, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error( + `Failed to list webhook commands: ${response.status} ${text}`, + ); + } + + const json = await response.json(); + return json?.data ?? []; +} + +// Create webhook command +async function createWebhookCommand( + jwt: string, + config: { + incomingWebhookId: string; + command: string; + filter?: Record | null; + }, +) { + const response = await fetch(`${realmServerURL}/_webhook-commands`, { + method: 'POST', + headers: { + Authorization: jwt, + 'Content-Type': 'application/vnd.api+json', + Accept: 'application/vnd.api+json', + }, + body: JSON.stringify({ + data: { + type: 'webhook-command', + attributes: config, + }, + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error( + `Failed to create webhook command: ${response.status} ${text}`, + ); + } + + const json = await response.json(); + return json?.data; +} + +// Idempotent: ensure webhook exists with given config +async function ensureIncomingWebhook( + jwt: string, + config: typeof webhookConfig, +) { + const webhooks = await fetchIncomingWebhooks(jwt); + + // Find existing webhook matching criteria + const existing = webhooks.find( + (w: any) => + w.attributes?.verificationType === config.verificationType && + JSON.stringify(w.attributes?.verificationConfig) === + JSON.stringify(config.verificationConfig), + ); + + if (existing) { + console.log(`Found existing webhook: ${existing.id}`); + return existing; + } + + console.log('Creating new incoming webhook...'); + return await createIncomingWebhook(jwt, config); +} + +// Idempotent: ensure webhook command exists +async function ensureWebhookCommand( + jwt: string, + config: { + incomingWebhookId: string; + command: string; + filter?: Record | null; + }, +) { + const commands = await fetchWebhookCommands(jwt, config.incomingWebhookId); + + // Find existing command with same URL and filter + const existing = commands.find( + (cmd: any) => + cmd.attributes?.command === config.command && + JSON.stringify(cmd.attributes?.filter ?? null) === + JSON.stringify(config.filter ?? null), + ); + + if (existing) { + console.log(`Found existing webhook command: ${existing.id}`); + return existing; + } + + console.log('Creating new webhook command...'); + return await createWebhookCommand(jwt, config); +} + +async function main() { + console.log('Starting GitHub webhook registration...'); + console.log(`Realm Server: ${realmServerURL}`); + console.log(`Public URL: ${publicURL || realmServerURL}`); + console.log(`Command URL: ${commandURL}`); + console.log(`Filter Event Type: ${filterEventType}`); + console.log(''); + + // Step 1: Authenticate + console.log('Authenticating...'); + const { jwt, userId } = await registerRealmUser(); + console.log(`Authenticated as: ${userId}`); + console.log(''); + + // Step 2: Ensure webhook exists (idempotent) + console.log('Setting up incoming webhook...'); + const webhook = await ensureIncomingWebhook(jwt, webhookConfig); + + // Use public URL for webhook endpoint if provided (for ngrok/tunneling) + const webhookBaseURL = publicURL || realmServerURL; + const webhookURL = `${webhookBaseURL}/_webhooks/${webhook.attributes.webhookPath}`; + + console.log('Webhook Details:'); + console.log(` ID: ${webhook.id}`); + console.log(` Path: ${webhook.attributes.webhookPath}`); + console.log(` Signing Secret: ${webhook.attributes.signingSecret}`); + console.log(` Local URL: ${realmServerURL}/_webhooks/${webhook.attributes.webhookPath}`); + if (publicURL) { + console.log(` Public URL: ${webhookURL}`); + } + console.log(''); + + // Step 3: Ensure webhook command exists (idempotent) + console.log('Setting up webhook command...'); + const commandConfig = { + incomingWebhookId: webhook.id, + command: commandURL, + filter: { + eventType: filterEventType, + }, + }; + + const command = await ensureWebhookCommand(jwt, commandConfig); + console.log(`Webhook command registered: ${command.id}`); + console.log(''); + + // Step 4: Output GitHub configuration instructions + console.log('='.repeat(70)); + console.log('GitHub Webhook Configuration:'); + console.log('='.repeat(70)); + console.log(''); + + if (publicURL) { + console.log('✓ Using public URL for local development testing'); + console.log(''); + } + + console.log('Add this webhook to your GitHub repository:'); + console.log(` URL: ${webhookURL}`); + console.log(` Secret: ${webhook.attributes.signingSecret}`); + console.log(` Content type: application/json`); + console.log(` Events: Pull requests, Pull request reviews`); + console.log(''); + console.log('Or use the GitHub CLI:'); + console.log(''); + console.log(` gh webhook forward \\`); + console.log(` --repo=OWNER/REPO \\`); + console.log(` --url="${webhookURL}" \\`); + console.log(` --secret="${webhook.attributes.signingSecret}" \\`); + console.log(` --events=pull_request,pull_request_review`); + console.log(''); + + if (publicURL) { + console.log('Local Testing Setup:'); + console.log(` 1. Start ngrok: ngrok http 4201`); + console.log(` 2. Use ngrok URL: ${publicURL}`); + console.log(` 3. GitHub webhooks will tunnel to localhost:4201`); + console.log(''); + } + + console.log('='.repeat(70)); +} + +if (require.main === module) { + main() + .then(() => { + console.log('✓ GitHub webhook registration complete'); + process.exit(0); + }) + .catch((error) => { + console.error('✗ GitHub webhook registration failed:', error.message); + process.exit(1); + }); +} diff --git a/packages/realm-server/handlers/handle-webhook-receiver.ts b/packages/realm-server/handlers/handle-webhook-receiver.ts index 6eb972b482..193cfb5589 100644 --- a/packages/realm-server/handlers/handle-webhook-receiver.ts +++ b/packages/realm-server/handlers/handle-webhook-receiver.ts @@ -70,13 +70,81 @@ export default function handleWebhookReceiverRequest({ return; } - // Signature verified. Command execution will be added in a future ticket. + let webhookId = webhook.id as string; + let commandRows; + try { + commandRows = await query(dbAdapter, [ + `SELECT id, incoming_webhook_id, command, command_filter`, + `FROM webhook_commands WHERE incoming_webhook_id = `, + param(webhookId), + ]); + } catch (_error) { + await sendResponseForSystemError( + ctxt, + 'failed to lookup webhook commands', + ); + return; + } + + // Parse the webhook payload to extract event information for filtering + let payload: Record = {}; + try { + payload = JSON.parse(rawBody); + } catch (_error) { + console.warn('Failed to parse webhook payload for filtering'); + } + + let eventType = ctxt.req.headers['x-github-event'] as string | undefined; + + let executedCommands = 0; + for (let commandRow of commandRows) { + let commandFilter = commandRow.command_filter as Record< + string, + any + > | null; + + // Apply filter if specified + if (commandFilter) { + // Check if event type matches filter + if (commandFilter.eventType && commandFilter.eventType !== eventType) { + continue; + } + + // Check if PR number matches filter (for pull_request events) + if ( + commandFilter.prNumber && + payload.pull_request?.number !== commandFilter.prNumber + ) { + continue; + } + + // Additional filter checks can be added here as needed + } + + // TODO: Load and execute the command GTS module + // Command is a URL pointing to a GTS module (e.g., http://realm/commands/process-webhook) + // Future implementation will: + // 1. Load the GTS module from the command URL + // 2. Execute the exported command function with the webhook context + // 3. Track successful executions in executedCommands + let commandURL = commandRow.command as string; + console.log( + `Webhook command registered but not yet executed: ${commandURL}`, + ); + } + await setContextResponse( ctxt, - new Response(JSON.stringify({ status: 'received' }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }), + new Response( + JSON.stringify({ + status: 'received', + commandsExecuted: executedCommands, + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + }, + ), ); }; } diff --git a/packages/realm-server/tests/server-endpoints/webhook-receiver-test.ts b/packages/realm-server/tests/server-endpoints/webhook-receiver-test.ts index 5bb7a9b648..1f0b4919c2 100644 --- a/packages/realm-server/tests/server-endpoints/webhook-receiver-test.ts +++ b/packages/realm-server/tests/server-endpoints/webhook-receiver-test.ts @@ -117,8 +117,8 @@ module(`server-endpoints/${basename(__filename)}`, function () { assert.strictEqual(response.status, 200, 'HTTP 200 status'); assert.deepEqual( response.body, - { status: 'received' }, - 'response indicates receipt', + { status: 'received', commandsExecuted: 0 }, + 'response indicates receipt with no commands executed', ); }); @@ -276,5 +276,384 @@ module(`server-endpoints/${basename(__filename)}`, function () { 'webhook receiver does not require JWT', ); }); + + test('executes webhook command when signature is valid', async function (assert) { + let matrixUserId = '@user:localhost'; + await insertUser( + context.dbAdapter, + matrixUserId, + 'cus_123', + 'user@example.com', + ); + + // Create webhook + let createWebhookResponse = await context.request2 + .post('/_incoming-webhooks') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set( + 'Authorization', + `Bearer ${createRealmServerJWT( + { user: matrixUserId, sessionRoom: 'session-room-test' }, + realmSecretSeed, + )}`, + ) + .send({ + data: { + type: 'incoming-webhook', + attributes: { + verificationType: 'HMAC_SHA256_HEADER', + verificationConfig: { + header: 'X-Hub-Signature-256', + encoding: 'hex', + }, + }, + }, + }); + + let webhookId = createWebhookResponse.body.data.id; + let webhookPath = createWebhookResponse.body.data.attributes.webhookPath; + let signingSecret = + createWebhookResponse.body.data.attributes.signingSecret; + + // Register webhook command + await context.request2 + .post('/_webhook-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set( + 'Authorization', + `Bearer ${createRealmServerJWT( + { user: matrixUserId, sessionRoom: 'session-room-test' }, + realmSecretSeed, + )}`, + ) + .send({ + data: { + type: 'webhook-command', + attributes: { + incomingWebhookId: webhookId, + command: `http://test-realm/commands/process-github-webhook`, + filter: null, + }, + }, + }); + + // Send webhook with valid signature + let payload = JSON.stringify({ + action: 'opened', + pull_request: { number: 123 }, + }); + let signature = + 'sha256=' + + createHmac('sha256', signingSecret) + .update(payload, 'utf8') + .digest('hex'); + + let response = await context.request2 + .post(`/_webhooks/${webhookPath}`) + .set('Content-Type', 'application/json') + .set('X-Hub-Signature-256', signature) + .set('X-GitHub-Event', 'pull_request') + .send(payload); + + assert.strictEqual(response.status, 200, 'HTTP 200 status'); + assert.strictEqual( + response.body.status, + 'received', + 'webhook was received', + ); + assert.strictEqual( + response.body.commandsExecuted, + 0, + 'command execution not yet implemented (pending headless chrome module loading)', + ); + }); + + test('filters commands by event type', async function (assert) { + let matrixUserId = '@user:localhost'; + await insertUser( + context.dbAdapter, + matrixUserId, + 'cus_123', + 'user@example.com', + ); + + // Create webhook + let createWebhookResponse = await context.request2 + .post('/_incoming-webhooks') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set( + 'Authorization', + `Bearer ${createRealmServerJWT( + { user: matrixUserId, sessionRoom: 'session-room-test' }, + realmSecretSeed, + )}`, + ) + .send({ + data: { + type: 'incoming-webhook', + attributes: { + verificationType: 'HMAC_SHA256_HEADER', + verificationConfig: { + header: 'X-Hub-Signature-256', + encoding: 'hex', + }, + }, + }, + }); + + let webhookId = createWebhookResponse.body.data.id; + let webhookPath = createWebhookResponse.body.data.attributes.webhookPath; + let signingSecret = + createWebhookResponse.body.data.attributes.signingSecret; + + // Register command filtered to 'push' events only + await context.request2 + .post('/_webhook-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set( + 'Authorization', + `Bearer ${createRealmServerJWT( + { user: matrixUserId, sessionRoom: 'session-room-test' }, + realmSecretSeed, + )}`, + ) + .send({ + data: { + type: 'webhook-command', + attributes: { + incomingWebhookId: webhookId, + command: `http://test-realm/commands/process-github-webhook`, + filter: { eventType: 'push' }, + }, + }, + }); + + // Send 'pull_request' event (should NOT execute command) + let payload = JSON.stringify({ + action: 'opened', + pull_request: { number: 123 }, + }); + let signature = + 'sha256=' + + createHmac('sha256', signingSecret) + .update(payload, 'utf8') + .digest('hex'); + + let response = await context.request2 + .post(`/_webhooks/${webhookPath}`) + .set('Content-Type', 'application/json') + .set('X-Hub-Signature-256', signature) + .set('X-GitHub-Event', 'pull_request') + .send(payload); + + assert.strictEqual(response.status, 200, 'HTTP 200 status'); + assert.strictEqual( + response.body.commandsExecuted, + 0, + 'command was filtered out by event type', + ); + }); + + test('filters commands by PR number', async function (assert) { + let matrixUserId = '@user:localhost'; + await insertUser( + context.dbAdapter, + matrixUserId, + 'cus_123', + 'user@example.com', + ); + + // Create webhook + let createWebhookResponse = await context.request2 + .post('/_incoming-webhooks') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set( + 'Authorization', + `Bearer ${createRealmServerJWT( + { user: matrixUserId, sessionRoom: 'session-room-test' }, + realmSecretSeed, + )}`, + ) + .send({ + data: { + type: 'incoming-webhook', + attributes: { + verificationType: 'HMAC_SHA256_HEADER', + verificationConfig: { + header: 'X-Hub-Signature-256', + encoding: 'hex', + }, + }, + }, + }); + + let webhookId = createWebhookResponse.body.data.id; + let webhookPath = createWebhookResponse.body.data.attributes.webhookPath; + let signingSecret = + createWebhookResponse.body.data.attributes.signingSecret; + + // Register command filtered to PR #456 only + await context.request2 + .post('/_webhook-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set( + 'Authorization', + `Bearer ${createRealmServerJWT( + { user: matrixUserId, sessionRoom: 'session-room-test' }, + realmSecretSeed, + )}`, + ) + .send({ + data: { + type: 'webhook-command', + attributes: { + incomingWebhookId: webhookId, + command: `http://test-realm/commands/process-github-webhook`, + filter: { eventType: 'pull_request', prNumber: 456 }, + }, + }, + }); + + // Send PR #123 event (should NOT execute command) + let payload = JSON.stringify({ + action: 'opened', + pull_request: { number: 123 }, + }); + let signature = + 'sha256=' + + createHmac('sha256', signingSecret) + .update(payload, 'utf8') + .digest('hex'); + + let response = await context.request2 + .post(`/_webhooks/${webhookPath}`) + .set('Content-Type', 'application/json') + .set('X-Hub-Signature-256', signature) + .set('X-GitHub-Event', 'pull_request') + .send(payload); + + assert.strictEqual(response.status, 200, 'HTTP 200 status'); + assert.strictEqual( + response.body.commandsExecuted, + 0, + 'command was filtered out by PR number', + ); + }); + + test('executes multiple matching commands', async function (assert) { + let matrixUserId = '@user:localhost'; + await insertUser( + context.dbAdapter, + matrixUserId, + 'cus_123', + 'user@example.com', + ); + + // Create webhook + let createWebhookResponse = await context.request2 + .post('/_incoming-webhooks') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set( + 'Authorization', + `Bearer ${createRealmServerJWT( + { user: matrixUserId, sessionRoom: 'session-room-test' }, + realmSecretSeed, + )}`, + ) + .send({ + data: { + type: 'incoming-webhook', + attributes: { + verificationType: 'HMAC_SHA256_HEADER', + verificationConfig: { + header: 'X-Hub-Signature-256', + encoding: 'hex', + }, + }, + }, + }); + + let webhookId = createWebhookResponse.body.data.id; + let webhookPath = createWebhookResponse.body.data.attributes.webhookPath; + let signingSecret = + createWebhookResponse.body.data.attributes.signingSecret; + + // Register two commands for same event + await context.request2 + .post('/_webhook-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set( + 'Authorization', + `Bearer ${createRealmServerJWT( + { user: matrixUserId, sessionRoom: 'session-room-test' }, + realmSecretSeed, + )}`, + ) + .send({ + data: { + type: 'webhook-command', + attributes: { + incomingWebhookId: webhookId, + command: `http://test-realm/commands/process-github-webhook`, + filter: { eventType: 'pull_request' }, + }, + }, + }); + + await context.request2 + .post('/_webhook-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set( + 'Authorization', + `Bearer ${createRealmServerJWT( + { user: matrixUserId, sessionRoom: 'session-room-test' }, + realmSecretSeed, + )}`, + ) + .send({ + data: { + type: 'webhook-command', + attributes: { + incomingWebhookId: webhookId, + command: `http://test-realm/commands/process-github-webhook`, + filter: null, // No filter - always executes + }, + }, + }); + + // Send pull_request event + let payload = JSON.stringify({ + action: 'opened', + pull_request: { number: 123 }, + }); + let signature = + 'sha256=' + + createHmac('sha256', signingSecret) + .update(payload, 'utf8') + .digest('hex'); + + let response = await context.request2 + .post(`/_webhooks/${webhookPath}`) + .set('Content-Type', 'application/json') + .set('X-Hub-Signature-256', signature) + .set('X-GitHub-Event', 'pull_request') + .send(payload); + + assert.strictEqual(response.status, 200, 'HTTP 200 status'); + assert.strictEqual( + response.body.commandsExecuted, + 0, + 'command execution not yet implemented (pending headless chrome module loading)', + ); + }); }); }); diff --git a/packages/runtime-common/pr-manifest.ts b/packages/runtime-common/pr-manifest.ts index 69856b817a..788f924a84 100644 --- a/packages/runtime-common/pr-manifest.ts +++ b/packages/runtime-common/pr-manifest.ts @@ -25,4 +25,9 @@ export interface PrManifest { lastCheckedAt?: string; error?: string; }; + webhook?: { + id: string; // UUID from incoming_webhooks table + path: string; // webhook_path (e.g., "whk_abc123...") + signingSecret: string; // For GitHub webhook configuration + }; }