|
1 | 1 | import crypto from 'crypto' |
2 | 2 | import { createLogger } from '@sim/logger' |
3 | 3 | import { safeCompare } from '@/lib/core/security/encryption' |
| 4 | +import { generateId } from '@/lib/core/utils/uuid' |
| 5 | +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' |
4 | 6 | import type { |
| 7 | + DeleteSubscriptionContext, |
| 8 | + EventMatchContext, |
5 | 9 | FormatInputContext, |
6 | 10 | FormatInputResult, |
| 11 | + SubscriptionContext, |
| 12 | + SubscriptionResult, |
7 | 13 | WebhookProviderHandler, |
8 | 14 | } from '@/lib/webhooks/providers/types' |
9 | 15 | import { createHmacVerifier } from '@/lib/webhooks/providers/utils' |
@@ -60,6 +66,169 @@ export const linearHandler: WebhookProviderHandler = { |
60 | 66 | } |
61 | 67 | }, |
62 | 68 |
|
| 69 | + async matchEvent({ body, requestId, providerConfig }: EventMatchContext) { |
| 70 | + const triggerId = providerConfig.triggerId as string | undefined |
| 71 | + if (triggerId && !triggerId.endsWith('_webhook') && !triggerId.endsWith('_webhook_v2')) { |
| 72 | + const { isLinearEventMatch } = await import('@/triggers/linear/utils') |
| 73 | + const obj = body as Record<string, unknown> |
| 74 | + const action = obj.action as string | undefined |
| 75 | + const type = obj.type as string | undefined |
| 76 | + if (!isLinearEventMatch(triggerId, type || '', action)) { |
| 77 | + logger.debug( |
| 78 | + `[${requestId}] Linear event mismatch for trigger ${triggerId}. Type: ${type}, Action: ${action}. Skipping.` |
| 79 | + ) |
| 80 | + return false |
| 81 | + } |
| 82 | + } |
| 83 | + return true |
| 84 | + }, |
| 85 | + |
| 86 | + async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> { |
| 87 | + const config = getProviderConfig(ctx.webhook) |
| 88 | + const triggerId = config.triggerId as string | undefined |
| 89 | + |
| 90 | + if (!triggerId || !triggerId.endsWith('_v2')) { |
| 91 | + return undefined |
| 92 | + } |
| 93 | + |
| 94 | + const apiKey = config.apiKey as string | undefined |
| 95 | + if (!apiKey) { |
| 96 | + logger.warn(`[${ctx.requestId}] Missing API key for Linear webhook ${ctx.webhook.id}`) |
| 97 | + throw new Error( |
| 98 | + 'Linear API key is required. Please provide a valid API key in the trigger configuration.' |
| 99 | + ) |
| 100 | + } |
| 101 | + |
| 102 | + const { LINEAR_RESOURCE_TYPE_MAP } = await import('@/triggers/linear/utils') |
| 103 | + const resourceTypes = LINEAR_RESOURCE_TYPE_MAP[triggerId] |
| 104 | + if (!resourceTypes) { |
| 105 | + logger.warn(`[${ctx.requestId}] Unknown Linear trigger ID: ${triggerId}`) |
| 106 | + throw new Error(`Unknown Linear trigger type: ${triggerId}`) |
| 107 | + } |
| 108 | + |
| 109 | + const notificationUrl = getNotificationUrl(ctx.webhook) |
| 110 | + const webhookSecret = generateId() |
| 111 | + const teamId = config.teamId as string | undefined |
| 112 | + |
| 113 | + const input: Record<string, unknown> = { |
| 114 | + url: notificationUrl, |
| 115 | + resourceTypes, |
| 116 | + secret: webhookSecret, |
| 117 | + enabled: true, |
| 118 | + } |
| 119 | + |
| 120 | + if (teamId) { |
| 121 | + input.teamId = teamId |
| 122 | + } else { |
| 123 | + input.allPublicTeams = true |
| 124 | + } |
| 125 | + |
| 126 | + try { |
| 127 | + const response = await fetch('https://api.linear.app/graphql', { |
| 128 | + method: 'POST', |
| 129 | + headers: { |
| 130 | + 'Content-Type': 'application/json', |
| 131 | + Authorization: apiKey, |
| 132 | + }, |
| 133 | + body: JSON.stringify({ |
| 134 | + query: `mutation WebhookCreate($input: WebhookCreateInput!) { |
| 135 | + webhookCreate(input: $input) { |
| 136 | + success |
| 137 | + webhook { id enabled } |
| 138 | + } |
| 139 | + }`, |
| 140 | + variables: { input }, |
| 141 | + }), |
| 142 | + }) |
| 143 | + |
| 144 | + if (!response.ok) { |
| 145 | + throw new Error( |
| 146 | + `Linear API returned HTTP ${response.status}. Please verify your API key and try again.` |
| 147 | + ) |
| 148 | + } |
| 149 | + |
| 150 | + const data = await response.json() |
| 151 | + const result = data?.data?.webhookCreate |
| 152 | + |
| 153 | + if (!result?.success) { |
| 154 | + const errors = data?.errors?.map((e: { message: string }) => e.message).join(', ') |
| 155 | + logger.error(`[${ctx.requestId}] Failed to create Linear webhook`, { |
| 156 | + errors, |
| 157 | + webhookId: ctx.webhook.id, |
| 158 | + }) |
| 159 | + throw new Error(errors || 'Failed to create Linear webhook. Please verify your API key.') |
| 160 | + } |
| 161 | + |
| 162 | + const externalId = result.webhook?.id |
| 163 | + logger.info( |
| 164 | + `[${ctx.requestId}] Created Linear webhook ${externalId} for webhook ${ctx.webhook.id}` |
| 165 | + ) |
| 166 | + |
| 167 | + return { |
| 168 | + providerConfigUpdates: { |
| 169 | + externalId, |
| 170 | + webhookSecret, |
| 171 | + }, |
| 172 | + } |
| 173 | + } catch (error) { |
| 174 | + if (error instanceof Error && error.message !== 'fetch failed') { |
| 175 | + throw error |
| 176 | + } |
| 177 | + logger.error(`[${ctx.requestId}] Error creating Linear webhook`, { |
| 178 | + error: error instanceof Error ? error.message : String(error), |
| 179 | + }) |
| 180 | + throw new Error('Failed to create Linear webhook. Please verify your API key and try again.') |
| 181 | + } |
| 182 | + }, |
| 183 | + |
| 184 | + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> { |
| 185 | + const config = getProviderConfig(ctx.webhook) |
| 186 | + const externalId = config.externalId as string | undefined |
| 187 | + const apiKey = config.apiKey as string | undefined |
| 188 | + |
| 189 | + if (!externalId || !apiKey) { |
| 190 | + return |
| 191 | + } |
| 192 | + |
| 193 | + try { |
| 194 | + const response = await fetch('https://api.linear.app/graphql', { |
| 195 | + method: 'POST', |
| 196 | + headers: { |
| 197 | + 'Content-Type': 'application/json', |
| 198 | + Authorization: apiKey, |
| 199 | + }, |
| 200 | + body: JSON.stringify({ |
| 201 | + query: `mutation WebhookDelete($id: String!) { |
| 202 | + webhookDelete(id: $id) { success } |
| 203 | + }`, |
| 204 | + variables: { id: externalId }, |
| 205 | + }), |
| 206 | + }) |
| 207 | + |
| 208 | + if (!response.ok) { |
| 209 | + logger.warn( |
| 210 | + `[${ctx.requestId}] Linear API returned HTTP ${response.status} during webhook deletion for ${externalId}` |
| 211 | + ) |
| 212 | + return |
| 213 | + } |
| 214 | + |
| 215 | + const data = await response.json() |
| 216 | + if (data?.data?.webhookDelete?.success) { |
| 217 | + logger.info( |
| 218 | + `[${ctx.requestId}] Deleted Linear webhook ${externalId} for webhook ${ctx.webhook.id}` |
| 219 | + ) |
| 220 | + } else { |
| 221 | + logger.warn( |
| 222 | + `[${ctx.requestId}] Linear webhook deletion returned unsuccessful for ${externalId}` |
| 223 | + ) |
| 224 | + } |
| 225 | + } catch (error) { |
| 226 | + logger.warn(`[${ctx.requestId}] Error deleting Linear webhook ${externalId} (non-fatal)`, { |
| 227 | + error: error instanceof Error ? error.message : String(error), |
| 228 | + }) |
| 229 | + } |
| 230 | + }, |
| 231 | + |
63 | 232 | extractIdempotencyId(body: unknown) { |
64 | 233 | const obj = body as Record<string, unknown> |
65 | 234 | const data = obj.data as Record<string, unknown> | undefined |
|
0 commit comments