feat: Add activity messages for linear actions (#11654)

This commit is contained in:
Muhsin Keloth
2025-06-13 11:57:11 +05:30
committed by GitHub
parent 58380c6d01
commit f4381e3b5d
14 changed files with 460 additions and 49 deletions

View File

@@ -1,5 +1,5 @@
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
before_action :fetch_conversation, only: [:link_issue, :linked_issues]
before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues]
before_action :fetch_hook, only: [:destroy]
def destroy
@@ -31,6 +31,12 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
Linear::ActivityMessageService.new(
conversation: @conversation,
action_type: :issue_created,
issue_data: { id: issue[:data][:identifier] },
user: Current.user
).perform
render json: issue[:data], status: :ok
end
end
@@ -42,17 +48,30 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
Linear::ActivityMessageService.new(
conversation: @conversation,
action_type: :issue_linked,
issue_data: { id: issue_id },
user: Current.user
).perform
render json: issue[:data], status: :ok
end
end
def unlink_issue
link_id = permitted_params[:link_id]
issue_id = permitted_params[:issue_id]
issue = linear_processor_service.unlink_issue(link_id)
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
Linear::ActivityMessageService.new(
conversation: @conversation,
action_type: :issue_unlinked,
issue_data: { id: issue_id },
user: Current.user
).perform
render json: issue[:data], status: :ok
end
end

View File

@@ -33,9 +33,11 @@ class LinearAPI extends ApiClient {
);
}
unlinkIssue(linkId) {
unlinkIssue(linkId, issueIdentifier, conversationId) {
return axios.post(`${this.url}/unlink_issue`, {
link_id: linkId,
issue_id: issueIdentifier,
conversation_id: conversationId,
});
}

View File

@@ -91,6 +91,19 @@ describe('#linearAPI', () => {
issueData
);
});
it('creates a valid request with conversation_id', () => {
const issueData = {
title: 'New Issue',
description: 'Issue description',
conversation_id: 123,
};
LinearAPIClient.createIssue(issueData);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/create_issue',
issueData
);
});
});
describe('link_issue', () => {
@@ -120,6 +133,18 @@ describe('#linearAPI', () => {
}
);
});
it('creates a valid request with title', () => {
LinearAPIClient.link_issue(1, 'ENG-123', 'Sample Issue');
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/link_issue',
{
issue_id: 'ENG-123',
conversation_id: 1,
title: 'Sample Issue',
}
);
});
});
describe('getLinkedIssue', () => {
@@ -164,12 +189,26 @@ describe('#linearAPI', () => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.unlinkIssue(1);
it('creates a valid request with link_id only', () => {
LinearAPIClient.unlinkIssue('link123');
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/unlink_issue',
{
link_id: 1,
link_id: 'link123',
issue_id: undefined,
conversation_id: undefined,
}
);
});
it('creates a valid request with all parameters', () => {
LinearAPIClient.unlinkIssue('link123', 'ENG-456', 789);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/unlink_issue',
{
link_id: 'link123',
issue_id: 'ENG-456',
conversation_id: 789,
}
);
});

View File

@@ -183,13 +183,18 @@ const createIssue = async () => {
state_id: formState.stateId || undefined,
priority: formState.priority || undefined,
label_ids: formState.labelId ? [formState.labelId] : undefined,
conversation_id: props.conversationId,
};
try {
isCreating.value = true;
const response = await LinearAPI.createIssue(payload);
const { id: issueId } = response.data;
await LinearAPI.link_issue(props.conversationId, issueId, props.title);
const { identifier: issueIdentifier } = response.data;
await LinearAPI.link_issue(
props.conversationId,
issueIdentifier,
props.title
);
useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_SUCCESS'));
useTrack(LINEAR_EVENTS.CREATE_ISSUE);
onClose();

View File

@@ -46,9 +46,9 @@ const loadLinkedIssues = async () => {
}
};
const unlinkIssue = async linkId => {
const unlinkIssue = async (linkId, issueIdentifier) => {
try {
await LinearAPI.unlinkIssue(linkId);
await LinearAPI.unlinkIssue(linkId, issueIdentifier, props.conversationId);
useTrack(LINEAR_EVENTS.UNLINK_ISSUE);
linkedIssues.value = linkedIssues.value.filter(
issue => issue.id !== linkId
@@ -110,7 +110,7 @@ onMounted(() => {
<LinearIssueItem
v-for="linkedIssue in linkedIssues"
:key="linkedIssue.id"
class="pt-3 px-4 pb-4 border-b border-n-weak last:border-b-0"
class="px-4 pt-3 pb-4 border-b border-n-weak last:border-b-0"
:linked-issue="linkedIssue"
@unlink-issue="unlinkIssue"
/>

View File

@@ -14,6 +14,8 @@ const props = defineProps({
const emit = defineEmits(['unlinkIssue']);
const { linkedIssue } = props;
const priorityMap = {
1: 'Urgent',
2: 'High',
@@ -21,7 +23,7 @@ const priorityMap = {
4: 'Low',
};
const issue = computed(() => props.linkedIssue.issue);
const issue = computed(() => linkedIssue.issue);
const assignee = computed(() => {
const assigneeDetails = issue.value.assignee;
@@ -37,7 +39,7 @@ const labels = computed(() => issue.value.labels?.nodes || []);
const priorityLabel = computed(() => priorityMap[issue.value.priority]);
const unlinkIssue = () => {
emit('unlinkIssue', props.linkedIssue.id);
emit('unlinkIssue', linkedIssue.id, linkedIssue.issue.identifier);
};
</script>

View File

@@ -63,7 +63,7 @@ const onSearch = async value => {
isFetching.value = true;
const response = await LinearAPI.searchIssues(value);
issues.value = response.data.map(issue => ({
id: issue.id,
id: issue.identifier,
name: `${issue.identifier} ${issue.title}`,
icon: 'status',
iconColor: issue.state.color,

View File

@@ -0,0 +1,41 @@
class Linear::ActivityMessageService
attr_reader :conversation, :action_type, :issue_data, :user
def initialize(conversation:, action_type:, user:, issue_data: {})
@conversation = conversation
@action_type = action_type
@issue_data = issue_data
@user = user
end
def perform
return unless conversation && issue_data[:id] && user
content = generate_activity_content
return unless content
::Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params(content))
end
private
def generate_activity_content
case action_type.to_sym
when :issue_created
I18n.t('conversations.activity.linear.issue_created', user_name: user.name, issue_id: issue_data[:id])
when :issue_linked
I18n.t('conversations.activity.linear.issue_linked', user_name: user.name, issue_id: issue_data[:id])
when :issue_unlinked
I18n.t('conversations.activity.linear.issue_unlinked', user_name: user.name, issue_id: issue_data[:id])
end
end
def activity_message_params(content)
{
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: :activity,
content: content
}
end
end

View File

@@ -190,6 +190,10 @@ en:
sla:
added: '%{user_name} added SLA policy %{sla_name}'
removed: '%{user_name} removed SLA policy %{sla_name}'
linear:
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
csat:
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
muted: '%{user_name} has muted the conversation'

View File

@@ -28,7 +28,8 @@ class Integrations::Linear::ProcessorService
{
data: { id: response['issueCreate']['issue']['id'],
title: response['issueCreate']['issue']['title'] }
title: response['issueCreate']['issue']['title'],
identifier: response['issueCreate']['issue']['identifier'] }
}
end

View File

@@ -25,6 +25,7 @@ module Linear::Mutations
issue {
id
title
identifier
}
}
}

View File

@@ -93,6 +93,8 @@ RSpec.describe 'Linear Integration API', type: :request do
end
describe 'POST /api/v1/accounts/:account_id/integrations/linear/create_issue' do
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
let(:issue_params) do
{
team_id: 'team1',
@@ -101,32 +103,56 @@ RSpec.describe 'Linear Integration API', type: :request do
assignee_id: 'user1',
priority: 'high',
state_id: 'state1',
label_ids: ['label1']
label_ids: ['label1'],
conversation_id: conversation.display_id
}
end
context 'when it is an authenticated user' do
context 'when the issue is created successfully' do
let(:created_issue) { { data: { 'id' => 'issue1', 'title' => 'Sample Issue' } } }
let(:created_issue) { { data: { identifier: 'ENG-123', title: 'Sample Issue' } } }
it 'returns the created issue' do
allow(processor_service).to receive(:create_issue).with(issue_params.stringify_keys).and_return(created_issue)
post "/api/v1/accounts/#{account.id}/integrations/linear/create_issue",
params: issue_params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(response.body).to include('Sample Issue')
end
it 'creates activity message when conversation is provided' do
allow(processor_service).to receive(:create_issue).with(issue_params.stringify_keys).and_return(created_issue)
expect do
post "/api/v1/accounts/#{account.id}/integrations/linear/create_issue",
params: issue_params,
headers: agent.create_new_auth_token,
as: :json
end.to have_enqueued_job(Conversations::ActivityMessageJob)
.with(conversation, {
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: :activity,
content: "Linear issue ENG-123 was created by #{agent.name}"
})
end
end
context 'when issue creation fails' do
it 'returns error message' do
it 'returns error message and does not create activity message' do
allow(processor_service).to receive(:create_issue).with(issue_params.stringify_keys).and_return(error: 'error message')
post "/api/v1/accounts/#{account.id}/integrations/linear/create_issue",
params: issue_params,
headers: agent.create_new_auth_token,
as: :json
expect do
post "/api/v1/accounts/#{account.id}/integrations/linear/create_issue",
params: issue_params,
headers: agent.create_new_auth_token,
as: :json
end.not_to have_enqueued_job(Conversations::ActivityMessageJob)
expect(response).to have_http_status(:unprocessable_entity)
expect(response.body).to include('error message')
end
@@ -135,7 +161,7 @@ RSpec.describe 'Linear Integration API', type: :request do
end
describe 'POST /api/v1/accounts/:account_id/integrations/linear/link_issue' do
let(:issue_id) { 'issue1' }
let(:issue_id) { 'ENG-456' }
let(:conversation) { create(:conversation, account: account) }
let(:link) { "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/conversations/#{conversation.display_id}" }
let(:title) { 'Sample Issue' }
@@ -144,24 +170,38 @@ RSpec.describe 'Linear Integration API', type: :request do
context 'when the issue is linked successfully' do
let(:linked_issue) { { data: { 'id' => 'issue1', 'link' => 'https://linear.app/issue1' } } }
it 'returns the linked issue' do
it 'returns the linked issue and creates activity message' do
allow(processor_service).to receive(:link_issue).with(link, issue_id, title).and_return(linked_issue)
post "/api/v1/accounts/#{account.id}/integrations/linear/link_issue",
params: { conversation_id: conversation.display_id, issue_id: issue_id, title: title },
headers: agent.create_new_auth_token,
as: :json
expect do
post "/api/v1/accounts/#{account.id}/integrations/linear/link_issue",
params: { conversation_id: conversation.display_id, issue_id: issue_id, title: title },
headers: agent.create_new_auth_token,
as: :json
end.to have_enqueued_job(Conversations::ActivityMessageJob)
.with(conversation, {
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: :activity,
content: "Linear issue ENG-456 was linked by #{agent.name}"
})
expect(response).to have_http_status(:ok)
expect(response.body).to include('https://linear.app/issue1')
end
end
context 'when issue linking fails' do
it 'returns error message' do
it 'returns error message and does not create activity message' do
allow(processor_service).to receive(:link_issue).with(link, issue_id, title).and_return(error: 'error message')
post "/api/v1/accounts/#{account.id}/integrations/linear/link_issue",
params: { conversation_id: conversation.display_id, issue_id: issue_id, title: title },
headers: agent.create_new_auth_token,
as: :json
expect do
post "/api/v1/accounts/#{account.id}/integrations/linear/link_issue",
params: { conversation_id: conversation.display_id, issue_id: issue_id, title: title },
headers: agent.create_new_auth_token,
as: :json
end.not_to have_enqueued_job(Conversations::ActivityMessageJob)
expect(response).to have_http_status(:unprocessable_entity)
expect(response.body).to include('error message')
end
@@ -171,29 +211,45 @@ RSpec.describe 'Linear Integration API', type: :request do
describe 'POST /api/v1/accounts/:account_id/integrations/linear/unlink_issue' do
let(:link_id) { 'attachment1' }
let(:issue_id) { 'ENG-789' }
let(:conversation) { create(:conversation, account: account) }
context 'when it is an authenticated user' do
context 'when the issue is unlinked successfully' do
let(:unlinked_issue) { { data: { 'id' => 'issue1', 'link' => 'https://linear.app/issue1' } } }
it 'returns the unlinked issue' do
it 'returns the unlinked issue and creates activity message' do
allow(processor_service).to receive(:unlink_issue).with(link_id).and_return(unlinked_issue)
post "/api/v1/accounts/#{account.id}/integrations/linear/unlink_issue",
params: { link_id: link_id },
headers: agent.create_new_auth_token,
as: :json
expect do
post "/api/v1/accounts/#{account.id}/integrations/linear/unlink_issue",
params: { link_id: link_id, issue_id: issue_id, conversation_id: conversation.display_id },
headers: agent.create_new_auth_token,
as: :json
end.to have_enqueued_job(Conversations::ActivityMessageJob)
.with(conversation, {
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: :activity,
content: "Linear issue ENG-789 was unlinked by #{agent.name}"
})
expect(response).to have_http_status(:ok)
expect(response.body).to include('https://linear.app/issue1')
end
end
context 'when issue unlinking fails' do
it 'returns error message' do
it 'returns error message and does not create activity message' do
allow(processor_service).to receive(:unlink_issue).with(link_id).and_return(error: 'error message')
post "/api/v1/accounts/#{account.id}/integrations/linear/unlink_issue",
params: { link_id: link_id },
headers: agent.create_new_auth_token,
as: :json
expect do
post "/api/v1/accounts/#{account.id}/integrations/linear/unlink_issue",
params: { link_id: link_id, issue_id: issue_id, conversation_id: conversation.display_id },
headers: agent.create_new_auth_token,
as: :json
end.not_to have_enqueued_job(Conversations::ActivityMessageJob)
expect(response).to have_http_status(:unprocessable_entity)
expect(response.body).to include('error message')
end

View File

@@ -82,15 +82,27 @@ describe Integrations::Linear::ProcessorService do
end
let(:issue_response) do
{
'issueCreate' => { 'issue' => { 'id' => 'issue1', 'title' => 'Issue title' } }
'issueCreate' => {
'issue' => {
'id' => 'issue1',
'title' => 'Issue title',
'identifier' => 'ENG-123'
}
}
}
end
context 'when Linear client returns valid data' do
it 'returns parsed issue data' do
it 'returns parsed issue data with identifier' do
allow(linear_client).to receive(:create_issue).with(params).and_return(issue_response)
result = service.create_issue(params)
expect(result).to eq({ data: { id: 'issue1', title: 'Issue title' } })
expect(result).to eq({
data: {
id: 'issue1',
title: 'Issue title',
identifier: 'ENG-123'
}
})
end
end
@@ -133,13 +145,13 @@ describe Integrations::Linear::ProcessorService do
describe '#unlink_issue' do
let(:link_id) { 'attachment1' }
let(:unlink_response) { { data: { link_id: link_id } } }
let(:linear_client_response) { { success: true } }
context 'when Linear client returns valid data' do
it 'returns parsed unlink data' do
allow(linear_client).to receive(:unlink_issue).with(link_id).and_return(unlink_response)
it 'returns unlink data with link_id' do
allow(linear_client).to receive(:unlink_issue).with(link_id).and_return(linear_client_response)
result = service.unlink_issue(link_id)
expect(result).to eq(unlink_response)
expect(result).to eq({ data: { link_id: link_id } })
end
end
@@ -207,4 +219,59 @@ describe Integrations::Linear::ProcessorService do
end
end
end
# Tests specifically for activity message integration
describe 'activity message data compatibility' do
let(:linear_client_response) { { success: true } }
describe '#create_issue' do
it 'includes identifier field needed for activity messages' do
params = { title: 'Test Issue', team_id: 'team1' }
response = {
'issueCreate' => {
'issue' => {
'id' => 'internal_id_123',
'title' => 'Test Issue',
'identifier' => 'ENG-456'
}
}
}
allow(linear_client).to receive(:create_issue).with(params).and_return(response)
result = service.create_issue(params)
expect(result[:data]).to have_key(:identifier)
expect(result[:data][:identifier]).to eq('ENG-456')
end
end
describe '#link_issue' do
it 'returns issue_id in response for activity messages' do
link = 'https://example.com'
issue_id = 'ENG-789'
title = 'Test Issue'
response = {
'attachmentLinkURL' => {
'attachment' => { 'id' => 'attachment123' }
}
}
allow(linear_client).to receive(:link_issue).with(link, issue_id, title).and_return(response)
result = service.link_issue(link, issue_id, title)
expect(result[:data][:id]).to eq(issue_id)
end
end
describe '#unlink_issue' do
it 'returns structured data for activity messages' do
link_id = 'attachment456'
allow(linear_client).to receive(:unlink_issue).with(link_id).and_return(linear_client_response)
result = service.unlink_issue(link_id)
expect(result).to eq({ data: { link_id: link_id } })
end
end
end
end

View File

@@ -0,0 +1,174 @@
require 'rails_helper'
RSpec.describe Linear::ActivityMessageService, type: :service do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
let(:user) { create(:user, account: account) }
describe '#perform' do
context 'when action_type is issue_created' do
let(:service) do
described_class.new(
conversation: conversation,
action_type: :issue_created,
issue_data: { id: 'ENG-123' },
user: user
)
end
it 'enqueues an activity message job' do
expect do
service.perform
end.to have_enqueued_job(Conversations::ActivityMessageJob)
.with(conversation, {
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: :activity,
content: "Linear issue ENG-123 was created by #{user.name}"
})
end
it 'does not enqueue job when issue data lacks id' do
service = described_class.new(
conversation: conversation,
action_type: :issue_created,
issue_data: { title: 'Some issue' },
user: user
)
expect do
service.perform
end.not_to have_enqueued_job(Conversations::ActivityMessageJob)
end
it 'does not enqueue job when issue_data is empty' do
service = described_class.new(
conversation: conversation,
action_type: :issue_created,
issue_data: {},
user: user
)
expect do
service.perform
end.not_to have_enqueued_job(Conversations::ActivityMessageJob)
end
it 'does not enqueue job when conversation is nil' do
service = described_class.new(
conversation: nil,
action_type: :issue_created,
issue_data: { id: 'ENG-123' },
user: user
)
expect do
service.perform
end.not_to have_enqueued_job(Conversations::ActivityMessageJob)
end
it 'does not enqueue job when user is nil' do
service = described_class.new(
conversation: conversation,
action_type: :issue_created,
issue_data: { id: 'ENG-123' },
user: nil
)
expect do
service.perform
end.not_to have_enqueued_job(Conversations::ActivityMessageJob)
end
end
context 'when action_type is issue_linked' do
let(:service) do
described_class.new(
conversation: conversation,
action_type: :issue_linked,
issue_data: { id: 'ENG-456' },
user: user
)
end
it 'enqueues an activity message job' do
expect do
service.perform
end.to have_enqueued_job(Conversations::ActivityMessageJob)
.with(conversation, {
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: :activity,
content: "Linear issue ENG-456 was linked by #{user.name}"
})
end
it 'does not enqueue job when issue data lacks id' do
service = described_class.new(
conversation: conversation,
action_type: :issue_linked,
issue_data: { title: 'Some issue' },
user: user
)
expect do
service.perform
end.not_to have_enqueued_job(Conversations::ActivityMessageJob)
end
end
context 'when action_type is issue_unlinked' do
let(:service) do
described_class.new(
conversation: conversation,
action_type: :issue_unlinked,
issue_data: { id: 'ENG-789' },
user: user
)
end
it 'enqueues an activity message job' do
expect do
service.perform
end.to have_enqueued_job(Conversations::ActivityMessageJob)
.with(conversation, {
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: :activity,
content: "Linear issue ENG-789 was unlinked by #{user.name}"
})
end
it 'does not enqueue job when issue data lacks id' do
service = described_class.new(
conversation: conversation,
action_type: :issue_unlinked,
issue_data: { title: 'Some issue' },
user: user
)
expect do
service.perform
end.not_to have_enqueued_job(Conversations::ActivityMessageJob)
end
end
context 'when action_type is unknown' do
let(:service) do
described_class.new(
conversation: conversation,
action_type: :unknown_action,
issue_data: { id: 'ENG-999' },
user: user
)
end
it 'does not enqueue job for unknown action types' do
expect do
service.perform
end.not_to have_enqueued_job(Conversations::ActivityMessageJob)
end
end
end
end