mirror of
https://github.com/danswer-ai/danswer.git
synced 2025-09-20 13:05:49 +02:00
Product board connector (#228)
Also fixes misc mypy issues across the repo
This commit is contained in:
@@ -26,5 +26,6 @@ class DocumentSource(str, Enum):
|
|||||||
CONFLUENCE = "confluence"
|
CONFLUENCE = "confluence"
|
||||||
SLAB = "slab"
|
SLAB = "slab"
|
||||||
JIRA = "jira"
|
JIRA = "jira"
|
||||||
|
PRODUCTBOARD = "productboard"
|
||||||
FILE = "file"
|
FILE = "file"
|
||||||
NOTION = "notion"
|
NOTION = "notion"
|
||||||
|
@@ -1,13 +1,18 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
|
||||||
class BookStackClientRequestFailedError(ConnectionError):
|
class BookStackClientRequestFailedError(ConnectionError):
|
||||||
def __init__(self, status: int, error: str) -> None:
|
def __init__(self, status: int, error: str) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
"BookStack Client request failed with status {status}: {error}".format(status=status, error=error)
|
"BookStack Client request failed with status {status}: {error}".format(
|
||||||
|
status=status, error=error
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
class BookStackApiClient:
|
|
||||||
|
|
||||||
|
class BookStackApiClient:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
base_url: str,
|
base_url: str,
|
||||||
@@ -18,7 +23,7 @@ class BookStackApiClient:
|
|||||||
self.token_id = token_id
|
self.token_id = token_id
|
||||||
self.token_secret = token_secret
|
self.token_secret = token_secret
|
||||||
|
|
||||||
def get(self, endpoint: str, params: dict[str, str]):
|
def get(self, endpoint: str, params: dict[str, str]) -> dict[str, Any]:
|
||||||
url: str = self._build_url(endpoint)
|
url: str = self._build_url(endpoint)
|
||||||
headers = self._build_headers()
|
headers = self._build_headers()
|
||||||
response = requests.get(url, headers=headers, params=params)
|
response = requests.get(url, headers=headers, params=params)
|
||||||
@@ -38,15 +43,15 @@ class BookStackApiClient:
|
|||||||
|
|
||||||
return json
|
return json
|
||||||
|
|
||||||
def _build_headers(self):
|
def _build_headers(self) -> dict[str, str]:
|
||||||
auth = 'Token ' + self.token_id + ':' + self.token_secret
|
auth = "Token " + self.token_id + ":" + self.token_secret
|
||||||
return {
|
return {
|
||||||
'Authorization': auth,
|
"Authorization": auth,
|
||||||
'Accept': 'application/json',
|
"Accept": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
def _build_url(self, endpoint: str):
|
def _build_url(self, endpoint: str) -> str:
|
||||||
return self.base_url.rstrip('/') + '/api/' + endpoint.lstrip('/')
|
return self.base_url.rstrip("/") + "/api/" + endpoint.lstrip("/")
|
||||||
|
|
||||||
def build_app_url(self, endpoint: str):
|
def build_app_url(self, endpoint: str) -> str:
|
||||||
return self.base_url.rstrip('/') + '/' + endpoint.lstrip('/')
|
return self.base_url.rstrip("/") + "/" + endpoint.lstrip("/")
|
||||||
|
@@ -8,20 +8,18 @@ from bs4 import BeautifulSoup
|
|||||||
from danswer.configs.app_configs import INDEX_BATCH_SIZE
|
from danswer.configs.app_configs import INDEX_BATCH_SIZE
|
||||||
from danswer.configs.constants import DocumentSource
|
from danswer.configs.constants import DocumentSource
|
||||||
from danswer.configs.constants import HTML_SEPARATOR
|
from danswer.configs.constants import HTML_SEPARATOR
|
||||||
|
from danswer.connectors.bookstack.client import BookStackApiClient
|
||||||
from danswer.connectors.interfaces import GenerateDocumentsOutput
|
from danswer.connectors.interfaces import GenerateDocumentsOutput
|
||||||
from danswer.connectors.interfaces import LoadConnector
|
from danswer.connectors.interfaces import LoadConnector
|
||||||
from danswer.connectors.interfaces import PollConnector
|
from danswer.connectors.interfaces import PollConnector
|
||||||
from danswer.connectors.interfaces import SecondsSinceUnixEpoch
|
from danswer.connectors.interfaces import SecondsSinceUnixEpoch
|
||||||
from danswer.connectors.bookstack.client import BookStackApiClient
|
|
||||||
from danswer.connectors.models import Document
|
from danswer.connectors.models import Document
|
||||||
from danswer.connectors.models import Section
|
from danswer.connectors.models import Section
|
||||||
|
|
||||||
|
|
||||||
class BookstackClientNotSetUpError(PermissionError):
|
class BookstackClientNotSetUpError(PermissionError):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__(
|
super().__init__("BookStack Client is not set up, was load_credentials called?")
|
||||||
"BookStack Client is not set up, was load_credentials called?"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class BookstackConnector(LoadConnector, PollConnector):
|
class BookstackConnector(LoadConnector, PollConnector):
|
||||||
@@ -40,10 +38,12 @@ class BookstackConnector(LoadConnector, PollConnector):
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
def _get_doc_batch(
|
def _get_doc_batch(
|
||||||
self,
|
batch_size: int,
|
||||||
|
bookstack_client: BookStackApiClient,
|
||||||
endpoint: str,
|
endpoint: str,
|
||||||
transformer: Callable[[dict], Document],
|
transformer: Callable[[BookStackApiClient, dict], Document],
|
||||||
start_ind: int,
|
start_ind: int,
|
||||||
start: SecondsSinceUnixEpoch | None = None,
|
start: SecondsSinceUnixEpoch | None = None,
|
||||||
end: SecondsSinceUnixEpoch | None = None,
|
end: SecondsSinceUnixEpoch | None = None,
|
||||||
@@ -51,70 +51,90 @@ class BookstackConnector(LoadConnector, PollConnector):
|
|||||||
doc_batch: list[Document] = []
|
doc_batch: list[Document] = []
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
"count": str(self.batch_size),
|
"count": str(batch_size),
|
||||||
"offset": str(start_ind),
|
"offset": str(start_ind),
|
||||||
"sort": "+id"
|
"sort": "+id",
|
||||||
}
|
}
|
||||||
|
|
||||||
if start:
|
if start:
|
||||||
params["filter[updated_at:gte]"] = datetime.utcfromtimestamp(start).strftime('%Y-%m-%d %H:%M:%S')
|
params["filter[updated_at:gte]"] = datetime.utcfromtimestamp(
|
||||||
|
start
|
||||||
|
).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
if end:
|
if end:
|
||||||
params["filter[updated_at:lte]"] = datetime.utcfromtimestamp(end).strftime('%Y-%m-%d %H:%M:%S')
|
params["filter[updated_at:lte]"] = datetime.utcfromtimestamp(end).strftime(
|
||||||
|
"%Y-%m-%d %H:%M:%S"
|
||||||
|
)
|
||||||
|
|
||||||
batch = self.bookstack_client.get(endpoint, params=params).get("data", [])
|
batch = bookstack_client.get(endpoint, params=params).get("data", [])
|
||||||
for item in batch:
|
for item in batch:
|
||||||
doc_batch.append(transformer(item))
|
doc_batch.append(transformer(bookstack_client, item))
|
||||||
|
|
||||||
return doc_batch, len(batch)
|
return doc_batch, len(batch)
|
||||||
|
|
||||||
def _book_to_document(self, book: dict):
|
@staticmethod
|
||||||
url = self.bookstack_client.build_app_url("/books/" + book.get("slug"))
|
def _book_to_document(
|
||||||
|
bookstack_client: BookStackApiClient, book: dict[str, Any]
|
||||||
|
) -> Document:
|
||||||
|
url = bookstack_client.build_app_url("/books/" + str(book.get("slug")))
|
||||||
text = book.get("name", "") + "\n" + book.get("description", "")
|
text = book.get("name", "") + "\n" + book.get("description", "")
|
||||||
return Document(
|
return Document(
|
||||||
id="book:" + str(book.get("id")),
|
id="book:" + str(book.get("id")),
|
||||||
sections=[Section(link=url, text=text)],
|
sections=[Section(link=url, text=text)],
|
||||||
source=DocumentSource.BOOKSTACK,
|
source=DocumentSource.BOOKSTACK,
|
||||||
semantic_identifier="Book: " + book.get("name"),
|
semantic_identifier="Book: " + str(book.get("name")),
|
||||||
metadata={
|
metadata={"type": "book", "updated_at": str(book.get("updated_at"))},
|
||||||
"type": "book",
|
|
||||||
"updated_at": book.get("updated_at")
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _chapter_to_document(self, chapter: dict):
|
@staticmethod
|
||||||
url = self.bookstack_client.build_app_url("/books/" + chapter.get("book_slug") + "/chapter/" + chapter.get("slug"))
|
def _chapter_to_document(
|
||||||
|
bookstack_client: BookStackApiClient, chapter: dict[str, Any]
|
||||||
|
) -> Document:
|
||||||
|
url = bookstack_client.build_app_url(
|
||||||
|
"/books/"
|
||||||
|
+ str(chapter.get("book_slug"))
|
||||||
|
+ "/chapter/"
|
||||||
|
+ str(chapter.get("slug"))
|
||||||
|
)
|
||||||
text = chapter.get("name", "") + "\n" + chapter.get("description", "")
|
text = chapter.get("name", "") + "\n" + chapter.get("description", "")
|
||||||
return Document(
|
return Document(
|
||||||
id="chapter:" + str(chapter.get("id")),
|
id="chapter:" + str(chapter.get("id")),
|
||||||
sections=[Section(link=url, text=text)],
|
sections=[Section(link=url, text=text)],
|
||||||
source=DocumentSource.BOOKSTACK,
|
source=DocumentSource.BOOKSTACK,
|
||||||
semantic_identifier="Chapter: " + chapter.get("name"),
|
semantic_identifier="Chapter: " + str(chapter.get("name")),
|
||||||
metadata={
|
metadata={"type": "chapter", "updated_at": str(chapter.get("updated_at"))},
|
||||||
"type": "chapter",
|
|
||||||
"updated_at": chapter.get("updated_at")
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _shelf_to_document(self, shelf: dict):
|
@staticmethod
|
||||||
url = self.bookstack_client.build_app_url("/shelves/" + shelf.get("slug"))
|
def _shelf_to_document(
|
||||||
|
bookstack_client: BookStackApiClient, shelf: dict[str, Any]
|
||||||
|
) -> Document:
|
||||||
|
url = bookstack_client.build_app_url("/shelves/" + str(shelf.get("slug")))
|
||||||
text = shelf.get("name", "") + "\n" + shelf.get("description", "")
|
text = shelf.get("name", "") + "\n" + shelf.get("description", "")
|
||||||
return Document(
|
return Document(
|
||||||
id="shelf:" + str(shelf.get("id")),
|
id="shelf:" + str(shelf.get("id")),
|
||||||
sections=[Section(link=url, text=text)],
|
sections=[Section(link=url, text=text)],
|
||||||
source=DocumentSource.BOOKSTACK,
|
source=DocumentSource.BOOKSTACK,
|
||||||
semantic_identifier="Shelf: " + shelf.get("name"),
|
semantic_identifier="Shelf: " + str(shelf.get("name")),
|
||||||
metadata={
|
metadata={"type": "shelf", "updated_at": shelf.get("updated_at")},
|
||||||
"type": "shelf",
|
|
||||||
"updated_at": shelf.get("updated_at")
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _page_to_document(self, page: dict):
|
@staticmethod
|
||||||
|
def _page_to_document(
|
||||||
|
bookstack_client: BookStackApiClient, page: dict[str, Any]
|
||||||
|
) -> Document:
|
||||||
page_id = str(page.get("id"))
|
page_id = str(page.get("id"))
|
||||||
page_data = self.bookstack_client.get("/pages/" + page_id, {})
|
page_name = str(page.get("name"))
|
||||||
url = self.bookstack_client.build_app_url("/books/" + page.get("book_slug") + "/page/" + page_data.get("slug"))
|
page_data = bookstack_client.get("/pages/" + page_id, {})
|
||||||
page_html = "<h1>" + html.escape(page_data.get("name")) + "</h1>" + page_data.get("html")
|
url = bookstack_client.build_app_url(
|
||||||
|
"/books/"
|
||||||
|
+ str(page.get("book_slug"))
|
||||||
|
+ "/page/"
|
||||||
|
+ str(page_data.get("slug"))
|
||||||
|
)
|
||||||
|
page_html = (
|
||||||
|
"<h1>" + html.escape(page_name) + "</h1>" + str(page_data.get("html"))
|
||||||
|
)
|
||||||
soup = BeautifulSoup(page_html, "html.parser")
|
soup = BeautifulSoup(page_html, "html.parser")
|
||||||
text = soup.get_text(HTML_SEPARATOR)
|
text = soup.get_text(HTML_SEPARATOR)
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
@@ -122,11 +142,8 @@ class BookstackConnector(LoadConnector, PollConnector):
|
|||||||
id="page:" + page_id,
|
id="page:" + page_id,
|
||||||
sections=[Section(link=url, text=text)],
|
sections=[Section(link=url, text=text)],
|
||||||
source=DocumentSource.BOOKSTACK,
|
source=DocumentSource.BOOKSTACK,
|
||||||
semantic_identifier="Page: " + page_data.get("name"),
|
semantic_identifier="Page: " + str(page_name),
|
||||||
metadata={
|
metadata={"type": "page", "updated_at": page_data.get("updated_at")},
|
||||||
"type": "page",
|
|
||||||
"updated_at": page_data.get("updated_at")
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def load_from_state(self) -> GenerateDocumentsOutput:
|
def load_from_state(self) -> GenerateDocumentsOutput:
|
||||||
@@ -141,7 +158,9 @@ class BookstackConnector(LoadConnector, PollConnector):
|
|||||||
if self.bookstack_client is None:
|
if self.bookstack_client is None:
|
||||||
raise BookstackClientNotSetUpError()
|
raise BookstackClientNotSetUpError()
|
||||||
|
|
||||||
transform_by_endpoint: dict[str, Callable[[dict], Document]] = {
|
transform_by_endpoint: dict[
|
||||||
|
str, Callable[[BookStackApiClient, dict], Document]
|
||||||
|
] = {
|
||||||
"/books": self._book_to_document,
|
"/books": self._book_to_document,
|
||||||
"/chapters": self._chapter_to_document,
|
"/chapters": self._chapter_to_document,
|
||||||
"/shelves": self._shelf_to_document,
|
"/shelves": self._shelf_to_document,
|
||||||
@@ -151,7 +170,15 @@ class BookstackConnector(LoadConnector, PollConnector):
|
|||||||
for endpoint, transform in transform_by_endpoint.items():
|
for endpoint, transform in transform_by_endpoint.items():
|
||||||
start_ind = 0
|
start_ind = 0
|
||||||
while True:
|
while True:
|
||||||
doc_batch, num_results = self._get_doc_batch(endpoint, transform, start_ind, start, end)
|
doc_batch, num_results = self._get_doc_batch(
|
||||||
|
batch_size=self.batch_size,
|
||||||
|
bookstack_client=self.bookstack_client,
|
||||||
|
endpoint=endpoint,
|
||||||
|
transformer=transform,
|
||||||
|
start_ind=start_ind,
|
||||||
|
start=start,
|
||||||
|
end=end,
|
||||||
|
)
|
||||||
start_ind += num_results
|
start_ind += num_results
|
||||||
if doc_batch:
|
if doc_batch:
|
||||||
yield doc_batch
|
yield doc_batch
|
||||||
|
@@ -14,6 +14,7 @@ from danswer.connectors.interfaces import EventConnector
|
|||||||
from danswer.connectors.interfaces import LoadConnector
|
from danswer.connectors.interfaces import LoadConnector
|
||||||
from danswer.connectors.interfaces import PollConnector
|
from danswer.connectors.interfaces import PollConnector
|
||||||
from danswer.connectors.models import InputType
|
from danswer.connectors.models import InputType
|
||||||
|
from danswer.connectors.productboard.connector import ProductboardConnector
|
||||||
from danswer.connectors.slab.connector import SlabConnector
|
from danswer.connectors.slab.connector import SlabConnector
|
||||||
from danswer.connectors.slack.connector import SlackLoadConnector
|
from danswer.connectors.slack.connector import SlackLoadConnector
|
||||||
from danswer.connectors.slack.connector import SlackPollConnector
|
from danswer.connectors.slack.connector import SlackPollConnector
|
||||||
@@ -42,6 +43,7 @@ def identify_connector_class(
|
|||||||
DocumentSource.BOOKSTACK: BookstackConnector,
|
DocumentSource.BOOKSTACK: BookstackConnector,
|
||||||
DocumentSource.CONFLUENCE: ConfluenceConnector,
|
DocumentSource.CONFLUENCE: ConfluenceConnector,
|
||||||
DocumentSource.JIRA: JiraConnector,
|
DocumentSource.JIRA: JiraConnector,
|
||||||
|
DocumentSource.PRODUCTBOARD: ProductboardConnector,
|
||||||
DocumentSource.SLAB: SlabConnector,
|
DocumentSource.SLAB: SlabConnector,
|
||||||
DocumentSource.NOTION: NotionConnector,
|
DocumentSource.NOTION: NotionConnector,
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import io
|
import io
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
|
from collections.abc import Sequence
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -170,11 +171,12 @@ class GoogleDriveConnector(LoadConnector, PollConnector):
|
|||||||
folder_names = path.split("/")
|
folder_names = path.split("/")
|
||||||
parent_id = "root"
|
parent_id = "root"
|
||||||
for folder_name in folder_names:
|
for folder_name in folder_names:
|
||||||
parent_id = get_folder_id(
|
found_parent_id = get_folder_id(
|
||||||
service=service, parent_id=parent_id, folder_name=folder_name
|
service=service, parent_id=parent_id, folder_name=folder_name
|
||||||
)
|
)
|
||||||
if parent_id is None:
|
if found_parent_id is None:
|
||||||
raise ValueError(f"Folder path '{path}' not found in Google Drive")
|
raise ValueError(f"Folder path '{path}' not found in Google Drive")
|
||||||
|
parent_id = found_parent_id
|
||||||
folder_ids.append(parent_id)
|
folder_ids.append(parent_id)
|
||||||
|
|
||||||
return folder_ids
|
return folder_ids
|
||||||
@@ -199,7 +201,9 @@ class GoogleDriveConnector(LoadConnector, PollConnector):
|
|||||||
raise PermissionError("Not logged into Google Drive")
|
raise PermissionError("Not logged into Google Drive")
|
||||||
|
|
||||||
service = discovery.build("drive", "v3", credentials=self.creds)
|
service = discovery.build("drive", "v3", credentials=self.creds)
|
||||||
folder_ids = self._process_folder_paths(service, self.folder_paths)
|
folder_ids: Sequence[str | None] = self._process_folder_paths(
|
||||||
|
service, self.folder_paths
|
||||||
|
)
|
||||||
if not folder_ids:
|
if not folder_ids:
|
||||||
folder_ids = [None]
|
folder_ids = [None]
|
||||||
|
|
||||||
|
@@ -18,7 +18,7 @@ class Document:
|
|||||||
sections: list[Section]
|
sections: list[Section]
|
||||||
source: DocumentSource
|
source: DocumentSource
|
||||||
semantic_identifier: str | None
|
semantic_identifier: str | None
|
||||||
metadata: dict[str, Any] | None
|
metadata: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
class InputType(str, Enum):
|
class InputType(str, Enum):
|
||||||
|
@@ -1,10 +1,13 @@
|
|||||||
"""Notion reader."""
|
"""Notion reader."""
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, fields
|
from dataclasses import dataclass
|
||||||
from typing import Any, Dict, List, Optional
|
from dataclasses import fields
|
||||||
|
from typing import Any
|
||||||
|
from typing import Dict
|
||||||
|
from typing import List
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from danswer.configs.app_configs import INDEX_BATCH_SIZE
|
from danswer.configs.app_configs import INDEX_BATCH_SIZE
|
||||||
from danswer.configs.constants import DocumentSource
|
from danswer.configs.constants import DocumentSource
|
||||||
from danswer.connectors.interfaces import GenerateDocumentsOutput
|
from danswer.connectors.interfaces import GenerateDocumentsOutput
|
||||||
@@ -26,7 +29,7 @@ class NotionPage:
|
|||||||
properties: Dict[str, Any]
|
properties: Dict[str, Any]
|
||||||
url: str
|
url: str
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs: dict[str, Any]) -> None:
|
||||||
names = set([f.name for f in fields(self)])
|
names = set([f.name for f in fields(self)])
|
||||||
for k, v in kwargs.items():
|
for k, v in kwargs.items():
|
||||||
if k in names:
|
if k in names:
|
||||||
@@ -41,7 +44,7 @@ class NotionSearchResponse:
|
|||||||
next_cursor: Optional[str]
|
next_cursor: Optional[str]
|
||||||
has_more: bool = False
|
has_more: bool = False
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs: dict[str, Any]) -> None:
|
||||||
names = set([f.name for f in fields(self)])
|
names = set([f.name for f in fields(self)])
|
||||||
for k, v in kwargs.items():
|
for k, v in kwargs.items():
|
||||||
if k in names:
|
if k in names:
|
||||||
|
0
backend/danswer/connectors/productboard/__init__.py
Normal file
0
backend/danswer/connectors/productboard/__init__.py
Normal file
253
backend/danswer/connectors/productboard/connector.py
Normal file
253
backend/danswer/connectors/productboard/connector.py
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
from collections.abc import Generator
|
||||||
|
from itertools import chain
|
||||||
|
from typing import Any
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from danswer.configs.app_configs import INDEX_BATCH_SIZE
|
||||||
|
from danswer.configs.constants import DocumentSource
|
||||||
|
from danswer.connectors.interfaces import GenerateDocumentsOutput
|
||||||
|
from danswer.connectors.interfaces import PollConnector
|
||||||
|
from danswer.connectors.interfaces import SecondsSinceUnixEpoch
|
||||||
|
from danswer.connectors.models import Document
|
||||||
|
from danswer.connectors.models import Section
|
||||||
|
from danswer.utils.logger import setup_logger
|
||||||
|
from dateutil import parser
|
||||||
|
from retry import retry
|
||||||
|
|
||||||
|
|
||||||
|
logger = setup_logger()
|
||||||
|
|
||||||
|
|
||||||
|
_PRODUCT_BOARD_BASE_URL = "https://api.productboard.com"
|
||||||
|
|
||||||
|
|
||||||
|
class ProductboardApiError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ProductboardConnector(PollConnector):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
batch_size: int = INDEX_BATCH_SIZE,
|
||||||
|
) -> None:
|
||||||
|
self.batch_size = batch_size
|
||||||
|
self.access_token: str | None = None
|
||||||
|
|
||||||
|
def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
self.access_token = credentials["productboard_access_token"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _build_headers(self) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {self.access_token}",
|
||||||
|
"X-Version": "1",
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_description_html(description_html: str) -> str:
|
||||||
|
soup = BeautifulSoup(description_html, "html.parser")
|
||||||
|
return soup.get_text()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_owner_email(productboard_obj: dict[str, Any]) -> str | None:
|
||||||
|
owner_dict = cast(dict[str, str] | None, productboard_obj.get("owner"))
|
||||||
|
if not owner_dict:
|
||||||
|
return None
|
||||||
|
return owner_dict.get("email")
|
||||||
|
|
||||||
|
def _fetch_documents(
|
||||||
|
self,
|
||||||
|
initial_link: str,
|
||||||
|
) -> Generator[dict[str, Any], None, None]:
|
||||||
|
headers = self._build_headers()
|
||||||
|
|
||||||
|
@retry(tries=3, delay=1, backoff=2)
|
||||||
|
def fetch(link: str) -> dict[str, Any]:
|
||||||
|
response = requests.get(link, headers=headers)
|
||||||
|
if not response.ok:
|
||||||
|
# rate-limiting is at 50 requests per second.
|
||||||
|
# The delay in this retry should handle this while this is
|
||||||
|
# not parallelized.
|
||||||
|
raise ProductboardApiError(
|
||||||
|
"Failed to fetch from productboard - status code:"
|
||||||
|
f" {response.status_code} - response: {response.text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
curr_link = initial_link
|
||||||
|
while True:
|
||||||
|
response_json = fetch(curr_link)
|
||||||
|
for entity in response_json["data"]:
|
||||||
|
yield entity
|
||||||
|
|
||||||
|
curr_link = response_json.get("links", {}).get("next")
|
||||||
|
if not curr_link:
|
||||||
|
break
|
||||||
|
|
||||||
|
def _get_features(self) -> Generator[Document, None, None]:
|
||||||
|
"""A Feature is like a ticket in Jira"""
|
||||||
|
for feature in self._fetch_documents(
|
||||||
|
initial_link=f"{_PRODUCT_BOARD_BASE_URL}/features"
|
||||||
|
):
|
||||||
|
yield Document(
|
||||||
|
id=feature["id"],
|
||||||
|
sections=[
|
||||||
|
Section(
|
||||||
|
link=feature["links"]["html"],
|
||||||
|
text=" - ".join(
|
||||||
|
(
|
||||||
|
feature["name"],
|
||||||
|
self._parse_description_html(feature["description"]),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
semantic_identifier=feature["name"],
|
||||||
|
source=DocumentSource.PRODUCTBOARD,
|
||||||
|
metadata={
|
||||||
|
"productboard_entity_type": feature["type"],
|
||||||
|
"status": feature["status"]["name"],
|
||||||
|
"owner": self._get_owner_email(feature),
|
||||||
|
"updated_at": feature["updatedAt"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_components(self) -> Generator[Document, None, None]:
|
||||||
|
"""A Component is like an epic in Jira. It contains Features"""
|
||||||
|
for component in self._fetch_documents(
|
||||||
|
initial_link=f"{_PRODUCT_BOARD_BASE_URL}/components"
|
||||||
|
):
|
||||||
|
yield Document(
|
||||||
|
id=component["id"],
|
||||||
|
sections=[
|
||||||
|
Section(
|
||||||
|
link=component["links"]["html"],
|
||||||
|
text=" - ".join(
|
||||||
|
(
|
||||||
|
component["name"],
|
||||||
|
self._parse_description_html(component["description"]),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
semantic_identifier=component["name"],
|
||||||
|
source=DocumentSource.PRODUCTBOARD,
|
||||||
|
metadata={
|
||||||
|
"productboard_entity_type": "component",
|
||||||
|
"owner": self._get_owner_email(component),
|
||||||
|
"updated_at": component["updatedAt"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_products(self) -> Generator[Document, None, None]:
|
||||||
|
"""A Product is the highest level of organization.
|
||||||
|
A Product contains components, which contains features."""
|
||||||
|
for product in self._fetch_documents(
|
||||||
|
initial_link=f"{_PRODUCT_BOARD_BASE_URL}/products"
|
||||||
|
):
|
||||||
|
yield Document(
|
||||||
|
id=product["id"],
|
||||||
|
sections=[
|
||||||
|
Section(
|
||||||
|
link=product["links"]["html"],
|
||||||
|
text=" - ".join(
|
||||||
|
(
|
||||||
|
product["name"],
|
||||||
|
self._parse_description_html(product["description"]),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
semantic_identifier=product["name"],
|
||||||
|
source=DocumentSource.PRODUCTBOARD,
|
||||||
|
metadata={
|
||||||
|
"productboard_entity_type": "product",
|
||||||
|
"owner": self._get_owner_email(product),
|
||||||
|
"updated_at": product["updatedAt"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_objectives(self) -> Generator[Document, None, None]:
|
||||||
|
for objective in self._fetch_documents(
|
||||||
|
initial_link=f"{_PRODUCT_BOARD_BASE_URL}/objectives"
|
||||||
|
):
|
||||||
|
yield Document(
|
||||||
|
id=objective["id"],
|
||||||
|
sections=[
|
||||||
|
Section(
|
||||||
|
link=objective["links"]["html"],
|
||||||
|
text=" - ".join(
|
||||||
|
(
|
||||||
|
objective["name"],
|
||||||
|
self._parse_description_html(objective["description"]),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
semantic_identifier=objective["name"],
|
||||||
|
source=DocumentSource.PRODUCTBOARD,
|
||||||
|
metadata={
|
||||||
|
"productboard_entity_type": "release",
|
||||||
|
"state": objective["state"],
|
||||||
|
"owner": self._get_owner_email(objective),
|
||||||
|
"updated_at": objective["updatedAt"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _is_updated_at_out_of_time_range(
|
||||||
|
self,
|
||||||
|
document: Document,
|
||||||
|
start: SecondsSinceUnixEpoch,
|
||||||
|
end: SecondsSinceUnixEpoch,
|
||||||
|
) -> bool:
|
||||||
|
updated_at = cast(str, document.metadata.get("updated_at", ""))
|
||||||
|
if updated_at:
|
||||||
|
updated_at_datetime = parser.parse(updated_at)
|
||||||
|
if (
|
||||||
|
updated_at_datetime.timestamp() < start
|
||||||
|
or updated_at_datetime.timestamp() > end
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error(f"Unable to find updated_at for document '{document.id}'")
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def poll_source(
|
||||||
|
self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch
|
||||||
|
) -> GenerateDocumentsOutput:
|
||||||
|
if self.access_token is None:
|
||||||
|
raise PermissionError(
|
||||||
|
"Access token is not set up, was load_credentials called?"
|
||||||
|
)
|
||||||
|
|
||||||
|
document_batch: list[Document] = []
|
||||||
|
|
||||||
|
# NOTE: there is a concept of a "Note" in productboard, however
|
||||||
|
# there is no read API for it atm. Additionally, comments are not
|
||||||
|
# included with features. Finally, "Releases" are not fetched atm,
|
||||||
|
# since they do not provide an updatedAt.
|
||||||
|
feature_documents = self._get_features()
|
||||||
|
component_documents = self._get_components()
|
||||||
|
product_documents = self._get_products()
|
||||||
|
objective_documents = self._get_objectives()
|
||||||
|
for document in chain(
|
||||||
|
feature_documents,
|
||||||
|
component_documents,
|
||||||
|
product_documents,
|
||||||
|
objective_documents,
|
||||||
|
):
|
||||||
|
# skip documents that are not in the time range
|
||||||
|
if self._is_updated_at_out_of_time_range(document, start, end):
|
||||||
|
continue
|
||||||
|
|
||||||
|
document_batch.append(document)
|
||||||
|
if len(document_batch) >= self.batch_size:
|
||||||
|
yield document_batch
|
||||||
|
document_batch = []
|
||||||
|
|
||||||
|
if document_batch:
|
||||||
|
yield document_batch
|
@@ -60,6 +60,6 @@ def log_generator_function_time(
|
|||||||
f"{func_name or func.__name__} took {time.time() - start_time} seconds"
|
f"{func_name or func.__name__} took {time.time() - start_time} seconds"
|
||||||
)
|
)
|
||||||
|
|
||||||
return cast(F, wrapped_func)
|
return cast(FG, wrapped_func)
|
||||||
|
|
||||||
return timing_wrapper
|
return timing_wrapper
|
||||||
|
239
web/src/app/admin/connectors/productboard/page.tsx
Normal file
239
web/src/app/admin/connectors/productboard/page.tsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as Yup from "yup";
|
||||||
|
import { ProductboardIcon, TrashIcon } from "@/components/icons/icons";
|
||||||
|
import { TextFormField } from "@/components/admin/connectors/Field";
|
||||||
|
import { HealthCheckBanner } from "@/components/health/healthcheck";
|
||||||
|
import { CredentialForm } from "@/components/admin/connectors/CredentialForm";
|
||||||
|
import {
|
||||||
|
Credential,
|
||||||
|
ProductboardConfig,
|
||||||
|
ConnectorIndexingStatus,
|
||||||
|
ProductboardCredentialJson,
|
||||||
|
} from "@/lib/types";
|
||||||
|
import useSWR, { useSWRConfig } from "swr";
|
||||||
|
import { fetcher } from "@/lib/fetcher";
|
||||||
|
import { LoadingAnimation } from "@/components/Loading";
|
||||||
|
import { deleteCredential, linkCredential } from "@/lib/credential";
|
||||||
|
import { ConnectorForm } from "@/components/admin/connectors/ConnectorForm";
|
||||||
|
import { ConnectorsTable } from "@/components/admin/connectors/table/ConnectorsTable";
|
||||||
|
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||||
|
|
||||||
|
const Main = () => {
|
||||||
|
const { popup, setPopup } = usePopup();
|
||||||
|
|
||||||
|
const { mutate } = useSWRConfig();
|
||||||
|
const {
|
||||||
|
data: connectorIndexingStatuses,
|
||||||
|
isLoading: isConnectorIndexingStatusesLoading,
|
||||||
|
error: isConnectorIndexingStatusesError,
|
||||||
|
} = useSWR<ConnectorIndexingStatus<any>[]>(
|
||||||
|
"/api/manage/admin/connector/indexing-status",
|
||||||
|
fetcher
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
data: credentialsData,
|
||||||
|
isLoading: isCredentialsLoading,
|
||||||
|
isValidating: isCredentialsValidating,
|
||||||
|
error: isCredentialsError,
|
||||||
|
} = useSWR<Credential<any>[]>("/api/manage/credential", fetcher);
|
||||||
|
|
||||||
|
if (
|
||||||
|
isConnectorIndexingStatusesLoading ||
|
||||||
|
isCredentialsLoading ||
|
||||||
|
isCredentialsValidating
|
||||||
|
) {
|
||||||
|
return <LoadingAnimation text="Loading" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isConnectorIndexingStatusesError || !connectorIndexingStatuses) {
|
||||||
|
return <div>Failed to load connectors</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCredentialsError || !credentialsData) {
|
||||||
|
return <div>Failed to load credentials</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const productboardConnectorIndexingStatuses =
|
||||||
|
connectorIndexingStatuses.filter(
|
||||||
|
(connectorIndexingStatus) =>
|
||||||
|
connectorIndexingStatus.connector.source === "productboard"
|
||||||
|
);
|
||||||
|
const productboardCredential = credentialsData.filter(
|
||||||
|
(credential) => credential.credential_json?.productboard_access_token
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{popup}
|
||||||
|
<p className="text-sm">
|
||||||
|
This connector allows you to sync all your <i>Features</i>,{" "}
|
||||||
|
<i>Components</i>, <i>Products</i>, and <i>Objectives</i> from
|
||||||
|
Productboard into Danswer. At this time, the Productboard APIs does not
|
||||||
|
support pulling in <i>Releases</i> or <i>Notes</i>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="font-bold mb-2 mt-6 ml-auto mr-auto">
|
||||||
|
Step 1: Provide your Credentials
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{productboardCredential ? (
|
||||||
|
<>
|
||||||
|
<div className="flex mb-1 text-sm">
|
||||||
|
<p className="my-auto">Existing Access Token: </p>
|
||||||
|
<p className="ml-1 italic my-auto max-w-md truncate">
|
||||||
|
{
|
||||||
|
productboardCredential.credential_json
|
||||||
|
?.productboard_access_token
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="ml-1 hover:bg-gray-700 rounded-full p-1"
|
||||||
|
onClick={async () => {
|
||||||
|
if (productboardConnectorIndexingStatuses.length > 0) {
|
||||||
|
setPopup({
|
||||||
|
type: "error",
|
||||||
|
message:
|
||||||
|
"Must delete all connectors before deleting credentials",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await deleteCredential(productboardCredential.id);
|
||||||
|
mutate("/api/manage/credential");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm">
|
||||||
|
To use the Productboard connector, first follow the guide{" "}
|
||||||
|
<a
|
||||||
|
className="text-blue-500"
|
||||||
|
href="https://developer.productboard.com/#section/Authentication/Public-API-Access-Token"
|
||||||
|
>
|
||||||
|
here
|
||||||
|
</a>{" "}
|
||||||
|
to generate an Access Token.
|
||||||
|
</p>
|
||||||
|
<div className="border-solid border-gray-600 border rounded-md p-6 mt-2">
|
||||||
|
<CredentialForm<ProductboardCredentialJson>
|
||||||
|
formBody={
|
||||||
|
<>
|
||||||
|
<TextFormField
|
||||||
|
name="productboard_access_token"
|
||||||
|
label="Access Token:"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
validationSchema={Yup.object().shape({
|
||||||
|
productboard_access_token: Yup.string().required(
|
||||||
|
"Please enter your Productboard access token"
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
initialValues={{
|
||||||
|
productboard_access_token: "",
|
||||||
|
}}
|
||||||
|
onSubmit={(isSuccess) => {
|
||||||
|
if (isSuccess) {
|
||||||
|
mutate("/api/manage/credential");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h2 className="font-bold mb-2 mt-6 ml-auto mr-auto">
|
||||||
|
Step 2: Start indexing!
|
||||||
|
</h2>
|
||||||
|
{productboardCredential ? (
|
||||||
|
!productboardConnectorIndexingStatuses.length ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm mb-2">
|
||||||
|
Click the button below to start indexing! We will pull the latest
|
||||||
|
features, components, and products from Productboard every{" "}
|
||||||
|
<b>10</b> minutes.
|
||||||
|
</p>
|
||||||
|
<div className="flex">
|
||||||
|
<ConnectorForm<ProductboardConfig>
|
||||||
|
nameBuilder={() => "ProductboardConnector"}
|
||||||
|
source="productboard"
|
||||||
|
inputType="poll"
|
||||||
|
formBody={null}
|
||||||
|
validationSchema={Yup.object().shape({})}
|
||||||
|
initialValues={{}}
|
||||||
|
refreshFreq={10 * 60} // 10 minutes
|
||||||
|
onSubmit={async (isSuccess, responseJson) => {
|
||||||
|
if (isSuccess && responseJson) {
|
||||||
|
await linkCredential(
|
||||||
|
responseJson.id,
|
||||||
|
productboardCredential.id
|
||||||
|
);
|
||||||
|
mutate("/api/manage/admin/connector/indexing-status");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm mb-2">
|
||||||
|
Productboard connector is setup! We are pulling the latest
|
||||||
|
features, components, and products from Productboard every{" "}
|
||||||
|
<b>10</b> minutes.
|
||||||
|
</p>
|
||||||
|
<ConnectorsTable<ProductboardConfig, ProductboardCredentialJson>
|
||||||
|
connectorIndexingStatuses={productboardConnectorIndexingStatuses}
|
||||||
|
liveCredential={productboardCredential}
|
||||||
|
getCredential={(credential) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
{credential.credential_json.productboard_access_token}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onCredentialLink={async (connectorId) => {
|
||||||
|
if (productboardCredential) {
|
||||||
|
await linkCredential(connectorId, productboardCredential.id);
|
||||||
|
mutate("/api/manage/admin/connector/indexing-status");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onUpdate={() =>
|
||||||
|
mutate("/api/manage/admin/connector/indexing-status")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm">
|
||||||
|
Please provide your access token in Step 1 first! Once done with
|
||||||
|
that, you can then start indexing all your Productboard features,
|
||||||
|
components, and products.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto container">
|
||||||
|
<div className="mb-4">
|
||||||
|
<HealthCheckBanner />
|
||||||
|
</div>
|
||||||
|
<div className="border-solid border-gray-600 border-b mb-4 pb-2 flex">
|
||||||
|
<ProductboardIcon size="32" />
|
||||||
|
<h1 className="text-3xl font-bold pl-2">Productboard</h1>
|
||||||
|
</div>
|
||||||
|
<Main />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -13,6 +13,7 @@ import {
|
|||||||
JiraIcon,
|
JiraIcon,
|
||||||
SlabIcon,
|
SlabIcon,
|
||||||
NotionIcon,
|
NotionIcon,
|
||||||
|
ProductboardIcon,
|
||||||
} from "@/components/icons/icons";
|
} from "@/components/icons/icons";
|
||||||
import { DISABLE_AUTH } from "@/lib/constants";
|
import { DISABLE_AUTH } from "@/lib/constants";
|
||||||
import { getCurrentUserSS } from "@/lib/userSS";
|
import { getCurrentUserSS } from "@/lib/userSS";
|
||||||
@@ -112,6 +113,15 @@ export default async function AdminLayout({
|
|||||||
),
|
),
|
||||||
link: "/admin/connectors/jira",
|
link: "/admin/connectors/jira",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: (
|
||||||
|
<div className="flex">
|
||||||
|
<ProductboardIcon size="16" />
|
||||||
|
<div className="ml-1">Productboard</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
link: "/admin/connectors/productboard",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: (
|
name: (
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
|
@@ -9,7 +9,6 @@ import {
|
|||||||
LinkBreak,
|
LinkBreak,
|
||||||
Link,
|
Link,
|
||||||
Plug,
|
Plug,
|
||||||
Bird,
|
|
||||||
Brain,
|
Brain,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import {
|
import {
|
||||||
@@ -136,6 +135,27 @@ export const JiraIcon = ({
|
|||||||
return <SiJira size={size} className={className} />;
|
return <SiJira size={size} className={className} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ProductboardIcon = ({
|
||||||
|
size = "16",
|
||||||
|
className = defaultTailwindCSS,
|
||||||
|
}: IconProps) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{ width: `${size}px`, height: `${size}px` }}
|
||||||
|
className={`w-[${size}px] h-[${size}px] ` + className}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 162 108">
|
||||||
|
<path
|
||||||
|
fill="#60A5FA"
|
||||||
|
d="M108.001 0L162 54l-54.001 54-53.996-54L108 0z"
|
||||||
|
></path>
|
||||||
|
<path fill="#60A5FA" d="M107.997 0L53.999 54 0 0h107.997z"></path>
|
||||||
|
<path fill="#60A5FA" d="M53.999 54l53.998 54H0l53.999-54z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const SlabIcon = ({
|
export const SlabIcon = ({
|
||||||
size = "16",
|
size = "16",
|
||||||
className = defaultTailwindCSS,
|
className = defaultTailwindCSS,
|
||||||
|
@@ -10,6 +10,7 @@ const sources: Source[] = [
|
|||||||
{ displayName: "BookStack", internalName: "bookstack" },
|
{ displayName: "BookStack", internalName: "bookstack" },
|
||||||
{ displayName: "Confluence", internalName: "confluence" },
|
{ displayName: "Confluence", internalName: "confluence" },
|
||||||
{ displayName: "Jira", internalName: "jira" },
|
{ displayName: "Jira", internalName: "jira" },
|
||||||
|
{ displayName: "Productboard", internalName: "productboard" },
|
||||||
{ displayName: "Slab", internalName: "slab" },
|
{ displayName: "Slab", internalName: "slab" },
|
||||||
{ displayName: "Github PRs", internalName: "github" },
|
{ displayName: "Github PRs", internalName: "github" },
|
||||||
{ displayName: "Web", internalName: "web" },
|
{ displayName: "Web", internalName: "web" },
|
||||||
|
@@ -8,6 +8,7 @@ import {
|
|||||||
GoogleDriveIcon,
|
GoogleDriveIcon,
|
||||||
JiraIcon,
|
JiraIcon,
|
||||||
NotionIcon,
|
NotionIcon,
|
||||||
|
ProductboardIcon,
|
||||||
SlabIcon,
|
SlabIcon,
|
||||||
SlackIcon,
|
SlackIcon,
|
||||||
} from "./icons/icons";
|
} from "./icons/icons";
|
||||||
@@ -68,6 +69,12 @@ export const getSourceMetadata = (sourceType: ValidSources): SourceMetadata => {
|
|||||||
displayName: "Jira",
|
displayName: "Jira",
|
||||||
adminPageLink: "/admin/connectors/jira",
|
adminPageLink: "/admin/connectors/jira",
|
||||||
};
|
};
|
||||||
|
case "productboard":
|
||||||
|
return {
|
||||||
|
icon: ProductboardIcon,
|
||||||
|
displayName: "Productboard",
|
||||||
|
adminPageLink: "/admin/connectors/productboard",
|
||||||
|
};
|
||||||
case "slab":
|
case "slab":
|
||||||
return {
|
return {
|
||||||
icon: SlabIcon,
|
icon: SlabIcon,
|
||||||
|
@@ -15,6 +15,7 @@ export type ValidSources =
|
|||||||
| "bookstack"
|
| "bookstack"
|
||||||
| "confluence"
|
| "confluence"
|
||||||
| "jira"
|
| "jira"
|
||||||
|
| "productboard"
|
||||||
| "slab"
|
| "slab"
|
||||||
| "file"
|
| "file"
|
||||||
| "notion";
|
| "notion";
|
||||||
@@ -60,6 +61,8 @@ export interface JiraConfig {
|
|||||||
jira_project_url: string;
|
jira_project_url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProductboardConfig {}
|
||||||
|
|
||||||
export interface SlackConfig {
|
export interface SlackConfig {
|
||||||
workspace: string;
|
workspace: string;
|
||||||
}
|
}
|
||||||
@@ -116,6 +119,10 @@ export interface JiraCredentialJson {
|
|||||||
jira_api_token: string;
|
jira_api_token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProductboardCredentialJson {
|
||||||
|
productboard_access_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SlackCredentialJson {
|
export interface SlackCredentialJson {
|
||||||
slack_bot_token: string;
|
slack_bot_token: string;
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user