Navegador

navegador / navegador / cluster / fossil_live.py
Blame History Raw 238 lines
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

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button