1use anyhow::{Context, Result};
2use serde::Deserialize;
3
4use crate::db::{self, Database};
5
6use super::{IssueConnection, LinearClient, PageInfo};
7
8const 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}