Coverage for me2ai_mcp\tools\github.py: 0%
284 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-13 11:30 +0200
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-13 11:30 +0200
1"""
2GitHub-related tools for ME2AI MCP servers.
4This module provides common tools for GitHub API operations
5that can be used across different MCP servers.
6"""
7from typing import Dict, List, Any, Optional
8import logging
9import os
10import base64
11from dataclasses import dataclass
12import requests
14from ..base import BaseTool
15from ..auth import AuthManager, TokenAuth
17# Configure logging
18logger = logging.getLogger("me2ai-mcp-tools-github")
21@dataclass
22class GitHubRepositoryTool(BaseTool):
23 """Tool for GitHub repository operations."""
25 name: str = "github_repository"
26 description: str = "GitHub repository search and metadata"
27 api_base_url: str = "https://api.github.com"
29 def __post_init__(self):
30 """Initialize with GitHub authentication if available."""
31 super().__post_init__()
32 self.auth = AuthManager.from_github_token()
34 def _get_headers(self) -> Dict[str, str]:
35 """Get headers for GitHub API requests."""
36 headers = {
37 "Accept": "application/vnd.github.v3+json",
38 "User-Agent": "ME2AI-GitHub-MCP-Tools/1.0"
39 }
41 if self.auth.has_token():
42 token = self.auth.get_token().token
43 headers["Authorization"] = f"token {token}"
45 return headers
47 async def execute(self, params: Dict[str, Any]) -> Dict[str, Any]:
48 """Execute GitHub repository operations.
50 Args:
51 params: Dictionary containing:
52 - operation: Operation to perform (search, get_details, list_contents)
53 - query: Search query (for search operation)
54 - repo_name: Repository name in format "owner/repo" (for get_details and list_contents)
55 - path: Path within the repository (for list_contents)
56 - ref: Git reference (branch, tag, commit) (for list_contents)
57 - sort: Sort field for search results (stars, forks, updated)
58 - order: Sort order (asc, desc)
59 - limit: Maximum number of results to return
61 Returns:
62 Dictionary containing operation results
63 """
64 operation = params.get("operation")
66 if not operation:
67 return {
68 "success": False,
69 "error": "operation parameter is required"
70 }
72 if operation == "search":
73 return await self._search_repositories(params)
74 elif operation == "get_details":
75 return await self._get_repository_details(params)
76 elif operation == "list_contents":
77 return await self._list_repository_contents(params)
78 else:
79 return {
80 "success": False,
81 "error": f"Unknown operation: {operation}"
82 }
84 async def _search_repositories(self, params: Dict[str, Any]) -> Dict[str, Any]:
85 """Search for GitHub repositories."""
86 query = params.get("query")
87 if not query:
88 return {
89 "success": False,
90 "error": "query parameter is required for search operation"
91 }
93 sort = params.get("sort", "stars")
94 order = params.get("order", "desc")
95 limit = params.get("limit", 10)
97 try:
98 api_params = {
99 "q": query,
100 "sort": sort,
101 "order": order,
102 "per_page": min(limit, 100) # GitHub API limits to 100 per page
103 }
105 url = f"{self.api_base_url}/search/repositories"
106 response = requests.get(url, headers=self._get_headers(), params=api_params)
107 response.raise_for_status()
109 data = response.json()
110 repositories = data.get("items", [])
112 # Format results
113 formatted_repos = []
114 for repo in repositories[:limit]:
115 formatted_repos.append({
116 "name": repo.get("name"),
117 "full_name": repo.get("full_name"),
118 "url": repo.get("html_url"),
119 "description": repo.get("description"),
120 "stars": repo.get("stargazers_count"),
121 "forks": repo.get("forks_count"),
122 "language": repo.get("language"),
123 "created_at": repo.get("created_at"),
124 "updated_at": repo.get("updated_at"),
125 "owner": {
126 "login": repo.get("owner", {}).get("login"),
127 "url": repo.get("owner", {}).get("html_url"),
128 "avatar_url": repo.get("owner", {}).get("avatar_url")
129 }
130 })
132 return {
133 "success": True,
134 "query": query,
135 "total_count": data.get("total_count", 0),
136 "count": len(formatted_repos),
137 "repositories": formatted_repos
138 }
139 except requests.RequestException as e:
140 logger.error(f"Error searching repositories: {str(e)}")
141 return {
142 "success": False,
143 "error": f"Failed to search repositories: {str(e)}",
144 "query": query
145 }
146 except Exception as e:
147 logger.error(f"Unexpected error in search_repositories: {str(e)}")
148 return {
149 "success": False,
150 "error": f"An unexpected error occurred: {str(e)}",
151 "query": query
152 }
154 async def _get_repository_details(self, params: Dict[str, Any]) -> Dict[str, Any]:
155 """Get detailed information about a GitHub repository."""
156 repo_name = params.get("repo_name")
157 if not repo_name:
158 return {
159 "success": False,
160 "error": "repo_name parameter is required for get_details operation"
161 }
163 try:
164 url = f"{self.api_base_url}/repos/{repo_name}"
165 response = requests.get(url, headers=self._get_headers())
166 response.raise_for_status()
168 repo = response.json()
170 # Get languages and topics
171 languages_url = f"{self.api_base_url}/repos/{repo_name}/languages"
172 languages_response = requests.get(languages_url, headers=self._get_headers())
173 languages = languages_response.json() if languages_response.status_code == 200 else {}
175 topics_url = f"{self.api_base_url}/repos/{repo_name}/topics"
176 topics_headers = self._get_headers()
177 topics_headers["Accept"] = "application/vnd.github.mercy-preview+json"
178 topics_response = requests.get(topics_url, headers=topics_headers)
179 topics = topics_response.json().get("names", []) if topics_response.status_code == 200 else []
181 # Format detailed repository information
182 repo_details = {
183 "name": repo.get("name"),
184 "full_name": repo.get("full_name"),
185 "description": repo.get("description"),
186 "url": repo.get("html_url"),
187 "api_url": repo.get("url"),
188 "created_at": repo.get("created_at"),
189 "updated_at": repo.get("updated_at"),
190 "pushed_at": repo.get("pushed_at"),
191 "size": repo.get("size"),
192 "stars": repo.get("stargazers_count"),
193 "watchers": repo.get("watchers_count"),
194 "forks": repo.get("forks_count"),
195 "open_issues": repo.get("open_issues_count"),
196 "default_branch": repo.get("default_branch"),
197 "languages": languages,
198 "topics": topics,
199 "license": repo.get("license", {}).get("name") if repo.get("license") else None,
200 "private": repo.get("private", False),
201 "archived": repo.get("archived", False),
202 "disabled": repo.get("disabled", False),
203 "fork": repo.get("fork", False),
204 "owner": {
205 "login": repo.get("owner", {}).get("login"),
206 "url": repo.get("owner", {}).get("html_url"),
207 "type": repo.get("owner", {}).get("type"),
208 "avatar_url": repo.get("owner", {}).get("avatar_url")
209 }
210 }
212 return {
213 "success": True,
214 "repository": repo_details
215 }
216 except requests.RequestException as e:
217 logger.error(f"Error getting repository details: {str(e)}")
218 return {
219 "success": False,
220 "error": f"Failed to get repository details: {str(e)}",
221 "repo_name": repo_name
222 }
223 except Exception as e:
224 logger.error(f"Unexpected error in get_repository_details: {str(e)}")
225 return {
226 "success": False,
227 "error": f"An unexpected error occurred: {str(e)}",
228 "repo_name": repo_name
229 }
231 async def _list_repository_contents(self, params: Dict[str, Any]) -> Dict[str, Any]:
232 """List contents of a GitHub repository."""
233 repo_name = params.get("repo_name")
234 if not repo_name:
235 return {
236 "success": False,
237 "error": "repo_name parameter is required for list_contents operation"
238 }
240 path = params.get("path", "")
241 ref = params.get("ref")
243 try:
244 url = f"{self.api_base_url}/repos/{repo_name}/contents/{path}"
245 api_params = {}
246 if ref:
247 api_params["ref"] = ref
249 response = requests.get(url, headers=self._get_headers(), params=api_params)
250 response.raise_for_status()
252 contents = response.json()
253 formatted_contents = []
255 # Handle single file response
256 if not isinstance(contents, list):
257 contents = [contents]
259 for item in contents:
260 formatted_contents.append({
261 "name": item.get("name"),
262 "path": item.get("path"),
263 "type": item.get("type"),
264 "size": item.get("size") if item.get("type") == "file" else None,
265 "download_url": item.get("download_url"),
266 "html_url": item.get("html_url"),
267 "git_url": item.get("git_url")
268 })
270 # Sort directories first, then files
271 formatted_contents.sort(key=lambda x: (0 if x["type"] == "dir" else 1, x["name"]))
273 return {
274 "success": True,
275 "repo_name": repo_name,
276 "path": path,
277 "ref": ref,
278 "contents": formatted_contents
279 }
280 except requests.RequestException as e:
281 logger.error(f"Error listing repository contents: {str(e)}")
282 return {
283 "success": False,
284 "error": f"Failed to list repository contents: {str(e)}",
285 "repo_name": repo_name,
286 "path": path
287 }
288 except Exception as e:
289 logger.error(f"Unexpected error in list_repository_contents: {str(e)}")
290 return {
291 "success": False,
292 "error": f"An unexpected error occurred: {str(e)}",
293 "repo_name": repo_name,
294 "path": path
295 }
298@dataclass
299class GitHubCodeTool(BaseTool):
300 """Tool for GitHub code operations."""
302 name: str = "github_code"
303 description: str = "GitHub code search and file operations"
304 api_base_url: str = "https://api.github.com"
306 def __post_init__(self):
307 """Initialize with GitHub authentication if available."""
308 super().__post_init__()
309 self.auth = AuthManager.from_github_token()
311 def _get_headers(self) -> Dict[str, str]:
312 """Get headers for GitHub API requests."""
313 headers = {
314 "Accept": "application/vnd.github.v3+json",
315 "User-Agent": "ME2AI-GitHub-MCP-Tools/1.0"
316 }
318 if self.auth.has_token():
319 token = self.auth.get_token().token
320 headers["Authorization"] = f"token {token}"
322 return headers
324 async def execute(self, params: Dict[str, Any]) -> Dict[str, Any]:
325 """Execute GitHub code operations.
327 Args:
328 params: Dictionary containing:
329 - operation: Operation to perform (search, get_file)
330 - query: Search query (for search operation)
331 - language: Filter by programming language (for search operation)
332 - repo: Limit search to specific repo in format "owner/repo" (for search operation)
333 - repo_name: Repository name in format "owner/repo" (for get_file)
334 - file_path: Path to the file within the repository (for get_file)
335 - ref: Git reference (branch, tag, commit) (for get_file)
336 - limit: Maximum number of results to return
338 Returns:
339 Dictionary containing operation results
340 """
341 operation = params.get("operation")
343 if not operation:
344 return {
345 "success": False,
346 "error": "operation parameter is required"
347 }
349 if operation == "search":
350 return await self._search_code(params)
351 elif operation == "get_file":
352 return await self._get_file_content(params)
353 else:
354 return {
355 "success": False,
356 "error": f"Unknown operation: {operation}"
357 }
359 async def _search_code(self, params: Dict[str, Any]) -> Dict[str, Any]:
360 """Search for code in GitHub repositories."""
361 query = params.get("query")
362 if not query:
363 return {
364 "success": False,
365 "error": "query parameter is required for search operation"
366 }
368 language = params.get("language")
369 repo = params.get("repo")
370 limit = params.get("limit", 10)
372 try:
373 # Build the search query
374 search_query = query
375 if language:
376 search_query += f" language:{language}"
377 if repo:
378 search_query += f" repo:{repo}"
380 api_params = {
381 "q": search_query,
382 "per_page": min(limit, 100) # GitHub API limits to 100 per page
383 }
385 url = f"{self.api_base_url}/search/code"
386 headers = self._get_headers()
388 # Code search requires specific accept header
389 headers["Accept"] = "application/vnd.github.v3.text-match+json"
391 response = requests.get(url, headers=headers, params=api_params)
392 response.raise_for_status()
394 data = response.json()
395 items = data.get("items", [])
397 # Format results
398 formatted_results = []
399 for item in items[:limit]:
400 # Extract matches if available
401 matches = []
402 for match in item.get("text_matches", []):
403 matches.append({
404 "fragment": match.get("fragment", ""),
405 "property": match.get("property", "")
406 })
408 formatted_results.append({
409 "name": item.get("name", ""),
410 "path": item.get("path", ""),
411 "repository": {
412 "name": item.get("repository", {}).get("name", ""),
413 "full_name": item.get("repository", {}).get("full_name", ""),
414 "url": item.get("repository", {}).get("html_url", "")
415 },
416 "html_url": item.get("html_url", ""),
417 "git_url": item.get("git_url", ""),
418 "matches": matches
419 })
421 return {
422 "success": True,
423 "query": query,
424 "language": language,
425 "repo": repo,
426 "total_count": data.get("total_count", 0),
427 "count": len(formatted_results),
428 "results": formatted_results
429 }
430 except requests.RequestException as e:
431 logger.error(f"Error searching code: {str(e)}")
432 return {
433 "success": False,
434 "error": f"Failed to search code: {str(e)}",
435 "query": query
436 }
437 except Exception as e:
438 logger.error(f"Unexpected error in search_code: {str(e)}")
439 return {
440 "success": False,
441 "error": f"An unexpected error occurred: {str(e)}",
442 "query": query
443 }
445 async def _get_file_content(self, params: Dict[str, Any]) -> Dict[str, Any]:
446 """Get the content of a file from a GitHub repository."""
447 repo_name = params.get("repo_name")
448 file_path = params.get("file_path")
450 if not repo_name:
451 return {
452 "success": False,
453 "error": "repo_name parameter is required for get_file operation"
454 }
456 if not file_path:
457 return {
458 "success": False,
459 "error": "file_path parameter is required for get_file operation"
460 }
462 ref = params.get("ref")
464 try:
465 url = f"{self.api_base_url}/repos/{repo_name}/contents/{file_path}"
466 api_params = {}
467 if ref:
468 api_params["ref"] = ref
470 response = requests.get(url, headers=self._get_headers(), params=api_params)
471 response.raise_for_status()
473 file_data = response.json()
475 if file_data.get("type") != "file":
476 return {
477 "success": False,
478 "error": "Specified path is not a file",
479 "repo_name": repo_name,
480 "file_path": file_path
481 }
483 # Decode content from base64
484 encoded_content = file_data.get("content", "")
485 # GitHub API returns base64 with newlines, remove them first
486 encoded_content = encoded_content.replace("\n", "")
487 content = base64.b64decode(encoded_content).decode("utf-8")
489 return {
490 "success": True,
491 "repo_name": repo_name,
492 "file_path": file_path,
493 "ref": ref,
494 "size": file_data.get("size", 0),
495 "name": file_data.get("name", ""),
496 "content": content,
497 "html_url": file_data.get("html_url"),
498 "download_url": file_data.get("download_url"),
499 "encoding": "utf-8" # We're decoding as UTF-8
500 }
501 except UnicodeDecodeError:
502 # Handle binary files
503 logger.warning(f"Binary file detected: {file_path}")
504 return {
505 "success": False,
506 "error": "Binary file detected, content not returned",
507 "repo_name": repo_name,
508 "file_path": file_path,
509 "is_binary": True
510 }
511 except requests.RequestException as e:
512 logger.error(f"Error getting file content: {str(e)}")
513 return {
514 "success": False,
515 "error": f"Failed to get file content: {str(e)}",
516 "repo_name": repo_name,
517 "file_path": file_path
518 }
519 except Exception as e:
520 logger.error(f"Unexpected error in get_file_content: {str(e)}")
521 return {
522 "success": False,
523 "error": f"An unexpected error occurred: {str(e)}",
524 "repo_name": repo_name,
525 "file_path": file_path
526 }
529@dataclass
530class GitHubIssuesTool(BaseTool):
531 """Tool for GitHub issues operations."""
533 name: str = "github_issues"
534 description: str = "GitHub issues management"
535 api_base_url: str = "https://api.github.com"
537 def __post_init__(self):
538 """Initialize with GitHub authentication if available."""
539 super().__post_init__()
540 self.auth = AuthManager.from_github_token()
542 def _get_headers(self) -> Dict[str, str]:
543 """Get headers for GitHub API requests."""
544 headers = {
545 "Accept": "application/vnd.github.v3+json",
546 "User-Agent": "ME2AI-GitHub-MCP-Tools/1.0"
547 }
549 if self.auth.has_token():
550 token = self.auth.get_token().token
551 headers["Authorization"] = f"token {token}"
553 return headers
555 async def execute(self, params: Dict[str, Any]) -> Dict[str, Any]:
556 """Execute GitHub issues operations.
558 Args:
559 params: Dictionary containing:
560 - operation: Operation to perform (list, get_details)
561 - repo_name: Repository name in format "owner/repo"
562 - issue_number: Issue number (for get_details)
563 - state: Filter by issue state (open, closed, all) (for list)
564 - sort: Sort field (created, updated, comments) (for list)
565 - direction: Sort direction (asc, desc) (for list)
566 - limit: Maximum number of results to return
568 Returns:
569 Dictionary containing operation results
570 """
571 operation = params.get("operation")
573 if not operation:
574 return {
575 "success": False,
576 "error": "operation parameter is required"
577 }
579 if operation == "list":
580 return await self._list_issues(params)
581 elif operation == "get_details":
582 return await self._get_issue_details(params)
583 else:
584 return {
585 "success": False,
586 "error": f"Unknown operation: {operation}"
587 }
589 async def _list_issues(self, params: Dict[str, Any]) -> Dict[str, Any]:
590 """List issues in a GitHub repository."""
591 repo_name = params.get("repo_name")
592 if not repo_name:
593 return {
594 "success": False,
595 "error": "repo_name parameter is required for list operation"
596 }
598 state = params.get("state", "open")
599 sort = params.get("sort", "created")
600 direction = params.get("direction", "desc")
601 limit = params.get("limit", 10)
603 # Validate state parameter
604 if state not in ["open", "closed", "all"]:
605 return {
606 "success": False,
607 "error": "Invalid state parameter. Must be one of: open, closed, all",
608 "repo_name": repo_name
609 }
611 # Validate sort parameter
612 if sort not in ["created", "updated", "comments"]:
613 return {
614 "success": False,
615 "error": "Invalid sort parameter. Must be one of: created, updated, comments",
616 "repo_name": repo_name
617 }
619 # Validate direction parameter
620 if direction not in ["asc", "desc"]:
621 return {
622 "success": False,
623 "error": "Invalid direction parameter. Must be one of: asc, desc",
624 "repo_name": repo_name
625 }
627 try:
628 url = f"{self.api_base_url}/repos/{repo_name}/issues"
629 api_params = {
630 "state": state,
631 "sort": sort,
632 "direction": direction,
633 "per_page": min(limit, 100)
634 }
636 response = requests.get(url, headers=self._get_headers(), params=api_params)
637 response.raise_for_status()
639 issues = response.json()
641 # Format results
642 formatted_issues = []
643 for issue in issues[:limit]:
644 # Skip pull requests (they show up in the issues endpoint)
645 if "pull_request" in issue:
646 continue
648 labels = [label.get("name") for label in issue.get("labels", [])]
650 formatted_issues.append({
651 "number": issue.get("number"),
652 "title": issue.get("title"),
653 "state": issue.get("state"),
654 "url": issue.get("html_url"),
655 "created_at": issue.get("created_at"),
656 "updated_at": issue.get("updated_at"),
657 "closed_at": issue.get("closed_at"),
658 "user": {
659 "login": issue.get("user", {}).get("login"),
660 "url": issue.get("user", {}).get("html_url")
661 },
662 "labels": labels,
663 "comments": issue.get("comments", 0),
664 "body_preview": issue.get("body", "")[:200] + ("..." if issue.get("body", "") and len(issue.get("body", "")) > 200 else "")
665 })
667 return {
668 "success": True,
669 "repo_name": repo_name,
670 "state": state,
671 "count": len(formatted_issues),
672 "issues": formatted_issues
673 }
674 except requests.RequestException as e:
675 logger.error(f"Error listing issues: {str(e)}")
676 return {
677 "success": False,
678 "error": f"Failed to list issues: {str(e)}",
679 "repo_name": repo_name
680 }
681 except Exception as e:
682 logger.error(f"Unexpected error in list_issues: {str(e)}")
683 return {
684 "success": False,
685 "error": f"An unexpected error occurred: {str(e)}",
686 "repo_name": repo_name
687 }
689 async def _get_issue_details(self, params: Dict[str, Any]) -> Dict[str, Any]:
690 """Get detailed information about a specific GitHub issue."""
691 repo_name = params.get("repo_name")
692 issue_number = params.get("issue_number")
694 if not repo_name:
695 return {
696 "success": False,
697 "error": "repo_name parameter is required for get_details operation"
698 }
700 if not issue_number:
701 return {
702 "success": False,
703 "error": "issue_number parameter is required for get_details operation"
704 }
706 try:
707 url = f"{self.api_base_url}/repos/{repo_name}/issues/{issue_number}"
708 response = requests.get(url, headers=self._get_headers())
709 response.raise_for_status()
711 issue = response.json()
713 # Check if this is a pull request
714 if "pull_request" in issue:
715 return {
716 "success": False,
717 "error": "The specified issue is a pull request, not an issue",
718 "repo_name": repo_name,
719 "issue_number": issue_number,
720 "pull_request_url": issue.get("pull_request", {}).get("html_url")
721 }
723 # Get comments
724 comments_url = f"{self.api_base_url}/repos/{repo_name}/issues/{issue_number}/comments"
725 comments_response = requests.get(comments_url, headers=self._get_headers())
726 comments = []
728 if comments_response.status_code == 200:
729 comments_data = comments_response.json()
730 for comment in comments_data:
731 comments.append({
732 "user": {
733 "login": comment.get("user", {}).get("login"),
734 "url": comment.get("user", {}).get("html_url")
735 },
736 "created_at": comment.get("created_at"),
737 "updated_at": comment.get("updated_at"),
738 "body": comment.get("body")
739 })
741 # Format detailed issue information
742 labels = [label.get("name") for label in issue.get("labels", [])]
744 issue_details = {
745 "number": issue.get("number"),
746 "title": issue.get("title"),
747 "state": issue.get("state"),
748 "url": issue.get("html_url"),
749 "created_at": issue.get("created_at"),
750 "updated_at": issue.get("updated_at"),
751 "closed_at": issue.get("closed_at"),
752 "user": {
753 "login": issue.get("user", {}).get("login"),
754 "url": issue.get("user", {}).get("html_url"),
755 "avatar_url": issue.get("user", {}).get("avatar_url")
756 },
757 "labels": labels,
758 "assignees": [
759 {
760 "login": assignee.get("login"),
761 "url": assignee.get("html_url")
762 }
763 for assignee in issue.get("assignees", [])
764 ],
765 "comments_count": issue.get("comments", 0),
766 "comments": comments,
767 "body": issue.get("body", "")
768 }
770 return {
771 "success": True,
772 "repo_name": repo_name,
773 "issue_number": issue_number,
774 "issue": issue_details
775 }
776 except requests.RequestException as e:
777 logger.error(f"Error getting issue details: {str(e)}")
778 return {
779 "success": False,
780 "error": f"Failed to get issue details: {str(e)}",
781 "repo_name": repo_name,
782 "issue_number": issue_number
783 }
784 except Exception as e:
785 logger.error(f"Unexpected error in get_issue_details: {str(e)}")
786 return {
787 "success": False,
788 "error": f"An unexpected error occurred: {str(e)}",
789 "repo_name": repo_name,
790 "issue_number": issue_number
791 }