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 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 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 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 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 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 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 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 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}