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,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,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
|
+
}
|