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:
co-authored by
Muhsin
Sojan Jose
parent
58fdd20625
commit
71cc5168be
+17
-10
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -54,6 +54,7 @@ module Linear::Queries
|
||||
title
|
||||
description
|
||||
identifier
|
||||
url
|
||||
state {
|
||||
name
|
||||
color
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user