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