diff --git a/app/controllers/api/v1/accounts/integrations/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb new file mode 100644 index 000000000..9d5d76d75 --- /dev/null +++ b/app/controllers/api/v1/accounts/integrations/linear_controller.rb @@ -0,0 +1,93 @@ +class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController + before_action :fetch_conversation, only: [:link_issue, :linked_issues] + + def teams + teams = linear_processor_service.teams + if teams[:error] + render json: { error: teams[:error] }, status: :unprocessable_entity + else + render json: teams[:data], status: :ok + end + end + + def team_entities + team_id = permitted_params[:team_id] + team_entities = linear_processor_service.team_entities(team_id) + if team_entities[:error] + render json: { error: team_entities[:error] }, status: :unprocessable_entity + else + render json: team_entities[:data], status: :ok + end + end + + def create_issue + issue = linear_processor_service.create_issue(permitted_params) + if issue[:error] + render json: { error: issue[:error] }, status: :unprocessable_entity + else + render json: issue[:data], status: :ok + end + end + + def link_issue + issue_id = permitted_params[:issue_id] + title = permitted_params[:title] + issue = linear_processor_service.link_issue(conversation_link, issue_id, title) + if issue[:error] + render json: { error: issue[:error] }, status: :unprocessable_entity + else + render json: issue[:data], status: :ok + end + end + + def unlink_issue + link_id = permitted_params[:link_id] + issue = linear_processor_service.unlink_issue(link_id) + + if issue[:error] + render json: { error: issue[:error] }, status: :unprocessable_entity + else + render json: issue[:data], status: :ok + end + end + + def linked_issues + issues = linear_processor_service.linked_issues(conversation_link) + + if issues[:error] + render json: { error: issues[:error] }, status: :unprocessable_entity + else + render json: issues[:data], status: :ok + end + end + + def search_issue + render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return + + term = params[:q] + issues = linear_processor_service.search_issue(term) + if issues[:error] + render json: { error: issues[:error] }, status: :unprocessable_entity + else + render json: issues[:data], status: :ok + end + end + + private + + def conversation_link + "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/conversations/#{@conversation.display_id}" + end + + def fetch_conversation + @conversation = Current.account.conversations.find_by!(display_id: permitted_params[:conversation_id]) + end + + def linear_processor_service + Integrations::Linear::ProcessorService.new(account: Current.account) + end + + def permitted_params + params.permit(:team_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: []) + end +end diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrationapps/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/integrationapps/Index.vue index 8e63ea3a4..22ba37105 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/integrationapps/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/integrationapps/Index.vue @@ -1,16 +1,16 @@ - 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 000000000..1921f4c3f Binary files /dev/null and b/public/dashboard/images/integrations/linear.png differ 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