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