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.
- kestrel_feature_github/SKILL.md +101 -0
- kestrel_feature_github/__init__.py +4 -0
- kestrel_feature_github/ast_analyzer.py +261 -0
- kestrel_feature_github/cache.py +288 -0
- kestrel_feature_github/client.py +541 -0
- kestrel_feature_github/feature.py +734 -0
- kestrel_feature_github/models.py +107 -0
- kestrel_feature_github-0.1.0.dist-info/METADATA +51 -0
- kestrel_feature_github-0.1.0.dist-info/RECORD +12 -0
- kestrel_feature_github-0.1.0.dist-info/WHEEL +4 -0
- kestrel_feature_github-0.1.0.dist-info/entry_points.txt +2 -0
- kestrel_feature_github-0.1.0.dist-info/licenses/LICENSE +106 -0
|
@@ -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)
|