From 2b33e4f513ca1d04103a6c3b156cd18750c3337e Mon Sep 17 00:00:00 2001 From: allen-munsch Date: Wed, 29 Mar 2023 19:39:09 -0500 Subject: [PATCH 01/10] initial attempt to add stackoverflow support --- README.md | 1 + .../sources/stackoverflow/__init__.py | 0 .../sources/stackoverflow/stackoverflow.py | 98 ++++++++++++++++++ .../data_source_icons/stackoverflow.png | Bin 0 -> 39301 bytes 4 files changed, 99 insertions(+) create mode 100644 app/data_source/sources/stackoverflow/__init__.py create mode 100644 app/data_source/sources/stackoverflow/stackoverflow.py create mode 100644 app/static/data_source_icons/stackoverflow.png diff --git a/README.md b/README.md index 86aeaad..fb8176f 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Coming Soon... - [ ] Azure DevOps (In PR :pray:) - [ ] Notion (In Progress... :pray:) - [ ] Trello (In Progress... :pray:) + - [ ] Stackoverflow Teams (In Progress... :pray:) - [ ] Microsoft Teams - [ ] Sharepoint - [ ] Jira diff --git a/app/data_source/sources/stackoverflow/__init__.py b/app/data_source/sources/stackoverflow/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/data_source/sources/stackoverflow/stackoverflow.py b/app/data_source/sources/stackoverflow/stackoverflow.py new file mode 100644 index 0000000..6ec26a2 --- /dev/null +++ b/app/data_source/sources/stackoverflow/stackoverflow.py @@ -0,0 +1,98 @@ +import logging +from dataclasses import dataclass +from typing import Dict, List +import requests +from data_source.api.base_data_source import BaseDataSource, ConfigField, HTMLInputType, BaseDataSourceConfig +from data_source.api.basic_document import DocumentType, BasicDocument +from queues.index_queue import IndexQueue + +logger = logging.getLogger(__name__) + +endpoints = [ + 'posts', + 'articles', +] + + +@dataclass +class StackOverflowPost: + post_id: int + post_type: str + title: str + link: str + body_markdown: str + owner_account_id: int + owner_reputation: int + owner_user_id: int + owner_user_type: str + owner_profile_image: str + owner_display_name: str + owner_link: str + score: int + last_activity_date: int + creation_date: int + +class StackOverflowConfig(BaseDataSourceConfig): + api_key: str + team_name: str + + +class StackOverflowDataSource(BaseDataSource): + + @staticmethod + def get_config_fields() -> List[ConfigField]: + return [ + ConfigField(label="PAT API Key", name="api_key", type=HTMLInputType.TEXT), + ConfigField(label="Team Name", name="team_name", type=HTMLInputType.TEXT), + ] + + @staticmethod + def validate_config(config: Dict) -> None: + so_config = StackOverflowConfig(**config) + StackOverflowDataSource._fetch_posts(so_config.api_key, so_config.team_name, 1) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + so_config = StackOverflowConfig(**self._raw_config) + self._api_key = so_config.api_key + self._team_name = so_config.team_name + + @staticmethod + def _fetch_posts(api_key: str, team_name: str, page: int, doc_type: str) -> Dict: + url = f'https://api.stackoverflowteams.com/2.3/{doc_type}?team={team_name}&filter=!nOedRLbqzB&page={page}' + response = requests.get(url, headers={'X-API-Access-Token': api_key}) + response.raise_for_status() + return response.json() + + def _feed_new_posts(self) -> None: + page = 1 + has_more = True + for doc_type in endpoints: + while has_more: + response = self._fetch_posts(self._api_key, page, doc_type) + owner_fields = {f"owner_{k}": v for k, v in response.pop('owner').items()} + posts = [StackOverflowPost(**post, **owner_fields) for post in response['items']] + logger.info(f'Fetched {len(posts)} posts from Stack Overflow') + for post in posts: + self.add_task_to_queue(self._feed_post, post=post) + has_more = response['has_more'] + page += 1 + + def _feed_post(self, post: StackOverflowPost) -> None: + logger.info(f'Feeding post {post.title}') + post_document = BasicDocument(title=post.title, content=post.body_markdown, author=post.owner_display_name, + timestamp=post.creation_date, id=post.post_id, + data_source_id=post.post_id, location=post.link, + url=post.link, author_image_url=post.owner_profile_image, + type=DocumentType.MESSAGE) + IndexQueue.get_instance().put_single(doc=post_document) + + def run(self): + self._feed_new_posts() + + +# if __name__ == '__main__': +# import os +# config = {"api_key": os.environ['SO_API_KEY'], "team_name": os.environ['SO_TEAM_NAME']} +# so = StackOverflowDataSource(config) +# so.run() \ No newline at end of file diff --git a/app/static/data_source_icons/stackoverflow.png b/app/static/data_source_icons/stackoverflow.png new file mode 100644 index 0000000000000000000000000000000000000000..af11628173afc020456abae754b94fd4818778b2 GIT binary patch literal 39301 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 Date: Wed, 29 Mar 2023 19:40:09 -0500 Subject: [PATCH 02/10] initial attempt to add stackoverflow support --- app/data_source/sources/stackoverflow/stackoverflow.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/data_source/sources/stackoverflow/stackoverflow.py b/app/data_source/sources/stackoverflow/stackoverflow.py index 6ec26a2..5fea4dd 100644 --- a/app/data_source/sources/stackoverflow/stackoverflow.py +++ b/app/data_source/sources/stackoverflow/stackoverflow.py @@ -2,9 +2,10 @@ from dataclasses import dataclass from typing import Dict, List import requests -from data_source.api.base_data_source import BaseDataSource, ConfigField, HTMLInputType, BaseDataSourceConfig -from data_source.api.basic_document import DocumentType, BasicDocument -from queues.index_queue import IndexQueue + +from app.data_source.api.base_data_source import BaseDataSource, ConfigField, HTMLInputType, BaseDataSourceConfig +from app.data_source.api.basic_document import DocumentType, BasicDocument +from app.queues.index_queue import IndexQueue logger = logging.getLogger(__name__) From 6d0b81085dad23ae9e11f7c38be7bca8a039ce0e Mon Sep 17 00:00:00 2001 From: allen-munsch Date: Wed, 29 Mar 2023 19:47:59 -0500 Subject: [PATCH 03/10] initial attempt to add stackoverflow support --- app/data_source/sources/stackoverflow/stackoverflow.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/app/data_source/sources/stackoverflow/stackoverflow.py b/app/data_source/sources/stackoverflow/stackoverflow.py index 5fea4dd..21bf32e 100644 --- a/app/data_source/sources/stackoverflow/stackoverflow.py +++ b/app/data_source/sources/stackoverflow/stackoverflow.py @@ -1,5 +1,6 @@ import logging from dataclasses import dataclass +from datetime import datetime from typing import Dict, List import requests @@ -65,7 +66,7 @@ def _fetch_posts(api_key: str, team_name: str, page: int, doc_type: str) -> Dict response.raise_for_status() return response.json() - def _feed_new_posts(self) -> None: + def _feed_new_documents(self) -> None: page = 1 has_more = True for doc_type in endpoints: @@ -82,15 +83,12 @@ def _feed_new_posts(self) -> None: def _feed_post(self, post: StackOverflowPost) -> None: logger.info(f'Feeding post {post.title}') post_document = BasicDocument(title=post.title, content=post.body_markdown, author=post.owner_display_name, - timestamp=post.creation_date, id=post.post_id, + timestamp=datetime.fromtimestamp(post.creation_date), id=post.post_id, data_source_id=post.post_id, location=post.link, url=post.link, author_image_url=post.owner_profile_image, type=DocumentType.MESSAGE) IndexQueue.get_instance().put_single(doc=post_document) - def run(self): - self._feed_new_posts() - # if __name__ == '__main__': # import os From 1f73af8073bdc7f57e809bf214b7e404e3262fdc Mon Sep 17 00:00:00 2001 From: allen-munsch Date: Wed, 29 Mar 2023 20:50:14 -0500 Subject: [PATCH 04/10] test the download, add the help text to the data-source-panel.tsx --- .../sources/stackoverflow/__main__.py | 5 ++ .../sources/stackoverflow/stackoverflow.py | 53 +++++++++++-------- ui/src/components/data-source-panel.tsx | 9 ++++ 3 files changed, 45 insertions(+), 22 deletions(-) create mode 100644 app/data_source/sources/stackoverflow/__main__.py diff --git a/app/data_source/sources/stackoverflow/__main__.py b/app/data_source/sources/stackoverflow/__main__.py new file mode 100644 index 0000000..830b6f1 --- /dev/null +++ b/app/data_source/sources/stackoverflow/__main__.py @@ -0,0 +1,5 @@ +import sys +from .stackoverflow import test + +if __name__ == '__main__': + sys.exit(test()) \ No newline at end of file diff --git a/app/data_source/sources/stackoverflow/stackoverflow.py b/app/data_source/sources/stackoverflow/stackoverflow.py index 21bf32e..957f5d1 100644 --- a/app/data_source/sources/stackoverflow/stackoverflow.py +++ b/app/data_source/sources/stackoverflow/stackoverflow.py @@ -1,12 +1,12 @@ import logging from dataclasses import dataclass from datetime import datetime -from typing import Dict, List +from typing import Dict, List, Optional import requests -from app.data_source.api.base_data_source import BaseDataSource, ConfigField, HTMLInputType, BaseDataSourceConfig -from app.data_source.api.basic_document import DocumentType, BasicDocument -from app.queues.index_queue import IndexQueue +from data_source.api.base_data_source import BaseDataSource, ConfigField, HTMLInputType, BaseDataSourceConfig +from data_source.api.basic_document import DocumentType, BasicDocument +from queues.index_queue import IndexQueue logger = logging.getLogger(__name__) @@ -20,19 +20,20 @@ class StackOverflowPost: post_id: int post_type: str - title: str link: str body_markdown: str - owner_account_id: int - owner_reputation: int - owner_user_id: int - owner_user_type: str - owner_profile_image: str - owner_display_name: str - owner_link: str score: int last_activity_date: int creation_date: int + owner_account_id: Optional[int] = None + owner_reputation: Optional[int] = None + owner_user_id: Optional[int] = None + owner_user_type: Optional[str] = None + owner_profile_image: Optional[str] = None + owner_display_name: Optional[str] = None + owner_link: Optional[str] = None + title: Optional[str] = None + last_edit_date: Optional[str] = None class StackOverflowConfig(BaseDataSourceConfig): api_key: str @@ -51,7 +52,7 @@ def get_config_fields() -> List[ConfigField]: @staticmethod def validate_config(config: Dict) -> None: so_config = StackOverflowConfig(**config) - StackOverflowDataSource._fetch_posts(so_config.api_key, so_config.team_name, 1) + StackOverflowDataSource._fetch_posts(so_config.api_key, so_config.team_name, 1, 'posts') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -71,11 +72,16 @@ def _feed_new_documents(self) -> None: has_more = True for doc_type in endpoints: while has_more: - response = self._fetch_posts(self._api_key, page, doc_type) - owner_fields = {f"owner_{k}": v for k, v in response.pop('owner').items()} - posts = [StackOverflowPost(**post, **owner_fields) for post in response['items']] + response = self._fetch_posts(self._api_key, self._team_name, page, doc_type) + posts = response['items'] logger.info(f'Fetched {len(posts)} posts from Stack Overflow') - for post in posts: + for post_dict in posts: + owner_fields = {} + if 'owner' in post_dict: + owner_fields = {f"owner_{k}": v for k, v in post_dict.pop('owner').items()} + if 'title' not in post_dict: + post_dict['title'] = post_dict['link'] + post = StackOverflowPost(**post_dict, **owner_fields) self.add_task_to_queue(self._feed_post, post=post) has_more = response['has_more'] page += 1 @@ -89,9 +95,12 @@ def _feed_post(self, post: StackOverflowPost) -> None: type=DocumentType.MESSAGE) IndexQueue.get_instance().put_single(doc=post_document) +def test(): + import os + config = {"api_key": os.environ['SO_API_KEY'], "team_name": os.environ['SO_TEAM_NAME']} + so = StackOverflowDataSource(config=config, data_source_id=0) + so._feed_new_documents() -# if __name__ == '__main__': -# import os -# config = {"api_key": os.environ['SO_API_KEY'], "team_name": os.environ['SO_TEAM_NAME']} -# so = StackOverflowDataSource(config) -# so.run() \ No newline at end of file + +if __name__ == '__main__': + test() \ No newline at end of file diff --git a/ui/src/components/data-source-panel.tsx b/ui/src/components/data-source-panel.tsx index 6c52d0b..73b672f 100644 --- a/ui/src/components/data-source-panel.tsx +++ b/ui/src/components/data-source-panel.tsx @@ -368,6 +368,15 @@ export default class DataSourcePanel extends React.ComponentNote that the url must begin with either http:// or https://

)} + {this.state.selectedDataSource.value === 'stack_overflow' && ( + + 1. {'Visit: https://stackoverflowteams.com/users/pats/'} + 2. {'Click Create a new PAT'} + 3. {'Name the token, and pick the team scope.'} + 4. {'Select an expiration date'} + 5. {'Click Create'} + + )}
From dd575dc2aefcb6c3b846342f5a5d1ce04d93b350 Mon Sep 17 00:00:00 2001 From: allen-munsch Date: Wed, 29 Mar 2023 20:55:04 -0500 Subject: [PATCH 05/10] test the download, add the help text to the data-source-panel.tsx, update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fb8176f..4cc4123 100644 --- a/README.md +++ b/README.md @@ -32,12 +32,12 @@ Coming Soon... - [X] Bookstack - by [@flifloo](https://github.com/flifloo) :pray: - [X] Mattermost - by [@itaykal](https://github.com/Itaykal) :pray: - [X] RocketChat - by [@flifloo](https://github.com/flifloo) :pray: + - [X] Stackoverflow Teams - by [@allen-munsch](https://github.com/allen-munsch) :pray: - [ ] Gitlab Issues (In PR :pray:) - [ ] Zendesk (In PR :pray:) - [ ] Azure DevOps (In PR :pray:) - [ ] Notion (In Progress... :pray:) - [ ] Trello (In Progress... :pray:) - - [ ] Stackoverflow Teams (In Progress... :pray:) - [ ] Microsoft Teams - [ ] Sharepoint - [ ] Jira From 036e7e540206b34b3f04ec985880581393aa5644 Mon Sep 17 00:00:00 2001 From: allen-munsch Date: Wed, 29 Mar 2023 21:22:43 -0500 Subject: [PATCH 06/10] comment out the test function --- .../sources/stackoverflow/__main__.py | 5 ----- .../sources/stackoverflow/stackoverflow.py | 20 +++++++++---------- 2 files changed, 10 insertions(+), 15 deletions(-) delete mode 100644 app/data_source/sources/stackoverflow/__main__.py diff --git a/app/data_source/sources/stackoverflow/__main__.py b/app/data_source/sources/stackoverflow/__main__.py deleted file mode 100644 index 830b6f1..0000000 --- a/app/data_source/sources/stackoverflow/__main__.py +++ /dev/null @@ -1,5 +0,0 @@ -import sys -from .stackoverflow import test - -if __name__ == '__main__': - sys.exit(test()) \ No newline at end of file diff --git a/app/data_source/sources/stackoverflow/stackoverflow.py b/app/data_source/sources/stackoverflow/stackoverflow.py index 957f5d1..79e0cad 100644 --- a/app/data_source/sources/stackoverflow/stackoverflow.py +++ b/app/data_source/sources/stackoverflow/stackoverflow.py @@ -90,17 +90,17 @@ def _feed_post(self, post: StackOverflowPost) -> None: logger.info(f'Feeding post {post.title}') post_document = BasicDocument(title=post.title, content=post.body_markdown, author=post.owner_display_name, timestamp=datetime.fromtimestamp(post.creation_date), id=post.post_id, - data_source_id=post.post_id, location=post.link, + data_source_id=self._data_source_id, location=post.link, url=post.link, author_image_url=post.owner_profile_image, type=DocumentType.MESSAGE) IndexQueue.get_instance().put_single(doc=post_document) -def test(): - import os - config = {"api_key": os.environ['SO_API_KEY'], "team_name": os.environ['SO_TEAM_NAME']} - so = StackOverflowDataSource(config=config, data_source_id=0) - so._feed_new_documents() - - -if __name__ == '__main__': - test() \ No newline at end of file +# def test(): +# import os +# config = {"api_key": os.environ['SO_API_KEY'], "team_name": os.environ['SO_TEAM_NAME']} +# so = StackOverflowDataSource(config=config, data_source_id=0) +# so._feed_new_documents() +# +# +# if __name__ == '__main__': +# test() \ No newline at end of file From e52ec767d45168eb3828c7c7cd04c0d6ae3df909 Mon Sep 17 00:00:00 2001 From: allen-munsch Date: Thu, 30 Mar 2023 10:03:07 -0500 Subject: [PATCH 07/10] try to address code review, move io bound to queue, check for last indexed times --- .../sources/stackoverflow/stackoverflow.py | 70 +++++++++---------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/app/data_source/sources/stackoverflow/stackoverflow.py b/app/data_source/sources/stackoverflow/stackoverflow.py index 79e0cad..809dee6 100644 --- a/app/data_source/sources/stackoverflow/stackoverflow.py +++ b/app/data_source/sources/stackoverflow/stackoverflow.py @@ -10,11 +10,6 @@ logger = logging.getLogger(__name__) -endpoints = [ - 'posts', - 'articles', -] - @dataclass class StackOverflowPost: @@ -60,45 +55,50 @@ def __init__(self, *args, **kwargs): self._api_key = so_config.api_key self._team_name = so_config.team_name - @staticmethod - def _fetch_posts(api_key: str, team_name: str, page: int, doc_type: str) -> Dict: - url = f'https://api.stackoverflowteams.com/2.3/{doc_type}?team={team_name}&filter=!nOedRLbqzB&page={page}' + def _fetch_posts(self, api_key: str, team_name: str, page: int, doc_type: str) -> None: + team_fragment = f'&team={team_name}' + # this is a filter for "body markdown" inclusion, all filters are unique and static + # i am not entirely sure if this is per account, or usable by everyone + filter_fragment = '&filter=!nOedRLbqzB' + page_fragment = f'&page={page}' + # it looked like the timestamp was 10 digits, lets only look at stuff that is newer than the last index time + from_date_fragment = f'&fromdate={self._last_index_time.timestamp():.10n}' + url = f'https://api.stackoverflowteams.com/2.3/{doc_type}?{team_fragment}{filter_fragment}{page_fragment}{from_date_fragment}' response = requests.get(url, headers={'X-API-Access-Token': api_key}) + has_more = response['has_more'] response.raise_for_status() - return response.json() + items = response['items'] + logger.info(f'Fetched {len(items)} {doc_type} from Stack Overflow') + for item_dict in items: + owner_fields = {} + if 'owner' in item_dict: + owner_fields = {f"owner_{k}": v for k, v in item_dict.pop('owner').items()} + if 'title' not in item_dict: + item_dict['title'] = item_dict['link'] + post = StackOverflowPost(**item_dict, **owner_fields) + last_modified = datetime.strptime(post.last_edit_date, "%Y-%m-%dT%H:%M:%S.%fZ") + if last_modified < self._last_index_time: + return + logger.info(f'Feeding {doc_type} {post.title}') + post_document = BasicDocument(title=post.title, content=post.body_markdown, author=post.owner_display_name, + timestamp=datetime.fromtimestamp(post.creation_date), id=post.post_id, + data_source_id=self._data_source_id, location=post.link, + url=post.link, author_image_url=post.owner_profile_image, + type=DocumentType.MESSAGE) + IndexQueue.get_instance().put_single(doc=post_document) + if has_more: + # paginate onto the queue + self.add_task_to_queue(self._fetch_posts, self._api_key, self._team_name, page + 1, doc_type) def _feed_new_documents(self) -> None: - page = 1 - has_more = True - for doc_type in endpoints: - while has_more: - response = self._fetch_posts(self._api_key, self._team_name, page, doc_type) - posts = response['items'] - logger.info(f'Fetched {len(posts)} posts from Stack Overflow') - for post_dict in posts: - owner_fields = {} - if 'owner' in post_dict: - owner_fields = {f"owner_{k}": v for k, v in post_dict.pop('owner').items()} - if 'title' not in post_dict: - post_dict['title'] = post_dict['link'] - post = StackOverflowPost(**post_dict, **owner_fields) - self.add_task_to_queue(self._feed_post, post=post) - has_more = response['has_more'] - page += 1 + self.add_task_to_queue(self._fetch_posts, self._api_key, self._team_name, 1, 'articles') + self.add_task_to_queue(self._fetch_posts, self._api_key, self._team_name, 1, 'posts') - def _feed_post(self, post: StackOverflowPost) -> None: - logger.info(f'Feeding post {post.title}') - post_document = BasicDocument(title=post.title, content=post.body_markdown, author=post.owner_display_name, - timestamp=datetime.fromtimestamp(post.creation_date), id=post.post_id, - data_source_id=self._data_source_id, location=post.link, - url=post.link, author_image_url=post.owner_profile_image, - type=DocumentType.MESSAGE) - IndexQueue.get_instance().put_single(doc=post_document) # def test(): # import os # config = {"api_key": os.environ['SO_API_KEY'], "team_name": os.environ['SO_TEAM_NAME']} -# so = StackOverflowDataSource(config=config, data_source_id=0) +# so = StackOverflowDataSource(config=config, data_source_id=1) # so._feed_new_documents() # # From 5f14fc3c60d0cf60af0df3f89e62271d413701f9 Mon Sep 17 00:00:00 2001 From: allen-munsch Date: Thu, 30 Mar 2023 20:31:03 -0500 Subject: [PATCH 08/10] rate limit the requests --- app/data_source/api/utils.py | 27 ++++++++++++++ .../sources/stackoverflow/stackoverflow.py | 37 +++++++++++++++---- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/app/data_source/api/utils.py b/app/data_source/api/utils.py index 24575fc..013ac4d 100644 --- a/app/data_source/api/utils.py +++ b/app/data_source/api/utils.py @@ -4,9 +4,35 @@ from functools import lru_cache from io import BytesIO from typing import Optional +import time +from functools import wraps import requests +def rate_limit(*, allowed_per_second: int): + max_period = 1.0 / allowed_per_second + def decorate(func): + last_call = [time.perf_counter()] + @wraps(func) + def limit(*args, **kwargs): + with rate_limiter(last_call, max_period): + return func(*args, **kwargs) + return limit + return decorate + + +def rate_limiter(last_call, max_period): + class RateLimiter: + def __enter__(self): + elapsed = time.perf_counter() - last_call[0] + hold = max_period - elapsed + if hold > 0: + time.sleep(hold) + def __exit__(self, exc_type, exc_val, exc_tb): + last_call[0] = time.perf_counter() + return RateLimiter() + + logger = logging.getLogger(__name__) @@ -55,3 +81,4 @@ def get_confluence_user_image(image_url: str, token: str) -> Optional[str]: return f"data:image/jpeg;base64,{base64.b64encode(image_bytes.getvalue()).decode()}" except: logger.warning(f"Failed to get confluence user image {image_url}") + diff --git a/app/data_source/sources/stackoverflow/stackoverflow.py b/app/data_source/sources/stackoverflow/stackoverflow.py index 809dee6..eb436b5 100644 --- a/app/data_source/sources/stackoverflow/stackoverflow.py +++ b/app/data_source/sources/stackoverflow/stackoverflow.py @@ -8,6 +8,8 @@ from data_source.api.basic_document import DocumentType, BasicDocument from queues.index_queue import IndexQueue +from data_source.api.utils import rate_limit + logger = logging.getLogger(__name__) @@ -29,12 +31,28 @@ class StackOverflowPost: owner_link: Optional[str] = None title: Optional[str] = None last_edit_date: Optional[str] = None + tags: Optional[List[str]] = None + view_count: Optional[int] = None class StackOverflowConfig(BaseDataSourceConfig): api_key: str team_name: str +@rate_limit(allowed_per_second=15) +def rate_limited_get(url, headers): + ''' + https://api.stackoverflowteams.com/docs/throttle + https://api.stackexchange.com/docs/throttle + Every application is subject to an IP based concurrent request throttle. + If a single IP is making more than 30 requests a second, new requests will be dropped. + The exact ban period is subject to change, but will be on the order of 30 seconds to a few minutes typically. + Note that exactly what response an application gets (in terms of HTTP code, text, and so on) + is undefined when subject to this ban; we consider > 30 request/sec per IP to be very abusive and thus cut the requests off very harshly. + ''' + return requests.get(url, headers=headers) + + class StackOverflowDataSource(BaseDataSource): @staticmethod @@ -47,7 +65,9 @@ def get_config_fields() -> List[ConfigField]: @staticmethod def validate_config(config: Dict) -> None: so_config = StackOverflowConfig(**config) - StackOverflowDataSource._fetch_posts(so_config.api_key, so_config.team_name, 1, 'posts') + url = f'https://api.stackoverflowteams.com/2.3/questions?&team={so_config.team_name}' + response = rate_limited_get(url, headers={'X-API-Access-Token': so_config.api_key}) + response.raise_for_status() def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -55,7 +75,7 @@ def __init__(self, *args, **kwargs): self._api_key = so_config.api_key self._team_name = so_config.team_name - def _fetch_posts(self, api_key: str, team_name: str, page: int, doc_type: str) -> None: + def _fetch_posts(self, *, api_key: str, team_name: str, page: int, doc_type: str) -> None: team_fragment = f'&team={team_name}' # this is a filter for "body markdown" inclusion, all filters are unique and static # i am not entirely sure if this is per account, or usable by everyone @@ -64,9 +84,10 @@ def _fetch_posts(self, api_key: str, team_name: str, page: int, doc_type: str) - # it looked like the timestamp was 10 digits, lets only look at stuff that is newer than the last index time from_date_fragment = f'&fromdate={self._last_index_time.timestamp():.10n}' url = f'https://api.stackoverflowteams.com/2.3/{doc_type}?{team_fragment}{filter_fragment}{page_fragment}{from_date_fragment}' - response = requests.get(url, headers={'X-API-Access-Token': api_key}) - has_more = response['has_more'] + response = rate_limited_get(url, headers={'X-API-Access-Token': api_key}) response.raise_for_status() + response = response.json() + has_more = response['has_more'] items = response['items'] logger.info(f'Fetched {len(items)} {doc_type} from Stack Overflow') for item_dict in items: @@ -76,7 +97,7 @@ def _fetch_posts(self, api_key: str, team_name: str, page: int, doc_type: str) - if 'title' not in item_dict: item_dict['title'] = item_dict['link'] post = StackOverflowPost(**item_dict, **owner_fields) - last_modified = datetime.strptime(post.last_edit_date, "%Y-%m-%dT%H:%M:%S.%fZ") + last_modified = datetime.fromtimestamp(post.last_edit_date or post.last_activity_date) if last_modified < self._last_index_time: return logger.info(f'Feeding {doc_type} {post.title}') @@ -88,11 +109,11 @@ def _fetch_posts(self, api_key: str, team_name: str, page: int, doc_type: str) - IndexQueue.get_instance().put_single(doc=post_document) if has_more: # paginate onto the queue - self.add_task_to_queue(self._fetch_posts, self._api_key, self._team_name, page + 1, doc_type) + self.add_task_to_queue(self._fetch_posts, api_key=self._api_key, team_name=self._team_name, page=page + 1, doc_type=doc_type) def _feed_new_documents(self) -> None: - self.add_task_to_queue(self._fetch_posts, self._api_key, self._team_name, 1, 'articles') - self.add_task_to_queue(self._fetch_posts, self._api_key, self._team_name, 1, 'posts') + self.add_task_to_queue(self._fetch_posts, api_key=self._api_key, team_name=self._team_name, page=1, doc_type='articles') + self.add_task_to_queue(self._fetch_posts, api_key=self._api_key, team_name=self._team_name, page=1, doc_type='posts') # def test(): From 05ae7c9c49fc52d538e44a69e81b2451a78dd0a5 Mon Sep 17 00:00:00 2001 From: allen-munsch Date: Thu, 30 Mar 2023 20:50:08 -0500 Subject: [PATCH 09/10] add a rate limiter --- app/static/data_source_icons/stack_overflow.png | Bin 0 -> 4598 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 app/static/data_source_icons/stack_overflow.png diff --git a/app/static/data_source_icons/stack_overflow.png b/app/static/data_source_icons/stack_overflow.png new file mode 100644 index 0000000000000000000000000000000000000000..6760d69f2ac059989a99a3f7125a9eb3c1aec660 GIT binary patch literal 4598 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 Date: Thu, 30 Mar 2023 23:10:47 -0500 Subject: [PATCH 10/10] wire up async sqlite, add rate limiter, fix rendering issues on DataSourcePanel --- app/api/data_source.py | 2 +- app/clear_ack_queue.sh | 1 + app/clear_data_sources.sh | 1 + app/data_source/api/base_data_source.py | 2 +- app/data_source/api/context.py | 36 +- app/data_source/api/utils.py | 34 +- .../sources/bookstack/bookstack.py | 2 +- .../sources/confluence/confluence.py | 2 +- .../sources/confluence/confluence_cloud.py | 2 +- .../sources/google_drive/google_drive.py | 2 +- .../sources/mattermost/mattermost.py | 2 +- .../sources/rocketchat/rocketchat.py | 2 +- app/data_source/sources/slack/slack.py | 2 +- .../sources/stackoverflow/stackoverflow.py | 21 +- app/db_engine.py | 10 +- app/requirements.txt | 143 ++- .../data_source_icons/stackoverflow.png | Bin 39301 -> 0 bytes ui/src/components/data-source-panel.tsx | 1085 +++++++++-------- 18 files changed, 760 insertions(+), 589 deletions(-) create mode 100755 app/clear_ack_queue.sh create mode 100644 app/clear_data_sources.sh delete mode 100644 app/static/data_source_icons/stackoverflow.png diff --git a/app/api/data_source.py b/app/api/data_source.py index 72e6ceb..3402523 100644 --- a/app/api/data_source.py +++ b/app/api/data_source.py @@ -85,7 +85,7 @@ async def list_locations(request: Request, data_source_name: str, config: dict) @router.post("") async def connect_data_source(request: Request, dto: AddDataSourceDto, background_tasks: BackgroundTasks) -> int: logger.info(f"Adding data source {dto.name} with config {json.dumps(dto.config)}") - data_source = DataSourceContext.create_data_source(name=dto.name, config=dto.config) + data_source = await DataSourceContext.create_data_source(name=dto.name, config=dto.config) Posthog.added_data_source(uuid=request.headers.get('uuid'), name=dto.name) # in main.py we have a background task that runs every 5 minutes and indexes the data source # but here we want to index the data source immediately diff --git a/app/clear_ack_queue.sh b/app/clear_ack_queue.sh new file mode 100755 index 0000000..ed183b8 --- /dev/null +++ b/app/clear_ack_queue.sh @@ -0,0 +1 @@ +sqlite3 ~/.gerev/storage/tasks.sqlite3/data.db 'delete from ack_queue_task where _id in (select _id from ack_queue_task);' diff --git a/app/clear_data_sources.sh b/app/clear_data_sources.sh new file mode 100644 index 0000000..f241e9c --- /dev/null +++ b/app/clear_data_sources.sh @@ -0,0 +1 @@ +sqlite3 ~/.gerev/storage/db.sqlite3 'delete from data_source where id in (select id from data_source);' diff --git a/app/data_source/api/base_data_source.py b/app/data_source/api/base_data_source.py index c3daa63..0cf475f 100644 --- a/app/data_source/api/base_data_source.py +++ b/app/data_source/api/base_data_source.py @@ -61,7 +61,7 @@ def get_config_fields() -> List[ConfigField]: @staticmethod @abstractmethod - def validate_config(config: Dict) -> None: + async def validate_config(config: Dict) -> None: """ Validates the config and raises an exception if it's invalid. """ diff --git a/app/data_source/api/context.py b/app/data_source/api/context.py index c751166..2acbe12 100644 --- a/app/data_source/api/context.py +++ b/app/data_source/api/context.py @@ -6,9 +6,10 @@ from data_source.api.base_data_source import BaseDataSource from data_source.api.dynamic_loader import DynamicLoader, ClassInfo from data_source.api.exception import KnownException -from db_engine import Session +from db_engine import Session, async_session +from pydantic.error_wrappers import ValidationError from schemas import DataSourceType, DataSource - +from sqlalchemy import select logger = logging.getLogger(__name__) @@ -48,22 +49,31 @@ def get_data_source_classes(cls) -> Dict[str, BaseDataSource]: return cls._data_source_classes @classmethod - def create_data_source(cls, name: str, config: dict) -> BaseDataSource: - with Session() as session: - data_source_type = session.query(DataSourceType).filter_by(name=name).first() + async def create_data_source(cls, name: str, config: dict) -> BaseDataSource: + async with async_session() as session: + data_source_type = await session.execute( + select(DataSourceType).filter_by(name=name) + ) + data_source_type = data_source_type.scalar_one_or_none() if data_source_type is None: raise KnownException(message=f"Data source type {name} does not exist") data_source_class = DynamicLoader.get_data_source_class(name) logger.info(f"validating config for data source {name}") - data_source_class.validate_config(config) + await data_source_class.validate_config(config) config_str = json.dumps(config) - data_source_row = DataSource(type_id=data_source_type.id, config=config_str, created_at=datetime.now()) + data_source_row = DataSource( + type_id=data_source_type.id, + config=config_str, + created_at=datetime.now(), + ) session.add(data_source_row) - session.commit() + await session.commit() - data_source = data_source_class(config=config, data_source_id=data_source_row.id) + data_source = data_source_class( + config=config, data_source_id=data_source_row.id + ) cls._data_source_instances[data_source_row.id] = data_source return data_source @@ -95,8 +105,12 @@ def _load_connected_sources_from_db(cls): for data_source in data_sources: data_source_cls = DynamicLoader.get_data_source_class(data_source.type.name) config = json.loads(data_source.config) - data_source_instance = data_source_cls(config=config, data_source_id=data_source.id, - last_index_time=data_source.last_indexed_at) + try: + data_source_instance = data_source_cls(config=config, data_source_id=data_source.id, + last_index_time=data_source.last_indexed_at) + except ValidationError as e: + logger.error(f"Error loading data source {data_source.id}: {e}") + return cls._data_source_instances[data_source.id] = data_source_instance cls._initialized = True diff --git a/app/data_source/api/utils.py b/app/data_source/api/utils.py index 013ac4d..2af09c0 100644 --- a/app/data_source/api/utils.py +++ b/app/data_source/api/utils.py @@ -5,37 +5,35 @@ from io import BytesIO from typing import Optional import time +import threading from functools import wraps import requests + +logger = logging.getLogger(__name__) + + def rate_limit(*, allowed_per_second: int): max_period = 1.0 / allowed_per_second + last_call = [time.perf_counter()] + lock = threading.Lock() + def decorate(func): - last_call = [time.perf_counter()] @wraps(func) def limit(*args, **kwargs): - with rate_limiter(last_call, max_period): - return func(*args, **kwargs) + with lock: + elapsed = time.perf_counter() - last_call[0] + hold = max_period - elapsed + if hold > 0: + time.sleep(hold) + result = func(*args, **kwargs) + last_call[0] = time.perf_counter() + return result return limit return decorate -def rate_limiter(last_call, max_period): - class RateLimiter: - def __enter__(self): - elapsed = time.perf_counter() - last_call[0] - hold = max_period - elapsed - if hold > 0: - time.sleep(hold) - def __exit__(self, exc_type, exc_val, exc_tb): - last_call[0] = time.perf_counter() - return RateLimiter() - - -logger = logging.getLogger(__name__) - - def snake_case_to_pascal_case(snake_case_string: str): """Converts a snake case string to a PascalCase string""" components = snake_case_string.split('_') diff --git a/app/data_source/sources/bookstack/bookstack.py b/app/data_source/sources/bookstack/bookstack.py index e0901f3..9c8ff14 100644 --- a/app/data_source/sources/bookstack/bookstack.py +++ b/app/data_source/sources/bookstack/bookstack.py @@ -132,7 +132,7 @@ def list_books(book_stack: BookStack) -> List[Dict]: raise e @staticmethod - def validate_config(config: Dict) -> None: + async def validate_config(config: Dict) -> None: try: parsed_config = BookStackConfig(**config) book_stack = BookStack(url=parsed_config.url, token_id=parsed_config.token_id, diff --git a/app/data_source/sources/confluence/confluence.py b/app/data_source/sources/confluence/confluence.py index 67dea3b..5be8172 100644 --- a/app/data_source/sources/confluence/confluence.py +++ b/app/data_source/sources/confluence/confluence.py @@ -61,7 +61,7 @@ def list_all_spaces(confluence: Confluence) -> List[Location]: return spaces @staticmethod - def validate_config(config: Dict) -> None: + async def validate_config(config: Dict) -> None: try: client = ConfluenceDataSource.confluence_client_from_config(config) ConfluenceDataSource.list_spaces(confluence=client) diff --git a/app/data_source/sources/confluence/confluence_cloud.py b/app/data_source/sources/confluence/confluence_cloud.py index 79d0577..6799cf8 100644 --- a/app/data_source/sources/confluence/confluence_cloud.py +++ b/app/data_source/sources/confluence/confluence_cloud.py @@ -25,7 +25,7 @@ def get_config_fields() -> List[ConfigField]: ] @staticmethod - def validate_config(config: Dict) -> None: + async def validate_config(config: Dict) -> None: try: client = ConfluenceCloudDataSource.confluence_client_from_config(config) ConfluenceCloudDataSource.list_spaces(confluence=client) diff --git a/app/data_source/sources/google_drive/google_drive.py b/app/data_source/sources/google_drive/google_drive.py index 120d2fa..26d1a8a 100644 --- a/app/data_source/sources/google_drive/google_drive.py +++ b/app/data_source/sources/google_drive/google_drive.py @@ -43,7 +43,7 @@ def get_config_fields() -> List[ConfigField]: ] @staticmethod - def validate_config(config: Dict) -> None: + async def validate_config(config: Dict) -> None: try: scopes = ['https://www.googleapis.com/auth/drive.readonly'] parsed_config = GoogleDriveConfig(**config) diff --git a/app/data_source/sources/mattermost/mattermost.py b/app/data_source/sources/mattermost/mattermost.py index bfe122c..f4c0c10 100644 --- a/app/data_source/sources/mattermost/mattermost.py +++ b/app/data_source/sources/mattermost/mattermost.py @@ -54,7 +54,7 @@ def get_config_fields() -> List[ConfigField]: ] @staticmethod - def validate_config(config: Dict) -> None: + async def validate_config(config: Dict) -> None: try: parsed_config = MattermostConfig(**config) maattermost = Driver(options=asdict(parsed_config)) diff --git a/app/data_source/sources/rocketchat/rocketchat.py b/app/data_source/sources/rocketchat/rocketchat.py index bfc196f..5f79633 100644 --- a/app/data_source/sources/rocketchat/rocketchat.py +++ b/app/data_source/sources/rocketchat/rocketchat.py @@ -54,7 +54,7 @@ def get_display_name(cls) -> str: return "Rocket.Chat" @staticmethod - def validate_config(config: Dict) -> None: + async def validate_config(config: Dict) -> None: rocket_chat_config = RocketchatConfig(**config) should_verify_ssl = os.environ.get('ROCKETCHAT_VERIFY_SSL') is not None rocket_chat = RocketChat(user_id=rocket_chat_config.token_id, auth_token=rocket_chat_config.token_secret, diff --git a/app/data_source/sources/slack/slack.py b/app/data_source/sources/slack/slack.py index 4758b27..24d3145 100644 --- a/app/data_source/sources/slack/slack.py +++ b/app/data_source/sources/slack/slack.py @@ -41,7 +41,7 @@ def get_config_fields() -> List[ConfigField]: ] @staticmethod - def validate_config(config: Dict) -> None: + async def validate_config(config: Dict) -> None: slack_config = SlackConfig(**config) slack = WebClient(token=slack_config.token) slack.auth_test() diff --git a/app/data_source/sources/stackoverflow/stackoverflow.py b/app/data_source/sources/stackoverflow/stackoverflow.py index eb436b5..2f31dee 100644 --- a/app/data_source/sources/stackoverflow/stackoverflow.py +++ b/app/data_source/sources/stackoverflow/stackoverflow.py @@ -1,4 +1,5 @@ import logging +import time from dataclasses import dataclass from datetime import datetime from typing import Dict, List, Optional @@ -15,13 +16,13 @@ @dataclass class StackOverflowPost: - post_id: int - post_type: str link: str - body_markdown: str score: int last_activity_date: int creation_date: int + post_id: Optional[int] = None + post_type: Optional[str] = None + body_markdown: Optional[str] = None owner_account_id: Optional[int] = None owner_reputation: Optional[int] = None owner_user_id: Optional[int] = None @@ -33,6 +34,8 @@ class StackOverflowPost: last_edit_date: Optional[str] = None tags: Optional[List[str]] = None view_count: Optional[int] = None + article_id: Optional[int] = None + article_type: Optional[str] = None class StackOverflowConfig(BaseDataSourceConfig): api_key: str @@ -50,7 +53,12 @@ def rate_limited_get(url, headers): Note that exactly what response an application gets (in terms of HTTP code, text, and so on) is undefined when subject to this ban; we consider > 30 request/sec per IP to be very abusive and thus cut the requests off very harshly. ''' - return requests.get(url, headers=headers) + resp = requests.get(url, headers=headers) + if resp.status_code == 429: + logger.warning('Rate limited, sleeping for 5 minutes') + time.sleep(300) + return rate_limited_get(url, headers) + return resp class StackOverflowDataSource(BaseDataSource): @@ -63,7 +71,7 @@ def get_config_fields() -> List[ConfigField]: ] @staticmethod - def validate_config(config: Dict) -> None: + async def validate_config(config: Dict) -> None: so_config = StackOverflowConfig(**config) url = f'https://api.stackoverflowteams.com/2.3/questions?&team={so_config.team_name}' response = rate_limited_get(url, headers={'X-API-Access-Token': so_config.api_key}) @@ -112,8 +120,9 @@ def _fetch_posts(self, *, api_key: str, team_name: str, page: int, doc_type: str self.add_task_to_queue(self._fetch_posts, api_key=self._api_key, team_name=self._team_name, page=page + 1, doc_type=doc_type) def _feed_new_documents(self) -> None: - self.add_task_to_queue(self._fetch_posts, api_key=self._api_key, team_name=self._team_name, page=1, doc_type='articles') self.add_task_to_queue(self._fetch_posts, api_key=self._api_key, team_name=self._team_name, page=1, doc_type='posts') + # TODO: figure out how to get articles + # self.add_task_to_queue(self._fetch_posts, api_key=self._api_key, team_name=self._team_name, page=1, doc_type='articles') # def test(): diff --git a/app/db_engine.py b/app/db_engine.py index 579b27f..5ca5d52 100644 --- a/app/db_engine.py +++ b/app/db_engine.py @@ -5,11 +5,19 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession # import base document and then register all classes from schemas.base import Base from paths import SQLITE_DB_PATH -engine = create_engine(f'sqlite:///{SQLITE_DB_PATH}') +db_url = f'sqlite:///{SQLITE_DB_PATH}' +print('DB engine path:', db_url) +engine = create_engine(db_url) Base.metadata.create_all(engine) Session = sessionmaker(bind=engine) + +async_db_url = db_url.replace('sqlite', 'sqlite+aiosqlite', 1) +print('ASYNC DB engine path:', async_db_url) +async_engine = create_async_engine(async_db_url) +async_session = sessionmaker(async_engine, expire_on_commit=False, class_=AsyncSession) diff --git a/app/requirements.txt b/app/requirements.txt index 3fb62a6..2f2178b 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -1,25 +1,118 @@ -faiss-cpu -transformers -sentence_transformers -sqlalchemy>=2.0.4 -fastapi -uvicorn -rank_bm25 -atlassian-python-api -beautifulsoup4 -python-dotenv -slack_sdk -pydantic -python-multipart -posthog -fastapi-restful -google-api-python-client -google-auth-httplib2 -google-auth-oauthlib -oauth2client -mammoth -python-pptx -alembic -rocketchat-API -mattermostdriver -persistqueue +aiosqlite==0.18.0 +alembic==1.10.2 +anyio==3.6.2 +asttokens==2.2.1 +atlassian-python-api==3.35.0 +backcall==0.2.0 +backoff==2.2.1 +beautifulsoup4==4.12.0 +cachetools==5.3.0 +certifi==2022.12.7 +charset-normalizer==3.1.0 +click==8.1.3 +cmake==3.26.1 +cobble==0.1.3 +decorator==5.1.1 +Deprecated==1.2.13 +executing==1.2.0 +faiss-cpu==1.7.3 +fastapi==0.95.0 +fastapi-restful==0.4.3 +filelock==3.10.7 +google-api-core==2.11.0 +google-api-python-client==2.83.0 +google-auth==2.17.0 +google-auth-httplib2==0.1.0 +google-auth-oauthlib==1.0.0 +googleapis-common-protos==1.59.0 +greenlet==2.0.2 +h11==0.14.0 +httplib2==0.22.0 +huggingface-hub==0.13.3 +idna==3.4 +ipython==8.11.0 +jedi==0.18.2 +Jinja2==3.1.2 +joblib==1.2.0 +lit==16.0.0 +lxml==4.9.2 +Mako==1.2.4 +mammoth==1.5.0 +MarkupSafe==2.1.2 +matplotlib-inline==0.1.6 +mattermostdriver==7.3.2 +monotonic==1.6 +mpmath==1.3.0 +networkx==3.0 +nltk==3.8.1 +numpy==1.24.2 +nvidia-cublas-cu11==11.10.3.66 +nvidia-cuda-cupti-cu11==11.7.101 +nvidia-cuda-nvrtc-cu11==11.7.99 +nvidia-cuda-runtime-cu11==11.7.99 +nvidia-cudnn-cu11==8.5.0.96 +nvidia-cufft-cu11==10.9.0.58 +nvidia-curand-cu11==10.2.10.91 +nvidia-cusolver-cu11==11.4.0.1 +nvidia-cusparse-cu11==11.7.4.91 +nvidia-nccl-cu11==2.14.3 +nvidia-nvtx-cu11==11.7.91 +oauth2client==4.1.3 +oauthlib==3.2.2 +packaging==23.0 +parso==0.8.3 +persist-queue==0.8.0 +persistQueue==0.1.6 +pexpect==4.8.0 +pickleshare==0.7.5 +Pillow==9.4.0 +posthog==2.4.0 +prompt-toolkit==3.0.38 +protobuf==4.22.1 +psutil==5.9.4 +ptyprocess==0.7.0 +pure-eval==0.2.2 +pyasn1==0.4.8 +pyasn1-modules==0.2.8 +pydantic==1.10.7 +Pygments==2.14.0 +pyparsing==3.0.9 +python-dateutil==2.8.2 +python-dotenv==1.0.0 +python-multipart==0.0.6 +python-pptx==0.6.21 +PyYAML==6.0 +rank-bm25==0.2.2 +regex==2023.3.23 +requests==2.28.2 +requests-oauthlib==1.3.1 +rocketchat-API==1.29.0 +rsa==4.9 +scikit-learn==1.2.2 +scipy==1.10.1 +sentence-transformers==2.2.2 +sentencepiece==0.1.97 +six==1.16.0 +slack-sdk==3.20.2 +sniffio==1.3.0 +soupsieve==2.4 +SQLAlchemy==2.0.7 +stack-data==0.6.2 +starlette==0.26.1 +sympy==1.11.1 +threadpoolctl==3.1.0 +tokenizers==0.13.2 +torch==2.0.0 +torchvision==0.15.1 +tqdm==4.65.0 +traitlets==5.9.0 +transformers==4.27.4 +triton==2.0.0 +typing_extensions==4.5.0 +uritemplate==4.1.1 +urllib3==1.26.15 +uvicorn==0.21.1 +wcwidth==0.2.6 +websockets==10.4 +wrapt==1.15.0 +XlsxWriter==3.0.9 diff --git a/app/static/data_source_icons/stackoverflow.png b/app/static/data_source_icons/stackoverflow.png deleted file mode 100644 index af11628173afc020456abae754b94fd4818778b2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 39301 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 void - onRemoved: (removed: ConnectedDataSource) => void - onClose: () => void + dataSourceTypesDict: { [key: string]: DataSourceType } + connectedDataSources: ConnectedDataSource[] + inIndexing: boolean + onAdded: (newlyConnected: ConnectedDataSource) => void + onRemoved: (removed: ConnectedDataSource) => void + onClose: () => void } const Option = props => ( - -
- logo - {props.label} -
-
+ +
+ logo + {props.label} +
+
); const slackManifest = { - "display_information": { - "name": "GerevAI" - }, - "features": { - "bot_user": { - "display_name": "GerevAIBot" - } - }, - "oauth_config": { - "scopes": { - "bot": [ - "channels:history", - "channels:join", - "channels:read", - "users:read" - ] - } - } + "display_information": { + "name": "GerevAI" + }, + "features": { + "bot_user": { + "display_name": "GerevAIBot" + } + }, + "oauth_config": { + "scopes": { + "bot": [ + "channels:history", + "channels:join", + "channels:read", + "users:read" + ] + } + } } +const capitilize = (str: string) => { + return str.charAt(0).toUpperCase() + str.slice(1); +} +const InputField = ({label, placeholder, onChange}: any) => { + let [value, setValue] = useState('') + return ( +
+

{label}

+ { + setValue(event.target.value) + onChange(event.target.value) + }} + className="w-96 h-10 rounded-lg bg-[#352C45] text-white p-2" + placeholder={placeholder}> +
+ ); +} +const TextAreaField = ({label, placeholder, onChange}: any) => { + let [value, setValue] = useState('') + return ( +
+

{label}

+ +
+ ) +} -export default class DataSourcePanel extends React.Component { - - constructor(props) { - super(props); - this.state = { - selectOptions: [], - isAdding: false, - isLoading: false, - isSelectingLocations: false, - locations: [], - selectedLocations: [], - selectedDataSource: { value: 'unknown', label: 'unknown', imageBase64: '', configFields: [], hasAdditionalSteps: false }, - removeInProgressIndex: -1, - editMode: false - } - } - - async componentDidMount() { - let options = Object.keys(this.props.dataSourceTypesDict).map((key) => { - let data_source = this.props.dataSourceTypesDict[key]; - return { - value: data_source.name, - label: data_source.display_name, - imageBase64: data_source.image_base64, - configFields: data_source.config_fields, - hasAdditionalSteps: data_source.has_prerequisites - } - } - ); - - this.setState({ - selectOptions: options, - selectedDataSource: options[0], - }) - } - - capitilize(str: string) { - return str.charAt(0).toUpperCase() + str.slice(1); - } - - dataSourceToAddSelected(dataSource: DataSourceType) { - let selectedDataSource = this.state.selectOptions.find((option) => { - return option.value === dataSource.name - }) - - if (!selectedDataSource) { // shouldn't happen, typescript... - return; - } - - this.setState({ isAdding: true, selectedDataSource: selectedDataSource }) - } - - confirmDelete = (index: number) => { - confirmAlert({ - title: 'Alert', - message: `Are you sure you want to delete ${this.capitilize(this.props.connectedDataSources[index].name)}?`, - buttons: [ - { - label: 'Yes, delete it', - onClick: () => this.removeDataSource(index) - }, - { - label: 'No' - } - ] - }); - }; - - - render() { - return ( -
+const DataSourcePanel = (props: DataSourcePanelProps) => { + let options = Object.keys(props.dataSourceTypesDict).map((key) => { + const { name, display_name, image_base64, config_fields, has_prerequisites } = props.dataSourceTypesDict[key]; + return { + value: name, + label: display_name, + imageBase64: image_base64, + configFields: config_fields, + hasAdditionalSteps: has_prerequisites, + }; + }); + let [state, setState] = React.useState({ + selectOptions: options, + selectedDataSource: options[0] || { + value: 'unknown', + label: 'unknown', + imageBase64: '', + configFields: [], + hasAdditionalSteps: false + }, + isAdding: false, + isLoading: false, + isSelectingLocations: false, + locations: [], + selectedLocations: [], + removeInProgressIndex: -1, + editMode: false + }) + + const dataSourceToAddSelected = (dataSource: DataSourceType) => { + let selectedDataSource = state.selectOptions.find((option) => { + return option.value === dataSource.name + }) + + if (!selectedDataSource) { // shouldn't happen, typescript... + return; + } + + setState({...state, isAdding: true, selectedDataSource: selectedDataSource}) + } + + const confirmDelete = (index: number) => { + confirmAlert({ + title: 'Alert', + message: `Are you sure you want to delete ${capitilize(props.connectedDataSources[index].name)}?`, + buttons: [ + { + label: 'Yes, delete it', + onClick: () => removeDataSource(index) + }, + { + label: 'No' + } + ] + }); + }; + const proceed = () => { + if (!state.selectedDataSource.hasAdditionalSteps) { + return submit(); + } + + if (!state.isSelectingLocations) { + return getLocations(); + } + + if (state.selectedLocations.length === 0) { + toast.error("Please select at least one location to index"); + return; + } + + submit(); + } + + const copyManifest = () => { + let manifestText = JSON.stringify(slackManifest); + if (!copy(manifestText)) { + toast.error("Error copying manifest"); + } else { + toast.success("Manifest copied to clipboard", {autoClose: 2000}); + } + } + + const getLocations = async () => { + if (!state.selectedDataSource) return; + + let config = {}; + state.selectedDataSource.configFields.forEach(field => { + config[field.name] = field.value; + }); + + toast.info("Looking for confluence spaces (this may take a while)", {autoClose: 6000}); + + setState({...state, isLoading: true}); + try { + let response = await api.post(`/data-sources/${state.selectedDataSource.value}/list-locations`, + config, { + headers: { + uuid: localStorage.getItem('uuid') + } + }) + toast.dismiss(); + toast.success("Listed locations", {autoClose: 2000}); + setState({...state, isSelectingLocations: true, locations: response.data, isLoading: false}); + } catch (error) { + toast.dismiss(); + toast.error("Error listing locations: " + error.response.data, {autoClose: 10000}); + setState({...state, isLoading: false}); + + } + } + + const indexEverything = () => { + setState({...state, selectedLocations: []}); + submit(); + } + + const submit = async () => { + if (!state.selectedDataSource || state.isLoading) return; + + let config = {}; + state.selectedDataSource.configFields.forEach(field => { + config[field.name] = field.value; + }); + config['locations_to_index'] = state.selectedLocations; + + let payload = { + name: state.selectedDataSource.value, + config: config + } + setState({...state, isLoading: true}); + try { + let response = await api.post(`/data-sources`, payload, { + headers: { + uuid: localStorage.getItem('uuid') + } + }) + toast.success("Data source added successfully, indexing..."); + let selectedDataSource = state.selectedDataSource; + selectedDataSource.configFields.forEach(field => { + field.value = ''; + }); + setState({...state, selectedDataSource: selectedDataSource}); + props.onAdded({name: state.selectedDataSource.value, id: response.data}); + reset(); + } catch (error) { + toast.error("Error adding data source: " + error.response.data, {autoClose: 10000}); + setState({...state, isLoading: false}); + } + } + + const reset = () => { + setState({ + ...state, + isLoading: false, + isAdding: false, + selectedDataSource: state.selectOptions[0], + isSelectingLocations: false, + selectedLocations: [] + }); + } + const onLocationsSelectChange = (event) => { + setState({...state, selectedLocations: event}); + } + + const onSourceSelectChange = (event) => { + setState({...state, selectedDataSource: event, isSelectingLocations: false, selectedLocations: []}); + } + + const removeDataSource = async (index: number) => { + if (props.inIndexing) { + toast.error("Cannot remove data source while indexing is in progress"); + return; + } + + if (state.removeInProgressIndex !== -1) { + toast.error("Cannot remove data source while another is being removed"); + return; + } + + let connectedDataSource = props.connectedDataSources[index]; + setState({...state, removeInProgressIndex: index}); + try { + await api.delete(`/data-sources/${connectedDataSource.id}`, { + headers: { + uuid: localStorage.getItem('uuid') + } + }) + toast.success(`${capitilize(connectedDataSource.name)} removed.`); + setState({...state, removeInProgressIndex: -1}); + props.onRemoved(connectedDataSource); + } catch (error) { + toast.error("Error removing data source: " + error.response.data, {autoClose: 10000}); + + } + } + + const switchMode = () => { + setState({...state, editMode: !state.editMode}) + } + return ( +
{ - !this.state.isAdding &&

Data Source Panel

+ !state.isAdding && +

Data Source + Panel

} {/* X and Edit in top right */}
- +
{/* Panel main page */} { - !this.state.isAdding && ( -
-

- {this.props.connectedDataSources.length > 0 ? (this.state.editMode ? 'Edit mode:' : 'Active data sources:') : 'No Active Data Sources. Add Now!'} - {this.props.connectedDataSources.length > 0 && } -

-
- {this.props.connectedDataSources.map((dataSource, index) => { - return ( - // connected data source -
- data-source -

{this.props.dataSourceTypesDict[dataSource.name].display_name}

- - {this.state.editMode ? ( - this.state.removeInProgressIndex === index ? - ( - - ) : - ( - this.confirmDelete(index)} - className="transition duration-150 ease-in-out ml-6 fill-[#7d4ac3] hover:cursor-pointer text-2xl hover:fill-[#d80b0b]" /> - ) - ) - : - ( - - ) - } + !state.isAdding && ( +
+

+ {props.connectedDataSources.length > 0 ? (state.editMode ? 'Edit mode:' : 'Active data sources:') : 'No Active Data Sources. Add Now!'} + {props.connectedDataSources.length > 0 && } +

+
+ {props.connectedDataSources.map((dataSource, index) => { + return ( + // connected data source +
+ data-source +

{props.dataSourceTypesDict[dataSource.name].display_name}

+ + {state.editMode ? ( + state.removeInProgressIndex === index ? + ( + + ) : + ( + confirmDelete(index)} + className="transition duration-150 ease-in-out ml-6 fill-[#7d4ac3] hover:cursor-pointer text-2xl hover:fill-[#d80b0b]"/> + ) + ) + : + ( + + ) + } -
- ) - }) - } - { - Object.keys(this.props.dataSourceTypesDict).map((key) => { - let dataSource = this.props.dataSourceTypesDict[key]; - if (!this.state.editMode && !this.props.connectedDataSources.find((connectedDataSource) => connectedDataSource.name === dataSource.name)) { - return ( - // unconnected data source -
this.dataSourceToAddSelected(dataSource)} className="flex hover:text-[#9875d4] py-2 pl-5 pr-3 m-2 flex-row items-center justify-center bg-[#36323b] hover:border-[#9875d4] rounded-lg font-poppins leading-[28px] border-[#777777] border-b-[.5px] transition duration-300 ease-in-out"> - - {/*

Add

*/} -

{dataSource.display_name}

-
- ) - } - return null; + ) + }) + } + { + Object.keys(props.dataSourceTypesDict).map((key, index) => { + let dataSource = props.dataSourceTypesDict[key]; + if (!state.editMode && !props.connectedDataSources.find((connectedDataSource) => connectedDataSource.name === dataSource.name)) { + return ( + // unconnected data source +
dataSourceToAddSelected(dataSource)} + className="flex hover:text-[#9875d4] py-2 pl-5 pr-3 m-2 flex-row items-center justify-center bg-[#36323b] hover:border-[#9875d4] rounded-lg font-poppins leading-[28px] border-[#777777] border-b-[.5px] transition duration-300 ease-in-out"> + + {/*

Add

*/} +

{dataSource.display_name}

+ +
+ ) + } + return null; - }) - } -
this.setState({ isAdding: true })} className="flex hover:text-[#9875d4] py-2 pl-5 pr-3 m-2 flex-row items-center justify-center bg-[#36323b] hover:border-[#9875d4] rounded-lg font-poppins leading-[28px] border-[#777777] border-b-[.5px] transition duration-300 ease-in-out"> -

Add

- + }) + } +
setState({...state, isAdding: true})} + className="flex hover:text-[#9875d4] py-2 pl-5 pr-3 m-2 flex-row items-center justify-center bg-[#36323b] hover:border-[#9875d4] rounded-lg font-poppins leading-[28px] border-[#777777] border-b-[.5px] transition duration-300 ease-in-out"> +

Add

+ +
-
-
- ) +
+ ) } {/* instructions + input page */} { - this.state.isAdding && ( -
-
- - ({ + ...baseStyles, + backgroundColor: '#352c45', + borderColor: '#472f61' + }), + singleValue: (baseStyles, state) => ({ + ...baseStyles, + color: '#ffffff', + }), + option: (baseStyles, state) => ({ + ...baseStyles, + backgroundColor: '#352c45', + ':hover': { + backgroundColor: '#52446b', + }, + }), + valueContainer: (baseStyles, state) => ({ + ...baseStyles, + backgroundColor: '#352c45', + }), + menuList: (baseStyles, state) => ({ + ...baseStyles, + backgroundColor: '#352c45', + }), + }}/> +
+ { + // instructions +
+
+ { + state.selectedDataSource.value === 'mattermost' && ( + 1. {'Go to your Mattermost -> top-right profile picture -> Profile'} 2. {'Security -> Personal Access Tokens -> Create token -> Name it'} 3. {"Copy the Access Token"} - {"* Personal Access Tokens must be on"} - Click for more info + {"* Personal Access Tokens must be on"} - Click for more info - ) - } - { - this.state.selectedDataSource.value === 'confluence' && ( - + ) + } + { + state.selectedDataSource.value === 'confluence' && ( + 1. {'Go to your Confluence -> top-right profile picture -> Settings'} 2. {'Personal Access Tokens -> Create token -> Name it'} 3. {"Uncheck 'Automatic expiry', create and copy the token"} - ) - } - { - this.state.selectedDataSource.value === 'confluence_cloud' && ( - + ) + } + { + state.selectedDataSource.value === 'confluence_cloud' && ( + 1. {'Go to your Confluence -> top-right profile picture -> Manage account'} 2. {'go security tab (at top) -> Create and manage API tokens -> Create API token'} 3. {"Name it, create and copy the token"} - ) - } - {this.state.selectedDataSource.value === 'slack' && ( - // slack instructions - + ) + } + {state.selectedDataSource.value === 'slack' && ( + // slack instructions + 1. - @@ -306,19 +484,23 @@ export default class DataSourcePanel extends React.Component 2. Create new app from manifest in   - + Slack Apps -   +   - + Create new App {' ->'} - + From an app manifest - + BETA @@ -326,39 +508,43 @@ export default class DataSourcePanel extends React.Component 3. {"Install App to Workspace."} - + Install to Workspace 4. Click left panel OAuth & Permissions. - + 5. Copy the Bot User OAuth Token. - + *Gerev bot will join your channels. - ) - } - {this.state.selectedDataSource.value === 'google_drive' && ( - // Google Drive instructions - - Follow these instructions + ) + } + {state.selectedDataSource.value === 'google_drive' && ( + // Google Drive instructions + + Follow these instructions - )} - { - this.state.selectedDataSource.value === 'bookstack' && ( - + )} + { + state.selectedDataSource.value === 'bookstack' && ( + 1. {'Go to your Bookstack -> top-right profile picture -> Edit profile'} 2. {'Scroll down to API tokens -> Create token -> Name it'} 3. {"Set 'Expiry Date' 01/01/2100, create, copy token id + token secret"} - ) - } - {this.state.selectedDataSource.value === 'rocketchat' && ( - + ) + } + {state.selectedDataSource.value === 'rocketchat' && ( + 1. {'In Rocket.Chat, click your profile picture -> My Account.'} 2. {'Click Personal Access Tokens.'} 3. {'Check "Ignore Two Factor Authentication".'} @@ -367,269 +553,130 @@ export default class DataSourcePanel extends React.Component6. {'Copy the token and user id here.'}

Note that the url must begin with either http:// or https://

- )} - {this.state.selectedDataSource.value === 'stack_overflow' && ( - + )} + {state.selectedDataSource.value === 'stack_overflow' && ( + 1. {'Visit: https://stackoverflowteams.com/users/pats/'} 2. {'Click Create a new PAT'} 3. {'Name the token, and pick the team scope.'} 4. {'Select an expiration date'} 5. {'Click Create'} - )} -
- -
- {/* for each field */} - { - this.state.selectedDataSource.configFields.map((field, index) => { - if (field.input_type === 'text' || field.input_type === 'password') { - return ( -
-

{field.label}

- { field.value = event.target.value }} - className="w-96 h-10 rounded-lg bg-[#352C45] text-white p-2" - placeholder={field.placeholder}> -
- ) - } else if (field.input_type === 'textarea') { - return ( -
-

{field.label}

- -
- ) + )} +
+ +
+ {/* for each field */} + { + state.selectedDataSource.configFields.map((field, index) => { + if (field.input_type === 'text' || field.input_type === 'password') { + return ( { + state.selectedDataSource.configFields[index].value = value; + setState({...state}); + }} />) + } else if (field.input_type === 'textarea') { + return ( { + state.selectedDataSource.configFields[index].value = value; + setState({...state}); + }} />) + } + return null; + }) } - return null; - }) - } - {/* Selecting locations */} - { - this.state.isSelectingLocations && ( -
-
-
-

Select locations to index

- ({ + ...baseStyles, + backgroundColor: '#352c45', + borderColor: '#472f61' + }), + singleValue: (baseStyles, state) => ({ + ...baseStyles, + color: '#ffffff', + }), + option: (baseStyles, state) => ({ + ...baseStyles, + backgroundColor: '#352c45', + ':hover': { + backgroundColor: '#52446b', + }, + }), + valueContainer: (baseStyles, state) => ({ + ...baseStyles, + backgroundColor: '#352c45', + color: '#ffffff', + }), + menuList: (baseStyles, state) => ({ + ...baseStyles, + backgroundColor: '#352c45', + }), + input: (baseStyles, state) => ({ + ...baseStyles, + color: '#ffffff', + }) + }}/> +
+
+
+ ) + } +
-

or Index everything

- - { - this.state.isLoading && - - } + {!state.isLoading && !state.selectedDataSource.hasAdditionalSteps && +

Submit

} + + {!state.isLoading && state.selectedDataSource.hasAdditionalSteps && +

+ {state.isSelectingLocations ? `Proceed with ${state.selectedLocations.length} locatons` : + 'Proceed'} +

} + + { + state.isLoading && + + }
- )} -
-
- } -
- ) + { + state.isSelectingLocations && ( +
+

or Index everything

+ + { + state.isLoading && + + } +
+ )} +
+
+ } +
+ ) } -
- ); - } - - proceed = () => { - if (!this.state.selectedDataSource.hasAdditionalSteps) { - return this.submit(); - } - - if (!this.state.isSelectingLocations) { - return this.getLocations(); - } - - if (this.state.selectedLocations.length === 0) { - toast.error("Please select at least one location to index"); - return; - } - - this.submit(); - } - - copyManifest = () => { - let manifestText = JSON.stringify(slackManifest); - if (!copy(manifestText)) { - toast.error("Error copying manifest"); - } else { - toast.success("Manifest copied to clipboard", { autoClose: 2000 }); - } - } - - getLocations = () => { - if (!this.state.selectedDataSource) return; - - let config = {}; - this.state.selectedDataSource.configFields.forEach(field => { - config[field.name] = field.value; - }); - - toast.info("Looking for confluence spaces (this may take a while)", { autoClose: 6000 }); - - this.setState({ isLoading: true }); - api.post(`/data-sources/${this.state.selectedDataSource.value}/list-locations`, config, { - headers: { - uuid: localStorage.getItem('uuid') - } - }).then(response => { - toast.dismiss(); - toast.success("Listed locations", { autoClose: 2000 }); - this.setState({ isSelectingLocations: true, locations: response.data, isLoading: false }); - }).catch(error => { - toast.dismiss(); - toast.error("Error listing locations: " + error.response.data, { autoClose: 10000 }); - this.setState({ isLoading: false }); - }); - } - - indexEverything = () => { - this.setState({ selectedLocations: [] }); - this.submit(); - } - - submit = () => { - if (!this.state.selectedDataSource || this.state.isLoading) return; - - let config = {}; - this.state.selectedDataSource.configFields.forEach(field => { - config[field.name] = field.value; - }); - config['locations_to_index'] = this.state.selectedLocations; - - let payload = { - name: this.state.selectedDataSource.value, - config: config - } - this.setState({ isLoading: true }); - api.post(`/data-sources`, payload, { - headers: { - uuid: localStorage.getItem('uuid') - } - }).then(response => { - toast.success("Data source added successfully, indexing..."); - - let selectedDataSource = this.state.selectedDataSource; - selectedDataSource.configFields.forEach(field => { - field.value = ''; - }); - this.setState({ selectedDataSource: selectedDataSource }); - this.props.onAdded({ name: this.state.selectedDataSource.value, id: response.data }); - this.reset(); - }).catch(error => { - toast.error("Error adding data source: " + error.response.data, { autoClose: 10000 }); - this.setState({ isLoading: false }); - }); - } - - reset = () => { - this.setState({ isLoading: false, isAdding: false, selectedDataSource: this.state.selectOptions[0], isSelectingLocations: false, selectedLocations: [] }); - } - - onLocationsSelectChange = (event) => { - this.setState({ selectedLocations: event }); - } - - onSourceSelectChange = (event) => { - this.setState({ selectedDataSource: event, isSelectingLocations: false, selectedLocations: [] }); - } - - removeDataSource = (index: number) => { - if (this.props.inIndexing) { - toast.error("Cannot remove data source while indexing is in progress"); - return; - } - - if (this.state.removeInProgressIndex !== -1) { - toast.error("Cannot remove data source while another is being removed"); - return; - } - - let connectedDataSource = this.props.connectedDataSources[index]; - this.setState({ removeInProgressIndex: index }); - - api.delete(`/data-sources/${connectedDataSource.id}`, { - headers: { - uuid: localStorage.getItem('uuid') - } - }).then(response => { - toast.success(`${this.capitilize(connectedDataSource.name)} removed.`); - this.setState({ removeInProgressIndex: -1 }); - this.props.onRemoved(connectedDataSource); - }).catch(error => { - toast.error("Error removing data source: " + error.response.data, { autoClose: 10000 }); - }); - } - - - swithcMode = () => { - this.setState({ editMode: !this.state.editMode }) - } +
+ ); } +export default DataSourcePanel; \ No newline at end of file