Skip to main content

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