From ddaeb4db53cdab3d7f1c2bba62a30f2507b7c301 Mon Sep 17 00:00:00 2001 From: Eric Gustin <34000337+EricGustin@users.noreply.github.com> Date: Wed, 30 Oct 2024 18:13:36 -0700 Subject: [PATCH] Add list stargazers tool (#130) # PR Description As a celebration for arcade-ai becoming open sourced, this PR adds a tool to list the stargazers for a particular repository ### Example `arcade chat -h localhost` usage: ![image](https://github.com/user-attachments/assets/c4ba9ce6-d3ec-461b-b356-72e78a09249b) --- .../arcade_github/tests/test_activity.py | 55 ---------- .../github/arcade_github/tools/activity.py | 55 +++++++++- toolkits/github/evals/eval_github_activity.py | 42 ++++++- toolkits/github/tests/test_activity.py | 103 ++++++++++++++++++ .../{arcade_github => }/tests/test_issues.py | 0 .../tests/test_pull_requests.py | 0 .../tests/test_repositories.py | 0 7 files changed, 198 insertions(+), 57 deletions(-) delete mode 100644 toolkits/github/arcade_github/tests/test_activity.py create mode 100644 toolkits/github/tests/test_activity.py rename toolkits/github/{arcade_github => }/tests/test_issues.py (100%) rename toolkits/github/{arcade_github => }/tests/test_pull_requests.py (100%) rename toolkits/github/{arcade_github => }/tests/test_repositories.py (100%) diff --git a/toolkits/github/arcade_github/tests/test_activity.py b/toolkits/github/arcade_github/tests/test_activity.py deleted file mode 100644 index 82971aed..00000000 --- a/toolkits/github/arcade_github/tests/test_activity.py +++ /dev/null @@ -1,55 +0,0 @@ -from unittest.mock import AsyncMock, patch - -import pytest -from arcade_github.tools.activity import set_starred -from httpx import Response - -from arcade.sdk.errors import ToolExecutionError - - -@pytest.fixture -def mock_context(): - context = AsyncMock() - context.authorization.token = "mock_token" # noqa: S105 - return context - - -@pytest.fixture -def mock_client(): - with patch("arcade_github.tools.activity.httpx.AsyncClient") as client: - yield client.return_value.__aenter__.return_value - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "starred,expected_message", - [ - (True, "Successfully starred the repository owner/repo"), - (False, "Successfully unstarred the repository owner/repo"), - ], -) -async def test_set_starred_success(mock_context, mock_client, starred, expected_message): - mock_client.put.return_value = mock_client.delete.return_value = Response(204) - - result = await set_starred(mock_context, "owner", "repo", starred) - assert result == expected_message - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "status_code,error_message,expected_error", - [ - (403, "Forbidden", "Error accessing.*: Forbidden"), - (404, "Not Found", "Error accessing.*: Resource not found"), - (500, "Internal Server Error", "Error accessing.*: Failed to process request"), - ], -) -async def test_set_starred_errors( - mock_context, mock_client, status_code, error_message, expected_error -): - mock_client.put.return_value = mock_client.delete.return_value = Response( - status_code, json={"message": error_message} - ) - - with pytest.raises(ToolExecutionError, match=expected_error): - await set_starred(mock_context, "owner", "repo", True) diff --git a/toolkits/github/arcade_github/tools/activity.py b/toolkits/github/arcade_github/tools/activity.py index f05e0b6e..62bc7030 100644 --- a/toolkits/github/arcade_github/tools/activity.py +++ b/toolkits/github/arcade_github/tools/activity.py @@ -1,4 +1,4 @@ -from typing import Annotated +from typing import Annotated, Optional import httpx @@ -38,3 +38,56 @@ async def set_starred( action = "starred" if starred else "unstarred" return f"Successfully {action} the repository {owner}/{name}" + + +# Implements https://docs.github.com/en/rest/activity/starring?apiVersion=2022-11-28#list-stargazers +# Example `arcade chat` usage: "list the stargazers for the ArcadeAI/arcade-ai repo" +@tool(requires_auth=GitHub()) +async def list_stargazers( + context: ToolContext, + owner: Annotated[str, "The owner of the repository"], + repo: Annotated[str, "The name of the repository"], + limit: Annotated[ + Optional[int], + "The maximum number of stargazers to return. If not provided, all stargazers will be returned.", + ] = None, +) -> Annotated[dict, "A dictionary containing the stargazers for the specified repository"]: + """List the stargazers for a GitHub repository.""" + url = get_url("repo_stargazers", owner=owner, repo=repo) + headers = get_github_json_headers(context.authorization.token) + + per_page = min(limit, 100) + page = 1 + stargazers = [] + + if limit is None: + limit = 2**64 - 1 + + async with httpx.AsyncClient() as client: + while len(stargazers) < limit: + response = await client.get( + url, headers=headers, params={"per_page": per_page, "page": page} + ) + handle_github_response(response, url) + + data = response.json() + if not data: + break + + stargazers.extend([ + { + "login": stargazer.get("login"), + "id": stargazer.get("id"), + "node_id": stargazer.get("node_id"), + "html_url": stargazer.get("html_url"), + } + for stargazer in data + ]) + + if len(data) < per_page: + break + + page += 1 + + stargazers = stargazers[:limit] + return {"number_of_stargazers": len(stargazers), "stargazers": stargazers} diff --git a/toolkits/github/evals/eval_github_activity.py b/toolkits/github/evals/eval_github_activity.py index 05cbc75e..5f3f40ed 100644 --- a/toolkits/github/evals/eval_github_activity.py +++ b/toolkits/github/evals/eval_github_activity.py @@ -1,5 +1,5 @@ import arcade_github -from arcade_github.tools.activity import set_starred +from arcade_github.tools.activity import list_stargazers, set_starred from arcade.sdk import ToolCatalog from arcade.sdk.eval import ( @@ -71,4 +71,44 @@ def github_activity_eval_suite() -> EvalSuite: ], ) + suite.add_case( + name="List stargazers for a repository", + user_message="List 42 stargazers for the ArcadeAI/arcade-ai repository.", + expected_tool_calls=[ + ( + list_stargazers, + { + "owner": "ArcadeAI", + "repo": "arcade-ai", + "limit": 42, + }, + ) + ], + critics=[ + BinaryCritic(critic_field="owner", weight=0.3), + BinaryCritic(critic_field="repo", weight=0.3), + BinaryCritic(critic_field="limit", weight=0.4), + ], + ) + + suite.add_case( + name="List stargazers for a repository", + user_message="List all of the stargazers for the ArcadeAI/arcade-ai repo", + expected_tool_calls=[ + ( + list_stargazers, + { + "owner": "ArcadeAI", + "repo": "arcade-ai", + "limit": None, + }, + ) + ], + critics=[ + BinaryCritic(critic_field="owner", weight=0.3), + BinaryCritic(critic_field="repo", weight=0.3), + BinaryCritic(critic_field="limit", weight=0.4), + ], + ) + return suite diff --git a/toolkits/github/tests/test_activity.py b/toolkits/github/tests/test_activity.py new file mode 100644 index 00000000..2c5f90c6 --- /dev/null +++ b/toolkits/github/tests/test_activity.py @@ -0,0 +1,103 @@ +from unittest.mock import AsyncMock, patch + +import pytest +from arcade_github.tools.activity import list_stargazers, set_starred +from httpx import Response + +from arcade.sdk.errors import ToolExecutionError + + +@pytest.fixture +def mock_context(): + context = AsyncMock() + context.authorization.token = "mock_token" # noqa: S105 + return context + + +@pytest.fixture +def mock_client(): + with patch("arcade_github.tools.activity.httpx.AsyncClient") as client: + yield client.return_value.__aenter__.return_value + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "starred,expected_message", + [ + (True, "Successfully starred the repository owner/repo"), + (False, "Successfully unstarred the repository owner/repo"), + ], +) +async def test_set_starred_success(mock_context, mock_client, starred, expected_message): + mock_client.put.return_value = mock_client.delete.return_value = Response(204) + + result = await set_starred(mock_context, "owner", "repo", starred) + assert result == expected_message + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "status_code,error_message,expected_error", + [ + (403, "Forbidden", "Error accessing.*: Forbidden"), + (404, "Not Found", "Error accessing.*: Resource not found"), + (500, "Internal Server Error", "Error accessing.*: Failed to process request"), + ], +) +async def test_set_starred_errors( + mock_context, mock_client, status_code, error_message, expected_error +): + mock_client.put.return_value = mock_client.delete.return_value = Response( + status_code, json={"message": error_message} + ) + + with pytest.raises(ToolExecutionError, match=expected_error): + await set_starred(mock_context, "owner", "repo", True) + + +@pytest.mark.asyncio +async def test_list_stargazers_success(mock_context, mock_client): + mock_response_data = [ + { + "login": "user1", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "html_url": "https://github.com/user1", + }, + { + "login": "user2", + "id": 2, + "node_id": "MDQ6VXNlcjI=", + "html_url": "https://github.com/user2", + }, + ] + mock_client.get.return_value = Response(200, json=mock_response_data) + + result = await list_stargazers(mock_context, "owner", "repo", limit=2) + assert result == {"number_of_stargazers": 2, "stargazers": mock_response_data} + + +@pytest.mark.asyncio +async def test_list_stargazers_empty(mock_context, mock_client): + mock_client.get.return_value = Response(200, json=[]) + + result = await list_stargazers(mock_context, "owner", "repo") + assert result == {"number_of_stargazers": 0, "stargazers": []} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "status_code,error_message,expected_error", + [ + (403, "Forbidden", "Error accessing.*: Forbidden"), + (404, "Not Found", "Error accessing.*: Resource not found"), + (500, "Internal Server Error", "Error accessing.*: Failed to process request"), + ], +) +async def test_list_stargazers_errors( + mock_context, mock_client, status_code, error_message, expected_error +): + mock_client.get.return_value = Response(status_code, json={"message": error_message}) + + with pytest.raises(ToolExecutionError, match=expected_error): + await list_stargazers(mock_context, "owner", "repo") diff --git a/toolkits/github/arcade_github/tests/test_issues.py b/toolkits/github/tests/test_issues.py similarity index 100% rename from toolkits/github/arcade_github/tests/test_issues.py rename to toolkits/github/tests/test_issues.py diff --git a/toolkits/github/arcade_github/tests/test_pull_requests.py b/toolkits/github/tests/test_pull_requests.py similarity index 100% rename from toolkits/github/arcade_github/tests/test_pull_requests.py rename to toolkits/github/tests/test_pull_requests.py diff --git a/toolkits/github/arcade_github/tests/test_repositories.py b/toolkits/github/tests/test_repositories.py similarity index 100% rename from toolkits/github/arcade_github/tests/test_repositories.py rename to toolkits/github/tests/test_repositories.py