Skip to main content

chainlink/db/
issues.rs

1use anyhow::Result;
2use chrono::Utc;
3use rusqlite::params;
4
5use super::{issue_from_row, validate_priority, validate_status, Database};
6use super::{MAX_DESCRIPTION_LEN, MAX_TITLE_LEN};
7use crate::models::Issue;
8
9impl Database {
10    pub fn create_issue(
11        &self,
12        title: &str,
13        description: Option<&str>,
14        priority: &str,
15    ) -> Result<i64> {
16        self.create_issue_with_parent(title, description, priority, None)
17    }
18
19    pub fn create_subissue(
20        &self,
21        parent_id: i64,
22        title: &str,
23        description: Option<&str>,
24        priority: &str,
25    ) -> Result<i64> {
26        self.create_issue_with_parent(title, description, priority, Some(parent_id))
27    }
28
29    fn create_issue_with_parent(
30        &self,
31        title: &str,
32        description: Option<&str>,
33        priority: &str,
34        parent_id: Option<i64>,
35    ) -> Result<i64> {
36        validate_priority(priority)?;
37        if title.len() > MAX_TITLE_LEN {
38            anyhow::bail!(
39                "Title exceeds maximum length of {} characters",
40                MAX_TITLE_LEN
41            );
42        }
43        if let Some(d) = description {
44            if d.len() > MAX_DESCRIPTION_LEN {
45                anyhow::bail!(
46                    "Description exceeds maximum length of {} bytes",
47                    MAX_DESCRIPTION_LEN
48                );
49            }
50        }
51        let now = Utc::now().to_rfc3339();
52        self.conn.execute(
53            "INSERT INTO issues (title, description, priority, parent_id, status, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, 'open', ?5, ?5)",
54            params![title, description, priority, parent_id, now],
55        )?;
56        Ok(self.conn.last_insert_rowid())
57    }
58
59    pub fn get_subissues(&self, parent_id: i64) -> Result<Vec<Issue>> {
60        let mut stmt = self.conn.prepare(
61            "SELECT id, title, description, status, priority, parent_id, created_at, updated_at, closed_at FROM issues WHERE parent_id = ?1 ORDER BY id",
62        )?;
63
64        let issues = stmt
65            .query_map([parent_id], issue_from_row)?
66            .collect::<std::result::Result<Vec<_>, _>>()?;
67
68        Ok(issues)
69    }
70
71    pub fn get_issue(&self, id: i64) -> Result<Option<Issue>> {
72        let mut stmt = self.conn.prepare(
73            "SELECT id, title, description, status, priority, parent_id, created_at, updated_at, closed_at FROM issues WHERE id = ?1",
74        )?;
75
76        let issue = stmt.query_row([id], issue_from_row).ok();
77
78        Ok(issue)
79    }
80
81    /// Get an issue by ID, returning an error if not found.
82    pub fn require_issue(&self, id: i64) -> Result<Issue> {
83        self.get_issue(id)?
84            .ok_or_else(|| anyhow::anyhow!("Issue {} not found", crate::utils::format_issue_id(id)))
85    }
86
87    pub fn list_issues(
88        &self,
89        status_filter: Option<&str>,
90        label_filter: Option<&str>,
91        priority_filter: Option<&str>,
92    ) -> Result<Vec<Issue>> {
93        let mut sql = String::from(
94            "SELECT DISTINCT i.id, i.title, i.description, i.status, i.priority, i.parent_id, i.created_at, i.updated_at, i.closed_at FROM issues i",
95        );
96        let mut conditions = Vec::new();
97        let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
98
99        if label_filter.is_some() {
100            sql.push_str(" JOIN labels l ON i.id = l.issue_id");
101        }
102
103        if let Some(status) = status_filter {
104            if status != "all" {
105                validate_status(status)?;
106                conditions.push("i.status = ?".to_string());
107                params_vec.push(Box::new(status.to_string()));
108            }
109        }
110
111        if let Some(label) = label_filter {
112            conditions.push("l.label = ?".to_string());
113            params_vec.push(Box::new(label.to_string()));
114        }
115
116        if let Some(priority) = priority_filter {
117            conditions.push("i.priority = ?".to_string());
118            params_vec.push(Box::new(priority.to_string()));
119        }
120
121        if !conditions.is_empty() {
122            sql.push_str(" WHERE ");
123            sql.push_str(&conditions.join(" AND "));
124        }
125
126        sql.push_str(" ORDER BY i.id DESC");
127
128        let mut stmt = self.conn.prepare(&sql)?;
129        let params_refs: Vec<&dyn rusqlite::ToSql> =
130            params_vec.iter().map(|p| p.as_ref()).collect();
131
132        let issues = stmt
133            .query_map(params_refs.as_slice(), issue_from_row)?
134            .collect::<std::result::Result<Vec<_>, _>>()?;
135
136        Ok(issues)
137    }
138
139    pub fn update_issue(
140        &self,
141        id: i64,
142        title: Option<&str>,
143        description: Option<&str>,
144        priority: Option<&str>,
145    ) -> Result<bool> {
146        if let Some(t) = title {
147            if t.len() > MAX_TITLE_LEN {
148                anyhow::bail!(
149                    "Title exceeds maximum length of {} characters",
150                    MAX_TITLE_LEN
151                );
152            }
153        }
154        if let Some(d) = description {
155            if d.len() > MAX_DESCRIPTION_LEN {
156                anyhow::bail!(
157                    "Description exceeds maximum length of {} bytes",
158                    MAX_DESCRIPTION_LEN
159                );
160            }
161        }
162        if let Some(p) = priority {
163            validate_priority(p)?;
164        }
165
166        let now = Utc::now().to_rfc3339();
167        let mut updates = vec!["updated_at = ?1".to_string()];
168        let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(now)];
169
170        if let Some(t) = title {
171            updates.push(format!("title = ?{}", params_vec.len() + 1));
172            params_vec.push(Box::new(t.to_string()));
173        }
174
175        if let Some(d) = description {
176            updates.push(format!("description = ?{}", params_vec.len() + 1));
177            params_vec.push(Box::new(d.to_string()));
178        }
179
180        if let Some(p) = priority {
181            updates.push(format!("priority = ?{}", params_vec.len() + 1));
182            params_vec.push(Box::new(p.to_string()));
183        }
184
185        params_vec.push(Box::new(id));
186        let sql = format!(
187            "UPDATE issues SET {} WHERE id = ?{}",
188            updates.join(", "),
189            params_vec.len()
190        );
191
192        let params_refs: Vec<&dyn rusqlite::ToSql> =
193            params_vec.iter().map(|p| p.as_ref()).collect();
194        let rows = self.conn.execute(&sql, params_refs.as_slice())?;
195        Ok(rows > 0)
196    }
197
198    pub fn close_issue(&self, id: i64) -> Result<bool> {
199        let now = Utc::now().to_rfc3339();
200        let rows = self.conn.execute(
201            "UPDATE issues SET status = 'closed', closed_at = ?1, updated_at = ?1 WHERE id = ?2",
202            params![now, id],
203        )?;
204        Ok(rows > 0)
205    }
206
207    pub fn reopen_issue(&self, id: i64) -> Result<bool> {
208        let now = Utc::now().to_rfc3339();
209        let rows = self.conn.execute(
210            "UPDATE issues SET status = 'open', closed_at = NULL, updated_at = ?1 WHERE id = ?2",
211            params![now, id],
212        )?;
213        Ok(rows > 0)
214    }
215
216    pub fn delete_issue(&self, id: i64) -> Result<bool> {
217        let rows = self
218            .conn
219            .execute("DELETE FROM issues WHERE id = ?1", [id])?;
220        Ok(rows > 0)
221    }
222
223    pub fn update_parent(&self, id: i64, parent_id: Option<i64>) -> Result<bool> {
224        let now = chrono::Utc::now().to_rfc3339();
225        let rows = self.conn.execute(
226            "UPDATE issues SET parent_id = ?1, updated_at = ?2 WHERE id = ?3",
227            params![parent_id, now, id],
228        )?;
229        Ok(rows > 0)
230    }
231
232    /// Search issues by query string across titles, descriptions, and comments
233    pub fn search_issues(&self, query: &str) -> Result<Vec<Issue>> {
234        let escaped = query.replace('%', "\\%").replace('_', "\\_");
235        let pattern = format!("%{}%", escaped);
236        let mut stmt = self.conn.prepare(
237            r#"
238            SELECT DISTINCT i.id, i.title, i.description, i.status, i.priority, i.parent_id, i.created_at, i.updated_at, i.closed_at
239            FROM issues i
240            LEFT JOIN comments c ON i.id = c.issue_id
241            WHERE i.title LIKE ?1 ESCAPE '\' COLLATE NOCASE
242               OR i.description LIKE ?1 ESCAPE '\' COLLATE NOCASE
243               OR c.content LIKE ?1 ESCAPE '\' COLLATE NOCASE
244            ORDER BY i.id DESC
245            "#,
246        )?;
247
248        let issues = stmt
249            .query_map([&pattern], issue_from_row)?
250            .collect::<std::result::Result<Vec<_>, _>>()?;
251
252        Ok(issues)
253    }
254}