Compare commits

...
29 Commits
Author SHA1 Message Date
Muhsin Keloth f4e134e9ed chore: add permitted params 2024-05-08 17:20:01 +05:30
Muhsin Keloth bacf4f52ba feat: Add create issue API 2024-05-08 16:21:31 +05:30
Muhsin Keloth 792bc5cf9d Merge branch 'feat/linear-backend' of https://github.com/chatwoot/chatwoot into feat/linear-backend 2024-05-08 13:46:43 +05:30
Muhsin Keloth bf40242497 Merge branch 'feat/linear-backend' of https://github.com/chatwoot/chatwoot into feat/linear-backend 2024-05-08 13:46:35 +05:30
Muhsin Keloth 2130a2ebe1 Merge branch 'feat/linear-backend' of https://github.com/chatwoot/chatwoot into feat/linear-backend 2024-05-08 12:36:54 +05:30
Muhsin KelothandGitHub 2bad7f435a Merge branch 'develop' into feat/linear-backend 2024-05-08 12:36:43 +05:30
Muhsin Keloth c0d985a62b chore: make applications component in vue 3 syntax 2024-05-08 12:36:33 +05:30
Muhsin Keloth 5b7af73abe Merge branch 'feat/linear-backend' of https://github.com/chatwoot/chatwoot into feat/linear-backend 2024-05-08 12:15:44 +05:30
Muhsin Keloth 2ae79bd9a1 chore: add specs 2024-05-08 12:15:38 +05:30
Muhsin Keloth 17b61ad2b4 chore: add specs 2024-05-08 10:51:10 +05:30
Muhsin KelothandGitHub e098e463ad Merge branch 'develop' into feat/linear-backend 2024-05-08 09:09:13 +05:30
Muhsin Keloth b11f4d5bd5 Update linear_controller.rb 2024-05-07 15:22:59 +05:30
Muhsin Keloth 0a45b7e156 chore: add feature flag linear_integration 2024-05-07 15:13:33 +05:30
Muhsin Keloth ee184d9792 chore: cleanup code 2024-05-07 14:19:34 +05:30
Muhsin KelothandGitHub 3595903fb5 Merge branch 'develop' into feat/linear-backend 2024-05-07 14:06:03 +05:30
Muhsin KelothandGitHub 68a7fce45c Merge branch 'develop' into feat/linear-backend 2024-05-06 22:35:00 +05:30
Muhsin KelothandGitHub 8e5cabd14d Merge branch 'develop' into feat/linear-backend 2024-05-06 16:31:59 +05:30
Muhsin Keloth dd2938dc03 Merge branch 'feat/linear-backend' of https://github.com/chatwoot/chatwoot into feat/linear-backend 2024-05-06 15:58:56 +05:30
Muhsin Keloth 0bf4fc9e21 chore: add ProcessorService spec 2024-05-06 15:58:53 +05:30
Muhsin KelothandGitHub 8f7162d6c1 Merge branch 'develop' into feat/linear-backend 2024-05-06 14:27:21 +05:30
Muhsin Keloth 243834c401 feat: add team entities API 2024-05-06 14:26:52 +05:30
Muhsin KelothandGitHub 1bba61a758 Merge branch 'develop' into feat/linear-backend 2024-05-03 14:29:47 +05:30
Muhsin Keloth e58f1ad98d fix: specs 2024-05-03 14:25:10 +05:30
Shivam Mishra 7e61397186 fix: linear spec 2024-05-03 13:36:24 +05:30
Muhsin Keloth c5e34669f6 chore: add linear schema 2024-05-03 12:59:34 +05:30
Muhsin Keloth 97f96db60e Create linear_spec.rb 2024-05-03 12:48:14 +05:30
Muhsin Keloth 0fafe37646 chore: code cleanup 2024-05-02 16:45:57 +05:30
Muhsin Keloth 68cb6b19b0 feat: add teams end point 2024-05-02 16:11:21 +05:30
Muhsin Keloth ead7cc0c36 feat: add liner integration 2024-05-02 13:39:57 +05:30
19 changed files with 79968 additions and 24 deletions
+3
View File
@@ -155,6 +155,9 @@ gem 'stripe'
## to populate db with sample data
gem 'faker'
# to consume GraphQL-based APIs
gem 'graphlient'
# Include logrange conditionally in intializer using env variable
gem 'lograge', '~> 0.14.0', require: false
+9
View File
@@ -333,6 +333,14 @@ GEM
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
graphlient (0.8.0)
faraday (~> 2.0)
graphql-client
graphql (2.3.2)
base64
graphql-client (0.22.0)
activesupport (>= 3.0)
graphql (>= 1.13.0)
groupdate (6.2.1)
activesupport (>= 5.2)
grpc (1.62.0)
@@ -876,6 +884,7 @@ DEPENDENCIES
google-cloud-dialogflow-v2
google-cloud-storage
google-cloud-translate-v3
graphlient
groupdate
grpc
haikunator
@@ -0,0 +1,42 @@
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
def teams
teams = linear_processor_service.teams
if teams.is_a?(Hash) && teams[:error]
render json: { error: teams[:error] }, status: :unprocessable_entity
else
render json: teams, status: :ok
end
end
def team_entites
team_id = params[:team_id]
entites = linear_processor_service.team_entites(team_id)
if entites.is_a?(Hash) && entites[:error]
render json: { error: entites[:error] }, status: :unprocessable_entity
else
render json: entites, status: :ok
end
end
def create_issue
team_id = permitted_params[:team_id]
title = permitted_params[:title]
description = permitted_params[:description]
issue = linear_processor_service.create_issue(team_id, title, description)
if issue.is_a?(Hash) && issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
render json: issue, status: :ok
end
end
private
def linear_processor_service
Integrations::Linear::ProcessorService.new(account: Current.account)
end
def permitted_params
params.permit(:team_id, :title, :description)
end
end
@@ -1,16 +1,16 @@
<template>
<div class="flex-shrink flex-grow overflow-auto p-4">
<div class="flex-grow flex-shrink p-4 overflow-auto">
<div class="flex flex-col">
<div v-if="uiFlags.isFetching" class="my-0 mx-auto">
<div v-if="uiFlags.isFetching" class="mx-auto my-0">
<woot-loading-state :message="$t('INTEGRATION_APPS.FETCHING')" />
</div>
<div v-else class="w-full">
<div>
<div
v-for="item in integrationsList"
v-for="item in enabledIntegrations"
:key="item.id"
class="bg-white dark:bg-slate-800 border border-solid border-slate-75 dark:border-slate-700/50 rounded-sm mb-4 p-4"
class="p-4 mb-4 bg-white border border-solid rounded-sm dark:bg-slate-800 border-slate-75 dark:border-slate-700/50"
>
<integration-item
:integration-id="item.id"
@@ -25,22 +25,38 @@
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import IntegrationItem from './IntegrationItem.vue';
export default {
components: {
IntegrationItem,
},
computed: {
...mapGetters({
uiFlags: 'labels/getUIFlags',
integrationsList: 'integrations/getAppIntegrations',
}),
},
mounted() {
this.$store.dispatch('integrations/get');
},
};
<script setup>
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import { computed, onMounted } from 'vue';
import IntegrationItem from './IntegrationItem.vue';
const store = useStore();
const getters = useStoreGetters();
const uiFlags = getters['integrations/getUIFlags'];
const accountId = getters.getCurrentAccountId;
const integrationList = computed(() => {
return getters['integrations/getAppIntegrations'].value;
});
const isLinearIntegrationEnabled = computed(() => {
return getters['accounts/isFeatureEnabledonAccount'].value(
accountId.value,
'linear_integration'
);
});
const enabledIntegrations = computed(() => {
if (!isLinearIntegrationEnabled.value) {
return integrationList.value.filter(
integration => integration.id !== 'linear'
);
}
return integrationList.value;
});
onMounted(() => {
store.dispatch('integrations/get');
});
</script>
@@ -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) {
+2
View File
@@ -83,3 +83,5 @@
- name: help_center_embedding_search
enabled: false
premium: true
- name: linear_integration
enabled: false
+24
View File
@@ -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: []
+3
View File
@@ -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: "Connect your account with Linear to get realtime updates on the status of your conversations."
public_portal:
search:
search_placeholder: Search for article by title or body...
+7
View File
@@ -227,6 +227,13 @@ Rails.application.routes.draw do
post :add_participant_to_meeting
end
end
resource :linear, controller: 'linear', only: [] do
collection do
get :teams
get :team_entites
post :create_issue
end
end
end
resources :working_hours, only: [:update]
@@ -0,0 +1,40 @@
class Integrations::Linear::ProcessorService
pattr_initialize [:account!]
def teams
response = linear_client.teams
return response if response[:error]
response['teams']['nodes'].map(&:as_json)
end
def team_entites(team_id)
response = linear_client.team_entites(team_id)
return response if response[:error]
{
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(team_id, title, description)
response = linear_client.create_issue(team_id, title, description)
return response if response[:error]
response
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
+69
View File
@@ -0,0 +1,69 @@
require 'graphlient'
require_relative 'linear_queries'
require_relative 'linear_mutations'
class Linear
BASE_URL = 'https://api.linear.app/graphql'.freeze
def initialize(api_key)
@api_key = api_key
raise ArgumentError, 'Missing Credentials' if @api_key.blank?
@client = Graphlient::Client.new(
BASE_URL,
headers: {
'Authorization' => @api_key,
'Content-Type' => 'application/json'
}
)
end
def teams
execute_query(LinearQueries::TEAMS_QUERY)
end
def team_entites(team_id)
raise ArgumentError, 'Missing team id' if team_id.blank?
execute_query(LinearQueries.team_entites_query(team_id))
end
def create_issue(team_id, title, description)
raise ArgumentError, 'Missing team id' if team_id.blank?
raise ArgumentError, 'Missing title' if title.blank?
raise ArgumentError, 'Missing description' if description.blank?
variables = {
title: title,
description: description,
teamId: team_id
}
execute_mutation(LinearMutations.issue_create(variables))
end
private
def execute_query(query)
response = @client.query(query)
log_and_return_error("Error retrieving data: #{response.errors.messages}") if response.data.nil? && response.errors.any?
response.data.to_h
rescue Graphlient::Errors::GraphQLError => e
log_and_return_error("GraphQL Error: #{e.message}")
rescue Graphlient::Errors::ServerError => e
log_and_return_error("Server Error: #{e.message}")
rescue StandardError => e
log_and_return_error("Unexpected Error: #{e.message}")
end
def execute_mutation(query)
response = @client.query(query)
log_and_return_error("Error creating issue: #{response.errors.messages}") if response.data.nil? && response.errors.any?
response.data.to_h
end
def log_and_return_error(message)
Rails.logger.error message
{ error: message }
end
end
+16
View File
@@ -0,0 +1,16 @@
module LinearMutations
def self.issue_create(input)
graphql_input = input.map { |key, value| "#{key}: \"#{value}\"" }.join(', ')
<<~GRAPHQL
mutation {
issueCreate(input: { #{graphql_input} }) {
success
issue {
id
title
}
}
}
GRAPHQL
end
end
+47
View File
@@ -0,0 +1,47 @@
module LinearQueries
TEAMS_QUERY = <<~GRAPHQL.freeze
query {
teams {
nodes {
id
name
}
}
}
GRAPHQL
def self.team_entites_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
end
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,87 @@
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
let(:path) { teams_api_v1_account_integrations_linear_path(account) }
context 'when it is an authenticated user' do
context 'when data is retrieved successfully' do
let(:teams_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_entites' do
let(:team_id) { 'team1' }
let(:path) { team_entites_api_v1_account_integrations_linear_path(account) }
context 'when it is an authenticated user' do
context 'when data is retrieved successfully' do
let(:team_entities_data) do
{
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_entites).with(team_id).and_return(team_entities_data)
get "/api/v1/accounts/#{account.id}/integrations/linear/team_entites",
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_entites).with(team_id).and_return(error: 'error message')
get "/api/v1/accounts/#{account.id}/integrations/linear/team_entites",
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
end
+5
View File
@@ -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
+79417
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,70 @@
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 contain_exactly({ '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_entites' 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_entites).with(team_id).and_return(entities_response)
result = service.team_entites(team_id)
expect(result).to have_key(:users)
expect(result).to have_key(:projects)
expect(result).to have_key(:states)
expect(result).to have_key(:labels)
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_entites).with(team_id).and_return(error_response)
result = service.team_entites(team_id)
expect(result).to eq(error_response)
end
end
end
end
+83
View File
@@ -0,0 +1,83 @@
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 } }
let(:schema) { JSON.parse(File.read('spec/fixtures/linear-schema.json')) }
let(:client) { Graphlient::Client.new(url, schema: Graphlient::Schema.new({ 'data' => schema }, nil), headers: headers) }
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
linear_client.instance_variable_set(:@client, client)
stub_request(:post, url)
.to_return(status: 200, body: { data: { teams: { nodes: [{ id: 'team1', name: 'Team One' }] } } }.to_json)
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
linear_client.instance_variable_set(:@client, client)
stub_request(:post, url)
.to_return(status: 422, body: { errors: [{ message: 'Error retrieving data' }] }.to_json)
end
it 'raises an exception' do
response = linear_client.teams
expect(response).to eq({ :error => 'Server Error: the server responded with status 422' })
end
end
end
context 'when querying team entities' do
let(:team_id) { 'team1' }
context 'when the API response is success' do
before do
linear_client.instance_variable_set(:@client, client)
stub_request(:post, url)
.to_return(status: 200, body: { 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)
end
it 'returns team entities' do
response = linear_client.team_entites(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
linear_client.instance_variable_set(:@client, client)
stub_request(:post, url)
.to_return(status: 422, body: { errors: [{ message: 'Error retrieving data' }] }.to_json)
end
it 'raises an exception' do
response = linear_client.team_entites(team_id)
expect(response).to eq({ :error => 'Server Error: the server responded with status 422' })
end
end
end
end