Skip to content

Commit d643be0

Browse files
authored
feat(triggers): add GitLab, PagerDuty, and Zendesk webhook triggers (#5150)
* 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. * 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). * 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). * 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. * 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. * docs(triggers): note GitLab tag_push only flows through the all-events trigger
1 parent aa57f10 commit d643be0

33 files changed

Lines changed: 1883 additions & 1 deletion

apps/sim/blocks/blocks/gitlab.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@ import { GitLabIcon } from '@/components/icons'
22
import type { BlockConfig, BlockMeta } from '@/blocks/types'
33
import { AuthMode, IntegrationType } from '@/blocks/types'
44
import type { GitLabResponse } from '@/tools/gitlab/types'
5+
import { getTrigger } from '@/triggers'
56

67
export const GitLabBlock: BlockConfig<GitLabResponse> = {
78
type: 'gitlab',
89
name: 'GitLab',
910
description: 'Interact with GitLab projects, issues, merge requests, and pipelines',
1011
authMode: AuthMode.ApiKey,
11-
triggerAllowed: false,
12+
triggerAllowed: true,
1213
longDescription:
1314
'Integrate GitLab into the workflow. Can manage projects, issues, merge requests, pipelines, and add comments. Supports all core GitLab DevOps operations.',
1415
docsLink: 'https://docs.sim.ai/integrations/gitlab',
@@ -437,6 +438,12 @@ Return ONLY the commit message - no explanations, no extra text.`,
437438
],
438439
},
439440
},
441+
...getTrigger('gitlab_push').subBlocks,
442+
...getTrigger('gitlab_merge_request').subBlocks,
443+
...getTrigger('gitlab_issue').subBlocks,
444+
...getTrigger('gitlab_pipeline').subBlocks,
445+
...getTrigger('gitlab_comment').subBlocks,
446+
...getTrigger('gitlab_webhook').subBlocks,
440447
],
441448
tools: {
442449
access: [
@@ -746,6 +753,18 @@ Return ONLY the commit message - no explanations, no extra text.`,
746753
// Success indicator
747754
success: { type: 'boolean', description: 'Operation success status' },
748755
},
756+
757+
triggers: {
758+
enabled: true,
759+
available: [
760+
'gitlab_push',
761+
'gitlab_merge_request',
762+
'gitlab_issue',
763+
'gitlab_pipeline',
764+
'gitlab_comment',
765+
'gitlab_webhook',
766+
],
767+
},
749768
}
750769

751770
export const GitLabBlockMeta = {

apps/sim/blocks/blocks/pagerduty.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { PagerDutyIcon } from '@/components/icons'
22
import { AuthMode, type BlockConfig, type BlockMeta, IntegrationType } from '@/blocks/types'
3+
import { getTrigger } from '@/triggers'
34

45
export const PagerDutyBlock: BlockConfig = {
56
type: 'pagerduty',
67
name: 'PagerDuty',
78
description: 'Manage incidents and on-call schedules with PagerDuty',
9+
triggerAllowed: true,
810
longDescription:
911
'Integrate PagerDuty into your workflow to list, create, and update incidents, add notes, list services, and check on-call schedules.',
1012
docsLink: 'https://docs.sim.ai/integrations/pagerduty',
@@ -315,6 +317,12 @@ export const PagerDutyBlock: BlockConfig = {
315317
generationType: 'timestamp',
316318
},
317319
},
320+
...getTrigger('pagerduty_incident_triggered').subBlocks,
321+
...getTrigger('pagerduty_incident_acknowledged').subBlocks,
322+
...getTrigger('pagerduty_incident_resolved').subBlocks,
323+
...getTrigger('pagerduty_incident_escalated').subBlocks,
324+
...getTrigger('pagerduty_incident_reassigned').subBlocks,
325+
...getTrigger('pagerduty_webhook').subBlocks,
318326
],
319327

320328
tools: {
@@ -481,6 +489,18 @@ export const PagerDutyBlock: BlockConfig = {
481489
description: 'Array of on-call entries (list_oncalls)',
482490
},
483491
},
492+
493+
triggers: {
494+
enabled: true,
495+
available: [
496+
'pagerduty_incident_triggered',
497+
'pagerduty_incident_acknowledged',
498+
'pagerduty_incident_resolved',
499+
'pagerduty_incident_escalated',
500+
'pagerduty_incident_reassigned',
501+
'pagerduty_webhook',
502+
],
503+
},
484504
}
485505

486506
export const PagerDutyBlockMeta = {

apps/sim/blocks/blocks/zendesk.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { ZendeskIcon } from '@/components/icons'
22
import type { BlockConfig, BlockMeta } from '@/blocks/types'
33
import { AuthMode, IntegrationType } from '@/blocks/types'
4+
import { getTrigger } from '@/triggers'
45

56
export const ZendeskBlock: BlockConfig = {
67
type: 'zendesk',
78
name: 'Zendesk',
89
description: 'Manage support tickets, users, and organizations in Zendesk',
10+
triggerAllowed: true,
911
longDescription:
1012
'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.',
1113
docsLink: 'https://docs.sim.ai/integrations/zendesk',
@@ -529,6 +531,11 @@ Return ONLY the search query - no explanations.`,
529531
},
530532
mode: 'advanced',
531533
},
534+
...getTrigger('zendesk_ticket_created').subBlocks,
535+
...getTrigger('zendesk_ticket_status_changed').subBlocks,
536+
...getTrigger('zendesk_ticket_comment_added').subBlocks,
537+
...getTrigger('zendesk_ticket_priority_changed').subBlocks,
538+
...getTrigger('zendesk_webhook').subBlocks,
532539
],
533540
tools: {
534541
access: [
@@ -695,6 +702,17 @@ Return ONLY the search query - no explanations.`,
695702
// Metadata (shared across all operations)
696703
metadata: { type: 'json', description: 'Operation metadata including operation type' },
697704
},
705+
706+
triggers: {
707+
enabled: true,
708+
available: [
709+
'zendesk_ticket_created',
710+
'zendesk_ticket_status_changed',
711+
'zendesk_ticket_comment_added',
712+
'zendesk_ticket_priority_changed',
713+
'zendesk_webhook',
714+
],
715+
},
698716
}
699717

700718
export const ZendeskBlockMeta = {

apps/sim/lib/core/idempotency/service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,7 @@ export class IdempotencyService {
493493
normalizedHeaders?.['x-webhook-id'] ||
494494
normalizedHeaders?.['x-shopify-webhook-id'] ||
495495
normalizedHeaders?.['x-github-delivery'] ||
496+
normalizedHeaders?.['x-gitlab-event-uuid'] ||
496497
normalizedHeaders?.['x-event-id'] ||
497498
normalizedHeaders?.['x-teams-notification-id'] ||
498499
normalizedHeaders?.['svix-id'] ||
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { createLogger } from '@sim/logger'
2+
import { safeCompare } from '@sim/security/compare'
3+
import { generateId } from '@sim/utils/id'
4+
import { NextResponse } from 'next/server'
5+
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
6+
import type {
7+
AuthContext,
8+
DeleteSubscriptionContext,
9+
EventMatchContext,
10+
FormatInputContext,
11+
FormatInputResult,
12+
SubscriptionContext,
13+
SubscriptionResult,
14+
WebhookProviderHandler,
15+
} from '@/lib/webhooks/providers/types'
16+
17+
const logger = createLogger('WebhookProvider:GitLab')
18+
19+
const GITLAB_API_BASE = 'https://gitlab.com/api/v4'
20+
21+
function asRecord(value: unknown): Record<string, unknown> {
22+
return (value as Record<string, unknown>) || {}
23+
}
24+
25+
function gitlabProjectHooksUrl(projectId: string): string {
26+
return `${GITLAB_API_BASE}/projects/${encodeURIComponent(projectId)}/hooks`
27+
}
28+
29+
/**
30+
* Best-effort cleanup that deletes any project hook pointing at `url`. Used to
31+
* avoid orphaning a hook when the create response can't be parsed for its id.
32+
*/
33+
async function cleanupGitLabHookByUrl(
34+
projectId: string,
35+
accessToken: string,
36+
url: string
37+
): Promise<void> {
38+
const res = await fetch(gitlabProjectHooksUrl(projectId), {
39+
headers: { 'PRIVATE-TOKEN': accessToken },
40+
}).catch(() => null)
41+
if (!res || !res.ok) return
42+
43+
const hooks = (await res.json().catch(() => null)) as Array<{ id?: number; url?: string }> | null
44+
if (!Array.isArray(hooks)) return
45+
46+
await Promise.all(
47+
hooks
48+
.filter((hook) => hook.url === url && hook.id != null)
49+
.map((hook) =>
50+
fetch(`${gitlabProjectHooksUrl(projectId)}/${hook.id}`, {
51+
method: 'DELETE',
52+
headers: { 'PRIVATE-TOKEN': accessToken },
53+
}).catch(() => null)
54+
)
55+
)
56+
}
57+
58+
export const gitlabHandler: WebhookProviderHandler = {
59+
/**
60+
* GitLab echoes the configured "Secret token" verbatim in the `X-Gitlab-Token`
61+
* header (plain equality, not an HMAC). The secret is generated during
62+
* auto-registration, so a missing secret means misconfiguration — fail closed.
63+
*/
64+
verifyAuth({ request, requestId, providerConfig }: AuthContext) {
65+
const secret = providerConfig.webhookSecret as string | undefined
66+
if (!secret) {
67+
logger.warn(`[${requestId}] GitLab webhook secret not configured`)
68+
return new NextResponse('Unauthorized - Missing GitLab webhook secret', { status: 401 })
69+
}
70+
71+
const token = request.headers.get('X-Gitlab-Token')
72+
if (!token) {
73+
logger.warn(`[${requestId}] GitLab webhook missing X-Gitlab-Token header`)
74+
return new NextResponse('Unauthorized - Missing GitLab token', { status: 401 })
75+
}
76+
77+
if (!safeCompare(token, secret)) {
78+
logger.warn(`[${requestId}] GitLab token verification failed`)
79+
return new NextResponse('Unauthorized - Invalid GitLab token', { status: 401 })
80+
}
81+
82+
return null
83+
},
84+
85+
async matchEvent({ body, requestId, providerConfig }: EventMatchContext) {
86+
const triggerId = providerConfig.triggerId as string | undefined
87+
if (!triggerId || triggerId === 'gitlab_webhook') return true
88+
89+
const objectKind = asRecord(body).object_kind as string | undefined
90+
91+
const { isGitLabEventMatch } = await import('@/triggers/gitlab/utils')
92+
if (!isGitLabEventMatch(triggerId, objectKind || '')) {
93+
logger.debug(
94+
`[${requestId}] GitLab event '${objectKind}' does not match trigger ${triggerId}, skipping`
95+
)
96+
return false
97+
}
98+
return true
99+
},
100+
101+
async formatInput({ body, headers }: FormatInputContext): Promise<FormatInputResult> {
102+
const b = asRecord(body)
103+
const eventType = headers['x-gitlab-event'] || ''
104+
const ref = (b.ref as string) || ''
105+
const branch = ref.replace('refs/heads/', '')
106+
return {
107+
input: { ...b, event_type: eventType, branch },
108+
}
109+
},
110+
111+
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
112+
const config = getProviderConfig(ctx.webhook)
113+
const accessToken = config.accessToken as string | undefined
114+
const projectId = config.projectId as string | undefined
115+
const triggerId = config.triggerId as string | undefined
116+
117+
if (!accessToken)
118+
throw new Error('GitLab Personal Access Token is required to create the webhook.')
119+
if (!projectId) throw new Error('GitLab Project ID is required to create the webhook.')
120+
121+
const { getGitLabEventFlags } = await import('@/triggers/gitlab/utils')
122+
const secretToken = generateId()
123+
const res = await fetch(gitlabProjectHooksUrl(projectId), {
124+
method: 'POST',
125+
headers: { 'PRIVATE-TOKEN': accessToken, 'Content-Type': 'application/json' },
126+
body: JSON.stringify({
127+
url: getNotificationUrl(ctx.webhook),
128+
token: secretToken,
129+
enable_ssl_verification: true,
130+
...getGitLabEventFlags(triggerId ?? 'gitlab_webhook'),
131+
}),
132+
})
133+
134+
if (!res.ok) {
135+
const detail = await res.text().catch(() => '')
136+
logger.error(`[${ctx.requestId}] Failed to create GitLab webhook (${res.status})`, { detail })
137+
if (res.status === 401)
138+
throw new Error(
139+
'GitLab authentication failed. Verify your Personal Access Token has the api scope.'
140+
)
141+
if (res.status === 403)
142+
throw new Error(
143+
'GitLab access denied. You need the Maintainer or Owner role on the project.'
144+
)
145+
if (res.status === 404) throw new Error('GitLab project not found. Verify the Project ID.')
146+
throw new Error(`Failed to create GitLab webhook: ${res.status}`)
147+
}
148+
149+
const created = (await res.json().catch(() => ({}))) as { id?: number | string }
150+
if (created.id === undefined || created.id === null) {
151+
// The hook was created but we can't read its id — delete it by URL so it
152+
// is not orphaned in GitLab.
153+
await cleanupGitLabHookByUrl(projectId, accessToken, getNotificationUrl(ctx.webhook))
154+
throw new Error('GitLab webhook created but no hook ID was returned.')
155+
}
156+
157+
logger.info(`[${ctx.requestId}] Created GitLab webhook ${created.id} for project ${projectId}`)
158+
return { providerConfigUpdates: { externalId: String(created.id), webhookSecret: secretToken } }
159+
},
160+
161+
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
162+
const config = getProviderConfig(ctx.webhook)
163+
const accessToken = config.accessToken as string | undefined
164+
const projectId = config.projectId as string | undefined
165+
const externalId = config.externalId as string | undefined
166+
167+
if (!accessToken || !projectId || !externalId) {
168+
if (ctx.strict) throw new Error('Missing GitLab credentials or hook ID for webhook deletion.')
169+
logger.warn(
170+
`[${ctx.requestId}] Skipping GitLab webhook cleanup — missing token, project, or hook ID`
171+
)
172+
return
173+
}
174+
175+
const res = await fetch(`${gitlabProjectHooksUrl(projectId)}/${externalId}`, {
176+
method: 'DELETE',
177+
headers: { 'PRIVATE-TOKEN': accessToken },
178+
})
179+
180+
if (!res.ok && res.status !== 404) {
181+
if (ctx.strict) throw new Error(`Failed to delete GitLab webhook: ${res.status}`)
182+
logger.warn(
183+
`[${ctx.requestId}] Failed to delete GitLab webhook ${externalId} (non-fatal): ${res.status}`
184+
)
185+
return
186+
}
187+
logger.info(`[${ctx.requestId}] Deleted GitLab webhook ${externalId}`)
188+
},
189+
}

0 commit comments

Comments
 (0)