1use std::future::ready;
2use std::sync::{Arc, Mutex};
3
4use anyhow::{Context, Result};
5use serde::Deserialize;
6use uuid::Uuid;
7
8use crate::db::{self, Database};
9
10use super::pagination::{paginate, ConnectionPage, LinearOperation, PageInfo};
11use super::{IssueConnection, LinearClient};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub struct ProjectSyncResult {
15 pub projects: usize,
16 pub milestones: usize,
17}
18
19const MILESTONE_PAGE_SIZE: usize = 50;
24const RESOURCE_LOOKUP_PAGE_SIZE: usize = 50;
25
26const PROJECT_FIELDS: &str = r#"
27 id slugId name description content icon color priority
28 startDate targetDate createdAt updatedAt archivedAt url progress
29 status { id name type color }
30 lead { id name }
31"#;
32
33const MILESTONE_FIELDS: &str = r#"
34 id name description targetDate status progress sortOrder
35 createdAt updatedAt archivedAt
36 project { id name }
37"#;
38
39const ISSUE_FIELDS: &str = r#"
40 id identifier url title description priority branchName
41 createdAt updatedAt
42 state { name type }
43 team { key }
44 assignee { name }
45 project { id name }
46 projectMilestone { id name }
47 cycle { id name number }
48"#;
49
50#[derive(Debug, Clone, Default)]
51pub struct CreateProjectInput {
52 pub name: String,
53 pub team_ids: Vec<String>,
54 pub description: Option<String>,
55 pub content: Option<String>,
56 pub icon: Option<String>,
57 pub color: Option<String>,
58 pub status_id: Option<String>,
59 pub priority: Option<i32>,
60 pub lead_id: Option<String>,
61 pub start_date: Option<String>,
62 pub target_date: Option<String>,
63 pub member_ids: Option<Vec<String>>,
64 pub label_ids: Option<Vec<String>>,
65}
66
67#[derive(Debug, Clone, Default)]
68pub struct UpdateProjectInput {
69 pub name: Option<String>,
70 pub team_ids: Option<Vec<String>>,
71 pub description: Option<String>,
72 pub content: Option<String>,
73 pub icon: Option<String>,
74 pub color: Option<String>,
75 pub status_id: Option<String>,
76 pub priority: Option<i32>,
77 pub lead_id: Option<String>,
78 pub start_date: Option<String>,
79 pub target_date: Option<String>,
80 pub member_ids: Option<Vec<String>>,
81 pub label_ids: Option<Vec<String>>,
82}
83
84#[derive(Debug, Clone, Default)]
85pub struct CreateProjectMilestoneInput {
86 pub project_id: String,
87 pub name: String,
88 pub description: Option<String>,
89 pub target_date: Option<String>,
90 pub sort_order: Option<f64>,
91}
92
93#[derive(Debug, Clone, Default)]
94pub struct UpdateProjectMilestoneInput {
95 pub project_id: Option<String>,
96 pub name: Option<String>,
97 pub description: Option<String>,
98 pub target_date: Option<String>,
99 pub sort_order: Option<f64>,
100}
101
102#[derive(Debug, Deserialize)]
103struct ProjectConnectionData {
104 projects: ProjectConnection,
105}
106
107#[derive(Debug, Deserialize)]
108struct ProjectConnection {
109 nodes: Vec<LinearProjectNode>,
110 #[serde(rename = "pageInfo")]
111 page_info: PageInfo,
112}
113
114#[derive(Debug, Deserialize)]
115struct SingleProjectData {
116 project: LinearProjectNode,
117}
118
119#[derive(Debug, Deserialize)]
120struct LinearProjectNode {
121 id: String,
122 #[serde(rename = "slugId")]
123 slug_id: String,
124 name: String,
125 description: String,
126 content: Option<String>,
127 icon: Option<String>,
128 color: String,
129 status: LinearProjectStatus,
130 lead: Option<LinearProjectUser>,
131 #[serde(default)]
132 teams: LinearProjectTeamConnection,
133 #[serde(default)]
134 members: LinearProjectUserConnection,
135 #[serde(default)]
136 labels: LinearProjectLabelConnection,
137 priority: i32,
138 #[serde(rename = "startDate")]
139 start_date: Option<String>,
140 #[serde(rename = "targetDate")]
141 target_date: Option<String>,
142 #[serde(rename = "createdAt")]
143 created_at: String,
144 #[serde(rename = "updatedAt")]
145 updated_at: String,
146 #[serde(rename = "archivedAt")]
147 archived_at: Option<String>,
148 url: String,
149 progress: f64,
150}
151
152#[derive(Debug, Deserialize)]
153struct LinearProjectStatus {
154 id: String,
155 name: String,
156 #[serde(rename = "type")]
157 status_type: String,
158 color: String,
159}
160
161#[derive(Debug, Deserialize)]
162struct LinearProjectUser {
163 id: String,
164 name: String,
165}
166
167#[derive(Debug, Deserialize, Default)]
168struct LinearProjectUserConnection {
169 nodes: Vec<LinearProjectUser>,
170}
171
172#[derive(Debug, Deserialize)]
173struct LinearProjectTeam {
174 id: String,
175 key: String,
176 name: String,
177}
178
179#[derive(Debug, Deserialize, Default)]
180struct LinearProjectTeamConnection {
181 nodes: Vec<LinearProjectTeam>,
182}
183
184#[derive(Debug, Deserialize)]
185struct LinearProjectLabel {
186 id: String,
187 name: String,
188 color: String,
189 description: Option<String>,
190}
191
192#[derive(Debug, Deserialize, Default)]
193struct LinearProjectLabelConnection {
194 nodes: Vec<LinearProjectLabel>,
195}
196
197#[derive(Debug, Deserialize)]
198struct ProjectTeamsData {
199 project: ProjectTeamsNode,
200}
201
202#[derive(Debug, Deserialize)]
203struct ProjectTeamsNode {
204 teams: PaginatedProjectTeamConnection,
205}
206
207#[derive(Debug, Deserialize)]
208struct PaginatedProjectTeamConnection {
209 nodes: Vec<LinearProjectTeam>,
210 #[serde(rename = "pageInfo")]
211 page_info: PageInfo,
212}
213
214#[derive(Debug, Deserialize)]
215struct ProjectMembersData {
216 project: ProjectMembersNode,
217}
218
219#[derive(Debug, Deserialize)]
220struct ProjectMembersNode {
221 members: PaginatedProjectMemberConnection,
222}
223
224#[derive(Debug, Deserialize)]
225struct PaginatedProjectMemberConnection {
226 nodes: Vec<LinearProjectUser>,
227 #[serde(rename = "pageInfo")]
228 page_info: PageInfo,
229}
230
231#[derive(Debug, Deserialize)]
232struct ProjectLabelsData {
233 project: ProjectLabelsNode,
234}
235
236#[derive(Debug, Deserialize)]
237struct ProjectLabelsNode {
238 labels: PaginatedProjectLabelConnection,
239}
240
241#[derive(Debug, Deserialize)]
242struct PaginatedProjectLabelConnection {
243 nodes: Vec<LinearProjectLabel>,
244 #[serde(rename = "pageInfo")]
245 page_info: PageInfo,
246}
247
248#[derive(Debug, Deserialize)]
249struct MilestoneConnectionData {
250 #[serde(rename = "projectMilestones")]
251 project_milestones: MilestoneConnection,
252}
253
254#[derive(Debug, Deserialize)]
255struct MilestoneConnection {
256 nodes: Vec<LinearProjectMilestoneNode>,
257 #[serde(rename = "pageInfo")]
258 page_info: PageInfo,
259}
260
261#[derive(Debug, Deserialize)]
262struct SingleMilestoneData {
263 #[serde(rename = "projectMilestone")]
264 project_milestone: LinearProjectMilestoneNode,
265}
266
267#[derive(Debug, Deserialize)]
268struct ProjectMilestonesData {
269 project: ProjectMilestones,
270}
271
272#[derive(Debug, Deserialize)]
273struct ProjectMilestones {
274 #[serde(rename = "projectMilestones")]
275 project_milestones: MilestoneConnection,
276}
277
278#[derive(Debug, Deserialize)]
279struct LinearProjectMilestoneNode {
280 id: String,
281 name: String,
282 description: Option<String>,
283 #[serde(rename = "targetDate")]
284 target_date: Option<String>,
285 status: String,
286 progress: f64,
287 #[serde(rename = "sortOrder")]
288 sort_order: f64,
289 #[serde(rename = "createdAt")]
290 created_at: String,
291 #[serde(rename = "updatedAt")]
292 updated_at: String,
293 #[serde(rename = "archivedAt")]
294 archived_at: Option<String>,
295 project: LinearProjectRef,
296}
297
298#[derive(Debug, Deserialize)]
299struct LinearProjectRef {
300 id: String,
301 name: String,
302}
303
304#[derive(Debug, Deserialize)]
305struct ProjectIssuesData {
306 project: ProjectIssues,
307}
308
309#[derive(Debug, Deserialize)]
310struct ProjectIssues {
311 issues: IssueConnection,
312}
313
314#[derive(Debug, Deserialize)]
315struct MilestoneIssuesData {
316 #[serde(rename = "projectMilestone")]
317 project_milestone: MilestoneIssues,
318}
319
320#[derive(Debug, Deserialize)]
321struct MilestoneIssues {
322 issues: IssueConnection,
323}
324
325#[derive(Debug, Deserialize)]
326struct MutationProjectData {
327 #[serde(rename = "projectCreate", alias = "projectUpdate")]
328 payload: ProjectPayload,
329}
330
331#[derive(Debug, Deserialize)]
332struct ProjectPayload {
333 success: bool,
334 project: Option<MutationResource>,
335}
336
337#[derive(Debug, Deserialize)]
338struct MutationMilestoneData {
339 #[serde(rename = "projectMilestoneCreate", alias = "projectMilestoneUpdate")]
340 payload: MilestonePayload,
341}
342
343#[derive(Debug, Deserialize)]
344struct MilestonePayload {
345 success: bool,
346 #[serde(rename = "projectMilestone")]
347 project_milestone: Option<MutationResource>,
348}
349
350#[derive(Debug, Deserialize)]
351struct MutationResource {
352 id: String,
353}
354
355#[derive(Debug, Deserialize)]
356struct DeleteProjectData {
357 #[serde(rename = "projectDelete")]
358 project_delete: SuccessPayload,
359}
360
361#[derive(Debug, Deserialize)]
362struct DeleteMilestoneData {
363 #[serde(rename = "projectMilestoneDelete")]
364 project_milestone_delete: SuccessPayload,
365}
366
367#[derive(Debug, Deserialize)]
368struct SuccessPayload {
369 success: bool,
370}
371
372impl LinearClient {
373 pub async fn fetch_projects(
374 &self,
375 after_cursor: Option<&str>,
376 include_archived: bool,
377 workspace_id: &str,
378 ) -> Result<(Vec<db::Project>, bool, Option<String>)> {
379 let mut page = self
380 .fetch_projects_page(
381 after_cursor,
382 include_archived,
383 workspace_id,
384 self.sync_query_config()
385 .page_size(LinearOperation::Projects),
386 None,
387 )
388 .await?;
389 for project in &mut page.nodes {
390 project.teams = self.fetch_all_project_teams(&project.id).await?;
391 project.members = self.fetch_all_project_members(&project.id).await?;
392 project.labels = self.fetch_all_project_labels(&project.id).await?;
393 }
394 Ok((
395 page.nodes,
396 page.page_info.has_next_page,
397 page.page_info.end_cursor,
398 ))
399 }
400
401 async fn fetch_projects_page(
402 &self,
403 after_cursor: Option<&str>,
404 include_archived: bool,
405 workspace_id: &str,
406 page_size: usize,
407 team_id: Option<&str>,
408 ) -> Result<ConnectionPage<db::Project>> {
409 let (variables, filter) = if team_id.is_some() {
410 (
411 "$first: Int!, $after: String, $includeArchived: Boolean!, $teamId: ID!",
412 "filter: { accessibleTeams: { some: { id: { eq: $teamId } } } },",
413 )
414 } else {
415 (
416 "$first: Int!, $after: String, $includeArchived: Boolean!",
417 "",
418 )
419 };
420 let query = format!(
421 r#"
422 query({variables}) {{
423 projects(
424 first: $first,
425 after: $after,
426 {filter}
427 includeArchived: $includeArchived,
428 orderBy: updatedAt
429 ) {{
430 nodes {{ __PROJECT_FIELDS__ }}
431 pageInfo {{ hasNextPage endCursor }}
432 }}
433 }}
434 "#
435 )
436 .replace("__PROJECT_FIELDS__", PROJECT_FIELDS)
437 .replace("{filter}", filter);
438 let data: ProjectConnectionData = self
439 .query_operation(
440 LinearOperation::Projects.name(),
441 after_cursor,
442 &query,
443 serde_json::json!({
444 "first": page_size,
445 "after": after_cursor,
446 "includeArchived": include_archived,
447 "teamId": team_id,
448 }),
449 )
450 .await?;
451 Ok(ConnectionPage {
452 nodes: data
453 .projects
454 .nodes
455 .into_iter()
456 .map(|project| convert_project(project, workspace_id))
457 .collect(),
458 page_info: data.projects.page_info,
459 })
460 }
461
462 pub async fn fetch_project(&self, id: &str, workspace_id: &str) -> Result<db::Project> {
463 let query = r#"
464 query($id: String!) {
465 project(id: $id) { __PROJECT_FIELDS__ }
466 }
467 "#
468 .replace("__PROJECT_FIELDS__", PROJECT_FIELDS);
469 let data: SingleProjectData = self.query(&query, serde_json::json!({ "id": id })).await?;
470 let mut project = convert_project(data.project, workspace_id);
471 project.teams = self.fetch_all_project_teams(id).await?;
472 project.members = self.fetch_all_project_members(id).await?;
473 project.labels = self.fetch_all_project_labels(id).await?;
474 Ok(project)
475 }
476
477 async fn fetch_project_teams_page(
478 &self,
479 project_id: &str,
480 cursor: Option<&str>,
481 page_size: usize,
482 ) -> Result<ConnectionPage<db::ProjectTeam>> {
483 let query = r#"
484 query($id: String!, $first: Int!, $after: String) {
485 project(id: $id) {
486 teams(first: $first, after: $after, orderBy: updatedAt) {
487 nodes { id key name }
488 pageInfo { hasNextPage endCursor }
489 }
490 }
491 }
492 "#;
493 let data: ProjectTeamsData = self
494 .query_operation(
495 LinearOperation::ProjectTeams.name(),
496 cursor,
497 query,
498 serde_json::json!({
499 "id": project_id,
500 "first": page_size,
501 "after": cursor,
502 }),
503 )
504 .await?;
505 Ok(ConnectionPage {
506 nodes: data
507 .project
508 .teams
509 .nodes
510 .into_iter()
511 .map(|team| db::ProjectTeam {
512 id: team.id,
513 key: team.key,
514 name: team.name,
515 })
516 .collect(),
517 page_info: data.project.teams.page_info,
518 })
519 }
520
521 async fn fetch_project_members_page(
522 &self,
523 project_id: &str,
524 cursor: Option<&str>,
525 page_size: usize,
526 ) -> Result<ConnectionPage<db::ProjectMember>> {
527 let query = r#"
528 query($id: String!, $first: Int!, $after: String) {
529 project(id: $id) {
530 members(first: $first, after: $after, orderBy: updatedAt) {
531 nodes { id name }
532 pageInfo { hasNextPage endCursor }
533 }
534 }
535 }
536 "#;
537 let data: ProjectMembersData = self
538 .query_operation(
539 LinearOperation::ProjectMembers.name(),
540 cursor,
541 query,
542 serde_json::json!({
543 "id": project_id,
544 "first": page_size,
545 "after": cursor,
546 }),
547 )
548 .await?;
549 Ok(ConnectionPage {
550 nodes: data
551 .project
552 .members
553 .nodes
554 .into_iter()
555 .map(|member| db::ProjectMember {
556 id: member.id,
557 name: member.name,
558 })
559 .collect(),
560 page_info: data.project.members.page_info,
561 })
562 }
563
564 async fn fetch_project_labels_page(
565 &self,
566 project_id: &str,
567 cursor: Option<&str>,
568 page_size: usize,
569 ) -> Result<ConnectionPage<db::ProjectLabel>> {
570 let query = r#"
571 query($id: String!, $first: Int!, $after: String) {
572 project(id: $id) {
573 labels(first: $first, after: $after, orderBy: updatedAt) {
574 nodes { id name color description }
575 pageInfo { hasNextPage endCursor }
576 }
577 }
578 }
579 "#;
580 let data: ProjectLabelsData = self
581 .query_operation(
582 LinearOperation::ProjectLabels.name(),
583 cursor,
584 query,
585 serde_json::json!({
586 "id": project_id,
587 "first": page_size,
588 "after": cursor,
589 }),
590 )
591 .await?;
592 Ok(ConnectionPage {
593 nodes: data
594 .project
595 .labels
596 .nodes
597 .into_iter()
598 .map(|label| db::ProjectLabel {
599 id: label.id,
600 name: label.name,
601 color: label.color,
602 description: label.description,
603 })
604 .collect(),
605 page_info: data.project.labels.page_info,
606 })
607 }
608
609 async fn fetch_all_project_teams(&self, project_id: &str) -> Result<Vec<db::ProjectTeam>> {
610 let mut teams = Vec::new();
611 paginate(
612 self.sync_query_config(),
613 LinearOperation::ProjectTeams,
614 Some(project_id.to_string()),
615 |request| async move {
616 self.fetch_project_teams_page(
617 project_id,
618 request.cursor.as_deref(),
619 request.page_size,
620 )
621 .await
622 },
623 |nodes, _| {
624 teams.extend(nodes);
625 ready(Ok(()))
626 },
627 |team| team.id.clone(),
628 |event| self.observe_sync_event(event),
629 )
630 .await?;
631 Ok(teams)
632 }
633
634 async fn fetch_all_project_members(&self, project_id: &str) -> Result<Vec<db::ProjectMember>> {
635 let mut members = Vec::new();
636 paginate(
637 self.sync_query_config(),
638 LinearOperation::ProjectMembers,
639 Some(project_id.to_string()),
640 |request| async move {
641 self.fetch_project_members_page(
642 project_id,
643 request.cursor.as_deref(),
644 request.page_size,
645 )
646 .await
647 },
648 |nodes, _| {
649 members.extend(nodes);
650 ready(Ok(()))
651 },
652 |member| member.id.clone(),
653 |event| self.observe_sync_event(event),
654 )
655 .await?;
656 Ok(members)
657 }
658
659 async fn fetch_all_project_labels(&self, project_id: &str) -> Result<Vec<db::ProjectLabel>> {
660 let mut labels = Vec::new();
661 paginate(
662 self.sync_query_config(),
663 LinearOperation::ProjectLabels,
664 Some(project_id.to_string()),
665 |request| async move {
666 self.fetch_project_labels_page(
667 project_id,
668 request.cursor.as_deref(),
669 request.page_size,
670 )
671 .await
672 },
673 |nodes, _| {
674 labels.extend(nodes);
675 ready(Ok(()))
676 },
677 |label| label.id.clone(),
678 |event| self.observe_sync_event(event),
679 )
680 .await?;
681 Ok(labels)
682 }
683
684 async fn sync_project_connections(
685 &self,
686 db: &Database,
687 project_id: &str,
688 sync_token: &str,
689 ) -> Result<()> {
690 paginate(
691 self.sync_query_config(),
692 LinearOperation::ProjectTeams,
693 Some(project_id.to_string()),
694 |request| async move {
695 self.fetch_project_teams_page(
696 project_id,
697 request.cursor.as_deref(),
698 request.page_size,
699 )
700 .await
701 },
702 |teams, _| ready(db.upsert_project_team_page(project_id, &teams, sync_token)),
703 |team| team.id.clone(),
704 |event| self.observe_sync_event(event),
705 )
706 .await?;
707 db.complete_project_team_sync(project_id, sync_token)?;
708
709 paginate(
710 self.sync_query_config(),
711 LinearOperation::ProjectMembers,
712 Some(project_id.to_string()),
713 |request| async move {
714 self.fetch_project_members_page(
715 project_id,
716 request.cursor.as_deref(),
717 request.page_size,
718 )
719 .await
720 },
721 |members, _| ready(db.upsert_project_member_page(project_id, &members, sync_token)),
722 |member| member.id.clone(),
723 |event| self.observe_sync_event(event),
724 )
725 .await?;
726 db.complete_project_member_sync(project_id, sync_token)?;
727
728 paginate(
729 self.sync_query_config(),
730 LinearOperation::ProjectLabels,
731 Some(project_id.to_string()),
732 |request| async move {
733 self.fetch_project_labels_page(
734 project_id,
735 request.cursor.as_deref(),
736 request.page_size,
737 )
738 .await
739 },
740 |labels, _| ready(db.upsert_project_label_page(project_id, &labels, sync_token)),
741 |label| label.id.clone(),
742 |event| self.observe_sync_event(event),
743 )
744 .await?;
745 db.complete_project_label_sync(project_id, sync_token)?;
746 Ok(())
747 }
748
749 pub async fn fetch_project_milestones(
750 &self,
751 after_cursor: Option<&str>,
752 include_archived: bool,
753 workspace_id: &str,
754 ) -> Result<(Vec<db::ProjectMilestone>, bool, Option<String>)> {
755 let query = r#"
756 query($after: String, $includeArchived: Boolean!) {
757 projectMilestones(
758 first: __MILESTONE_PAGE_SIZE__,
759 after: $after,
760 includeArchived: $includeArchived,
761 orderBy: updatedAt
762 ) {
763 nodes { __MILESTONE_FIELDS__ }
764 pageInfo { hasNextPage endCursor }
765 }
766 }
767 "#
768 .replace("__MILESTONE_FIELDS__", MILESTONE_FIELDS)
769 .replace("__MILESTONE_PAGE_SIZE__", &MILESTONE_PAGE_SIZE.to_string());
770 let data: MilestoneConnectionData = self
771 .query(
772 &query,
773 serde_json::json!({
774 "after": after_cursor,
775 "includeArchived": include_archived,
776 }),
777 )
778 .await?;
779 Ok((
780 data.project_milestones
781 .nodes
782 .into_iter()
783 .map(|milestone| convert_milestone(milestone, workspace_id))
784 .collect(),
785 data.project_milestones.page_info.has_next_page,
786 data.project_milestones.page_info.end_cursor,
787 ))
788 }
789
790 pub async fn fetch_project_milestone(
791 &self,
792 id: &str,
793 workspace_id: &str,
794 ) -> Result<db::ProjectMilestone> {
795 let query = r#"
796 query($id: String!) {
797 projectMilestone(id: $id) { __MILESTONE_FIELDS__ }
798 }
799 "#
800 .replace("__MILESTONE_FIELDS__", MILESTONE_FIELDS);
801 let data: SingleMilestoneData = self.query(&query, serde_json::json!({ "id": id })).await?;
802 Ok(convert_milestone(data.project_milestone, workspace_id))
803 }
804
805 pub async fn fetch_milestones_for_project(
806 &self,
807 project_id: &str,
808 after_cursor: Option<&str>,
809 include_archived: bool,
810 workspace_id: &str,
811 ) -> Result<(Vec<db::ProjectMilestone>, bool, Option<String>)> {
812 let page = self
813 .fetch_milestones_for_project_page(
814 project_id,
815 after_cursor,
816 include_archived,
817 workspace_id,
818 self.sync_query_config()
819 .page_size(LinearOperation::ProjectMilestones),
820 )
821 .await?;
822 Ok((
823 page.nodes,
824 page.page_info.has_next_page,
825 page.page_info.end_cursor,
826 ))
827 }
828
829 async fn fetch_milestones_for_project_page(
830 &self,
831 project_id: &str,
832 after_cursor: Option<&str>,
833 include_archived: bool,
834 workspace_id: &str,
835 page_size: usize,
836 ) -> Result<ConnectionPage<db::ProjectMilestone>> {
837 let query = r#"
838 query($id: String!, $first: Int!, $after: String, $includeArchived: Boolean!) {
839 project(id: $id) {
840 projectMilestones(
841 first: $first,
842 after: $after,
843 includeArchived: $includeArchived,
844 orderBy: updatedAt
845 ) {
846 nodes { __MILESTONE_FIELDS__ }
847 pageInfo { hasNextPage endCursor }
848 }
849 }
850 }
851 "#
852 .replace("__MILESTONE_FIELDS__", MILESTONE_FIELDS);
853 let data: ProjectMilestonesData = self
854 .query_operation(
855 LinearOperation::ProjectMilestones.name(),
856 after_cursor,
857 &query,
858 serde_json::json!({
859 "id": project_id,
860 "first": page_size,
861 "after": after_cursor,
862 "includeArchived": include_archived,
863 }),
864 )
865 .await?;
866 let milestones = data.project.project_milestones;
867 Ok(ConnectionPage {
868 nodes: milestones
869 .nodes
870 .into_iter()
871 .map(|milestone| convert_milestone(milestone, workspace_id))
872 .collect(),
873 page_info: milestones.page_info,
874 })
875 }
876
877 pub async fn sync_projects(&self, db: &Database, workspace_id: &str) -> Result<(usize, usize)> {
878 self.sync_projects_scoped(db, workspace_id, None, None, true)
879 .await
880 }
881
882 pub async fn sync_projects_for_team(
883 &self,
884 db: &Database,
885 workspace_id: &str,
886 team_key: &str,
887 include_archived: bool,
888 ) -> Result<(usize, usize)> {
889 let team_id = self.get_team_id(team_key).await?;
890 self.sync_projects_scoped(
891 db,
892 workspace_id,
893 Some(&team_id),
894 Some(team_key),
895 include_archived,
896 )
897 .await
898 }
899
900 pub async fn sync_team_projects(
901 &self,
902 db: &Database,
903 team_key: &str,
904 workspace_id: &str,
905 ) -> Result<ProjectSyncResult> {
906 let (projects, milestones) = self
907 .sync_projects_for_team(db, workspace_id, team_key, true)
908 .await?;
909 Ok(ProjectSyncResult {
910 projects,
911 milestones,
912 })
913 }
914
915 async fn sync_projects_scoped(
916 &self,
917 db: &Database,
918 workspace_id: &str,
919 team_id: Option<&str>,
920 team_key: Option<&str>,
921 include_archived: bool,
922 ) -> Result<(usize, usize)> {
923 let sync_token = Uuid::new_v4().to_string();
924 let scope = team_key.unwrap_or("*");
925 for (family, page_size) in [
926 (
927 "projects",
928 Some(
929 self.sync_query_config()
930 .page_size(LinearOperation::Projects),
931 ),
932 ),
933 (
934 "project milestones",
935 Some(
936 self.sync_query_config()
937 .page_size(LinearOperation::ProjectMilestones),
938 ),
939 ),
940 ] {
941 db.mark_sync_family_running(workspace_id, scope, family, None, page_size, &sync_token)?;
942 }
943 let project_result = paginate(
944 self.sync_query_config(),
945 LinearOperation::Projects,
946 team_key.map(ToString::to_string),
947 |request| async move {
948 self.fetch_projects_page(
949 request.cursor.as_deref(),
950 include_archived,
951 workspace_id,
952 request.page_size,
953 team_id,
954 )
955 .await
956 },
957 |projects, context| {
958 let result = (|| {
959 for project in projects {
960 db.upsert_project_metadata(&project)?;
961 db.mark_project_sync_token(&project.id, &sync_token)?;
962 }
963 db.mark_sync_family_running(
964 workspace_id,
965 scope,
966 "projects",
967 context.cursor.as_deref(),
968 Some(context.page_size),
969 &sync_token,
970 )
971 })();
972 ready(result)
973 },
974 |project| project.id.clone(),
975 |event| self.observe_sync_event(event),
976 )
977 .await;
978 let project_stats = match project_result {
979 Ok(stats) => stats,
980 Err(error) => {
981 let message = self.redacted_error_message(&error);
982 for family in ["projects", "project milestones"] {
983 db.mark_sync_family_failed(workspace_id, scope, family, &sync_token, &message)?;
984 }
985 return Err(error);
986 }
987 };
988
989 let mut milestone_total = 0;
990 let hydration_result: Result<()> = async {
991 let mut after_id = None;
992 loop {
993 let project_ids = db.list_project_ids_for_sync_token(
994 workspace_id,
995 &sync_token,
996 after_id.as_deref(),
997 100,
998 )?;
999 if project_ids.is_empty() {
1000 break;
1001 }
1002 for project_id in &project_ids {
1003 self.sync_project_connections(db, project_id, &sync_token)
1004 .await?;
1005 milestone_total += self
1006 .sync_project_milestones(
1007 db,
1008 workspace_id,
1009 project_id,
1010 &sync_token,
1011 include_archived,
1012 )
1013 .await?;
1014 }
1015 after_id = project_ids.last().cloned();
1016 }
1017 Ok(())
1018 }
1019 .await;
1020 if let Err(error) = hydration_result {
1021 let message = self.redacted_error_message(&error);
1022 for family in ["projects", "project milestones"] {
1023 db.mark_sync_family_failed(workspace_id, scope, family, &sync_token, &message)?;
1024 }
1025 return Err(error);
1026 }
1027
1028 if let Some(team_key) = team_key {
1029 db.reconcile_team_projects(workspace_id, team_key, &sync_token)?;
1030 } else {
1031 db.reconcile_workspace_projects(workspace_id, &sync_token)?;
1032 }
1033 db.mark_sync_family_complete(
1034 workspace_id,
1035 scope,
1036 "projects",
1037 Some(
1038 self.sync_query_config()
1039 .page_size(LinearOperation::Projects),
1040 ),
1041 &sync_token,
1042 )?;
1043 db.mark_sync_family_complete(
1044 workspace_id,
1045 scope,
1046 "project milestones",
1047 Some(
1048 self.sync_query_config()
1049 .page_size(LinearOperation::ProjectMilestones),
1050 ),
1051 &sync_token,
1052 )?;
1053 Ok((project_stats.nodes, milestone_total))
1054 }
1055
1056 async fn sync_project_milestones(
1057 &self,
1058 db: &Database,
1059 workspace_id: &str,
1060 project_id: &str,
1061 sync_token: &str,
1062 include_archived: bool,
1063 ) -> Result<usize> {
1064 let stats = paginate(
1065 self.sync_query_config(),
1066 LinearOperation::ProjectMilestones,
1067 Some(project_id.to_string()),
1068 |request| async move {
1069 self.fetch_milestones_for_project_page(
1070 project_id,
1071 request.cursor.as_deref(),
1072 include_archived,
1073 workspace_id,
1074 request.page_size,
1075 )
1076 .await
1077 },
1078 |milestones, _| {
1079 let result = (|| {
1080 for milestone in milestones {
1081 db.upsert_project_milestone(&milestone)?;
1082 db.mark_project_milestone_sync_token(&milestone.id, sync_token)?;
1083 }
1084 Ok(())
1085 })();
1086 ready(result)
1087 },
1088 |milestone| milestone.id.clone(),
1089 |event| self.observe_sync_event(event),
1090 )
1091 .await?;
1092 db.reconcile_project_milestones_by_token(project_id, sync_token)?;
1093 Ok(stats.nodes)
1094 }
1095
1096 pub async fn create_project(&self, input: &CreateProjectInput) -> Result<String> {
1097 if input.name.trim().is_empty() {
1098 anyhow::bail!("Project name cannot be empty");
1099 }
1100 if input.team_ids.is_empty() {
1101 anyhow::bail!("At least one team is required to create a project");
1102 }
1103 let graphql_input = project_create_value(input);
1104 let query = r#"
1105 mutation($input: ProjectCreateInput!) {
1106 projectCreate(input: $input) {
1107 success
1108 project { id }
1109 }
1110 }
1111 "#;
1112 let data: MutationProjectData = self
1113 .query(query, serde_json::json!({ "input": graphql_input }))
1114 .await?;
1115 if !data.payload.success {
1116 anyhow::bail!("Failed to create project");
1117 }
1118 data.payload
1119 .project
1120 .map(|project| project.id)
1121 .context("Linear did not return the created project")
1122 }
1123
1124 pub async fn update_project(&self, id: &str, input: &UpdateProjectInput) -> Result<()> {
1125 let graphql_input = project_update_value(input);
1126 if graphql_input.is_empty() {
1127 anyhow::bail!("No project fields were provided to update");
1128 }
1129 let query = r#"
1130 mutation($id: String!, $input: ProjectUpdateInput!) {
1131 projectUpdate(id: $id, input: $input) {
1132 success
1133 project { id }
1134 }
1135 }
1136 "#;
1137 let data: MutationProjectData = self
1138 .query(
1139 query,
1140 serde_json::json!({ "id": id, "input": graphql_input }),
1141 )
1142 .await?;
1143 if !data.payload.success {
1144 anyhow::bail!("Failed to update project");
1145 }
1146 Ok(())
1147 }
1148
1149 pub async fn delete_project(&self, id: &str) -> Result<()> {
1150 let query = r#"
1151 mutation($id: String!) {
1152 projectDelete(id: $id) { success }
1153 }
1154 "#;
1155 let data: DeleteProjectData = self.query(query, serde_json::json!({ "id": id })).await?;
1156 if !data.project_delete.success {
1157 anyhow::bail!("Failed to delete project");
1158 }
1159 Ok(())
1160 }
1161
1162 pub async fn create_project_milestone(
1163 &self,
1164 input: &CreateProjectMilestoneInput,
1165 ) -> Result<String> {
1166 if input.name.trim().is_empty() {
1167 anyhow::bail!("Milestone name cannot be empty");
1168 }
1169 let graphql_input = milestone_create_value(input);
1170 let query = r#"
1171 mutation($input: ProjectMilestoneCreateInput!) {
1172 projectMilestoneCreate(input: $input) {
1173 success
1174 projectMilestone { id }
1175 }
1176 }
1177 "#;
1178 let data: MutationMilestoneData = self
1179 .query(query, serde_json::json!({ "input": graphql_input }))
1180 .await?;
1181 if !data.payload.success {
1182 anyhow::bail!("Failed to create project milestone");
1183 }
1184 data.payload
1185 .project_milestone
1186 .map(|milestone| milestone.id)
1187 .context("Linear did not return the created milestone")
1188 }
1189
1190 pub async fn update_project_milestone(
1191 &self,
1192 id: &str,
1193 input: &UpdateProjectMilestoneInput,
1194 ) -> Result<()> {
1195 let graphql_input = milestone_update_value(input);
1196 if graphql_input.is_empty() {
1197 anyhow::bail!("No milestone fields were provided to update");
1198 }
1199 let query = r#"
1200 mutation($id: String!, $input: ProjectMilestoneUpdateInput!) {
1201 projectMilestoneUpdate(id: $id, input: $input) {
1202 success
1203 projectMilestone { id }
1204 }
1205 }
1206 "#;
1207 let data: MutationMilestoneData = self
1208 .query(
1209 query,
1210 serde_json::json!({ "id": id, "input": graphql_input }),
1211 )
1212 .await?;
1213 if !data.payload.success {
1214 anyhow::bail!("Failed to update project milestone");
1215 }
1216 Ok(())
1217 }
1218
1219 pub async fn delete_project_milestone(&self, id: &str) -> Result<()> {
1220 let query = r#"
1221 mutation($id: String!) {
1222 projectMilestoneDelete(id: $id) { success }
1223 }
1224 "#;
1225 let data: DeleteMilestoneData = self.query(query, serde_json::json!({ "id": id })).await?;
1226 if !data.project_milestone_delete.success {
1227 anyhow::bail!("Failed to delete project milestone");
1228 }
1229 Ok(())
1230 }
1231
1232 pub async fn get_project_status_id(&self, status_name: &str) -> Result<String> {
1233 let query = r#"
1234 query {
1235 projectStatuses(first: 250, includeArchived: false) {
1236 nodes { id name }
1237 }
1238 }
1239 "#;
1240 let data: serde_json::Value = self.query(query, serde_json::json!({})).await?;
1241 find_resource_id(
1242 &data["projectStatuses"]["nodes"],
1243 status_name,
1244 "project status",
1245 )
1246 }
1247
1248 pub async fn get_project_label_ids(&self, names: &[String]) -> Result<Vec<String>> {
1249 if names.is_empty() {
1250 return Ok(Vec::new());
1251 }
1252 let mut cursor = None;
1253 let mut labels = Vec::new();
1254 loop {
1255 let query = r#"
1256 query($after: String) {
1257 projectLabels(first: 250, after: $after, includeArchived: false) {
1258 nodes { id name }
1259 pageInfo { hasNextPage endCursor }
1260 }
1261 }
1262 "#;
1263 let data: serde_json::Value = self
1264 .query(query, serde_json::json!({ "after": cursor }))
1265 .await?;
1266 labels.extend(
1267 data["projectLabels"]["nodes"]
1268 .as_array()
1269 .context("No project labels in response")?
1270 .iter()
1271 .filter_map(|label| {
1272 Some((
1273 label["id"].as_str()?.to_string(),
1274 label["name"].as_str()?.to_string(),
1275 ))
1276 }),
1277 );
1278 if !data["projectLabels"]["pageInfo"]["hasNextPage"]
1279 .as_bool()
1280 .unwrap_or(false)
1281 {
1282 break;
1283 }
1284 cursor = data["projectLabels"]["pageInfo"]["endCursor"]
1285 .as_str()
1286 .map(ToString::to_string);
1287 }
1288
1289 names
1290 .iter()
1291 .map(|name| {
1292 labels
1293 .iter()
1294 .find(|(id, candidate)| id == name || candidate.eq_ignore_ascii_case(name))
1295 .map(|(id, _)| id.clone())
1296 .with_context(|| {
1297 format!(
1298 "Project label '{}' not found. Available: {}",
1299 name,
1300 labels
1301 .iter()
1302 .map(|(_, label)| label.as_str())
1303 .collect::<Vec<_>>()
1304 .join(", ")
1305 )
1306 })
1307 })
1308 .collect()
1309 }
1310
1311 pub async fn find_project_by_name(&self, id_or_name: &str) -> Result<String> {
1312 let mut cursor = None;
1313 let mut available = Vec::new();
1314 loop {
1315 let query = r#"
1316 query($after: String) {
1317 projects(first: __LOOKUP_PAGE_SIZE__, after: $after, includeArchived: true) {
1318 nodes { id slugId name }
1319 pageInfo { hasNextPage endCursor }
1320 }
1321 }
1322 "#;
1323 let query = query.replace(
1324 "__LOOKUP_PAGE_SIZE__",
1325 &RESOURCE_LOOKUP_PAGE_SIZE.to_string(),
1326 );
1327 let data: serde_json::Value = self
1328 .query(&query, serde_json::json!({ "after": cursor }))
1329 .await?;
1330 let nodes = data["projects"]["nodes"]
1331 .as_array()
1332 .context("No projects in response")?;
1333 for project in nodes {
1334 let id = project["id"].as_str().unwrap_or_default();
1335 let slug = project["slugId"].as_str().unwrap_or_default();
1336 let name = project["name"].as_str().unwrap_or_default();
1337 if id == id_or_name
1338 || slug.eq_ignore_ascii_case(id_or_name)
1339 || name.eq_ignore_ascii_case(id_or_name)
1340 {
1341 return Ok(id.to_string());
1342 }
1343 if !name.is_empty() {
1344 available.push(name.to_string());
1345 }
1346 }
1347 if !data["projects"]["pageInfo"]["hasNextPage"]
1348 .as_bool()
1349 .unwrap_or(false)
1350 {
1351 break;
1352 }
1353 cursor = data["projects"]["pageInfo"]["endCursor"]
1354 .as_str()
1355 .map(ToString::to_string);
1356 }
1357 anyhow::bail!(
1358 "Project '{}' not found. Available: {}",
1359 id_or_name,
1360 available.join(", ")
1361 )
1362 }
1363
1364 pub async fn find_project_milestone(
1365 &self,
1366 project_id: Option<&str>,
1367 id_or_name: &str,
1368 ) -> Result<String> {
1369 let mut cursor = None;
1370 let mut available = Vec::new();
1371 loop {
1372 let query = r#"
1373 query($after: String) {
1374 projectMilestones(
1375 first: __LOOKUP_PAGE_SIZE__,
1376 after: $after,
1377 includeArchived: true
1378 ) {
1379 nodes { id name project { id } }
1380 pageInfo { hasNextPage endCursor }
1381 }
1382 }
1383 "#;
1384 let query = query.replace(
1385 "__LOOKUP_PAGE_SIZE__",
1386 &RESOURCE_LOOKUP_PAGE_SIZE.to_string(),
1387 );
1388 let data: serde_json::Value = self
1389 .query(&query, serde_json::json!({ "after": cursor }))
1390 .await?;
1391 let nodes = data["projectMilestones"]["nodes"]
1392 .as_array()
1393 .context("No project milestones in response")?;
1394 for milestone in nodes {
1395 let id = milestone["id"].as_str().unwrap_or_default();
1396 let name = milestone["name"].as_str().unwrap_or_default();
1397 let owning_project = milestone["project"]["id"].as_str().unwrap_or_default();
1398 if project_id.is_some_and(|expected| expected != owning_project) {
1399 continue;
1400 }
1401 if id == id_or_name || name.eq_ignore_ascii_case(id_or_name) {
1402 return Ok(id.to_string());
1403 }
1404 if !name.is_empty() {
1405 available.push(name.to_string());
1406 }
1407 }
1408 if !data["projectMilestones"]["pageInfo"]["hasNextPage"]
1409 .as_bool()
1410 .unwrap_or(false)
1411 {
1412 break;
1413 }
1414 cursor = data["projectMilestones"]["pageInfo"]["endCursor"]
1415 .as_str()
1416 .map(ToString::to_string);
1417 }
1418 anyhow::bail!(
1419 "Project milestone '{}' not found. Available: {}",
1420 id_or_name,
1421 available.join(", ")
1422 )
1423 }
1424
1425 pub async fn import_project(
1426 &self,
1427 db: &Database,
1428 workspace_id: &str,
1429 id_or_name: &str,
1430 ) -> Result<db::ProjectBundle> {
1431 let project_id = self.find_project_by_name(id_or_name).await?;
1432 let project = self.fetch_project(&project_id, workspace_id).await?;
1433 db.upsert_project(&project)?;
1434 self.import_project_milestones(db, workspace_id, &project_id)
1435 .await?;
1436 self.import_hierarchy_issues(db, workspace_id, &project_id, false)
1437 .await?;
1438 db.get_project_bundle(workspace_id, &project_id)?
1439 .context("Imported project was not found in the local database")
1440 }
1441
1442 pub async fn import_project_milestone(
1443 &self,
1444 db: &Database,
1445 workspace_id: &str,
1446 project_id: Option<&str>,
1447 id_or_name: &str,
1448 ) -> Result<db::ProjectMilestoneBundle> {
1449 let milestone_id = self.find_project_milestone(project_id, id_or_name).await?;
1450 let milestone = self
1451 .fetch_project_milestone(&milestone_id, workspace_id)
1452 .await?;
1453 let project = self
1454 .fetch_project(&milestone.project_id, workspace_id)
1455 .await?;
1456 db.upsert_project(&project)?;
1457 db.upsert_project_milestone(&milestone)?;
1458 self.import_hierarchy_issues(db, workspace_id, &milestone_id, true)
1459 .await?;
1460 db.get_project_milestone_bundle(workspace_id, &milestone_id, None)?
1461 .context("Imported milestone was not found in the local database")
1462 }
1463
1464 async fn import_project_milestones(
1465 &self,
1466 db: &Database,
1467 workspace_id: &str,
1468 project_id: &str,
1469 ) -> Result<()> {
1470 let sync_token = Uuid::new_v4().to_string();
1471 self.sync_project_milestones(db, workspace_id, project_id, &sync_token, true)
1472 .await?;
1473 Ok(())
1474 }
1475
1476 async fn import_hierarchy_issues(
1477 &self,
1478 db: &Database,
1479 workspace_id: &str,
1480 resource_id: &str,
1481 milestone: bool,
1482 ) -> Result<()> {
1483 self.sync_labels_catalog(db, workspace_id).await?;
1484 let issue_ids = Arc::new(Mutex::new(Vec::new()));
1485 paginate(
1486 self.sync_query_config(),
1487 LinearOperation::Issues,
1488 Some(resource_id.to_string()),
1489 |request| async move {
1490 self.fetch_hierarchy_issues(
1491 resource_id,
1492 milestone,
1493 request.cursor.as_deref(),
1494 request.page_size,
1495 )
1496 .await
1497 },
1498 |issues, _| {
1499 let issue_ids = Arc::clone(&issue_ids);
1500 async move {
1501 for (mut issue, _relations, _label_ids) in issues {
1502 issue.workspace_id = workspace_id.to_string();
1503 db.upsert_issue_preserving_labels(&issue)?;
1504 self.sync_issue_labels(db, &issue.id).await?;
1505 self.sync_issue_relations(db, &issue.id).await?;
1506 issue_ids.lock().unwrap().push(issue.id);
1507 }
1508 Ok(())
1509 }
1510 },
1511 |(issue, _, _)| issue.id.clone(),
1512 |event| self.observe_sync_event(event),
1513 )
1514 .await?;
1515 let issue_ids = issue_ids.lock().unwrap().clone();
1516 if milestone {
1517 db.reconcile_project_milestone_issue_membership(workspace_id, resource_id, &issue_ids)?;
1518 } else {
1519 db.reconcile_project_issue_membership(workspace_id, resource_id, &issue_ids)?;
1520 }
1521 Ok(())
1522 }
1523
1524 async fn fetch_hierarchy_issues(
1525 &self,
1526 resource_id: &str,
1527 milestone: bool,
1528 after_cursor: Option<&str>,
1529 page_size: usize,
1530 ) -> Result<ConnectionPage<(db::Issue, Vec<db::Relation>, Vec<String>)>> {
1531 let query = if milestone {
1532 r#"
1533 query($id: String!, $after: String) {
1534 projectMilestone(id: $id) {
1535 issues(
1536 first: __ISSUE_PAGE_SIZE__,
1537 after: $after,
1538 includeArchived: true
1539 ) {
1540 nodes { __ISSUE_FIELDS__ }
1541 pageInfo { hasNextPage endCursor }
1542 }
1543 }
1544 }
1545 "#
1546 } else {
1547 r#"
1548 query($id: String!, $after: String) {
1549 project(id: $id) {
1550 issues(
1551 first: __ISSUE_PAGE_SIZE__,
1552 after: $after,
1553 includeArchived: true
1554 ) {
1555 nodes { __ISSUE_FIELDS__ }
1556 pageInfo { hasNextPage endCursor }
1557 }
1558 }
1559 }
1560 "#
1561 }
1562 .replace("__ISSUE_FIELDS__", ISSUE_FIELDS)
1563 .replace("__ISSUE_PAGE_SIZE__", &page_size.to_string());
1564 let variables = serde_json::json!({ "id": resource_id, "after": after_cursor });
1565 let connection = if milestone {
1566 let data: MilestoneIssuesData = self.query(&query, variables).await?;
1567 data.project_milestone.issues
1568 } else {
1569 let data: ProjectIssuesData = self.query(&query, variables).await?;
1570 data.project.issues
1571 };
1572 Ok(ConnectionPage {
1573 nodes: connection
1574 .nodes
1575 .into_iter()
1576 .map(Self::convert_linear_issue)
1577 .collect(),
1578 page_info: connection.page_info,
1579 })
1580 }
1581}
1582
1583fn convert_project(project: LinearProjectNode, workspace_id: &str) -> db::Project {
1584 db::Project {
1585 id: project.id,
1586 workspace_id: workspace_id.to_string(),
1587 slug_id: project.slug_id,
1588 name: project.name,
1589 description: project.description,
1590 content: project.content,
1591 icon: project.icon,
1592 color: project.color,
1593 status_id: project.status.id,
1594 status_name: project.status.name,
1595 status_type: project.status.status_type,
1596 status_color: project.status.color,
1597 priority: project.priority,
1598 start_date: project.start_date,
1599 target_date: project.target_date,
1600 lead_id: project.lead.as_ref().map(|lead| lead.id.clone()),
1601 lead_name: project.lead.map(|lead| lead.name),
1602 created_at: project.created_at,
1603 updated_at: project.updated_at,
1604 archived_at: project.archived_at,
1605 url: project.url,
1606 progress: project.progress,
1607 synced_at: None,
1608 teams: project
1609 .teams
1610 .nodes
1611 .into_iter()
1612 .map(|team| db::ProjectTeam {
1613 id: team.id,
1614 key: team.key,
1615 name: team.name,
1616 })
1617 .collect(),
1618 members: project
1619 .members
1620 .nodes
1621 .into_iter()
1622 .map(|member| db::ProjectMember {
1623 id: member.id,
1624 name: member.name,
1625 })
1626 .collect(),
1627 labels: project
1628 .labels
1629 .nodes
1630 .into_iter()
1631 .map(|label| db::ProjectLabel {
1632 id: label.id,
1633 name: label.name,
1634 color: label.color,
1635 description: label.description,
1636 })
1637 .collect(),
1638 }
1639}
1640
1641fn convert_milestone(
1642 milestone: LinearProjectMilestoneNode,
1643 workspace_id: &str,
1644) -> db::ProjectMilestone {
1645 db::ProjectMilestone {
1646 id: milestone.id,
1647 workspace_id: workspace_id.to_string(),
1648 project_id: milestone.project.id,
1649 project_name: milestone.project.name,
1650 name: milestone.name,
1651 description: milestone.description,
1652 target_date: milestone.target_date,
1653 status: milestone.status,
1654 progress: milestone.progress,
1655 sort_order: milestone.sort_order,
1656 created_at: milestone.created_at,
1657 updated_at: milestone.updated_at,
1658 archived_at: milestone.archived_at,
1659 synced_at: None,
1660 }
1661}
1662
1663fn project_create_value(input: &CreateProjectInput) -> serde_json::Map<String, serde_json::Value> {
1664 let mut value = serde_json::Map::new();
1665 value.insert("name".into(), serde_json::json!(input.name));
1666 value.insert("teamIds".into(), serde_json::json!(input.team_ids));
1667 insert_optional_string(
1668 &mut value,
1669 "description",
1670 input.description.as_deref(),
1671 false,
1672 );
1673 insert_optional_string(&mut value, "content", input.content.as_deref(), false);
1674 insert_optional_string(&mut value, "icon", input.icon.as_deref(), true);
1675 insert_optional_string(&mut value, "color", input.color.as_deref(), true);
1676 insert_optional_string(&mut value, "statusId", input.status_id.as_deref(), true);
1677 insert_optional_string(&mut value, "leadId", input.lead_id.as_deref(), true);
1678 insert_optional_string(&mut value, "startDate", input.start_date.as_deref(), true);
1679 insert_optional_string(&mut value, "targetDate", input.target_date.as_deref(), true);
1680 if let Some(priority) = input.priority {
1681 value.insert("priority".into(), serde_json::json!(priority));
1682 }
1683 if let Some(member_ids) = &input.member_ids {
1684 value.insert("memberIds".into(), serde_json::json!(member_ids));
1685 }
1686 if let Some(label_ids) = &input.label_ids {
1687 value.insert("labelIds".into(), serde_json::json!(label_ids));
1688 }
1689 value
1690}
1691
1692fn project_update_value(input: &UpdateProjectInput) -> serde_json::Map<String, serde_json::Value> {
1693 let mut value = serde_json::Map::new();
1694 insert_optional_string(&mut value, "name", input.name.as_deref(), false);
1695 insert_optional_string(
1696 &mut value,
1697 "description",
1698 input.description.as_deref(),
1699 false,
1700 );
1701 insert_optional_string(&mut value, "content", input.content.as_deref(), false);
1702 insert_optional_string(&mut value, "icon", input.icon.as_deref(), true);
1703 insert_optional_string(&mut value, "color", input.color.as_deref(), true);
1704 insert_optional_string(&mut value, "statusId", input.status_id.as_deref(), true);
1705 insert_optional_string(&mut value, "leadId", input.lead_id.as_deref(), true);
1706 insert_optional_string(&mut value, "startDate", input.start_date.as_deref(), true);
1707 insert_optional_string(&mut value, "targetDate", input.target_date.as_deref(), true);
1708 if let Some(team_ids) = &input.team_ids {
1709 value.insert("teamIds".into(), serde_json::json!(team_ids));
1710 }
1711 if let Some(member_ids) = &input.member_ids {
1712 value.insert("memberIds".into(), serde_json::json!(member_ids));
1713 }
1714 if let Some(label_ids) = &input.label_ids {
1715 value.insert("labelIds".into(), serde_json::json!(label_ids));
1716 }
1717 if let Some(priority) = input.priority {
1718 value.insert("priority".into(), serde_json::json!(priority));
1719 }
1720 value
1721}
1722
1723fn milestone_create_value(
1724 input: &CreateProjectMilestoneInput,
1725) -> serde_json::Map<String, serde_json::Value> {
1726 let mut value = serde_json::Map::new();
1727 value.insert("projectId".into(), serde_json::json!(input.project_id));
1728 value.insert("name".into(), serde_json::json!(input.name));
1729 insert_optional_string(
1730 &mut value,
1731 "description",
1732 input.description.as_deref(),
1733 false,
1734 );
1735 insert_optional_string(&mut value, "targetDate", input.target_date.as_deref(), true);
1736 if let Some(sort_order) = input.sort_order {
1737 value.insert("sortOrder".into(), serde_json::json!(sort_order));
1738 }
1739 value
1740}
1741
1742fn milestone_update_value(
1743 input: &UpdateProjectMilestoneInput,
1744) -> serde_json::Map<String, serde_json::Value> {
1745 let mut value = serde_json::Map::new();
1746 insert_optional_string(&mut value, "projectId", input.project_id.as_deref(), true);
1747 insert_optional_string(&mut value, "name", input.name.as_deref(), false);
1748 insert_optional_string(
1749 &mut value,
1750 "description",
1751 input.description.as_deref(),
1752 false,
1753 );
1754 insert_optional_string(&mut value, "targetDate", input.target_date.as_deref(), true);
1755 if let Some(sort_order) = input.sort_order {
1756 value.insert("sortOrder".into(), serde_json::json!(sort_order));
1757 }
1758 value
1759}
1760
1761fn insert_optional_string(
1762 value: &mut serde_json::Map<String, serde_json::Value>,
1763 key: &str,
1764 input: Option<&str>,
1765 nullable: bool,
1766) {
1767 let Some(input) = input else { return };
1768 if nullable && (input.is_empty() || input.eq_ignore_ascii_case("none")) {
1769 value.insert(key.into(), serde_json::Value::Null);
1770 } else {
1771 value.insert(key.into(), serde_json::json!(input));
1772 }
1773}
1774
1775fn find_resource_id(nodes: &serde_json::Value, name: &str, kind: &str) -> Result<String> {
1776 let nodes = nodes
1777 .as_array()
1778 .with_context(|| format!("No {kind} values in response"))?;
1779 for resource in nodes {
1780 if resource["id"].as_str() == Some(name)
1781 || resource["name"]
1782 .as_str()
1783 .is_some_and(|candidate| candidate.eq_ignore_ascii_case(name))
1784 {
1785 return resource["id"]
1786 .as_str()
1787 .map(ToString::to_string)
1788 .with_context(|| format!("{kind} has no id"));
1789 }
1790 }
1791 let available = nodes
1792 .iter()
1793 .filter_map(|resource| resource["name"].as_str())
1794 .collect::<Vec<_>>()
1795 .join(", ");
1796 anyhow::bail!("{} '{}' not found. Available: {}", kind, name, available)
1797}
1798
1799#[cfg(test)]
1800mod tests {
1801 use super::*;
1802
1803 #[test]
1804 fn project_update_serializes_clearable_metadata_as_null() {
1805 let input = UpdateProjectInput {
1806 lead_id: Some("none".into()),
1807 target_date: Some(String::new()),
1808 description: Some(String::new()),
1809 priority: Some(1),
1810 label_ids: Some(vec!["label-1".into()]),
1811 ..Default::default()
1812 };
1813 let value = project_update_value(&input);
1814 assert_eq!(value["leadId"], serde_json::Value::Null);
1815 assert_eq!(value["targetDate"], serde_json::Value::Null);
1816 assert_eq!(value["description"], serde_json::json!(""));
1817 assert_eq!(value["priority"], serde_json::json!(1));
1818 assert_eq!(value["labelIds"], serde_json::json!(["label-1"]));
1819 }
1820
1821 #[test]
1822 fn milestone_create_serializes_project_relationship() {
1823 let input = CreateProjectMilestoneInput {
1824 project_id: "project-1".into(),
1825 name: "Beta".into(),
1826 target_date: Some("2026-09-01".into()),
1827 ..Default::default()
1828 };
1829 let value = milestone_create_value(&input);
1830 assert_eq!(value["projectId"], serde_json::json!("project-1"));
1831 assert_eq!(value["name"], serde_json::json!("Beta"));
1832 assert_eq!(value["targetDate"], serde_json::json!("2026-09-01"));
1833 }
1834
1835 #[test]
1836 fn mutation_payloads_accept_create_and_update_field_names() {
1837 for field in ["projectCreate", "projectUpdate"] {
1838 let payload = serde_json::json!({
1839 (field): {
1840 "success": true,
1841 "project": { "id": "project-1" }
1842 }
1843 });
1844 let parsed: MutationProjectData = serde_json::from_value(payload).unwrap();
1845 assert!(parsed.payload.success);
1846 assert_eq!(parsed.payload.project.unwrap().id, "project-1");
1847 }
1848
1849 for field in ["projectMilestoneCreate", "projectMilestoneUpdate"] {
1850 let payload = serde_json::json!({
1851 (field): {
1852 "success": true,
1853 "projectMilestone": { "id": "milestone-1" }
1854 }
1855 });
1856 let parsed: MutationMilestoneData = serde_json::from_value(payload).unwrap();
1857 assert!(parsed.payload.success);
1858 assert_eq!(parsed.payload.project_milestone.unwrap().id, "milestone-1");
1859 }
1860 }
1861
1862 #[test]
1863 fn project_and_milestone_responses_preserve_graphql_metadata() {
1864 let project: LinearProjectNode = serde_json::from_value(serde_json::json!({
1865 "id": "project-1",
1866 "slugId": "api-reliability",
1867 "name": "API Reliability",
1868 "description": "Service resilience",
1869 "content": "Detailed rollout plan",
1870 "icon": "Cube",
1871 "color": "#f2994a",
1872 "priority": 2,
1873 "startDate": "2026-07-01",
1874 "targetDate": "2026-09-01",
1875 "createdAt": "2026-07-01T00:00:00Z",
1876 "updatedAt": "2026-07-16T00:00:00Z",
1877 "archivedAt": null,
1878 "url": "https://linear.app/acme/project/api-reliability",
1879 "progress": 0.25,
1880 "status": {
1881 "id": "status-1",
1882 "name": "Backlog",
1883 "type": "backlog",
1884 "color": "#888888"
1885 },
1886 "lead": { "id": "user-1", "name": "Alex Morgan" },
1887 "teams": { "nodes": [{
1888 "id": "team-1", "key": "ENG", "name": "Engineering"
1889 }]},
1890 "members": { "nodes": [{ "id": "user-1", "name": "Alex Morgan" }]},
1891 "labels": { "nodes": [{
1892 "id": "label-1",
1893 "name": "Infrastructure",
1894 "color": "#f2994a",
1895 "description": "Platform engineering"
1896 }]}
1897 }))
1898 .unwrap();
1899 let project = convert_project(project, "home");
1900 assert_eq!(project.status_name, "Backlog");
1901 assert_eq!(project.teams[0].key, "ENG");
1902 assert_eq!(project.lead_name.as_deref(), Some("Alex Morgan"));
1903 assert_eq!(project.labels[0].name, "Infrastructure");
1904
1905 let milestone: LinearProjectMilestoneNode = serde_json::from_value(serde_json::json!({
1906 "id": "milestone-1",
1907 "name": "Request tracing",
1908 "description": "Instrument critical request paths",
1909 "targetDate": "2026-08-15",
1910 "status": "next",
1911 "progress": 0.5,
1912 "sortOrder": 1.0,
1913 "createdAt": "2026-07-01T00:00:00Z",
1914 "updatedAt": "2026-07-16T00:00:00Z",
1915 "archivedAt": null,
1916 "project": { "id": "project-1", "name": "API Reliability" }
1917 }))
1918 .unwrap();
1919 let milestone = convert_milestone(milestone, "home");
1920 assert_eq!(milestone.project_name, "API Reliability");
1921 assert_eq!(milestone.target_date.as_deref(), Some("2026-08-15"));
1922 }
1923}