Skip to main content

rectilinear_core/db/
mod.rs

1pub mod schema;
2
3use anyhow::{Context, Result};
4use rusqlite::Connection;
5use serde::{Deserialize, Serialize};
6use std::path::Path;
7use std::sync::{Arc, Mutex};
8
9pub struct BlockerRow {
10    pub issue_id: String,
11    pub identifier: String,
12    pub title: String,
13    pub state_name: String,
14    pub state_type: String,
15}
16
17#[derive(Clone)]
18pub struct Database {
19    conn: Arc<Mutex<Connection>>,
20}
21
22impl Database {
23    pub fn open(path: &Path) -> Result<Self> {
24        let conn = Connection::open(path)
25            .with_context(|| format!("Failed to open database at {}", path.display()))?;
26
27        conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
28
29        let db = Self {
30            conn: Arc::new(Mutex::new(conn)),
31        };
32        db.migrate()?;
33        Ok(db)
34    }
35
36    fn migrate(&self) -> Result<()> {
37        let conn = self.conn.lock().unwrap();
38        schema::run_migrations(&conn)?;
39        Ok(())
40    }
41
42    pub fn with_conn<F, T>(&self, f: F) -> Result<T>
43    where
44        F: FnOnce(&Connection) -> Result<T>,
45    {
46        let conn = self.conn.lock().unwrap();
47        f(&conn)
48    }
49
50    // --- Issue CRUD ---
51
52    pub fn upsert_issue(&self, issue: &Issue) -> Result<()> {
53        self.with_conn(|conn| {
54            conn.execute(
55                "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)
56                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, datetime('now'), ?15)
57                 ON CONFLICT(id) DO UPDATE SET
58                   identifier=excluded.identifier, team_key=excluded.team_key, title=excluded.title,
59                   description=excluded.description, state_name=excluded.state_name, state_type=excluded.state_type,
60                   priority=excluded.priority, assignee_name=excluded.assignee_name, project_name=excluded.project_name,
61                   labels_json=excluded.labels_json, updated_at=excluded.updated_at,
62                   content_hash=excluded.content_hash, url=excluded.url, synced_at=datetime('now')",
63                rusqlite::params![
64                    issue.id, issue.identifier, issue.team_key, issue.title, issue.description,
65                    issue.state_name, issue.state_type, issue.priority, issue.assignee_name,
66                    issue.project_name, issue.labels_json, issue.created_at, issue.updated_at,
67                    issue.content_hash, issue.url,
68                ],
69            )?;
70            Ok(())
71        })
72    }
73
74    pub fn get_issue(&self, id_or_identifier: &str) -> Result<Option<Issue>> {
75        self.with_conn(|conn| {
76            let mut stmt = conn.prepare(
77                "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
78                 FROM issues WHERE id = ?1 OR identifier = ?1"
79            )?;
80            let mut rows = stmt.query(rusqlite::params![id_or_identifier])?;
81            if let Some(row) = rows.next()? {
82                Ok(Some(Issue::from_row(row)?))
83            } else {
84                Ok(None)
85            }
86        })
87    }
88
89    pub fn get_unprioritized_issues(
90        &self,
91        team_key: Option<&str>,
92        include_completed: bool,
93    ) -> Result<Vec<Issue>> {
94        self.with_conn(|conn| {
95            let state_filter = if include_completed {
96                ""
97            } else {
98                " AND state_type NOT IN ('completed', 'canceled')"
99            };
100            let (sql, params): (String, Vec<Box<dyn rusqlite::types::ToSql>>) = if let Some(team) = team_key {
101                (
102                    format!(
103                        "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
104                         FROM issues WHERE priority = 0{} AND team_key = ?1
105                         ORDER BY created_at DESC", state_filter
106                    ),
107                    vec![Box::new(team.to_string()) as Box<dyn rusqlite::types::ToSql>],
108                )
109            } else {
110                (
111                    format!(
112                        "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
113                         FROM issues WHERE priority = 0{}
114                         ORDER BY created_at DESC", state_filter
115                    ),
116                    vec![],
117                )
118            };
119            let mut stmt = conn.prepare(&sql)?;
120            let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
121            let rows = stmt.query_map(param_refs.as_slice(), |row| {
122                Ok(Issue::from_row(row).unwrap())
123            })?;
124            Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
125        })
126    }
127
128    pub fn get_issues_by_state_types(
129        &self,
130        team_key: &str,
131        state_types: &[String],
132    ) -> Result<Vec<Issue>> {
133        self.with_conn(|conn| {
134            let placeholders: String = state_types
135                .iter()
136                .enumerate()
137                .map(|(i, _)| format!("?{}", i + 2))
138                .collect::<Vec<_>>()
139                .join(", ");
140            let sql = format!(
141                "SELECT id, identifier, team_key, title, description, state_name, state_type, \
142                 priority, assignee_name, project_name, labels_json, created_at, updated_at, \
143                 content_hash, synced_at, url \
144                 FROM issues WHERE team_key = ?1 AND state_type IN ({placeholders}) \
145                 ORDER BY priority ASC, created_at DESC"
146            );
147            let mut stmt = conn.prepare(&sql)?;
148            let mut params: Vec<Box<dyn rusqlite::types::ToSql>> =
149                vec![Box::new(team_key.to_string())];
150            for st in state_types {
151                params.push(Box::new(st.clone()));
152            }
153            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
154                params.iter().map(|p| p.as_ref()).collect();
155            let rows = stmt.query_map(param_refs.as_slice(), |row| {
156                Ok(Issue::from_row(row).unwrap())
157            })?;
158            Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
159        })
160    }
161
162    /// For a set of issue IDs, return all `blocked_by` relations with resolved state info.
163    /// Returns (issue_id, blocker_identifier, blocker_title, blocker_state_name, blocker_state_type).
164    pub fn get_blockers_for_issues(&self, issue_ids: &[String]) -> Result<Vec<BlockerRow>> {
165        if issue_ids.is_empty() {
166            return Ok(vec![]);
167        }
168        self.with_conn(|conn| {
169            let placeholders: String = issue_ids
170                .iter()
171                .enumerate()
172                .map(|(i, _)| format!("?{}", i + 1))
173                .collect::<Vec<_>>()
174                .join(", ");
175
176            // Forward: issue has a "blocked_by" relation
177            let sql_fwd = format!(
178                "SELECT r.issue_id, COALESCE(i.identifier, r.related_issue_identifier),
179                        COALESCE(i.title, ''), COALESCE(i.state_name, ''), COALESCE(i.state_type, '')
180                 FROM issue_relations r
181                 LEFT JOIN issues i ON r.related_issue_id = i.id
182                 WHERE r.issue_id IN ({placeholders}) AND r.relation_type = 'blocked_by'"
183            );
184
185            // Inverse: another issue has a "blocks" relation pointing at this issue
186            let sql_inv = format!(
187                "SELECT r.related_issue_id, i2.identifier,
188                        COALESCE(i2.title, ''), COALESCE(i2.state_name, ''), COALESCE(i2.state_type, '')
189                 FROM issue_relations r
190                 JOIN issues i ON r.related_issue_id = i.id
191                 JOIN issues i2 ON r.issue_id = i2.id
192                 WHERE r.related_issue_id IN ({placeholders}) AND r.relation_type = 'blocks'"
193            );
194
195            let mut results = Vec::new();
196            let params: Vec<Box<dyn rusqlite::types::ToSql>> =
197                issue_ids.iter().map(|id| Box::new(id.clone()) as _).collect();
198            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
199                params.iter().map(|p| p.as_ref()).collect();
200
201            for sql in [&sql_fwd, &sql_inv] {
202                let mut stmt = conn.prepare(sql)?;
203                let rows = stmt.query_map(param_refs.as_slice(), |row| {
204                    Ok(BlockerRow {
205                        issue_id: row.get(0)?,
206                        identifier: row.get(1)?,
207                        title: row.get(2)?,
208                        state_name: row.get(3)?,
209                        state_type: row.get(4)?,
210                    })
211                })?;
212                for row in rows {
213                    results.push(row?);
214                }
215            }
216            Ok(results)
217        })
218    }
219
220    pub fn count_issues(&self, team_key: Option<&str>) -> Result<usize> {
221        self.with_conn(|conn| {
222            let count: usize = if let Some(team) = team_key {
223                conn.query_row(
224                    "SELECT COUNT(*) FROM issues WHERE team_key = ?1",
225                    rusqlite::params![team],
226                    |row| row.get(0),
227                )?
228            } else {
229                conn.query_row("SELECT COUNT(*) FROM issues", [], |row| row.get(0))?
230            };
231            Ok(count)
232        })
233    }
234
235    // --- Relations ---
236
237    pub fn upsert_relations(&self, issue_id: &str, relations: &[Relation]) -> Result<()> {
238        self.with_conn(|conn| {
239            conn.execute(
240                "DELETE FROM issue_relations WHERE issue_id = ?1",
241                rusqlite::params![issue_id],
242            )?;
243            let mut stmt = conn.prepare(
244                "INSERT OR IGNORE INTO issue_relations (id, issue_id, related_issue_id, related_issue_identifier, relation_type)
245                 VALUES (?1, ?2, ?3, ?4, ?5)"
246            )?;
247            for rel in relations {
248                stmt.execute(rusqlite::params![
249                    rel.id, rel.issue_id, rel.related_issue_id,
250                    rel.related_issue_identifier, rel.relation_type,
251                ])?;
252            }
253            Ok(())
254        })
255    }
256
257    pub fn get_relations_enriched(&self, issue_id: &str) -> Result<Vec<EnrichedRelation>> {
258        self.with_conn(|conn| {
259            // Relations where this issue is the source
260            let mut stmt = conn.prepare(
261                "SELECT r.id, r.relation_type, r.related_issue_identifier,
262                        COALESCE(i.title, ''), COALESCE(i.state_name, ''), COALESCE(i.url, '')
263                 FROM issue_relations r
264                 LEFT JOIN issues i ON r.related_issue_id = i.id
265                 WHERE r.issue_id = ?1",
266            )?;
267            let forward = stmt
268                .query_map(rusqlite::params![issue_id], |row| {
269                    Ok(EnrichedRelation {
270                        relation_id: row.get(0)?,
271                        relation_type: row.get(1)?,
272                        issue_identifier: row.get(2)?,
273                        issue_title: row.get(3)?,
274                        issue_state: row.get(4)?,
275                        issue_url: row.get(5)?,
276                    })
277                })?
278                .collect::<std::result::Result<Vec<_>, _>>()?;
279
280            // Relations where this issue is the target — flip direction
281            let mut stmt2 = conn.prepare(
282                "SELECT r.id, r.relation_type, i2.identifier,
283                        COALESCE(i2.title, ''), COALESCE(i2.state_name, ''), COALESCE(i2.url, '')
284                 FROM issue_relations r
285                 JOIN issues i ON r.related_issue_id = i.id
286                 JOIN issues i2 ON r.issue_id = i2.id
287                 WHERE r.related_issue_id = i.id AND i.id = ?1",
288            )?;
289            let inverse = stmt2
290                .query_map(rusqlite::params![issue_id], |row| {
291                    let raw_type: String = row.get(1)?;
292                    let flipped = match raw_type.as_str() {
293                        "blocks" => "blocked_by".to_string(),
294                        "blocked_by" => "blocks".to_string(),
295                        other => other.to_string(), // related, duplicate are symmetric
296                    };
297                    Ok(EnrichedRelation {
298                        relation_id: row.get(0)?,
299                        relation_type: flipped,
300                        issue_identifier: row.get(2)?,
301                        issue_title: row.get(3)?,
302                        issue_state: row.get(4)?,
303                        issue_url: row.get(5)?,
304                    })
305                })?
306                .collect::<std::result::Result<Vec<_>, _>>()?;
307
308            let mut all = forward;
309            all.extend(inverse);
310            Ok(all)
311        })
312    }
313
314    /// Look up a relation ID between two issues (by identifier) for deletion
315    pub fn find_relation_id(
316        &self,
317        issue_id: &str,
318        related_issue_id: &str,
319        relation_type: &str,
320    ) -> Result<Option<String>> {
321        self.with_conn(|conn| {
322            let mut stmt = conn.prepare(
323                "SELECT id FROM issue_relations WHERE issue_id = ?1 AND related_issue_id = ?2 AND relation_type = ?3"
324            )?;
325            let mut rows = stmt.query(rusqlite::params![issue_id, related_issue_id, relation_type])?;
326            if let Some(row) = rows.next()? {
327                Ok(Some(row.get(0)?))
328            } else {
329                Ok(None)
330            }
331        })
332    }
333
334    // --- Chunks (embeddings) ---
335
336    pub fn upsert_chunks(&self, issue_id: &str, chunks: &[(usize, String, Vec<u8>)]) -> Result<()> {
337        self.with_conn(|conn| {
338            conn.execute(
339                "DELETE FROM chunks WHERE issue_id = ?1",
340                rusqlite::params![issue_id],
341            )?;
342            let mut stmt = conn.prepare(
343                "INSERT INTO chunks (issue_id, chunk_index, chunk_text, embedding) VALUES (?1, ?2, ?3, ?4)"
344            )?;
345            for (idx, text, embedding) in chunks {
346                stmt.execute(rusqlite::params![issue_id, idx, text, embedding])?;
347            }
348            Ok(())
349        })
350    }
351
352    pub fn get_all_chunks(&self) -> Result<Vec<Chunk>> {
353        self.with_conn(|conn| {
354            let mut stmt = conn.prepare(
355                "SELECT c.issue_id, c.embedding, i.identifier
356                 FROM chunks c JOIN issues i ON c.issue_id = i.id",
357            )?;
358            let rows = stmt.query_map([], |row| {
359                Ok(Chunk {
360                    issue_id: row.get(0)?,
361                    embedding: row.get(1)?,
362                    identifier: row.get(2)?,
363                })
364            })?;
365            Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
366        })
367    }
368
369    pub fn get_chunks_for_team(&self, team_key: &str) -> Result<Vec<Chunk>> {
370        self.with_conn(|conn| {
371            let mut stmt = conn.prepare(
372                "SELECT c.issue_id, c.embedding, i.identifier
373                 FROM chunks c JOIN issues i ON c.issue_id = i.id
374                 WHERE i.team_key = ?1",
375            )?;
376            let rows = stmt.query_map(rusqlite::params![team_key], |row| {
377                Ok(Chunk {
378                    issue_id: row.get(0)?,
379                    embedding: row.get(1)?,
380                    identifier: row.get(2)?,
381                })
382            })?;
383            Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
384        })
385    }
386
387    pub fn count_embedded_issues(&self, team_key: Option<&str>) -> Result<usize> {
388        self.with_conn(|conn| {
389            let count: usize = if let Some(team) = team_key {
390                conn.query_row(
391                    "SELECT COUNT(DISTINCT c.issue_id) FROM chunks c JOIN issues i ON c.issue_id = i.id WHERE i.team_key = ?1",
392                    rusqlite::params![team],
393                    |row| row.get(0),
394                )?
395            } else {
396                conn.query_row("SELECT COUNT(DISTINCT issue_id) FROM chunks", [], |row| row.get(0))?
397            };
398            Ok(count)
399        })
400    }
401
402    pub fn get_issues_needing_embedding(
403        &self,
404        team_key: Option<&str>,
405        force: bool,
406    ) -> Result<Vec<Issue>> {
407        self.with_conn(|conn| {
408            let sql = if force {
409                if let Some(team) = team_key {
410                    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
412                         FROM issues WHERE team_key = '{}'", team
413                    )
414                } else {
415                    "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
416                     FROM issues".to_string()
417                }
418            } else {
419                let team_filter = if let Some(team) = team_key {
420                    format!("AND i.team_key = '{}'", team)
421                } else {
422                    String::new()
423                };
424                format!(
425                    "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
426                     FROM issues i
427                     LEFT JOIN (SELECT DISTINCT issue_id FROM chunks) c ON i.id = c.issue_id
428                     WHERE c.issue_id IS NULL {}",
429                    team_filter
430                )
431            };
432            let mut stmt = conn.prepare(&sql)?;
433            let rows = stmt.query_map([], |row| {
434                Ok(Issue::from_row(row).unwrap())
435            })?;
436            Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
437        })
438    }
439
440    // --- Comments ---
441
442    pub fn get_comments(&self, issue_id: &str) -> Result<Vec<Comment>> {
443        self.with_conn(|conn| {
444            let mut stmt = conn.prepare(
445                "SELECT id, issue_id, body, user_name, created_at FROM comments WHERE issue_id = ?1 ORDER BY created_at"
446            )?;
447            let rows = stmt.query_map(rusqlite::params![issue_id], |row| {
448                Ok(Comment {
449                    id: row.get(0)?,
450                    issue_id: row.get(1)?,
451                    body: row.get(2)?,
452                    user_name: row.get(3)?,
453                    created_at: row.get(4)?,
454                })
455            })?;
456            Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
457        })
458    }
459
460    // --- Sync state ---
461
462    pub fn get_sync_cursor(&self, team_key: &str) -> Result<Option<String>> {
463        self.with_conn(|conn| {
464            let mut stmt =
465                conn.prepare("SELECT last_updated_at FROM sync_state WHERE team_key = ?1")?;
466            let mut rows = stmt.query(rusqlite::params![team_key])?;
467            if let Some(row) = rows.next()? {
468                Ok(Some(row.get(0)?))
469            } else {
470                Ok(None)
471            }
472        })
473    }
474
475    pub fn set_sync_cursor(&self, team_key: &str, last_updated_at: &str) -> Result<()> {
476        self.with_conn(|conn| {
477            conn.execute(
478                "INSERT INTO sync_state (team_key, last_updated_at, full_sync_done)
479                 VALUES (?1, ?2, 1)
480                 ON CONFLICT(team_key) DO UPDATE SET last_updated_at=excluded.last_updated_at, full_sync_done=1",
481                rusqlite::params![team_key, last_updated_at],
482            )?;
483            Ok(())
484        })
485    }
486
487    pub fn is_full_sync_done(&self, team_key: &str) -> Result<bool> {
488        self.with_conn(|conn| {
489            let mut stmt =
490                conn.prepare("SELECT full_sync_done FROM sync_state WHERE team_key = ?1")?;
491            let mut rows = stmt.query(rusqlite::params![team_key])?;
492            if let Some(row) = rows.next()? {
493                let done: bool = row.get(0)?;
494                Ok(done)
495            } else {
496                Ok(false)
497            }
498        })
499    }
500
501    // --- Metadata ---
502
503    pub fn get_metadata(&self, key: &str) -> Result<Option<String>> {
504        self.with_conn(|conn| {
505            let mut stmt = conn.prepare("SELECT value FROM metadata WHERE key = ?1")?;
506            let mut rows = stmt.query(rusqlite::params![key])?;
507            if let Some(row) = rows.next()? {
508                Ok(Some(row.get(0)?))
509            } else {
510                Ok(None)
511            }
512        })
513    }
514
515    pub fn set_metadata(&self, key: &str, value: &str) -> Result<()> {
516        self.with_conn(|conn| {
517            conn.execute(
518                "INSERT INTO metadata (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
519                rusqlite::params![key, value],
520            )?;
521            Ok(())
522        })
523    }
524
525    // --- FTS search ---
526
527    pub fn fts_search(&self, query: &str, limit: usize) -> Result<Vec<FtsResult>> {
528        self.with_conn(|conn| {
529            let mut stmt = conn.prepare(
530                "SELECT i.id, i.identifier, i.title, i.state_name, i.priority, bm25(issues_fts) as rank
531                 FROM issues_fts f
532                 JOIN issues i ON f.rowid = i.rowid
533                 WHERE issues_fts MATCH ?1
534                 ORDER BY rank
535                 LIMIT ?2"
536            )?;
537            let rows = stmt.query_map(rusqlite::params![query, limit], |row| {
538                Ok(FtsResult {
539                    issue_id: row.get(0)?,
540                    identifier: row.get(1)?,
541                    title: row.get(2)?,
542                    state_name: row.get(3)?,
543                    priority: row.get(4)?,
544                    bm25_score: row.get(5)?,
545                })
546            })?;
547            Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
548        })
549    }
550}
551
552// --- Data types ---
553
554#[derive(Debug, Clone, Serialize, Deserialize)]
555pub struct Issue {
556    pub id: String,
557    pub identifier: String,
558    pub team_key: String,
559    pub title: String,
560    pub description: Option<String>,
561    pub state_name: String,
562    pub state_type: String,
563    pub priority: i32,
564    pub assignee_name: Option<String>,
565    pub project_name: Option<String>,
566    pub labels_json: String,
567    pub created_at: String,
568    pub updated_at: String,
569    pub content_hash: String,
570    pub synced_at: Option<String>,
571    pub url: String,
572}
573
574impl Issue {
575    pub fn from_row(row: &rusqlite::Row) -> rusqlite::Result<Self> {
576        Ok(Self {
577            id: row.get(0)?,
578            identifier: row.get(1)?,
579            team_key: row.get(2)?,
580            title: row.get(3)?,
581            description: row.get(4)?,
582            state_name: row.get(5)?,
583            state_type: row.get(6)?,
584            priority: row.get(7)?,
585            assignee_name: row.get(8)?,
586            project_name: row.get(9)?,
587            labels_json: row.get(10)?,
588            created_at: row.get(11)?,
589            updated_at: row.get(12)?,
590            content_hash: row.get(13)?,
591            synced_at: row.get(14)?,
592            url: row.get(15)?,
593        })
594    }
595
596    pub fn labels(&self) -> Vec<String> {
597        serde_json::from_str(&self.labels_json).unwrap_or_default()
598    }
599
600    pub fn priority_label(&self) -> &str {
601        match self.priority {
602            0 => "No priority",
603            1 => "Urgent",
604            2 => "High",
605            3 => "Medium",
606            4 => "Low",
607            _ => "Unknown",
608        }
609    }
610}
611
612#[derive(Debug, Clone, Serialize, Deserialize)]
613pub struct Relation {
614    pub id: String,
615    pub issue_id: String,
616    pub related_issue_id: String,
617    pub related_issue_identifier: String,
618    pub relation_type: String,
619}
620
621#[derive(Debug, Clone, Serialize, Deserialize)]
622pub struct EnrichedRelation {
623    pub relation_id: String,
624    pub relation_type: String,
625    pub issue_identifier: String,
626    pub issue_title: String,
627    pub issue_state: String,
628    pub issue_url: String,
629}
630
631#[derive(Debug, Clone)]
632pub struct Chunk {
633    pub issue_id: String,
634    pub embedding: Vec<u8>,
635    pub identifier: String,
636}
637
638#[derive(Debug, Clone, Serialize, Deserialize)]
639pub struct Comment {
640    pub id: String,
641    pub issue_id: String,
642    pub body: String,
643    pub user_name: Option<String>,
644    pub created_at: String,
645}
646
647#[derive(Debug, Clone)]
648pub struct FtsResult {
649    pub issue_id: String,
650    pub identifier: String,
651    pub title: String,
652    pub state_name: String,
653    pub priority: i32,
654    pub bm25_score: f64,
655}