diff --git a/src/dataModel/model.py b/src/dataModel/model.py index 0ae9376..394846b 100644 --- a/src/dataModel/model.py +++ b/src/dataModel/model.py @@ -4,6 +4,7 @@ class AccessorType(str, Enum): OPENAI = "openai" ANTHROPIC = "anthropic" + GITHUB_COPILOT = "github_copilot" MOCK = "mock" class Model(BaseModel): diff --git a/src/modelAccessors/github_copilot_accessor.py b/src/modelAccessors/github_copilot_accessor.py new file mode 100644 index 0000000..f55c65b --- /dev/null +++ b/src/modelAccessors/github_copilot_accessor.py @@ -0,0 +1,116 @@ +import json +import subprocess +from typing import Any, Optional +import os + +from pydantic import TypeAdapter + +from .base_accessor import BaseModelAccessor, Tool +from src.dataModel.model_response import ModelResponse + + +class GitHubCopilotAccessor(BaseModelAccessor): + """GitHub Copilot accessor that creates agentic tasks and returns issue IDs.""" + + def __init__(self): + # Verify that gh CLI is available + try: + subprocess.run(['gh', '--version'], capture_output=True, text=True, check=True) + self._gh_available = True + except (subprocess.CalledProcessError, FileNotFoundError): + self._gh_available = False + + # GitHub Copilot models that support tool usage + self.tool_supported_models = ["gpt-4", "gpt-3.5-turbo", "gpt-4o"] + + def call_model( + self, + prompt: str, + *, + adapter: TypeAdapter[ModelResponse], + schema: dict, + model: str = "gpt-4", + system_prompt: str = "", + tools: Optional[list[Tool]] = None, + ) -> ModelResponse: + """ + Call GitHub Copilot to create an agentic task. + + This uses the gh copilot CLI to create a GitHub issue with an agentic task + that will handle the code generation. The response contains the issue ID + that was created. + """ + + if not self._gh_available: + raise RuntimeError( + "GitHub CLI (gh) is not available. Please install it and authenticate with 'gh auth login'. " + "See https://github.com/cli/cli#installation for installation instructions." + ) + + # Combine system prompt and user prompt for the task description + full_prompt = f"{system_prompt}\n\n{prompt}".strip() + + try: + # Create a GitHub Copilot agentic task + # The command format: gh copilot agent-task create "" [--follow] + cmd = ['gh', 'copilot', 'agent-task', 'create', full_prompt] + + # Add --follow flag to track the task progress + cmd.append('--follow') + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=True, + env=os.environ.copy() + ) + + output = result.stdout.strip() + + # Parse the output to extract the issue ID + # The gh copilot agent-task create command should return an issue URL or ID + issue_id = self._extract_issue_id(output) + + # Create a response with the issue ID + response_data = { + "type": "implemented", + "content": f"GitHub Copilot agentic task created. Issue ID: {issue_id}", + "artifacts": [issue_id] + } + + return adapter.validate_python(response_data) + + except subprocess.CalledProcessError as e: + # If the command fails, it might be because the extension or feature is not available + raise RuntimeError( + f"GitHub Copilot agent-task creation failed: {e.stderr}\n" + "Make sure you have the GitHub Copilot CLI extension installed: " + "gh extension install github/gh-copilot" + ) from e + + def _extract_issue_id(self, output: str) -> str: + """ + Extract the issue ID from gh copilot output. + + The output might contain a URL like https://github.com/owner/repo/issues/123 + or just the issue number. + """ + import re + + # Try to find issue URL pattern + url_match = re.search(r'github\.com/[^/]+/[^/]+/issues/(\d+)', output) + if url_match: + return url_match.group(1) + + # Try to find standalone issue number + num_match = re.search(r'#(\d+)', output) + if num_match: + return num_match.group(1) + + # If no pattern matches, return the full output + return output + + def supports_tools(self, model: str) -> bool: + """Check if model supports tools.""" + return model in self.tool_supported_models \ No newline at end of file diff --git a/src/orchestrator/orchestrator.py b/src/orchestrator/orchestrator.py index 59c1ac3..b31e4d4 100644 --- a/src/orchestrator/orchestrator.py +++ b/src/orchestrator/orchestrator.py @@ -29,6 +29,7 @@ from src.modelAccessors.base_accessor import BaseModelAccessor from src.modelAccessors.openai_accessor import OpenAIAccessor from src.modelAccessors.anthropic_accessor import AnthropicAccessor +from src.modelAccessors.github_copilot_accessor import GitHubCopilotAccessor from src.modelAccessors.mock_accessor import MockAccessor from src.agentNodes.clarifier import Clarifier from src.agentNodes.hld_designer import HLDDesigner @@ -126,6 +127,8 @@ def _get_accessor(self, accessor_type: AccessorType) -> BaseModelAccessor: return OpenAIAccessor() case AccessorType.ANTHROPIC: return AnthropicAccessor() + case AccessorType.GITHUB_COPILOT: + return GitHubCopilotAccessor() case AccessorType.MOCK: return MockAccessor() case _: diff --git a/src/tools/__init__.py b/src/tools/__init__.py index 822f87f..48bdfcc 100644 --- a/src/tools/__init__.py +++ b/src/tools/__init__.py @@ -15,6 +15,22 @@ read_directory, write_directory, ) +from .github_tools import ( + GitHubIssueManager, + get_issue, + create_issue, + update_issue, + comment_on_issue, + list_issues, + close_issue, + GET_ISSUE_TOOL, + CREATE_ISSUE_TOOL, + UPDATE_ISSUE_TOOL, + COMMENT_ISSUE_TOOL, + LIST_ISSUES_TOOL, + CLOSE_ISSUE_TOOL, + GITHUB_TOOLS, +) __all__ = [ "WEB_SEARCH_TOOL", @@ -31,4 +47,18 @@ "WRITE_DIRECTORY_TOOL", "read_directory", "write_directory", + "GitHubIssueManager", + "get_issue", + "create_issue", + "update_issue", + "comment_on_issue", + "list_issues", + "close_issue", + "GET_ISSUE_TOOL", + "CREATE_ISSUE_TOOL", + "UPDATE_ISSUE_TOOL", + "COMMENT_ISSUE_TOOL", + "LIST_ISSUES_TOOL", + "CLOSE_ISSUE_TOOL", + "GITHUB_TOOLS", ] diff --git a/src/tools/github_tools.py b/src/tools/github_tools.py new file mode 100644 index 0000000..5738d85 --- /dev/null +++ b/src/tools/github_tools.py @@ -0,0 +1,174 @@ +import json +import os +from typing import Any, Dict, Optional + +import requests + +from src.modelAccessors.data.tool import Tool + + +class GitHubIssueManager: + """Manager for GitHub issue operations using GitHub API.""" + + @staticmethod + def _get_api_headers() -> Dict[str, str]: + """Get headers for GitHub API requests.""" + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + if not token: + raise RuntimeError( + "GitHub token not found. Set GITHUB_TOKEN or GH_TOKEN environment variable. " + "You can create a token at https://github.com/settings/tokens" + ) + + return { + "Authorization": f"token {token}", + "Accept": "application/vnd.github.v3+json", + "Content-Type": "application/json" + } + + @staticmethod + def _make_api_request(method: str, url: str, data: Optional[Dict] = None) -> Dict[str, Any]: + """Make a request to the GitHub API.""" + headers = GitHubIssueManager._get_api_headers() + + try: + if method.upper() == "GET": + response = requests.get(url, headers=headers) + elif method.upper() == "POST": + response = requests.post(url, headers=headers, json=data) + elif method.upper() == "PATCH": + response = requests.patch(url, headers=headers, json=data) + else: + raise ValueError(f"Unsupported HTTP method: {method}") + + response.raise_for_status() + return response.json() if response.content else {} + except requests.exceptions.RequestException as e: + raise RuntimeError(f"GitHub API request failed: {e}") from e + + +def get_issue(repo: str, issue_number: int) -> Dict[str, Any]: + """Get details of a specific GitHub issue.""" + url = f"https://api.github.com/repos/{repo}/issues/{issue_number}" + return GitHubIssueManager._make_api_request("GET", url) + + +def create_issue(repo: str, title: str, body: str = "", labels: Optional[list[str]] = None) -> Dict[str, Any]: + """Create a new GitHub issue.""" + url = f"https://api.github.com/repos/{repo}/issues" + data = { + "title": title, + "body": body + } + if labels: + data["labels"] = labels + + return GitHubIssueManager._make_api_request("POST", url, data) + + +def update_issue(repo: str, issue_number: int, title: Optional[str] = None, body: Optional[str] = None, state: Optional[str] = None) -> Dict[str, Any]: + """Update a GitHub issue.""" + url = f"https://api.github.com/repos/{repo}/issues/{issue_number}" + data = {} + + if title: + data["title"] = title + if body: + data["body"] = body + if state: + data["state"] = state + + return GitHubIssueManager._make_api_request("PATCH", url, data) + + +def comment_on_issue(repo: str, issue_number: int, comment: str) -> Dict[str, Any]: + """Add a comment to a GitHub issue.""" + url = f"https://api.github.com/repos/{repo}/issues/{issue_number}/comments" + data = {"body": comment} + return GitHubIssueManager._make_api_request("POST", url, data) + + +def list_issues(repo: str, state: str = "open", limit: int = 10) -> list[Dict[str, Any]]: + """List GitHub issues.""" + url = f"https://api.github.com/repos/{repo}/issues?state={state}&per_page={limit}" + result = GitHubIssueManager._make_api_request("GET", url) + # GitHub API returns a list directly + return result if isinstance(result, list) else [result] + + +def close_issue(repo: str, issue_number: int) -> Dict[str, Any]: + """Close a GitHub issue.""" + return update_issue(repo, issue_number, state="closed") + + +# Tool definitions for use with model accessors +GET_ISSUE_TOOL = Tool( + name="get_github_issue", + description="Get details of a specific GitHub issue by repository and issue number", + parameters={ + "repo": {"type": "string", "description": "Repository in format 'owner/repo'"}, + "issue_number": {"type": "integer", "description": "Issue number"} + } +) + +CREATE_ISSUE_TOOL = Tool( + name="create_github_issue", + description="Create a new GitHub issue", + parameters={ + "repo": {"type": "string", "description": "Repository in format 'owner/repo'"}, + "title": {"type": "string", "description": "Issue title"}, + "body": {"type": "string", "description": "Issue body/description"}, + "labels": {"type": "array", "items": {"type": "string"}, "description": "Optional labels for the issue"} + } +) + +UPDATE_ISSUE_TOOL = Tool( + name="update_github_issue", + description="Update an existing GitHub issue", + parameters={ + "repo": {"type": "string", "description": "Repository in format 'owner/repo'"}, + "issue_number": {"type": "integer", "description": "Issue number"}, + "title": {"type": "string", "description": "New title (optional)"}, + "body": {"type": "string", "description": "New body/description (optional)"}, + "state": {"type": "string", "enum": ["open", "closed"], "description": "New state (optional)"} + } +) + +COMMENT_ISSUE_TOOL = Tool( + name="comment_github_issue", + description="Add a comment to a GitHub issue", + parameters={ + "repo": {"type": "string", "description": "Repository in format 'owner/repo'"}, + "issue_number": {"type": "integer", "description": "Issue number"}, + "comment": {"type": "string", "description": "Comment text"} + } +) + +LIST_ISSUES_TOOL = Tool( + name="list_github_issues", + description="List GitHub issues in a repository", + parameters={ + "repo": {"type": "string", "description": "Repository in format 'owner/repo'"}, + "state": {"type": "string", "enum": ["open", "closed", "all"], "description": "Issue state filter"}, + "limit": {"type": "integer", "description": "Maximum number of issues to return"} + } +) + +CLOSE_ISSUE_TOOL = Tool( + name="close_github_issue", + description="Close a GitHub issue", + parameters={ + "repo": {"type": "string", "description": "Repository in format 'owner/repo'"}, + "issue_number": {"type": "integer", "description": "Issue number"} + } +) + +# All GitHub tools for easy import +GITHUB_TOOLS = [ + GET_ISSUE_TOOL, + CREATE_ISSUE_TOOL, + UPDATE_ISSUE_TOOL, + COMMENT_ISSUE_TOOL, + LIST_ISSUES_TOOL, + CLOSE_ISSUE_TOOL, +] \ No newline at end of file diff --git a/tests/modelAccessors/test_github_copilot_accessor.py b/tests/modelAccessors/test_github_copilot_accessor.py new file mode 100644 index 0000000..17c1f94 --- /dev/null +++ b/tests/modelAccessors/test_github_copilot_accessor.py @@ -0,0 +1,104 @@ +"""Tests for GitHub Copilot accessor.""" + +import pytest +from unittest.mock import patch, MagicMock +from src.modelAccessors.github_copilot_accessor import GitHubCopilotAccessor + + +def test_github_copilot_accessor_init(): + """Test GitHubCopilotAccessor initialization.""" + with patch('subprocess.run') as mock_run: + mock_run.return_value.returncode = 0 + accessor = GitHubCopilotAccessor() + assert accessor._gh_available is True + assert "gpt-4" in accessor.tool_supported_models + + +def test_github_copilot_accessor_init_no_gh(): + """Test GitHubCopilotAccessor initialization when gh CLI is not available.""" + with patch('subprocess.run', side_effect=FileNotFoundError): + accessor = GitHubCopilotAccessor() + assert accessor._gh_available is False + + +def test_supports_tools(): + """Test supports_tools method.""" + with patch('subprocess.run') as mock_run: + mock_run.return_value.returncode = 0 + accessor = GitHubCopilotAccessor() + assert accessor.supports_tools("gpt-4") is True + assert accessor.supports_tools("unsupported-model") is False + + +def test_extract_issue_id_from_url(): + """Test _extract_issue_id with URL format.""" + with patch('subprocess.run') as mock_run: + mock_run.return_value.returncode = 0 + accessor = GitHubCopilotAccessor() + + output = "Created issue: https://github.com/owner/repo/issues/123" + issue_id = accessor._extract_issue_id(output) + assert issue_id == "123" + + +def test_extract_issue_id_from_hash(): + """Test _extract_issue_id with # format.""" + with patch('subprocess.run') as mock_run: + mock_run.return_value.returncode = 0 + accessor = GitHubCopilotAccessor() + + output = "Issue #456 created" + issue_id = accessor._extract_issue_id(output) + assert issue_id == "456" + + +def test_call_model_no_gh(): + """Test call_model when gh CLI is not available.""" + with patch('subprocess.run', side_effect=FileNotFoundError): + accessor = GitHubCopilotAccessor() + + with pytest.raises(RuntimeError, match="GitHub CLI \\(gh\\) is not available"): + accessor.call_model("test prompt", adapter=MagicMock(), schema={}) + + +@patch('subprocess.run') +def test_call_model_success(mock_run): + """Test successful call_model execution.""" + # Setup mock for gh version check and agent-task create + mock_run.side_effect = [ + MagicMock(returncode=0), # gh version check + MagicMock(stdout='Created issue: https://github.com/owner/repo/issues/42', returncode=0) # gh copilot agent-task + ] + + accessor = GitHubCopilotAccessor() + + # Mock TypeAdapter + mock_adapter = MagicMock() + mock_response = MagicMock() + mock_adapter.validate_python.return_value = mock_response + + result = accessor.call_model("test prompt", adapter=mock_adapter, schema={}) + + # Verify the adapter was called with correct data + call_args = mock_adapter.validate_python.call_args[0][0] + assert call_args["type"] == "implemented" + assert "42" in call_args["content"] + assert "42" in call_args["artifacts"] + assert result == mock_response + + +@patch('subprocess.run') +def test_call_model_failure(mock_run): + """Test call_model when gh copilot command fails.""" + from subprocess import CalledProcessError + + # Setup mock for gh version check succeeds, agent-task fails + mock_run.side_effect = [ + MagicMock(returncode=0), # gh version check + CalledProcessError(1, 'gh', stderr='Extension not found') # gh copilot agent-task fails + ] + + accessor = GitHubCopilotAccessor() + + with pytest.raises(RuntimeError, match="GitHub Copilot agent-task creation failed"): + accessor.call_model("test prompt", adapter=MagicMock(), schema={}) \ No newline at end of file diff --git a/tests/tools/test_github_tools.py b/tests/tools/test_github_tools.py new file mode 100644 index 0000000..c5a7726 --- /dev/null +++ b/tests/tools/test_github_tools.py @@ -0,0 +1,162 @@ +"""Tests for GitHub tools.""" + +import pytest +from unittest.mock import patch, MagicMock +import json + +from src.tools.github_tools import ( + get_issue, + create_issue, + update_issue, + comment_on_issue, + list_issues, + close_issue, + GitHubIssueManager, + GET_ISSUE_TOOL, + CREATE_ISSUE_TOOL, + GITHUB_TOOLS, +) + + +@patch.dict('os.environ', {'GITHUB_TOKEN': 'test_token'}) +def test_github_issue_manager_get_api_headers(): + """Test getting API headers.""" + headers = GitHubIssueManager._get_api_headers() + assert headers["Authorization"] == "token test_token" + assert "application/vnd.github.v3+json" in headers["Accept"] + + +def test_github_issue_manager_no_token(): + """Test that missing token raises an error.""" + with patch.dict('os.environ', {}, clear=True): + with pytest.raises(RuntimeError, match="GitHub token not found"): + GitHubIssueManager._get_api_headers() + + +@patch('requests.get') +@patch.dict('os.environ', {'GITHUB_TOKEN': 'test_token'}) +def test_get_issue(mock_get): + """Test get_issue function.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "number": 123, + "title": "Test Issue", + "body": "Test body", + "state": "open" + } + mock_response.content = True + mock_get.return_value = mock_response + + result = get_issue("owner/repo", 123) + + assert result["title"] == "Test Issue" + assert result["state"] == "open" + mock_get.assert_called_once() + assert "https://api.github.com/repos/owner/repo/issues/123" in mock_get.call_args[0] + + +@patch('requests.post') +@patch.dict('os.environ', {'GITHUB_TOKEN': 'test_token'}) +def test_create_issue(mock_post): + """Test create_issue function.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "number": 123, + "html_url": "https://github.com/owner/repo/issues/123" + } + mock_response.content = True + mock_post.return_value = mock_response + + result = create_issue("owner/repo", "Test Issue", "Test body", ["bug", "enhancement"]) + + assert result["number"] == 123 + mock_post.assert_called_once() + call_kwargs = mock_post.call_args[1] + assert call_kwargs["json"]["title"] == "Test Issue" + assert call_kwargs["json"]["labels"] == ["bug", "enhancement"] + + +@patch('requests.patch') +@patch.dict('os.environ', {'GITHUB_TOKEN': 'test_token'}) +def test_update_issue(mock_patch): + """Test update_issue function.""" + mock_response = MagicMock() + mock_response.json.return_value = {"number": 123, "title": "New Title"} + mock_response.content = True + mock_patch.return_value = mock_response + + result = update_issue("owner/repo", 123, "New Title", "New body") + + assert result["title"] == "New Title" + mock_patch.assert_called_once() + call_kwargs = mock_patch.call_args[1] + assert call_kwargs["json"]["title"] == "New Title" + assert call_kwargs["json"]["body"] == "New body" + + +@patch('requests.post') +@patch.dict('os.environ', {'GITHUB_TOKEN': 'test_token'}) +def test_comment_on_issue(mock_post): + """Test comment_on_issue function.""" + mock_response = MagicMock() + mock_response.json.return_value = {"id": 456, "body": "Test comment"} + mock_response.content = True + mock_post.return_value = mock_response + + result = comment_on_issue("owner/repo", 123, "Test comment") + + assert result["body"] == "Test comment" + mock_post.assert_called_once() + assert "comments" in mock_post.call_args[0][0] + + +@patch('requests.get') +@patch.dict('os.environ', {'GITHUB_TOKEN': 'test_token'}) +def test_list_issues(mock_get): + """Test list_issues function.""" + mock_issues = [ + {"number": 1, "title": "Issue 1", "state": "open"}, + {"number": 2, "title": "Issue 2", "state": "closed"} + ] + mock_response = MagicMock() + mock_response.json.return_value = mock_issues + mock_response.content = True + mock_get.return_value = mock_response + + result = list_issues("owner/repo", "all", 20) + + assert len(result) == 2 + assert result[0]["title"] == "Issue 1" + mock_get.assert_called_once() + assert "state=all" in mock_get.call_args[0][0] + assert "per_page=20" in mock_get.call_args[0][0] + + +@patch('requests.patch') +@patch.dict('os.environ', {'GITHUB_TOKEN': 'test_token'}) +def test_close_issue(mock_patch): + """Test close_issue function.""" + mock_response = MagicMock() + mock_response.json.return_value = {"number": 123, "state": "closed"} + mock_response.content = True + mock_patch.return_value = mock_response + + result = close_issue("owner/repo", 123) + + assert result["state"] == "closed" + mock_patch.assert_called_once() + call_kwargs = mock_patch.call_args[1] + assert call_kwargs["json"]["state"] == "closed" + + +def test_tool_definitions(): + """Test that tool definitions are properly structured.""" + assert GET_ISSUE_TOOL.name == "get_github_issue" + assert "repo" in GET_ISSUE_TOOL.parameters + assert "issue_number" in GET_ISSUE_TOOL.parameters + + assert CREATE_ISSUE_TOOL.name == "create_github_issue" + assert "repo" in CREATE_ISSUE_TOOL.parameters + assert "title" in CREATE_ISSUE_TOOL.parameters + + assert len(GITHUB_TOOLS) == 6 # All 6 GitHub tools \ No newline at end of file