Skip to main content

oven_cli/db/
mod.rs

1pub mod agent_runs;
2pub mod runs;
3
4use std::path::Path;
5
6use anyhow::Context;
7use rusqlite::Connection;
8use rusqlite_migration::{M, Migrations};
9
10pub static MIGRATIONS: std::sync::LazyLock<Migrations<'static>> = std::sync::LazyLock::new(|| {
11    Migrations::new(vec![
12        M::up(
13            "CREATE TABLE runs (
14    id TEXT PRIMARY KEY,
15    issue_number INTEGER NOT NULL,
16    status TEXT NOT NULL DEFAULT 'pending',
17    pr_number INTEGER,
18    branch TEXT,
19    worktree_path TEXT,
20    cost_usd REAL NOT NULL DEFAULT 0.0,
21    auto_merge INTEGER NOT NULL DEFAULT 0,
22    started_at TEXT NOT NULL DEFAULT (datetime('now')),
23    finished_at TEXT,
24    error_message TEXT
25);
26
27CREATE TABLE agent_runs (
28    id INTEGER PRIMARY KEY AUTOINCREMENT,
29    run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
30    agent TEXT NOT NULL,
31    cycle INTEGER NOT NULL DEFAULT 1,
32    status TEXT NOT NULL DEFAULT 'pending',
33    cost_usd REAL NOT NULL DEFAULT 0.0,
34    turns INTEGER NOT NULL DEFAULT 0,
35    started_at TEXT NOT NULL DEFAULT (datetime('now')),
36    finished_at TEXT,
37    output_summary TEXT,
38    error_message TEXT
39);
40
41CREATE TABLE review_findings (
42    id INTEGER PRIMARY KEY AUTOINCREMENT,
43    agent_run_id INTEGER NOT NULL REFERENCES agent_runs(id) ON DELETE CASCADE,
44    severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
45    category TEXT NOT NULL,
46    file_path TEXT,
47    line_number INTEGER,
48    message TEXT NOT NULL,
49    resolved INTEGER NOT NULL DEFAULT 0
50);
51
52CREATE INDEX idx_runs_status ON runs(status);
53CREATE INDEX idx_runs_issue ON runs(issue_number);
54CREATE INDEX idx_agent_runs_run ON agent_runs(run_id);
55CREATE INDEX idx_findings_agent_run ON review_findings(agent_run_id);
56CREATE INDEX idx_findings_severity ON review_findings(severity);",
57        ),
58        M::up("ALTER TABLE runs ADD COLUMN complexity TEXT NOT NULL DEFAULT 'full';"),
59        M::up("ALTER TABLE runs ADD COLUMN issue_source TEXT NOT NULL DEFAULT 'github';"),
60        M::up("ALTER TABLE agent_runs ADD COLUMN raw_output TEXT;"),
61    ])
62});
63
64pub fn open(path: &Path) -> anyhow::Result<Connection> {
65    let mut conn = Connection::open(path).context("opening database")?;
66    configure(&conn)?;
67    MIGRATIONS.to_latest(&mut conn).context("running database migrations")?;
68    Ok(conn)
69}
70
71pub fn open_in_memory() -> anyhow::Result<Connection> {
72    let mut conn = Connection::open_in_memory().context("opening in-memory database")?;
73    configure(&conn)?;
74    MIGRATIONS.to_latest(&mut conn).context("running database migrations")?;
75    Ok(conn)
76}
77
78fn configure(conn: &Connection) -> anyhow::Result<()> {
79    conn.pragma_update(None, "journal_mode", "WAL")?;
80    conn.pragma_update(None, "synchronous", "NORMAL")?;
81    conn.pragma_update(None, "busy_timeout", "5000")?;
82    conn.pragma_update(None, "foreign_keys", "ON")?;
83    Ok(())
84}
85
86/// Run status for pipeline runs.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
88pub enum RunStatus {
89    Pending,
90    Implementing,
91    Reviewing,
92    Fixing,
93    Merging,
94    Complete,
95    Failed,
96}
97
98impl std::fmt::Display for RunStatus {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        f.write_str(match self {
101            Self::Pending => "pending",
102            Self::Implementing => "implementing",
103            Self::Reviewing => "reviewing",
104            Self::Fixing => "fixing",
105            Self::Merging => "merging",
106            Self::Complete => "complete",
107            Self::Failed => "failed",
108        })
109    }
110}
111
112impl std::str::FromStr for RunStatus {
113    type Err = anyhow::Error;
114
115    fn from_str(s: &str) -> Result<Self, Self::Err> {
116        match s {
117            "pending" => Ok(Self::Pending),
118            "implementing" => Ok(Self::Implementing),
119            "reviewing" => Ok(Self::Reviewing),
120            "fixing" => Ok(Self::Fixing),
121            "merging" => Ok(Self::Merging),
122            "complete" => Ok(Self::Complete),
123            "failed" => Ok(Self::Failed),
124            other => anyhow::bail!("unknown run status: {other}"),
125        }
126    }
127}
128
129/// A pipeline run record.
130#[derive(Debug, Clone)]
131pub struct Run {
132    pub id: String,
133    pub issue_number: u32,
134    pub status: RunStatus,
135    pub pr_number: Option<u32>,
136    pub branch: Option<String>,
137    pub worktree_path: Option<String>,
138    pub cost_usd: f64,
139    pub auto_merge: bool,
140    pub started_at: String,
141    pub finished_at: Option<String>,
142    pub error_message: Option<String>,
143    pub complexity: String,
144    pub issue_source: String,
145}
146
147/// An agent execution record.
148#[derive(Debug, Clone)]
149pub struct AgentRun {
150    pub id: i64,
151    pub run_id: String,
152    pub agent: String,
153    pub cycle: u32,
154    pub status: String,
155    pub cost_usd: f64,
156    pub turns: u32,
157    pub started_at: String,
158    pub finished_at: Option<String>,
159    pub output_summary: Option<String>,
160    pub error_message: Option<String>,
161    pub raw_output: Option<String>,
162}
163
164/// A review finding from the reviewer agent.
165#[derive(Debug, Clone)]
166pub struct ReviewFinding {
167    pub id: i64,
168    pub agent_run_id: i64,
169    pub severity: String,
170    pub category: String,
171    pub file_path: Option<String>,
172    pub line_number: Option<u32>,
173    pub message: String,
174    pub resolved: bool,
175}
176
177#[cfg(test)]
178mod tests {
179    use proptest::prelude::*;
180
181    use super::*;
182
183    const ALL_STATUSES: [RunStatus; 7] = [
184        RunStatus::Pending,
185        RunStatus::Implementing,
186        RunStatus::Reviewing,
187        RunStatus::Fixing,
188        RunStatus::Merging,
189        RunStatus::Complete,
190        RunStatus::Failed,
191    ];
192
193    proptest! {
194        #[test]
195        fn run_status_display_fromstr_roundtrip(idx in 0..7usize) {
196            let status = ALL_STATUSES[idx];
197            let s = status.to_string();
198            let parsed: RunStatus = s.parse().unwrap();
199            assert_eq!(status, parsed);
200        }
201
202        #[test]
203        fn arbitrary_strings_never_panic_on_parse(s in "\\PC{1,50}") {
204            // Parsing arbitrary strings should never panic, only return Ok or Err
205            let _ = s.parse::<RunStatus>();
206        }
207    }
208
209    #[test]
210    fn migrations_validate() {
211        MIGRATIONS.validate().unwrap();
212    }
213
214    #[test]
215    fn open_in_memory_succeeds() {
216        let conn = open_in_memory().unwrap();
217        // Verify tables exist by querying them
218        let count: i64 = conn.query_row("SELECT COUNT(*) FROM runs", [], |row| row.get(0)).unwrap();
219        assert_eq!(count, 0);
220    }
221
222    #[test]
223    fn run_status_display_roundtrip() {
224        let statuses = [
225            RunStatus::Pending,
226            RunStatus::Implementing,
227            RunStatus::Reviewing,
228            RunStatus::Fixing,
229            RunStatus::Merging,
230            RunStatus::Complete,
231            RunStatus::Failed,
232        ];
233        for status in statuses {
234            let s = status.to_string();
235            let parsed: RunStatus = s.parse().unwrap();
236            assert_eq!(status, parsed);
237        }
238    }
239
240    #[test]
241    fn run_status_unknown_returns_error() {
242        let result = "banana".parse::<RunStatus>();
243        assert!(result.is_err());
244    }
245}