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