-
diff --git a/app/javascript/dashboard/store/modules/integrations.js b/app/javascript/dashboard/store/modules/integrations.js
index 477c02b03..3183afbe3 100644
--- a/app/javascript/dashboard/store/modules/integrations.js
+++ b/app/javascript/dashboard/store/modules/integrations.js
@@ -21,9 +21,13 @@ const state = {
};
const isAValidAppIntegration = integration => {
- return ['dialogflow', 'dyte', 'google_translate', 'openai'].includes(
- integration.id
- );
+ return [
+ 'dialogflow',
+ 'dyte',
+ 'google_translate',
+ 'openai',
+ 'linear',
+ ].includes(integration.id);
};
export const getters = {
getIntegrations($state) {
diff --git a/config/features.yml b/config/features.yml
index 42714bc73..98c4f608d 100644
--- a/config/features.yml
+++ b/config/features.yml
@@ -83,3 +83,5 @@
- name: help_center_embedding_search
enabled: false
premium: true
+- name: linear_integration
+ enabled: false
diff --git a/config/integration/apps.yml b/config/integration/apps.yml
index 408c56e1c..538f7ee81 100644
--- a/config/integration/apps.yml
+++ b/config/integration/apps.yml
@@ -159,3 +159,27 @@ openai:
},
]
visible_properties: ['api_key', 'label_suggestion']
+linear:
+ id: linear
+ logo: linear.png
+ i18n_key: linear
+ action: /linear
+ hook_type: account
+ allow_multiple_hooks: false
+ settings_json_schema: {
+ "type": "object",
+ "properties": {
+ "api_key": { "type": "string" },
+ },
+ "required": ["api_key"],
+ "additionalProperties": false,
+ }
+ settings_form_schema: [
+ {
+ "label": "API Key",
+ "type": "text",
+ "name": "api_key",
+ "validation": "required",
+ },
+ ]
+ visible_properties: []
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 44c4d76cb..dae3b36e0 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -224,6 +224,9 @@ en:
openai:
name: "OpenAI"
description: "Integrate powerful AI features into Chatwoot by leveraging the GPT models from OpenAI."
+ linear:
+ name: "Linear"
+ description: "Create Linear issues from conversations, or link existing ones for seamless tracking."
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/routes.rb b/config/routes.rb
index 0593b9e16..fd059a9e4 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -227,6 +227,17 @@ Rails.application.routes.draw do
post :add_participant_to_meeting
end
end
+ resource :linear, controller: 'linear', only: [] do
+ collection do
+ get :teams
+ get :team_entities
+ post :create_issue
+ post :link_issue
+ post :unlink_issue
+ get :search_issue
+ get :linked_issues
+ end
+ end
end
resources :working_hours, only: [:update]
diff --git a/lib/integrations/linear/processor_service.rb b/lib/integrations/linear/processor_service.rb
new file mode 100644
index 000000000..20b04a301
--- /dev/null
+++ b/lib/integrations/linear/processor_service.rb
@@ -0,0 +1,82 @@
+class Integrations::Linear::ProcessorService
+ pattr_initialize [:account!]
+
+ def teams
+ response = linear_client.teams
+ return { error: response[:error] } if response[:error]
+
+ { data: response['teams']['nodes'].map(&:as_json) }
+ end
+
+ def team_entities(team_id)
+ response = linear_client.team_entities(team_id)
+ return response if response[:error]
+
+ {
+ data: {
+ users: response['users']['nodes'].map(&:as_json),
+ projects: response['projects']['nodes'].map(&:as_json),
+ states: response['workflowStates']['nodes'].map(&:as_json),
+ labels: response['issueLabels']['nodes'].map(&:as_json)
+ }
+ }
+ end
+
+ def create_issue(params)
+ response = linear_client.create_issue(params)
+ return response if response[:error]
+
+ {
+ data: { id: response['issueCreate']['issue']['id'],
+ title: response['issueCreate']['issue']['title'] }
+ }
+ end
+
+ def link_issue(link, issue_id, title)
+ response = linear_client.link_issue(link, issue_id, title)
+ return response if response[:error]
+
+ {
+ data: {
+ id: issue_id,
+ link: link,
+ link_id: response.with_indifferent_access[:attachmentLinkURL][:attachment][:id]
+ }
+ }
+ end
+
+ def unlink_issue(link_id)
+ response = linear_client.unlink_issue(link_id)
+ return response if response[:error]
+
+ {
+ data: { link_id: link_id }
+ }
+ end
+
+ def search_issue(term)
+ response = linear_client.search_issue(term)
+
+ return response if response[:error]
+
+ { data: response['searchIssues']['nodes'].map(&:as_json) }
+ end
+
+ def linked_issues(url)
+ response = linear_client.linked_issues(url)
+ return response if response[:error]
+
+ { data: response['attachmentsForURL']['nodes'].map(&:as_json) }
+ end
+
+ private
+
+ def linear_hook
+ @linear_hook ||= account.hooks.find_by!(app_id: 'linear')
+ end
+
+ def linear_client
+ credentials = linear_hook.settings
+ @linear_client ||= Linear.new(credentials['api_key'])
+ end
+end
diff --git a/lib/linear.rb b/lib/linear.rb
new file mode 100644
index 000000000..63489b060
--- /dev/null
+++ b/lib/linear.rb
@@ -0,0 +1,120 @@
+class Linear
+ BASE_URL = 'https://api.linear.app/graphql'.freeze
+ PRIORITY_LEVELS = (0..4).to_a
+
+ def initialize(api_key)
+ @api_key = api_key
+ raise ArgumentError, 'Missing Credentials' if api_key.blank?
+ end
+
+ def teams
+ query = {
+ query: Linear::Queries::TEAMS_QUERY
+ }
+ response = post(query)
+ process_response(response)
+ end
+
+ def team_entities(team_id)
+ raise ArgumentError, 'Missing team id' if team_id.blank?
+
+ query = {
+ query: Linear::Queries.team_entities_query(team_id)
+ }
+ response = post(query)
+ process_response(response)
+ end
+
+ def search_issue(term)
+ raise ArgumentError, 'Missing search term' if term.blank?
+
+ query = {
+ query: Linear::Queries.search_issue(term)
+ }
+ response = post(query)
+ process_response(response)
+ end
+
+ def linked_issues(url)
+ raise ArgumentError, 'Missing link' if url.blank?
+
+ query = {
+ query: Linear::Queries.linked_issues(url)
+ }
+ response = post(query)
+ process_response(response)
+ end
+
+ def create_issue(params)
+ 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]
+ }.compact
+ mutation = Linear::Mutations.issue_create(variables)
+ response = post({ query: mutation })
+ process_response(response)
+ end
+
+ def link_issue(link, issue_id, title)
+ 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)
+ }
+ response = post(payload)
+ process_response(response)
+ end
+
+ def unlink_issue(link_id)
+ raise ArgumentError, 'Missing link id' if link_id.blank?
+
+ payload = {
+ query: Linear::Mutations.unlink_issue(link_id)
+ }
+ response = post(payload)
+ process_response(response)
+ end
+
+ private
+
+ def validate_team_and_title(params)
+ raise ArgumentError, 'Missing team id' if params[:team_id].blank?
+ raise ArgumentError, 'Missing title' if params[:title].blank?
+ end
+
+ def validate_priority(priority)
+ return if priority.nil? || PRIORITY_LEVELS.include?(priority)
+
+ raise ArgumentError, 'Invalid priority value. Priority must be 0, 1, 2, 3, or 4.'
+ end
+
+ def validate_label_ids(label_ids)
+ return if label_ids.nil?
+ return if label_ids.is_a?(Array) && label_ids.all?(String)
+
+ raise ArgumentError, 'label_ids must be an array of strings.'
+ end
+
+ def post(payload)
+ HTTParty.post(
+ BASE_URL,
+ headers: { 'Authorization' => @api_key, 'Content-Type' => 'application/json' },
+ body: payload.to_json
+ )
+ end
+
+ def process_response(response)
+ return response.parsed_response['data'].with_indifferent_access if response.success? && !response.parsed_response['data'].nil?
+
+ { error: response.parsed_response, error_code: response.code }
+ end
+end
diff --git a/lib/linear/mutations.rb b/lib/linear/mutations.rb
new file mode 100644
index 000000000..f887c39c8
--- /dev/null
+++ b/lib/linear/mutations.rb
@@ -0,0 +1,56 @@
+module Linear::Mutations
+ def self.graphql_value(value)
+ case value
+ when String
+ # Strings must be enclosed in double quotes
+ "\"#{value}\""
+ when Array
+ # Arrays need to be recursively converted
+ "[#{value.map { |v| graphql_value(v) }.join(', ')}]"
+ else
+ # Other types (numbers, booleans) can be directly converted to strings
+ value.to_s
+ end
+ end
+
+ def self.graphql_input(input)
+ input.map { |key, value| "#{key}: #{graphql_value(value)}" }.join(', ')
+ end
+
+ def self.issue_create(input)
+ <<~GRAPHQL
+ mutation {
+ issueCreate(input: { #{graphql_input(input)} }) {
+ success
+ issue {
+ id
+ title
+ }
+ }
+ }
+ GRAPHQL
+ end
+
+ def self.issue_link(issue_id, link, title)
+ <<~GRAPHQL
+ mutation {
+ attachmentLinkURL(url: "#{link}", issueId: "#{issue_id}", title: "#{title}") {
+ success
+ attachment {
+ id
+ }
+ }
+ }
+ GRAPHQL
+ end
+
+ def self.unlink_issue(link_id)
+ <<~GRAPHQL
+ mutation {
+ attachmentDelete(id: "#{link_id}") {
+ success
+ }
+ }
+ GRAPHQL
+ end
+end
diff --git a/lib/linear/queries.rb b/lib/linear/queries.rb
new file mode 100644
index 000000000..78b2108f9
--- /dev/null
+++ b/lib/linear/queries.rb
@@ -0,0 +1,100 @@
+module Linear::Queries
+ TEAMS_QUERY = <<~GRAPHQL.freeze
+ query {
+ teams {
+ nodes {
+ id
+ name
+ }
+ }
+ }
+ GRAPHQL
+
+ def self.team_entities_query(team_id)
+ <<~GRAPHQL
+ query {
+ users {
+ nodes {
+ id
+ name
+ }
+ }
+ projects {
+ nodes {
+ id
+ name
+ }
+ }
+ workflowStates(
+ filter: { team: { id: { eq: "#{team_id}" } } }
+ ) {
+ nodes {
+ id
+ name
+ }
+ }
+ issueLabels(
+ filter: { team: { id: { eq: "#{team_id}" } } }
+ ) {
+ nodes {
+ id
+ name
+ }
+ }
+ }
+ GRAPHQL
+ end
+
+ def self.search_issue(term)
+ <<~GRAPHQL
+ query {
+ searchIssues(term: "#{term}") {
+ nodes {
+ id
+ title
+ description
+ identifier
+ }
+ }
+ }
+ GRAPHQL
+ end
+
+ def self.linked_issues(url)
+ <<~GRAPHQL
+ query {
+ attachmentsForURL(url: "#{url}") {
+ nodes {
+ id
+ title
+ issue {
+ id
+ identifier
+ title
+ description
+ priority
+ createdAt
+ url
+ assignee {
+ name
+ avatarUrl
+ }
+ state {
+ name
+ color
+ }
+ labels {
+ nodes{
+ id
+ name
+ color
+ description
+ }
+ }
+ }
+ }
+ }
+ }
+ GRAPHQL
+ end
+end
diff --git a/public/dashboard/images/integrations/linear.png b/public/dashboard/images/integrations/linear.png
new file mode 100644
index 0000000000000000000000000000000000000000..1921f4c3f3987045c61bd1656e5e1446555c8382
GIT binary patch
literal 1449
zcmV;a1y=frP)
uIy^%+B^ByhXt}Qf$rOnn?i^Kl(q>4f(5!{7PP(hYnQM93K#g|b-&3N+lRR-
zxd42;4IKU2TJxYl3KRI#+na5R!!ayg+&qPf%w3@S>XRev;d#+jH7SgN$10Vhyzh|~
zt~mZwFG4N}%`t`8A&S5=sOPGwXYAZjKY4ma!4RJ{1n)
z&!I%v%yQ4qzHh4@pS>GXK8XWtR5nx_73C^Son=|Nhhxl=Rp*)*4wVB!Ngxe>`0YP`
zy?pcgo-Ds?5@{$(({C!rDhb4Ten3A*f_u715_$1;E3IBWBYT6(>v0ezm5_|V5%?s(Y7t=j;PQG#I?9>dMMjS3>eT8X|fEdz+g3w
z0iEC&iNTb9=zhHL2o&PC$*`26_@)P(n3W-unREa(!Rv<;8#YEUQQCf*A(KkL$5iD#cVjFAZcB$RheGi*6X@O}U
zZ~JUbTHs|p6-KWOXt&mORE|}iOkiRZwLP>nk;v#J7S)mRR#^3?vsxQHtjeXA$b~qH
z>i5l~T!p8^RApj?t1~;jXgzO20us*$vdU;DEE*=PWF*q(TGhKlNJwIVanDUpSSgA0
zd92E}#{J%8B(B0!;;PhyM_-+1^Ay`_>l(@hm=lYt^x^#D!mkmR(13&%e^j)+yzdd8?^v&
z5K^FpNo3>q3%*mCSxG}-0tqS*w=5U`lRb&ZLM7r+HT(ikX*w=zLlH%x0%r*#lA*|l
z!@y!BHoLVVr9HR~STGbJz^rXCeg)_zjy$ca%gt_Xy8Dge9_YfwNGg(9gJN
z!PXsT#o0a!UPMcenXw+@q7P5`Wf2X7!C){L3=82iC>N@TJG(NT00000NkvXXu0mjf
D5?7!i
literal 0
HcmV?d00001
diff --git a/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb b/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb
new file mode 100644
index 000000000..8b5de48dd
--- /dev/null
+++ b/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb
@@ -0,0 +1,257 @@
+require 'rails_helper'
+
+RSpec.describe 'Linear Integration API', type: :request do
+ let(:account) { create(:account) }
+ let(:user) { create(:user) }
+ let(:api_key) { 'valid_api_key' }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:processor_service) { instance_double(Integrations::Linear::ProcessorService) }
+
+ before do
+ create(:integrations_hook, :linear, account: account)
+ allow(Integrations::Linear::ProcessorService).to receive(:new).with(account: account).and_return(processor_service)
+ end
+
+ describe 'GET /api/v1/accounts/:account_id/integrations/linear/teams' do
+ context 'when it is an authenticated user' do
+ context 'when data is retrieved successfully' do
+ let(:teams_data) { { data: [{ 'id' => 'team1', 'name' => 'Team One' }] } }
+
+ it 'returns team data' do
+ allow(processor_service).to receive(:teams).and_return(teams_data)
+ get "/api/v1/accounts/#{account.id}/integrations/linear/teams",
+ headers: agent.create_new_auth_token,
+ as: :json
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include('Team One')
+ end
+ end
+
+ context 'when data retrieval fails' do
+ it 'returns error message' do
+ allow(processor_service).to receive(:teams).and_return(error: 'error message')
+ get "/api/v1/accounts/#{account.id}/integrations/linear/teams",
+ headers: agent.create_new_auth_token,
+ as: :json
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.body).to include('error message')
+ end
+ end
+ end
+ end
+
+ describe 'GET /api/v1/accounts/:account_id/integrations/linear/team_entities' do
+ let(:team_id) { 'team1' }
+
+ context 'when it is an authenticated user' do
+ context 'when data is retrieved successfully' do
+ let(:team_entities_data) do
+ { data: {
+ users: [{ 'id' => 'user1', 'name' => 'User One' }],
+ projects: [{ 'id' => 'project1', 'name' => 'Project One' }],
+ states: [{ 'id' => 'state1', 'name' => 'State One' }],
+ labels: [{ 'id' => 'label1', 'name' => 'Label One' }]
+ } }
+ end
+
+ it 'returns team entities data' do
+ allow(processor_service).to receive(:team_entities).with(team_id).and_return(team_entities_data)
+ get "/api/v1/accounts/#{account.id}/integrations/linear/team_entities",
+ params: { team_id: team_id },
+ headers: agent.create_new_auth_token,
+ as: :json
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include('User One')
+ expect(response.body).to include('Project One')
+ expect(response.body).to include('State One')
+ expect(response.body).to include('Label One')
+ end
+ end
+
+ context 'when data retrieval fails' do
+ it 'returns error message' do
+ allow(processor_service).to receive(:team_entities).with(team_id).and_return(error: 'error message')
+ get "/api/v1/accounts/#{account.id}/integrations/linear/team_entities",
+ params: { team_id: team_id },
+ headers: agent.create_new_auth_token,
+ as: :json
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.body).to include('error message')
+ end
+ end
+ end
+ end
+
+ describe 'POST /api/v1/accounts/:account_id/integrations/linear/create_issue' do
+ let(:issue_params) do
+ {
+ team_id: 'team1',
+ title: 'Sample Issue',
+ description: 'This is a sample issue.',
+ assignee_id: 'user1',
+ priority: 'high',
+ label_ids: ['label1']
+ }
+ end
+
+ context 'when it is an authenticated user' do
+ context 'when the issue is created successfully' do
+ let(:created_issue) { { data: { 'id' => 'issue1', '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)
+ post "/api/v1/accounts/#{account.id}/integrations/linear/create_issue",
+ params: issue_params,
+ headers: agent.create_new_auth_token,
+ as: :json
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include('Sample Issue')
+ end
+ end
+
+ context 'when issue creation fails' do
+ it 'returns error message' do
+ allow(processor_service).to receive(:create_issue).with(issue_params.stringify_keys).and_return(error: 'error message')
+ post "/api/v1/accounts/#{account.id}/integrations/linear/create_issue",
+ params: issue_params,
+ headers: agent.create_new_auth_token,
+ as: :json
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.body).to include('error message')
+ end
+ end
+ end
+ end
+
+ describe 'POST /api/v1/accounts/:account_id/integrations/linear/link_issue' do
+ let(:issue_id) { 'issue1' }
+ let(:conversation) { create(:conversation, account: account) }
+ let(:link) { "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/conversations/#{conversation.display_id}" }
+ let(:title) { 'Sample Issue' }
+
+ context 'when it is an authenticated user' do
+ context 'when the issue is linked successfully' do
+ let(:linked_issue) { { data: { 'id' => 'issue1', 'link' => 'https://linear.app/issue1' } } }
+
+ it 'returns the linked issue' do
+ allow(processor_service).to receive(:link_issue).with(link, issue_id, title).and_return(linked_issue)
+ post "/api/v1/accounts/#{account.id}/integrations/linear/link_issue",
+ params: { conversation_id: conversation.display_id, issue_id: issue_id, title: title },
+ headers: agent.create_new_auth_token,
+ as: :json
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include('https://linear.app/issue1')
+ end
+ end
+
+ context 'when issue linking fails' do
+ it 'returns error message' do
+ allow(processor_service).to receive(:link_issue).with(link, issue_id, title).and_return(error: 'error message')
+ post "/api/v1/accounts/#{account.id}/integrations/linear/link_issue",
+ params: { conversation_id: conversation.display_id, issue_id: issue_id, title: title },
+ headers: agent.create_new_auth_token,
+ as: :json
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.body).to include('error message')
+ end
+ end
+ end
+ end
+
+ describe 'POST /api/v1/accounts/:account_id/integrations/linear/unlink_issue' do
+ let(:link_id) { 'attachment1' }
+
+ context 'when it is an authenticated user' do
+ context 'when the issue is unlinked successfully' do
+ let(:unlinked_issue) { { data: { 'id' => 'issue1', 'link' => 'https://linear.app/issue1' } } }
+
+ it 'returns the unlinked issue' do
+ allow(processor_service).to receive(:unlink_issue).with(link_id).and_return(unlinked_issue)
+ post "/api/v1/accounts/#{account.id}/integrations/linear/unlink_issue",
+ params: { link_id: link_id },
+ headers: agent.create_new_auth_token,
+ as: :json
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include('https://linear.app/issue1')
+ end
+ end
+
+ context 'when issue unlinking fails' do
+ it 'returns error message' do
+ allow(processor_service).to receive(:unlink_issue).with(link_id).and_return(error: 'error message')
+ post "/api/v1/accounts/#{account.id}/integrations/linear/unlink_issue",
+ params: { link_id: link_id },
+ headers: agent.create_new_auth_token,
+ as: :json
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.body).to include('error message')
+ end
+ end
+ end
+ end
+
+ describe 'GET /api/v1/accounts/:account_id/integrations/linear/search_issue' do
+ let(:term) { 'issue' }
+
+ context 'when it is an authenticated user' do
+ context 'when search is successful' do
+ let(:search_results) { { data: [{ 'id' => 'issue1', 'title' => 'Sample Issue' }] } }
+
+ it 'returns search results' do
+ allow(processor_service).to receive(:search_issue).with(term).and_return(search_results)
+ get "/api/v1/accounts/#{account.id}/integrations/linear/search_issue",
+ params: { q: term },
+ headers: agent.create_new_auth_token,
+ as: :json
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include('Sample Issue')
+ end
+ end
+
+ context 'when search fails' do
+ it 'returns error message' do
+ allow(processor_service).to receive(:search_issue).with(term).and_return(error: 'error message')
+ get "/api/v1/accounts/#{account.id}/integrations/linear/search_issue",
+ params: { q: term },
+ headers: agent.create_new_auth_token,
+ as: :json
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.body).to include('error message')
+ end
+ end
+ end
+ end
+
+ describe 'GET /api/v1/accounts/:account_id/integrations/linear/linked_issues' do
+ let(:conversation) { create(:conversation, account: account) }
+ let(:link) { "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/conversations/#{conversation.display_id}" }
+
+ context 'when it is an authenticated user' do
+ context 'when linked issue is found' do
+ let(:linked_issue) { { data: [{ 'id' => 'issue1', 'title' => 'Sample Issue' }] } }
+
+ it 'returns linked issue' do
+ allow(processor_service).to receive(:linked_issues).with(link).and_return(linked_issue)
+ get "/api/v1/accounts/#{account.id}/integrations/linear/linked_issues",
+ params: { conversation_id: conversation.display_id },
+ headers: agent.create_new_auth_token,
+ as: :json
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include('Sample Issue')
+ end
+ end
+
+ context 'when linked issue is not found' do
+ it 'returns error message' do
+ allow(processor_service).to receive(:linked_issues).with(link).and_return(error: 'error message')
+ get "/api/v1/accounts/#{account.id}/integrations/linear/linked_issues",
+ params: { conversation_id: conversation.display_id },
+ headers: agent.create_new_auth_token,
+ as: :json
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.body).to include('error message')
+ end
+ end
+ end
+ end
+end
diff --git a/spec/factories/integrations/hooks.rb b/spec/factories/integrations/hooks.rb
index 7219884df..12958c1ac 100644
--- a/spec/factories/integrations/hooks.rb
+++ b/spec/factories/integrations/hooks.rb
@@ -26,5 +26,10 @@ FactoryBot.define do
app_id { 'openai' }
settings { { api_key: 'api_key' } }
end
+
+ trait :linear do
+ app_id { 'linear' }
+ settings { { api_key: 'api_key' } }
+ end
end
end
diff --git a/spec/lib/integrations/linear/processor_service_spec.rb b/spec/lib/integrations/linear/processor_service_spec.rb
new file mode 100644
index 000000000..07cf27654
--- /dev/null
+++ b/spec/lib/integrations/linear/processor_service_spec.rb
@@ -0,0 +1,209 @@
+require 'rails_helper'
+
+describe Integrations::Linear::ProcessorService do
+ let(:account) { create(:account) }
+ let(:api_key) { 'valid_api_key' }
+ let(:linear_client) { instance_double(Linear) }
+ let(:service) { described_class.new(account: account) }
+
+ before do
+ create(:integrations_hook, :linear, account: account)
+ allow(Linear).to receive(:new).and_return(linear_client)
+ end
+
+ describe '#teams' do
+ context 'when Linear client returns valid data' do
+ let(:teams_response) do
+ { 'teams' => { 'nodes' => [{ 'id' => 'team1', 'name' => 'Team One' }] } }
+ end
+
+ it 'returns parsed team data' do
+ allow(linear_client).to receive(:teams).and_return(teams_response)
+ result = service.teams
+ expect(result).to eq({ data: [{ 'id' => 'team1', 'name' => 'Team One' }] })
+ 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(:teams).and_return(error_response)
+ result = service.teams
+ expect(result).to eq(error_response)
+ end
+ end
+ end
+
+ describe '#team_entities' do
+ let(:team_id) { 'team1' }
+ let(:entities_response) do
+ {
+ 'users' => { 'nodes' => [{ 'id' => 'user1', 'name' => 'User One' }] },
+ 'projects' => { 'nodes' => [{ 'id' => 'project1', 'name' => 'Project One' }] },
+ 'workflowStates' => { 'nodes' => [] },
+ 'issueLabels' => { 'nodes' => [{ 'id' => 'bug', 'name' => 'Bug' }] }
+ }
+ end
+
+ context 'when Linear client returns valid data' do
+ it 'returns parsed entity data' do
+ allow(linear_client).to receive(:team_entities).with(team_id).and_return(entities_response)
+ result = service.team_entities(team_id)
+ expect(result).to eq({ :data => { :users =>
+ [{ 'id' => 'user1', 'name' => 'User One' }],
+ :projects => [{ 'id' => 'project1', 'name' => 'Project One' }],
+ :states => [], :labels => [{ 'id' => 'bug', 'name' => 'Bug' }] } })
+ 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(:team_entities).with(team_id).and_return(error_response)
+ result = service.team_entities(team_id)
+ expect(result).to eq(error_response)
+ end
+ end
+ end
+
+ describe '#create_issue' do
+ let(:params) do
+ {
+ title: 'Issue title',
+ team_id: 'team1',
+ description: 'Issue description',
+ assignee_id: 'user1',
+ priority: 2,
+ label_ids: %w[bug]
+ }
+ end
+ let(:issue_response) do
+ {
+ 'issueCreate' => { 'issue' => { 'id' => 'issue1', 'title' => 'Issue title' } }
+ }
+ end
+
+ context 'when Linear client returns valid data' do
+ it 'returns parsed issue data' do
+ allow(linear_client).to receive(:create_issue).with(params).and_return(issue_response)
+ result = service.create_issue(params)
+ expect(result).to eq({ data: { id: 'issue1', title: 'Issue title' } })
+ 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)
+ result = service.create_issue(params)
+ expect(result).to eq(error_response)
+ end
+ end
+ end
+
+ describe '#link_issue' do
+ let(:link) { 'https://example.com' }
+ let(:issue_id) { 'issue1' }
+ let(:title) { 'Title' }
+ 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)
+ result = service.link_issue(link, issue_id, title)
+ expect(result).to eq(link_response)
+ 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)
+ result = service.link_issue(link, issue_id, title)
+ expect(result).to eq(error_response)
+ end
+ end
+ end
+
+ describe '#unlink_issue' do
+ let(:link_id) { 'attachment1' }
+ let(:unlink_response) { { data: { link_id: link_id } } }
+
+ context 'when Linear client returns valid data' do
+ it 'returns parsed unlink data' do
+ allow(linear_client).to receive(:unlink_issue).with(link_id).and_return(unlink_response)
+ result = service.unlink_issue(link_id)
+ expect(result).to eq(unlink_response)
+ 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(:unlink_issue).with(link_id).and_return(error_response)
+ result = service.unlink_issue(link_id)
+ expect(result).to eq(error_response)
+ end
+ end
+ end
+
+ describe '#search_issue' do
+ let(:term) { 'search term' }
+ let(:search_response) do
+ {
+ 'searchIssues' => { 'nodes' => [{ 'id' => 'issue1', 'title' => 'Issue title', 'description' => 'Issue description' }] }
+ }
+ end
+
+ context 'when Linear client returns valid data' do
+ it 'returns parsed search data' do
+ allow(linear_client).to receive(:search_issue).with(term).and_return(search_response)
+ result = service.search_issue(term)
+ expect(result).to eq({ :data => [{ 'description' => 'Issue description', 'id' => 'issue1', 'title' => 'Issue title' }] })
+ 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(:search_issue).with(term).and_return(error_response)
+ result = service.search_issue(term)
+ expect(result).to eq(error_response)
+ end
+ end
+ end
+
+ describe '#linked_issues' do
+ let(:url) { 'https://example.com' }
+ let(:linked_response) do
+ {
+ 'attachmentsForURL' => { 'nodes' => [{ 'id' => 'attachment1', :issue => { 'id' => 'issue1' } }] }
+ }
+ end
+
+ context 'when Linear client returns valid data' do
+ it 'returns parsed linked data' do
+ allow(linear_client).to receive(:linked_issues).with(url).and_return(linked_response)
+ result = service.linked_issues(url)
+ expect(result).to eq({ :data => [{ 'id' => 'attachment1', 'issue' => { 'id' => 'issue1' } }] })
+ 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(:linked_issues).with(url).and_return(error_response)
+ result = service.linked_issues(url)
+ expect(result).to eq(error_response)
+ end
+ end
+ end
+end
diff --git a/spec/lib/linear_spec.rb b/spec/lib/linear_spec.rb
new file mode 100644
index 000000000..a81313d6c
--- /dev/null
+++ b/spec/lib/linear_spec.rb
@@ -0,0 +1,302 @@
+require 'rails_helper'
+
+describe Linear do
+ let(:api_key) { 'valid_api_key' }
+ let(:url) { 'https://api.linear.app/graphql' }
+ let(:linear_client) { described_class.new(api_key) }
+ let(:headers) { { 'Content-Type' => 'application/json', 'Authorization' => api_key } }
+
+ it 'raises an exception if the API key is absent' do
+ expect { described_class.new(nil) }.to raise_error(ArgumentError, 'Missing Credentials')
+ end
+
+ context 'when querying teams' do
+ context 'when the API response is success' do
+ before do
+ stub_request(:post, url)
+ .to_return(status: 200,
+ body: { success: true, data: { teams: { nodes: [{ id: 'team1', name: 'Team One' }] } } }.to_json,
+ headers: headers)
+ end
+
+ it 'returns team data' do
+ response = linear_client.teams
+ expect(response).to eq({ 'teams' => { 'nodes' => [{ 'id' => 'team1', 'name' => 'Team One' }] } })
+ end
+ end
+
+ context 'when the API response is an error' do
+ before do
+ stub_request(:post, url)
+ .to_return(status: 422, body: { errors: [{ message: 'Error retrieving data' }] }.to_json,
+ headers: headers)
+ end
+
+ it 'raises an exception' do
+ response = linear_client.teams
+ expect(response).to eq({ :error => { 'errors' => [{ 'message' => 'Error retrieving data' }] }, :error_code => 422 })
+ end
+ end
+ end
+
+ context 'when querying team entities' do
+ let(:team_id) { 'team1' }
+
+ context 'when the API response is success' do
+ before do
+ stub_request(:post, url)
+ .to_return(status: 200,
+ body: { success: true, data: {
+ users: { nodes: [{ id: 'user1', name: 'User One' }] },
+ projects: { nodes: [{ id: 'project1', name: 'Project One' }] },
+ workflowStates: { nodes: [] },
+ issueLabels: { nodes: [{ id: 'bug', name: 'Bug' }] }
+ } }.to_json,
+ headers: headers)
+ end
+
+ it 'returns team entities' do
+ response = linear_client.team_entities(team_id)
+ expect(response).to eq({
+ 'users' => { 'nodes' => [{ 'id' => 'user1', 'name' => 'User One' }] },
+ 'projects' => { 'nodes' => [{ 'id' => 'project1', 'name' => 'Project One' }] },
+ 'workflowStates' => { 'nodes' => [] },
+ 'issueLabels' => { 'nodes' => [{ 'id' => 'bug', 'name' => 'Bug' }] }
+ })
+ end
+ end
+
+ context 'when the API response is an error' do
+ before do
+ stub_request(:post, url)
+ .to_return(status: 422, body: { errors: [{ message: 'Error retrieving data' }] }.to_json,
+ headers: headers)
+ end
+
+ it 'raises an exception' do
+ response = linear_client.team_entities(team_id)
+ expect(response).to eq({ :error => { 'errors' => [{ 'message' => 'Error retrieving data' }] }, :error_code => 422 })
+ end
+ end
+ end
+
+ context 'when creating an issue' do
+ let(:params) do
+ {
+ title: 'Title',
+ team_id: 'team1',
+ description: 'Description',
+ assignee_id: 'user1',
+ priority: 1,
+ label_ids: ['bug']
+ }
+ end
+
+ context 'when the API response is success' do
+ before do
+ stub_request(:post, url)
+ .to_return(status: 200, body: { success: true, data: { issueCreate: { id: 'issue1', title: 'Title' } } }.to_json, headers: headers)
+ end
+
+ it 'creates an issue' do
+ response = linear_client.create_issue(params)
+ expect(response).to eq({ 'issueCreate' => { 'id' => 'issue1', 'title' => 'Title' } })
+ end
+
+ context 'when the priority is invalid' do
+ let(:params) { { title: 'Title', team_id: 'team1', priority: 5 } }
+
+ it 'raises an exception' do
+ expect { linear_client.create_issue(params) }.to raise_error(ArgumentError, 'Invalid priority value. Priority must be 0, 1, 2, 3, or 4.')
+ end
+ end
+
+ context 'when the label_ids are invalid' do
+ let(:params) { { title: 'Title', team_id: 'team1', label_ids: 'bug' } }
+
+ it 'raises an exception' do
+ expect { linear_client.create_issue(params) }.to raise_error(ArgumentError, 'label_ids must be an array of strings.')
+ end
+ end
+
+ context 'when the title is missing' do
+ let(:params) { { team_id: 'team1' } }
+
+ it 'raises an exception' do
+ expect { linear_client.create_issue(params) }.to raise_error(ArgumentError, 'Missing title')
+ end
+ end
+
+ context 'when the team_id is missing' do
+ let(:params) { { title: 'Title' } }
+
+ it 'raises an exception' do
+ expect { linear_client.create_issue(params) }.to raise_error(ArgumentError, 'Missing team id')
+ end
+ end
+
+ context 'when the API key is invalid' do
+ before do
+ stub_request(:post, url)
+ .to_return(status: 401, body: { errors: [{ message: 'Invalid API key' }] }.to_json, headers: headers)
+ end
+
+ it 'raises an exception' do
+ response = linear_client.create_issue(params)
+ expect(response).to eq({ :error => { 'errors' => [{ 'message' => 'Invalid API key' }] }, :error_code => 401 })
+ end
+ end
+ end
+
+ context 'when the API response is an error' do
+ before do
+ stub_request(:post, url)
+ .to_return(status: 422, body: { errors: [{ message: 'Error creating issue' }] }.to_json, headers: headers)
+ end
+
+ it 'raises an exception' do
+ response = linear_client.create_issue(params)
+ expect(response).to eq({ :error => { 'errors' => [{ 'message' => 'Error creating issue' }] }, :error_code => 422 })
+ end
+ end
+ end
+
+ context 'when linking an issue' do
+ let(:link) { 'https://example.com' }
+ let(:issue_id) { 'issue1' }
+ let(:title) { 'Title' }
+
+ context 'when the API response is success' do
+ before do
+ stub_request(:post, url)
+ .to_return(status: 200, body: { success: true, data: { attachmentLinkURL: { id: 'attachment1' } } }.to_json, headers: headers)
+ end
+
+ it 'links an issue' do
+ response = linear_client.link_issue(link, issue_id, title)
+ expect(response).to eq({ 'attachmentLinkURL' => { 'id' => 'attachment1' } })
+ end
+
+ context 'when the link is missing' do
+ let(:link) { '' }
+
+ it 'raises an exception' do
+ expect { linear_client.link_issue(link, issue_id, title) }.to raise_error(ArgumentError, 'Missing link')
+ end
+ end
+
+ context 'when the issue_id is missing' do
+ let(:issue_id) { '' }
+
+ it 'raises an exception' do
+ expect { linear_client.link_issue(link, issue_id, title) }.to raise_error(ArgumentError, 'Missing issue id')
+ end
+ end
+ end
+
+ context 'when the API response is an error' do
+ before do
+ stub_request(:post, url)
+ .to_return(status: 422, body: { errors: [{ message: 'Error linking issue' }] }.to_json, headers: headers)
+ end
+
+ it 'raises an exception' do
+ response = linear_client.link_issue(link, issue_id, title)
+ expect(response).to eq({ :error => { 'errors' => [{ 'message' => 'Error linking issue' }] }, :error_code => 422 })
+ end
+ end
+ end
+
+ context 'when unlinking an issue' do
+ let(:link_id) { 'attachment1' }
+
+ context 'when the API response is success' do
+ before do
+ stub_request(:post, url)
+ .to_return(status: 200, body: { success: true, data: { attachmentLinkURL: { id: 'attachment1' } } }.to_json, headers: headers)
+ end
+
+ it 'unlinks an issue' do
+ response = linear_client.unlink_issue(link_id)
+ expect(response).to eq({ 'attachmentLinkURL' => { 'id' => 'attachment1' } })
+ end
+
+ context 'when the link_id is missing' do
+ let(:link_id) { '' }
+
+ it 'raises an exception' do
+ expect { linear_client.unlink_issue(link_id) }.to raise_error(ArgumentError, 'Missing link id')
+ end
+ end
+ end
+
+ context 'when the API response is an error' do
+ before do
+ stub_request(:post, url)
+ .to_return(status: 422, body: { errors: [{ message: 'Error unlinking issue' }] }.to_json, headers: headers)
+ end
+
+ it 'raises an exception' do
+ response = linear_client.unlink_issue(link_id)
+ expect(response).to eq({ :error => { 'errors' => [{ 'message' => 'Error unlinking issue' }] }, :error_code => 422 })
+ end
+ end
+ end
+
+ context 'when querying issues' do
+ let(:term) { 'term' }
+
+ context 'when the API response is success' do
+ before do
+ stub_request(:post, url)
+ .to_return(status: 200, body: { success: true,
+ data: { searchIssues: { nodes: [{ id: 'issue1', title: 'Title' }] } } }.to_json, headers: headers)
+ end
+
+ it 'returns issues' do
+ response = linear_client.search_issue(term)
+ expect(response).to eq({ 'searchIssues' => { 'nodes' => [{ 'id' => 'issue1', 'title' => 'Title' }] } })
+ end
+ end
+
+ context 'when the API response is an error' do
+ before do
+ stub_request(:post, url)
+ .to_return(status: 422, body: { errors: [{ message: 'Error retrieving data' }] }.to_json,
+ headers: headers)
+ end
+
+ it 'raises an exception' do
+ response = linear_client.search_issue(term)
+ expect(response).to eq({ :error => { 'errors' => [{ 'message' => 'Error retrieving data' }] }, :error_code => 422 })
+ end
+ end
+ end
+
+ context 'when querying linked issues' do
+ context 'when the API response is success' do
+ before do
+ stub_request(:post, url)
+ .to_return(status: 200, body: { success: true, data: { linkedIssue: { id: 'issue1', title: 'Title' } } }.to_json, headers: headers)
+ end
+
+ it 'returns linked issues' do
+ response = linear_client.linked_issues('app.chatwoot.com')
+ expect(response).to eq({ 'linkedIssue' => { 'id' => 'issue1', 'title' => 'Title' } })
+ end
+ end
+
+ context 'when the API response is an error' do
+ before do
+ stub_request(:post, url)
+ .to_return(status: 422, body: { errors: [{ message: 'Error retrieving data' }] }.to_json,
+ headers: headers)
+ end
+
+ it 'raises an exception' do
+ response = linear_client.linked_issues('app.chatwoot.com')
+ expect(response).to eq({ :error => { 'errors' => [{ 'message' => 'Error retrieving data' }] }, :error_code => 422 })
+ end
+ end
+ end
+end