Skip to content

Commit 008af87

Browse files
waleedlatif1icecrasher321
authored andcommitted
feat(triggers): add Linear v2 triggers with automatic webhook registration (#3991)
* feat(triggers): add Linear v2 triggers with automatic webhook registration * fix(triggers): preserve specific Linear API error messages in catch block * fix(triggers): check response.ok before JSON parsing, replace as any with as unknown * fix linear subscription params * fix build --------- Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
1 parent d28a976 commit 008af87

21 files changed

+926
-11
lines changed

apps/sim/blocks/blocks/linear.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ import { getTrigger } from '@/triggers'
88

99
export const LinearBlock: BlockConfig<LinearResponse> = {
1010
type: 'linear',
11-
name: 'Linear',
11+
name: 'Linear (Legacy)',
1212
description: 'Interact with Linear issues, projects, and more',
13+
hideFromToolbar: true,
1314
authMode: AuthMode.OAuth,
1415
triggerAllowed: true,
1516
longDescription:
@@ -2543,3 +2544,62 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
25432544
],
25442545
},
25452546
}
2547+
2548+
/**
2549+
* Linear V2 Block
2550+
*
2551+
* Uses automatic webhook registration via the Linear GraphQL API.
2552+
* Inherits all tool operations from the legacy block.
2553+
*/
2554+
export const LinearV2Block: BlockConfig<LinearResponse> = {
2555+
...LinearBlock,
2556+
type: 'linear_v2',
2557+
name: 'Linear',
2558+
hideFromToolbar: false,
2559+
subBlocks: [
2560+
...LinearBlock.subBlocks.filter(
2561+
(sb) =>
2562+
!sb.id?.startsWith('webhookUrlDisplay') &&
2563+
!sb.id?.startsWith('webhookSecret') &&
2564+
!sb.id?.startsWith('triggerSave') &&
2565+
!sb.id?.startsWith('triggerInstructions') &&
2566+
!sb.id?.startsWith('selectedTriggerId')
2567+
),
2568+
// V2 Trigger SubBlocks
2569+
...getTrigger('linear_issue_created_v2').subBlocks,
2570+
...getTrigger('linear_issue_updated_v2').subBlocks,
2571+
...getTrigger('linear_issue_removed_v2').subBlocks,
2572+
...getTrigger('linear_comment_created_v2').subBlocks,
2573+
...getTrigger('linear_comment_updated_v2').subBlocks,
2574+
...getTrigger('linear_project_created_v2').subBlocks,
2575+
...getTrigger('linear_project_updated_v2').subBlocks,
2576+
...getTrigger('linear_cycle_created_v2').subBlocks,
2577+
...getTrigger('linear_cycle_updated_v2').subBlocks,
2578+
...getTrigger('linear_label_created_v2').subBlocks,
2579+
...getTrigger('linear_label_updated_v2').subBlocks,
2580+
...getTrigger('linear_project_update_created_v2').subBlocks,
2581+
...getTrigger('linear_customer_request_created_v2').subBlocks,
2582+
...getTrigger('linear_customer_request_updated_v2').subBlocks,
2583+
...getTrigger('linear_webhook_v2').subBlocks,
2584+
],
2585+
triggers: {
2586+
enabled: true,
2587+
available: [
2588+
'linear_issue_created_v2',
2589+
'linear_issue_updated_v2',
2590+
'linear_issue_removed_v2',
2591+
'linear_comment_created_v2',
2592+
'linear_comment_updated_v2',
2593+
'linear_project_created_v2',
2594+
'linear_project_updated_v2',
2595+
'linear_cycle_created_v2',
2596+
'linear_cycle_updated_v2',
2597+
'linear_label_created_v2',
2598+
'linear_label_updated_v2',
2599+
'linear_project_update_created_v2',
2600+
'linear_customer_request_created_v2',
2601+
'linear_customer_request_updated_v2',
2602+
'linear_webhook_v2',
2603+
],
2604+
},
2605+
}

apps/sim/blocks/registry.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ import { KnowledgeBlock } from '@/blocks/blocks/knowledge'
102102
import { LangsmithBlock } from '@/blocks/blocks/langsmith'
103103
import { LaunchDarklyBlock } from '@/blocks/blocks/launchdarkly'
104104
import { LemlistBlock } from '@/blocks/blocks/lemlist'
105-
import { LinearBlock } from '@/blocks/blocks/linear'
105+
import { LinearBlock, LinearV2Block } from '@/blocks/blocks/linear'
106106
import { LinkedInBlock } from '@/blocks/blocks/linkedin'
107107
import { LinkupBlock } from '@/blocks/blocks/linkup'
108108
import { LoopsBlock } from '@/blocks/blocks/loops'
@@ -338,6 +338,7 @@ export const registry: Record<string, BlockConfig> = {
338338
launchdarkly: LaunchDarklyBlock,
339339
lemlist: LemlistBlock,
340340
linear: LinearBlock,
341+
linear_v2: LinearV2Block,
341342
linkedin: LinkedInBlock,
342343
linkup: LinkupBlock,
343344
loops: LoopsBlock,

apps/sim/lib/webhooks/providers/linear.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
import crypto from 'crypto'
22
import { createLogger } from '@sim/logger'
33
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'
46
import type {
7+
DeleteSubscriptionContext,
8+
EventMatchContext,
59
FormatInputContext,
610
FormatInputResult,
11+
SubscriptionContext,
12+
SubscriptionResult,
713
WebhookProviderHandler,
814
} from '@/lib/webhooks/providers/types'
915
import { createHmacVerifier } from '@/lib/webhooks/providers/utils'
@@ -60,6 +66,169 @@ export const linearHandler: WebhookProviderHandler = {
6066
}
6167
},
6268

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+
63232
extractIdempotencyId(body: unknown) {
64233
const obj = body as Record<string, unknown>
65234
const data = obj.data as Record<string, unknown> | undefined
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { LinearIcon } from '@/components/icons'
2+
import { buildCommentOutputs, buildLinearV2SubBlocks } from '@/triggers/linear/utils'
3+
import type { TriggerConfig } from '@/triggers/types'
4+
5+
export const linearCommentCreatedV2Trigger: TriggerConfig = {
6+
id: 'linear_comment_created_v2',
7+
name: 'Linear Comment Created',
8+
provider: 'linear',
9+
description: 'Trigger workflow when a new comment is created in Linear',
10+
version: '2.0.0',
11+
icon: LinearIcon,
12+
13+
subBlocks: buildLinearV2SubBlocks({
14+
triggerId: 'linear_comment_created_v2',
15+
eventType: 'Comment (create)',
16+
}),
17+
18+
outputs: buildCommentOutputs(),
19+
20+
webhook: {
21+
method: 'POST',
22+
headers: {
23+
'Content-Type': 'application/json',
24+
'Linear-Event': 'Comment',
25+
'Linear-Delivery': 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
26+
'Linear-Signature': 'sha256...',
27+
'User-Agent': 'Linear-Webhook',
28+
},
29+
},
30+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { LinearIcon } from '@/components/icons'
2+
import { buildCommentOutputs, buildLinearV2SubBlocks } from '@/triggers/linear/utils'
3+
import type { TriggerConfig } from '@/triggers/types'
4+
5+
export const linearCommentUpdatedV2Trigger: TriggerConfig = {
6+
id: 'linear_comment_updated_v2',
7+
name: 'Linear Comment Updated',
8+
provider: 'linear',
9+
description: 'Trigger workflow when a comment is updated in Linear',
10+
version: '2.0.0',
11+
icon: LinearIcon,
12+
13+
subBlocks: buildLinearV2SubBlocks({
14+
triggerId: 'linear_comment_updated_v2',
15+
eventType: 'Comment (update)',
16+
}),
17+
18+
outputs: buildCommentOutputs(),
19+
20+
webhook: {
21+
method: 'POST',
22+
headers: {
23+
'Content-Type': 'application/json',
24+
'Linear-Event': 'Comment',
25+
'Linear-Delivery': 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
26+
'Linear-Signature': 'sha256...',
27+
'User-Agent': 'Linear-Webhook',
28+
},
29+
},
30+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { LinearIcon } from '@/components/icons'
2+
import { buildCustomerRequestOutputs, buildLinearV2SubBlocks } from '@/triggers/linear/utils'
3+
import type { TriggerConfig } from '@/triggers/types'
4+
5+
export const linearCustomerRequestCreatedV2Trigger: TriggerConfig = {
6+
id: 'linear_customer_request_created_v2',
7+
name: 'Linear Customer Request Created',
8+
provider: 'linear',
9+
description: 'Trigger workflow when a new customer request is created in Linear',
10+
version: '2.0.0',
11+
icon: LinearIcon,
12+
13+
subBlocks: buildLinearV2SubBlocks({
14+
triggerId: 'linear_customer_request_created_v2',
15+
eventType: 'Customer Requests',
16+
}),
17+
18+
outputs: buildCustomerRequestOutputs(),
19+
20+
webhook: {
21+
method: 'POST',
22+
headers: {
23+
'Content-Type': 'application/json',
24+
'Linear-Event': 'CustomerNeed',
25+
'Linear-Delivery': 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
26+
'Linear-Signature': 'sha256...',
27+
'User-Agent': 'Linear-Webhook',
28+
},
29+
},
30+
}

0 commit comments

Comments
 (0)