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

1""" 

2GitHub-related tools for ME2AI MCP servers. 

3 

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 

13 

14from ..base import BaseTool 

15from ..auth import AuthManager, TokenAuth 

16 

17# Configure logging 

18logger = logging.getLogger("me2ai-mcp-tools-github") 

19 

20 

21@dataclass 

22class GitHubRepositoryTool(BaseTool): 

23 """Tool for GitHub repository operations.""" 

24 

25 name: str = "github_repository" 

26 description: str = "GitHub repository search and metadata" 

27 api_base_url: str = "https://api.github.com" 

28 

29 def __post_init__(self): 

30 """Initialize with GitHub authentication if available.""" 

31 super().__post_init__() 

32 self.auth = AuthManager.from_github_token() 

33 

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 } 

40 

41 if self.auth.has_token(): 

42 token = self.auth.get_token().token 

43 headers["Authorization"] = f"token {token}" 

44 

45 return headers 

46 

47 async def execute(self, params: Dict[str, Any]) -> Dict[str, Any]: 

48 """Execute GitHub repository operations. 

49  

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 

60  

61 Returns: 

62 Dictionary containing operation results 

63 """ 

64 operation = params.get("operation") 

65 

66 if not operation: 

67 return { 

68 "success": False, 

69 "error": "operation parameter is required" 

70 } 

71 

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 } 

83 

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 } 

92 

93 sort = params.get("sort", "stars") 

94 order = params.get("order", "desc") 

95 limit = params.get("limit", 10) 

96 

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 } 

104 

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() 

108 

109 data = response.json() 

110 repositories = data.get("items", []) 

111 

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 }) 

131 

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 } 

153 

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 } 

162 

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() 

167 

168 repo = response.json() 

169 

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 {} 

174 

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 [] 

180 

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 } 

211 

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 } 

230 

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 } 

239 

240 path = params.get("path", "") 

241 ref = params.get("ref") 

242 

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 

248 

249 response = requests.get(url, headers=self._get_headers(), params=api_params) 

250 response.raise_for_status() 

251 

252 contents = response.json() 

253 formatted_contents = [] 

254 

255 # Handle single file response 

256 if not isinstance(contents, list): 

257 contents = [contents] 

258 

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 }) 

269 

270 # Sort directories first, then files 

271 formatted_contents.sort(key=lambda x: (0 if x["type"] == "dir" else 1, x["name"])) 

272 

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 } 

296 

297 

298@dataclass 

299class GitHubCodeTool(BaseTool): 

300 """Tool for GitHub code operations.""" 

301 

302 name: str = "github_code" 

303 description: str = "GitHub code search and file operations" 

304 api_base_url: str = "https://api.github.com" 

305 

306 def __post_init__(self): 

307 """Initialize with GitHub authentication if available.""" 

308 super().__post_init__() 

309 self.auth = AuthManager.from_github_token() 

310 

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 } 

317 

318 if self.auth.has_token(): 

319 token = self.auth.get_token().token 

320 headers["Authorization"] = f"token {token}" 

321 

322 return headers 

323 

324 async def execute(self, params: Dict[str, Any]) -> Dict[str, Any]: 

325 """Execute GitHub code operations. 

326  

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 

337  

338 Returns: 

339 Dictionary containing operation results 

340 """ 

341 operation = params.get("operation") 

342 

343 if not operation: 

344 return { 

345 "success": False, 

346 "error": "operation parameter is required" 

347 } 

348 

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 } 

358 

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 } 

367 

368 language = params.get("language") 

369 repo = params.get("repo") 

370 limit = params.get("limit", 10) 

371 

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}" 

379 

380 api_params = { 

381 "q": search_query, 

382 "per_page": min(limit, 100) # GitHub API limits to 100 per page 

383 } 

384 

385 url = f"{self.api_base_url}/search/code" 

386 headers = self._get_headers() 

387 

388 # Code search requires specific accept header 

389 headers["Accept"] = "application/vnd.github.v3.text-match+json" 

390 

391 response = requests.get(url, headers=headers, params=api_params) 

392 response.raise_for_status() 

393 

394 data = response.json() 

395 items = data.get("items", []) 

396 

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 }) 

407 

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 }) 

420 

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 } 

444 

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") 

449 

450 if not repo_name: 

451 return { 

452 "success": False, 

453 "error": "repo_name parameter is required for get_file operation" 

454 } 

455 

456 if not file_path: 

457 return { 

458 "success": False, 

459 "error": "file_path parameter is required for get_file operation" 

460 } 

461 

462 ref = params.get("ref") 

463 

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 

469 

470 response = requests.get(url, headers=self._get_headers(), params=api_params) 

471 response.raise_for_status() 

472 

473 file_data = response.json() 

474 

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 } 

482 

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") 

488 

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 } 

527 

528 

529@dataclass 

530class GitHubIssuesTool(BaseTool): 

531 """Tool for GitHub issues operations.""" 

532 

533 name: str = "github_issues" 

534 description: str = "GitHub issues management" 

535 api_base_url: str = "https://api.github.com" 

536 

537 def __post_init__(self): 

538 """Initialize with GitHub authentication if available.""" 

539 super().__post_init__() 

540 self.auth = AuthManager.from_github_token() 

541 

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 } 

548 

549 if self.auth.has_token(): 

550 token = self.auth.get_token().token 

551 headers["Authorization"] = f"token {token}" 

552 

553 return headers 

554 

555 async def execute(self, params: Dict[str, Any]) -> Dict[str, Any]: 

556 """Execute GitHub issues operations. 

557  

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 

567  

568 Returns: 

569 Dictionary containing operation results 

570 """ 

571 operation = params.get("operation") 

572 

573 if not operation: 

574 return { 

575 "success": False, 

576 "error": "operation parameter is required" 

577 } 

578 

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 } 

588 

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 } 

597 

598 state = params.get("state", "open") 

599 sort = params.get("sort", "created") 

600 direction = params.get("direction", "desc") 

601 limit = params.get("limit", 10) 

602 

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 } 

610 

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 } 

618 

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 } 

626 

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 } 

635 

636 response = requests.get(url, headers=self._get_headers(), params=api_params) 

637 response.raise_for_status() 

638 

639 issues = response.json() 

640 

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 

647 

648 labels = [label.get("name") for label in issue.get("labels", [])] 

649 

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 }) 

666 

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 } 

688 

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") 

693 

694 if not repo_name: 

695 return { 

696 "success": False, 

697 "error": "repo_name parameter is required for get_details operation" 

698 } 

699 

700 if not issue_number: 

701 return { 

702 "success": False, 

703 "error": "issue_number parameter is required for get_details operation" 

704 } 

705 

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() 

710 

711 issue = response.json() 

712 

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 } 

722 

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 = [] 

727 

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 }) 

740 

741 # Format detailed issue information 

742 labels = [label.get("name") for label in issue.get("labels", [])] 

743 

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 } 

769 

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 }