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