From 71cc5168beef55f0fae07e3f0d7f9dd7dea1692d Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 12 May 2026 13:03:40 +0400 Subject: [PATCH] feat(linear): Auto link Linear issues from private notes (#14405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/jobs/hook_job.rb | 27 ++- app/listeners/hook_listener.rb | 3 +- config/locales/en.yml | 1 + lib/integrations/linear/auto_link_service.rb | 93 +++++++++ lib/linear/queries.rb | 1 + spec/jobs/hook_job_spec.rb | 7 + .../linear/auto_link_service_spec.rb | 178 ++++++++++++++++++ spec/listeners/hook_listener_spec.rb | 7 + 8 files changed, 306 insertions(+), 11 deletions(-) create mode 100644 lib/integrations/linear/auto_link_service.rb create mode 100644 spec/lib/integrations/linear/auto_link_service_spec.rb diff --git a/app/jobs/hook_job.rb b/app/jobs/hook_job.rb index deb7c81d8..eff80844f 100644 --- a/app/jobs/hook_job.rb +++ b/app/jobs/hook_job.rb @@ -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 diff --git a/app/listeners/hook_listener.rb b/app/listeners/hook_listener.rb index 6176d53dd..7c55b76d1 100644 --- a/app/listeners/hook_listener.rb +++ b/app/listeners/hook_listener.rb @@ -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) diff --git a/config/locales/en.yml b/config/locales/en.yml index f7b98a47b..c590f8be9 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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.' diff --git a/lib/integrations/linear/auto_link_service.rb b/lib/integrations/linear/auto_link_service.rb new file mode 100644 index 000000000..a980e56e4 --- /dev/null +++ b/lib/integrations/linear/auto_link_service.rb @@ -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 diff --git a/lib/linear/queries.rb b/lib/linear/queries.rb index cc705625e..7d7c9ae97 100644 --- a/lib/linear/queries.rb +++ b/lib/linear/queries.rb @@ -54,6 +54,7 @@ module Linear::Queries title description identifier + url state { name color diff --git a/spec/jobs/hook_job_spec.rb b/spec/jobs/hook_job_spec.rb index b55b05838..c6faebd86 100644 --- a/spec/jobs/hook_job_spec.rb +++ b/spec/jobs/hook_job_spec.rb @@ -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 diff --git a/spec/lib/integrations/linear/auto_link_service_spec.rb b/spec/lib/integrations/linear/auto_link_service_spec.rb new file mode 100644 index 000000000..f90c5f060 --- /dev/null +++ b/spec/lib/integrations/linear/auto_link_service_spec.rb @@ -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 diff --git a/spec/listeners/hook_listener_spec.rb b/spec/listeners/hook_listener_spec.rb index c048761d1..0f96ae4af 100644 --- a/spec/listeners/hook_listener_spec.rb +++ b/spec/listeners/hook_listener_spec.rb @@ -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