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