1use anyhow::{Context, Result};
2use serde::Deserialize;
3
4use crate::db::{self, Database};
5
6use super::{IssueConnection, LinearClient, PageInfo};
7
8const PROJECT_FIELDS: &str = r#"
9 id slugId name description content icon color priority
10 startDate targetDate createdAt updatedAt archivedAt url progress
11 status { id name type color }
12 lead { id name }
13 teams(first: 50) { nodes { id key name } }
14 members(first: 250) { nodes { id name } }
15 labels(first: 250) { nodes { id name color description } }
16"#;
17
18const MILESTONE_FIELDS: &str = r#"
19 id name description targetDate status progress sortOrder
20 createdAt updatedAt archivedAt
21 project { id name }
22"#;
23
24const ISSUE_FIELDS: &str = r#"
25 id identifier url title description priority branchName
26 createdAt updatedAt
27 state { name type }
28 team { key }
29 assignee { name }
30 project { id name }
31 projectMilestone { id name }
32 labels { nodes { id name } }
33 relations { nodes { id type relatedIssue { id identifier } } }
34"#;
35
36#[derive(Debug, Clone, Default)]
37pub struct CreateProjectInput {
38 pub name: String,
39 pub team_ids: Vec<String>,
40 pub description: Option<String>,
41 pub content: Option<String>,
42 pub icon: Option<String>,
43 pub color: Option<String>,
44 pub status_id: Option<String>,
45 pub priority: Option<i32>,
46 pub lead_id: Option<String>,
47 pub start_date: Option<String>,
48 pub target_date: Option<String>,
49 pub member_ids: Option<Vec<String>>,
50 pub label_ids: Option<Vec<String>>,
51}
52
53#[derive(Debug, Clone, Default)]
54pub struct UpdateProjectInput {
55 pub name: Option<String>,
56 pub team_ids: Option<Vec<String>>,
57 pub description: Option<String>,
58 pub content: Option<String>,
59 pub icon: Option<String>,
60 pub color: Option<String>,
61 pub status_id: Option<String>,
62 pub priority: Option<i32>,
63 pub lead_id: Option<String>,
64 pub start_date: Option<String>,
65 pub target_date: Option<String>,
66 pub member_ids: Option<Vec<String>>,
67 pub label_ids: Option<Vec<String>>,
68}
69
70#[derive(Debug, Clone, Default)]
71pub struct CreateProjectMilestoneInput {
72 pub project_id: String,
73 pub name: String,
74 pub description: Option<String>,
75 pub target_date: Option<String>,
76 pub sort_order: Option<f64>,
77}
78
79#[derive(Debug, Clone, Default)]
80pub struct UpdateProjectMilestoneInput {
81 pub project_id: Option<String>,
82 pub name: Option<String>,
83 pub description: Option<String>,
84 pub target_date: Option<String>,
85 pub sort_order: Option<f64>,
86}
87
88#[derive(Debug, Deserialize)]
89struct ProjectConnectionData {
90 projects: ProjectConnection,
91}
92
93#[derive(Debug, Deserialize)]
94struct ProjectConnection {
95 nodes: Vec<LinearProjectNode>,
96 #[serde(rename = "pageInfo")]
97 page_info: PageInfo,
98}
99
100#[derive(Debug, Deserialize)]
101struct SingleProjectData {
102 project: LinearProjectNode,
103}
104
105#[derive(Debug, Deserialize)]
106struct LinearProjectNode {
107 id: String,
108 #[serde(rename = "slugId")]
109 slug_id: String,
110 name: String,
111 description: String,
112 content: Option<String>,
113 icon: Option<String>,
114 color: String,
115 status: LinearProjectStatus,
116 lead: Option<LinearProjectUser>,
117 teams: LinearProjectTeamConnection,
118 members: LinearProjectUserConnection,
119 labels: LinearProjectLabelConnection,
120 priority: i32,
121 #[serde(rename = "startDate")]
122 start_date: Option<String>,
123 #[serde(rename = "targetDate")]
124 target_date: Option<String>,
125 #[serde(rename = "createdAt")]
126 created_at: String,
127 #[serde(rename = "updatedAt")]
128 updated_at: String,
129 #[serde(rename = "archivedAt")]
130 archived_at: Option<String>,
131 url: String,
132 progress: f64,
133}
134
135#[derive(Debug, Deserialize)]
136struct LinearProjectStatus {
137 id: String,
138 name: String,
139 #[serde(rename = "type")]
140 status_type: String,
141 color: String,
142}
143
144#[derive(Debug, Deserialize)]
145struct LinearProjectUser {
146 id: String,
147 name: String,
148}
149
150#[derive(Debug, Deserialize)]
151struct LinearProjectUserConnection {
152 nodes: Vec<LinearProjectUser>,
153}
154
155#[derive(Debug, Deserialize)]
156struct LinearProjectTeam {
157 id: String,
158 key: String,
159 name: String,
160}
161
162#[derive(Debug, Deserialize)]
163struct LinearProjectTeamConnection {
164 nodes: Vec<LinearProjectTeam>,
165}
166
167#[derive(Debug, Deserialize)]
168struct LinearProjectLabel {
169 id: String,
170 name: String,
171 color: String,
172 description: Option<String>,
173}
174
175#[derive(Debug, Deserialize)]
176struct LinearProjectLabelConnection {
177 nodes: Vec<LinearProjectLabel>,
178}
179
180#[derive(Debug, Deserialize)]
181struct MilestoneConnectionData {
182 #[serde(rename = "projectMilestones")]
183 project_milestones: MilestoneConnection,
184}
185
186#[derive(Debug, Deserialize)]
187struct MilestoneConnection {
188 nodes: Vec<LinearProjectMilestoneNode>,
189 #[serde(rename = "pageInfo")]
190 page_info: PageInfo,
191}
192
193#[derive(Debug, Deserialize)]
194struct SingleMilestoneData {
195 #[serde(rename = "projectMilestone")]
196 project_milestone: LinearProjectMilestoneNode,
197}
198
199#[derive(Debug, Deserialize)]
200struct ProjectMilestonesData {
201 project: ProjectMilestones,
202}
203
204#[derive(Debug, Deserialize)]
205struct ProjectMilestones {
206 #[serde(rename = "projectMilestones")]
207 project_milestones: MilestoneConnection,
208}
209
210#[derive(Debug, Deserialize)]
211struct LinearProjectMilestoneNode {
212 id: String,
213 name: String,
214 description: Option<String>,
215 #[serde(rename = "targetDate")]
216 target_date: Option<String>,
217 status: String,
218 progress: f64,
219 #[serde(rename = "sortOrder")]
220 sort_order: f64,
221 #[serde(rename = "createdAt")]
222 created_at: String,
223 #[serde(rename = "updatedAt")]
224 updated_at: String,
225 #[serde(rename = "archivedAt")]
226 archived_at: Option<String>,
227 project: LinearProjectRef,
228}
229
230#[derive(Debug, Deserialize)]
231struct LinearProjectRef {
232 id: String,
233 name: String,
234}
235
236#[derive(Debug, Deserialize)]
237struct ProjectIssuesData {
238 project: ProjectIssues,
239}
240
241#[derive(Debug, Deserialize)]
242struct ProjectIssues {
243 issues: IssueConnection,
244}
245
246#[derive(Debug, Deserialize)]
247struct MilestoneIssuesData {
248 #[serde(rename = "projectMilestone")]
249 project_milestone: MilestoneIssues,
250}
251
252#[derive(Debug, Deserialize)]
253struct MilestoneIssues {
254 issues: IssueConnection,
255}
256
257#[derive(Debug, Deserialize)]
258struct MutationProjectData {
259 #[serde(rename = "projectCreate", alias = "projectUpdate")]
260 payload: ProjectPayload,
261}
262
263#[derive(Debug, Deserialize)]
264struct ProjectPayload {
265 success: bool,
266 project: Option<MutationResource>,
267}
268
269#[derive(Debug, Deserialize)]
270struct MutationMilestoneData {
271 #[serde(rename = "projectMilestoneCreate", alias = "projectMilestoneUpdate")]
272 payload: MilestonePayload,
273}
274
275#[derive(Debug, Deserialize)]
276struct MilestonePayload {
277 success: bool,
278 #[serde(rename = "projectMilestone")]
279 project_milestone: Option<MutationResource>,
280}
281
282#[derive(Debug, Deserialize)]
283struct MutationResource {
284 id: String,
285}
286
287#[derive(Debug, Deserialize)]
288struct DeleteProjectData {
289 #[serde(rename = "projectDelete")]
290 project_delete: SuccessPayload,
291}
292
293#[derive(Debug, Deserialize)]
294struct DeleteMilestoneData {
295 #[serde(rename = "projectMilestoneDelete")]
296 project_milestone_delete: SuccessPayload,
297}
298
299#[derive(Debug, Deserialize)]
300struct SuccessPayload {
301 success: bool,
302}
303
304impl LinearClient {
305 pub async fn fetch_projects(
306 &self,
307 after_cursor: Option<&str>,
308 include_archived: bool,
309 workspace_id: &str,
310 ) -> Result<(Vec<db::Project>, bool, Option<String>)> {
311 let query = r#"
312 query($after: String, $includeArchived: Boolean!) {
313 projects(
314 first: 100,
315 after: $after,
316 includeArchived: $includeArchived,
317 orderBy: updatedAt
318 ) {
319 nodes { __PROJECT_FIELDS__ }
320 pageInfo { hasNextPage endCursor }
321 }
322 }
323 "#
324 .replace("__PROJECT_FIELDS__", PROJECT_FIELDS);
325 let data: ProjectConnectionData = self
326 .query(
327 &query,
328 serde_json::json!({
329 "after": after_cursor,
330 "includeArchived": include_archived,
331 }),
332 )
333 .await?;
334 Ok((
335 data.projects
336 .nodes
337 .into_iter()
338 .map(|project| convert_project(project, workspace_id))
339 .collect(),
340 data.projects.page_info.has_next_page,
341 data.projects.page_info.end_cursor,
342 ))
343 }
344
345 pub async fn fetch_project(&self, id: &str, workspace_id: &str) -> Result<db::Project> {
346 let query = r#"
347 query($id: String!) {
348 project(id: $id) { __PROJECT_FIELDS__ }
349 }
350 "#
351 .replace("__PROJECT_FIELDS__", PROJECT_FIELDS);
352 let data: SingleProjectData = self.query(&query, serde_json::json!({ "id": id })).await?;
353 Ok(convert_project(data.project, workspace_id))
354 }
355
356 pub async fn fetch_project_milestones(
357 &self,
358 after_cursor: Option<&str>,
359 include_archived: bool,
360 workspace_id: &str,
361 ) -> Result<(Vec<db::ProjectMilestone>, bool, Option<String>)> {
362 let query = r#"
363 query($after: String, $includeArchived: Boolean!) {
364 projectMilestones(
365 first: 250,
366 after: $after,
367 includeArchived: $includeArchived,
368 orderBy: updatedAt
369 ) {
370 nodes { __MILESTONE_FIELDS__ }
371 pageInfo { hasNextPage endCursor }
372 }
373 }
374 "#
375 .replace("__MILESTONE_FIELDS__", MILESTONE_FIELDS);
376 let data: MilestoneConnectionData = self
377 .query(
378 &query,
379 serde_json::json!({
380 "after": after_cursor,
381 "includeArchived": include_archived,
382 }),
383 )
384 .await?;
385 Ok((
386 data.project_milestones
387 .nodes
388 .into_iter()
389 .map(|milestone| convert_milestone(milestone, workspace_id))
390 .collect(),
391 data.project_milestones.page_info.has_next_page,
392 data.project_milestones.page_info.end_cursor,
393 ))
394 }
395
396 pub async fn fetch_project_milestone(
397 &self,
398 id: &str,
399 workspace_id: &str,
400 ) -> Result<db::ProjectMilestone> {
401 let query = r#"
402 query($id: String!) {
403 projectMilestone(id: $id) { __MILESTONE_FIELDS__ }
404 }
405 "#
406 .replace("__MILESTONE_FIELDS__", MILESTONE_FIELDS);
407 let data: SingleMilestoneData = self.query(&query, serde_json::json!({ "id": id })).await?;
408 Ok(convert_milestone(data.project_milestone, workspace_id))
409 }
410
411 pub async fn fetch_milestones_for_project(
412 &self,
413 project_id: &str,
414 after_cursor: Option<&str>,
415 include_archived: bool,
416 workspace_id: &str,
417 ) -> Result<(Vec<db::ProjectMilestone>, bool, Option<String>)> {
418 let query = r#"
419 query($id: String!, $after: String, $includeArchived: Boolean!) {
420 project(id: $id) {
421 projectMilestones(
422 first: 250,
423 after: $after,
424 includeArchived: $includeArchived,
425 orderBy: updatedAt
426 ) {
427 nodes { __MILESTONE_FIELDS__ }
428 pageInfo { hasNextPage endCursor }
429 }
430 }
431 }
432 "#
433 .replace("__MILESTONE_FIELDS__", MILESTONE_FIELDS);
434 let data: ProjectMilestonesData = self
435 .query(
436 &query,
437 serde_json::json!({
438 "id": project_id,
439 "after": after_cursor,
440 "includeArchived": include_archived,
441 }),
442 )
443 .await?;
444 let milestones = data.project.project_milestones;
445 Ok((
446 milestones
447 .nodes
448 .into_iter()
449 .map(|milestone| convert_milestone(milestone, workspace_id))
450 .collect(),
451 milestones.page_info.has_next_page,
452 milestones.page_info.end_cursor,
453 ))
454 }
455
456 pub async fn sync_projects(&self, db: &Database, workspace_id: &str) -> Result<(usize, usize)> {
457 let mut project_cursor = None;
458 let mut project_ids = Vec::new();
459 loop {
460 let (projects, has_next, next_cursor) = self
461 .fetch_projects(project_cursor.as_deref(), true, workspace_id)
462 .await?;
463 for project in projects {
464 project_ids.push(project.id.clone());
465 db.upsert_project(&project)?;
466 }
467 if !has_next {
468 break;
469 }
470 project_cursor = next_cursor;
471 }
472 db.delete_projects_for_workspace_not_in(workspace_id, &project_ids)?;
473
474 let mut milestone_cursor = None;
475 let mut milestone_ids = Vec::new();
476 loop {
477 let (milestones, has_next, next_cursor) = self
478 .fetch_project_milestones(milestone_cursor.as_deref(), true, workspace_id)
479 .await?;
480 for milestone in milestones {
481 milestone_ids.push(milestone.id.clone());
482 db.upsert_project_milestone(&milestone)?;
483 }
484 if !has_next {
485 break;
486 }
487 milestone_cursor = next_cursor;
488 }
489 db.delete_milestones_for_workspace_not_in(workspace_id, &milestone_ids)?;
490 Ok((project_ids.len(), milestone_ids.len()))
491 }
492
493 pub async fn create_project(&self, input: &CreateProjectInput) -> Result<String> {
494 if input.name.trim().is_empty() {
495 anyhow::bail!("Project name cannot be empty");
496 }
497 if input.team_ids.is_empty() {
498 anyhow::bail!("At least one team is required to create a project");
499 }
500 let graphql_input = project_create_value(input);
501 let query = r#"
502 mutation($input: ProjectCreateInput!) {
503 projectCreate(input: $input) {
504 success
505 project { id }
506 }
507 }
508 "#;
509 let data: MutationProjectData = self
510 .query(query, serde_json::json!({ "input": graphql_input }))
511 .await?;
512 if !data.payload.success {
513 anyhow::bail!("Failed to create project");
514 }
515 data.payload
516 .project
517 .map(|project| project.id)
518 .context("Linear did not return the created project")
519 }
520
521 pub async fn update_project(&self, id: &str, input: &UpdateProjectInput) -> Result<()> {
522 let graphql_input = project_update_value(input);
523 if graphql_input.is_empty() {
524 anyhow::bail!("No project fields were provided to update");
525 }
526 let query = r#"
527 mutation($id: String!, $input: ProjectUpdateInput!) {
528 projectUpdate(id: $id, input: $input) {
529 success
530 project { id }
531 }
532 }
533 "#;
534 let data: MutationProjectData = self
535 .query(
536 query,
537 serde_json::json!({ "id": id, "input": graphql_input }),
538 )
539 .await?;
540 if !data.payload.success {
541 anyhow::bail!("Failed to update project");
542 }
543 Ok(())
544 }
545
546 pub async fn delete_project(&self, id: &str) -> Result<()> {
547 let query = r#"
548 mutation($id: String!) {
549 projectDelete(id: $id) { success }
550 }
551 "#;
552 let data: DeleteProjectData = self.query(query, serde_json::json!({ "id": id })).await?;
553 if !data.project_delete.success {
554 anyhow::bail!("Failed to delete project");
555 }
556 Ok(())
557 }
558
559 pub async fn create_project_milestone(
560 &self,
561 input: &CreateProjectMilestoneInput,
562 ) -> Result<String> {
563 if input.name.trim().is_empty() {
564 anyhow::bail!("Milestone name cannot be empty");
565 }
566 let graphql_input = milestone_create_value(input);
567 let query = r#"
568 mutation($input: ProjectMilestoneCreateInput!) {
569 projectMilestoneCreate(input: $input) {
570 success
571 projectMilestone { id }
572 }
573 }
574 "#;
575 let data: MutationMilestoneData = self
576 .query(query, serde_json::json!({ "input": graphql_input }))
577 .await?;
578 if !data.payload.success {
579 anyhow::bail!("Failed to create project milestone");
580 }
581 data.payload
582 .project_milestone
583 .map(|milestone| milestone.id)
584 .context("Linear did not return the created milestone")
585 }
586
587 pub async fn update_project_milestone(
588 &self,
589 id: &str,
590 input: &UpdateProjectMilestoneInput,
591 ) -> Result<()> {
592 let graphql_input = milestone_update_value(input);
593 if graphql_input.is_empty() {
594 anyhow::bail!("No milestone fields were provided to update");
595 }
596 let query = r#"
597 mutation($id: String!, $input: ProjectMilestoneUpdateInput!) {
598 projectMilestoneUpdate(id: $id, input: $input) {
599 success
600 projectMilestone { id }
601 }
602 }
603 "#;
604 let data: MutationMilestoneData = self
605 .query(
606 query,
607 serde_json::json!({ "id": id, "input": graphql_input }),
608 )
609 .await?;
610 if !data.payload.success {
611 anyhow::bail!("Failed to update project milestone");
612 }
613 Ok(())
614 }
615
616 pub async fn delete_project_milestone(&self, id: &str) -> Result<()> {
617 let query = r#"
618 mutation($id: String!) {
619 projectMilestoneDelete(id: $id) { success }
620 }
621 "#;
622 let data: DeleteMilestoneData = self.query(query, serde_json::json!({ "id": id })).await?;
623 if !data.project_milestone_delete.success {
624 anyhow::bail!("Failed to delete project milestone");
625 }
626 Ok(())
627 }
628
629 pub async fn get_project_status_id(&self, status_name: &str) -> Result<String> {
630 let query = r#"
631 query {
632 projectStatuses(first: 250, includeArchived: false) {
633 nodes { id name }
634 }
635 }
636 "#;
637 let data: serde_json::Value = self.query(query, serde_json::json!({})).await?;
638 find_resource_id(
639 &data["projectStatuses"]["nodes"],
640 status_name,
641 "project status",
642 )
643 }
644
645 pub async fn get_project_label_ids(&self, names: &[String]) -> Result<Vec<String>> {
646 if names.is_empty() {
647 return Ok(Vec::new());
648 }
649 let mut cursor = None;
650 let mut labels = Vec::new();
651 loop {
652 let query = r#"
653 query($after: String) {
654 projectLabels(first: 250, after: $after, includeArchived: false) {
655 nodes { id name }
656 pageInfo { hasNextPage endCursor }
657 }
658 }
659 "#;
660 let data: serde_json::Value = self
661 .query(query, serde_json::json!({ "after": cursor }))
662 .await?;
663 labels.extend(
664 data["projectLabels"]["nodes"]
665 .as_array()
666 .context("No project labels in response")?
667 .iter()
668 .filter_map(|label| {
669 Some((
670 label["id"].as_str()?.to_string(),
671 label["name"].as_str()?.to_string(),
672 ))
673 }),
674 );
675 if !data["projectLabels"]["pageInfo"]["hasNextPage"]
676 .as_bool()
677 .unwrap_or(false)
678 {
679 break;
680 }
681 cursor = data["projectLabels"]["pageInfo"]["endCursor"]
682 .as_str()
683 .map(ToString::to_string);
684 }
685
686 names
687 .iter()
688 .map(|name| {
689 labels
690 .iter()
691 .find(|(id, candidate)| id == name || candidate.eq_ignore_ascii_case(name))
692 .map(|(id, _)| id.clone())
693 .with_context(|| {
694 format!(
695 "Project label '{}' not found. Available: {}",
696 name,
697 labels
698 .iter()
699 .map(|(_, label)| label.as_str())
700 .collect::<Vec<_>>()
701 .join(", ")
702 )
703 })
704 })
705 .collect()
706 }
707
708 pub async fn find_project_by_name(&self, id_or_name: &str) -> Result<String> {
709 let mut cursor = None;
710 let mut available = Vec::new();
711 loop {
712 let query = r#"
713 query($after: String) {
714 projects(first: 250, after: $after, includeArchived: true) {
715 nodes { id slugId name }
716 pageInfo { hasNextPage endCursor }
717 }
718 }
719 "#;
720 let data: serde_json::Value = self
721 .query(query, serde_json::json!({ "after": cursor }))
722 .await?;
723 let nodes = data["projects"]["nodes"]
724 .as_array()
725 .context("No projects in response")?;
726 for project in nodes {
727 let id = project["id"].as_str().unwrap_or_default();
728 let slug = project["slugId"].as_str().unwrap_or_default();
729 let name = project["name"].as_str().unwrap_or_default();
730 if id == id_or_name
731 || slug.eq_ignore_ascii_case(id_or_name)
732 || name.eq_ignore_ascii_case(id_or_name)
733 {
734 return Ok(id.to_string());
735 }
736 if !name.is_empty() {
737 available.push(name.to_string());
738 }
739 }
740 if !data["projects"]["pageInfo"]["hasNextPage"]
741 .as_bool()
742 .unwrap_or(false)
743 {
744 break;
745 }
746 cursor = data["projects"]["pageInfo"]["endCursor"]
747 .as_str()
748 .map(ToString::to_string);
749 }
750 anyhow::bail!(
751 "Project '{}' not found. Available: {}",
752 id_or_name,
753 available.join(", ")
754 )
755 }
756
757 pub async fn find_project_milestone(
758 &self,
759 project_id: Option<&str>,
760 id_or_name: &str,
761 ) -> Result<String> {
762 let mut cursor = None;
763 let mut available = Vec::new();
764 loop {
765 let query = r#"
766 query($after: String) {
767 projectMilestones(first: 250, after: $after, includeArchived: true) {
768 nodes { id name project { id } }
769 pageInfo { hasNextPage endCursor }
770 }
771 }
772 "#;
773 let data: serde_json::Value = self
774 .query(query, serde_json::json!({ "after": cursor }))
775 .await?;
776 let nodes = data["projectMilestones"]["nodes"]
777 .as_array()
778 .context("No project milestones in response")?;
779 for milestone in nodes {
780 let id = milestone["id"].as_str().unwrap_or_default();
781 let name = milestone["name"].as_str().unwrap_or_default();
782 let owning_project = milestone["project"]["id"].as_str().unwrap_or_default();
783 if project_id.is_some_and(|expected| expected != owning_project) {
784 continue;
785 }
786 if id == id_or_name || name.eq_ignore_ascii_case(id_or_name) {
787 return Ok(id.to_string());
788 }
789 if !name.is_empty() {
790 available.push(name.to_string());
791 }
792 }
793 if !data["projectMilestones"]["pageInfo"]["hasNextPage"]
794 .as_bool()
795 .unwrap_or(false)
796 {
797 break;
798 }
799 cursor = data["projectMilestones"]["pageInfo"]["endCursor"]
800 .as_str()
801 .map(ToString::to_string);
802 }
803 anyhow::bail!(
804 "Project milestone '{}' not found. Available: {}",
805 id_or_name,
806 available.join(", ")
807 )
808 }
809
810 pub async fn import_project(
811 &self,
812 db: &Database,
813 workspace_id: &str,
814 id_or_name: &str,
815 ) -> Result<db::ProjectBundle> {
816 let project_id = self.find_project_by_name(id_or_name).await?;
817 let project = self.fetch_project(&project_id, workspace_id).await?;
818 db.upsert_project(&project)?;
819 self.import_project_milestones(db, workspace_id, &project_id)
820 .await?;
821 self.import_hierarchy_issues(db, workspace_id, &project_id, false)
822 .await?;
823 db.get_project_bundle(workspace_id, &project_id)?
824 .context("Imported project was not found in the local database")
825 }
826
827 pub async fn import_project_milestone(
828 &self,
829 db: &Database,
830 workspace_id: &str,
831 project_id: Option<&str>,
832 id_or_name: &str,
833 ) -> Result<db::ProjectMilestoneBundle> {
834 let milestone_id = self.find_project_milestone(project_id, id_or_name).await?;
835 let milestone = self
836 .fetch_project_milestone(&milestone_id, workspace_id)
837 .await?;
838 let project = self
839 .fetch_project(&milestone.project_id, workspace_id)
840 .await?;
841 db.upsert_project(&project)?;
842 db.upsert_project_milestone(&milestone)?;
843 self.import_hierarchy_issues(db, workspace_id, &milestone_id, true)
844 .await?;
845 db.get_project_milestone_bundle(workspace_id, &milestone_id, None)?
846 .context("Imported milestone was not found in the local database")
847 }
848
849 async fn import_project_milestones(
850 &self,
851 db: &Database,
852 workspace_id: &str,
853 project_id: &str,
854 ) -> Result<()> {
855 let mut cursor = None;
856 let mut milestone_ids = Vec::new();
857 loop {
858 let (milestones, has_next, next_cursor) = self
859 .fetch_milestones_for_project(project_id, cursor.as_deref(), true, workspace_id)
860 .await?;
861 for milestone in milestones {
862 milestone_ids.push(milestone.id.clone());
863 db.upsert_project_milestone(&milestone)?;
864 }
865 if !has_next {
866 break;
867 }
868 cursor = next_cursor;
869 }
870 db.delete_milestones_for_project_not_in(project_id, &milestone_ids)?;
871 Ok(())
872 }
873
874 async fn import_hierarchy_issues(
875 &self,
876 db: &Database,
877 workspace_id: &str,
878 resource_id: &str,
879 milestone: bool,
880 ) -> Result<()> {
881 if let Err(error) = self.sync_labels_catalog(db, workspace_id).await {
882 eprintln!(
883 "warning: failed to sync label catalog for workspace '{}': {}",
884 workspace_id, error
885 );
886 }
887 let mut cursor = None;
888 let mut issue_ids = Vec::new();
889 loop {
890 let (issues, has_next, next_cursor) = self
891 .fetch_hierarchy_issues(resource_id, milestone, cursor.as_deref())
892 .await?;
893 for (mut issue, relations, label_ids) in issues {
894 issue.workspace_id = workspace_id.to_string();
895 issue_ids.push(issue.id.clone());
896 db.upsert_issue(&issue)?;
897 db.upsert_relations(&issue.id, &relations)?;
898 db.replace_issue_labels(&issue.id, &label_ids)?;
899 }
900 if !has_next {
901 break;
902 }
903 cursor = next_cursor;
904 }
905 if milestone {
906 db.reconcile_project_milestone_issue_membership(workspace_id, resource_id, &issue_ids)?;
907 } else {
908 db.reconcile_project_issue_membership(workspace_id, resource_id, &issue_ids)?;
909 }
910 Ok(())
911 }
912
913 async fn fetch_hierarchy_issues(
914 &self,
915 resource_id: &str,
916 milestone: bool,
917 after_cursor: Option<&str>,
918 ) -> Result<(
919 Vec<(db::Issue, Vec<db::Relation>, Vec<String>)>,
920 bool,
921 Option<String>,
922 )> {
923 let query = if milestone {
924 r#"
925 query($id: String!, $after: String) {
926 projectMilestone(id: $id) {
927 issues(first: 250, after: $after, includeArchived: true) {
928 nodes { __ISSUE_FIELDS__ }
929 pageInfo { hasNextPage endCursor }
930 }
931 }
932 }
933 "#
934 } else {
935 r#"
936 query($id: String!, $after: String) {
937 project(id: $id) {
938 issues(first: 250, after: $after, includeArchived: true) {
939 nodes { __ISSUE_FIELDS__ }
940 pageInfo { hasNextPage endCursor }
941 }
942 }
943 }
944 "#
945 }
946 .replace("__ISSUE_FIELDS__", ISSUE_FIELDS);
947 let variables = serde_json::json!({ "id": resource_id, "after": after_cursor });
948 let connection = if milestone {
949 let data: MilestoneIssuesData = self.query(&query, variables).await?;
950 data.project_milestone.issues
951 } else {
952 let data: ProjectIssuesData = self.query(&query, variables).await?;
953 data.project.issues
954 };
955 Ok((
956 connection
957 .nodes
958 .into_iter()
959 .map(Self::convert_linear_issue)
960 .collect(),
961 connection.page_info.has_next_page,
962 connection.page_info.end_cursor,
963 ))
964 }
965}
966
967fn convert_project(project: LinearProjectNode, workspace_id: &str) -> db::Project {
968 db::Project {
969 id: project.id,
970 workspace_id: workspace_id.to_string(),
971 slug_id: project.slug_id,
972 name: project.name,
973 description: project.description,
974 content: project.content,
975 icon: project.icon,
976 color: project.color,
977 status_id: project.status.id,
978 status_name: project.status.name,
979 status_type: project.status.status_type,
980 status_color: project.status.color,
981 priority: project.priority,
982 start_date: project.start_date,
983 target_date: project.target_date,
984 lead_id: project.lead.as_ref().map(|lead| lead.id.clone()),
985 lead_name: project.lead.map(|lead| lead.name),
986 created_at: project.created_at,
987 updated_at: project.updated_at,
988 archived_at: project.archived_at,
989 url: project.url,
990 progress: project.progress,
991 synced_at: None,
992 teams: project
993 .teams
994 .nodes
995 .into_iter()
996 .map(|team| db::ProjectTeam {
997 id: team.id,
998 key: team.key,
999 name: team.name,
1000 })
1001 .collect(),
1002 members: project
1003 .members
1004 .nodes
1005 .into_iter()
1006 .map(|member| db::ProjectMember {
1007 id: member.id,
1008 name: member.name,
1009 })
1010 .collect(),
1011 labels: project
1012 .labels
1013 .nodes
1014 .into_iter()
1015 .map(|label| db::ProjectLabel {
1016 id: label.id,
1017 name: label.name,
1018 color: label.color,
1019 description: label.description,
1020 })
1021 .collect(),
1022 }
1023}
1024
1025fn convert_milestone(
1026 milestone: LinearProjectMilestoneNode,
1027 workspace_id: &str,
1028) -> db::ProjectMilestone {
1029 db::ProjectMilestone {
1030 id: milestone.id,
1031 workspace_id: workspace_id.to_string(),
1032 project_id: milestone.project.id,
1033 project_name: milestone.project.name,
1034 name: milestone.name,
1035 description: milestone.description,
1036 target_date: milestone.target_date,
1037 status: milestone.status,
1038 progress: milestone.progress,
1039 sort_order: milestone.sort_order,
1040 created_at: milestone.created_at,
1041 updated_at: milestone.updated_at,
1042 archived_at: milestone.archived_at,
1043 synced_at: None,
1044 }
1045}
1046
1047fn project_create_value(input: &CreateProjectInput) -> serde_json::Map<String, serde_json::Value> {
1048 let mut value = serde_json::Map::new();
1049 value.insert("name".into(), serde_json::json!(input.name));
1050 value.insert("teamIds".into(), serde_json::json!(input.team_ids));
1051 insert_optional_string(
1052 &mut value,
1053 "description",
1054 input.description.as_deref(),
1055 false,
1056 );
1057 insert_optional_string(&mut value, "content", input.content.as_deref(), false);
1058 insert_optional_string(&mut value, "icon", input.icon.as_deref(), true);
1059 insert_optional_string(&mut value, "color", input.color.as_deref(), true);
1060 insert_optional_string(&mut value, "statusId", input.status_id.as_deref(), true);
1061 insert_optional_string(&mut value, "leadId", input.lead_id.as_deref(), true);
1062 insert_optional_string(&mut value, "startDate", input.start_date.as_deref(), true);
1063 insert_optional_string(&mut value, "targetDate", input.target_date.as_deref(), true);
1064 if let Some(priority) = input.priority {
1065 value.insert("priority".into(), serde_json::json!(priority));
1066 }
1067 if let Some(member_ids) = &input.member_ids {
1068 value.insert("memberIds".into(), serde_json::json!(member_ids));
1069 }
1070 if let Some(label_ids) = &input.label_ids {
1071 value.insert("labelIds".into(), serde_json::json!(label_ids));
1072 }
1073 value
1074}
1075
1076fn project_update_value(input: &UpdateProjectInput) -> serde_json::Map<String, serde_json::Value> {
1077 let mut value = serde_json::Map::new();
1078 insert_optional_string(&mut value, "name", input.name.as_deref(), false);
1079 insert_optional_string(
1080 &mut value,
1081 "description",
1082 input.description.as_deref(),
1083 false,
1084 );
1085 insert_optional_string(&mut value, "content", input.content.as_deref(), false);
1086 insert_optional_string(&mut value, "icon", input.icon.as_deref(), true);
1087 insert_optional_string(&mut value, "color", input.color.as_deref(), true);
1088 insert_optional_string(&mut value, "statusId", input.status_id.as_deref(), true);
1089 insert_optional_string(&mut value, "leadId", input.lead_id.as_deref(), true);
1090 insert_optional_string(&mut value, "startDate", input.start_date.as_deref(), true);
1091 insert_optional_string(&mut value, "targetDate", input.target_date.as_deref(), true);
1092 if let Some(team_ids) = &input.team_ids {
1093 value.insert("teamIds".into(), serde_json::json!(team_ids));
1094 }
1095 if let Some(member_ids) = &input.member_ids {
1096 value.insert("memberIds".into(), serde_json::json!(member_ids));
1097 }
1098 if let Some(label_ids) = &input.label_ids {
1099 value.insert("labelIds".into(), serde_json::json!(label_ids));
1100 }
1101 if let Some(priority) = input.priority {
1102 value.insert("priority".into(), serde_json::json!(priority));
1103 }
1104 value
1105}
1106
1107fn milestone_create_value(
1108 input: &CreateProjectMilestoneInput,
1109) -> serde_json::Map<String, serde_json::Value> {
1110 let mut value = serde_json::Map::new();
1111 value.insert("projectId".into(), serde_json::json!(input.project_id));
1112 value.insert("name".into(), serde_json::json!(input.name));
1113 insert_optional_string(
1114 &mut value,
1115 "description",
1116 input.description.as_deref(),
1117 false,
1118 );
1119 insert_optional_string(&mut value, "targetDate", input.target_date.as_deref(), true);
1120 if let Some(sort_order) = input.sort_order {
1121 value.insert("sortOrder".into(), serde_json::json!(sort_order));
1122 }
1123 value
1124}
1125
1126fn milestone_update_value(
1127 input: &UpdateProjectMilestoneInput,
1128) -> serde_json::Map<String, serde_json::Value> {
1129 let mut value = serde_json::Map::new();
1130 insert_optional_string(&mut value, "projectId", input.project_id.as_deref(), true);
1131 insert_optional_string(&mut value, "name", input.name.as_deref(), false);
1132 insert_optional_string(
1133 &mut value,
1134 "description",
1135 input.description.as_deref(),
1136 false,
1137 );
1138 insert_optional_string(&mut value, "targetDate", input.target_date.as_deref(), true);
1139 if let Some(sort_order) = input.sort_order {
1140 value.insert("sortOrder".into(), serde_json::json!(sort_order));
1141 }
1142 value
1143}
1144
1145fn insert_optional_string(
1146 value: &mut serde_json::Map<String, serde_json::Value>,
1147 key: &str,
1148 input: Option<&str>,
1149 nullable: bool,
1150) {
1151 let Some(input) = input else { return };
1152 if nullable && (input.is_empty() || input.eq_ignore_ascii_case("none")) {
1153 value.insert(key.into(), serde_json::Value::Null);
1154 } else {
1155 value.insert(key.into(), serde_json::json!(input));
1156 }
1157}
1158
1159fn find_resource_id(nodes: &serde_json::Value, name: &str, kind: &str) -> Result<String> {
1160 let nodes = nodes
1161 .as_array()
1162 .with_context(|| format!("No {kind} values in response"))?;
1163 for resource in nodes {
1164 if resource["id"].as_str() == Some(name)
1165 || resource["name"]
1166 .as_str()
1167 .is_some_and(|candidate| candidate.eq_ignore_ascii_case(name))
1168 {
1169 return resource["id"]
1170 .as_str()
1171 .map(ToString::to_string)
1172 .with_context(|| format!("{kind} has no id"));
1173 }
1174 }
1175 let available = nodes
1176 .iter()
1177 .filter_map(|resource| resource["name"].as_str())
1178 .collect::<Vec<_>>()
1179 .join(", ");
1180 anyhow::bail!("{} '{}' not found. Available: {}", kind, name, available)
1181}
1182
1183#[cfg(test)]
1184mod tests {
1185 use super::*;
1186
1187 #[test]
1188 fn project_update_serializes_clearable_metadata_as_null() {
1189 let input = UpdateProjectInput {
1190 lead_id: Some("none".into()),
1191 target_date: Some(String::new()),
1192 description: Some(String::new()),
1193 priority: Some(1),
1194 label_ids: Some(vec!["label-1".into()]),
1195 ..Default::default()
1196 };
1197 let value = project_update_value(&input);
1198 assert_eq!(value["leadId"], serde_json::Value::Null);
1199 assert_eq!(value["targetDate"], serde_json::Value::Null);
1200 assert_eq!(value["description"], serde_json::json!(""));
1201 assert_eq!(value["priority"], serde_json::json!(1));
1202 assert_eq!(value["labelIds"], serde_json::json!(["label-1"]));
1203 }
1204
1205 #[test]
1206 fn milestone_create_serializes_project_relationship() {
1207 let input = CreateProjectMilestoneInput {
1208 project_id: "project-1".into(),
1209 name: "Beta".into(),
1210 target_date: Some("2026-09-01".into()),
1211 ..Default::default()
1212 };
1213 let value = milestone_create_value(&input);
1214 assert_eq!(value["projectId"], serde_json::json!("project-1"));
1215 assert_eq!(value["name"], serde_json::json!("Beta"));
1216 assert_eq!(value["targetDate"], serde_json::json!("2026-09-01"));
1217 }
1218
1219 #[test]
1220 fn mutation_payloads_accept_create_and_update_field_names() {
1221 for field in ["projectCreate", "projectUpdate"] {
1222 let payload = serde_json::json!({
1223 (field): {
1224 "success": true,
1225 "project": { "id": "project-1" }
1226 }
1227 });
1228 let parsed: MutationProjectData = serde_json::from_value(payload).unwrap();
1229 assert!(parsed.payload.success);
1230 assert_eq!(parsed.payload.project.unwrap().id, "project-1");
1231 }
1232
1233 for field in ["projectMilestoneCreate", "projectMilestoneUpdate"] {
1234 let payload = serde_json::json!({
1235 (field): {
1236 "success": true,
1237 "projectMilestone": { "id": "milestone-1" }
1238 }
1239 });
1240 let parsed: MutationMilestoneData = serde_json::from_value(payload).unwrap();
1241 assert!(parsed.payload.success);
1242 assert_eq!(parsed.payload.project_milestone.unwrap().id, "milestone-1");
1243 }
1244 }
1245
1246 #[test]
1247 fn project_and_milestone_responses_preserve_graphql_metadata() {
1248 let project: LinearProjectNode = serde_json::from_value(serde_json::json!({
1249 "id": "project-1",
1250 "slugId": "api-reliability",
1251 "name": "API Reliability",
1252 "description": "Service resilience",
1253 "content": "Detailed rollout plan",
1254 "icon": "Cube",
1255 "color": "#f2994a",
1256 "priority": 2,
1257 "startDate": "2026-07-01",
1258 "targetDate": "2026-09-01",
1259 "createdAt": "2026-07-01T00:00:00Z",
1260 "updatedAt": "2026-07-16T00:00:00Z",
1261 "archivedAt": null,
1262 "url": "https://linear.app/acme/project/api-reliability",
1263 "progress": 0.25,
1264 "status": {
1265 "id": "status-1",
1266 "name": "Backlog",
1267 "type": "backlog",
1268 "color": "#888888"
1269 },
1270 "lead": { "id": "user-1", "name": "Alex Morgan" },
1271 "teams": { "nodes": [{
1272 "id": "team-1", "key": "ENG", "name": "Engineering"
1273 }]},
1274 "members": { "nodes": [{ "id": "user-1", "name": "Alex Morgan" }]},
1275 "labels": { "nodes": [{
1276 "id": "label-1",
1277 "name": "Infrastructure",
1278 "color": "#f2994a",
1279 "description": "Platform engineering"
1280 }]}
1281 }))
1282 .unwrap();
1283 let project = convert_project(project, "home");
1284 assert_eq!(project.status_name, "Backlog");
1285 assert_eq!(project.teams[0].key, "ENG");
1286 assert_eq!(project.lead_name.as_deref(), Some("Alex Morgan"));
1287 assert_eq!(project.labels[0].name, "Infrastructure");
1288
1289 let milestone: LinearProjectMilestoneNode = serde_json::from_value(serde_json::json!({
1290 "id": "milestone-1",
1291 "name": "Request tracing",
1292 "description": "Instrument critical request paths",
1293 "targetDate": "2026-08-15",
1294 "status": "next",
1295 "progress": 0.5,
1296 "sortOrder": 1.0,
1297 "createdAt": "2026-07-01T00:00:00Z",
1298 "updatedAt": "2026-07-16T00:00:00Z",
1299 "archivedAt": null,
1300 "project": { "id": "project-1", "name": "API Reliability" }
1301 }))
1302 .unwrap();
1303 let milestone = convert_milestone(milestone, "home");
1304 assert_eq!(milestone.project_name, "API Reliability");
1305 assert_eq!(milestone.target_date.as_deref(), Some("2026-08-15"));
1306 }
1307}