kestrel-feature-github 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,734 @@
1
+ """GitHub Feature - Repository access and code introspection."""
2
+ import logging
3
+ import os
4
+ from typing import Any, Optional
5
+
6
+ import yaml
7
+
8
+ from kestrel_sdk.features.base import Feature, tool
9
+ from kestrel_sdk.tools.base import ToolCategory
10
+
11
+ from .ast_analyzer import ASTAnalyzer
12
+ from .cache import GitHubCache
13
+ from .client import GitHubClient, GitHubClientError
14
+ from .models import ComponentManifest, FileType
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ # Configuration
20
+ GITHUB_SELF_REPO = os.getenv("GITHUB_SELF_REPO", "KestrelSovereignAI/kestrel-sovereign")
21
+ GITHUB_DEFAULT_BRANCH = os.getenv("GITHUB_DEFAULT_BRANCH", "main")
22
+ GITHUB_SELF_FEATURES_ROOT = os.getenv("GITHUB_SELF_FEATURES_ROOT", "kestrel_sovereign/features")
23
+
24
+
25
+ class GitHubFeature(Feature):
26
+ """Feature for accessing GitHub repositories and code introspection.
27
+
28
+ Supports:
29
+ - Reading files from any GitHub repository
30
+ - Listing directory contents
31
+ - Searching code
32
+ - AST-based code analysis for Python files
33
+ - Component manifest discovery for self-introspection
34
+ - "self" alias for the agent's own codebase
35
+ """
36
+
37
+ tool_name = "github"
38
+ tool_description = "Access GitHub repositories, read source code, and analyze the agent's own codebase"
39
+
40
+ def __init__(self, agent=None):
41
+ """Initialize GitHub feature."""
42
+ super().__init__(agent)
43
+ self._client: Optional[GitHubClient] = None
44
+ self._cache: Optional[GitHubCache] = None
45
+
46
+ async def initialize(self):
47
+ """Initialize the feature."""
48
+ # Client and cache are lazily initialized
49
+ pass
50
+
51
+ @property
52
+ def is_available(self) -> bool:
53
+ """Check if the GitHub feature is available (has token configured)."""
54
+ return self.client._configured
55
+
56
+ @property
57
+ def client(self) -> GitHubClient:
58
+ """Get or create GitHub client."""
59
+ if self._client is None:
60
+ self._client = GitHubClient()
61
+ return self._client
62
+
63
+ @property
64
+ def cache(self) -> GitHubCache:
65
+ """Get or create cache."""
66
+ if self._cache is None:
67
+ self._cache = GitHubCache()
68
+ return self._cache
69
+
70
+ def _resolve_repo(self, repo: str) -> str:
71
+ """Resolve 'self' alias to actual repo."""
72
+ if repo.lower() == "self":
73
+ return GITHUB_SELF_REPO
74
+ return repo
75
+
76
+ async def cleanup(self):
77
+ """Clean up resources."""
78
+ if self._client:
79
+ await self._client.close()
80
+
81
+ # ============== Tools ==============
82
+
83
+ @tool(
84
+ name="read_github_file",
85
+ description="Read a file from a GitHub repository. Use 'self' as repo to read from the agent's own codebase.",
86
+ category=ToolCategory.DATA_ACCESS,
87
+ )
88
+ async def read_github_file(
89
+ self,
90
+ repo: str,
91
+ path: str,
92
+ ref: str = "main",
93
+ ) -> str:
94
+ """Read a file from GitHub.
95
+
96
+ Args:
97
+ repo: Repository in 'owner/repo' format, or 'self' for agent's codebase
98
+ path: Path to file within the repository
99
+ ref: Branch, tag, or commit SHA (default: main)
100
+
101
+ Returns:
102
+ File content with path header
103
+ """
104
+ repo = self._resolve_repo(repo)
105
+ if ref == "main":
106
+ ref = GITHUB_DEFAULT_BRANCH
107
+
108
+ # Check cache first
109
+ cached = await self.cache.get(repo, path, ref)
110
+ if cached:
111
+ return f"# {path} (cached)\n\n{cached.content}"
112
+
113
+ # Fetch from GitHub
114
+ try:
115
+ content = await self.client.get_file_content(repo, path, ref)
116
+ # Cache it
117
+ await self.cache.set(content)
118
+ return f"# {path}\n\n{content.content}"
119
+ except GitHubClientError as e:
120
+ return f"Error reading {path}: {e}"
121
+
122
+ @tool(
123
+ name="list_github_files",
124
+ description="List files in a GitHub repository directory. Use 'self' as repo for agent's codebase.",
125
+ category=ToolCategory.DATA_ACCESS,
126
+ )
127
+ async def list_github_files(
128
+ self,
129
+ repo: str,
130
+ path: str = "",
131
+ ref: str = "main",
132
+ recursive: bool = False,
133
+ ) -> str:
134
+ """List files in a directory.
135
+
136
+ Args:
137
+ repo: Repository in 'owner/repo' format, or 'self'
138
+ path: Directory path (empty for root)
139
+ ref: Branch, tag, or commit SHA
140
+ recursive: If true, list all files recursively
141
+
142
+ Returns:
143
+ Formatted file listing
144
+ """
145
+ repo = self._resolve_repo(repo)
146
+ if ref == "main":
147
+ ref = GITHUB_DEFAULT_BRANCH
148
+
149
+ try:
150
+ if recursive:
151
+ files = await self.client.get_tree(repo, ref, recursive=True)
152
+ # Filter by path prefix if specified
153
+ if path:
154
+ files = [f for f in files if f.path.startswith(path)]
155
+ else:
156
+ files = await self.client.list_directory(repo, path, ref)
157
+
158
+ # Format output
159
+ lines = [f"# Files in {repo}:{path or '/'}\n"]
160
+
161
+ for f in sorted(files, key=lambda x: (not x.is_dir(), x.path)):
162
+ if f.is_dir():
163
+ lines.append(f"\U0001f4c1 {f.path}/")
164
+ else:
165
+ size = f"{f.size:,}" if f.size else "?"
166
+ lines.append(f"\U0001f4c4 {f.path} ({size} bytes)")
167
+
168
+ return "\n".join(lines)
169
+ except GitHubClientError as e:
170
+ return f"Error listing {path}: {e}"
171
+
172
+ @tool(
173
+ name="search_github_code",
174
+ description="Search for code in GitHub repositories. Use 'self' to search agent's codebase.",
175
+ category=ToolCategory.DATA_ACCESS,
176
+ )
177
+ async def search_github_code(
178
+ self,
179
+ query: str,
180
+ repo: Optional[str] = None,
181
+ path: Optional[str] = None,
182
+ extension: Optional[str] = None,
183
+ max_results: int = 20,
184
+ ) -> str:
185
+ """Search for code in GitHub.
186
+
187
+ Args:
188
+ query: Search query
189
+ repo: Limit to specific repo (optional, use 'self' for agent)
190
+ path: Limit to path prefix (optional)
191
+ extension: Limit to file extension (e.g., 'py')
192
+ max_results: Maximum results (default 20)
193
+
194
+ Returns:
195
+ Formatted search results
196
+ """
197
+ if repo:
198
+ repo = self._resolve_repo(repo)
199
+
200
+ try:
201
+ results = await self.client.search_code(
202
+ query, repo=repo, path=path, extension=extension, max_results=max_results
203
+ )
204
+
205
+ if not results:
206
+ return f"No results found for: {query}"
207
+
208
+ lines = [f"# Search results for: {query}\n"]
209
+
210
+ for r in results:
211
+ lines.append(f"\n## {r.repo}: {r.path}")
212
+ lines.append(f"[View on GitHub]({r.html_url})")
213
+
214
+ # Include text matches if available
215
+ for match in r.text_matches[:2]:
216
+ fragment = match.get("fragment", "")
217
+ if fragment:
218
+ lines.append(f"\n```\n{fragment}\n```")
219
+
220
+ return "\n".join(lines)
221
+ except GitHubClientError as e:
222
+ return f"Search error: {e}"
223
+
224
+ @tool(
225
+ name="get_code_definition",
226
+ description="Get a function or class definition from a Python file. Uses AST for accurate extraction.",
227
+ category=ToolCategory.DATA_ACCESS,
228
+ )
229
+ async def get_code_definition(
230
+ self,
231
+ repo: str,
232
+ path: str,
233
+ name: str,
234
+ ref: str = "main",
235
+ ) -> str:
236
+ """Get a specific function or class definition.
237
+
238
+ Args:
239
+ repo: Repository or 'self'
240
+ path: Path to Python file
241
+ name: Function or class name
242
+ ref: Branch, tag, or commit SHA
243
+
244
+ Returns:
245
+ Definition with signature, docstring, and source
246
+ """
247
+ repo = self._resolve_repo(repo)
248
+ if ref == "main":
249
+ ref = GITHUB_DEFAULT_BRANCH
250
+
251
+ if not path.endswith(".py"):
252
+ return "Error: AST analysis only supports Python files (.py)"
253
+
254
+ # Get file content
255
+ try:
256
+ cached = await self.cache.get(repo, path, ref)
257
+ if cached:
258
+ content = cached.content
259
+ else:
260
+ file_content = await self.client.get_file_content(repo, path, ref)
261
+ await self.cache.set(file_content)
262
+ content = file_content.content
263
+ except GitHubClientError as e:
264
+ return f"Error reading {path}: {e}"
265
+
266
+ # Parse and find definition
267
+ analyzer = ASTAnalyzer(content, path)
268
+ defn = analyzer.get_definition(name)
269
+
270
+ if not defn:
271
+ # List available definitions
272
+ all_defs = analyzer.get_definitions()
273
+ available = [d.name for d in all_defs[:20]]
274
+ return f"Definition '{name}' not found in {path}.\n\nAvailable: {', '.join(available)}"
275
+
276
+ return f"""# {defn.type.title()}: {defn.name}
277
+
278
+ **File:** {path}
279
+ **Lines:** {defn.start_line}-{defn.end_line}
280
+ **Signature:** `{defn.signature}`
281
+
282
+ ## Docstring
283
+ {defn.docstring or "(no docstring)"}
284
+
285
+ ## Source
286
+ ```python
287
+ {defn.source}
288
+ ```"""
289
+
290
+ @tool(
291
+ name="list_code_definitions",
292
+ description="List all functions and classes in a Python file.",
293
+ category=ToolCategory.DATA_ACCESS,
294
+ )
295
+ async def list_code_definitions(
296
+ self,
297
+ repo: str,
298
+ path: str,
299
+ ref: str = "main",
300
+ ) -> str:
301
+ """List all definitions in a Python file.
302
+
303
+ Args:
304
+ repo: Repository or 'self'
305
+ path: Path to Python file
306
+ ref: Branch, tag, or commit SHA
307
+
308
+ Returns:
309
+ Organized list of all definitions
310
+ """
311
+ repo = self._resolve_repo(repo)
312
+ if ref == "main":
313
+ ref = GITHUB_DEFAULT_BRANCH
314
+
315
+ if not path.endswith(".py"):
316
+ return "Error: AST analysis only supports Python files (.py)"
317
+
318
+ # Get file content
319
+ try:
320
+ cached = await self.cache.get(repo, path, ref)
321
+ if cached:
322
+ content = cached.content
323
+ else:
324
+ file_content = await self.client.get_file_content(repo, path, ref)
325
+ await self.cache.set(file_content)
326
+ content = file_content.content
327
+ except GitHubClientError as e:
328
+ return f"Error reading {path}: {e}"
329
+
330
+ # Parse
331
+ analyzer = ASTAnalyzer(content, path)
332
+ definitions = analyzer.get_definitions()
333
+
334
+ if not definitions:
335
+ return f"No function or class definitions found in {path}"
336
+
337
+ lines = [f"# Definitions in {path}\n"]
338
+
339
+ # Group by type
340
+ classes = [d for d in definitions if d.type == "class"]
341
+ functions = [d for d in definitions if d.type == "function"]
342
+ methods = [d for d in definitions if d.type == "method"]
343
+
344
+ if classes:
345
+ lines.append("\n## Classes")
346
+ for d in classes:
347
+ lines.append(f"- `{d.signature}` (lines {d.start_line}-{d.end_line})")
348
+
349
+ if functions:
350
+ lines.append("\n## Functions")
351
+ for d in functions:
352
+ lines.append(f"- `{d.signature}` (lines {d.start_line}-{d.end_line})")
353
+
354
+ if methods:
355
+ lines.append(f"\n## Methods ({len(methods)} total)")
356
+ for d in methods[:30]: # Limit output
357
+ lines.append(f"- `{d.name}` (line {d.start_line})")
358
+ if len(methods) > 30:
359
+ lines.append(f" ... and {len(methods) - 30} more")
360
+
361
+ return "\n".join(lines)
362
+
363
+ @tool(
364
+ name="get_self_repo_info",
365
+ description="Get information about the agent's own source repository.",
366
+ category=ToolCategory.DATA_ACCESS,
367
+ )
368
+ async def get_self_repo_info(self) -> str:
369
+ """Get info about the agent's own repository.
370
+
371
+ Returns:
372
+ Repository metadata
373
+ """
374
+ repo = GITHUB_SELF_REPO
375
+
376
+ try:
377
+ info = await self.client.get_repo_info(repo)
378
+
379
+ return f"""# Agent Source Repository
380
+
381
+ **Repository:** {info.get('full_name')}
382
+ **Description:** {info.get('description', 'N/A')}
383
+ **Default Branch:** {info.get('default_branch', 'main')}
384
+ **Visibility:** {info.get('visibility', 'unknown')}
385
+ **Language:** {info.get('language', 'Python')}
386
+ **Size:** {info.get('size', 0):,} KB
387
+ **URL:** {info.get('html_url')}
388
+
389
+ ## Stats
390
+ - Stars: {info.get('stargazers_count', 0)}
391
+ - Forks: {info.get('forks_count', 0)}
392
+ - Open Issues: {info.get('open_issues_count', 0)}
393
+ - Last Updated: {info.get('updated_at', 'unknown')}
394
+
395
+ Use `list_source_components` to see the feature components that make up this agent."""
396
+ except GitHubClientError as e:
397
+ return f"Error getting repo info: {e}"
398
+
399
+ @tool(
400
+ name="list_source_components",
401
+ description="List all feature components in the agent's source code with their manifests.",
402
+ category=ToolCategory.DATA_ACCESS,
403
+ )
404
+ async def list_source_components(self, include_files: bool = False) -> str:
405
+ """List all feature components.
406
+
407
+ Args:
408
+ include_files: Include file listings for each component
409
+
410
+ Returns:
411
+ Formatted component list with manifests
412
+ """
413
+ repo = GITHUB_SELF_REPO
414
+ ref = GITHUB_DEFAULT_BRANCH
415
+
416
+ # Get features directory listing
417
+ try:
418
+ files = await self.client.list_directory(repo, GITHUB_SELF_FEATURES_ROOT, ref)
419
+ except GitHubClientError as e:
420
+ return f"Could not access features directory: {e}"
421
+
422
+ components = []
423
+
424
+ for f in files:
425
+ if f.is_dir() and not f.name.startswith("_"):
426
+ # Try to get component.yaml
427
+ manifest = None
428
+ try:
429
+ manifest_content = await self.client.get_file_content(
430
+ repo, f"{GITHUB_SELF_FEATURES_ROOT}/{f.name}/component.yaml", ref
431
+ )
432
+ manifest_data = yaml.safe_load(manifest_content.content)
433
+ manifest = ComponentManifest.from_dict(manifest_data, f.name)
434
+ except GitHubClientError:
435
+ # No manifest, create basic info
436
+ manifest = ComponentManifest(
437
+ feature_name=f.name,
438
+ description="(no component.yaml)",
439
+ )
440
+
441
+ component_info = {
442
+ "name": f.name,
443
+ "manifest": manifest,
444
+ }
445
+
446
+ if include_files:
447
+ # List files in component directory
448
+ try:
449
+ comp_files = await self.client.get_tree(repo, ref)
450
+ comp_files = [
451
+ cf for cf in comp_files
452
+ if cf.path.startswith(f"{GITHUB_SELF_FEATURES_ROOT}/{f.name}/") and cf.is_file()
453
+ ]
454
+ component_info["files"] = [cf.path for cf in comp_files]
455
+ except GitHubClientError:
456
+ component_info["files"] = []
457
+
458
+ components.append(component_info)
459
+
460
+ # Format output
461
+ lines = ["# Agent Source Components\n"]
462
+
463
+ for comp in components:
464
+ m = comp["manifest"]
465
+ lines.append(f"\n## {m.feature_name}")
466
+ lines.append(f"**Description:** {m.description}")
467
+ lines.append(f"**Version:** {m.version}")
468
+ lines.append(f"**Entry Point:** {GITHUB_SELF_FEATURES_ROOT}/{m.feature_name}/{m.entry_point}")
469
+
470
+ if m.tools:
471
+ lines.append(f"**Tools:** {', '.join(m.tools)}")
472
+
473
+ if include_files and comp.get("files"):
474
+ lines.append("\n**Files:**")
475
+ for path in comp["files"][:20]:
476
+ lines.append(f" - {path}")
477
+ if len(comp["files"]) > 20:
478
+ lines.append(f" ... and {len(comp['files']) - 20} more")
479
+
480
+ return "\n".join(lines)
481
+
482
+ @tool(
483
+ name="get_component_source",
484
+ description="Get all source files for a specific feature component.",
485
+ category=ToolCategory.DATA_ACCESS,
486
+ )
487
+ async def get_component_source(
488
+ self,
489
+ component: str,
490
+ include_content: bool = False,
491
+ ) -> str:
492
+ """Get source files for a component.
493
+
494
+ Args:
495
+ component: Component name (e.g., 'compute', 'security', 'github')
496
+ include_content: Include file contents (warning: may be large)
497
+
498
+ Returns:
499
+ Component files and optionally contents
500
+ """
501
+ repo = GITHUB_SELF_REPO
502
+ ref = GITHUB_DEFAULT_BRANCH
503
+
504
+ component_path = f"{GITHUB_SELF_FEATURES_ROOT}/{component}"
505
+
506
+ # Get all files in component
507
+ try:
508
+ all_files = await self.client.get_tree(repo, ref)
509
+ comp_files = [
510
+ f for f in all_files
511
+ if f.path.startswith(component_path + "/") and f.is_file()
512
+ ]
513
+ except GitHubClientError as e:
514
+ return f"Could not access component '{component}': {e}"
515
+
516
+ if not comp_files:
517
+ return f"Component '{component}' not found or has no files"
518
+
519
+ lines = [f"# Component: {component}\n"]
520
+ lines.append(f"**Path:** {component_path}")
521
+ lines.append(f"**Files:** {len(comp_files)}")
522
+
523
+ # Try to get manifest
524
+ try:
525
+ manifest_content = await self.client.get_file_content(
526
+ repo, f"{component_path}/component.yaml", ref
527
+ )
528
+ lines.append("\n## Manifest (component.yaml)")
529
+ lines.append(f"```yaml\n{manifest_content.content}\n```")
530
+ except GitHubClientError:
531
+ lines.append("\n*No component.yaml manifest*")
532
+
533
+ lines.append("\n## Files")
534
+
535
+ for f in sorted(comp_files, key=lambda x: x.path):
536
+ rel_path = f.path[len(component_path) + 1:]
537
+ lines.append(f"\n### {rel_path}")
538
+
539
+ if include_content and f.path.endswith(".py"):
540
+ try:
541
+ content = await self.client.get_file_content(repo, f.path, ref)
542
+ await self.cache.set(content)
543
+ lines.append(f"```python\n{content.content}\n```")
544
+ except GitHubClientError as e:
545
+ lines.append(f"*Could not read: {e}*")
546
+ else:
547
+ lines.append(f"*Size: {f.size:,} bytes*")
548
+
549
+ return "\n".join(lines)
550
+
551
+ @tool(
552
+ name="invalidate_github_cache",
553
+ description="Invalidate cached GitHub content to force fresh fetch.",
554
+ category=ToolCategory.DATA_ACCESS,
555
+ )
556
+ async def invalidate_github_cache(
557
+ self,
558
+ repo: str,
559
+ path: Optional[str] = None,
560
+ ) -> str:
561
+ """Invalidate cache entries.
562
+
563
+ Args:
564
+ repo: Repository to invalidate (or 'self')
565
+ path: Specific path to invalidate (optional)
566
+
567
+ Returns:
568
+ Confirmation message
569
+ """
570
+ repo = self._resolve_repo(repo)
571
+
572
+ await self.cache.invalidate(repo, path=path)
573
+
574
+ if path:
575
+ return f"Invalidated cache for {repo}:{path}"
576
+ return f"Invalidated all cache for {repo}"
577
+
578
+ # --- Issue tools ---
579
+
580
+ @tool(
581
+ name="list_github_issues",
582
+ description="List issues in a GitHub repository. Filters out pull requests.",
583
+ category=ToolCategory.DATA_ACCESS,
584
+ )
585
+ async def list_github_issues(
586
+ self,
587
+ repo: str = "self",
588
+ state: str = "open",
589
+ labels: Optional[str] = None,
590
+ max_results: int = 30,
591
+ ) -> str:
592
+ """List issues in a repository.
593
+
594
+ Args:
595
+ repo: Repository in 'owner/repo' format, or 'self' for agent's own repo
596
+ state: Issue state filter ('open', 'closed', 'all')
597
+ labels: Comma-separated label names to filter by
598
+ max_results: Maximum number of issues to return (max 100)
599
+
600
+ Returns:
601
+ Formatted issue list
602
+ """
603
+ repo = self._resolve_repo(repo)
604
+
605
+ try:
606
+ label_list = [l.strip() for l in labels.split(",")] if labels else None
607
+ issues = await self.client.list_issues(
608
+ repo, state=state, labels=label_list, per_page=max_results,
609
+ )
610
+ except GitHubClientError as e:
611
+ return f"Could not list issues: {e}"
612
+
613
+ if not issues:
614
+ return f"No {state} issues found in {repo}"
615
+
616
+ lines = [f"# Issues in {repo} ({state})\n"]
617
+ for issue in issues:
618
+ number = issue.get("number")
619
+ title = issue.get("title", "")
620
+ issue_labels = [l["name"] for l in issue.get("labels", [])]
621
+ assignees = [a["login"] for a in issue.get("assignees", [])]
622
+ updated = issue.get("updated_at", "")[:10]
623
+
624
+ line = f"- **#{number}** {title}"
625
+ if issue_labels:
626
+ line += f" [{', '.join(issue_labels)}]"
627
+ if assignees:
628
+ line += f" @{', @'.join(assignees)}"
629
+ line += f" (updated {updated})"
630
+ lines.append(line)
631
+
632
+ lines.append(f"\n*{len(issues)} issue(s) shown*")
633
+ return "\n".join(lines)
634
+
635
+ @tool(
636
+ name="get_github_issue",
637
+ description="Get details of a specific GitHub issue by number.",
638
+ category=ToolCategory.DATA_ACCESS,
639
+ )
640
+ async def get_github_issue(
641
+ self,
642
+ issue_number: int,
643
+ repo: str = "self",
644
+ ) -> str:
645
+ """Get a specific issue.
646
+
647
+ Args:
648
+ issue_number: Issue number
649
+ repo: Repository in 'owner/repo' format, or 'self' for agent's own repo
650
+
651
+ Returns:
652
+ Formatted issue details
653
+ """
654
+ repo = self._resolve_repo(repo)
655
+
656
+ try:
657
+ issue = await self.client.get_issue(repo, issue_number)
658
+ except GitHubClientError as e:
659
+ return f"Could not get issue #{issue_number}: {e}"
660
+
661
+ title = issue.get("title", "")
662
+ state = issue.get("state", "")
663
+ body = issue.get("body", "") or "(no description)"
664
+ author = issue.get("user", {}).get("login", "unknown")
665
+ created = issue.get("created_at", "")[:10]
666
+ updated = issue.get("updated_at", "")[:10]
667
+ issue_labels = [l["name"] for l in issue.get("labels", [])]
668
+ assignees = [a["login"] for a in issue.get("assignees", [])]
669
+ milestone = issue.get("milestone", {})
670
+ milestone_name = milestone.get("title") if milestone else None
671
+ comments_count = issue.get("comments", 0)
672
+
673
+ lines = [
674
+ f"# #{issue_number}: {title}\n",
675
+ f"**State:** {state}",
676
+ f"**Author:** @{author}",
677
+ f"**Created:** {created} | **Updated:** {updated}",
678
+ ]
679
+ if issue_labels:
680
+ lines.append(f"**Labels:** {', '.join(issue_labels)}")
681
+ if assignees:
682
+ lines.append(f"**Assignees:** {', '.join('@' + a for a in assignees)}")
683
+ if milestone_name:
684
+ lines.append(f"**Milestone:** {milestone_name}")
685
+ lines.append(f"**Comments:** {comments_count}")
686
+ lines.append(f"\n---\n\n{body}")
687
+
688
+ return "\n".join(lines)
689
+
690
+ @tool(
691
+ name="get_github_issue_comments",
692
+ description="Get comments on a specific GitHub issue.",
693
+ category=ToolCategory.DATA_ACCESS,
694
+ )
695
+ async def get_github_issue_comments(
696
+ self,
697
+ issue_number: int,
698
+ repo: str = "self",
699
+ max_results: int = 30,
700
+ ) -> str:
701
+ """Get comments on an issue.
702
+
703
+ Args:
704
+ issue_number: Issue number
705
+ repo: Repository in 'owner/repo' format, or 'self' for agent's own repo
706
+ max_results: Maximum number of comments to return
707
+
708
+ Returns:
709
+ Formatted comment list
710
+ """
711
+ repo = self._resolve_repo(repo)
712
+
713
+ try:
714
+ comments = await self.client.get_issue_comments(
715
+ repo, issue_number, per_page=max_results,
716
+ )
717
+ except GitHubClientError as e:
718
+ return f"Could not get comments for issue #{issue_number}: {e}"
719
+
720
+ if not comments:
721
+ return f"No comments on issue #{issue_number} in {repo}"
722
+
723
+ lines = [f"# Comments on #{issue_number} in {repo}\n"]
724
+ for comment in comments:
725
+ author = comment.get("user", {}).get("login", "unknown")
726
+ created = comment.get("created_at", "")[:10]
727
+ body = comment.get("body", "")
728
+
729
+ lines.append(f"## @{author} ({created})\n")
730
+ lines.append(body)
731
+ lines.append("")
732
+
733
+ lines.append(f"\n*{len(comments)} comment(s)*")
734
+ return "\n".join(lines)