1pub mod schema;
2#[cfg(test)]
3mod test_helpers;
4
5use anyhow::{Context, Result};
6use rusqlite::Connection;
7use serde::{Deserialize, Serialize};
8use std::path::Path;
9use std::sync::{Arc, Mutex};
10
11pub struct BlockerRow {
12 pub issue_id: String,
13 pub identifier: String,
14 pub title: String,
15 pub state_name: String,
16 pub state_type: String,
17}
18
19#[derive(Clone)]
20pub struct Database {
21 conn: Arc<Mutex<Connection>>,
22}
23
24impl Database {
25 pub fn open(path: &Path) -> Result<Self> {
26 let conn = Connection::open(path)
27 .with_context(|| format!("Failed to open database at {}", path.display()))?;
28
29 conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
30
31 let db = Self {
32 conn: Arc::new(Mutex::new(conn)),
33 };
34 db.migrate()?;
35 Ok(db)
36 }
37
38 fn migrate(&self) -> Result<()> {
39 let conn = self.conn.lock().unwrap();
40 schema::run_migrations(&conn)?;
41 Ok(())
42 }
43
44 pub fn with_conn<F, T>(&self, f: F) -> Result<T>
45 where
46 F: FnOnce(&Connection) -> Result<T>,
47 {
48 let conn = self.conn.lock().unwrap();
49 f(&conn)
50 }
51
52 pub fn upsert_workspace(
55 &self,
56 id: &str,
57 linear_org_id: Option<&str>,
58 display_name: Option<&str>,
59 ) -> Result<()> {
60 self.with_conn(|conn| {
61 conn.execute(
62 "INSERT INTO workspaces (id, linear_org_id, display_name)
63 VALUES (?1, ?2, ?3)
64 ON CONFLICT(id) DO UPDATE SET
65 linear_org_id=excluded.linear_org_id,
66 display_name=excluded.display_name",
67 rusqlite::params![id, linear_org_id, display_name],
68 )?;
69 Ok(())
70 })
71 }
72
73 pub fn get_workspace(&self, id: &str) -> Result<Option<WorkspaceRow>> {
74 self.with_conn(|conn| {
75 let mut stmt = conn.prepare(
76 "SELECT id, linear_org_id, display_name, created_at FROM workspaces WHERE id = ?1",
77 )?;
78 let mut rows = stmt.query(rusqlite::params![id])?;
79 if let Some(row) = rows.next()? {
80 Ok(Some(WorkspaceRow {
81 id: row.get(0)?,
82 linear_org_id: row.get(1)?,
83 display_name: row.get(2)?,
84 created_at: row.get(3)?,
85 }))
86 } else {
87 Ok(None)
88 }
89 })
90 }
91
92 pub fn list_workspaces(&self) -> Result<Vec<WorkspaceRow>> {
93 self.with_conn(|conn| {
94 let mut stmt = conn.prepare(
95 "SELECT id, linear_org_id, display_name, created_at FROM workspaces ORDER BY id",
96 )?;
97 let rows = stmt.query_map([], |row| {
98 Ok(WorkspaceRow {
99 id: row.get(0)?,
100 linear_org_id: row.get(1)?,
101 display_name: row.get(2)?,
102 created_at: row.get(3)?,
103 })
104 })?;
105 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
106 })
107 }
108
109 pub fn delete_workspace(&self, id: &str) -> Result<usize> {
111 self.with_conn(|conn| {
112 let issue_count: usize = conn.query_row(
114 "SELECT COUNT(*) FROM issues WHERE workspace_id = ?1",
115 rusqlite::params![id],
116 |row| row.get(0),
117 )?;
118 conn.execute(
119 "DELETE FROM issues WHERE workspace_id = ?1",
120 rusqlite::params![id],
121 )?;
122 conn.execute(
123 "DELETE FROM comments WHERE workspace_id = ?1",
124 rusqlite::params![id],
125 )?;
126 conn.execute(
127 "DELETE FROM sync_state WHERE workspace_id = ?1",
128 rusqlite::params![id],
129 )?;
130 conn.execute(
131 "DELETE FROM labels WHERE workspace_id = ?1",
132 rusqlite::params![id],
133 )?;
134 conn.execute(
135 "DELETE FROM workspaces WHERE id = ?1",
136 rusqlite::params![id],
137 )?;
138 Ok(issue_count)
139 })
140 }
141
142 pub fn upsert_label(&self, label: &Label) -> Result<()> {
145 self.with_conn(|conn| {
146 conn.execute(
147 "INSERT INTO labels (id, workspace_id, name, color, parent_id)
148 VALUES (?1, ?2, ?3, ?4, ?5)
149 ON CONFLICT(id) DO UPDATE SET
150 workspace_id=excluded.workspace_id,
151 name=excluded.name,
152 color=excluded.color,
153 parent_id=excluded.parent_id",
154 rusqlite::params![label.id, label.workspace_id, label.name, label.color, label.parent_id],
155 )?;
156 Ok(())
157 })
158 }
159
160 pub fn list_labels(&self, workspace_id: &str) -> Result<Vec<Label>> {
161 self.with_conn(|conn| {
162 let mut stmt = conn.prepare(
163 "SELECT id, workspace_id, name, color, parent_id
164 FROM labels WHERE workspace_id = ?1
165 ORDER BY name COLLATE NOCASE ASC",
166 )?;
167 let rows = stmt.query_map(rusqlite::params![workspace_id], |row| {
168 Ok(Label {
169 id: row.get(0)?,
170 workspace_id: row.get(1)?,
171 name: row.get(2)?,
172 color: row.get(3)?,
173 parent_id: row.get(4)?,
174 })
175 })?;
176 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
177 })
178 }
179
180 pub fn delete_labels_for_workspace_not_in(
183 &self,
184 workspace_id: &str,
185 keep_ids: &[String],
186 ) -> Result<usize> {
187 self.with_conn(|conn| {
188 if keep_ids.is_empty() {
189 let n = conn.execute(
190 "DELETE FROM labels WHERE workspace_id = ?1",
191 rusqlite::params![workspace_id],
192 )?;
193 return Ok(n);
194 }
195 let placeholders = (0..keep_ids.len())
196 .map(|i| format!("?{}", i + 2))
197 .collect::<Vec<_>>()
198 .join(", ");
199 let sql = format!(
200 "DELETE FROM labels WHERE workspace_id = ?1 AND id NOT IN ({placeholders})"
201 );
202 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> =
203 vec![Box::new(workspace_id.to_string())];
204 for id in keep_ids {
205 params.push(Box::new(id.clone()));
206 }
207 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
208 params.iter().map(|p| p.as_ref()).collect();
209 let n = conn.execute(&sql, param_refs.as_slice())?;
210 Ok(n)
211 })
212 }
213
214 pub fn resolve_label_ids_local(
217 &self,
218 workspace_id: &str,
219 names: &[String],
220 ) -> Result<(Vec<String>, Vec<String>)> {
221 if names.is_empty() {
222 return Ok((Vec::new(), Vec::new()));
223 }
224 self.with_conn(|conn| {
225 let mut resolved = Vec::new();
226 let mut unknown = Vec::new();
227 let mut stmt = conn.prepare(
228 "SELECT id FROM labels WHERE workspace_id = ?1 AND name = ?2 COLLATE NOCASE",
229 )?;
230 for name in names {
231 let mut rows = stmt.query(rusqlite::params![workspace_id, name])?;
232 if let Some(row) = rows.next()? {
233 resolved.push(row.get::<_, String>(0)?);
234 } else {
235 unknown.push(name.clone());
236 }
237 }
238 Ok((resolved, unknown))
239 })
240 }
241
242 pub fn replace_issue_labels(&self, issue_id: &str, label_ids: &[String]) -> Result<()> {
247 self.with_conn(|conn| {
248 let tx = conn.unchecked_transaction()?;
249 tx.execute(
250 "DELETE FROM issue_labels WHERE issue_id = ?1",
251 rusqlite::params![issue_id],
252 )?;
253 for lid in label_ids {
254 let exists: i64 = tx.query_row(
255 "SELECT COUNT(*) FROM labels WHERE id = ?1",
256 rusqlite::params![lid],
257 |r| r.get(0),
258 )?;
259 if exists == 0 {
260 eprintln!(
261 "warning: skipping unknown label id '{}' for issue '{}'",
262 lid, issue_id
263 );
264 continue;
265 }
266 tx.execute(
267 "INSERT OR IGNORE INTO issue_labels (issue_id, label_id) VALUES (?1, ?2)",
268 rusqlite::params![issue_id, lid],
269 )?;
270 }
271 tx.commit()?;
272 Ok(())
273 })
274 }
275
276 pub fn get_issue_label_ids(&self, issue_id: &str) -> Result<Vec<String>> {
277 self.with_conn(|conn| {
278 let mut stmt = conn.prepare(
279 "SELECT label_id FROM issue_labels WHERE issue_id = ?1 ORDER BY label_id",
280 )?;
281 let rows = stmt.query_map(rusqlite::params![issue_id], |row| row.get::<_, String>(0))?;
282 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
283 })
284 }
285
286 pub fn upsert_issue(&self, issue: &Issue) -> Result<()> {
289 self.with_conn(|conn| {
290 conn.execute(
291 "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, branch_name, workspace_id)
292 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, datetime('now'), ?15, ?16, ?17)
293 ON CONFLICT(id) DO UPDATE SET
294 identifier=excluded.identifier, team_key=excluded.team_key, title=excluded.title,
295 description=excluded.description, state_name=excluded.state_name, state_type=excluded.state_type,
296 priority=excluded.priority, assignee_name=excluded.assignee_name, project_name=excluded.project_name,
297 labels_json=excluded.labels_json, updated_at=excluded.updated_at,
298 content_hash=excluded.content_hash, url=excluded.url, branch_name=excluded.branch_name,
299 workspace_id=excluded.workspace_id, synced_at=datetime('now')",
300 rusqlite::params![
301 issue.id, issue.identifier, issue.team_key, issue.title, issue.description,
302 issue.state_name, issue.state_type, issue.priority, issue.assignee_name,
303 issue.project_name, issue.labels_json, issue.created_at, issue.updated_at,
304 issue.content_hash, issue.url, issue.branch_name, issue.workspace_id,
305 ],
306 )?;
307 Ok(())
308 })
309 }
310
311 pub fn get_issue(&self, id_or_identifier: &str) -> Result<Option<Issue>> {
312 self.with_conn(|conn| {
313 let mut stmt = conn.prepare(
314 "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, branch_name, workspace_id
315 FROM issues WHERE id = ?1 OR identifier = ?1"
316 )?;
317 let mut rows = stmt.query(rusqlite::params![id_or_identifier])?;
318 if let Some(row) = rows.next()? {
319 Ok(Some(Issue::from_row(row)?))
320 } else {
321 Ok(None)
322 }
323 })
324 }
325
326 fn label_filter_fragment(
332 label_ids: &[String],
333 param_offset: usize,
334 table_alias: &str,
335 ) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
336 let n = label_ids.len();
337 let placeholders = (0..n)
338 .map(|i| format!("?{}", param_offset + i))
339 .collect::<Vec<_>>()
340 .join(", ");
341 let sql = format!(
342 "{table_alias}.id IN (\
343 SELECT issue_id FROM issue_labels \
344 WHERE label_id IN ({placeholders}) \
345 GROUP BY issue_id \
346 HAVING COUNT(DISTINCT label_id) = {n}\
347 )"
348 );
349 let params: Vec<Box<dyn rusqlite::types::ToSql>> =
350 label_ids.iter().map(|s| Box::new(s.clone()) as Box<dyn rusqlite::types::ToSql>).collect();
351 (sql, params)
352 }
353
354 pub fn get_unprioritized_issues(
355 &self,
356 team_key: Option<&str>,
357 include_completed: bool,
358 workspace_id: &str,
359 ) -> Result<Vec<Issue>> {
360 self.get_unprioritized_issues_filtered(team_key, include_completed, workspace_id, None)
361 }
362
363 pub fn get_unprioritized_issues_filtered(
364 &self,
365 team_key: Option<&str>,
366 include_completed: bool,
367 workspace_id: &str,
368 label_ids: Option<&[String]>,
369 ) -> Result<Vec<Issue>> {
370 self.with_conn(|conn| {
371 let state_filter = if include_completed {
372 ""
373 } else {
374 " AND state_type NOT IN ('completed', 'canceled')"
375 };
376
377 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
379 let base_where: String = if let Some(team) = team_key {
380 params.push(Box::new(team.to_string()));
381 params.push(Box::new(workspace_id.to_string()));
382 "team_key = ?1 AND workspace_id = ?2".to_string()
383 } else {
384 params.push(Box::new(workspace_id.to_string()));
385 "workspace_id = ?1".to_string()
386 };
387
388 let label_clause = if let Some(ids) = label_ids.filter(|ids| !ids.is_empty()) {
389 let (frag, mut lp) = Self::label_filter_fragment(ids, params.len() + 1, "issues");
390 params.append(&mut lp);
391 format!(" AND {frag}")
392 } else {
393 String::new()
394 };
395
396 let sql = format!(
397 "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, branch_name, workspace_id
398 FROM issues WHERE priority = 0{state_filter} AND {base_where}{label_clause}
399 ORDER BY created_at DESC"
400 );
401
402 let mut stmt = conn.prepare(&sql)?;
403 let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
404 let rows = stmt.query_map(param_refs.as_slice(), |row| Ok(Issue::from_row(row).unwrap()))?;
405 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
406 })
407 }
408
409 pub fn get_issues_by_state_types(
410 &self,
411 team_key: &str,
412 state_types: &[String],
413 workspace_id: &str,
414 ) -> Result<Vec<Issue>> {
415 self.with_conn(|conn| {
416 let placeholders: String = state_types
417 .iter()
418 .enumerate()
419 .map(|(i, _)| format!("?{}", i + 3))
420 .collect::<Vec<_>>()
421 .join(", ");
422 let sql = format!(
423 "SELECT id, identifier, team_key, title, description, state_name, state_type, \
424 priority, assignee_name, project_name, labels_json, created_at, updated_at, \
425 content_hash, synced_at, url, branch_name, workspace_id \
426 FROM issues WHERE team_key = ?1 AND workspace_id = ?2 AND state_type IN ({placeholders}) \
427 ORDER BY priority ASC, created_at DESC"
428 );
429 let mut stmt = conn.prepare(&sql)?;
430 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> =
431 vec![Box::new(team_key.to_string()), Box::new(workspace_id.to_string())];
432 for st in state_types {
433 params.push(Box::new(st.clone()));
434 }
435 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
436 params.iter().map(|p| p.as_ref()).collect();
437 let rows = stmt.query_map(param_refs.as_slice(), |row| {
438 Ok(Issue::from_row(row).unwrap())
439 })?;
440 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
441 })
442 }
443
444 pub fn get_blockers_for_issues(&self, issue_ids: &[String]) -> Result<Vec<BlockerRow>> {
447 if issue_ids.is_empty() {
448 return Ok(vec![]);
449 }
450 self.with_conn(|conn| {
451 let placeholders: String = issue_ids
452 .iter()
453 .enumerate()
454 .map(|(i, _)| format!("?{}", i + 1))
455 .collect::<Vec<_>>()
456 .join(", ");
457
458 let sql_fwd = format!(
460 "SELECT r.issue_id, COALESCE(i.identifier, r.related_issue_identifier),
461 COALESCE(i.title, ''), COALESCE(i.state_name, ''), COALESCE(i.state_type, '')
462 FROM issue_relations r
463 LEFT JOIN issues i ON r.related_issue_id = i.id
464 WHERE r.issue_id IN ({placeholders}) AND r.relation_type = 'blocked_by'"
465 );
466
467 let sql_inv = format!(
469 "SELECT r.related_issue_id, i2.identifier,
470 COALESCE(i2.title, ''), COALESCE(i2.state_name, ''), COALESCE(i2.state_type, '')
471 FROM issue_relations r
472 JOIN issues i ON r.related_issue_id = i.id
473 JOIN issues i2 ON r.issue_id = i2.id
474 WHERE r.related_issue_id IN ({placeholders}) AND r.relation_type = 'blocks'"
475 );
476
477 let mut results = Vec::new();
478 let params: Vec<Box<dyn rusqlite::types::ToSql>> =
479 issue_ids.iter().map(|id| Box::new(id.clone()) as _).collect();
480 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
481 params.iter().map(|p| p.as_ref()).collect();
482
483 for sql in [&sql_fwd, &sql_inv] {
484 let mut stmt = conn.prepare(sql)?;
485 let rows = stmt.query_map(param_refs.as_slice(), |row| {
486 Ok(BlockerRow {
487 issue_id: row.get(0)?,
488 identifier: row.get(1)?,
489 title: row.get(2)?,
490 state_name: row.get(3)?,
491 state_type: row.get(4)?,
492 })
493 })?;
494 for row in rows {
495 results.push(row?);
496 }
497 }
498 Ok(results)
499 })
500 }
501
502 pub fn count_issues(&self, team_key: Option<&str>, workspace_id: &str) -> Result<usize> {
503 self.with_conn(|conn| {
504 let count: usize = if let Some(team) = team_key {
505 conn.query_row(
506 "SELECT COUNT(*) FROM issues WHERE team_key = ?1 AND workspace_id = ?2",
507 rusqlite::params![team, workspace_id],
508 |row| row.get(0),
509 )?
510 } else {
511 conn.query_row(
512 "SELECT COUNT(*) FROM issues WHERE workspace_id = ?1",
513 rusqlite::params![workspace_id],
514 |row| row.get(0),
515 )?
516 };
517 Ok(count)
518 })
519 }
520
521 pub fn get_field_completeness(
523 &self,
524 team_key: Option<&str>,
525 workspace_id: &str,
526 ) -> Result<(usize, usize, usize, usize, usize)> {
527 self.with_conn(|conn| {
528 let (sql, params): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
529 if let Some(team) = team_key {
530 (
531 "SELECT COUNT(*),
532 SUM(CASE WHEN description IS NOT NULL AND description != '' THEN 1 ELSE 0 END),
533 SUM(CASE WHEN priority > 0 THEN 1 ELSE 0 END),
534 SUM(CASE WHEN labels_json != '[]' THEN 1 ELSE 0 END),
535 SUM(CASE WHEN project_name IS NOT NULL AND project_name != '' THEN 1 ELSE 0 END)
536 FROM issues WHERE team_key = ?1 AND workspace_id = ?2"
537 .to_string(),
538 vec![Box::new(team.to_string()) as Box<dyn rusqlite::types::ToSql>, Box::new(workspace_id.to_string())],
539 )
540 } else {
541 (
542 "SELECT COUNT(*),
543 SUM(CASE WHEN description IS NOT NULL AND description != '' THEN 1 ELSE 0 END),
544 SUM(CASE WHEN priority > 0 THEN 1 ELSE 0 END),
545 SUM(CASE WHEN labels_json != '[]' THEN 1 ELSE 0 END),
546 SUM(CASE WHEN project_name IS NOT NULL AND project_name != '' THEN 1 ELSE 0 END)
547 FROM issues WHERE workspace_id = ?1"
548 .to_string(),
549 vec![Box::new(workspace_id.to_string()) as Box<dyn rusqlite::types::ToSql>],
550 )
551 };
552 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
553 params.iter().map(|p| p.as_ref()).collect();
554 let row = conn.query_row(&sql, param_refs.as_slice(), |row| {
555 Ok((
556 row.get::<_, usize>(0)?,
557 row.get::<_, Option<usize>>(1)?.unwrap_or(0),
558 row.get::<_, Option<usize>>(2)?.unwrap_or(0),
559 row.get::<_, Option<usize>>(3)?.unwrap_or(0),
560 row.get::<_, Option<usize>>(4)?.unwrap_or(0),
561 ))
562 })?;
563 Ok(row)
564 })
565 }
566
567 #[allow(unused_assignments)]
570 pub fn list_all_issues(
571 &self,
572 team_key: Option<&str>,
573 filter: Option<&str>,
574 limit: usize,
575 offset: usize,
576 workspace_id: &str,
577 ) -> Result<Vec<IssueSummary>> {
578 self.with_conn(|conn| {
579 let mut conditions = Vec::new();
580 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
581 let mut param_idx = 1;
582
583 conditions.push(format!("i.workspace_id = ?{param_idx}"));
585 params.push(Box::new(workspace_id.to_string()));
586 param_idx += 1;
587
588 if let Some(team) = team_key {
589 conditions.push(format!("i.team_key = ?{param_idx}"));
590 params.push(Box::new(team.to_string()));
591 param_idx += 1;
592 }
593
594 if let Some(text) = filter {
595 let like = format!("%{text}%");
596 conditions.push(format!(
597 "(i.identifier LIKE ?{} OR i.title LIKE ?{})",
598 param_idx,
599 param_idx + 1
600 ));
601 params.push(Box::new(like.clone()));
602 params.push(Box::new(like));
603 param_idx += 2;
604 }
605
606 let _ = param_idx;
607
608 let where_clause = if conditions.is_empty() {
609 String::new()
610 } else {
611 format!("WHERE {}", conditions.join(" AND "))
612 };
613
614 let limit_idx = params.len() + 1;
615 let offset_idx = params.len() + 2;
616
617 let sql = format!(
618 "SELECT i.id, i.identifier, i.team_key, i.title, i.state_name, i.state_type,
619 i.priority, i.project_name, i.labels_json, i.updated_at, i.url,
620 i.description IS NOT NULL AND i.description != '' AS has_desc,
621 EXISTS(SELECT 1 FROM chunks c WHERE c.issue_id = i.id) AS has_emb
622 FROM issues i
623 {where_clause}
624 ORDER BY i.updated_at DESC
625 LIMIT ?{limit_idx} OFFSET ?{offset_idx}"
626 );
627 params.push(Box::new(limit as i64));
628 params.push(Box::new(offset as i64));
629
630 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
631 params.iter().map(|p| p.as_ref()).collect();
632 let mut stmt = conn.prepare(&sql)?;
633 let rows = stmt.query_map(param_refs.as_slice(), |row| {
634 let labels_json: String = row.get(8)?;
635 let labels: Vec<String> = serde_json::from_str(&labels_json).unwrap_or_default();
636 Ok(IssueSummary {
637 id: row.get(0)?,
638 identifier: row.get(1)?,
639 team_key: row.get(2)?,
640 title: row.get(3)?,
641 state_name: row.get(4)?,
642 state_type: row.get(5)?,
643 priority: row.get(6)?,
644 project_name: row.get(7)?,
645 labels,
646 updated_at: row.get(9)?,
647 url: row.get(10)?,
648 has_description: row.get(11)?,
649 has_embedding: row.get(12)?,
650 })
651 })?;
652 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
653 })
654 }
655
656 pub fn upsert_relations(&self, issue_id: &str, relations: &[Relation]) -> Result<()> {
659 self.with_conn(|conn| {
660 conn.execute(
661 "DELETE FROM issue_relations WHERE issue_id = ?1",
662 rusqlite::params![issue_id],
663 )?;
664 let mut stmt = conn.prepare(
665 "INSERT OR IGNORE INTO issue_relations (id, issue_id, related_issue_id, related_issue_identifier, relation_type)
666 VALUES (?1, ?2, ?3, ?4, ?5)"
667 )?;
668 for rel in relations {
669 stmt.execute(rusqlite::params![
670 rel.id, rel.issue_id, rel.related_issue_id,
671 rel.related_issue_identifier, rel.relation_type,
672 ])?;
673 }
674 Ok(())
675 })
676 }
677
678 pub fn get_relations_enriched(&self, issue_id: &str) -> Result<Vec<EnrichedRelation>> {
679 self.with_conn(|conn| {
680 let mut stmt = conn.prepare(
682 "SELECT r.id, r.relation_type, r.related_issue_identifier,
683 COALESCE(i.title, ''), COALESCE(i.state_name, ''), COALESCE(i.url, '')
684 FROM issue_relations r
685 LEFT JOIN issues i ON r.related_issue_id = i.id
686 WHERE r.issue_id = ?1",
687 )?;
688 let forward = stmt
689 .query_map(rusqlite::params![issue_id], |row| {
690 Ok(EnrichedRelation {
691 relation_id: row.get(0)?,
692 relation_type: row.get(1)?,
693 issue_identifier: row.get(2)?,
694 issue_title: row.get(3)?,
695 issue_state: row.get(4)?,
696 issue_url: row.get(5)?,
697 })
698 })?
699 .collect::<std::result::Result<Vec<_>, _>>()?;
700
701 let mut stmt2 = conn.prepare(
703 "SELECT r.id, r.relation_type, i2.identifier,
704 COALESCE(i2.title, ''), COALESCE(i2.state_name, ''), COALESCE(i2.url, '')
705 FROM issue_relations r
706 JOIN issues i ON r.related_issue_id = i.id
707 JOIN issues i2 ON r.issue_id = i2.id
708 WHERE r.related_issue_id = i.id AND i.id = ?1",
709 )?;
710 let inverse = stmt2
711 .query_map(rusqlite::params![issue_id], |row| {
712 let raw_type: String = row.get(1)?;
713 let flipped = match raw_type.as_str() {
714 "blocks" => "blocked_by".to_string(),
715 "blocked_by" => "blocks".to_string(),
716 other => other.to_string(), };
718 Ok(EnrichedRelation {
719 relation_id: row.get(0)?,
720 relation_type: flipped,
721 issue_identifier: row.get(2)?,
722 issue_title: row.get(3)?,
723 issue_state: row.get(4)?,
724 issue_url: row.get(5)?,
725 })
726 })?
727 .collect::<std::result::Result<Vec<_>, _>>()?;
728
729 let mut all = forward;
730 all.extend(inverse);
731 Ok(all)
732 })
733 }
734
735 pub fn find_relation_id(
737 &self,
738 issue_id: &str,
739 related_issue_id: &str,
740 relation_type: &str,
741 ) -> Result<Option<String>> {
742 self.with_conn(|conn| {
743 let mut stmt = conn.prepare(
744 "SELECT id FROM issue_relations WHERE issue_id = ?1 AND related_issue_id = ?2 AND relation_type = ?3"
745 )?;
746 let mut rows = stmt.query(rusqlite::params![issue_id, related_issue_id, relation_type])?;
747 if let Some(row) = rows.next()? {
748 Ok(Some(row.get(0)?))
749 } else {
750 Ok(None)
751 }
752 })
753 }
754
755 pub fn upsert_chunks(&self, issue_id: &str, chunks: &[(usize, String, Vec<u8>)]) -> Result<()> {
758 self.upsert_chunks_with_model(issue_id, chunks, "")
759 }
760
761 pub fn upsert_chunks_with_model(
762 &self,
763 issue_id: &str,
764 chunks: &[(usize, String, Vec<u8>)],
765 model_name: &str,
766 ) -> Result<()> {
767 self.with_conn(|conn| {
768 conn.execute(
769 "DELETE FROM chunks WHERE issue_id = ?1",
770 rusqlite::params![issue_id],
771 )?;
772 let mut stmt = conn.prepare(
773 "INSERT INTO chunks (issue_id, chunk_index, chunk_text, embedding, model_name) VALUES (?1, ?2, ?3, ?4, ?5)"
774 )?;
775 for (idx, text, embedding) in chunks {
776 stmt.execute(rusqlite::params![issue_id, idx, text, embedding, model_name])?;
777 }
778 Ok(())
779 })
780 }
781
782 pub fn get_embedding_model(&self, issue_id: &str) -> Result<Option<String>> {
784 self.with_conn(|conn| {
785 let mut stmt =
786 conn.prepare("SELECT model_name FROM chunks WHERE issue_id = ?1 LIMIT 1")?;
787 let mut rows = stmt.query(rusqlite::params![issue_id])?;
788 if let Some(row) = rows.next()? {
789 let name: String = row.get(0)?;
790 Ok(if name.is_empty() { None } else { Some(name) })
791 } else {
792 Ok(None)
793 }
794 })
795 }
796
797 pub fn get_all_chunks(&self, workspace_id: &str) -> Result<Vec<Chunk>> {
798 self.with_conn(|conn| {
799 let mut stmt = conn.prepare(
800 "SELECT c.issue_id, c.embedding, i.identifier
801 FROM chunks c JOIN issues i ON c.issue_id = i.id
802 WHERE i.workspace_id = ?1",
803 )?;
804 let rows = stmt.query_map(rusqlite::params![workspace_id], |row| {
805 Ok(Chunk {
806 issue_id: row.get(0)?,
807 embedding: row.get(1)?,
808 identifier: row.get(2)?,
809 })
810 })?;
811 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
812 })
813 }
814
815 pub fn get_chunks_for_team(&self, team_key: &str, workspace_id: &str) -> Result<Vec<Chunk>> {
816 self.with_conn(|conn| {
817 let mut stmt = conn.prepare(
818 "SELECT c.issue_id, c.embedding, i.identifier
819 FROM chunks c JOIN issues i ON c.issue_id = i.id
820 WHERE i.team_key = ?1 AND i.workspace_id = ?2",
821 )?;
822 let rows = stmt.query_map(rusqlite::params![team_key, workspace_id], |row| {
823 Ok(Chunk {
824 issue_id: row.get(0)?,
825 embedding: row.get(1)?,
826 identifier: row.get(2)?,
827 })
828 })?;
829 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
830 })
831 }
832
833 pub fn count_embedded_issues(
834 &self,
835 team_key: Option<&str>,
836 workspace_id: &str,
837 ) -> Result<usize> {
838 self.with_conn(|conn| {
839 let count: usize = if let Some(team) = team_key {
840 conn.query_row(
841 "SELECT COUNT(DISTINCT c.issue_id) FROM chunks c JOIN issues i ON c.issue_id = i.id WHERE i.team_key = ?1 AND i.workspace_id = ?2",
842 rusqlite::params![team, workspace_id],
843 |row| row.get(0),
844 )?
845 } else {
846 conn.query_row(
847 "SELECT COUNT(DISTINCT c.issue_id) FROM chunks c JOIN issues i ON c.issue_id = i.id WHERE i.workspace_id = ?1",
848 rusqlite::params![workspace_id],
849 |row| row.get(0),
850 )?
851 };
852 Ok(count)
853 })
854 }
855
856 pub fn get_issues_needing_embedding(
857 &self,
858 team_key: Option<&str>,
859 force: bool,
860 workspace_id: &str,
861 ) -> Result<Vec<Issue>> {
862 self.with_conn(|conn| {
863 let sql = if force {
864 if let Some(team) = team_key {
865 format!(
866 "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, branch_name, workspace_id
867 FROM issues WHERE team_key = '{}' AND workspace_id = '{}'", team, workspace_id
868 )
869 } else {
870 format!(
871 "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, branch_name, workspace_id
872 FROM issues WHERE workspace_id = '{}'", workspace_id
873 )
874 }
875 } else {
876 let team_filter = if let Some(team) = team_key {
877 format!("AND i.team_key = '{}'", team)
878 } else {
879 String::new()
880 };
881 format!(
882 "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, i.branch_name, i.workspace_id
883 FROM issues i
884 LEFT JOIN (SELECT DISTINCT issue_id FROM chunks) c ON i.id = c.issue_id
885 WHERE c.issue_id IS NULL AND i.workspace_id = '{}' {}",
886 workspace_id, team_filter
887 )
888 };
889 let mut stmt = conn.prepare(&sql)?;
890 let rows = stmt.query_map([], |row| {
891 Ok(Issue::from_row(row).unwrap())
892 })?;
893 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
894 })
895 }
896
897 pub fn get_comments(&self, issue_id: &str) -> Result<Vec<Comment>> {
900 self.with_conn(|conn| {
901 let mut stmt = conn.prepare(
902 "SELECT id, issue_id, body, user_name, created_at FROM comments WHERE issue_id = ?1 ORDER BY created_at"
903 )?;
904 let rows = stmt.query_map(rusqlite::params![issue_id], |row| {
905 Ok(Comment {
906 id: row.get(0)?,
907 issue_id: row.get(1)?,
908 body: row.get(2)?,
909 user_name: row.get(3)?,
910 created_at: row.get(4)?,
911 })
912 })?;
913 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
914 })
915 }
916
917 pub fn get_sync_cursor(&self, workspace_id: &str, team_key: &str) -> Result<Option<String>> {
920 self.with_conn(|conn| {
921 let mut stmt = conn.prepare(
922 "SELECT last_updated_at FROM sync_state WHERE workspace_id = ?1 AND team_key = ?2",
923 )?;
924 let mut rows = stmt.query(rusqlite::params![workspace_id, team_key])?;
925 if let Some(row) = rows.next()? {
926 Ok(Some(row.get(0)?))
927 } else {
928 Ok(None)
929 }
930 })
931 }
932
933 pub fn set_sync_cursor(
934 &self,
935 workspace_id: &str,
936 team_key: &str,
937 last_updated_at: &str,
938 ) -> Result<()> {
939 self.with_conn(|conn| {
940 conn.execute(
941 "INSERT INTO sync_state (workspace_id, team_key, last_updated_at, full_sync_done, last_synced_at)
942 VALUES (?1, ?2, ?3, 1, datetime('now'))
943 ON CONFLICT(workspace_id, team_key) DO UPDATE SET last_updated_at=excluded.last_updated_at, full_sync_done=1, last_synced_at=datetime('now')",
944 rusqlite::params![workspace_id, team_key, last_updated_at],
945 )?;
946 Ok(())
947 })
948 }
949
950 pub fn is_full_sync_done(&self, workspace_id: &str, team_key: &str) -> Result<bool> {
951 self.with_conn(|conn| {
952 let mut stmt = conn.prepare(
953 "SELECT full_sync_done FROM sync_state WHERE workspace_id = ?1 AND team_key = ?2",
954 )?;
955 let mut rows = stmt.query(rusqlite::params![workspace_id, team_key])?;
956 if let Some(row) = rows.next()? {
957 let done: bool = row.get(0)?;
958 Ok(done)
959 } else {
960 Ok(false)
961 }
962 })
963 }
964
965 pub fn get_last_synced_at(&self, workspace_id: &str, team_key: &str) -> Result<Option<String>> {
967 self.with_conn(|conn| {
968 let mut stmt = conn.prepare(
969 "SELECT last_synced_at FROM sync_state WHERE workspace_id = ?1 AND team_key = ?2",
970 )?;
971 let mut rows = stmt.query(rusqlite::params![workspace_id, team_key])?;
972 if let Some(row) = rows.next()? {
973 Ok(row.get(0)?)
974 } else {
975 Ok(None)
976 }
977 })
978 }
979
980 pub fn get_metadata(&self, key: &str) -> Result<Option<String>> {
983 self.with_conn(|conn| {
984 let mut stmt = conn.prepare("SELECT value FROM metadata WHERE key = ?1")?;
985 let mut rows = stmt.query(rusqlite::params![key])?;
986 if let Some(row) = rows.next()? {
987 Ok(Some(row.get(0)?))
988 } else {
989 Ok(None)
990 }
991 })
992 }
993
994 pub fn set_metadata(&self, key: &str, value: &str) -> Result<()> {
995 self.with_conn(|conn| {
996 conn.execute(
997 "INSERT INTO metadata (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
998 rusqlite::params![key, value],
999 )?;
1000 Ok(())
1001 })
1002 }
1003
1004 pub fn list_synced_teams(&self, workspace_id: &str) -> Result<Vec<TeamSummary>> {
1009 self.with_conn(|conn| {
1010 let mut stmt = conn.prepare(
1011 "SELECT i.team_key,
1012 COUNT(DISTINCT i.id) AS issue_count,
1013 COUNT(DISTINCT c.issue_id) AS embedded_count,
1014 s.last_synced_at
1015 FROM issues i
1016 LEFT JOIN chunks c ON i.id = c.issue_id
1017 LEFT JOIN sync_state s ON i.team_key = s.team_key AND s.workspace_id = ?1
1018 WHERE i.workspace_id = ?1
1019 GROUP BY i.team_key
1020 ORDER BY i.team_key",
1021 )?;
1022 let rows = stmt.query_map(rusqlite::params![workspace_id], |row| {
1023 Ok(TeamSummary {
1024 key: row.get(0)?,
1025 issue_count: row.get(1)?,
1026 embedded_count: row.get(2)?,
1027 last_synced_at: row.get(3)?,
1028 })
1029 })?;
1030 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
1031 })
1032 }
1033
1034 pub fn fts_search(
1035 &self,
1036 query: &str,
1037 limit: usize,
1038 workspace_id: &str,
1039 ) -> Result<Vec<FtsResult>> {
1040 self.fts_search_filtered(query, limit, workspace_id, None)
1041 }
1042
1043 pub fn fts_search_filtered(
1044 &self,
1045 query: &str,
1046 limit: usize,
1047 workspace_id: &str,
1048 label_ids: Option<&[String]>,
1049 ) -> Result<Vec<FtsResult>> {
1050 self.with_conn(|conn| {
1051 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![
1052 Box::new(query.to_string()),
1053 Box::new(limit as i64),
1054 Box::new(workspace_id.to_string()),
1055 ];
1056
1057 let label_clause = if let Some(ids) = label_ids.filter(|ids| !ids.is_empty()) {
1058 let (frag, mut lp) = Self::label_filter_fragment(ids, params.len() + 1, "i");
1059 params.append(&mut lp);
1060 format!(" AND {frag}")
1061 } else {
1062 String::new()
1063 };
1064
1065 let sql = format!(
1066 "SELECT i.id, i.identifier, i.title, i.state_name, i.priority, bm25(issues_fts) as rank
1067 FROM issues_fts f
1068 JOIN issues i ON f.rowid = i.rowid
1069 WHERE issues_fts MATCH ?1 AND i.workspace_id = ?3{label_clause}
1070 ORDER BY rank
1071 LIMIT ?2"
1072 );
1073 let mut stmt = conn.prepare(&sql)?;
1074 let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
1075 let rows = stmt.query_map(param_refs.as_slice(), |row| {
1076 Ok(FtsResult {
1077 issue_id: row.get(0)?,
1078 identifier: row.get(1)?,
1079 title: row.get(2)?,
1080 state_name: row.get(3)?,
1081 priority: row.get(4)?,
1082 bm25_score: row.get(5)?,
1083 })
1084 })?;
1085 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
1086 })
1087 }
1088}
1089
1090fn default_workspace_id() -> String {
1093 "default".to_string()
1094}
1095
1096#[derive(Debug, Clone, Serialize, Deserialize)]
1097pub struct Issue {
1098 pub id: String,
1099 pub identifier: String,
1100 pub team_key: String,
1101 pub title: String,
1102 pub description: Option<String>,
1103 pub state_name: String,
1104 pub state_type: String,
1105 pub priority: i32,
1106 pub assignee_name: Option<String>,
1107 pub project_name: Option<String>,
1108 pub labels_json: String,
1109 pub created_at: String,
1110 pub updated_at: String,
1111 pub content_hash: String,
1112 pub synced_at: Option<String>,
1113 pub url: String,
1114 pub branch_name: Option<String>,
1115 #[serde(default = "default_workspace_id")]
1116 pub workspace_id: String,
1117}
1118
1119impl Issue {
1120 pub fn from_row(row: &rusqlite::Row) -> rusqlite::Result<Self> {
1121 Ok(Self {
1122 id: row.get(0)?,
1123 identifier: row.get(1)?,
1124 team_key: row.get(2)?,
1125 title: row.get(3)?,
1126 description: row.get(4)?,
1127 state_name: row.get(5)?,
1128 state_type: row.get(6)?,
1129 priority: row.get(7)?,
1130 assignee_name: row.get(8)?,
1131 project_name: row.get(9)?,
1132 labels_json: row.get(10)?,
1133 created_at: row.get(11)?,
1134 updated_at: row.get(12)?,
1135 content_hash: row.get(13)?,
1136 synced_at: row.get(14)?,
1137 url: row.get(15)?,
1138 branch_name: row.get(16).unwrap_or(None),
1139 workspace_id: row.get(17).unwrap_or_else(|_| "default".to_string()),
1140 })
1141 }
1142
1143 pub fn labels(&self) -> Vec<String> {
1144 serde_json::from_str(&self.labels_json).unwrap_or_default()
1145 }
1146
1147 pub fn priority_label(&self) -> &str {
1148 match self.priority {
1149 0 => "No priority",
1150 1 => "Urgent",
1151 2 => "High",
1152 3 => "Medium",
1153 4 => "Low",
1154 _ => "Unknown",
1155 }
1156 }
1157}
1158
1159#[derive(Debug, Clone, Serialize, Deserialize)]
1160pub struct Relation {
1161 pub id: String,
1162 pub issue_id: String,
1163 pub related_issue_id: String,
1164 pub related_issue_identifier: String,
1165 pub relation_type: String,
1166}
1167
1168#[derive(Debug, Clone, Serialize, Deserialize)]
1169pub struct EnrichedRelation {
1170 pub relation_id: String,
1171 pub relation_type: String,
1172 pub issue_identifier: String,
1173 pub issue_title: String,
1174 pub issue_state: String,
1175 pub issue_url: String,
1176}
1177
1178#[derive(Debug, Clone)]
1179pub struct Chunk {
1180 pub issue_id: String,
1181 pub embedding: Vec<u8>,
1182 pub identifier: String,
1183}
1184
1185#[derive(Debug, Clone, Serialize, Deserialize)]
1186pub struct Comment {
1187 pub id: String,
1188 pub issue_id: String,
1189 pub body: String,
1190 pub user_name: Option<String>,
1191 pub created_at: String,
1192}
1193
1194#[derive(Debug, Clone)]
1195pub struct FtsResult {
1196 pub issue_id: String,
1197 pub identifier: String,
1198 pub title: String,
1199 pub state_name: String,
1200 pub priority: i32,
1201 pub bm25_score: f64,
1202}
1203
1204#[derive(Debug, Clone)]
1205pub struct IssueSummary {
1206 pub id: String,
1207 pub identifier: String,
1208 pub team_key: String,
1209 pub title: String,
1210 pub state_name: String,
1211 pub state_type: String,
1212 pub priority: i32,
1213 pub project_name: Option<String>,
1214 pub labels: Vec<String>,
1215 pub updated_at: String,
1216 pub url: String,
1217 pub has_description: bool,
1218 pub has_embedding: bool,
1219}
1220
1221#[derive(Debug, Clone)]
1222pub struct TeamSummary {
1223 pub key: String,
1224 pub issue_count: usize,
1225 pub embedded_count: usize,
1226 pub last_synced_at: Option<String>,
1227}
1228
1229#[derive(Debug, Clone, Serialize, Deserialize)]
1230pub struct WorkspaceRow {
1231 pub id: String,
1232 pub linear_org_id: Option<String>,
1233 pub display_name: Option<String>,
1234 pub created_at: String,
1235}
1236
1237#[derive(Debug, Clone, Serialize, Deserialize)]
1238pub struct Label {
1239 pub id: String,
1240 pub workspace_id: String,
1241 pub name: String,
1242 pub color: Option<String>,
1243 pub parent_id: Option<String>,
1244}
1245
1246#[cfg(test)]
1247mod tests {
1248 use super::test_helpers::*;
1249
1250 #[test]
1251 fn count_embedded_issues_empty_db() {
1252 let (db, _dir) = test_db();
1253 assert_eq!(db.count_embedded_issues(None, "default").unwrap(), 0);
1254 }
1255
1256 #[test]
1257 fn count_embedded_issues_with_data() {
1258 let (db, _dir) = test_db();
1259
1260 let issue1 = make_issue("TST-1", "TST");
1261 let issue2 = make_issue("TST-2", "TST");
1262 let issue3 = make_issue("OTH-1", "OTH");
1263 db.upsert_issue(&issue1).unwrap();
1264 db.upsert_issue(&issue2).unwrap();
1265 db.upsert_issue(&issue3).unwrap();
1266
1267 db.upsert_chunks(&issue1.id, &[(0, "chunk".into(), fake_embedding(768))])
1269 .unwrap();
1270 db.upsert_chunks(&issue3.id, &[(0, "chunk".into(), fake_embedding(768))])
1271 .unwrap();
1272
1273 assert_eq!(db.count_embedded_issues(None, "default").unwrap(), 2);
1275 assert_eq!(db.count_embedded_issues(Some("TST"), "default").unwrap(), 1);
1277 assert_eq!(db.count_embedded_issues(Some("OTH"), "default").unwrap(), 1);
1278 assert_eq!(
1279 db.count_embedded_issues(Some("NONE"), "default").unwrap(),
1280 0
1281 );
1282 }
1283
1284 #[test]
1285 fn get_field_completeness_empty_db() {
1286 let (db, _dir) = test_db();
1287 let (total, desc, pri, labels, proj) = db.get_field_completeness(None, "default").unwrap();
1288 assert_eq!(total, 0);
1289 assert_eq!(desc, 0);
1290 assert_eq!(pri, 0);
1291 assert_eq!(labels, 0);
1292 assert_eq!(proj, 0);
1293 }
1294
1295 #[test]
1296 fn get_field_completeness_with_data() {
1297 let (db, _dir) = test_db();
1298
1299 let mut full = make_issue("TST-1", "TST");
1301 full.description = Some("Has desc".into());
1302 full.priority = 2;
1303 full.labels_json = r#"["bug"]"#.into();
1304 full.project_name = Some("Proj".into());
1305 db.upsert_issue(&full).unwrap();
1306
1307 let mut sparse = make_issue("TST-2", "TST");
1309 sparse.description = None;
1310 sparse.priority = 0;
1311 sparse.labels_json = "[]".into();
1312 sparse.project_name = None;
1313 db.upsert_issue(&sparse).unwrap();
1314
1315 let mut other = make_issue("OTH-1", "OTH");
1317 other.description = Some("Has desc".into());
1318 other.priority = 0;
1319 other.labels_json = "[]".into();
1320 other.project_name = None;
1321 db.upsert_issue(&other).unwrap();
1322
1323 let (total, desc, pri, labels, proj) = db.get_field_completeness(None, "default").unwrap();
1325 assert_eq!(total, 3);
1326 assert_eq!(desc, 2); assert_eq!(pri, 1); assert_eq!(labels, 1); assert_eq!(proj, 1); let (total, desc, pri, labels, proj) =
1333 db.get_field_completeness(Some("TST"), "default").unwrap();
1334 assert_eq!(total, 2);
1335 assert_eq!(desc, 1);
1336 assert_eq!(pri, 1);
1337 assert_eq!(labels, 1);
1338 assert_eq!(proj, 1);
1339 }
1340
1341 #[test]
1342 fn list_all_issues_pagination_and_filter() {
1343 let (db, _dir) = test_db();
1344
1345 for i in 1..=5 {
1346 let mut issue = make_issue(&format!("TST-{i}"), "TST");
1347 issue.updated_at = format!("2026-01-0{i}T00:00:00Z");
1348 db.upsert_issue(&issue).unwrap();
1349 }
1350 let mut other = make_issue("OTH-1", "OTH");
1351 other.updated_at = "2026-01-06T00:00:00Z".to_string();
1352 db.upsert_issue(&other).unwrap();
1353
1354 let page1 = db.list_all_issues(None, None, 3, 0, "default").unwrap();
1356 assert_eq!(page1.len(), 3);
1357 assert_eq!(page1[0].identifier, "OTH-1");
1359
1360 let page2 = db.list_all_issues(None, None, 3, 3, "default").unwrap();
1362 assert_eq!(page2.len(), 3);
1363
1364 let page3 = db.list_all_issues(None, None, 3, 6, "default").unwrap();
1366 assert_eq!(page3.len(), 0);
1367
1368 let tst = db
1370 .list_all_issues(Some("TST"), None, 10, 0, "default")
1371 .unwrap();
1372 assert_eq!(tst.len(), 5);
1373
1374 let filtered = db
1376 .list_all_issues(None, Some("TST-3"), 10, 0, "default")
1377 .unwrap();
1378 assert_eq!(filtered.len(), 1);
1379 assert_eq!(filtered[0].identifier, "TST-3");
1380
1381 let title_match = db
1383 .list_all_issues(None, Some("Test issue OTH"), 10, 0, "default")
1384 .unwrap();
1385 assert_eq!(title_match.len(), 1);
1386 }
1387
1388 #[test]
1389 fn list_all_issues_has_embedding_flag() {
1390 let (db, _dir) = test_db();
1391
1392 let issue1 = make_issue("TST-1", "TST");
1393 let issue2 = make_issue("TST-2", "TST");
1394 db.upsert_issue(&issue1).unwrap();
1395 db.upsert_issue(&issue2).unwrap();
1396
1397 db.upsert_chunks(&issue1.id, &[(0, "chunk".into(), fake_embedding(768))])
1399 .unwrap();
1400
1401 let issues = db.list_all_issues(None, None, 10, 0, "default").unwrap();
1402 let by_id: std::collections::HashMap<_, _> =
1403 issues.iter().map(|i| (i.identifier.as_str(), i)).collect();
1404
1405 assert!(by_id["TST-1"].has_embedding);
1406 assert!(!by_id["TST-2"].has_embedding);
1407 }
1408
1409 #[test]
1410 fn list_synced_teams_empty_db() {
1411 let (db, _dir) = test_db();
1412 let teams = db.list_synced_teams("default").unwrap();
1413 assert!(teams.is_empty());
1414 }
1415
1416 #[test]
1417 fn list_synced_teams_with_data() {
1418 let (db, _dir) = test_db();
1419
1420 for i in 1..=3 {
1422 let issue = make_issue(&format!("TST-{i}"), "TST");
1423 db.upsert_issue(&issue).unwrap();
1424 if i <= 2 {
1425 db.upsert_chunks(&issue.id, &[(0, "chunk".into(), fake_embedding(768))])
1427 .unwrap();
1428 }
1429 }
1430 let other = make_issue("OTH-1", "OTH");
1431 db.upsert_issue(&other).unwrap();
1432
1433 let teams = db.list_synced_teams("default").unwrap();
1434 assert_eq!(teams.len(), 2);
1435
1436 let by_key: std::collections::HashMap<_, _> =
1438 teams.iter().map(|t| (t.key.as_str(), t)).collect();
1439
1440 assert_eq!(by_key["TST"].issue_count, 3);
1441 assert_eq!(by_key["TST"].embedded_count, 2);
1442 assert_eq!(by_key["OTH"].issue_count, 1);
1443 assert_eq!(by_key["OTH"].embedded_count, 0);
1444 }
1445
1446 #[test]
1447 fn list_synced_teams_includes_last_synced_at() {
1448 let (db, _dir) = test_db();
1449
1450 let issue = make_issue("TST-1", "TST");
1451 db.upsert_issue(&issue).unwrap();
1452
1453 let teams = db.list_synced_teams("default").unwrap();
1455 assert_eq!(teams.len(), 1);
1456 assert!(teams[0].last_synced_at.is_none());
1457
1458 db.set_sync_cursor("default", "TST", "2026-01-01T00:00:00Z")
1460 .unwrap();
1461 let teams = db.list_synced_teams("default").unwrap();
1462 assert!(teams[0].last_synced_at.is_some());
1463 }
1464
1465 #[test]
1466 fn list_synced_teams_multi_chunk_issue() {
1467 let (db, _dir) = test_db();
1468
1469 let issue = make_issue("TST-1", "TST");
1470 db.upsert_issue(&issue).unwrap();
1471 db.upsert_chunks(
1473 &issue.id,
1474 &[
1475 (0, "chunk0".into(), fake_embedding(768)),
1476 (1, "chunk1".into(), fake_embedding(768)),
1477 (2, "chunk2".into(), fake_embedding(768)),
1478 ],
1479 )
1480 .unwrap();
1481
1482 let teams = db.list_synced_teams("default").unwrap();
1483 assert_eq!(teams.len(), 1);
1484 assert_eq!(teams[0].issue_count, 1); assert_eq!(teams[0].embedded_count, 1);
1486 }
1487
1488 #[test]
1489 fn workspace_crud() {
1490 let (db, _dir) = test_db();
1491
1492 let ws = db.get_workspace("default").unwrap();
1494 assert!(ws.is_some());
1495
1496 db.upsert_workspace("work", None, None).unwrap();
1498 let ws = db.get_workspace("work").unwrap().unwrap();
1499 assert_eq!(ws.id, "work");
1500 assert!(ws.linear_org_id.is_none());
1501
1502 db.upsert_workspace("work", Some("org-123"), Some("Work Org"))
1504 .unwrap();
1505 let ws = db.get_workspace("work").unwrap().unwrap();
1506 assert_eq!(ws.linear_org_id.as_deref(), Some("org-123"));
1507 assert_eq!(ws.display_name.as_deref(), Some("Work Org"));
1508
1509 let all = db.list_workspaces().unwrap();
1511 assert_eq!(all.len(), 2);
1512
1513 db.delete_workspace("work").unwrap();
1515 let ws = db.get_workspace("work").unwrap();
1516 assert!(ws.is_none());
1517 }
1518
1519 #[test]
1520 fn issues_isolated_by_workspace() {
1521 let (db, _dir) = test_db();
1522
1523 db.upsert_workspace("work", None, None).unwrap();
1525
1526 let mut issue1 = make_issue("TST-1", "TST");
1528 issue1.workspace_id = "default".to_string();
1529 issue1.priority = 0;
1530 db.upsert_issue(&issue1).unwrap();
1531
1532 let mut issue2 = make_issue("TST-2", "TST");
1534 issue2.id = "id-2".to_string();
1535 issue2.workspace_id = "work".to_string();
1536 issue2.priority = 0;
1537 db.upsert_issue(&issue2).unwrap();
1538
1539 assert_eq!(db.count_issues(None, "default").unwrap(), 1);
1541 assert_eq!(db.count_issues(None, "work").unwrap(), 1);
1542
1543 let default_unpri = db.get_unprioritized_issues(None, false, "default").unwrap();
1545 assert_eq!(default_unpri.len(), 1);
1546 assert_eq!(default_unpri[0].identifier, "TST-1");
1547
1548 let work_unpri = db.get_unprioritized_issues(None, false, "work").unwrap();
1549 assert_eq!(work_unpri.len(), 1);
1550 assert_eq!(work_unpri[0].identifier, "TST-2");
1551 }
1552
1553 #[test]
1554 fn sync_state_isolated_by_workspace() {
1555 let (db, _dir) = test_db();
1556 db.upsert_workspace("work", None, None).unwrap();
1557
1558 db.set_sync_cursor("default", "TST", "2024-01-01T00:00:00Z")
1560 .unwrap();
1561 db.set_sync_cursor("work", "TST", "2024-06-01T00:00:00Z")
1562 .unwrap();
1563
1564 assert_eq!(
1565 db.get_sync_cursor("default", "TST").unwrap().as_deref(),
1566 Some("2024-01-01T00:00:00Z")
1567 );
1568 assert_eq!(
1569 db.get_sync_cursor("work", "TST").unwrap().as_deref(),
1570 Some("2024-06-01T00:00:00Z")
1571 );
1572
1573 assert!(db.is_full_sync_done("default", "TST").unwrap());
1574 assert!(db.is_full_sync_done("work", "TST").unwrap());
1575 assert!(!db.is_full_sync_done("default", "OTHER").unwrap());
1576 }
1577
1578 #[test]
1579 fn list_synced_teams_workspace_scoped() {
1580 let (db, _dir) = test_db();
1581 db.upsert_workspace("work", None, None).unwrap();
1582
1583 let mut issue1 = make_issue("TST-1", "TST");
1584 issue1.workspace_id = "default".to_string();
1585 db.upsert_issue(&issue1).unwrap();
1586
1587 let mut issue2 = make_issue("WRK-1", "WRK");
1588 issue2.id = "id-wrk".to_string();
1589 issue2.workspace_id = "work".to_string();
1590 db.upsert_issue(&issue2).unwrap();
1591
1592 let default_teams = db.list_synced_teams("default").unwrap();
1593 assert_eq!(default_teams.len(), 1);
1594 assert_eq!(default_teams[0].key, "TST");
1595
1596 let work_teams = db.list_synced_teams("work").unwrap();
1597 assert_eq!(work_teams.len(), 1);
1598 assert_eq!(work_teams[0].key, "WRK");
1599 }
1600
1601 #[test]
1602 fn migration_8_creates_label_tables_and_resets_sync_state() {
1603 let dir = tempfile::tempdir().unwrap();
1604 let path = dir.path().join("test.db");
1605 let conn = rusqlite::Connection::open(&path).unwrap();
1606
1607 conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")
1608 .unwrap();
1609
1610 crate::db::schema::run_migrations(&conn).unwrap();
1612
1613 conn.execute("DELETE FROM schema_version WHERE version = 8", [])
1615 .unwrap();
1616
1617 conn.execute(
1619 "INSERT INTO sync_state (workspace_id, team_key, last_updated_at, full_sync_done, last_synced_at)
1620 VALUES ('default', 'ENG', '2026-04-01T00:00:00Z', 1, '2026-04-01T00:00:00Z')",
1621 [],
1622 )
1623 .unwrap();
1624
1625 let full_done_before: i64 = conn
1627 .query_row(
1628 "SELECT full_sync_done FROM sync_state WHERE workspace_id='default' AND team_key='ENG'",
1629 [],
1630 |r| r.get(0),
1631 )
1632 .unwrap();
1633 assert_eq!(full_done_before, 1);
1634
1635 crate::db::schema::run_migrations(&conn).unwrap();
1637
1638 let labels_count: i64 = conn
1640 .query_row(
1641 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='labels'",
1642 [],
1643 |r| r.get(0),
1644 )
1645 .unwrap();
1646 assert_eq!(labels_count, 1);
1647 let join_count: i64 = conn
1648 .query_row(
1649 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='issue_labels'",
1650 [],
1651 |r| r.get(0),
1652 )
1653 .unwrap();
1654 assert_eq!(join_count, 1);
1655
1656 let full_done: i64 = conn
1658 .query_row(
1659 "SELECT full_sync_done FROM sync_state WHERE workspace_id='default' AND team_key='ENG'",
1660 [],
1661 |r| r.get(0),
1662 )
1663 .unwrap();
1664 assert_eq!(full_done, 0);
1665 let last_updated: String = conn
1666 .query_row(
1667 "SELECT last_updated_at FROM sync_state WHERE workspace_id='default' AND team_key='ENG'",
1668 [],
1669 |r| r.get(0),
1670 )
1671 .unwrap();
1672 assert_eq!(last_updated, "1970-01-01T00:00:00Z");
1673 }
1674
1675 #[test]
1676 fn upsert_label_inserts_and_renames_in_place() {
1677 use super::test_helpers::{test_db, make_label};
1678 let (db, _dir) = test_db();
1679
1680 let mut l = make_label("lbl_1", "Vanta", "default");
1681 db.upsert_label(&l).unwrap();
1682
1683 let listed = db.list_labels("default").unwrap();
1684 assert_eq!(listed.len(), 1);
1685 assert_eq!(listed[0].name, "Vanta");
1686
1687 l.name = "Compliance".to_string();
1689 db.upsert_label(&l).unwrap();
1690
1691 let listed = db.list_labels("default").unwrap();
1692 assert_eq!(listed.len(), 1);
1693 assert_eq!(listed[0].name, "Compliance");
1694 }
1695
1696 #[test]
1697 fn list_labels_is_workspace_scoped_and_sorted() {
1698 use super::test_helpers::{test_db, make_label};
1699 let (db, _dir) = test_db();
1700 db.upsert_workspace("work", None, None).unwrap();
1701
1702 db.upsert_label(&make_label("a", "Zebra", "default")).unwrap();
1703 db.upsert_label(&make_label("b", "Apple", "default")).unwrap();
1704 db.upsert_label(&make_label("c", "OnlyInWork", "work")).unwrap();
1705
1706 let default_labels = db.list_labels("default").unwrap();
1707 assert_eq!(default_labels.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(),
1708 vec!["Apple", "Zebra"]);
1709 let work_labels = db.list_labels("work").unwrap();
1710 assert_eq!(work_labels.len(), 1);
1711 assert_eq!(work_labels[0].name, "OnlyInWork");
1712 }
1713
1714 #[test]
1715 fn delete_labels_for_workspace_not_in_removes_orphans() {
1716 use super::test_helpers::{test_db, make_label};
1717 let (db, _dir) = test_db();
1718
1719 db.upsert_label(&make_label("keep", "Keep", "default")).unwrap();
1720 db.upsert_label(&make_label("drop", "Drop", "default")).unwrap();
1721
1722 let kept = db.delete_labels_for_workspace_not_in("default", &["keep".to_string()]).unwrap();
1723 assert_eq!(kept, 1, "should report 1 deleted");
1724
1725 let listed = db.list_labels("default").unwrap();
1726 assert_eq!(listed.len(), 1);
1727 assert_eq!(listed[0].name, "Keep");
1728 }
1729
1730 #[test]
1731 fn replace_issue_labels_overwrites_existing() {
1732 use super::test_helpers::{test_db, make_issue, make_label};
1733 let (db, _dir) = test_db();
1734
1735 let issue = make_issue("ENG-1", "ENG");
1736 db.upsert_issue(&issue).unwrap();
1737 db.upsert_label(&make_label("l1", "Bug", "default")).unwrap();
1738 db.upsert_label(&make_label("l2", "UI", "default")).unwrap();
1739 db.upsert_label(&make_label("l3", "Backend", "default")).unwrap();
1740
1741 db.replace_issue_labels(&issue.id, &["l1".to_string(), "l2".to_string()]).unwrap();
1742 let labels = db.get_issue_label_ids(&issue.id).unwrap();
1743 assert_eq!(labels, vec!["l1".to_string(), "l2".to_string()]);
1744
1745 db.replace_issue_labels(&issue.id, &["l3".to_string()]).unwrap();
1747 let labels = db.get_issue_label_ids(&issue.id).unwrap();
1748 assert_eq!(labels, vec!["l3".to_string()]);
1749 }
1750
1751 #[test]
1752 fn deleting_issue_cascades_to_issue_labels() {
1753 use super::test_helpers::{test_db, make_issue, make_label};
1754 let (db, _dir) = test_db();
1755
1756 let issue = make_issue("ENG-2", "ENG");
1757 db.upsert_issue(&issue).unwrap();
1758 db.upsert_label(&make_label("l1", "Bug", "default")).unwrap();
1759 db.replace_issue_labels(&issue.id, &["l1".to_string()]).unwrap();
1760
1761 db.with_conn(|conn| {
1762 conn.execute("DELETE FROM issues WHERE id = ?1", rusqlite::params![&issue.id])?;
1763 let n: i64 = conn.query_row(
1764 "SELECT COUNT(*) FROM issue_labels WHERE issue_id = ?1",
1765 rusqlite::params![&issue.id], |r| r.get(0))?;
1766 assert_eq!(n, 0);
1767 Ok(())
1768 }).unwrap();
1769 }
1770
1771 #[test]
1772 fn deleting_label_cascades_to_issue_labels() {
1773 use super::test_helpers::{test_db, make_issue, make_label};
1774 let (db, _dir) = test_db();
1775
1776 let issue = make_issue("ENG-3", "ENG");
1777 db.upsert_issue(&issue).unwrap();
1778 db.upsert_label(&make_label("l1", "Bug", "default")).unwrap();
1779 db.replace_issue_labels(&issue.id, &["l1".to_string()]).unwrap();
1780
1781 db.delete_labels_for_workspace_not_in("default", &[]).unwrap();
1782 let labels = db.get_issue_label_ids(&issue.id).unwrap();
1783 assert!(labels.is_empty());
1784 }
1785
1786 #[test]
1787 fn resolve_label_ids_local_matches_case_insensitive_and_returns_unknowns() {
1788 use super::test_helpers::{test_db, make_label};
1789 let (db, _dir) = test_db();
1790
1791 db.upsert_label(&make_label("l1", "Vanta", "default")).unwrap();
1792 db.upsert_label(&make_label("l2", "Security", "default")).unwrap();
1793
1794 let (resolved, unknown) = db
1795 .resolve_label_ids_local("default", &["vanta".to_string(), "secURity".to_string(), "missing".to_string()])
1796 .unwrap();
1797 assert_eq!(resolved.len(), 2);
1798 assert!(resolved.contains(&"l1".to_string()));
1799 assert!(resolved.contains(&"l2".to_string()));
1800 assert_eq!(unknown, vec!["missing".to_string()]);
1801 }
1802
1803 #[test]
1804 fn resolve_label_ids_local_is_workspace_scoped() {
1805 use super::test_helpers::{test_db, make_label};
1806 let (db, _dir) = test_db();
1807 db.upsert_workspace("work", None, None).unwrap();
1808
1809 db.upsert_label(&make_label("l1", "Vanta", "default")).unwrap();
1810 db.upsert_label(&make_label("l2", "Vanta", "work")).unwrap();
1811
1812 let (resolved, _) = db.resolve_label_ids_local("work", &["vanta".to_string()]).unwrap();
1813 assert_eq!(resolved, vec!["l2".to_string()]);
1814 }
1815
1816 #[test]
1817 fn get_unprioritized_issues_filters_by_labels_with_and_semantics() {
1818 use super::test_helpers::{test_db, make_issue, make_label};
1819 let (db, _dir) = test_db();
1820
1821 let mut a = make_issue("ENG-10", "ENG"); a.priority = 0;
1822 let mut b = make_issue("ENG-11", "ENG"); b.priority = 0;
1823 let mut c = make_issue("ENG-12", "ENG"); c.priority = 0;
1824 db.upsert_issue(&a).unwrap();
1825 db.upsert_issue(&b).unwrap();
1826 db.upsert_issue(&c).unwrap();
1827
1828 db.upsert_label(&make_label("vanta", "Vanta", "default")).unwrap();
1829 db.upsert_label(&make_label("sec", "Security", "default")).unwrap();
1830
1831 db.replace_issue_labels(&a.id, &["vanta".to_string(), "sec".to_string()]).unwrap();
1832 db.replace_issue_labels(&b.id, &["vanta".to_string()]).unwrap();
1833 db.replace_issue_labels(&c.id, &["sec".to_string()]).unwrap();
1834
1835 let result = db.get_unprioritized_issues_filtered(
1837 Some("ENG"), false, "default",
1838 Some(&["vanta".to_string(), "sec".to_string()]),
1839 ).unwrap();
1840 let idents: Vec<_> = result.iter().map(|i| i.identifier.as_str()).collect();
1841 assert_eq!(idents, vec!["ENG-10"]);
1842
1843 let result = db.get_unprioritized_issues_filtered(
1845 Some("ENG"), false, "default",
1846 Some(&["vanta".to_string()]),
1847 ).unwrap();
1848 let idents: Vec<_> = result.iter().map(|i| i.identifier.as_str()).collect();
1849 assert!(idents.contains(&"ENG-10"));
1850 assert!(idents.contains(&"ENG-11"));
1851 assert!(!idents.contains(&"ENG-12"));
1852
1853 let result = db.get_unprioritized_issues_filtered(Some("ENG"), false, "default", None).unwrap();
1855 assert_eq!(result.len(), 3);
1856 }
1857
1858 #[test]
1859 fn fts_search_with_label_filter_intersects() {
1860 use super::test_helpers::{test_db, make_issue, make_label};
1861 let (db, _dir) = test_db();
1862
1863 let mut a = make_issue("ENG-20", "ENG");
1864 a.title = "Audit logging gap".to_string();
1865 let mut b = make_issue("ENG-21", "ENG");
1866 b.title = "Audit something else".to_string();
1867 db.upsert_issue(&a).unwrap();
1868 db.upsert_issue(&b).unwrap();
1869
1870 db.upsert_label(&make_label("vanta", "Vanta", "default")).unwrap();
1871 db.replace_issue_labels(&a.id, &["vanta".to_string()]).unwrap();
1872
1873 let r = db.fts_search_filtered("\"audit\"", 10, "default", None).unwrap();
1875 assert_eq!(r.len(), 2);
1876
1877 let r = db.fts_search_filtered("\"audit\"", 10, "default", Some(&["vanta".to_string()])).unwrap();
1879 assert_eq!(r.len(), 1);
1880 assert_eq!(r[0].identifier, "ENG-20");
1881 }
1882
1883 #[test]
1884 fn delete_workspace_cleans_up_labels() {
1885 use super::test_helpers::{test_db, make_label};
1886 let (db, _dir) = test_db();
1887 db.upsert_workspace("doomed", None, None).unwrap();
1888 db.upsert_label(&make_label("l1", "Lab", "doomed")).unwrap();
1889 db.delete_workspace("doomed").unwrap();
1890 let listed = db.list_labels("doomed").unwrap();
1891 assert!(listed.is_empty());
1892 }
1893}