Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 61 additions & 1 deletion apps/sim/blocks/blocks/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import { getTrigger } from '@/triggers'

export const LinearBlock: BlockConfig<LinearResponse> = {
type: 'linear',
name: 'Linear',
name: 'Linear (Legacy)',
description: 'Interact with Linear issues, projects, and more',
hideFromToolbar: true,
authMode: AuthMode.OAuth,
triggerAllowed: true,
longDescription:
Expand Down Expand Up @@ -2543,3 +2544,62 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
],
},
}

/**
* Linear V2 Block
*
* Uses automatic webhook registration via the Linear GraphQL API.
* Inherits all tool operations from the legacy block.
*/
export const LinearV2Block: BlockConfig<LinearResponse> = {
...LinearBlock,
type: 'linear_v2',
name: 'Linear',
hideFromToolbar: false,
subBlocks: [
...LinearBlock.subBlocks.filter(
(sb) =>
!sb.id?.startsWith('webhookUrlDisplay') &&
!sb.id?.startsWith('webhookSecret') &&
!sb.id?.startsWith('triggerSave') &&
!sb.id?.startsWith('triggerInstructions') &&
!sb.id?.startsWith('selectedTriggerId')
),
// V2 Trigger SubBlocks
...getTrigger('linear_issue_created_v2').subBlocks,
...getTrigger('linear_issue_updated_v2').subBlocks,
...getTrigger('linear_issue_removed_v2').subBlocks,
...getTrigger('linear_comment_created_v2').subBlocks,
...getTrigger('linear_comment_updated_v2').subBlocks,
...getTrigger('linear_project_created_v2').subBlocks,
...getTrigger('linear_project_updated_v2').subBlocks,
...getTrigger('linear_cycle_created_v2').subBlocks,
...getTrigger('linear_cycle_updated_v2').subBlocks,
...getTrigger('linear_label_created_v2').subBlocks,
...getTrigger('linear_label_updated_v2').subBlocks,
...getTrigger('linear_project_update_created_v2').subBlocks,
...getTrigger('linear_customer_request_created_v2').subBlocks,
...getTrigger('linear_customer_request_updated_v2').subBlocks,
...getTrigger('linear_webhook_v2').subBlocks,
],
triggers: {
enabled: true,
available: [
'linear_issue_created_v2',
'linear_issue_updated_v2',
'linear_issue_removed_v2',
'linear_comment_created_v2',
'linear_comment_updated_v2',
'linear_project_created_v2',
'linear_project_updated_v2',
'linear_cycle_created_v2',
'linear_cycle_updated_v2',
'linear_label_created_v2',
'linear_label_updated_v2',
'linear_project_update_created_v2',
'linear_customer_request_created_v2',
'linear_customer_request_updated_v2',
'linear_webhook_v2',
],
},
}
3 changes: 2 additions & 1 deletion apps/sim/blocks/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ import { KnowledgeBlock } from '@/blocks/blocks/knowledge'
import { LangsmithBlock } from '@/blocks/blocks/langsmith'
import { LaunchDarklyBlock } from '@/blocks/blocks/launchdarkly'
import { LemlistBlock } from '@/blocks/blocks/lemlist'
import { LinearBlock } from '@/blocks/blocks/linear'
import { LinearBlock, LinearV2Block } from '@/blocks/blocks/linear'
import { LinkedInBlock } from '@/blocks/blocks/linkedin'
import { LinkupBlock } from '@/blocks/blocks/linkup'
import { LoopsBlock } from '@/blocks/blocks/loops'
Expand Down Expand Up @@ -337,6 +337,7 @@ export const registry: Record<string, BlockConfig> = {
launchdarkly: LaunchDarklyBlock,
lemlist: LemlistBlock,
linear: LinearBlock,
linear_v2: LinearV2Block,
linkedin: LinkedInBlock,
linkup: LinkupBlock,
loops: LoopsBlock,
Expand Down
169 changes: 169 additions & 0 deletions apps/sim/lib/webhooks/providers/linear.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import crypto from 'crypto'
import { createLogger } from '@sim/logger'
import { safeCompare } from '@/lib/core/security/encryption'
import { generateId } from '@/lib/core/utils/uuid'
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils'
import type {
DeleteSubscriptionContext,
EventMatchContext,
FormatInputContext,
FormatInputResult,
SubscriptionContext,
SubscriptionResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { createHmacVerifier } from '@/lib/webhooks/providers/utils'
Expand Down Expand Up @@ -60,6 +66,169 @@ export const linearHandler: WebhookProviderHandler = {
}
},

async matchEvent({ body, requestId, providerConfig }: EventMatchContext) {
const triggerId = providerConfig.triggerId as string | undefined
if (triggerId && !triggerId.endsWith('_webhook') && !triggerId.endsWith('_webhook_v2')) {
const { isLinearEventMatch } = await import('@/triggers/linear/utils')
const obj = body as Record<string, unknown>
const action = obj.action as string | undefined
const type = obj.type as string | undefined
if (!isLinearEventMatch(triggerId, type || '', action)) {
logger.debug(
`[${requestId}] Linear event mismatch for trigger ${triggerId}. Type: ${type}, Action: ${action}. Skipping.`
)
return false
}
}
return true
},

async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
const config = getProviderConfig(ctx.webhook)
const triggerId = config.triggerId as string | undefined

if (!triggerId || !triggerId.endsWith('_v2')) {
return undefined
}

const apiKey = config.apiKey as string | undefined
if (!apiKey) {
logger.warn(`[${ctx.requestId}] Missing API key for Linear webhook ${ctx.webhook.id}`)
throw new Error(
'Linear API key is required. Please provide a valid API key in the trigger configuration.'
)
}

const { LINEAR_RESOURCE_TYPE_MAP } = await import('@/triggers/linear/utils')
const resourceTypes = LINEAR_RESOURCE_TYPE_MAP[triggerId]
if (!resourceTypes) {
logger.warn(`[${ctx.requestId}] Unknown Linear trigger ID: ${triggerId}`)
throw new Error(`Unknown Linear trigger type: ${triggerId}`)
}

const notificationUrl = getNotificationUrl(ctx.webhook)
const webhookSecret = generateId()
const teamId = config.teamId as string | undefined

const input: Record<string, unknown> = {
url: notificationUrl,
resourceTypes,
secret: webhookSecret,
enabled: true,
}

if (teamId) {
input.teamId = teamId
} else {
input.allPublicTeams = true
}

try {
const response = await fetch('https://api.linear.app/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: apiKey,
},
body: JSON.stringify({
query: `mutation WebhookCreate($input: WebhookCreateInput!) {
webhookCreate(input: $input) {
success
webhook { id enabled }
}
}`,
variables: { input },
}),
})

if (!response.ok) {
throw new Error(
`Linear API returned HTTP ${response.status}. Please verify your API key and try again.`
)
}

const data = await response.json()
const result = data?.data?.webhookCreate

if (!result?.success) {
const errors = data?.errors?.map((e: { message: string }) => e.message).join(', ')
logger.error(`[${ctx.requestId}] Failed to create Linear webhook`, {
errors,
webhookId: ctx.webhook.id,
})
throw new Error(errors || 'Failed to create Linear webhook. Please verify your API key.')
}

const externalId = result.webhook?.id
logger.info(
`[${ctx.requestId}] Created Linear webhook ${externalId} for webhook ${ctx.webhook.id}`
)

return {
providerConfigUpdates: {
externalId,
webhookSecret,
},
}
} catch (error) {
if (error instanceof Error && error.message !== 'fetch failed') {
throw error
}
logger.error(`[${ctx.requestId}] Error creating Linear webhook`, {
error: error instanceof Error ? error.message : String(error),
})
throw new Error('Failed to create Linear webhook. Please verify your API key and try again.')
}
},

async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
const config = getProviderConfig(ctx.webhook)
const externalId = config.externalId as string | undefined
const apiKey = config.apiKey as string | undefined

if (!externalId || !apiKey) {
return
}

try {
const response = await fetch('https://api.linear.app/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: apiKey,
},
body: JSON.stringify({
query: `mutation WebhookDelete($id: String!) {
webhookDelete(id: $id) { success }
}`,
variables: { id: externalId },
}),
})

if (!response.ok) {
logger.warn(
`[${ctx.requestId}] Linear API returned HTTP ${response.status} during webhook deletion for ${externalId}`
)
return
}

const data = await response.json()
if (data?.data?.webhookDelete?.success) {
logger.info(
`[${ctx.requestId}] Deleted Linear webhook ${externalId} for webhook ${ctx.webhook.id}`
)
} else {
logger.warn(
`[${ctx.requestId}] Linear webhook deletion returned unsuccessful for ${externalId}`
)
}
} catch (error) {
logger.warn(`[${ctx.requestId}] Error deleting Linear webhook ${externalId} (non-fatal)`, {
error: error instanceof Error ? error.message : String(error),
})
}
},

extractIdempotencyId(body: unknown) {
const obj = body as Record<string, unknown>
const data = obj.data as Record<string, unknown> | undefined
Expand Down
30 changes: 30 additions & 0 deletions apps/sim/triggers/linear/comment_created_v2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { LinearIcon } from '@/components/icons'
import { buildCommentOutputs, buildLinearV2SubBlocks } from '@/triggers/linear/utils'
import type { TriggerConfig } from '@/triggers/types'

export const linearCommentCreatedV2Trigger: TriggerConfig = {
id: 'linear_comment_created_v2',
name: 'Linear Comment Created',
provider: 'linear',
description: 'Trigger workflow when a new comment is created in Linear',
version: '2.0.0',
icon: LinearIcon,

subBlocks: buildLinearV2SubBlocks({
triggerId: 'linear_comment_created_v2',
eventType: 'Comment (create)',
}),

outputs: buildCommentOutputs(),

webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Linear-Event': 'Comment',
'Linear-Delivery': 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
'Linear-Signature': 'sha256...',
'User-Agent': 'Linear-Webhook',
},
},
}
30 changes: 30 additions & 0 deletions apps/sim/triggers/linear/comment_updated_v2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { LinearIcon } from '@/components/icons'
import { buildCommentOutputs, buildLinearV2SubBlocks } from '@/triggers/linear/utils'
import type { TriggerConfig } from '@/triggers/types'

export const linearCommentUpdatedV2Trigger: TriggerConfig = {
id: 'linear_comment_updated_v2',
name: 'Linear Comment Updated',
provider: 'linear',
description: 'Trigger workflow when a comment is updated in Linear',
version: '2.0.0',
icon: LinearIcon,

subBlocks: buildLinearV2SubBlocks({
triggerId: 'linear_comment_updated_v2',
eventType: 'Comment (update)',
}),

outputs: buildCommentOutputs(),

webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Linear-Event': 'Comment',
'Linear-Delivery': 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
'Linear-Signature': 'sha256...',
'User-Agent': 'Linear-Webhook',
},
},
}
30 changes: 30 additions & 0 deletions apps/sim/triggers/linear/customer_request_created_v2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { LinearIcon } from '@/components/icons'
import { buildCustomerRequestOutputs, buildLinearV2SubBlocks } from '@/triggers/linear/utils'
import type { TriggerConfig } from '@/triggers/types'

export const linearCustomerRequestCreatedV2Trigger: TriggerConfig = {
id: 'linear_customer_request_created_v2',
name: 'Linear Customer Request Created',
provider: 'linear',
description: 'Trigger workflow when a new customer request is created in Linear',
version: '2.0.0',
icon: LinearIcon,

subBlocks: buildLinearV2SubBlocks({
triggerId: 'linear_customer_request_created_v2',
eventType: 'Customer Requests',
}),

outputs: buildCustomerRequestOutputs(),

webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Linear-Event': 'CustomerNeed',
'Linear-Delivery': 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
'Linear-Signature': 'sha256...',
'User-Agent': 'Linear-Webhook',
},
},
}
Loading
Loading