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 comment_sync_state WHERE workspace_id = ?1",
128 rusqlite::params![id],
129 )?;
130 conn.execute(
131 "DELETE FROM sync_state WHERE workspace_id = ?1",
132 rusqlite::params![id],
133 )?;
134 conn.execute(
135 "DELETE FROM labels WHERE workspace_id = ?1",
136 rusqlite::params![id],
137 )?;
138 conn.execute(
139 "DELETE FROM workspaces WHERE id = ?1",
140 rusqlite::params![id],
141 )?;
142 Ok(issue_count)
143 })
144 }
145
146 pub fn upsert_label(&self, label: &Label) -> Result<()> {
149 self.with_conn(|conn| {
150 conn.execute(
151 "INSERT INTO labels (id, workspace_id, name, color, parent_id)
152 VALUES (?1, ?2, ?3, ?4, ?5)
153 ON CONFLICT(id) DO UPDATE SET
154 workspace_id=excluded.workspace_id,
155 name=excluded.name,
156 color=excluded.color,
157 parent_id=excluded.parent_id",
158 rusqlite::params![label.id, label.workspace_id, label.name, label.color, label.parent_id],
159 )?;
160 Ok(())
161 })
162 }
163
164 pub fn list_labels(&self, workspace_id: &str) -> Result<Vec<Label>> {
165 self.with_conn(|conn| {
166 let mut stmt = conn.prepare(
167 "SELECT id, workspace_id, name, color, parent_id
168 FROM labels WHERE workspace_id = ?1
169 ORDER BY name COLLATE NOCASE ASC",
170 )?;
171 let rows = stmt.query_map(rusqlite::params![workspace_id], |row| {
172 Ok(Label {
173 id: row.get(0)?,
174 workspace_id: row.get(1)?,
175 name: row.get(2)?,
176 color: row.get(3)?,
177 parent_id: row.get(4)?,
178 })
179 })?;
180 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
181 })
182 }
183
184 pub fn delete_labels_for_workspace_not_in(
187 &self,
188 workspace_id: &str,
189 keep_ids: &[String],
190 ) -> Result<usize> {
191 self.with_conn(|conn| {
192 if keep_ids.is_empty() {
193 let n = conn.execute(
194 "DELETE FROM labels WHERE workspace_id = ?1",
195 rusqlite::params![workspace_id],
196 )?;
197 return Ok(n);
198 }
199 let placeholders = (0..keep_ids.len())
200 .map(|i| format!("?{}", i + 2))
201 .collect::<Vec<_>>()
202 .join(", ");
203 let sql = format!(
204 "DELETE FROM labels WHERE workspace_id = ?1 AND id NOT IN ({placeholders})"
205 );
206 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> =
207 vec![Box::new(workspace_id.to_string())];
208 for id in keep_ids {
209 params.push(Box::new(id.clone()));
210 }
211 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
212 params.iter().map(|p| p.as_ref()).collect();
213 let n = conn.execute(&sql, param_refs.as_slice())?;
214 Ok(n)
215 })
216 }
217
218 pub fn resolve_label_ids_local(
221 &self,
222 workspace_id: &str,
223 names: &[String],
224 ) -> Result<(Vec<String>, Vec<String>)> {
225 if names.is_empty() {
226 return Ok((Vec::new(), Vec::new()));
227 }
228 self.with_conn(|conn| {
229 let mut resolved = Vec::new();
230 let mut unknown = Vec::new();
231 let mut stmt = conn.prepare(
232 "SELECT id FROM labels WHERE workspace_id = ?1 AND name = ?2 COLLATE NOCASE",
233 )?;
234 for name in names {
235 let mut rows = stmt.query(rusqlite::params![workspace_id, name])?;
236 if let Some(row) = rows.next()? {
237 resolved.push(row.get::<_, String>(0)?);
238 } else {
239 unknown.push(name.clone());
240 }
241 }
242 Ok((resolved, unknown))
243 })
244 }
245
246 pub fn replace_issue_labels(&self, issue_id: &str, label_ids: &[String]) -> Result<()> {
251 self.with_conn(|conn| {
252 let tx = conn.unchecked_transaction()?;
253 tx.execute(
254 "DELETE FROM issue_labels WHERE issue_id = ?1",
255 rusqlite::params![issue_id],
256 )?;
257 for lid in label_ids {
258 let exists: i64 = tx.query_row(
259 "SELECT COUNT(*) FROM labels WHERE id = ?1",
260 rusqlite::params![lid],
261 |r| r.get(0),
262 )?;
263 if exists == 0 {
264 eprintln!(
265 "warning: skipping unknown label id '{}' for issue '{}'",
266 lid, issue_id
267 );
268 continue;
269 }
270 tx.execute(
271 "INSERT OR IGNORE INTO issue_labels (issue_id, label_id) VALUES (?1, ?2)",
272 rusqlite::params![issue_id, lid],
273 )?;
274 }
275 tx.commit()?;
276 Ok(())
277 })
278 }
279
280 pub fn get_issue_label_ids(&self, issue_id: &str) -> Result<Vec<String>> {
281 self.with_conn(|conn| {
282 let mut stmt = conn.prepare(
283 "SELECT label_id FROM issue_labels WHERE issue_id = ?1 ORDER BY label_id",
284 )?;
285 let rows = stmt.query_map(rusqlite::params![issue_id], |row| row.get::<_, String>(0))?;
286 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
287 })
288 }
289
290 pub fn upsert_issue(&self, issue: &Issue) -> Result<()> {
293 self.with_conn(|conn| {
294 conn.execute(
295 "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)
296 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, datetime('now'), ?15, ?16, ?17)
297 ON CONFLICT(id) DO UPDATE SET
298 identifier=excluded.identifier, team_key=excluded.team_key, title=excluded.title,
299 description=excluded.description, state_name=excluded.state_name, state_type=excluded.state_type,
300 priority=excluded.priority, assignee_name=excluded.assignee_name, project_name=excluded.project_name,
301 labels_json=excluded.labels_json, updated_at=excluded.updated_at,
302 content_hash=excluded.content_hash, url=excluded.url, branch_name=excluded.branch_name,
303 workspace_id=excluded.workspace_id, synced_at=datetime('now')",
304 rusqlite::params![
305 issue.id, issue.identifier, issue.team_key, issue.title, issue.description,
306 issue.state_name, issue.state_type, issue.priority, issue.assignee_name,
307 issue.project_name, issue.labels_json, issue.created_at, issue.updated_at,
308 issue.content_hash, issue.url, issue.branch_name, issue.workspace_id,
309 ],
310 )?;
311 Ok(())
312 })
313 }
314
315 pub fn get_issue(&self, id_or_identifier: &str) -> Result<Option<Issue>> {
316 self.with_conn(|conn| {
317 let mut stmt = conn.prepare(
318 "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
319 FROM issues WHERE id = ?1 OR identifier = ?1"
320 )?;
321 let mut rows = stmt.query(rusqlite::params![id_or_identifier])?;
322 if let Some(row) = rows.next()? {
323 Ok(Some(Issue::from_row(row)?))
324 } else {
325 Ok(None)
326 }
327 })
328 }
329
330 fn label_filter_fragment(
336 label_ids: &[String],
337 param_offset: usize,
338 table_alias: &str,
339 ) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
340 let n = label_ids.len();
341 let placeholders = (0..n)
342 .map(|i| format!("?{}", param_offset + i))
343 .collect::<Vec<_>>()
344 .join(", ");
345 let sql = format!(
346 "{table_alias}.id IN (\
347 SELECT issue_id FROM issue_labels \
348 WHERE label_id IN ({placeholders}) \
349 GROUP BY issue_id \
350 HAVING COUNT(DISTINCT label_id) = {n}\
351 )"
352 );
353 let params: Vec<Box<dyn rusqlite::types::ToSql>> =
354 label_ids.iter().map(|s| Box::new(s.clone()) as Box<dyn rusqlite::types::ToSql>).collect();
355 (sql, params)
356 }
357
358 pub fn get_unprioritized_issues(
359 &self,
360 team_key: Option<&str>,
361 include_completed: bool,
362 workspace_id: &str,
363 ) -> Result<Vec<Issue>> {
364 self.get_unprioritized_issues_filtered(team_key, include_completed, workspace_id, None)
365 }
366
367 pub fn get_unprioritized_issues_filtered(
368 &self,
369 team_key: Option<&str>,
370 include_completed: bool,
371 workspace_id: &str,
372 label_ids: Option<&[String]>,
373 ) -> Result<Vec<Issue>> {
374 self.with_conn(|conn| {
375 let state_filter = if include_completed {
376 ""
377 } else {
378 " AND state_type NOT IN ('completed', 'canceled')"
379 };
380
381 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
383 let base_where: String = if let Some(team) = team_key {
384 params.push(Box::new(team.to_string()));
385 params.push(Box::new(workspace_id.to_string()));
386 "team_key = ?1 AND workspace_id = ?2".to_string()
387 } else {
388 params.push(Box::new(workspace_id.to_string()));
389 "workspace_id = ?1".to_string()
390 };
391
392 let label_clause = if let Some(ids) = label_ids.filter(|ids| !ids.is_empty()) {
393 let (frag, mut lp) = Self::label_filter_fragment(ids, params.len() + 1, "issues");
394 params.append(&mut lp);
395 format!(" AND {frag}")
396 } else {
397 String::new()
398 };
399
400 let sql = format!(
401 "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
402 FROM issues WHERE priority = 0{state_filter} AND {base_where}{label_clause}
403 ORDER BY created_at DESC"
404 );
405
406 let mut stmt = conn.prepare(&sql)?;
407 let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
408 let rows = stmt.query_map(param_refs.as_slice(), |row| Ok(Issue::from_row(row).unwrap()))?;
409 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
410 })
411 }
412
413 pub fn get_issues_by_state_types(
414 &self,
415 team_key: &str,
416 state_types: &[String],
417 workspace_id: &str,
418 ) -> Result<Vec<Issue>> {
419 self.with_conn(|conn| {
420 let placeholders: String = state_types
421 .iter()
422 .enumerate()
423 .map(|(i, _)| format!("?{}", i + 3))
424 .collect::<Vec<_>>()
425 .join(", ");
426 let sql = format!(
427 "SELECT id, identifier, team_key, title, description, state_name, state_type, \
428 priority, assignee_name, project_name, labels_json, created_at, updated_at, \
429 content_hash, synced_at, url, branch_name, workspace_id \
430 FROM issues WHERE team_key = ?1 AND workspace_id = ?2 AND state_type IN ({placeholders}) \
431 ORDER BY priority ASC, created_at DESC"
432 );
433 let mut stmt = conn.prepare(&sql)?;
434 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> =
435 vec![Box::new(team_key.to_string()), Box::new(workspace_id.to_string())];
436 for st in state_types {
437 params.push(Box::new(st.clone()));
438 }
439 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
440 params.iter().map(|p| p.as_ref()).collect();
441 let rows = stmt.query_map(param_refs.as_slice(), |row| {
442 Ok(Issue::from_row(row).unwrap())
443 })?;
444 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
445 })
446 }
447
448 pub fn get_blockers_for_issues(&self, issue_ids: &[String]) -> Result<Vec<BlockerRow>> {
451 if issue_ids.is_empty() {
452 return Ok(vec![]);
453 }
454 self.with_conn(|conn| {
455 let placeholders: String = issue_ids
456 .iter()
457 .enumerate()
458 .map(|(i, _)| format!("?{}", i + 1))
459 .collect::<Vec<_>>()
460 .join(", ");
461
462 let sql_fwd = format!(
464 "SELECT r.issue_id, COALESCE(i.identifier, r.related_issue_identifier),
465 COALESCE(i.title, ''), COALESCE(i.state_name, ''), COALESCE(i.state_type, '')
466 FROM issue_relations r
467 LEFT JOIN issues i ON r.related_issue_id = i.id
468 WHERE r.issue_id IN ({placeholders}) AND r.relation_type = 'blocked_by'"
469 );
470
471 let sql_inv = format!(
473 "SELECT r.related_issue_id, i2.identifier,
474 COALESCE(i2.title, ''), COALESCE(i2.state_name, ''), COALESCE(i2.state_type, '')
475 FROM issue_relations r
476 JOIN issues i ON r.related_issue_id = i.id
477 JOIN issues i2 ON r.issue_id = i2.id
478 WHERE r.related_issue_id IN ({placeholders}) AND r.relation_type = 'blocks'"
479 );
480
481 let mut results = Vec::new();
482 let params: Vec<Box<dyn rusqlite::types::ToSql>> =
483 issue_ids.iter().map(|id| Box::new(id.clone()) as _).collect();
484 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
485 params.iter().map(|p| p.as_ref()).collect();
486
487 for sql in [&sql_fwd, &sql_inv] {
488 let mut stmt = conn.prepare(sql)?;
489 let rows = stmt.query_map(param_refs.as_slice(), |row| {
490 Ok(BlockerRow {
491 issue_id: row.get(0)?,
492 identifier: row.get(1)?,
493 title: row.get(2)?,
494 state_name: row.get(3)?,
495 state_type: row.get(4)?,
496 })
497 })?;
498 for row in rows {
499 results.push(row?);
500 }
501 }
502 Ok(results)
503 })
504 }
505
506 pub fn count_issues(&self, team_key: Option<&str>, workspace_id: &str) -> Result<usize> {
507 self.with_conn(|conn| {
508 let count: usize = if let Some(team) = team_key {
509 conn.query_row(
510 "SELECT COUNT(*) FROM issues WHERE team_key = ?1 AND workspace_id = ?2",
511 rusqlite::params![team, workspace_id],
512 |row| row.get(0),
513 )?
514 } else {
515 conn.query_row(
516 "SELECT COUNT(*) FROM issues WHERE workspace_id = ?1",
517 rusqlite::params![workspace_id],
518 |row| row.get(0),
519 )?
520 };
521 Ok(count)
522 })
523 }
524
525 pub fn get_field_completeness(
527 &self,
528 team_key: Option<&str>,
529 workspace_id: &str,
530 ) -> Result<(usize, usize, usize, usize, usize)> {
531 self.with_conn(|conn| {
532 let (sql, params): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
533 if let Some(team) = team_key {
534 (
535 "SELECT COUNT(*),
536 SUM(CASE WHEN description IS NOT NULL AND description != '' THEN 1 ELSE 0 END),
537 SUM(CASE WHEN priority > 0 THEN 1 ELSE 0 END),
538 SUM(CASE WHEN labels_json != '[]' THEN 1 ELSE 0 END),
539 SUM(CASE WHEN project_name IS NOT NULL AND project_name != '' THEN 1 ELSE 0 END)
540 FROM issues WHERE team_key = ?1 AND workspace_id = ?2"
541 .to_string(),
542 vec![Box::new(team.to_string()) as Box<dyn rusqlite::types::ToSql>, Box::new(workspace_id.to_string())],
543 )
544 } else {
545 (
546 "SELECT COUNT(*),
547 SUM(CASE WHEN description IS NOT NULL AND description != '' THEN 1 ELSE 0 END),
548 SUM(CASE WHEN priority > 0 THEN 1 ELSE 0 END),
549 SUM(CASE WHEN labels_json != '[]' THEN 1 ELSE 0 END),
550 SUM(CASE WHEN project_name IS NOT NULL AND project_name != '' THEN 1 ELSE 0 END)
551 FROM issues WHERE workspace_id = ?1"
552 .to_string(),
553 vec![Box::new(workspace_id.to_string()) as Box<dyn rusqlite::types::ToSql>],
554 )
555 };
556 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
557 params.iter().map(|p| p.as_ref()).collect();
558 let row = conn.query_row(&sql, param_refs.as_slice(), |row| {
559 Ok((
560 row.get::<_, usize>(0)?,
561 row.get::<_, Option<usize>>(1)?.unwrap_or(0),
562 row.get::<_, Option<usize>>(2)?.unwrap_or(0),
563 row.get::<_, Option<usize>>(3)?.unwrap_or(0),
564 row.get::<_, Option<usize>>(4)?.unwrap_or(0),
565 ))
566 })?;
567 Ok(row)
568 })
569 }
570
571 #[allow(unused_assignments)]
574 pub fn list_all_issues(
575 &self,
576 team_key: Option<&str>,
577 filter: Option<&str>,
578 limit: usize,
579 offset: usize,
580 workspace_id: &str,
581 ) -> Result<Vec<IssueSummary>> {
582 self.with_conn(|conn| {
583 let mut conditions = Vec::new();
584 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
585 let mut param_idx = 1;
586
587 conditions.push(format!("i.workspace_id = ?{param_idx}"));
589 params.push(Box::new(workspace_id.to_string()));
590 param_idx += 1;
591
592 if let Some(team) = team_key {
593 conditions.push(format!("i.team_key = ?{param_idx}"));
594 params.push(Box::new(team.to_string()));
595 param_idx += 1;
596 }
597
598 if let Some(text) = filter {
599 let like = format!("%{text}%");
600 conditions.push(format!(
601 "(i.identifier LIKE ?{} OR i.title LIKE ?{})",
602 param_idx,
603 param_idx + 1
604 ));
605 params.push(Box::new(like.clone()));
606 params.push(Box::new(like));
607 param_idx += 2;
608 }
609
610 let _ = param_idx;
611
612 let where_clause = if conditions.is_empty() {
613 String::new()
614 } else {
615 format!("WHERE {}", conditions.join(" AND "))
616 };
617
618 let limit_idx = params.len() + 1;
619 let offset_idx = params.len() + 2;
620
621 let sql = format!(
622 "SELECT i.id, i.identifier, i.team_key, i.title, i.state_name, i.state_type,
623 i.priority, i.project_name, i.labels_json, i.updated_at, i.url,
624 i.description IS NOT NULL AND i.description != '' AS has_desc,
625 EXISTS(SELECT 1 FROM chunks c WHERE c.issue_id = i.id) AS has_emb
626 FROM issues i
627 {where_clause}
628 ORDER BY i.updated_at DESC
629 LIMIT ?{limit_idx} OFFSET ?{offset_idx}"
630 );
631 params.push(Box::new(limit as i64));
632 params.push(Box::new(offset as i64));
633
634 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
635 params.iter().map(|p| p.as_ref()).collect();
636 let mut stmt = conn.prepare(&sql)?;
637 let rows = stmt.query_map(param_refs.as_slice(), |row| {
638 let labels_json: String = row.get(8)?;
639 let labels: Vec<String> = serde_json::from_str(&labels_json).unwrap_or_default();
640 Ok(IssueSummary {
641 id: row.get(0)?,
642 identifier: row.get(1)?,
643 team_key: row.get(2)?,
644 title: row.get(3)?,
645 state_name: row.get(4)?,
646 state_type: row.get(5)?,
647 priority: row.get(6)?,
648 project_name: row.get(7)?,
649 labels,
650 updated_at: row.get(9)?,
651 url: row.get(10)?,
652 has_description: row.get(11)?,
653 has_embedding: row.get(12)?,
654 })
655 })?;
656 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
657 })
658 }
659
660 pub fn upsert_relations(&self, issue_id: &str, relations: &[Relation]) -> Result<()> {
663 self.with_conn(|conn| {
664 conn.execute(
665 "DELETE FROM issue_relations WHERE issue_id = ?1",
666 rusqlite::params![issue_id],
667 )?;
668 let mut stmt = conn.prepare(
669 "INSERT OR IGNORE INTO issue_relations (id, issue_id, related_issue_id, related_issue_identifier, relation_type)
670 VALUES (?1, ?2, ?3, ?4, ?5)"
671 )?;
672 for rel in relations {
673 stmt.execute(rusqlite::params![
674 rel.id, rel.issue_id, rel.related_issue_id,
675 rel.related_issue_identifier, rel.relation_type,
676 ])?;
677 }
678 Ok(())
679 })
680 }
681
682 pub fn get_relations_enriched(&self, issue_id: &str) -> Result<Vec<EnrichedRelation>> {
683 self.with_conn(|conn| {
684 let mut stmt = conn.prepare(
686 "SELECT r.id, r.relation_type, r.related_issue_identifier,
687 COALESCE(i.title, ''), COALESCE(i.state_name, ''), COALESCE(i.url, '')
688 FROM issue_relations r
689 LEFT JOIN issues i ON r.related_issue_id = i.id
690 WHERE r.issue_id = ?1",
691 )?;
692 let forward = stmt
693 .query_map(rusqlite::params![issue_id], |row| {
694 Ok(EnrichedRelation {
695 relation_id: row.get(0)?,
696 relation_type: row.get(1)?,
697 issue_identifier: row.get(2)?,
698 issue_title: row.get(3)?,
699 issue_state: row.get(4)?,
700 issue_url: row.get(5)?,
701 })
702 })?
703 .collect::<std::result::Result<Vec<_>, _>>()?;
704
705 let mut stmt2 = conn.prepare(
707 "SELECT r.id, r.relation_type, i2.identifier,
708 COALESCE(i2.title, ''), COALESCE(i2.state_name, ''), COALESCE(i2.url, '')
709 FROM issue_relations r
710 JOIN issues i ON r.related_issue_id = i.id
711 JOIN issues i2 ON r.issue_id = i2.id
712 WHERE r.related_issue_id = i.id AND i.id = ?1",
713 )?;
714 let inverse = stmt2
715 .query_map(rusqlite::params![issue_id], |row| {
716 let raw_type: String = row.get(1)?;
717 let flipped = match raw_type.as_str() {
718 "blocks" => "blocked_by".to_string(),
719 "blocked_by" => "blocks".to_string(),
720 other => other.to_string(), };
722 Ok(EnrichedRelation {
723 relation_id: row.get(0)?,
724 relation_type: flipped,
725 issue_identifier: row.get(2)?,
726 issue_title: row.get(3)?,
727 issue_state: row.get(4)?,
728 issue_url: row.get(5)?,
729 })
730 })?
731 .collect::<std::result::Result<Vec<_>, _>>()?;
732
733 let mut all = forward;
734 all.extend(inverse);
735 Ok(all)
736 })
737 }
738
739 pub fn find_relation_id(
741 &self,
742 issue_id: &str,
743 related_issue_id: &str,
744 relation_type: &str,
745 ) -> Result<Option<String>> {
746 self.with_conn(|conn| {
747 let mut stmt = conn.prepare(
748 "SELECT id FROM issue_relations WHERE issue_id = ?1 AND related_issue_id = ?2 AND relation_type = ?3"
749 )?;
750 let mut rows = stmt.query(rusqlite::params![issue_id, related_issue_id, relation_type])?;
751 if let Some(row) = rows.next()? {
752 Ok(Some(row.get(0)?))
753 } else {
754 Ok(None)
755 }
756 })
757 }
758
759 pub fn upsert_chunks(&self, issue_id: &str, chunks: &[(usize, String, Vec<u8>)]) -> Result<()> {
762 self.upsert_chunks_with_model(issue_id, chunks, "")
763 }
764
765 pub fn upsert_chunks_with_model(
766 &self,
767 issue_id: &str,
768 chunks: &[(usize, String, Vec<u8>)],
769 model_name: &str,
770 ) -> Result<()> {
771 self.with_conn(|conn| {
772 conn.execute(
773 "DELETE FROM chunks WHERE issue_id = ?1",
774 rusqlite::params![issue_id],
775 )?;
776 let mut stmt = conn.prepare(
777 "INSERT INTO chunks (issue_id, chunk_index, chunk_text, embedding, model_name) VALUES (?1, ?2, ?3, ?4, ?5)"
778 )?;
779 for (idx, text, embedding) in chunks {
780 stmt.execute(rusqlite::params![issue_id, idx, text, embedding, model_name])?;
781 }
782 Ok(())
783 })
784 }
785
786 pub fn get_embedding_model(&self, issue_id: &str) -> Result<Option<String>> {
788 self.with_conn(|conn| {
789 let mut stmt =
790 conn.prepare("SELECT model_name FROM chunks WHERE issue_id = ?1 LIMIT 1")?;
791 let mut rows = stmt.query(rusqlite::params![issue_id])?;
792 if let Some(row) = rows.next()? {
793 let name: String = row.get(0)?;
794 Ok(if name.is_empty() { None } else { Some(name) })
795 } else {
796 Ok(None)
797 }
798 })
799 }
800
801 pub fn get_all_chunks(&self, workspace_id: &str) -> Result<Vec<Chunk>> {
802 self.with_conn(|conn| {
803 let mut stmt = conn.prepare(
804 "SELECT c.issue_id, c.embedding, i.identifier
805 FROM chunks c JOIN issues i ON c.issue_id = i.id
806 WHERE i.workspace_id = ?1",
807 )?;
808 let rows = stmt.query_map(rusqlite::params![workspace_id], |row| {
809 Ok(Chunk {
810 issue_id: row.get(0)?,
811 embedding: row.get(1)?,
812 identifier: row.get(2)?,
813 })
814 })?;
815 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
816 })
817 }
818
819 pub fn get_chunks_for_team(&self, team_key: &str, workspace_id: &str) -> Result<Vec<Chunk>> {
820 self.with_conn(|conn| {
821 let mut stmt = conn.prepare(
822 "SELECT c.issue_id, c.embedding, i.identifier
823 FROM chunks c JOIN issues i ON c.issue_id = i.id
824 WHERE i.team_key = ?1 AND i.workspace_id = ?2",
825 )?;
826 let rows = stmt.query_map(rusqlite::params![team_key, workspace_id], |row| {
827 Ok(Chunk {
828 issue_id: row.get(0)?,
829 embedding: row.get(1)?,
830 identifier: row.get(2)?,
831 })
832 })?;
833 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
834 })
835 }
836
837 pub fn count_embedded_issues(
838 &self,
839 team_key: Option<&str>,
840 workspace_id: &str,
841 ) -> Result<usize> {
842 self.with_conn(|conn| {
843 let count: usize = if let Some(team) = team_key {
844 conn.query_row(
845 "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",
846 rusqlite::params![team, workspace_id],
847 |row| row.get(0),
848 )?
849 } else {
850 conn.query_row(
851 "SELECT COUNT(DISTINCT c.issue_id) FROM chunks c JOIN issues i ON c.issue_id = i.id WHERE i.workspace_id = ?1",
852 rusqlite::params![workspace_id],
853 |row| row.get(0),
854 )?
855 };
856 Ok(count)
857 })
858 }
859
860 pub fn get_issues_needing_embedding(
861 &self,
862 team_key: Option<&str>,
863 force: bool,
864 workspace_id: &str,
865 ) -> Result<Vec<Issue>> {
866 self.with_conn(|conn| {
867 let sql = if force {
868 if let Some(team) = team_key {
869 format!(
870 "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
871 FROM issues WHERE team_key = '{}' AND workspace_id = '{}'", team, workspace_id
872 )
873 } else {
874 format!(
875 "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
876 FROM issues WHERE workspace_id = '{}'", workspace_id
877 )
878 }
879 } else {
880 let team_filter = if let Some(team) = team_key {
881 format!("AND i.team_key = '{}'", team)
882 } else {
883 String::new()
884 };
885 format!(
886 "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
887 FROM issues i
888 LEFT JOIN (SELECT DISTINCT issue_id FROM chunks) c ON i.id = c.issue_id
889 WHERE c.issue_id IS NULL AND i.workspace_id = '{}' {}",
890 workspace_id, team_filter
891 )
892 };
893 let mut stmt = conn.prepare(&sql)?;
894 let rows = stmt.query_map([], |row| {
895 Ok(Issue::from_row(row).unwrap())
896 })?;
897 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
898 })
899 }
900
901 pub fn get_comments(&self, issue_id: &str) -> Result<Vec<Comment>> {
904 self.with_conn(|conn| {
905 let mut stmt = conn.prepare(
906 "SELECT id, issue_id, body, user_name, created_at, updated_at, parent_id, url, workspace_id
907 FROM comments
908 WHERE issue_id = ?1
909 ORDER BY created_at"
910 )?;
911 let rows = stmt.query_map(rusqlite::params![issue_id], |row| {
912 Ok(Comment {
913 id: row.get(0)?,
914 issue_id: row.get(1)?,
915 body: row.get(2)?,
916 user_name: row.get(3)?,
917 created_at: row.get(4)?,
918 updated_at: row.get(5)?,
919 parent_id: row.get(6)?,
920 url: row.get(7)?,
921 workspace_id: row.get(8)?,
922 })
923 })?;
924 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
925 })
926 }
927
928 pub fn replace_issue_comments(
929 &self,
930 issue_id: &str,
931 workspace_id: &str,
932 comments: &[Comment],
933 ) -> Result<()> {
934 self.with_conn(|conn| {
935 let tx = conn.unchecked_transaction()?;
936 tx.execute(
937 "DELETE FROM comments WHERE issue_id = ?1 AND workspace_id = ?2",
938 rusqlite::params![issue_id, workspace_id],
939 )?;
940 for comment in comments {
941 tx.execute(
942 "INSERT INTO comments
943 (id, issue_id, body, user_name, created_at, workspace_id, updated_at, parent_id, url)
944 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
945 ON CONFLICT(id) DO UPDATE SET
946 issue_id=excluded.issue_id,
947 body=excluded.body,
948 user_name=excluded.user_name,
949 created_at=excluded.created_at,
950 workspace_id=excluded.workspace_id,
951 updated_at=excluded.updated_at,
952 parent_id=excluded.parent_id,
953 url=excluded.url",
954 rusqlite::params![
955 comment.id,
956 comment.issue_id,
957 comment.body,
958 comment.user_name,
959 comment.created_at,
960 workspace_id,
961 comment.updated_at,
962 comment.parent_id,
963 comment.url,
964 ],
965 )?;
966 }
967 tx.commit()?;
968 Ok(())
969 })
970 }
971
972 pub fn get_comment_sync_state(&self, issue_id: &str) -> Result<CommentSyncState> {
973 self.with_conn(|conn| {
974 let mut stmt = conn.prepare(
975 "SELECT status, sync_error, synced_at
976 FROM comment_sync_state
977 WHERE issue_id = ?1",
978 )?;
979 let mut rows = stmt.query(rusqlite::params![issue_id])?;
980 if let Some(row) = rows.next()? {
981 Ok(CommentSyncState {
982 status: row.get(0)?,
983 sync_error: row.get(1)?,
984 synced_at: row.get(2)?,
985 })
986 } else {
987 Ok(CommentSyncState::not_synced())
988 }
989 })
990 }
991
992 pub fn mark_comments_synced(
993 &self,
994 issue_id: &str,
995 workspace_id: &str,
996 comment_count: usize,
997 ) -> Result<()> {
998 let status = if comment_count == 0 {
999 "none_found"
1000 } else {
1001 "synced"
1002 };
1003 self.set_comment_sync_state(issue_id, workspace_id, status, None)
1004 }
1005
1006 pub fn mark_comments_sync_failed(
1007 &self,
1008 issue_id: &str,
1009 workspace_id: &str,
1010 status: &str,
1011 error: &str,
1012 ) -> Result<()> {
1013 self.set_comment_sync_state(issue_id, workspace_id, status, Some(error))
1014 }
1015
1016 fn set_comment_sync_state(
1017 &self,
1018 issue_id: &str,
1019 workspace_id: &str,
1020 status: &str,
1021 error: Option<&str>,
1022 ) -> Result<()> {
1023 self.with_conn(|conn| {
1024 conn.execute(
1025 "INSERT INTO comment_sync_state (issue_id, workspace_id, status, sync_error, synced_at)
1026 VALUES (?1, ?2, ?3, ?4, datetime('now'))
1027 ON CONFLICT(issue_id) DO UPDATE SET
1028 workspace_id=excluded.workspace_id,
1029 status=excluded.status,
1030 sync_error=excluded.sync_error,
1031 synced_at=datetime('now')",
1032 rusqlite::params![issue_id, workspace_id, status, error],
1033 )?;
1034 Ok(())
1035 })
1036 }
1037
1038 pub fn get_sync_cursor(&self, workspace_id: &str, team_key: &str) -> Result<Option<String>> {
1041 self.with_conn(|conn| {
1042 let mut stmt = conn.prepare(
1043 "SELECT last_updated_at FROM sync_state WHERE workspace_id = ?1 AND team_key = ?2",
1044 )?;
1045 let mut rows = stmt.query(rusqlite::params![workspace_id, team_key])?;
1046 if let Some(row) = rows.next()? {
1047 Ok(Some(row.get(0)?))
1048 } else {
1049 Ok(None)
1050 }
1051 })
1052 }
1053
1054 pub fn set_sync_cursor(
1055 &self,
1056 workspace_id: &str,
1057 team_key: &str,
1058 last_updated_at: &str,
1059 ) -> Result<()> {
1060 self.with_conn(|conn| {
1061 conn.execute(
1062 "INSERT INTO sync_state (workspace_id, team_key, last_updated_at, full_sync_done, last_synced_at)
1063 VALUES (?1, ?2, ?3, 1, datetime('now'))
1064 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')",
1065 rusqlite::params![workspace_id, team_key, last_updated_at],
1066 )?;
1067 Ok(())
1068 })
1069 }
1070
1071 pub fn is_full_sync_done(&self, workspace_id: &str, team_key: &str) -> Result<bool> {
1072 self.with_conn(|conn| {
1073 let mut stmt = conn.prepare(
1074 "SELECT full_sync_done FROM sync_state WHERE workspace_id = ?1 AND team_key = ?2",
1075 )?;
1076 let mut rows = stmt.query(rusqlite::params![workspace_id, team_key])?;
1077 if let Some(row) = rows.next()? {
1078 let done: bool = row.get(0)?;
1079 Ok(done)
1080 } else {
1081 Ok(false)
1082 }
1083 })
1084 }
1085
1086 pub fn get_last_synced_at(&self, workspace_id: &str, team_key: &str) -> Result<Option<String>> {
1088 self.with_conn(|conn| {
1089 let mut stmt = conn.prepare(
1090 "SELECT last_synced_at FROM sync_state WHERE workspace_id = ?1 AND team_key = ?2",
1091 )?;
1092 let mut rows = stmt.query(rusqlite::params![workspace_id, team_key])?;
1093 if let Some(row) = rows.next()? {
1094 Ok(row.get(0)?)
1095 } else {
1096 Ok(None)
1097 }
1098 })
1099 }
1100
1101 pub fn get_metadata(&self, key: &str) -> Result<Option<String>> {
1104 self.with_conn(|conn| {
1105 let mut stmt = conn.prepare("SELECT value FROM metadata WHERE key = ?1")?;
1106 let mut rows = stmt.query(rusqlite::params![key])?;
1107 if let Some(row) = rows.next()? {
1108 Ok(Some(row.get(0)?))
1109 } else {
1110 Ok(None)
1111 }
1112 })
1113 }
1114
1115 pub fn set_metadata(&self, key: &str, value: &str) -> Result<()> {
1116 self.with_conn(|conn| {
1117 conn.execute(
1118 "INSERT INTO metadata (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
1119 rusqlite::params![key, value],
1120 )?;
1121 Ok(())
1122 })
1123 }
1124
1125 pub fn list_synced_teams(&self, workspace_id: &str) -> Result<Vec<TeamSummary>> {
1130 self.with_conn(|conn| {
1131 let mut stmt = conn.prepare(
1132 "SELECT i.team_key,
1133 COUNT(DISTINCT i.id) AS issue_count,
1134 COUNT(DISTINCT c.issue_id) AS embedded_count,
1135 s.last_synced_at
1136 FROM issues i
1137 LEFT JOIN chunks c ON i.id = c.issue_id
1138 LEFT JOIN sync_state s ON i.team_key = s.team_key AND s.workspace_id = ?1
1139 WHERE i.workspace_id = ?1
1140 GROUP BY i.team_key
1141 ORDER BY i.team_key",
1142 )?;
1143 let rows = stmt.query_map(rusqlite::params![workspace_id], |row| {
1144 Ok(TeamSummary {
1145 key: row.get(0)?,
1146 issue_count: row.get(1)?,
1147 embedded_count: row.get(2)?,
1148 last_synced_at: row.get(3)?,
1149 })
1150 })?;
1151 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
1152 })
1153 }
1154
1155 pub fn fts_search(
1156 &self,
1157 query: &str,
1158 limit: usize,
1159 workspace_id: &str,
1160 ) -> Result<Vec<FtsResult>> {
1161 self.fts_search_filtered(query, limit, workspace_id, None)
1162 }
1163
1164 pub fn fts_search_filtered(
1165 &self,
1166 query: &str,
1167 limit: usize,
1168 workspace_id: &str,
1169 label_ids: Option<&[String]>,
1170 ) -> Result<Vec<FtsResult>> {
1171 self.with_conn(|conn| {
1172 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![
1173 Box::new(query.to_string()),
1174 Box::new(limit as i64),
1175 Box::new(workspace_id.to_string()),
1176 ];
1177
1178 let label_clause = if let Some(ids) = label_ids.filter(|ids| !ids.is_empty()) {
1179 let (frag, mut lp) = Self::label_filter_fragment(ids, params.len() + 1, "i");
1180 params.append(&mut lp);
1181 format!(" AND {frag}")
1182 } else {
1183 String::new()
1184 };
1185
1186 let sql = format!(
1187 "SELECT i.id, i.identifier, i.title, i.state_name, i.priority, bm25(issues_fts) as rank
1188 FROM issues_fts f
1189 JOIN issues i ON f.rowid = i.rowid
1190 WHERE issues_fts MATCH ?1 AND i.workspace_id = ?3{label_clause}
1191 ORDER BY rank
1192 LIMIT ?2"
1193 );
1194 let mut stmt = conn.prepare(&sql)?;
1195 let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
1196 let rows = stmt.query_map(param_refs.as_slice(), |row| {
1197 Ok(FtsResult {
1198 issue_id: row.get(0)?,
1199 identifier: row.get(1)?,
1200 title: row.get(2)?,
1201 state_name: row.get(3)?,
1202 priority: row.get(4)?,
1203 bm25_score: row.get(5)?,
1204 })
1205 })?;
1206 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
1207 })
1208 }
1209}
1210
1211fn default_workspace_id() -> String {
1214 "default".to_string()
1215}
1216
1217#[derive(Debug, Clone, Serialize, Deserialize)]
1218pub struct Issue {
1219 pub id: String,
1220 pub identifier: String,
1221 pub team_key: String,
1222 pub title: String,
1223 pub description: Option<String>,
1224 pub state_name: String,
1225 pub state_type: String,
1226 pub priority: i32,
1227 pub assignee_name: Option<String>,
1228 pub project_name: Option<String>,
1229 pub labels_json: String,
1230 pub created_at: String,
1231 pub updated_at: String,
1232 pub content_hash: String,
1233 pub synced_at: Option<String>,
1234 pub url: String,
1235 pub branch_name: Option<String>,
1236 #[serde(default = "default_workspace_id")]
1237 pub workspace_id: String,
1238}
1239
1240impl Issue {
1241 pub fn from_row(row: &rusqlite::Row) -> rusqlite::Result<Self> {
1242 Ok(Self {
1243 id: row.get(0)?,
1244 identifier: row.get(1)?,
1245 team_key: row.get(2)?,
1246 title: row.get(3)?,
1247 description: row.get(4)?,
1248 state_name: row.get(5)?,
1249 state_type: row.get(6)?,
1250 priority: row.get(7)?,
1251 assignee_name: row.get(8)?,
1252 project_name: row.get(9)?,
1253 labels_json: row.get(10)?,
1254 created_at: row.get(11)?,
1255 updated_at: row.get(12)?,
1256 content_hash: row.get(13)?,
1257 synced_at: row.get(14)?,
1258 url: row.get(15)?,
1259 branch_name: row.get(16).unwrap_or(None),
1260 workspace_id: row.get(17).unwrap_or_else(|_| "default".to_string()),
1261 })
1262 }
1263
1264 pub fn labels(&self) -> Vec<String> {
1265 serde_json::from_str(&self.labels_json).unwrap_or_default()
1266 }
1267
1268 pub fn priority_label(&self) -> &str {
1269 match self.priority {
1270 0 => "No priority",
1271 1 => "Urgent",
1272 2 => "High",
1273 3 => "Medium",
1274 4 => "Low",
1275 _ => "Unknown",
1276 }
1277 }
1278}
1279
1280#[derive(Debug, Clone, Serialize, Deserialize)]
1281pub struct Relation {
1282 pub id: String,
1283 pub issue_id: String,
1284 pub related_issue_id: String,
1285 pub related_issue_identifier: String,
1286 pub relation_type: String,
1287}
1288
1289#[derive(Debug, Clone, Serialize, Deserialize)]
1290pub struct EnrichedRelation {
1291 pub relation_id: String,
1292 pub relation_type: String,
1293 pub issue_identifier: String,
1294 pub issue_title: String,
1295 pub issue_state: String,
1296 pub issue_url: String,
1297}
1298
1299#[derive(Debug, Clone)]
1300pub struct Chunk {
1301 pub issue_id: String,
1302 pub embedding: Vec<u8>,
1303 pub identifier: String,
1304}
1305
1306#[derive(Debug, Clone, Serialize, Deserialize)]
1307pub struct Comment {
1308 pub id: String,
1309 pub issue_id: String,
1310 pub body: String,
1311 pub user_name: Option<String>,
1312 pub created_at: String,
1313 pub updated_at: Option<String>,
1314 pub parent_id: Option<String>,
1315 pub url: Option<String>,
1316 #[serde(default = "default_workspace_id")]
1317 pub workspace_id: String,
1318}
1319
1320#[derive(Debug, Clone, Serialize, Deserialize)]
1321pub struct CommentSyncState {
1322 pub status: String,
1323 pub sync_error: Option<String>,
1324 pub synced_at: Option<String>,
1325}
1326
1327impl CommentSyncState {
1328 pub fn not_synced() -> Self {
1329 Self {
1330 status: "not_synced".to_string(),
1331 sync_error: None,
1332 synced_at: None,
1333 }
1334 }
1335}
1336
1337#[derive(Debug, Clone)]
1338pub struct FtsResult {
1339 pub issue_id: String,
1340 pub identifier: String,
1341 pub title: String,
1342 pub state_name: String,
1343 pub priority: i32,
1344 pub bm25_score: f64,
1345}
1346
1347#[derive(Debug, Clone)]
1348pub struct IssueSummary {
1349 pub id: String,
1350 pub identifier: String,
1351 pub team_key: String,
1352 pub title: String,
1353 pub state_name: String,
1354 pub state_type: String,
1355 pub priority: i32,
1356 pub project_name: Option<String>,
1357 pub labels: Vec<String>,
1358 pub updated_at: String,
1359 pub url: String,
1360 pub has_description: bool,
1361 pub has_embedding: bool,
1362}
1363
1364#[derive(Debug, Clone)]
1365pub struct TeamSummary {
1366 pub key: String,
1367 pub issue_count: usize,
1368 pub embedded_count: usize,
1369 pub last_synced_at: Option<String>,
1370}
1371
1372#[derive(Debug, Clone, Serialize, Deserialize)]
1373pub struct WorkspaceRow {
1374 pub id: String,
1375 pub linear_org_id: Option<String>,
1376 pub display_name: Option<String>,
1377 pub created_at: String,
1378}
1379
1380#[derive(Debug, Clone, Serialize, Deserialize)]
1381pub struct Label {
1382 pub id: String,
1383 pub workspace_id: String,
1384 pub name: String,
1385 pub color: Option<String>,
1386 pub parent_id: Option<String>,
1387}
1388
1389#[cfg(test)]
1390mod tests {
1391 use super::Comment;
1392 use super::test_helpers::*;
1393
1394 #[test]
1395 fn count_embedded_issues_empty_db() {
1396 let (db, _dir) = test_db();
1397 assert_eq!(db.count_embedded_issues(None, "default").unwrap(), 0);
1398 }
1399
1400 #[test]
1401 fn comment_sync_state_defaults_to_not_synced() {
1402 let (db, _dir) = test_db();
1403 let issue = make_issue("TST-1", "TST");
1404 db.upsert_issue(&issue).unwrap();
1405
1406 let state = db.get_comment_sync_state(&issue.id).unwrap();
1407 assert_eq!(state.status, "not_synced");
1408 assert!(state.synced_at.is_none());
1409 assert!(state.sync_error.is_none());
1410 }
1411
1412 #[test]
1413 fn replace_issue_comments_preserves_thread_metadata() {
1414 let (db, _dir) = test_db();
1415 let issue = make_issue("TST-1", "TST");
1416 db.upsert_issue(&issue).unwrap();
1417
1418 db.replace_issue_comments(
1419 &issue.id,
1420 "default",
1421 &[Comment {
1422 id: "comment-1".to_string(),
1423 issue_id: issue.id.clone(),
1424 body: "fixed in linked PR".to_string(),
1425 user_name: Some("Ada".to_string()),
1426 created_at: "2026-01-03T00:00:00Z".to_string(),
1427 updated_at: Some("2026-01-03T01:00:00Z".to_string()),
1428 parent_id: Some("parent-1".to_string()),
1429 url: Some("https://linear.app/comment/comment-1".to_string()),
1430 workspace_id: "default".to_string(),
1431 }],
1432 )
1433 .unwrap();
1434 db.mark_comments_synced(&issue.id, "default", 1).unwrap();
1435
1436 let comments = db.get_comments(&issue.id).unwrap();
1437 assert_eq!(comments.len(), 1);
1438 assert_eq!(comments[0].parent_id.as_deref(), Some("parent-1"));
1439 assert_eq!(comments[0].updated_at.as_deref(), Some("2026-01-03T01:00:00Z"));
1440 assert_eq!(
1441 comments[0].url.as_deref(),
1442 Some("https://linear.app/comment/comment-1")
1443 );
1444 assert_eq!(
1445 db.get_comment_sync_state(&issue.id).unwrap().status,
1446 "synced"
1447 );
1448 }
1449
1450 #[test]
1451 fn empty_comment_sync_records_none_found() {
1452 let (db, _dir) = test_db();
1453 let issue = make_issue("TST-1", "TST");
1454 db.upsert_issue(&issue).unwrap();
1455
1456 db.replace_issue_comments(&issue.id, "default", &[]).unwrap();
1457 db.mark_comments_synced(&issue.id, "default", 0).unwrap();
1458
1459 assert!(db.get_comments(&issue.id).unwrap().is_empty());
1460 let state = db.get_comment_sync_state(&issue.id).unwrap();
1461 assert_eq!(state.status, "none_found");
1462 assert!(state.synced_at.is_some());
1463 }
1464
1465 #[test]
1466 fn count_embedded_issues_with_data() {
1467 let (db, _dir) = test_db();
1468
1469 let issue1 = make_issue("TST-1", "TST");
1470 let issue2 = make_issue("TST-2", "TST");
1471 let issue3 = make_issue("OTH-1", "OTH");
1472 db.upsert_issue(&issue1).unwrap();
1473 db.upsert_issue(&issue2).unwrap();
1474 db.upsert_issue(&issue3).unwrap();
1475
1476 db.upsert_chunks(&issue1.id, &[(0, "chunk".into(), fake_embedding(768))])
1478 .unwrap();
1479 db.upsert_chunks(&issue3.id, &[(0, "chunk".into(), fake_embedding(768))])
1480 .unwrap();
1481
1482 assert_eq!(db.count_embedded_issues(None, "default").unwrap(), 2);
1484 assert_eq!(db.count_embedded_issues(Some("TST"), "default").unwrap(), 1);
1486 assert_eq!(db.count_embedded_issues(Some("OTH"), "default").unwrap(), 1);
1487 assert_eq!(
1488 db.count_embedded_issues(Some("NONE"), "default").unwrap(),
1489 0
1490 );
1491 }
1492
1493 #[test]
1494 fn get_field_completeness_empty_db() {
1495 let (db, _dir) = test_db();
1496 let (total, desc, pri, labels, proj) = db.get_field_completeness(None, "default").unwrap();
1497 assert_eq!(total, 0);
1498 assert_eq!(desc, 0);
1499 assert_eq!(pri, 0);
1500 assert_eq!(labels, 0);
1501 assert_eq!(proj, 0);
1502 }
1503
1504 #[test]
1505 fn get_field_completeness_with_data() {
1506 let (db, _dir) = test_db();
1507
1508 let mut full = make_issue("TST-1", "TST");
1510 full.description = Some("Has desc".into());
1511 full.priority = 2;
1512 full.labels_json = r#"["bug"]"#.into();
1513 full.project_name = Some("Proj".into());
1514 db.upsert_issue(&full).unwrap();
1515
1516 let mut sparse = make_issue("TST-2", "TST");
1518 sparse.description = None;
1519 sparse.priority = 0;
1520 sparse.labels_json = "[]".into();
1521 sparse.project_name = None;
1522 db.upsert_issue(&sparse).unwrap();
1523
1524 let mut other = make_issue("OTH-1", "OTH");
1526 other.description = Some("Has desc".into());
1527 other.priority = 0;
1528 other.labels_json = "[]".into();
1529 other.project_name = None;
1530 db.upsert_issue(&other).unwrap();
1531
1532 let (total, desc, pri, labels, proj) = db.get_field_completeness(None, "default").unwrap();
1534 assert_eq!(total, 3);
1535 assert_eq!(desc, 2); assert_eq!(pri, 1); assert_eq!(labels, 1); assert_eq!(proj, 1); let (total, desc, pri, labels, proj) =
1542 db.get_field_completeness(Some("TST"), "default").unwrap();
1543 assert_eq!(total, 2);
1544 assert_eq!(desc, 1);
1545 assert_eq!(pri, 1);
1546 assert_eq!(labels, 1);
1547 assert_eq!(proj, 1);
1548 }
1549
1550 #[test]
1551 fn list_all_issues_pagination_and_filter() {
1552 let (db, _dir) = test_db();
1553
1554 for i in 1..=5 {
1555 let mut issue = make_issue(&format!("TST-{i}"), "TST");
1556 issue.updated_at = format!("2026-01-0{i}T00:00:00Z");
1557 db.upsert_issue(&issue).unwrap();
1558 }
1559 let mut other = make_issue("OTH-1", "OTH");
1560 other.updated_at = "2026-01-06T00:00:00Z".to_string();
1561 db.upsert_issue(&other).unwrap();
1562
1563 let page1 = db.list_all_issues(None, None, 3, 0, "default").unwrap();
1565 assert_eq!(page1.len(), 3);
1566 assert_eq!(page1[0].identifier, "OTH-1");
1568
1569 let page2 = db.list_all_issues(None, None, 3, 3, "default").unwrap();
1571 assert_eq!(page2.len(), 3);
1572
1573 let page3 = db.list_all_issues(None, None, 3, 6, "default").unwrap();
1575 assert_eq!(page3.len(), 0);
1576
1577 let tst = db
1579 .list_all_issues(Some("TST"), None, 10, 0, "default")
1580 .unwrap();
1581 assert_eq!(tst.len(), 5);
1582
1583 let filtered = db
1585 .list_all_issues(None, Some("TST-3"), 10, 0, "default")
1586 .unwrap();
1587 assert_eq!(filtered.len(), 1);
1588 assert_eq!(filtered[0].identifier, "TST-3");
1589
1590 let title_match = db
1592 .list_all_issues(None, Some("Test issue OTH"), 10, 0, "default")
1593 .unwrap();
1594 assert_eq!(title_match.len(), 1);
1595 }
1596
1597 #[test]
1598 fn list_all_issues_has_embedding_flag() {
1599 let (db, _dir) = test_db();
1600
1601 let issue1 = make_issue("TST-1", "TST");
1602 let issue2 = make_issue("TST-2", "TST");
1603 db.upsert_issue(&issue1).unwrap();
1604 db.upsert_issue(&issue2).unwrap();
1605
1606 db.upsert_chunks(&issue1.id, &[(0, "chunk".into(), fake_embedding(768))])
1608 .unwrap();
1609
1610 let issues = db.list_all_issues(None, None, 10, 0, "default").unwrap();
1611 let by_id: std::collections::HashMap<_, _> =
1612 issues.iter().map(|i| (i.identifier.as_str(), i)).collect();
1613
1614 assert!(by_id["TST-1"].has_embedding);
1615 assert!(!by_id["TST-2"].has_embedding);
1616 }
1617
1618 #[test]
1619 fn list_synced_teams_empty_db() {
1620 let (db, _dir) = test_db();
1621 let teams = db.list_synced_teams("default").unwrap();
1622 assert!(teams.is_empty());
1623 }
1624
1625 #[test]
1626 fn list_synced_teams_with_data() {
1627 let (db, _dir) = test_db();
1628
1629 for i in 1..=3 {
1631 let issue = make_issue(&format!("TST-{i}"), "TST");
1632 db.upsert_issue(&issue).unwrap();
1633 if i <= 2 {
1634 db.upsert_chunks(&issue.id, &[(0, "chunk".into(), fake_embedding(768))])
1636 .unwrap();
1637 }
1638 }
1639 let other = make_issue("OTH-1", "OTH");
1640 db.upsert_issue(&other).unwrap();
1641
1642 let teams = db.list_synced_teams("default").unwrap();
1643 assert_eq!(teams.len(), 2);
1644
1645 let by_key: std::collections::HashMap<_, _> =
1647 teams.iter().map(|t| (t.key.as_str(), t)).collect();
1648
1649 assert_eq!(by_key["TST"].issue_count, 3);
1650 assert_eq!(by_key["TST"].embedded_count, 2);
1651 assert_eq!(by_key["OTH"].issue_count, 1);
1652 assert_eq!(by_key["OTH"].embedded_count, 0);
1653 }
1654
1655 #[test]
1656 fn list_synced_teams_includes_last_synced_at() {
1657 let (db, _dir) = test_db();
1658
1659 let issue = make_issue("TST-1", "TST");
1660 db.upsert_issue(&issue).unwrap();
1661
1662 let teams = db.list_synced_teams("default").unwrap();
1664 assert_eq!(teams.len(), 1);
1665 assert!(teams[0].last_synced_at.is_none());
1666
1667 db.set_sync_cursor("default", "TST", "2026-01-01T00:00:00Z")
1669 .unwrap();
1670 let teams = db.list_synced_teams("default").unwrap();
1671 assert!(teams[0].last_synced_at.is_some());
1672 }
1673
1674 #[test]
1675 fn list_synced_teams_multi_chunk_issue() {
1676 let (db, _dir) = test_db();
1677
1678 let issue = make_issue("TST-1", "TST");
1679 db.upsert_issue(&issue).unwrap();
1680 db.upsert_chunks(
1682 &issue.id,
1683 &[
1684 (0, "chunk0".into(), fake_embedding(768)),
1685 (1, "chunk1".into(), fake_embedding(768)),
1686 (2, "chunk2".into(), fake_embedding(768)),
1687 ],
1688 )
1689 .unwrap();
1690
1691 let teams = db.list_synced_teams("default").unwrap();
1692 assert_eq!(teams.len(), 1);
1693 assert_eq!(teams[0].issue_count, 1); assert_eq!(teams[0].embedded_count, 1);
1695 }
1696
1697 #[test]
1698 fn workspace_crud() {
1699 let (db, _dir) = test_db();
1700
1701 let ws = db.get_workspace("default").unwrap();
1703 assert!(ws.is_some());
1704
1705 db.upsert_workspace("work", None, None).unwrap();
1707 let ws = db.get_workspace("work").unwrap().unwrap();
1708 assert_eq!(ws.id, "work");
1709 assert!(ws.linear_org_id.is_none());
1710
1711 db.upsert_workspace("work", Some("org-123"), Some("Work Org"))
1713 .unwrap();
1714 let ws = db.get_workspace("work").unwrap().unwrap();
1715 assert_eq!(ws.linear_org_id.as_deref(), Some("org-123"));
1716 assert_eq!(ws.display_name.as_deref(), Some("Work Org"));
1717
1718 let all = db.list_workspaces().unwrap();
1720 assert_eq!(all.len(), 2);
1721
1722 db.delete_workspace("work").unwrap();
1724 let ws = db.get_workspace("work").unwrap();
1725 assert!(ws.is_none());
1726 }
1727
1728 #[test]
1729 fn issues_isolated_by_workspace() {
1730 let (db, _dir) = test_db();
1731
1732 db.upsert_workspace("work", None, None).unwrap();
1734
1735 let mut issue1 = make_issue("TST-1", "TST");
1737 issue1.workspace_id = "default".to_string();
1738 issue1.priority = 0;
1739 db.upsert_issue(&issue1).unwrap();
1740
1741 let mut issue2 = make_issue("TST-2", "TST");
1743 issue2.id = "id-2".to_string();
1744 issue2.workspace_id = "work".to_string();
1745 issue2.priority = 0;
1746 db.upsert_issue(&issue2).unwrap();
1747
1748 assert_eq!(db.count_issues(None, "default").unwrap(), 1);
1750 assert_eq!(db.count_issues(None, "work").unwrap(), 1);
1751
1752 let default_unpri = db.get_unprioritized_issues(None, false, "default").unwrap();
1754 assert_eq!(default_unpri.len(), 1);
1755 assert_eq!(default_unpri[0].identifier, "TST-1");
1756
1757 let work_unpri = db.get_unprioritized_issues(None, false, "work").unwrap();
1758 assert_eq!(work_unpri.len(), 1);
1759 assert_eq!(work_unpri[0].identifier, "TST-2");
1760 }
1761
1762 #[test]
1763 fn sync_state_isolated_by_workspace() {
1764 let (db, _dir) = test_db();
1765 db.upsert_workspace("work", None, None).unwrap();
1766
1767 db.set_sync_cursor("default", "TST", "2024-01-01T00:00:00Z")
1769 .unwrap();
1770 db.set_sync_cursor("work", "TST", "2024-06-01T00:00:00Z")
1771 .unwrap();
1772
1773 assert_eq!(
1774 db.get_sync_cursor("default", "TST").unwrap().as_deref(),
1775 Some("2024-01-01T00:00:00Z")
1776 );
1777 assert_eq!(
1778 db.get_sync_cursor("work", "TST").unwrap().as_deref(),
1779 Some("2024-06-01T00:00:00Z")
1780 );
1781
1782 assert!(db.is_full_sync_done("default", "TST").unwrap());
1783 assert!(db.is_full_sync_done("work", "TST").unwrap());
1784 assert!(!db.is_full_sync_done("default", "OTHER").unwrap());
1785 }
1786
1787 #[test]
1788 fn list_synced_teams_workspace_scoped() {
1789 let (db, _dir) = test_db();
1790 db.upsert_workspace("work", None, None).unwrap();
1791
1792 let mut issue1 = make_issue("TST-1", "TST");
1793 issue1.workspace_id = "default".to_string();
1794 db.upsert_issue(&issue1).unwrap();
1795
1796 let mut issue2 = make_issue("WRK-1", "WRK");
1797 issue2.id = "id-wrk".to_string();
1798 issue2.workspace_id = "work".to_string();
1799 db.upsert_issue(&issue2).unwrap();
1800
1801 let default_teams = db.list_synced_teams("default").unwrap();
1802 assert_eq!(default_teams.len(), 1);
1803 assert_eq!(default_teams[0].key, "TST");
1804
1805 let work_teams = db.list_synced_teams("work").unwrap();
1806 assert_eq!(work_teams.len(), 1);
1807 assert_eq!(work_teams[0].key, "WRK");
1808 }
1809
1810 #[test]
1811 fn migration_8_creates_label_tables_and_resets_sync_state() {
1812 let dir = tempfile::tempdir().unwrap();
1813 let path = dir.path().join("test.db");
1814 let conn = rusqlite::Connection::open(&path).unwrap();
1815
1816 conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")
1817 .unwrap();
1818
1819 crate::db::schema::run_migrations(&conn).unwrap();
1821
1822 conn.execute("DELETE FROM schema_version WHERE version >= 8", [])
1824 .unwrap();
1825
1826 conn.execute(
1828 "INSERT INTO sync_state (workspace_id, team_key, last_updated_at, full_sync_done, last_synced_at)
1829 VALUES ('default', 'ENG', '2026-04-01T00:00:00Z', 1, '2026-04-01T00:00:00Z')",
1830 [],
1831 )
1832 .unwrap();
1833
1834 let full_done_before: i64 = conn
1836 .query_row(
1837 "SELECT full_sync_done FROM sync_state WHERE workspace_id='default' AND team_key='ENG'",
1838 [],
1839 |r| r.get(0),
1840 )
1841 .unwrap();
1842 assert_eq!(full_done_before, 1);
1843
1844 crate::db::schema::run_migrations(&conn).unwrap();
1846
1847 let labels_count: i64 = conn
1849 .query_row(
1850 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='labels'",
1851 [],
1852 |r| r.get(0),
1853 )
1854 .unwrap();
1855 assert_eq!(labels_count, 1);
1856 let join_count: i64 = conn
1857 .query_row(
1858 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='issue_labels'",
1859 [],
1860 |r| r.get(0),
1861 )
1862 .unwrap();
1863 assert_eq!(join_count, 1);
1864
1865 let full_done: i64 = conn
1867 .query_row(
1868 "SELECT full_sync_done FROM sync_state WHERE workspace_id='default' AND team_key='ENG'",
1869 [],
1870 |r| r.get(0),
1871 )
1872 .unwrap();
1873 assert_eq!(full_done, 0);
1874 let last_updated: String = conn
1875 .query_row(
1876 "SELECT last_updated_at FROM sync_state WHERE workspace_id='default' AND team_key='ENG'",
1877 [],
1878 |r| r.get(0),
1879 )
1880 .unwrap();
1881 assert_eq!(last_updated, "1970-01-01T00:00:00Z");
1882 }
1883
1884 #[test]
1885 fn upsert_label_inserts_and_renames_in_place() {
1886 use super::test_helpers::{test_db, make_label};
1887 let (db, _dir) = test_db();
1888
1889 let mut l = make_label("lbl_1", "Vanta", "default");
1890 db.upsert_label(&l).unwrap();
1891
1892 let listed = db.list_labels("default").unwrap();
1893 assert_eq!(listed.len(), 1);
1894 assert_eq!(listed[0].name, "Vanta");
1895
1896 l.name = "Compliance".to_string();
1898 db.upsert_label(&l).unwrap();
1899
1900 let listed = db.list_labels("default").unwrap();
1901 assert_eq!(listed.len(), 1);
1902 assert_eq!(listed[0].name, "Compliance");
1903 }
1904
1905 #[test]
1906 fn list_labels_is_workspace_scoped_and_sorted() {
1907 use super::test_helpers::{test_db, make_label};
1908 let (db, _dir) = test_db();
1909 db.upsert_workspace("work", None, None).unwrap();
1910
1911 db.upsert_label(&make_label("a", "Zebra", "default")).unwrap();
1912 db.upsert_label(&make_label("b", "Apple", "default")).unwrap();
1913 db.upsert_label(&make_label("c", "OnlyInWork", "work")).unwrap();
1914
1915 let default_labels = db.list_labels("default").unwrap();
1916 assert_eq!(default_labels.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(),
1917 vec!["Apple", "Zebra"]);
1918 let work_labels = db.list_labels("work").unwrap();
1919 assert_eq!(work_labels.len(), 1);
1920 assert_eq!(work_labels[0].name, "OnlyInWork");
1921 }
1922
1923 #[test]
1924 fn delete_labels_for_workspace_not_in_removes_orphans() {
1925 use super::test_helpers::{test_db, make_label};
1926 let (db, _dir) = test_db();
1927
1928 db.upsert_label(&make_label("keep", "Keep", "default")).unwrap();
1929 db.upsert_label(&make_label("drop", "Drop", "default")).unwrap();
1930
1931 let kept = db.delete_labels_for_workspace_not_in("default", &["keep".to_string()]).unwrap();
1932 assert_eq!(kept, 1, "should report 1 deleted");
1933
1934 let listed = db.list_labels("default").unwrap();
1935 assert_eq!(listed.len(), 1);
1936 assert_eq!(listed[0].name, "Keep");
1937 }
1938
1939 #[test]
1940 fn replace_issue_labels_overwrites_existing() {
1941 use super::test_helpers::{test_db, make_issue, make_label};
1942 let (db, _dir) = test_db();
1943
1944 let issue = make_issue("ENG-1", "ENG");
1945 db.upsert_issue(&issue).unwrap();
1946 db.upsert_label(&make_label("l1", "Bug", "default")).unwrap();
1947 db.upsert_label(&make_label("l2", "UI", "default")).unwrap();
1948 db.upsert_label(&make_label("l3", "Backend", "default")).unwrap();
1949
1950 db.replace_issue_labels(&issue.id, &["l1".to_string(), "l2".to_string()]).unwrap();
1951 let labels = db.get_issue_label_ids(&issue.id).unwrap();
1952 assert_eq!(labels, vec!["l1".to_string(), "l2".to_string()]);
1953
1954 db.replace_issue_labels(&issue.id, &["l3".to_string()]).unwrap();
1956 let labels = db.get_issue_label_ids(&issue.id).unwrap();
1957 assert_eq!(labels, vec!["l3".to_string()]);
1958 }
1959
1960 #[test]
1961 fn deleting_issue_cascades_to_issue_labels() {
1962 use super::test_helpers::{test_db, make_issue, make_label};
1963 let (db, _dir) = test_db();
1964
1965 let issue = make_issue("ENG-2", "ENG");
1966 db.upsert_issue(&issue).unwrap();
1967 db.upsert_label(&make_label("l1", "Bug", "default")).unwrap();
1968 db.replace_issue_labels(&issue.id, &["l1".to_string()]).unwrap();
1969
1970 db.with_conn(|conn| {
1971 conn.execute("DELETE FROM issues WHERE id = ?1", rusqlite::params![&issue.id])?;
1972 let n: i64 = conn.query_row(
1973 "SELECT COUNT(*) FROM issue_labels WHERE issue_id = ?1",
1974 rusqlite::params![&issue.id], |r| r.get(0))?;
1975 assert_eq!(n, 0);
1976 Ok(())
1977 }).unwrap();
1978 }
1979
1980 #[test]
1981 fn deleting_label_cascades_to_issue_labels() {
1982 use super::test_helpers::{test_db, make_issue, make_label};
1983 let (db, _dir) = test_db();
1984
1985 let issue = make_issue("ENG-3", "ENG");
1986 db.upsert_issue(&issue).unwrap();
1987 db.upsert_label(&make_label("l1", "Bug", "default")).unwrap();
1988 db.replace_issue_labels(&issue.id, &["l1".to_string()]).unwrap();
1989
1990 db.delete_labels_for_workspace_not_in("default", &[]).unwrap();
1991 let labels = db.get_issue_label_ids(&issue.id).unwrap();
1992 assert!(labels.is_empty());
1993 }
1994
1995 #[test]
1996 fn resolve_label_ids_local_matches_case_insensitive_and_returns_unknowns() {
1997 use super::test_helpers::{test_db, make_label};
1998 let (db, _dir) = test_db();
1999
2000 db.upsert_label(&make_label("l1", "Vanta", "default")).unwrap();
2001 db.upsert_label(&make_label("l2", "Security", "default")).unwrap();
2002
2003 let (resolved, unknown) = db
2004 .resolve_label_ids_local("default", &["vanta".to_string(), "secURity".to_string(), "missing".to_string()])
2005 .unwrap();
2006 assert_eq!(resolved.len(), 2);
2007 assert!(resolved.contains(&"l1".to_string()));
2008 assert!(resolved.contains(&"l2".to_string()));
2009 assert_eq!(unknown, vec!["missing".to_string()]);
2010 }
2011
2012 #[test]
2013 fn resolve_label_ids_local_is_workspace_scoped() {
2014 use super::test_helpers::{test_db, make_label};
2015 let (db, _dir) = test_db();
2016 db.upsert_workspace("work", None, None).unwrap();
2017
2018 db.upsert_label(&make_label("l1", "Vanta", "default")).unwrap();
2019 db.upsert_label(&make_label("l2", "Vanta", "work")).unwrap();
2020
2021 let (resolved, _) = db.resolve_label_ids_local("work", &["vanta".to_string()]).unwrap();
2022 assert_eq!(resolved, vec!["l2".to_string()]);
2023 }
2024
2025 #[test]
2026 fn get_unprioritized_issues_filters_by_labels_with_and_semantics() {
2027 use super::test_helpers::{test_db, make_issue, make_label};
2028 let (db, _dir) = test_db();
2029
2030 let mut a = make_issue("ENG-10", "ENG"); a.priority = 0;
2031 let mut b = make_issue("ENG-11", "ENG"); b.priority = 0;
2032 let mut c = make_issue("ENG-12", "ENG"); c.priority = 0;
2033 db.upsert_issue(&a).unwrap();
2034 db.upsert_issue(&b).unwrap();
2035 db.upsert_issue(&c).unwrap();
2036
2037 db.upsert_label(&make_label("vanta", "Vanta", "default")).unwrap();
2038 db.upsert_label(&make_label("sec", "Security", "default")).unwrap();
2039
2040 db.replace_issue_labels(&a.id, &["vanta".to_string(), "sec".to_string()]).unwrap();
2041 db.replace_issue_labels(&b.id, &["vanta".to_string()]).unwrap();
2042 db.replace_issue_labels(&c.id, &["sec".to_string()]).unwrap();
2043
2044 let result = db.get_unprioritized_issues_filtered(
2046 Some("ENG"), false, "default",
2047 Some(&["vanta".to_string(), "sec".to_string()]),
2048 ).unwrap();
2049 let idents: Vec<_> = result.iter().map(|i| i.identifier.as_str()).collect();
2050 assert_eq!(idents, vec!["ENG-10"]);
2051
2052 let result = db.get_unprioritized_issues_filtered(
2054 Some("ENG"), false, "default",
2055 Some(&["vanta".to_string()]),
2056 ).unwrap();
2057 let idents: Vec<_> = result.iter().map(|i| i.identifier.as_str()).collect();
2058 assert!(idents.contains(&"ENG-10"));
2059 assert!(idents.contains(&"ENG-11"));
2060 assert!(!idents.contains(&"ENG-12"));
2061
2062 let result = db.get_unprioritized_issues_filtered(Some("ENG"), false, "default", None).unwrap();
2064 assert_eq!(result.len(), 3);
2065 }
2066
2067 #[test]
2068 fn fts_search_with_label_filter_intersects() {
2069 use super::test_helpers::{test_db, make_issue, make_label};
2070 let (db, _dir) = test_db();
2071
2072 let mut a = make_issue("ENG-20", "ENG");
2073 a.title = "Audit logging gap".to_string();
2074 let mut b = make_issue("ENG-21", "ENG");
2075 b.title = "Audit something else".to_string();
2076 db.upsert_issue(&a).unwrap();
2077 db.upsert_issue(&b).unwrap();
2078
2079 db.upsert_label(&make_label("vanta", "Vanta", "default")).unwrap();
2080 db.replace_issue_labels(&a.id, &["vanta".to_string()]).unwrap();
2081
2082 let r = db.fts_search_filtered("\"audit\"", 10, "default", None).unwrap();
2084 assert_eq!(r.len(), 2);
2085
2086 let r = db.fts_search_filtered("\"audit\"", 10, "default", Some(&["vanta".to_string()])).unwrap();
2088 assert_eq!(r.len(), 1);
2089 assert_eq!(r[0].identifier, "ENG-20");
2090 }
2091
2092 #[test]
2093 fn delete_workspace_cleans_up_labels() {
2094 use super::test_helpers::{test_db, make_label};
2095 let (db, _dir) = test_db();
2096 db.upsert_workspace("doomed", None, None).unwrap();
2097 db.upsert_label(&make_label("l1", "Lab", "doomed")).unwrap();
2098 db.delete_workspace("doomed").unwrap();
2099 let listed = db.list_labels("doomed").unwrap();
2100 assert!(listed.is_empty());
2101 }
2102}