mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-04-08 16:32:43 +00:00
Compare commits
9 Commits
cli/v0.2.1
...
temp/pr-60
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3996727515 | ||
|
|
0979293bc6 | ||
|
|
763359b2a8 | ||
|
|
71ec663ca5 | ||
|
|
c7e9c996cb | ||
|
|
1e33ba7df5 | ||
|
|
a627f2f9b5 | ||
|
|
8a63358266 | ||
|
|
f1f5217da8 |
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
3
backend/onyx/connectors/testrail/__init__.py
Normal file
3
backend/onyx/connectors/testrail/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# Package marker for TestRail connector
|
||||
|
||||
|
||||
526
backend/onyx/connectors/testrail/connector.py
Normal file
526
backend/onyx/connectors/testrail/connector.py
Normal 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}")
|
||||
|
||||
@@ -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 """
|
||||
return re.sub(r'!\[[^\]]*\]\([^\)]+\)', '', text)
|
||||
|
||||
1
web/public/Testrail.svg
Normal file
1
web/public/Testrail.svg
Normal 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 |
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -502,6 +502,7 @@ export enum ValidSources {
|
||||
Highspot = "highspot",
|
||||
Imap = "imap",
|
||||
Bitbucket = "bitbucket",
|
||||
TestRail = "testrail",
|
||||
|
||||
// Federated Connectors
|
||||
FederatedSlack = "federated_slack",
|
||||
|
||||
Reference in New Issue
Block a user