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