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:
Muhsin Keloth
2025-07-01 16:49:26 +05:30
committed by GitHub
parent 14ba73fc63
commit 01acbe3cda
8 changed files with 168 additions and 35 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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]
{

View File

@@ -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?

View File

@@ -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

View File

@@ -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",

View File

@@ -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)

View File

@@ -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) { '' }