Compare commits

...
Author SHA1 Message Date
Muhsin KelothandGitHub e992c23d03 Merge branch 'feat/linear-backend' into feat/linear-frontend 2024-05-16 17:30:58 +05:30
Muhsin Keloth 669a138114 chore: team validation 2024-05-16 16:20:24 +05:30
Muhsin Keloth 31732a1a7b chore: replace graphlient with HTTParty 2024-05-16 16:18:47 +05:30
Muhsin KelothandGitHub b663f3201c Merge branch 'develop' into feat/linear-backend 2024-05-16 15:25:33 +05:30
Muhsin Keloth 01ddb61c9e chore: add url in issue 2024-05-16 15:25:10 +05:30
Muhsin Keloth 87e8e342c9 Update index.vue 2024-05-14 12:27:52 +05:30
Muhsin Keloth 27ec845faa chore: add url in issue 2024-05-14 08:28:24 +05:30
iamsivin 91cefe2072 chore: Fix input styles 2024-05-14 00:55:54 +05:30
Muhsin KelothandGitHub d356067300 Merge branch 'develop' into feat/linear-backend 2024-05-13 22:06:57 +05:30
Muhsin Keloth aa3f2b56bc chore: minor fix 2024-05-13 21:08:59 +05:30
Muhsin Keloth bc5449f208 chore: translation updates 2024-05-13 19:13:03 +05:30
Muhsin Keloth e1bfd9a17a chore: translation updates 2024-05-13 19:04:06 +05:30
Muhsin Keloth 4825ab1a29 chore: code cleanup 2024-05-13 18:34:40 +05:30
iamsivin 7efc2b9325 chore: Style fix 2024-05-13 14:33:01 +05:30
Muhsin Keloth 4a491d8a91 feat: add or link issue 2024-05-13 13:59:38 +05:30
Muhsin Keloth f2db469c75 feat: add basic create issue feature 2024-05-10 16:59:41 +05:30
Muhsin Keloth 5d48c2b1e5 chore: add more fields in get issue querey 2024-05-10 16:53:01 +05:30
Muhsin Keloth ecce6e021b chore: update link issue API 2024-05-10 15:50:13 +05:30
Muhsin Keloth 4d47fc1c25 chore: rename from team_entites to team_entities 2024-05-10 15:16:56 +05:30
Muhsin KelothandGitHub b25e395bf7 Merge branch 'develop' into feat/linear-backend 2024-05-10 11:40:56 +05:30
Muhsin KelothandGitHub 12b3323364 Merge branch 'develop' into feat/linear-backend 2024-05-10 09:44:59 +05:30
Muhsin KelothandGitHub 793420c341 Merge branch 'develop' into feat/linear-backend 2024-05-09 23:41:57 +05:30
Muhsin KelothandGitHub 775e8251a6 Merge branch 'develop' into feat/linear-backend 2024-05-09 22:45:43 +05:30
Muhsin Keloth 4645e57e9a feat: add linked issue api 2024-05-09 22:40:42 +05:30
Muhsin Keloth 5e1dbfb64d feat: search issue API 2024-05-09 22:28:47 +05:30
Muhsin Keloth df29b43540 feat: unlink issue api 2024-05-09 21:44:42 +05:30
Muhsin Keloth 3e4f43d7da chore: add specs for create and link apis 2024-05-09 16:24:31 +05:30
Muhsin Keloth 2b7c972cbf feat: add specs for linear processor 2024-05-09 15:16:14 +05:30
Muhsin Keloth 429cfd0166 feat: add specs for create and link issue 2024-05-09 15:12:20 +05:30
Muhsin Keloth fc41669178 feat: add link issue API 2024-05-09 15:05:36 +05:30
Muhsin Keloth e567be8fb2 chore: issue input validation 2024-05-09 14:25:00 +05:30
Muhsin Keloth 1ddc05dadb feat: add create issue API 2024-05-09 12:23:14 +05:30
Muhsin KelothandGitHub 0268d544d1 Merge branch 'develop' into feat/linear-backend 2024-05-09 11:33:07 +05:30
Muhsin KelothandGitHub 2960bcdf7d Merge branch 'develop' into feat/linear-backend 2024-05-09 10:37:54 +05:30
Muhsin KelothandGitHub 3c4a7bb9f7 Merge branch 'develop' into feat/linear-backend 2024-05-09 07:32:51 +05:30
Muhsin Keloth 792bc5cf9d Merge branch 'feat/linear-backend' of https://github.com/chatwoot/chatwoot into feat/linear-backend 2024-05-08 13:46:43 +05:30
Muhsin Keloth bf40242497 Merge branch 'feat/linear-backend' of https://github.com/chatwoot/chatwoot into feat/linear-backend 2024-05-08 13:46:35 +05:30
Muhsin Keloth 2130a2ebe1 Merge branch 'feat/linear-backend' of https://github.com/chatwoot/chatwoot into feat/linear-backend 2024-05-08 12:36:54 +05:30
Muhsin KelothandGitHub 2bad7f435a Merge branch 'develop' into feat/linear-backend 2024-05-08 12:36:43 +05:30
Muhsin Keloth c0d985a62b chore: make applications component in vue 3 syntax 2024-05-08 12:36:33 +05:30
Muhsin Keloth 5b7af73abe Merge branch 'feat/linear-backend' of https://github.com/chatwoot/chatwoot into feat/linear-backend 2024-05-08 12:15:44 +05:30
Muhsin Keloth 2ae79bd9a1 chore: add specs 2024-05-08 12:15:38 +05:30
Muhsin Keloth 17b61ad2b4 chore: add specs 2024-05-08 10:51:10 +05:30
Muhsin KelothandGitHub e098e463ad Merge branch 'develop' into feat/linear-backend 2024-05-08 09:09:13 +05:30
Muhsin Keloth b11f4d5bd5 Update linear_controller.rb 2024-05-07 15:22:59 +05:30
Muhsin Keloth 0a45b7e156 chore: add feature flag linear_integration 2024-05-07 15:13:33 +05:30
Muhsin Keloth ee184d9792 chore: cleanup code 2024-05-07 14:19:34 +05:30
Muhsin KelothandGitHub 3595903fb5 Merge branch 'develop' into feat/linear-backend 2024-05-07 14:06:03 +05:30
Muhsin KelothandGitHub 68a7fce45c Merge branch 'develop' into feat/linear-backend 2024-05-06 22:35:00 +05:30
Muhsin KelothandGitHub 8e5cabd14d Merge branch 'develop' into feat/linear-backend 2024-05-06 16:31:59 +05:30
Muhsin Keloth dd2938dc03 Merge branch 'feat/linear-backend' of https://github.com/chatwoot/chatwoot into feat/linear-backend 2024-05-06 15:58:56 +05:30
Muhsin Keloth 0bf4fc9e21 chore: add ProcessorService spec 2024-05-06 15:58:53 +05:30
Muhsin KelothandGitHub 8f7162d6c1 Merge branch 'develop' into feat/linear-backend 2024-05-06 14:27:21 +05:30
Muhsin Keloth 243834c401 feat: add team entities API 2024-05-06 14:26:52 +05:30
Muhsin KelothandGitHub 1bba61a758 Merge branch 'develop' into feat/linear-backend 2024-05-03 14:29:47 +05:30
Muhsin Keloth e58f1ad98d fix: specs 2024-05-03 14:25:10 +05:30
Shivam Mishra 7e61397186 fix: linear spec 2024-05-03 13:36:24 +05:30
Muhsin Keloth c5e34669f6 chore: add linear schema 2024-05-03 12:59:34 +05:30
Muhsin Keloth 97f96db60e Create linear_spec.rb 2024-05-03 12:48:14 +05:30
Muhsin Keloth 0fafe37646 chore: code cleanup 2024-05-02 16:45:57 +05:30
Muhsin Keloth 68cb6b19b0 feat: add teams end point 2024-05-02 16:11:21 +05:30
Muhsin Keloth ead7cc0c36 feat: add liner integration 2024-05-02 13:39:57 +05:30
27 changed files with 2221 additions and 25 deletions
@@ -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();
+1
View File
@@ -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) {
+2
View File
@@ -83,3 +83,5 @@
- name: help_center_embedding_search
enabled: false
premium: true
- name: linear_integration
enabled: false
+24
View File
@@ -159,3 +159,27 @@ openai:
},
]
visible_properties: ['api_key', 'label_suggestion']
linear:
id: linear
logo: linear.png
i18n_key: linear
action: /linear
hook_type: account
allow_multiple_hooks: false
settings_json_schema: {
"type": "object",
"properties": {
"api_key": { "type": "string" },
},
"required": ["api_key"],
"additionalProperties": false,
}
settings_form_schema: [
{
"label": "API Key",
"type": "text",
"name": "api_key",
"validation": "required",
},
]
visible_properties: []
+3
View File
@@ -224,6 +224,9 @@ en:
openai:
name: "OpenAI"
description: "Integrate powerful AI features into Chatwoot by leveraging the GPT models from OpenAI."
linear:
name: "Linear"
description: "Connect your account with Linear to get realtime updates on the status of your conversations."
public_portal:
search:
search_placeholder: Search for article by title or body...
+11
View File
@@ -227,6 +227,17 @@ Rails.application.routes.draw do
post :add_participant_to_meeting
end
end
resource :linear, controller: 'linear', only: [] do
collection do
get :teams
get :team_entities
post :create_issue
post :link_issue
post :unlink_issue
get :search_issue
get :linked_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
View File
@@ -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
+57
View File
@@ -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
+98
View File
@@ -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
+5
View File
@@ -26,5 +26,10 @@ FactoryBot.define do
app_id { 'openai' }
settings { { api_key: 'api_key' } }
end
trait :linear do
app_id { 'linear' }
settings { { api_key: 'api_key' } }
end
end
end
@@ -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
+301
View File
@@ -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