mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-29 10:12:34 +00:00
feat: Add user attribution to Linear integration with actor authorization (#11774)
- Add `actor=app` parameter to Linear OAuth authorization URL for consistent app-level authorization https://linear.app/developers/oauth-actor-authorization - Implement user attribution for Linear issue creation and linking using `createAsUser` and `displayIconUrl` parameters - Enhance Linear integration to properly attribute actions to specific Chatwoot agents **Note** - The displayIconUrl parameter is being sent correctly to Linear's GraphQL API (verified through testing), but there is an issues with icon is not attaching properly. - We might need to disconnect the integration connect again.
This commit is contained in:
@@ -28,7 +28,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
|
||||
end
|
||||
|
||||
def create_issue
|
||||
issue = linear_processor_service.create_issue(permitted_params)
|
||||
issue = linear_processor_service.create_issue(permitted_params, Current.user)
|
||||
if issue[:error]
|
||||
render json: { error: issue[:error] }, status: :unprocessable_entity
|
||||
else
|
||||
@@ -45,7 +45,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
|
||||
def link_issue
|
||||
issue_id = permitted_params[:issue_id]
|
||||
title = permitted_params[:title]
|
||||
issue = linear_processor_service.link_issue(conversation_link, issue_id, title)
|
||||
issue = linear_processor_service.link_issue(conversation_link, issue_id, title, Current.user)
|
||||
if issue[:error]
|
||||
render json: { error: issue[:error] }, status: :unprocessable_entity
|
||||
else
|
||||
|
||||
@@ -73,7 +73,8 @@ class Integrations::App
|
||||
"redirect_uri=#{self.class.linear_integration_url}",
|
||||
"state=#{encode_state}",
|
||||
'scope=read,write',
|
||||
'prompt=consent'
|
||||
'prompt=consent',
|
||||
'actor=app'
|
||||
].join('&')
|
||||
end
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ class Integrations::Linear::ProcessorService
|
||||
}
|
||||
end
|
||||
|
||||
def create_issue(params)
|
||||
response = linear_client.create_issue(params)
|
||||
def create_issue(params, user = nil)
|
||||
response = linear_client.create_issue(params, user)
|
||||
return response if response[:error]
|
||||
|
||||
{
|
||||
@@ -33,8 +33,8 @@ class Integrations::Linear::ProcessorService
|
||||
}
|
||||
end
|
||||
|
||||
def link_issue(link, issue_id, title)
|
||||
response = linear_client.link_issue(link, issue_id, title)
|
||||
def link_issue(link, issue_id, title, user = nil)
|
||||
response = linear_client.link_issue(link, issue_id, title, user)
|
||||
return response if response[:error]
|
||||
|
||||
{
|
||||
|
||||
@@ -46,33 +46,24 @@ class Linear
|
||||
process_response(response)
|
||||
end
|
||||
|
||||
def create_issue(params)
|
||||
def create_issue(params, user = nil)
|
||||
validate_team_and_title(params)
|
||||
validate_priority(params[:priority])
|
||||
validate_label_ids(params[:label_ids])
|
||||
|
||||
variables = {
|
||||
title: params[:title],
|
||||
teamId: params[:team_id],
|
||||
description: params[:description],
|
||||
assigneeId: params[:assignee_id],
|
||||
priority: params[:priority],
|
||||
labelIds: params[:label_ids],
|
||||
projectId: params[:project_id],
|
||||
stateId: params[:state_id]
|
||||
}.compact
|
||||
variables = build_issue_variables(params, user)
|
||||
mutation = Linear::Mutations.issue_create(variables)
|
||||
response = post({ query: mutation })
|
||||
process_response(response)
|
||||
end
|
||||
|
||||
def link_issue(link, issue_id, title)
|
||||
def link_issue(link, issue_id, title, user = nil)
|
||||
raise ArgumentError, 'Missing link' if link.blank?
|
||||
raise ArgumentError, 'Missing issue id' if issue_id.blank?
|
||||
|
||||
payload = {
|
||||
query: Linear::Mutations.issue_link(issue_id, link, title)
|
||||
}
|
||||
link_params = build_link_params(issue_id, link, title, user)
|
||||
payload = { query: Linear::Mutations.issue_link(link_params) }
|
||||
|
||||
response = post(payload)
|
||||
process_response(response)
|
||||
end
|
||||
@@ -97,6 +88,42 @@ class Linear
|
||||
|
||||
private
|
||||
|
||||
def build_issue_variables(params, user)
|
||||
variables = {
|
||||
title: params[:title],
|
||||
teamId: params[:team_id],
|
||||
description: params[:description],
|
||||
assigneeId: params[:assignee_id],
|
||||
priority: params[:priority],
|
||||
labelIds: params[:label_ids],
|
||||
projectId: params[:project_id],
|
||||
stateId: params[:state_id]
|
||||
}.compact
|
||||
|
||||
# Add user attribution if available
|
||||
if user&.name.present?
|
||||
variables[:createAsUser] = user.name
|
||||
variables[:displayIconUrl] = user.avatar_url if user.avatar_url.present?
|
||||
end
|
||||
|
||||
variables
|
||||
end
|
||||
|
||||
def build_link_params(issue_id, link, title, user)
|
||||
params = {
|
||||
issue_id: issue_id,
|
||||
link: link,
|
||||
title: title
|
||||
}
|
||||
|
||||
if user.present?
|
||||
params[:user_name] = user.name if user.name.present?
|
||||
params[:user_avatar_url] = user.avatar_url if user.avatar_url.present?
|
||||
end
|
||||
|
||||
params
|
||||
end
|
||||
|
||||
def validate_team_and_title(params)
|
||||
raise ArgumentError, 'Missing team id' if params[:team_id].blank?
|
||||
raise ArgumentError, 'Missing title' if params[:title].blank?
|
||||
|
||||
@@ -32,10 +32,22 @@ module Linear::Mutations
|
||||
GRAPHQL
|
||||
end
|
||||
|
||||
def self.issue_link(issue_id, link, title)
|
||||
def self.issue_link(params)
|
||||
issue_id = params[:issue_id]
|
||||
link = params[:link]
|
||||
title = params[:title]
|
||||
user_name = params[:user_name]
|
||||
user_avatar_url = params[:user_avatar_url]
|
||||
|
||||
user_params = []
|
||||
user_params << "createAsUser: #{graphql_value(user_name)}" if user_name.present?
|
||||
user_params << "displayIconUrl: #{graphql_value(user_avatar_url)}" if user_avatar_url.present?
|
||||
|
||||
user_params_str = user_params.any? ? ", #{user_params.join(', ')}" : ''
|
||||
|
||||
<<~GRAPHQL
|
||||
mutation {
|
||||
attachmentLinkURL(url: "#{link}", issueId: "#{issue_id}", title: "#{title}") {
|
||||
attachmentLinkURL(url: "#{link}", issueId: "#{issue_id}", title: "#{title}"#{user_params_str}) {
|
||||
success
|
||||
attachment {
|
||||
id
|
||||
|
||||
@@ -119,7 +119,7 @@ RSpec.describe 'Linear Integration API', type: :request do
|
||||
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)
|
||||
allow(processor_service).to receive(:create_issue).with(issue_params.stringify_keys, agent).and_return(created_issue)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/integrations/linear/create_issue",
|
||||
params: issue_params,
|
||||
@@ -131,7 +131,7 @@ RSpec.describe 'Linear Integration API', type: :request do
|
||||
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)
|
||||
allow(processor_service).to receive(:create_issue).with(issue_params.stringify_keys, agent).and_return(created_issue)
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/integrations/linear/create_issue",
|
||||
@@ -150,7 +150,7 @@ RSpec.describe 'Linear Integration API', type: :request do
|
||||
|
||||
context 'when issue creation fails' 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')
|
||||
allow(processor_service).to receive(:create_issue).with(issue_params.stringify_keys, agent).and_return(error: 'error message')
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/integrations/linear/create_issue",
|
||||
@@ -177,7 +177,7 @@ RSpec.describe 'Linear Integration API', type: :request do
|
||||
let(:linked_issue) { { data: { 'id' => 'issue1', 'link' => 'https://linear.app/issue1' } } }
|
||||
|
||||
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)
|
||||
allow(processor_service).to receive(:link_issue).with(link, issue_id, title, agent).and_return(linked_issue)
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/integrations/linear/link_issue",
|
||||
@@ -199,7 +199,7 @@ RSpec.describe 'Linear Integration API', type: :request do
|
||||
|
||||
context 'when issue linking fails' 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')
|
||||
allow(processor_service).to receive(:link_issue).with(link, issue_id, title, agent).and_return(error: 'error message')
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/integrations/linear/link_issue",
|
||||
|
||||
@@ -80,6 +80,7 @@ describe Integrations::Linear::ProcessorService do
|
||||
label_ids: %w[bug]
|
||||
}
|
||||
end
|
||||
let(:user) { instance_double(User, name: 'John Doe', avatar_url: 'https://example.com/avatar.jpg') }
|
||||
let(:issue_response) do
|
||||
{
|
||||
'issueCreate' => {
|
||||
@@ -94,7 +95,7 @@ describe Integrations::Linear::ProcessorService do
|
||||
|
||||
context 'when Linear client returns valid data' do
|
||||
it 'returns parsed issue data with identifier' do
|
||||
allow(linear_client).to receive(:create_issue).with(params).and_return(issue_response)
|
||||
allow(linear_client).to receive(:create_issue).with(params, nil).and_return(issue_response)
|
||||
result = service.create_issue(params)
|
||||
expect(result).to eq({
|
||||
data: {
|
||||
@@ -104,13 +105,27 @@ describe Integrations::Linear::ProcessorService do
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
context 'when user is provided' do
|
||||
it 'passes user to Linear client' do
|
||||
allow(linear_client).to receive(:create_issue).with(params, user).and_return(issue_response)
|
||||
result = service.create_issue(params, user)
|
||||
expect(result).to eq({
|
||||
data: {
|
||||
id: 'issue1',
|
||||
title: 'Issue title',
|
||||
identifier: 'ENG-123'
|
||||
}
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Linear client returns an error' do
|
||||
let(:error_response) { { error: 'Some error message' } }
|
||||
|
||||
it 'returns the error' do
|
||||
allow(linear_client).to receive(:create_issue).with(params).and_return(error_response)
|
||||
allow(linear_client).to receive(:create_issue).with(params, nil).and_return(error_response)
|
||||
result = service.create_issue(params)
|
||||
expect(result).to eq(error_response)
|
||||
end
|
||||
@@ -121,22 +136,31 @@ describe Integrations::Linear::ProcessorService do
|
||||
let(:link) { 'https://example.com' }
|
||||
let(:issue_id) { 'issue1' }
|
||||
let(:title) { 'Title' }
|
||||
let(:user) { instance_double(User, name: 'John Doe', avatar_url: 'https://example.com/avatar.jpg') }
|
||||
let(:link_issue_response) { { id: issue_id, link: link, 'attachmentLinkURL': { 'attachment': { 'id': 'attachment1' } } } }
|
||||
let(:link_response) { { data: { id: issue_id, link: link, link_id: 'attachment1' } } }
|
||||
|
||||
context 'when Linear client returns valid data' do
|
||||
it 'returns parsed link data' do
|
||||
allow(linear_client).to receive(:link_issue).with(link, issue_id, title).and_return(link_issue_response)
|
||||
allow(linear_client).to receive(:link_issue).with(link, issue_id, title, nil).and_return(link_issue_response)
|
||||
result = service.link_issue(link, issue_id, title)
|
||||
expect(result).to eq(link_response)
|
||||
end
|
||||
|
||||
context 'when user is provided' do
|
||||
it 'passes user to Linear client' do
|
||||
allow(linear_client).to receive(:link_issue).with(link, issue_id, title, user).and_return(link_issue_response)
|
||||
result = service.link_issue(link, issue_id, title, user)
|
||||
expect(result).to eq(link_response)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Linear client returns an error' do
|
||||
let(:error_response) { { error: 'Some error message' } }
|
||||
|
||||
it 'returns the error' do
|
||||
allow(linear_client).to receive(:link_issue).with(link, issue_id, title).and_return(error_response)
|
||||
allow(linear_client).to receive(:link_issue).with(link, issue_id, title, nil).and_return(error_response)
|
||||
result = service.link_issue(link, issue_id, title)
|
||||
expect(result).to eq(error_response)
|
||||
end
|
||||
@@ -237,7 +261,7 @@ describe Integrations::Linear::ProcessorService do
|
||||
}
|
||||
}
|
||||
|
||||
allow(linear_client).to receive(:create_issue).with(params).and_return(response)
|
||||
allow(linear_client).to receive(:create_issue).with(params, nil).and_return(response)
|
||||
result = service.create_issue(params)
|
||||
|
||||
expect(result[:data]).to have_key(:identifier)
|
||||
@@ -256,7 +280,7 @@ describe Integrations::Linear::ProcessorService do
|
||||
}
|
||||
}
|
||||
|
||||
allow(linear_client).to receive(:link_issue).with(link, issue_id, title).and_return(response)
|
||||
allow(linear_client).to receive(:link_issue).with(link, issue_id, title, nil).and_return(response)
|
||||
result = service.link_issue(link, issue_id, title)
|
||||
|
||||
expect(result[:data][:id]).to eq(issue_id)
|
||||
|
||||
@@ -91,6 +91,7 @@ describe Linear do
|
||||
label_ids: ['bug']
|
||||
}
|
||||
end
|
||||
let(:user) { instance_double(User, name: 'John Doe', avatar_url: 'https://example.com/avatar.jpg') }
|
||||
|
||||
context 'when the API response is success' do
|
||||
before do
|
||||
@@ -103,6 +104,34 @@ describe Linear do
|
||||
expect(response).to eq({ 'issueCreate' => { 'id' => 'issue1', 'title' => 'Title' } })
|
||||
end
|
||||
|
||||
context 'when user is provided' do
|
||||
it 'includes user attribution in the request' do
|
||||
allow(linear_client).to receive(:post) do |payload|
|
||||
expect(payload[:query]).to include('createAsUser: "John Doe"')
|
||||
expect(payload[:query]).to include('displayIconUrl: "https://example.com/avatar.jpg"')
|
||||
instance_double(HTTParty::Response, success?: true,
|
||||
parsed_response: { 'data' => { 'issueCreate' => { 'id' => 'issue1', 'title' => 'Title' } } })
|
||||
end
|
||||
|
||||
linear_client.create_issue(params, user)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has no avatar' do
|
||||
let(:user_no_avatar) { instance_double(User, name: 'Jane Doe', avatar_url: '') }
|
||||
|
||||
it 'includes only user name in the request' do
|
||||
allow(linear_client).to receive(:post) do |payload|
|
||||
expect(payload[:query]).to include('createAsUser: "Jane Doe"')
|
||||
expect(payload[:query]).not_to include('displayIconUrl')
|
||||
instance_double(HTTParty::Response, success?: true,
|
||||
parsed_response: { 'data' => { 'issueCreate' => { 'id' => 'issue1', 'title' => 'Title' } } })
|
||||
end
|
||||
|
||||
linear_client.create_issue(params, user_no_avatar)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the priority is invalid' do
|
||||
let(:params) { { title: 'Title', team_id: 'team1', priority: 5 } }
|
||||
|
||||
@@ -182,6 +211,7 @@ describe Linear do
|
||||
let(:link) { 'https://example.com' }
|
||||
let(:issue_id) { 'issue1' }
|
||||
let(:title) { 'Title' }
|
||||
let(:user) { instance_double(User, name: 'John Doe', avatar_url: 'https://example.com/avatar.jpg') }
|
||||
|
||||
context 'when the API response is success' do
|
||||
before do
|
||||
@@ -194,6 +224,45 @@ describe Linear do
|
||||
expect(response).to eq({ 'attachmentLinkURL' => { 'id' => 'attachment1' } })
|
||||
end
|
||||
|
||||
context 'when user is provided' do
|
||||
it 'includes user attribution in the request' do
|
||||
expected_params = {
|
||||
issue_id: issue_id,
|
||||
link: link,
|
||||
title: title,
|
||||
user_name: 'John Doe',
|
||||
user_avatar_url: 'https://example.com/avatar.jpg'
|
||||
}
|
||||
|
||||
expect(Linear::Mutations).to receive(:issue_link).with(expected_params).and_call_original
|
||||
allow(linear_client).to receive(:post).and_return(
|
||||
instance_double(HTTParty::Response, success?: true, parsed_response: { 'data' => { 'attachmentLinkURL' => { 'id' => 'attachment1' } } })
|
||||
)
|
||||
|
||||
linear_client.link_issue(link, issue_id, title, user)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has no avatar' do
|
||||
let(:user_no_avatar) { instance_double(User, name: 'Jane Doe', avatar_url: '') }
|
||||
|
||||
it 'includes only user name in the request' do
|
||||
expected_params = {
|
||||
issue_id: issue_id,
|
||||
link: link,
|
||||
title: title,
|
||||
user_name: 'Jane Doe'
|
||||
}
|
||||
|
||||
expect(Linear::Mutations).to receive(:issue_link).with(expected_params).and_call_original
|
||||
allow(linear_client).to receive(:post).and_return(
|
||||
instance_double(HTTParty::Response, success?: true, parsed_response: { 'data' => { 'attachmentLinkURL' => { 'id' => 'attachment1' } } })
|
||||
)
|
||||
|
||||
linear_client.link_issue(link, issue_id, title, user_no_avatar)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the link is missing' do
|
||||
let(:link) { '' }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user