Skip to main content

rectilinear_core/db/
projects.rs

1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3
4use super::{Database, Issue};
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7pub struct ProjectTeam {
8    pub id: String,
9    pub key: String,
10    pub name: String,
11}
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14pub struct ProjectMember {
15    pub id: String,
16    pub name: String,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
20pub struct ProjectLabel {
21    pub id: String,
22    pub name: String,
23    pub color: String,
24    pub description: Option<String>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
28pub struct Project {
29    pub id: String,
30    pub workspace_id: String,
31    pub slug_id: String,
32    pub name: String,
33    pub description: String,
34    pub content: Option<String>,
35    pub icon: Option<String>,
36    pub color: String,
37    pub status_id: String,
38    pub status_name: String,
39    pub status_type: String,
40    pub status_color: String,
41    pub priority: i32,
42    pub start_date: Option<String>,
43    pub target_date: Option<String>,
44    pub lead_id: Option<String>,
45    pub lead_name: Option<String>,
46    pub created_at: String,
47    pub updated_at: String,
48    pub archived_at: Option<String>,
49    pub url: String,
50    pub progress: f64,
51    pub synced_at: Option<String>,
52    pub teams: Vec<ProjectTeam>,
53    pub members: Vec<ProjectMember>,
54    pub labels: Vec<ProjectLabel>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
58pub struct ProjectMilestone {
59    pub id: String,
60    pub workspace_id: String,
61    pub project_id: String,
62    pub project_name: String,
63    pub name: String,
64    pub description: Option<String>,
65    pub target_date: Option<String>,
66    pub status: String,
67    pub progress: f64,
68    pub sort_order: f64,
69    pub created_at: String,
70    pub updated_at: String,
71    pub archived_at: Option<String>,
72    pub synced_at: Option<String>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ProjectBundle {
77    pub project: Project,
78    pub milestones: Vec<ProjectMilestone>,
79    pub issues: Vec<Issue>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct ProjectMilestoneBundle {
84    pub project: Project,
85    pub milestone: ProjectMilestone,
86    pub issues: Vec<Issue>,
87}
88
89const PROJECT_COLUMNS: &str =
90    "id, workspace_id, slug_id, name, description, content, icon, color, \
91     status_id, status_name, status_type, status_color, priority, start_date, \
92     target_date, lead_id, lead_name, created_at, updated_at, archived_at, url, \
93     progress, synced_at";
94
95const ISSUE_COLUMNS: &str =
96    "id, identifier, team_key, title, description, state_name, state_type, \
97     priority, assignee_name, project_name, labels_json, created_at, updated_at, \
98     content_hash, synced_at, url, branch_name, workspace_id, project_id, \
99     project_milestone_id, project_milestone_name, cycle_id, cycle_name";
100
101impl Database {
102    pub fn upsert_project_metadata(&self, project: &Project) -> Result<()> {
103        self.with_conn(|conn| {
104            conn.execute(
105                "INSERT OR IGNORE INTO workspaces (id) VALUES (?1)",
106                rusqlite::params![project.workspace_id],
107            )?;
108            conn.execute(
109                "INSERT INTO projects (
110                    id, workspace_id, slug_id, name, description, content, icon, color,
111                    status_id, status_name, status_type, status_color, priority,
112                    start_date, target_date, lead_id, lead_name, created_at, updated_at,
113                    archived_at, url, progress, synced_at
114                 ) VALUES (
115                    ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13,
116                    ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, datetime('now')
117                 ) ON CONFLICT(id) DO UPDATE SET
118                    workspace_id=excluded.workspace_id, slug_id=excluded.slug_id,
119                    name=excluded.name, description=excluded.description,
120                    content=excluded.content, icon=excluded.icon, color=excluded.color,
121                    status_id=excluded.status_id, status_name=excluded.status_name,
122                    status_type=excluded.status_type, status_color=excluded.status_color,
123                    priority=excluded.priority, start_date=excluded.start_date,
124                    target_date=excluded.target_date, lead_id=excluded.lead_id,
125                    lead_name=excluded.lead_name, created_at=excluded.created_at,
126                    updated_at=excluded.updated_at, archived_at=excluded.archived_at,
127                    url=excluded.url, progress=excluded.progress, synced_at=datetime('now')",
128                rusqlite::params![
129                    project.id,
130                    project.workspace_id,
131                    project.slug_id,
132                    project.name,
133                    project.description,
134                    project.content,
135                    project.icon,
136                    project.color,
137                    project.status_id,
138                    project.status_name,
139                    project.status_type,
140                    project.status_color,
141                    project.priority,
142                    project.start_date,
143                    project.target_date,
144                    project.lead_id,
145                    project.lead_name,
146                    project.created_at,
147                    project.updated_at,
148                    project.archived_at,
149                    project.url,
150                    project.progress,
151                ],
152            )?;
153            Ok(())
154        })
155    }
156
157    pub fn upsert_project(&self, project: &Project) -> Result<()> {
158        self.upsert_project_metadata(project)?;
159        self.with_conn(|conn| {
160            let tx = conn.unchecked_transaction()?;
161
162            tx.execute(
163                "DELETE FROM project_teams WHERE project_id = ?1",
164                rusqlite::params![project.id],
165            )?;
166            for team in &project.teams {
167                tx.execute(
168                    "INSERT INTO project_teams (project_id, team_id, team_key, team_name)
169                     VALUES (?1, ?2, ?3, ?4)",
170                    rusqlite::params![project.id, team.id, team.key, team.name],
171                )?;
172            }
173
174            tx.execute(
175                "DELETE FROM project_members WHERE project_id = ?1",
176                rusqlite::params![project.id],
177            )?;
178            for member in &project.members {
179                tx.execute(
180                    "INSERT INTO project_members (project_id, user_id, user_name)
181                     VALUES (?1, ?2, ?3)",
182                    rusqlite::params![project.id, member.id, member.name],
183                )?;
184            }
185
186            tx.execute(
187                "DELETE FROM project_labels WHERE project_id = ?1",
188                rusqlite::params![project.id],
189            )?;
190            for label in &project.labels {
191                tx.execute(
192                    "INSERT INTO project_labels (
193                        project_id, label_id, label_name, color, description
194                     ) VALUES (?1, ?2, ?3, ?4, ?5)",
195                    rusqlite::params![
196                        project.id,
197                        label.id,
198                        label.name,
199                        label.color,
200                        label.description,
201                    ],
202                )?;
203            }
204            tx.commit()?;
205            Ok(())
206        })
207    }
208
209    pub fn upsert_project_milestone(&self, milestone: &ProjectMilestone) -> Result<()> {
210        self.with_conn(|conn| {
211            conn.execute(
212                "INSERT INTO project_milestones (
213                    id, workspace_id, project_id, name, description, target_date,
214                    status, progress, sort_order, created_at, updated_at, archived_at, synced_at
215                 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, datetime('now'))
216                 ON CONFLICT(id) DO UPDATE SET
217                    workspace_id=excluded.workspace_id, project_id=excluded.project_id,
218                    name=excluded.name, description=excluded.description,
219                    target_date=excluded.target_date, status=excluded.status,
220                    progress=excluded.progress, sort_order=excluded.sort_order,
221                    created_at=excluded.created_at, updated_at=excluded.updated_at,
222                    archived_at=excluded.archived_at, synced_at=datetime('now')",
223                rusqlite::params![
224                    milestone.id,
225                    milestone.workspace_id,
226                    milestone.project_id,
227                    milestone.name,
228                    milestone.description,
229                    milestone.target_date,
230                    milestone.status,
231                    milestone.progress,
232                    milestone.sort_order,
233                    milestone.created_at,
234                    milestone.updated_at,
235                    milestone.archived_at,
236                ],
237            )?;
238            Ok(())
239        })
240    }
241
242    pub fn get_project(&self, workspace_id: &str, id_or_name: &str) -> Result<Option<Project>> {
243        self.with_conn(|conn| {
244            let sql = format!(
245                "SELECT {PROJECT_COLUMNS} FROM projects
246                 WHERE workspace_id = ?1 AND (id = ?2 OR slug_id = ?2 OR name = ?2 COLLATE NOCASE)
247                 ORDER BY CASE WHEN id = ?2 OR slug_id = ?2 THEN 0 ELSE 1 END, updated_at DESC
248                 LIMIT 1"
249            );
250            let mut stmt = conn.prepare(&sql)?;
251            let mut rows = stmt.query(rusqlite::params![workspace_id, id_or_name])?;
252            Ok(rows
253                .next()?
254                .map(|row| project_from_row(conn, row))
255                .transpose()?)
256        })
257    }
258
259    pub fn list_projects(
260        &self,
261        workspace_id: &str,
262        include_archived: bool,
263    ) -> Result<Vec<Project>> {
264        self.with_conn(|conn| {
265            let archive_filter = if include_archived {
266                ""
267            } else {
268                "AND archived_at IS NULL"
269            };
270            let sql = format!(
271                "SELECT {PROJECT_COLUMNS} FROM projects
272                 WHERE workspace_id = ?1 {archive_filter}
273                 ORDER BY updated_at DESC, name COLLATE NOCASE"
274            );
275            let mut stmt = conn.prepare(&sql)?;
276            let rows = stmt.query_map(rusqlite::params![workspace_id], |row| {
277                project_from_row(conn, row)
278            })?;
279            Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
280        })
281    }
282
283    pub fn get_project_milestone(
284        &self,
285        workspace_id: &str,
286        id_or_name: &str,
287        project_id: Option<&str>,
288    ) -> Result<Option<ProjectMilestone>> {
289        self.with_conn(|conn| {
290            let project_filter = if project_id.is_some() {
291                "AND pm.project_id = ?3"
292            } else {
293                ""
294            };
295            let sql = format!(
296                "SELECT pm.id, pm.workspace_id, pm.project_id, p.name, pm.name,
297                        pm.description, pm.target_date, pm.status, pm.progress,
298                        pm.sort_order, pm.created_at, pm.updated_at, pm.archived_at, pm.synced_at
299                 FROM project_milestones pm
300                 JOIN projects p ON p.id = pm.project_id
301                 WHERE pm.workspace_id = ?1
302                   AND (pm.id = ?2 OR pm.name = ?2 COLLATE NOCASE)
303                   {project_filter}
304                 ORDER BY CASE WHEN pm.id = ?2 THEN 0 ELSE 1 END, pm.updated_at DESC
305                 LIMIT 1"
306            );
307            let mut stmt = conn.prepare(&sql)?;
308            let mut rows = if let Some(pid) = project_id {
309                stmt.query(rusqlite::params![workspace_id, id_or_name, pid])?
310            } else {
311                stmt.query(rusqlite::params![workspace_id, id_or_name])?
312            };
313            Ok(rows.next()?.map(milestone_from_row).transpose()?)
314        })
315    }
316
317    pub fn list_project_milestones(&self, project_id: &str) -> Result<Vec<ProjectMilestone>> {
318        self.with_conn(|conn| {
319            let mut stmt = conn.prepare(
320                "SELECT pm.id, pm.workspace_id, pm.project_id, p.name, pm.name,
321                        pm.description, pm.target_date, pm.status, pm.progress,
322                        pm.sort_order, pm.created_at, pm.updated_at, pm.archived_at, pm.synced_at
323                 FROM project_milestones pm
324                 JOIN projects p ON p.id = pm.project_id
325                 WHERE pm.project_id = ?1
326                 ORDER BY pm.sort_order, pm.target_date, pm.created_at",
327            )?;
328            let rows = stmt.query_map(rusqlite::params![project_id], milestone_from_row)?;
329            Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
330        })
331    }
332
333    pub fn get_project_bundle(
334        &self,
335        workspace_id: &str,
336        id_or_name: &str,
337    ) -> Result<Option<ProjectBundle>> {
338        let Some(project) = self.get_project(workspace_id, id_or_name)? else {
339            return Ok(None);
340        };
341        let milestones = self.list_project_milestones(&project.id)?;
342        let issues = self.list_project_issues(workspace_id, &project.id)?;
343        Ok(Some(ProjectBundle {
344            project,
345            milestones,
346            issues,
347        }))
348    }
349
350    pub fn get_project_milestone_bundle(
351        &self,
352        workspace_id: &str,
353        id_or_name: &str,
354        project_id: Option<&str>,
355    ) -> Result<Option<ProjectMilestoneBundle>> {
356        let Some(milestone) = self.get_project_milestone(workspace_id, id_or_name, project_id)?
357        else {
358            return Ok(None);
359        };
360        let Some(project) = self.get_project(workspace_id, &milestone.project_id)? else {
361            return Ok(None);
362        };
363        let issues = self.list_project_milestone_issues(workspace_id, &milestone.id)?;
364        Ok(Some(ProjectMilestoneBundle {
365            project,
366            milestone,
367            issues,
368        }))
369    }
370
371    pub fn list_project_issues(&self, workspace_id: &str, project_id: &str) -> Result<Vec<Issue>> {
372        self.list_issues_for_hierarchy(workspace_id, "project_id", project_id)
373    }
374
375    pub fn list_project_milestone_issues(
376        &self,
377        workspace_id: &str,
378        milestone_id: &str,
379    ) -> Result<Vec<Issue>> {
380        self.list_issues_for_hierarchy(workspace_id, "project_milestone_id", milestone_id)
381    }
382
383    pub fn reconcile_project_issue_membership(
384        &self,
385        workspace_id: &str,
386        project_id: &str,
387        keep_issue_ids: &[String],
388    ) -> Result<usize> {
389        self.clear_issue_membership_not_in(
390            workspace_id,
391            "project_id",
392            project_id,
393            "project_id = NULL, project_name = NULL, project_milestone_id = NULL, \
394             project_milestone_name = NULL",
395            keep_issue_ids,
396        )
397    }
398
399    pub fn reconcile_project_milestone_issue_membership(
400        &self,
401        workspace_id: &str,
402        milestone_id: &str,
403        keep_issue_ids: &[String],
404    ) -> Result<usize> {
405        self.clear_issue_membership_not_in(
406            workspace_id,
407            "project_milestone_id",
408            milestone_id,
409            "project_milestone_id = NULL, project_milestone_name = NULL",
410            keep_issue_ids,
411        )
412    }
413
414    fn list_issues_for_hierarchy(
415        &self,
416        workspace_id: &str,
417        column: &str,
418        value: &str,
419    ) -> Result<Vec<Issue>> {
420        debug_assert!(matches!(column, "project_id" | "project_milestone_id"));
421        self.with_conn(|conn| {
422            let sql = format!(
423                "SELECT {ISSUE_COLUMNS} FROM issues
424                 WHERE workspace_id = ?1 AND {column} = ?2
425                 ORDER BY team_key, identifier"
426            );
427            let mut stmt = conn.prepare(&sql)?;
428            let rows = stmt.query_map(rusqlite::params![workspace_id, value], Issue::from_row)?;
429            Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
430        })
431    }
432
433    fn clear_issue_membership_not_in(
434        &self,
435        workspace_id: &str,
436        hierarchy_column: &str,
437        hierarchy_id: &str,
438        assignments: &str,
439        keep_issue_ids: &[String],
440    ) -> Result<usize> {
441        debug_assert!(matches!(
442            hierarchy_column,
443            "project_id" | "project_milestone_id"
444        ));
445        self.with_conn(|conn| {
446            let exclusion = if keep_issue_ids.is_empty() {
447                String::new()
448            } else {
449                let placeholders = (0..keep_issue_ids.len())
450                    .map(|index| format!("?{}", index + 3))
451                    .collect::<Vec<_>>()
452                    .join(", ");
453                format!("AND id NOT IN ({placeholders})")
454            };
455            let sql = format!(
456                "UPDATE issues SET {assignments}, synced_at = datetime('now')
457                 WHERE workspace_id = ?1 AND {hierarchy_column} = ?2 {exclusion}"
458            );
459            let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![
460                Box::new(workspace_id.to_string()),
461                Box::new(hierarchy_id.to_string()),
462            ];
463            params.extend(
464                keep_issue_ids
465                    .iter()
466                    .cloned()
467                    .map(|id| Box::new(id) as Box<dyn rusqlite::types::ToSql>),
468            );
469            let refs = params
470                .iter()
471                .map(|value| value.as_ref())
472                .collect::<Vec<_>>();
473            Ok(conn.execute(&sql, refs.as_slice())?)
474        })
475    }
476
477    pub fn delete_project_local(&self, project_id: &str) -> Result<usize> {
478        self.with_conn(|conn| {
479            let tx = conn.unchecked_transaction()?;
480            tx.execute(
481                "UPDATE issues SET project_id = NULL, project_name = NULL,
482                        project_milestone_id = NULL, project_milestone_name = NULL,
483                        synced_at = datetime('now')
484                 WHERE project_id = ?1",
485                rusqlite::params![project_id],
486            )?;
487            let changed = tx.execute(
488                "DELETE FROM projects WHERE id = ?1",
489                rusqlite::params![project_id],
490            )?;
491            tx.commit()?;
492            Ok(changed)
493        })
494    }
495
496    pub fn delete_project_milestone_local(&self, milestone_id: &str) -> Result<usize> {
497        self.with_conn(|conn| {
498            let tx = conn.unchecked_transaction()?;
499            tx.execute(
500                "UPDATE issues SET project_milestone_id = NULL,
501                        project_milestone_name = NULL, synced_at = datetime('now')
502                 WHERE project_milestone_id = ?1",
503                rusqlite::params![milestone_id],
504            )?;
505            let changed = tx.execute(
506                "DELETE FROM project_milestones WHERE id = ?1",
507                rusqlite::params![milestone_id],
508            )?;
509            tx.commit()?;
510            Ok(changed)
511        })
512    }
513
514    pub fn delete_projects_for_workspace_not_in(
515        &self,
516        workspace_id: &str,
517        keep_ids: &[String],
518    ) -> Result<usize> {
519        self.delete_hierarchy_not_in("projects", "workspace_id", workspace_id, keep_ids)
520    }
521
522    pub fn delete_milestones_for_workspace_not_in(
523        &self,
524        workspace_id: &str,
525        keep_ids: &[String],
526    ) -> Result<usize> {
527        self.delete_hierarchy_not_in("project_milestones", "workspace_id", workspace_id, keep_ids)
528    }
529
530    pub fn delete_milestones_for_project_not_in(
531        &self,
532        project_id: &str,
533        keep_ids: &[String],
534    ) -> Result<usize> {
535        self.delete_hierarchy_not_in("project_milestones", "project_id", project_id, keep_ids)
536    }
537
538    fn delete_hierarchy_not_in(
539        &self,
540        table: &str,
541        scope_column: &str,
542        scope: &str,
543        keep_ids: &[String],
544    ) -> Result<usize> {
545        debug_assert!(matches!(table, "projects" | "project_milestones"));
546        debug_assert!(matches!(scope_column, "workspace_id" | "project_id"));
547        self.with_conn(|conn| {
548            let exclusion = if keep_ids.is_empty() {
549                String::new()
550            } else {
551                let placeholders = (0..keep_ids.len())
552                    .map(|index| format!("?{}", index + 2))
553                    .collect::<Vec<_>>()
554                    .join(", ");
555                format!("AND id NOT IN ({placeholders})")
556            };
557            let stale_filter = format!("{scope_column} = ?1 {exclusion}");
558            let mut params: Vec<Box<dyn rusqlite::types::ToSql>> =
559                vec![Box::new(scope.to_string())];
560            params.extend(
561                keep_ids
562                    .iter()
563                    .cloned()
564                    .map(|id| Box::new(id) as Box<dyn rusqlite::types::ToSql>),
565            );
566            let refs = params
567                .iter()
568                .map(|value| value.as_ref())
569                .collect::<Vec<_>>();
570            let issue_update = if table == "projects" {
571                format!(
572                    "UPDATE issues SET project_id = NULL, project_name = NULL,
573                            project_milestone_id = NULL, project_milestone_name = NULL,
574                            synced_at = datetime('now')
575                     WHERE project_id IN (SELECT id FROM projects WHERE {stale_filter})"
576                )
577            } else {
578                format!(
579                    "UPDATE issues SET project_milestone_id = NULL,
580                            project_milestone_name = NULL, synced_at = datetime('now')
581                     WHERE project_milestone_id IN (
582                         SELECT id FROM project_milestones WHERE {stale_filter}
583                     )"
584                )
585            };
586            conn.execute(&issue_update, refs.as_slice())?;
587            let sql = format!("DELETE FROM {table} WHERE {stale_filter}");
588            Ok(conn.execute(&sql, refs.as_slice())?)
589        })
590    }
591}
592
593fn project_from_row(conn: &rusqlite::Connection, row: &rusqlite::Row) -> rusqlite::Result<Project> {
594    let project_id: String = row.get(0)?;
595    let teams = {
596        let mut stmt = conn.prepare(
597            "SELECT team_id, team_key, team_name FROM project_teams
598             WHERE project_id = ?1 ORDER BY team_key",
599        )?;
600        let rows = stmt.query_map(rusqlite::params![project_id], |team_row| {
601            Ok(ProjectTeam {
602                id: team_row.get(0)?,
603                key: team_row.get(1)?,
604                name: team_row.get(2)?,
605            })
606        })?;
607        rows.collect::<std::result::Result<Vec<_>, _>>()?
608    };
609    let members = {
610        let mut stmt = conn.prepare(
611            "SELECT user_id, user_name FROM project_members
612             WHERE project_id = ?1 ORDER BY user_name COLLATE NOCASE",
613        )?;
614        let rows = stmt.query_map(rusqlite::params![project_id], |member_row| {
615            Ok(ProjectMember {
616                id: member_row.get(0)?,
617                name: member_row.get(1)?,
618            })
619        })?;
620        rows.collect::<std::result::Result<Vec<_>, _>>()?
621    };
622    let labels = {
623        let mut stmt = conn.prepare(
624            "SELECT label_id, label_name, color, description FROM project_labels
625             WHERE project_id = ?1 ORDER BY label_name COLLATE NOCASE",
626        )?;
627        let rows = stmt.query_map(rusqlite::params![project_id], |label_row| {
628            Ok(ProjectLabel {
629                id: label_row.get(0)?,
630                name: label_row.get(1)?,
631                color: label_row.get(2)?,
632                description: label_row.get(3)?,
633            })
634        })?;
635        rows.collect::<std::result::Result<Vec<_>, _>>()?
636    };
637    Ok(Project {
638        id: project_id,
639        workspace_id: row.get(1)?,
640        slug_id: row.get(2)?,
641        name: row.get(3)?,
642        description: row.get(4)?,
643        content: row.get(5)?,
644        icon: row.get(6)?,
645        color: row.get(7)?,
646        status_id: row.get(8)?,
647        status_name: row.get(9)?,
648        status_type: row.get(10)?,
649        status_color: row.get(11)?,
650        priority: row.get(12)?,
651        start_date: row.get(13)?,
652        target_date: row.get(14)?,
653        lead_id: row.get(15)?,
654        lead_name: row.get(16)?,
655        created_at: row.get(17)?,
656        updated_at: row.get(18)?,
657        archived_at: row.get(19)?,
658        url: row.get(20)?,
659        progress: row.get(21)?,
660        synced_at: row.get(22)?,
661        teams,
662        members,
663        labels,
664    })
665}
666
667fn milestone_from_row(row: &rusqlite::Row) -> rusqlite::Result<ProjectMilestone> {
668    Ok(ProjectMilestone {
669        id: row.get(0)?,
670        workspace_id: row.get(1)?,
671        project_id: row.get(2)?,
672        project_name: row.get(3)?,
673        name: row.get(4)?,
674        description: row.get(5)?,
675        target_date: row.get(6)?,
676        status: row.get(7)?,
677        progress: row.get(8)?,
678        sort_order: row.get(9)?,
679        created_at: row.get(10)?,
680        updated_at: row.get(11)?,
681        archived_at: row.get(12)?,
682        synced_at: row.get(13)?,
683    })
684}
685
686#[cfg(test)]
687mod tests {
688    use super::*;
689    use crate::db::test_helpers::{make_issue, test_db};
690
691    fn project() -> Project {
692        Project {
693            id: "project-1".into(),
694            workspace_id: "default".into(),
695            slug_id: "api-reliability".into(),
696            name: "API Reliability".into(),
697            description: "Service resilience".into(),
698            content: Some("Detailed rollout plan".into()),
699            icon: Some("Cube".into()),
700            color: "#f2994a".into(),
701            status_id: "status-1".into(),
702            status_name: "Backlog".into(),
703            status_type: "backlog".into(),
704            status_color: "#888888".into(),
705            priority: 2,
706            start_date: None,
707            target_date: Some("2026-09-01".into()),
708            lead_id: Some("user-1".into()),
709            lead_name: Some("Alex Morgan".into()),
710            created_at: "2026-07-01T00:00:00Z".into(),
711            updated_at: "2026-07-16T00:00:00Z".into(),
712            archived_at: None,
713            url: "https://linear.app/acme/project/api-reliability".into(),
714            progress: 0.25,
715            synced_at: None,
716            teams: vec![ProjectTeam {
717                id: "team-1".into(),
718                key: "ENG".into(),
719                name: "Engineering".into(),
720            }],
721            members: vec![ProjectMember {
722                id: "user-1".into(),
723                name: "Alex Morgan".into(),
724            }],
725            labels: vec![ProjectLabel {
726                id: "label-1".into(),
727                name: "Infrastructure".into(),
728                color: "#f2994a".into(),
729                description: Some("Platform engineering".into()),
730            }],
731        }
732    }
733
734    fn milestone() -> ProjectMilestone {
735        ProjectMilestone {
736            id: "milestone-1".into(),
737            workspace_id: "default".into(),
738            project_id: "project-1".into(),
739            project_name: "API Reliability".into(),
740            name: "Request tracing".into(),
741            description: Some("Instrument critical request paths".into()),
742            target_date: Some("2026-08-15".into()),
743            status: "next".into(),
744            progress: 0.5,
745            sort_order: 1.0,
746            created_at: "2026-07-01T00:00:00Z".into(),
747            updated_at: "2026-07-16T00:00:00Z".into(),
748            archived_at: None,
749            synced_at: None,
750        }
751    }
752
753    #[test]
754    fn project_round_trip_preserves_metadata_and_relationships() {
755        let (db, _dir) = test_db();
756        db.upsert_project(&project()).unwrap();
757        db.upsert_project_milestone(&milestone()).unwrap();
758
759        let stored = db
760            .get_project("default", "api-reliability")
761            .unwrap()
762            .unwrap();
763        assert_eq!(stored.name, "API Reliability");
764        assert_eq!(stored.status_name, "Backlog");
765        assert_eq!(stored.teams[0].key, "ENG");
766        assert_eq!(stored.members[0].name, "Alex Morgan");
767        assert_eq!(stored.labels[0].name, "Infrastructure");
768
769        let milestones = db.list_project_milestones(&stored.id).unwrap();
770        assert_eq!(milestones.len(), 1);
771        assert_eq!(milestones[0].name, "Request tracing");
772        assert!(milestones[0].synced_at.is_some());
773    }
774
775    #[test]
776    fn project_and_milestone_bundles_include_linked_issues() {
777        let (db, _dir) = test_db();
778        db.upsert_project(&project()).unwrap();
779        db.upsert_project_milestone(&milestone()).unwrap();
780        let mut issue = make_issue("ENG-1", "ENG");
781        issue.priority = 0;
782        issue.project_id = Some("project-1".into());
783        issue.project_milestone_id = Some("milestone-1".into());
784        issue.project_milestone_name = Some("Request tracing".into());
785        db.upsert_issue(&issue).unwrap();
786
787        let bundle = db
788            .get_project_bundle("default", "API Reliability")
789            .unwrap()
790            .unwrap();
791        assert_eq!(bundle.milestones.len(), 1);
792        assert_eq!(bundle.issues[0].identifier, "ENG-1");
793
794        let milestone_bundle = db
795            .get_project_milestone_bundle("default", "Request tracing", Some("project-1"))
796            .unwrap()
797            .unwrap();
798        assert_eq!(
799            milestone_bundle.issues[0].project_milestone_name.as_deref(),
800            Some("Request tracing")
801        );
802
803        let triage_issues = db.get_unprioritized_issues(None, false, "default").unwrap();
804        assert_eq!(triage_issues[0].project_id.as_deref(), Some("project-1"));
805        assert_eq!(
806            triage_issues[0].project_milestone_id.as_deref(),
807            Some("milestone-1")
808        );
809    }
810
811    #[test]
812    fn deleting_project_cascades_milestones_but_not_issues() {
813        let (db, _dir) = test_db();
814        db.upsert_project(&project()).unwrap();
815        db.upsert_project_milestone(&milestone()).unwrap();
816        let mut issue = make_issue("ENG-1", "ENG");
817        issue.project_id = Some("project-1".into());
818        issue.project_milestone_id = Some("milestone-1".into());
819        db.upsert_issue(&issue).unwrap();
820
821        assert_eq!(db.delete_project_local("project-1").unwrap(), 1);
822        assert!(db.list_project_milestones("project-1").unwrap().is_empty());
823        let issue = db.get_issue("ENG-1").unwrap().unwrap();
824        assert!(issue.project_id.is_none());
825        assert!(issue.project_milestone_id.is_none());
826    }
827
828    #[test]
829    fn deleting_milestone_clears_issue_milestone_but_keeps_project() {
830        let (db, _dir) = test_db();
831        db.upsert_project(&project()).unwrap();
832        db.upsert_project_milestone(&milestone()).unwrap();
833        let mut issue = make_issue("ENG-1", "ENG");
834        issue.project_id = Some("project-1".into());
835        issue.project_milestone_id = Some("milestone-1".into());
836        db.upsert_issue(&issue).unwrap();
837
838        assert_eq!(db.delete_project_milestone_local("milestone-1").unwrap(), 1);
839        let issue = db.get_issue("ENG-1").unwrap().unwrap();
840        assert_eq!(issue.project_id.as_deref(), Some("project-1"));
841        assert!(issue.project_milestone_id.is_none());
842    }
843
844    #[test]
845    fn hierarchy_reconciliation_clears_stale_issue_relationships() {
846        let (db, _dir) = test_db();
847        db.upsert_project(&project()).unwrap();
848        db.upsert_project_milestone(&milestone()).unwrap();
849        for identifier in ["ENG-1", "ENG-2"] {
850            let mut issue = make_issue(identifier, "ENG");
851            issue.project_id = Some("project-1".into());
852            issue.project_name = Some("API Reliability".into());
853            issue.project_milestone_id = Some("milestone-1".into());
854            issue.project_milestone_name = Some("Request tracing".into());
855            db.upsert_issue(&issue).unwrap();
856        }
857
858        db.reconcile_project_milestone_issue_membership(
859            "default",
860            "milestone-1",
861            &["ENG-1".into()],
862        )
863        .unwrap();
864        let stale = db.get_issue("ENG-2").unwrap().unwrap();
865        assert!(stale.project_id.is_some());
866        assert!(stale.project_milestone_id.is_none());
867
868        db.reconcile_project_issue_membership("default", "project-1", &["ENG-1".into()])
869            .unwrap();
870        let stale = db.get_issue("ENG-2").unwrap().unwrap();
871        assert!(stale.project_id.is_none());
872        assert!(stale.project_milestone_name.is_none());
873    }
874}