From 56612f16cfb28fc2426b84831ba992b19c808476 Mon Sep 17 00:00:00 2001 From: Nicholas Tindle Date: Fri, 17 Jan 2025 07:35:58 -0600 Subject: [PATCH] feat(platform): Linear integration (#9269) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I want to be able to do stuff with linear automatically ### Changes 🏗️ - Adds all the backing details to add linear auth and API access with oauth (and prep for API key) ### Checklist 📋 #### For code changes: - [ ] I have clearly listed my changes in the PR description - [ ] I have made a test plan - [ ] I have tested my changes according to the test plan: - [ ] ...
Example test plan - [ ] Create from scratch and execute an agent with at least 3 blocks - [ ] Import an agent from file upload, and confirm it executes correctly - [ ] Upload agent to marketplace - [ ] Import an agent from marketplace and confirm it executes correctly - [ ] Edit an agent from monitor, and confirm it executes correctly
#### For configuration changes: - [ ] `.env.example` is updated or already compatible with my changes - [ ] `docker-compose.yml` is updated or already compatible with my changes - [ ] I have included a list of my configuration changes in the PR description (under **Changes**)
Examples of configuration changes - Changing ports - Adding new services that need to communicate with each other - Secrets or environment variable changes - New or infrastructure changes such as databases
--------- Co-authored-by: Aarushi <50577581+aarushik93@users.noreply.github.com> --- autogpt_platform/backend/.env.example | 6 + .../backend/backend/blocks/basic.py | 46 +++ .../backend/backend/blocks/linear/_api.py | 272 ++++++++++++++++++ .../backend/backend/blocks/linear/_auth.py | 101 +++++++ .../backend/backend/blocks/linear/comment.py | 81 ++++++ .../backend/backend/blocks/linear/issues.py | 186 ++++++++++++ .../backend/backend/blocks/linear/models.py | 41 +++ .../backend/backend/blocks/linear/projects.py | 93 ++++++ .../backend/backend/blocks/linear/triggers.py | 0 .../backend/backend/data/block.py | 2 + .../backend/integrations/oauth/__init__.py | 2 + .../backend/integrations/oauth/linear.py | 165 +++++++++++ .../backend/backend/integrations/providers.py | 1 + .../backend/server/integrations/router.py | 5 + .../backend/backend/util/settings.py | 3 + .../integrations/credentials-input.tsx | 4 +- .../integrations/credentials-provider.tsx | 1 + .../src/lib/autogpt-server-api/types.ts | 1 + 18 files changed, 1009 insertions(+), 1 deletion(-) create mode 100644 autogpt_platform/backend/backend/blocks/linear/_api.py create mode 100644 autogpt_platform/backend/backend/blocks/linear/_auth.py create mode 100644 autogpt_platform/backend/backend/blocks/linear/comment.py create mode 100644 autogpt_platform/backend/backend/blocks/linear/issues.py create mode 100644 autogpt_platform/backend/backend/blocks/linear/models.py create mode 100644 autogpt_platform/backend/backend/blocks/linear/projects.py create mode 100644 autogpt_platform/backend/backend/blocks/linear/triggers.py create mode 100644 autogpt_platform/backend/backend/integrations/oauth/linear.py diff --git a/autogpt_platform/backend/.env.example b/autogpt_platform/backend/.env.example index ab7b914a7..838cfdb28 100644 --- a/autogpt_platform/backend/.env.example +++ b/autogpt_platform/backend/.env.example @@ -75,6 +75,12 @@ GOOGLE_CLIENT_SECRET= TWITTER_CLIENT_ID= TWITTER_CLIENT_SECRET= +# Linear App +# Make a new workspace for your OAuth APP -- trust me +# https://linear.app/settings/api/applications/new +# Callback URL: http://localhost:3000/auth/integrations/oauth_callback +LINEAR_CLIENT_ID= +LINEAR_CLIENT_SECRET= ## ===== OPTIONAL API KEYS ===== ## diff --git a/autogpt_platform/backend/backend/blocks/basic.py b/autogpt_platform/backend/backend/blocks/basic.py index b68c04bad..d7dd468c4 100644 --- a/autogpt_platform/backend/backend/blocks/basic.py +++ b/autogpt_platform/backend/backend/blocks/basic.py @@ -1,9 +1,11 @@ +import enum from typing import Any, List from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema, BlockType from backend.data.model import SchemaField from backend.util.mock import MockObject from backend.util.text import TextFormatter +from backend.util.type import convert formatter = TextFormatter() @@ -590,3 +592,47 @@ class CreateListBlock(Block): yield "list", input_data.values except Exception as e: yield "error", f"Failed to create list: {str(e)}" + + +class TypeOptions(enum.Enum): + STRING = "string" + NUMBER = "number" + BOOLEAN = "boolean" + LIST = "list" + DICTIONARY = "dictionary" + + +class UniversalTypeConverterBlock(Block): + class Input(BlockSchema): + value: Any = SchemaField( + description="The value to convert to a universal type." + ) + type: TypeOptions = SchemaField(description="The type to convert the value to.") + + class Output(BlockSchema): + value: Any = SchemaField(description="The converted value.") + + def __init__(self): + super().__init__( + id="95d1b990-ce13-4d88-9737-ba5c2070c97b", + description="This block is used to convert a value to a universal type.", + categories={BlockCategory.BASIC}, + input_schema=UniversalTypeConverterBlock.Input, + output_schema=UniversalTypeConverterBlock.Output, + ) + + def run(self, input_data: Input, **kwargs) -> BlockOutput: + try: + converted_value = convert( + input_data.value, + { + TypeOptions.STRING: str, + TypeOptions.NUMBER: float, + TypeOptions.BOOLEAN: bool, + TypeOptions.LIST: list, + TypeOptions.DICTIONARY: dict, + }[input_data.type], + ) + yield "value", converted_value + except Exception as e: + yield "error", f"Failed to convert value: {str(e)}" diff --git a/autogpt_platform/backend/backend/blocks/linear/_api.py b/autogpt_platform/backend/backend/blocks/linear/_api.py new file mode 100644 index 000000000..4639975e8 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/linear/_api.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +import json +from typing import Any, Dict, Optional + +from backend.blocks.linear._auth import LinearCredentials +from backend.blocks.linear.models import ( + CreateCommentResponse, + CreateIssueResponse, + Issue, + Project, +) +from backend.util.request import Requests + + +class LinearAPIException(Exception): + def __init__(self, message: str, status_code: int): + super().__init__(message) + self.status_code = status_code + + +class LinearClient: + """Client for the Linear API + + If you're looking for the schema: https://studio.apollographql.com/public/Linear-API/variant/current/schema + """ + + API_URL = "https://api.linear.app/graphql" + + def __init__( + self, + credentials: LinearCredentials | None = None, + custom_requests: Optional[Requests] = None, + ): + if custom_requests: + self._requests = custom_requests + else: + + headers: Dict[str, str] = { + "Content-Type": "application/json", + } + if credentials: + headers["Authorization"] = credentials.bearer() + + self._requests = Requests( + extra_headers=headers, + trusted_origins=["https://api.linear.app"], + raise_for_status=False, + ) + + def _execute_graphql_request( + self, query: str, variables: dict | None = None + ) -> Any: + """ + Executes a GraphQL request against the Linear API and returns the response data. + + Args: + query: The GraphQL query string. + variables (optional): Any GraphQL query variables + + Returns: + The parsed JSON response data, or raises a LinearAPIException on error. + """ + payload: Dict[str, Any] = {"query": query} + if variables: + payload["variables"] = variables + + response = self._requests.post(self.API_URL, json=payload) + + if not response.ok: + + try: + error_data = response.json() + error_message = error_data.get("errors", [{}])[0].get("message", "") + except json.JSONDecodeError: + error_message = response.text + + raise LinearAPIException( + f"Linear API request failed ({response.status_code}): {error_message}", + response.status_code, + ) + + response_data = response.json() + if "errors" in response_data: + + error_messages = [ + error.get("message", "") for error in response_data["errors"] + ] + raise LinearAPIException( + f"Linear API returned errors: {', '.join(error_messages)}", + response.status_code, + ) + + return response_data["data"] + + def query(self, query: str, variables: Optional[dict] = None) -> dict: + """Executes a GraphQL query. + + Args: + query: The GraphQL query string. + variables: Query variables, if any. + + Returns: + The response data. + """ + return self._execute_graphql_request(query, variables) + + def mutate(self, mutation: str, variables: Optional[dict] = None) -> dict: + """Executes a GraphQL mutation. + + Args: + mutation: The GraphQL mutation string. + variables: Query variables, if any. + + Returns: + The response data. + """ + return self._execute_graphql_request(mutation, variables) + + def try_create_comment(self, issue_id: str, comment: str) -> CreateCommentResponse: + try: + mutation = """ + mutation CommentCreate($input: CommentCreateInput!) { + commentCreate(input: $input) { + success + comment { + id + body + } + } + } + """ + + variables = { + "input": { + "body": comment, + "issueId": issue_id, + } + } + + added_comment = self.mutate(mutation, variables) + # Select the commentCreate field from the mutation response + return CreateCommentResponse(**added_comment["commentCreate"]) + except LinearAPIException as e: + raise e + + def try_get_team_by_name(self, team_name: str) -> str: + try: + query = """ + query GetTeamId($searchTerm: String!) { + teams(filter: { + or: [ + { name: { eqIgnoreCase: $searchTerm } }, + { key: { eqIgnoreCase: $searchTerm } } + ] + }) { + nodes { + id + name + key + } + } + } + """ + + variables: dict[str, Any] = { + "searchTerm": team_name, + } + + team_id = self.query(query, variables) + return team_id["teams"]["nodes"][0]["id"] + except LinearAPIException as e: + raise e + + def try_create_issue( + self, + team_id: str, + title: str, + description: str | None = None, + priority: int | None = None, + project_id: str | None = None, + ) -> CreateIssueResponse: + try: + mutation = """ + mutation IssueCreate($input: IssueCreateInput!) { + issueCreate(input: $input) { + issue { + title + description + id + identifier + priority + } + } + } + """ + + variables: dict[str, Any] = { + "input": { + "teamId": team_id, + "title": title, + } + } + + if project_id: + variables["input"]["projectId"] = project_id + + if description: + variables["input"]["description"] = description + + if priority: + variables["input"]["priority"] = priority + + added_issue = self.mutate(mutation, variables) + return CreateIssueResponse(**added_issue["issueCreate"]) + except LinearAPIException as e: + raise e + + def try_search_projects(self, term: str) -> list[Project]: + try: + query = """ + query SearchProjects($term: String!, $includeComments: Boolean!) { + searchProjects(term: $term, includeComments: $includeComments) { + nodes { + id + name + description + priority + progress + content + } + } + } + """ + + variables: dict[str, Any] = { + "term": term, + "includeComments": True, + } + + projects = self.query(query, variables) + return [ + Project(**project) for project in projects["searchProjects"]["nodes"] + ] + except LinearAPIException as e: + raise e + + def try_search_issues(self, term: str) -> list[Issue]: + try: + query = """ + query SearchIssues($term: String!, $includeComments: Boolean!) { + searchIssues(term: $term, includeComments: $includeComments) { + nodes { + id + identifier + title + description + priority + } + } + } + """ + + variables: dict[str, Any] = { + "term": term, + "includeComments": True, + } + + issues = self.query(query, variables) + return [Issue(**issue) for issue in issues["searchIssues"]["nodes"]] + except LinearAPIException as e: + raise e diff --git a/autogpt_platform/backend/backend/blocks/linear/_auth.py b/autogpt_platform/backend/backend/blocks/linear/_auth.py new file mode 100644 index 000000000..fb91fbfe7 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/linear/_auth.py @@ -0,0 +1,101 @@ +from enum import Enum +from typing import Literal + +from pydantic import SecretStr + +from backend.data.model import ( + APIKeyCredentials, + CredentialsField, + CredentialsMetaInput, + OAuth2Credentials, +) +from backend.integrations.providers import ProviderName +from backend.util.settings import Secrets + +secrets = Secrets() +LINEAR_OAUTH_IS_CONFIGURED = bool( + secrets.linear_client_id and secrets.linear_client_secret +) + +LinearCredentials = OAuth2Credentials | APIKeyCredentials +# LinearCredentialsInput = CredentialsMetaInput[ +# Literal[ProviderName.LINEAR], +# Literal["oauth2", "api_key"] if LINEAR_OAUTH_IS_CONFIGURED else Literal["oauth2"], +# ] +LinearCredentialsInput = CredentialsMetaInput[ + Literal[ProviderName.LINEAR], Literal["oauth2"] +] + + +# (required) Comma separated list of scopes: + +# read - (Default) Read access for the user's account. This scope will always be present. + +# write - Write access for the user's account. If your application only needs to create comments, use a more targeted scope + +# issues:create - Allows creating new issues and their attachments + +# comments:create - Allows creating new issue comments + +# timeSchedule:write - Allows creating and modifying time schedules + + +# admin - Full access to admin level endpoints. You should never ask for this permission unless it's absolutely needed +class LinearScope(str, Enum): + READ = "read" + WRITE = "write" + ISSUES_CREATE = "issues:create" + COMMENTS_CREATE = "comments:create" + TIME_SCHEDULE_WRITE = "timeSchedule:write" + ADMIN = "admin" + + +def LinearCredentialsField(scopes: list[LinearScope]) -> LinearCredentialsInput: + """ + Creates a Linear credentials input on a block. + + Params: + scope: The authorization scope needed for the block to work. ([list of available scopes](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps#available-scopes)) + """ # noqa + return CredentialsField( + required_scopes=set([LinearScope.READ.value]).union( + set([scope.value for scope in scopes]) + ), + description="The Linear integration can be used with OAuth, " + "or any API key with sufficient permissions for the blocks it is used on.", + ) + + +TEST_CREDENTIALS_OAUTH = OAuth2Credentials( + id="01234567-89ab-cdef-0123-456789abcdef", + provider="linear", + title="Mock Linear API key", + username="mock-linear-username", + access_token=SecretStr("mock-linear-access-token"), + access_token_expires_at=None, + refresh_token=SecretStr("mock-linear-refresh-token"), + refresh_token_expires_at=None, + scopes=["mock-linear-scopes"], +) + +TEST_CREDENTIALS_API_KEY = APIKeyCredentials( + id="01234567-89ab-cdef-0123-456789abcdef", + provider="linear", + title="Mock Linear API key", + api_key=SecretStr("mock-linear-api-key"), + expires_at=None, +) + +TEST_CREDENTIALS_INPUT_OAUTH = { + "provider": TEST_CREDENTIALS_OAUTH.provider, + "id": TEST_CREDENTIALS_OAUTH.id, + "type": TEST_CREDENTIALS_OAUTH.type, + "title": TEST_CREDENTIALS_OAUTH.type, +} + +TEST_CREDENTIALS_INPUT_API_KEY = { + "provider": TEST_CREDENTIALS_API_KEY.provider, + "id": TEST_CREDENTIALS_API_KEY.id, + "type": TEST_CREDENTIALS_API_KEY.type, + "title": TEST_CREDENTIALS_API_KEY.type, +} diff --git a/autogpt_platform/backend/backend/blocks/linear/comment.py b/autogpt_platform/backend/backend/blocks/linear/comment.py new file mode 100644 index 000000000..ea2ff1e61 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/linear/comment.py @@ -0,0 +1,81 @@ +from backend.blocks.linear._api import LinearAPIException, LinearClient +from backend.blocks.linear._auth import ( + TEST_CREDENTIALS_INPUT_OAUTH, + TEST_CREDENTIALS_OAUTH, + LinearCredentials, + LinearCredentialsField, + LinearCredentialsInput, + LinearScope, +) +from backend.blocks.linear.models import CreateCommentResponse +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + + +class LinearCreateCommentBlock(Block): + """Block for creating comments on Linear issues""" + + class Input(BlockSchema): + credentials: LinearCredentialsInput = LinearCredentialsField( + scopes=[LinearScope.COMMENTS_CREATE], + ) + issue_id: str = SchemaField(description="ID of the issue to comment on") + comment: str = SchemaField(description="Comment text to add to the issue") + + class Output(BlockSchema): + comment_id: str = SchemaField(description="ID of the created comment") + comment_body: str = SchemaField( + description="Text content of the created comment" + ) + error: str = SchemaField(description="Error message if comment creation failed") + + def __init__(self): + super().__init__( + id="8f7d3a2e-9b5c-4c6a-8f1d-7c8b3e4a5d6c", + description="Creates a new comment on a Linear issue", + input_schema=self.Input, + output_schema=self.Output, + categories={BlockCategory.PRODUCTIVITY, BlockCategory.ISSUE_TRACKING}, + test_input={ + "issue_id": "TEST-123", + "comment": "Test comment", + "credentials": TEST_CREDENTIALS_INPUT_OAUTH, + }, + test_credentials=TEST_CREDENTIALS_OAUTH, + test_output=[("comment_id", "abc123"), ("comment_body", "Test comment")], + test_mock={ + "create_comment": lambda *args, **kwargs: ( + "abc123", + "Test comment", + ) + }, + ) + + @staticmethod + def create_comment( + credentials: LinearCredentials, issue_id: str, comment: str + ) -> tuple[str, str]: + client = LinearClient(credentials=credentials) + response: CreateCommentResponse = client.try_create_comment( + issue_id=issue_id, comment=comment + ) + return response.comment.id, response.comment.body + + def run( + self, input_data: Input, *, credentials: LinearCredentials, **kwargs + ) -> BlockOutput: + """Execute the comment creation""" + try: + comment_id, comment_body = self.create_comment( + credentials=credentials, + issue_id=input_data.issue_id, + comment=input_data.comment, + ) + + yield "comment_id", comment_id + yield "comment_body", comment_body + + except LinearAPIException as e: + yield "error", str(e) + except Exception as e: + yield "error", f"Unexpected error: {str(e)}" diff --git a/autogpt_platform/backend/backend/blocks/linear/issues.py b/autogpt_platform/backend/backend/blocks/linear/issues.py new file mode 100644 index 000000000..4c2c3ffa0 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/linear/issues.py @@ -0,0 +1,186 @@ +from backend.blocks.linear._api import LinearAPIException, LinearClient +from backend.blocks.linear._auth import ( + TEST_CREDENTIALS_INPUT_OAUTH, + TEST_CREDENTIALS_OAUTH, + LinearCredentials, + LinearCredentialsField, + LinearCredentialsInput, + LinearScope, +) +from backend.blocks.linear.models import CreateIssueResponse, Issue +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + + +class LinearCreateIssueBlock(Block): + """Block for creating issues on Linear""" + + class Input(BlockSchema): + credentials: LinearCredentialsInput = LinearCredentialsField( + scopes=[LinearScope.ISSUES_CREATE], + ) + title: str = SchemaField(description="Title of the issue") + description: str | None = SchemaField(description="Description of the issue") + team_name: str = SchemaField( + description="Name of the team to create the issue on" + ) + priority: int | None = SchemaField( + description="Priority of the issue", + default=None, + minimum=0, + maximum=4, + ) + project_name: str | None = SchemaField( + description="Name of the project to create the issue on", + default=None, + ) + + class Output(BlockSchema): + issue_id: str = SchemaField(description="ID of the created issue") + issue_title: str = SchemaField(description="Title of the created issue") + error: str = SchemaField(description="Error message if issue creation failed") + + def __init__(self): + super().__init__( + id="f9c68f55-dcca-40a8-8771-abf9601680aa", + description="Creates a new issue on Linear", + input_schema=self.Input, + output_schema=self.Output, + categories={BlockCategory.PRODUCTIVITY, BlockCategory.ISSUE_TRACKING}, + test_input={ + "title": "Test issue", + "description": "Test description", + "team_name": "Test team", + "project_name": "Test project", + "credentials": TEST_CREDENTIALS_INPUT_OAUTH, + }, + test_credentials=TEST_CREDENTIALS_OAUTH, + test_output=[("issue_id", "abc123"), ("issue_title", "Test issue")], + test_mock={ + "create_issue": lambda *args, **kwargs: ( + "abc123", + "Test issue", + ) + }, + ) + + @staticmethod + def create_issue( + credentials: LinearCredentials, + team_name: str, + title: str, + description: str | None = None, + priority: int | None = None, + project_name: str | None = None, + ) -> tuple[str, str]: + client = LinearClient(credentials=credentials) + team_id = client.try_get_team_by_name(team_name=team_name) + project_id: str | None = None + if project_name: + projects = client.try_search_projects(term=project_name) + if projects: + project_id = projects[0].id + else: + raise LinearAPIException("Project not found", status_code=404) + response: CreateIssueResponse = client.try_create_issue( + team_id=team_id, + title=title, + description=description, + priority=priority, + project_id=project_id, + ) + return response.issue.identifier, response.issue.title + + def run( + self, input_data: Input, *, credentials: LinearCredentials, **kwargs + ) -> BlockOutput: + """Execute the issue creation""" + try: + issue_id, issue_title = self.create_issue( + credentials=credentials, + team_name=input_data.team_name, + title=input_data.title, + description=input_data.description, + priority=input_data.priority, + project_name=input_data.project_name, + ) + + yield "issue_id", issue_id + yield "issue_title", issue_title + + except LinearAPIException as e: + yield "error", str(e) + except Exception as e: + yield "error", f"Unexpected error: {str(e)}" + + +class LinearSearchIssuesBlock(Block): + """Block for searching issues on Linear""" + + class Input(BlockSchema): + term: str = SchemaField(description="Term to search for issues") + credentials: LinearCredentialsInput = LinearCredentialsField( + scopes=[LinearScope.READ], + ) + + class Output(BlockSchema): + issues: list[Issue] = SchemaField(description="List of issues") + + def __init__(self): + super().__init__( + id="b5a2a0e6-26b4-4c5b-8a42-bc79e9cb65c2", + description="Searches for issues on Linear", + input_schema=self.Input, + output_schema=self.Output, + test_input={ + "term": "Test issue", + "credentials": TEST_CREDENTIALS_INPUT_OAUTH, + }, + test_credentials=TEST_CREDENTIALS_OAUTH, + test_output=[ + ( + "issues", + [ + Issue( + id="abc123", + identifier="abc123", + title="Test issue", + description="Test description", + priority=1, + ) + ], + ) + ], + test_mock={ + "search_issues": lambda *args, **kwargs: [ + Issue( + id="abc123", + identifier="abc123", + title="Test issue", + description="Test description", + priority=1, + ) + ] + }, + ) + + @staticmethod + def search_issues( + credentials: LinearCredentials, + term: str, + ) -> list[Issue]: + client = LinearClient(credentials=credentials) + response: list[Issue] = client.try_search_issues(term=term) + return response + + def run( + self, input_data: Input, *, credentials: LinearCredentials, **kwargs + ) -> BlockOutput: + """Execute the issue search""" + try: + issues = self.search_issues(credentials=credentials, term=input_data.term) + yield "issues", issues + except LinearAPIException as e: + yield "error", str(e) + except Exception as e: + yield "error", f"Unexpected error: {str(e)}" diff --git a/autogpt_platform/backend/backend/blocks/linear/models.py b/autogpt_platform/backend/backend/blocks/linear/models.py new file mode 100644 index 000000000..a6a2de3cd --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/linear/models.py @@ -0,0 +1,41 @@ +from pydantic import BaseModel + + +class Comment(BaseModel): + id: str + body: str + + +class CreateCommentInput(BaseModel): + body: str + issueId: str + + +class CreateCommentResponse(BaseModel): + success: bool + comment: Comment + + +class CreateCommentResponseWrapper(BaseModel): + commentCreate: CreateCommentResponse + + +class Issue(BaseModel): + id: str + identifier: str + title: str + description: str | None + priority: int + + +class CreateIssueResponse(BaseModel): + issue: Issue + + +class Project(BaseModel): + id: str + name: str + description: str + priority: int + progress: int + content: str diff --git a/autogpt_platform/backend/backend/blocks/linear/projects.py b/autogpt_platform/backend/backend/blocks/linear/projects.py new file mode 100644 index 000000000..9d33b3f77 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/linear/projects.py @@ -0,0 +1,93 @@ +from backend.blocks.linear._api import LinearAPIException, LinearClient +from backend.blocks.linear._auth import ( + TEST_CREDENTIALS_INPUT_OAUTH, + TEST_CREDENTIALS_OAUTH, + LinearCredentials, + LinearCredentialsField, + LinearCredentialsInput, + LinearScope, +) +from backend.blocks.linear.models import Project +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + + +class LinearSearchProjectsBlock(Block): + """Block for searching projects on Linear""" + + class Input(BlockSchema): + credentials: LinearCredentialsInput = LinearCredentialsField( + scopes=[LinearScope.READ], + ) + term: str = SchemaField(description="Term to search for projects") + + class Output(BlockSchema): + projects: list[Project] = SchemaField(description="List of projects") + error: str = SchemaField(description="Error message if issue creation failed") + + def __init__(self): + super().__init__( + id="446a1d35-9d8f-4ac5-83ea-7684ec50e6af", + description="Searches for projects on Linear", + input_schema=self.Input, + output_schema=self.Output, + categories={BlockCategory.PRODUCTIVITY, BlockCategory.ISSUE_TRACKING}, + test_input={ + "term": "Test project", + "credentials": TEST_CREDENTIALS_INPUT_OAUTH, + }, + test_credentials=TEST_CREDENTIALS_OAUTH, + test_output=[ + ( + "projects", + [ + Project( + id="abc123", + name="Test project", + description="Test description", + priority=1, + progress=1, + content="Test content", + ) + ], + ) + ], + test_mock={ + "search_projects": lambda *args, **kwargs: [ + Project( + id="abc123", + name="Test project", + description="Test description", + priority=1, + progress=1, + content="Test content", + ) + ] + }, + ) + + @staticmethod + def search_projects( + credentials: LinearCredentials, + term: str, + ) -> list[Project]: + client = LinearClient(credentials=credentials) + response: list[Project] = client.try_search_projects(term=term) + return response + + def run( + self, input_data: Input, *, credentials: LinearCredentials, **kwargs + ) -> BlockOutput: + """Execute the project search""" + try: + projects = self.search_projects( + credentials=credentials, + term=input_data.term, + ) + + yield "projects", projects + + except LinearAPIException as e: + yield "error", str(e) + except Exception as e: + yield "error", f"Unexpected error: {str(e)}" diff --git a/autogpt_platform/backend/backend/blocks/linear/triggers.py b/autogpt_platform/backend/backend/blocks/linear/triggers.py new file mode 100644 index 000000000..e69de29bb diff --git a/autogpt_platform/backend/backend/data/block.py b/autogpt_platform/backend/backend/data/block.py index add06dbb1..effdb2c5d 100644 --- a/autogpt_platform/backend/backend/data/block.py +++ b/autogpt_platform/backend/backend/data/block.py @@ -64,6 +64,8 @@ class BlockCategory(Enum): SAFETY = ( "Block that provides AI safety mechanisms such as detecting harmful content" ) + PRODUCTIVITY = "Block that helps with productivity" + ISSUE_TRACKING = "Block that helps with issue tracking" def dict(self) -> dict[str, str]: return {"category": self.name, "description": self.value} diff --git a/autogpt_platform/backend/backend/integrations/oauth/__init__.py b/autogpt_platform/backend/backend/integrations/oauth/__init__.py index ec45189c5..50f8a155a 100644 --- a/autogpt_platform/backend/backend/integrations/oauth/__init__.py +++ b/autogpt_platform/backend/backend/integrations/oauth/__init__.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING from .github import GitHubOAuthHandler from .google import GoogleOAuthHandler +from .linear import LinearOAuthHandler from .notion import NotionOAuthHandler from .twitter import TwitterOAuthHandler @@ -17,6 +18,7 @@ HANDLERS_BY_NAME: dict["ProviderName", type["BaseOAuthHandler"]] = { GoogleOAuthHandler, NotionOAuthHandler, TwitterOAuthHandler, + LinearOAuthHandler, ] } # --8<-- [end:HANDLERS_BY_NAMEExample] diff --git a/autogpt_platform/backend/backend/integrations/oauth/linear.py b/autogpt_platform/backend/backend/integrations/oauth/linear.py new file mode 100644 index 000000000..fd9d379c1 --- /dev/null +++ b/autogpt_platform/backend/backend/integrations/oauth/linear.py @@ -0,0 +1,165 @@ +import json +from typing import Optional +from urllib.parse import urlencode + +from pydantic import SecretStr + +from backend.blocks.linear._api import LinearAPIException +from backend.data.model import APIKeyCredentials, OAuth2Credentials +from backend.integrations.providers import ProviderName +from backend.util.request import requests + +from .base import BaseOAuthHandler + + +class LinearOAuthHandler(BaseOAuthHandler): + """ + OAuth2 handler for Linear. + """ + + PROVIDER_NAME = ProviderName.LINEAR + + def __init__(self, client_id: str, client_secret: str, redirect_uri: str): + self.client_id = client_id + self.client_secret = client_secret + self.redirect_uri = redirect_uri + self.auth_base_url = "https://linear.app/oauth/authorize" + self.token_url = "https://api.linear.app/oauth/token" # Correct token URL + self.revoke_url = "https://api.linear.app/oauth/revoke" + + def get_login_url( + self, scopes: list[str], state: str, code_challenge: Optional[str] + ) -> str: + + params = { + "client_id": self.client_id, + "redirect_uri": self.redirect_uri, + "response_type": "code", # Important: include "response_type" + "scope": ",".join(scopes), # Comma-separated, not space-separated + "state": state, + } + return f"{self.auth_base_url}?{urlencode(params)}" + + def exchange_code_for_tokens( + self, code: str, scopes: list[str], code_verifier: Optional[str] + ) -> OAuth2Credentials: + return self._request_tokens({"code": code, "redirect_uri": self.redirect_uri}) + + def revoke_tokens(self, credentials: OAuth2Credentials) -> bool: + if not credentials.access_token: + raise ValueError("No access token to revoke") + + headers = { + "Authorization": f"Bearer {credentials.access_token.get_secret_value()}" + } + + response = requests.post(self.revoke_url, headers=headers) + if not response.ok: + try: + error_data = response.json() + error_message = error_data.get("error", "Unknown error") + except json.JSONDecodeError: + error_message = response.text + raise LinearAPIException( + f"Failed to revoke Linear tokens ({response.status_code}): {error_message}", + response.status_code, + ) + + return True # Linear doesn't return JSON on successful revoke + + def _refresh_tokens(self, credentials: OAuth2Credentials) -> OAuth2Credentials: + if not credentials.refresh_token: + raise ValueError( + "No refresh token available." + ) # Linear uses non-expiring tokens + + return self._request_tokens( + { + "refresh_token": credentials.refresh_token.get_secret_value(), + "grant_type": "refresh_token", + } + ) + + def _request_tokens( + self, + params: dict[str, str], + current_credentials: Optional[OAuth2Credentials] = None, + ) -> OAuth2Credentials: + request_body = { + "client_id": self.client_id, + "client_secret": self.client_secret, + "grant_type": "authorization_code", # Ensure grant_type is correct + **params, + } + + headers = { + "Content-Type": "application/x-www-form-urlencoded" + } # Correct header for token request + response = requests.post(self.token_url, data=request_body, headers=headers) + + if not response.ok: + try: + error_data = response.json() + error_message = error_data.get("error", "Unknown error") + + except json.JSONDecodeError: + error_message = response.text + raise LinearAPIException( + f"Failed to fetch Linear tokens ({response.status_code}): {error_message}", + response.status_code, + ) + + token_data = response.json() + + # Note: Linear access tokens do not expire, so we set expires_at to None + new_credentials = OAuth2Credentials( + provider=self.PROVIDER_NAME, + title=current_credentials.title if current_credentials else None, + username=token_data.get("user", {}).get( + "name", "Unknown User" + ), # extract name or set appropriate + access_token=token_data["access_token"], + scopes=token_data["scope"].split( + "," + ), # Linear returns comma-separated scopes + refresh_token=token_data.get( + "refresh_token" + ), # Linear uses non-expiring tokens so this might be null + access_token_expires_at=None, + refresh_token_expires_at=None, + ) + if current_credentials: + new_credentials.id = current_credentials.id + return new_credentials + + def _request_username(self, access_token: str) -> Optional[str]: + + # Use the LinearClient to fetch user details using GraphQL + from backend.blocks.linear._api import LinearClient + + try: + + linear_client = LinearClient( + APIKeyCredentials( + api_key=SecretStr(access_token), + title="temp", + provider=self.PROVIDER_NAME, + expires_at=None, + ) + ) # Temporary credentials for this request + + query = """ + query Viewer { + viewer { + name + } + } + """ + + response = linear_client.query(query) + return response["viewer"]["name"] + + except Exception as e: # Handle any errors + + print(f"Error fetching username: {e}") + return None diff --git a/autogpt_platform/backend/backend/integrations/providers.py b/autogpt_platform/backend/backend/integrations/providers.py index d08d50e02..95751e92d 100644 --- a/autogpt_platform/backend/backend/integrations/providers.py +++ b/autogpt_platform/backend/backend/integrations/providers.py @@ -17,6 +17,7 @@ class ProviderName(str, Enum): HUBSPOT = "hubspot" IDEOGRAM = "ideogram" JINA = "jina" + LINEAR = "linear" MEDIUM = "medium" NOTION = "notion" NVIDIA = "nvidia" diff --git a/autogpt_platform/backend/backend/server/integrations/router.py b/autogpt_platform/backend/backend/server/integrations/router.py index 6a8c274dd..b85a55137 100644 --- a/autogpt_platform/backend/backend/server/integrations/router.py +++ b/autogpt_platform/backend/backend/server/integrations/router.py @@ -110,6 +110,11 @@ def callback( logger.debug(f"Received credentials with final scopes: {credentials.scopes}") + # Linear returns scopes as a single string with spaces, so we need to split them + # TODO: make a bypass of this part of the OAuth handler + if len(credentials.scopes) == 1 and " " in credentials.scopes[0]: + credentials.scopes = credentials.scopes[0].split(" ") + # Check if the granted scopes are sufficient for the requested scopes if not set(scopes).issubset(set(credentials.scopes)): # For now, we'll just log the warning and continue diff --git a/autogpt_platform/backend/backend/util/settings.py b/autogpt_platform/backend/backend/util/settings.py index e08b5ca0b..8c33c1459 100644 --- a/autogpt_platform/backend/backend/util/settings.py +++ b/autogpt_platform/backend/backend/util/settings.py @@ -313,6 +313,9 @@ class Secrets(UpdateTrackingModel["Secrets"], BaseSettings): e2b_api_key: str = Field(default="", description="E2B API key") nvidia_api_key: str = Field(default="", description="Nvidia API key") + linear_client_id: str = Field(default="", description="Linear client ID") + linear_client_secret: str = Field(default="", description="Linear client secret") + stripe_api_key: str = Field(default="", description="Stripe API Key") stripe_webhook_secret: str = Field(default="", description="Stripe Webhook Secret") diff --git a/autogpt_platform/frontend/src/components/integrations/credentials-input.tsx b/autogpt_platform/frontend/src/components/integrations/credentials-input.tsx index 7a32b4378..93afa31c6 100644 --- a/autogpt_platform/frontend/src/components/integrations/credentials-input.tsx +++ b/autogpt_platform/frontend/src/components/integrations/credentials-input.tsx @@ -14,6 +14,7 @@ import { FaGoogle, FaMedium, FaKey, + FaHubspot, } from "react-icons/fa"; import { FC, useMemo, useState } from "react"; import { @@ -66,6 +67,7 @@ export const providerIcons: Record< google_maps: FaGoogle, jina: fallbackIcon, ideogram: fallbackIcon, + linear: fallbackIcon, medium: FaMedium, ollama: fallbackIcon, openai: fallbackIcon, @@ -79,7 +81,7 @@ export const providerIcons: Record< twitter: FaTwitter, unreal_speech: fallbackIcon, exa: fallbackIcon, - hubspot: fallbackIcon, + hubspot: FaHubspot, }; // --8<-- [end:ProviderIconsEmbed] diff --git a/autogpt_platform/frontend/src/components/integrations/credentials-provider.tsx b/autogpt_platform/frontend/src/components/integrations/credentials-provider.tsx index 38c97443c..23a840e79 100644 --- a/autogpt_platform/frontend/src/components/integrations/credentials-provider.tsx +++ b/autogpt_platform/frontend/src/components/integrations/credentials-provider.tsx @@ -26,6 +26,7 @@ const providerDisplayNames: Record = { groq: "Groq", ideogram: "Ideogram", jina: "Jina", + linear: "Linear", medium: "Medium", notion: "Notion", nvidia: "Nvidia", diff --git a/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts b/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts index 0eaf4fcbe..cd839d2cd 100644 --- a/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts +++ b/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts @@ -111,6 +111,7 @@ export const PROVIDER_NAMES = { GROQ: "groq", IDEOGRAM: "ideogram", JINA: "jina", + LINEAR: "linear", MEDIUM: "medium", NOTION: "notion", NVIDIA: "nvidia",