diff --git a/common/constants.py b/common/constants.py index 83a18f9ba..26526f868 100644 --- a/common/constants.py +++ b/common/constants.py @@ -129,6 +129,7 @@ class FileSource(StrEnum): OCI_STORAGE = "oci_storage" GOOGLE_CLOUD_STORAGE = "google_cloud_storage" AIRTABLE = "airtable" + ASANA = "asana" class PipelineTaskType(StrEnum): diff --git a/common/data_source/__init__.py b/common/data_source/__init__.py index 914233460..f79f349b3 100644 --- a/common/data_source/__init__.py +++ b/common/data_source/__init__.py @@ -37,6 +37,7 @@ from .teams_connector import TeamsConnector from .webdav_connector import WebDAVConnector from .moodle_connector import MoodleConnector from .airtable_connector import AirtableConnector +from .asana_connector import AsanaConnector from .config import BlobType, DocumentSource from .models import Document, TextSection, ImageSection, BasicExpertInfo from .exceptions import ( @@ -73,4 +74,5 @@ __all__ = [ "InsufficientPermissionsError", "UnexpectedValidationError", "AirtableConnector", + "AsanaConnector", ] diff --git a/common/data_source/asana_connector.py b/common/data_source/asana_connector.py new file mode 100644 index 000000000..1dddcb6df --- /dev/null +++ b/common/data_source/asana_connector.py @@ -0,0 +1,454 @@ +from collections.abc import Iterator +import time +from datetime import datetime +import logging +from typing import Any, Dict +import asana +import requests +from common.data_source.config import CONTINUE_ON_CONNECTOR_FAILURE, INDEX_BATCH_SIZE, DocumentSource +from common.data_source.interfaces import LoadConnector, PollConnector +from common.data_source.models import Document, GenerateDocumentsOutput, SecondsSinceUnixEpoch +from common.data_source.utils import extract_size_bytes, get_file_ext + + + +# https://github.com/Asana/python-asana/tree/master?tab=readme-ov-file#documentation-for-api-endpoints +class AsanaTask: + def __init__( + self, + id: str, + title: str, + text: str, + link: str, + last_modified: datetime, + project_gid: str, + project_name: str, + ) -> None: + self.id = id + self.title = title + self.text = text + self.link = link + self.last_modified = last_modified + self.project_gid = project_gid + self.project_name = project_name + + def __str__(self) -> str: + return f"ID: {self.id}\nTitle: {self.title}\nLast modified: {self.last_modified}\nText: {self.text}" + + +class AsanaAPI: + def __init__( + self, api_token: str, workspace_gid: str, team_gid: str | None + ) -> None: + self._user = None + self.workspace_gid = workspace_gid + self.team_gid = team_gid + + self.configuration = asana.Configuration() + self.api_client = asana.ApiClient(self.configuration) + self.tasks_api = asana.TasksApi(self.api_client) + self.attachments_api = asana.AttachmentsApi(self.api_client) + self.stories_api = asana.StoriesApi(self.api_client) + self.users_api = asana.UsersApi(self.api_client) + self.project_api = asana.ProjectsApi(self.api_client) + self.project_memberships_api = asana.ProjectMembershipsApi(self.api_client) + self.workspaces_api = asana.WorkspacesApi(self.api_client) + + self.api_error_count = 0 + self.configuration.access_token = api_token + self.task_count = 0 + + def get_tasks( + self, project_gids: list[str] | None, start_date: str + ) -> Iterator[AsanaTask]: + """Get all tasks from the projects with the given gids that were modified since the given date. + If project_gids is None, get all tasks from all projects in the workspace.""" + logging.info("Starting to fetch Asana projects") + projects = self.project_api.get_projects( + opts={ + "workspace": self.workspace_gid, + "opt_fields": "gid,name,archived,modified_at", + } + ) + start_seconds = int(time.mktime(datetime.now().timetuple())) + projects_list = [] + project_count = 0 + for project_info in projects: + project_gid = project_info["gid"] + if project_gids is None or project_gid in project_gids: + projects_list.append(project_gid) + else: + logging.debug( + f"Skipping project: {project_gid} - not in accepted project_gids" + ) + project_count += 1 + if project_count % 100 == 0: + logging.info(f"Processed {project_count} projects") + logging.info(f"Found {len(projects_list)} projects to process") + for project_gid in projects_list: + for task in self._get_tasks_for_project( + project_gid, start_date, start_seconds + ): + yield task + logging.info(f"Completed fetching {self.task_count} tasks from Asana") + if self.api_error_count > 0: + logging.warning( + f"Encountered {self.api_error_count} API errors during task fetching" + ) + + def _get_tasks_for_project( + self, project_gid: str, start_date: str, start_seconds: int + ) -> Iterator[AsanaTask]: + project = self.project_api.get_project(project_gid, opts={}) + project_name = project.get("name", project_gid) + team = project.get("team") or {} + team_gid = team.get("gid") + + if project.get("archived"): + logging.info(f"Skipping archived project: {project_name} ({project_gid})") + return + if not team_gid: + logging.info( + f"Skipping project without a team: {project_name} ({project_gid})" + ) + return + if project.get("privacy_setting") == "private": + if self.team_gid and team_gid != self.team_gid: + logging.info( + f"Skipping private project not in configured team: {project_name} ({project_gid})" + ) + return + logging.info( + f"Processing private project in configured team: {project_name} ({project_gid})" + ) + + simple_start_date = start_date.split(".")[0].split("+")[0] + logging.info( + f"Fetching tasks modified since {simple_start_date} for project: {project_name} ({project_gid})" + ) + + opts = { + "opt_fields": "name,memberships,memberships.project,completed_at,completed_by,created_at," + "created_by,custom_fields,dependencies,due_at,due_on,external,html_notes,liked,likes," + "modified_at,notes,num_hearts,parent,projects,resource_subtype,resource_type,start_on," + "workspace,permalink_url", + "modified_since": start_date, + } + tasks_from_api = self.tasks_api.get_tasks_for_project(project_gid, opts) + for data in tasks_from_api: + self.task_count += 1 + if self.task_count % 10 == 0: + end_seconds = time.mktime(datetime.now().timetuple()) + runtime_seconds = end_seconds - start_seconds + if runtime_seconds > 0: + logging.info( + f"Processed {self.task_count} tasks in {runtime_seconds:.0f} seconds " + f"({self.task_count / runtime_seconds:.2f} tasks/second)" + ) + + logging.debug(f"Processing Asana task: {data['name']}") + + text = self._construct_task_text(data) + + try: + text += self._fetch_and_add_comments(data["gid"]) + + last_modified_date = self.format_date(data["modified_at"]) + text += f"Last modified: {last_modified_date}\n" + + task = AsanaTask( + id=data["gid"], + title=data["name"], + text=text, + link=data["permalink_url"], + last_modified=datetime.fromisoformat(data["modified_at"]), + project_gid=project_gid, + project_name=project_name, + ) + yield task + except Exception: + logging.error( + f"Error processing task {data['gid']} in project {project_gid}", + exc_info=True, + ) + self.api_error_count += 1 + + def _construct_task_text(self, data: Dict) -> str: + text = f"{data['name']}\n\n" + + if data["notes"]: + text += f"{data['notes']}\n\n" + + if data["created_by"] and data["created_by"]["gid"]: + creator = self.get_user(data["created_by"]["gid"])["name"] + created_date = self.format_date(data["created_at"]) + text += f"Created by: {creator} on {created_date}\n" + + if data["due_on"]: + due_date = self.format_date(data["due_on"]) + text += f"Due date: {due_date}\n" + + if data["completed_at"]: + completed_date = self.format_date(data["completed_at"]) + text += f"Completed on: {completed_date}\n" + + text += "\n" + return text + + def _fetch_and_add_comments(self, task_gid: str) -> str: + text = "" + stories_opts: Dict[str, str] = {} + story_start = time.time() + stories = self.stories_api.get_stories_for_task(task_gid, stories_opts) + + story_count = 0 + comment_count = 0 + + for story in stories: + story_count += 1 + if story["resource_subtype"] == "comment_added": + comment = self.stories_api.get_story( + story["gid"], opts={"opt_fields": "text,created_by,created_at"} + ) + commenter = self.get_user(comment["created_by"]["gid"])["name"] + text += f"Comment by {commenter}: {comment['text']}\n\n" + comment_count += 1 + + story_duration = time.time() - story_start + logging.debug( + f"Processed {story_count} stories (including {comment_count} comments) in {story_duration:.2f} seconds" + ) + + return text + + def get_attachments(self, task_gid: str) -> list[dict]: + """ + Fetch full attachment info (including download_url) for a task. + """ + attachments: list[dict] = [] + + try: + # Step 1: list attachment compact records + for att in self.attachments_api.get_attachments_for_object( + parent=task_gid, + opts={} + ): + gid = att.get("gid") + if not gid: + continue + + try: + # Step 2: expand to full attachment + full = self.attachments_api.get_attachment( + attachment_gid=gid, + opts={ + "opt_fields": "name,download_url,size,created_at" + } + ) + + if full.get("download_url"): + attachments.append(full) + + except Exception: + logging.exception( + f"Failed to fetch attachment detail {gid} for task {task_gid}" + ) + self.api_error_count += 1 + + except Exception: + logging.exception(f"Failed to list attachments for task {task_gid}") + self.api_error_count += 1 + + return attachments + + def get_accessible_emails( + self, + workspace_id: str, + project_ids: list[str] | None, + team_id: str | None, + ): + + ws_users = self.users_api.get_users( + opts={ + "workspace": workspace_id, + "opt_fields": "gid,name,email" + } + ) + + workspace_users = { + u["gid"]: u.get("email") + for u in ws_users + if u.get("email") + } + + if not project_ids: + return set(workspace_users.values()) + + + project_emails = set() + + for pid in project_ids: + project = self.project_api.get_project( + pid, + opts={"opt_fields": "team,privacy_setting"} + ) + + if project["privacy_setting"] == "private": + if team_id and project.get("team", {}).get("gid") != team_id: + continue + + memberships = self.project_memberships_api.get_project_membership( + pid, + opts={"opt_fields": "user.gid,user.email"} + ) + + for m in memberships: + email = m["user"].get("email") + if email: + project_emails.add(email) + + return project_emails + + def get_user(self, user_gid: str) -> Dict: + if self._user is not None: + return self._user + self._user = self.users_api.get_user(user_gid, {"opt_fields": "name,email"}) + + if not self._user: + logging.warning(f"Unable to fetch user information for user_gid: {user_gid}") + return {"name": "Unknown"} + return self._user + + def format_date(self, date_str: str) -> str: + date = datetime.fromisoformat(date_str) + return time.strftime("%Y-%m-%d", date.timetuple()) + + def get_time(self) -> str: + return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + + +class AsanaConnector(LoadConnector, PollConnector): + def __init__( + self, + asana_workspace_id: str, + asana_project_ids: str | None = None, + asana_team_id: str | None = None, + batch_size: int = INDEX_BATCH_SIZE, + continue_on_failure: bool = CONTINUE_ON_CONNECTOR_FAILURE, + ) -> None: + self.workspace_id = asana_workspace_id + self.project_ids_to_index: list[str] | None = ( + asana_project_ids.split(",") if asana_project_ids else None + ) + self.asana_team_id = asana_team_id if asana_team_id else None + self.batch_size = batch_size + self.continue_on_failure = continue_on_failure + self.size_threshold = None + logging.info( + f"AsanaConnector initialized with workspace_id: {asana_workspace_id}" + ) + + def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None: + self.api_token = credentials["asana_api_token_secret"] + self.asana_client = AsanaAPI( + api_token=self.api_token, + workspace_gid=self.workspace_id, + team_gid=self.asana_team_id, + ) + self.workspace_users_email = self.asana_client.get_accessible_emails(self.workspace_id, self.project_ids_to_index, self.asana_team_id) + logging.info("Asana credentials loaded and API client initialized") + return None + + def poll_source( + self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch | None + ) -> GenerateDocumentsOutput: + start_time = datetime.fromtimestamp(start).isoformat() + logging.info(f"Starting Asana poll from {start_time}") + docs_batch: list[Document] = [] + tasks = self.asana_client.get_tasks(self.project_ids_to_index, start_time) + for task in tasks: + docs = self._task_to_documents(task) + docs_batch.extend(docs) + + if len(docs_batch) >= self.batch_size: + logging.info(f"Yielding batch of {len(docs_batch)} documents") + yield docs_batch + docs_batch = [] + + if docs_batch: + logging.info(f"Yielding final batch of {len(docs_batch)} documents") + yield docs_batch + + logging.info("Asana poll completed") + + def load_from_state(self) -> GenerateDocumentsOutput: + logging.info("Starting full index of all Asana tasks") + return self.poll_source(start=0, end=None) + + def _task_to_documents(self, task: AsanaTask) -> list[Document]: + docs: list[Document] = [] + + attachments = self.asana_client.get_attachments(task.id) + + for att in attachments: + try: + resp = requests.get(att["download_url"], timeout=30) + resp.raise_for_status() + file_blob = resp.content + filename = att.get("name", "attachment") + size_bytes = extract_size_bytes(att) + if ( + self.size_threshold is not None + and isinstance(size_bytes, int) + and size_bytes > self.size_threshold + ): + logging.warning( + f"{filename} exceeds size threshold of {self.size_threshold}. Skipping." + ) + continue + docs.append( + Document( + id=f"asana:{task.id}:{att['gid']}", + blob=file_blob, + extension=get_file_ext(filename) or "", + size_bytes=size_bytes, + doc_updated_at=task.last_modified, + source=DocumentSource.ASANA, + semantic_identifier=filename, + primary_owners=list(self.workspace_users_email), + ) + ) + except Exception: + logging.exception( + f"Failed to download attachment {att.get('gid')} for task {task.id}" + ) + + return docs + + + +if __name__ == "__main__": + import time + import os + + logging.info("Starting Asana connector test") + connector = AsanaConnector( + os.environ["WORKSPACE_ID"], + os.environ["PROJECT_IDS"], + os.environ["TEAM_ID"], + ) + connector.load_credentials( + { + "asana_api_token_secret": os.environ["API_TOKEN"], + } + ) + logging.info("Loading all documents from Asana") + all_docs = connector.load_from_state() + current = time.time() + one_day_ago = current - 24 * 60 * 60 # 1 day + logging.info("Polling for documents updated in the last 24 hours") + latest_docs = connector.poll_source(one_day_ago, current) + for docs in all_docs: + for doc in docs: + print(doc.id) + logging.info("Asana connector test completed") \ No newline at end of file diff --git a/common/data_source/config.py b/common/data_source/config.py index cbf5aade0..e36ee404b 100644 --- a/common/data_source/config.py +++ b/common/data_source/config.py @@ -54,6 +54,7 @@ class DocumentSource(str, Enum): DROPBOX = "dropbox" BOX = "box" AIRTABLE = "airtable" + ASANA = "asana" class FileOrigin(str, Enum): """File origins""" @@ -256,6 +257,10 @@ AIRTABLE_CONNECTOR_SIZE_THRESHOLD = int( os.environ.get("AIRTABLE_CONNECTOR_SIZE_THRESHOLD", 10 * 1024 * 1024) ) +ASANA_CONNECTOR_SIZE_THRESHOLD = int( + os.environ.get("ASANA_CONNECTOR_SIZE_THRESHOLD", 10 * 1024 * 1024) +) + _USER_NOT_FOUND = "Unknown Confluence User" _COMMENT_EXPANSION_FIELDS = ["body.storage.value"] diff --git a/pyproject.toml b/pyproject.toml index aa02097f4..c8a8755ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -149,6 +149,7 @@ dependencies = [ # "cryptography==46.0.3", # "jinja2>=3.1.0", "pyairtable>=3.3.0", + "asana>=5.2.2", ] [dependency-groups] diff --git a/rag/svr/sync_data_source.py b/rag/svr/sync_data_source.py index 3e2172d4b..f3e30c8cc 100644 --- a/rag/svr/sync_data_source.py +++ b/rag/svr/sync_data_source.py @@ -38,8 +38,7 @@ from api.db.services.connector_service import ConnectorService, SyncLogsService from api.db.services.knowledgebase_service import KnowledgebaseService from common import settings from common.config_utils import show_configs -from common.data_source import BlobStorageConnector, NotionConnector, DiscordConnector, GoogleDriveConnector, \ - MoodleConnector, JiraConnector, DropboxConnector, WebDAVConnector, AirtableConnector +from common.data_source import BlobStorageConnector, NotionConnector, DiscordConnector, GoogleDriveConnector, MoodleConnector, JiraConnector, DropboxConnector, WebDAVConnector, AirtableConnector, AsanaConnector from common.constants import FileSource, TaskStatus from common.data_source.config import INDEX_BATCH_SIZE from common.data_source.confluence_connector import ConfluenceConnector @@ -801,6 +800,48 @@ class Airtable(SyncBase): return document_generator +class Asana(SyncBase): + SOURCE_NAME: str = FileSource.ASANA + + async def _generate(self, task: dict): + self.connector = AsanaConnector( + self.conf.get("asana_workspace_id"), + self.conf.get("asana_project_ids"), + self.conf.get("asana_team_id"), + ) + credentials = self.conf.get("credentials", {}) + if "asana_api_token_secret" not in credentials: + raise ValueError("Missing asana_api_token_secret in credentials") + + self.connector.load_credentials( + {"asana_api_token_secret": credentials["asana_api_token_secret"]} + ) + + if task.get("reindex") == "1" or not task.get("poll_range_start"): + document_generator = self.connector.load_from_state() + begin_info = "totally" + else: + poll_start = task.get("poll_range_start") + if poll_start is None: + document_generator = self.connector.load_from_state() + begin_info = "totally" + else: + document_generator = self.connector.poll_source( + poll_start.timestamp(), + datetime.now(timezone.utc).timestamp(), + ) + begin_info = f"from {poll_start}" + + logging.info( + "Connect to Asana: workspace_id(%s), project_ids(%s), team_id(%s) %s", + self.conf.get("asana_workspace_id"), + self.conf.get("asana_project_ids"), + self.conf.get("asana_team_id"), + begin_info, + ) + + return document_generator + func_factory = { FileSource.S3: S3, @@ -821,6 +862,7 @@ func_factory = { FileSource.WEBDAV: WebDAV, FileSource.BOX: BOX, FileSource.AIRTABLE: Airtable, + FileSource.ASANA: Asana } diff --git a/uv.lock b/uv.lock index 075e6e03a..cfaaa401f 100644 --- a/uv.lock +++ b/uv.lock @@ -345,6 +345,21 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b7/7b/7bf42178d227b26d3daf94cdd22a72a4ed5bf235548c4f5aea49c51c6458/arxiv-2.1.3-py3-none-any.whl", hash = "sha256:6f43673ab770a9e848d7d4fc1894824df55edeac3c3572ea280c9ba2e3c0f39f", size = 11478, upload-time = "2024-06-25T02:56:17.032Z" }, ] +[[package]] +name = "asana" +version = "5.2.2" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "certifi" }, + { name = "python-dateutil" }, + { name = "six" }, + { name = "urllib3" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/db/59/af14efdd03d332c33d4a77aed8f1f7151e3de5c2441e4bea3b1c6dbcc9d7/asana-5.2.2.tar.gz", hash = "sha256:d280ce2e8edf0355ccf21e548d887617ca8c926e1cb41309b8a173ca3181632c", size = 126424, upload-time = "2025-09-24T21:31:04.055Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/26/5e/337125441af40aba86b087dee3dbe829413b6e42eac74defae2076926dbe/asana-5.2.2-py3-none-any.whl", hash = "sha256:1c8d15949a6cb9aa12363a5b7cfc6c0544cb3ae77290dd2e3255c0ec70668458", size = 203161, upload-time = "2025-09-24T21:31:02.401Z" }, +] + [[package]] name = "aspose-slides" version = "24.7.0" @@ -6089,6 +6104,7 @@ dependencies = [ { name = "akshare" }, { name = "anthropic" }, { name = "arxiv" }, + { name = "asana" }, { name = "aspose-slides", marker = "platform_machine == 'x86_64' or (platform_machine == 'arm64' and sys_platform == 'darwin')" }, { name = "atlassian-python-api" }, { name = "azure-identity" }, @@ -6219,6 +6235,7 @@ requires-dist = [ { name = "akshare", specifier = ">=1.15.78,<2.0.0" }, { name = "anthropic", specifier = "==0.34.1" }, { name = "arxiv", specifier = "==2.1.3" }, + { name = "asana", specifier = ">=5.2.2" }, { name = "aspose-slides", marker = "platform_machine == 'x86_64' or (platform_machine == 'arm64' and sys_platform == 'darwin')", specifier = "==24.7.0" }, { name = "atlassian-python-api", specifier = "==4.0.7" }, { name = "azure-identity", specifier = "==1.17.1" }, diff --git a/web/src/assets/svg/data-source/asana.svg b/web/src/assets/svg/data-source/asana.svg new file mode 100644 index 000000000..01b755d97 --- /dev/null +++ b/web/src/assets/svg/data-source/asana.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 9407cc7b4..4cd15d5c6 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -933,6 +933,8 @@ Example: Virtual Hosted Style`, boxDescription: 'Connect your Box drive to sync files and folders.', airtableDescription: 'Connect to Airtable and synchronize files from a specified table within a designated workspace.', + asanaDescription: + 'Connect to Asana and synchronize files from a specified workspace.', dropboxAccessTokenTip: 'Generate a long-lived access token in the Dropbox App Console with files.metadata.read, files.content.read, and sharing.read scopes.', moodleDescription: diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index 32fd04d5d..ea736a466 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -749,6 +749,8 @@ export default { 'Подключите ваш диск Box для синхронизации файлов и папок.', airtableDescription: 'Подключите Airtable и синхронизируйте файлы из указанной таблицы в заданном рабочем пространстве.', + asanaDescription: + 'Подключите Asana и синхронизируйте файлы из рабочего пространства.', google_driveDescription: 'Подключите ваш Google Drive через OAuth и синхронизируйте определенные папки или диски.', gmailDescription: diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index a90f7b194..c6a6237d7 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -862,6 +862,7 @@ General:实体和关系提取提示来自 GitHub - microsoft/graphrag:基于 dropboxDescription: '连接 Dropbox,同步指定账号下的文件与文件夹。', boxDescription: '连接你的 Box 云盘以同步文件和文件夹。', airtableDescription: '连接 Airtable,同步指定工作区下指定表格中的文件。', + asanaDescription: '连接 Asana,同步工作区中的文件。', r2Description: '连接你的 Cloudflare R2 存储桶以导入和同步文件。', dropboxAccessTokenTip: '请在 Dropbox App Console 生成 Access Token,并勾选 files.metadata.read、files.content.read、sharing.read 等必要权限。', diff --git a/web/src/pages/user-setting/data-source/constant/index.tsx b/web/src/pages/user-setting/data-source/constant/index.tsx index ab8bb730f..b7ff9380c 100644 --- a/web/src/pages/user-setting/data-source/constant/index.tsx +++ b/web/src/pages/user-setting/data-source/constant/index.tsx @@ -25,6 +25,7 @@ export enum DataSourceKey { OCI_STORAGE = 'oci_storage', GOOGLE_CLOUD_STORAGE = 'google_cloud_storage', AIRTABLE = 'airtable', + ASANA = 'asana', // SHAREPOINT = 'sharepoint', // SLACK = 'slack', // TEAMS = 'teams', @@ -109,6 +110,11 @@ export const generateDataSourceInfo = (t: TFunction) => { description: t(`setting.${DataSourceKey.AIRTABLE}Description`), icon: , }, + [DataSourceKey.ASANA]: { + name: 'Asana', + description: t(`setting.${DataSourceKey.ASANA}Description`), + icon: , + }, }; }; @@ -652,6 +658,32 @@ export const DataSourceFormFields = { required: true, }, ], + [DataSourceKey.ASANA]: [ + { + label: 'API Token', + name: 'config.credentials.asana_api_token_secret', + type: FormFieldType.Text, + required: true, + }, + { + label: 'Workspace ID', + name: 'config.asana_workspace_id', + type: FormFieldType.Text, + required: true, + }, + { + label: 'Project IDs', + name: 'config.asana_project_ids', + type: FormFieldType.Text, + required: false, + }, + { + label: 'Team ID', + name: 'config.asana_team_id', + type: FormFieldType.Text, + required: false, + }, + ], }; export const DataSourceFormDefaultValues = { @@ -851,4 +883,17 @@ export const DataSourceFormDefaultValues = { }, }, }, + [DataSourceKey.ASANA]: { + name: '', + source: DataSourceKey.ASANA, + config: { + name: '', + asana_workspace_id: '', + asana_project_ids: '', + asana_team_id: '', + credentials: { + asana_api_token_secret: '', + }, + }, + }, };