From 760996916926c947bb08994ecf8a5aaa07f630ae Mon Sep 17 00:00:00 2001 From: Nicholas Tindle Date: Tue, 28 Jan 2025 12:34:09 +0000 Subject: [PATCH] feat(blocks): Add blocks for GitHub checks & statuses (#9271) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We really want to be able to automate with our agents some behaviors that involve blocking PRs or unblocking them based on automations. ### Changes 🏗️ - Adds Status Blocks for github - Modifies the pull requests block to not have include pr changes as advanced because that's a wild place to hide it - Adds some disabled checks blocks that require github app - Added IfMatches Block to make using stuff easier ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Built agent using --------- Co-authored-by: Aarushi <50577581+aarushik93@users.noreply.github.com> --- .../backend/backend/blocks/branching.py | 80 ++++ .../backend/backend/blocks/github/_api.py | 10 +- .../backend/backend/blocks/github/_auth.py | 30 ++ .../backend/backend/blocks/github/checks.py | 356 ++++++++++++++++++ .../backend/blocks/github/pull_requests.py | 1 + .../backend/backend/blocks/github/statuses.py | 180 +++++++++ 6 files changed, 655 insertions(+), 2 deletions(-) create mode 100644 autogpt_platform/backend/backend/blocks/github/checks.py create mode 100644 autogpt_platform/backend/backend/blocks/github/statuses.py diff --git a/autogpt_platform/backend/backend/blocks/branching.py b/autogpt_platform/backend/backend/blocks/branching.py index daf967bc6a7d..a3424d3374d8 100644 --- a/autogpt_platform/backend/backend/blocks/branching.py +++ b/autogpt_platform/backend/backend/blocks/branching.py @@ -107,3 +107,83 @@ def run(self, input_data: Input, **kwargs) -> BlockOutput: yield "yes_output", yes_value else: yield "no_output", no_value + + +class IfInputMatchesBlock(Block): + class Input(BlockSchema): + input: Any = SchemaField( + description="The input to match against", + placeholder="For example: 10 or 'hello' or True", + ) + value: Any = SchemaField( + description="The value to output if the input matches", + placeholder="For example: 'Greater' or 20 or False", + ) + yes_value: Any = SchemaField( + description="The value to output if the input matches", + placeholder="For example: 'Greater' or 20 or False", + default=None, + ) + no_value: Any = SchemaField( + description="The value to output if the input does not match", + placeholder="For example: 'Greater' or 20 or False", + default=None, + ) + + class Output(BlockSchema): + result: bool = SchemaField( + description="The result of the condition evaluation (True or False)" + ) + yes_output: Any = SchemaField( + description="The output value if the condition is true" + ) + no_output: Any = SchemaField( + description="The output value if the condition is false" + ) + + def __init__(self): + super().__init__( + id="6dbbc4b3-ca6c-42b6-b508-da52d23e13f2", + input_schema=IfInputMatchesBlock.Input, + output_schema=IfInputMatchesBlock.Output, + description="Handles conditional logic based on comparison operators", + categories={BlockCategory.LOGIC}, + test_input=[ + { + "input": 10, + "value": 10, + "yes_value": "Greater", + "no_value": "Not greater", + }, + { + "input": 10, + "value": 20, + "yes_value": "Greater", + "no_value": "Not greater", + }, + { + "input": 10, + "value": None, + "yes_value": "Yes", + "no_value": "No", + }, + ], + test_output=[ + ("result", True), + ("yes_output", "Greater"), + ("result", False), + ("no_output", "Not greater"), + ("result", False), + ("no_output", "No"), + # ("result", True), + # ("yes_output", "Yes"), + ], + ) + + def run(self, input_data: Input, **kwargs) -> BlockOutput: + if input_data.input == input_data.value or input_data.input is input_data.value: + yield "result", True + yield "yes_output", input_data.yes_value + else: + yield "result", False + yield "no_output", input_data.no_value diff --git a/autogpt_platform/backend/backend/blocks/github/_api.py b/autogpt_platform/backend/backend/blocks/github/_api.py index 72d25d9307a0..92436e865292 100644 --- a/autogpt_platform/backend/backend/blocks/github/_api.py +++ b/autogpt_platform/backend/backend/blocks/github/_api.py @@ -1,6 +1,9 @@ from urllib.parse import urlparse -from backend.blocks.github._auth import GithubCredentials +from backend.blocks.github._auth import ( + GithubCredentials, + GithubFineGrainedAPICredentials, +) from backend.util.request import Requests @@ -35,7 +38,10 @@ def _get_headers(credentials: GithubCredentials) -> dict[str, str]: } -def get_api(credentials: GithubCredentials, convert_urls: bool = True) -> Requests: +def get_api( + credentials: GithubCredentials | GithubFineGrainedAPICredentials, + convert_urls: bool = True, +) -> Requests: return Requests( trusted_origins=["https://api.github.com", "https://github.com"], extra_url_validator=_convert_to_api_url if convert_urls else None, diff --git a/autogpt_platform/backend/backend/blocks/github/_auth.py b/autogpt_platform/backend/backend/blocks/github/_auth.py index df7eed90f701..3109024abf0e 100644 --- a/autogpt_platform/backend/backend/blocks/github/_auth.py +++ b/autogpt_platform/backend/backend/blocks/github/_auth.py @@ -22,6 +22,11 @@ Literal["api_key", "oauth2"] if GITHUB_OAUTH_IS_CONFIGURED else Literal["api_key"], ] +GithubFineGrainedAPICredentials = APIKeyCredentials +GithubFineGrainedAPICredentialsInput = CredentialsMetaInput[ + Literal[ProviderName.GITHUB], Literal["api_key"] +] + def GithubCredentialsField(scope: str) -> GithubCredentialsInput: """ @@ -37,6 +42,16 @@ def GithubCredentialsField(scope: str) -> GithubCredentialsInput: ) +def GithubFineGrainedAPICredentialsField( + scope: str, +) -> GithubFineGrainedAPICredentialsInput: + return CredentialsField( + required_scopes={scope}, + description="The GitHub integration can be used with OAuth, " + "or any API key with sufficient permissions for the blocks it is used on.", + ) + + TEST_CREDENTIALS = APIKeyCredentials( id="01234567-89ab-cdef-0123-456789abcdef", provider="github", @@ -50,3 +65,18 @@ def GithubCredentialsField(scope: str) -> GithubCredentialsInput: "type": TEST_CREDENTIALS.type, "title": TEST_CREDENTIALS.type, } + +TEST_FINE_GRAINED_CREDENTIALS = APIKeyCredentials( + id="01234567-89ab-cdef-0123-456789abcdef", + provider="github", + api_key=SecretStr("mock-github-api-key"), + title="Mock GitHub API key", + expires_at=None, +) + +TEST_FINE_GRAINED_CREDENTIALS_INPUT = { + "provider": TEST_FINE_GRAINED_CREDENTIALS.provider, + "id": TEST_FINE_GRAINED_CREDENTIALS.id, + "type": TEST_FINE_GRAINED_CREDENTIALS.type, + "title": TEST_FINE_GRAINED_CREDENTIALS.type, +} diff --git a/autogpt_platform/backend/backend/blocks/github/checks.py b/autogpt_platform/backend/backend/blocks/github/checks.py new file mode 100644 index 000000000000..6d9ac1897c05 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/github/checks.py @@ -0,0 +1,356 @@ +from enum import Enum +from typing import Optional + +from pydantic import BaseModel + +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + +from ._api import get_api +from ._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + GithubCredentials, + GithubCredentialsField, + GithubCredentialsInput, +) + + +# queued, in_progress, completed, waiting, requested, pending +class ChecksStatus(Enum): + QUEUED = "queued" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + WAITING = "waiting" + REQUESTED = "requested" + PENDING = "pending" + + +class ChecksConclusion(Enum): + SUCCESS = "success" + FAILURE = "failure" + NEUTRAL = "neutral" + CANCELLED = "cancelled" + TIMED_OUT = "timed_out" + ACTION_REQUIRED = "action_required" + SKIPPED = "skipped" + + +class GithubCreateCheckRunBlock(Block): + """Block for creating a new check run on a GitHub repository.""" + + class Input(BlockSchema): + credentials: GithubCredentialsInput = GithubCredentialsField("repo:status") + repo_url: str = SchemaField( + description="URL of the GitHub repository", + placeholder="https://github.com/owner/repo", + ) + name: str = SchemaField( + description="The name of the check run (e.g., 'code-coverage')", + ) + head_sha: str = SchemaField( + description="The SHA of the commit to check", + ) + status: ChecksStatus = SchemaField( + description="Current status of the check run", + default=ChecksStatus.QUEUED, + ) + conclusion: Optional[ChecksConclusion] = SchemaField( + description="The final conclusion of the check (required if status is completed)", + default=None, + ) + details_url: str = SchemaField( + description="The URL for the full details of the check", + default="", + ) + output_title: str = SchemaField( + description="Title of the check run output", + default="", + ) + output_summary: str = SchemaField( + description="Summary of the check run output", + default="", + ) + output_text: str = SchemaField( + description="Detailed text of the check run output", + default="", + ) + + class Output(BlockSchema): + class CheckRunResult(BaseModel): + id: int + html_url: str + status: str + + check_run: CheckRunResult = SchemaField( + description="Details of the created check run" + ) + error: str = SchemaField( + description="Error message if check run creation failed" + ) + + def __init__(self): + super().__init__( + id="2f45e89a-3b7d-4f22-b89e-6c4f5c7e1234", + description="Creates a new check run for a specific commit in a GitHub repository", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=GithubCreateCheckRunBlock.Input, + output_schema=GithubCreateCheckRunBlock.Output, + test_input={ + "repo_url": "https://github.com/owner/repo", + "name": "test-check", + "head_sha": "ce587453ced02b1526dfb4cb910479d431683101", + "status": ChecksStatus.COMPLETED.value, + "conclusion": ChecksConclusion.SUCCESS.value, + "output_title": "Test Results", + "output_summary": "All tests passed", + "credentials": TEST_CREDENTIALS_INPUT, + }, + # requires a github app not available to oauth in our current system + disabled=True, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ( + "check_run", + { + "id": 4, + "html_url": "https://github.com/owner/repo/runs/4", + "status": "completed", + }, + ), + ], + test_mock={ + "create_check_run": lambda *args, **kwargs: { + "id": 4, + "html_url": "https://github.com/owner/repo/runs/4", + "status": "completed", + } + }, + ) + + @staticmethod + def create_check_run( + credentials: GithubCredentials, + repo_url: str, + name: str, + head_sha: str, + status: ChecksStatus, + conclusion: Optional[ChecksConclusion] = None, + details_url: Optional[str] = None, + output_title: Optional[str] = None, + output_summary: Optional[str] = None, + output_text: Optional[str] = None, + ) -> dict: + api = get_api(credentials) + + class CheckRunData(BaseModel): + name: str + head_sha: str + status: str + conclusion: Optional[str] = None + details_url: Optional[str] = None + output: Optional[dict[str, str]] = None + + data = CheckRunData( + name=name, + head_sha=head_sha, + status=status.value, + ) + + if conclusion: + data.conclusion = conclusion.value + + if details_url: + data.details_url = details_url + + if output_title or output_summary or output_text: + output_data = { + "title": output_title or "", + "summary": output_summary or "", + "text": output_text or "", + } + data.output = output_data + + check_runs_url = f"{repo_url}/check-runs" + response = api.post(check_runs_url) + result = response.json() + + return { + "id": result["id"], + "html_url": result["html_url"], + "status": result["status"], + } + + def run( + self, + input_data: Input, + *, + credentials: GithubCredentials, + **kwargs, + ) -> BlockOutput: + try: + result = self.create_check_run( + credentials=credentials, + repo_url=input_data.repo_url, + name=input_data.name, + head_sha=input_data.head_sha, + status=input_data.status, + conclusion=input_data.conclusion, + details_url=input_data.details_url, + output_title=input_data.output_title, + output_summary=input_data.output_summary, + output_text=input_data.output_text, + ) + yield "check_run", result + except Exception as e: + yield "error", str(e) + + +class GithubUpdateCheckRunBlock(Block): + """Block for updating an existing check run on a GitHub repository.""" + + class Input(BlockSchema): + credentials: GithubCredentialsInput = GithubCredentialsField("repo:status") + repo_url: str = SchemaField( + description="URL of the GitHub repository", + placeholder="https://github.com/owner/repo", + ) + check_run_id: int = SchemaField( + description="The ID of the check run to update", + ) + status: ChecksStatus = SchemaField( + description="New status of the check run", + ) + conclusion: ChecksConclusion = SchemaField( + description="The final conclusion of the check (required if status is completed)", + ) + output_title: Optional[str] = SchemaField( + description="New title of the check run output", + default=None, + ) + output_summary: Optional[str] = SchemaField( + description="New summary of the check run output", + default=None, + ) + output_text: Optional[str] = SchemaField( + description="New detailed text of the check run output", + default=None, + ) + + class Output(BlockSchema): + class CheckRunResult(BaseModel): + id: int + html_url: str + status: str + conclusion: Optional[str] + + check_run: CheckRunResult = SchemaField( + description="Details of the updated check run" + ) + error: str = SchemaField(description="Error message if check run update failed") + + def __init__(self): + super().__init__( + id="8a23c567-9d01-4e56-b789-0c12d3e45678", # Generated UUID + description="Updates an existing check run in a GitHub repository", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=GithubUpdateCheckRunBlock.Input, + output_schema=GithubUpdateCheckRunBlock.Output, + # requires a github app not available to oauth in our current system + disabled=True, + test_input={ + "repo_url": "https://github.com/owner/repo", + "check_run_id": 4, + "status": ChecksStatus.COMPLETED.value, + "conclusion": ChecksConclusion.SUCCESS.value, + "output_title": "Updated Results", + "output_summary": "All tests passed after retry", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ( + "check_run", + { + "id": 4, + "html_url": "https://github.com/owner/repo/runs/4", + "status": "completed", + "conclusion": "success", + }, + ), + ], + test_mock={ + "update_check_run": lambda *args, **kwargs: { + "id": 4, + "html_url": "https://github.com/owner/repo/runs/4", + "status": "completed", + "conclusion": "success", + } + }, + ) + + @staticmethod + def update_check_run( + credentials: GithubCredentials, + repo_url: str, + check_run_id: int, + status: ChecksStatus, + conclusion: Optional[ChecksConclusion] = None, + output_title: Optional[str] = None, + output_summary: Optional[str] = None, + output_text: Optional[str] = None, + ) -> dict: + api = get_api(credentials) + + class UpdateCheckRunData(BaseModel): + status: str + conclusion: Optional[str] = None + output: Optional[dict[str, str]] = None + + data = UpdateCheckRunData( + status=status.value, + ) + + if conclusion: + data.conclusion = conclusion.value + + if output_title or output_summary or output_text: + output_data = { + "title": output_title or "", + "summary": output_summary or "", + "text": output_text or "", + } + data.output = output_data + + check_run_url = f"{repo_url}/check-runs/{check_run_id}" + response = api.patch(check_run_url) + result = response.json() + + return { + "id": result["id"], + "html_url": result["html_url"], + "status": result["status"], + "conclusion": result.get("conclusion"), + } + + def run( + self, + input_data: Input, + *, + credentials: GithubCredentials, + **kwargs, + ) -> BlockOutput: + try: + result = self.update_check_run( + credentials=credentials, + repo_url=input_data.repo_url, + check_run_id=input_data.check_run_id, + status=input_data.status, + conclusion=input_data.conclusion, + output_title=input_data.output_title, + output_summary=input_data.output_summary, + output_text=input_data.output_text, + ) + yield "check_run", result + except Exception as e: + yield "error", str(e) diff --git a/autogpt_platform/backend/backend/blocks/github/pull_requests.py b/autogpt_platform/backend/backend/blocks/github/pull_requests.py index e8fad2daa66a..b29db0ff3439 100644 --- a/autogpt_platform/backend/backend/blocks/github/pull_requests.py +++ b/autogpt_platform/backend/backend/blocks/github/pull_requests.py @@ -200,6 +200,7 @@ class Input(BlockSchema): include_pr_changes: bool = SchemaField( description="Whether to include the changes made in the pull request", default=False, + advanced=False, ) class Output(BlockSchema): diff --git a/autogpt_platform/backend/backend/blocks/github/statuses.py b/autogpt_platform/backend/backend/blocks/github/statuses.py new file mode 100644 index 000000000000..8abf27928f32 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/github/statuses.py @@ -0,0 +1,180 @@ +from enum import Enum +from typing import Optional + +from pydantic import BaseModel + +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + +from ._api import get_api +from ._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + GithubFineGrainedAPICredentials, + GithubFineGrainedAPICredentialsField, + GithubFineGrainedAPICredentialsInput, +) + + +class StatusState(Enum): + ERROR = "error" + FAILURE = "failure" + PENDING = "pending" + SUCCESS = "success" + + +class GithubCreateStatusBlock(Block): + """Block for creating a commit status on a GitHub repository.""" + + class Input(BlockSchema): + credentials: GithubFineGrainedAPICredentialsInput = ( + GithubFineGrainedAPICredentialsField("repo:status") + ) + repo_url: str = SchemaField( + description="URL of the GitHub repository", + placeholder="https://github.com/owner/repo", + ) + sha: str = SchemaField( + description="The SHA of the commit to set status for", + ) + state: StatusState = SchemaField( + description="The state of the status (error, failure, pending, success)", + ) + target_url: Optional[str] = SchemaField( + description="URL with additional details about this status", + default=None, + ) + description: Optional[str] = SchemaField( + description="Short description of the status", + default=None, + ) + check_name: Optional[str] = SchemaField( + description="Label to differentiate this status from others", + default="AutoGPT Platform Checks", + advanced=False, + ) + + class Output(BlockSchema): + class StatusResult(BaseModel): + id: int + url: str + state: str + context: str + description: Optional[str] + target_url: Optional[str] + created_at: str + updated_at: str + + status: StatusResult = SchemaField(description="Details of the created status") + error: str = SchemaField(description="Error message if status creation failed") + + def __init__(self): + super().__init__( + id="3d67f123-a4b5-4c89-9d01-2e34f5c67890", # Generated UUID + description="Creates a new commit status in a GitHub repository", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=GithubCreateStatusBlock.Input, + output_schema=GithubCreateStatusBlock.Output, + test_input={ + "repo_url": "https://github.com/owner/repo", + "sha": "ce587453ced02b1526dfb4cb910479d431683101", + "state": StatusState.SUCCESS.value, + "target_url": "https://example.com/build/status", + "description": "The build succeeded!", + "check_name": "continuous-integration/jenkins", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ( + "status", + { + "id": 1234567890, + "url": "https://api.github.com/repos/owner/repo/statuses/ce587453ced02b1526dfb4cb910479d431683101", + "state": "success", + "context": "continuous-integration/jenkins", + "description": "The build succeeded!", + "target_url": "https://example.com/build/status", + "created_at": "2024-01-21T10:00:00Z", + "updated_at": "2024-01-21T10:00:00Z", + }, + ), + ], + test_mock={ + "create_status": lambda *args, **kwargs: { + "id": 1234567890, + "url": "https://api.github.com/repos/owner/repo/statuses/ce587453ced02b1526dfb4cb910479d431683101", + "state": "success", + "context": "continuous-integration/jenkins", + "description": "The build succeeded!", + "target_url": "https://example.com/build/status", + "created_at": "2024-01-21T10:00:00Z", + "updated_at": "2024-01-21T10:00:00Z", + } + }, + ) + + @staticmethod + def create_status( + credentials: GithubFineGrainedAPICredentials, + repo_url: str, + sha: str, + state: StatusState, + target_url: Optional[str] = None, + description: Optional[str] = None, + context: str = "default", + ) -> dict: + api = get_api(credentials) + + class StatusData(BaseModel): + state: str + target_url: Optional[str] = None + description: Optional[str] = None + context: str + + data = StatusData( + state=state.value, + context=context, + ) + + if target_url: + data.target_url = target_url + + if description: + data.description = description + + status_url = f"{repo_url}/statuses/{sha}" + response = api.post(status_url, json=data) + result = response.json() + + return { + "id": result["id"], + "url": result["url"], + "state": result["state"], + "context": result["context"], + "description": result.get("description"), + "target_url": result.get("target_url"), + "created_at": result["created_at"], + "updated_at": result["updated_at"], + } + + def run( + self, + input_data: Input, + *, + credentials: GithubFineGrainedAPICredentials, + **kwargs, + ) -> BlockOutput: + try: + result = self.create_status( + credentials=credentials, + repo_url=input_data.repo_url, + sha=input_data.sha, + state=input_data.state, + target_url=input_data.target_url, + description=input_data.description, + context=input_data.check_name or "AutoGPT Platform Checks", + ) + yield "status", result + except Exception as e: + yield "error", str(e)