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,101 @@
1
+ # GitHubFeature
2
+
3
+ > GitHub integration for issues, PRs, repository access, and code introspection.
4
+
5
+ ## Skills
6
+
7
+ ### read_github_file
8
+ - **Description**: Read a file from a GitHub repository
9
+ - **Category**: data_access
10
+ - **Parameters**:
11
+ - `repo` (string, required): Repository in 'owner/repo' format, or 'self'
12
+ - `path` (string, required): Path to file within the repository
13
+ - `ref` (string, optional): Branch, tag, or commit SHA (default: main)
14
+
15
+ ### list_github_files
16
+ - **Description**: List files in a GitHub repository directory
17
+ - **Category**: data_access
18
+ - **Parameters**:
19
+ - `repo` (string, required): Repository in 'owner/repo' format, or 'self'
20
+ - `path` (string, optional): Directory path (empty for root)
21
+ - `ref` (string, optional): Branch, tag, or commit SHA
22
+ - `recursive` (boolean, optional): If true, list all files recursively
23
+
24
+ ### search_github_code
25
+ - **Description**: Search for code in GitHub repositories
26
+ - **Category**: data_access
27
+ - **Parameters**:
28
+ - `query` (string, required): Search query
29
+ - `repo` (string, optional): Limit to specific repo
30
+ - `path` (string, optional): Limit to path prefix
31
+ - `extension` (string, optional): Limit to file extension
32
+ - `max_results` (integer, optional): Maximum results (default 20)
33
+
34
+ ### get_code_definition
35
+ - **Description**: Get a function or class definition from a Python file using AST
36
+ - **Category**: data_access
37
+ - **Parameters**:
38
+ - `repo` (string, required): Repository or 'self'
39
+ - `path` (string, required): Path to Python file
40
+ - `name` (string, required): Function or class name
41
+ - `ref` (string, optional): Branch, tag, or commit SHA
42
+
43
+ ### list_code_definitions
44
+ - **Description**: List all functions and classes in a Python file
45
+ - **Category**: data_access
46
+ - **Parameters**:
47
+ - `repo` (string, required): Repository or 'self'
48
+ - `path` (string, required): Path to Python file
49
+ - `ref` (string, optional): Branch, tag, or commit SHA
50
+
51
+ ### get_self_repo_info
52
+ - **Description**: Get information about the agent's own source repository
53
+ - **Category**: data_access
54
+
55
+ ### list_source_components
56
+ - **Description**: List all feature components in the agent's source code
57
+ - **Category**: data_access
58
+ - **Parameters**:
59
+ - `include_files` (boolean, optional): Include file listings for each component
60
+
61
+ ### get_component_source
62
+ - **Description**: Get all source files for a specific feature component
63
+ - **Category**: data_access
64
+ - **Parameters**:
65
+ - `component` (string, required): Component name
66
+ - `include_content` (boolean, optional): Include file contents
67
+
68
+ ### invalidate_github_cache
69
+ - **Description**: Invalidate cached GitHub content to force fresh fetch
70
+ - **Category**: data_access
71
+ - **Parameters**:
72
+ - `repo` (string, required): Repository to invalidate (or 'self')
73
+ - `path` (string, optional): Specific path to invalidate
74
+
75
+ ### list_github_issues
76
+ - **Description**: List issues in a GitHub repository
77
+ - **Category**: data_access
78
+ - **Parameters**:
79
+ - `repo` (string, optional): Repository or 'self' (default: self)
80
+ - `state` (string, optional): Issue state filter (default: open)
81
+ - `labels` (string, optional): Comma-separated label names
82
+ - `max_results` (integer, optional): Maximum results (default 30)
83
+
84
+ ### get_github_issue
85
+ - **Description**: Get details of a specific GitHub issue
86
+ - **Category**: data_access
87
+ - **Parameters**:
88
+ - `issue_number` (integer, required): Issue number
89
+ - `repo` (string, optional): Repository or 'self' (default: self)
90
+
91
+ ### get_github_issue_comments
92
+ - **Description**: Get comments on a specific GitHub issue
93
+ - **Category**: data_access
94
+ - **Parameters**:
95
+ - `issue_number` (integer, required): Issue number
96
+ - `repo` (string, optional): Repository or 'self' (default: self)
97
+ - `max_results` (integer, optional): Maximum comments (default 30)
98
+
99
+ ## Dependencies
100
+
101
+ - Requires: kestrel-sovereign, httpx, pyyaml, aiosqlite
@@ -0,0 +1,4 @@
1
+ """Kestrel Feature: GitHub integration for issues, PRs, and repository management."""
2
+ from .feature import GitHubFeature
3
+
4
+ __all__ = ["GitHubFeature"]
@@ -0,0 +1,261 @@
1
+ """AST-based code analysis for Python files."""
2
+ import ast
3
+ import logging
4
+ from typing import Optional
5
+
6
+ from .models import CodeDefinition
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class ASTAnalyzer:
12
+ """Extract code definitions from Python source using AST."""
13
+
14
+ def __init__(self, source: str, path: str = ""):
15
+ """Initialize with source code.
16
+
17
+ Args:
18
+ source: Python source code
19
+ path: File path for error messages
20
+ """
21
+ self.source = source
22
+ self.path = path
23
+ self.lines = source.splitlines()
24
+ self._tree: Optional[ast.AST] = None
25
+
26
+ def parse(self) -> bool:
27
+ """Parse source into AST.
28
+
29
+ Returns:
30
+ True if parsing succeeded
31
+ """
32
+ try:
33
+ self._tree = ast.parse(self.source, filename=self.path)
34
+ return True
35
+ except SyntaxError as e:
36
+ logger.warning(f"Failed to parse {self.path}: {e}")
37
+ return False
38
+
39
+ def get_definitions(self) -> list[CodeDefinition]:
40
+ """Extract all function and class definitions.
41
+
42
+ Returns:
43
+ List of code definitions
44
+ """
45
+ if self._tree is None:
46
+ if not self.parse():
47
+ return []
48
+
49
+ definitions = []
50
+
51
+ for node in ast.walk(self._tree):
52
+ if isinstance(node, ast.FunctionDef):
53
+ definitions.append(self._extract_function(node))
54
+ elif isinstance(node, ast.AsyncFunctionDef):
55
+ definitions.append(self._extract_function(node, is_async=True))
56
+ elif isinstance(node, ast.ClassDef):
57
+ definitions.append(self._extract_class(node))
58
+
59
+ return definitions
60
+
61
+ def get_definition(self, name: str) -> Optional[CodeDefinition]:
62
+ """Get a specific definition by name.
63
+
64
+ Args:
65
+ name: Function or class name
66
+
67
+ Returns:
68
+ CodeDefinition if found
69
+ """
70
+ for defn in self.get_definitions():
71
+ if defn.name == name:
72
+ return defn
73
+ return None
74
+
75
+ def get_imports(self) -> list[dict]:
76
+ """Extract import statements.
77
+
78
+ Returns:
79
+ List of import info dicts
80
+ """
81
+ if self._tree is None:
82
+ if not self.parse():
83
+ return []
84
+
85
+ imports = []
86
+
87
+ for node in ast.walk(self._tree):
88
+ if isinstance(node, ast.Import):
89
+ for alias in node.names:
90
+ imports.append({
91
+ "type": "import",
92
+ "module": alias.name,
93
+ "alias": alias.asname,
94
+ "line": node.lineno,
95
+ })
96
+ elif isinstance(node, ast.ImportFrom):
97
+ module = node.module or ""
98
+ for alias in node.names:
99
+ imports.append({
100
+ "type": "from",
101
+ "module": module,
102
+ "name": alias.name,
103
+ "alias": alias.asname,
104
+ "line": node.lineno,
105
+ "level": node.level,
106
+ })
107
+
108
+ return imports
109
+
110
+ def _extract_function(
111
+ self,
112
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
113
+ is_async: bool = False,
114
+ ) -> CodeDefinition:
115
+ """Extract function definition."""
116
+ # Get signature
117
+ args = self._format_arguments(node.args)
118
+ returns = ""
119
+ if node.returns:
120
+ returns = f" -> {ast.unparse(node.returns)}"
121
+
122
+ prefix = "async def" if is_async else "def"
123
+ signature = f"{prefix} {node.name}({args}){returns}"
124
+
125
+ # Get docstring
126
+ docstring = ast.get_docstring(node) or ""
127
+
128
+ # Get source lines
129
+ end_line = self._find_end_line(node)
130
+ source = "\n".join(self.lines[node.lineno - 1:end_line])
131
+
132
+ # Determine type
133
+ def_type = "method" if self._is_method(node) else "function"
134
+
135
+ return CodeDefinition(
136
+ name=node.name,
137
+ type=def_type,
138
+ path=self.path,
139
+ start_line=node.lineno,
140
+ end_line=end_line,
141
+ signature=signature,
142
+ docstring=docstring,
143
+ source=source,
144
+ )
145
+
146
+ def _extract_class(self, node: ast.ClassDef) -> CodeDefinition:
147
+ """Extract class definition."""
148
+ # Get bases
149
+ bases = ", ".join(ast.unparse(base) for base in node.bases)
150
+ signature = f"class {node.name}({bases})" if bases else f"class {node.name}"
151
+
152
+ # Get docstring
153
+ docstring = ast.get_docstring(node) or ""
154
+
155
+ # Get source lines
156
+ end_line = self._find_end_line(node)
157
+ source = "\n".join(self.lines[node.lineno - 1:end_line])
158
+
159
+ return CodeDefinition(
160
+ name=node.name,
161
+ type="class",
162
+ path=self.path,
163
+ start_line=node.lineno,
164
+ end_line=end_line,
165
+ signature=signature,
166
+ docstring=docstring,
167
+ source=source,
168
+ )
169
+
170
+ def _format_arguments(self, args: ast.arguments) -> str:
171
+ """Format function arguments."""
172
+ parts = []
173
+
174
+ # Regular args
175
+ defaults_start = len(args.args) - len(args.defaults)
176
+ for i, arg in enumerate(args.args):
177
+ arg_str = arg.arg
178
+ if arg.annotation:
179
+ arg_str += f": {ast.unparse(arg.annotation)}"
180
+
181
+ default_idx = i - defaults_start
182
+ if default_idx >= 0:
183
+ arg_str += f" = {ast.unparse(args.defaults[default_idx])}"
184
+
185
+ parts.append(arg_str)
186
+
187
+ # *args
188
+ if args.vararg:
189
+ arg_str = f"*{args.vararg.arg}"
190
+ if args.vararg.annotation:
191
+ arg_str += f": {ast.unparse(args.vararg.annotation)}"
192
+ parts.append(arg_str)
193
+ elif args.kwonlyargs:
194
+ parts.append("*")
195
+
196
+ # Keyword-only args
197
+ for i, arg in enumerate(args.kwonlyargs):
198
+ arg_str = arg.arg
199
+ if arg.annotation:
200
+ arg_str += f": {ast.unparse(arg.annotation)}"
201
+ if args.kw_defaults[i]:
202
+ arg_str += f" = {ast.unparse(args.kw_defaults[i])}"
203
+ parts.append(arg_str)
204
+
205
+ # **kwargs
206
+ if args.kwarg:
207
+ arg_str = f"**{args.kwarg.arg}"
208
+ if args.kwarg.annotation:
209
+ arg_str += f": {ast.unparse(args.kwarg.annotation)}"
210
+ parts.append(arg_str)
211
+
212
+ return ", ".join(parts)
213
+
214
+ def _find_end_line(self, node: ast.AST) -> int:
215
+ """Find the last line of a node."""
216
+ end_line = node.lineno
217
+
218
+ for child in ast.walk(node):
219
+ if hasattr(child, "lineno"):
220
+ end_line = max(end_line, child.lineno)
221
+ if hasattr(child, "end_lineno") and child.end_lineno:
222
+ end_line = max(end_line, child.end_lineno)
223
+
224
+ return end_line
225
+
226
+ def _is_method(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
227
+ """Check if function is a method (inside a class)."""
228
+ # Check if first argument is self or cls
229
+ if node.args.args:
230
+ first_arg = node.args.args[0].arg
231
+ return first_arg in ("self", "cls")
232
+ return False
233
+
234
+
235
+ def extract_definition(source: str, name: str, path: str = "") -> Optional[CodeDefinition]:
236
+ """Convenience function to extract a single definition.
237
+
238
+ Args:
239
+ source: Python source code
240
+ name: Name to find
241
+ path: File path for context
242
+
243
+ Returns:
244
+ CodeDefinition if found
245
+ """
246
+ analyzer = ASTAnalyzer(source, path)
247
+ return analyzer.get_definition(name)
248
+
249
+
250
+ def list_definitions(source: str, path: str = "") -> list[CodeDefinition]:
251
+ """Convenience function to list all definitions.
252
+
253
+ Args:
254
+ source: Python source code
255
+ path: File path for context
256
+
257
+ Returns:
258
+ List of all definitions
259
+ """
260
+ analyzer = ASTAnalyzer(source, path)
261
+ return analyzer.get_definitions()
@@ -0,0 +1,288 @@
1
+ """SQLite cache for GitHub content."""
2
+ import json
3
+ import logging
4
+ import os
5
+ import aiosqlite
6
+ from datetime import datetime, timedelta, timezone
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from .models import FileContent
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class GitHubCache:
16
+ """Async SQLite-backed cache for GitHub file content."""
17
+
18
+ DEFAULT_TTL = timedelta(hours=1)
19
+
20
+ def __init__(self, db_path: Optional[str] = None):
21
+ """Initialize cache.
22
+
23
+ Args:
24
+ db_path: Path to SQLite database. Defaults to KESTREL_DB_PATH/github_cache.db
25
+ """
26
+ if db_path is None:
27
+ base_path = os.getenv("KESTREL_DB_PATH", "./agent_data")
28
+ db_path = os.path.join(base_path, "github_cache.db")
29
+
30
+ self.db_path = db_path
31
+ Path(db_path).parent.mkdir(parents=True, exist_ok=True)
32
+ self._initialized = False
33
+
34
+ async def _ensure_initialized(self):
35
+ """Initialize database schema if not already done."""
36
+ if self._initialized:
37
+ return
38
+
39
+ async with aiosqlite.connect(self.db_path) as conn:
40
+ await conn.execute("""
41
+ CREATE TABLE IF NOT EXISTS file_cache (
42
+ cache_key TEXT PRIMARY KEY,
43
+ repo TEXT NOT NULL,
44
+ path TEXT NOT NULL,
45
+ ref TEXT NOT NULL,
46
+ content TEXT NOT NULL,
47
+ sha TEXT NOT NULL,
48
+ size INTEGER NOT NULL,
49
+ cached_at TEXT NOT NULL,
50
+ expires_at TEXT NOT NULL
51
+ )
52
+ """)
53
+ await conn.execute("""
54
+ CREATE INDEX IF NOT EXISTS idx_file_cache_repo
55
+ ON file_cache(repo, path)
56
+ """)
57
+ await conn.execute("""
58
+ CREATE TABLE IF NOT EXISTS tree_cache (
59
+ cache_key TEXT PRIMARY KEY,
60
+ repo TEXT NOT NULL,
61
+ ref TEXT NOT NULL,
62
+ tree_json TEXT NOT NULL,
63
+ cached_at TEXT NOT NULL,
64
+ expires_at TEXT NOT NULL
65
+ )
66
+ """)
67
+ await conn.commit()
68
+ self._initialized = True
69
+
70
+ def _make_key(self, repo: str, path: str, ref: str) -> str:
71
+ """Create cache key."""
72
+ return f"{repo}:{ref}:{path}"
73
+
74
+ async def get(self, repo: str, path: str, ref: str = "main") -> Optional[FileContent]:
75
+ """Get file from cache if not expired.
76
+
77
+ Args:
78
+ repo: Repository in 'owner/repo' format
79
+ path: Path to file
80
+ ref: Branch, tag, or SHA
81
+
82
+ Returns:
83
+ FileContent if cached and not expired, else None
84
+ """
85
+ await self._ensure_initialized()
86
+ key = self._make_key(repo, path, ref)
87
+ now = datetime.now(timezone.utc).isoformat()
88
+
89
+ async with aiosqlite.connect(self.db_path) as conn:
90
+ cursor = await conn.execute(
91
+ """
92
+ SELECT content, sha, size, cached_at
93
+ FROM file_cache
94
+ WHERE cache_key = ? AND expires_at > ?
95
+ """,
96
+ (key, now),
97
+ )
98
+ row = await cursor.fetchone()
99
+
100
+ if row:
101
+ return FileContent(
102
+ path=path,
103
+ content=row[0],
104
+ sha=row[1],
105
+ size=row[2],
106
+ repo=repo,
107
+ ref=ref,
108
+ cached_at=datetime.fromisoformat(row[3]),
109
+ )
110
+
111
+ return None
112
+
113
+ async def set(
114
+ self,
115
+ file_content: FileContent,
116
+ ttl: Optional[timedelta] = None,
117
+ ):
118
+ """Cache file content.
119
+
120
+ Args:
121
+ file_content: Content to cache
122
+ ttl: Time to live, defaults to 1 hour
123
+ """
124
+ await self._ensure_initialized()
125
+ if ttl is None:
126
+ ttl = self.DEFAULT_TTL
127
+
128
+ key = self._make_key(file_content.repo, file_content.path, file_content.ref)
129
+ now = datetime.now(timezone.utc)
130
+ expires = now + ttl
131
+
132
+ async with aiosqlite.connect(self.db_path) as conn:
133
+ await conn.execute(
134
+ """
135
+ INSERT OR REPLACE INTO file_cache
136
+ (cache_key, repo, path, ref, content, sha, size, cached_at, expires_at)
137
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
138
+ """,
139
+ (
140
+ key,
141
+ file_content.repo,
142
+ file_content.path,
143
+ file_content.ref,
144
+ file_content.content,
145
+ file_content.sha,
146
+ file_content.size,
147
+ now.isoformat(),
148
+ expires.isoformat(),
149
+ ),
150
+ )
151
+ await conn.commit()
152
+
153
+ async def get_tree(self, repo: str, ref: str = "main") -> Optional[list[dict]]:
154
+ """Get cached repository tree.
155
+
156
+ Args:
157
+ repo: Repository in 'owner/repo' format
158
+ ref: Branch, tag, or SHA
159
+
160
+ Returns:
161
+ List of tree entries if cached, else None
162
+ """
163
+ await self._ensure_initialized()
164
+ key = f"{repo}:{ref}:tree"
165
+ now = datetime.now(timezone.utc).isoformat()
166
+
167
+ async with aiosqlite.connect(self.db_path) as conn:
168
+ cursor = await conn.execute(
169
+ """
170
+ SELECT tree_json FROM tree_cache
171
+ WHERE cache_key = ? AND expires_at > ?
172
+ """,
173
+ (key, now),
174
+ )
175
+ row = await cursor.fetchone()
176
+
177
+ if row:
178
+ return json.loads(row[0])
179
+
180
+ return None
181
+
182
+ async def set_tree(
183
+ self,
184
+ repo: str,
185
+ ref: str,
186
+ tree: list[dict],
187
+ ttl: Optional[timedelta] = None,
188
+ ):
189
+ """Cache repository tree.
190
+
191
+ Args:
192
+ repo: Repository in 'owner/repo' format
193
+ ref: Branch, tag, or SHA
194
+ tree: Tree entries to cache
195
+ ttl: Time to live
196
+ """
197
+ await self._ensure_initialized()
198
+ if ttl is None:
199
+ ttl = self.DEFAULT_TTL
200
+
201
+ key = f"{repo}:{ref}:tree"
202
+ now = datetime.now(timezone.utc)
203
+ expires = now + ttl
204
+
205
+ async with aiosqlite.connect(self.db_path) as conn:
206
+ await conn.execute(
207
+ """
208
+ INSERT OR REPLACE INTO tree_cache
209
+ (cache_key, repo, ref, tree_json, cached_at, expires_at)
210
+ VALUES (?, ?, ?, ?, ?, ?)
211
+ """,
212
+ (key, repo, ref, json.dumps(tree), now.isoformat(), expires.isoformat()),
213
+ )
214
+ await conn.commit()
215
+
216
+ async def invalidate(self, repo: str, path: Optional[str] = None, ref: Optional[str] = None):
217
+ """Invalidate cache entries.
218
+
219
+ Args:
220
+ repo: Repository to invalidate
221
+ path: Specific path to invalidate (optional)
222
+ ref: Specific ref to invalidate (optional)
223
+ """
224
+ await self._ensure_initialized()
225
+ async with aiosqlite.connect(self.db_path) as conn:
226
+ if path and ref:
227
+ key = self._make_key(repo, path, ref)
228
+ await conn.execute("DELETE FROM file_cache WHERE cache_key = ?", (key,))
229
+ elif ref:
230
+ await conn.execute(
231
+ "DELETE FROM file_cache WHERE repo = ? AND ref = ?",
232
+ (repo, ref),
233
+ )
234
+ await conn.execute(
235
+ "DELETE FROM tree_cache WHERE repo = ? AND ref = ?",
236
+ (repo, ref),
237
+ )
238
+ else:
239
+ await conn.execute("DELETE FROM file_cache WHERE repo = ?", (repo,))
240
+ await conn.execute("DELETE FROM tree_cache WHERE repo = ?", (repo,))
241
+ await conn.commit()
242
+
243
+ async def clear_expired(self):
244
+ """Remove all expired cache entries."""
245
+ await self._ensure_initialized()
246
+ now = datetime.now(timezone.utc).isoformat()
247
+
248
+ async with aiosqlite.connect(self.db_path) as conn:
249
+ await conn.execute("DELETE FROM file_cache WHERE expires_at < ?", (now,))
250
+ await conn.execute("DELETE FROM tree_cache WHERE expires_at < ?", (now,))
251
+ await conn.commit()
252
+
253
+ async def stats(self) -> dict:
254
+ """Get cache statistics.
255
+
256
+ Returns:
257
+ Dict with cache stats
258
+ """
259
+ await self._ensure_initialized()
260
+ now = datetime.now(timezone.utc).isoformat()
261
+
262
+ async with aiosqlite.connect(self.db_path) as conn:
263
+ cursor = await conn.execute(
264
+ "SELECT COUNT(*) FROM file_cache WHERE expires_at > ?", (now,)
265
+ )
266
+ file_count = (await cursor.fetchone())[0]
267
+
268
+ cursor = await conn.execute(
269
+ "SELECT COUNT(*) FROM tree_cache WHERE expires_at > ?", (now,)
270
+ )
271
+ tree_count = (await cursor.fetchone())[0]
272
+
273
+ cursor = await conn.execute(
274
+ "SELECT COALESCE(SUM(size), 0) FROM file_cache WHERE expires_at > ?", (now,)
275
+ )
276
+ total_size = (await cursor.fetchone())[0]
277
+
278
+ cursor = await conn.execute(
279
+ "SELECT DISTINCT repo FROM file_cache WHERE expires_at > ?", (now,)
280
+ )
281
+ repos = await cursor.fetchall()
282
+
283
+ return {
284
+ "cached_files": file_count,
285
+ "cached_trees": tree_count,
286
+ "total_size_bytes": total_size,
287
+ "repos": [r[0] for r in repos],
288
+ }