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',
|
||||
INBOUND_EMAILS: 'inbound_emails',
|
||||
IP_LOOKUP: 'ip_lookup',
|
||||
LINEAR: 'linear_integration',
|
||||
};
|
||||
|
||||
@@ -203,6 +203,74 @@
|
||||
"API_SUCCESS": "Dashboard app deleted successfully",
|
||||
"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: 'previous_conversation' },
|
||||
{ name: 'conversation_participants' },
|
||||
{ name: 'linear' },
|
||||
];
|
||||
export const DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER = [
|
||||
{ name: 'contact_attributes' },
|
||||
|
||||
@@ -23,6 +23,18 @@
|
||||
:key="element.name"
|
||||
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
|
||||
v-if="element.name === 'conversation_actions'"
|
||||
class="conversation--actions"
|
||||
@@ -145,7 +157,8 @@ import CustomAttributes from './customAttributes/CustomAttributes.vue';
|
||||
import draggable from 'vuedraggable';
|
||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
import MacrosList from './Macros/List.vue';
|
||||
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import Linear from './linear/index.vue';
|
||||
export default {
|
||||
components: {
|
||||
AccordionItem,
|
||||
@@ -157,6 +170,7 @@ export default {
|
||||
ConversationParticipant,
|
||||
draggable,
|
||||
MacrosList,
|
||||
Linear,
|
||||
},
|
||||
mixins: [alertMixin, uiSettingsMixin],
|
||||
props: {
|
||||
@@ -185,6 +199,8 @@ export default {
|
||||
currentChat: 'getSelectedChat',
|
||||
currentUser: 'getCurrentUser',
|
||||
uiFlags: 'inboxAssignableAgents/getUIFlags',
|
||||
accountId: 'getCurrentAccountId',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
}),
|
||||
conversationAdditionalAttributes() {
|
||||
return this.currentConversationMetaData.additional_attributes || {};
|
||||
@@ -210,6 +226,12 @@ export default {
|
||||
const { custom_attributes: customAttributes } = this.contact;
|
||||
return customAttributes && Object.keys(customAttributes).length;
|
||||
},
|
||||
isLinearIntegrationEnabled() {
|
||||
return this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.LINEAR
|
||||
);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
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>
|
||||
<div class="flex-shrink flex-grow overflow-auto p-4">
|
||||
<div class="flex-grow flex-shrink p-4 overflow-auto">
|
||||
<div class="flex flex-col">
|
||||
<div v-if="uiFlags.isFetching" class="my-0 mx-auto">
|
||||
<div v-if="uiFlags.isFetching" class="mx-auto my-0">
|
||||
<woot-loading-state :message="$t('INTEGRATION_APPS.FETCHING')" />
|
||||
</div>
|
||||
|
||||
<div v-else class="w-full">
|
||||
<div>
|
||||
<div
|
||||
v-for="item in integrationsList"
|
||||
v-for="item in enabledIntegrations"
|
||||
:key="item.id"
|
||||
class="bg-white dark:bg-slate-800 border border-solid border-slate-75 dark:border-slate-700/50 rounded-sm mb-4 p-4"
|
||||
class="p-4 mb-4 bg-white border border-solid rounded-sm dark:bg-slate-800 border-slate-75 dark:border-slate-700/50"
|
||||
>
|
||||
<integration-item
|
||||
:integration-id="item.id"
|
||||
@@ -25,22 +25,38 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import IntegrationItem from './IntegrationItem.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
IntegrationItem,
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
uiFlags: 'labels/getUIFlags',
|
||||
integrationsList: 'integrations/getAppIntegrations',
|
||||
}),
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('integrations/get');
|
||||
},
|
||||
};
|
||||
<script setup>
|
||||
import { useStoreGetters, useStore } from 'dashboard/composables/store';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import IntegrationItem from './IntegrationItem.vue';
|
||||
const store = useStore();
|
||||
const getters = useStoreGetters();
|
||||
|
||||
const uiFlags = getters['integrations/getUIFlags'];
|
||||
|
||||
const accountId = getters.getCurrentAccountId;
|
||||
|
||||
const integrationList = computed(() => {
|
||||
return getters['integrations/getAppIntegrations'].value;
|
||||
});
|
||||
|
||||
const isLinearIntegrationEnabled = computed(() => {
|
||||
return getters['accounts/isFeatureEnabledonAccount'].value(
|
||||
accountId.value,
|
||||
'linear_integration'
|
||||
);
|
||||
});
|
||||
const enabledIntegrations = computed(() => {
|
||||
if (!isLinearIntegrationEnabled.value) {
|
||||
return integrationList.value.filter(
|
||||
integration => integration.id !== 'linear'
|
||||
);
|
||||
}
|
||||
return integrationList.value;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('integrations/get');
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -21,9 +21,13 @@ const state = {
|
||||
};
|
||||
|
||||
const isAValidAppIntegration = integration => {
|
||||
return ['dialogflow', 'dyte', 'google_translate', 'openai'].includes(
|
||||
integration.id
|
||||
);
|
||||
return [
|
||||
'dialogflow',
|
||||
'dyte',
|
||||
'google_translate',
|
||||
'openai',
|
||||
'linear',
|
||||
].includes(integration.id);
|
||||
};
|
||||
export const getters = {
|
||||
getIntegrations($state) {
|
||||
|
||||
@@ -83,3 +83,5 @@
|
||||
- name: help_center_embedding_search
|
||||
enabled: false
|
||||
premium: true
|
||||
- name: linear_integration
|
||||
enabled: false
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -224,6 +224,9 @@ en:
|
||||
openai:
|
||||
name: "OpenAI"
|
||||
description: "Integrate powerful AI features into Chatwoot by leveraging the GPT models from OpenAI."
|
||||
linear:
|
||||
name: "Linear"
|
||||
description: "Connect your account with Linear to get realtime updates on the status of your conversations."
|
||||
public_portal:
|
||||
search:
|
||||
search_placeholder: Search for article by title or body...
|
||||
|
||||
@@ -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_issue
|
||||
end
|
||||
end
|
||||
end
|
||||
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' }
|
||||
settings { { api_key: 'api_key' } }
|
||||
end
|
||||
|
||||
trait :linear do
|
||||
app_id { 'linear' }
|
||||
settings { { api_key: 'api_key' } }
|
||||
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