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 migration_10_repairs_missing_project_join_tables() {
1903 let dir = tempfile::tempdir().unwrap();
1904 let path = dir.path().join("test.db");
1905 let conn = rusqlite::Connection::open(&path).unwrap();
1906
1907 crate::db::schema::run_migrations(&conn).unwrap();
1908 conn.execute("DROP TABLE project_labels", []).unwrap();
1909
1910 let version: i64 = conn
1911 .query_row("SELECT MAX(version) FROM schema_version", [], |row| row.get(0))
1912 .unwrap();
1913 assert_eq!(version, 10);
1914
1915 crate::db::schema::run_migrations(&conn).unwrap();
1916
1917 let table_count: i64 = conn
1918 .query_row(
1919 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='project_labels'",
1920 [],
1921 |row| row.get(0),
1922 )
1923 .unwrap();
1924 assert_eq!(table_count, 1);
1925 }
1926
1927 #[test]
1928 fn upsert_label_inserts_and_renames_in_place() {
1929 use super::test_helpers::{test_db, make_label};
1930 let (db, _dir) = test_db();
1931
1932 let mut l = make_label("lbl_1", "Vanta", "default");
1933 db.upsert_label(&l).unwrap();
1934
1935 let listed = db.list_labels("default").unwrap();
1936 assert_eq!(listed.len(), 1);
1937 assert_eq!(listed[0].name, "Vanta");
1938
1939 l.name = "Compliance".to_string();
1941 db.upsert_label(&l).unwrap();
1942
1943 let listed = db.list_labels("default").unwrap();
1944 assert_eq!(listed.len(), 1);
1945 assert_eq!(listed[0].name, "Compliance");
1946 }
1947
1948 #[test]
1949 fn list_labels_is_workspace_scoped_and_sorted() {
1950 use super::test_helpers::{test_db, make_label};
1951 let (db, _dir) = test_db();
1952 db.upsert_workspace("work", None, None).unwrap();
1953
1954 db.upsert_label(&make_label("a", "Zebra", "default")).unwrap();
1955 db.upsert_label(&make_label("b", "Apple", "default")).unwrap();
1956 db.upsert_label(&make_label("c", "OnlyInWork", "work")).unwrap();
1957
1958 let default_labels = db.list_labels("default").unwrap();
1959 assert_eq!(default_labels.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(),
1960 vec!["Apple", "Zebra"]);
1961 let work_labels = db.list_labels("work").unwrap();
1962 assert_eq!(work_labels.len(), 1);
1963 assert_eq!(work_labels[0].name, "OnlyInWork");
1964 }
1965
1966 #[test]
1967 fn delete_labels_for_workspace_not_in_removes_orphans() {
1968 use super::test_helpers::{test_db, make_label};
1969 let (db, _dir) = test_db();
1970
1971 db.upsert_label(&make_label("keep", "Keep", "default")).unwrap();
1972 db.upsert_label(&make_label("drop", "Drop", "default")).unwrap();
1973
1974 let kept = db.delete_labels_for_workspace_not_in("default", &["keep".to_string()]).unwrap();
1975 assert_eq!(kept, 1, "should report 1 deleted");
1976
1977 let listed = db.list_labels("default").unwrap();
1978 assert_eq!(listed.len(), 1);
1979 assert_eq!(listed[0].name, "Keep");
1980 }
1981
1982 #[test]
1983 fn replace_issue_labels_overwrites_existing() {
1984 use super::test_helpers::{test_db, make_issue, make_label};
1985 let (db, _dir) = test_db();
1986
1987 let issue = make_issue("ENG-1", "ENG");
1988 db.upsert_issue(&issue).unwrap();
1989 db.upsert_label(&make_label("l1", "Bug", "default")).unwrap();
1990 db.upsert_label(&make_label("l2", "UI", "default")).unwrap();
1991 db.upsert_label(&make_label("l3", "Backend", "default")).unwrap();
1992
1993 db.replace_issue_labels(&issue.id, &["l1".to_string(), "l2".to_string()]).unwrap();
1994 let labels = db.get_issue_label_ids(&issue.id).unwrap();
1995 assert_eq!(labels, vec!["l1".to_string(), "l2".to_string()]);
1996
1997 db.replace_issue_labels(&issue.id, &["l3".to_string()]).unwrap();
1999 let labels = db.get_issue_label_ids(&issue.id).unwrap();
2000 assert_eq!(labels, vec!["l3".to_string()]);
2001 }
2002
2003 #[test]
2004 fn deleting_issue_cascades_to_issue_labels() {
2005 use super::test_helpers::{test_db, make_issue, make_label};
2006 let (db, _dir) = test_db();
2007
2008 let issue = make_issue("ENG-2", "ENG");
2009 db.upsert_issue(&issue).unwrap();
2010 db.upsert_label(&make_label("l1", "Bug", "default")).unwrap();
2011 db.replace_issue_labels(&issue.id, &["l1".to_string()]).unwrap();
2012
2013 db.with_conn(|conn| {
2014 conn.execute("DELETE FROM issues WHERE id = ?1", rusqlite::params![&issue.id])?;
2015 let n: i64 = conn.query_row(
2016 "SELECT COUNT(*) FROM issue_labels WHERE issue_id = ?1",
2017 rusqlite::params![&issue.id], |r| r.get(0))?;
2018 assert_eq!(n, 0);
2019 Ok(())
2020 }).unwrap();
2021 }
2022
2023 #[test]
2024 fn deleting_label_cascades_to_issue_labels() {
2025 use super::test_helpers::{test_db, make_issue, make_label};
2026 let (db, _dir) = test_db();
2027
2028 let issue = make_issue("ENG-3", "ENG");
2029 db.upsert_issue(&issue).unwrap();
2030 db.upsert_label(&make_label("l1", "Bug", "default")).unwrap();
2031 db.replace_issue_labels(&issue.id, &["l1".to_string()]).unwrap();
2032
2033 db.delete_labels_for_workspace_not_in("default", &[]).unwrap();
2034 let labels = db.get_issue_label_ids(&issue.id).unwrap();
2035 assert!(labels.is_empty());
2036 }
2037
2038 #[test]
2039 fn resolve_label_ids_local_matches_case_insensitive_and_returns_unknowns() {
2040 use super::test_helpers::{test_db, make_label};
2041 let (db, _dir) = test_db();
2042
2043 db.upsert_label(&make_label("l1", "Vanta", "default")).unwrap();
2044 db.upsert_label(&make_label("l2", "Security", "default")).unwrap();
2045
2046 let (resolved, unknown) = db
2047 .resolve_label_ids_local("default", &["vanta".to_string(), "secURity".to_string(), "missing".to_string()])
2048 .unwrap();
2049 assert_eq!(resolved.len(), 2);
2050 assert!(resolved.contains(&"l1".to_string()));
2051 assert!(resolved.contains(&"l2".to_string()));
2052 assert_eq!(unknown, vec!["missing".to_string()]);
2053 }
2054
2055 #[test]
2056 fn resolve_label_ids_local_is_workspace_scoped() {
2057 use super::test_helpers::{test_db, make_label};
2058 let (db, _dir) = test_db();
2059 db.upsert_workspace("work", None, None).unwrap();
2060
2061 db.upsert_label(&make_label("l1", "Vanta", "default")).unwrap();
2062 db.upsert_label(&make_label("l2", "Vanta", "work")).unwrap();
2063
2064 let (resolved, _) = db.resolve_label_ids_local("work", &["vanta".to_string()]).unwrap();
2065 assert_eq!(resolved, vec!["l2".to_string()]);
2066 }
2067
2068 #[test]
2069 fn get_unprioritized_issues_filters_by_labels_with_and_semantics() {
2070 use super::test_helpers::{test_db, make_issue, make_label};
2071 let (db, _dir) = test_db();
2072
2073 let mut a = make_issue("ENG-10", "ENG"); a.priority = 0;
2074 let mut b = make_issue("ENG-11", "ENG"); b.priority = 0;
2075 let mut c = make_issue("ENG-12", "ENG"); c.priority = 0;
2076 db.upsert_issue(&a).unwrap();
2077 db.upsert_issue(&b).unwrap();
2078 db.upsert_issue(&c).unwrap();
2079
2080 db.upsert_label(&make_label("vanta", "Vanta", "default")).unwrap();
2081 db.upsert_label(&make_label("sec", "Security", "default")).unwrap();
2082
2083 db.replace_issue_labels(&a.id, &["vanta".to_string(), "sec".to_string()]).unwrap();
2084 db.replace_issue_labels(&b.id, &["vanta".to_string()]).unwrap();
2085 db.replace_issue_labels(&c.id, &["sec".to_string()]).unwrap();
2086
2087 let result = db.get_unprioritized_issues_filtered(
2089 Some("ENG"), false, "default",
2090 Some(&["vanta".to_string(), "sec".to_string()]),
2091 ).unwrap();
2092 let idents: Vec<_> = result.iter().map(|i| i.identifier.as_str()).collect();
2093 assert_eq!(idents, vec!["ENG-10"]);
2094
2095 let result = db.get_unprioritized_issues_filtered(
2097 Some("ENG"), false, "default",
2098 Some(&["vanta".to_string()]),
2099 ).unwrap();
2100 let idents: Vec<_> = result.iter().map(|i| i.identifier.as_str()).collect();
2101 assert!(idents.contains(&"ENG-10"));
2102 assert!(idents.contains(&"ENG-11"));
2103 assert!(!idents.contains(&"ENG-12"));
2104
2105 let result = db.get_unprioritized_issues_filtered(Some("ENG"), false, "default", None).unwrap();
2107 assert_eq!(result.len(), 3);
2108 }
2109
2110 #[test]
2111 fn fts_search_with_label_filter_intersects() {
2112 use super::test_helpers::{test_db, make_issue, make_label};
2113 let (db, _dir) = test_db();
2114
2115 let mut a = make_issue("ENG-20", "ENG");
2116 a.title = "Audit logging gap".to_string();
2117 let mut b = make_issue("ENG-21", "ENG");
2118 b.title = "Audit something else".to_string();
2119 db.upsert_issue(&a).unwrap();
2120 db.upsert_issue(&b).unwrap();
2121
2122 db.upsert_label(&make_label("vanta", "Vanta", "default")).unwrap();
2123 db.replace_issue_labels(&a.id, &["vanta".to_string()]).unwrap();
2124
2125 let r = db.fts_search_filtered("\"audit\"", 10, "default", None).unwrap();
2127 assert_eq!(r.len(), 2);
2128
2129 let r = db.fts_search_filtered("\"audit\"", 10, "default", Some(&["vanta".to_string()])).unwrap();
2131 assert_eq!(r.len(), 1);
2132 assert_eq!(r[0].identifier, "ENG-20");
2133 }
2134
2135 #[test]
2136 fn delete_workspace_cleans_up_labels() {
2137 use super::test_helpers::{test_db, make_label};
2138 let (db, _dir) = test_db();
2139 db.upsert_workspace("doomed", None, None).unwrap();
2140 db.upsert_label(&make_label("l1", "Lab", "doomed")).unwrap();
2141 db.delete_workspace("doomed").unwrap();
2142 let listed = db.list_labels("doomed").unwrap();
2143 assert!(listed.is_empty());
2144 }
2145}