gtg 0.4.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.
- goodtogo/__init__.py +66 -0
- goodtogo/adapters/__init__.py +22 -0
- goodtogo/adapters/agent_state.py +490 -0
- goodtogo/adapters/cache_memory.py +208 -0
- goodtogo/adapters/cache_sqlite.py +305 -0
- goodtogo/adapters/github.py +523 -0
- goodtogo/adapters/time_provider.py +123 -0
- goodtogo/cli.py +311 -0
- goodtogo/container.py +313 -0
- goodtogo/core/__init__.py +0 -0
- goodtogo/core/analyzer.py +982 -0
- goodtogo/core/errors.py +100 -0
- goodtogo/core/interfaces.py +388 -0
- goodtogo/core/models.py +312 -0
- goodtogo/core/validation.py +144 -0
- goodtogo/parsers/__init__.py +0 -0
- goodtogo/parsers/claude.py +188 -0
- goodtogo/parsers/coderabbit.py +352 -0
- goodtogo/parsers/cursor.py +135 -0
- goodtogo/parsers/generic.py +192 -0
- goodtogo/parsers/greptile.py +249 -0
- gtg-0.4.0.dist-info/METADATA +278 -0
- gtg-0.4.0.dist-info/RECORD +27 -0
- gtg-0.4.0.dist-info/WHEEL +5 -0
- gtg-0.4.0.dist-info/entry_points.txt +2 -0
- gtg-0.4.0.dist-info/licenses/LICENSE +21 -0
- gtg-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""In-memory cache adapter for testing.
|
|
2
|
+
|
|
3
|
+
This module provides a simple dict-based cache implementation that implements
|
|
4
|
+
the CachePort interface. It is primarily intended for testing and development
|
|
5
|
+
scenarios where persistence is not required.
|
|
6
|
+
|
|
7
|
+
The cache stores entries with expiration timestamps and tracks hit/miss
|
|
8
|
+
statistics for performance monitoring.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from fnmatch import fnmatch
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from goodtogo.adapters.time_provider import SystemTimeProvider
|
|
18
|
+
from goodtogo.core.interfaces import CachePort, TimeProvider
|
|
19
|
+
from goodtogo.core.models import CacheStats
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class CacheEntry:
|
|
24
|
+
"""A single cache entry with value and expiration.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
value: The cached string value.
|
|
28
|
+
expires_at: Unix timestamp when this entry expires.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
value: str
|
|
32
|
+
expires_at: float
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class InMemoryCacheAdapter(CachePort):
|
|
36
|
+
"""Simple dict-based cache implementation for testing.
|
|
37
|
+
|
|
38
|
+
This adapter provides an in-memory cache that:
|
|
39
|
+
- Stores entries with expiration timestamps
|
|
40
|
+
- Tracks cache hit/miss statistics
|
|
41
|
+
- Supports glob-style pattern matching for invalidation
|
|
42
|
+
- Automatically excludes expired entries from get operations
|
|
43
|
+
|
|
44
|
+
The cache is not thread-safe and is intended for single-threaded
|
|
45
|
+
test scenarios. For production use, consider SqliteCacheAdapter
|
|
46
|
+
or RedisCacheAdapter.
|
|
47
|
+
|
|
48
|
+
Example:
|
|
49
|
+
cache = InMemoryCacheAdapter()
|
|
50
|
+
cache.set("key", "value", ttl_seconds=300)
|
|
51
|
+
value = cache.get("key") # Returns "value"
|
|
52
|
+
cache.invalidate_pattern("key:*") # Invalidate matching keys
|
|
53
|
+
|
|
54
|
+
Attributes:
|
|
55
|
+
_store: Internal dictionary storing CacheEntry objects.
|
|
56
|
+
_hits: Counter for cache hits.
|
|
57
|
+
_misses: Counter for cache misses.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, time_provider: Optional[TimeProvider] = None) -> None:
|
|
61
|
+
"""Initialize an empty in-memory cache.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
time_provider: TimeProvider for getting current time.
|
|
65
|
+
Defaults to SystemTimeProvider if not provided.
|
|
66
|
+
"""
|
|
67
|
+
self._store: dict[str, CacheEntry] = {}
|
|
68
|
+
self._hits: int = 0
|
|
69
|
+
self._misses: int = 0
|
|
70
|
+
self._time_provider: TimeProvider = time_provider or SystemTimeProvider()
|
|
71
|
+
|
|
72
|
+
def get(self, key: str) -> Optional[str]:
|
|
73
|
+
"""Get cached value if it exists and has not expired.
|
|
74
|
+
|
|
75
|
+
Retrieves a value from the cache. If the entry exists but has
|
|
76
|
+
expired, it is treated as a cache miss and the entry is removed.
|
|
77
|
+
|
|
78
|
+
This method updates hit/miss statistics.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
key: Cache key to retrieve.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
The cached string value if found and not expired, None otherwise.
|
|
85
|
+
"""
|
|
86
|
+
entry = self._store.get(key)
|
|
87
|
+
|
|
88
|
+
if entry is None:
|
|
89
|
+
self._misses += 1
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
# Check if entry has expired
|
|
93
|
+
if entry.expires_at <= self._time_provider.now():
|
|
94
|
+
# Remove expired entry
|
|
95
|
+
del self._store[key]
|
|
96
|
+
self._misses += 1
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
self._hits += 1
|
|
100
|
+
return entry.value
|
|
101
|
+
|
|
102
|
+
def set(self, key: str, value: str, ttl_seconds: int) -> None:
|
|
103
|
+
"""Set cached value with TTL.
|
|
104
|
+
|
|
105
|
+
Stores a value in the cache with an expiration time. If an entry
|
|
106
|
+
with the same key already exists, it is overwritten.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
key: Cache key to store under.
|
|
110
|
+
value: String value to cache.
|
|
111
|
+
ttl_seconds: Time to live in seconds. The entry will be
|
|
112
|
+
considered expired after this duration.
|
|
113
|
+
"""
|
|
114
|
+
expires_at = self._time_provider.now() + ttl_seconds
|
|
115
|
+
self._store[key] = CacheEntry(value=value, expires_at=expires_at)
|
|
116
|
+
|
|
117
|
+
def delete(self, key: str) -> None:
|
|
118
|
+
"""Delete cached value.
|
|
119
|
+
|
|
120
|
+
Removes a specific entry from the cache. If the key does not
|
|
121
|
+
exist, this is a no-op.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
key: Cache key to delete.
|
|
125
|
+
"""
|
|
126
|
+
self._store.pop(key, None)
|
|
127
|
+
|
|
128
|
+
def invalidate_pattern(self, pattern: str) -> None:
|
|
129
|
+
"""Invalidate all keys matching pattern.
|
|
130
|
+
|
|
131
|
+
Removes all cache entries whose keys match the given glob-style
|
|
132
|
+
pattern. Uses fnmatch for pattern matching, which supports:
|
|
133
|
+
- * matches everything
|
|
134
|
+
- ? matches any single character
|
|
135
|
+
- [seq] matches any character in seq
|
|
136
|
+
- [!seq] matches any character not in seq
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
pattern: Glob-style pattern to match keys against.
|
|
140
|
+
Example: 'pr:myorg:myrepo:123:*' matches all keys
|
|
141
|
+
starting with 'pr:myorg:myrepo:123:'.
|
|
142
|
+
"""
|
|
143
|
+
# Collect keys to delete (avoid modifying dict during iteration)
|
|
144
|
+
keys_to_delete = [key for key in self._store if fnmatch(key, pattern)]
|
|
145
|
+
|
|
146
|
+
for key in keys_to_delete:
|
|
147
|
+
del self._store[key]
|
|
148
|
+
|
|
149
|
+
def cleanup_expired(self) -> None:
|
|
150
|
+
"""Remove all expired entries.
|
|
151
|
+
|
|
152
|
+
Scans the entire cache and removes any entries whose TTL has
|
|
153
|
+
expired. This should be called periodically to prevent unbounded
|
|
154
|
+
memory growth from accumulated expired entries.
|
|
155
|
+
"""
|
|
156
|
+
current_time = self._time_provider.now()
|
|
157
|
+
|
|
158
|
+
# Collect expired keys (avoid modifying dict during iteration)
|
|
159
|
+
expired_keys = [
|
|
160
|
+
key for key, entry in self._store.items() if entry.expires_at <= current_time
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
for key in expired_keys:
|
|
164
|
+
del self._store[key]
|
|
165
|
+
|
|
166
|
+
def get_stats(self) -> CacheStats:
|
|
167
|
+
"""Get cache hit/miss statistics.
|
|
168
|
+
|
|
169
|
+
Returns metrics about cache performance. The hit rate is calculated
|
|
170
|
+
as hits / (hits + misses). If no operations have been performed,
|
|
171
|
+
the hit rate is 0.0.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
CacheStats object containing hits, misses, and hit_rate.
|
|
175
|
+
"""
|
|
176
|
+
total = self._hits + self._misses
|
|
177
|
+
hit_rate = self._hits / total if total > 0 else 0.0
|
|
178
|
+
|
|
179
|
+
return CacheStats(hits=self._hits, misses=self._misses, hit_rate=hit_rate)
|
|
180
|
+
|
|
181
|
+
def clear(self) -> None:
|
|
182
|
+
"""Clear all entries and reset statistics.
|
|
183
|
+
|
|
184
|
+
Removes all cached entries and resets hit/miss counters to zero.
|
|
185
|
+
This is useful for test setup/teardown.
|
|
186
|
+
"""
|
|
187
|
+
self._store.clear()
|
|
188
|
+
self._hits = 0
|
|
189
|
+
self._misses = 0
|
|
190
|
+
|
|
191
|
+
def __len__(self) -> int:
|
|
192
|
+
"""Return the number of entries in the cache.
|
|
193
|
+
|
|
194
|
+
Note that this includes potentially expired entries that have
|
|
195
|
+
not yet been cleaned up.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
Number of entries currently in the cache.
|
|
199
|
+
"""
|
|
200
|
+
return len(self._store)
|
|
201
|
+
|
|
202
|
+
def __repr__(self) -> str:
|
|
203
|
+
"""Return a string representation of the cache.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
String showing cache type and entry count.
|
|
207
|
+
"""
|
|
208
|
+
return f"InMemoryCacheAdapter(entries={len(self._store)})"
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""SQLite cache adapter for GoodToMerge.
|
|
2
|
+
|
|
3
|
+
This module provides a SQLite-based implementation of the CachePort interface.
|
|
4
|
+
It offers zero-configuration local caching with automatic TTL expiration,
|
|
5
|
+
secure file permissions, and pattern-based invalidation.
|
|
6
|
+
|
|
7
|
+
Security features:
|
|
8
|
+
- Cache directory created with 0700 permissions (owner only)
|
|
9
|
+
- Cache file created with 0600 permissions (owner read/write only)
|
|
10
|
+
- Existing permissive permissions are fixed with a warning
|
|
11
|
+
- All inputs validated before use
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import sqlite3
|
|
17
|
+
import stat
|
|
18
|
+
import warnings
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import TYPE_CHECKING, Optional
|
|
21
|
+
|
|
22
|
+
from goodtogo.adapters.time_provider import SystemTimeProvider
|
|
23
|
+
from goodtogo.core.interfaces import CachePort, TimeProvider
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from goodtogo.core.models import CacheStats
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# SQL statements for schema creation and operations
|
|
30
|
+
_CREATE_TABLES_SQL = """
|
|
31
|
+
CREATE TABLE IF NOT EXISTS pr_cache (
|
|
32
|
+
key TEXT PRIMARY KEY,
|
|
33
|
+
value TEXT NOT NULL,
|
|
34
|
+
expires_at INTEGER NOT NULL,
|
|
35
|
+
created_at INTEGER NOT NULL
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
CREATE INDEX IF NOT EXISTS idx_expires_at ON pr_cache(expires_at);
|
|
39
|
+
|
|
40
|
+
CREATE TABLE IF NOT EXISTS cache_stats (
|
|
41
|
+
key_prefix TEXT PRIMARY KEY,
|
|
42
|
+
hits INTEGER DEFAULT 0,
|
|
43
|
+
misses INTEGER DEFAULT 0
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
-- Initialize global stats if not exists
|
|
47
|
+
INSERT OR IGNORE INTO cache_stats (key_prefix, hits, misses) VALUES ('global', 0, 0);
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SqliteCacheAdapter(CachePort):
|
|
52
|
+
"""SQLite-based cache adapter implementing the CachePort interface.
|
|
53
|
+
|
|
54
|
+
This adapter provides persistent local caching using SQLite with:
|
|
55
|
+
- Automatic TTL-based expiration
|
|
56
|
+
- Pattern-based key invalidation using SQL LIKE
|
|
57
|
+
- Hit/miss statistics tracking
|
|
58
|
+
- Secure file permissions
|
|
59
|
+
|
|
60
|
+
The cache database is created automatically on first use with
|
|
61
|
+
appropriate file permissions to protect cached data.
|
|
62
|
+
|
|
63
|
+
Example:
|
|
64
|
+
>>> cache = SqliteCacheAdapter(".goodtogo/cache.db")
|
|
65
|
+
>>> cache.set("pr:myorg:myrepo:123:meta", '{"title": "My PR"}', ttl_seconds=300)
|
|
66
|
+
>>> value = cache.get("pr:myorg:myrepo:123:meta")
|
|
67
|
+
>>> cache.invalidate_pattern("pr:myorg:myrepo:123:%")
|
|
68
|
+
|
|
69
|
+
Attributes:
|
|
70
|
+
db_path: Path to the SQLite database file.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, db_path: str, time_provider: Optional[TimeProvider] = None) -> None:
|
|
74
|
+
"""Initialize the SQLite cache adapter.
|
|
75
|
+
|
|
76
|
+
Creates the cache directory and database file with secure permissions
|
|
77
|
+
if they don't exist. If the file exists with permissive permissions,
|
|
78
|
+
they are tightened and a warning is issued.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
db_path: Path to the SQLite database file. Parent directories
|
|
82
|
+
will be created if they don't exist.
|
|
83
|
+
time_provider: Optional TimeProvider for time operations.
|
|
84
|
+
Defaults to SystemTimeProvider if not provided.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
OSError: If unable to create directory or set permissions.
|
|
88
|
+
"""
|
|
89
|
+
self.db_path = db_path
|
|
90
|
+
self._time_provider = time_provider or SystemTimeProvider()
|
|
91
|
+
self._connection: Optional[sqlite3.Connection] = None
|
|
92
|
+
self._ensure_secure_path()
|
|
93
|
+
self._init_database()
|
|
94
|
+
|
|
95
|
+
def _ensure_secure_path(self) -> None:
|
|
96
|
+
"""Ensure cache directory and file have secure permissions.
|
|
97
|
+
|
|
98
|
+
Creates the directory with 0700 permissions and ensures the file
|
|
99
|
+
(if it exists) has 0600 permissions. Issues a warning if existing
|
|
100
|
+
permissions were too permissive.
|
|
101
|
+
"""
|
|
102
|
+
path = Path(self.db_path)
|
|
103
|
+
cache_dir = path.parent
|
|
104
|
+
|
|
105
|
+
# Create directory with secure permissions if needed
|
|
106
|
+
if cache_dir and not cache_dir.exists():
|
|
107
|
+
cache_dir.mkdir(parents=True, mode=0o700, exist_ok=True)
|
|
108
|
+
elif cache_dir and cache_dir.exists():
|
|
109
|
+
# Ensure existing directory has correct permissions
|
|
110
|
+
current_mode = stat.S_IMODE(cache_dir.stat().st_mode)
|
|
111
|
+
if current_mode != 0o700:
|
|
112
|
+
cache_dir.chmod(0o700)
|
|
113
|
+
|
|
114
|
+
# Check existing file permissions and fix if necessary
|
|
115
|
+
if path.exists():
|
|
116
|
+
current_mode = stat.S_IMODE(path.stat().st_mode)
|
|
117
|
+
# Check if group or others have any permissions
|
|
118
|
+
if current_mode & (stat.S_IRWXG | stat.S_IRWXO):
|
|
119
|
+
warnings.warn(
|
|
120
|
+
f"Cache file {self.db_path} had permissive permissions "
|
|
121
|
+
f"({oct(current_mode)}). Fixing to 0600.",
|
|
122
|
+
UserWarning,
|
|
123
|
+
stacklevel=2,
|
|
124
|
+
)
|
|
125
|
+
path.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
126
|
+
|
|
127
|
+
def _init_database(self) -> None:
|
|
128
|
+
"""Initialize the database schema.
|
|
129
|
+
|
|
130
|
+
Creates the pr_cache and cache_stats tables if they don't exist.
|
|
131
|
+
Sets file permissions to 0600 after creation.
|
|
132
|
+
"""
|
|
133
|
+
conn = self._get_connection()
|
|
134
|
+
conn.executescript(_CREATE_TABLES_SQL)
|
|
135
|
+
conn.commit()
|
|
136
|
+
|
|
137
|
+
# Ensure file has correct permissions after creation
|
|
138
|
+
path = Path(self.db_path)
|
|
139
|
+
if path.exists():
|
|
140
|
+
path.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
141
|
+
|
|
142
|
+
def _get_connection(self) -> sqlite3.Connection:
|
|
143
|
+
"""Get or create a database connection.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Active SQLite connection with row factory set to sqlite3.Row.
|
|
147
|
+
"""
|
|
148
|
+
if self._connection is None:
|
|
149
|
+
self._connection = sqlite3.connect(self.db_path)
|
|
150
|
+
self._connection.row_factory = sqlite3.Row
|
|
151
|
+
return self._connection
|
|
152
|
+
|
|
153
|
+
def get(self, key: str) -> Optional[str]:
|
|
154
|
+
"""Get cached value.
|
|
155
|
+
|
|
156
|
+
Retrieves a value from the cache if it exists and has not expired.
|
|
157
|
+
Updates hit/miss statistics accordingly.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
key: Cache key (e.g., 'pr:myorg:myrepo:123:meta').
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Cached value as string if found and not expired, None otherwise.
|
|
164
|
+
"""
|
|
165
|
+
conn = self._get_connection()
|
|
166
|
+
current_time = self._time_provider.now_int()
|
|
167
|
+
|
|
168
|
+
cursor = conn.execute(
|
|
169
|
+
"SELECT value FROM pr_cache WHERE key = ? AND expires_at > ?",
|
|
170
|
+
(key, current_time),
|
|
171
|
+
)
|
|
172
|
+
row = cursor.fetchone()
|
|
173
|
+
|
|
174
|
+
if row is not None:
|
|
175
|
+
# Cache hit
|
|
176
|
+
conn.execute("UPDATE cache_stats SET hits = hits + 1 WHERE key_prefix = 'global'")
|
|
177
|
+
conn.commit()
|
|
178
|
+
return str(row["value"])
|
|
179
|
+
else:
|
|
180
|
+
# Cache miss
|
|
181
|
+
conn.execute("UPDATE cache_stats SET misses = misses + 1 WHERE key_prefix = 'global'")
|
|
182
|
+
conn.commit()
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
def set(self, key: str, value: str, ttl_seconds: int) -> None:
|
|
186
|
+
"""Set cached value with TTL.
|
|
187
|
+
|
|
188
|
+
Stores a value in the cache with an expiration time. If a value
|
|
189
|
+
already exists for the key, it is replaced.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
key: Cache key (e.g., 'pr:myorg:myrepo:123:meta').
|
|
193
|
+
value: Value to cache (typically JSON string).
|
|
194
|
+
ttl_seconds: Time to live in seconds. After this duration,
|
|
195
|
+
the entry is considered expired.
|
|
196
|
+
"""
|
|
197
|
+
conn = self._get_connection()
|
|
198
|
+
current_time = self._time_provider.now_int()
|
|
199
|
+
expires_at = current_time + ttl_seconds
|
|
200
|
+
|
|
201
|
+
conn.execute(
|
|
202
|
+
"""
|
|
203
|
+
INSERT OR REPLACE INTO pr_cache (key, value, expires_at, created_at)
|
|
204
|
+
VALUES (?, ?, ?, ?)
|
|
205
|
+
""",
|
|
206
|
+
(key, value, expires_at, current_time),
|
|
207
|
+
)
|
|
208
|
+
conn.commit()
|
|
209
|
+
|
|
210
|
+
def delete(self, key: str) -> None:
|
|
211
|
+
"""Delete cached value.
|
|
212
|
+
|
|
213
|
+
Removes a specific entry from the cache.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
key: Cache key to delete.
|
|
217
|
+
"""
|
|
218
|
+
conn = self._get_connection()
|
|
219
|
+
conn.execute("DELETE FROM pr_cache WHERE key = ?", (key,))
|
|
220
|
+
conn.commit()
|
|
221
|
+
|
|
222
|
+
def invalidate_pattern(self, pattern: str) -> None:
|
|
223
|
+
"""Invalidate all keys matching pattern.
|
|
224
|
+
|
|
225
|
+
Removes all cache entries whose keys match the given pattern.
|
|
226
|
+
Uses SQL LIKE pattern matching where '%' matches any sequence
|
|
227
|
+
of characters and '_' matches any single character.
|
|
228
|
+
|
|
229
|
+
For glob-style patterns using '*', convert to SQL LIKE:
|
|
230
|
+
- 'pr:myorg:myrepo:123:*' -> 'pr:myorg:myrepo:123:%'
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
pattern: Pattern to match keys against. Use '%' as wildcard
|
|
234
|
+
for SQL LIKE matching (e.g., 'pr:myorg:myrepo:123:%').
|
|
235
|
+
|
|
236
|
+
Note:
|
|
237
|
+
The pattern is converted from glob-style to SQL LIKE:
|
|
238
|
+
- '*' is converted to '%'
|
|
239
|
+
- '?' is converted to '_'
|
|
240
|
+
"""
|
|
241
|
+
conn = self._get_connection()
|
|
242
|
+
|
|
243
|
+
# Convert glob-style wildcards to SQL LIKE pattern
|
|
244
|
+
sql_pattern = pattern.replace("*", "%").replace("?", "_")
|
|
245
|
+
|
|
246
|
+
conn.execute("DELETE FROM pr_cache WHERE key LIKE ?", (sql_pattern,))
|
|
247
|
+
conn.commit()
|
|
248
|
+
|
|
249
|
+
def cleanup_expired(self) -> None:
|
|
250
|
+
"""Remove expired entries.
|
|
251
|
+
|
|
252
|
+
Deletes all entries whose TTL has expired. This should be called
|
|
253
|
+
periodically to prevent unbounded cache growth.
|
|
254
|
+
"""
|
|
255
|
+
conn = self._get_connection()
|
|
256
|
+
current_time = self._time_provider.now_int()
|
|
257
|
+
conn.execute("DELETE FROM pr_cache WHERE expires_at <= ?", (current_time,))
|
|
258
|
+
conn.commit()
|
|
259
|
+
|
|
260
|
+
def get_stats(self) -> CacheStats:
|
|
261
|
+
"""Get cache hit/miss statistics.
|
|
262
|
+
|
|
263
|
+
Returns metrics about cache performance for monitoring and
|
|
264
|
+
debugging purposes.
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
CacheStats object containing:
|
|
268
|
+
- hits: Number of successful cache lookups
|
|
269
|
+
- misses: Number of cache misses
|
|
270
|
+
- hit_rate: Ratio of hits to total lookups (0.0 to 1.0)
|
|
271
|
+
"""
|
|
272
|
+
# Import here to avoid circular imports
|
|
273
|
+
from goodtogo.core.models import CacheStats
|
|
274
|
+
|
|
275
|
+
conn = self._get_connection()
|
|
276
|
+
cursor = conn.execute("SELECT hits, misses FROM cache_stats WHERE key_prefix = 'global'")
|
|
277
|
+
row = cursor.fetchone()
|
|
278
|
+
|
|
279
|
+
if row is None:
|
|
280
|
+
return CacheStats(hits=0, misses=0, hit_rate=0.0)
|
|
281
|
+
|
|
282
|
+
hits = row["hits"]
|
|
283
|
+
misses = row["misses"]
|
|
284
|
+
total = hits + misses
|
|
285
|
+
hit_rate = hits / total if total > 0 else 0.0
|
|
286
|
+
|
|
287
|
+
return CacheStats(hits=hits, misses=misses, hit_rate=hit_rate)
|
|
288
|
+
|
|
289
|
+
def close(self) -> None:
|
|
290
|
+
"""Close the database connection.
|
|
291
|
+
|
|
292
|
+
Should be called when the cache is no longer needed to release
|
|
293
|
+
database resources.
|
|
294
|
+
"""
|
|
295
|
+
if self._connection is not None:
|
|
296
|
+
self._connection.close()
|
|
297
|
+
self._connection = None
|
|
298
|
+
|
|
299
|
+
def __del__(self) -> None:
|
|
300
|
+
"""Ensure connection is closed on garbage collection."""
|
|
301
|
+
self.close()
|
|
302
|
+
|
|
303
|
+
def __repr__(self) -> str:
|
|
304
|
+
"""Return string representation of the adapter."""
|
|
305
|
+
return f"SqliteCacheAdapter(db_path={self.db_path!r})"
|