diff --git a/app/controllers/api/v1/accounts/integrations/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb index c121ec9a4..eb6525bb1 100644 --- a/app/controllers/api/v1/accounts/integrations/linear_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/linear_controller.rb @@ -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 diff --git a/app/models/integrations/app.rb b/app/models/integrations/app.rb index 3b5cd821a..6a1378f1e 100644 --- a/app/models/integrations/app.rb +++ b/app/models/integrations/app.rb @@ -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 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 d96be1b5d..2ffcf8cca 100644 --- a/lib/linear.rb +++ b/lib/linear.rb @@ -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? 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 995b7c879..5f512b2bd 100644 --- a/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb @@ -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", 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) { '' }