feat: Add APIs for linear integration (#9346)

This commit is contained in:
Muhsin Keloth
2024-05-22 13:37:58 +05:30
committed by GitHub
parent 0d13c11c44
commit 023b3ad507
16 changed files with 1308 additions and 24 deletions

View File

@@ -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

View File

@@ -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>

View File

@@ -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) {

View File

@@ -83,3 +83,5 @@
- name: help_center_embedding_search
enabled: false
premium: true
- name: linear_integration
enabled: false

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: []

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: "Create Linear issues from conversations, or link existing ones for seamless tracking."
public_portal:
search:
search_placeholder: Search for article by title or body...

View File

@@ -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]

View File

@@ -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

120
lib/linear.rb Normal file
View File

@@ -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

56
lib/linear/mutations.rb Normal file
View File

@@ -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

100
lib/linear/queries.rb Normal file
View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -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

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

View File

@@ -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

302
spec/lib/linear_spec.rb Normal file
View File

@@ -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