feat(linear): Auto link Linear issues from private notes (#14405)

When an agent pastes a Linear issue URL into a private note on a
conversation, Chatwoot now links the issue to the conversation
automatically — no need to click "Link to Linear issue" first. The
standard activity message ("X linked Linear issue ABC-123") is posted
just like a manual link.

Fixes
[CW-7032](https://linear.app/chatwoot/issue/CW-7032/if-someone-post-a-linear-url-in-the-private-notes-automatically-link)

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
This commit is contained in:
Muhsin Keloth
2026-05-12 13:03:40 +04:00
committed by GitHub
co-authored by Muhsin Sojan Jose
parent 58fdd20625
commit 71cc5168be
8 changed files with 306 additions and 11 deletions
+17 -10
View File
@@ -3,19 +3,19 @@ class HookJob < MutexApplicationJob
queue_as :medium
INTEGRATION_PROCESSORS = {
'slack' => :process_slack_integration,
'dialogflow' => :process_dialogflow_integration,
'google_translate' => :google_translate_integration,
'leadsquared' => :process_leadsquared_integration_with_lock,
'linear' => :process_linear_integration
}.freeze
def perform(hook, event_name, event_data = {})
return if hook.disabled?
case hook.app_id
when 'slack'
process_slack_integration(hook, event_name, event_data)
when 'dialogflow'
process_dialogflow_integration(hook, event_name, event_data)
when 'google_translate'
google_translate_integration(hook, event_name, event_data)
when 'leadsquared'
process_leadsquared_integration_with_lock(hook, event_name, event_data)
end
processor = INTEGRATION_PROCESSORS[hook.app_id]
send(processor, hook, event_name, event_data) if processor
rescue StandardError => e
Rails.logger.error e
end
@@ -57,6 +57,13 @@ class HookJob < MutexApplicationJob
Integrations::GoogleTranslate::DetectLanguageService.new(hook: hook, message: message).perform
end
def process_linear_integration(hook, event_name, event_data)
return unless event_name == 'message.created'
message = event_data[:message]
Integrations::Linear::AutoLinkService.new(account: hook.account, message: message).perform
end
def process_leadsquared_integration_with_lock(hook, event_name, event_data)
# Why do we need a mutex here? glad you asked
# When a new conversation is created. We get a contact created event, immediately followed by
+2 -1
View File
@@ -62,7 +62,8 @@ class HookListener < BaseListener
'slack' => ['message.created', 'message.updated'],
'dialogflow' => ['message.created', 'message.updated'],
'google_translate' => ['message.created'],
'leadsquared' => ['contact.updated', 'conversation.created', 'conversation.resolved']
'leadsquared' => ['contact.updated', 'conversation.created', 'conversation.resolved'],
'linear' => ['message.created']
}
return false unless supported_events_map.key?(hook.app_id)
+1
View File
@@ -367,6 +367,7 @@ en:
name: 'Linear'
short_description: 'Create and link Linear issues directly from conversations.'
description: 'Create issues in Linear directly from your conversation window. Alternatively, link existing Linear issues for a more streamlined and efficient issue tracking process.'
attachment_link_title: 'Conversation (#%{conversation_id}) with %{name}'
notion:
name: 'Notion'
short_description: 'Integrate databases, documents and pages directly with Captain.'
@@ -0,0 +1,93 @@
class Integrations::Linear::AutoLinkService
pattr_initialize [:account!, :message!]
LINEAR_URL_REGEX = %r{https?://linear\.app/[^/\s]+/issue/[A-Z][A-Z0-9_]+-\d+(?:/[^\s)]*)?}
IDENTIFIER_REGEX = %r{/issue/([A-Z][A-Z0-9_]+-\d+)}i
WORKSPACE_REGEX = %r{//linear\.app/([^/\s]+)/}i
def perform
return unless valid_message?
attempt_link
end
private
def valid_message?
message.private? && message.content.present? && message.sender.is_a?(User)
end
def attempt_link
linear_url = message.content[LINEAR_URL_REGEX]
return if linear_url.blank?
identifier = linear_url[IDENTIFIER_REGEX, 1]&.upcase
workspace = linear_url[WORKSPACE_REGEX, 1]&.downcase
return if identifier.blank? || workspace.blank? || already_linked?(identifier)
finalize_link(workspace, identifier)
end
def finalize_link(workspace, identifier)
node_id = resolve_node_id(workspace, identifier)
return if node_id.blank?
return unless link_to_linear(node_id, identifier)
post_activity_message(identifier)
end
def already_linked?(identifier)
response = processor.linked_issues(conversation_link)
return false if response[:error]
response[:data].any? { |attachment| attachment.dig('issue', 'identifier') == identifier }
end
def resolve_node_id(workspace, identifier)
response = processor.search_issue(identifier)
return if response[:error]
node = response[:data].find do |issue|
issue['identifier'] == identifier && node_workspace(issue) == workspace
end
node && node['id']
end
def node_workspace(node)
node['url']&.match(WORKSPACE_REGEX)&.[](1)&.downcase
end
def link_to_linear(node_id, identifier)
response = processor.link_issue(conversation_link, node_id, attachment_title, message.sender)
if response[:error].present?
Rails.logger.warn("[Linear::AutoLinkService] link_issue failed for #{identifier}: #{response[:error]}")
return false
end
true
end
def attachment_title
I18n.t(
'integration_apps.linear.attachment_link_title',
conversation_id: message.conversation.display_id,
name: message.conversation.contact&.name
)
end
def post_activity_message(identifier)
Linear::ActivityMessageService.new(
conversation: message.conversation,
action_type: :issue_linked,
user: message.sender,
issue_data: { id: identifier }
).perform
end
def conversation_link
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{message.account_id}/conversations/#{message.conversation.display_id}"
end
def processor
@processor ||= Integrations::Linear::ProcessorService.new(account: account)
end
end
+1
View File
@@ -54,6 +54,7 @@ module Linear::Queries
title
description
identifier
url
state {
name
color
+7
View File
@@ -65,6 +65,13 @@ RSpec.describe HookJob do
expect(Integrations::GoogleTranslate::DetectLanguageService).to receive(:new).with(hook: hook, message: event_data[:message])
described_class.perform_now(hook, event_name, event_data)
end
it "calls Integrations::Linear::AutoLinkService when it's a linear hook" do
hook = create(:integrations_hook, :linear, account: account)
allow(Integrations::Linear::AutoLinkService).to receive(:new).and_return(process_service)
expect(Integrations::Linear::AutoLinkService).to receive(:new).with(account: account, message: event_data[:message])
described_class.perform_now(hook, event_name, event_data)
end
end
context 'when handleable events like message.updated for slack' do
@@ -0,0 +1,178 @@
require 'rails_helper'
describe Integrations::Linear::AutoLinkService do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
let(:processor) { instance_double(Integrations::Linear::ProcessorService) }
let(:activity_service) { instance_double(Linear::ActivityMessageService, perform: true) }
let(:linear_url) { 'https://linear.app/chatwoot/issue/CW-1234/some-slug' }
let(:identifier) { 'CW-1234' }
let(:node_id) { 'linear-node-id-1' }
let(:search_response) do
{ data: [{ 'id' => node_id, 'identifier' => identifier, 'title' => 'Issue title',
'url' => 'https://linear.app/chatwoot/issue/CW-1234/issue-title' }] }
end
before do
allow(Integrations::Linear::ProcessorService).to receive(:new).with(account: account).and_return(processor)
allow(Linear::ActivityMessageService).to receive(:new).and_return(activity_service)
allow(processor).to receive(:linked_issues).and_return({ data: [] })
allow(processor).to receive(:search_issue).and_return(search_response)
allow(processor).to receive(:link_issue).and_return({ data: { id: node_id, link_id: 'attachment-1' } })
end
def build_private_note(content)
create(:message,
account: account,
inbox: inbox,
conversation: conversation,
sender: user,
message_type: :outgoing,
private: true,
content: content)
end
describe '#perform' do
context 'when the message is not a private note' do
it 'does no work' do
message = create(:message, account: account, inbox: inbox, conversation: conversation,
sender: user, message_type: :outgoing, private: false,
content: "see #{linear_url}")
described_class.new(account: account, message: message).perform
expect(processor).not_to have_received(:linked_issues)
expect(processor).not_to have_received(:search_issue)
expect(processor).not_to have_received(:link_issue)
expect(Linear::ActivityMessageService).not_to have_received(:new)
end
end
context 'when the sender is not a User' do
it 'does no work' do
contact = create(:contact, account: account)
message = create(:message, account: account, inbox: inbox, conversation: conversation,
sender: contact, message_type: :incoming, private: true,
content: "see #{linear_url}")
described_class.new(account: account, message: message).perform
expect(processor).not_to have_received(:link_issue)
expect(Linear::ActivityMessageService).not_to have_received(:new)
end
end
context 'when the private note has no Linear URL' do
it 'does no work' do
message = build_private_note('just a regular note with no link')
described_class.new(account: account, message: message).perform
expect(processor).not_to have_received(:link_issue)
expect(Linear::ActivityMessageService).not_to have_received(:new)
end
end
context 'when the issue identifier is already linked from this conversation' do
it 'skips silently' do
message = build_private_note("see #{linear_url}")
allow(processor).to receive(:linked_issues).and_return(
{ data: [{ 'id' => 'attachment-prev', 'issue' => { 'id' => node_id, 'identifier' => identifier } }] }
)
described_class.new(account: account, message: message).perform
expect(processor).not_to have_received(:search_issue)
expect(processor).not_to have_received(:link_issue)
expect(Linear::ActivityMessageService).not_to have_received(:new)
end
end
context 'when Linear search returns no exact match for the identifier' do
it 'does not link' do
message = build_private_note("see #{linear_url}")
allow(processor).to receive(:search_issue).with(identifier).and_return(
{ data: [{ 'id' => 'other', 'identifier' => 'OTHER-1', 'url' => 'https://linear.app/chatwoot/issue/OTHER-1' }] }
)
described_class.new(account: account, message: message).perform
expect(processor).not_to have_received(:link_issue)
expect(Linear::ActivityMessageService).not_to have_received(:new)
end
end
context 'when the matching issue belongs to a different Linear workspace' do
it 'does not link' do
message = build_private_note("see #{linear_url}")
allow(processor).to receive(:search_issue).with(identifier).and_return(
{ data: [{ 'id' => node_id, 'identifier' => identifier,
'url' => 'https://linear.app/other-workspace/issue/CW-1234' }] }
)
described_class.new(account: account, message: message).perform
expect(processor).not_to have_received(:link_issue)
expect(Linear::ActivityMessageService).not_to have_received(:new)
end
end
context 'when Linear search returns an error' do
it 'does not link' do
message = build_private_note("see #{linear_url}")
allow(processor).to receive(:search_issue).with(identifier).and_return({ error: 'boom' })
described_class.new(account: account, message: message).perform
expect(processor).not_to have_received(:link_issue)
expect(Linear::ActivityMessageService).not_to have_received(:new)
end
end
context 'when link_issue returns an error' do
it 'does not post the activity message' do
message = build_private_note("see #{linear_url}")
allow(processor).to receive(:link_issue).and_return({ error: 'nope' })
described_class.new(account: account, message: message).perform
expect(Linear::ActivityMessageService).not_to have_received(:new)
end
end
context 'when the private note contains a Linear URL' do
it 'links the issue and posts an activity message' do
message = build_private_note("Found it: #{linear_url}")
described_class.new(account: account, message: message).perform
expect(processor).to have_received(:link_issue).with(
a_string_matching(%r{/conversations/#{conversation.display_id}\z}),
node_id,
anything,
user
)
expect(Linear::ActivityMessageService).to have_received(:new).with(
conversation: conversation,
action_type: :issue_linked,
user: user,
issue_data: { id: identifier }
)
expect(activity_service).to have_received(:perform)
end
it 'links only the first Linear URL when multiple are present' do
second_url = 'https://linear.app/chatwoot/issue/CW-9999'
message = build_private_note("see #{linear_url} and #{second_url}")
described_class.new(account: account, message: message).perform
expect(processor).to have_received(:search_issue).with(identifier).once
expect(processor).not_to have_received(:search_issue).with('CW-9999')
end
end
end
end
+7
View File
@@ -84,6 +84,13 @@ describe HookListener do
listener.message_created(event)
end
it 'enqueues the job for linear' do
hook = create(:integrations_hook, :linear, account: account)
expect(HookJob).to receive(:perform_later).with(hook, event_name, message: message, previous_changes: nil)
listener.message_created(event)
end
end
context 'with disabled hook' do