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.
Files changed (58) hide show
  1. dashboard/__init__.py +10 -0
  2. dashboard/backend/__init__.py +11 -0
  3. dashboard/backend/__main__.py +6 -0
  4. dashboard/backend/ai/__init__.py +6 -0
  5. dashboard/backend/ai/provider.py +102 -0
  6. dashboard/backend/ai/service.py +757 -0
  7. dashboard/backend/api/__init__.py +5 -0
  8. dashboard/backend/api/ai.py +142 -0
  9. dashboard/backend/api/dashboard.py +34 -0
  10. dashboard/backend/api/deps.py +51 -0
  11. dashboard/backend/api/findings.py +102 -0
  12. dashboard/backend/api/intel.py +41 -0
  13. dashboard/backend/api/repositories.py +62 -0
  14. dashboard/backend/api/scans.py +168 -0
  15. dashboard/backend/api/system.py +69 -0
  16. dashboard/backend/config.py +180 -0
  17. dashboard/backend/main.py +183 -0
  18. dashboard/backend/persistence/__init__.py +5 -0
  19. dashboard/backend/persistence/database.py +278 -0
  20. dashboard/backend/persistence/store.py +600 -0
  21. dashboard/backend/profiles.py +141 -0
  22. dashboard/backend/schemas.py +452 -0
  23. dashboard/backend/security.py +158 -0
  24. dashboard/backend/services/__init__.py +7 -0
  25. dashboard/backend/services/dashboard.py +172 -0
  26. dashboard/backend/services/intel.py +341 -0
  27. dashboard/backend/services/paths.py +38 -0
  28. dashboard/backend/services/repositories.py +123 -0
  29. dashboard/backend/services/scans.py +328 -0
  30. dashboard/backend/services/settings.py +49 -0
  31. dashboard/frontend/dist/assets/index-COC-CdqD.js +492 -0
  32. dashboard/frontend/dist/assets/index-hWDLRaLr.css +1 -0
  33. dashboard/frontend/dist/index.html +29 -0
  34. dashboard/frontend/dist/phone-mascots.gif +0 -0
  35. dashboard/launcher.py +48 -0
  36. vibe_coding_qa/__init__.py +140 -0
  37. vibe_coding_qa/__main__.py +5 -0
  38. vibe_coding_qa/ai_review.py +165 -0
  39. vibe_coding_qa/cli.py +157 -0
  40. vibe_coding_qa/engine.py +860 -0
  41. vibe_coding_qa/fixes.py +222 -0
  42. vibe_coding_qa/gates.py +300 -0
  43. vibe_coding_qa/profile.py +471 -0
  44. vibe_coding_qa/repo.py +188 -0
  45. vibe_coding_qa/reporters/__init__.py +253 -0
  46. vibe_coding_qa/scanners/__init__.py +195 -0
  47. vibe_coding_qa/scanners/compliance.py +167 -0
  48. vibe_coding_qa/scanners/database.py +316 -0
  49. vibe_coding_qa/scanners/dependencies.py +595 -0
  50. vibe_coding_qa/scanners/infrastructure.py +125 -0
  51. vibe_coding_qa/scanners/performance.py +84 -0
  52. vibe_coding_qa/scanners/quality.py +129 -0
  53. vibe_coding_qa/scanners/security.py +521 -0
  54. vibe_coding_qa-0.5.0.dist-info/METADATA +323 -0
  55. vibe_coding_qa-0.5.0.dist-info/RECORD +58 -0
  56. vibe_coding_qa-0.5.0.dist-info/WHEEL +5 -0
  57. vibe_coding_qa-0.5.0.dist-info/entry_points.txt +3 -0
  58. 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,))