1use anyhow::{Context, Result};
2use rusqlite::{Connection, params};
3
4use super::{AgentRun, ReviewFinding};
5
6pub fn insert_agent_run(conn: &Connection, agent_run: &AgentRun) -> Result<i64> {
7 conn.execute(
8 "INSERT INTO agent_runs (run_id, agent, cycle, status, cost_usd, turns, \
9 started_at, finished_at, output_summary, error_message, raw_output) \
10 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
11 params![
12 agent_run.run_id,
13 agent_run.agent,
14 agent_run.cycle,
15 agent_run.status,
16 agent_run.cost_usd,
17 agent_run.turns,
18 agent_run.started_at,
19 agent_run.finished_at,
20 agent_run.output_summary,
21 agent_run.error_message,
22 agent_run.raw_output,
23 ],
24 )
25 .context("inserting agent run")?;
26 Ok(conn.last_insert_rowid())
27}
28
29pub fn get_agent_runs_for_run(conn: &Connection, run_id: &str) -> Result<Vec<AgentRun>> {
30 let mut stmt = conn
31 .prepare(
32 "SELECT id, run_id, agent, cycle, status, cost_usd, turns, \
33 started_at, finished_at, output_summary, error_message, raw_output \
34 FROM agent_runs WHERE run_id = ?1 ORDER BY id",
35 )
36 .context("preparing get_agent_runs_for_run")?;
37
38 let rows = stmt
39 .query_map(params![run_id], |row| {
40 Ok(AgentRun {
41 id: row.get(0)?,
42 run_id: row.get(1)?,
43 agent: row.get(2)?,
44 cycle: row.get(3)?,
45 status: row.get(4)?,
46 cost_usd: row.get(5)?,
47 turns: row.get(6)?,
48 started_at: row.get(7)?,
49 finished_at: row.get(8)?,
50 output_summary: row.get(9)?,
51 error_message: row.get(10)?,
52 raw_output: row.get(11)?,
53 })
54 })
55 .context("querying agent runs")?;
56
57 rows.collect::<std::result::Result<Vec<_>, _>>().context("collecting agent runs")
58}
59
60#[allow(clippy::too_many_arguments)]
61pub fn finish_agent_run(
62 conn: &Connection,
63 id: i64,
64 status: &str,
65 cost_usd: f64,
66 turns: u32,
67 output_summary: Option<&str>,
68 error_message: Option<&str>,
69 raw_output: Option<&str>,
70) -> Result<()> {
71 conn.execute(
72 "UPDATE agent_runs SET status = ?1, cost_usd = ?2, turns = ?3, \
73 finished_at = datetime('now'), output_summary = ?4, error_message = ?5, \
74 raw_output = ?6 WHERE id = ?7",
75 params![status, cost_usd, turns, output_summary, error_message, raw_output, id],
76 )
77 .context("finishing agent run")?;
78 Ok(())
79}
80
81pub fn insert_finding(conn: &Connection, finding: &ReviewFinding) -> Result<i64> {
82 conn.execute(
83 "INSERT INTO review_findings (agent_run_id, severity, category, file_path, \
84 line_number, message, resolved, dispute_reason) \
85 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
86 params![
87 finding.agent_run_id,
88 finding.severity,
89 finding.category,
90 finding.file_path,
91 finding.line_number,
92 finding.message,
93 finding.resolved,
94 finding.dispute_reason,
95 ],
96 )
97 .context("inserting finding")?;
98 Ok(conn.last_insert_rowid())
99}
100
101pub fn get_findings_for_agent_run(
102 conn: &Connection,
103 agent_run_id: i64,
104) -> Result<Vec<ReviewFinding>> {
105 let mut stmt = conn
106 .prepare(
107 "SELECT id, agent_run_id, severity, category, file_path, line_number, \
108 message, resolved, dispute_reason FROM review_findings WHERE agent_run_id = ?1",
109 )
110 .context("preparing get_findings_for_agent_run")?;
111
112 let rows =
113 stmt.query_map(params![agent_run_id], row_to_finding).context("querying findings")?;
114
115 rows.collect::<std::result::Result<Vec<_>, _>>().context("collecting findings")
116}
117
118pub fn get_unresolved_findings(conn: &Connection, run_id: &str) -> Result<Vec<ReviewFinding>> {
119 let mut stmt = conn
120 .prepare(
121 "SELECT f.id, f.agent_run_id, f.severity, f.category, f.file_path, \
122 f.line_number, f.message, f.resolved, f.dispute_reason \
123 FROM review_findings f \
124 JOIN agent_runs a ON f.agent_run_id = a.id \
125 WHERE a.run_id = ?1 AND f.resolved = 0 AND f.severity != 'info'",
126 )
127 .context("preparing get_unresolved_findings")?;
128
129 let rows =
130 stmt.query_map(params![run_id], row_to_finding).context("querying unresolved findings")?;
131
132 rows.collect::<std::result::Result<Vec<_>, _>>().context("collecting unresolved findings")
133}
134
135pub fn resolve_finding(conn: &Connection, finding_id: i64, reason: &str) -> Result<()> {
136 conn.execute(
137 "UPDATE review_findings SET resolved = 1, dispute_reason = ?2 WHERE id = ?1",
138 params![finding_id, reason],
139 )
140 .context("resolving finding")?;
141 Ok(())
142}
143
144fn row_to_finding(row: &rusqlite::Row<'_>) -> rusqlite::Result<ReviewFinding> {
145 Ok(ReviewFinding {
146 id: row.get(0)?,
147 agent_run_id: row.get(1)?,
148 severity: row.get(2)?,
149 category: row.get(3)?,
150 file_path: row.get(4)?,
151 line_number: row.get(5)?,
152 message: row.get(6)?,
153 resolved: row.get(7)?,
154 dispute_reason: row.get(8)?,
155 })
156}
157
158pub fn get_resolved_findings(conn: &Connection, run_id: &str) -> Result<Vec<ReviewFinding>> {
159 let mut stmt = conn
160 .prepare(
161 "SELECT f.id, f.agent_run_id, f.severity, f.category, f.file_path, \
162 f.line_number, f.message, f.resolved, f.dispute_reason \
163 FROM review_findings f \
164 JOIN agent_runs a ON f.agent_run_id = a.id \
165 WHERE a.run_id = ?1 AND f.resolved = 1",
166 )
167 .context("preparing get_resolved_findings")?;
168
169 let rows =
170 stmt.query_map(params![run_id], row_to_finding).context("querying resolved findings")?;
171
172 rows.collect::<std::result::Result<Vec<_>, _>>().context("collecting resolved findings")
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178 use crate::db::{self, RunStatus, runs};
179
180 fn test_db() -> Connection {
181 db::open_in_memory().unwrap()
182 }
183
184 fn insert_test_run(conn: &Connection, id: &str) {
185 runs::insert_run(
186 conn,
187 &db::Run {
188 id: id.to_string(),
189 issue_number: 1,
190 status: RunStatus::Pending,
191 pr_number: None,
192 branch: None,
193 worktree_path: None,
194 cost_usd: 0.0,
195 auto_merge: false,
196 started_at: "2026-03-12T00:00:00".to_string(),
197 finished_at: None,
198 error_message: None,
199 complexity: "full".to_string(),
200 issue_source: "github".to_string(),
201 },
202 )
203 .unwrap();
204 }
205
206 fn sample_agent_run(run_id: &str, agent: &str) -> AgentRun {
207 AgentRun {
208 id: 0,
209 run_id: run_id.to_string(),
210 agent: agent.to_string(),
211 cycle: 1,
212 status: "running".to_string(),
213 cost_usd: 0.0,
214 turns: 0,
215 started_at: "2026-03-12T00:00:00".to_string(),
216 finished_at: None,
217 output_summary: None,
218 error_message: None,
219 raw_output: None,
220 }
221 }
222
223 #[test]
224 fn insert_and_get_agent_run() {
225 let conn = test_db();
226 insert_test_run(&conn, "run1");
227
228 let ar = sample_agent_run("run1", "implementer");
229 let id = insert_agent_run(&conn, &ar).unwrap();
230 assert!(id > 0);
231
232 let runs = get_agent_runs_for_run(&conn, "run1").unwrap();
233 assert_eq!(runs.len(), 1);
234 assert_eq!(runs[0].agent, "implementer");
235 }
236
237 #[test]
238 fn finish_agent_run_updates_fields() {
239 let conn = test_db();
240 insert_test_run(&conn, "run1");
241 let id = insert_agent_run(&conn, &sample_agent_run("run1", "reviewer")).unwrap();
242
243 finish_agent_run(&conn, id, "complete", 1.50, 8, Some("all good"), None, None).unwrap();
244
245 let runs = get_agent_runs_for_run(&conn, "run1").unwrap();
246 assert_eq!(runs[0].status, "complete");
247 assert!((runs[0].cost_usd - 1.50).abs() < f64::EPSILON);
248 assert_eq!(runs[0].turns, 8);
249 assert_eq!(runs[0].output_summary.as_deref(), Some("all good"));
250 assert!(runs[0].finished_at.is_some());
251 }
252
253 #[test]
254 fn insert_and_get_findings() {
255 let conn = test_db();
256 insert_test_run(&conn, "run1");
257 let ar_id = insert_agent_run(&conn, &sample_agent_run("run1", "reviewer")).unwrap();
258
259 let finding = ReviewFinding {
260 id: 0,
261 agent_run_id: ar_id,
262 severity: "critical".to_string(),
263 category: "bug".to_string(),
264 file_path: Some("src/main.rs".to_string()),
265 line_number: Some(42),
266 message: "null pointer".to_string(),
267 resolved: false,
268 dispute_reason: None,
269 };
270 let fid = insert_finding(&conn, &finding).unwrap();
271 assert!(fid > 0);
272
273 let findings = get_findings_for_agent_run(&conn, ar_id).unwrap();
274 assert_eq!(findings.len(), 1);
275 assert_eq!(findings[0].severity, "critical");
276 assert_eq!(findings[0].message, "null pointer");
277 }
278
279 #[test]
280 fn resolve_finding_updates_flag() {
281 let conn = test_db();
282 insert_test_run(&conn, "run1");
283 let ar_id = insert_agent_run(&conn, &sample_agent_run("run1", "reviewer")).unwrap();
284
285 let finding = ReviewFinding {
286 id: 0,
287 agent_run_id: ar_id,
288 severity: "warning".to_string(),
289 category: "style".to_string(),
290 file_path: None,
291 line_number: None,
292 message: "missing docs".to_string(),
293 resolved: false,
294 dispute_reason: None,
295 };
296 let fid = insert_finding(&conn, &finding).unwrap();
297
298 resolve_finding(&conn, fid, "test reason").unwrap();
299
300 let findings = get_findings_for_agent_run(&conn, ar_id).unwrap();
301 assert!(findings[0].resolved);
302 assert_eq!(findings[0].dispute_reason.as_deref(), Some("test reason"));
303 }
304
305 #[test]
306 fn get_unresolved_findings_filters() {
307 let conn = test_db();
308 insert_test_run(&conn, "run1");
309 let ar_id = insert_agent_run(&conn, &sample_agent_run("run1", "reviewer")).unwrap();
310
311 insert_finding(
313 &conn,
314 &ReviewFinding {
315 id: 0,
316 agent_run_id: ar_id,
317 severity: "critical".to_string(),
318 category: "bug".to_string(),
319 file_path: None,
320 line_number: None,
321 message: "bad".to_string(),
322 resolved: false,
323 dispute_reason: None,
324 },
325 )
326 .unwrap();
327
328 insert_finding(
330 &conn,
331 &ReviewFinding {
332 id: 0,
333 agent_run_id: ar_id,
334 severity: "info".to_string(),
335 category: "note".to_string(),
336 file_path: None,
337 line_number: None,
338 message: "fyi".to_string(),
339 resolved: false,
340 dispute_reason: None,
341 },
342 )
343 .unwrap();
344
345 let wid = insert_finding(
347 &conn,
348 &ReviewFinding {
349 id: 0,
350 agent_run_id: ar_id,
351 severity: "warning".to_string(),
352 category: "style".to_string(),
353 file_path: None,
354 line_number: None,
355 message: "meh".to_string(),
356 resolved: false,
357 dispute_reason: None,
358 },
359 )
360 .unwrap();
361 resolve_finding(&conn, wid, "not applicable").unwrap();
362
363 let unresolved = get_unresolved_findings(&conn, "run1").unwrap();
364 assert_eq!(unresolved.len(), 1);
365 assert_eq!(unresolved[0].message, "bad");
366 }
367
368 #[test]
369 fn raw_output_round_trips() {
370 let conn = test_db();
371 insert_test_run(&conn, "run1");
372 let id = insert_agent_run(&conn, &sample_agent_run("run1", "implementer")).unwrap();
373
374 let raw = r#"{"batches":[{"batch":1,"issues":[]}]}"#;
375 finish_agent_run(&conn, id, "complete", 0.5, 3, Some("ok"), None, Some(raw)).unwrap();
376
377 let runs = get_agent_runs_for_run(&conn, "run1").unwrap();
378 assert_eq!(runs[0].raw_output.as_deref(), Some(raw));
379 }
380
381 #[test]
382 fn cascade_delete_removes_agent_runs_and_findings() {
383 let conn = test_db();
384 insert_test_run(&conn, "run1");
385 let ar_id = insert_agent_run(&conn, &sample_agent_run("run1", "reviewer")).unwrap();
386 insert_finding(
387 &conn,
388 &ReviewFinding {
389 id: 0,
390 agent_run_id: ar_id,
391 severity: "critical".to_string(),
392 category: "bug".to_string(),
393 file_path: None,
394 line_number: None,
395 message: "bad".to_string(),
396 resolved: false,
397 dispute_reason: None,
398 },
399 )
400 .unwrap();
401
402 conn.execute("DELETE FROM runs WHERE id = ?1", params!["run1"]).unwrap();
404
405 let agent_runs = get_agent_runs_for_run(&conn, "run1").unwrap();
407 assert!(agent_runs.is_empty());
408
409 let findings = get_findings_for_agent_run(&conn, ar_id).unwrap();
410 assert!(findings.is_empty());
411 }
412
413 #[test]
414 fn resolve_finding_stores_dispute_reason() {
415 let conn = test_db();
416 insert_test_run(&conn, "run1");
417 let ar_id = insert_agent_run(&conn, &sample_agent_run("run1", "reviewer")).unwrap();
418
419 let finding = ReviewFinding {
420 id: 0,
421 agent_run_id: ar_id,
422 severity: "critical".to_string(),
423 category: "convention".to_string(),
424 file_path: Some("src/app.rs".to_string()),
425 line_number: Some(10),
426 message: "missing estimatedItemSize".to_string(),
427 resolved: false,
428 dispute_reason: None,
429 };
430 let fid = insert_finding(&conn, &finding).unwrap();
431
432 resolve_finding(&conn, fid, "FlashList v2 removed this prop").unwrap();
433
434 let findings = get_findings_for_agent_run(&conn, ar_id).unwrap();
435 assert!(findings[0].resolved);
436 assert_eq!(findings[0].dispute_reason.as_deref(), Some("FlashList v2 removed this prop"));
437 }
438
439 #[test]
440 fn get_resolved_findings_returns_only_resolved() {
441 let conn = test_db();
442 insert_test_run(&conn, "run1");
443 let ar_id = insert_agent_run(&conn, &sample_agent_run("run1", "reviewer")).unwrap();
444
445 insert_finding(
447 &conn,
448 &ReviewFinding {
449 id: 0,
450 agent_run_id: ar_id,
451 severity: "critical".to_string(),
452 category: "bug".to_string(),
453 file_path: None,
454 line_number: None,
455 message: "unresolved".to_string(),
456 resolved: false,
457 dispute_reason: None,
458 },
459 )
460 .unwrap();
461
462 let fid = insert_finding(
464 &conn,
465 &ReviewFinding {
466 id: 0,
467 agent_run_id: ar_id,
468 severity: "warning".to_string(),
469 category: "convention".to_string(),
470 file_path: Some("src/lib.rs".to_string()),
471 line_number: None,
472 message: "disputed".to_string(),
473 resolved: false,
474 dispute_reason: None,
475 },
476 )
477 .unwrap();
478 resolve_finding(&conn, fid, "API does not exist").unwrap();
479
480 let resolved = get_resolved_findings(&conn, "run1").unwrap();
481 assert_eq!(resolved.len(), 1);
482 assert_eq!(resolved[0].message, "disputed");
483 assert_eq!(resolved[0].dispute_reason.as_deref(), Some("API does not exist"));
484 }
485
486 #[test]
487 fn get_resolved_findings_empty_when_none_resolved() {
488 let conn = test_db();
489 insert_test_run(&conn, "run1");
490 let ar_id = insert_agent_run(&conn, &sample_agent_run("run1", "reviewer")).unwrap();
491
492 insert_finding(
493 &conn,
494 &ReviewFinding {
495 id: 0,
496 agent_run_id: ar_id,
497 severity: "critical".to_string(),
498 category: "bug".to_string(),
499 file_path: None,
500 line_number: None,
501 message: "not resolved".to_string(),
502 resolved: false,
503 dispute_reason: None,
504 },
505 )
506 .unwrap();
507
508 let resolved = get_resolved_findings(&conn, "run1").unwrap();
509 assert!(resolved.is_empty());
510 }
511}