Compare commits

...

9 Commits

Author SHA1 Message Date
Sashank
3996727515 project id clarity 2025-11-21 09:41:25 -08:00
Sashank
0979293bc6 field casE 2025-11-21 09:41:25 -08:00
Sashank
763359b2a8 update comment 2025-11-21 09:41:25 -08:00
Sashank
71ec663ca5 last updatE 2025-11-21 09:41:25 -08:00
Sashank
c7e9c996cb updates 2025-11-21 09:41:25 -08:00
Sashank
1e33ba7df5 mvp 2025-11-21 09:41:25 -08:00
Sashank
a627f2f9b5 move back to 0.85 2025-11-21 09:41:25 -08:00
Sashank
8a63358266 testrail logo and http error handling 2025-11-21 09:41:25 -08:00
Sashank
f1f5217da8 initial 2025-11-21 09:41:25 -08:00
12 changed files with 611 additions and 0 deletions

View File

@@ -581,6 +581,12 @@ EXPERIMENTAL_CHECKPOINTING_ENABLED = (
os.environ.get("EXPERIMENTAL_CHECKPOINTING_ENABLED", "").lower() == "true"
)
# TestRail specific configs
TESTRAIL_BASE_URL = os.environ.get("TESTRAIL_BASE_URL", "")
TESTRAIL_USERNAME = os.environ.get("TESTRAIL_USERNAME", "")
TESTRAIL_API_KEY = os.environ.get("TESTRAIL_API_KEY", "")
LEAVE_CONNECTOR_ACTIVE_ON_INITIALIZATION_FAILURE = (
os.environ.get("LEAVE_CONNECTOR_ACTIVE_ON_INITIALIZATION_FAILURE", "").lower()
== "true"

View File

@@ -211,6 +211,7 @@ class DocumentSource(str, Enum):
IMAP = "imap"
BITBUCKET = "bitbucket"
TESTRAIL = "testrail"
# Special case just for integration tests
MOCK_CONNECTOR = "mock_connector"
@@ -618,4 +619,5 @@ project management, and collaboration tools into a single, customizable platform
DocumentSource.AIRTABLE: "airtable - database",
DocumentSource.HIGHSPOT: "highspot - CRM data",
DocumentSource.IMAP: "imap - email data",
DocumentSource.TESTRAIL: "testrail - test case management tool for QA processes",
}

View File

@@ -200,6 +200,10 @@ CONNECTOR_CLASS_MAP = {
module_path="onyx.connectors.bitbucket.connector",
class_name="BitbucketConnector",
),
DocumentSource.TESTRAIL: ConnectorMapping(
module_path="onyx.connectors.testrail.connector",
class_name="TestRailConnector",
),
# just for integration tests
DocumentSource.MOCK_CONNECTOR: ConnectorMapping(
module_path="onyx.connectors.mock_connector.connector",

View File

@@ -0,0 +1,3 @@
# Package marker for TestRail connector

View File

@@ -0,0 +1,526 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, ClassVar, Iterator, Optional
import requests
from bs4 import BeautifulSoup
from onyx.configs.app_configs import INDEX_BATCH_SIZE
from onyx.configs.constants import DocumentSource
from onyx.connectors.interfaces import (
GenerateDocumentsOutput,
LoadConnector,
PollConnector,
SecondsSinceUnixEpoch,
)
from onyx.connectors.models import (
ConnectorMissingCredentialError,
Document,
TextSection,
)
from onyx.connectors.exceptions import (
CredentialExpiredError,
InsufficientPermissionsError,
UnexpectedValidationError,
)
from onyx.utils.logger import setup_logger
from onyx.utils.text_processing import remove_markdown_image_references
from onyx.file_processing.html_utils import format_document_soup
logger = setup_logger()
class TestRailConnector(LoadConnector, PollConnector):
"""Connector for TestRail.
Minimal implementation that indexes Test Cases per project.
"""
document_source_type: ClassVar[DocumentSource] = DocumentSource.TESTRAIL
# Fields that need ID-to-label value mapping
FIELDS_NEEDING_VALUE_MAPPING: ClassVar[set[str]] = {
"priority_id",
"custom_automation_type",
"custom_scenario_db_automation",
"custom_case_golden_canvas_automation",
"custom_customers",
"custom_case_environments",
"custom_case_overall_automation",
"custom_case_team_ownership",
"custom_case_unit_or_integration_automation",
"custom_effort",
}
def __init__(
self,
batch_size: int = INDEX_BATCH_SIZE,
project_ids: list[int] | None = None,
cases_page_size: int | None = None,
max_pages: int | None = None,
skip_doc_absolute_chars: int | None = None,
) -> None:
self.base_url: str | None = None
self.username: str | None = None
self.api_key: str | None = None
self.batch_size = batch_size
# Parse project_ids from string if needed
# None = all projects (no filtering), [] = no projects, [1,2,3] = specific projects
if isinstance(project_ids, str):
if project_ids.strip():
self.project_ids = [int(x.strip()) for x in project_ids.split(",") if x.strip()]
else:
# Empty string from UI means "all projects"
self.project_ids = None
else:
self.project_ids = project_ids
# Handle empty strings from UI and convert to int with defaults
self.cases_page_size = (
int(cases_page_size) if cases_page_size and str(cases_page_size).strip() else 250
)
self.max_pages = (
int(max_pages) if max_pages and str(max_pages).strip() else 10000
)
self.skip_doc_absolute_chars = (
int(skip_doc_absolute_chars) if skip_doc_absolute_chars and str(skip_doc_absolute_chars).strip() else 200000
)
# Cache for field labels and value mappings - will be populated on first use
self._field_labels: dict[str, str] | None = None
self._value_maps: dict[str, dict[str, str]] | None = None
# --- Rich text sanitization helpers ---
# Note: TestRail stores some fields as HTML (e.g. shared test steps).
# This function handles both HTML and plain text.
@staticmethod
def _sanitize_rich_text(value: Any) -> str:
if value is None:
return ""
text = str(value)
# Parse HTML and remove image tags
soup = BeautifulSoup(text, 'html.parser')
# Remove all img tags and their containers
for img_tag in soup.find_all('img'):
img_tag.decompose()
for span in soup.find_all('span', class_='markdown-img-container'):
span.decompose()
# Use format_document_soup for better HTML-to-text conversion
# This preserves document structure (paragraphs, lists, line breaks, etc.)
text = format_document_soup(soup)
# Also remove markdown-style image references (in case any remain)
text = remove_markdown_image_references(text)
return text.strip()
def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None:
# Expected keys from UI credential JSON
self.base_url = str(credentials["testrail_base_url"]).rstrip("/")
self.username = str(credentials["testrail_username"]) # email or username
self.api_key = str(credentials["testrail_api_key"]) # API key (password)
return None
def validate_connector_settings(self) -> None:
"""Lightweight validation to surface common misconfigurations early."""
projects = self._list_projects()
if not projects:
logger.warning("TestRail: no projects visible to this credential.")
# ---- API helpers ----
def _api_get(self, endpoint: str, params: Optional[dict[str, Any]] = None) -> Any:
if not self.base_url or not self.username or not self.api_key:
raise ConnectorMissingCredentialError("testrail")
# TestRail API base is typically /index.php?/api/v2/<endpoint>
url = f"{self.base_url}/index.php?/api/v2/{endpoint}"
try:
response = requests.get(
url,
auth=(self.username, self.api_key),
params=params,
)
response.raise_for_status()
except requests.exceptions.HTTPError as e:
status = e.response.status_code if getattr(e, "response", None) else None
if status == 401:
raise CredentialExpiredError(
"Invalid or expired TestRail credentials (HTTP 401)."
) from e
if status == 403:
raise InsufficientPermissionsError(
"Insufficient permissions to access TestRail resources (HTTP 403)."
) from e
raise UnexpectedValidationError(
f"Unexpected TestRail HTTP error (status={status})."
) from e
except requests.exceptions.RequestException as e:
raise UnexpectedValidationError(f"TestRail request failed: {e}") from e
try:
return response.json()
except ValueError as e:
raise UnexpectedValidationError("Invalid JSON returned by TestRail API") from e
def _list_projects(self) -> list[dict[str, Any]]:
projects = self._api_get("get_projects")
if isinstance(projects, dict):
projects_list = projects.get("projects")
return projects_list if isinstance(projects_list, list) else []
return []
def _list_suites(self, project_id: int) -> list[dict[str, Any]]:
"""Return suites for a project. If the project is in single-suite mode,
some TestRail instances may return an empty list; callers should
gracefully fallback to calling get_cases without suite_id.
"""
suites = self._api_get(f"get_suites/{project_id}")
if isinstance(suites, dict):
suites_list = suites.get("suites")
return suites_list if isinstance(suites_list, list) else []
return []
def _get_case_fields(self) -> list[dict[str, Any]]:
"""Get case field definitions from TestRail API."""
try:
fields = self._api_get("get_case_fields")
return fields if isinstance(fields, list) else []
except Exception as e:
logger.warning(f"Failed to fetch case fields from TestRail: {e}")
return []
def _parse_items_string(self, items_str: str) -> dict[str, str]:
"""Parse items string from field config into ID -> label mapping.
Format: "1, Option A\\n2, Option B\\n3, Option C"
Returns: {"1": "Option A", "2": "Option B", "3": "Option C"}
"""
id_to_label = {}
if not items_str:
return id_to_label
for line in items_str.split('\n'):
line = line.strip()
if not line:
continue
parts = line.split(',', 1)
if len(parts) == 2:
item_id = parts[0].strip()
item_label = parts[1].strip()
id_to_label[item_id] = item_label
return id_to_label
def _build_field_maps(self) -> tuple[dict[str, str], dict[str, dict[str, str]]]:
"""Build both field labels and value mappings in one pass.
Returns:
(field_labels, value_maps) where:
- field_labels: system_name -> label
- value_maps: system_name -> {id -> label}
"""
field_labels = {}
value_maps = {}
try:
fields = self._get_case_fields()
for field in fields:
system_name = field.get("system_name")
# Build field label map
label = field.get("label")
if system_name and label:
field_labels[system_name] = label
# Build value map if needed
if system_name in self.FIELDS_NEEDING_VALUE_MAPPING:
configs = field.get("configs", [])
if configs and len(configs) > 0:
options = configs[0].get("options", {})
items_str = options.get("items")
if items_str:
value_maps[system_name] = self._parse_items_string(items_str)
except Exception as e:
logger.warning(f"Failed to build field maps from TestRail: {e}")
return field_labels, value_maps
def _get_field_labels(self) -> dict[str, str]:
"""Get field labels, fetching from API if not cached."""
if self._field_labels is None:
self._field_labels, self._value_maps = self._build_field_maps()
return self._field_labels
def _get_value_maps(self) -> dict[str, dict[str, str]]:
"""Get value maps, fetching from API if not cached."""
if self._value_maps is None:
self._field_labels, self._value_maps = self._build_field_maps()
return self._value_maps
def _map_field_value(self, field_name: str, field_value: Any) -> str:
"""Map a field value using the value map if available.
Examples:
- priority_id: 2 -> "Medium"
- custom_case_team_ownership: [10] -> "Sim Platform"
- custom_case_environments: [1, 2] -> "Local, Cloud"
"""
if field_value is None or field_value == "":
return ""
# Get value map for this field
value_maps = self._get_value_maps()
value_map = value_maps.get(field_name, {})
# Handle list values
if isinstance(field_value, list):
if not field_value:
return ""
mapped = [value_map.get(str(v), str(v)) for v in field_value]
return ", ".join(mapped)
# Handle single values
val_str = str(field_value)
return value_map.get(val_str, val_str)
def _get_cases(
self, project_id: int, suite_id: Optional[int], limit: int, offset: int
) -> list[dict[str, Any]]:
"""Get cases for a project from the API."""
params: dict[str, Any] = {"limit": limit, "offset": offset}
if suite_id is not None:
params["suite_id"] = suite_id
cases_response = self._api_get(f"get_cases/{project_id}", params=params)
cases_list: list[dict[str, Any]] = []
if isinstance(cases_response, dict):
cases_items = cases_response.get("cases")
if isinstance(cases_items, list):
cases_list = cases_items
return cases_list
def _iter_cases(
self,
project_id: int,
suite_id: Optional[int] = None,
start: Optional[SecondsSinceUnixEpoch] = None,
end: Optional[SecondsSinceUnixEpoch] = None,
) -> Iterator[dict[str, Any]]:
# Pagination: TestRail supports 'limit' and 'offset' for many list endpoints
limit = self.cases_page_size
# Use a bounded page loop to avoid infinite loops on API anomalies
for page_index in range(self.max_pages):
offset = page_index * limit
cases = self._get_cases(project_id, suite_id, limit, offset)
if not cases:
break
# Filter by updated window if provided
for case in cases:
# 'updated_on' is unix timestamp (seconds)
updated_on = case.get("updated_on") or case.get("created_on")
if start is not None and updated_on is not None and updated_on < start:
continue
if end is not None and updated_on is not None and updated_on > end:
continue
yield case
if len(cases) < limit:
break
def _build_case_link(self, project_id: int, case_id: int) -> str:
# Standard UI link to a case
return f"{self.base_url}/index.php?/cases/view/{case_id}"
def _doc_from_case(
self,
project: dict[str, Any],
case: dict[str, Any],
suite: dict[str, Any] | None = None,
) -> Document | None:
project_id = project.get("id")
case_id = case.get("id")
title = case.get("title", f"Case {case_id}")
case_key = f"C{case_id}" if case_id is not None else None
# Convert epoch seconds to aware datetime if available
updated = case.get("updated_on") or case.get("created_on")
updated_dt = (
datetime.fromtimestamp(updated, tz=timezone.utc) if isinstance(updated, (int, float)) else None
)
text_lines: list[str] = []
if case.get("title"):
text_lines.append(f"Title: {case['title']}")
if case_key:
text_lines.append(f"Case ID: {case_key}")
if case_id is not None:
text_lines.append(f"ID: {case_id}")
doc_link = case.get("custom_documentation_link")
if doc_link:
text_lines.append(f"Documentation: {doc_link}")
# Add fields that need value mapping
field_labels = self._get_field_labels()
for field_name in self.FIELDS_NEEDING_VALUE_MAPPING:
field_value = case.get(field_name)
if field_value is not None and field_value != "" and field_value != []:
mapped_value = self._map_field_value(field_name, field_value)
if mapped_value:
# Get label from TestRail field definition
label = field_labels.get(field_name, field_name.replace("_", " ").title())
text_lines.append(f"{label}: {mapped_value}")
pre = self._sanitize_rich_text(case.get("custom_preconds"))
if pre:
text_lines.append(f"Preconditions: {pre}")
# Steps: use separated steps format if available
steps_added = False
steps_separated = case.get("custom_steps_separated")
if isinstance(steps_separated, list) and steps_separated:
rendered_steps: list[str] = []
for idx, step_item in enumerate(steps_separated, start=1):
step_content = self._sanitize_rich_text(step_item.get("content"))
step_expected = self._sanitize_rich_text(step_item.get("expected"))
parts: list[str] = []
if step_content:
parts.append(f"Step {idx}: {step_content}")
else:
parts.append(f"Step {idx}:")
if step_expected:
parts.append(f"Expected: {step_expected}")
rendered_steps.append("\n".join(parts))
if rendered_steps:
text_lines.append("Steps:\n" + "\n".join(rendered_steps))
steps_added = True
# Fallback to custom_steps and custom_expected if no separated steps
if not steps_added:
custom_steps = self._sanitize_rich_text(case.get("custom_steps"))
custom_expected = self._sanitize_rich_text(case.get("custom_expected"))
if custom_steps:
text_lines.append(f"Steps: {custom_steps}")
if custom_expected:
text_lines.append(f"Expected: {custom_expected}")
link = self._build_case_link(project_id, case_id)
# Build full text and apply size policies
full_text = "\n".join(text_lines)
if len(full_text) > self.skip_doc_absolute_chars:
logger.warning(
f"Skipping TestRail case {case_id} due to excessive size: {len(full_text)} chars"
)
return None
# Metadata for document identification
metadata: dict[str, Any] = {}
if case_key:
metadata["case_key"] = case_key
# Include the human-friendly case key in identifiers for easier search
display_title = f"{case_key}: {title}" if case_key else title
return Document(
id=f"TESTRAIL_CASE_{case_id}",
source=DocumentSource.TESTRAIL,
semantic_identifier=display_title,
title=display_title,
sections=[TextSection(link=link, text=full_text)],
metadata=metadata,
doc_updated_at=updated_dt,
)
def _generate_documents(
self,
start: Optional[SecondsSinceUnixEpoch],
end: Optional[SecondsSinceUnixEpoch],
) -> GenerateDocumentsOutput:
if not self.base_url or not self.username or not self.api_key:
raise ConnectorMissingCredentialError("testrail")
doc_batch: list[Document] = []
projects = self._list_projects()
project_filter: list[int] | None = self.project_ids
for project in projects:
project_id = project.get("id")
# None = index all, [] = index none, [1,2,3] = index only those
if project_filter is not None and project_id not in project_filter:
continue
suites = self._list_suites(project_id)
if suites:
for s in suites:
suite_id = s.get("id")
for case in self._iter_cases(project_id, suite_id, start, end):
doc = self._doc_from_case(project, case, s)
if doc is None:
continue
doc_batch.append(doc)
if len(doc_batch) >= self.batch_size:
yield doc_batch
doc_batch = []
else:
# single-suite mode fallback
for case in self._iter_cases(project_id, None, start, end):
doc = self._doc_from_case(project, case, None)
if doc is None:
continue
doc_batch.append(doc)
if len(doc_batch) >= self.batch_size:
yield doc_batch
doc_batch = []
if doc_batch:
yield doc_batch
# ---- Onyx interfaces ----
def load_from_state(self) -> GenerateDocumentsOutput:
return self._generate_documents(start=None, end=None)
def poll_source(
self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch
) -> GenerateDocumentsOutput:
return self._generate_documents(start=start, end=end)
if __name__ == "__main__":
from onyx.configs.app_configs import (
TESTRAIL_API_KEY,
TESTRAIL_BASE_URL,
TESTRAIL_USERNAME,
)
connector = TestRailConnector()
connector.load_credentials(
{
"testrail_base_url": TESTRAIL_BASE_URL,
"testrail_username": TESTRAIL_USERNAME,
"testrail_api_key": TESTRAIL_API_KEY,
}
)
connector.validate_connector_settings()
# Probe a tiny batch from load
total = 0
for batch in connector.load_from_state():
print(f"Fetched batch: {len(batch)} docs")
total += len(batch)
if total >= 10:
break
print(f"Total fetched in test: {total}")

View File

@@ -160,3 +160,8 @@ def is_valid_email(text: str) -> bool:
def count_punctuation(text: str) -> int:
return sum(1 for char in text if char in string.punctuation)
def remove_markdown_image_references(text: str) -> str:
"""Remove markdown-style image references like ![alt text](url)"""
return re.sub(r'!\[[^\]]*\]\([^\)]+\)', '', text)

1
web/public/Testrail.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><title>Testrail SVG Icon</title><path fill="#65c179" d="M7.27 23.896L4.5 21.124a.35.35 0 0 1 0-.5l2.772-2.77a.35.35 0 0 1 .5 0l2.772 2.772a.35.35 0 0 1 0 .5l-2.772 2.77a.35.35 0 0 1-.5 0zm4.48-4.48l-2.772-2.772a.35.35 0 0 1 0-.498l2.772-2.772a.35.35 0 0 1 .5 0l2.77 2.772a.35.35 0 0 1 0 .5l-2.77 2.77a.35.35 0 0 1-.499 0zm4.48-4.48l-2.77-2.772a.35.35 0 0 1 0-.498l2.771-2.772a.35.35 0 0 1 .5 0l2.77 2.772a.35.35 0 0 1 0 .498l-2.772 2.772a.35.35 0 0 1-.5 0h.002zm-8.876.084l-2.772-2.77a.35.35 0 0 1 0-.499l2.772-2.773a.35.35 0 0 1 .5 0l2.772 2.772a.35.35 0 0 1 0 .498l-2.772 2.774a.35.35 0 0 1-.5 0zm4.48-4.48L9.062 7.77a.35.35 0 0 1 0-.5l2.772-2.772a.35.35 0 0 1 .5 0l2.77 2.772a.35.35 0 0 1 0 .498l-2.77 2.772a.35.35 0 0 1-.499 0v-.002v.001zM7.44 6.15L4.666 3.37a.35.35 0 0 1 0-.5L7.44.104a.35.35 0 0 1 .5 0l2.772 2.772a.35.35 0 0 1 0 .5L7.938 6.142a.35.35 0 0 1-.5 0l.002.006z"/></svg>

After

Width:  |  Height:  |  Size: 970 B

View File

@@ -98,6 +98,7 @@ import xenforoIcon from "../../../public/Xenforo.svg";
import zAIIcon from "../../../public/Z_AI.png";
import zendeskIcon from "../../../public/Zendesk.svg";
import zulipIcon from "../../../public/Zulip.png";
import testrailSVG from "../../../public/Testrail.svg";
import gitlabIcon from "../../../public/Gitlab.png";
import gmailIcon from "../../../public/Gmail.png";
@@ -2890,6 +2891,7 @@ export const NomicIcon = createLogoIcon(nomicSVG);
export const NotionIcon = createLogoIcon(notionIcon, { monochromatic: true });
export const OCIStorageIcon = createLogoIcon(OCIStorageSVG);
export const OllamaIcon = createLogoIcon(ollamaIcon);
export const TestRailIcon = createLogoIcon(testrailSVG);
export const OpenAIISVG = ({
size = 16,
className = defaultTailwindCSS,

View File

@@ -245,6 +245,45 @@ export const connectorConfigs: Record<
],
advanced_values: [],
},
testrail: {
description: "Configure TestRail connector",
values: [
{
type: "text",
label: "Project IDs",
name: "project_ids",
optional: true,
description:
"Comma-separated list of TestRail project IDs to index (e.g., 1 or 1,2,3). Leave empty to index all projects.",
},
],
advanced_values: [
{
type: "number",
label: "Cases Page Size",
name: "cases_page_size",
optional: true,
description:
"Number of test cases to fetch per page from the TestRail API (default: 250)",
},
{
type: "number",
label: "Max Pages",
name: "max_pages",
optional: true,
description:
"Maximum number of pages to fetch to prevent infinite loops (default: 10000)",
},
{
type: "number",
label: "Skip Document Character Limit",
name: "skip_doc_absolute_chars",
optional: true,
description:
"Skip indexing test cases that exceed this character limit (default: 200000)",
},
],
},
gitlab: {
description: "Configure GitLab connector",
values: [

View File

@@ -267,6 +267,12 @@ export interface ImapCredentialJson {
imap_password: string;
}
export interface TestRailCredentialJson {
testrail_base_url: string;
testrail_username: string;
testrail_api_key: string;
}
export const credentialTemplates: Record<ValidSources, any> = {
github: { github_access_token: "" } as GithubCredentialJson,
gitlab: {
@@ -464,6 +470,11 @@ export const credentialTemplates: Record<ValidSources, any> = {
imap_username: "",
imap_password: "",
} as ImapCredentialJson,
testrail: {
testrail_base_url: "",
testrail_username: "",
testrail_api_key: "",
} as TestRailCredentialJson,
};
export const credentialDisplayNames: Record<string, string> = {
@@ -558,6 +569,11 @@ export const credentialDisplayNames: Record<string, string> = {
imap_username: "IMAP Username",
imap_password: "IMAP Password",
// TestRail
testrail_base_url: "TestRail Base URL (e.g. https://yourcompany.testrail.io)",
testrail_username: "TestRail Username or Email",
testrail_api_key: "TestRail API Key",
// S3
aws_access_key_id: "AWS Access Key ID",
aws_secret_access_key: "AWS Secret Access Key",

View File

@@ -47,6 +47,7 @@ import {
GitbookIcon,
HighspotIcon,
EmailIcon,
TestRailIcon,
} from "@/components/icons/icons";
import { ValidSources } from "./types";
import { SourceCategory, SourceMetadata } from "./search/interfaces";
@@ -271,6 +272,11 @@ export const SOURCE_METADATA_MAP: SourceMap = {
category: SourceCategory.TicketingAndTaskManagement,
docs: "https://docs.onyx.app/admin/connectors/official/productboard",
},
testrail: {
icon: TestRailIcon,
displayName: "TestRail",
category: SourceCategory.TicketingAndTaskManagement,
},
// Messaging
slack: slackMetadata,

View File

@@ -502,6 +502,7 @@ export enum ValidSources {
Highspot = "highspot",
Imap = "imap",
Bitbucket = "bitbucket",
TestRail = "testrail",
// Federated Connectors
FederatedSlack = "federated_slack",