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"
|
||||
SLAB = "slab"
|
||||
JIRA = "jira"
|
||||
PRODUCTBOARD = "productboard"
|
||||
FILE = "file"
|
||||
NOTION = "notion"
|
||||
|
@@ -1,13 +1,18 @@
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class BookStackClientRequestFailedError(ConnectionError):
|
||||
def __init__(self, status: int, error: str) -> None:
|
||||
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__(
|
||||
self,
|
||||
base_url: str,
|
||||
@@ -18,7 +23,7 @@ class BookStackApiClient:
|
||||
self.token_id = token_id
|
||||
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)
|
||||
headers = self._build_headers()
|
||||
response = requests.get(url, headers=headers, params=params)
|
||||
@@ -38,15 +43,15 @@ class BookStackApiClient:
|
||||
|
||||
return json
|
||||
|
||||
def _build_headers(self):
|
||||
auth = 'Token ' + self.token_id + ':' + self.token_secret
|
||||
def _build_headers(self) -> dict[str, str]:
|
||||
auth = "Token " + self.token_id + ":" + self.token_secret
|
||||
return {
|
||||
'Authorization': auth,
|
||||
'Accept': 'application/json',
|
||||
"Authorization": auth,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
def _build_url(self, endpoint: str):
|
||||
return self.base_url.rstrip('/') + '/api/' + endpoint.lstrip('/')
|
||||
def _build_url(self, endpoint: str) -> str:
|
||||
return self.base_url.rstrip("/") + "/api/" + endpoint.lstrip("/")
|
||||
|
||||
def build_app_url(self, endpoint: str):
|
||||
return self.base_url.rstrip('/') + '/' + endpoint.lstrip('/')
|
||||
def build_app_url(self, endpoint: str) -> str:
|
||||
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.constants import DocumentSource
|
||||
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 LoadConnector
|
||||
from danswer.connectors.interfaces import PollConnector
|
||||
from danswer.connectors.interfaces import SecondsSinceUnixEpoch
|
||||
from danswer.connectors.bookstack.client import BookStackApiClient
|
||||
from danswer.connectors.models import Document
|
||||
from danswer.connectors.models import Section
|
||||
|
||||
|
||||
class BookstackClientNotSetUpError(PermissionError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
"BookStack Client is not set up, was load_credentials called?"
|
||||
)
|
||||
super().__init__("BookStack Client is not set up, was load_credentials called?")
|
||||
|
||||
|
||||
class BookstackConnector(LoadConnector, PollConnector):
|
||||
@@ -40,10 +38,12 @@ class BookstackConnector(LoadConnector, PollConnector):
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_doc_batch(
|
||||
self,
|
||||
batch_size: int,
|
||||
bookstack_client: BookStackApiClient,
|
||||
endpoint: str,
|
||||
transformer: Callable[[dict], Document],
|
||||
transformer: Callable[[BookStackApiClient, dict], Document],
|
||||
start_ind: int,
|
||||
start: SecondsSinceUnixEpoch | None = None,
|
||||
end: SecondsSinceUnixEpoch | None = None,
|
||||
@@ -51,70 +51,90 @@ class BookstackConnector(LoadConnector, PollConnector):
|
||||
doc_batch: list[Document] = []
|
||||
|
||||
params = {
|
||||
"count": str(self.batch_size),
|
||||
"count": str(batch_size),
|
||||
"offset": str(start_ind),
|
||||
"sort": "+id"
|
||||
"sort": "+id",
|
||||
}
|
||||
|
||||
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:
|
||||
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:
|
||||
doc_batch.append(transformer(item))
|
||||
doc_batch.append(transformer(bookstack_client, item))
|
||||
|
||||
return doc_batch, len(batch)
|
||||
|
||||
def _book_to_document(self, book: dict):
|
||||
url = self.bookstack_client.build_app_url("/books/" + book.get("slug"))
|
||||
@staticmethod
|
||||
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", "")
|
||||
return Document(
|
||||
id="book:" + str(book.get("id")),
|
||||
sections=[Section(link=url, text=text)],
|
||||
source=DocumentSource.BOOKSTACK,
|
||||
semantic_identifier="Book: " + book.get("name"),
|
||||
metadata={
|
||||
"type": "book",
|
||||
"updated_at": book.get("updated_at")
|
||||
},
|
||||
semantic_identifier="Book: " + str(book.get("name")),
|
||||
metadata={"type": "book", "updated_at": str(book.get("updated_at"))},
|
||||
)
|
||||
|
||||
def _chapter_to_document(self, chapter: dict):
|
||||
url = self.bookstack_client.build_app_url("/books/" + chapter.get("book_slug") + "/chapter/" + chapter.get("slug"))
|
||||
@staticmethod
|
||||
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", "")
|
||||
return Document(
|
||||
id="chapter:" + str(chapter.get("id")),
|
||||
sections=[Section(link=url, text=text)],
|
||||
source=DocumentSource.BOOKSTACK,
|
||||
semantic_identifier="Chapter: " + chapter.get("name"),
|
||||
metadata={
|
||||
"type": "chapter",
|
||||
"updated_at": chapter.get("updated_at")
|
||||
},
|
||||
semantic_identifier="Chapter: " + str(chapter.get("name")),
|
||||
metadata={"type": "chapter", "updated_at": str(chapter.get("updated_at"))},
|
||||
)
|
||||
|
||||
def _shelf_to_document(self, shelf: dict):
|
||||
url = self.bookstack_client.build_app_url("/shelves/" + shelf.get("slug"))
|
||||
@staticmethod
|
||||
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", "")
|
||||
return Document(
|
||||
id="shelf:" + str(shelf.get("id")),
|
||||
sections=[Section(link=url, text=text)],
|
||||
source=DocumentSource.BOOKSTACK,
|
||||
semantic_identifier="Shelf: " + shelf.get("name"),
|
||||
metadata={
|
||||
"type": "shelf",
|
||||
"updated_at": shelf.get("updated_at")
|
||||
},
|
||||
semantic_identifier="Shelf: " + str(shelf.get("name")),
|
||||
metadata={"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_data = self.bookstack_client.get("/pages/" + page_id, {})
|
||||
url = self.bookstack_client.build_app_url("/books/" + page.get("book_slug") + "/page/" + page_data.get("slug"))
|
||||
page_html = "<h1>" + html.escape(page_data.get("name")) + "</h1>" + page_data.get("html")
|
||||
page_name = str(page.get("name"))
|
||||
page_data = bookstack_client.get("/pages/" + page_id, {})
|
||||
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")
|
||||
text = soup.get_text(HTML_SEPARATOR)
|
||||
time.sleep(0.1)
|
||||
@@ -122,11 +142,8 @@ class BookstackConnector(LoadConnector, PollConnector):
|
||||
id="page:" + page_id,
|
||||
sections=[Section(link=url, text=text)],
|
||||
source=DocumentSource.BOOKSTACK,
|
||||
semantic_identifier="Page: " + page_data.get("name"),
|
||||
metadata={
|
||||
"type": "page",
|
||||
"updated_at": page_data.get("updated_at")
|
||||
},
|
||||
semantic_identifier="Page: " + str(page_name),
|
||||
metadata={"type": "page", "updated_at": page_data.get("updated_at")},
|
||||
)
|
||||
|
||||
def load_from_state(self) -> GenerateDocumentsOutput:
|
||||
@@ -141,7 +158,9 @@ class BookstackConnector(LoadConnector, PollConnector):
|
||||
if self.bookstack_client is None:
|
||||
raise BookstackClientNotSetUpError()
|
||||
|
||||
transform_by_endpoint: dict[str, Callable[[dict], Document]] = {
|
||||
transform_by_endpoint: dict[
|
||||
str, Callable[[BookStackApiClient, dict], Document]
|
||||
] = {
|
||||
"/books": self._book_to_document,
|
||||
"/chapters": self._chapter_to_document,
|
||||
"/shelves": self._shelf_to_document,
|
||||
@@ -151,7 +170,15 @@ class BookstackConnector(LoadConnector, PollConnector):
|
||||
for endpoint, transform in transform_by_endpoint.items():
|
||||
start_ind = 0
|
||||
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
|
||||
if 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 PollConnector
|
||||
from danswer.connectors.models import InputType
|
||||
from danswer.connectors.productboard.connector import ProductboardConnector
|
||||
from danswer.connectors.slab.connector import SlabConnector
|
||||
from danswer.connectors.slack.connector import SlackLoadConnector
|
||||
from danswer.connectors.slack.connector import SlackPollConnector
|
||||
@@ -42,6 +43,7 @@ def identify_connector_class(
|
||||
DocumentSource.BOOKSTACK: BookstackConnector,
|
||||
DocumentSource.CONFLUENCE: ConfluenceConnector,
|
||||
DocumentSource.JIRA: JiraConnector,
|
||||
DocumentSource.PRODUCTBOARD: ProductboardConnector,
|
||||
DocumentSource.SLAB: SlabConnector,
|
||||
DocumentSource.NOTION: NotionConnector,
|
||||
}
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import datetime
|
||||
import io
|
||||
from collections.abc import Generator
|
||||
from collections.abc import Sequence
|
||||
from itertools import chain
|
||||
from typing import Any
|
||||
|
||||
@@ -170,11 +171,12 @@ class GoogleDriveConnector(LoadConnector, PollConnector):
|
||||
folder_names = path.split("/")
|
||||
parent_id = "root"
|
||||
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
|
||||
)
|
||||
if parent_id is None:
|
||||
if found_parent_id is None:
|
||||
raise ValueError(f"Folder path '{path}' not found in Google Drive")
|
||||
parent_id = found_parent_id
|
||||
folder_ids.append(parent_id)
|
||||
|
||||
return folder_ids
|
||||
@@ -199,7 +201,9 @@ class GoogleDriveConnector(LoadConnector, PollConnector):
|
||||
raise PermissionError("Not logged into Google Drive")
|
||||
|
||||
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:
|
||||
folder_ids = [None]
|
||||
|
||||
|
@@ -18,7 +18,7 @@ class Document:
|
||||
sections: list[Section]
|
||||
source: DocumentSource
|
||||
semantic_identifier: str | None
|
||||
metadata: dict[str, Any] | None
|
||||
metadata: dict[str, Any]
|
||||
|
||||
|
||||
class InputType(str, Enum):
|
||||
|
@@ -1,10 +1,13 @@
|
||||
"""Notion reader."""
|
||||
import time
|
||||
from dataclasses import dataclass, fields
|
||||
from typing import Any, Dict, List, Optional
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import fields
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
from danswer.configs.app_configs import INDEX_BATCH_SIZE
|
||||
from danswer.configs.constants import DocumentSource
|
||||
from danswer.connectors.interfaces import GenerateDocumentsOutput
|
||||
@@ -26,7 +29,7 @@ class NotionPage:
|
||||
properties: Dict[str, Any]
|
||||
url: str
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
def __init__(self, **kwargs: dict[str, Any]) -> None:
|
||||
names = set([f.name for f in fields(self)])
|
||||
for k, v in kwargs.items():
|
||||
if k in names:
|
||||
@@ -41,7 +44,7 @@ class NotionSearchResponse:
|
||||
next_cursor: Optional[str]
|
||||
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)])
|
||||
for k, v in kwargs.items():
|
||||
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"
|
||||
)
|
||||
|
||||
return cast(F, wrapped_func)
|
||||
return cast(FG, wrapped_func)
|
||||
|
||||
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,
|
||||
SlabIcon,
|
||||
NotionIcon,
|
||||
ProductboardIcon,
|
||||
} from "@/components/icons/icons";
|
||||
import { DISABLE_AUTH } from "@/lib/constants";
|
||||
import { getCurrentUserSS } from "@/lib/userSS";
|
||||
@@ -112,6 +113,15 @@ export default async function AdminLayout({
|
||||
),
|
||||
link: "/admin/connectors/jira",
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
<ProductboardIcon size="16" />
|
||||
<div className="ml-1">Productboard</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/connectors/productboard",
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
|
@@ -9,7 +9,6 @@ import {
|
||||
LinkBreak,
|
||||
Link,
|
||||
Plug,
|
||||
Bird,
|
||||
Brain,
|
||||
} from "@phosphor-icons/react";
|
||||
import {
|
||||
@@ -136,6 +135,27 @@ export const JiraIcon = ({
|
||||
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 = ({
|
||||
size = "16",
|
||||
className = defaultTailwindCSS,
|
||||
|
@@ -10,6 +10,7 @@ const sources: Source[] = [
|
||||
{ displayName: "BookStack", internalName: "bookstack" },
|
||||
{ displayName: "Confluence", internalName: "confluence" },
|
||||
{ displayName: "Jira", internalName: "jira" },
|
||||
{ displayName: "Productboard", internalName: "productboard" },
|
||||
{ displayName: "Slab", internalName: "slab" },
|
||||
{ displayName: "Github PRs", internalName: "github" },
|
||||
{ displayName: "Web", internalName: "web" },
|
||||
|
@@ -8,6 +8,7 @@ import {
|
||||
GoogleDriveIcon,
|
||||
JiraIcon,
|
||||
NotionIcon,
|
||||
ProductboardIcon,
|
||||
SlabIcon,
|
||||
SlackIcon,
|
||||
} from "./icons/icons";
|
||||
@@ -68,6 +69,12 @@ export const getSourceMetadata = (sourceType: ValidSources): SourceMetadata => {
|
||||
displayName: "Jira",
|
||||
adminPageLink: "/admin/connectors/jira",
|
||||
};
|
||||
case "productboard":
|
||||
return {
|
||||
icon: ProductboardIcon,
|
||||
displayName: "Productboard",
|
||||
adminPageLink: "/admin/connectors/productboard",
|
||||
};
|
||||
case "slab":
|
||||
return {
|
||||
icon: SlabIcon,
|
||||
|
@@ -15,6 +15,7 @@ export type ValidSources =
|
||||
| "bookstack"
|
||||
| "confluence"
|
||||
| "jira"
|
||||
| "productboard"
|
||||
| "slab"
|
||||
| "file"
|
||||
| "notion";
|
||||
@@ -60,6 +61,8 @@ export interface JiraConfig {
|
||||
jira_project_url: string;
|
||||
}
|
||||
|
||||
export interface ProductboardConfig {}
|
||||
|
||||
export interface SlackConfig {
|
||||
workspace: string;
|
||||
}
|
||||
@@ -116,6 +119,10 @@ export interface JiraCredentialJson {
|
||||
jira_api_token: string;
|
||||
}
|
||||
|
||||
export interface ProductboardCredentialJson {
|
||||
productboard_access_token: string;
|
||||
}
|
||||
|
||||
export interface SlackCredentialJson {
|
||||
slack_bot_token: string;
|
||||
}
|
||||
|
Reference in New Issue
Block a user