Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e992c23d03 | ||
|
|
669a138114 | ||
|
|
31732a1a7b | ||
|
|
b663f3201c | ||
|
|
01ddb61c9e | ||
|
|
87e8e342c9 | ||
|
|
27ec845faa | ||
|
|
91cefe2072 | ||
|
|
d356067300 | ||
|
|
aa3f2b56bc | ||
|
|
bc5449f208 | ||
|
|
e1bfd9a17a | ||
|
|
4825ab1a29 | ||
|
|
7efc2b9325 | ||
|
|
4a491d8a91 | ||
|
|
f2db469c75 | ||
|
|
5d48c2b1e5 | ||
|
|
ecce6e021b | ||
|
|
4d47fc1c25 | ||
|
|
b25e395bf7 | ||
|
|
12b3323364 | ||
|
|
793420c341 | ||
|
|
775e8251a6 | ||
|
|
4645e57e9a | ||
|
|
5e1dbfb64d | ||
|
|
df29b43540 | ||
|
|
3e4f43d7da | ||
|
|
2b7c972cbf | ||
|
|
429cfd0166 | ||
|
|
fc41669178 | ||
|
|
e567be8fb2 | ||
|
|
1ddc05dadb | ||
|
|
0268d544d1 | ||
|
|
2960bcdf7d | ||
|
|
3c4a7bb9f7 | ||
|
|
792bc5cf9d | ||
|
|
bf40242497 | ||
|
|
2130a2ebe1 | ||
|
|
2bad7f435a | ||
|
|
c0d985a62b | ||
|
|
5b7af73abe | ||
|
|
2ae79bd9a1 | ||
|
|
17b61ad2b4 | ||
|
|
e098e463ad | ||
|
|
b11f4d5bd5 | ||
|
|
0a45b7e156 | ||
|
|
ee184d9792 | ||
|
|
3595903fb5 | ||
|
|
68a7fce45c | ||
|
|
8e5cabd14d | ||
|
|
dd2938dc03 | ||
|
|
0bf4fc9e21 | ||
|
|
8f7162d6c1 | ||
|
|
243834c401 | ||
|
|
1bba61a758 | ||
|
|
e58f1ad98d | ||
|
|
7e61397186 | ||
|
|
c5e34669f6 | ||
|
|
97f96db60e | ||
|
|
0fafe37646 | ||
|
|
68cb6b19b0 | ||
|
|
ead7cc0c36 |
@@ -0,0 +1,92 @@
|
|||||||
|
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
|
||||||
|
before_action :fetch_conversation, only: [:link_issue, :linked_issue]
|
||||||
|
|
||||||
|
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_entities
|
||||||
|
team_id = params[:team_id]
|
||||||
|
entites = linear_processor_service.team_entities(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
|
||||||
|
issue = linear_processor_service.create_issue(permitted_params)
|
||||||
|
if issue.is_a?(Hash) && issue[:error]
|
||||||
|
render json: { error: issue[:error] }, status: :unprocessable_entity
|
||||||
|
else
|
||||||
|
render json: issue, status: :ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def link_issue
|
||||||
|
issue_id = params[:issue_id]
|
||||||
|
issue = linear_processor_service.link_issue(conversation_link, issue_id)
|
||||||
|
if issue.is_a?(Hash) && issue[:error]
|
||||||
|
render json: { error: issue[:error] }, status: :unprocessable_entity
|
||||||
|
else
|
||||||
|
render json: issue, status: :ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def unlink_issue
|
||||||
|
link_id = params[:link_id]
|
||||||
|
issue = linear_processor_service.unlink_issue(link_id)
|
||||||
|
|
||||||
|
if issue.is_a?(Hash) && issue[:error]
|
||||||
|
render json: { error: issue[:error] }, status: :unprocessable_entity
|
||||||
|
else
|
||||||
|
render json: issue, status: :ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def linked_issue
|
||||||
|
issues = linear_processor_service.linked_issue(conversation_link)
|
||||||
|
|
||||||
|
if issues.is_a?(Hash) && issues[:error]
|
||||||
|
render json: { error: issues[:error] }, status: :unprocessable_entity
|
||||||
|
else
|
||||||
|
render json: issues, 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.is_a?(Hash) && issues[:error]
|
||||||
|
render json: { error: issues[:error] }, status: :unprocessable_entity
|
||||||
|
else
|
||||||
|
render json: issues, 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
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/* global axios */
|
||||||
|
|
||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
class LinearAPI extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('integrations/linear', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
getTeams() {
|
||||||
|
return axios.get(`${this.url}/teams`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getTeamEntities(teamId) {
|
||||||
|
return axios.get(`${this.url}/team_entities?team_id=${teamId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
createIssue(data) {
|
||||||
|
return axios.post(`${this.url}/create_issue`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
link_issue(conversationId, issueId) {
|
||||||
|
return axios.post(`${this.url}/link_issue`, {
|
||||||
|
issue_id: issueId,
|
||||||
|
conversation_id: conversationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getLinkedIssue(conversationId) {
|
||||||
|
return axios.get(
|
||||||
|
`${this.url}/linked_issue?conversation_id=${conversationId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
unlinkIssue(linkId) {
|
||||||
|
return axios.post(`${this.url}/unlink_issue`, {
|
||||||
|
link_id: linkId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
searchIssues(query) {
|
||||||
|
return axios.get(`${this.url}/search_issue?q=${query}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new LinearAPI();
|
||||||
@@ -30,4 +30,5 @@ export const FEATURE_FLAGS = {
|
|||||||
EMAIL_CONTINUITY_ON_API_CHANNEL: 'email_continuity_on_api_channel',
|
EMAIL_CONTINUITY_ON_API_CHANNEL: 'email_continuity_on_api_channel',
|
||||||
INBOUND_EMAILS: 'inbound_emails',
|
INBOUND_EMAILS: 'inbound_emails',
|
||||||
IP_LOOKUP: 'ip_lookup',
|
IP_LOOKUP: 'ip_lookup',
|
||||||
|
LINEAR: 'linear_integration',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -203,6 +203,74 @@
|
|||||||
"API_SUCCESS": "Dashboard app deleted successfully",
|
"API_SUCCESS": "Dashboard app deleted successfully",
|
||||||
"API_ERROR": "We couldn't delete the app. Please try again later"
|
"API_ERROR": "We couldn't delete the app. Please try again later"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"LINEAR": {
|
||||||
|
"ADD_OR_LINK_BUTTON": "Create/Link Linear Issue",
|
||||||
|
"LOADING": "Fetching linear issues...",
|
||||||
|
"LOADING_ERROR": "There was an error fetching the linear issues, please try again",
|
||||||
|
"CREATE": "Create",
|
||||||
|
"LINK": {
|
||||||
|
"SEARCH": "Search linear issues",
|
||||||
|
"SELECT": "Select issue",
|
||||||
|
"TITLE": "Link",
|
||||||
|
"EMPTY_LIST": "No linear issues found",
|
||||||
|
"LOADING": "Loading",
|
||||||
|
"ERROR": "There was an error fetching the linear issues, please try again",
|
||||||
|
"LINK_SUCCESS": "Issue linked successfully",
|
||||||
|
"LINK_ERROR": "There was an error linking the issue, please try again"
|
||||||
|
},
|
||||||
|
"ADD_OR_LINK": {
|
||||||
|
"TITLE": "Create/link linear issue",
|
||||||
|
"DESCRIPTION": "Create or link a linear issue to the conversation",
|
||||||
|
"FORM": {
|
||||||
|
"TITLE": {
|
||||||
|
"LABEL": "Title",
|
||||||
|
"PLACEHOLDER": "Enter title",
|
||||||
|
"REQUIRED_ERROR": "Title is required"
|
||||||
|
},
|
||||||
|
"DESCRIPTION": {
|
||||||
|
"LABEL": "Description",
|
||||||
|
"PLACEHOLDER": "Enter description"
|
||||||
|
},
|
||||||
|
"TEAM": {
|
||||||
|
"LABEL": "Team",
|
||||||
|
"REQUIRED_ERROR": "Team is required"
|
||||||
|
},
|
||||||
|
"ASSIGNEE": {
|
||||||
|
"LABEL": "Assignee"
|
||||||
|
},
|
||||||
|
"PRIORITY": {
|
||||||
|
"LABEL": "Priority"
|
||||||
|
},
|
||||||
|
"LABEL": {
|
||||||
|
"LABEL": "Label"
|
||||||
|
},
|
||||||
|
"STATUS": {
|
||||||
|
"LABEL": "Status"
|
||||||
|
},
|
||||||
|
"PROJECT": {
|
||||||
|
"LABEL": "Project"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CREATE": "Create",
|
||||||
|
"CANCEL": "Cancel",
|
||||||
|
"CREATE_SUCCESS": "Issue created successfully",
|
||||||
|
"CREATE_ERROR": "There was an error creating the issue, please try again",
|
||||||
|
"LOADING_TEAM_ERROR": "There was an error fetching the teams, please try again",
|
||||||
|
"LOADING_TEAM_ENTITIES_ERROR": "There was an error fetching the team entities, please try again"
|
||||||
|
},
|
||||||
|
"ISSUE": {
|
||||||
|
"STATUS": "Status",
|
||||||
|
"PRIORITY": "Priority",
|
||||||
|
"ASSIGNEE": "Assignee",
|
||||||
|
"LABELS": "Labels",
|
||||||
|
"CREATED_AT": "Created at"
|
||||||
|
},
|
||||||
|
"UNLINK": {
|
||||||
|
"TITLE": "Unlink",
|
||||||
|
"SUCCESS": "Issue unlinked successfully",
|
||||||
|
"ERROR": "There was an error unlinking the issue, please try again"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = [
|
|||||||
{ name: 'contact_attributes' },
|
{ name: 'contact_attributes' },
|
||||||
{ name: 'previous_conversation' },
|
{ name: 'previous_conversation' },
|
||||||
{ name: 'conversation_participants' },
|
{ name: 'conversation_participants' },
|
||||||
|
{ name: 'linear' },
|
||||||
];
|
];
|
||||||
export const DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER = [
|
export const DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER = [
|
||||||
{ name: 'contact_attributes' },
|
{ name: 'contact_attributes' },
|
||||||
|
|||||||
@@ -23,6 +23,18 @@
|
|||||||
:key="element.name"
|
:key="element.name"
|
||||||
class="bg-white dark:bg-gray-800"
|
class="bg-white dark:bg-gray-800"
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
v-if="element.name === 'linear' && isLinearIntegrationEnabled"
|
||||||
|
class="conversation--actions"
|
||||||
|
>
|
||||||
|
<accordion-item
|
||||||
|
title="Linear"
|
||||||
|
:is-open="isContactSidebarItemOpen('is_linear_open')"
|
||||||
|
@click="value => toggleSidebarUIState('is_linear_open', value)"
|
||||||
|
>
|
||||||
|
<linear :conversation-id="conversationId" />
|
||||||
|
</accordion-item>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="element.name === 'conversation_actions'"
|
v-if="element.name === 'conversation_actions'"
|
||||||
class="conversation--actions"
|
class="conversation--actions"
|
||||||
@@ -145,7 +157,8 @@ import CustomAttributes from './customAttributes/CustomAttributes.vue';
|
|||||||
import draggable from 'vuedraggable';
|
import draggable from 'vuedraggable';
|
||||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||||
import MacrosList from './Macros/List.vue';
|
import MacrosList from './Macros/List.vue';
|
||||||
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
|
import Linear from './linear/index.vue';
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
AccordionItem,
|
AccordionItem,
|
||||||
@@ -157,6 +170,7 @@ export default {
|
|||||||
ConversationParticipant,
|
ConversationParticipant,
|
||||||
draggable,
|
draggable,
|
||||||
MacrosList,
|
MacrosList,
|
||||||
|
Linear,
|
||||||
},
|
},
|
||||||
mixins: [alertMixin, uiSettingsMixin],
|
mixins: [alertMixin, uiSettingsMixin],
|
||||||
props: {
|
props: {
|
||||||
@@ -185,6 +199,8 @@ export default {
|
|||||||
currentChat: 'getSelectedChat',
|
currentChat: 'getSelectedChat',
|
||||||
currentUser: 'getCurrentUser',
|
currentUser: 'getCurrentUser',
|
||||||
uiFlags: 'inboxAssignableAgents/getUIFlags',
|
uiFlags: 'inboxAssignableAgents/getUIFlags',
|
||||||
|
accountId: 'getCurrentAccountId',
|
||||||
|
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||||
}),
|
}),
|
||||||
conversationAdditionalAttributes() {
|
conversationAdditionalAttributes() {
|
||||||
return this.currentConversationMetaData.additional_attributes || {};
|
return this.currentConversationMetaData.additional_attributes || {};
|
||||||
@@ -210,6 +226,12 @@ export default {
|
|||||||
const { custom_attributes: customAttributes } = this.contact;
|
const { custom_attributes: customAttributes } = this.contact;
|
||||||
return customAttributes && Object.keys(customAttributes).length;
|
return customAttributes && Object.keys(customAttributes).length;
|
||||||
},
|
},
|
||||||
|
isLinearIntegrationEnabled() {
|
||||||
|
return this.isFeatureEnabledonAccount(
|
||||||
|
this.accountId,
|
||||||
|
FEATURE_FLAGS.LINEAR
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
conversationId(newConversationId, prevConversationId) {
|
conversationId(newConversationId, prevConversationId) {
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
<template>
|
||||||
|
<div @submit.prevent="onSubmit">
|
||||||
|
<woot-input
|
||||||
|
v-model="title"
|
||||||
|
:class="{ error: $v.title.$error }"
|
||||||
|
class="w-full"
|
||||||
|
:styles="{ ...inputStyles, padding: '6px 12px' }"
|
||||||
|
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.LABEL')"
|
||||||
|
:placeholder="
|
||||||
|
$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.PLACEHOLDER')
|
||||||
|
"
|
||||||
|
:error="
|
||||||
|
$v.title.$error
|
||||||
|
? $t(
|
||||||
|
'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.REQUIRED_ERROR'
|
||||||
|
)
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
@input="$v.title.$touch"
|
||||||
|
/>
|
||||||
|
<label>
|
||||||
|
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.DESCRIPTION.LABEL') }}
|
||||||
|
<textarea
|
||||||
|
v-model="description"
|
||||||
|
:style="{ ...inputStyles, padding: '8px 12px' }"
|
||||||
|
rows="3"
|
||||||
|
type="text"
|
||||||
|
class="text-sm"
|
||||||
|
:placeholder="
|
||||||
|
$t(
|
||||||
|
'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.DESCRIPTION.PLACEHOLDER'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label :class="{ error: $v.teamId.$error }">
|
||||||
|
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TEAM.LABEL') }}
|
||||||
|
<select
|
||||||
|
v-model="teamId"
|
||||||
|
:style="inputStyles"
|
||||||
|
@change="onChangeTeam($event)"
|
||||||
|
>
|
||||||
|
<option v-for="item in teams" :key="item.name" :value="item.id">
|
||||||
|
{{ item.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<span v-if="$v.teamId.$error" class="message">
|
||||||
|
{{
|
||||||
|
$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TEAM.REQUIRED_ERROR')
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.ASSIGNEE.LABEL') }}
|
||||||
|
<select v-model="assigneeId" :style="inputStyles">
|
||||||
|
<option v-for="item in assignees" :key="item.name" :value="item.id">
|
||||||
|
{{ item.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.LABEL.LABEL') }}
|
||||||
|
<select v-model="labelId" :style="inputStyles">
|
||||||
|
<option v-for="item in labels" :key="item.name" :value="item.id">
|
||||||
|
{{ item.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PRIORITY.LABEL') }}
|
||||||
|
<select v-model="priority" :style="inputStyles">
|
||||||
|
<option v-for="item in priorities" :key="item.name" :value="item.id">
|
||||||
|
{{ item.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PROJECT.LABEL') }}
|
||||||
|
<select v-model="projectId" :style="inputStyles">
|
||||||
|
<option v-for="item in projects" :key="item.name" :value="item.id">
|
||||||
|
{{ item.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.STATUS.LABEL') }}
|
||||||
|
<select v-model="stateId" :style="inputStyles">
|
||||||
|
<option v-for="item in statuses" :key="item.name" :value="item.id">
|
||||||
|
{{ item.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end w-full gap-2 mt-8">
|
||||||
|
<woot-button
|
||||||
|
class="px-4 rounded-xl button clear outline-woot-200/50 outline"
|
||||||
|
@click.prevent="onClose"
|
||||||
|
>
|
||||||
|
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CANCEL') }}
|
||||||
|
</woot-button>
|
||||||
|
<woot-button
|
||||||
|
:is-disabled="isSubmitDisabled"
|
||||||
|
class="px-4 rounded-xl"
|
||||||
|
:is-loading="isCreating"
|
||||||
|
@click.prevent="createIssue"
|
||||||
|
>
|
||||||
|
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE') }}
|
||||||
|
</woot-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import alertMixin from 'shared/mixins/alertMixin';
|
||||||
|
import LinearAPI from 'dashboard/api/integrations/linear';
|
||||||
|
|
||||||
|
import validations from './validations';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mixins: [alertMixin],
|
||||||
|
props: {
|
||||||
|
accountId: {
|
||||||
|
type: [Number, String],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
conversationId: {
|
||||||
|
type: [Number, String],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
teamId: '',
|
||||||
|
assigneeId: '',
|
||||||
|
projectId: '',
|
||||||
|
stateId: '',
|
||||||
|
labelId: '',
|
||||||
|
priority: 0,
|
||||||
|
teams: [],
|
||||||
|
assignees: [],
|
||||||
|
projects: [],
|
||||||
|
labels: [],
|
||||||
|
statuses: [],
|
||||||
|
selectedTabIndex: 0,
|
||||||
|
priorities: [
|
||||||
|
{
|
||||||
|
id: 0,
|
||||||
|
name: 'No priority',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Urgent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'High',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: 'Normal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: 'Low',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isCreating: false,
|
||||||
|
searchResults: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Test',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
inputStyles: {
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '14px',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
validations,
|
||||||
|
computed: {
|
||||||
|
isSubmitDisabled() {
|
||||||
|
return this.$v.title.$invalid || this.isCreating;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.getTeams();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onClose() {
|
||||||
|
this.$emit('close');
|
||||||
|
},
|
||||||
|
onClickTabChange(index) {
|
||||||
|
this.selectedTabIndex = index;
|
||||||
|
},
|
||||||
|
onChangeTeam(event) {
|
||||||
|
this.teamId = event.target.value;
|
||||||
|
this.assigneeId = '';
|
||||||
|
this.stateId = '';
|
||||||
|
this.labelId = '';
|
||||||
|
this.getTeamEntities();
|
||||||
|
},
|
||||||
|
async getTeams() {
|
||||||
|
try {
|
||||||
|
const response = await LinearAPI.getTeams();
|
||||||
|
this.teams = response.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.showAlert(
|
||||||
|
this.$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.LOADING_TEAM_ERROR')
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getTeamEntities() {
|
||||||
|
try {
|
||||||
|
const response = await LinearAPI.getTeamEntities(this.teamId);
|
||||||
|
const { users, labels, projects, states } = response.data;
|
||||||
|
this.assignees = users;
|
||||||
|
this.labels = labels;
|
||||||
|
this.statuses = states;
|
||||||
|
this.projects = projects;
|
||||||
|
} catch (error) {
|
||||||
|
this.showAlert(
|
||||||
|
this.$t(
|
||||||
|
'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.LOADING_TEAM_ENTITIES_ERROR'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async createIssue() {
|
||||||
|
this.$v.$touch();
|
||||||
|
if (this.$v.$invalid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
team_id: this.teamId,
|
||||||
|
title: this.title,
|
||||||
|
};
|
||||||
|
if (this.description) {
|
||||||
|
payload.description = this.description;
|
||||||
|
}
|
||||||
|
if (this.assigneeId) {
|
||||||
|
payload.assignee_id = this.assigneeId;
|
||||||
|
}
|
||||||
|
if (this.projectId) {
|
||||||
|
payload.project_id = this.projectId;
|
||||||
|
}
|
||||||
|
if (this.stateId) {
|
||||||
|
payload.state_id = this.stateId;
|
||||||
|
}
|
||||||
|
if (this.priority) {
|
||||||
|
payload.priority = this.priority;
|
||||||
|
}
|
||||||
|
if (this.labelId) {
|
||||||
|
payload.label_ids = [this.labelId];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.isCreating = true;
|
||||||
|
const response = await LinearAPI.createIssue(payload);
|
||||||
|
const { id: issueId } = response.data;
|
||||||
|
await LinearAPI.link_issue(this.conversationId, issueId);
|
||||||
|
this.showAlert(
|
||||||
|
this.$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_SUCCESS')
|
||||||
|
);
|
||||||
|
this.onClose();
|
||||||
|
} catch (error) {
|
||||||
|
this.showAlert(
|
||||||
|
this.$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_ERROR')
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.isCreating = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleAgentsFilterSelection() {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-auto overflow-auto">
|
||||||
|
<woot-modal-header
|
||||||
|
:header-title="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.TITLE')"
|
||||||
|
:header-content="
|
||||||
|
$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.DESCRIPTION')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-col h-auto overflow-auto">
|
||||||
|
<div class="flex flex-col px-8 pb-4">
|
||||||
|
<woot-tabs
|
||||||
|
class="ltr:[&>ul]:pl-0 rtl:[&>ul]:pr-0"
|
||||||
|
:index="selectedTabIndex"
|
||||||
|
@change="onClickTabChange"
|
||||||
|
>
|
||||||
|
<woot-tabs-item
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.key"
|
||||||
|
:name="tab.name"
|
||||||
|
:show-badge="false"
|
||||||
|
/>
|
||||||
|
</woot-tabs>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedTabIndex === 0" class="flex flex-col px-8 pb-4">
|
||||||
|
<create-issue
|
||||||
|
:account-id="accountId"
|
||||||
|
:conversation-id="conversationId"
|
||||||
|
@close="onClose"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex flex-col px-8 pb-4">
|
||||||
|
<link-issue :conversation-id="conversationId" @close="onClose" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import alertMixin from 'shared/mixins/alertMixin';
|
||||||
|
import LinkIssue from './LinkIssue';
|
||||||
|
import CreateIssue from './CreateIssue';
|
||||||
|
|
||||||
|
import validations from './validations';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
LinkIssue,
|
||||||
|
CreateIssue,
|
||||||
|
},
|
||||||
|
mixins: [alertMixin],
|
||||||
|
props: {
|
||||||
|
accountId: {
|
||||||
|
type: [Number, String],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
conversationId: {
|
||||||
|
type: [Number, String],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
selectedTabIndex: 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
validations,
|
||||||
|
computed: {
|
||||||
|
tabs() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 0,
|
||||||
|
name: this.$t('INTEGRATION_SETTINGS.LINEAR.CREATE'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 1,
|
||||||
|
name: this.$t('INTEGRATION_SETTINGS.LINEAR.LINK.TITLE'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onClose() {
|
||||||
|
this.$emit('close');
|
||||||
|
},
|
||||||
|
onClickTabChange(index) {
|
||||||
|
this.selectedTabIndex = index;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="flex items-center justify-between group">
|
||||||
|
<a
|
||||||
|
:href="issue.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="inline-block rounded-sm mb-0 break-all py-0.5 text-primary-600"
|
||||||
|
>
|
||||||
|
{{ `${issue.identifier} ${issue.title}` }}
|
||||||
|
</a>
|
||||||
|
<woot-button
|
||||||
|
size="small"
|
||||||
|
variant="clear"
|
||||||
|
color-scheme="secondary"
|
||||||
|
class="!px-2 hover:!bg-transparent hidden dark:hover:!bg-transparent underline group-hover:block"
|
||||||
|
@click="unlinkIssue"
|
||||||
|
>
|
||||||
|
{{ $t('INTEGRATION_SETTINGS.LINEAR.UNLINK.TITLE') }}
|
||||||
|
</woot-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="text-sm font-normal text-slate-900 dark:text-slate-25">
|
||||||
|
{{ issue.description }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between w-full">
|
||||||
|
<span
|
||||||
|
class="text-sm sticky top-0 h-fit font-normal tracking-[-0.6%] min-w-[140px] truncate text-slate-600 dark:text-slate-200"
|
||||||
|
>
|
||||||
|
{{ $t('INTEGRATION_SETTINGS.LINEAR.ISSUE.STATUS') }}
|
||||||
|
</span>
|
||||||
|
<div class="flex flex-col w-full gap-2">
|
||||||
|
<span
|
||||||
|
class="text-sm font-normal text-left text-slate-900 dark:text-slate-25 tabular-nums"
|
||||||
|
>
|
||||||
|
{{ issue.state.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between w-full">
|
||||||
|
<span
|
||||||
|
class="text-sm sticky top-0 h-fit font-normal tracking-[-0.6%] min-w-[140px] truncate text-slate-600 dark:text-slate-200"
|
||||||
|
>
|
||||||
|
{{ $t('INTEGRATION_SETTINGS.LINEAR.ISSUE.PRIORITY') }}
|
||||||
|
</span>
|
||||||
|
<div class="flex flex-col w-full gap-2">
|
||||||
|
<span
|
||||||
|
class="text-sm font-normal text-left text-slate-900 dark:text-slate-25 tabular-nums"
|
||||||
|
>
|
||||||
|
{{ priorityLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between w-full">
|
||||||
|
<span
|
||||||
|
class="text-sm sticky top-0 h-fit font-normal tracking-[-0.6%] min-w-[140px] truncate text-slate-600 dark:text-slate-200"
|
||||||
|
>
|
||||||
|
{{ $t('INTEGRATION_SETTINGS.LINEAR.ISSUE.ASSIGNEE') }}
|
||||||
|
</span>
|
||||||
|
<div class="flex flex-col w-full gap-2">
|
||||||
|
<span
|
||||||
|
class="text-sm font-normal text-left text-slate-900 dark:text-slate-25 tabular-nums"
|
||||||
|
>
|
||||||
|
{{ assigneeName }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between w-full">
|
||||||
|
<span
|
||||||
|
class="text-sm sticky top-0 h-fit font-normal tracking-[-0.6%] min-w-[140px] truncate text-slate-600 dark:text-slate-200"
|
||||||
|
>
|
||||||
|
{{ $t('INTEGRATION_SETTINGS.LINEAR.ISSUE.LABELS') }}
|
||||||
|
</span>
|
||||||
|
<div class="flex flex-col w-full gap-2">
|
||||||
|
<span
|
||||||
|
class="text-sm font-normal text-left text-slate-900 dark:text-slate-25 tabular-nums"
|
||||||
|
>
|
||||||
|
{{ labels }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between w-full">
|
||||||
|
<span
|
||||||
|
class="text-sm sticky top-0 h-fit font-normal tracking-[-0.6%] min-w-[140px] truncate text-slate-600 dark:text-slate-200"
|
||||||
|
>
|
||||||
|
{{ $t('INTEGRATION_SETTINGS.LINEAR.ISSUE.CREATED_AT') }}
|
||||||
|
</span>
|
||||||
|
<div class="flex flex-col w-full gap-2">
|
||||||
|
<span
|
||||||
|
class="text-sm font-normal text-left text-slate-900 dark:text-slate-25 tabular-nums"
|
||||||
|
>
|
||||||
|
{{ formatDate(issue.createdAt) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
const priorityMap = {
|
||||||
|
0: 'Low',
|
||||||
|
1: 'Medium',
|
||||||
|
2: 'High',
|
||||||
|
3: 'Urgent',
|
||||||
|
};
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
issue: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
linkId: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
priorityLabel() {
|
||||||
|
return priorityMap[this.issue.priority];
|
||||||
|
},
|
||||||
|
assigneeName() {
|
||||||
|
return this.issue.assignee ? this.issue.assignee.name : '---';
|
||||||
|
},
|
||||||
|
labels() {
|
||||||
|
return this.issue.labels.nodes.length
|
||||||
|
? this.issue.labels.nodes.map(label => label.name).join(', ')
|
||||||
|
: '---';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
unlinkIssue() {
|
||||||
|
this.$emit('unlink-issue', this.linkId);
|
||||||
|
},
|
||||||
|
formatDate(timestamp) {
|
||||||
|
return format(new Date(timestamp), 'MMM dd, hh:mm a');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="multiselect-wrap--small">
|
||||||
|
<multiselect
|
||||||
|
v-model="selectedOptions"
|
||||||
|
class="no-margin !pl-0"
|
||||||
|
:placeholder="$t('INTEGRATION_SETTINGS.LINEAR.LINK.SEARCH')"
|
||||||
|
:select-label="$t('INTEGRATION_SETTINGS.LINEAR.LINK.SELECT')"
|
||||||
|
label="title"
|
||||||
|
track-by="id"
|
||||||
|
:options="options"
|
||||||
|
:option-height="24"
|
||||||
|
:show-labels="false"
|
||||||
|
:max-height="500"
|
||||||
|
@search-change="handleSearchChange"
|
||||||
|
@select="handleInput"
|
||||||
|
>
|
||||||
|
<template #noResult>
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
{{ emptyText }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #noOptions>
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
{{ $t('INTEGRATION_SETTINGS.LINEAR.LINK.EMPTY_LIST') }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</multiselect>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-end w-full gap-2 mt-8">
|
||||||
|
<woot-button
|
||||||
|
class="px-4 rounded-xl button clear outline-woot-200/50 outline"
|
||||||
|
@click.prevent="onClose"
|
||||||
|
>
|
||||||
|
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CANCEL') }}
|
||||||
|
</woot-button>
|
||||||
|
<woot-button
|
||||||
|
:is-disabled="isSubmitDisabled"
|
||||||
|
class="px-4 rounded-xl"
|
||||||
|
:is-loading="isLinking"
|
||||||
|
@click.prevent="linkIssue"
|
||||||
|
>
|
||||||
|
{{ $t('INTEGRATION_SETTINGS.LINEAR.LINK.TITLE') }}
|
||||||
|
</woot-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import alertMixin from 'shared/mixins/alertMixin';
|
||||||
|
import LinearAPI from 'dashboard/api/integrations/linear';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mixins: [alertMixin],
|
||||||
|
props: {
|
||||||
|
conversationId: {
|
||||||
|
type: [Number, String],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
selectedOptions: null,
|
||||||
|
options: [],
|
||||||
|
inputStyles: {
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '6px 12px',
|
||||||
|
fontSize: '14px',
|
||||||
|
},
|
||||||
|
isFetching: false,
|
||||||
|
isLinking: false,
|
||||||
|
searchQuery: '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
emptyText() {
|
||||||
|
if (this.isFetching) {
|
||||||
|
return this.$t('INTEGRATION_SETTINGS.LINEAR.LINK.LOADING');
|
||||||
|
}
|
||||||
|
if (this.searchQuery) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return this.$t('INTEGRATION_SETTINGS.LINEAR.LINK.EMPTY_LIST');
|
||||||
|
},
|
||||||
|
isSubmitDisabled() {
|
||||||
|
return !this.selectedOptions || this.isLinking;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onClose() {
|
||||||
|
this.$emit('close');
|
||||||
|
},
|
||||||
|
async handleSearchChange(value) {
|
||||||
|
if (!value) return;
|
||||||
|
this.searchQuery = value;
|
||||||
|
try {
|
||||||
|
this.isFetching = true;
|
||||||
|
const response = await LinearAPI.searchIssues(value);
|
||||||
|
this.options = response.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.showAlert(this.$t('INTEGRATION_SETTINGS.LINEAR.LINK.ERROR'));
|
||||||
|
} finally {
|
||||||
|
this.isFetching = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleInput() {
|
||||||
|
this.$emit('agents-filter-selection', this.selectedOptions);
|
||||||
|
},
|
||||||
|
async linkIssue() {
|
||||||
|
const { id: issueId } = this.selectedOptions;
|
||||||
|
try {
|
||||||
|
this.isLinking = true;
|
||||||
|
await LinearAPI.link_issue(this.conversationId, issueId);
|
||||||
|
this.showAlert(
|
||||||
|
this.$t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_SUCCESS')
|
||||||
|
);
|
||||||
|
this.searchQuery = '';
|
||||||
|
this.onClose();
|
||||||
|
} catch (error) {
|
||||||
|
this.showAlert(this.$t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_ERROR'));
|
||||||
|
} finally {
|
||||||
|
this.isLinking = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="flex flex-row gap-2">
|
||||||
|
<woot-button
|
||||||
|
v-if="shouldShowAddButton"
|
||||||
|
variant="smooth"
|
||||||
|
icon="add"
|
||||||
|
size="tiny"
|
||||||
|
@click="showCreateIssuePopup"
|
||||||
|
>
|
||||||
|
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK_BUTTON') }}
|
||||||
|
</woot-button>
|
||||||
|
</div>
|
||||||
|
<div v-if="isFetching" class="flex items-center justify-center">
|
||||||
|
<span class="text-sm font-medium text-slate-800 dark:text-slate-100">
|
||||||
|
{{ $t('INTEGRATION_SETTINGS.LINEAR.LOADING') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="shouldShowItem" class="flex flex-col gap-2">
|
||||||
|
<issue-item
|
||||||
|
:issue="linkedIssue.issue"
|
||||||
|
:link-id="linkedIssue.id"
|
||||||
|
:conversation-id="conversationId"
|
||||||
|
@unlink-issue="unlinkIssue"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<woot-modal
|
||||||
|
:show.sync="showPopup"
|
||||||
|
:on-close="closePopup"
|
||||||
|
class="!items-start [&>div]:!top-12"
|
||||||
|
>
|
||||||
|
<create-or-link-issue
|
||||||
|
:conversation-id="conversationId"
|
||||||
|
:account-id="currentAccountId"
|
||||||
|
@close="closePopup"
|
||||||
|
/>
|
||||||
|
</woot-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import { mapGetters } from 'vuex';
|
||||||
|
import IssueItem from './IssueItem.vue';
|
||||||
|
import accountMixin from 'dashboard/mixins/account.js';
|
||||||
|
import alertMixin from 'shared/mixins/alertMixin';
|
||||||
|
import CreateOrLinkIssue from './CreateOrLinkIssue.vue';
|
||||||
|
import LinearAPI from 'dashboard/api/integrations/linear';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
IssueItem,
|
||||||
|
CreateOrLinkIssue,
|
||||||
|
},
|
||||||
|
mixins: [accountMixin, alertMixin],
|
||||||
|
props: {
|
||||||
|
conversationId: {
|
||||||
|
type: [Number, String],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isFetching: false,
|
||||||
|
showPopup: false,
|
||||||
|
linkedIssue: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapGetters({
|
||||||
|
currentAccountId: 'getCurrentAccountId',
|
||||||
|
}),
|
||||||
|
shouldShowAddButton() {
|
||||||
|
return !this.isFetching && !this.linkedIssue;
|
||||||
|
},
|
||||||
|
shouldShowItem() {
|
||||||
|
return !this.isFetching && this.linkedIssue;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
conversationId(newConversationId, prevConversationId) {
|
||||||
|
if (newConversationId && newConversationId !== prevConversationId) {
|
||||||
|
this.loadLinkedIssue();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.loadLinkedIssue();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
showCreateIssuePopup() {
|
||||||
|
this.showPopup = true;
|
||||||
|
},
|
||||||
|
closePopup() {
|
||||||
|
this.showPopup = false;
|
||||||
|
this.loadLinkedIssue();
|
||||||
|
},
|
||||||
|
async loadLinkedIssue() {
|
||||||
|
try {
|
||||||
|
this.isFetching = true;
|
||||||
|
const response = await LinearAPI.getLinkedIssue(this.conversationId);
|
||||||
|
const issues = response.data;
|
||||||
|
this.linkedIssue = issues && issues.length ? issues[0] : null;
|
||||||
|
} catch (error) {
|
||||||
|
this.showAlert(this.$t('INTEGRATION_SETTINGS.LINEAR.LOADING_ERROR'));
|
||||||
|
} finally {
|
||||||
|
this.isFetching = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async unlinkIssue(linkId) {
|
||||||
|
try {
|
||||||
|
await LinearAPI.unlinkIssue(linkId);
|
||||||
|
this.linkedIssue = null;
|
||||||
|
this.showAlert(this.$t('INTEGRATION_SETTINGS.LINEAR.UNLINK.SUCCESS'));
|
||||||
|
} catch (error) {
|
||||||
|
this.showAlert(
|
||||||
|
this.$t('INTEGRATION_SETTINGS.LINEAR.UNLINK.DELETE_ERROR')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { required } from 'vuelidate/lib/validators';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: {
|
||||||
|
required,
|
||||||
|
},
|
||||||
|
teamId: {
|
||||||
|
required,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
<template>
|
<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 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')" />
|
<woot-loading-state :message="$t('INTEGRATION_APPS.FETCHING')" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="w-full">
|
<div v-else class="w-full">
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
v-for="item in integrationsList"
|
v-for="item in enabledIntegrations"
|
||||||
:key="item.id"
|
: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-item
|
||||||
:integration-id="item.id"
|
:integration-id="item.id"
|
||||||
@@ -25,22 +25,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
|
||||||
import { mapGetters } from 'vuex';
|
|
||||||
import IntegrationItem from './IntegrationItem.vue';
|
|
||||||
|
|
||||||
export default {
|
<script setup>
|
||||||
components: {
|
import { useStoreGetters, useStore } from 'dashboard/composables/store';
|
||||||
IntegrationItem,
|
import { computed, onMounted } from 'vue';
|
||||||
},
|
import IntegrationItem from './IntegrationItem.vue';
|
||||||
computed: {
|
const store = useStore();
|
||||||
...mapGetters({
|
const getters = useStoreGetters();
|
||||||
uiFlags: 'labels/getUIFlags',
|
|
||||||
integrationsList: 'integrations/getAppIntegrations',
|
const uiFlags = getters['integrations/getUIFlags'];
|
||||||
}),
|
|
||||||
},
|
const accountId = getters.getCurrentAccountId;
|
||||||
mounted() {
|
|
||||||
this.$store.dispatch('integrations/get');
|
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>
|
</script>
|
||||||
|
|||||||
@@ -21,9 +21,13 @@ const state = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isAValidAppIntegration = integration => {
|
const isAValidAppIntegration = integration => {
|
||||||
return ['dialogflow', 'dyte', 'google_translate', 'openai'].includes(
|
return [
|
||||||
integration.id
|
'dialogflow',
|
||||||
);
|
'dyte',
|
||||||
|
'google_translate',
|
||||||
|
'openai',
|
||||||
|
'linear',
|
||||||
|
].includes(integration.id);
|
||||||
};
|
};
|
||||||
export const getters = {
|
export const getters = {
|
||||||
getIntegrations($state) {
|
getIntegrations($state) {
|
||||||
|
|||||||
@@ -83,3 +83,5 @@
|
|||||||
- name: help_center_embedding_search
|
- name: help_center_embedding_search
|
||||||
enabled: false
|
enabled: false
|
||||||
premium: true
|
premium: true
|
||||||
|
- name: linear_integration
|
||||||
|
enabled: false
|
||||||
|
|||||||
@@ -159,3 +159,27 @@ openai:
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
visible_properties: ['api_key', 'label_suggestion']
|
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: []
|
||||||
|
|||||||
@@ -224,6 +224,9 @@ en:
|
|||||||
openai:
|
openai:
|
||||||
name: "OpenAI"
|
name: "OpenAI"
|
||||||
description: "Integrate powerful AI features into Chatwoot by leveraging the GPT models from 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:
|
public_portal:
|
||||||
search:
|
search:
|
||||||
search_placeholder: Search for article by title or body...
|
search_placeholder: Search for article by title or body...
|
||||||
|
|||||||
@@ -227,6 +227,17 @@ Rails.application.routes.draw do
|
|||||||
post :add_participant_to_meeting
|
post :add_participant_to_meeting
|
||||||
end
|
end
|
||||||
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_issue
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
resources :working_hours, only: [:update]
|
resources :working_hours, only: [:update]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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_entities(team_id)
|
||||||
|
response = linear_client.team_entities(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(params)
|
||||||
|
response = linear_client.create_issue(params)
|
||||||
|
return response if response[:error]
|
||||||
|
|
||||||
|
{
|
||||||
|
id: response['issueCreate']['issue']['id'],
|
||||||
|
title: response['issueCreate']['issue']['title']
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def link_issue(link, issue_id)
|
||||||
|
response = linear_client.link_issue(link, issue_id)
|
||||||
|
return response if response[:error]
|
||||||
|
|
||||||
|
{
|
||||||
|
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]
|
||||||
|
|
||||||
|
{
|
||||||
|
link_id: link_id
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def search_issue(term)
|
||||||
|
response = linear_client.search_issue(term)
|
||||||
|
|
||||||
|
return response if response[:error]
|
||||||
|
|
||||||
|
response['searchIssues']['nodes'].map(&:as_json)
|
||||||
|
end
|
||||||
|
|
||||||
|
def linked_issue(url)
|
||||||
|
response = linear_client.linked_issue(url)
|
||||||
|
return response if response[:error]
|
||||||
|
|
||||||
|
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
|
||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
require_relative 'linear_queries'
|
||||||
|
require_relative 'linear_mutations'
|
||||||
|
|
||||||
|
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: LinearQueries::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: LinearQueries.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: LinearQueries.search_issue(term)
|
||||||
|
}
|
||||||
|
response = post(query)
|
||||||
|
process_response(response)
|
||||||
|
end
|
||||||
|
|
||||||
|
def linked_issue(url)
|
||||||
|
raise ArgumentError, 'Missing link' if url.blank?
|
||||||
|
|
||||||
|
query = {
|
||||||
|
query: LinearQueries.linked_issue(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 = LinearMutations.issue_create(variables)
|
||||||
|
response = post({ query: mutation })
|
||||||
|
process_response(response)
|
||||||
|
end
|
||||||
|
|
||||||
|
def link_issue(link, issue_id)
|
||||||
|
raise ArgumentError, 'Missing link' if link.blank?
|
||||||
|
raise ArgumentError, 'Missing issue id' if issue_id.blank?
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
query: LinearMutations.issue_link(issue_id, link)
|
||||||
|
}
|
||||||
|
response = post(payload)
|
||||||
|
process_response(response)
|
||||||
|
end
|
||||||
|
|
||||||
|
def unlink_issue(link_id)
|
||||||
|
raise ArgumentError, 'Missing link id' if link_id.blank?
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
query: LinearMutations.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?
|
||||||
|
|
||||||
|
{ error: response.parsed_response, error_code: response.code }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
module LinearMutations
|
||||||
|
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
|
||||||
|
|
||||||
|
# Main mutation creation function
|
||||||
|
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)
|
||||||
|
<<~GRAPHQL
|
||||||
|
mutation {
|
||||||
|
attachmentLinkURL(url: "#{link}", issueId: "#{issue_id}") {
|
||||||
|
success
|
||||||
|
attachment {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GRAPHQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.unlink_issue(link_id)
|
||||||
|
<<~GRAPHQL
|
||||||
|
mutation {
|
||||||
|
attachmentDelete(id: "#{link_id}") {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GRAPHQL
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
module LinearQueries
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GRAPHQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.linked_issue(url)
|
||||||
|
<<~GRAPHQL
|
||||||
|
query {
|
||||||
|
attachmentsForURL(url: "#{url}") {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
issue {
|
||||||
|
id
|
||||||
|
identifier
|
||||||
|
title
|
||||||
|
description
|
||||||
|
priority
|
||||||
|
createdAt
|
||||||
|
url
|
||||||
|
state {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
state {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
assignee {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
labels {
|
||||||
|
nodes{
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GRAPHQL
|
||||||
|
end
|
||||||
|
end
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,256 @@
|
|||||||
|
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) { [{ '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
|
||||||
|
{
|
||||||
|
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) { { '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}" }
|
||||||
|
|
||||||
|
context 'when it is an authenticated user' do
|
||||||
|
context 'when the issue is linked successfully' do
|
||||||
|
let(:linked_issue) { { 'id' => 'issue1', 'link' => 'https://linear.app/issue1' } }
|
||||||
|
|
||||||
|
it 'returns the linked issue' do
|
||||||
|
allow(processor_service).to receive(:link_issue).with(link, issue_id).and_return(linked_issue)
|
||||||
|
post "/api/v1/accounts/#{account.id}/integrations/linear/link_issue",
|
||||||
|
params: { conversation_id: conversation.display_id, issue_id: issue_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 linking fails' do
|
||||||
|
it 'returns error message' do
|
||||||
|
allow(processor_service).to receive(:link_issue).with(link, issue_id).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 },
|
||||||
|
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) { { '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) { [{ '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_issue' 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) { [{ 'id' => 'issue1', 'title' => 'Sample Issue' }] }
|
||||||
|
|
||||||
|
it 'returns linked issue' do
|
||||||
|
allow(processor_service).to receive(:linked_issue).with(link).and_return(linked_issue)
|
||||||
|
get "/api/v1/accounts/#{account.id}/integrations/linear/linked_issue",
|
||||||
|
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_issue).with(link).and_return(error: 'error message')
|
||||||
|
get "/api/v1/accounts/#{account.id}/integrations/linear/linked_issue",
|
||||||
|
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
|
||||||
@@ -26,5 +26,10 @@ FactoryBot.define do
|
|||||||
app_id { 'openai' }
|
app_id { 'openai' }
|
||||||
settings { { api_key: 'api_key' } }
|
settings { { api_key: 'api_key' } }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
trait :linear do
|
||||||
|
app_id { 'linear' }
|
||||||
|
settings { { api_key: 'api_key' } }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
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_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 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_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({ 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(:link_issue_response) { { id: issue_id, link: link, 'attachmentLinkURL': { 'attachment': { 'id': 'attachment1' } } } }
|
||||||
|
let(:link_response) { { 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).and_return(link_issue_response)
|
||||||
|
result = service.link_issue(link, issue_id)
|
||||||
|
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).and_return(error_response)
|
||||||
|
result = service.link_issue(link, issue_id)
|
||||||
|
expect(result).to eq(error_response)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#unlink_issue' do
|
||||||
|
let(:link_id) { 'attachment1' }
|
||||||
|
let(:unlink_response) { { 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 contain_exactly({ 'id' => 'issue1', 'title' => 'Issue title', 'description' => 'Issue description' })
|
||||||
|
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_issue' 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_issue).with(url).and_return(linked_response)
|
||||||
|
result = service.linked_issue(url)
|
||||||
|
expect(result).to contain_exactly({ '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_issue).with(url).and_return(error_response)
|
||||||
|
result = service.linked_issue(url)
|
||||||
|
expect(result).to eq(error_response)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
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' }
|
||||||
|
|
||||||
|
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)
|
||||||
|
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) }.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) }.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)
|
||||||
|
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_issue('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_issue('app.chatwoot.com')
|
||||||
|
expect(response).to eq({ :error => { 'errors' => [{ 'message' => 'Error retrieving data' }] }, :error_code => 422 })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user