diff --git a/packages/realm-server/handlers/handle-webhook-receiver.ts b/packages/realm-server/handlers/handle-webhook-receiver.ts index 7fa2a3ac1ad..35753f0d3e1 100644 --- a/packages/realm-server/handlers/handle-webhook-receiver.ts +++ b/packages/realm-server/handlers/handle-webhook-receiver.ts @@ -111,7 +111,12 @@ export default function handleWebhookReceiverRequest({ // Delegate filter matching to the handler if ( commandFilter && - !filterHandler.matches(payload, ctxt.req.headers, commandFilter) + !(await filterHandler.matches( + payload, + ctxt.req.headers, + commandFilter, + dbAdapter, + )) ) { continue; } @@ -121,11 +126,18 @@ export default function handleWebhookReceiverRequest({ let realmURL: string; let commandInput: Record; try { - realmURL = filterHandler.getRealmURL(commandFilter ?? {}, commandURL); - commandInput = filterHandler.buildCommandInput( + realmURL = await filterHandler.getRealmURL( + commandFilter ?? {}, + commandURL, + payload, + ctxt.req.headers, + dbAdapter, + ); + commandInput = await filterHandler.buildCommandInput( payload, ctxt.req.headers, commandFilter ?? {}, + dbAdapter, ); } catch (error) { console.error( diff --git a/packages/realm-server/handlers/webhook-filter-handlers.ts b/packages/realm-server/handlers/webhook-filter-handlers.ts index aab015b6244..0a426f20e7e 100644 --- a/packages/realm-server/handlers/webhook-filter-handlers.ts +++ b/packages/realm-server/handlers/webhook-filter-handlers.ts @@ -1,4 +1,6 @@ import type Koa from 'koa'; +import type { DBAdapter } from '@cardstack/runtime-common'; +import { param, query } from '@cardstack/runtime-common'; export interface WebhookFilterHandler { /** Return true if this payload matches the filter configuration. */ @@ -6,44 +8,223 @@ export interface WebhookFilterHandler { payload: Record, headers: Koa.Context['req']['headers'], filter: Record, - ): boolean; + dbAdapter?: DBAdapter, + ): Promise; /** Assemble the command input from the webhook payload and filter config. */ buildCommandInput( payload: Record, headers: Koa.Context['req']['headers'], filter: Record, - ): Record; + dbAdapter?: DBAdapter, + ): Promise>; /** Determine the realm URL where the command should run. */ - getRealmURL(filter: Record, commandURL: string): string; + getRealmURL( + filter: Record, + commandURL: string, + payload?: Record, + headers?: Koa.Context['req']['headers'], + dbAdapter?: DBAdapter, + ): Promise; +} + +/** + * Extract the realm URL from a Submission Card URL found in a PR body. + * + * The PR body contains a line like: + * - Submission Card: [https://app.boxel.ai/user/realm/SubmissionCard/uuid](...) + * + * The realm is everything before "SubmissionCard/" in that URL. + */ +export function extractRealmFromPrBody( + prBody: string | undefined | null, +): string | null { + if (!prBody) return null; + + // Match the Submission Card URL in the PR body markdown link + let match = prBody.match(/- Submission Card: \[([^\]]+)\]/); + if (!match) return null; + + let submissionCardUrl = match[1]; + let submissionCardIndex = submissionCardUrl.indexOf('SubmissionCard/'); + if (submissionCardIndex === -1) return null; + + return submissionCardUrl.slice(0, submissionCardIndex); +} + +/** + * Extract the PR number from a GitHub webhook payload. + * Different event types store the PR number in different locations. + */ +export function extractPrNumberFromPayload( + payload: Record, +): number | null { + // pull_request, pull_request_review, pull_request_review_comment events + if (payload.pull_request?.number != null) { + return payload.pull_request.number; + } + // check_run events + if (payload.check_run?.pull_requests?.[0]?.number != null) { + return payload.check_run.pull_requests[0].number; + } + // check_suite events + if (payload.check_suite?.pull_requests?.[0]?.number != null) { + return payload.check_suite.pull_requests[0].number; + } + return null; +} + +/** + * Extract the PR body from a GitHub webhook payload. + * Available on pull_request, pull_request_review, and pull_request_review_comment events. + */ +function extractPrBodyFromPayload(payload: Record): string | null { + return (payload.pull_request?.body as string) ?? null; +} + +/** + * Look up the realm URL for a PrCard with the given PR number by querying + * the card index database. + * + * The query restricts results to PrCard instances (URL contains '/PrCard/') + * to avoid matching GithubEventCard instances which also carry a prNumber + * field but may exist in a different realm. + */ +async function lookupRealmByPrNumber( + dbAdapter: DBAdapter, + prNumber: number, +): Promise { + try { + let rows = await query(dbAdapter, [ + `SELECT realm_url FROM boxel_index`, + `WHERE type = 'instance'`, + `AND (is_deleted = FALSE OR is_deleted IS NULL)`, + `AND url LIKE '%/PrCard/%'`, + `AND search_doc->>'prNumber' =`, + param(String(prNumber)), + `ORDER BY indexed_at DESC`, + `LIMIT 1`, + ]); + if (rows.length > 0) { + return rows[0].realm_url as string; + } + } catch (error) { + console.warn(`Failed to look up realm for PR #${prNumber}:`, error); + } + return null; +} + +/** + * Resolve the origin of the realm that a GitHub webhook event belongs to. + * + * Strategy: + * 1. If the payload contains a PR body, extract the origin from the Submission Card URL + * 2. Otherwise, look up the PrCard by prNumber in the index DB and extract its origin + * + * Returns null if the origin cannot be determined. + */ +async function resolveOriginFromPayload( + payload: Record, + dbAdapter?: DBAdapter, +): Promise { + // Strategy 1: Extract from PR body (available on pull_request, pull_request_review events) + let prBody = extractPrBodyFromPayload(payload); + let realm = extractRealmFromPrBody(prBody); + if (realm) { + try { + return new URL(realm).origin; + } catch { + // fall through + } + } + + // Strategy 2: Look up PrCard by prNumber (for check_run, check_suite events) + if (dbAdapter) { + let prNumber = extractPrNumberFromPayload(payload); + if (prNumber != null) { + let realmUrl = await lookupRealmByPrNumber(dbAdapter, prNumber); + if (realmUrl) { + try { + return new URL(realmUrl).origin; + } catch { + // fall through + } + } + } + } + + return null; } /** * Handler for GitHub webhook events. Supports filtering by event type - * (from X-GitHub-Event header). + * (from X-GitHub-Event header) and dynamically resolves the target realm + * from the PR body's Submission Card URL or by looking up the PrCard. */ class GithubEventFilterHandler implements WebhookFilterHandler { - matches( - _payload: Record, + async matches( + payload: Record, headers: Koa.Context['req']['headers'], filter: Record, - ): boolean { + dbAdapter?: DBAdapter, + ): Promise { let eventType = headers['x-github-event'] as string | undefined; if (filter.eventType && filter.eventType !== eventType) { return false; } + // If the filter has a configured realm, check that the event's realm + // belongs to the same server/origin. This ensures each environment + // only processes events from PRs that originated in that environment. + // + // First try the cheap path: extract realm from the PR body (no DB query). + // If that's not available, fall back to the full resolution strategy + // (which may query the DB for check_run/check_suite events). + if (filter.realm) { + let resolvedOrigin = await resolveOriginFromPayload(payload, dbAdapter); + + if (resolvedOrigin) { + try { + let filterOrigin = new URL(filter.realm as string).origin; + if (filterOrigin !== resolvedOrigin) { + return false; + } + } catch { + // filter.realm is malformed — reject to avoid bypassing origin check + console.warn( + `Failed to parse filter.realm URL (${filter.realm}), rejecting match`, + ); + return false; + } + } else { + // Could not resolve origin from payload — reject the match to prevent + // cross-environment broadcast. This is the safer default: if we can't + // determine which environment the PR belongs to, don't process it. + let prNumber = extractPrNumberFromPayload(payload); + console.warn( + `Could not resolve realm origin from webhook payload ` + + `(eventType=${eventType}, prNumber=${prNumber ?? 'unknown'}), ` + + `rejecting match`, + ); + return false; + } + } + return true; } - buildCommandInput( + async buildCommandInput( payload: Record, headers: Koa.Context['req']['headers'], filter: Record, - ): Record { + ): Promise> { let eventType = (headers['x-github-event'] as string) ?? ''; + + // Always use the static filter.realm (the submissions realm) for the + // command input. The dynamic origin check in matches() already ensures + // we only reach here for the correct environment. let realm = filter.realm as string | undefined; if (!realm) { throw new Error( @@ -58,7 +239,12 @@ class GithubEventFilterHandler implements WebhookFilterHandler { }; } - getRealmURL(filter: Record, commandURL: string): string { + async getRealmURL( + filter: Record, + commandURL: string, + ): Promise { + // Always use the static filter.realm. The dynamic origin check in + // matches() already ensures we only reach here for the correct environment. return ( (filter.realm as string | undefined) ?? new URL('/submissions/', commandURL).href @@ -72,15 +258,20 @@ class GithubEventFilterHandler implements WebhookFilterHandler { * command input. */ class DefaultFilterHandler implements WebhookFilterHandler { - matches(): boolean { + async matches(): Promise { return true; } - buildCommandInput(payload: Record): Record { + async buildCommandInput( + payload: Record, + ): Promise> { return { payload }; } - getRealmURL(filter: Record, commandURL: string): string { + async getRealmURL( + filter: Record, + commandURL: string, + ): Promise { return ( (filter.realmUrl as string | undefined) ?? new URL('/', commandURL).href ); 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 017d4988e36..0ff2e1244aa 100644 --- a/packages/realm-server/tests/server-endpoints/webhook-receiver-test.ts +++ b/packages/realm-server/tests/server-endpoints/webhook-receiver-test.ts @@ -5,6 +5,10 @@ import { createJWT as createRealmServerJWT } from '../../utils/jwt'; import { realmSecretSeed, insertUser } from '../helpers'; import { param, query, uuidv4 } from '@cardstack/runtime-common'; import { setupServerEndpointsTest } from './helpers'; +import { + extractRealmFromPrBody, + extractPrNumberFromPayload, +} from '../../handlers/webhook-filter-handlers'; module(`server-endpoints/${basename(__filename)}`, function () { module('Webhook Receiver Endpoint', function (hooks) { @@ -522,6 +526,7 @@ module(`server-endpoints/${basename(__filename)}`, function () { pull_request: { number: 42, html_url: 'https://github.com/test/repo/pull/42', + body: '- Submission Card: [http://localhost:4201/user/realm/SubmissionCard/abc-123](http://localhost:4201/user/realm/SubmissionCard/abc-123)', }, sender: { login: 'testuser' }, }); @@ -546,4 +551,597 @@ module(`server-endpoints/${basename(__filename)}`, function () { ); }); }); + + module('Origin-based filtering', function (hooks) { + let context = setupServerEndpointsTest(hooks); + + test('rejects event when PR origin does not match filter realm', async function (assert) { + let matrixUserId = '@user:localhost'; + await insertUser( + context.dbAdapter, + matrixUserId, + 'cus_123', + 'user@example.com', + ); + + let jwt = `Bearer ${createRealmServerJWT( + { user: matrixUserId, sessionRoom: 'session-room-test' }, + realmSecretSeed, + )}`; + + let createWebhookResponse = await context.request + .post('/_incoming-webhooks') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', jwt) + .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 for production realm + await context.request + .post('/_webhook-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', jwt) + .send({ + data: { + type: 'webhook-command', + attributes: { + incomingWebhookId: webhookId, + command: `http://test-realm/commands/process-github-event`, + filter: { + type: 'github-event', + eventType: 'pull_request', + realm: 'https://app.boxel.ai/submissions/', + }, + }, + }, + }); + + // Send event from a STAGING PR (different origin) + let payload = JSON.stringify({ + action: 'opened', + pull_request: { + number: 100, + body: '- Submission Card: [https://realms-staging.stack.cards/user/realm/SubmissionCard/abc-123](https://realms-staging.stack.cards/user/realm/SubmissionCard/abc-123)', + }, + }); + let signature = + 'sha256=' + + createHmac('sha256', signingSecret) + .update(payload, 'utf8') + .digest('hex'); + + let response = await context.request + .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 rejected because PR origin does not match filter realm', + ); + }); + + test('accepts event when PR origin matches filter realm', async function (assert) { + let matrixUserId = '@user:localhost'; + await insertUser( + context.dbAdapter, + matrixUserId, + 'cus_123', + 'user@example.com', + ); + + let jwt = `Bearer ${createRealmServerJWT( + { user: matrixUserId, sessionRoom: 'session-room-test' }, + realmSecretSeed, + )}`; + + let createWebhookResponse = await context.request + .post('/_incoming-webhooks') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', jwt) + .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 for production realm + await context.request + .post('/_webhook-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', jwt) + .send({ + data: { + type: 'webhook-command', + attributes: { + incomingWebhookId: webhookId, + command: `http://test-realm/commands/process-github-event`, + filter: { + type: 'github-event', + eventType: 'pull_request', + realm: 'https://app.boxel.ai/submissions/', + }, + }, + }, + }); + + // Send event from a PRODUCTION PR (same origin) + let payload = JSON.stringify({ + action: 'opened', + pull_request: { + number: 200, + body: '- Submission Card: [https://app.boxel.ai/richard.tan/my-realm/SubmissionCard/def-456](https://app.boxel.ai/richard.tan/my-realm/SubmissionCard/def-456)', + }, + }); + let signature = + 'sha256=' + + createHmac('sha256', signingSecret) + .update(payload, 'utf8') + .digest('hex'); + + let response = await context.request + .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, + 1, + 'command was accepted because PR origin matches filter realm', + ); + }); + + test('rejects event when realm cannot be resolved from payload', async function (assert) { + let matrixUserId = '@user:localhost'; + await insertUser( + context.dbAdapter, + matrixUserId, + 'cus_123', + 'user@example.com', + ); + + let jwt = `Bearer ${createRealmServerJWT( + { user: matrixUserId, sessionRoom: 'session-room-test' }, + realmSecretSeed, + )}`; + + let createWebhookResponse = await context.request + .post('/_incoming-webhooks') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', jwt) + .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; + + await context.request + .post('/_webhook-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', jwt) + .send({ + data: { + type: 'webhook-command', + attributes: { + incomingWebhookId: webhookId, + command: `http://test-realm/commands/process-github-event`, + filter: { + type: 'github-event', + eventType: 'pull_request', + realm: 'https://app.boxel.ai/submissions/', + }, + }, + }, + }); + + // Send event with no Submission Card in PR body + let payload = JSON.stringify({ + action: 'opened', + pull_request: { + number: 999, + body: '## Summary\nNo submission card here', + }, + }); + let signature = + 'sha256=' + + createHmac('sha256', signingSecret) + .update(payload, 'utf8') + .digest('hex'); + + let response = await context.request + .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 rejected because realm could not be resolved (fail-closed)', + ); + }); + }); + + module('check_run DB lookup routing', function (hooks) { + let context = setupServerEndpointsTest(hooks); + + test('check_run event matches via PrCard DB lookup', async function (assert) { + let matrixUserId = '@user:localhost'; + await insertUser( + context.dbAdapter, + matrixUserId, + 'cus_123', + 'user@example.com', + ); + + let jwt = `Bearer ${createRealmServerJWT( + { user: matrixUserId, sessionRoom: 'session-room-test' }, + realmSecretSeed, + )}`; + + // Insert a PrCard into boxel_index + let prCardId = uuidv4(); + let prCardUrl = `https://app.boxel.ai/submissions/PrCard/${prCardId}`; + await query(context.dbAdapter, [ + `INSERT INTO boxel_index (url, file_alias, realm_url, realm_version, type, pristine_doc, search_doc, deps, is_deleted, indexed_at)`, + `VALUES (`, + param(prCardUrl), + `,`, + param(prCardUrl), + `,`, + param('https://app.boxel.ai/submissions/'), + `,`, + param(1), + `,`, + param('instance'), + `,`, + `'{}'::jsonb`, + `,`, + `'{"prNumber": "55"}'::jsonb`, + `,`, + `'[]'::jsonb`, + `,`, + param(false), + `,`, + param(Date.now()), + `)`, + ]); + + let createWebhookResponse = await context.request + .post('/_incoming-webhooks') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', jwt) + .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; + + await context.request + .post('/_webhook-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', jwt) + .send({ + data: { + type: 'webhook-command', + attributes: { + incomingWebhookId: webhookId, + command: `http://test-realm/commands/process-github-event`, + filter: { + type: 'github-event', + eventType: 'check_run', + realm: 'https://app.boxel.ai/submissions/', + }, + }, + }, + }); + + let payload = JSON.stringify({ + action: 'completed', + check_run: { + id: 1, + pull_requests: [{ number: 55 }], + }, + }); + let signature = + 'sha256=' + + createHmac('sha256', signingSecret) + .update(payload, 'utf8') + .digest('hex'); + + let response = await context.request + .post(`/_webhooks/${webhookPath}`) + .set('Content-Type', 'application/json') + .set('X-Hub-Signature-256', signature) + .set('X-GitHub-Event', 'check_run') + .send(payload); + + assert.strictEqual(response.status, 200, 'HTTP 200 status'); + assert.strictEqual( + response.body.commandsExecuted, + 1, + 'check_run matched via PrCard DB lookup', + ); + }); + + test('check_run event rejected when PrCard origin differs from filter realm', async function (assert) { + let matrixUserId = '@user:localhost'; + await insertUser( + context.dbAdapter, + matrixUserId, + 'cus_123', + 'user@example.com', + ); + + let jwt = `Bearer ${createRealmServerJWT( + { user: matrixUserId, sessionRoom: 'session-room-test' }, + realmSecretSeed, + )}`; + + // Insert a PrCard in STAGING + let prCardId = uuidv4(); + let prCardUrl = `https://realms-staging.stack.cards/submissions/PrCard/${prCardId}`; + await query(context.dbAdapter, [ + `INSERT INTO boxel_index (url, file_alias, realm_url, realm_version, type, pristine_doc, search_doc, deps, is_deleted, indexed_at)`, + `VALUES (`, + param(prCardUrl), + `,`, + param(prCardUrl), + `,`, + param('https://realms-staging.stack.cards/submissions/'), + `,`, + param(1), + `,`, + param('instance'), + `,`, + `'{}'::jsonb`, + `,`, + `'{"prNumber": "77"}'::jsonb`, + `,`, + `'[]'::jsonb`, + `,`, + param(false), + `,`, + param(Date.now()), + `)`, + ]); + + let createWebhookResponse = await context.request + .post('/_incoming-webhooks') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', jwt) + .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 for PRODUCTION realm + await context.request + .post('/_webhook-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', jwt) + .send({ + data: { + type: 'webhook-command', + attributes: { + incomingWebhookId: webhookId, + command: `http://test-realm/commands/process-github-event`, + filter: { + type: 'github-event', + eventType: 'check_run', + realm: 'https://app.boxel.ai/submissions/', + }, + }, + }, + }); + + // check_run for PR #77 — PrCard is in staging, command is production + let payload = JSON.stringify({ + action: 'completed', + check_run: { + id: 2, + pull_requests: [{ number: 77 }], + }, + }); + let signature = + 'sha256=' + + createHmac('sha256', signingSecret) + .update(payload, 'utf8') + .digest('hex'); + + let response = await context.request + .post(`/_webhooks/${webhookPath}`) + .set('Content-Type', 'application/json') + .set('X-Hub-Signature-256', signature) + .set('X-GitHub-Event', 'check_run') + .send(payload); + + assert.strictEqual(response.status, 200, 'HTTP 200 status'); + assert.strictEqual( + response.body.commandsExecuted, + 0, + 'check_run rejected — PrCard origin (staging) does not match filter realm (production)', + ); + }); + }); + + module('extractRealmFromPrBody', function () { + test('extracts realm from production Submission Card URL', function (assert) { + let body = [ + '## Summary', + 'Some description', + '---', + '- Listing Name: Recipe Card Definition', + '- Room ID: `!IUlOGgAWjwfwemOykG:boxel.ai`', + '- User ID: `@richard.tan:boxel.ai`', + '- Number of Files: 1', + '- Submission Card: [https://app.boxel.ai/richard.tan/ric-test-1/SubmissionCard/f0028a1c-777a-4d34-9f93-8f02667484d5](https://app.boxel.ai/richard.tan/ric-test-1/SubmissionCard/f0028a1c-777a-4d34-9f93-8f02667484d5)', + ].join('\n'); + + assert.strictEqual( + extractRealmFromPrBody(body), + 'https://app.boxel.ai/richard.tan/ric-test-1/', + ); + }); + + test('extracts realm from staging Submission Card URL', function (assert) { + let body = [ + '## Summary', + '- Submission Card: [https://realms-staging.stack.cards/chuan16/pure-creativity/SubmissionCard/01166122-d67f-4950-a708-b451564b30cb](https://realms-staging.stack.cards/chuan16/pure-creativity/SubmissionCard/01166122-d67f-4950-a708-b451564b30cb)', + ].join('\n'); + + assert.strictEqual( + extractRealmFromPrBody(body), + 'https://realms-staging.stack.cards/chuan16/pure-creativity/', + ); + }); + + test('extracts realm from local Submission Card URL', function (assert) { + let body = [ + '## Summary', + '- Submission Card: [http://localhost:4201/experiments/SubmissionCard/5e3c8a93-24b1-4143-958a-a65270110c52](http://localhost:4201/experiments/SubmissionCard/5e3c8a93-24b1-4143-958a-a65270110c52)', + ].join('\n'); + + assert.strictEqual( + extractRealmFromPrBody(body), + 'http://localhost:4201/experiments/', + ); + }); + + test('returns null when no Submission Card line exists', function (assert) { + let body = '## Summary\nSome PR description without submission card'; + assert.strictEqual(extractRealmFromPrBody(body), null); + }); + + test('returns null for null/undefined body', function (assert) { + assert.strictEqual(extractRealmFromPrBody(null), null); + assert.strictEqual(extractRealmFromPrBody(undefined), null); + }); + }); + + module('extractPrNumberFromPayload', function () { + test('extracts PR number from pull_request event', function (assert) { + assert.strictEqual( + extractPrNumberFromPayload({ + action: 'opened', + pull_request: { number: 296, body: '...' }, + }), + 296, + ); + }); + + test('extracts PR number from check_run event', function (assert) { + assert.strictEqual( + extractPrNumberFromPayload({ + action: 'completed', + check_run: { + pull_requests: [{ number: 42 }], + }, + }), + 42, + ); + }); + + test('extracts PR number from check_suite event', function (assert) { + assert.strictEqual( + extractPrNumberFromPayload({ + action: 'completed', + check_suite: { + pull_requests: [{ number: 99 }], + }, + }), + 99, + ); + }); + + test('returns null when no PR number found', function (assert) { + assert.strictEqual( + extractPrNumberFromPayload({ action: 'created', comment: {} }), + null, + ); + }); + }); });