|
1
|
""" |
|
2
|
Fossil live integration — ATTACH DATABASE for zero-copy cross-DB queries. |
|
3
|
|
|
4
|
Attaches a Fossil SCM SQLite database to FalkorDB's SQLite connection so that |
|
5
|
Cypher-side and raw-SQL-side queries can share the same connection without |
|
6
|
copying data. Falls back gracefully when the underlying database is not |
|
7
|
SQLite-backed. |
|
8
|
""" |
|
9
|
|
|
10
|
from __future__ import annotations |
|
11
|
|
|
12
|
import logging |
|
13
|
from pathlib import Path |
|
14
|
from typing import TYPE_CHECKING, Any |
|
15
|
|
|
16
|
if TYPE_CHECKING: |
|
17
|
from navegador.graph.store import GraphStore |
|
18
|
|
|
19
|
logger = logging.getLogger(__name__) |
|
20
|
|
|
21
|
# Label used when importing Fossil timeline events into the navegador graph |
|
22
|
_FOSSIL_COMMIT_LABEL = "FossilCommit" |
|
23
|
_FOSSIL_TICKET_LABEL = "FossilTicket" |
|
24
|
|
|
25
|
|
|
26
|
class FossilLiveAdapter: |
|
27
|
""" |
|
28
|
Bridge between a Fossil SCM repository database and navegador's graph. |
|
29
|
|
|
30
|
The preferred approach is ``ATTACH DATABASE`` — attaching the Fossil |
|
31
|
SQLite file to the same SQLite connection used by FalkorDB's SQLite |
|
32
|
backend (falkordblite) so cross-DB queries work with zero data copying. |
|
33
|
|
|
34
|
When a direct SQLite connection is not available (e.g. Redis-backed |
|
35
|
FalkorDB), the adapter falls back to opening its own ``sqlite3`` |
|
36
|
connection to the Fossil DB. |
|
37
|
|
|
38
|
Args: |
|
39
|
fossil_db_path: Path to the Fossil ``.fossil`` or ``.db`` repository |
|
40
|
file (the SQLite database Fossil uses internally). |
|
41
|
""" |
|
42
|
|
|
43
|
def __init__(self, fossil_db_path: str | Path, _sqlite_conn: Any = None) -> None: |
|
44
|
self._fossil_path = Path(fossil_db_path) |
|
45
|
self._conn: Any = _sqlite_conn # injected in tests; opened lazily otherwise |
|
46
|
self._attached = False |
|
47
|
|
|
48
|
# ── Internal ────────────────────────────────────────────────────────────── |
|
49
|
|
|
50
|
def _get_conn(self) -> Any: |
|
51
|
"""Return a sqlite3 connection to the Fossil DB.""" |
|
52
|
if self._conn is None: |
|
53
|
import sqlite3 |
|
54
|
|
|
55
|
self._conn = sqlite3.connect(str(self._fossil_path)) |
|
56
|
self._conn.row_factory = sqlite3.Row |
|
57
|
return self._conn |
|
58
|
|
|
59
|
def _extract_sqlite_conn(self, store: "GraphStore") -> Any | None: |
|
60
|
""" |
|
61
|
Try to pull the underlying sqlite3 connection out of a falkordblite |
|
62
|
GraphStore so we can run ATTACH DATABASE on it. |
|
63
|
|
|
64
|
Returns None when the store is Redis-backed. |
|
65
|
""" |
|
66
|
try: |
|
67
|
# falkordblite wraps redislite; the raw sqlite3 connection is buried |
|
68
|
# several layers deep — we try the most common attribute paths. |
|
69
|
client = store._client # type: ignore[attr-defined] |
|
70
|
for attr in ("_db", "connection", "_connection", "db"): |
|
71
|
conn = getattr(client, attr, None) |
|
72
|
if conn is not None: |
|
73
|
import sqlite3 |
|
74
|
|
|
75
|
if isinstance(conn, sqlite3.Connection): |
|
76
|
return conn |
|
77
|
except Exception: |
|
78
|
pass |
|
79
|
return None |
|
80
|
|
|
81
|
# ── Public API ──────────────────────────────────────────────────────────── |
|
82
|
|
|
83
|
def attach(self, store: "GraphStore") -> None: |
|
84
|
""" |
|
85
|
Attach the Fossil SQLite DB to FalkorDB's SQLite connection. |
|
86
|
|
|
87
|
If the underlying connection cannot be retrieved (Redis backend), the |
|
88
|
method logs a warning and falls back to a standalone connection. |
|
89
|
|
|
90
|
Args: |
|
91
|
store: The GraphStore whose SQLite connection to attach into. |
|
92
|
""" |
|
93
|
if self._attached: |
|
94
|
return |
|
95
|
|
|
96
|
native_conn = self._extract_sqlite_conn(store) |
|
97
|
if native_conn is not None: |
|
98
|
native_conn.execute("ATTACH DATABASE ? AS fossil", (str(self._fossil_path),)) |
|
99
|
self._conn = native_conn |
|
100
|
self._attached = True |
|
101
|
logger.info("Fossil DB attached to FalkorDB SQLite: %s", self._fossil_path) |
|
102
|
else: |
|
103
|
logger.warning( |
|
104
|
"Could not attach Fossil DB to FalkorDB connection (not SQLite-backed); " |
|
105
|
"falling back to standalone connection." |
|
106
|
) |
|
107
|
# Open a standalone connection so queries still work |
|
108
|
self._get_conn() |
|
109
|
|
|
110
|
def query_timeline(self, limit: int = 50) -> list[dict]: |
|
111
|
""" |
|
112
|
Query the Fossil event (timeline) table. |
|
113
|
|
|
114
|
Fossil stores timeline events in a table called ``event``. Each row |
|
115
|
represents a commit, wiki edit, ticket change, etc. |
|
116
|
|
|
117
|
Args: |
|
118
|
limit: Maximum number of events to return (newest first). |
|
119
|
|
|
120
|
Returns: |
|
121
|
List of dicts with keys: ``type``, ``mtime``, ``objid``, |
|
122
|
``tagid``, ``uid``, ``bgcolor``, ``euser``, ``user``, |
|
123
|
``ecomment``, ``comment``, ``brief``. |
|
124
|
""" |
|
125
|
conn = self._get_conn() |
|
126
|
prefix = "fossil." if self._attached else "" |
|
127
|
sql = f""" |
|
128
|
SELECT type, mtime, objid, uid, user, |
|
129
|
euser, comment, ecomment |
|
130
|
FROM {prefix}event |
|
131
|
ORDER BY mtime DESC |
|
132
|
LIMIT ? |
|
133
|
""" |
|
134
|
cursor = conn.execute(sql, (limit,)) |
|
135
|
rows = cursor.fetchall() |
|
136
|
result = [] |
|
137
|
for row in rows: |
|
138
|
if hasattr(row, "keys"): |
|
139
|
result.append(dict(row)) |
|
140
|
else: |
|
141
|
result.append( |
|
142
|
{ |
|
143
|
"type": row[0], |
|
144
|
"mtime": row[1], |
|
145
|
"objid": row[2], |
|
146
|
"uid": row[3], |
|
147
|
"user": row[4], |
|
148
|
"euser": row[5], |
|
149
|
"comment": row[6], |
|
150
|
"ecomment": row[7], |
|
151
|
} |
|
152
|
) |
|
153
|
return result |
|
154
|
|
|
155
|
def query_tickets(self) -> list[dict]: |
|
156
|
""" |
|
157
|
Query Fossil tickets. |
|
158
|
|
|
159
|
Fossil stores tickets in the ``ticket`` table. The schema can vary |
|
160
|
per-repository; we return the full row as a dict. |
|
161
|
|
|
162
|
Returns: |
|
163
|
List of dicts representing ticket rows. |
|
164
|
""" |
|
165
|
conn = self._get_conn() |
|
166
|
prefix = "fossil." if self._attached else "" |
|
167
|
try: |
|
168
|
sql = f"SELECT * FROM {prefix}ticket ORDER BY tkt_mtime DESC" |
|
169
|
cursor = conn.execute(sql) |
|
170
|
cols = [description[0] for description in cursor.description] |
|
171
|
rows = cursor.fetchall() |
|
172
|
return [dict(zip(cols, row)) for row in rows] |
|
173
|
except Exception as exc: |
|
174
|
logger.warning("Could not query Fossil tickets: %s", exc) |
|
175
|
return [] |
|
176
|
|
|
177
|
def sync_to_graph(self, store: "GraphStore") -> dict: |
|
178
|
""" |
|
179
|
Import Fossil timeline and ticket data into the navegador graph. |
|
180
|
|
|
181
|
Creates :class:`FossilCommit` and :class:`FossilTicket` nodes. |
|
182
|
|
|
183
|
Args: |
|
184
|
store: Target GraphStore. |
|
185
|
|
|
186
|
Returns: |
|
187
|
Dict with keys: ``commits``, ``tickets`` (counts of imported items). |
|
188
|
""" |
|
189
|
commit_count = 0 |
|
190
|
ticket_count = 0 |
|
191
|
|
|
192
|
# Import timeline events as FossilCommit nodes |
|
193
|
events = self.query_timeline(limit=200) |
|
194
|
for event in events: |
|
195
|
if event.get("type") not in ("ci", "c"): # "ci" = check-in |
|
196
|
continue |
|
197
|
props = { |
|
198
|
"name": str(event.get("objid", "")), |
|
199
|
"file_path": "", |
|
200
|
"user": str(event.get("user") or event.get("euser") or ""), |
|
201
|
"comment": str(event.get("comment") or event.get("ecomment") or ""), |
|
202
|
"mtime": str(event.get("mtime", "")), |
|
203
|
"source": "fossil", |
|
204
|
} |
|
205
|
prop_str = ", ".join(f"n.{k} = ${k}" for k in props) |
|
206
|
store.query( |
|
207
|
f"MERGE (n:{_FOSSIL_COMMIT_LABEL} {{name: $name, file_path: $file_path}}) " |
|
208
|
f"SET {prop_str}", |
|
209
|
props, |
|
210
|
) |
|
211
|
commit_count += 1 |
|
212
|
|
|
213
|
# Import tickets as FossilTicket nodes |
|
214
|
tickets = self.query_tickets() |
|
215
|
for ticket in tickets: |
|
216
|
tkt_id = str(ticket.get("tkt_uuid") or ticket.get("tkt_id") or "") |
|
217
|
if not tkt_id: |
|
218
|
continue |
|
219
|
props = { |
|
220
|
"name": tkt_id, |
|
221
|
"file_path": "", |
|
222
|
"title": str(ticket.get("title") or ""), |
|
223
|
"status": str(ticket.get("status") or ""), |
|
224
|
"type": str(ticket.get("type") or ""), |
|
225
|
"mtime": str(ticket.get("tkt_mtime") or ""), |
|
226
|
"source": "fossil", |
|
227
|
} |
|
228
|
prop_str = ", ".join(f"n.{k} = ${k}" for k in props) |
|
229
|
store.query( |
|
230
|
f"MERGE (n:{_FOSSIL_TICKET_LABEL} {{name: $name, file_path: $file_path}}) " |
|
231
|
f"SET {prop_str}", |
|
232
|
props, |
|
233
|
) |
|
234
|
ticket_count += 1 |
|
235
|
|
|
236
|
logger.info("Fossil sync complete: %d commits, %d tickets", commit_count, ticket_count) |
|
237
|
return {"commits": commit_count, "tickets": ticket_count} |
|
238
|
|