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 end
def create_issue def create_issue
issue = linear_processor_service.create_issue(permitted_params) issue = linear_processor_service.create_issue(permitted_params, Current.user)
if issue[:error] if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity render json: { error: issue[:error] }, status: :unprocessable_entity
else else
@@ -45,7 +45,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
def link_issue def link_issue
issue_id = permitted_params[:issue_id] issue_id = permitted_params[:issue_id]
title = permitted_params[:title] 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] if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity render json: { error: issue[:error] }, status: :unprocessable_entity
else else

View File

@@ -73,7 +73,8 @@ class Integrations::App
"redirect_uri=#{self.class.linear_integration_url}", "redirect_uri=#{self.class.linear_integration_url}",
"state=#{encode_state}", "state=#{encode_state}",
'scope=read,write', 'scope=read,write',
'prompt=consent' 'prompt=consent',
'actor=app'
].join('&') ].join('&')
end end

View File

@@ -22,8 +22,8 @@ class Integrations::Linear::ProcessorService
} }
end end
def create_issue(params) def create_issue(params, user = nil)
response = linear_client.create_issue(params) response = linear_client.create_issue(params, user)
return response if response[:error] return response if response[:error]
{ {
@@ -33,8 +33,8 @@ class Integrations::Linear::ProcessorService
} }
end end
def link_issue(link, issue_id, title) def link_issue(link, issue_id, title, user = nil)
response = linear_client.link_issue(link, issue_id, title) response = linear_client.link_issue(link, issue_id, title, user)
return response if response[:error] return response if response[:error]
{ {

View File

@@ -46,33 +46,24 @@ class Linear
process_response(response) process_response(response)
end end
def create_issue(params) def create_issue(params, user = nil)
validate_team_and_title(params) validate_team_and_title(params)
validate_priority(params[:priority]) validate_priority(params[:priority])
validate_label_ids(params[:label_ids]) validate_label_ids(params[:label_ids])
variables = { variables = build_issue_variables(params, user)
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
mutation = Linear::Mutations.issue_create(variables) mutation = Linear::Mutations.issue_create(variables)
response = post({ query: mutation }) response = post({ query: mutation })
process_response(response) process_response(response)
end 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 link' if link.blank?
raise ArgumentError, 'Missing issue id' if issue_id.blank? raise ArgumentError, 'Missing issue id' if issue_id.blank?
payload = { link_params = build_link_params(issue_id, link, title, user)
query: Linear::Mutations.issue_link(issue_id, link, title) payload = { query: Linear::Mutations.issue_link(link_params) }
}
response = post(payload) response = post(payload)
process_response(response) process_response(response)
end end
@@ -97,6 +88,42 @@ class Linear
private 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) def validate_team_and_title(params)
raise ArgumentError, 'Missing team id' if params[:team_id].blank? raise ArgumentError, 'Missing team id' if params[:team_id].blank?
raise ArgumentError, 'Missing title' if params[:title].blank? raise ArgumentError, 'Missing title' if params[:title].blank?

View File

@@ -32,10 +32,22 @@ module Linear::Mutations
GRAPHQL GRAPHQL
end 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 <<~GRAPHQL
mutation { mutation {
attachmentLinkURL(url: "#{link}", issueId: "#{issue_id}", title: "#{title}") { attachmentLinkURL(url: "#{link}", issueId: "#{issue_id}", title: "#{title}"#{user_params_str}) {
success success
attachment { attachment {
id 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' } } } let(:created_issue) { { data: { identifier: 'ENG-123', title: 'Sample Issue' } } }
it 'returns the created issue' do 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", post "/api/v1/accounts/#{account.id}/integrations/linear/create_issue",
params: issue_params, params: issue_params,
@@ -131,7 +131,7 @@ RSpec.describe 'Linear Integration API', type: :request do
end end
it 'creates activity message when conversation is provided' do 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 expect do
post "/api/v1/accounts/#{account.id}/integrations/linear/create_issue", 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 context 'when issue creation fails' do
it 'returns error message and does not create activity 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') allow(processor_service).to receive(:create_issue).with(issue_params.stringify_keys, agent).and_return(error: 'error message')
expect do expect do
post "/api/v1/accounts/#{account.id}/integrations/linear/create_issue", 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' } } } let(:linked_issue) { { data: { 'id' => 'issue1', 'link' => 'https://linear.app/issue1' } } }
it 'returns the linked issue and creates activity message' 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) allow(processor_service).to receive(:link_issue).with(link, issue_id, title, agent).and_return(linked_issue)
expect do expect do
post "/api/v1/accounts/#{account.id}/integrations/linear/link_issue", 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 context 'when issue linking fails' do
it 'returns error message and does not create activity 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') allow(processor_service).to receive(:link_issue).with(link, issue_id, title, agent).and_return(error: 'error message')
expect do expect do
post "/api/v1/accounts/#{account.id}/integrations/linear/link_issue", 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] label_ids: %w[bug]
} }
end end
let(:user) { instance_double(User, name: 'John Doe', avatar_url: 'https://example.com/avatar.jpg') }
let(:issue_response) do let(:issue_response) do
{ {
'issueCreate' => { 'issueCreate' => {
@@ -94,7 +95,7 @@ describe Integrations::Linear::ProcessorService do
context 'when Linear client returns valid data' do context 'when Linear client returns valid data' do
it 'returns parsed issue data with identifier' 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) result = service.create_issue(params)
expect(result).to eq({ expect(result).to eq({
data: { data: {
@@ -104,13 +105,27 @@ describe Integrations::Linear::ProcessorService do
} }
}) })
end 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 end
context 'when Linear client returns an error' do context 'when Linear client returns an error' do
let(:error_response) { { error: 'Some error message' } } let(:error_response) { { error: 'Some error message' } }
it 'returns the error' do 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) result = service.create_issue(params)
expect(result).to eq(error_response) expect(result).to eq(error_response)
end end
@@ -121,22 +136,31 @@ describe Integrations::Linear::ProcessorService do
let(:link) { 'https://example.com' } let(:link) { 'https://example.com' }
let(:issue_id) { 'issue1' } let(:issue_id) { 'issue1' }
let(:title) { 'Title' } 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_issue_response) { { id: issue_id, link: link, 'attachmentLinkURL': { 'attachment': { 'id': 'attachment1' } } } }
let(:link_response) { { data: { id: issue_id, link: link, link_id: 'attachment1' } } } let(:link_response) { { data: { id: issue_id, link: link, link_id: 'attachment1' } } }
context 'when Linear client returns valid data' do context 'when Linear client returns valid data' do
it 'returns parsed link 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) result = service.link_issue(link, issue_id, title)
expect(result).to eq(link_response) expect(result).to eq(link_response)
end 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 end
context 'when Linear client returns an error' do context 'when Linear client returns an error' do
let(:error_response) { { error: 'Some error message' } } let(:error_response) { { error: 'Some error message' } }
it 'returns the error' do 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) result = service.link_issue(link, issue_id, title)
expect(result).to eq(error_response) expect(result).to eq(error_response)
end 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) result = service.create_issue(params)
expect(result[:data]).to have_key(:identifier) 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) result = service.link_issue(link, issue_id, title)
expect(result[:data][:id]).to eq(issue_id) expect(result[:data][:id]).to eq(issue_id)

View File

@@ -91,6 +91,7 @@ describe Linear do
label_ids: ['bug'] label_ids: ['bug']
} }
end end
let(:user) { instance_double(User, name: 'John Doe', avatar_url: 'https://example.com/avatar.jpg') }
context 'when the API response is success' do context 'when the API response is success' do
before do before do
@@ -103,6 +104,34 @@ describe Linear do
expect(response).to eq({ 'issueCreate' => { 'id' => 'issue1', 'title' => 'Title' } }) expect(response).to eq({ 'issueCreate' => { 'id' => 'issue1', 'title' => 'Title' } })
end 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 context 'when the priority is invalid' do
let(:params) { { title: 'Title', team_id: 'team1', priority: 5 } } let(:params) { { title: 'Title', team_id: 'team1', priority: 5 } }
@@ -182,6 +211,7 @@ describe Linear do
let(:link) { 'https://example.com' } let(:link) { 'https://example.com' }
let(:issue_id) { 'issue1' } let(:issue_id) { 'issue1' }
let(:title) { 'Title' } 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 context 'when the API response is success' do
before do before do
@@ -194,6 +224,45 @@ describe Linear do
expect(response).to eq({ 'attachmentLinkURL' => { 'id' => 'attachment1' } }) expect(response).to eq({ 'attachmentLinkURL' => { 'id' => 'attachment1' } })
end 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 context 'when the link is missing' do
let(:link) { '' } let(:link) { '' }