Skip to main content

rectilinear_core/linear/
projects.rs

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
19// Linear charges query complexity per connection item, including every nested
20// connection selected for that item. Project metadata is unusually rich, so
21// conservative page sizes keep these queries below the workspace complexity
22// limit even when a workspace has hundreds of projects.
23const 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}