Skip to main content

rectilinear_core/linear/
projects.rs

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