diff --git a/app/controllers/api/v1/accounts/integrations/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb index bfdfff058..eb6525bb1 100644 --- a/app/controllers/api/v1/accounts/integrations/linear_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/linear_controller.rb @@ -3,6 +3,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas before_action :fetch_hook, only: [:destroy] def destroy + revoke_linear_token @hook.destroy! head :ok end @@ -27,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 @@ -44,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 @@ -120,4 +121,15 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas def fetch_hook @hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'linear') end + + def revoke_linear_token + return unless @hook&.access_token + + begin + linear_client = Linear.new(@hook.access_token) + linear_client.revoke_token + rescue StandardError => e + Rails.logger.error "Failed to revoke Linear token: #{e.message}" + end + end end diff --git a/app/models/integrations/app.rb b/app/models/integrations/app.rb index ae58b07d1..ce897fbcb 100644 --- a/app/models/integrations/app.rb +++ b/app/models/integrations/app.rb @@ -81,7 +81,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 diff --git a/app/models/message.rb b/app/models/message.rb index d4036416e..cc04a088e 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -168,7 +168,7 @@ class Message < ApplicationRecord additional_attributes: additional_attributes, content_attributes: content_attributes, content_type: content_type, - content: content, + content: outgoing_content, conversation: conversation.webhook_data, created_at: created_at, id: id, diff --git a/lib/integrations/linear/processor_service.rb b/lib/integrations/linear/processor_service.rb index a53d17f4c..a3447b79e 100644 --- a/lib/integrations/linear/processor_service.rb +++ b/lib/integrations/linear/processor_service.rb @@ -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] { diff --git a/lib/linear.rb b/lib/linear.rb index 8bf967fc3..2ffcf8cca 100644 --- a/lib/linear.rb +++ b/lib/linear.rb @@ -1,5 +1,6 @@ class Linear BASE_URL = 'https://api.linear.app/graphql'.freeze + REVOKE_URL = 'https://api.linear.app/oauth/revoke'.freeze PRIORITY_LEVELS = (0..4).to_a def initialize(access_token) @@ -45,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 @@ -86,8 +78,52 @@ class Linear process_response(response) end + def revoke_token + response = HTTParty.post( + REVOKE_URL, + headers: { 'Authorization' => "Bearer #{@access_token}", 'Content-Type' => 'application/json' } + ) + response.success? + end + 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? diff --git a/lib/linear/mutations.rb b/lib/linear/mutations.rb index 04774c705..5f34e602b 100644 --- a/lib/linear/mutations.rb +++ b/lib/linear/mutations.rb @@ -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 diff --git a/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb b/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb index 851c5dbaf..5f512b2bd 100644 --- a/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb @@ -14,6 +14,12 @@ RSpec.describe 'Linear Integration API', type: :request do describe 'DELETE /api/v1/accounts/:account_id/integrations/linear' do it 'deletes the linear integration' do + # Stub the HTTP call to Linear's revoke endpoint + allow(HTTParty).to receive(:post).with( + 'https://api.linear.app/oauth/revoke', + anything + ).and_return(instance_double(HTTParty::Response, success?: true)) + delete "/api/v1/accounts/#{account.id}/integrations/linear", headers: agent.create_new_auth_token, as: :json @@ -113,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, @@ -125,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", @@ -144,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", @@ -171,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", @@ -193,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", diff --git a/spec/lib/integrations/linear/processor_service_spec.rb b/spec/lib/integrations/linear/processor_service_spec.rb index 8830e658e..9af9e08d3 100644 --- a/spec/lib/integrations/linear/processor_service_spec.rb +++ b/spec/lib/integrations/linear/processor_service_spec.rb @@ -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) diff --git a/spec/lib/linear_spec.rb b/spec/lib/linear_spec.rb index 2ebaec1a8..e15f64382 100644 --- a/spec/lib/linear_spec.rb +++ b/spec/lib/linear_spec.rb @@ -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) { '' } diff --git a/spec/models/message_spec.rb b/spec/models/message_spec.rb index 3bf64a231..6de36a55e 100644 --- a/spec/models/message_spec.rb +++ b/spec/models/message_spec.rb @@ -267,6 +267,23 @@ RSpec.describe Message do message = create(:message) expect(message.webhook_data.key?(:attachments)).to be false end + + it 'uses outgoing_content for webhook content' do + message = create(:message, content: 'Test content') + expect(message).to receive(:outgoing_content).and_return('Outgoing test content') + + webhook_data = message.webhook_data + expect(webhook_data[:content]).to eq('Outgoing test content') + end + + it 'includes CSAT survey link in webhook content for input_csat messages' do + inbox = create(:inbox, channel: create(:channel_api)) + conversation = create(:conversation, inbox: inbox) + message = create(:message, conversation: conversation, content_type: 'input_csat', content: 'Rate your experience') + + expect(message.outgoing_content).to include('survey/responses/') + expect(message.webhook_data[:content]).to include('survey/responses/') + end end context 'when message is created' do