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