From c904e3ac7ba9b65d4a96ecf8e8c700363d9f1617 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Sep 2025 03:31:08 +0000 Subject: [PATCH 1/4] Initial plan From d67111e4ea6f6eca0318962ddecb57cc404b72d7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Sep 2025 03:38:09 +0000 Subject: [PATCH 2/4] Add GitHub Copilot accessor and GitHub tools integration Co-authored-by: JPrier <24302717+JPrier@users.noreply.github.com> --- pyproject.toml | 1 + src/cli/copilot_cli.py | 212 ++++++++++++++++++ src/dataModel/model.py | 1 + src/modelAccessors/github_copilot_accessor.py | 95 ++++++++ src/orchestrator/orchestrator.py | 3 + src/tools/__init__.py | 30 +++ src/tools/github_tools.py | 160 +++++++++++++ 7 files changed, 502 insertions(+) create mode 100644 src/cli/copilot_cli.py create mode 100644 src/modelAccessors/github_copilot_accessor.py create mode 100644 src/tools/github_tools.py diff --git a/pyproject.toml b/pyproject.toml index 3c7c460..b04d464 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dev = [ [project.scripts] treeagent = "src.cli:main" treeagent-swebench = "src.cli.bench.swebench_cli:main" +gh-copilot-agent-task = "src.cli.copilot_cli:main" [tool.hatch.build.targets.wheel] packages = ["src"] diff --git a/src/cli/copilot_cli.py b/src/cli/copilot_cli.py new file mode 100644 index 0000000..3d6f3a1 --- /dev/null +++ b/src/cli/copilot_cli.py @@ -0,0 +1,212 @@ +"""GitHub Copilot style CLI interface for TreeAgent.""" + +from __future__ import annotations + +import argparse +import sys +from typing import Optional + +from ..orchestrator import AgentOrchestrator +from ..dataModel.model import AccessorType +from ..tools.github_tools import create_issue, get_issue, comment_on_issue, list_issues + + +def parse_copilot_args() -> argparse.Namespace: + """Parse gh copilot agent-task style arguments.""" + parser = argparse.ArgumentParser( + prog="gh copilot agent-task", + description="GitHub Copilot style interface for TreeAgent tasks", + ) + + subparsers = parser.add_subparsers(dest="command", help="Available commands") + + # create subcommand + create_parser = subparsers.add_parser("create", help="Create a new agent task") + create_parser.add_argument( + "prompt", + help="Description of the task to create", + ) + create_parser.add_argument( + "--follow", + action="store_true", + help="Follow the task progress", + ) + create_parser.add_argument( + "--model-type", + choices=[t.value for t in AccessorType], + default="github_copilot", + help="Model accessor to use for the task", + ) + create_parser.add_argument( + "--repo", + help="GitHub repository to create issue in (format: owner/repo)", + ) + create_parser.add_argument( + "--checkpoint-dir", + default="checkpoints", + help="Directory to store project checkpoints", + ) + + # follow subcommand for existing tasks + follow_parser = subparsers.add_parser("follow", help="Follow an existing task") + follow_parser.add_argument( + "task_id", + help="Task/Issue ID to follow", + ) + follow_parser.add_argument( + "--repo", + help="GitHub repository (format: owner/repo)", + ) + + # list subcommand + list_parser = subparsers.add_parser("list", help="List agent tasks") + list_parser.add_argument( + "--repo", + help="GitHub repository to list issues from (format: owner/repo)", + ) + list_parser.add_argument( + "--state", + choices=["open", "closed", "all"], + default="open", + help="Filter by issue state", + ) + list_parser.add_argument( + "--limit", + type=int, + default=10, + help="Maximum number of tasks to list", + ) + + return parser.parse_args() + + +def create_agent_task(prompt: str, model_type: str, repo: Optional[str] = None, + checkpoint_dir: str = "checkpoints", follow: bool = False) -> None: + """Create a new agent task.""" + print(f"Creating agent task: {prompt}") + + # Create GitHub issue if repo is provided + issue_url = None + issue_number = None + if repo: + try: + result = create_issue(repo, f"Agent Task: {prompt[:50]}...", prompt) + issue_url = result.get("url") + # Extract issue number from URL + if issue_url: + issue_number = issue_url.split("/")[-1] + print(f"Created GitHub issue: {issue_url}") + except Exception as e: + print(f"Warning: Could not create GitHub issue: {e}") + + # Set up orchestrator + accessor_type = AccessorType(model_type) + orchestrator = AgentOrchestrator(default_accessor_type=accessor_type) + + # Run the task + try: + project = orchestrator.implement_project(prompt, checkpoint_dir=checkpoint_dir) + + # Update GitHub issue with results + if repo and issue_number: + try: + summary = f"""Task completed! + +**Project Summary:** +- Completed Tasks: {len(project.completedTasks)} +- In Progress Tasks: {len(project.inProgressTasks)} +- Failed Tasks: {len(project.failedTasks)} +- Queued Tasks: {len(project.queuedTasks)} + +Latest response type: {project.latestResponse.type if project.latestResponse else "None"} +""" + comment_on_issue(repo, int(issue_number), summary) + print(f"Updated GitHub issue with results: {issue_url}") + except Exception as e: + print(f"Warning: Could not update GitHub issue: {e}") + + # Print summary + print("\nProject Summary:") + print(f"Completed Tasks: {len(project.completedTasks)}") + print(f"In Progress Tasks: {len(project.inProgressTasks)}") + print(f"Failed Tasks: {len(project.failedTasks)}") + print(f"Queued Tasks: {len(project.queuedTasks)}") + + if follow and repo and issue_number: + follow_task(issue_number, repo) + + except Exception as e: + print(f"Error executing task: {e}") + if repo and issue_number: + try: + comment_on_issue(repo, int(issue_number), f"Task failed with error: {e}") + except Exception: + pass # Ignore secondary errors + sys.exit(1) + + +def follow_task(task_id: str, repo: Optional[str] = None) -> None: + """Follow an existing task.""" + if not repo: + print("Error: --repo is required for following tasks") + sys.exit(1) + + try: + issue = get_issue(repo, int(task_id)) + print(f"Task {task_id}: {issue['title']}") + print(f"State: {issue['state']}") + print(f"Author: {issue['author']['login']}") + if issue.get('labels'): + labels = [label['name'] for label in issue['labels']] + print(f"Labels: {', '.join(labels)}") + print(f"\nDescription:\n{issue['body']}") + except Exception as e: + print(f"Error following task {task_id}: {e}") + sys.exit(1) + + +def list_tasks(repo: Optional[str] = None, state: str = "open", limit: int = 10) -> None: + """List agent tasks.""" + if not repo: + print("Error: --repo is required for listing tasks") + sys.exit(1) + + try: + issues = list_issues(repo, state, limit) + print(f"Tasks in {repo} (state: {state}):") + for issue in issues: + labels = [label['name'] for label in issue.get('labels', [])] + label_str = f" [{', '.join(labels)}]" if labels else "" + print(f"#{issue['number']}: {issue['title']} ({issue['state']}){label_str}") + except Exception as e: + print(f"Error listing tasks: {e}") + sys.exit(1) + + +def main() -> None: + """Entry point for the GitHub Copilot style CLI.""" + args = parse_copilot_args() + + if not args.command: + print("Error: No command specified. Use --help for usage information.") + sys.exit(1) + + if args.command == "create": + create_agent_task( + args.prompt, + args.model_type, + args.repo, + args.checkpoint_dir, + args.follow + ) + elif args.command == "follow": + follow_task(args.task_id, args.repo) + elif args.command == "list": + list_tasks(args.repo, args.state, args.limit) + else: + print(f"Error: Unknown command '{args.command}'") + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file 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..6834194 --- /dev/null +++ b/src/modelAccessors/github_copilot_accessor.py @@ -0,0 +1,95 @@ +import json +import subprocess +from typing import Any, Optional + +from pydantic import TypeAdapter + +from .base_accessor import BaseModelAccessor, Tool +from src.dataModel.model_response import ModelResponse + + +class GitHubCopilotAccessor(BaseModelAccessor): + """GitHub Copilot accessor using gh copilot CLI.""" + + def __init__(self): + # Verify that gh CLI is available + try: + result = 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"] + + 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 using gh copilot CLI.""" + + 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 + full_prompt = f"{system_prompt}\n\n{prompt}".strip() + + if tools and self.supports_tools(model): + # Format tools into the prompt for now, as gh copilot may not support native tool calling + tools_description = self._format_tools_for_prompt(tools) + full_prompt += f"\n\nAvailable tools:\n{tools_description}" + full_prompt += "\n\nWhen using tools, format your response as JSON with the structure matching the expected schema." + + try: + # Use gh copilot to generate response + cmd = ['gh', 'copilot', 'suggest', '--type', 'shell'] + + # For now, we'll use the suggest command and parse the output + # In a real implementation, you might want to use the API directly + result = subprocess.run( + cmd, + input=full_prompt, + capture_output=True, + text=True, + check=True + ) + + content = result.stdout.strip() + + # Try to parse as JSON first, fallback to text response + try: + json_content = json.loads(content) + except json.JSONDecodeError: + # If it's not JSON, wrap it in a basic response structure + json_content = { + "type": "implemented", + "content": content, + "artifacts": [] + } + + return adapter.validate_python(json_content) + + except subprocess.CalledProcessError as e: + raise RuntimeError(f"GitHub Copilot CLI call failed: {e.stderr}") from e + + def supports_tools(self, model: str) -> bool: + """Check if model supports tools (currently limited support).""" + return model in self.tool_supported_models + + def _format_tools_for_prompt(self, tools: list[Tool]) -> str: + """Format tools into a readable description for the prompt.""" + tool_descriptions = [] + for tool in tools: + params_desc = ", ".join(f"{k}" for k in tool.parameters.keys()) + tool_descriptions.append(f"- {tool.name}: {tool.description} [Parameters: {params_desc}]") + + return "\n".join(tool_descriptions) \ 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..74f3122 --- /dev/null +++ b/src/tools/github_tools.py @@ -0,0 +1,160 @@ +import subprocess +import json +from typing import Any, Dict, Optional + +from pydantic import BaseModel + +from src.modelAccessors.data.tool import Tool + + +class GitHubIssueManager: + """Manager for GitHub issue operations using gh CLI.""" + + @staticmethod + def _run_gh_command(cmd: list[str]) -> str: + """Run a gh CLI command and return the output.""" + try: + result = subprocess.run( + ['gh'] + cmd, + capture_output=True, + text=True, + check=True + ) + return result.stdout.strip() + except subprocess.CalledProcessError as e: + raise RuntimeError(f"GitHub CLI command failed: {e.stderr}") from e + except FileNotFoundError: + 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." + ) from None + + +def get_issue(repo: str, issue_number: int) -> Dict[str, Any]: + """Get details of a specific GitHub issue.""" + cmd = ['issue', 'view', str(issue_number), '--repo', repo, '--json', 'title,body,state,author,labels,assignees'] + output = GitHubIssueManager._run_gh_command(cmd) + return json.loads(output) + + +def create_issue(repo: str, title: str, body: str = "", labels: Optional[list[str]] = None) -> Dict[str, Any]: + """Create a new GitHub issue.""" + cmd = ['issue', 'create', '--repo', repo, '--title', title, '--body', body] + + if labels: + cmd.extend(['--label', ','.join(labels)]) + + output = GitHubIssueManager._run_gh_command(cmd) + # Extract issue URL/number from output + issue_url = output.strip() + return {"url": issue_url, "created": True} + + +def update_issue(repo: str, issue_number: int, title: Optional[str] = None, body: Optional[str] = None) -> Dict[str, Any]: + """Update a GitHub issue.""" + cmd = ['issue', 'edit', str(issue_number), '--repo', repo] + + if title: + cmd.extend(['--title', title]) + if body: + cmd.extend(['--body', body]) + + GitHubIssueManager._run_gh_command(cmd) + return {"updated": True, "issue_number": issue_number} + + +def comment_on_issue(repo: str, issue_number: int, comment: str) -> Dict[str, Any]: + """Add a comment to a GitHub issue.""" + cmd = ['issue', 'comment', str(issue_number), '--repo', repo, '--body', comment] + GitHubIssueManager._run_gh_command(cmd) + return {"commented": True, "issue_number": issue_number} + + +def list_issues(repo: str, state: str = "open", limit: int = 10) -> list[Dict[str, Any]]: + """List GitHub issues.""" + cmd = ['issue', 'list', '--repo', repo, '--state', state, '--limit', str(limit), '--json', 'number,title,state,author,labels'] + output = GitHubIssueManager._run_gh_command(cmd) + return json.loads(output) + + +def close_issue(repo: str, issue_number: int, reason: Optional[str] = None) -> Dict[str, Any]: + """Close a GitHub issue.""" + cmd = ['issue', 'close', str(issue_number), '--repo', repo] + if reason: + cmd.extend(['--reason', reason]) + + GitHubIssueManager._run_gh_command(cmd) + return {"closed": True, "issue_number": issue_number} + + +# 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)"} + } +) + +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"}, + "reason": {"type": "string", "enum": ["completed", "not_planned"], "description": "Reason for closing"} + } +) + +# 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 From 474a216faf97ff461cc436997a2ffd5698359ad7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Sep 2025 03:43:17 +0000 Subject: [PATCH 3/4] Complete GitHub Copilot integration with tests and documentation Co-authored-by: JPrier <24302717+JPrier@users.noreply.github.com> --- GITHUB_COPILOT_INTEGRATION.md | 134 +++++++++++++++++ README.md | 20 +++ src/modelAccessors/github_copilot_accessor.py | 9 ++ tests/cli/test_copilot_cli.py | 139 +++++++++++++++++ .../test_github_copilot_accessor.py | 77 ++++++++++ tests/tools/test_github_tools.py | 142 ++++++++++++++++++ 6 files changed, 521 insertions(+) create mode 100644 GITHUB_COPILOT_INTEGRATION.md create mode 100644 tests/cli/test_copilot_cli.py create mode 100644 tests/modelAccessors/test_github_copilot_accessor.py create mode 100644 tests/tools/test_github_tools.py diff --git a/GITHUB_COPILOT_INTEGRATION.md b/GITHUB_COPILOT_INTEGRATION.md new file mode 100644 index 0000000..8191952 --- /dev/null +++ b/GITHUB_COPILOT_INTEGRATION.md @@ -0,0 +1,134 @@ +# GitHub Copilot Integration + +This document describes the GitHub Copilot integration added to TreeAgent. + +## Features + +### GitHub Copilot Model Accessor + +A new model accessor `github_copilot` has been added that interfaces with GitHub Copilot via the `gh copilot` CLI command. This accessor: + +- Uses the GitHub CLI (`gh`) to interact with GitHub Copilot +- Automatically includes GitHub issue tracking tools +- Supports tool usage for enhanced functionality + +### GitHub Tools + +New tools have been added for GitHub issue tracking: + +- `get_github_issue` - Get details of a specific issue +- `create_github_issue` - Create a new issue +- `update_github_issue` - Update an existing issue +- `comment_github_issue` - Add comments to issues +- `list_github_issues` - List issues in a repository +- `close_github_issue` - Close an issue + +### Command Line Interface + +A new CLI command `gh-copilot-agent-task` provides GitHub Copilot style interface: + +#### Create a new task +```bash +gh-copilot-agent-task create "Create a basic Flask app with one GET /hello endpoint returning JSON {'message': 'Hello'}" --repo owner/repo --follow +``` + +#### Follow an existing task +```bash +gh-copilot-agent-task follow 123 --repo owner/repo +``` + +#### List tasks +```bash +gh-copilot-agent-task list --repo owner/repo --state open --limit 10 +``` + +## Prerequisites + +1. **GitHub CLI**: Install and authenticate with GitHub CLI + ```bash + # Install gh CLI (see https://github.com/cli/cli#installation) + gh auth login + ``` + +2. **GitHub Copilot**: Ensure you have GitHub Copilot access and the CLI extension installed + ```bash + gh extension install github/gh-copilot + ``` + +## Usage Examples + +### Using GitHub Copilot accessor programmatically + +```python +from src.orchestrator import AgentOrchestrator +from src.dataModel.model import AccessorType + +orchestrator = AgentOrchestrator(default_accessor_type=AccessorType.GITHUB_COPILOT) +project = orchestrator.implement_project("Create a REST API server") +``` + +### Using the CLI with GitHub integration + +```bash +# Create a task and track it in a GitHub issue +gh-copilot-agent-task create "Build a React todo app" --repo myorg/myrepo --follow + +# Follow up on an existing task +gh-copilot-agent-task follow 42 --repo myorg/myrepo + +# List all open tasks +gh-copilot-agent-task list --repo myorg/myrepo --state open +``` + +### Using GitHub tools directly + +```python +from src.tools.github_tools import create_issue, comment_on_issue + +# Create an issue +result = create_issue("owner/repo", "New Feature Request", "Description of the feature") + +# Add a comment +comment_on_issue("owner/repo", 123, "Implementation completed!") +``` + +## Architecture + +The GitHub Copilot integration consists of: + +1. **GitHubCopilotAccessor** (`src/modelAccessors/github_copilot_accessor.py`) + - Interfaces with `gh copilot` CLI + - Automatically includes GitHub tools + - Handles JSON response parsing + +2. **GitHub Tools** (`src/tools/github_tools.py`) + - Complete issue management functionality + - Uses `gh` CLI for all operations + - Provides both functions and tool definitions + +3. **Copilot CLI** (`src/cli/copilot_cli.py`) + - GitHub Copilot style command interface + - Integrates with GitHub issues for task tracking + - Supports follow-up workflows + +## Error Handling + +The integration includes robust error handling: + +- Checks for `gh` CLI availability +- Validates GitHub authentication +- Provides clear error messages +- Graceful fallbacks for optional GitHub features + +## Testing + +Comprehensive tests are included: + +- `tests/modelAccessors/test_github_copilot_accessor.py` - Accessor tests +- `tests/tools/test_github_tools.py` - GitHub tools tests +- `tests/cli/test_copilot_cli.py` - CLI tests + +Run tests with: +```bash +pytest tests/ -v +``` \ No newline at end of file diff --git a/README.md b/README.md index 961587e..f24b5e8 100644 --- a/README.md +++ b/README.md @@ -165,3 +165,23 @@ processing the remaining tasks. 5. Metrics & logging – capture token counts, wall-clock time, and per-node error traces. 6. Extensible UI – optional graph-viz or web dashboard to visualise task trees. + +--- + +## GitHub Copilot Integration + +TreeAgent now includes GitHub Copilot integration for enhanced AI-assisted development: + +- **GitHub Copilot Accessor**: Use GitHub Copilot as a model accessor via `gh copilot` CLI +- **GitHub Issue Tools**: Complete issue management (create, update, comment, track) +- **Copilot-style CLI**: GitHub Copilot inspired command interface + +```bash +# Create and track a task in GitHub +gh-copilot-agent-task create "Build a REST API" --repo owner/repo --follow + +# Follow existing tasks +gh-copilot-agent-task follow 123 --repo owner/repo +``` + +See [GITHUB_COPILOT_INTEGRATION.md](GITHUB_COPILOT_INTEGRATION.md) for detailed usage instructions. diff --git a/src/modelAccessors/github_copilot_accessor.py b/src/modelAccessors/github_copilot_accessor.py index 6834194..55e9417 100644 --- a/src/modelAccessors/github_copilot_accessor.py +++ b/src/modelAccessors/github_copilot_accessor.py @@ -40,6 +40,15 @@ def call_model( "See https://github.com/cli/cli#installation for installation instructions." ) + # Auto-include GitHub tools if not already provided + if tools is None: + from ..tools.github_tools import GITHUB_TOOLS + tools = GITHUB_TOOLS + elif tools and not any(tool.name.startswith('github') for tool in tools): + # Add GitHub tools if they're not already included + from ..tools.github_tools import GITHUB_TOOLS + tools = tools + GITHUB_TOOLS + # Combine system prompt and user prompt full_prompt = f"{system_prompt}\n\n{prompt}".strip() diff --git a/tests/cli/test_copilot_cli.py b/tests/cli/test_copilot_cli.py new file mode 100644 index 0000000..605fd6a --- /dev/null +++ b/tests/cli/test_copilot_cli.py @@ -0,0 +1,139 @@ +"""Tests for GitHub Copilot CLI.""" + +import pytest +from unittest.mock import patch, MagicMock +import sys +from io import StringIO + +from src.cli.copilot_cli import parse_copilot_args, create_agent_task, follow_task, list_tasks + + +def test_parse_copilot_args_create(): + """Test parsing create command arguments.""" + sys.argv = ['gh-copilot-agent-task', 'create', 'Test task', '--follow', '--repo', 'owner/repo'] + args = parse_copilot_args() + + assert args.command == 'create' + assert args.prompt == 'Test task' + assert args.follow is True + assert args.repo == 'owner/repo' + assert args.model_type == 'github_copilot' + + +def test_parse_copilot_args_follow(): + """Test parsing follow command arguments.""" + sys.argv = ['gh-copilot-agent-task', 'follow', '123', '--repo', 'owner/repo'] + args = parse_copilot_args() + + assert args.command == 'follow' + assert args.task_id == '123' + assert args.repo == 'owner/repo' + + +def test_parse_copilot_args_list(): + """Test parsing list command arguments.""" + sys.argv = ['gh-copilot-agent-task', 'list', '--repo', 'owner/repo', '--state', 'closed', '--limit', '5'] + args = parse_copilot_args() + + assert args.command == 'list' + assert args.repo == 'owner/repo' + assert args.state == 'closed' + assert args.limit == 5 + + +@patch('src.cli.copilot_cli.create_issue') +@patch('src.cli.copilot_cli.AgentOrchestrator') +def test_create_agent_task_with_repo(mock_orchestrator, mock_create_issue): + """Test create_agent_task with repository.""" + # Mock GitHub issue creation + mock_create_issue.return_value = {"url": "https://github.com/owner/repo/issues/123"} + + # Mock orchestrator + mock_project = MagicMock() + mock_project.completedTasks = [] + mock_project.inProgressTasks = [] + mock_project.failedTasks = [] + mock_project.queuedTasks = [] + mock_project.latestResponse = None + + mock_orchestrator_instance = MagicMock() + mock_orchestrator_instance.implement_project.return_value = mock_project + mock_orchestrator.return_value = mock_orchestrator_instance + + # Capture stdout + captured_output = StringIO() + with patch('sys.stdout', captured_output): + create_agent_task("Test task", "github_copilot", "owner/repo") + + # Verify calls + mock_create_issue.assert_called_once() + mock_orchestrator_instance.implement_project.assert_called_once() + + output = captured_output.getvalue() + assert "Creating agent task: Test task" in output + assert "Created GitHub issue:" in output + + +@patch('src.cli.copilot_cli.get_issue') +def test_follow_task(mock_get_issue): + """Test follow_task function.""" + mock_get_issue.return_value = { + "title": "Test Issue", + "state": "open", + "author": {"login": "testuser"}, + "body": "Test description", + "labels": [{"name": "enhancement"}] + } + + captured_output = StringIO() + with patch('sys.stdout', captured_output): + follow_task("123", "owner/repo") + + mock_get_issue.assert_called_once_with("owner/repo", 123) + + output = captured_output.getvalue() + assert "Task 123: Test Issue" in output + assert "State: open" in output + assert "Author: testuser" in output + + +@patch('src.cli.copilot_cli.list_issues') +def test_list_tasks(mock_list_issues): + """Test list_tasks function.""" + mock_list_issues.return_value = [ + {"number": 1, "title": "Issue 1", "state": "open", "labels": []}, + {"number": 2, "title": "Issue 2", "state": "closed", "labels": [{"name": "bug"}]} + ] + + captured_output = StringIO() + with patch('sys.stdout', captured_output): + list_tasks("owner/repo", "all", 10) + + mock_list_issues.assert_called_once_with("owner/repo", "all", 10) + + output = captured_output.getvalue() + assert "Tasks in owner/repo" in output + assert "#1: Issue 1 (open)" in output + assert "#2: Issue 2 (closed) [bug]" in output + + +def test_follow_task_no_repo(): + """Test follow_task without repo raises error.""" + captured_output = StringIO() + with patch('sys.stdout', captured_output), patch('sys.stderr', captured_output): + with pytest.raises(SystemExit): + follow_task("123") + + output = captured_output.getvalue() + assert "Error: --repo is required for following tasks" in output + + +def test_list_tasks_no_repo(): + """Test list_tasks without repo raises error.""" + captured_output = StringIO() + with patch('sys.stdout', captured_output), patch('sys.stderr', captured_output): + with pytest.raises(SystemExit): + list_tasks() + + output = captured_output.getvalue() + assert "Error: --repo is required for listing tasks" in output \ 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..ab785ca --- /dev/null +++ b/tests/modelAccessors/test_github_copilot_accessor.py @@ -0,0 +1,77 @@ +"""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_format_tools_for_prompt(): + """Test _format_tools_for_prompt method.""" + with patch('subprocess.run') as mock_run: + mock_run.return_value.returncode = 0 + accessor = GitHubCopilotAccessor() + + from src.modelAccessors.data.tool import Tool + tools = [ + Tool(name="test_tool", description="Test tool", parameters={"param1": {"type": "string"}}), + Tool(name="another_tool", description="Another tool", parameters={"param2": {"type": "int"}}) + ] + + result = accessor._format_tools_for_prompt(tools) + assert "test_tool: Test tool [Parameters: param1]" in result + assert "another_tool: Another tool [Parameters: param2]" in result + + +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 + mock_run.side_effect = [ + MagicMock(returncode=0), # gh version check + MagicMock(stdout='{"type": "implemented", "content": "test response", "artifacts": []}') # gh copilot call + ] + + accessor = GitHubCopilotAccessor() + + # Mock TypeAdapter + mock_adapter = MagicMock() + mock_adapter.validate_python.return_value = {"type": "implemented", "content": "test response"} + + result = accessor.call_model("test prompt", adapter=mock_adapter, schema={}) + + mock_adapter.validate_python.assert_called_once() + assert mock_run.call_count == 2 # version check + actual call \ 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..c09c3c3 --- /dev/null +++ b/tests/tools/test_github_tools.py @@ -0,0 +1,142 @@ +"""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, +) + + +def test_github_issue_manager_run_gh_command_success(): + """Test successful gh command execution.""" + with patch('subprocess.run') as mock_run: + mock_run.return_value.stdout = "test output" + result = GitHubIssueManager._run_gh_command(['issue', 'list']) + assert result == "test output" + mock_run.assert_called_once_with( + ['gh', 'issue', 'list'], + capture_output=True, + text=True, + check=True + ) + + +def test_github_issue_manager_run_gh_command_no_gh(): + """Test gh command execution when gh CLI is not available.""" + with patch('subprocess.run', side_effect=FileNotFoundError): + with pytest.raises(RuntimeError, match="GitHub CLI \\(gh\\) is not available"): + GitHubIssueManager._run_gh_command(['issue', 'list']) + + +@patch('src.tools.github_tools.GitHubIssueManager._run_gh_command') +def test_get_issue(mock_run_command): + """Test get_issue function.""" + mock_run_command.return_value = '{"title": "Test Issue", "body": "Test body", "state": "open"}' + + result = get_issue("owner/repo", 123) + + assert result["title"] == "Test Issue" + assert result["state"] == "open" + mock_run_command.assert_called_once_with([ + 'issue', 'view', '123', '--repo', 'owner/repo', '--json', 'title,body,state,author,labels,assignees' + ]) + + +@patch('src.tools.github_tools.GitHubIssueManager._run_gh_command') +def test_create_issue(mock_run_command): + """Test create_issue function.""" + mock_run_command.return_value = "https://github.com/owner/repo/issues/123" + + result = create_issue("owner/repo", "Test Issue", "Test body", ["bug", "enhancement"]) + + assert result["url"] == "https://github.com/owner/repo/issues/123" + assert result["created"] is True + mock_run_command.assert_called_once_with([ + 'issue', 'create', '--repo', 'owner/repo', '--title', 'Test Issue', + '--body', 'Test body', '--label', 'bug,enhancement' + ]) + + +@patch('src.tools.github_tools.GitHubIssueManager._run_gh_command') +def test_update_issue(mock_run_command): + """Test update_issue function.""" + mock_run_command.return_value = "" + + result = update_issue("owner/repo", 123, "New Title", "New body") + + assert result["updated"] is True + assert result["issue_number"] == 123 + mock_run_command.assert_called_once_with([ + 'issue', 'edit', '123', '--repo', 'owner/repo', '--title', 'New Title', '--body', 'New body' + ]) + + +@patch('src.tools.github_tools.GitHubIssueManager._run_gh_command') +def test_comment_on_issue(mock_run_command): + """Test comment_on_issue function.""" + mock_run_command.return_value = "" + + result = comment_on_issue("owner/repo", 123, "Test comment") + + assert result["commented"] is True + assert result["issue_number"] == 123 + mock_run_command.assert_called_once_with([ + 'issue', 'comment', '123', '--repo', 'owner/repo', '--body', 'Test comment' + ]) + + +@patch('src.tools.github_tools.GitHubIssueManager._run_gh_command') +def test_list_issues(mock_run_command): + """Test list_issues function.""" + mock_issues = [ + {"number": 1, "title": "Issue 1", "state": "open"}, + {"number": 2, "title": "Issue 2", "state": "closed"} + ] + mock_run_command.return_value = json.dumps(mock_issues) + + result = list_issues("owner/repo", "all", 20) + + assert len(result) == 2 + assert result[0]["title"] == "Issue 1" + mock_run_command.assert_called_once_with([ + 'issue', 'list', '--repo', 'owner/repo', '--state', 'all', '--limit', '20', + '--json', 'number,title,state,author,labels' + ]) + + +@patch('src.tools.github_tools.GitHubIssueManager._run_gh_command') +def test_close_issue(mock_run_command): + """Test close_issue function.""" + mock_run_command.return_value = "" + + result = close_issue("owner/repo", 123, "completed") + + assert result["closed"] is True + assert result["issue_number"] == 123 + mock_run_command.assert_called_once_with([ + 'issue', 'close', '123', '--repo', 'owner/repo', '--reason', 'completed' + ]) + + +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 From 6bac6aff0e83a32fdb135d0705213515caf37e8c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Sep 2025 03:17:07 +0000 Subject: [PATCH 4/4] Refactor GitHub Copilot integration per feedback - Remove custom CLI tool (gh-copilot-agent-task) - Update accessor to use gh copilot agent-task create for agentic tasks - Accessor now returns issue ID as response - Rewrite GitHub tools to use GitHub API instead of CLI - Update tests to match new implementation - Remove GITHUB_COPILOT_INTEGRATION.md documentation file Co-authored-by: JPrier <24302717+JPrier@users.noreply.github.com> --- GITHUB_COPILOT_INTEGRATION.md | 134 ----------- README.md | 20 -- pyproject.toml | 1 - src/cli/copilot_cli.py | 212 ------------------ src/modelAccessors/github_copilot_accessor.py | 112 ++++----- src/tools/github_tools.py | 116 +++++----- tests/cli/test_copilot_cli.py | 139 ------------ .../test_github_copilot_accessor.py | 57 +++-- tests/tools/test_github_tools.py | 156 +++++++------ 9 files changed, 257 insertions(+), 690 deletions(-) delete mode 100644 GITHUB_COPILOT_INTEGRATION.md delete mode 100644 src/cli/copilot_cli.py delete mode 100644 tests/cli/test_copilot_cli.py diff --git a/GITHUB_COPILOT_INTEGRATION.md b/GITHUB_COPILOT_INTEGRATION.md deleted file mode 100644 index 8191952..0000000 --- a/GITHUB_COPILOT_INTEGRATION.md +++ /dev/null @@ -1,134 +0,0 @@ -# GitHub Copilot Integration - -This document describes the GitHub Copilot integration added to TreeAgent. - -## Features - -### GitHub Copilot Model Accessor - -A new model accessor `github_copilot` has been added that interfaces with GitHub Copilot via the `gh copilot` CLI command. This accessor: - -- Uses the GitHub CLI (`gh`) to interact with GitHub Copilot -- Automatically includes GitHub issue tracking tools -- Supports tool usage for enhanced functionality - -### GitHub Tools - -New tools have been added for GitHub issue tracking: - -- `get_github_issue` - Get details of a specific issue -- `create_github_issue` - Create a new issue -- `update_github_issue` - Update an existing issue -- `comment_github_issue` - Add comments to issues -- `list_github_issues` - List issues in a repository -- `close_github_issue` - Close an issue - -### Command Line Interface - -A new CLI command `gh-copilot-agent-task` provides GitHub Copilot style interface: - -#### Create a new task -```bash -gh-copilot-agent-task create "Create a basic Flask app with one GET /hello endpoint returning JSON {'message': 'Hello'}" --repo owner/repo --follow -``` - -#### Follow an existing task -```bash -gh-copilot-agent-task follow 123 --repo owner/repo -``` - -#### List tasks -```bash -gh-copilot-agent-task list --repo owner/repo --state open --limit 10 -``` - -## Prerequisites - -1. **GitHub CLI**: Install and authenticate with GitHub CLI - ```bash - # Install gh CLI (see https://github.com/cli/cli#installation) - gh auth login - ``` - -2. **GitHub Copilot**: Ensure you have GitHub Copilot access and the CLI extension installed - ```bash - gh extension install github/gh-copilot - ``` - -## Usage Examples - -### Using GitHub Copilot accessor programmatically - -```python -from src.orchestrator import AgentOrchestrator -from src.dataModel.model import AccessorType - -orchestrator = AgentOrchestrator(default_accessor_type=AccessorType.GITHUB_COPILOT) -project = orchestrator.implement_project("Create a REST API server") -``` - -### Using the CLI with GitHub integration - -```bash -# Create a task and track it in a GitHub issue -gh-copilot-agent-task create "Build a React todo app" --repo myorg/myrepo --follow - -# Follow up on an existing task -gh-copilot-agent-task follow 42 --repo myorg/myrepo - -# List all open tasks -gh-copilot-agent-task list --repo myorg/myrepo --state open -``` - -### Using GitHub tools directly - -```python -from src.tools.github_tools import create_issue, comment_on_issue - -# Create an issue -result = create_issue("owner/repo", "New Feature Request", "Description of the feature") - -# Add a comment -comment_on_issue("owner/repo", 123, "Implementation completed!") -``` - -## Architecture - -The GitHub Copilot integration consists of: - -1. **GitHubCopilotAccessor** (`src/modelAccessors/github_copilot_accessor.py`) - - Interfaces with `gh copilot` CLI - - Automatically includes GitHub tools - - Handles JSON response parsing - -2. **GitHub Tools** (`src/tools/github_tools.py`) - - Complete issue management functionality - - Uses `gh` CLI for all operations - - Provides both functions and tool definitions - -3. **Copilot CLI** (`src/cli/copilot_cli.py`) - - GitHub Copilot style command interface - - Integrates with GitHub issues for task tracking - - Supports follow-up workflows - -## Error Handling - -The integration includes robust error handling: - -- Checks for `gh` CLI availability -- Validates GitHub authentication -- Provides clear error messages -- Graceful fallbacks for optional GitHub features - -## Testing - -Comprehensive tests are included: - -- `tests/modelAccessors/test_github_copilot_accessor.py` - Accessor tests -- `tests/tools/test_github_tools.py` - GitHub tools tests -- `tests/cli/test_copilot_cli.py` - CLI tests - -Run tests with: -```bash -pytest tests/ -v -``` \ No newline at end of file diff --git a/README.md b/README.md index f24b5e8..961587e 100644 --- a/README.md +++ b/README.md @@ -165,23 +165,3 @@ processing the remaining tasks. 5. Metrics & logging – capture token counts, wall-clock time, and per-node error traces. 6. Extensible UI – optional graph-viz or web dashboard to visualise task trees. - ---- - -## GitHub Copilot Integration - -TreeAgent now includes GitHub Copilot integration for enhanced AI-assisted development: - -- **GitHub Copilot Accessor**: Use GitHub Copilot as a model accessor via `gh copilot` CLI -- **GitHub Issue Tools**: Complete issue management (create, update, comment, track) -- **Copilot-style CLI**: GitHub Copilot inspired command interface - -```bash -# Create and track a task in GitHub -gh-copilot-agent-task create "Build a REST API" --repo owner/repo --follow - -# Follow existing tasks -gh-copilot-agent-task follow 123 --repo owner/repo -``` - -See [GITHUB_COPILOT_INTEGRATION.md](GITHUB_COPILOT_INTEGRATION.md) for detailed usage instructions. diff --git a/pyproject.toml b/pyproject.toml index b04d464..3c7c460 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,6 @@ dev = [ [project.scripts] treeagent = "src.cli:main" treeagent-swebench = "src.cli.bench.swebench_cli:main" -gh-copilot-agent-task = "src.cli.copilot_cli:main" [tool.hatch.build.targets.wheel] packages = ["src"] diff --git a/src/cli/copilot_cli.py b/src/cli/copilot_cli.py deleted file mode 100644 index 3d6f3a1..0000000 --- a/src/cli/copilot_cli.py +++ /dev/null @@ -1,212 +0,0 @@ -"""GitHub Copilot style CLI interface for TreeAgent.""" - -from __future__ import annotations - -import argparse -import sys -from typing import Optional - -from ..orchestrator import AgentOrchestrator -from ..dataModel.model import AccessorType -from ..tools.github_tools import create_issue, get_issue, comment_on_issue, list_issues - - -def parse_copilot_args() -> argparse.Namespace: - """Parse gh copilot agent-task style arguments.""" - parser = argparse.ArgumentParser( - prog="gh copilot agent-task", - description="GitHub Copilot style interface for TreeAgent tasks", - ) - - subparsers = parser.add_subparsers(dest="command", help="Available commands") - - # create subcommand - create_parser = subparsers.add_parser("create", help="Create a new agent task") - create_parser.add_argument( - "prompt", - help="Description of the task to create", - ) - create_parser.add_argument( - "--follow", - action="store_true", - help="Follow the task progress", - ) - create_parser.add_argument( - "--model-type", - choices=[t.value for t in AccessorType], - default="github_copilot", - help="Model accessor to use for the task", - ) - create_parser.add_argument( - "--repo", - help="GitHub repository to create issue in (format: owner/repo)", - ) - create_parser.add_argument( - "--checkpoint-dir", - default="checkpoints", - help="Directory to store project checkpoints", - ) - - # follow subcommand for existing tasks - follow_parser = subparsers.add_parser("follow", help="Follow an existing task") - follow_parser.add_argument( - "task_id", - help="Task/Issue ID to follow", - ) - follow_parser.add_argument( - "--repo", - help="GitHub repository (format: owner/repo)", - ) - - # list subcommand - list_parser = subparsers.add_parser("list", help="List agent tasks") - list_parser.add_argument( - "--repo", - help="GitHub repository to list issues from (format: owner/repo)", - ) - list_parser.add_argument( - "--state", - choices=["open", "closed", "all"], - default="open", - help="Filter by issue state", - ) - list_parser.add_argument( - "--limit", - type=int, - default=10, - help="Maximum number of tasks to list", - ) - - return parser.parse_args() - - -def create_agent_task(prompt: str, model_type: str, repo: Optional[str] = None, - checkpoint_dir: str = "checkpoints", follow: bool = False) -> None: - """Create a new agent task.""" - print(f"Creating agent task: {prompt}") - - # Create GitHub issue if repo is provided - issue_url = None - issue_number = None - if repo: - try: - result = create_issue(repo, f"Agent Task: {prompt[:50]}...", prompt) - issue_url = result.get("url") - # Extract issue number from URL - if issue_url: - issue_number = issue_url.split("/")[-1] - print(f"Created GitHub issue: {issue_url}") - except Exception as e: - print(f"Warning: Could not create GitHub issue: {e}") - - # Set up orchestrator - accessor_type = AccessorType(model_type) - orchestrator = AgentOrchestrator(default_accessor_type=accessor_type) - - # Run the task - try: - project = orchestrator.implement_project(prompt, checkpoint_dir=checkpoint_dir) - - # Update GitHub issue with results - if repo and issue_number: - try: - summary = f"""Task completed! - -**Project Summary:** -- Completed Tasks: {len(project.completedTasks)} -- In Progress Tasks: {len(project.inProgressTasks)} -- Failed Tasks: {len(project.failedTasks)} -- Queued Tasks: {len(project.queuedTasks)} - -Latest response type: {project.latestResponse.type if project.latestResponse else "None"} -""" - comment_on_issue(repo, int(issue_number), summary) - print(f"Updated GitHub issue with results: {issue_url}") - except Exception as e: - print(f"Warning: Could not update GitHub issue: {e}") - - # Print summary - print("\nProject Summary:") - print(f"Completed Tasks: {len(project.completedTasks)}") - print(f"In Progress Tasks: {len(project.inProgressTasks)}") - print(f"Failed Tasks: {len(project.failedTasks)}") - print(f"Queued Tasks: {len(project.queuedTasks)}") - - if follow and repo and issue_number: - follow_task(issue_number, repo) - - except Exception as e: - print(f"Error executing task: {e}") - if repo and issue_number: - try: - comment_on_issue(repo, int(issue_number), f"Task failed with error: {e}") - except Exception: - pass # Ignore secondary errors - sys.exit(1) - - -def follow_task(task_id: str, repo: Optional[str] = None) -> None: - """Follow an existing task.""" - if not repo: - print("Error: --repo is required for following tasks") - sys.exit(1) - - try: - issue = get_issue(repo, int(task_id)) - print(f"Task {task_id}: {issue['title']}") - print(f"State: {issue['state']}") - print(f"Author: {issue['author']['login']}") - if issue.get('labels'): - labels = [label['name'] for label in issue['labels']] - print(f"Labels: {', '.join(labels)}") - print(f"\nDescription:\n{issue['body']}") - except Exception as e: - print(f"Error following task {task_id}: {e}") - sys.exit(1) - - -def list_tasks(repo: Optional[str] = None, state: str = "open", limit: int = 10) -> None: - """List agent tasks.""" - if not repo: - print("Error: --repo is required for listing tasks") - sys.exit(1) - - try: - issues = list_issues(repo, state, limit) - print(f"Tasks in {repo} (state: {state}):") - for issue in issues: - labels = [label['name'] for label in issue.get('labels', [])] - label_str = f" [{', '.join(labels)}]" if labels else "" - print(f"#{issue['number']}: {issue['title']} ({issue['state']}){label_str}") - except Exception as e: - print(f"Error listing tasks: {e}") - sys.exit(1) - - -def main() -> None: - """Entry point for the GitHub Copilot style CLI.""" - args = parse_copilot_args() - - if not args.command: - print("Error: No command specified. Use --help for usage information.") - sys.exit(1) - - if args.command == "create": - create_agent_task( - args.prompt, - args.model_type, - args.repo, - args.checkpoint_dir, - args.follow - ) - elif args.command == "follow": - follow_task(args.task_id, args.repo) - elif args.command == "list": - list_tasks(args.repo, args.state, args.limit) - else: - print(f"Error: Unknown command '{args.command}'") - sys.exit(1) - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/src/modelAccessors/github_copilot_accessor.py b/src/modelAccessors/github_copilot_accessor.py index 55e9417..f55c65b 100644 --- a/src/modelAccessors/github_copilot_accessor.py +++ b/src/modelAccessors/github_copilot_accessor.py @@ -1,6 +1,7 @@ import json import subprocess from typing import Any, Optional +import os from pydantic import TypeAdapter @@ -9,18 +10,18 @@ class GitHubCopilotAccessor(BaseModelAccessor): - """GitHub Copilot accessor using gh copilot CLI.""" + """GitHub Copilot accessor that creates agentic tasks and returns issue IDs.""" def __init__(self): # Verify that gh CLI is available try: - result = subprocess.run(['gh', '--version'], capture_output=True, text=True, check=True) + 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"] + self.tool_supported_models = ["gpt-4", "gpt-3.5-turbo", "gpt-4o"] def call_model( self, @@ -32,7 +33,13 @@ def call_model( system_prompt: str = "", tools: Optional[list[Tool]] = None, ) -> ModelResponse: - """Call GitHub Copilot using gh copilot CLI.""" + """ + 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( @@ -40,65 +47,70 @@ def call_model( "See https://github.com/cli/cli#installation for installation instructions." ) - # Auto-include GitHub tools if not already provided - if tools is None: - from ..tools.github_tools import GITHUB_TOOLS - tools = GITHUB_TOOLS - elif tools and not any(tool.name.startswith('github') for tool in tools): - # Add GitHub tools if they're not already included - from ..tools.github_tools import GITHUB_TOOLS - tools = tools + GITHUB_TOOLS - - # Combine system prompt and user prompt + # Combine system prompt and user prompt for the task description full_prompt = f"{system_prompt}\n\n{prompt}".strip() - if tools and self.supports_tools(model): - # Format tools into the prompt for now, as gh copilot may not support native tool calling - tools_description = self._format_tools_for_prompt(tools) - full_prompt += f"\n\nAvailable tools:\n{tools_description}" - full_prompt += "\n\nWhen using tools, format your response as JSON with the structure matching the expected schema." - try: - # Use gh copilot to generate response - cmd = ['gh', 'copilot', 'suggest', '--type', 'shell'] + # 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') - # For now, we'll use the suggest command and parse the output - # In a real implementation, you might want to use the API directly result = subprocess.run( cmd, - input=full_prompt, capture_output=True, text=True, - check=True + check=True, + env=os.environ.copy() ) - content = result.stdout.strip() + output = result.stdout.strip() - # Try to parse as JSON first, fallback to text response - try: - json_content = json.loads(content) - except json.JSONDecodeError: - # If it's not JSON, wrap it in a basic response structure - json_content = { - "type": "implemented", - "content": content, - "artifacts": [] - } + # 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) - return adapter.validate_python(json_content) + # 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: - raise RuntimeError(f"GitHub Copilot CLI call failed: {e.stderr}") from e - - def supports_tools(self, model: str) -> bool: - """Check if model supports tools (currently limited support).""" - return model in self.tool_supported_models + # 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 _format_tools_for_prompt(self, tools: list[Tool]) -> str: - """Format tools into a readable description for the prompt.""" - tool_descriptions = [] - for tool in tools: - params_desc = ", ".join(f"{k}" for k in tool.parameters.keys()) - tool_descriptions.append(f"- {tool.name}: {tool.description} [Parameters: {params_desc}]") + 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) - return "\n".join(tool_descriptions) \ No newline at end of file + # 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/tools/github_tools.py b/src/tools/github_tools.py index 74f3122..5738d85 100644 --- a/src/tools/github_tools.py +++ b/src/tools/github_tools.py @@ -1,90 +1,104 @@ -import subprocess import json +import os from typing import Any, Dict, Optional -from pydantic import BaseModel +import requests from src.modelAccessors.data.tool import Tool class GitHubIssueManager: - """Manager for GitHub issue operations using gh CLI.""" + """Manager for GitHub issue operations using GitHub API.""" @staticmethod - def _run_gh_command(cmd: list[str]) -> str: - """Run a gh CLI command and return the output.""" - try: - result = subprocess.run( - ['gh'] + cmd, - capture_output=True, - text=True, - check=True - ) - return result.stdout.strip() - except subprocess.CalledProcessError as e: - raise RuntimeError(f"GitHub CLI command failed: {e.stderr}") from e - except FileNotFoundError: + 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 CLI (gh) is not available. Please install it and authenticate with 'gh auth login'. " - "See https://github.com/cli/cli#installation for installation instructions." - ) from None + "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.""" - cmd = ['issue', 'view', str(issue_number), '--repo', repo, '--json', 'title,body,state,author,labels,assignees'] - output = GitHubIssueManager._run_gh_command(cmd) - return json.loads(output) + 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.""" - cmd = ['issue', 'create', '--repo', repo, '--title', title, '--body', body] - + url = f"https://api.github.com/repos/{repo}/issues" + data = { + "title": title, + "body": body + } if labels: - cmd.extend(['--label', ','.join(labels)]) + data["labels"] = labels - output = GitHubIssueManager._run_gh_command(cmd) - # Extract issue URL/number from output - issue_url = output.strip() - return {"url": issue_url, "created": True} + return GitHubIssueManager._make_api_request("POST", url, data) -def update_issue(repo: str, issue_number: int, title: Optional[str] = None, body: Optional[str] = None) -> Dict[str, Any]: +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.""" - cmd = ['issue', 'edit', str(issue_number), '--repo', repo] + url = f"https://api.github.com/repos/{repo}/issues/{issue_number}" + data = {} if title: - cmd.extend(['--title', title]) + data["title"] = title if body: - cmd.extend(['--body', body]) + data["body"] = body + if state: + data["state"] = state - GitHubIssueManager._run_gh_command(cmd) - return {"updated": True, "issue_number": issue_number} + 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.""" - cmd = ['issue', 'comment', str(issue_number), '--repo', repo, '--body', comment] - GitHubIssueManager._run_gh_command(cmd) - return {"commented": True, "issue_number": issue_number} + 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.""" - cmd = ['issue', 'list', '--repo', repo, '--state', state, '--limit', str(limit), '--json', 'number,title,state,author,labels'] - output = GitHubIssueManager._run_gh_command(cmd) - return json.loads(output) + 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, reason: Optional[str] = None) -> Dict[str, Any]: +def close_issue(repo: str, issue_number: int) -> Dict[str, Any]: """Close a GitHub issue.""" - cmd = ['issue', 'close', str(issue_number), '--repo', repo] - if reason: - cmd.extend(['--reason', reason]) - - GitHubIssueManager._run_gh_command(cmd) - return {"closed": True, "issue_number": issue_number} + return update_issue(repo, issue_number, state="closed") # Tool definitions for use with model accessors @@ -115,7 +129,8 @@ def close_issue(repo: str, issue_number: int, reason: Optional[str] = None) -> D "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)"} + "body": {"type": "string", "description": "New body/description (optional)"}, + "state": {"type": "string", "enum": ["open", "closed"], "description": "New state (optional)"} } ) @@ -144,8 +159,7 @@ def close_issue(repo: str, issue_number: int, reason: Optional[str] = None) -> D description="Close a GitHub issue", parameters={ "repo": {"type": "string", "description": "Repository in format 'owner/repo'"}, - "issue_number": {"type": "integer", "description": "Issue number"}, - "reason": {"type": "string", "enum": ["completed", "not_planned"], "description": "Reason for closing"} + "issue_number": {"type": "integer", "description": "Issue number"} } ) diff --git a/tests/cli/test_copilot_cli.py b/tests/cli/test_copilot_cli.py deleted file mode 100644 index 605fd6a..0000000 --- a/tests/cli/test_copilot_cli.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Tests for GitHub Copilot CLI.""" - -import pytest -from unittest.mock import patch, MagicMock -import sys -from io import StringIO - -from src.cli.copilot_cli import parse_copilot_args, create_agent_task, follow_task, list_tasks - - -def test_parse_copilot_args_create(): - """Test parsing create command arguments.""" - sys.argv = ['gh-copilot-agent-task', 'create', 'Test task', '--follow', '--repo', 'owner/repo'] - args = parse_copilot_args() - - assert args.command == 'create' - assert args.prompt == 'Test task' - assert args.follow is True - assert args.repo == 'owner/repo' - assert args.model_type == 'github_copilot' - - -def test_parse_copilot_args_follow(): - """Test parsing follow command arguments.""" - sys.argv = ['gh-copilot-agent-task', 'follow', '123', '--repo', 'owner/repo'] - args = parse_copilot_args() - - assert args.command == 'follow' - assert args.task_id == '123' - assert args.repo == 'owner/repo' - - -def test_parse_copilot_args_list(): - """Test parsing list command arguments.""" - sys.argv = ['gh-copilot-agent-task', 'list', '--repo', 'owner/repo', '--state', 'closed', '--limit', '5'] - args = parse_copilot_args() - - assert args.command == 'list' - assert args.repo == 'owner/repo' - assert args.state == 'closed' - assert args.limit == 5 - - -@patch('src.cli.copilot_cli.create_issue') -@patch('src.cli.copilot_cli.AgentOrchestrator') -def test_create_agent_task_with_repo(mock_orchestrator, mock_create_issue): - """Test create_agent_task with repository.""" - # Mock GitHub issue creation - mock_create_issue.return_value = {"url": "https://github.com/owner/repo/issues/123"} - - # Mock orchestrator - mock_project = MagicMock() - mock_project.completedTasks = [] - mock_project.inProgressTasks = [] - mock_project.failedTasks = [] - mock_project.queuedTasks = [] - mock_project.latestResponse = None - - mock_orchestrator_instance = MagicMock() - mock_orchestrator_instance.implement_project.return_value = mock_project - mock_orchestrator.return_value = mock_orchestrator_instance - - # Capture stdout - captured_output = StringIO() - with patch('sys.stdout', captured_output): - create_agent_task("Test task", "github_copilot", "owner/repo") - - # Verify calls - mock_create_issue.assert_called_once() - mock_orchestrator_instance.implement_project.assert_called_once() - - output = captured_output.getvalue() - assert "Creating agent task: Test task" in output - assert "Created GitHub issue:" in output - - -@patch('src.cli.copilot_cli.get_issue') -def test_follow_task(mock_get_issue): - """Test follow_task function.""" - mock_get_issue.return_value = { - "title": "Test Issue", - "state": "open", - "author": {"login": "testuser"}, - "body": "Test description", - "labels": [{"name": "enhancement"}] - } - - captured_output = StringIO() - with patch('sys.stdout', captured_output): - follow_task("123", "owner/repo") - - mock_get_issue.assert_called_once_with("owner/repo", 123) - - output = captured_output.getvalue() - assert "Task 123: Test Issue" in output - assert "State: open" in output - assert "Author: testuser" in output - - -@patch('src.cli.copilot_cli.list_issues') -def test_list_tasks(mock_list_issues): - """Test list_tasks function.""" - mock_list_issues.return_value = [ - {"number": 1, "title": "Issue 1", "state": "open", "labels": []}, - {"number": 2, "title": "Issue 2", "state": "closed", "labels": [{"name": "bug"}]} - ] - - captured_output = StringIO() - with patch('sys.stdout', captured_output): - list_tasks("owner/repo", "all", 10) - - mock_list_issues.assert_called_once_with("owner/repo", "all", 10) - - output = captured_output.getvalue() - assert "Tasks in owner/repo" in output - assert "#1: Issue 1 (open)" in output - assert "#2: Issue 2 (closed) [bug]" in output - - -def test_follow_task_no_repo(): - """Test follow_task without repo raises error.""" - captured_output = StringIO() - with patch('sys.stdout', captured_output), patch('sys.stderr', captured_output): - with pytest.raises(SystemExit): - follow_task("123") - - output = captured_output.getvalue() - assert "Error: --repo is required for following tasks" in output - - -def test_list_tasks_no_repo(): - """Test list_tasks without repo raises error.""" - captured_output = StringIO() - with patch('sys.stdout', captured_output), patch('sys.stderr', captured_output): - with pytest.raises(SystemExit): - list_tasks() - - output = captured_output.getvalue() - assert "Error: --repo is required for listing tasks" in output \ No newline at end of file diff --git a/tests/modelAccessors/test_github_copilot_accessor.py b/tests/modelAccessors/test_github_copilot_accessor.py index ab785ca..17c1f94 100644 --- a/tests/modelAccessors/test_github_copilot_accessor.py +++ b/tests/modelAccessors/test_github_copilot_accessor.py @@ -30,21 +30,26 @@ def test_supports_tools(): assert accessor.supports_tools("unsupported-model") is False -def test_format_tools_for_prompt(): - """Test _format_tools_for_prompt method.""" +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() - from src.modelAccessors.data.tool import Tool - tools = [ - Tool(name="test_tool", description="Test tool", parameters={"param1": {"type": "string"}}), - Tool(name="another_tool", description="Another tool", parameters={"param2": {"type": "int"}}) - ] + 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() - result = accessor._format_tools_for_prompt(tools) - assert "test_tool: Test tool [Parameters: param1]" in result - assert "another_tool: Another tool [Parameters: param2]" in result + output = "Issue #456 created" + issue_id = accessor._extract_issue_id(output) + assert issue_id == "456" def test_call_model_no_gh(): @@ -59,19 +64,41 @@ def test_call_model_no_gh(): @patch('subprocess.run') def test_call_model_success(mock_run): """Test successful call_model execution.""" - # Setup mock for gh version check + # Setup mock for gh version check and agent-task create mock_run.side_effect = [ MagicMock(returncode=0), # gh version check - MagicMock(stdout='{"type": "implemented", "content": "test response", "artifacts": []}') # gh copilot call + 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_adapter.validate_python.return_value = {"type": "implemented", "content": "test response"} + mock_response = MagicMock() + mock_adapter.validate_python.return_value = mock_response result = accessor.call_model("test prompt", adapter=mock_adapter, schema={}) - mock_adapter.validate_python.assert_called_once() - assert mock_run.call_count == 2 # version check + actual call \ No newline at end of file + # 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 index c09c3c3..c5a7726 100644 --- a/tests/tools/test_github_tools.py +++ b/tests/tools/test_github_tools.py @@ -18,115 +18,135 @@ ) -def test_github_issue_manager_run_gh_command_success(): - """Test successful gh command execution.""" - with patch('subprocess.run') as mock_run: - mock_run.return_value.stdout = "test output" - result = GitHubIssueManager._run_gh_command(['issue', 'list']) - assert result == "test output" - mock_run.assert_called_once_with( - ['gh', 'issue', 'list'], - capture_output=True, - text=True, - check=True - ) - - -def test_github_issue_manager_run_gh_command_no_gh(): - """Test gh command execution when gh CLI is not available.""" - with patch('subprocess.run', side_effect=FileNotFoundError): - with pytest.raises(RuntimeError, match="GitHub CLI \\(gh\\) is not available"): - GitHubIssueManager._run_gh_command(['issue', 'list']) - - -@patch('src.tools.github_tools.GitHubIssueManager._run_gh_command') -def test_get_issue(mock_run_command): +@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_run_command.return_value = '{"title": "Test Issue", "body": "Test body", "state": "open"}' + 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_run_command.assert_called_once_with([ - 'issue', 'view', '123', '--repo', 'owner/repo', '--json', 'title,body,state,author,labels,assignees' - ]) + mock_get.assert_called_once() + assert "https://api.github.com/repos/owner/repo/issues/123" in mock_get.call_args[0] -@patch('src.tools.github_tools.GitHubIssueManager._run_gh_command') -def test_create_issue(mock_run_command): +@patch('requests.post') +@patch.dict('os.environ', {'GITHUB_TOKEN': 'test_token'}) +def test_create_issue(mock_post): """Test create_issue function.""" - mock_run_command.return_value = "https://github.com/owner/repo/issues/123" + 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["url"] == "https://github.com/owner/repo/issues/123" - assert result["created"] is True - mock_run_command.assert_called_once_with([ - 'issue', 'create', '--repo', 'owner/repo', '--title', 'Test Issue', - '--body', 'Test body', '--label', '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('src.tools.github_tools.GitHubIssueManager._run_gh_command') -def test_update_issue(mock_run_command): +@patch('requests.patch') +@patch.dict('os.environ', {'GITHUB_TOKEN': 'test_token'}) +def test_update_issue(mock_patch): """Test update_issue function.""" - mock_run_command.return_value = "" + 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["updated"] is True - assert result["issue_number"] == 123 - mock_run_command.assert_called_once_with([ - 'issue', 'edit', '123', '--repo', 'owner/repo', '--title', 'New Title', '--body', '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('src.tools.github_tools.GitHubIssueManager._run_gh_command') -def test_comment_on_issue(mock_run_command): +@patch('requests.post') +@patch.dict('os.environ', {'GITHUB_TOKEN': 'test_token'}) +def test_comment_on_issue(mock_post): """Test comment_on_issue function.""" - mock_run_command.return_value = "" + 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["commented"] is True - assert result["issue_number"] == 123 - mock_run_command.assert_called_once_with([ - 'issue', 'comment', '123', '--repo', 'owner/repo', '--body', 'Test comment' - ]) + assert result["body"] == "Test comment" + mock_post.assert_called_once() + assert "comments" in mock_post.call_args[0][0] -@patch('src.tools.github_tools.GitHubIssueManager._run_gh_command') -def test_list_issues(mock_run_command): +@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_run_command.return_value = json.dumps(mock_issues) + 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_run_command.assert_called_once_with([ - 'issue', 'list', '--repo', 'owner/repo', '--state', 'all', '--limit', '20', - '--json', 'number,title,state,author,labels' - ]) + 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('src.tools.github_tools.GitHubIssueManager._run_gh_command') -def test_close_issue(mock_run_command): +@patch('requests.patch') +@patch.dict('os.environ', {'GITHUB_TOKEN': 'test_token'}) +def test_close_issue(mock_patch): """Test close_issue function.""" - mock_run_command.return_value = "" + 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, "completed") + result = close_issue("owner/repo", 123) - assert result["closed"] is True - assert result["issue_number"] == 123 - mock_run_command.assert_called_once_with([ - 'issue', 'close', '123', '--repo', 'owner/repo', '--reason', 'completed' - ]) + 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():