1use std::future::ready;
2use std::time::Duration;
3
4use anyhow::{Context, Result};
5use serde::Deserialize;
6use sha2::{Digest, Sha256};
7use uuid::Uuid;
8
9use crate::config::Config;
10use crate::db::{self, Database};
11
12mod cycles;
13mod projects;
14mod pagination;
15pub use projects::*;
16pub use pagination::{LinearErrorKind, LinearOperation, LinearOperationError, SyncEvent, SyncQueryConfig};
17
18use pagination::{paginate, ConnectionPage, PageInfo};
19
20const LINEAR_API_URL: &str = "https://api.linear.app/graphql";
21
22#[derive(Clone)]
23pub struct LinearClient {
24 client: reqwest::Client,
25 api_key: String,
26 viewer_id: std::sync::Arc<std::sync::RwLock<Option<String>>>,
27 sync_query_config: SyncQueryConfig,
28}
29
30#[derive(Debug, Deserialize)]
31struct GraphQLResponse<T> {
32 data: Option<T>,
33 errors: Option<Vec<GraphQLError>>,
34}
35
36#[derive(Debug, Deserialize)]
37struct GraphQLError {
38 message: String,
39}
40
41#[derive(Debug, Deserialize)]
44struct IssuesData {
45 issues: IssueConnection,
46}
47
48#[derive(Debug, Deserialize)]
49struct IssueConnection {
50 nodes: Vec<LinearIssue>,
51 #[serde(rename = "pageInfo")]
52 page_info: PageInfo,
53}
54
55#[derive(Debug, Deserialize)]
56struct LinearIssue {
57 id: String,
58 identifier: String,
59 url: String,
60 title: String,
61 description: Option<String>,
62 priority: i32,
63 #[serde(rename = "createdAt")]
64 created_at: String,
65 #[serde(rename = "updatedAt")]
66 updated_at: String,
67 state: LinearState,
68 team: LinearTeam,
69 assignee: Option<LinearUser>,
70 project: Option<LinearProject>,
71 #[serde(rename = "projectMilestone")]
72 project_milestone: Option<LinearProjectMilestoneRef>,
73 cycle: Option<LinearCycleRef>,
74 #[serde(default)]
75 labels: LinearLabelConnection,
76 #[serde(default)]
77 relations: LinearRelationConnection,
78 #[serde(rename = "branchName")]
79 branch_name: Option<String>,
80}
81
82#[derive(Debug, Deserialize, Default)]
83struct LinearRelationConnection {
84 nodes: Vec<LinearRelation>,
85}
86
87#[derive(Debug, Deserialize)]
88struct IssueRelationsData {
89 issue: IssueRelationsNode,
90}
91
92#[derive(Debug, Deserialize)]
93struct IssueRelationsNode {
94 relations: PaginatedRelationConnection,
95}
96
97#[derive(Debug, Deserialize)]
98struct PaginatedRelationConnection {
99 nodes: Vec<LinearRelation>,
100 #[serde(rename = "pageInfo")]
101 page_info: PageInfo,
102}
103
104#[derive(Debug, Deserialize)]
105struct LinearRelation {
106 id: String,
107 #[serde(rename = "type")]
108 relation_type: String,
109 #[serde(rename = "relatedIssue")]
110 related_issue: LinearRelatedIssue,
111}
112
113#[derive(Debug, Deserialize)]
114struct LinearRelatedIssue {
115 id: String,
116 identifier: String,
117}
118
119#[derive(Debug, Deserialize)]
120struct LinearState {
121 name: String,
122 #[serde(rename = "type")]
123 state_type: String,
124}
125
126#[derive(Debug, Deserialize)]
127struct LinearTeam {
128 key: String,
129}
130
131#[derive(Debug, Deserialize)]
132struct LinearUser {
133 name: String,
134}
135
136#[derive(Debug, Deserialize)]
137struct LinearExternalUser {
138 name: Option<String>,
139 #[serde(rename = "displayName")]
140 display_name: Option<String>,
141}
142
143#[derive(Debug, Deserialize)]
144struct LinearProject {
145 id: String,
146 name: String,
147}
148
149#[derive(Debug, Deserialize)]
150struct LinearProjectMilestoneRef {
151 id: String,
152 name: String,
153}
154
155#[derive(Debug, Deserialize)]
156struct LinearCycleRef {
157 id: String,
158 name: Option<String>,
159 number: i32,
160}
161
162#[derive(Debug, Deserialize, Default)]
163struct LinearLabelConnection {
164 nodes: Vec<LinearLabel>,
165}
166
167#[derive(Debug, Deserialize)]
168struct IssueLabelsForIssueData {
169 issue: IssueLabelsForIssueNode,
170}
171
172#[derive(Debug, Deserialize)]
173struct IssueLabelsForIssueNode {
174 labels: PaginatedIssueLabelConnection,
175}
176
177#[derive(Debug, Deserialize)]
178struct PaginatedIssueLabelConnection {
179 nodes: Vec<LinearLabel>,
180 #[serde(rename = "pageInfo")]
181 page_info: PageInfo,
182}
183
184#[derive(Debug, Deserialize)]
185struct LinearLabel {
186 id: String,
187 name: String,
188}
189
190#[derive(Debug, Deserialize)]
193struct TeamsData {
194 teams: TeamConnection,
195}
196
197#[derive(Debug, Deserialize)]
198struct TeamConnection {
199 nodes: Vec<TeamNode>,
200 #[serde(rename = "pageInfo")]
201 page_info: PageInfo,
202}
203
204#[derive(Debug, Deserialize)]
205#[allow(dead_code)]
206pub struct TeamNode {
207 pub id: String,
208 pub key: String,
209 pub name: String,
210}
211
212#[derive(Debug, Clone)]
213pub struct LabelCatalogEntry {
214 pub id: String,
215 pub name: String,
216 pub color: Option<String>,
217 pub parent_id: Option<String>,
218}
219
220#[derive(Debug, Deserialize)]
221struct IssueLabelsData {
222 #[serde(rename = "issueLabels")]
223 issue_labels: IssueLabelCatalogConnection,
224}
225
226#[derive(Debug, Deserialize)]
227struct IssueLabelCatalogConnection {
228 nodes: Vec<IssueLabelCatalogNode>,
229 #[serde(rename = "pageInfo")]
230 page_info: PageInfo,
231}
232
233#[derive(Debug, Deserialize)]
234struct IssueLabelCatalogNode {
235 id: String,
236 name: String,
237 color: Option<String>,
238 parent: Option<IssueLabelParent>,
239}
240
241#[derive(Debug, Deserialize)]
242struct IssueLabelParent {
243 id: String,
244}
245
246#[derive(Debug, Deserialize)]
249struct CreateIssueData {
250 #[serde(rename = "issueCreate")]
251 issue_create: CreateIssuePayload,
252}
253
254#[derive(Debug, Deserialize)]
255struct CreateIssuePayload {
256 success: bool,
257 issue: Option<CreatedIssue>,
258}
259
260#[derive(Debug, Deserialize)]
261struct CreatedIssue {
262 id: String,
263 identifier: String,
264}
265
266#[derive(Debug)]
267pub struct CreateIssueInput<'a> {
268 pub team_id: &'a str,
269 pub title: &'a str,
270 pub description: Option<&'a str>,
271 pub priority: Option<i32>,
272 pub label_ids: &'a [String],
273 pub assignee_id: Option<&'a str>,
274 pub parent_id: Option<&'a str>,
275 pub project_id: Option<&'a str>,
276 pub project_milestone_id: Option<&'a str>,
277}
278
279#[derive(Debug, Deserialize)]
282struct CreateCommentData {
283 #[serde(rename = "commentCreate")]
284 comment_create: CreateCommentPayload,
285}
286
287#[derive(Debug, Deserialize)]
288struct CreateCommentPayload {
289 success: bool,
290}
291
292#[derive(Debug, Deserialize)]
295struct CommentsData {
296 comments: LinearCommentConnection,
297}
298
299#[derive(Debug, Deserialize)]
300struct LinearCommentConnection {
301 nodes: Vec<LinearComment>,
302 #[serde(rename = "pageInfo")]
303 page_info: PageInfo,
304}
305
306#[derive(Debug, Deserialize)]
307struct LinearComment {
308 id: String,
309 body: String,
310 #[serde(rename = "createdAt")]
311 created_at: String,
312 #[serde(rename = "updatedAt")]
313 updated_at: String,
314 #[serde(rename = "parentId")]
315 parent_id: Option<String>,
316 url: String,
317 user: Option<LinearUser>,
318 #[serde(rename = "externalUser")]
319 external_user: Option<LinearExternalUser>,
320}
321
322#[derive(Debug, Deserialize)]
325struct UpdateIssueData {
326 #[serde(rename = "issueUpdate")]
327 issue_update: UpdateIssuePayload,
328}
329
330#[derive(Debug, Deserialize)]
331struct UpdateIssuePayload {
332 success: bool,
333}
334
335#[derive(Debug, Default)]
336pub struct UpdateIssueInput<'a> {
337 pub title: Option<&'a str>,
338 pub description: Option<&'a str>,
339 pub priority: Option<i32>,
340 pub state_id: Option<&'a str>,
341 pub label_ids: Option<&'a [String]>,
342 pub project_id: Option<&'a str>,
343 pub assignee_id: Option<&'a str>,
344 pub project_milestone_id: Option<&'a str>,
345}
346
347#[derive(Debug, Deserialize)]
350struct CreateRelationData {
351 #[serde(rename = "issueRelationCreate")]
352 issue_relation_create: CreateRelationPayload,
353}
354
355#[derive(Debug, Deserialize)]
356struct CreateRelationPayload {
357 success: bool,
358 #[serde(rename = "issueRelation")]
359 issue_relation: Option<CreatedRelation>,
360}
361
362#[derive(Debug, Deserialize)]
363struct CreatedRelation {
364 id: String,
365}
366
367#[derive(Debug, Deserialize)]
368struct DeleteRelationData {
369 #[serde(rename = "issueRelationDelete")]
370 issue_relation_delete: DeleteRelationPayload,
371}
372
373#[derive(Debug, Deserialize)]
374struct DeleteRelationPayload {
375 success: bool,
376}
377
378#[derive(Debug, Deserialize)]
381struct SingleIssueData {
382 issue: LinearIssue,
383}
384
385impl LinearClient {
386 pub fn new(config: &Config) -> Result<Self> {
387 let api_key = config.linear_api_key()?.to_string();
388 let client = reqwest::Client::new();
389 Ok(Self {
390 client,
391 api_key,
392 viewer_id: std::sync::Arc::new(std::sync::RwLock::new(None)),
393 sync_query_config: SyncQueryConfig::from_environment(),
394 })
395 }
396
397 pub fn with_api_key(api_key: &str) -> Self {
399 Self {
400 client: reqwest::Client::new(),
401 api_key: api_key.to_string(),
402 viewer_id: std::sync::Arc::new(std::sync::RwLock::new(None)),
403 sync_query_config: SyncQueryConfig::from_environment(),
404 }
405 }
406
407 pub fn with_http_client(client: reqwest::Client, api_key: &str) -> Self {
412 Self {
413 client,
414 api_key: api_key.to_string(),
415 viewer_id: std::sync::Arc::new(std::sync::RwLock::new(None)),
416 sync_query_config: SyncQueryConfig::from_environment(),
417 }
418 }
419
420 pub fn with_sync_query_config(mut self, sync_query_config: SyncQueryConfig) -> Self {
421 self.sync_query_config = sync_query_config;
422 self
423 }
424
425 pub fn sync_query_config(&self) -> &SyncQueryConfig {
426 &self.sync_query_config
427 }
428
429 fn observe_sync_event(&self, event: SyncEvent) {
430 if !self.sync_query_config.verbose {
431 return;
432 }
433 let parent = event
434 .parent
435 .as_deref()
436 .map(|value| format!(" parent={value}"))
437 .unwrap_or_default();
438 let reduction = if event.adaptive_reduction {
439 " adaptive-page-size=true"
440 } else {
441 ""
442 };
443 if let Some(failure) = event.failure {
444 eprintln!(
445 "sync operation={}{} page={} nodes={} page_size={}{} status=failed error={}",
446 event.operation,
447 parent,
448 event.page_number,
449 event.nodes_received,
450 event.page_size,
451 reduction,
452 failure
453 );
454 } else {
455 eprintln!(
456 "sync operation={}{} page={} nodes={} page_size={}{} status={}",
457 event.operation,
458 parent,
459 event.page_number,
460 event.nodes_received,
461 event.page_size,
462 reduction,
463 if event.completed { "complete" } else { "running" }
464 );
465 }
466 }
467
468 async fn query<T: serde::de::DeserializeOwned>(
469 &self,
470 query: &str,
471 variables: serde_json::Value,
472 ) -> Result<T> {
473 self.query_operation("GraphQL query", None, query, variables)
474 .await
475 }
476
477 async fn query_operation<T: serde::de::DeserializeOwned>(
478 &self,
479 operation: &str,
480 cursor: Option<&str>,
481 query: &str,
482 variables: serde_json::Value,
483 ) -> Result<T> {
484 let body = serde_json::json!({
485 "query": query,
486 "variables": variables,
487 });
488
489 let resp = self
490 .client
491 .post(LINEAR_API_URL)
492 .header("Authorization", &self.api_key)
493 .header("Content-Type", "application/json")
494 .json(&body)
495 .send()
496 .await
497 .map_err(|error| {
498 LinearOperationError::new(
499 LinearErrorKind::Transport,
500 operation,
501 cursor,
502 error.to_string(),
503 )
504 })?;
505
506 let status = resp.status();
507 if !status.is_success() {
508 let retry_after = resp
509 .headers()
510 .get(reqwest::header::RETRY_AFTER)
511 .and_then(|value| value.to_str().ok())
512 .and_then(|value| value.parse::<u64>().ok())
513 .map(Duration::from_secs);
514 let kind = classify_http_status(status.as_u16());
515 return Err(LinearOperationError::new(
516 kind,
517 operation,
518 cursor,
519 format!("HTTP {status} (response body omitted)"),
520 )
521 .with_retry_after(retry_after)
522 .into());
523 }
524
525 let response: GraphQLResponse<T> = resp
526 .json()
527 .await
528 .map_err(|error| {
529 LinearOperationError::new(
530 LinearErrorKind::Api,
531 operation,
532 cursor,
533 format!("failed to parse response: {error}"),
534 )
535 })?;
536
537 if let Some(errors) = response.errors {
538 let message = errors
539 .iter()
540 .map(|error| error.message.as_str())
541 .collect::<Vec<_>>()
542 .join(", ");
543 let kind = classify_graphql_message(&message);
544 return Err(LinearOperationError::new(kind, operation, cursor, message).into());
545 }
546
547 response.data.ok_or_else(|| {
548 LinearOperationError::new(
549 LinearErrorKind::Api,
550 operation,
551 cursor,
552 "response did not contain data",
553 )
554 .into()
555 })
556 }
557
558 pub async fn list_teams(&self) -> Result<Vec<TeamNode>> {
559 let query = r#"
560 query($first: Int!, $after: String) {
561 teams(first: $first, after: $after, orderBy: updatedAt) {
562 nodes { id key name }
563 pageInfo { hasNextPage endCursor }
564 }
565 }
566 "#;
567 let mut teams = Vec::new();
568 paginate(
569 &self.sync_query_config,
570 LinearOperation::Teams,
571 None,
572 |request| async move {
573 let data: TeamsData = self
574 .query_operation(
575 LinearOperation::Teams.name(),
576 request.cursor.as_deref(),
577 query,
578 serde_json::json!({
579 "first": request.page_size,
580 "after": request.cursor,
581 }),
582 )
583 .await?;
584 Ok(ConnectionPage {
585 nodes: data.teams.nodes,
586 page_info: data.teams.page_info,
587 })
588 },
589 |nodes, _| {
590 teams.extend(nodes);
591 ready(Ok(()))
592 },
593 |team| team.id.clone(),
594 |event| self.observe_sync_event(event),
595 )
596 .await?;
597 Ok(teams)
598 }
599
600 fn extract_relations(issue_id: &str, linear_issue: &LinearIssue) -> Vec<db::Relation> {
601 linear_issue
602 .relations
603 .nodes
604 .iter()
605 .map(|r| db::Relation {
606 id: r.id.clone(),
607 issue_id: issue_id.to_string(),
608 related_issue_id: r.related_issue.id.clone(),
609 related_issue_identifier: r.related_issue.identifier.clone(),
610 relation_type: r.relation_type.clone(),
611 })
612 .collect()
613 }
614
615 fn convert_linear_relation(issue_id: &str, relation: LinearRelation) -> db::Relation {
616 db::Relation {
617 id: relation.id,
618 issue_id: issue_id.to_string(),
619 related_issue_id: relation.related_issue.id,
620 related_issue_identifier: relation.related_issue.identifier,
621 relation_type: relation.relation_type,
622 }
623 }
624
625 pub async fn fetch_issues(
626 &self,
627 team_key: &str,
628 after_cursor: Option<&str>,
629 updated_after: Option<&str>,
630 include_archived: bool,
631 ) -> Result<(Vec<(db::Issue, Vec<db::Relation>, Vec<String>)>, bool, Option<String>)> {
632 let page = self
633 .fetch_issues_page(
634 team_key,
635 after_cursor,
636 updated_after,
637 include_archived,
638 self.sync_query_config.page_size(LinearOperation::Issues),
639 )
640 .await?;
641 Ok((
642 page.nodes,
643 page.page_info.has_next_page,
644 page.page_info.end_cursor,
645 ))
646 }
647
648 async fn fetch_issues_page(
649 &self,
650 team_key: &str,
651 after_cursor: Option<&str>,
652 updated_after: Option<&str>,
653 include_archived: bool,
654 page_size: usize,
655 ) -> Result<ConnectionPage<(db::Issue, Vec<db::Relation>, Vec<String>)>> {
656 let mut filter_parts = vec![format!("team: {{ key: {{ eq: \"{}\" }} }}", team_key)];
657 if let Some(after) = updated_after {
658 filter_parts.push(format!("updatedAt: {{ gt: \"{}\" }}", after));
659 }
660 let filter = filter_parts.join(", ");
661 let query = format!(
662 r#"query($first: Int!, $after: String, $includeArchived: Boolean!) {{
663 issues(
664 first: $first,
665 after: $after,
666 filter: {{ {} }},
667 includeArchived: $includeArchived,
668 orderBy: updatedAt
669 ) {{
670 nodes {{
671 id identifier url title description priority branchName
672 createdAt updatedAt
673 state {{ name type }}
674 team {{ key }}
675 assignee {{ name }}
676 project {{ id name }}
677 projectMilestone {{ id name }}
678 cycle {{ id name number }}
679 }}
680 pageInfo {{ hasNextPage endCursor }}
681 }}
682 }}"#,
683 filter
684 );
685
686 let data: IssuesData = self
687 .query_operation(
688 LinearOperation::Issues.name(),
689 after_cursor,
690 &query,
691 issue_page_variables(page_size, after_cursor, include_archived),
692 )
693 .await?;
694
695 let issues: Vec<(db::Issue, Vec<db::Relation>, Vec<String>)> = data
696 .issues
697 .nodes
698 .into_iter()
699 .map(Self::convert_linear_issue)
700 .collect();
701
702 Ok(ConnectionPage { nodes: issues, page_info: data.issues.page_info })
703 }
704
705 pub async fn sync_team(
706 &self,
707 db: &Database,
708 team_key: &str,
709 workspace_id: &str,
710 full: bool,
711 include_archived: bool,
712 progress: Option<&(dyn Fn(usize) + Send + Sync)>,
713 ) -> Result<usize> {
714 self.sync_projects_for_team(db, workspace_id, team_key, include_archived).await.with_context(|| {
715 format!("project synchronization failed for workspace '{workspace_id}'")
716 })?;
717 self.sync_labels_catalog(db, workspace_id)
718 .await
719 .with_context(|| format!("label synchronization failed for workspace '{workspace_id}'"))?;
720 self.sync_cycles(db, team_key, workspace_id, include_archived)
721 .await
722 .with_context(|| format!("cycle synchronization failed for team '{team_key}'"))?;
723
724 let updated_after = if full {
725 None
726 } else {
727 db.get_sync_cursor(workspace_id, team_key)?
728 };
729
730 let sync_token = Uuid::new_v4().to_string();
731 let mut max_updated: Option<String> = None;
732 let mut persisted_total = 0;
733 db.mark_sync_family_running(
734 workspace_id,
735 team_key,
736 "issues",
737 None,
738 Some(self.sync_query_config.page_size(LinearOperation::Issues)),
739 &sync_token,
740 )?;
741 let issue_result = paginate(
742 &self.sync_query_config,
743 LinearOperation::Issues,
744 Some(team_key.to_string()),
745 |request| {
746 let updated_after = updated_after.clone();
747 async move {
748 self.fetch_issues_page(
749 team_key,
750 request.cursor.as_deref(),
751 updated_after.as_deref(),
752 include_archived,
753 request.page_size,
754 )
755 .await
756 }
757 },
758 |issues, context| {
759 let count = issues.len();
760 let result = (|| {
761 for (mut issue, _relations, _label_ids) in issues {
762 issue.workspace_id = workspace_id.to_string();
763 if max_updated.is_none()
764 || Some(&issue.updated_at) > max_updated.as_ref()
765 {
766 max_updated = Some(issue.updated_at.clone());
767 }
768 db.upsert_issue_preserving_labels(&issue)?;
769 db.mark_issue_sync_token(&issue.id, &sync_token)?;
770 }
771 persisted_total += count;
772 db.mark_sync_family_running(
773 workspace_id,
774 team_key,
775 "issues",
776 context.cursor.as_deref(),
777 Some(context.page_size),
778 &sync_token,
779 )?;
780 if let Some(callback) = progress {
781 callback(persisted_total);
782 }
783 Ok(())
784 })();
785 ready(result)
786 },
787 |(issue, _, _)| issue.id.clone(),
788 |event| self.observe_sync_event(event),
789 )
790 .await;
791
792 let stats = match issue_result {
793 Ok(stats) => stats,
794 Err(error) => {
795 let message = Self::redacted_error_message(&error);
796 db.mark_sync_family_failed(
797 workspace_id,
798 team_key,
799 "issues",
800 &sync_token,
801 &message,
802 )?;
803 return Err(error);
804 }
805 };
806 if full {
807 db.reconcile_full_issue_sync(workspace_id, team_key, &sync_token)?;
808 }
809 db.mark_sync_family_complete(
810 workspace_id,
811 team_key,
812 "issues",
813 Some(self.sync_query_config.page_size(LinearOperation::Issues)),
814 &sync_token,
815 )?;
816
817 for family in ["issue labels", "relations", "comments"] {
818 db.mark_sync_family_running(
819 workspace_id,
820 team_key,
821 family,
822 None,
823 None,
824 &sync_token,
825 )?;
826 }
827 let hydration_result: Result<()> = async {
828 let mut after_id = None;
829 loop {
830 let issue_refs = db.list_issue_sync_refs(
831 workspace_id,
832 team_key,
833 &sync_token,
834 after_id.as_deref(),
835 100,
836 )?;
837 if issue_refs.is_empty() {
838 break;
839 }
840 for issue in &issue_refs {
841 self.sync_issue_labels(db, &issue.id)
842 .await
843 .with_context(|| {
844 format!("label synchronization failed for {}", issue.identifier)
845 })?;
846 self.sync_issue_relations(db, &issue.id)
847 .await
848 .with_context(|| {
849 format!("relation synchronization failed for {}", issue.identifier)
850 })?;
851 self.sync_issue_comments(db, &issue.id, workspace_id)
852 .await
853 .with_context(|| {
854 format!("comment synchronization failed for {}", issue.identifier)
855 })?;
856 }
857 after_id = issue_refs.last().map(|issue| issue.id.clone());
858 }
859 Ok(())
860 }
861 .await;
862 if let Err(error) = hydration_result {
863 let message = Self::redacted_error_message(&error);
864 for family in ["issue labels", "relations", "comments"] {
865 db.mark_sync_family_failed(
866 workspace_id,
867 team_key,
868 family,
869 &sync_token,
870 &message,
871 )?;
872 }
873 return Err(error);
874 }
875 for family in ["issue labels", "relations", "comments"] {
876 db.mark_sync_family_complete(
877 workspace_id,
878 team_key,
879 family,
880 None,
881 &sync_token,
882 )?;
883 }
884
885 let next_updated = max_updated
886 .or(updated_after)
887 .unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
888 db.set_sync_cursor(workspace_id, team_key, &next_updated)?;
889 Ok(stats.nodes)
890 }
891
892 pub async fn create_issue(&self, create: CreateIssueInput<'_>) -> Result<(String, String)> {
893 let input = create_issue_value(&create);
894
895 let query = r#"
896 mutation($input: IssueCreateInput!) {
897 issueCreate(input: $input) {
898 success
899 issue { id identifier }
900 }
901 }
902 "#;
903
904 let data: CreateIssueData = self
905 .query(query, serde_json::json!({ "input": input }))
906 .await?;
907
908 if !data.issue_create.success {
909 anyhow::bail!("Failed to create issue");
910 }
911
912 let issue = data.issue_create.issue.context("No issue returned")?;
913 Ok((issue.id, issue.identifier))
914 }
915
916 pub async fn add_comment(&self, issue_id: &str, body: &str) -> Result<()> {
917 let query = r#"
918 mutation($input: CommentCreateInput!) {
919 commentCreate(input: $input) {
920 success
921 }
922 }
923 "#;
924
925 let input = serde_json::json!({
926 "issueId": issue_id,
927 "body": body,
928 });
929
930 let data: CreateCommentData = self
931 .query(query, serde_json::json!({ "input": input }))
932 .await?;
933
934 if !data.comment_create.success {
935 anyhow::bail!("Failed to create comment");
936 }
937
938 Ok(())
939 }
940
941 async fn fetch_issue_comments_page(
942 &self,
943 issue_id: &str,
944 cursor: Option<&str>,
945 page_size: usize,
946 ) -> Result<ConnectionPage<db::Comment>> {
947 let query = r#"
948 query($issueId: ID!, $first: Int!, $after: String) {
949 comments(
950 filter: { issue: { id: { eq: $issueId } } },
951 first: $first,
952 after: $after,
953 includeArchived: true,
954 orderBy: createdAt
955 ) {
956 nodes {
957 id body createdAt updatedAt parentId url
958 user { name }
959 externalUser { displayName name }
960 }
961 pageInfo { hasNextPage endCursor }
962 }
963 }
964 "#;
965 let data: CommentsData = self
966 .query_operation(
967 LinearOperation::Comments.name(),
968 cursor,
969 query,
970 serde_json::json!({
971 "issueId": issue_id,
972 "first": page_size,
973 "after": cursor,
974 }),
975 )
976 .await?;
977 Ok(ConnectionPage {
978 nodes: data
979 .comments
980 .nodes
981 .into_iter()
982 .map(|comment| Self::convert_linear_comment(issue_id, comment))
983 .collect(),
984 page_info: data.comments.page_info,
985 })
986 }
987
988 pub async fn fetch_issue_comments(&self, issue_id: &str) -> Result<Vec<db::Comment>> {
989 let mut comments = Vec::new();
990 paginate(
991 &self.sync_query_config,
992 LinearOperation::Comments,
993 Some(issue_id.to_string()),
994 |request| async move {
995 self.fetch_issue_comments_page(
996 issue_id,
997 request.cursor.as_deref(),
998 request.page_size,
999 )
1000 .await
1001 },
1002 |nodes, _| {
1003 comments.extend(nodes);
1004 ready(Ok(()))
1005 },
1006 |comment| comment.id.clone(),
1007 |event| self.observe_sync_event(event),
1008 )
1009 .await?;
1010 Ok(comments)
1011 }
1012
1013 pub async fn sync_issue_comments(
1014 &self,
1015 db: &Database,
1016 issue_id: &str,
1017 workspace_id: &str,
1018 ) -> Result<usize> {
1019 let sync_token = Uuid::new_v4().to_string();
1020 let result = paginate(
1021 &self.sync_query_config,
1022 LinearOperation::Comments,
1023 Some(issue_id.to_string()),
1024 |request| async move {
1025 self.fetch_issue_comments_page(
1026 issue_id,
1027 request.cursor.as_deref(),
1028 request.page_size,
1029 )
1030 .await
1031 },
1032 |mut comments, _| {
1033 for comment in &mut comments {
1034 comment.workspace_id = workspace_id.to_string();
1035 }
1036 ready(db.upsert_comment_page(
1037 issue_id,
1038 workspace_id,
1039 &comments,
1040 &sync_token,
1041 ))
1042 },
1043 |comment| comment.id.clone(),
1044 |event| self.observe_sync_event(event),
1045 )
1046 .await;
1047 match result {
1048 Ok(stats) => {
1049 db.complete_comment_sync(issue_id, workspace_id, &sync_token)?;
1050 db.mark_comments_synced(issue_id, workspace_id, stats.nodes)?;
1051 Ok(stats.nodes)
1052 }
1053 Err(error) => {
1054 let status = Self::comment_error_status(&error);
1055 let message = Self::redacted_error_message(&error);
1056 db.mark_comments_sync_failed(issue_id, workspace_id, status, &message)?;
1057 Err(error)
1058 }
1059 }
1060 }
1061
1062 async fn fetch_issue_relations_page(
1063 &self,
1064 issue_id: &str,
1065 cursor: Option<&str>,
1066 page_size: usize,
1067 ) -> Result<ConnectionPage<db::Relation>> {
1068 let query = r#"
1069 query($issueId: String!, $first: Int!, $after: String) {
1070 issue(id: $issueId) {
1071 relations(first: $first, after: $after) {
1072 nodes { id type relatedIssue { id identifier } }
1073 pageInfo { hasNextPage endCursor }
1074 }
1075 }
1076 }
1077 "#;
1078 let data: IssueRelationsData = self
1079 .query_operation(
1080 LinearOperation::Relations.name(),
1081 cursor,
1082 query,
1083 serde_json::json!({
1084 "issueId": issue_id,
1085 "first": page_size,
1086 "after": cursor,
1087 }),
1088 )
1089 .await?;
1090 Ok(ConnectionPage {
1091 nodes: data
1092 .issue
1093 .relations
1094 .nodes
1095 .into_iter()
1096 .map(|relation| Self::convert_linear_relation(issue_id, relation))
1097 .collect(),
1098 page_info: data.issue.relations.page_info,
1099 })
1100 }
1101
1102 pub async fn sync_issue_relations(
1103 &self,
1104 db: &Database,
1105 issue_id: &str,
1106 ) -> Result<usize> {
1107 let sync_token = Uuid::new_v4().to_string();
1108 let stats = paginate(
1109 &self.sync_query_config,
1110 LinearOperation::Relations,
1111 Some(issue_id.to_string()),
1112 |request| async move {
1113 self.fetch_issue_relations_page(
1114 issue_id,
1115 request.cursor.as_deref(),
1116 request.page_size,
1117 )
1118 .await
1119 },
1120 |relations, _| {
1121 ready(db.upsert_relation_page(issue_id, &relations, &sync_token))
1122 },
1123 |relation| relation.id.clone(),
1124 |event| self.observe_sync_event(event),
1125 )
1126 .await?;
1127 db.complete_relation_sync(issue_id, &sync_token)?;
1128 Ok(stats.nodes)
1129 }
1130
1131 async fn fetch_issue_labels_page(
1132 &self,
1133 issue_id: &str,
1134 cursor: Option<&str>,
1135 page_size: usize,
1136 ) -> Result<ConnectionPage<LinearLabel>> {
1137 let query = r#"
1138 query($issueId: String!, $first: Int!, $after: String) {
1139 issue(id: $issueId) {
1140 labels(first: $first, after: $after, orderBy: updatedAt) {
1141 nodes { id name }
1142 pageInfo { hasNextPage endCursor }
1143 }
1144 }
1145 }
1146 "#;
1147 let data: IssueLabelsForIssueData = self
1148 .query_operation(
1149 "issue labels",
1150 cursor,
1151 query,
1152 serde_json::json!({
1153 "issueId": issue_id,
1154 "first": page_size,
1155 "after": cursor,
1156 }),
1157 )
1158 .await?;
1159 Ok(ConnectionPage {
1160 nodes: data.issue.labels.nodes,
1161 page_info: data.issue.labels.page_info,
1162 })
1163 }
1164
1165 pub async fn sync_issue_labels(&self, db: &Database, issue_id: &str) -> Result<usize> {
1166 let sync_token = Uuid::new_v4().to_string();
1167 let mut names = Vec::new();
1168 let stats = paginate(
1169 &self.sync_query_config,
1170 LinearOperation::Labels,
1171 Some(issue_id.to_string()),
1172 |request| async move {
1173 self.fetch_issue_labels_page(
1174 issue_id,
1175 request.cursor.as_deref(),
1176 request.page_size,
1177 )
1178 .await
1179 },
1180 |labels, _| {
1181 let ids = labels
1182 .iter()
1183 .map(|label| label.id.clone())
1184 .collect::<Vec<_>>();
1185 names.extend(labels.into_iter().map(|label| label.name));
1186 ready(db.upsert_issue_label_page(issue_id, &ids, &sync_token))
1187 },
1188 |label| label.id.clone(),
1189 |event| self.observe_sync_event(event),
1190 )
1191 .await?;
1192 db.complete_issue_label_sync(issue_id, &sync_token)?;
1193
1194 let mut issue = db
1195 .get_issue(issue_id)?
1196 .with_context(|| format!("issue '{issue_id}' disappeared during label sync"))?;
1197 issue.labels_json = serde_json::to_string(&names)?;
1198 let mut hasher = Sha256::new();
1199 hasher.update(&issue.title);
1200 hasher.update(issue.description.as_deref().unwrap_or(""));
1201 hasher.update(&issue.labels_json);
1202 issue.content_hash = hex::encode(hasher.finalize());
1203 db.upsert_issue(&issue)?;
1204 Ok(stats.nodes)
1205 }
1206
1207 async fn fetch_all_issue_labels_remote(&self, issue_id: &str) -> Result<Vec<LinearLabel>> {
1208 let mut labels = Vec::new();
1209 paginate(
1210 &self.sync_query_config,
1211 LinearOperation::Labels,
1212 Some(issue_id.to_string()),
1213 |request| async move {
1214 self.fetch_issue_labels_page(
1215 issue_id,
1216 request.cursor.as_deref(),
1217 request.page_size,
1218 )
1219 .await
1220 },
1221 |nodes, _| {
1222 labels.extend(nodes);
1223 ready(Ok(()))
1224 },
1225 |label| label.id.clone(),
1226 |event| self.observe_sync_event(event),
1227 )
1228 .await?;
1229 Ok(labels)
1230 }
1231
1232 async fn fetch_all_issue_relations_remote(
1233 &self,
1234 issue_id: &str,
1235 ) -> Result<Vec<db::Relation>> {
1236 let mut relations = Vec::new();
1237 paginate(
1238 &self.sync_query_config,
1239 LinearOperation::Relations,
1240 Some(issue_id.to_string()),
1241 |request| async move {
1242 self.fetch_issue_relations_page(
1243 issue_id,
1244 request.cursor.as_deref(),
1245 request.page_size,
1246 )
1247 .await
1248 },
1249 |nodes, _| {
1250 relations.extend(nodes);
1251 ready(Ok(()))
1252 },
1253 |relation| relation.id.clone(),
1254 |event| self.observe_sync_event(event),
1255 )
1256 .await?;
1257 Ok(relations)
1258 }
1259
1260 pub fn comment_error_status(error: &anyhow::Error) -> &'static str {
1261 let message = error.to_string().to_lowercase();
1262 if message.contains("permission")
1263 || message.contains("forbidden")
1264 || message.contains("unauthorized")
1265 || message.contains("access")
1266 {
1267 "permission_denied"
1268 } else {
1269 "unavailable"
1270 }
1271 }
1272
1273 fn redacted_error_message(error: &anyhow::Error) -> String {
1274 error.to_string().chars().take(500).collect()
1275 }
1276
1277 pub async fn update_issue(
1278 &self,
1279 issue_id: &str,
1280 update: UpdateIssueInput<'_>,
1281 ) -> Result<()> {
1282 let mut input = serde_json::Map::new();
1283 if let Some(t) = update.title {
1284 input.insert("title".into(), serde_json::Value::String(t.to_string()));
1285 }
1286 if let Some(d) = update.description {
1287 input.insert(
1288 "description".into(),
1289 serde_json::Value::String(d.to_string()),
1290 );
1291 }
1292 if let Some(p) = update.priority {
1293 input.insert("priority".into(), serde_json::Value::Number(p.into()));
1294 }
1295 if let Some(sid) = update.state_id {
1296 input.insert("stateId".into(), serde_json::Value::String(sid.to_string()));
1297 }
1298 if let Some(lids) = update.label_ids {
1299 input.insert("labelIds".into(), serde_json::json!(lids));
1300 }
1301 if let Some(pid) = update.project_id {
1302 let value = if pid.is_empty() {
1303 serde_json::Value::Null
1304 } else {
1305 serde_json::Value::String(pid.to_string())
1306 };
1307 input.insert("projectId".into(), value);
1308 }
1309 if let Some(aid) = update.assignee_id {
1310 let value = if aid.is_empty() {
1311 serde_json::Value::Null
1312 } else {
1313 serde_json::Value::String(aid.to_string())
1314 };
1315 input.insert("assigneeId".into(), value);
1316 }
1317 if let Some(mid) = update.project_milestone_id {
1318 let value = if mid.is_empty() {
1319 serde_json::Value::Null
1320 } else {
1321 serde_json::Value::String(mid.to_string())
1322 };
1323 input.insert("projectMilestoneId".into(), value);
1324 }
1325
1326 let query = r#"
1327 mutation($id: String!, $input: IssueUpdateInput!) {
1328 issueUpdate(id: $id, input: $input) {
1329 success
1330 }
1331 }
1332 "#;
1333
1334 let data: UpdateIssueData = self
1335 .query(query, serde_json::json!({ "id": issue_id, "input": input }))
1336 .await?;
1337
1338 if !data.issue_update.success {
1339 anyhow::bail!("Failed to update issue");
1340 }
1341
1342 Ok(())
1343 }
1344
1345 pub async fn fetch_single_issue(
1346 &self,
1347 issue_id: &str,
1348 ) -> Result<(db::Issue, Vec<db::Relation>, Vec<String>)> {
1349 let query = r#"
1350 query($id: String!) {
1351 issue(id: $id) {
1352 id identifier url title description priority branchName
1353 createdAt updatedAt
1354 state { name type }
1355 team { key }
1356 assignee { name }
1357 project { id name }
1358 projectMilestone { id name }
1359 cycle { id name number }
1360 }
1361 }
1362 "#;
1363
1364 let data: SingleIssueData = self
1365 .query(query, serde_json::json!({ "id": issue_id }))
1366 .await?;
1367 let issue_id = data.issue.id.clone();
1368 let (mut issue, _, _) = Self::convert_linear_issue(data.issue);
1369 let labels = self.fetch_all_issue_labels_remote(&issue_id).await?;
1370 let label_ids = apply_issue_labels(&mut issue, labels);
1371 let relations = self.fetch_all_issue_relations_remote(&issue_id).await?;
1372 Ok((issue, relations, label_ids))
1373 }
1374
1375 pub async fn fetch_issue_by_identifier(
1378 &self,
1379 identifier: &str,
1380 ) -> Result<Option<(db::Issue, Vec<db::Relation>, Vec<String>)>> {
1381 let parts: Vec<&str> = identifier.rsplitn(2, '-').collect();
1383 if parts.len() != 2 {
1384 anyhow::bail!(
1385 "Invalid issue identifier '{}': expected format like 'ENG-123'",
1386 identifier
1387 );
1388 }
1389 let number: i32 = parts[0]
1390 .parse()
1391 .with_context(|| format!("Invalid issue number in '{}'", identifier))?;
1392 let team_key = parts[1];
1393
1394 let query = format!(
1395 r#"query {{
1396 issues(
1397 filter: {{
1398 team: {{ key: {{ eq: "{}" }} }},
1399 number: {{ eq: {} }}
1400 }},
1401 first: 1,
1402 includeArchived: true
1403 ) {{
1404 nodes {{
1405 id identifier url title description priority branchName
1406 createdAt updatedAt
1407 state {{ name type }}
1408 team {{ key }}
1409 assignee {{ name }}
1410 project {{ id name }}
1411 projectMilestone {{ id name }}
1412 cycle {{ id name number }}
1413 }}
1414 pageInfo {{ hasNextPage endCursor }}
1415 }}
1416 }}"#,
1417 team_key, number
1418 );
1419
1420 let data: IssuesData = self.query(&query, serde_json::json!({})).await?;
1421
1422 let Some(linear_issue) = data.issues.nodes.into_iter().next() else {
1423 return Ok(None);
1424 };
1425 let issue_id = linear_issue.id.clone();
1426 let (mut issue, _, _) = Self::convert_linear_issue(linear_issue);
1427 let labels = self.fetch_all_issue_labels_remote(&issue_id).await?;
1428 let label_ids = apply_issue_labels(&mut issue, labels);
1429 let relations = self.fetch_all_issue_relations_remote(&issue_id).await?;
1430 Ok(Some((issue, relations, label_ids)))
1431 }
1432
1433 fn convert_linear_issue(i: LinearIssue) -> (db::Issue, Vec<db::Relation>, Vec<String>) {
1434 let labels: Vec<String> = i.labels.nodes.iter().map(|l| l.name.clone()).collect();
1435 let label_ids: Vec<String> = i.labels.nodes.iter().map(|l| l.id.clone()).collect();
1436 let labels_json = serde_json::to_string(&labels).unwrap_or_else(|_| "[]".to_string());
1437
1438 let mut hasher = Sha256::new();
1439 hasher.update(&i.title);
1440 hasher.update(i.description.as_deref().unwrap_or(""));
1441 hasher.update(&labels_json);
1442 let content_hash = hex::encode(hasher.finalize());
1443
1444 let relations = Self::extract_relations(&i.id, &i);
1445
1446 let project_id = i.project.as_ref().map(|project| project.id.clone());
1447 let project_name = i.project.map(|project| project.name);
1448 let project_milestone_id = i
1449 .project_milestone
1450 .as_ref()
1451 .map(|milestone| milestone.id.clone());
1452 let project_milestone_name = i.project_milestone.map(|milestone| milestone.name);
1453 let cycle_id = i.cycle.as_ref().map(|cycle| cycle.id.clone());
1454 let cycle_name = i.cycle.map(|cycle| {
1455 cycle
1456 .name
1457 .unwrap_or_else(|| format!("Cycle {}", cycle.number))
1458 });
1459
1460 let issue = db::Issue {
1461 id: i.id,
1462 identifier: i.identifier,
1463 url: i.url,
1464 team_key: i.team.key,
1465 title: i.title,
1466 description: i.description,
1467 state_name: i.state.name,
1468 state_type: i.state.state_type,
1469 priority: i.priority,
1470 assignee_name: i.assignee.map(|a| a.name),
1471 project_name,
1472 labels_json,
1473 created_at: i.created_at,
1474 updated_at: i.updated_at,
1475 content_hash,
1476 synced_at: None,
1477 branch_name: i.branch_name,
1478 workspace_id: "default".to_string(),
1479 project_id,
1480 project_milestone_id,
1481 project_milestone_name,
1482 cycle_id,
1483 cycle_name,
1484 };
1485
1486 (issue, relations, label_ids)
1487 }
1488
1489 fn convert_linear_comment(issue_id: &str, comment: LinearComment) -> db::Comment {
1490 let external_name = comment
1491 .external_user
1492 .and_then(|u| u.display_name.or(u.name));
1493 db::Comment {
1494 id: comment.id,
1495 issue_id: issue_id.to_string(),
1496 body: comment.body,
1497 user_name: comment.user.map(|u| u.name).or(external_name),
1498 created_at: comment.created_at,
1499 updated_at: Some(comment.updated_at),
1500 parent_id: comment.parent_id,
1501 url: Some(comment.url),
1502 workspace_id: "default".to_string(),
1503 }
1504 }
1505
1506 pub async fn get_team_id(&self, team_key: &str) -> Result<String> {
1508 let teams = self.list_teams().await?;
1509 teams
1510 .iter()
1511 .find(|t| t.key.eq_ignore_ascii_case(team_key))
1512 .map(|t| t.id.clone())
1513 .with_context(|| format!("Team '{}' not found", team_key))
1514 }
1515
1516 pub async fn get_state_id(&self, team_key: &str, state_name: &str) -> Result<String> {
1519 let team_id = self.get_team_id(team_key).await?;
1520 let query = r#"
1521 query($teamId: String!) {
1522 team(id: $teamId) {
1523 states { nodes { id name type } }
1524 }
1525 }
1526 "#;
1527
1528 let data: serde_json::Value = self
1529 .query(query, serde_json::json!({ "teamId": team_id }))
1530 .await?;
1531
1532 let states = data["team"]["states"]["nodes"]
1533 .as_array()
1534 .context("No states in response")?;
1535
1536 for state in states {
1537 if let Some(name) = state["name"].as_str() {
1538 if name.eq_ignore_ascii_case(state_name) {
1539 return state["id"]
1540 .as_str()
1541 .map(|s| s.to_string())
1542 .context("State has no id");
1543 }
1544 }
1545 }
1546
1547 for state in states {
1549 if let Some(t) = state["type"].as_str() {
1550 if t.eq_ignore_ascii_case(state_name) {
1551 return state["id"]
1552 .as_str()
1553 .map(|s| s.to_string())
1554 .context("State has no id");
1555 }
1556 }
1557 }
1558
1559 let available: Vec<&str> = states.iter().filter_map(|s| s["name"].as_str()).collect();
1560 anyhow::bail!(
1561 "State '{}' not found for team {}. Available: {}",
1562 state_name,
1563 team_key,
1564 available.join(", ")
1565 )
1566 }
1567
1568 pub async fn get_label_ids(&self, label_names: &[String]) -> Result<Vec<String>> {
1572 if label_names.is_empty() {
1573 return Ok(Vec::new());
1574 }
1575
1576 let labels = self.fetch_labels().await?;
1577
1578 let mut ids = Vec::new();
1579 for name in label_names {
1580 let found = labels
1581 .iter()
1582 .find(|label| label.name.eq_ignore_ascii_case(name));
1583 match found {
1584 Some(label) => ids.push(label.id.clone()),
1585 None => {
1586 let available = labels
1587 .iter()
1588 .map(|label| label.name.as_str())
1589 .collect::<Vec<_>>();
1590 anyhow::bail!(
1591 "Label '{}' not found. Available: {}",
1592 name,
1593 available.join(", ")
1594 );
1595 }
1596 }
1597 }
1598
1599 Ok(ids)
1600 }
1601
1602 pub async fn resolve_assignee_id(&self, input: &str) -> Result<String> {
1609 let trimmed = input.trim();
1610 if trimmed.eq_ignore_ascii_case("none") {
1611 return Ok(String::new());
1612 }
1613 if trimmed.eq_ignore_ascii_case("me") {
1614 if let Some(cached) = self.viewer_id.read().unwrap().clone() {
1615 return Ok(cached);
1616 }
1617 let data: serde_json::Value = self
1618 .query("query { viewer { id } }", serde_json::json!({}))
1619 .await?;
1620 let id = data["viewer"]["id"]
1621 .as_str()
1622 .context("viewer query returned no id")?
1623 .to_string();
1624 *self.viewer_id.write().unwrap() = Some(id.clone());
1625 return Ok(id);
1626 }
1627
1628 let data: serde_json::Value = self
1630 .query(
1631 "query { users(first: 250) { nodes { id name } } }",
1632 serde_json::json!({}),
1633 )
1634 .await?;
1635 let nodes = data["users"]["nodes"]
1636 .as_array()
1637 .context("users query returned no nodes")?;
1638 let matches: Vec<(String, String)> = nodes
1639 .iter()
1640 .filter_map(|n| {
1641 let name = n["name"].as_str()?;
1642 if name.eq_ignore_ascii_case(trimmed) {
1643 Some((n["id"].as_str()?.to_string(), name.to_string()))
1644 } else {
1645 None
1646 }
1647 })
1648 .collect();
1649
1650 match matches.len() {
1651 0 => anyhow::bail!("Assignee '{}' not found in Linear users.", trimmed),
1652 1 => Ok(matches.into_iter().next().unwrap().0),
1653 _ => {
1654 let names: Vec<&str> = matches.iter().map(|(_, n)| n.as_str()).collect();
1655 anyhow::bail!(
1656 "Assignee '{}' matched multiple users: {}. Use a more specific name.",
1657 trimmed,
1658 names.join(", ")
1659 )
1660 }
1661 }
1662 }
1663
1664 pub async fn fetch_labels(&self) -> Result<Vec<LabelCatalogEntry>> {
1666 let mut out = Vec::new();
1667 paginate(
1668 &self.sync_query_config,
1669 LinearOperation::Labels,
1670 None,
1671 |request| async move {
1672 self.fetch_labels_page(request.cursor.as_deref(), request.page_size)
1673 .await
1674 },
1675 |nodes, _| {
1676 out.extend(nodes);
1677 ready(Ok(()))
1678 },
1679 |label| label.id.clone(),
1680 |event| self.observe_sync_event(event),
1681 )
1682 .await?;
1683 Ok(out)
1684 }
1685
1686 async fn fetch_labels_page(
1687 &self,
1688 cursor: Option<&str>,
1689 page_size: usize,
1690 ) -> Result<ConnectionPage<LabelCatalogEntry>> {
1691 let query = r#"
1692 query($first: Int!, $after: String) {
1693 issueLabels(first: $first, after: $after, orderBy: updatedAt) {
1694 nodes { id name color parent { id } }
1695 pageInfo { hasNextPage endCursor }
1696 }
1697 }
1698 "#;
1699 let data: IssueLabelsData = self
1700 .query_operation(
1701 LinearOperation::Labels.name(),
1702 cursor,
1703 query,
1704 serde_json::json!({ "first": page_size, "after": cursor }),
1705 )
1706 .await?;
1707 Ok(ConnectionPage {
1708 nodes: data
1709 .issue_labels
1710 .nodes
1711 .into_iter()
1712 .map(|label| LabelCatalogEntry {
1713 id: label.id,
1714 name: label.name,
1715 color: label.color,
1716 parent_id: label.parent.map(|parent| parent.id),
1717 })
1718 .collect(),
1719 page_info: data.issue_labels.page_info,
1720 })
1721 }
1722
1723 pub async fn sync_labels_catalog(&self, db: &Database, workspace_id: &str) -> Result<usize> {
1726 let sync_token = Uuid::new_v4().to_string();
1727 db.mark_sync_family_running(
1728 workspace_id,
1729 "*",
1730 "labels",
1731 None,
1732 Some(self.sync_query_config.page_size(LinearOperation::Labels)),
1733 &sync_token,
1734 )?;
1735 let result = paginate(
1736 &self.sync_query_config,
1737 LinearOperation::Labels,
1738 None,
1739 |request| async move {
1740 self.fetch_labels_page(request.cursor.as_deref(), request.page_size)
1741 .await
1742 },
1743 |entries, context| {
1744 let result = (|| {
1745 for entry in entries {
1746 db.upsert_label(&db::Label {
1747 id: entry.id.clone(),
1748 workspace_id: workspace_id.to_string(),
1749 name: entry.name,
1750 color: entry.color,
1751 parent_id: entry.parent_id,
1752 })?;
1753 db.mark_label_sync_token(&entry.id, &sync_token)?;
1754 }
1755 db.mark_sync_family_running(
1756 workspace_id,
1757 "*",
1758 "labels",
1759 context.cursor.as_deref(),
1760 Some(context.page_size),
1761 &sync_token,
1762 )
1763 })();
1764 ready(result)
1765 },
1766 |label| label.id.clone(),
1767 |event| self.observe_sync_event(event),
1768 )
1769 .await;
1770 match result {
1771 Ok(stats) => {
1772 db.reconcile_label_sync(workspace_id, &sync_token)?;
1773 db.mark_sync_family_complete(
1774 workspace_id,
1775 "*",
1776 "labels",
1777 Some(self.sync_query_config.page_size(LinearOperation::Labels)),
1778 &sync_token,
1779 )?;
1780 Ok(stats.nodes)
1781 }
1782 Err(error) => {
1783 let message = Self::redacted_error_message(&error);
1784 db.mark_sync_family_failed(
1785 workspace_id,
1786 "*",
1787 "labels",
1788 &sync_token,
1789 &message,
1790 )?;
1791 Err(error)
1792 }
1793 }
1794 }
1795
1796 pub async fn get_project_id(&self, project_name: &str) -> Result<String> {
1798 self.find_project_by_name(project_name).await
1799 }
1800
1801 pub async fn create_relation(
1805 &self,
1806 issue_id: &str,
1807 related_issue_id: &str,
1808 relation_type: &str,
1809 ) -> Result<String> {
1810 let (actual_issue_id, actual_related_id, api_type) = if relation_type == "blocked_by" {
1811 (related_issue_id, issue_id, "blocks")
1812 } else {
1813 (issue_id, related_issue_id, relation_type)
1814 };
1815
1816 let query = r#"
1817 mutation($input: IssueRelationCreateInput!) {
1818 issueRelationCreate(input: $input) {
1819 success
1820 issueRelation { id }
1821 }
1822 }
1823 "#;
1824
1825 let input = serde_json::json!({
1826 "issueId": actual_issue_id,
1827 "relatedIssueId": actual_related_id,
1828 "type": api_type,
1829 });
1830
1831 let data: CreateRelationData = self
1832 .query(query, serde_json::json!({ "input": input }))
1833 .await?;
1834
1835 if !data.issue_relation_create.success {
1836 anyhow::bail!("Failed to create relation");
1837 }
1838
1839 let relation = data
1840 .issue_relation_create
1841 .issue_relation
1842 .context("No relation returned")?;
1843 Ok(relation.id)
1844 }
1845
1846 pub async fn delete_relation(&self, relation_id: &str) -> Result<()> {
1848 let query = r#"
1849 mutation($id: String!) {
1850 issueRelationDelete(id: $id) {
1851 success
1852 }
1853 }
1854 "#;
1855
1856 let data: DeleteRelationData = self
1857 .query(query, serde_json::json!({ "id": relation_id }))
1858 .await?;
1859
1860 if !data.issue_relation_delete.success {
1861 anyhow::bail!("Failed to delete relation");
1862 }
1863
1864 Ok(())
1865 }
1866}
1867
1868fn create_issue_value(create: &CreateIssueInput<'_>) -> serde_json::Value {
1869 let mut input = serde_json::json!({
1870 "teamId": create.team_id,
1871 "title": create.title,
1872 });
1873 if let Some(desc) = create.description {
1874 input["description"] = serde_json::Value::String(desc.to_string());
1875 }
1876 if let Some(priority) = create.priority {
1877 input["priority"] = serde_json::Value::Number(priority.into());
1878 }
1879 if !create.label_ids.is_empty() {
1880 input["labelIds"] = serde_json::json!(create.label_ids);
1881 }
1882 if let Some(assignee_id) = create.assignee_id {
1883 input["assigneeId"] = serde_json::Value::String(assignee_id.to_string());
1884 }
1885 if let Some(parent_id) = create.parent_id {
1886 input["parentId"] = serde_json::Value::String(parent_id.to_string());
1887 }
1888 if let Some(project_id) = create.project_id {
1889 input["projectId"] = serde_json::Value::String(project_id.to_string());
1890 }
1891 if let Some(milestone_id) = create.project_milestone_id {
1892 input["projectMilestoneId"] = serde_json::Value::String(milestone_id.to_string());
1893 }
1894 input
1895}
1896
1897fn issue_page_variables(
1898 page_size: usize,
1899 cursor: Option<&str>,
1900 include_archived: bool,
1901) -> serde_json::Value {
1902 serde_json::json!({
1903 "first": page_size,
1904 "after": cursor,
1905 "includeArchived": include_archived,
1906 })
1907}
1908
1909fn apply_issue_labels(issue: &mut db::Issue, labels: Vec<LinearLabel>) -> Vec<String> {
1910 let label_names = labels
1911 .iter()
1912 .map(|label| label.name.clone())
1913 .collect::<Vec<_>>();
1914 let label_ids = labels.into_iter().map(|label| label.id).collect::<Vec<_>>();
1915 issue.labels_json = serde_json::to_string(&label_names).unwrap_or_else(|_| "[]".to_string());
1916 let mut hasher = Sha256::new();
1917 hasher.update(&issue.title);
1918 hasher.update(issue.description.as_deref().unwrap_or(""));
1919 hasher.update(&issue.labels_json);
1920 issue.content_hash = hex::encode(hasher.finalize());
1921 label_ids
1922}
1923
1924fn classify_http_status(status: u16) -> LinearErrorKind {
1925 match status {
1926 401 | 403 => LinearErrorKind::Authentication,
1927 429 => LinearErrorKind::RateLimit,
1928 _ => LinearErrorKind::Api,
1929 }
1930}
1931
1932fn classify_graphql_message(message: &str) -> LinearErrorKind {
1933 let lower = message.to_lowercase();
1934 if lower.contains("complexity")
1935 || lower.contains("maximum allowed")
1936 || lower.contains("query cost")
1937 {
1938 LinearErrorKind::Complexity
1939 } else if lower.contains("rate limit") || lower.contains("too many requests") {
1940 LinearErrorKind::RateLimit
1941 } else if lower.contains("unauthorized")
1942 || lower.contains("forbidden")
1943 || lower.contains("authentication")
1944 {
1945 LinearErrorKind::Authentication
1946 } else if lower.contains("validation")
1947 || lower.contains("cannot query field")
1948 || lower.contains("unknown argument")
1949 {
1950 LinearErrorKind::Validation
1951 } else {
1952 LinearErrorKind::Api
1953 }
1954}
1955
1956#[cfg(test)]
1957mod tests {
1958 use super::*;
1959
1960 #[test]
1961 fn issue_create_serializes_project_and_milestone_relationships() {
1962 let labels = vec!["label-1".to_string()];
1963 let value = create_issue_value(&CreateIssueInput {
1964 team_id: "team-1",
1965 title: "Add request tracing",
1966 description: None,
1967 priority: Some(2),
1968 label_ids: &labels,
1969 assignee_id: None,
1970 parent_id: None,
1971 project_id: Some("project-1"),
1972 project_milestone_id: Some("milestone-1"),
1973 });
1974 assert_eq!(value["projectId"], serde_json::json!("project-1"));
1975 assert_eq!(
1976 value["projectMilestoneId"],
1977 serde_json::json!("milestone-1")
1978 );
1979 assert_eq!(value["labelIds"], serde_json::json!(["label-1"]));
1980 }
1981
1982 #[test]
1983 fn issue_pages_explicitly_toggle_archived_records() {
1984 assert_eq!(
1985 issue_page_variables(50, None, false)["includeArchived"],
1986 serde_json::json!(false)
1987 );
1988 assert_eq!(
1989 issue_page_variables(50, Some("cursor-1"), true)["includeArchived"],
1990 serde_json::json!(true)
1991 );
1992 }
1993
1994 #[test]
1995 fn graphql_errors_are_classified_without_stringly_typed_callers() {
1996 assert_eq!(
1997 classify_graphql_message("Query complexity: 72,400; maximum allowed: 10,000"),
1998 LinearErrorKind::Complexity
1999 );
2000 assert_eq!(
2001 classify_graphql_message("Cannot query field 'cycles'"),
2002 LinearErrorKind::Validation
2003 );
2004 assert_eq!(
2005 classify_graphql_message("Unauthorized"),
2006 LinearErrorKind::Authentication
2007 );
2008 assert_eq!(classify_http_status(429), LinearErrorKind::RateLimit);
2009 }
2010}