1pub mod schema;
2
3use anyhow::{Context, Result};
4use rusqlite::Connection;
5use serde::{Deserialize, Serialize};
6use std::path::Path;
7use std::sync::{Arc, Mutex};
8
9pub struct BlockerRow {
10 pub issue_id: String,
11 pub identifier: String,
12 pub title: String,
13 pub state_name: String,
14 pub state_type: String,
15}
16
17#[derive(Clone)]
18pub struct Database {
19 conn: Arc<Mutex<Connection>>,
20}
21
22impl Database {
23 pub fn open(path: &Path) -> Result<Self> {
24 let conn = Connection::open(path)
25 .with_context(|| format!("Failed to open database at {}", path.display()))?;
26
27 conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
28
29 let db = Self {
30 conn: Arc::new(Mutex::new(conn)),
31 };
32 db.migrate()?;
33 Ok(db)
34 }
35
36 fn migrate(&self) -> Result<()> {
37 let conn = self.conn.lock().unwrap();
38 schema::run_migrations(&conn)?;
39 Ok(())
40 }
41
42 pub fn with_conn<F, T>(&self, f: F) -> Result<T>
43 where
44 F: FnOnce(&Connection) -> Result<T>,
45 {
46 let conn = self.conn.lock().unwrap();
47 f(&conn)
48 }
49
50 pub fn upsert_issue(&self, issue: &Issue) -> Result<()> {
53 self.with_conn(|conn| {
54 conn.execute(
55 "INSERT INTO issues (id, identifier, team_key, title, description, state_name, state_type, priority, assignee_name, project_name, labels_json, created_at, updated_at, content_hash, synced_at, url)
56 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, datetime('now'), ?15)
57 ON CONFLICT(id) DO UPDATE SET
58 identifier=excluded.identifier, team_key=excluded.team_key, title=excluded.title,
59 description=excluded.description, state_name=excluded.state_name, state_type=excluded.state_type,
60 priority=excluded.priority, assignee_name=excluded.assignee_name, project_name=excluded.project_name,
61 labels_json=excluded.labels_json, updated_at=excluded.updated_at,
62 content_hash=excluded.content_hash, url=excluded.url, synced_at=datetime('now')",
63 rusqlite::params![
64 issue.id, issue.identifier, issue.team_key, issue.title, issue.description,
65 issue.state_name, issue.state_type, issue.priority, issue.assignee_name,
66 issue.project_name, issue.labels_json, issue.created_at, issue.updated_at,
67 issue.content_hash, issue.url,
68 ],
69 )?;
70 Ok(())
71 })
72 }
73
74 pub fn get_issue(&self, id_or_identifier: &str) -> Result<Option<Issue>> {
75 self.with_conn(|conn| {
76 let mut stmt = conn.prepare(
77 "SELECT id, identifier, team_key, title, description, state_name, state_type, priority, assignee_name, project_name, labels_json, created_at, updated_at, content_hash, synced_at, url
78 FROM issues WHERE id = ?1 OR identifier = ?1"
79 )?;
80 let mut rows = stmt.query(rusqlite::params![id_or_identifier])?;
81 if let Some(row) = rows.next()? {
82 Ok(Some(Issue::from_row(row)?))
83 } else {
84 Ok(None)
85 }
86 })
87 }
88
89 pub fn get_unprioritized_issues(
90 &self,
91 team_key: Option<&str>,
92 include_completed: bool,
93 ) -> Result<Vec<Issue>> {
94 self.with_conn(|conn| {
95 let state_filter = if include_completed {
96 ""
97 } else {
98 " AND state_type NOT IN ('completed', 'canceled')"
99 };
100 let (sql, params): (String, Vec<Box<dyn rusqlite::types::ToSql>>) = if let Some(team) = team_key {
101 (
102 format!(
103 "SELECT id, identifier, team_key, title, description, state_name, state_type, priority, assignee_name, project_name, labels_json, created_at, updated_at, content_hash, synced_at, url
104 FROM issues WHERE priority = 0{} AND team_key = ?1
105 ORDER BY created_at DESC", state_filter
106 ),
107 vec![Box::new(team.to_string()) as Box<dyn rusqlite::types::ToSql>],
108 )
109 } else {
110 (
111 format!(
112 "SELECT id, identifier, team_key, title, description, state_name, state_type, priority, assignee_name, project_name, labels_json, created_at, updated_at, content_hash, synced_at, url
113 FROM issues WHERE priority = 0{}
114 ORDER BY created_at DESC", state_filter
115 ),
116 vec![],
117 )
118 };
119 let mut stmt = conn.prepare(&sql)?;
120 let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
121 let rows = stmt.query_map(param_refs.as_slice(), |row| {
122 Ok(Issue::from_row(row).unwrap())
123 })?;
124 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
125 })
126 }
127
128 pub fn get_issues_by_state_types(
129 &self,
130 team_key: &str,
131 state_types: &[String],
132 ) -> Result<Vec<Issue>> {
133 self.with_conn(|conn| {
134 let placeholders: String = state_types
135 .iter()
136 .enumerate()
137 .map(|(i, _)| format!("?{}", i + 2))
138 .collect::<Vec<_>>()
139 .join(", ");
140 let sql = format!(
141 "SELECT id, identifier, team_key, title, description, state_name, state_type, \
142 priority, assignee_name, project_name, labels_json, created_at, updated_at, \
143 content_hash, synced_at, url \
144 FROM issues WHERE team_key = ?1 AND state_type IN ({placeholders}) \
145 ORDER BY priority ASC, created_at DESC"
146 );
147 let mut stmt = conn.prepare(&sql)?;
148 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> =
149 vec![Box::new(team_key.to_string())];
150 for st in state_types {
151 params.push(Box::new(st.clone()));
152 }
153 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
154 params.iter().map(|p| p.as_ref()).collect();
155 let rows = stmt.query_map(param_refs.as_slice(), |row| {
156 Ok(Issue::from_row(row).unwrap())
157 })?;
158 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
159 })
160 }
161
162 pub fn get_blockers_for_issues(&self, issue_ids: &[String]) -> Result<Vec<BlockerRow>> {
165 if issue_ids.is_empty() {
166 return Ok(vec![]);
167 }
168 self.with_conn(|conn| {
169 let placeholders: String = issue_ids
170 .iter()
171 .enumerate()
172 .map(|(i, _)| format!("?{}", i + 1))
173 .collect::<Vec<_>>()
174 .join(", ");
175
176 let sql_fwd = format!(
178 "SELECT r.issue_id, COALESCE(i.identifier, r.related_issue_identifier),
179 COALESCE(i.title, ''), COALESCE(i.state_name, ''), COALESCE(i.state_type, '')
180 FROM issue_relations r
181 LEFT JOIN issues i ON r.related_issue_id = i.id
182 WHERE r.issue_id IN ({placeholders}) AND r.relation_type = 'blocked_by'"
183 );
184
185 let sql_inv = format!(
187 "SELECT r.related_issue_id, i2.identifier,
188 COALESCE(i2.title, ''), COALESCE(i2.state_name, ''), COALESCE(i2.state_type, '')
189 FROM issue_relations r
190 JOIN issues i ON r.related_issue_id = i.id
191 JOIN issues i2 ON r.issue_id = i2.id
192 WHERE r.related_issue_id IN ({placeholders}) AND r.relation_type = 'blocks'"
193 );
194
195 let mut results = Vec::new();
196 let params: Vec<Box<dyn rusqlite::types::ToSql>> =
197 issue_ids.iter().map(|id| Box::new(id.clone()) as _).collect();
198 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
199 params.iter().map(|p| p.as_ref()).collect();
200
201 for sql in [&sql_fwd, &sql_inv] {
202 let mut stmt = conn.prepare(sql)?;
203 let rows = stmt.query_map(param_refs.as_slice(), |row| {
204 Ok(BlockerRow {
205 issue_id: row.get(0)?,
206 identifier: row.get(1)?,
207 title: row.get(2)?,
208 state_name: row.get(3)?,
209 state_type: row.get(4)?,
210 })
211 })?;
212 for row in rows {
213 results.push(row?);
214 }
215 }
216 Ok(results)
217 })
218 }
219
220 pub fn count_issues(&self, team_key: Option<&str>) -> Result<usize> {
221 self.with_conn(|conn| {
222 let count: usize = if let Some(team) = team_key {
223 conn.query_row(
224 "SELECT COUNT(*) FROM issues WHERE team_key = ?1",
225 rusqlite::params![team],
226 |row| row.get(0),
227 )?
228 } else {
229 conn.query_row("SELECT COUNT(*) FROM issues", [], |row| row.get(0))?
230 };
231 Ok(count)
232 })
233 }
234
235 pub fn upsert_relations(&self, issue_id: &str, relations: &[Relation]) -> Result<()> {
238 self.with_conn(|conn| {
239 conn.execute(
240 "DELETE FROM issue_relations WHERE issue_id = ?1",
241 rusqlite::params![issue_id],
242 )?;
243 let mut stmt = conn.prepare(
244 "INSERT OR IGNORE INTO issue_relations (id, issue_id, related_issue_id, related_issue_identifier, relation_type)
245 VALUES (?1, ?2, ?3, ?4, ?5)"
246 )?;
247 for rel in relations {
248 stmt.execute(rusqlite::params![
249 rel.id, rel.issue_id, rel.related_issue_id,
250 rel.related_issue_identifier, rel.relation_type,
251 ])?;
252 }
253 Ok(())
254 })
255 }
256
257 pub fn get_relations_enriched(&self, issue_id: &str) -> Result<Vec<EnrichedRelation>> {
258 self.with_conn(|conn| {
259 let mut stmt = conn.prepare(
261 "SELECT r.id, r.relation_type, r.related_issue_identifier,
262 COALESCE(i.title, ''), COALESCE(i.state_name, ''), COALESCE(i.url, '')
263 FROM issue_relations r
264 LEFT JOIN issues i ON r.related_issue_id = i.id
265 WHERE r.issue_id = ?1",
266 )?;
267 let forward = stmt
268 .query_map(rusqlite::params![issue_id], |row| {
269 Ok(EnrichedRelation {
270 relation_id: row.get(0)?,
271 relation_type: row.get(1)?,
272 issue_identifier: row.get(2)?,
273 issue_title: row.get(3)?,
274 issue_state: row.get(4)?,
275 issue_url: row.get(5)?,
276 })
277 })?
278 .collect::<std::result::Result<Vec<_>, _>>()?;
279
280 let mut stmt2 = conn.prepare(
282 "SELECT r.id, r.relation_type, i2.identifier,
283 COALESCE(i2.title, ''), COALESCE(i2.state_name, ''), COALESCE(i2.url, '')
284 FROM issue_relations r
285 JOIN issues i ON r.related_issue_id = i.id
286 JOIN issues i2 ON r.issue_id = i2.id
287 WHERE r.related_issue_id = i.id AND i.id = ?1",
288 )?;
289 let inverse = stmt2
290 .query_map(rusqlite::params![issue_id], |row| {
291 let raw_type: String = row.get(1)?;
292 let flipped = match raw_type.as_str() {
293 "blocks" => "blocked_by".to_string(),
294 "blocked_by" => "blocks".to_string(),
295 other => other.to_string(), };
297 Ok(EnrichedRelation {
298 relation_id: row.get(0)?,
299 relation_type: flipped,
300 issue_identifier: row.get(2)?,
301 issue_title: row.get(3)?,
302 issue_state: row.get(4)?,
303 issue_url: row.get(5)?,
304 })
305 })?
306 .collect::<std::result::Result<Vec<_>, _>>()?;
307
308 let mut all = forward;
309 all.extend(inverse);
310 Ok(all)
311 })
312 }
313
314 pub fn find_relation_id(
316 &self,
317 issue_id: &str,
318 related_issue_id: &str,
319 relation_type: &str,
320 ) -> Result<Option<String>> {
321 self.with_conn(|conn| {
322 let mut stmt = conn.prepare(
323 "SELECT id FROM issue_relations WHERE issue_id = ?1 AND related_issue_id = ?2 AND relation_type = ?3"
324 )?;
325 let mut rows = stmt.query(rusqlite::params![issue_id, related_issue_id, relation_type])?;
326 if let Some(row) = rows.next()? {
327 Ok(Some(row.get(0)?))
328 } else {
329 Ok(None)
330 }
331 })
332 }
333
334 pub fn upsert_chunks(&self, issue_id: &str, chunks: &[(usize, String, Vec<u8>)]) -> Result<()> {
337 self.with_conn(|conn| {
338 conn.execute(
339 "DELETE FROM chunks WHERE issue_id = ?1",
340 rusqlite::params![issue_id],
341 )?;
342 let mut stmt = conn.prepare(
343 "INSERT INTO chunks (issue_id, chunk_index, chunk_text, embedding) VALUES (?1, ?2, ?3, ?4)"
344 )?;
345 for (idx, text, embedding) in chunks {
346 stmt.execute(rusqlite::params![issue_id, idx, text, embedding])?;
347 }
348 Ok(())
349 })
350 }
351
352 pub fn get_all_chunks(&self) -> Result<Vec<Chunk>> {
353 self.with_conn(|conn| {
354 let mut stmt = conn.prepare(
355 "SELECT c.issue_id, c.embedding, i.identifier
356 FROM chunks c JOIN issues i ON c.issue_id = i.id",
357 )?;
358 let rows = stmt.query_map([], |row| {
359 Ok(Chunk {
360 issue_id: row.get(0)?,
361 embedding: row.get(1)?,
362 identifier: row.get(2)?,
363 })
364 })?;
365 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
366 })
367 }
368
369 pub fn get_chunks_for_team(&self, team_key: &str) -> Result<Vec<Chunk>> {
370 self.with_conn(|conn| {
371 let mut stmt = conn.prepare(
372 "SELECT c.issue_id, c.embedding, i.identifier
373 FROM chunks c JOIN issues i ON c.issue_id = i.id
374 WHERE i.team_key = ?1",
375 )?;
376 let rows = stmt.query_map(rusqlite::params![team_key], |row| {
377 Ok(Chunk {
378 issue_id: row.get(0)?,
379 embedding: row.get(1)?,
380 identifier: row.get(2)?,
381 })
382 })?;
383 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
384 })
385 }
386
387 pub fn count_embedded_issues(&self, team_key: Option<&str>) -> Result<usize> {
388 self.with_conn(|conn| {
389 let count: usize = if let Some(team) = team_key {
390 conn.query_row(
391 "SELECT COUNT(DISTINCT c.issue_id) FROM chunks c JOIN issues i ON c.issue_id = i.id WHERE i.team_key = ?1",
392 rusqlite::params![team],
393 |row| row.get(0),
394 )?
395 } else {
396 conn.query_row("SELECT COUNT(DISTINCT issue_id) FROM chunks", [], |row| row.get(0))?
397 };
398 Ok(count)
399 })
400 }
401
402 pub fn get_issues_needing_embedding(
403 &self,
404 team_key: Option<&str>,
405 force: bool,
406 ) -> Result<Vec<Issue>> {
407 self.with_conn(|conn| {
408 let sql = if force {
409 if let Some(team) = team_key {
410 format!(
411 "SELECT id, identifier, team_key, title, description, state_name, state_type, priority, assignee_name, project_name, labels_json, created_at, updated_at, content_hash, synced_at, url
412 FROM issues WHERE team_key = '{}'", team
413 )
414 } else {
415 "SELECT id, identifier, team_key, title, description, state_name, state_type, priority, assignee_name, project_name, labels_json, created_at, updated_at, content_hash, synced_at, url
416 FROM issues".to_string()
417 }
418 } else {
419 let team_filter = if let Some(team) = team_key {
420 format!("AND i.team_key = '{}'", team)
421 } else {
422 String::new()
423 };
424 format!(
425 "SELECT i.id, i.identifier, i.team_key, i.title, i.description, i.state_name, i.state_type, i.priority, i.assignee_name, i.project_name, i.labels_json, i.created_at, i.updated_at, i.content_hash, i.synced_at, i.url
426 FROM issues i
427 LEFT JOIN (SELECT DISTINCT issue_id FROM chunks) c ON i.id = c.issue_id
428 WHERE c.issue_id IS NULL {}",
429 team_filter
430 )
431 };
432 let mut stmt = conn.prepare(&sql)?;
433 let rows = stmt.query_map([], |row| {
434 Ok(Issue::from_row(row).unwrap())
435 })?;
436 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
437 })
438 }
439
440 pub fn get_comments(&self, issue_id: &str) -> Result<Vec<Comment>> {
443 self.with_conn(|conn| {
444 let mut stmt = conn.prepare(
445 "SELECT id, issue_id, body, user_name, created_at FROM comments WHERE issue_id = ?1 ORDER BY created_at"
446 )?;
447 let rows = stmt.query_map(rusqlite::params![issue_id], |row| {
448 Ok(Comment {
449 id: row.get(0)?,
450 issue_id: row.get(1)?,
451 body: row.get(2)?,
452 user_name: row.get(3)?,
453 created_at: row.get(4)?,
454 })
455 })?;
456 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
457 })
458 }
459
460 pub fn get_sync_cursor(&self, team_key: &str) -> Result<Option<String>> {
463 self.with_conn(|conn| {
464 let mut stmt =
465 conn.prepare("SELECT last_updated_at FROM sync_state WHERE team_key = ?1")?;
466 let mut rows = stmt.query(rusqlite::params![team_key])?;
467 if let Some(row) = rows.next()? {
468 Ok(Some(row.get(0)?))
469 } else {
470 Ok(None)
471 }
472 })
473 }
474
475 pub fn set_sync_cursor(&self, team_key: &str, last_updated_at: &str) -> Result<()> {
476 self.with_conn(|conn| {
477 conn.execute(
478 "INSERT INTO sync_state (team_key, last_updated_at, full_sync_done)
479 VALUES (?1, ?2, 1)
480 ON CONFLICT(team_key) DO UPDATE SET last_updated_at=excluded.last_updated_at, full_sync_done=1",
481 rusqlite::params![team_key, last_updated_at],
482 )?;
483 Ok(())
484 })
485 }
486
487 pub fn is_full_sync_done(&self, team_key: &str) -> Result<bool> {
488 self.with_conn(|conn| {
489 let mut stmt =
490 conn.prepare("SELECT full_sync_done FROM sync_state WHERE team_key = ?1")?;
491 let mut rows = stmt.query(rusqlite::params![team_key])?;
492 if let Some(row) = rows.next()? {
493 let done: bool = row.get(0)?;
494 Ok(done)
495 } else {
496 Ok(false)
497 }
498 })
499 }
500
501 pub fn get_metadata(&self, key: &str) -> Result<Option<String>> {
504 self.with_conn(|conn| {
505 let mut stmt = conn.prepare("SELECT value FROM metadata WHERE key = ?1")?;
506 let mut rows = stmt.query(rusqlite::params![key])?;
507 if let Some(row) = rows.next()? {
508 Ok(Some(row.get(0)?))
509 } else {
510 Ok(None)
511 }
512 })
513 }
514
515 pub fn set_metadata(&self, key: &str, value: &str) -> Result<()> {
516 self.with_conn(|conn| {
517 conn.execute(
518 "INSERT INTO metadata (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
519 rusqlite::params![key, value],
520 )?;
521 Ok(())
522 })
523 }
524
525 pub fn fts_search(&self, query: &str, limit: usize) -> Result<Vec<FtsResult>> {
528 self.with_conn(|conn| {
529 let mut stmt = conn.prepare(
530 "SELECT i.id, i.identifier, i.title, i.state_name, i.priority, bm25(issues_fts) as rank
531 FROM issues_fts f
532 JOIN issues i ON f.rowid = i.rowid
533 WHERE issues_fts MATCH ?1
534 ORDER BY rank
535 LIMIT ?2"
536 )?;
537 let rows = stmt.query_map(rusqlite::params![query, limit], |row| {
538 Ok(FtsResult {
539 issue_id: row.get(0)?,
540 identifier: row.get(1)?,
541 title: row.get(2)?,
542 state_name: row.get(3)?,
543 priority: row.get(4)?,
544 bm25_score: row.get(5)?,
545 })
546 })?;
547 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
548 })
549 }
550}
551
552#[derive(Debug, Clone, Serialize, Deserialize)]
555pub struct Issue {
556 pub id: String,
557 pub identifier: String,
558 pub team_key: String,
559 pub title: String,
560 pub description: Option<String>,
561 pub state_name: String,
562 pub state_type: String,
563 pub priority: i32,
564 pub assignee_name: Option<String>,
565 pub project_name: Option<String>,
566 pub labels_json: String,
567 pub created_at: String,
568 pub updated_at: String,
569 pub content_hash: String,
570 pub synced_at: Option<String>,
571 pub url: String,
572}
573
574impl Issue {
575 pub fn from_row(row: &rusqlite::Row) -> rusqlite::Result<Self> {
576 Ok(Self {
577 id: row.get(0)?,
578 identifier: row.get(1)?,
579 team_key: row.get(2)?,
580 title: row.get(3)?,
581 description: row.get(4)?,
582 state_name: row.get(5)?,
583 state_type: row.get(6)?,
584 priority: row.get(7)?,
585 assignee_name: row.get(8)?,
586 project_name: row.get(9)?,
587 labels_json: row.get(10)?,
588 created_at: row.get(11)?,
589 updated_at: row.get(12)?,
590 content_hash: row.get(13)?,
591 synced_at: row.get(14)?,
592 url: row.get(15)?,
593 })
594 }
595
596 pub fn labels(&self) -> Vec<String> {
597 serde_json::from_str(&self.labels_json).unwrap_or_default()
598 }
599
600 pub fn priority_label(&self) -> &str {
601 match self.priority {
602 0 => "No priority",
603 1 => "Urgent",
604 2 => "High",
605 3 => "Medium",
606 4 => "Low",
607 _ => "Unknown",
608 }
609 }
610}
611
612#[derive(Debug, Clone, Serialize, Deserialize)]
613pub struct Relation {
614 pub id: String,
615 pub issue_id: String,
616 pub related_issue_id: String,
617 pub related_issue_identifier: String,
618 pub relation_type: String,
619}
620
621#[derive(Debug, Clone, Serialize, Deserialize)]
622pub struct EnrichedRelation {
623 pub relation_id: String,
624 pub relation_type: String,
625 pub issue_identifier: String,
626 pub issue_title: String,
627 pub issue_state: String,
628 pub issue_url: String,
629}
630
631#[derive(Debug, Clone)]
632pub struct Chunk {
633 pub issue_id: String,
634 pub embedding: Vec<u8>,
635 pub identifier: String,
636}
637
638#[derive(Debug, Clone, Serialize, Deserialize)]
639pub struct Comment {
640 pub id: String,
641 pub issue_id: String,
642 pub body: String,
643 pub user_name: Option<String>,
644 pub created_at: String,
645}
646
647#[derive(Debug, Clone)]
648pub struct FtsResult {
649 pub issue_id: String,
650 pub identifier: String,
651 pub title: String,
652 pub state_name: String,
653 pub priority: i32,
654 pub bm25_score: f64,
655}