vibe-coding-qa 0.5.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.
- dashboard/__init__.py +10 -0
- dashboard/backend/__init__.py +11 -0
- dashboard/backend/__main__.py +6 -0
- dashboard/backend/ai/__init__.py +6 -0
- dashboard/backend/ai/provider.py +102 -0
- dashboard/backend/ai/service.py +757 -0
- dashboard/backend/api/__init__.py +5 -0
- dashboard/backend/api/ai.py +142 -0
- dashboard/backend/api/dashboard.py +34 -0
- dashboard/backend/api/deps.py +51 -0
- dashboard/backend/api/findings.py +102 -0
- dashboard/backend/api/intel.py +41 -0
- dashboard/backend/api/repositories.py +62 -0
- dashboard/backend/api/scans.py +168 -0
- dashboard/backend/api/system.py +69 -0
- dashboard/backend/config.py +180 -0
- dashboard/backend/main.py +183 -0
- dashboard/backend/persistence/__init__.py +5 -0
- dashboard/backend/persistence/database.py +278 -0
- dashboard/backend/persistence/store.py +600 -0
- dashboard/backend/profiles.py +141 -0
- dashboard/backend/schemas.py +452 -0
- dashboard/backend/security.py +158 -0
- dashboard/backend/services/__init__.py +7 -0
- dashboard/backend/services/dashboard.py +172 -0
- dashboard/backend/services/intel.py +341 -0
- dashboard/backend/services/paths.py +38 -0
- dashboard/backend/services/repositories.py +123 -0
- dashboard/backend/services/scans.py +328 -0
- dashboard/backend/services/settings.py +49 -0
- dashboard/frontend/dist/assets/index-COC-CdqD.js +492 -0
- dashboard/frontend/dist/assets/index-hWDLRaLr.css +1 -0
- dashboard/frontend/dist/index.html +29 -0
- dashboard/frontend/dist/phone-mascots.gif +0 -0
- dashboard/launcher.py +48 -0
- vibe_coding_qa/__init__.py +140 -0
- vibe_coding_qa/__main__.py +5 -0
- vibe_coding_qa/ai_review.py +165 -0
- vibe_coding_qa/cli.py +157 -0
- vibe_coding_qa/engine.py +860 -0
- vibe_coding_qa/fixes.py +222 -0
- vibe_coding_qa/gates.py +300 -0
- vibe_coding_qa/profile.py +471 -0
- vibe_coding_qa/repo.py +188 -0
- vibe_coding_qa/reporters/__init__.py +253 -0
- vibe_coding_qa/scanners/__init__.py +195 -0
- vibe_coding_qa/scanners/compliance.py +167 -0
- vibe_coding_qa/scanners/database.py +316 -0
- vibe_coding_qa/scanners/dependencies.py +595 -0
- vibe_coding_qa/scanners/infrastructure.py +125 -0
- vibe_coding_qa/scanners/performance.py +84 -0
- vibe_coding_qa/scanners/quality.py +129 -0
- vibe_coding_qa/scanners/security.py +521 -0
- vibe_coding_qa-0.5.0.dist-info/METADATA +323 -0
- vibe_coding_qa-0.5.0.dist-info/RECORD +58 -0
- vibe_coding_qa-0.5.0.dist-info/WHEEL +5 -0
- vibe_coding_qa-0.5.0.dist-info/entry_points.txt +3 -0
- vibe_coding_qa-0.5.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
"""Data-access layer for the dashboard.
|
|
2
|
+
|
|
3
|
+
Everything the API needs is expressed as explicit methods here so routes stay
|
|
4
|
+
thin and the SQL stays in one place. Rows are returned as plain dicts.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from typing import Any, Iterable
|
|
9
|
+
|
|
10
|
+
from .database import Database, utcnow
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _loads(value: Any, default: Any) -> Any:
|
|
14
|
+
if value in (None, ""):
|
|
15
|
+
return default
|
|
16
|
+
try:
|
|
17
|
+
return json.loads(value)
|
|
18
|
+
except (TypeError, ValueError):
|
|
19
|
+
return default
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _dumps(value: Any) -> str:
|
|
23
|
+
return json.dumps(value, ensure_ascii=False)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def finding_fingerprint(rule: str, file: str, line: int) -> str:
|
|
27
|
+
"""Stable identity for a finding: rule + file + line."""
|
|
28
|
+
return f"{rule}|{file}|{line}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Store:
|
|
32
|
+
def __init__(self, db: Database):
|
|
33
|
+
self.db = db
|
|
34
|
+
|
|
35
|
+
# ------------------------------------------------------------------ repos
|
|
36
|
+
def create_repository(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
name: str,
|
|
40
|
+
source: str,
|
|
41
|
+
source_type: str,
|
|
42
|
+
main_language: str | None = None,
|
|
43
|
+
frameworks: list[str] | None = None,
|
|
44
|
+
package_manager: str | None = None,
|
|
45
|
+
database_tech: list[str] | None = None,
|
|
46
|
+
auth_tech: list[str] | None = None,
|
|
47
|
+
deployment: list[str] | None = None,
|
|
48
|
+
default_branch: str | None = None,
|
|
49
|
+
last_commit_sha: str | None = None,
|
|
50
|
+
) -> dict:
|
|
51
|
+
now = utcnow()
|
|
52
|
+
cursor = self.db.execute(
|
|
53
|
+
"""
|
|
54
|
+
INSERT INTO repositories
|
|
55
|
+
(name, source, source_type, created_at, updated_at, main_language,
|
|
56
|
+
frameworks, package_manager, database_tech, auth_tech, deployment,
|
|
57
|
+
default_branch, last_commit_sha)
|
|
58
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
59
|
+
""",
|
|
60
|
+
(
|
|
61
|
+
name, source, source_type, now, now, main_language,
|
|
62
|
+
_dumps(frameworks or []), package_manager,
|
|
63
|
+
_dumps(database_tech or []), _dumps(auth_tech or []),
|
|
64
|
+
_dumps(deployment or []), default_branch, last_commit_sha,
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
return self.get_repository(cursor.lastrowid)
|
|
68
|
+
|
|
69
|
+
def list_repositories(self) -> list[dict]:
|
|
70
|
+
rows = self.db.query("SELECT * FROM repositories ORDER BY name COLLATE NOCASE")
|
|
71
|
+
return [self._repo_row(row) for row in rows]
|
|
72
|
+
|
|
73
|
+
def get_repository(self, repository_id: int) -> dict | None:
|
|
74
|
+
row = self.db.query_one("SELECT * FROM repositories WHERE id = ?", (repository_id,))
|
|
75
|
+
return self._repo_row(row) if row else None
|
|
76
|
+
|
|
77
|
+
def get_repository_by_source(self, source: str) -> dict | None:
|
|
78
|
+
row = self.db.query_one("SELECT * FROM repositories WHERE source = ?", (source,))
|
|
79
|
+
return self._repo_row(row) if row else None
|
|
80
|
+
|
|
81
|
+
def update_repository(self, repository_id: int, **fields) -> dict | None:
|
|
82
|
+
if not fields:
|
|
83
|
+
return self.get_repository(repository_id)
|
|
84
|
+
json_fields = {"frameworks", "database_tech", "auth_tech", "deployment"}
|
|
85
|
+
columns = []
|
|
86
|
+
params: list[Any] = []
|
|
87
|
+
for key, value in fields.items():
|
|
88
|
+
columns.append(f"{key} = ?")
|
|
89
|
+
params.append(_dumps(value) if key in json_fields else value)
|
|
90
|
+
columns.append("updated_at = ?")
|
|
91
|
+
params.append(utcnow())
|
|
92
|
+
params.append(repository_id)
|
|
93
|
+
self.db.execute(f"UPDATE repositories SET {', '.join(columns)} WHERE id = ?", tuple(params))
|
|
94
|
+
return self.get_repository(repository_id)
|
|
95
|
+
|
|
96
|
+
def delete_repository(self, repository_id: int) -> bool:
|
|
97
|
+
cursor = self.db.execute("DELETE FROM repositories WHERE id = ?", (repository_id,))
|
|
98
|
+
return cursor.rowcount > 0
|
|
99
|
+
|
|
100
|
+
def _repo_row(self, row) -> dict:
|
|
101
|
+
data = dict(row)
|
|
102
|
+
for key in ("frameworks", "database_tech", "auth_tech", "deployment"):
|
|
103
|
+
data[key] = _loads(data.get(key), [])
|
|
104
|
+
latest = self.db.query_one(
|
|
105
|
+
"""
|
|
106
|
+
SELECT id, status, overall_score, gate, critical_count, high_count,
|
|
107
|
+
completed_at, created_at, test_status
|
|
108
|
+
FROM scans WHERE repository_id = ? AND status = 'completed'
|
|
109
|
+
ORDER BY id DESC LIMIT 1
|
|
110
|
+
""",
|
|
111
|
+
(data["id"],),
|
|
112
|
+
)
|
|
113
|
+
data["latest_scan"] = dict(latest) if latest else None
|
|
114
|
+
return data
|
|
115
|
+
|
|
116
|
+
# ------------------------------------------------------------------ scans
|
|
117
|
+
def create_scan(self, *, repository_id: int, profile: str, options: dict, trigger: str = "dashboard") -> dict:
|
|
118
|
+
now = utcnow()
|
|
119
|
+
cursor = self.db.execute(
|
|
120
|
+
"""
|
|
121
|
+
INSERT INTO scans (repository_id, status, profile, created_at, options, trigger)
|
|
122
|
+
VALUES (?, 'queued', ?, ?, ?, ?)
|
|
123
|
+
""",
|
|
124
|
+
(repository_id, profile, now, _dumps(options), trigger),
|
|
125
|
+
)
|
|
126
|
+
return self.get_scan(cursor.lastrowid)
|
|
127
|
+
|
|
128
|
+
def update_scan(self, scan_id: int, **fields) -> dict | None:
|
|
129
|
+
if not fields:
|
|
130
|
+
return self.get_scan(scan_id)
|
|
131
|
+
json_fields = {"scanner_versions", "coverage_gaps", "options"}
|
|
132
|
+
columns = []
|
|
133
|
+
params: list[Any] = []
|
|
134
|
+
for key, value in fields.items():
|
|
135
|
+
columns.append(f"{key} = ?")
|
|
136
|
+
params.append(_dumps(value) if key in json_fields else value)
|
|
137
|
+
params.append(scan_id)
|
|
138
|
+
self.db.execute(f"UPDATE scans SET {', '.join(columns)} WHERE id = ?", tuple(params))
|
|
139
|
+
return self.get_scan(scan_id)
|
|
140
|
+
|
|
141
|
+
def get_scan(self, scan_id: int) -> dict | None:
|
|
142
|
+
row = self.db.query_one("SELECT * FROM scans WHERE id = ?", (scan_id,))
|
|
143
|
+
return self._scan_row(row) if row else None
|
|
144
|
+
|
|
145
|
+
def list_scans(
|
|
146
|
+
self,
|
|
147
|
+
*,
|
|
148
|
+
repository_id: int | None = None,
|
|
149
|
+
status: str | None = None,
|
|
150
|
+
limit: int = 50,
|
|
151
|
+
offset: int = 0,
|
|
152
|
+
) -> tuple[list[dict], int]:
|
|
153
|
+
clauses = []
|
|
154
|
+
params: list[Any] = []
|
|
155
|
+
if repository_id is not None:
|
|
156
|
+
clauses.append("s.repository_id = ?")
|
|
157
|
+
params.append(repository_id)
|
|
158
|
+
if status:
|
|
159
|
+
clauses.append("s.status = ?")
|
|
160
|
+
params.append(status)
|
|
161
|
+
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
162
|
+
total_row = self.db.query_one(f"SELECT COUNT(*) AS c FROM scans s {where}", tuple(params))
|
|
163
|
+
rows = self.db.query(
|
|
164
|
+
f"""
|
|
165
|
+
SELECT s.*, r.name AS repository_name, r.source AS repository_source
|
|
166
|
+
FROM scans s JOIN repositories r ON r.id = s.repository_id
|
|
167
|
+
{where}
|
|
168
|
+
ORDER BY s.id DESC LIMIT ? OFFSET ?
|
|
169
|
+
""",
|
|
170
|
+
tuple(params) + (limit, offset),
|
|
171
|
+
)
|
|
172
|
+
return [self._scan_row(row, with_repo=True) for row in rows], int(total_row["c"])
|
|
173
|
+
|
|
174
|
+
def latest_completed_scan(self, repository_id: int) -> dict | None:
|
|
175
|
+
row = self.db.query_one(
|
|
176
|
+
"SELECT * FROM scans WHERE repository_id = ? AND status = 'completed' ORDER BY id DESC LIMIT 1",
|
|
177
|
+
(repository_id,),
|
|
178
|
+
)
|
|
179
|
+
return self._scan_row(row) if row else None
|
|
180
|
+
|
|
181
|
+
def delete_scan(self, scan_id: int) -> bool:
|
|
182
|
+
cursor = self.db.execute("DELETE FROM scans WHERE id = ?", (scan_id,))
|
|
183
|
+
return cursor.rowcount > 0
|
|
184
|
+
|
|
185
|
+
def mark_interrupted_scans(self) -> list[int]:
|
|
186
|
+
"""Mark scans left queued/running by a previous process as failed."""
|
|
187
|
+
rows = self.db.query("SELECT id FROM scans WHERE status IN ('queued', 'running')")
|
|
188
|
+
ids = [row["id"] for row in rows]
|
|
189
|
+
if ids:
|
|
190
|
+
self.db.execute(
|
|
191
|
+
"""
|
|
192
|
+
UPDATE scans
|
|
193
|
+
SET status = 'failed', completed_at = ?,
|
|
194
|
+
error = COALESCE(error, 'Interrupted by a server restart before completion.')
|
|
195
|
+
WHERE status IN ('queued', 'running')
|
|
196
|
+
""",
|
|
197
|
+
(utcnow(),),
|
|
198
|
+
)
|
|
199
|
+
return ids
|
|
200
|
+
|
|
201
|
+
def _scan_row(self, row, with_repo: bool = False) -> dict:
|
|
202
|
+
data = dict(row)
|
|
203
|
+
data["options"] = _loads(data.get("options"), {})
|
|
204
|
+
data["coverage_gaps"] = _loads(data.get("coverage_gaps"), [])
|
|
205
|
+
data["scanner_versions"] = _loads(data.get("scanner_versions"), {})
|
|
206
|
+
if not with_repo:
|
|
207
|
+
repo = self.db.query_one("SELECT name, source FROM repositories WHERE id = ?", (data["repository_id"],))
|
|
208
|
+
if repo:
|
|
209
|
+
data["repository_name"] = repo["name"]
|
|
210
|
+
data["repository_source"] = repo["source"]
|
|
211
|
+
return data
|
|
212
|
+
|
|
213
|
+
# --------------------------------------------------------------- findings
|
|
214
|
+
def insert_findings(self, scan_id: int, repository_id: int, findings: Iterable[dict]) -> int:
|
|
215
|
+
now = utcnow()
|
|
216
|
+
rows = []
|
|
217
|
+
count = 0
|
|
218
|
+
for finding in findings:
|
|
219
|
+
fingerprint = finding_fingerprint(finding["rule"], finding["file"], int(finding["line"]))
|
|
220
|
+
prior = self.db.query_one(
|
|
221
|
+
"SELECT first_seen_scan_id FROM findings WHERE fingerprint = ? AND repository_id = ? ORDER BY id LIMIT 1",
|
|
222
|
+
(fingerprint, repository_id),
|
|
223
|
+
)
|
|
224
|
+
first_seen = prior["first_seen_scan_id"] if prior else scan_id
|
|
225
|
+
rows.append((
|
|
226
|
+
scan_id, repository_id, fingerprint, finding["rule"], finding["module"],
|
|
227
|
+
finding["severity"], finding["file"], int(finding["line"]), int(finding.get("column", 0)),
|
|
228
|
+
finding.get("message", ""), finding.get("suggestion", ""), finding.get("confidence", "medium"),
|
|
229
|
+
1 if finding.get("fixable") else 0, "open", first_seen, now,
|
|
230
|
+
))
|
|
231
|
+
count += 1
|
|
232
|
+
if rows:
|
|
233
|
+
self.db.executemany(
|
|
234
|
+
"""
|
|
235
|
+
INSERT INTO findings
|
|
236
|
+
(scan_id, repository_id, fingerprint, rule, module, severity, file, line, column,
|
|
237
|
+
message, suggestion, confidence, fixable, status, first_seen_scan_id, created_at)
|
|
238
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
239
|
+
""",
|
|
240
|
+
rows,
|
|
241
|
+
)
|
|
242
|
+
return count
|
|
243
|
+
|
|
244
|
+
def list_findings(
|
|
245
|
+
self,
|
|
246
|
+
*,
|
|
247
|
+
repository_id: int | None = None,
|
|
248
|
+
scan_id: int | None = None,
|
|
249
|
+
severity: list[str] | None = None,
|
|
250
|
+
module: list[str] | None = None,
|
|
251
|
+
status: list[str] | None = None,
|
|
252
|
+
search: str | None = None,
|
|
253
|
+
limit: int = 50,
|
|
254
|
+
offset: int = 0,
|
|
255
|
+
) -> tuple[list[dict], int]:
|
|
256
|
+
clauses: list[str] = []
|
|
257
|
+
params: list[Any] = []
|
|
258
|
+
if scan_id is not None:
|
|
259
|
+
clauses.append("f.scan_id = ?")
|
|
260
|
+
params.append(scan_id)
|
|
261
|
+
elif repository_id is not None:
|
|
262
|
+
# Restrict to the newest scan per repository unless a scan is given,
|
|
263
|
+
# so the UI shows the current state rather than historical duplicates.
|
|
264
|
+
clauses.append(
|
|
265
|
+
"f.scan_id = (SELECT id FROM scans WHERE repository_id = ? AND status = 'completed' ORDER BY id DESC LIMIT 1)"
|
|
266
|
+
)
|
|
267
|
+
params.append(repository_id)
|
|
268
|
+
else:
|
|
269
|
+
clauses.append(
|
|
270
|
+
"f.scan_id IN (SELECT MAX(id) FROM scans WHERE status = 'completed' GROUP BY repository_id)"
|
|
271
|
+
)
|
|
272
|
+
if severity:
|
|
273
|
+
clauses.append(f"f.severity IN ({','.join('?' for _ in severity)})")
|
|
274
|
+
params.extend(severity)
|
|
275
|
+
if module:
|
|
276
|
+
clauses.append(f"f.module IN ({','.join('?' for _ in module)})")
|
|
277
|
+
params.extend(module)
|
|
278
|
+
if status:
|
|
279
|
+
clauses.append(f"f.status IN ({','.join('?' for _ in status)})")
|
|
280
|
+
params.extend(status)
|
|
281
|
+
if search:
|
|
282
|
+
like = f"%{search.lower()}%"
|
|
283
|
+
clauses.append("(LOWER(f.rule) LIKE ? OR LOWER(f.file) LIKE ? OR LOWER(f.message) LIKE ?)")
|
|
284
|
+
params.extend([like, like, like])
|
|
285
|
+
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
286
|
+
total_row = self.db.query_one(f"SELECT COUNT(*) AS c FROM findings f {where}", tuple(params))
|
|
287
|
+
rows = self.db.query(
|
|
288
|
+
f"""
|
|
289
|
+
SELECT f.*, r.name AS repository_name
|
|
290
|
+
FROM findings f JOIN repositories r ON r.id = f.repository_id
|
|
291
|
+
{where}
|
|
292
|
+
ORDER BY
|
|
293
|
+
CASE f.severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1
|
|
294
|
+
WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END,
|
|
295
|
+
f.file, f.line
|
|
296
|
+
LIMIT ? OFFSET ?
|
|
297
|
+
""",
|
|
298
|
+
tuple(params) + (limit, offset),
|
|
299
|
+
)
|
|
300
|
+
return [dict(row) for row in rows], int(total_row["c"])
|
|
301
|
+
|
|
302
|
+
def get_finding(self, finding_id: int) -> dict | None:
|
|
303
|
+
row = self.db.query_one(
|
|
304
|
+
"""
|
|
305
|
+
SELECT f.*, r.name AS repository_name, r.source AS repository_source,
|
|
306
|
+
s.profile AS scan_profile, s.completed_at AS scan_completed_at
|
|
307
|
+
FROM findings f
|
|
308
|
+
JOIN repositories r ON r.id = f.repository_id
|
|
309
|
+
LEFT JOIN scans s ON s.id = f.scan_id
|
|
310
|
+
WHERE f.id = ?
|
|
311
|
+
""",
|
|
312
|
+
(finding_id,),
|
|
313
|
+
)
|
|
314
|
+
return dict(row) if row else None
|
|
315
|
+
|
|
316
|
+
def set_finding_status(self, finding_id: int, status: str) -> dict | None:
|
|
317
|
+
self.db.execute("UPDATE findings SET status = ? WHERE id = ?", (status, finding_id))
|
|
318
|
+
return self.get_finding(finding_id)
|
|
319
|
+
|
|
320
|
+
def finding_history(self, fingerprint: str, repository_id: int) -> list[dict]:
|
|
321
|
+
rows = self.db.query(
|
|
322
|
+
"""
|
|
323
|
+
SELECT f.id, f.scan_id, f.status, f.line, f.severity, f.created_at, s.completed_at
|
|
324
|
+
FROM findings f LEFT JOIN scans s ON s.id = f.scan_id
|
|
325
|
+
WHERE f.fingerprint = ? AND f.repository_id = ?
|
|
326
|
+
ORDER BY f.id
|
|
327
|
+
""",
|
|
328
|
+
(fingerprint, repository_id),
|
|
329
|
+
)
|
|
330
|
+
return [dict(row) for row in rows]
|
|
331
|
+
|
|
332
|
+
def severity_counts(self, repository_id: int | None = None, scan_id: int | None = None) -> dict[str, int]:
|
|
333
|
+
clause = ""
|
|
334
|
+
params: tuple = ()
|
|
335
|
+
if scan_id is not None:
|
|
336
|
+
clause = "WHERE scan_id = ?"
|
|
337
|
+
params = (scan_id,)
|
|
338
|
+
elif repository_id is not None:
|
|
339
|
+
clause = (
|
|
340
|
+
"WHERE scan_id = (SELECT id FROM scans WHERE repository_id = ? "
|
|
341
|
+
"AND status = 'completed' ORDER BY id DESC LIMIT 1)"
|
|
342
|
+
)
|
|
343
|
+
params = (repository_id,)
|
|
344
|
+
rows = self.db.query(f"SELECT severity, COUNT(*) AS c FROM findings {clause} GROUP BY severity", params)
|
|
345
|
+
counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
|
346
|
+
for row in rows:
|
|
347
|
+
counts[row["severity"]] = int(row["c"])
|
|
348
|
+
return counts
|
|
349
|
+
|
|
350
|
+
def module_counts(self, repository_id: int | None = None, scan_id: int | None = None) -> dict[str, int]:
|
|
351
|
+
clause = ""
|
|
352
|
+
params: tuple = ()
|
|
353
|
+
if scan_id is not None:
|
|
354
|
+
clause = "WHERE scan_id = ?"
|
|
355
|
+
params = (scan_id,)
|
|
356
|
+
elif repository_id is not None:
|
|
357
|
+
clause = (
|
|
358
|
+
"WHERE scan_id = (SELECT id FROM scans WHERE repository_id = ? "
|
|
359
|
+
"AND status = 'completed' ORDER BY id DESC LIMIT 1)"
|
|
360
|
+
)
|
|
361
|
+
params = (repository_id,)
|
|
362
|
+
rows = self.db.query(f"SELECT module, COUNT(*) AS c FROM findings {clause} GROUP BY module", params)
|
|
363
|
+
return {row["module"]: int(row["c"]) for row in rows}
|
|
364
|
+
|
|
365
|
+
def findings_in_file(self, repository_id: int, file: str, exclude_id: int | None = None) -> list[dict]:
|
|
366
|
+
"""Other findings in the same file (used for related-finding context)."""
|
|
367
|
+
rows = self.db.query(
|
|
368
|
+
"""
|
|
369
|
+
SELECT f.id, f.rule, f.severity, f.line, f.message, f.module
|
|
370
|
+
FROM findings f
|
|
371
|
+
WHERE f.repository_id = ? AND f.file = ?
|
|
372
|
+
AND f.scan_id = (SELECT id FROM scans WHERE repository_id = ? AND status = 'completed' ORDER BY id DESC LIMIT 1)
|
|
373
|
+
AND (? IS NULL OR f.id != ?)
|
|
374
|
+
ORDER BY f.line
|
|
375
|
+
""",
|
|
376
|
+
(repository_id, file, repository_id, exclude_id, exclude_id),
|
|
377
|
+
)
|
|
378
|
+
return [dict(row) for row in rows]
|
|
379
|
+
|
|
380
|
+
def open_finding_count(self, repository_id: int | None = None) -> int:
|
|
381
|
+
clause = ""
|
|
382
|
+
params: tuple = ()
|
|
383
|
+
if repository_id is not None:
|
|
384
|
+
clause = (
|
|
385
|
+
"AND scan_id = (SELECT id FROM scans WHERE repository_id = ? "
|
|
386
|
+
"AND status = 'completed' ORDER BY id DESC LIMIT 1)"
|
|
387
|
+
)
|
|
388
|
+
params = (repository_id,)
|
|
389
|
+
row = self.db.query_one(
|
|
390
|
+
f"SELECT COUNT(*) AS c FROM findings WHERE status IN ('open', 'reviewed') {clause}",
|
|
391
|
+
params,
|
|
392
|
+
)
|
|
393
|
+
return int(row["c"]) if row else 0
|
|
394
|
+
|
|
395
|
+
def latest_completed_scans(self) -> list[dict]:
|
|
396
|
+
"""The most recent completed scan for every repository that has one."""
|
|
397
|
+
rows = self.db.query(
|
|
398
|
+
"""
|
|
399
|
+
SELECT s.*, r.name AS repository_name, r.source AS repository_source
|
|
400
|
+
FROM scans s
|
|
401
|
+
JOIN repositories r ON r.id = s.repository_id
|
|
402
|
+
WHERE s.status = 'completed'
|
|
403
|
+
AND s.id = (SELECT MAX(id) FROM scans WHERE repository_id = s.repository_id AND status = 'completed')
|
|
404
|
+
ORDER BY s.completed_at DESC
|
|
405
|
+
"""
|
|
406
|
+
)
|
|
407
|
+
return [self._scan_row(row, with_repo=True) for row in rows]
|
|
408
|
+
|
|
409
|
+
def recent_scans(self, limit: int = 30) -> list[dict]:
|
|
410
|
+
rows = self.db.query(
|
|
411
|
+
"""
|
|
412
|
+
SELECT s.*, r.name AS repository_name, r.source AS repository_source
|
|
413
|
+
FROM scans s JOIN repositories r ON r.id = s.repository_id
|
|
414
|
+
WHERE s.status = 'completed'
|
|
415
|
+
ORDER BY s.id DESC LIMIT ?
|
|
416
|
+
""",
|
|
417
|
+
(limit,),
|
|
418
|
+
)
|
|
419
|
+
return [self._scan_row(row, with_repo=True) for row in rows]
|
|
420
|
+
|
|
421
|
+
def count_repositories(self) -> int:
|
|
422
|
+
row = self.db.query_one("SELECT COUNT(*) AS c FROM repositories")
|
|
423
|
+
return int(row["c"]) if row else 0
|
|
424
|
+
|
|
425
|
+
def count_scans(self) -> int:
|
|
426
|
+
row = self.db.query_one("SELECT COUNT(*) AS c FROM scans")
|
|
427
|
+
return int(row["c"]) if row else 0
|
|
428
|
+
|
|
429
|
+
def severity_counts_for_scan(self, scan_id: int) -> dict[str, int]:
|
|
430
|
+
return self.severity_counts(scan_id=scan_id)
|
|
431
|
+
|
|
432
|
+
# ----------------------------------------------------------- scan events
|
|
433
|
+
def add_event(self, scan_id: int, stage: str, *, status: str = "running", message: str = "", data: dict | None = None) -> dict:
|
|
434
|
+
cursor = self.db.execute(
|
|
435
|
+
"INSERT INTO scan_events (scan_id, ts, stage, status, message, data) VALUES (?, ?, ?, ?, ?, ?)",
|
|
436
|
+
(scan_id, utcnow(), stage, status, message, _dumps(data or {})),
|
|
437
|
+
)
|
|
438
|
+
return {"id": cursor.lastrowid, "scan_id": scan_id, "stage": stage, "status": status, "message": message, "data": data or {}}
|
|
439
|
+
|
|
440
|
+
def list_events(self, scan_id: int, after_id: int = 0) -> list[dict]:
|
|
441
|
+
rows = self.db.query(
|
|
442
|
+
"SELECT * FROM scan_events WHERE scan_id = ? AND id > ? ORDER BY id",
|
|
443
|
+
(scan_id, after_id),
|
|
444
|
+
)
|
|
445
|
+
result = []
|
|
446
|
+
for row in rows:
|
|
447
|
+
item = dict(row)
|
|
448
|
+
item["data"] = _loads(item.get("data"), {})
|
|
449
|
+
result.append(item)
|
|
450
|
+
return result
|
|
451
|
+
|
|
452
|
+
# -------------------------------------------------------------------- AI
|
|
453
|
+
def add_ai_review(self, *, finding_id: int | None, scan_id: int | None, provider: str, model: str, kind: str, content: dict | None, error: str | None) -> dict:
|
|
454
|
+
cursor = self.db.execute(
|
|
455
|
+
"""
|
|
456
|
+
INSERT INTO ai_reviews (finding_id, scan_id, provider, model, kind, created_at, content, error)
|
|
457
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
458
|
+
""",
|
|
459
|
+
(finding_id, scan_id, provider, model, kind, utcnow(), _dumps(content) if content is not None else None, error),
|
|
460
|
+
)
|
|
461
|
+
return {"id": cursor.lastrowid}
|
|
462
|
+
|
|
463
|
+
def latest_ai_review(self, finding_id: int, kind: str | None = None) -> dict | None:
|
|
464
|
+
clause = "WHERE finding_id = ?"
|
|
465
|
+
params: list[Any] = [finding_id]
|
|
466
|
+
if kind:
|
|
467
|
+
clause += " AND kind = ?"
|
|
468
|
+
params.append(kind)
|
|
469
|
+
row = self.db.query_one(f"SELECT * FROM ai_reviews {clause} ORDER BY id DESC LIMIT 1", tuple(params))
|
|
470
|
+
if not row:
|
|
471
|
+
return None
|
|
472
|
+
data = dict(row)
|
|
473
|
+
data["content"] = _loads(data.get("content"), {})
|
|
474
|
+
return data
|
|
475
|
+
|
|
476
|
+
def create_conversation(self, repository_id: int | None, title: str) -> dict:
|
|
477
|
+
now = utcnow()
|
|
478
|
+
cursor = self.db.execute(
|
|
479
|
+
"INSERT INTO ai_conversations (repository_id, title, created_at, updated_at) VALUES (?, ?, ?, ?)",
|
|
480
|
+
(repository_id, title, now, now),
|
|
481
|
+
)
|
|
482
|
+
return {"id": cursor.lastrowid, "repository_id": repository_id, "title": title, "created_at": now, "updated_at": now}
|
|
483
|
+
|
|
484
|
+
def list_conversations(self, repository_id: int | None = None) -> list[dict]:
|
|
485
|
+
if repository_id is not None:
|
|
486
|
+
rows = self.db.query(
|
|
487
|
+
"SELECT * FROM ai_conversations WHERE repository_id = ? ORDER BY id DESC",
|
|
488
|
+
(repository_id,),
|
|
489
|
+
)
|
|
490
|
+
else:
|
|
491
|
+
rows = self.db.query("SELECT * FROM ai_conversations ORDER BY id DESC")
|
|
492
|
+
return [dict(row) for row in rows]
|
|
493
|
+
|
|
494
|
+
def get_conversation(self, conversation_id: int) -> dict | None:
|
|
495
|
+
row = self.db.query_one("SELECT * FROM ai_conversations WHERE id = ?", (conversation_id,))
|
|
496
|
+
if not row:
|
|
497
|
+
return None
|
|
498
|
+
data = dict(row)
|
|
499
|
+
data["messages"] = self.list_messages(conversation_id)
|
|
500
|
+
return data
|
|
501
|
+
|
|
502
|
+
def delete_conversation(self, conversation_id: int) -> bool:
|
|
503
|
+
cursor = self.db.execute("DELETE FROM ai_conversations WHERE id = ?", (conversation_id,))
|
|
504
|
+
return cursor.rowcount > 0
|
|
505
|
+
|
|
506
|
+
def add_message(self, conversation_id: int, role: str, content: str, metadata: dict | None = None) -> dict:
|
|
507
|
+
now = utcnow()
|
|
508
|
+
cursor = self.db.execute(
|
|
509
|
+
"INSERT INTO ai_messages (conversation_id, role, content, created_at, metadata) VALUES (?, ?, ?, ?, ?)",
|
|
510
|
+
(conversation_id, role, content, now, _dumps(metadata or {})),
|
|
511
|
+
)
|
|
512
|
+
self.db.execute("UPDATE ai_conversations SET updated_at = ? WHERE id = ?", (now, conversation_id))
|
|
513
|
+
return {"id": cursor.lastrowid, "conversation_id": conversation_id, "role": role, "content": content, "created_at": now, "metadata": metadata or {}}
|
|
514
|
+
|
|
515
|
+
def list_messages(self, conversation_id: int) -> list[dict]:
|
|
516
|
+
rows = self.db.query("SELECT * FROM ai_messages WHERE conversation_id = ? ORDER BY id", (conversation_id,))
|
|
517
|
+
result = []
|
|
518
|
+
for row in rows:
|
|
519
|
+
item = dict(row)
|
|
520
|
+
item["metadata"] = _loads(item.get("metadata"), {})
|
|
521
|
+
result.append(item)
|
|
522
|
+
return result
|
|
523
|
+
|
|
524
|
+
# ------------------------------------------------------- remediation
|
|
525
|
+
def create_proposal(self, *, finding_id: int | None, repository_id: int, provider: str, model: str, file: str, original: str, proposed: str, diff: str, rationale: str) -> dict:
|
|
526
|
+
now = utcnow()
|
|
527
|
+
cursor = self.db.execute(
|
|
528
|
+
"""
|
|
529
|
+
INSERT INTO remediation_proposals
|
|
530
|
+
(finding_id, repository_id, status, created_at, updated_at, provider, model, file, original, proposed, diff, rationale)
|
|
531
|
+
VALUES (?, ?, 'proposed', ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
532
|
+
""",
|
|
533
|
+
(finding_id, repository_id, now, now, provider, model, file, original, proposed, diff, rationale),
|
|
534
|
+
)
|
|
535
|
+
self.add_remediation_history(cursor.lastrowid, "proposed", detail="AI generated a proposed patch")
|
|
536
|
+
return self.get_proposal(cursor.lastrowid)
|
|
537
|
+
|
|
538
|
+
def get_proposal(self, proposal_id: int) -> dict | None:
|
|
539
|
+
row = self.db.query_one("SELECT * FROM remediation_proposals WHERE id = ?", (proposal_id,))
|
|
540
|
+
if not row:
|
|
541
|
+
return None
|
|
542
|
+
data = dict(row)
|
|
543
|
+
data["history"] = self.list_remediation_history(proposal_id)
|
|
544
|
+
return data
|
|
545
|
+
|
|
546
|
+
def list_proposals(self, repository_id: int | None = None, finding_id: int | None = None) -> list[dict]:
|
|
547
|
+
clauses = []
|
|
548
|
+
params: list[Any] = []
|
|
549
|
+
if repository_id is not None:
|
|
550
|
+
clauses.append("repository_id = ?")
|
|
551
|
+
params.append(repository_id)
|
|
552
|
+
if finding_id is not None:
|
|
553
|
+
clauses.append("finding_id = ?")
|
|
554
|
+
params.append(finding_id)
|
|
555
|
+
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
556
|
+
rows = self.db.query(f"SELECT * FROM remediation_proposals {where} ORDER BY id DESC", tuple(params))
|
|
557
|
+
return [dict(row) for row in rows]
|
|
558
|
+
|
|
559
|
+
def update_proposal(self, proposal_id: int, **fields) -> dict | None:
|
|
560
|
+
if not fields:
|
|
561
|
+
return self.get_proposal(proposal_id)
|
|
562
|
+
columns = [f"{key} = ?" for key in fields]
|
|
563
|
+
params: list[Any] = list(fields.values())
|
|
564
|
+
columns.append("updated_at = ?")
|
|
565
|
+
params.append(utcnow())
|
|
566
|
+
params.append(proposal_id)
|
|
567
|
+
self.db.execute(f"UPDATE remediation_proposals SET {', '.join(columns)} WHERE id = ?", tuple(params))
|
|
568
|
+
return self.get_proposal(proposal_id)
|
|
569
|
+
|
|
570
|
+
def add_remediation_history(self, proposal_id: int, action: str, *, actor: str = "local-user", detail: str = "") -> dict:
|
|
571
|
+
cursor = self.db.execute(
|
|
572
|
+
"INSERT INTO remediation_history (proposal_id, action, ts, actor, detail) VALUES (?, ?, ?, ?, ?)",
|
|
573
|
+
(proposal_id, action, utcnow(), actor, detail),
|
|
574
|
+
)
|
|
575
|
+
return {"id": cursor.lastrowid, "proposal_id": proposal_id, "action": action, "detail": detail}
|
|
576
|
+
|
|
577
|
+
def list_remediation_history(self, proposal_id: int) -> list[dict]:
|
|
578
|
+
rows = self.db.query("SELECT * FROM remediation_history WHERE proposal_id = ? ORDER BY id", (proposal_id,))
|
|
579
|
+
return [dict(row) for row in rows]
|
|
580
|
+
|
|
581
|
+
# ------------------------------------------------------------- settings
|
|
582
|
+
def get_setting(self, key: str, default: Any = None) -> Any:
|
|
583
|
+
row = self.db.query_one("SELECT value FROM app_settings WHERE key = ?", (key,))
|
|
584
|
+
return _loads(row["value"], default) if row else default
|
|
585
|
+
|
|
586
|
+
def set_setting(self, key: str, value: Any) -> None:
|
|
587
|
+
self.db.execute(
|
|
588
|
+
"""
|
|
589
|
+
INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
|
590
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
|
591
|
+
""",
|
|
592
|
+
(key, _dumps(value), utcnow()),
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
def all_settings(self) -> dict:
|
|
596
|
+
rows = self.db.query("SELECT key, value FROM app_settings")
|
|
597
|
+
return {row["key"]: _loads(row["value"], None) for row in rows}
|
|
598
|
+
|
|
599
|
+
def delete_setting(self, key: str) -> None:
|
|
600
|
+
self.db.execute("DELETE FROM app_settings WHERE key = ?", (key,))
|