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}