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
@@ -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
|
||||
Reference in New Issue
Block a user