From fd4de8d4b962c5c9faa45b43d18fb0f8532e0b86 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 19 Jun 2026 22:59:42 -0700 Subject: [PATCH 1/6] feat(triggers): add GitLab, PagerDuty, and Zendesk webhook triggers Add webhook trigger support for three integrations that previously had blocks but no triggers: - GitLab: push, merge request, issue, pipeline, comment, and all-events. Verifies the X-Gitlab-Token secret token; filters by object_kind. - PagerDuty: incident triggered/acknowledged/resolved/escalated/reassigned and all-events. Verifies X-PagerDuty-Signature (HMAC-SHA256 over raw body, comma-separated rotation); idempotency on event id. - Zendesk: ticket created/status changed/comment added/priority changed and all-events. Verifies X-Zendesk-Webhook-Signature (base64 HMAC-SHA256 over timestamp+body); idempotency on event id. Register GitLab's X-Gitlab-Event-UUID delivery header for webhook idempotency dedup. --- apps/sim/blocks/blocks/gitlab.ts | 21 +- apps/sim/blocks/blocks/pagerduty.ts | 20 ++ apps/sim/blocks/blocks/zendesk.ts | 18 ++ apps/sim/lib/core/idempotency/service.ts | 1 + apps/sim/lib/webhooks/providers/gitlab.ts | 68 ++++++ apps/sim/lib/webhooks/providers/pagerduty.ts | 99 +++++++++ apps/sim/lib/webhooks/providers/registry.ts | 6 + apps/sim/lib/webhooks/providers/zendesk.ts | 112 ++++++++++ apps/sim/triggers/gitlab/comment.ts | 33 +++ apps/sim/triggers/gitlab/index.ts | 6 + apps/sim/triggers/gitlab/issue.ts | 33 +++ apps/sim/triggers/gitlab/merge_request.ts | 33 +++ apps/sim/triggers/gitlab/pipeline.ts | 33 +++ apps/sim/triggers/gitlab/push.ts | 34 +++ apps/sim/triggers/gitlab/utils.ts | 200 ++++++++++++++++++ apps/sim/triggers/gitlab/webhook.ts | 33 +++ .../pagerduty/incident_acknowledged.ts | 32 +++ .../triggers/pagerduty/incident_escalated.ts | 32 +++ .../triggers/pagerduty/incident_reassigned.ts | 32 +++ .../triggers/pagerduty/incident_resolved.ts | 32 +++ .../triggers/pagerduty/incident_triggered.ts | 33 +++ apps/sim/triggers/pagerduty/index.ts | 6 + apps/sim/triggers/pagerduty/utils.ts | 122 +++++++++++ apps/sim/triggers/pagerduty/webhook.ts | 32 +++ apps/sim/triggers/registry.ts | 40 ++++ apps/sim/triggers/zendesk/index.ts | 5 + .../triggers/zendesk/ticket_comment_added.ts | 33 +++ apps/sim/triggers/zendesk/ticket_created.ts | 34 +++ .../zendesk/ticket_priority_changed.ts | 33 +++ .../triggers/zendesk/ticket_status_changed.ts | 33 +++ apps/sim/triggers/zendesk/utils.ts | 111 ++++++++++ apps/sim/triggers/zendesk/webhook.ts | 33 +++ 32 files changed, 1392 insertions(+), 1 deletion(-) create mode 100644 apps/sim/lib/webhooks/providers/gitlab.ts create mode 100644 apps/sim/lib/webhooks/providers/pagerduty.ts create mode 100644 apps/sim/lib/webhooks/providers/zendesk.ts create mode 100644 apps/sim/triggers/gitlab/comment.ts create mode 100644 apps/sim/triggers/gitlab/index.ts create mode 100644 apps/sim/triggers/gitlab/issue.ts create mode 100644 apps/sim/triggers/gitlab/merge_request.ts create mode 100644 apps/sim/triggers/gitlab/pipeline.ts create mode 100644 apps/sim/triggers/gitlab/push.ts create mode 100644 apps/sim/triggers/gitlab/utils.ts create mode 100644 apps/sim/triggers/gitlab/webhook.ts create mode 100644 apps/sim/triggers/pagerduty/incident_acknowledged.ts create mode 100644 apps/sim/triggers/pagerduty/incident_escalated.ts create mode 100644 apps/sim/triggers/pagerduty/incident_reassigned.ts create mode 100644 apps/sim/triggers/pagerduty/incident_resolved.ts create mode 100644 apps/sim/triggers/pagerduty/incident_triggered.ts create mode 100644 apps/sim/triggers/pagerduty/index.ts create mode 100644 apps/sim/triggers/pagerduty/utils.ts create mode 100644 apps/sim/triggers/pagerduty/webhook.ts create mode 100644 apps/sim/triggers/zendesk/index.ts create mode 100644 apps/sim/triggers/zendesk/ticket_comment_added.ts create mode 100644 apps/sim/triggers/zendesk/ticket_created.ts create mode 100644 apps/sim/triggers/zendesk/ticket_priority_changed.ts create mode 100644 apps/sim/triggers/zendesk/ticket_status_changed.ts create mode 100644 apps/sim/triggers/zendesk/utils.ts create mode 100644 apps/sim/triggers/zendesk/webhook.ts diff --git a/apps/sim/blocks/blocks/gitlab.ts b/apps/sim/blocks/blocks/gitlab.ts index 493950d5fde..59de55f8c26 100644 --- a/apps/sim/blocks/blocks/gitlab.ts +++ b/apps/sim/blocks/blocks/gitlab.ts @@ -2,13 +2,14 @@ import { GitLabIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import type { GitLabResponse } from '@/tools/gitlab/types' +import { getTrigger } from '@/triggers' export const GitLabBlock: BlockConfig = { type: 'gitlab', name: 'GitLab', description: 'Interact with GitLab projects, issues, merge requests, and pipelines', authMode: AuthMode.ApiKey, - triggerAllowed: false, + triggerAllowed: true, longDescription: 'Integrate GitLab into the workflow. Can manage projects, issues, merge requests, pipelines, and add comments. Supports all core GitLab DevOps operations.', docsLink: 'https://docs.sim.ai/integrations/gitlab', @@ -437,6 +438,12 @@ Return ONLY the commit message - no explanations, no extra text.`, ], }, }, + ...getTrigger('gitlab_push').subBlocks, + ...getTrigger('gitlab_merge_request').subBlocks, + ...getTrigger('gitlab_issue').subBlocks, + ...getTrigger('gitlab_pipeline').subBlocks, + ...getTrigger('gitlab_comment').subBlocks, + ...getTrigger('gitlab_webhook').subBlocks, ], tools: { access: [ @@ -746,6 +753,18 @@ Return ONLY the commit message - no explanations, no extra text.`, // Success indicator success: { type: 'boolean', description: 'Operation success status' }, }, + + triggers: { + enabled: true, + available: [ + 'gitlab_push', + 'gitlab_merge_request', + 'gitlab_issue', + 'gitlab_pipeline', + 'gitlab_comment', + 'gitlab_webhook', + ], + }, } export const GitLabBlockMeta = { diff --git a/apps/sim/blocks/blocks/pagerduty.ts b/apps/sim/blocks/blocks/pagerduty.ts index c47d27733bc..2ed88a7b96c 100644 --- a/apps/sim/blocks/blocks/pagerduty.ts +++ b/apps/sim/blocks/blocks/pagerduty.ts @@ -1,10 +1,12 @@ import { PagerDutyIcon } from '@/components/icons' import { AuthMode, type BlockConfig, type BlockMeta, IntegrationType } from '@/blocks/types' +import { getTrigger } from '@/triggers' export const PagerDutyBlock: BlockConfig = { type: 'pagerduty', name: 'PagerDuty', description: 'Manage incidents and on-call schedules with PagerDuty', + triggerAllowed: true, longDescription: 'Integrate PagerDuty into your workflow to list, create, and update incidents, add notes, list services, and check on-call schedules.', docsLink: 'https://docs.sim.ai/integrations/pagerduty', @@ -315,6 +317,12 @@ export const PagerDutyBlock: BlockConfig = { generationType: 'timestamp', }, }, + ...getTrigger('pagerduty_incident_triggered').subBlocks, + ...getTrigger('pagerduty_incident_acknowledged').subBlocks, + ...getTrigger('pagerduty_incident_resolved').subBlocks, + ...getTrigger('pagerduty_incident_escalated').subBlocks, + ...getTrigger('pagerduty_incident_reassigned').subBlocks, + ...getTrigger('pagerduty_webhook').subBlocks, ], tools: { @@ -481,6 +489,18 @@ export const PagerDutyBlock: BlockConfig = { description: 'Array of on-call entries (list_oncalls)', }, }, + + triggers: { + enabled: true, + available: [ + 'pagerduty_incident_triggered', + 'pagerduty_incident_acknowledged', + 'pagerduty_incident_resolved', + 'pagerduty_incident_escalated', + 'pagerduty_incident_reassigned', + 'pagerduty_webhook', + ], + }, } export const PagerDutyBlockMeta = { diff --git a/apps/sim/blocks/blocks/zendesk.ts b/apps/sim/blocks/blocks/zendesk.ts index 063191ec227..9d51552cf82 100644 --- a/apps/sim/blocks/blocks/zendesk.ts +++ b/apps/sim/blocks/blocks/zendesk.ts @@ -1,11 +1,13 @@ import { ZendeskIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' +import { getTrigger } from '@/triggers' export const ZendeskBlock: BlockConfig = { type: 'zendesk', name: 'Zendesk', description: 'Manage support tickets, users, and organizations in Zendesk', + triggerAllowed: true, longDescription: 'Integrate Zendesk into the workflow. Can get tickets, get ticket, create ticket, create tickets bulk, update ticket, update tickets bulk, delete ticket, merge tickets, get users, get user, get current user, search users, create user, create users bulk, update user, update users bulk, delete user, get organizations, get organization, autocomplete organizations, create organization, create organizations bulk, update organization, delete organization, search, search count.', docsLink: 'https://docs.sim.ai/integrations/zendesk', @@ -529,6 +531,11 @@ Return ONLY the search query - no explanations.`, }, mode: 'advanced', }, + ...getTrigger('zendesk_ticket_created').subBlocks, + ...getTrigger('zendesk_ticket_status_changed').subBlocks, + ...getTrigger('zendesk_ticket_comment_added').subBlocks, + ...getTrigger('zendesk_ticket_priority_changed').subBlocks, + ...getTrigger('zendesk_webhook').subBlocks, ], tools: { access: [ @@ -695,6 +702,17 @@ Return ONLY the search query - no explanations.`, // Metadata (shared across all operations) metadata: { type: 'json', description: 'Operation metadata including operation type' }, }, + + triggers: { + enabled: true, + available: [ + 'zendesk_ticket_created', + 'zendesk_ticket_status_changed', + 'zendesk_ticket_comment_added', + 'zendesk_ticket_priority_changed', + 'zendesk_webhook', + ], + }, } export const ZendeskBlockMeta = { diff --git a/apps/sim/lib/core/idempotency/service.ts b/apps/sim/lib/core/idempotency/service.ts index fc75bfb0bd1..c1696fc7c2c 100644 --- a/apps/sim/lib/core/idempotency/service.ts +++ b/apps/sim/lib/core/idempotency/service.ts @@ -493,6 +493,7 @@ export class IdempotencyService { normalizedHeaders?.['x-webhook-id'] || normalizedHeaders?.['x-shopify-webhook-id'] || normalizedHeaders?.['x-github-delivery'] || + normalizedHeaders?.['x-gitlab-event-uuid'] || normalizedHeaders?.['x-event-id'] || normalizedHeaders?.['x-teams-notification-id'] || normalizedHeaders?.['svix-id'] || diff --git a/apps/sim/lib/webhooks/providers/gitlab.ts b/apps/sim/lib/webhooks/providers/gitlab.ts new file mode 100644 index 00000000000..72f2fd70b73 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/gitlab.ts @@ -0,0 +1,68 @@ +import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { NextResponse } from 'next/server' +import type { + AuthContext, + EventMatchContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:GitLab') + +function asRecord(value: unknown): Record { + return (value as Record) || {} +} + +export const gitlabHandler: WebhookProviderHandler = { + /** + * GitLab echoes the configured "Secret token" verbatim in the `X-Gitlab-Token` + * header (plain equality, not an HMAC). Skip verification when no token is set. + */ + verifyAuth({ request, requestId, providerConfig }: AuthContext) { + const secret = providerConfig.webhookSecret as string | undefined + if (!secret) { + return null + } + + const token = request.headers.get('X-Gitlab-Token') + if (!token) { + logger.warn(`[${requestId}] GitLab webhook missing X-Gitlab-Token header`) + return new NextResponse('Unauthorized - Missing GitLab token', { status: 401 }) + } + + if (!safeCompare(token, secret)) { + logger.warn(`[${requestId}] GitLab token verification failed`) + return new NextResponse('Unauthorized - Invalid GitLab token', { status: 401 }) + } + + return null + }, + + async matchEvent({ body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + if (!triggerId || triggerId === 'gitlab_webhook') return true + + const objectKind = asRecord(body).object_kind as string | undefined + + const { isGitLabEventMatch } = await import('@/triggers/gitlab/utils') + if (!isGitLabEventMatch(triggerId, objectKind || '')) { + logger.debug( + `[${requestId}] GitLab event '${objectKind}' does not match trigger ${triggerId}, skipping` + ) + return false + } + return true + }, + + async formatInput({ body, headers }: FormatInputContext): Promise { + const b = asRecord(body) + const eventType = headers['x-gitlab-event'] || '' + const ref = (b.ref as string) || '' + const branch = ref.replace('refs/heads/', '') + return { + input: { ...b, event_type: eventType, branch }, + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/pagerduty.ts b/apps/sim/lib/webhooks/providers/pagerduty.ts new file mode 100644 index 00000000000..1e39925812b --- /dev/null +++ b/apps/sim/lib/webhooks/providers/pagerduty.ts @@ -0,0 +1,99 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import type { + EventMatchContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:PagerDuty') + +/** + * PagerDuty V3 signs the raw body with HMAC-SHA256 and sends it in the + * `X-PagerDuty-Signature` header as one or more comma-separated `v1=` + * values (multiple appear during signing-secret rotation). The delivery is + * valid when our computed signature matches any of them. + */ +function validatePagerDutySignature(secret: string, signature: string, body: string): boolean { + if (!secret || !signature || !body) return false + const computed = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + return signature + .split(',') + .map((part) => part.trim()) + .filter((part) => part.startsWith('v1=')) + .some((part) => safeCompare(part.slice(3), computed)) +} + +function asRecord(value: unknown): Record { + return (value as Record) || {} +} + +function referenceSummary( + value: unknown +): { id?: unknown; summary?: unknown; html_url?: unknown } | null { + if (!value || typeof value !== 'object') return null + const ref = value as Record + return { id: ref.id, summary: ref.summary, html_url: ref.html_url } +} + +export const pagerdutyHandler: WebhookProviderHandler = { + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'X-PagerDuty-Signature', + validateFn: validatePagerDutySignature, + providerLabel: 'PagerDuty', + }), + + async matchEvent({ body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + if (!triggerId || triggerId === 'pagerduty_webhook') return true + + const event = asRecord(asRecord(body).event) + const eventType = event.event_type as string | undefined + + const { isPagerDutyEventMatch } = await import('@/triggers/pagerduty/utils') + if (!isPagerDutyEventMatch(triggerId, eventType || '')) { + logger.debug( + `[${requestId}] PagerDuty event '${eventType}' does not match trigger ${triggerId}, skipping` + ) + return false + } + return true + }, + + async formatInput({ body }: FormatInputContext): Promise { + const event = asRecord(asRecord(body).event) + const data = asRecord(event.data) + const priority = referenceSummary(data.priority) + + return { + input: { + event_id: event.id, + event_type: event.event_type, + occurred_at: event.occurred_at, + agent: event.agent ?? null, + incident: { + id: data.id, + number: data.number, + title: data.title, + status: data.status, + urgency: data.urgency, + html_url: data.html_url, + created_at: data.created_at, + priority: priority?.summary ?? null, + service: referenceSummary(data.service), + escalation_policy: referenceSummary(data.escalation_policy), + assignees: Array.isArray(data.assignees) ? data.assignees : [], + }, + }, + } + }, + + extractIdempotencyId(body: unknown) { + const event = asRecord(asRecord(body).event) + return (event.id as string | undefined) || null + }, +} diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts index 487cc851727..bbb464ae35c 100644 --- a/apps/sim/lib/webhooks/providers/registry.ts +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -13,6 +13,7 @@ import { fathomHandler } from '@/lib/webhooks/providers/fathom' import { firefliesHandler } from '@/lib/webhooks/providers/fireflies' import { genericHandler } from '@/lib/webhooks/providers/generic' import { githubHandler } from '@/lib/webhooks/providers/github' +import { gitlabHandler } from '@/lib/webhooks/providers/gitlab' import { gmailHandler } from '@/lib/webhooks/providers/gmail' import { gongHandler } from '@/lib/webhooks/providers/gong' import { googleFormsHandler } from '@/lib/webhooks/providers/google-forms' @@ -29,6 +30,7 @@ import { microsoftTeamsHandler } from '@/lib/webhooks/providers/microsoft-teams' import { mondayHandler } from '@/lib/webhooks/providers/monday' import { notionHandler } from '@/lib/webhooks/providers/notion' import { outlookHandler } from '@/lib/webhooks/providers/outlook' +import { pagerdutyHandler } from '@/lib/webhooks/providers/pagerduty' import { resendHandler } from '@/lib/webhooks/providers/resend' import { rssHandler } from '@/lib/webhooks/providers/rss' import { salesforceHandler } from '@/lib/webhooks/providers/salesforce' @@ -46,6 +48,7 @@ import { verifyTokenAuth } from '@/lib/webhooks/providers/utils' import { vercelHandler } from '@/lib/webhooks/providers/vercel' import { webflowHandler } from '@/lib/webhooks/providers/webflow' import { whatsappHandler } from '@/lib/webhooks/providers/whatsapp' +import { zendeskHandler } from '@/lib/webhooks/providers/zendesk' import { zoomHandler } from '@/lib/webhooks/providers/zoom' const logger = createLogger('WebhookProviderRegistry') @@ -64,6 +67,7 @@ const PROVIDER_HANDLERS: Record = { generic: genericHandler, gmail: gmailHandler, github: githubHandler, + gitlab: gitlabHandler, gong: gongHandler, google_forms: googleFormsHandler, fathom: fathomHandler, @@ -81,6 +85,7 @@ const PROVIDER_HANDLERS: Record = { 'microsoft-teams': microsoftTeamsHandler, notion: notionHandler, outlook: outlookHandler, + pagerduty: pagerdutyHandler, rss: rssHandler, salesforce: salesforceHandler, sendblue: sendblueHandler, @@ -95,6 +100,7 @@ const PROVIDER_HANDLERS: Record = { vercel: vercelHandler, webflow: webflowHandler, whatsapp: whatsappHandler, + zendesk: zendeskHandler, zoom: zoomHandler, } diff --git a/apps/sim/lib/webhooks/providers/zendesk.ts b/apps/sim/lib/webhooks/providers/zendesk.ts new file mode 100644 index 00000000000..9d8c04ec69f --- /dev/null +++ b/apps/sim/lib/webhooks/providers/zendesk.ts @@ -0,0 +1,112 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { NextResponse } from 'next/server' +import type { + AuthContext, + EventMatchContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:Zendesk') + +function asRecord(value: unknown): Record { + return (value as Record) || {} +} + +/** + * Zendesk signs `timestamp + rawBody` (no separator) with HMAC-SHA256 keyed by + * the webhook's signing secret, then base64-encodes it into + * `X-Zendesk-Webhook-Signature`. The timestamp is sent in a separate header. + */ +function validateZendeskSignature( + secret: string, + signature: string, + timestamp: string, + body: string +): boolean { + if (!secret || !signature || !timestamp) return false + const computed = crypto + .createHmac('sha256', secret) + .update(timestamp + body, 'utf8') + .digest('base64') + return safeCompare(computed, signature) +} + +export const zendeskHandler: WebhookProviderHandler = { + verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { + const secret = providerConfig.webhookSecret as string | undefined + if (!secret) { + return null + } + + const signature = request.headers.get('X-Zendesk-Webhook-Signature') + const timestamp = request.headers.get('X-Zendesk-Webhook-Signature-Timestamp') + if (!signature || !timestamp) { + logger.warn(`[${requestId}] Zendesk webhook missing signature headers`) + return new NextResponse('Unauthorized - Missing Zendesk signature', { status: 401 }) + } + + if (!validateZendeskSignature(secret, signature, timestamp, rawBody)) { + logger.warn(`[${requestId}] Zendesk signature verification failed`) + return new NextResponse('Unauthorized - Invalid Zendesk signature', { status: 401 }) + } + + return null + }, + + async matchEvent({ body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + if (!triggerId || triggerId === 'zendesk_webhook') return true + + const eventType = asRecord(body).type as string | undefined + + const { isZendeskEventMatch } = await import('@/triggers/zendesk/utils') + if (!isZendeskEventMatch(triggerId, eventType || '')) { + logger.debug( + `[${requestId}] Zendesk event '${eventType}' does not match trigger ${triggerId}, skipping` + ) + return false + } + return true + }, + + async formatInput({ body }: FormatInputContext): Promise { + const b = asRecord(body) + const detail = asRecord(b.detail) + const via = asRecord(detail.via) + + return { + input: { + event_id: b.id, + event_type: b.type, + time: b.time, + account_id: b.account_id, + ticket: { + id: detail.id, + subject: detail.subject, + status: detail.status, + priority: detail.priority, + ticket_type: detail.type, + description: detail.description, + requester_id: detail.requester_id, + assignee_id: detail.assignee_id, + group_id: detail.group_id, + organization_id: detail.organization_id, + tags: Array.isArray(detail.tags) ? detail.tags : [], + via_channel: via.channel, + is_public: detail.is_public, + created_at: detail.created_at, + updated_at: detail.updated_at, + }, + event: b.event ?? null, + }, + } + }, + + extractIdempotencyId(body: unknown) { + return (asRecord(body).id as string | undefined) || null + }, +} diff --git a/apps/sim/triggers/gitlab/comment.ts b/apps/sim/triggers/gitlab/comment.ts new file mode 100644 index 00000000000..4c0c7c6eb14 --- /dev/null +++ b/apps/sim/triggers/gitlab/comment.ts @@ -0,0 +1,33 @@ +import { GitLabIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildGitLabCommentOutputs, + buildGitLabExtraFields, + gitlabSetupInstructions, + gitlabTriggerOptions, +} from '@/triggers/gitlab/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const gitlabCommentTrigger: TriggerConfig = { + id: 'gitlab_comment', + name: 'GitLab Comment', + provider: 'gitlab', + description: 'Trigger workflow when a comment is added on a commit, merge request, or issue', + version: '1.0.0', + icon: GitLabIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'gitlab_comment', + triggerOptions: gitlabTriggerOptions, + setupInstructions: gitlabSetupInstructions('Comment', 'Comments'), + extraFields: buildGitLabExtraFields('gitlab_comment'), + }), + outputs: buildGitLabCommentOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Gitlab-Event': 'Note Hook', + 'X-Gitlab-Token': '...', + }, + }, +} diff --git a/apps/sim/triggers/gitlab/index.ts b/apps/sim/triggers/gitlab/index.ts new file mode 100644 index 00000000000..f6e75320a7a --- /dev/null +++ b/apps/sim/triggers/gitlab/index.ts @@ -0,0 +1,6 @@ +export { gitlabCommentTrigger } from './comment' +export { gitlabIssueTrigger } from './issue' +export { gitlabMergeRequestTrigger } from './merge_request' +export { gitlabPipelineTrigger } from './pipeline' +export { gitlabPushTrigger } from './push' +export { gitlabWebhookTrigger } from './webhook' diff --git a/apps/sim/triggers/gitlab/issue.ts b/apps/sim/triggers/gitlab/issue.ts new file mode 100644 index 00000000000..041ec33936a --- /dev/null +++ b/apps/sim/triggers/gitlab/issue.ts @@ -0,0 +1,33 @@ +import { GitLabIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildGitLabExtraFields, + buildGitLabIssueOutputs, + gitlabSetupInstructions, + gitlabTriggerOptions, +} from '@/triggers/gitlab/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const gitlabIssueTrigger: TriggerConfig = { + id: 'gitlab_issue', + name: 'GitLab Issue', + provider: 'gitlab', + description: 'Trigger workflow when an issue is opened, updated, or closed in GitLab', + version: '1.0.0', + icon: GitLabIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'gitlab_issue', + triggerOptions: gitlabTriggerOptions, + setupInstructions: gitlabSetupInstructions('Issue', 'Issues events'), + extraFields: buildGitLabExtraFields('gitlab_issue'), + }), + outputs: buildGitLabIssueOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Gitlab-Event': 'Issue Hook', + 'X-Gitlab-Token': '...', + }, + }, +} diff --git a/apps/sim/triggers/gitlab/merge_request.ts b/apps/sim/triggers/gitlab/merge_request.ts new file mode 100644 index 00000000000..3a9476c9850 --- /dev/null +++ b/apps/sim/triggers/gitlab/merge_request.ts @@ -0,0 +1,33 @@ +import { GitLabIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildGitLabExtraFields, + buildGitLabMergeRequestOutputs, + gitlabSetupInstructions, + gitlabTriggerOptions, +} from '@/triggers/gitlab/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const gitlabMergeRequestTrigger: TriggerConfig = { + id: 'gitlab_merge_request', + name: 'GitLab Merge Request', + provider: 'gitlab', + description: 'Trigger workflow when a merge request is opened, updated, or merged in GitLab', + version: '1.0.0', + icon: GitLabIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'gitlab_merge_request', + triggerOptions: gitlabTriggerOptions, + setupInstructions: gitlabSetupInstructions('Merge Request', 'Merge request events'), + extraFields: buildGitLabExtraFields('gitlab_merge_request'), + }), + outputs: buildGitLabMergeRequestOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Gitlab-Event': 'Merge Request Hook', + 'X-Gitlab-Token': '...', + }, + }, +} diff --git a/apps/sim/triggers/gitlab/pipeline.ts b/apps/sim/triggers/gitlab/pipeline.ts new file mode 100644 index 00000000000..408775828f8 --- /dev/null +++ b/apps/sim/triggers/gitlab/pipeline.ts @@ -0,0 +1,33 @@ +import { GitLabIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildGitLabExtraFields, + buildGitLabPipelineOutputs, + gitlabSetupInstructions, + gitlabTriggerOptions, +} from '@/triggers/gitlab/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const gitlabPipelineTrigger: TriggerConfig = { + id: 'gitlab_pipeline', + name: 'GitLab Pipeline', + provider: 'gitlab', + description: 'Trigger workflow when a pipeline status changes in GitLab', + version: '1.0.0', + icon: GitLabIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'gitlab_pipeline', + triggerOptions: gitlabTriggerOptions, + setupInstructions: gitlabSetupInstructions('Pipeline', 'Pipeline events'), + extraFields: buildGitLabExtraFields('gitlab_pipeline'), + }), + outputs: buildGitLabPipelineOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Gitlab-Event': 'Pipeline Hook', + 'X-Gitlab-Token': '...', + }, + }, +} diff --git a/apps/sim/triggers/gitlab/push.ts b/apps/sim/triggers/gitlab/push.ts new file mode 100644 index 00000000000..455d1c708e2 --- /dev/null +++ b/apps/sim/triggers/gitlab/push.ts @@ -0,0 +1,34 @@ +import { GitLabIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildGitLabExtraFields, + buildGitLabPushOutputs, + gitlabSetupInstructions, + gitlabTriggerOptions, +} from '@/triggers/gitlab/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const gitlabPushTrigger: TriggerConfig = { + id: 'gitlab_push', + name: 'GitLab Push', + provider: 'gitlab', + description: 'Trigger workflow when commits are pushed to a GitLab project', + version: '1.0.0', + icon: GitLabIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'gitlab_push', + triggerOptions: gitlabTriggerOptions, + includeDropdown: true, + setupInstructions: gitlabSetupInstructions('Push', 'Push events'), + extraFields: buildGitLabExtraFields('gitlab_push'), + }), + outputs: buildGitLabPushOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Gitlab-Event': 'Push Hook', + 'X-Gitlab-Token': '...', + }, + }, +} diff --git a/apps/sim/triggers/gitlab/utils.ts b/apps/sim/triggers/gitlab/utils.ts new file mode 100644 index 00000000000..b1dfc50251e --- /dev/null +++ b/apps/sim/triggers/gitlab/utils.ts @@ -0,0 +1,200 @@ +import type { SubBlockConfig } from '@/blocks/types' +import type { TriggerOutput } from '@/triggers/types' + +/** + * Shared trigger dropdown options for all GitLab triggers + */ +export const gitlabTriggerOptions = [ + { label: 'Push', id: 'gitlab_push' }, + { label: 'Merge Request', id: 'gitlab_merge_request' }, + { label: 'Issue', id: 'gitlab_issue' }, + { label: 'Pipeline', id: 'gitlab_pipeline' }, + { label: 'Comment', id: 'gitlab_comment' }, + { label: 'All Events', id: 'gitlab_webhook' }, +] + +/** + * Maps each GitLab trigger to the payload `object_kind` it listens for. + * `gitlab_webhook` is intentionally absent — it matches every event. + */ +const TRIGGER_OBJECT_KINDS: Record = { + gitlab_push: 'push', + gitlab_merge_request: 'merge_request', + gitlab_issue: 'issue', + gitlab_pipeline: 'pipeline', + gitlab_comment: 'note', +} + +/** + * Generate setup instructions for a specific GitLab webhook event. + * + * @param triggerLabel - Friendly event name shown to the user. + * @param checkboxLabel - The exact checkbox label in the GitLab "Trigger" section. + */ +export function gitlabSetupInstructions(triggerLabel: string, checkboxLabel: string): string { + const instructions = [ + 'In GitLab, go to your Project > Settings > Webhooks and click Add new webhook.', + 'Paste the Webhook URL above into the URL field.', + 'Enter the same Secret token you set above so deliveries can be verified.', + `Under Trigger, enable ${checkboxLabel}.`, + 'Click Add webhook to save.', + ] + return instructions + .map( + (instruction, index) => + `
${index + 1}. ${instruction}
` + ) + .join('') +} + +/** + * Secret token field used to verify the X-Gitlab-Token header. + */ +export function buildGitLabExtraFields(triggerId: string): SubBlockConfig[] { + return [ + { + id: 'webhookSecret', + title: 'Secret Token', + type: 'short-input', + placeholder: 'Generate or enter a strong secret token', + description: 'Validates that webhook deliveries originate from GitLab (X-Gitlab-Token).', + password: true, + required: false, + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + ] +} + +const projectOutputs = { + id: { type: 'number', description: 'Project ID' }, + name: { type: 'string', description: 'Project name' }, + web_url: { type: 'string', description: 'Project web URL' }, + path_with_namespace: { type: 'string', description: 'Full path (namespace/project)' }, +} as const + +const actorUserOutputs = { + id: { type: 'number', description: 'User ID' }, + name: { type: 'string', description: 'User display name' }, + username: { type: 'string', description: 'Username' }, +} as const + +export function buildGitLabPushOutputs(): Record { + return { + object_kind: { type: 'string', description: 'Event kind (push)' }, + event_type: { type: 'string', description: 'GitLab event type from the X-Gitlab-Event header' }, + ref: { type: 'string', description: 'Git ref that was pushed (e.g. refs/heads/main)' }, + branch: { type: 'string', description: 'Branch name derived from ref' }, + before: { type: 'string', description: 'SHA before the push' }, + after: { type: 'string', description: 'SHA after the push' }, + checkout_sha: { type: 'string', description: 'SHA of the most recent commit' }, + user_username: { type: 'string', description: 'Username of the pusher' }, + user_name: { type: 'string', description: 'Display name of the pusher' }, + user_email: { type: 'string', description: 'Email of the pusher' }, + total_commits_count: { type: 'number', description: 'Number of commits in the push' }, + project: projectOutputs, + commits: { type: 'json', description: 'Array of commit objects included in this push' }, + } +} + +export function buildGitLabMergeRequestOutputs(): Record { + return { + object_kind: { type: 'string', description: 'Event kind (merge_request)' }, + event_type: { type: 'string', description: 'GitLab event type from the X-Gitlab-Event header' }, + user: actorUserOutputs, + project: projectOutputs, + object_attributes: { + id: { type: 'number', description: 'Global merge request ID' }, + iid: { type: 'number', description: 'Project-scoped merge request number' }, + title: { type: 'string', description: 'Merge request title' }, + state: { type: 'string', description: 'State (opened, closed, merged, locked)' }, + action: { type: 'string', description: 'Action (open, close, reopen, update, merge, etc.)' }, + source_branch: { type: 'string', description: 'Source branch' }, + target_branch: { type: 'string', description: 'Target branch' }, + merge_status: { type: 'string', description: 'Merge status' }, + draft: { type: 'boolean', description: 'Whether the merge request is a draft' }, + url: { type: 'string', description: 'Merge request URL' }, + }, + } +} + +export function buildGitLabIssueOutputs(): Record { + return { + object_kind: { type: 'string', description: 'Event kind (issue)' }, + event_type: { type: 'string', description: 'GitLab event type from the X-Gitlab-Event header' }, + user: actorUserOutputs, + project: projectOutputs, + object_attributes: { + id: { type: 'number', description: 'Global issue ID' }, + iid: { type: 'number', description: 'Project-scoped issue number' }, + title: { type: 'string', description: 'Issue title' }, + state: { type: 'string', description: 'State (opened, closed)' }, + action: { type: 'string', description: 'Action (open, close, reopen, update)' }, + description: { type: 'string', description: 'Issue description' }, + confidential: { type: 'boolean', description: 'Whether the issue is confidential' }, + url: { type: 'string', description: 'Issue URL' }, + }, + } +} + +export function buildGitLabPipelineOutputs(): Record { + return { + object_kind: { type: 'string', description: 'Event kind (pipeline)' }, + event_type: { type: 'string', description: 'GitLab event type from the X-Gitlab-Event header' }, + user: actorUserOutputs, + project: projectOutputs, + object_attributes: { + id: { type: 'number', description: 'Pipeline ID' }, + status: { type: 'string', description: 'Pipeline status (success, failed, running, etc.)' }, + detailed_status: { type: 'string', description: 'Detailed pipeline status' }, + ref: { type: 'string', description: 'Ref the pipeline ran on' }, + sha: { type: 'string', description: 'Commit SHA' }, + source: { type: 'string', description: 'Pipeline source (push, web, schedule, etc.)' }, + duration: { type: 'number', description: 'Pipeline duration in seconds' }, + url: { type: 'string', description: 'Pipeline URL' }, + }, + } +} + +export function buildGitLabCommentOutputs(): Record { + return { + object_kind: { type: 'string', description: 'Event kind (note)' }, + event_type: { type: 'string', description: 'GitLab event type from the X-Gitlab-Event header' }, + user: actorUserOutputs, + project: projectOutputs, + object_attributes: { + id: { type: 'number', description: 'Comment ID' }, + note: { type: 'string', description: 'Comment body' }, + noteable_type: { + type: 'string', + description: 'What the comment is on (Commit, MergeRequest, Issue, Snippet)', + }, + action: { type: 'string', description: 'Action (create, update)' }, + url: { type: 'string', description: 'Comment URL' }, + }, + } +} + +export function buildGitLabWebhookOutputs(): Record { + return { + object_kind: { type: 'string', description: 'Event kind (push, merge_request, issue, etc.)' }, + event_type: { type: 'string', description: 'GitLab event type from the X-Gitlab-Event header' }, + user: { type: 'json', description: 'Actor that triggered the event (when present)' }, + project: projectOutputs, + object_attributes: { + type: 'json', + description: 'Event-specific attributes (varies by object_kind)', + }, + } +} + +/** + * Returns true when an incoming webhook's object_kind matches the configured trigger. + */ +export function isGitLabEventMatch(triggerId: string, objectKind: string): boolean { + const expected = TRIGGER_OBJECT_KINDS[triggerId] + if (!expected) { + return true + } + return expected === objectKind +} diff --git a/apps/sim/triggers/gitlab/webhook.ts b/apps/sim/triggers/gitlab/webhook.ts new file mode 100644 index 00000000000..0b132a42c24 --- /dev/null +++ b/apps/sim/triggers/gitlab/webhook.ts @@ -0,0 +1,33 @@ +import { GitLabIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildGitLabExtraFields, + buildGitLabWebhookOutputs, + gitlabSetupInstructions, + gitlabTriggerOptions, +} from '@/triggers/gitlab/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const gitlabWebhookTrigger: TriggerConfig = { + id: 'gitlab_webhook', + name: 'GitLab Event', + provider: 'gitlab', + description: 'Trigger workflow from any GitLab webhook event', + version: '1.0.0', + icon: GitLabIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'gitlab_webhook', + triggerOptions: gitlabTriggerOptions, + setupInstructions: gitlabSetupInstructions('All Events', 'the events you want'), + extraFields: buildGitLabExtraFields('gitlab_webhook'), + }), + outputs: buildGitLabWebhookOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Gitlab-Event': 'Push Hook', + 'X-Gitlab-Token': '...', + }, + }, +} diff --git a/apps/sim/triggers/pagerduty/incident_acknowledged.ts b/apps/sim/triggers/pagerduty/incident_acknowledged.ts new file mode 100644 index 00000000000..42c3d8a08fe --- /dev/null +++ b/apps/sim/triggers/pagerduty/incident_acknowledged.ts @@ -0,0 +1,32 @@ +import { PagerDutyIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildPagerDutyExtraFields, + buildPagerDutyIncidentOutputs, + pagerdutySetupInstructions, + pagerdutyTriggerOptions, +} from '@/triggers/pagerduty/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const pagerdutyIncidentAcknowledgedTrigger: TriggerConfig = { + id: 'pagerduty_incident_acknowledged', + name: 'PagerDuty Incident Acknowledged', + provider: 'pagerduty', + description: 'Trigger workflow when an incident is acknowledged in PagerDuty', + version: '1.0.0', + icon: PagerDutyIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'pagerduty_incident_acknowledged', + triggerOptions: pagerdutyTriggerOptions, + setupInstructions: pagerdutySetupInstructions('Incident Acknowledged'), + extraFields: buildPagerDutyExtraFields('pagerduty_incident_acknowledged'), + }), + outputs: buildPagerDutyIncidentOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-PagerDuty-Signature': 'v1=...', + }, + }, +} diff --git a/apps/sim/triggers/pagerduty/incident_escalated.ts b/apps/sim/triggers/pagerduty/incident_escalated.ts new file mode 100644 index 00000000000..b3e27bc12b4 --- /dev/null +++ b/apps/sim/triggers/pagerduty/incident_escalated.ts @@ -0,0 +1,32 @@ +import { PagerDutyIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildPagerDutyExtraFields, + buildPagerDutyIncidentOutputs, + pagerdutySetupInstructions, + pagerdutyTriggerOptions, +} from '@/triggers/pagerduty/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const pagerdutyIncidentEscalatedTrigger: TriggerConfig = { + id: 'pagerduty_incident_escalated', + name: 'PagerDuty Incident Escalated', + provider: 'pagerduty', + description: 'Trigger workflow when an incident is escalated in PagerDuty', + version: '1.0.0', + icon: PagerDutyIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'pagerduty_incident_escalated', + triggerOptions: pagerdutyTriggerOptions, + setupInstructions: pagerdutySetupInstructions('Incident Escalated'), + extraFields: buildPagerDutyExtraFields('pagerduty_incident_escalated'), + }), + outputs: buildPagerDutyIncidentOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-PagerDuty-Signature': 'v1=...', + }, + }, +} diff --git a/apps/sim/triggers/pagerduty/incident_reassigned.ts b/apps/sim/triggers/pagerduty/incident_reassigned.ts new file mode 100644 index 00000000000..9725bb34306 --- /dev/null +++ b/apps/sim/triggers/pagerduty/incident_reassigned.ts @@ -0,0 +1,32 @@ +import { PagerDutyIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildPagerDutyExtraFields, + buildPagerDutyIncidentOutputs, + pagerdutySetupInstructions, + pagerdutyTriggerOptions, +} from '@/triggers/pagerduty/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const pagerdutyIncidentReassignedTrigger: TriggerConfig = { + id: 'pagerduty_incident_reassigned', + name: 'PagerDuty Incident Reassigned', + provider: 'pagerduty', + description: 'Trigger workflow when an incident is reassigned in PagerDuty', + version: '1.0.0', + icon: PagerDutyIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'pagerduty_incident_reassigned', + triggerOptions: pagerdutyTriggerOptions, + setupInstructions: pagerdutySetupInstructions('Incident Reassigned'), + extraFields: buildPagerDutyExtraFields('pagerduty_incident_reassigned'), + }), + outputs: buildPagerDutyIncidentOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-PagerDuty-Signature': 'v1=...', + }, + }, +} diff --git a/apps/sim/triggers/pagerduty/incident_resolved.ts b/apps/sim/triggers/pagerduty/incident_resolved.ts new file mode 100644 index 00000000000..f80d86dd48d --- /dev/null +++ b/apps/sim/triggers/pagerduty/incident_resolved.ts @@ -0,0 +1,32 @@ +import { PagerDutyIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildPagerDutyExtraFields, + buildPagerDutyIncidentOutputs, + pagerdutySetupInstructions, + pagerdutyTriggerOptions, +} from '@/triggers/pagerduty/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const pagerdutyIncidentResolvedTrigger: TriggerConfig = { + id: 'pagerduty_incident_resolved', + name: 'PagerDuty Incident Resolved', + provider: 'pagerduty', + description: 'Trigger workflow when an incident is resolved in PagerDuty', + version: '1.0.0', + icon: PagerDutyIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'pagerduty_incident_resolved', + triggerOptions: pagerdutyTriggerOptions, + setupInstructions: pagerdutySetupInstructions('Incident Resolved'), + extraFields: buildPagerDutyExtraFields('pagerduty_incident_resolved'), + }), + outputs: buildPagerDutyIncidentOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-PagerDuty-Signature': 'v1=...', + }, + }, +} diff --git a/apps/sim/triggers/pagerduty/incident_triggered.ts b/apps/sim/triggers/pagerduty/incident_triggered.ts new file mode 100644 index 00000000000..32bcd7db66c --- /dev/null +++ b/apps/sim/triggers/pagerduty/incident_triggered.ts @@ -0,0 +1,33 @@ +import { PagerDutyIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildPagerDutyExtraFields, + buildPagerDutyIncidentOutputs, + pagerdutySetupInstructions, + pagerdutyTriggerOptions, +} from '@/triggers/pagerduty/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const pagerdutyIncidentTriggeredTrigger: TriggerConfig = { + id: 'pagerduty_incident_triggered', + name: 'PagerDuty Incident Triggered', + provider: 'pagerduty', + description: 'Trigger workflow when a new incident is triggered in PagerDuty', + version: '1.0.0', + icon: PagerDutyIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'pagerduty_incident_triggered', + triggerOptions: pagerdutyTriggerOptions, + includeDropdown: true, + setupInstructions: pagerdutySetupInstructions('Incident Triggered'), + extraFields: buildPagerDutyExtraFields('pagerduty_incident_triggered'), + }), + outputs: buildPagerDutyIncidentOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-PagerDuty-Signature': 'v1=...', + }, + }, +} diff --git a/apps/sim/triggers/pagerduty/index.ts b/apps/sim/triggers/pagerduty/index.ts new file mode 100644 index 00000000000..0f522e6605a --- /dev/null +++ b/apps/sim/triggers/pagerduty/index.ts @@ -0,0 +1,6 @@ +export { pagerdutyIncidentAcknowledgedTrigger } from './incident_acknowledged' +export { pagerdutyIncidentEscalatedTrigger } from './incident_escalated' +export { pagerdutyIncidentReassignedTrigger } from './incident_reassigned' +export { pagerdutyIncidentResolvedTrigger } from './incident_resolved' +export { pagerdutyIncidentTriggeredTrigger } from './incident_triggered' +export { pagerdutyWebhookTrigger } from './webhook' diff --git a/apps/sim/triggers/pagerduty/utils.ts b/apps/sim/triggers/pagerduty/utils.ts new file mode 100644 index 00000000000..4cf1e46ced0 --- /dev/null +++ b/apps/sim/triggers/pagerduty/utils.ts @@ -0,0 +1,122 @@ +import type { SubBlockConfig } from '@/blocks/types' +import type { TriggerOutput } from '@/triggers/types' + +/** + * Shared trigger dropdown options for all PagerDuty triggers + */ +export const pagerdutyTriggerOptions = [ + { label: 'Incident Triggered', id: 'pagerduty_incident_triggered' }, + { label: 'Incident Acknowledged', id: 'pagerduty_incident_acknowledged' }, + { label: 'Incident Resolved', id: 'pagerduty_incident_resolved' }, + { label: 'Incident Escalated', id: 'pagerduty_incident_escalated' }, + { label: 'Incident Reassigned', id: 'pagerduty_incident_reassigned' }, + { label: 'All Incident Events', id: 'pagerduty_webhook' }, +] + +/** + * Maps each PagerDuty trigger to the V3 webhook `event_type` it listens for. + * `pagerduty_webhook` is intentionally absent — it matches every incident event. + */ +const TRIGGER_EVENT_TYPES: Record = { + pagerduty_incident_triggered: 'incident.triggered', + pagerduty_incident_acknowledged: 'incident.acknowledged', + pagerduty_incident_resolved: 'incident.resolved', + pagerduty_incident_escalated: 'incident.escalated', + pagerduty_incident_reassigned: 'incident.reassigned', +} + +/** + * Generate setup instructions for a specific PagerDuty incident event. + */ +export function pagerdutySetupInstructions(eventLabel: string): string { + const instructions = [ + 'In PagerDuty, go to Integrations > Generic Webhooks (v3) and click New Webhook.', + 'Paste the Webhook URL above into the Webhook URL field.', + 'Scope the webhook to your account, service, or team as needed.', + `Under Event Subscription, select ${eventLabel}.`, + 'After saving, PagerDuty shows a signing secret once — copy it and paste it into the Signing Secret field above to verify deliveries.', + ] + return instructions + .map( + (instruction, index) => + `
${index + 1}. ${instruction}
` + ) + .join('') +} + +/** + * Signing secret field used to verify the X-PagerDuty-Signature HMAC. + */ +export function buildPagerDutyExtraFields(triggerId: string): SubBlockConfig[] { + return [ + { + id: 'webhookSecret', + title: 'Signing Secret', + type: 'short-input', + placeholder: 'Paste the signing secret shown when you created the webhook', + description: + 'Validates that webhook deliveries originate from PagerDuty (X-PagerDuty-Signature).', + password: true, + required: false, + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + ] +} + +/** + * Output schema shared by every PagerDuty incident trigger — V3 webhook + * payloads share the same `event` envelope and `event.data` incident shape. + */ +export function buildPagerDutyIncidentOutputs(): Record { + return { + event_id: { type: 'string', description: 'Unique ID of the webhook event' }, + event_type: { + type: 'string', + description: 'Event type (e.g. incident.triggered, incident.resolved)', + }, + occurred_at: { type: 'string', description: 'When the event occurred (ISO 8601)' }, + agent: { + type: 'json', + description: 'The user or service that caused the event (may be null)', + }, + incident: { + id: { type: 'string', description: 'Incident ID' }, + number: { type: 'number', description: 'Incident number' }, + title: { type: 'string', description: 'Incident title' }, + status: { + type: 'string', + description: 'Incident status (triggered, acknowledged, resolved)', + }, + urgency: { type: 'string', description: 'Incident urgency (high or low)' }, + html_url: { type: 'string', description: 'Web URL of the incident' }, + created_at: { type: 'string', description: 'Incident creation timestamp' }, + priority: { type: 'string', description: 'Priority label (may be null)' }, + service: { + id: { type: 'string', description: 'Service ID' }, + summary: { type: 'string', description: 'Service name' }, + html_url: { type: 'string', description: 'Service web URL' }, + }, + escalation_policy: { + id: { type: 'string', description: 'Escalation policy ID' }, + summary: { type: 'string', description: 'Escalation policy name' }, + html_url: { type: 'string', description: 'Escalation policy web URL' }, + }, + assignees: { + type: 'json', + description: 'Array of assignee references ({ id, summary, html_url })', + }, + }, + } +} + +/** + * Returns true when an incoming V3 webhook event matches the configured trigger. + */ +export function isPagerDutyEventMatch(triggerId: string, eventType: string): boolean { + const expected = TRIGGER_EVENT_TYPES[triggerId] + if (!expected) { + return true + } + return expected === eventType +} diff --git a/apps/sim/triggers/pagerduty/webhook.ts b/apps/sim/triggers/pagerduty/webhook.ts new file mode 100644 index 00000000000..bbd338ab3f4 --- /dev/null +++ b/apps/sim/triggers/pagerduty/webhook.ts @@ -0,0 +1,32 @@ +import { PagerDutyIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildPagerDutyExtraFields, + buildPagerDutyIncidentOutputs, + pagerdutySetupInstructions, + pagerdutyTriggerOptions, +} from '@/triggers/pagerduty/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const pagerdutyWebhookTrigger: TriggerConfig = { + id: 'pagerduty_webhook', + name: 'PagerDuty Incident Event', + provider: 'pagerduty', + description: 'Trigger workflow from any PagerDuty incident event', + version: '1.0.0', + icon: PagerDutyIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'pagerduty_webhook', + triggerOptions: pagerdutyTriggerOptions, + setupInstructions: pagerdutySetupInstructions('all incident events'), + extraFields: buildPagerDutyExtraFields('pagerduty_webhook'), + }), + outputs: buildPagerDutyIncidentOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-PagerDuty-Signature': 'v1=...', + }, + }, +} diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index d388c8ab303..d2926ad7997 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -119,6 +119,14 @@ import { githubWebhookTrigger, githubWorkflowRunTrigger, } from '@/triggers/github' +import { + gitlabCommentTrigger, + gitlabIssueTrigger, + gitlabMergeRequestTrigger, + gitlabPipelineTrigger, + gitlabPushTrigger, + gitlabWebhookTrigger, +} from '@/triggers/gitlab' import { gmailPollingTrigger } from '@/triggers/gmail' import { gongCallCompletedTrigger, gongWebhookTrigger } from '@/triggers/gong' import { googleCalendarPollingTrigger } from '@/triggers/google-calendar' @@ -271,6 +279,14 @@ import { notionWebhookTrigger, } from '@/triggers/notion' import { outlookPollingTrigger } from '@/triggers/outlook' +import { + pagerdutyIncidentAcknowledgedTrigger, + pagerdutyIncidentEscalatedTrigger, + pagerdutyIncidentReassignedTrigger, + pagerdutyIncidentResolvedTrigger, + pagerdutyIncidentTriggeredTrigger, + pagerdutyWebhookTrigger, +} from '@/triggers/pagerduty' import { resendEmailBouncedTrigger, resendEmailClickedTrigger, @@ -326,6 +342,13 @@ import { webflowFormSubmissionTrigger, } from '@/triggers/webflow' import { whatsappWebhookTrigger } from '@/triggers/whatsapp' +import { + zendeskTicketCommentAddedTrigger, + zendeskTicketCreatedTrigger, + zendeskTicketPriorityChangedTrigger, + zendeskTicketStatusChangedTrigger, + zendeskWebhookTrigger, +} from '@/triggers/zendesk' import { zoomMeetingEndedTrigger, zoomMeetingStartedTrigger, @@ -443,6 +466,12 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { github_push: githubPushTrigger, github_release_published: githubReleasePublishedTrigger, github_workflow_run: githubWorkflowRunTrigger, + gitlab_push: gitlabPushTrigger, + gitlab_merge_request: gitlabMergeRequestTrigger, + gitlab_issue: gitlabIssueTrigger, + gitlab_pipeline: gitlabPipelineTrigger, + gitlab_comment: gitlabCommentTrigger, + gitlab_webhook: gitlabWebhookTrigger, fireflies_transcription_complete: firefliesTranscriptionCompleteTrigger, fathom_new_meeting: fathomNewMeetingTrigger, fathom_webhook: fathomWebhookTrigger, @@ -543,6 +572,12 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { notion_comment_created: notionCommentCreatedTrigger, notion_webhook: notionWebhookTrigger, outlook_poller: outlookPollingTrigger, + pagerduty_incident_triggered: pagerdutyIncidentTriggeredTrigger, + pagerduty_incident_acknowledged: pagerdutyIncidentAcknowledgedTrigger, + pagerduty_incident_resolved: pagerdutyIncidentResolvedTrigger, + pagerduty_incident_escalated: pagerdutyIncidentEscalatedTrigger, + pagerduty_incident_reassigned: pagerdutyIncidentReassignedTrigger, + pagerduty_webhook: pagerdutyWebhookTrigger, resend_email_sent: resendEmailSentTrigger, resend_email_delivered: resendEmailDeliveredTrigger, resend_email_bounced: resendEmailBouncedTrigger, @@ -614,6 +649,11 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { instantly_lead_no_show: instantlyLeadNoShowTrigger, instantly_supersearch_enrichment_completed: instantlySupersearchEnrichmentCompletedTrigger, zoom_meeting_started: zoomMeetingStartedTrigger, + zendesk_ticket_created: zendeskTicketCreatedTrigger, + zendesk_ticket_status_changed: zendeskTicketStatusChangedTrigger, + zendesk_ticket_comment_added: zendeskTicketCommentAddedTrigger, + zendesk_ticket_priority_changed: zendeskTicketPriorityChangedTrigger, + zendesk_webhook: zendeskWebhookTrigger, zoom_meeting_ended: zoomMeetingEndedTrigger, zoom_participant_joined: zoomParticipantJoinedTrigger, zoom_participant_left: zoomParticipantLeftTrigger, diff --git a/apps/sim/triggers/zendesk/index.ts b/apps/sim/triggers/zendesk/index.ts new file mode 100644 index 00000000000..fa1f1df1bf0 --- /dev/null +++ b/apps/sim/triggers/zendesk/index.ts @@ -0,0 +1,5 @@ +export { zendeskTicketCommentAddedTrigger } from './ticket_comment_added' +export { zendeskTicketCreatedTrigger } from './ticket_created' +export { zendeskTicketPriorityChangedTrigger } from './ticket_priority_changed' +export { zendeskTicketStatusChangedTrigger } from './ticket_status_changed' +export { zendeskWebhookTrigger } from './webhook' diff --git a/apps/sim/triggers/zendesk/ticket_comment_added.ts b/apps/sim/triggers/zendesk/ticket_comment_added.ts new file mode 100644 index 00000000000..ef8d7e922ef --- /dev/null +++ b/apps/sim/triggers/zendesk/ticket_comment_added.ts @@ -0,0 +1,33 @@ +import { ZendeskIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import type { TriggerConfig } from '@/triggers/types' +import { + buildZendeskExtraFields, + buildZendeskTicketOutputs, + zendeskSetupInstructions, + zendeskTriggerOptions, +} from '@/triggers/zendesk/utils' + +export const zendeskTicketCommentAddedTrigger: TriggerConfig = { + id: 'zendesk_ticket_comment_added', + name: 'Zendesk Ticket Comment Added', + provider: 'zendesk', + description: 'Trigger workflow when a comment is added to a Zendesk ticket', + version: '1.0.0', + icon: ZendeskIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'zendesk_ticket_comment_added', + triggerOptions: zendeskTriggerOptions, + setupInstructions: zendeskSetupInstructions('Ticket Comment Added'), + extraFields: buildZendeskExtraFields('zendesk_ticket_comment_added'), + }), + outputs: buildZendeskTicketOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Zendesk-Webhook-Signature': '...', + 'X-Zendesk-Webhook-Signature-Timestamp': '...', + }, + }, +} diff --git a/apps/sim/triggers/zendesk/ticket_created.ts b/apps/sim/triggers/zendesk/ticket_created.ts new file mode 100644 index 00000000000..a156afcf56c --- /dev/null +++ b/apps/sim/triggers/zendesk/ticket_created.ts @@ -0,0 +1,34 @@ +import { ZendeskIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import type { TriggerConfig } from '@/triggers/types' +import { + buildZendeskExtraFields, + buildZendeskTicketOutputs, + zendeskSetupInstructions, + zendeskTriggerOptions, +} from '@/triggers/zendesk/utils' + +export const zendeskTicketCreatedTrigger: TriggerConfig = { + id: 'zendesk_ticket_created', + name: 'Zendesk Ticket Created', + provider: 'zendesk', + description: 'Trigger workflow when a new ticket is created in Zendesk', + version: '1.0.0', + icon: ZendeskIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'zendesk_ticket_created', + triggerOptions: zendeskTriggerOptions, + includeDropdown: true, + setupInstructions: zendeskSetupInstructions('Ticket Created'), + extraFields: buildZendeskExtraFields('zendesk_ticket_created'), + }), + outputs: buildZendeskTicketOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Zendesk-Webhook-Signature': '...', + 'X-Zendesk-Webhook-Signature-Timestamp': '...', + }, + }, +} diff --git a/apps/sim/triggers/zendesk/ticket_priority_changed.ts b/apps/sim/triggers/zendesk/ticket_priority_changed.ts new file mode 100644 index 00000000000..df257b4ea1e --- /dev/null +++ b/apps/sim/triggers/zendesk/ticket_priority_changed.ts @@ -0,0 +1,33 @@ +import { ZendeskIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import type { TriggerConfig } from '@/triggers/types' +import { + buildZendeskExtraFields, + buildZendeskTicketOutputs, + zendeskSetupInstructions, + zendeskTriggerOptions, +} from '@/triggers/zendesk/utils' + +export const zendeskTicketPriorityChangedTrigger: TriggerConfig = { + id: 'zendesk_ticket_priority_changed', + name: 'Zendesk Ticket Priority Changed', + provider: 'zendesk', + description: 'Trigger workflow when a ticket priority changes in Zendesk', + version: '1.0.0', + icon: ZendeskIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'zendesk_ticket_priority_changed', + triggerOptions: zendeskTriggerOptions, + setupInstructions: zendeskSetupInstructions('Ticket Priority Changed'), + extraFields: buildZendeskExtraFields('zendesk_ticket_priority_changed'), + }), + outputs: buildZendeskTicketOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Zendesk-Webhook-Signature': '...', + 'X-Zendesk-Webhook-Signature-Timestamp': '...', + }, + }, +} diff --git a/apps/sim/triggers/zendesk/ticket_status_changed.ts b/apps/sim/triggers/zendesk/ticket_status_changed.ts new file mode 100644 index 00000000000..97d10aa5704 --- /dev/null +++ b/apps/sim/triggers/zendesk/ticket_status_changed.ts @@ -0,0 +1,33 @@ +import { ZendeskIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import type { TriggerConfig } from '@/triggers/types' +import { + buildZendeskExtraFields, + buildZendeskTicketOutputs, + zendeskSetupInstructions, + zendeskTriggerOptions, +} from '@/triggers/zendesk/utils' + +export const zendeskTicketStatusChangedTrigger: TriggerConfig = { + id: 'zendesk_ticket_status_changed', + name: 'Zendesk Ticket Status Changed', + provider: 'zendesk', + description: 'Trigger workflow when a ticket status changes in Zendesk', + version: '1.0.0', + icon: ZendeskIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'zendesk_ticket_status_changed', + triggerOptions: zendeskTriggerOptions, + setupInstructions: zendeskSetupInstructions('Ticket Status Changed'), + extraFields: buildZendeskExtraFields('zendesk_ticket_status_changed'), + }), + outputs: buildZendeskTicketOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Zendesk-Webhook-Signature': '...', + 'X-Zendesk-Webhook-Signature-Timestamp': '...', + }, + }, +} diff --git a/apps/sim/triggers/zendesk/utils.ts b/apps/sim/triggers/zendesk/utils.ts new file mode 100644 index 00000000000..98e8014536f --- /dev/null +++ b/apps/sim/triggers/zendesk/utils.ts @@ -0,0 +1,111 @@ +import type { SubBlockConfig } from '@/blocks/types' +import type { TriggerOutput } from '@/triggers/types' + +/** + * Shared trigger dropdown options for all Zendesk triggers + */ +export const zendeskTriggerOptions = [ + { label: 'Ticket Created', id: 'zendesk_ticket_created' }, + { label: 'Ticket Status Changed', id: 'zendesk_ticket_status_changed' }, + { label: 'Ticket Comment Added', id: 'zendesk_ticket_comment_added' }, + { label: 'Ticket Priority Changed', id: 'zendesk_ticket_priority_changed' }, + { label: 'All Ticket Events', id: 'zendesk_webhook' }, +] + +/** + * Maps each Zendesk trigger to the native event-subscription `type` it listens for. + * `zendesk_webhook` is intentionally absent — it matches every ticket event. + */ +const TRIGGER_EVENT_TYPES: Record = { + zendesk_ticket_created: 'zen:event-type:ticket.created', + zendesk_ticket_status_changed: 'zen:event-type:ticket.status_changed', + zendesk_ticket_comment_added: 'zen:event-type:ticket.comment_added', + zendesk_ticket_priority_changed: 'zen:event-type:ticket.priority_changed', +} + +/** + * Generate setup instructions for a specific Zendesk ticket event. + */ +export function zendeskSetupInstructions(eventLabel: string): string { + const instructions = [ + 'In Zendesk Admin Center, go to Apps and integrations > Webhooks > Webhooks and click Create webhook.', + 'Choose Connect with events, then select Ticket events as the source.', + `Subscribe to the ${eventLabel} event type.`, + 'Paste the Webhook URL above into the Endpoint URL field and save.', + 'Open the webhook and click Reveal secret, then paste it into the Signing Secret field above to verify deliveries.', + ] + return instructions + .map( + (instruction, index) => + `
${index + 1}. ${instruction}
` + ) + .join('') +} + +/** + * Signing secret field used to verify the X-Zendesk-Webhook-Signature HMAC. + */ +export function buildZendeskExtraFields(triggerId: string): SubBlockConfig[] { + return [ + { + id: 'webhookSecret', + title: 'Signing Secret', + type: 'short-input', + placeholder: 'Paste the webhook signing secret from Zendesk', + description: + 'Validates that webhook deliveries originate from Zendesk (X-Zendesk-Webhook-Signature).', + password: true, + required: false, + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + ] +} + +/** + * Output schema shared by every Zendesk ticket trigger — native + * event-subscription deliveries share the same envelope and `detail` shape. + */ +export function buildZendeskTicketOutputs(): Record { + return { + event_id: { type: 'string', description: 'Unique ID of the webhook event' }, + event_type: { + type: 'string', + description: 'Full event type (e.g. zen:event-type:ticket.created)', + }, + time: { type: 'string', description: 'When the event occurred (ISO 8601)' }, + account_id: { type: 'number', description: 'Zendesk account ID' }, + ticket: { + id: { type: 'string', description: 'Ticket ID' }, + subject: { type: 'string', description: 'Ticket subject' }, + status: { type: 'string', description: 'Ticket status (new, open, pending, solved, etc.)' }, + priority: { type: 'string', description: 'Ticket priority (low, normal, high, urgent)' }, + ticket_type: { + type: 'string', + description: 'Ticket type (question, incident, problem, task)', + }, + description: { type: 'string', description: 'Ticket description' }, + requester_id: { type: 'string', description: 'ID of the requester' }, + assignee_id: { type: 'string', description: 'ID of the assignee' }, + group_id: { type: 'string', description: 'ID of the assigned group' }, + organization_id: { type: 'string', description: 'ID of the organization' }, + tags: { type: 'json', description: 'Array of ticket tags' }, + via_channel: { type: 'string', description: 'Channel the ticket came in through' }, + is_public: { type: 'boolean', description: 'Whether the ticket is public' }, + created_at: { type: 'string', description: 'Ticket creation timestamp' }, + updated_at: { type: 'string', description: 'Ticket last update timestamp' }, + }, + event: { type: 'json', description: 'Event-specific changed data (e.g. status/priority diff)' }, + } +} + +/** + * Returns true when an incoming event-subscription delivery matches the configured trigger. + */ +export function isZendeskEventMatch(triggerId: string, eventType: string): boolean { + const expected = TRIGGER_EVENT_TYPES[triggerId] + if (!expected) { + return true + } + return expected === eventType +} diff --git a/apps/sim/triggers/zendesk/webhook.ts b/apps/sim/triggers/zendesk/webhook.ts new file mode 100644 index 00000000000..23390d6c83b --- /dev/null +++ b/apps/sim/triggers/zendesk/webhook.ts @@ -0,0 +1,33 @@ +import { ZendeskIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import type { TriggerConfig } from '@/triggers/types' +import { + buildZendeskExtraFields, + buildZendeskTicketOutputs, + zendeskSetupInstructions, + zendeskTriggerOptions, +} from '@/triggers/zendesk/utils' + +export const zendeskWebhookTrigger: TriggerConfig = { + id: 'zendesk_webhook', + name: 'Zendesk Ticket Event', + provider: 'zendesk', + description: 'Trigger workflow from any Zendesk ticket event', + version: '1.0.0', + icon: ZendeskIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'zendesk_webhook', + triggerOptions: zendeskTriggerOptions, + setupInstructions: zendeskSetupInstructions('the ticket events you want'), + extraFields: buildZendeskExtraFields('zendesk_webhook'), + }), + outputs: buildZendeskTicketOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Zendesk-Webhook-Signature': '...', + 'X-Zendesk-Webhook-Signature-Timestamp': '...', + }, + }, +} From c2e7e019534f5ec423261cfb23f7de25d06e7939 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 19 Jun 2026 23:42:44 -0700 Subject: [PATCH 2/6] fix(triggers): scope webhook secrets to owner and add Zendesk replay protection Address review feedback: - Add paramVisibility: 'user-only' to the webhookSecret fields for GitLab, PagerDuty, and Zendesk so signing secrets are scoped to the credential owner and not exposed to workspace collaborators (repo convention). - Reject Zendesk deliveries whose signed timestamp is more than 5 minutes from now, closing a replay window once an event id ages out of the idempotency cache. The X-Zendesk-Webhook-Signature-Timestamp header is ISO-8601, so it is parsed with Date.parse (matches the Slack handler's skew-check convention). --- apps/sim/lib/webhooks/providers/zendesk.ts | 21 +++++++++++++++++++++ apps/sim/triggers/gitlab/utils.ts | 1 + apps/sim/triggers/pagerduty/utils.ts | 1 + apps/sim/triggers/zendesk/utils.ts | 1 + 4 files changed, 24 insertions(+) diff --git a/apps/sim/lib/webhooks/providers/zendesk.ts b/apps/sim/lib/webhooks/providers/zendesk.ts index 9d8c04ec69f..681940c2ae1 100644 --- a/apps/sim/lib/webhooks/providers/zendesk.ts +++ b/apps/sim/lib/webhooks/providers/zendesk.ts @@ -16,6 +16,20 @@ function asRecord(value: unknown): Record { return (value as Record) || {} } +/** Maximum allowed clock skew (5 minutes) between Zendesk's signed timestamp and now, per Zendesk docs. */ +const ZENDESK_TIMESTAMP_MAX_SKEW_MS = 5 * 60 * 1000 + +/** + * Verify the signed timestamp is recent to prevent replay of captured deliveries. + * Zendesk sends `X-Zendesk-Webhook-Signature-Timestamp` as an ISO-8601 string + * (e.g. `2025-01-24T15:30:00.000Z`), so it is parsed with `Date.parse`. + */ +function isZendeskTimestampFresh(timestamp: string): boolean { + const signedAt = Date.parse(timestamp) + if (Number.isNaN(signedAt)) return false + return Math.abs(Date.now() - signedAt) <= ZENDESK_TIMESTAMP_MAX_SKEW_MS +} + /** * Zendesk signs `timestamp + rawBody` (no separator) with HMAC-SHA256 keyed by * the webhook's signing secret, then base64-encodes it into @@ -49,6 +63,13 @@ export const zendeskHandler: WebhookProviderHandler = { return new NextResponse('Unauthorized - Missing Zendesk signature', { status: 401 }) } + if (!isZendeskTimestampFresh(timestamp)) { + logger.warn(`[${requestId}] Zendesk webhook timestamp outside the allowed window`, { + timestamp, + }) + return new NextResponse('Unauthorized - Stale Zendesk timestamp', { status: 401 }) + } + if (!validateZendeskSignature(secret, signature, timestamp, rawBody)) { logger.warn(`[${requestId}] Zendesk signature verification failed`) return new NextResponse('Unauthorized - Invalid Zendesk signature', { status: 401 }) diff --git a/apps/sim/triggers/gitlab/utils.ts b/apps/sim/triggers/gitlab/utils.ts index b1dfc50251e..7d99c3b936d 100644 --- a/apps/sim/triggers/gitlab/utils.ts +++ b/apps/sim/triggers/gitlab/utils.ts @@ -59,6 +59,7 @@ export function buildGitLabExtraFields(triggerId: string): SubBlockConfig[] { placeholder: 'Generate or enter a strong secret token', description: 'Validates that webhook deliveries originate from GitLab (X-Gitlab-Token).', password: true, + paramVisibility: 'user-only', required: false, mode: 'trigger', condition: { field: 'selectedTriggerId', value: triggerId }, diff --git a/apps/sim/triggers/pagerduty/utils.ts b/apps/sim/triggers/pagerduty/utils.ts index 4cf1e46ced0..512771f5662 100644 --- a/apps/sim/triggers/pagerduty/utils.ts +++ b/apps/sim/triggers/pagerduty/utils.ts @@ -57,6 +57,7 @@ export function buildPagerDutyExtraFields(triggerId: string): SubBlockConfig[] { description: 'Validates that webhook deliveries originate from PagerDuty (X-PagerDuty-Signature).', password: true, + paramVisibility: 'user-only', required: false, mode: 'trigger', condition: { field: 'selectedTriggerId', value: triggerId }, diff --git a/apps/sim/triggers/zendesk/utils.ts b/apps/sim/triggers/zendesk/utils.ts index 98e8014536f..8f6989497b2 100644 --- a/apps/sim/triggers/zendesk/utils.ts +++ b/apps/sim/triggers/zendesk/utils.ts @@ -55,6 +55,7 @@ export function buildZendeskExtraFields(triggerId: string): SubBlockConfig[] { description: 'Validates that webhook deliveries originate from Zendesk (X-Zendesk-Webhook-Signature).', password: true, + paramVisibility: 'user-only', required: false, mode: 'trigger', condition: { field: 'selectedTriggerId', value: triggerId }, From 5aa6d7845c0b08a8fbe631a9b4662cf4e06d44f5 Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 20 Jun 2026 14:20:30 -0700 Subject: [PATCH 3/6] feat(triggers): auto-register GitLab, PagerDuty, and Zendesk webhooks Replace the manual-registration model with automatic webhook creation on deploy and cleanup on undeploy, via createSubscription/deleteSubscription on each provider handler: - GitLab: POST /projects/:id/hooks with a Personal Access Token; generates the secret token (stored for X-Gitlab-Token verification) and enables only the event flags for the selected trigger. Deletes the hook on undeploy. - PagerDuty: POST /webhook_subscriptions (account-scoped) with a REST API key; captures delivery_method.secret (returned only on create) for X-PagerDuty-Signature verification. Deletes the subscription on undeploy. - Zendesk: POST /api/v2/webhooks with native event subscriptions, then GET /webhooks/:id/signing_secret for X-Zendesk-Webhook-Signature verification. Deletes the webhook on undeploy. Trigger config now collects the provider credentials (user-only) instead of a pasted signing secret; the signing secret is generated or fetched and stored in providerConfig by the orchestration layer (no route/deploy changes). --- apps/sim/lib/webhooks/providers/gitlab.ts | 87 +++++++++++++++ apps/sim/lib/webhooks/providers/pagerduty.ts | 92 +++++++++++++++ apps/sim/lib/webhooks/providers/zendesk.ts | 111 +++++++++++++++++++ apps/sim/triggers/gitlab/comment.ts | 2 +- apps/sim/triggers/gitlab/issue.ts | 2 +- apps/sim/triggers/gitlab/merge_request.ts | 2 +- apps/sim/triggers/gitlab/pipeline.ts | 2 +- apps/sim/triggers/gitlab/push.ts | 2 +- apps/sim/triggers/gitlab/utils.ts | 68 +++++++++--- apps/sim/triggers/gitlab/webhook.ts | 2 +- apps/sim/triggers/pagerduty/utils.ts | 34 +++--- apps/sim/triggers/zendesk/utils.ts | 54 ++++++--- 12 files changed, 410 insertions(+), 48 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/gitlab.ts b/apps/sim/lib/webhooks/providers/gitlab.ts index 72f2fd70b73..711306643db 100644 --- a/apps/sim/lib/webhooks/providers/gitlab.ts +++ b/apps/sim/lib/webhooks/providers/gitlab.ts @@ -1,20 +1,31 @@ import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' +import { generateId } from '@sim/utils/id' import { NextResponse } from 'next/server' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { AuthContext, + DeleteSubscriptionContext, EventMatchContext, FormatInputContext, FormatInputResult, + SubscriptionContext, + SubscriptionResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' const logger = createLogger('WebhookProvider:GitLab') +const GITLAB_API_BASE = 'https://gitlab.com/api/v4' + function asRecord(value: unknown): Record { return (value as Record) || {} } +function gitlabProjectHooksUrl(projectId: string): string { + return `${GITLAB_API_BASE}/projects/${encodeURIComponent(projectId)}/hooks` +} + export const gitlabHandler: WebhookProviderHandler = { /** * GitLab echoes the configured "Secret token" verbatim in the `X-Gitlab-Token` @@ -65,4 +76,80 @@ export const gitlabHandler: WebhookProviderHandler = { input: { ...b, event_type: eventType, branch }, } }, + + async createSubscription(ctx: SubscriptionContext): Promise { + const config = getProviderConfig(ctx.webhook) + const accessToken = config.accessToken as string | undefined + const projectId = config.projectId as string | undefined + const triggerId = config.triggerId as string | undefined + + if (!accessToken) + throw new Error('GitLab Personal Access Token is required to create the webhook.') + if (!projectId) throw new Error('GitLab Project ID is required to create the webhook.') + + const { getGitLabEventFlags } = await import('@/triggers/gitlab/utils') + const secretToken = generateId() + const res = await fetch(gitlabProjectHooksUrl(projectId), { + method: 'POST', + headers: { 'PRIVATE-TOKEN': accessToken, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: getNotificationUrl(ctx.webhook), + token: secretToken, + enable_ssl_verification: true, + ...getGitLabEventFlags(triggerId ?? 'gitlab_webhook'), + }), + }) + + if (!res.ok) { + const detail = await res.text().catch(() => '') + logger.error(`[${ctx.requestId}] Failed to create GitLab webhook (${res.status})`, { detail }) + if (res.status === 401) + throw new Error( + 'GitLab authentication failed. Verify your Personal Access Token has the api scope.' + ) + if (res.status === 403) + throw new Error( + 'GitLab access denied. You need the Maintainer or Owner role on the project.' + ) + if (res.status === 404) throw new Error('GitLab project not found. Verify the Project ID.') + throw new Error(`Failed to create GitLab webhook: ${res.status}`) + } + + const created = (await res.json().catch(() => ({}))) as { id?: number | string } + if (created.id === undefined || created.id === null) { + throw new Error('GitLab webhook created but no hook ID was returned.') + } + + logger.info(`[${ctx.requestId}] Created GitLab webhook ${created.id} for project ${projectId}`) + return { providerConfigUpdates: { externalId: String(created.id), webhookSecret: secretToken } } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + const config = getProviderConfig(ctx.webhook) + const accessToken = config.accessToken as string | undefined + const projectId = config.projectId as string | undefined + const externalId = config.externalId as string | undefined + + if (!accessToken || !projectId || !externalId) { + if (ctx.strict) throw new Error('Missing GitLab credentials or hook ID for webhook deletion.') + logger.warn( + `[${ctx.requestId}] Skipping GitLab webhook cleanup — missing token, project, or hook ID` + ) + return + } + + const res = await fetch(`${gitlabProjectHooksUrl(projectId)}/${externalId}`, { + method: 'DELETE', + headers: { 'PRIVATE-TOKEN': accessToken }, + }) + + if (!res.ok && res.status !== 404) { + if (ctx.strict) throw new Error(`Failed to delete GitLab webhook: ${res.status}`) + logger.warn( + `[${ctx.requestId}] Failed to delete GitLab webhook ${externalId} (non-fatal): ${res.status}` + ) + return + } + logger.info(`[${ctx.requestId}] Deleted GitLab webhook ${externalId}`) + }, } diff --git a/apps/sim/lib/webhooks/providers/pagerduty.ts b/apps/sim/lib/webhooks/providers/pagerduty.ts index 1e39925812b..dfec45c9734 100644 --- a/apps/sim/lib/webhooks/providers/pagerduty.ts +++ b/apps/sim/lib/webhooks/providers/pagerduty.ts @@ -1,16 +1,31 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { + DeleteSubscriptionContext, EventMatchContext, FormatInputContext, FormatInputResult, + SubscriptionContext, + SubscriptionResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' import { createHmacVerifier } from '@/lib/webhooks/providers/utils' const logger = createLogger('WebhookProvider:PagerDuty') +const PAGERDUTY_API_BASE = 'https://api.pagerduty.com' + +/** Shared headers for PagerDuty REST API calls (the v2 Accept header is required). */ +function pagerdutyHeaders(apiKey: string): Record { + return { + Authorization: `Token token=${apiKey}`, + 'Content-Type': 'application/json', + Accept: 'application/vnd.pagerduty+json;version=2', + } +} + /** * PagerDuty V3 signs the raw body with HMAC-SHA256 and sends it in the * `X-PagerDuty-Signature` header as one or more comma-separated `v1=` @@ -96,4 +111,81 @@ export const pagerdutyHandler: WebhookProviderHandler = { const event = asRecord(asRecord(body).event) return (event.id as string | undefined) || null }, + + async createSubscription(ctx: SubscriptionContext): Promise { + const config = getProviderConfig(ctx.webhook) + const apiKey = config.apiKey as string | undefined + const triggerId = config.triggerId as string | undefined + + if (!apiKey) + throw new Error('PagerDuty API Key is required to create the webhook subscription.') + + const { getPagerDutyEvents } = await import('@/triggers/pagerduty/utils') + const res = await fetch(`${PAGERDUTY_API_BASE}/webhook_subscriptions`, { + method: 'POST', + headers: pagerdutyHeaders(apiKey), + body: JSON.stringify({ + webhook_subscription: { + type: 'webhook_subscription', + delivery_method: { type: 'http_delivery_method', url: getNotificationUrl(ctx.webhook) }, + events: getPagerDutyEvents(triggerId ?? 'pagerduty_webhook'), + filter: { type: 'account_reference' }, + }, + }), + }) + + if (!res.ok) { + const detail = await res.text().catch(() => '') + logger.error(`[${ctx.requestId}] Failed to create PagerDuty webhook (${res.status})`, { + detail, + }) + if (res.status === 401) + throw new Error('PagerDuty authentication failed. Verify your REST API key.') + if (res.status === 403) + throw new Error('PagerDuty access denied. The API key must have read/write access.') + throw new Error(`Failed to create PagerDuty webhook subscription: ${res.status}`) + } + + const created = asRecord((await res.json().catch(() => ({}))) as unknown) + const subscription = asRecord(created.webhook_subscription) + const externalId = subscription.id as string | undefined + const secret = asRecord(subscription.delivery_method).secret as string | undefined + + if (!externalId) + throw new Error('PagerDuty webhook created but no subscription ID was returned.') + if (!secret) { + throw new Error('PagerDuty webhook created but no signing secret was returned on creation.') + } + + logger.info(`[${ctx.requestId}] Created PagerDuty webhook subscription ${externalId}`) + return { providerConfigUpdates: { externalId, webhookSecret: secret } } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + const config = getProviderConfig(ctx.webhook) + const apiKey = config.apiKey as string | undefined + const externalId = config.externalId as string | undefined + + if (!apiKey || !externalId) { + if (ctx.strict) throw new Error('Missing PagerDuty API key or subscription ID for deletion.') + logger.warn( + `[${ctx.requestId}] Skipping PagerDuty webhook cleanup — missing API key or subscription ID` + ) + return + } + + const res = await fetch(`${PAGERDUTY_API_BASE}/webhook_subscriptions/${externalId}`, { + method: 'DELETE', + headers: pagerdutyHeaders(apiKey), + }) + + if (!res.ok && res.status !== 404) { + if (ctx.strict) throw new Error(`Failed to delete PagerDuty webhook: ${res.status}`) + logger.warn( + `[${ctx.requestId}] Failed to delete PagerDuty webhook ${externalId} (non-fatal): ${res.status}` + ) + return + } + logger.info(`[${ctx.requestId}] Deleted PagerDuty webhook subscription ${externalId}`) + }, } diff --git a/apps/sim/lib/webhooks/providers/zendesk.ts b/apps/sim/lib/webhooks/providers/zendesk.ts index 681940c2ae1..a33479b269a 100644 --- a/apps/sim/lib/webhooks/providers/zendesk.ts +++ b/apps/sim/lib/webhooks/providers/zendesk.ts @@ -2,11 +2,15 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' import { NextResponse } from 'next/server' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { AuthContext, + DeleteSubscriptionContext, EventMatchContext, FormatInputContext, FormatInputResult, + SubscriptionContext, + SubscriptionResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' @@ -16,6 +20,16 @@ function asRecord(value: unknown): Record { return (value as Record) || {} } +/** Zendesk API base for a subdomain. */ +function zendeskApiBase(subdomain: string): string { + return `https://${subdomain}.zendesk.com/api/v2` +} + +/** Basic auth header for the Zendesk API-token scheme (`email/token:apiToken`). */ +function zendeskAuthHeader(email: string, apiToken: string): string { + return `Basic ${Buffer.from(`${email}/token:${apiToken}`).toString('base64')}` +} + /** Maximum allowed clock skew (5 minutes) between Zendesk's signed timestamp and now, per Zendesk docs. */ const ZENDESK_TIMESTAMP_MAX_SKEW_MS = 5 * 60 * 1000 @@ -130,4 +144,101 @@ export const zendeskHandler: WebhookProviderHandler = { extractIdempotencyId(body: unknown) { return (asRecord(body).id as string | undefined) || null }, + + async createSubscription(ctx: SubscriptionContext): Promise { + const config = getProviderConfig(ctx.webhook) + const subdomain = config.subdomain as string | undefined + const email = config.email as string | undefined + const apiToken = config.apiToken as string | undefined + const triggerId = config.triggerId as string | undefined + + if (!subdomain) throw new Error('Zendesk subdomain is required to create the webhook.') + if (!email) throw new Error('Zendesk admin email is required to create the webhook.') + if (!apiToken) throw new Error('Zendesk API token is required to create the webhook.') + + const { getZendeskSubscriptions } = await import('@/triggers/zendesk/utils') + const apiBase = zendeskApiBase(subdomain) + const authHeader = zendeskAuthHeader(email, apiToken) + + const createRes = await fetch(`${apiBase}/webhooks`, { + method: 'POST', + headers: { Authorization: authHeader, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + webhook: { + name: `Sim webhook (${ctx.webhook.id})`, + endpoint: getNotificationUrl(ctx.webhook), + http_method: 'POST', + request_format: 'json', + status: 'active', + subscriptions: getZendeskSubscriptions(triggerId ?? 'zendesk_webhook'), + }, + }), + }) + + if (!createRes.ok) { + const detail = await createRes.text().catch(() => '') + logger.error(`[${ctx.requestId}] Failed to create Zendesk webhook (${createRes.status})`, { + detail, + }) + if (createRes.status === 401 || createRes.status === 403) { + throw new Error( + 'Zendesk authentication failed. Verify the subdomain, admin email, and API token.' + ) + } + throw new Error(`Failed to create Zendesk webhook: ${createRes.status}`) + } + + const created = asRecord((await createRes.json().catch(() => ({}))) as unknown) + const externalId = asRecord(created.webhook).id as string | undefined + if (!externalId) throw new Error('Zendesk webhook created but no webhook ID was returned.') + + const secretRes = await fetch(`${apiBase}/webhooks/${externalId}/signing_secret`, { + headers: { Authorization: authHeader }, + }) + if (!secretRes.ok) { + const detail = await secretRes.text().catch(() => '') + logger.error( + `[${ctx.requestId}] Created Zendesk webhook ${externalId} but failed to fetch signing secret (${secretRes.status})`, + { detail } + ) + throw new Error(`Failed to fetch Zendesk signing secret: ${secretRes.status}`) + } + + const secretBody = asRecord((await secretRes.json().catch(() => ({}))) as unknown) + const secret = asRecord(secretBody.signing_secret).secret as string | undefined + if (!secret) throw new Error('Zendesk did not return a signing secret for the webhook.') + + logger.info(`[${ctx.requestId}] Created Zendesk webhook ${externalId}`) + return { providerConfigUpdates: { externalId, webhookSecret: secret } } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + const config = getProviderConfig(ctx.webhook) + const subdomain = config.subdomain as string | undefined + const email = config.email as string | undefined + const apiToken = config.apiToken as string | undefined + const externalId = config.externalId as string | undefined + + if (!subdomain || !email || !apiToken || !externalId) { + if (ctx.strict) throw new Error('Missing Zendesk credentials or webhook ID for deletion.') + logger.warn( + `[${ctx.requestId}] Skipping Zendesk webhook cleanup — missing credentials or webhook ID` + ) + return + } + + const res = await fetch(`${zendeskApiBase(subdomain)}/webhooks/${externalId}`, { + method: 'DELETE', + headers: { Authorization: zendeskAuthHeader(email, apiToken) }, + }) + + if (!res.ok && res.status !== 404) { + if (ctx.strict) throw new Error(`Failed to delete Zendesk webhook: ${res.status}`) + logger.warn( + `[${ctx.requestId}] Failed to delete Zendesk webhook ${externalId} (non-fatal): ${res.status}` + ) + return + } + logger.info(`[${ctx.requestId}] Deleted Zendesk webhook ${externalId}`) + }, } diff --git a/apps/sim/triggers/gitlab/comment.ts b/apps/sim/triggers/gitlab/comment.ts index 4c0c7c6eb14..1435f370ab4 100644 --- a/apps/sim/triggers/gitlab/comment.ts +++ b/apps/sim/triggers/gitlab/comment.ts @@ -18,7 +18,7 @@ export const gitlabCommentTrigger: TriggerConfig = { subBlocks: buildTriggerSubBlocks({ triggerId: 'gitlab_comment', triggerOptions: gitlabTriggerOptions, - setupInstructions: gitlabSetupInstructions('Comment', 'Comments'), + setupInstructions: gitlabSetupInstructions('Comment'), extraFields: buildGitLabExtraFields('gitlab_comment'), }), outputs: buildGitLabCommentOutputs(), diff --git a/apps/sim/triggers/gitlab/issue.ts b/apps/sim/triggers/gitlab/issue.ts index 041ec33936a..a9fd802ba2a 100644 --- a/apps/sim/triggers/gitlab/issue.ts +++ b/apps/sim/triggers/gitlab/issue.ts @@ -18,7 +18,7 @@ export const gitlabIssueTrigger: TriggerConfig = { subBlocks: buildTriggerSubBlocks({ triggerId: 'gitlab_issue', triggerOptions: gitlabTriggerOptions, - setupInstructions: gitlabSetupInstructions('Issue', 'Issues events'), + setupInstructions: gitlabSetupInstructions('Issue'), extraFields: buildGitLabExtraFields('gitlab_issue'), }), outputs: buildGitLabIssueOutputs(), diff --git a/apps/sim/triggers/gitlab/merge_request.ts b/apps/sim/triggers/gitlab/merge_request.ts index 3a9476c9850..cdba6a4c178 100644 --- a/apps/sim/triggers/gitlab/merge_request.ts +++ b/apps/sim/triggers/gitlab/merge_request.ts @@ -18,7 +18,7 @@ export const gitlabMergeRequestTrigger: TriggerConfig = { subBlocks: buildTriggerSubBlocks({ triggerId: 'gitlab_merge_request', triggerOptions: gitlabTriggerOptions, - setupInstructions: gitlabSetupInstructions('Merge Request', 'Merge request events'), + setupInstructions: gitlabSetupInstructions('Merge Request'), extraFields: buildGitLabExtraFields('gitlab_merge_request'), }), outputs: buildGitLabMergeRequestOutputs(), diff --git a/apps/sim/triggers/gitlab/pipeline.ts b/apps/sim/triggers/gitlab/pipeline.ts index 408775828f8..2999bf599cc 100644 --- a/apps/sim/triggers/gitlab/pipeline.ts +++ b/apps/sim/triggers/gitlab/pipeline.ts @@ -18,7 +18,7 @@ export const gitlabPipelineTrigger: TriggerConfig = { subBlocks: buildTriggerSubBlocks({ triggerId: 'gitlab_pipeline', triggerOptions: gitlabTriggerOptions, - setupInstructions: gitlabSetupInstructions('Pipeline', 'Pipeline events'), + setupInstructions: gitlabSetupInstructions('Pipeline'), extraFields: buildGitLabExtraFields('gitlab_pipeline'), }), outputs: buildGitLabPipelineOutputs(), diff --git a/apps/sim/triggers/gitlab/push.ts b/apps/sim/triggers/gitlab/push.ts index 455d1c708e2..1a93bdf2791 100644 --- a/apps/sim/triggers/gitlab/push.ts +++ b/apps/sim/triggers/gitlab/push.ts @@ -19,7 +19,7 @@ export const gitlabPushTrigger: TriggerConfig = { triggerId: 'gitlab_push', triggerOptions: gitlabTriggerOptions, includeDropdown: true, - setupInstructions: gitlabSetupInstructions('Push', 'Push events'), + setupInstructions: gitlabSetupInstructions('Push'), extraFields: buildGitLabExtraFields('gitlab_push'), }), outputs: buildGitLabPushOutputs(), diff --git a/apps/sim/triggers/gitlab/utils.ts b/apps/sim/triggers/gitlab/utils.ts index 7d99c3b936d..27e2d188192 100644 --- a/apps/sim/triggers/gitlab/utils.ts +++ b/apps/sim/triggers/gitlab/utils.ts @@ -26,18 +26,43 @@ const TRIGGER_OBJECT_KINDS: Record = { } /** - * Generate setup instructions for a specific GitLab webhook event. - * - * @param triggerLabel - Friendly event name shown to the user. - * @param checkboxLabel - The exact checkbox label in the GitLab "Trigger" section. + * Boolean event flags sent to the GitLab project-hooks API, keyed by trigger. + * `gitlab_webhook` subscribes to every supported event. */ -export function gitlabSetupInstructions(triggerLabel: string, checkboxLabel: string): string { +const ALL_EVENT_FLAGS = { + push_events: true, + merge_requests_events: true, + issues_events: true, + pipeline_events: true, + note_events: true, + tag_push_events: true, +} as const + +const TRIGGER_EVENT_FLAGS: Record> = { + gitlab_push: { push_events: true }, + gitlab_merge_request: { merge_requests_events: true }, + gitlab_issue: { issues_events: true }, + gitlab_pipeline: { pipeline_events: true }, + gitlab_comment: { note_events: true }, +} + +/** + * Returns the GitLab hook event flags to enable for a given trigger. + */ +export function getGitLabEventFlags(triggerId: string): Record { + return TRIGGER_EVENT_FLAGS[triggerId] ?? { ...ALL_EVENT_FLAGS } +} + +/** + * Generate setup instructions for a specific GitLab webhook event. The webhook + * is created automatically on deploy, so the user only supplies credentials. + */ +export function gitlabSetupInstructions(eventLabel: string): string { const instructions = [ - 'In GitLab, go to your Project > Settings > Webhooks and click Add new webhook.', - 'Paste the Webhook URL above into the URL field.', - 'Enter the same Secret token you set above so deliveries can be verified.', - `Under Trigger, enable ${checkboxLabel}.`, - 'Click Add webhook to save.', + 'Create a Personal Access Token with the api scope under GitLab > Settings > Access Tokens.', + 'Enter the token and your Project ID (numeric ID or group/project path) above.', + `Deploy the workflow — Sim creates the webhook in GitLab automatically and starts listening for ${eventLabel} events.`, + 'Undeploying the workflow removes the webhook from GitLab.', ] return instructions .map( @@ -48,19 +73,30 @@ export function gitlabSetupInstructions(triggerLabel: string, checkboxLabel: str } /** - * Secret token field used to verify the X-Gitlab-Token header. + * Credentials Sim uses to create and delete the GitLab project webhook. */ export function buildGitLabExtraFields(triggerId: string): SubBlockConfig[] { return [ { - id: 'webhookSecret', - title: 'Secret Token', + id: 'accessToken', + title: 'Personal Access Token', type: 'short-input', - placeholder: 'Generate or enter a strong secret token', - description: 'Validates that webhook deliveries originate from GitLab (X-Gitlab-Token).', + placeholder: 'GitLab PAT with the api scope', + description: + 'Used to create the webhook in your project. Requires the Maintainer or Owner role.', password: true, paramVisibility: 'user-only', - required: false, + required: true, + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + { + id: 'projectId', + title: 'Project ID', + type: 'short-input', + placeholder: 'Numeric ID or group/project path', + description: 'The GitLab project to register the webhook on.', + required: true, mode: 'trigger', condition: { field: 'selectedTriggerId', value: triggerId }, }, diff --git a/apps/sim/triggers/gitlab/webhook.ts b/apps/sim/triggers/gitlab/webhook.ts index 0b132a42c24..a6e5cbdef90 100644 --- a/apps/sim/triggers/gitlab/webhook.ts +++ b/apps/sim/triggers/gitlab/webhook.ts @@ -18,7 +18,7 @@ export const gitlabWebhookTrigger: TriggerConfig = { subBlocks: buildTriggerSubBlocks({ triggerId: 'gitlab_webhook', triggerOptions: gitlabTriggerOptions, - setupInstructions: gitlabSetupInstructions('All Events', 'the events you want'), + setupInstructions: gitlabSetupInstructions('all'), extraFields: buildGitLabExtraFields('gitlab_webhook'), }), outputs: buildGitLabWebhookOutputs(), diff --git a/apps/sim/triggers/pagerduty/utils.ts b/apps/sim/triggers/pagerduty/utils.ts index 512771f5662..18e175d590c 100644 --- a/apps/sim/triggers/pagerduty/utils.ts +++ b/apps/sim/triggers/pagerduty/utils.ts @@ -26,15 +26,24 @@ const TRIGGER_EVENT_TYPES: Record = { } /** - * Generate setup instructions for a specific PagerDuty incident event. + * Returns the V3 webhook event types to subscribe to for a given trigger. + * `pagerduty_webhook` subscribes to every supported incident event. + */ +export function getPagerDutyEvents(triggerId: string): string[] { + const specific = TRIGGER_EVENT_TYPES[triggerId] + return specific ? [specific] : Object.values(TRIGGER_EVENT_TYPES) +} + +/** + * Generate setup instructions for a specific PagerDuty incident event. The + * webhook is created automatically on deploy, so the user only supplies an API key. */ export function pagerdutySetupInstructions(eventLabel: string): string { const instructions = [ - 'In PagerDuty, go to Integrations > Generic Webhooks (v3) and click New Webhook.', - 'Paste the Webhook URL above into the Webhook URL field.', - 'Scope the webhook to your account, service, or team as needed.', - `Under Event Subscription, select ${eventLabel}.`, - 'After saving, PagerDuty shows a signing secret once — copy it and paste it into the Signing Secret field above to verify deliveries.', + 'Create a General Access REST API Key under PagerDuty > Integrations > API Access Keys.', + 'Enter the API key above.', + `Deploy the workflow — Sim creates the account-level webhook subscription in PagerDuty automatically and listens for ${eventLabel}.`, + 'Undeploying the workflow removes the webhook subscription from PagerDuty.', ] return instructions .map( @@ -45,20 +54,19 @@ export function pagerdutySetupInstructions(eventLabel: string): string { } /** - * Signing secret field used to verify the X-PagerDuty-Signature HMAC. + * API key Sim uses to create and delete the PagerDuty webhook subscription. */ export function buildPagerDutyExtraFields(triggerId: string): SubBlockConfig[] { return [ { - id: 'webhookSecret', - title: 'Signing Secret', + id: 'apiKey', + title: 'API Key', type: 'short-input', - placeholder: 'Paste the signing secret shown when you created the webhook', - description: - 'Validates that webhook deliveries originate from PagerDuty (X-PagerDuty-Signature).', + placeholder: 'PagerDuty General Access REST API key', + description: 'Used to create the webhook subscription. Must be a read/write REST API key.', password: true, paramVisibility: 'user-only', - required: false, + required: true, mode: 'trigger', condition: { field: 'selectedTriggerId', value: triggerId }, }, diff --git a/apps/sim/triggers/zendesk/utils.ts b/apps/sim/triggers/zendesk/utils.ts index 8f6989497b2..1d737240025 100644 --- a/apps/sim/triggers/zendesk/utils.ts +++ b/apps/sim/triggers/zendesk/utils.ts @@ -24,15 +24,24 @@ const TRIGGER_EVENT_TYPES: Record = { } /** - * Generate setup instructions for a specific Zendesk ticket event. + * Returns the native event-subscription types for a given trigger. + * `zendesk_webhook` subscribes to every supported ticket event. + */ +export function getZendeskSubscriptions(triggerId: string): string[] { + const specific = TRIGGER_EVENT_TYPES[triggerId] + return specific ? [specific] : Object.values(TRIGGER_EVENT_TYPES) +} + +/** + * Generate setup instructions for a specific Zendesk ticket event. The webhook + * is created automatically on deploy, so the user only supplies API credentials. */ export function zendeskSetupInstructions(eventLabel: string): string { const instructions = [ - 'In Zendesk Admin Center, go to Apps and integrations > Webhooks > Webhooks and click Create webhook.', - 'Choose Connect with events, then select Ticket events as the source.', - `Subscribe to the ${eventLabel} event type.`, - 'Paste the Webhook URL above into the Endpoint URL field and save.', - 'Open the webhook and click Reveal secret, then paste it into the Signing Secret field above to verify deliveries.', + 'Enable token access under Zendesk Admin Center > Apps and integrations > APIs > Zendesk API and create an API token.', + 'Enter your subdomain (from subdomain.zendesk.com), the admin email, and the API token above.', + `Deploy the workflow — Sim creates the event-subscription webhook in Zendesk automatically and listens for ${eventLabel}.`, + 'Undeploying the workflow removes the webhook from Zendesk.', ] return instructions .map( @@ -43,20 +52,39 @@ export function zendeskSetupInstructions(eventLabel: string): string { } /** - * Signing secret field used to verify the X-Zendesk-Webhook-Signature HMAC. + * Credentials Sim uses to create and delete the Zendesk webhook (admin-scoped). */ export function buildZendeskExtraFields(triggerId: string): SubBlockConfig[] { return [ { - id: 'webhookSecret', - title: 'Signing Secret', + id: 'subdomain', + title: 'Subdomain', + type: 'short-input', + placeholder: 'yourcompany (from yourcompany.zendesk.com)', + description: 'Your Zendesk subdomain.', + required: true, + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + { + id: 'email', + title: 'Admin Email', + type: 'short-input', + placeholder: 'admin@yourcompany.com', + description: 'Email of a Zendesk admin used with the API token.', + required: true, + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + { + id: 'apiToken', + title: 'API Token', type: 'short-input', - placeholder: 'Paste the webhook signing secret from Zendesk', - description: - 'Validates that webhook deliveries originate from Zendesk (X-Zendesk-Webhook-Signature).', + placeholder: 'Zendesk API token', + description: 'Used to create the webhook. Requires admin access.', password: true, paramVisibility: 'user-only', - required: false, + required: true, mode: 'trigger', condition: { field: 'selectedTriggerId', value: triggerId }, }, From 3c6cdd54d0f113ba2a0d4e3514fc0dabc802f6cf Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 20 Jun 2026 14:30:15 -0700 Subject: [PATCH 4/6] fix(triggers): fail closed on missing webhook secret and clean up Zendesk orphans Address review feedback on the auto-registration changes: - verifyAuth now rejects (401) when webhookSecret is absent for GitLab, PagerDuty, and Zendesk. Since the secret is generated/fetched during auto-registration and stored before the webhook can receive deliveries, a missing secret indicates misconfiguration and must fail closed rather than skip signature verification. Adds an opt-in requireSecret flag to createHmacVerifier (default off, preserving behavior for other providers). - Zendesk createSubscription now deletes the just-created webhook if the follow-up signing-secret fetch fails, avoiding an orphaned subscription in Zendesk when setup cannot complete. --- apps/sim/lib/webhooks/providers/gitlab.ts | 6 +++-- apps/sim/lib/webhooks/providers/pagerduty.ts | 3 +++ apps/sim/lib/webhooks/providers/utils.ts | 13 +++++++++++ apps/sim/lib/webhooks/providers/zendesk.ts | 24 ++++++++++++++++++-- 4 files changed, 42 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/gitlab.ts b/apps/sim/lib/webhooks/providers/gitlab.ts index 711306643db..4b2aa3ba423 100644 --- a/apps/sim/lib/webhooks/providers/gitlab.ts +++ b/apps/sim/lib/webhooks/providers/gitlab.ts @@ -29,12 +29,14 @@ function gitlabProjectHooksUrl(projectId: string): string { export const gitlabHandler: WebhookProviderHandler = { /** * GitLab echoes the configured "Secret token" verbatim in the `X-Gitlab-Token` - * header (plain equality, not an HMAC). Skip verification when no token is set. + * header (plain equality, not an HMAC). The secret is generated during + * auto-registration, so a missing secret means misconfiguration — fail closed. */ verifyAuth({ request, requestId, providerConfig }: AuthContext) { const secret = providerConfig.webhookSecret as string | undefined if (!secret) { - return null + logger.warn(`[${requestId}] GitLab webhook secret not configured`) + return new NextResponse('Unauthorized - Missing GitLab webhook secret', { status: 401 }) } const token = request.headers.get('X-Gitlab-Token') diff --git a/apps/sim/lib/webhooks/providers/pagerduty.ts b/apps/sim/lib/webhooks/providers/pagerduty.ts index dfec45c9734..736808a447b 100644 --- a/apps/sim/lib/webhooks/providers/pagerduty.ts +++ b/apps/sim/lib/webhooks/providers/pagerduty.ts @@ -60,6 +60,9 @@ export const pagerdutyHandler: WebhookProviderHandler = { headerName: 'X-PagerDuty-Signature', validateFn: validatePagerDutySignature, providerLabel: 'PagerDuty', + // The signing secret is captured during auto-registration, so a missing + // secret means misconfiguration — fail closed rather than skip verification. + requireSecret: true, }), async matchEvent({ body, requestId, providerConfig }: EventMatchContext) { diff --git a/apps/sim/lib/webhooks/providers/utils.ts b/apps/sim/lib/webhooks/providers/utils.ts index dff3db7ce1e..43ff2b45fed 100644 --- a/apps/sim/lib/webhooks/providers/utils.ts +++ b/apps/sim/lib/webhooks/providers/utils.ts @@ -11,6 +11,12 @@ interface HmacVerifierOptions { headerName: string validateFn: (secret: string, signature: string, rawBody: string) => boolean | Promise providerLabel: string + /** + * When true, reject (401) if no secret is configured instead of skipping + * verification. Use for providers where the secret is always present (e.g. + * auto-registered webhooks) so a missing secret fails closed. + */ + requireSecret?: boolean } /** @@ -22,6 +28,7 @@ export function createHmacVerifier({ headerName, validateFn, providerLabel, + requireSecret = false, }: HmacVerifierOptions) { return async ({ request, @@ -31,6 +38,12 @@ export function createHmacVerifier({ }: AuthContext): Promise => { const secret = providerConfig[configKey] as string | undefined if (!secret) { + if (requireSecret) { + logger.warn(`[${requestId}] ${providerLabel} webhook secret not configured`) + return new NextResponse(`Unauthorized - Missing ${providerLabel} webhook secret`, { + status: 401, + }) + } return null } diff --git a/apps/sim/lib/webhooks/providers/zendesk.ts b/apps/sim/lib/webhooks/providers/zendesk.ts index a33479b269a..54c50b914b0 100644 --- a/apps/sim/lib/webhooks/providers/zendesk.ts +++ b/apps/sim/lib/webhooks/providers/zendesk.ts @@ -30,6 +30,18 @@ function zendeskAuthHeader(email: string, apiToken: string): string { return `Basic ${Buffer.from(`${email}/token:${apiToken}`).toString('base64')}` } +/** Best-effort delete used to avoid orphaning a webhook when post-create setup fails. */ +async function deleteZendeskWebhookQuietly( + apiBase: string, + authHeader: string, + webhookId: string +): Promise { + await fetch(`${apiBase}/webhooks/${webhookId}`, { + method: 'DELETE', + headers: { Authorization: authHeader }, + }).catch(() => {}) +} + /** Maximum allowed clock skew (5 minutes) between Zendesk's signed timestamp and now, per Zendesk docs. */ const ZENDESK_TIMESTAMP_MAX_SKEW_MS = 5 * 60 * 1000 @@ -67,7 +79,10 @@ export const zendeskHandler: WebhookProviderHandler = { verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { const secret = providerConfig.webhookSecret as string | undefined if (!secret) { - return null + // The signing secret is fetched during auto-registration, so a missing + // secret means misconfiguration — fail closed rather than skip. + logger.warn(`[${requestId}] Zendesk webhook secret not configured`) + return new NextResponse('Unauthorized - Missing Zendesk webhook secret', { status: 401 }) } const signature = request.headers.get('X-Zendesk-Webhook-Signature') @@ -201,12 +216,17 @@ export const zendeskHandler: WebhookProviderHandler = { `[${ctx.requestId}] Created Zendesk webhook ${externalId} but failed to fetch signing secret (${secretRes.status})`, { detail } ) + // Avoid leaving an orphaned webhook in Zendesk when secret retrieval fails. + await deleteZendeskWebhookQuietly(apiBase, authHeader, externalId) throw new Error(`Failed to fetch Zendesk signing secret: ${secretRes.status}`) } const secretBody = asRecord((await secretRes.json().catch(() => ({}))) as unknown) const secret = asRecord(secretBody.signing_secret).secret as string | undefined - if (!secret) throw new Error('Zendesk did not return a signing secret for the webhook.') + if (!secret) { + await deleteZendeskWebhookQuietly(apiBase, authHeader, externalId) + throw new Error('Zendesk did not return a signing secret for the webhook.') + } logger.info(`[${ctx.requestId}] Created Zendesk webhook ${externalId}`) return { providerConfigUpdates: { externalId, webhookSecret: secret } } From 7db4ca80a9450f7ac7255ec121540058e9fd2194 Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 20 Jun 2026 14:39:10 -0700 Subject: [PATCH 5/6] fix(triggers): clean up GitLab and PagerDuty webhooks on failed setup Extend the orphan-prevention fix to the remaining providers. When a create call succeeds but post-create validation fails, the created webhook is now deleted before throwing: - GitLab: if the create response can't be parsed for its hook id, the hook is located by its URL and deleted. - PagerDuty: if the subscription response lacks an id or signing secret, the subscription is deleted (by id when known, otherwise located by URL). Both cleanups are best-effort and never throw. --- apps/sim/lib/webhooks/providers/gitlab.ts | 32 +++++++++++++++++ apps/sim/lib/webhooks/providers/pagerduty.ts | 38 ++++++++++++++++++-- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/gitlab.ts b/apps/sim/lib/webhooks/providers/gitlab.ts index 4b2aa3ba423..3f6ffcbf12e 100644 --- a/apps/sim/lib/webhooks/providers/gitlab.ts +++ b/apps/sim/lib/webhooks/providers/gitlab.ts @@ -26,6 +26,35 @@ function gitlabProjectHooksUrl(projectId: string): string { return `${GITLAB_API_BASE}/projects/${encodeURIComponent(projectId)}/hooks` } +/** + * Best-effort cleanup that deletes any project hook pointing at `url`. Used to + * avoid orphaning a hook when the create response can't be parsed for its id. + */ +async function cleanupGitLabHookByUrl( + projectId: string, + accessToken: string, + url: string +): Promise { + const res = await fetch(gitlabProjectHooksUrl(projectId), { + headers: { 'PRIVATE-TOKEN': accessToken }, + }).catch(() => null) + if (!res || !res.ok) return + + const hooks = (await res.json().catch(() => null)) as Array<{ id?: number; url?: string }> | null + if (!Array.isArray(hooks)) return + + await Promise.all( + hooks + .filter((hook) => hook.url === url && hook.id != null) + .map((hook) => + fetch(`${gitlabProjectHooksUrl(projectId)}/${hook.id}`, { + method: 'DELETE', + headers: { 'PRIVATE-TOKEN': accessToken }, + }).catch(() => null) + ) + ) +} + export const gitlabHandler: WebhookProviderHandler = { /** * GitLab echoes the configured "Secret token" verbatim in the `X-Gitlab-Token` @@ -119,6 +148,9 @@ export const gitlabHandler: WebhookProviderHandler = { const created = (await res.json().catch(() => ({}))) as { id?: number | string } if (created.id === undefined || created.id === null) { + // The hook was created but we can't read its id — delete it by URL so it + // is not orphaned in GitLab. + await cleanupGitLabHookByUrl(projectId, accessToken, getNotificationUrl(ctx.webhook)) throw new Error('GitLab webhook created but no hook ID was returned.') } diff --git a/apps/sim/lib/webhooks/providers/pagerduty.ts b/apps/sim/lib/webhooks/providers/pagerduty.ts index 736808a447b..f16124eaa1f 100644 --- a/apps/sim/lib/webhooks/providers/pagerduty.ts +++ b/apps/sim/lib/webhooks/providers/pagerduty.ts @@ -46,6 +46,34 @@ function asRecord(value: unknown): Record { return (value as Record) || {} } +/** + * Best-effort cleanup of a webhook subscription after a failed setup. Deletes by + * id when known, otherwise finds the subscription pointing at `url` and deletes + * it, so a created subscription is never orphaned in PagerDuty. + */ +async function cleanupPagerDutySubscription( + apiKey: string, + url: string, + subscriptionId?: string +): Promise { + let id = subscriptionId + if (!id) { + const listRes = await fetch(`${PAGERDUTY_API_BASE}/webhook_subscriptions`, { + headers: pagerdutyHeaders(apiKey), + }).catch(() => null) + if (!listRes || !listRes.ok) return + const body = (await listRes.json().catch(() => null)) as { + webhook_subscriptions?: Array<{ id?: string; delivery_method?: { url?: string } }> + } | null + id = body?.webhook_subscriptions?.find((sub) => sub.delivery_method?.url === url)?.id + } + if (!id) return + await fetch(`${PAGERDUTY_API_BASE}/webhook_subscriptions/${id}`, { + method: 'DELETE', + headers: pagerdutyHeaders(apiKey), + }).catch(() => null) +} + function referenceSummary( value: unknown ): { id?: unknown; summary?: unknown; html_url?: unknown } | null { @@ -154,9 +182,13 @@ export const pagerdutyHandler: WebhookProviderHandler = { const externalId = subscription.id as string | undefined const secret = asRecord(subscription.delivery_method).secret as string | undefined - if (!externalId) - throw new Error('PagerDuty webhook created but no subscription ID was returned.') - if (!secret) { + // The subscription exists once PagerDuty returns success; if it is missing + // its id or signing secret, delete it so it is not orphaned, then fail. + if (!externalId || !secret) { + await cleanupPagerDutySubscription(apiKey, getNotificationUrl(ctx.webhook), externalId) + if (!externalId) { + throw new Error('PagerDuty webhook created but no subscription ID was returned.') + } throw new Error('PagerDuty webhook created but no signing secret was returned on creation.') } From 04ab0b048975ed47553b78b078bf83ad8dae02ee Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 20 Jun 2026 14:46:27 -0700 Subject: [PATCH 6/6] docs(triggers): note GitLab tag_push only flows through the all-events trigger --- apps/sim/triggers/gitlab/utils.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/sim/triggers/gitlab/utils.ts b/apps/sim/triggers/gitlab/utils.ts index 27e2d188192..6f7848ad2fe 100644 --- a/apps/sim/triggers/gitlab/utils.ts +++ b/apps/sim/triggers/gitlab/utils.ts @@ -38,6 +38,9 @@ const ALL_EVENT_FLAGS = { tag_push_events: true, } as const +// Tag pushes (object_kind 'tag_push') only flow through the all-events trigger; +// there is no dedicated single-event trigger for them. A future "GitLab Tag Push" +// trigger would need its own object_kind mapping in TRIGGER_OBJECT_KINDS above. const TRIGGER_EVENT_FLAGS: Record> = { gitlab_push: { push_events: true }, gitlab_merge_request: { merge_requests_events: true },