grits_core/
store.rs

1use crate::fs::FileSystem;
2use crate::models::Issue;
3#[cfg(not(target_arch = "wasm32"))]
4use crate::models::{Comment, Dependency};
5use anyhow::Result;
6use std::path::Path;
7
8pub trait Store {
9    fn get_config(&self, key: &str) -> Result<Option<String>>;
10    fn set_config(&self, key: &str, value: &str) -> Result<()>;
11    fn update_issue(&self, issue: &Issue) -> Result<()>;
12    fn list_config(&self) -> Result<Vec<(String, String)>>;
13    fn execute_raw(&self, sql: &str) -> Result<()>;
14    fn get_issue(&self, id: &str) -> Result<Option<Issue>>;
15    fn list_issues(
16        &self,
17        status: Option<&str>,
18        assignee: Option<&str>,
19        priority: Option<i32>,
20        issue_type: Option<&str>,
21        label: Option<&str>,
22        sort_by: Option<&str>,
23    ) -> Result<Vec<Issue>>;
24    fn import_from_jsonl(&mut self, jsonl_path: &Path, fs: &dyn FileSystem) -> Result<()>;
25    fn generate_unique_id(
26        &self,
27        prefix: &str,
28        title: &str,
29        description: &str,
30        creator: &str,
31    ) -> Result<String>;
32    fn create_issue(&self, issue: &Issue) -> Result<()>;
33    fn export_to_jsonl(&self, jsonl_path: &Path, fs: &dyn FileSystem) -> Result<()>;
34}
35
36#[cfg(not(target_arch = "wasm32"))]
37pub use sqlite_impl::SqliteStore;
38
39#[cfg(not(target_arch = "wasm32"))]
40pub mod sqlite_impl {
41    use super::*;
42    use crate::util;
43    use chrono::{DateTime, NaiveDateTime, Utc};
44    use rusqlite::{params, Connection};
45    use sha2::{Digest, Sha256};
46    use std::collections::HashMap;
47    use std::io::{BufRead, BufWriter, Write};
48
49    pub struct SqliteStore {
50        conn: Connection,
51    }
52
53    impl SqliteStore {
54        pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
55            let conn = Connection::open(&path)?;
56
57            conn.execute_batch(
58                "
59                CREATE TABLE IF NOT EXISTS issues (
60                    id TEXT PRIMARY KEY,
61                    content_hash TEXT DEFAULT '',
62                    title TEXT,
63                    description TEXT,
64                    design TEXT DEFAULT '',
65                    acceptance_criteria TEXT DEFAULT '',
66                    notes TEXT DEFAULT '',
67                    status TEXT,
68                    priority INTEGER,
69                    issue_type TEXT,
70                    assignee TEXT,
71                    estimated_minutes INTEGER,
72                    created_at TEXT,
73                    updated_at TEXT,
74                    closed_at TEXT,
75                    external_ref TEXT,
76                    sender TEXT DEFAULT '',
77                    ephemeral BOOLEAN DEFAULT 0,
78                    replies_to TEXT DEFAULT '',
79                    relates_to TEXT DEFAULT '',
80                    duplicate_of TEXT DEFAULT '',
81                    superseded_by TEXT DEFAULT '',
82                    deleted_at TEXT,
83                    deleted_by TEXT DEFAULT '',
84                    delete_reason TEXT DEFAULT '',
85                    original_type TEXT DEFAULT '',
86                    affected_symbols TEXT DEFAULT '',
87                    solid_volume TEXT,
88                    topology_hash TEXT DEFAULT '',
89                    is_solid BOOLEAN DEFAULT 0
90                );
91
92                CREATE TABLE IF NOT EXISTS labels (
93                    issue_id TEXT,
94                    label TEXT,
95                    PRIMARY KEY (issue_id, label)
96                );
97
98                CREATE TABLE IF NOT EXISTS dependencies (
99                    issue_id TEXT,
100                    depends_on_id TEXT,
101                    type TEXT,
102                    created_at TEXT,
103                    created_by TEXT,
104                    PRIMARY KEY (issue_id, depends_on_id, type)
105                );
106
107                CREATE TABLE IF NOT EXISTS comments (
108                    id TEXT PRIMARY KEY,
109                    issue_id TEXT,
110                    author TEXT,
111                    text TEXT,
112                    created_at TEXT
113                );
114
115                CREATE TABLE IF NOT EXISTS dirty_issues (
116                    issue_id TEXT PRIMARY KEY
117                );
118
119                CREATE TABLE IF NOT EXISTS metadata (
120                    key TEXT PRIMARY KEY,
121                    value TEXT
122                );
123
124                CREATE TABLE IF NOT EXISTS config (
125                    key TEXT PRIMARY KEY,
126                    value TEXT
127                );
128                ",
129            )?;
130
131            Ok(SqliteStore { conn })
132        }
133
134        fn export_all_issues(&self) -> Result<Vec<Issue>> {
135            // Fetch all related data first (bulk)
136            let labels_map = self.get_all_labels()?;
137            let deps_map = self.get_all_dependencies()?;
138            let comments_map = self.get_all_comments()?;
139
140            let mut stmt = self.conn.prepare(
141                "SELECT
142                    id, content_hash, title, description, design, acceptance_criteria, notes,
143                    status, priority, issue_type, assignee, estimated_minutes,
144                    created_at, updated_at, closed_at, external_ref,
145                    sender, ephemeral, replies_to, relates_to, duplicate_of, superseded_by,
146                    deleted_at, deleted_by, delete_reason, original_type,
147                    affected_symbols, solid_volume, topology_hash, is_solid
148                FROM issues
149                ORDER BY id",
150            )?;
151
152            let issue_iter = stmt.query_map([], |row| {
153                let id: String = row.get(0)?;
154                let created_at_s: String = row.get(12)?;
155                let updated_at_s: String = row.get(13)?;
156                let closed_at_s: Option<String> = row.get(14)?;
157                let deleted_at_s: Option<String> = row.get(22)?;
158
159                let created_at = parse_timestamp(&created_at_s).unwrap_or_else(|| Utc::now());
160                let updated_at = parse_timestamp(&updated_at_s).unwrap_or_else(|| Utc::now());
161                let closed_at = closed_at_s.and_then(|s| parse_timestamp(&s));
162                let deleted_at = deleted_at_s.and_then(|s| parse_timestamp(&s));
163
164                let relates_to_s: String = row.get(19).unwrap_or_default();
165                let relates_to = if relates_to_s.is_empty() {
166                    Vec::new()
167                } else {
168                    serde_json::from_str(&relates_to_s).unwrap_or_default()
169                };
170
171                let affected_symbols_s: String = row.get(26).unwrap_or_default();
172                let affected_symbols = if affected_symbols_s.is_empty() {
173                    Vec::new()
174                } else {
175                    serde_json::from_str(&affected_symbols_s).unwrap_or_default()
176                };
177
178                Ok(Issue {
179                    id: id.clone(),
180                    content_hash: row.get(1).unwrap_or_default(),
181                    title: row.get(2).unwrap_or_default(),
182                    description: row.get(3).unwrap_or_default(),
183                    design: row.get(4).unwrap_or_default(),
184                    acceptance_criteria: row.get(5).unwrap_or_default(),
185                    notes: row.get(6).unwrap_or_default(),
186                    status: row.get(7).unwrap_or_default(),
187                    priority: row.get(8).unwrap_or_default(),
188                    issue_type: row.get(9).unwrap_or_default(),
189                    assignee: row.get(10)?,
190                    estimated_minutes: row.get(11)?,
191                    created_at,
192                    updated_at,
193                    closed_at,
194                    external_ref: row.get(15)?,
195                    sender: row.get(16).unwrap_or_default(),
196                    ephemeral: row.get(17).unwrap_or(false),
197                    replies_to: row.get(18).unwrap_or_default(),
198                    relates_to,
199                    duplicate_of: row.get(20).unwrap_or_default(),
200                    superseded_by: row.get(21).unwrap_or_default(),
201
202                    deleted_at,
203                    deleted_by: row.get(23).unwrap_or_default(),
204                    delete_reason: row.get(24).unwrap_or_default(),
205                    original_type: row.get(25).unwrap_or_default(),
206
207                    labels: labels_map.get(&id).cloned().unwrap_or_default(),
208                    dependencies: deps_map.get(&id).cloned().unwrap_or_default(),
209                    comments: comments_map.get(&id).cloned().unwrap_or_default(),
210
211                    affected_symbols,
212                    solid_volume: row.get(27).ok(),
213                    topology_hash: row.get(28).unwrap_or_default(),
214                    is_solid: row.get(29).unwrap_or(false),
215                })
216            })?;
217
218            let mut issues = Vec::new();
219            for issue in issue_iter {
220                issues.push(issue?);
221            }
222            Ok(issues)
223        }
224
225        fn get_all_labels(&self) -> Result<HashMap<String, Vec<String>>> {
226            let mut stmt = self.conn.prepare("SELECT issue_id, label FROM labels")?;
227            let rows = stmt.query_map([], |row| {
228                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
229            })?;
230
231            let mut map: HashMap<String, Vec<String>> = HashMap::new();
232            for row in rows {
233                let (id, label) = row?;
234                map.entry(id).or_default().push(label);
235            }
236            Ok(map)
237        }
238
239        fn get_all_dependencies(&self) -> Result<HashMap<String, Vec<Dependency>>> {
240            let mut stmt = self.conn.prepare(
241                "SELECT issue_id, depends_on_id, type, created_at, created_by FROM dependencies",
242            )?;
243            let rows = stmt.query_map([], |row| {
244                let created_at_s: String = row.get(3)?;
245                let created_at = parse_timestamp(&created_at_s).unwrap_or_else(|| Utc::now());
246
247                Ok(Dependency {
248                    issue_id: row.get(0)?,
249                    depends_on_id: row.get(1)?,
250                    type_: row.get(2)?,
251                    created_at,
252                    created_by: row.get(4)?,
253                })
254            })?;
255
256            let mut map: HashMap<String, Vec<Dependency>> = HashMap::new();
257            for row in rows {
258                let dep = row?;
259                map.entry(dep.issue_id.clone()).or_default().push(dep);
260            }
261            Ok(map)
262        }
263
264        fn get_all_comments(&self) -> Result<HashMap<String, Vec<Comment>>> {
265            let mut stmt = self
266                .conn
267                .prepare("SELECT id, issue_id, author, text, created_at FROM comments")?;
268            let rows = stmt.query_map([], |row| {
269                let created_at_s: String = row.get(4)?;
270                let created_at = parse_timestamp(&created_at_s).unwrap_or_else(|| Utc::now());
271
272                Ok(Comment {
273                    id: row.get(0)?,
274                    issue_id: row.get(1)?,
275                    author: row.get(2)?,
276                    text: row.get(3)?,
277                    created_at,
278                })
279            })?;
280
281            let mut map: HashMap<String, Vec<Comment>> = HashMap::new();
282            for row in rows {
283                let comment = row?;
284                map.entry(comment.issue_id.clone())
285                    .or_default()
286                    .push(comment);
287            }
288            Ok(map)
289        }
290    }
291
292    impl Store for SqliteStore {
293        fn get_config(&self, key: &str) -> Result<Option<String>> {
294            let mut stmt = self
295                .conn
296                .prepare("SELECT value FROM config WHERE key = ?1")?;
297            let mut rows = stmt.query([key])?;
298            if let Some(row) = rows.next()? {
299                Ok(Some(row.get(0)?))
300            } else {
301                Ok(None)
302            }
303        }
304
305        fn set_config(&self, key: &str, value: &str) -> Result<()> {
306            self.conn.execute(
307                "INSERT OR REPLACE INTO config (key, value) VALUES (?1, ?2)",
308                params![key, value],
309            )?;
310            Ok(())
311        }
312
313        fn update_issue(&self, issue: &Issue) -> Result<()> {
314            // Serialize nested fields
315            let relates_to_json = serde_json::to_string(&issue.relates_to).unwrap_or_default();
316            let affected_symbols_json =
317                serde_json::to_string(&issue.affected_symbols).unwrap_or_default();
318
319            // Update main issue record
320            self.conn.execute(
321                "UPDATE issues SET
322                    content_hash = ?2, title = ?3, description = ?4, design = ?5, acceptance_criteria = ?6, notes = ?7,
323                    status = ?8, priority = ?9, issue_type = ?10, assignee = ?11, estimated_minutes = ?12,
324                    created_at = ?13, updated_at = ?14, closed_at = ?15, external_ref = ?16,
325                    sender = ?17, ephemeral = ?18, replies_to = ?19, relates_to = ?20,
326                    duplicate_of = ?21, superseded_by = ?22,
327                    deleted_at = ?23, deleted_by = ?24, delete_reason = ?25, original_type = ?26,
328                    affected_symbols = ?27, solid_volume = ?28, topology_hash = ?29, is_solid = ?30
329                WHERE id = ?1",
330                params![
331                    &issue.id,
332                    &issue.content_hash,
333                    &issue.title,
334                    &issue.description,
335                    &issue.design,
336                    &issue.acceptance_criteria,
337                    &issue.notes,
338                    &issue.status,
339                    &issue.priority,
340                    &issue.issue_type,
341                    &issue.assignee,
342                    &issue.estimated_minutes,
343                    issue.created_at.to_rfc3339(),
344                    issue.updated_at.to_rfc3339(),
345                    issue.closed_at.map(|t| t.to_rfc3339()),
346                    &issue.external_ref,
347                    &issue.sender,
348                    issue.ephemeral,
349                    &issue.replies_to,
350                    relates_to_json,
351                    &issue.duplicate_of,
352                    &issue.superseded_by,
353                    issue.deleted_at.map(|t| t.to_rfc3339()),
354                    &issue.deleted_by,
355                    &issue.delete_reason,
356                    &issue.original_type,
357                    affected_symbols_json,
358                    &issue.solid_volume,
359                    &issue.topology_hash,
360                    issue.is_solid,
361                ],
362            )?;
363
364            // Replace labels
365            self.conn
366                .execute("DELETE FROM labels WHERE issue_id = ?1", params![&issue.id])?;
367            for label in &issue.labels {
368                self.conn.execute(
369                    "INSERT INTO labels (issue_id, label) VALUES (?1, ?2)",
370                    params![&issue.id, label],
371                )?;
372            }
373
374            // Replace dependencies
375            self.conn.execute(
376                "DELETE FROM dependencies WHERE issue_id = ?1",
377                params![&issue.id],
378            )?;
379            for dep in &issue.dependencies {
380                self.conn.execute(
381                    "INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by) VALUES (?1, ?2, ?3, ?4, ?5)",
382                    params![&dep.issue_id, &dep.depends_on_id, &dep.type_, dep.created_at.to_rfc3339(), &dep.created_by],
383                )?;
384            }
385
386            // Mark as dirty
387            self.conn.execute(
388                "INSERT OR IGNORE INTO dirty_issues (issue_id) VALUES (?1)",
389                params![&issue.id],
390            )?;
391            Ok(())
392        }
393
394        fn list_config(&self) -> Result<Vec<(String, String)>> {
395            let mut stmt = self
396                .conn
397                .prepare("SELECT key, value FROM config ORDER BY key")?;
398            let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
399
400            let mut result = Vec::new();
401            for row in rows {
402                result.push(row?);
403            }
404            Ok(result)
405        }
406
407        fn execute_raw(&self, sql: &str) -> Result<()> {
408            self.conn.execute_batch(sql)?;
409            Ok(())
410        }
411
412        fn get_issue(&self, id: &str) -> Result<Option<Issue>> {
413            // Handle short ID (prefix match)
414            let query_id = if id.len() < 36 {
415                format!("{}%", id)
416            } else {
417                id.to_string()
418            };
419
420            let mut stmt = self.conn.prepare(
421                "SELECT
422                    id, content_hash, title, description, design, acceptance_criteria, notes,
423                    status, priority, issue_type, assignee, estimated_minutes,
424                    created_at, updated_at, closed_at, external_ref,
425                    sender, ephemeral, replies_to, relates_to, duplicate_of, superseded_by,
426                    deleted_at, deleted_by, delete_reason, original_type,
427                    affected_symbols, solid_volume, topology_hash, is_solid
428                FROM issues
429                WHERE id LIKE ?1
430                LIMIT 1",
431            )?;
432
433            let mut rows = stmt.query([&query_id])?;
434
435            let row = if let Some(row) = rows.next()? {
436                row
437            } else {
438                return Ok(None);
439            };
440
441            let id: String = row.get(0)?;
442
443            // Fetch children
444            let mut labels = Vec::new();
445            let mut labels_stmt = self
446                .conn
447                .prepare("SELECT label FROM labels WHERE issue_id = ?1")?;
448            let labels_rows = labels_stmt.query_map([&id], |r| r.get(0))?;
449            for l in labels_rows {
450                labels.push(l?);
451            }
452
453            let mut deps = Vec::new();
454            let mut deps_stmt = self.conn.prepare("SELECT depends_on_id, type, created_at, created_by FROM dependencies WHERE issue_id = ?1")?;
455            let deps_rows = deps_stmt.query_map([&id], |r| {
456                let created_at_s: String = r.get(2)?;
457                let created_at = parse_timestamp(&created_at_s).unwrap_or_else(|| Utc::now());
458                Ok(Dependency {
459                    issue_id: id.clone(),
460                    depends_on_id: r.get(0)?,
461                    type_: r.get(1)?,
462                    created_at,
463                    created_by: r.get(3)?,
464                })
465            })?;
466            for d in deps_rows {
467                deps.push(d?);
468            }
469
470            let mut comments = Vec::new();
471            let mut comments_stmt = self.conn.prepare("SELECT id, author, text, created_at FROM comments WHERE issue_id = ?1 ORDER BY created_at")?;
472            let comments_rows = comments_stmt.query_map([&id], |r| {
473                let created_at_s: String = r.get(3)?;
474                let created_at = parse_timestamp(&created_at_s).unwrap_or_else(|| Utc::now());
475                Ok(Comment {
476                    id: r.get(0)?,
477                    issue_id: id.clone(),
478                    author: r.get(1)?,
479                    text: r.get(2)?,
480                    created_at,
481                })
482            })?;
483            for c in comments_rows {
484                comments.push(c?);
485            }
486
487            let created_at_s: String = row.get(12)?;
488            let updated_at_s: String = row.get(13)?;
489            let closed_at_s: Option<String> = row.get(14)?;
490            let deleted_at_s: Option<String> = row.get(22)?;
491
492            let created_at = parse_timestamp(&created_at_s).unwrap_or_else(|| Utc::now());
493            let updated_at = parse_timestamp(&updated_at_s).unwrap_or_else(|| Utc::now());
494            let closed_at = closed_at_s.and_then(|s| parse_timestamp(&s));
495            let deleted_at = deleted_at_s.and_then(|s| parse_timestamp(&s));
496
497            let relates_to_s: String = row.get(19).unwrap_or_default();
498            let relates_to = if relates_to_s.is_empty() {
499                Vec::new()
500            } else {
501                serde_json::from_str(&relates_to_s).unwrap_or_default()
502            };
503
504            let affected_symbols_s: String = row.get(26).unwrap_or_default();
505            let affected_symbols = if affected_symbols_s.is_empty() {
506                Vec::new()
507            } else {
508                serde_json::from_str(&affected_symbols_s).unwrap_or_default()
509            };
510
511            Ok(Some(Issue {
512                id,
513                content_hash: row.get(1).unwrap_or_default(),
514                title: row.get(2).unwrap_or_default(),
515                description: row.get(3).unwrap_or_default(),
516                design: row.get(4).unwrap_or_default(),
517                acceptance_criteria: row.get(5).unwrap_or_default(),
518                notes: row.get(6).unwrap_or_default(),
519                status: row.get(7).unwrap_or_default(),
520                priority: row.get(8).unwrap_or_default(),
521                issue_type: row.get(9).unwrap_or_default(),
522                assignee: row.get(10)?,
523                estimated_minutes: row.get(11)?,
524                created_at,
525                updated_at,
526                closed_at,
527                external_ref: row.get(15)?,
528                sender: row.get(16).unwrap_or_default(),
529                ephemeral: row.get(17).unwrap_or(false),
530                replies_to: row.get(18).unwrap_or_default(),
531                relates_to,
532                duplicate_of: row.get(20).unwrap_or_default(),
533                superseded_by: row.get(21).unwrap_or_default(),
534                deleted_at,
535                deleted_by: row.get(23).unwrap_or_default(),
536                delete_reason: row.get(24).unwrap_or_default(),
537                original_type: row.get(25).unwrap_or_default(),
538                labels,
539                dependencies: deps,
540                comments,
541                affected_symbols,
542                solid_volume: row.get(27).ok(),
543                topology_hash: row.get(28).unwrap_or_default(),
544                is_solid: row.get(29).unwrap_or(false),
545            }))
546        }
547
548        fn list_issues(
549            &self,
550            status: Option<&str>,
551            assignee: Option<&str>,
552            priority: Option<i32>,
553            issue_type: Option<&str>,
554            label: Option<&str>,
555            sort_by: Option<&str>,
556        ) -> Result<Vec<Issue>> {
557            let mut conditions = Vec::new();
558            let mut args: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
559
560            if let Some(s) = status {
561                conditions.push("issues.status = ?");
562                args.push(Box::new(s.to_string()));
563            }
564
565            if let Some(a) = assignee {
566                if a == "unassigned" {
567                    conditions.push("(issues.assignee IS NULL OR issues.assignee = '')");
568                } else {
569                    conditions.push("issues.assignee = ?");
570                    args.push(Box::new(a.to_string()));
571                }
572            }
573
574            if let Some(p) = priority {
575                conditions.push("issues.priority = ?");
576                args.push(Box::new(p));
577            }
578
579            if let Some(t) = issue_type {
580                conditions.push("issues.issue_type = ?");
581                args.push(Box::new(t.to_string()));
582            }
583
584            let mut sql = "SELECT issues.id, issues.title, issues.description, issues.status, issues.priority, issues.issue_type, issues.created_at, issues.updated_at, issues.assignee, issues.affected_symbols FROM issues".to_string();
585
586            if let Some(l) = label {
587                sql.push_str(" INNER JOIN labels ON issues.id = labels.issue_id");
588                conditions.push("labels.label = ?");
589                args.push(Box::new(l.to_string()));
590            }
591
592            if !conditions.is_empty() {
593                sql.push_str(" WHERE ");
594                sql.push_str(&conditions.join(" AND "));
595            }
596
597            let order_clause = match sort_by {
598                Some("updated") => "ORDER BY issues.updated_at DESC",
599                Some("created") => "ORDER BY issues.created_at DESC",
600                Some("priority") => "ORDER BY issues.priority ASC, issues.created_at DESC",
601                _ => "ORDER BY issues.created_at DESC",
602            };
603            sql.push_str(" ");
604            sql.push_str(order_clause);
605
606            let mut stmt = self.conn.prepare(&sql)?;
607
608            let issue_iter = stmt.query_map(rusqlite::params_from_iter(args.iter()), |row| {
609                let created_at_s: String = row.get(6)?;
610                let updated_at_s: String = row.get(7)?;
611
612                let created_at = parse_timestamp(&created_at_s).unwrap_or_else(|| Utc::now());
613                let updated_at = parse_timestamp(&updated_at_s).unwrap_or_else(|| Utc::now());
614
615                let affected_symbols_s: String = row.get(9).unwrap_or_default();
616                let affected_symbols = if affected_symbols_s.is_empty() {
617                    Vec::new()
618                } else {
619                    serde_json::from_str(&affected_symbols_s).unwrap_or_default()
620                };
621
622                Ok(Issue {
623                    id: row.get(0)?,
624                    content_hash: String::new(),
625                    title: row.get(1)?,
626                    description: row.get(2)?,
627                    design: String::new(),
628                    acceptance_criteria: String::new(),
629                    notes: String::new(),
630                    status: row.get(3)?,
631                    priority: row.get(4)?,
632                    issue_type: row.get(5)?,
633                    assignee: row.get(8)?,
634                    estimated_minutes: None,
635                    created_at,
636                    updated_at,
637                    closed_at: None,
638                    external_ref: None,
639                    sender: String::new(),
640                    ephemeral: false,
641                    replies_to: String::new(),
642                    relates_to: Vec::new(),
643                    duplicate_of: String::new(),
644                    superseded_by: String::new(),
645
646                    deleted_at: None,
647                    deleted_by: String::new(),
648                    delete_reason: String::new(),
649                    original_type: String::new(),
650
651                    labels: Vec::new(),
652                    dependencies: Vec::new(),
653                    comments: Vec::new(),
654                    affected_symbols,
655                    solid_volume: None,
656                    topology_hash: String::new(),
657                    is_solid: false,
658                })
659            })?;
660
661            let mut issues = Vec::new();
662            for issue in issue_iter {
663                issues.push(issue?);
664            }
665            Ok(issues)
666        }
667
668        fn import_from_jsonl(&mut self, jsonl_path: &Path, fs: &dyn FileSystem) -> Result<()> {
669            if !fs.exists(jsonl_path) {
670                return Ok(());
671            }
672
673            let reader = fs.open_read(jsonl_path)?;
674
675            let tx = self.conn.transaction()?;
676
677            for line in reader.lines() {
678                let line = line?;
679                if line.trim().is_empty() {
680                    continue;
681                }
682                let issue: Issue = serde_json::from_str(&line)?;
683
684                let relates_to_json = serde_json::to_string(&issue.relates_to).unwrap_or_default();
685                let affected_symbols_json =
686                    serde_json::to_string(&issue.affected_symbols).unwrap_or_default();
687
688                tx.execute(
689                    "INSERT OR REPLACE INTO issues (
690                        id, content_hash, title, description, design, acceptance_criteria, notes,
691                        status, priority, issue_type, assignee, estimated_minutes,
692                        created_at, updated_at, closed_at, external_ref,
693                        sender, ephemeral, replies_to, relates_to, duplicate_of, superseded_by,
694                        deleted_at, deleted_by, delete_reason, original_type,
695                        affected_symbols, solid_volume, topology_hash, is_solid
696                    )
697                    VALUES (
698                        ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12,
699                        ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22,
700                        ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30
701                    )",
702                    params![
703                        &issue.id,
704                        &issue.content_hash,
705                        &issue.title,
706                        &issue.description,
707                        &issue.design,
708                        &issue.acceptance_criteria,
709                        &issue.notes,
710                        &issue.status,
711                        &issue.priority,
712                        &issue.issue_type,
713                        &issue.assignee,
714                        &issue.estimated_minutes,
715                        issue.created_at.to_rfc3339(),
716                        issue.updated_at.to_rfc3339(),
717                        issue.closed_at.map(|t| t.to_rfc3339()),
718                        &issue.external_ref,
719                        &issue.sender,
720                        issue.ephemeral,
721                        &issue.replies_to,
722                        relates_to_json,
723                        &issue.duplicate_of,
724                        &issue.superseded_by,
725                        issue.deleted_at.map(|t| t.to_rfc3339()),
726                        &issue.deleted_by,
727                        &issue.delete_reason,
728                        &issue.original_type,
729                        affected_symbols_json,
730                        &issue.solid_volume,
731                        &issue.topology_hash,
732                        issue.is_solid,
733                    ],
734                )?;
735
736                tx.execute("DELETE FROM labels WHERE issue_id = ?1", params![&issue.id])?;
737                for label in &issue.labels {
738                    tx.execute(
739                        "INSERT INTO labels (issue_id, label) VALUES (?1, ?2)",
740                        params![&issue.id, label],
741                    )?;
742                }
743
744                tx.execute(
745                    "DELETE FROM dependencies WHERE issue_id = ?1",
746                    params![&issue.id],
747                )?;
748                for dep in &issue.dependencies {
749                    tx.execute(
750                        "INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by) VALUES (?1, ?2, ?3, ?4, ?5)",
751                        params![&dep.issue_id, &dep.depends_on_id, &dep.type_, dep.created_at.to_rfc3339(), &dep.created_by],
752                    )?;
753                }
754
755                let mut stmt =
756                    tx.prepare_cached("SELECT author, text FROM comments WHERE issue_id = ?1")?;
757                let existing_comments: std::collections::HashSet<(String, String)> = stmt
758                    .query_map([&issue.id], |row| Ok((row.get(0)?, row.get(1)?)))?
759                    .filter_map(Result::ok)
760                    .collect();
761                drop(stmt);
762
763                for comment in &issue.comments {
764                    if !existing_comments.contains(&(comment.author.clone(), comment.text.clone()))
765                    {
766                        tx.execute(
767                            "INSERT INTO comments (id, issue_id, author, text, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
768                            params![&comment.id, &issue.id, &comment.author, &comment.text, comment.created_at.to_rfc3339()],
769                        )?;
770                    }
771                }
772            }
773
774            tx.commit()?;
775            Ok(())
776        }
777
778        fn generate_unique_id(
779            &self,
780            prefix: &str,
781            title: &str,
782            description: &str,
783            creator: &str,
784        ) -> Result<String> {
785            let created_at = Utc::now();
786            let base_length = 6;
787            let max_length = 8;
788
789            for length in base_length..=max_length {
790                for nonce in 0..10 {
791                    let candidate = util::generate_hash_id(
792                        prefix,
793                        title,
794                        description,
795                        creator,
796                        created_at,
797                        length,
798                        nonce,
799                    );
800                    let count: i64 = self.conn.query_row(
801                        "SELECT COUNT(*) FROM issues WHERE id = ?1",
802                        params![&candidate],
803                        |row| row.get(0),
804                    )?;
805
806                    if count == 0 {
807                        return Ok(candidate);
808                    }
809                }
810            }
811            Err(anyhow::anyhow!(
812                "Failed to generate unique ID after retries"
813            ))
814        }
815
816        fn create_issue(&self, issue: &Issue) -> Result<()> {
817            let relates_to_json = serde_json::to_string(&issue.relates_to).unwrap_or_default();
818            let affected_symbols_json =
819                serde_json::to_string(&issue.affected_symbols).unwrap_or_default();
820
821            self.conn.execute(
822                "INSERT INTO issues (
823                    id, content_hash, title, description, design, acceptance_criteria, notes,
824                    status, priority, issue_type, assignee, estimated_minutes,
825                    created_at, updated_at, closed_at, external_ref,
826                    sender, ephemeral, replies_to, relates_to, duplicate_of, superseded_by,
827                    deleted_at, deleted_by, delete_reason, original_type,
828                    affected_symbols, solid_volume, topology_hash, is_solid
829                )
830                VALUES (
831                    ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12,
832                    ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22,
833                    ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30
834                )",
835                params![
836                    &issue.id,
837                    &issue.content_hash,
838                    &issue.title,
839                    &issue.description,
840                    &issue.design,
841                    &issue.acceptance_criteria,
842                    &issue.notes,
843                    &issue.status,
844                    &issue.priority,
845                    &issue.issue_type,
846                    &issue.assignee,
847                    &issue.estimated_minutes,
848                    issue.created_at.to_rfc3339(),
849                    issue.updated_at.to_rfc3339(),
850                    issue.closed_at.map(|t| t.to_rfc3339()),
851                    &issue.external_ref,
852                    &issue.sender,
853                    issue.ephemeral,
854                    &issue.replies_to,
855                    relates_to_json,
856                    &issue.duplicate_of,
857                    &issue.superseded_by,
858                    issue.deleted_at.map(|t| t.to_rfc3339()),
859                    &issue.deleted_by,
860                    &issue.delete_reason,
861                    &issue.original_type,
862                    affected_symbols_json,
863                    &issue.solid_volume,
864                    &issue.topology_hash,
865                    issue.is_solid,
866                ],
867            )?;
868
869            for label in &issue.labels {
870                self.conn.execute(
871                    "INSERT INTO labels (issue_id, label) VALUES (?1, ?2)",
872                    params![&issue.id, label],
873                )?;
874            }
875
876            for dep in &issue.dependencies {
877                self.conn.execute(
878                    "INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by) VALUES (?1, ?2, ?3, ?4, ?5)",
879                    params![&dep.issue_id, &dep.depends_on_id, &dep.type_, dep.created_at.to_rfc3339(), &dep.created_by],
880                )?;
881            }
882
883            for comment in &issue.comments {
884                self.conn.execute(
885                    "INSERT INTO comments (id, issue_id, author, text, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
886                    params![&comment.id, &comment.issue_id, &comment.author, &comment.text, comment.created_at.to_rfc3339()],
887                )?;
888            }
889
890            self.conn.execute(
891                "INSERT OR IGNORE INTO dirty_issues (issue_id) VALUES (?1)",
892                params![&issue.id],
893            )?;
894            Ok(())
895        }
896
897        fn export_to_jsonl(&self, jsonl_path: &Path, fs: &dyn FileSystem) -> Result<()> {
898            let issues = self.export_all_issues()?;
899
900            let dir = jsonl_path.parent().unwrap_or_else(|| Path::new("."));
901            let file_name = jsonl_path.file_name().unwrap_or_default();
902            let temp_path = dir.join(format!(".{}.tmp", file_name.to_string_lossy()));
903
904            {
905                let file = fs.open_write(&temp_path)?;
906                let mut writer = BufWriter::new(file);
907
908                for issue in &issues {
909                    let json = serde_json::to_string(issue)?;
910                    writeln!(writer, "{}", json)?;
911                }
912                writer.flush()?;
913            }
914
915            fs.rename(&temp_path, jsonl_path)?;
916
917            let mut reader = fs.open_read(jsonl_path)?;
918            let mut hasher = Sha256::new();
919            std::io::copy(&mut reader, &mut hasher)?;
920            let hash = hex::encode(hasher.finalize());
921
922            self.conn.execute(
923                "INSERT OR REPLACE INTO metadata (key, value) VALUES (?1, ?2)",
924                params!["jsonl_content_hash", &hash],
925            )?;
926
927            self.conn.execute("DELETE FROM dirty_issues", [])?;
928
929            Ok(())
930        }
931    }
932
933    fn parse_timestamp(s: &str) -> Option<DateTime<Utc>> {
934        if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
935            return Some(dt.with_timezone(&Utc));
936        }
937        if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
938            return Some(DateTime::from_naive_utc_and_offset(dt, Utc));
939        }
940        None
941    }
942}