From 61b89b000a41002c36208b88105ffc3030dc5acc Mon Sep 17 00:00:00 2001 From: Abhishek Patil <83769052+abhishekpatil4@users.noreply.github.com> Date: Tue, 27 May 2025 23:21:02 +0530 Subject: [PATCH] feat: Add Composio GitHub component (#7640) * chore: action params naming * chore: remove comments * chore: replaced MessageTextInput field with StrInput * feat: add google calendar component * feat: replaced loops with hardcoded display-name action-enum mapping to improve performance * chore: format * fix: add type ignore for action_key in getattr call * feat: add google sheets component * fix: format google calendar utils * feat: add google meet Component * chore: minor improvement * chore: format & lint * fix: google meet component * feat: add GitHub component * fix: format * fix: lint * fix: typo * feat: add Slack Component * fix: format * fix: rest bool value to None * chore: disabled slack tools temporarily * fix: add condition to set list variables to None in when action is changed * chore: capitalise display names * fix: update list issues field to MessateTextInput * fix: format/lint in slack component * fix: google calendar logo * fix: revert setting bool field to None * feat: composio-core & composio-core version bump to 0.7.10 * fix: minor bugs * feat: add accepted values to AccessType field in google meet component * feat: add accepted values for entry point access field in Google meet component * fix: Google Calendar display names * feat: replace list with nested list for batch update field in Google sheets * fix: display name in Google sheets * fix: format * fix: titlecase display name in google meet component * feat: set advaced to true for advanced fields * feat: add condition to skip empty list fields in execute_action * chore: improve display names GitHub Component * fix: slack component display names & minor enhancements * feat: update condition to skip empty fields while executing action * feat: fix google calendar field description * feat: update googlemeet component to use new inputs & composio base class * chore: update googlemeet component filename * feat: update github component to use new inputs & composio base class * feat: update google calendar to use new inputs & composio base class * feat: update google sheets component to use new inputs & Composio base class * feat: update slack component to use new inputs & Composio base class * fix: format * chore: cleanup un-used code * chore: format * feat: add missing fields & actions * chore: fix typo * feat: rm other components * feat: improve error message format & revert composio libs bump * chore: revert uv.lock file * update tests * fix: remove duplicate action field in GMAIL_FETCH_EMAILS * fix: remove unused code * fix: add ignore statement * feat: add github Component * feat: delete Gmail component relates files to keep PR clean * add gmail component & test file to keep PR clean * clean pr * clean PR * fix: lint/format * fix: typo in testcase file * fix: remove component name * fix: replace separator in field names * fix: add app_name * fix: minor bugs & improved response format * chore: empty commit * chore: improve input field info * fix: format/lint * fix: Composio GitHub component unit tests --------- Co-authored-by: Edwin Jose --- .../langflow/components/composio/__init__.py | 2 + .../components/composio/github_composio.py | 649 ++++++++++++++++++ .../bundles/composio/test_github.py | 226 ++++++ src/frontend/src/icons/github/github.jsx | 12 + src/frontend/src/icons/github/github.svg | 1 + src/frontend/src/icons/github/index.tsx | 9 + src/frontend/src/utils/styleUtils.ts | 1 + 7 files changed, 900 insertions(+) create mode 100644 src/backend/base/langflow/components/composio/github_composio.py create mode 100644 src/backend/tests/unit/components/bundles/composio/test_github.py create mode 100644 src/frontend/src/icons/github/github.jsx create mode 100644 src/frontend/src/icons/github/github.svg create mode 100644 src/frontend/src/icons/github/index.tsx diff --git a/src/backend/base/langflow/components/composio/__init__.py b/src/backend/base/langflow/components/composio/__init__.py index 61596ba63..a7d3af130 100644 --- a/src/backend/base/langflow/components/composio/__init__.py +++ b/src/backend/base/langflow/components/composio/__init__.py @@ -1,10 +1,12 @@ from .composio_api import ComposioAPIComponent +from .github_composio import ComposioGitHubAPIComponent from .gmail_composio import ComposioGmailAPIComponent from .googlecalendar_composio import ComposioGoogleCalendarAPIComponent from .slack_composio import ComposioSlackAPIComponent __all__ = [ "ComposioAPIComponent", + "ComposioGitHubAPIComponent", "ComposioGmailAPIComponent", "ComposioGoogleCalendarAPIComponent", "ComposioSlackAPIComponent", diff --git a/src/backend/base/langflow/components/composio/github_composio.py b/src/backend/base/langflow/components/composio/github_composio.py new file mode 100644 index 000000000..bf5fdfa29 --- /dev/null +++ b/src/backend/base/langflow/components/composio/github_composio.py @@ -0,0 +1,649 @@ +import json +from typing import Any + +from composio import Action + +from langflow.base.composio.composio_base import ComposioBaseComponent +from langflow.inputs import ( + BoolInput, + IntInput, + MessageTextInput, +) +from langflow.logging import logger + + +class ComposioGitHubAPIComponent(ComposioBaseComponent): + """GitHub API component for interacting with GitHub services.""" + + display_name: str = "GitHub" + description: str = "GitHub API" + icon = "Github" + documentation: str = "https://docs.composio.dev" + app_name = "github" + + # GitHub-specific actions + _actions_data: dict = { + "GITHUB_CREATE_A_PULL_REQUEST": { + "display_name": "Create A Pull Request", + "action_fields": [ + "GITHUB_CREATE_A_PULL_REQUEST_owner", + "GITHUB_CREATE_A_PULL_REQUEST_repo", + "GITHUB_CREATE_A_PULL_REQUEST_title", + "GITHUB_CREATE_A_PULL_REQUEST_head", + "GITHUB_CREATE_A_PULL_REQUEST_head_repo", + "GITHUB_CREATE_A_PULL_REQUEST_base", + "GITHUB_CREATE_A_PULL_REQUEST_body", + "GITHUB_CREATE_A_PULL_REQUEST_maintainer_can_modify", + "GITHUB_CREATE_A_PULL_REQUEST_draft", + "GITHUB_CREATE_A_PULL_REQUEST_issue", + ], + }, + "GITHUB_STAR_A_REPOSITORY_FOR_THE_AUTHENTICATED_USER": { + "display_name": "Star A Repository", + "action_fields": [ + "GITHUB_STAR_A_REPOSITORY_FOR_THE_AUTHENTICATED_USER_owner", + "GITHUB_STAR_A_REPOSITORY_FOR_THE_AUTHENTICATED_USER_repo", + ], + }, + "GITHUB_LIST_COMMITS": { + "display_name": "List Commits", + "action_fields": [ + "GITHUB_LIST_COMMITS_owner", + "GITHUB_LIST_COMMITS_repo", + "GITHUB_LIST_COMMITS_sha", + "GITHUB_LIST_COMMITS_path", + "GITHUB_LIST_COMMITS_author", + "GITHUB_LIST_COMMITS_committer", + "GITHUB_LIST_COMMITS_since", + "GITHUB_LIST_COMMITS_until", + "GITHUB_LIST_COMMITS_per_page", + "GITHUB_LIST_COMMITS_page", + ], + }, + "GITHUB_GET_A_PULL_REQUEST": { + "display_name": "Get A Pull Request", + "action_fields": [ + "GITHUB_GET_A_PULL_REQUEST_owner", + "GITHUB_GET_A_PULL_REQUEST_repo", + "GITHUB_GET_A_PULL_REQUEST_pull_number", + ], + }, + "GITHUB_CREATE_AN_ISSUE": { + "display_name": "Create An Issue", + "action_fields": [ + "GITHUB_CREATE_AN_ISSUE_owner", + "GITHUB_CREATE_AN_ISSUE_repo", + "GITHUB_CREATE_AN_ISSUE_title", + "GITHUB_CREATE_AN_ISSUE_body", + "GITHUB_CREATE_AN_ISSUE_assignee", + "GITHUB_CREATE_AN_ISSUE_milestone", + "GITHUB_CREATE_AN_ISSUE_labels", + "GITHUB_CREATE_AN_ISSUE_assignees", + ], + }, + "GITHUB_LIST_REPOSITORY_ISSUES": { + "display_name": "List Repository Issues", + "action_fields": [ + "GITHUB_LIST_REPOSITORY_ISSUES_owner", + "GITHUB_LIST_REPOSITORY_ISSUES_repo", + "GITHUB_LIST_REPOSITORY_ISSUES_milestone", + "GITHUB_LIST_REPOSITORY_ISSUES_state", + "GITHUB_LIST_REPOSITORY_ISSUES_assignee", + "GITHUB_LIST_REPOSITORY_ISSUES_creator", + "GITHUB_LIST_REPOSITORY_ISSUES_mentioned", + "GITHUB_LIST_REPOSITORY_ISSUES_labels", + "GITHUB_LIST_REPOSITORY_ISSUES_sort", + "GITHUB_LIST_REPOSITORY_ISSUES_direction", + "GITHUB_LIST_REPOSITORY_ISSUES_since", + "GITHUB_LIST_REPOSITORY_ISSUES_per_page", + "GITHUB_LIST_REPOSITORY_ISSUES_page", + ], + }, + "GITHUB_LIST_BRANCHES": { + "display_name": "List Branches", + "action_fields": [ + "GITHUB_LIST_BRANCHES_owner", + "GITHUB_LIST_BRANCHES_repo", + "GITHUB_LIST_BRANCHES_protected", + "GITHUB_LIST_BRANCHES_per_page", + "GITHUB_LIST_BRANCHES_page", + ], + }, + "GITHUB_LIST_PULL_REQUESTS": { + "display_name": "List Pull Requests", + "action_fields": [ + "GITHUB_LIST_PULL_REQUESTS_owner", + "GITHUB_LIST_PULL_REQUESTS_repo", + "GITHUB_LIST_PULL_REQUESTS_state", + "GITHUB_LIST_PULL_REQUESTS_head", + "GITHUB_LIST_PULL_REQUESTS_base", + "GITHUB_LIST_PULL_REQUESTS_sort", + "GITHUB_LIST_PULL_REQUESTS_direction", + "GITHUB_LIST_PULL_REQUESTS_per_page", + "GITHUB_LIST_PULL_REQUESTS_page", + ], + }, + } + + _all_fields = {field for action_data in _actions_data.values() for field in action_data["action_fields"]} + _bool_variables = { + "GITHUB_CREATE_A_PULL_REQUEST_maintainer_can_modify", + "GITHUB_CREATE_A_PULL_REQUEST_draft", + "GITHUB_LIST_BRANCHES_protected", + } + + inputs = [ + *ComposioBaseComponent._base_inputs, + MessageTextInput( + name="GITHUB_CREATE_AN_ISSUE_owner", + display_name="Owner", + info="The account owner of the repository. The name is not case sensitive.", + show=False, + required=True, + ), + MessageTextInput( + name="GITHUB_CREATE_AN_ISSUE_repo", + display_name="Repo", + info="The name of the repository. The name is not case sensitive. ", + show=False, + required=True, + ), + MessageTextInput( + name="GITHUB_CREATE_AN_ISSUE_title", + display_name="Title", + info="The title of the issue.", + show=False, + required=True, + ), + MessageTextInput( + name="GITHUB_CREATE_AN_ISSUE_body", + display_name="Body", + info="The contents of the issue.", + show=False, + ), + MessageTextInput( + name="GITHUB_CREATE_AN_ISSUE_assignee", + display_name="Assignee", + info="Login for the user that this issue should be assigned to. _NOTE: Only users with push access can set the assignee for new issues. The assignee is silently dropped otherwise. **This field is deprecated.**_ ", # noqa: E501 + show=False, + advanced=True, + ), + MessageTextInput( + name="GITHUB_CREATE_AN_ISSUE_milestone", + display_name="Milestone", + info="Milestone", + show=False, + advanced=True, + ), + MessageTextInput( + name="GITHUB_CREATE_AN_ISSUE_labels", + display_name="Labels", + info="Labels to associate with this issue. _NOTE: Only users with push access can set labels for new issues. Labels are silently dropped otherwise._ ", # noqa: E501 + show=False, + advanced=True, + ), + MessageTextInput( + name="GITHUB_CREATE_AN_ISSUE_assignees", + display_name="Assignees", + info="Logins for Users to assign to this issue. _NOTE: Only users with push access can set assignees for new issues. Assignees are silently dropped otherwise._ ", # noqa: E501 + show=False, + advanced=True, + ), + MessageTextInput( + name="GITHUB_LIST_PULL_REQUESTS_owner", + display_name="Owner", + info="The account owner of the repository. The name is not case sensitive.", + show=False, + required=True, + ), + MessageTextInput( + name="GITHUB_LIST_PULL_REQUESTS_repo", + display_name="Repo", + info="The name of the repository. The name is not case sensitive. ", + show=False, + required=True, + ), + MessageTextInput( + name="GITHUB_LIST_PULL_REQUESTS_state", + display_name="State", + info="Either `open`, `closed`, or `all` to filter by state.", + show=False, + value="open", + advanced=True, + ), + MessageTextInput( + name="GITHUB_LIST_PULL_REQUESTS_head", + display_name="Head", + info="Filter pulls by head user or head organization and branch name in the format of `user:ref-name` or `organization:ref-name`. For example: `github:new-script-format` or `octocat:test-branch`. ", # noqa: E501 + show=False, + advanced=True, + ), + MessageTextInput( + name="GITHUB_LIST_PULL_REQUESTS_base", + display_name="Base", + info="Filter pulls by base branch name. Example: `gh-pages`.", + show=False, + advanced=True, + ), + MessageTextInput( + name="GITHUB_LIST_PULL_REQUESTS_sort", + display_name="Sort", + info="What to sort results by. `popularity` will sort by the number of comments. `long-running` will sort by date created and will limit the results to pull requests that have been open for more than a month and have had activity within the past month. ", # noqa: E501 + show=False, + value="created", + advanced=True, + ), + MessageTextInput( + name="GITHUB_LIST_PULL_REQUESTS_direction", + display_name="Direction", + info="The direction of the sort. Default: `desc` when sort is `created` or sort is not specified, otherwise `asc`. ", # noqa: E501 + show=False, + advanced=True, + ), + IntInput( + name="GITHUB_LIST_PULL_REQUESTS_per_page", + display_name="Per Page", + info="The number of results per page (max 100)", + show=False, + value=1, + advanced=True, + ), + IntInput( + name="GITHUB_LIST_PULL_REQUESTS_page", + display_name="Page", + info="The page number of the results to fetch", + show=False, + value=1, + advanced=True, + ), + MessageTextInput( + name="GITHUB_CREATE_A_PULL_REQUEST_owner", + display_name="Owner", + info="The account owner of the repository. The name is not case sensitive.", + show=False, + required=True, + ), + MessageTextInput( + name="GITHUB_CREATE_A_PULL_REQUEST_repo", + display_name="Repo", + info="The name of the repository. The name is not case sensitive. ", + show=False, + required=True, + ), + MessageTextInput( + name="GITHUB_CREATE_A_PULL_REQUEST_title", + display_name="Title", + info="The title of the new pull request. Required unless `issue` is specified.", + show=False, + ), + MessageTextInput( + name="GITHUB_CREATE_A_PULL_REQUEST_head", + display_name="Head", + info="The name of the branch where your changes are implemented. For cross-repository pull requests in the same network, namespace `head` with a user like this: `username:branch`. ", # noqa: E501 + show=False, + required=True, + ), + MessageTextInput( + name="GITHUB_CREATE_A_PULL_REQUEST_head_repo", + display_name="Head Repo", + info="The name of the repository where the changes in the pull request were made. This field is required for cross-repository pull requests if both repositories are owned by the same organization. ", # noqa: E501 + show=False, + advanced=True, + ), + MessageTextInput( + name="GITHUB_CREATE_A_PULL_REQUEST_base", + display_name="Base", + info="The name of the branch you want the changes pulled into. This should be an existing branch on the current repository. You cannot submit a pull request to one repository that requests a merge to a base of another repository. ", # noqa: E501 + show=False, + required=True, + ), + MessageTextInput( + name="GITHUB_CREATE_A_PULL_REQUEST_body", + display_name="Body", + info="The contents of the pull request.", + show=False, + ), + BoolInput( + name="GITHUB_CREATE_A_PULL_REQUEST_maintainer_can_modify", + display_name="Maintainer Can Modify", + info="Indicates whether maintainers can modify the pull request", + show=False, + advanced=True, + ), + BoolInput( + name="GITHUB_CREATE_A_PULL_REQUEST_draft", + display_name="Draft", + info="Indicates whether the pull request is a draft", + show=False, + advanced=True, + ), + IntInput( + name="GITHUB_CREATE_A_PULL_REQUEST_issue", + display_name="Issue", + info="An issue in the repository to convert to a pull request. The issue title, body, and comments will become the title, body, and comments on the new pull request. Required unless `title` is specified. ", # noqa: E501 + show=False, + advanced=True, + ), + MessageTextInput( + name="GITHUB_LIST_REPOSITORY_ISSUES_owner", + display_name="Owner", + info="The account owner of the repository. The name is not case sensitive.", + show=False, + required=True, + ), + MessageTextInput( + name="GITHUB_LIST_REPOSITORY_ISSUES_repo", + display_name="Repo", + info="The name of the repository. The name is not case sensitive. ", + show=False, + required=True, + ), + MessageTextInput( + name="GITHUB_LIST_REPOSITORY_ISSUES_milestone", + display_name="Milestone", + info="If an `integer` is passed, it should refer to a milestone by its `number` field. If the string `*` is passed, issues with any milestone are accepted. If the string `none` is passed, issues without milestones are returned. ", # noqa: E501 + show=False, + advanced=True, + ), + MessageTextInput( + name="GITHUB_LIST_REPOSITORY_ISSUES_state", + display_name="State", + info="Indicates the state of the issues to return.", + show=False, + value="open", + advanced=True, + ), + MessageTextInput( + name="GITHUB_LIST_REPOSITORY_ISSUES_assignee", + display_name="Assignee", + info="Can be the name of a user. Pass in `none` for issues with no assigned user, and `*` for issues assigned to any user. ", # noqa: E501 + show=False, + advanced=True, + ), + MessageTextInput( + name="GITHUB_LIST_REPOSITORY_ISSUES_creator", + display_name="Creator", + info="The user that created the issue.", + show=False, + advanced=True, + ), + MessageTextInput( + name="GITHUB_LIST_REPOSITORY_ISSUES_mentioned", + display_name="Mentioned", + info="A user that's mentioned in the issue.", + show=False, + advanced=True, + ), + MessageTextInput( + name="GITHUB_LIST_REPOSITORY_ISSUES_labels", + display_name="Labels", + info="A list of comma separated label names. Example: `bug,ui,@high`", + show=False, + advanced=True, + ), + MessageTextInput( + name="GITHUB_LIST_REPOSITORY_ISSUES_sort", + display_name="Sort", + info="What to sort results by", + show=False, + value="created", + advanced=True, + ), + MessageTextInput( + name="GITHUB_LIST_REPOSITORY_ISSUES_direction", + display_name="Direction", + info="The direction to sort the results by", + show=False, + value="desc", + advanced=True, + ), + MessageTextInput( + name="GITHUB_LIST_REPOSITORY_ISSUES_since", + display_name="Since", + info="Only show results that were last updated after the given time. This is a timestamp in ISO 8601 (https://en.wikipedia.org/wiki/ISO_8601) format: `YYYY-MM-DDTHH:MM:SSZ`. ", # noqa: E501 + show=False, + advanced=True, + ), + IntInput( + name="GITHUB_LIST_REPOSITORY_ISSUES_per_page", + display_name="Per Page", + info="The number of results per page (max 100)", + show=False, + value=1, + advanced=True, + ), + IntInput( + name="GITHUB_LIST_REPOSITORY_ISSUES_page", + display_name="Page", + info="The page number of the results to fetch", + show=False, + value=1, + advanced=True, + ), + MessageTextInput( + name="GITHUB_LIST_BRANCHES_owner", + display_name="Owner", + info="The account owner of the repository. The name is not case sensitive.", + show=False, + required=True, + ), + MessageTextInput( + name="GITHUB_LIST_BRANCHES_repo", + display_name="Repo", + info="The name of the repository. The name is not case sensitive. ", + show=False, + required=True, + ), + BoolInput( + name="GITHUB_LIST_BRANCHES_protected", + display_name="Protected", + info="Setting to `true` returns only protected branches. When set to `false`, only unprotected branches are returned. Omitting this parameter returns all branches", # noqa: E501 + show=False, + ), + IntInput( + name="GITHUB_LIST_BRANCHES_per_page", + display_name="Per Page", + info="The number of results per page (max 100)", + show=False, + value=30, + advanced=True, + ), + IntInput( + name="GITHUB_LIST_BRANCHES_page", + display_name="Page", + info="The page number of the results to fetch", + show=False, + value=1, + advanced=True, + ), + MessageTextInput( + name="GITHUB_STAR_A_REPOSITORY_FOR_THE_AUTHENTICATED_USER_owner", + display_name="Owner", + info="The account owner of the repository. The name is not case sensitive.", + show=False, + required=True, + ), + MessageTextInput( + name="GITHUB_STAR_A_REPOSITORY_FOR_THE_AUTHENTICATED_USER_repo", + display_name="Repo", + info="The name of the repository. The name is not case sensitive.", + show=False, + required=True, + ), + MessageTextInput( + name="GITHUB_GET_A_PULL_REQUEST_owner", + display_name="Owner", + info="The account owner of the repository. The name is not case sensitive.", + show=False, + required=True, + ), + MessageTextInput( + name="GITHUB_GET_A_PULL_REQUEST_repo", + display_name="Repo", + info="The name of the repository. The name is not case sensitive. ", + show=False, + required=True, + ), + IntInput( + name="GITHUB_GET_A_PULL_REQUEST_pull_number", + display_name="Pull Number", + info="The number that identifies the pull request.", + show=False, + required=True, + ), + MessageTextInput( + name="GITHUB_LIST_COMMITS_owner", + display_name="Owner", + info="The account owner of the repository. The name is not case sensitive.", + show=False, + required=True, + ), + MessageTextInput( + name="GITHUB_LIST_COMMITS_repo", + display_name="Repo", + info="The name of the repository. The name is not case sensitive. ", + show=False, + required=True, + ), + MessageTextInput( + name="GITHUB_LIST_COMMITS_sha", + display_name="SHA", + info="SHA or branch to start listing commits from. Default: the repository's default branch (usually `main`). ", # noqa: E501 + show=False, + advanced=True, + ), + MessageTextInput( + name="GITHUB_LIST_COMMITS_path", + display_name="Path", + info="Only commits containing this file path will be returned.", + show=False, + advanced=True, + ), + MessageTextInput( + name="GITHUB_LIST_COMMITS_author", + display_name="Author", + info="GitHub username or email address to use to filter by commit author.", + show=False, + advanced=True, + ), + MessageTextInput( + name="GITHUB_LIST_COMMITS_committer", + display_name="Committer", + info="GitHub username or email address to use to filter by commit committer.", + show=False, + advanced=True, + ), + MessageTextInput( + name="GITHUB_LIST_COMMITS_since", + display_name="Since", + info="Only show results that were last updated after the given time. This is a timestamp in ISO 8601 (https://en.wikipedia.org/wiki/ISO_8601) format: `YYYY-MM-DDTHH:MM:SSZ`. ", # noqa: E501 + show=False, + advanced=True, + ), + MessageTextInput( + name="GITHUB_LIST_COMMITS_until", + display_name="Until", + info="Only commits before this date will be returned. This is a timestamp in ISO 8601 (https://en.wikipedia.org/wiki/ISO_8601) format: `YYYY-MM-DDTHH:MM:SSZ`. ", # noqa: E501 + show=False, + advanced=True, + ), + IntInput( + name="GITHUB_LIST_COMMITS_per_page", + display_name="Per Page", + info="The number of results per page (max 100)", + show=False, + value=1, + advanced=True, + ), + IntInput( + name="GITHUB_LIST_COMMITS_page", + display_name="Page", + info="The page number of the results to fetch", + show=False, + value=1, + advanced=True, + ), + ] + + def execute_action(self): + """Execute action and return response as Message.""" + toolset = self._build_wrapper() + + try: + self._build_action_maps() + # Get the display name from the action list + display_name = self.action[0]["name"] if isinstance(self.action, list) and self.action else self.action + # Use the display_to_key_map to get the action key + action_key = self._display_to_key_map.get(display_name) + if not action_key: + msg = f"Invalid action: {display_name}" + raise ValueError(msg) + + enum_name = getattr(Action, action_key) + params = {} + if action_key in self._actions_data: + for field in self._actions_data[action_key]["action_fields"]: + value = getattr(self, field) + + if value is None or value == "": + continue + + if ( + field + in [ + "GITHUB_CREATE_AN_ISSUE_labels", + "GITHUB_CREATE_AN_ISSUE_assignees", + "GITHUB_LIST_REPOSITORY_ISSUES_labels", + ] + and value + ): + value = [item.strip() for item in value.split(",")] + + if field in self._bool_variables: + value = bool(value) + + param_name = field.replace(action_key + "_", "") + params[param_name] = value + + result = toolset.execute_action( + action=enum_name, + params=params, + ) + if not result.get("successful"): + try: + message_str = result.get("error", {}) + error_message = message_str.split("`")[1] + error_msg_json = json.loads(error_message) + except (IndexError, json.JSONDecodeError): + return {"error": str(message_str)} + return { + "code": error_msg_json.get("status"), + "message": error_msg_json.get("message"), + "documentation_url": error_msg_json.get("documentation_url"), + } + + result_data = result.get("data", []) + if ( + len(result_data) != 1 + and not self._actions_data.get(action_key, {}).get("result_field") + and self._actions_data.get(action_key, {}).get("get_result_field") + ): + msg = f"Expected a dict with a single key, got {len(result_data)} keys: {result_data.keys()}" + raise ValueError(msg) + if isinstance(result_data.get("details"), list): + return result_data.get("details") + return result_data # noqa: TRY300 + except Exception as e: + logger.error(f"Error executing action: {e}") + display_name = self.action[0]["name"] if isinstance(self.action, list) and self.action else str(self.action) + msg = f"Failed to execute {display_name}: {e!s}" + raise ValueError(msg) from e + + def update_build_config(self, build_config: dict, field_value: Any, field_name: str | None = None) -> dict: + return super().update_build_config(build_config, field_value, field_name) + + def set_default_tools(self): + self._default_tools = { + self.sanitize_action_name("GITHUB_STAR_A_REPOSITORY_FOR_THE_AUTHENTICATED_USER").replace(" ", "-"), + self.sanitize_action_name("GITHUB_CREATE_A_PULL_REQUEST").replace(" ", "-"), + } diff --git a/src/backend/tests/unit/components/bundles/composio/test_github.py b/src/backend/tests/unit/components/bundles/composio/test_github.py new file mode 100644 index 000000000..0d61f687d --- /dev/null +++ b/src/backend/tests/unit/components/bundles/composio/test_github.py @@ -0,0 +1,226 @@ +from unittest.mock import patch + +import pytest +from composio import Action +from langflow.components.composio.github_composio import ComposioGitHubAPIComponent +from langflow.schema.dataframe import DataFrame + +from tests.base import DID_NOT_EXIST, ComponentTestBaseWithoutClient + +from .test_base import MockComposioToolSet + + +class MockAction: + GITHUB_STAR_A_REPOSITORY_FOR_THE_AUTHENTICATED_USER = "GITHUB_STAR_A_REPOSITORY_FOR_THE_AUTHENTICATED_USER" + GITHUB_LIST_BRANCHES = "GITHUB_LIST_BRANCHES" + GITHUB_LIST_REPOSITORY_ISSUES = "GITHUB_LIST_REPOSITORY_ISSUES" + + +class TestGitHubComponent(ComponentTestBaseWithoutClient): + @pytest.fixture(autouse=True) + def mock_composio_toolset(self): + with patch("langflow.base.composio.composio_base.ComposioToolSet", MockComposioToolSet): + yield + + @pytest.fixture + def component_class(self): + return ComposioGitHubAPIComponent + + @pytest.fixture + def default_kwargs(self): + return { + "api_key": "", + "entity_id": "default", + "action": None, + } + + @pytest.fixture + def file_names_mapping(self): + # Component not yet released, mark all versions as non-existent + return [ + {"version": "1.0.17", "module": "composio", "file_name": DID_NOT_EXIST}, + {"version": "1.0.18", "module": "composio", "file_name": DID_NOT_EXIST}, + {"version": "1.0.19", "module": "composio", "file_name": DID_NOT_EXIST}, + {"version": "1.1.0", "module": "composio", "file_name": DID_NOT_EXIST}, + {"version": "1.1.1", "module": "composio", "file_name": DID_NOT_EXIST}, + ] + + def test_init(self, component_class, default_kwargs): + component = component_class(**default_kwargs) + assert component.display_name == "GitHub" + assert component.app_name == "github" + assert "GITHUB_STAR_A_REPOSITORY_FOR_THE_AUTHENTICATED_USER" in component._actions_data + assert "GITHUB_LIST_BRANCHES" in component._actions_data + + def test_execute_action_star_a_repo(self, component_class, default_kwargs, monkeypatch): + # Mock Action enum + monkeypatch.setattr( + Action, + "GITHUB_STAR_A_REPOSITORY_FOR_THE_AUTHENTICATED_USER", + MockAction.GITHUB_STAR_A_REPOSITORY_FOR_THE_AUTHENTICATED_USER, + ) + + # Setup component + component = component_class(**default_kwargs) + component.api_key = "test_key" + component.action = [{"name": "Star A Repository"}] + component.GITHUB_STAR_A_REPOSITORY_FOR_THE_AUTHENTICATED_USER_owner = "langflow-ai" + component.GITHUB_STAR_A_REPOSITORY_FOR_THE_AUTHENTICATED_USER_repo = "langflow" + + # For this specific test, customize the _actions_data to not use get_result_field + component._actions_data = { + "GITHUB_STAR_A_REPOSITORY_FOR_THE_AUTHENTICATED_USER": { + "display_name": "Star A Repository", + "action_fields": [ + "GITHUB_STAR_A_REPOSITORY_FOR_THE_AUTHENTICATED_USER_owner", + "GITHUB_STAR_A_REPOSITORY_FOR_THE_AUTHENTICATED_USER_repo", + ], + }, + } + + # Execute action + result = component.execute_action() + assert result == {"result": "mocked response"} + + def test_execute_action_list_branches(self, component_class, default_kwargs, monkeypatch): + # Mock Action enum + monkeypatch.setattr(Action, "GITHUB_LIST_BRANCHES", MockAction.GITHUB_LIST_BRANCHES) + + # Setup component + component = component_class(**default_kwargs) + component.api_key = "test_key" + component.action = [{"name": "List Branches"}] + component.GITHUB_LIST_BRANCHES_owner = "langflow-ai" + component.GITHUB_LIST_BRANCHES_repo = "langflow" + + # For this specific test, customize the _actions_data to not use get_result_field + component._actions_data = { + "GITHUB_LIST_BRANCHES": { + "display_name": "List Branches", + "action_fields": [ + "GITHUB_LIST_BRANCHES_owner", + "GITHUB_LIST_BRANCHES_repo", + "GITHUB_LIST_BRANCHES_protected", + "GITHUB_LIST_BRANCHES_per_page", + "GITHUB_LIST_BRANCHES_page", + ], + }, + } + + # Execute action + result = component.execute_action() + assert result == {"result": "mocked response"} + + def test_execute_action_list_repo_issues(self, component_class, default_kwargs, monkeypatch): + # Mock Action enum + monkeypatch.setattr(Action, "GITHUB_LIST_REPOSITORY_ISSUES", MockAction.GITHUB_LIST_REPOSITORY_ISSUES) + + # Setup component + component = component_class(**default_kwargs) + component.api_key = "test_key" + component.action = [{"name": "List Repository Issues"}] + component.GITHUB_LIST_REPOSITORY_ISSUES_owner = "langflow-ai" + component.GITHUB_LIST_REPOSITORY_ISSUES_repo = "langflow" + + # For this specific test, customize the _actions_data to not use get_result_field + component._actions_data = { + "GITHUB_LIST_REPOSITORY_ISSUES": { + "display_name": "List Repository Issues", + "action_fields": [ + "GITHUB_LIST_REPOSITORY_ISSUES_owner", + "GITHUB_LIST_REPOSITORY_ISSUES_repo", + "GITHUB_LIST_REPOSITORY_ISSUES_milestone", + "GITHUB_LIST_REPOSITORY_ISSUES_state", + "GITHUB_LIST_REPOSITORY_ISSUES_assignee", + "GITHUB_LIST_REPOSITORY_ISSUES_creator", + "GITHUB_LIST_REPOSITORY_ISSUES_mentioned", + "GITHUB_LIST_REPOSITORY_ISSUES_labels", + "GITHUB_LIST_REPOSITORY_ISSUES_sort", + "GITHUB_LIST_REPOSITORY_ISSUES_direction", + "GITHUB_LIST_REPOSITORY_ISSUES_since", + "GITHUB_LIST_REPOSITORY_ISSUES_per_page", + "GITHUB_LIST_REPOSITORY_ISSUES_page", + ], + }, + } + + # Execute action + result = component.execute_action() + assert result == {"result": "mocked response"} + + def test_execute_action_invalid_action(self, component_class, default_kwargs): + # Setup component + component = component_class(**default_kwargs) + component.api_key = "test_key" + component.action = [{"name": "Invalid Action"}] + + # Execute action should raise ValueError + with pytest.raises(ValueError, match="Invalid action: Invalid Action"): + component.execute_action() + + def test_as_dataframe(self, component_class, default_kwargs, monkeypatch): + # Mock Action enum + monkeypatch.setattr(Action, "GITHUB_LIST_REPOSITORY_ISSUES", MockAction.GITHUB_LIST_REPOSITORY_ISSUES) + + # Setup component + component = component_class(**default_kwargs) + component.api_key = "test_key" + component.action = [{"name": "List Repository Issues"}] + component.max_results = 10 + + # Create mock email data that would be returned by execute_action + mock_issues = [ + { + "url": "url1", + "repository_url": "repository_url1", + "id": "id1", + "title": "test issue", + "state": "open", + }, + { + "url": "url2", + "repository_url": "repository_url2", + "id": "id2", + "title": "test issue", + "state": "open", + }, + ] + + # Mock the execute_action method to return our mock data + with patch.object(component, "execute_action", return_value=mock_issues): + # Test as_dataframe method + result = component.as_dataframe() + + # Verify the result is a DataFrame + assert isinstance(result, DataFrame) + + # Verify the DataFrame is not empty + assert not result.empty + + # Check for expected content in the DataFrame string representation + data_str = str(result) + assert "test issue" in data_str + + def test_update_build_config(self, component_class, default_kwargs): + # Test that the GitHub component properly inherits and uses the base component's + # update_build_config method + component = component_class(**default_kwargs) + build_config = { + "auth_link": {"value": "", "auth_tooltip": ""}, + "action": { + "options": [], + "helper_text": "", + "helper_text_metadata": {}, + }, + } + + # Test with empty API key + result = component.update_build_config(build_config, "", "api_key") + assert result["auth_link"]["value"] == "" + assert "Please provide a valid Composio API Key" in result["auth_link"]["auth_tooltip"] + assert result["action"]["options"] == [] + + # Test with valid API key + component.api_key = "test_key" + result = component.update_build_config(build_config, "test_key", "api_key") + assert len(result["action"]["options"]) > 0 # Should have GitHub actions diff --git a/src/frontend/src/icons/github/github.jsx b/src/frontend/src/icons/github/github.jsx new file mode 100644 index 000000000..c3cb817db --- /dev/null +++ b/src/frontend/src/icons/github/github.jsx @@ -0,0 +1,12 @@ +const Icon = (props) => ( + + {" "} + + +); +export default Icon; diff --git a/src/frontend/src/icons/github/github.svg b/src/frontend/src/icons/github/github.svg new file mode 100644 index 000000000..46c6c8326 --- /dev/null +++ b/src/frontend/src/icons/github/github.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/frontend/src/icons/github/index.tsx b/src/frontend/src/icons/github/index.tsx new file mode 100644 index 000000000..9d5c56ad4 --- /dev/null +++ b/src/frontend/src/icons/github/index.tsx @@ -0,0 +1,9 @@ +import React, { forwardRef } from "react"; +import GithubIconSVG from "./github"; + +export const GithubIcon = forwardRef< + SVGSVGElement, + React.PropsWithChildren<{}> +>((props, ref) => { + return ; +}); diff --git a/src/frontend/src/utils/styleUtils.ts b/src/frontend/src/utils/styleUtils.ts index 343bc4f8f..8c9b624a7 100644 --- a/src/frontend/src/utils/styleUtils.ts +++ b/src/frontend/src/utils/styleUtils.ts @@ -231,6 +231,7 @@ export const SIDEBAR_CATEGORIES = [ export const SIDEBAR_BUNDLES = [ { display_name: "Amazon", name: "amazon", icon: "Amazon" }, { display_name: "Gmail", name: "gmail", icon: "Gmail" }, + { display_name: "GitHub", name: "github", icon: "Github" }, { display_name: "Googlecalendar", name: "googlecalendar",