Product board connector (#228)

Also fixes misc mypy issues across the repo
This commit is contained in:
Chris Weaver
2023-07-22 13:00:51 -07:00
committed by GitHub
parent 25a028c4a7
commit dd084d40f6
16 changed files with 645 additions and 66 deletions

View File

@@ -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"

View File

@@ -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("/")

View File

@@ -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

View File

@@ -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,
} }

View File

@@ -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]

View File

@@ -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):

View File

@@ -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:

View 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

View File

@@ -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

View 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>
);
}

View File

@@ -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">

View File

@@ -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,

View File

@@ -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" },

View File

@@ -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,

View File

@@ -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;
} }