Cs 10217 trigger command on webhook event#4074
Cs 10217 trigger command on webhook event#4074richardhjtan wants to merge 11 commits intocs-9945-submission-bot-open-a-github-prfrom
Conversation
…-10217-trigger-command-on-webhook-event
…-10217-trigger-command-on-webhook-event
…-10217-trigger-command-on-webhook-event
when running github event command experience timeout and file list is not updating in submission realm
| realm: input.realm, | ||
| localDir: input.localDir, | ||
| }); | ||
| if (input.doNotWaitForPersist) { |
There was a problem hiding this comment.
Added doNotWaitForPersist option for save card command, this avoid timeout issue when running the command in headless chrome
Preview deployments |
| " AND username = '*'", | ||
| ' AND read = true', | ||
| ' )', | ||
| ')', |
There was a problem hiding this comment.
This is to fix the issue for the user with read-only access (*) to the public submission realm. Before that, users have to refresh the browser to get the latest file list after the GitHub event card is created from the command runner.
The original query only matched rows where a user had an explicit permission entry. It never matched the wildcard username = * row, so users whose only access to a realm comes from a world-readable permission (* read = true) were silently excluded.
The submissions realm only has explicit entries for the realm owner and the submission bot — everyone else accesses it via *. So the realm server never sent Matrix events to their session rooms after a write, and the UI never received the incremental index notification needed to refresh the card list.
There was a problem hiding this comment.
Hello @lukemelia @IanCal, does this fix sound reasonable to you?
Host Test Results 1 files ±0 1 suites ±0 1h 33m 46s ⏱️ - 2m 43s For more details on these errors, see this check. Results for commit d08e7f7. ± Comparison against base commit 36bd710. ♻️ This comment has been updated with latest results. |
…-10217-trigger-command-on-webhook-event
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d08e7f78c8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| await this.realmServer.createWebhookCommand({ | ||
| incomingWebhookId: githubWebhook.id, | ||
| command: `${catalogRealmURL}commands/process-github-event/default`, | ||
| filter: { | ||
| submissionRealmUrl: this.realmServer.submissionRealmURL, |
There was a problem hiding this comment.
Prevent duplicate webhook command registrations
Every CreateListingPRRequestCommand run calls registerPRWebhook(), and this code unconditionally POSTs a new webhook command without checking whether the same (incomingWebhookId, command, filter) already exists. Because webhook_commands has no uniqueness guard, repeated PR requests in the same environment will accumulate duplicate registrations, and each incoming GitHub event will enqueue the same command multiple times, causing duplicate event cards and unnecessary queue load.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
I was wondering the same thing. Isn't this done by matrix/scripts/register-github-webhook.ts?
| this.store.add(input.card, { | ||
| realm: input.realm, | ||
| localDir: input.localDir, | ||
| doNotWaitForPersist: true, |
There was a problem hiding this comment.
Await save-card add call in non-blocking persist mode
This branch drops the this.store.add(...) promise entirely when doNotWaitForPersist is set. doNotWaitForPersist already makes persistence fire-and-forget inside StoreService.add, but add still performs async setup/validation and can reject before persistence is queued. Not awaiting here can report command success even when save setup fails and can surface unhandled promise rejections under webhook load.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Pull request overview
This pull request implements webhook command execution functionality to automatically run commands when GitHub webhook events are received. It introduces a GitHub event card definition for storing webhook event data, adds a command to process GitHub events, and includes comprehensive test coverage for the new functionality.
Changes:
- Add webhook command execution with filtering support (event type, PR number)
- Introduce GitHub event card for storing webhook events as data
- Update session room queries to support world-readable realms
- Add webhook management methods to realm server service
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
packages/realm-server/handlers/handle-webhook-receiver.ts |
Implements command execution logic with event filtering and queuing |
packages/runtime-common/db-queries/session-room-queries.ts |
Modifies query to include users with world-readable realm access |
packages/realm-server/tests/server-endpoints/webhook-receiver-test.ts |
Adds comprehensive tests for webhook command execution and filtering |
packages/matrix/scripts/register-github-webhook.ts |
New script for registering GitHub webhooks with event filtering |
packages/host/app/services/realm-server.ts |
Adds webhook management API methods (list, create) |
packages/host/app/commands/save-card.ts |
Adds support for non-blocking card persistence |
packages/host/app/commands/bot-requests/create-listing-pr-request.ts |
Integrates webhook registration into PR creation flow |
packages/catalog-realm/github-event/github-event.gts |
New card definition for storing GitHub webhook events |
packages/catalog-realm/commands/process-github-event.gts |
New command to process GitHub webhook events and create event cards |
packages/base/commands/search-card-result.gts |
Adds queryableValue implementation for JsonField |
packages/base/command.gts |
Adds doNotWaitForPersist field to SaveCardInput |
packages/host/app/commands/create-listing-pr.ts |
Minor comment update |
Comments suppressed due to low confidence (2)
packages/realm-server/handlers/handle-webhook-receiver.ts:166
- Inconsistent logging approach: The code uses
console.warnandconsole.errordirectly, while other handlers in the codebase use theloggerutility from@cardstack/runtime-common. For consistency and better log management (including proper log levels and module identification), consider using the logger utility:const log = logger('webhook-receiver');and thenlog.warn(...)andlog.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
}
let commandURL = commandRow.command as string;
let submissionRealmUrl =
(commandFilter?.submissionRealmUrl as string | undefined) ??
new URL('/submissions/', commandURL).href;
// Run as the realm owner so they have write permissions in the submission realm
let realmOwnerRows = await query(dbAdapter, [
`SELECT username FROM realm_user_permissions WHERE realm_url = `,
param(submissionRealmUrl),
` AND realm_owner = true LIMIT 1`,
]);
let runAs =
(realmOwnerRows[0]?.username as string | undefined) ??
(webhook.username as string);
let commandInput = {
eventType: eventType ?? '',
submissionRealmUrl,
payload,
};
try {
await enqueueRunCommandJob(
{
realmURL: submissionRealmUrl,
realmUsername: runAs,
runAs,
command: commandURL,
commandInput,
},
queue,
dbAdapter,
userInitiatedPriority,
);
executedCommands++;
} catch (error) {
console.error(
`Failed to enqueue webhook command ${commandURL}:`,
error,
);
packages/matrix/scripts/register-github-webhook.ts:20
- Header name case mismatch: The script uses lowercase 'x-hub-signature-256' in the webhook configuration, but the tests use 'X-Hub-Signature-256' with proper capitalization. While the handler correctly normalizes headers to lowercase when reading (line 213), this inconsistency could cause confusion. Consider using the canonical capitalization 'X-Hub-Signature-256' consistently in both places, as this is the standard format used by GitHub webhooks.
header: 'x-hub-signature-256',
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| import type MatrixService from '../services/matrix-service'; | ||
| import type RealmServerService from '../services/realm-server'; | ||
| import type StoreService from '../services/store'; |
There was a problem hiding this comment.
The import paths for services are incorrect. Since this file is in the bot-requests subdirectory, the imports should use '../../services/' instead of '../services/'. The correct paths should be:
import type MatrixService from '../../services/matrix-service';import type RealmServerService from '../../services/realm-server';import type StoreService from '../../services/store';
This pattern is correctly used in send-bot-trigger-event.ts in the same directory.
| import type MatrixService from '../services/matrix-service'; | |
| import type RealmServerService from '../services/realm-server'; | |
| import type StoreService from '../services/store'; | |
| import type MatrixService from '../../services/matrix-service'; | |
| import type RealmServerService from '../../services/realm-server'; | |
| import type StoreService from '../../services/store'; |
| let realmOwnerRows = await query(dbAdapter, [ | ||
| `SELECT username FROM realm_user_permissions WHERE realm_url = `, | ||
| param(submissionRealmUrl), | ||
| ` AND realm_owner = true LIMIT 1`, | ||
| ]); | ||
| let runAs = | ||
| (realmOwnerRows[0]?.username as string | undefined) ?? | ||
| (webhook.username as string); |
There was a problem hiding this comment.
The database query to fetch realm owner is not wrapped in a try-catch block, but it's inside a loop that processes webhook commands. If this query fails for any reason (e.g., database connection issues), it will cause the entire webhook request to fail with an unhandled exception, potentially losing valid webhook events. Consider wrapping this query in a try-catch block and logging the error while continuing to process other commands, or handle the error gracefully by skipping this specific command.
| let realmOwnerRows = await query(dbAdapter, [ | |
| `SELECT username FROM realm_user_permissions WHERE realm_url = `, | |
| param(submissionRealmUrl), | |
| ` AND realm_owner = true LIMIT 1`, | |
| ]); | |
| let runAs = | |
| (realmOwnerRows[0]?.username as string | undefined) ?? | |
| (webhook.username as string); | |
| let runAs = webhook.username as string; | |
| try { | |
| let realmOwnerRows = await query(dbAdapter, [ | |
| `SELECT username FROM realm_user_permissions WHERE realm_url = `, | |
| param(submissionRealmUrl), | |
| ` AND realm_owner = true LIMIT 1`, | |
| ]); | |
| if (realmOwnerRows[0]?.username) { | |
| runAs = realmOwnerRows[0].username as string; | |
| } | |
| } catch (error) { | |
| console.error( | |
| `Failed to fetch realm owner for submission realm ${submissionRealmUrl}:`, | |
| error, | |
| ); | |
| } |
| 'WHERE u.session_room_id IS NOT NULL', | ||
| 'AND (', | ||
| ' EXISTS (', | ||
| ' SELECT 1 FROM realm_user_permissions', | ||
| ' WHERE realm_url =', | ||
| param(realmURL), | ||
| 'AND (rup.read = true OR rup.write = true)', | ||
| 'AND u.session_room_id IS NOT NULL', | ||
| ' AND username = u.matrix_user_id', | ||
| ' AND (read = true OR write = true)', | ||
| ' )', | ||
| ' OR EXISTS (', | ||
| ' SELECT 1 FROM realm_user_permissions', | ||
| ' WHERE realm_url =', | ||
| param(realmURL), | ||
| " AND username = '*'", | ||
| ' AND read = true', | ||
| ' )', | ||
| ')', |
There was a problem hiding this comment.
The refactored query logic appears to have a potential issue. When a realm is world-readable (username = '*' with read = true), the query will return ALL users with session rooms, not just those who should have access to this specific realm. The OR EXISTS clause for world-readable realms doesn't restrict which users are returned - it just checks if such a permission exists for the realm, and if it does, all users in the WHERE clause match.
This could expose session room information for users who shouldn't have access to this realm. The original JOIN-based approach correctly filtered users to only those with explicit permissions for this realm. Consider revising the logic to ensure that world-readable access doesn't inadvertently expose all user session rooms.
| await this.realmServer.createWebhookCommand({ | ||
| incomingWebhookId: githubWebhook.id, | ||
| command: `${catalogRealmURL}commands/process-github-event/default`, | ||
| filter: { | ||
| submissionRealmUrl: this.realmServer.submissionRealmURL, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
The registerPRWebhook method creates a new webhook command every time a PR request is made, without checking if an equivalent command already exists. This could lead to duplicate webhook commands being created, which would cause the same webhook event to trigger multiple command executions. Consider adding logic to check if a webhook command with the same filter already exists before creating a new one, similar to the idempotent approach used in the register-github-webhook.ts script (see ensureWebhookCommand function at line 162).
lukemelia
left a comment
There was a problem hiding this comment.
This needs some work. See comments.
| @field card = linksTo(CardDef); | ||
| @field realm = contains(StringField); | ||
| @field localDir = contains(StringField); | ||
| @field doNotWaitForPersist = contains(BooleanField); |
There was a problem hiding this comment.
Why are we doing this? It seems problematic -- i.e. what if saving fails?
| const githubWebhook = webhooks.find( | ||
| (w: { verificationType: string }) => | ||
| w.verificationType === 'HMAC_SHA256_HEADER', | ||
| ); |
There was a problem hiding this comment.
This is not a robust way to lookup the webhook. Other webhooks could use this verification type as well couldn't they?
There was a problem hiding this comment.
Probably should go ahead and do the above TODO now
| get submissionRealmURL(): string { | ||
| return `${this.url.href}submissions/`; | ||
| } |
There was a problem hiding this comment.
This seems awkwardly specific for the realm-server service. Can we define it somewhere else?
| static [queryableValue](_value: any, _stack: BaseDef[]): null { | ||
| return null; | ||
| } |
| @field action = contains(StringField); // 'opened', 'completed', etc. | ||
| @field prNumber = contains(NumberField); | ||
| @field prUrl = contains(StringField); | ||
| @field payload = contains(JsonField); // full raw payload |
There was a problem hiding this comment.
If you are going to store the full payload, should we just make the other fields computed on it?
| this.store.add(input.card, { | ||
| realm: input.realm, | ||
| localDir: input.localDir, | ||
| doNotWaitForPersist: true, |
| 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 |
There was a problem hiding this comment.
The command filter should specify a type along with its configuration and we should use the type to look up a code block somewhere that has this specific logic of comparing the payload with the filter configuration. The logic should not be in line within this handle-webhook-receiver.
| let submissionRealmUrl = | ||
| (commandFilter?.submissionRealmUrl as string | undefined) ?? | ||
| new URL('/submissions/', commandURL).href; | ||
|
|
||
| // Run as the realm owner so they have write permissions in the submission realm | ||
| let realmOwnerRows = await query(dbAdapter, [ | ||
| `SELECT username FROM realm_user_permissions WHERE realm_url = `, | ||
| param(submissionRealmUrl), | ||
| ` AND realm_owner = true LIMIT 1`, | ||
| ]); | ||
| let runAs = | ||
| (realmOwnerRows[0]?.username as string | undefined) ?? | ||
| (webhook.username as string); | ||
|
|
||
| let commandInput = { | ||
| eventType: eventType ?? '', | ||
| submissionRealmUrl, | ||
| payload, | ||
| }; |
There was a problem hiding this comment.
This logic of assembling the command input also should not be in line here. It needs to be abstracted in some way. The idea is that this route handler should handle any webhook in the system, not just the github webhook.
| try { | ||
| await enqueueRunCommandJob( | ||
| { | ||
| realmURL: submissionRealmUrl, |
There was a problem hiding this comment.
This shouldn't be fixed. It should be based on something from the database.
| @@ -55,12 +57,23 @@ export async function fetchRealmSessionRooms( | |||
| let rows = await query(dbAdapter, [ | |||
| 'SELECT u.matrix_user_id, u.session_room_id', | |||
| 'FROM users u', | |||
| 'JOIN realm_user_permissions rup', | |||
| 'ON rup.username = u.matrix_user_id', | |||
| 'WHERE rup.realm_url =', | |||
| 'WHERE u.session_room_id IS NOT NULL', | |||
| 'AND (', | |||
| ' EXISTS (', | |||
| ' SELECT 1 FROM realm_user_permissions', | |||
| ' WHERE realm_url =', | |||
| param(realmURL), | |||
| 'AND (rup.read = true OR rup.write = true)', | |||
| 'AND u.session_room_id IS NOT NULL', | |||
| ' AND username = u.matrix_user_id', | |||
| ' AND (read = true OR write = true)', | |||
| ' )', | |||
| ' OR EXISTS (', | |||
| ' SELECT 1 FROM realm_user_permissions', | |||
| ' WHERE realm_url =', | |||
| param(realmURL), | |||
| " AND username = '*'", | |||
| ' AND read = true', | |||
| ' )', | |||
| ')', | |||
There was a problem hiding this comment.
Did you check other consumers of this method to see if there are any unintended consequences of this change?
Uh oh!
There was an error while loading. Please reload this page.