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::{operation_error, 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 api_url: String,
27 viewer_id: std::sync::Arc<std::sync::RwLock<Option<String>>>,
28 sync_query_config: SyncQueryConfig,
29}
30
31#[derive(Debug, Deserialize)]
32struct GraphQLResponse<T> {
33 data: Option<T>,
34 errors: Option<Vec<GraphQLError>>,
35}
36
37#[derive(Debug, Deserialize)]
38struct GraphQLError {
39 message: String,
40}
41
42#[derive(Debug, Deserialize)]
45struct IssuesData {
46 issues: IssueConnection,
47}
48
49#[derive(Debug, Deserialize)]
50struct IssueConnection {
51 nodes: Vec<LinearIssue>,
52 #[serde(rename = "pageInfo")]
53 page_info: PageInfo,
54}
55
56#[derive(Debug, Deserialize)]
57struct LinearIssue {
58 id: String,
59 identifier: String,
60 url: String,
61 title: String,
62 description: Option<String>,
63 priority: i32,
64 #[serde(rename = "createdAt")]
65 created_at: String,
66 #[serde(rename = "updatedAt")]
67 updated_at: String,
68 state: LinearState,
69 team: LinearTeam,
70 assignee: Option<LinearUser>,
71 project: Option<LinearProject>,
72 #[serde(rename = "projectMilestone")]
73 project_milestone: Option<LinearProjectMilestoneRef>,
74 cycle: Option<LinearCycleRef>,
75 #[serde(default)]
76 labels: LinearLabelConnection,
77 #[serde(default)]
78 relations: LinearRelationConnection,
79 #[serde(rename = "branchName")]
80 branch_name: Option<String>,
81}
82
83#[derive(Debug, Deserialize, Default)]
84struct LinearRelationConnection {
85 nodes: Vec<LinearRelation>,
86}
87
88#[derive(Debug, Deserialize)]
89struct IssueRelationsData {
90 issue: IssueRelationsNode,
91}
92
93#[derive(Debug, Deserialize)]
94struct IssueRelationsNode {
95 relations: PaginatedRelationConnection,
96}
97
98#[derive(Debug, Deserialize)]
99struct PaginatedRelationConnection {
100 nodes: Vec<LinearRelation>,
101 #[serde(rename = "pageInfo")]
102 page_info: PageInfo,
103}
104
105#[derive(Debug, Deserialize)]
106struct LinearRelation {
107 id: String,
108 #[serde(rename = "type")]
109 relation_type: String,
110 #[serde(rename = "relatedIssue")]
111 related_issue: LinearRelatedIssue,
112}
113
114#[derive(Debug, Deserialize)]
115struct LinearRelatedIssue {
116 id: String,
117 identifier: String,
118}
119
120#[derive(Debug, Deserialize)]
121struct LinearState {
122 name: String,
123 #[serde(rename = "type")]
124 state_type: String,
125}
126
127#[derive(Debug, Deserialize)]
128struct LinearTeam {
129 key: String,
130}
131
132#[derive(Debug, Deserialize)]
133struct LinearUser {
134 name: String,
135}
136
137#[derive(Debug, Deserialize)]
138struct LinearExternalUser {
139 name: Option<String>,
140 #[serde(rename = "displayName")]
141 display_name: Option<String>,
142}
143
144#[derive(Debug, Deserialize)]
145struct LinearProject {
146 id: String,
147 name: String,
148}
149
150#[derive(Debug, Deserialize)]
151struct LinearProjectMilestoneRef {
152 id: String,
153 name: String,
154}
155
156#[derive(Debug, Deserialize)]
157struct LinearCycleRef {
158 id: String,
159 name: Option<String>,
160 number: i32,
161}
162
163#[derive(Debug, Deserialize, Default)]
164struct LinearLabelConnection {
165 nodes: Vec<LinearLabel>,
166}
167
168#[derive(Debug, Deserialize)]
169struct IssueLabelsForIssueData {
170 issue: IssueLabelsForIssueNode,
171}
172
173#[derive(Debug, Deserialize)]
174struct IssueLabelsForIssueNode {
175 labels: PaginatedIssueLabelConnection,
176}
177
178#[derive(Debug, Deserialize)]
179struct PaginatedIssueLabelConnection {
180 nodes: Vec<LinearLabel>,
181 #[serde(rename = "pageInfo")]
182 page_info: PageInfo,
183}
184
185#[derive(Debug, Deserialize)]
186struct LinearLabel {
187 id: String,
188 name: String,
189}
190
191#[derive(Debug, Deserialize)]
194struct TeamsData {
195 teams: TeamConnection,
196}
197
198#[derive(Debug, Deserialize)]
199struct TeamConnection {
200 nodes: Vec<TeamNode>,
201 #[serde(rename = "pageInfo")]
202 page_info: PageInfo,
203}
204
205#[derive(Debug, Deserialize)]
206#[allow(dead_code)]
207pub struct TeamNode {
208 pub id: String,
209 pub key: String,
210 pub name: String,
211}
212
213#[derive(Debug, Clone)]
214pub struct LabelCatalogEntry {
215 pub id: String,
216 pub name: String,
217 pub color: Option<String>,
218 pub parent_id: Option<String>,
219}
220
221#[derive(Debug, Deserialize)]
222struct IssueLabelsData {
223 #[serde(rename = "issueLabels")]
224 issue_labels: IssueLabelCatalogConnection,
225}
226
227#[derive(Debug, Deserialize)]
228struct IssueLabelCatalogConnection {
229 nodes: Vec<IssueLabelCatalogNode>,
230 #[serde(rename = "pageInfo")]
231 page_info: PageInfo,
232}
233
234#[derive(Debug, Deserialize)]
235struct IssueLabelCatalogNode {
236 id: String,
237 name: String,
238 color: Option<String>,
239 parent: Option<IssueLabelParent>,
240}
241
242#[derive(Debug, Deserialize)]
243struct IssueLabelParent {
244 id: String,
245}
246
247#[derive(Debug, Deserialize)]
250struct CreateIssueData {
251 #[serde(rename = "issueCreate")]
252 issue_create: CreateIssuePayload,
253}
254
255#[derive(Debug, Deserialize)]
256struct CreateIssuePayload {
257 success: bool,
258 issue: Option<CreatedIssue>,
259}
260
261#[derive(Debug, Deserialize)]
262struct CreatedIssue {
263 id: String,
264 identifier: String,
265}
266
267#[derive(Debug)]
268pub struct CreateIssueInput<'a> {
269 pub team_id: &'a str,
270 pub title: &'a str,
271 pub description: Option<&'a str>,
272 pub priority: Option<i32>,
273 pub label_ids: &'a [String],
274 pub assignee_id: Option<&'a str>,
275 pub parent_id: Option<&'a str>,
276 pub project_id: Option<&'a str>,
277 pub project_milestone_id: Option<&'a str>,
278}
279
280#[derive(Debug, Deserialize)]
283struct CreateCommentData {
284 #[serde(rename = "commentCreate")]
285 comment_create: CreateCommentPayload,
286}
287
288#[derive(Debug, Deserialize)]
289struct CreateCommentPayload {
290 success: bool,
291}
292
293#[derive(Debug, Deserialize)]
296struct CommentsData {
297 comments: LinearCommentConnection,
298}
299
300#[derive(Debug, Deserialize)]
301struct LinearCommentConnection {
302 nodes: Vec<LinearComment>,
303 #[serde(rename = "pageInfo")]
304 page_info: PageInfo,
305}
306
307#[derive(Debug, Deserialize)]
308struct LinearComment {
309 id: String,
310 body: String,
311 #[serde(rename = "createdAt")]
312 created_at: String,
313 #[serde(rename = "updatedAt")]
314 updated_at: String,
315 #[serde(rename = "parentId")]
316 parent_id: Option<String>,
317 url: String,
318 user: Option<LinearUser>,
319 #[serde(rename = "externalUser")]
320 external_user: Option<LinearExternalUser>,
321}
322
323#[derive(Debug, Deserialize)]
326struct UpdateIssueData {
327 #[serde(rename = "issueUpdate")]
328 issue_update: UpdateIssuePayload,
329}
330
331#[derive(Debug, Deserialize)]
332struct UpdateIssuePayload {
333 success: bool,
334}
335
336#[derive(Debug, Default)]
337pub struct UpdateIssueInput<'a> {
338 pub title: Option<&'a str>,
339 pub description: Option<&'a str>,
340 pub priority: Option<i32>,
341 pub state_id: Option<&'a str>,
342 pub label_ids: Option<&'a [String]>,
343 pub project_id: Option<&'a str>,
344 pub assignee_id: Option<&'a str>,
345 pub project_milestone_id: Option<&'a str>,
346}
347
348#[derive(Debug, Deserialize)]
351struct CreateRelationData {
352 #[serde(rename = "issueRelationCreate")]
353 issue_relation_create: CreateRelationPayload,
354}
355
356#[derive(Debug, Deserialize)]
357struct CreateRelationPayload {
358 success: bool,
359 #[serde(rename = "issueRelation")]
360 issue_relation: Option<CreatedRelation>,
361}
362
363#[derive(Debug, Deserialize)]
364struct CreatedRelation {
365 id: String,
366}
367
368#[derive(Debug, Deserialize)]
369struct DeleteRelationData {
370 #[serde(rename = "issueRelationDelete")]
371 issue_relation_delete: DeleteRelationPayload,
372}
373
374#[derive(Debug, Deserialize)]
375struct DeleteRelationPayload {
376 success: bool,
377}
378
379#[derive(Debug, Deserialize)]
382struct SingleIssueData {
383 issue: LinearIssue,
384}
385
386impl LinearClient {
387 pub fn new(config: &Config) -> Result<Self> {
388 let api_key = config.linear_api_key()?.to_string();
389 let client = reqwest::Client::new();
390 Ok(Self {
391 client,
392 api_key,
393 api_url: LINEAR_API_URL.to_string(),
394 viewer_id: std::sync::Arc::new(std::sync::RwLock::new(None)),
395 sync_query_config: SyncQueryConfig::from_environment(),
396 })
397 }
398
399 pub fn with_api_key(api_key: &str) -> Self {
401 Self {
402 client: reqwest::Client::new(),
403 api_key: api_key.to_string(),
404 api_url: LINEAR_API_URL.to_string(),
405 viewer_id: std::sync::Arc::new(std::sync::RwLock::new(None)),
406 sync_query_config: SyncQueryConfig::from_environment(),
407 }
408 }
409
410 pub fn with_http_client(client: reqwest::Client, api_key: &str) -> Self {
415 Self {
416 client,
417 api_key: api_key.to_string(),
418 api_url: LINEAR_API_URL.to_string(),
419 viewer_id: std::sync::Arc::new(std::sync::RwLock::new(None)),
420 sync_query_config: SyncQueryConfig::from_environment(),
421 }
422 }
423
424 pub fn with_sync_query_config(mut self, sync_query_config: SyncQueryConfig) -> Self {
425 self.sync_query_config = sync_query_config;
426 self
427 }
428
429 #[cfg(test)]
430 fn with_api_url(mut self, api_url: impl Into<String>) -> Self {
431 self.api_url = api_url.into();
432 self
433 }
434
435 pub fn sync_query_config(&self) -> &SyncQueryConfig {
436 &self.sync_query_config
437 }
438
439 fn observe_sync_event(&self, event: SyncEvent) {
440 if !self.sync_query_config.verbose {
441 return;
442 }
443 let parent = event
444 .parent
445 .as_deref()
446 .map(|value| format!(" parent={value}"))
447 .unwrap_or_default();
448 let reduction = if event.adaptive_reduction {
449 " adaptive-page-size=true"
450 } else {
451 ""
452 };
453 if let Some(failure) = event.failure {
454 let failure = self.redacted_message(failure);
455 let status = if failure.starts_with("retrying attempt ") {
456 "retrying"
457 } else {
458 "failed"
459 };
460 eprintln!(
461 "sync operation={}{} page={} nodes={} page_size={}{} status={} error={}",
462 event.operation,
463 parent,
464 event.page_number,
465 event.nodes_received,
466 event.page_size,
467 reduction,
468 status,
469 failure
470 );
471 } else {
472 eprintln!(
473 "sync operation={}{} page={} nodes={} page_size={}{} status={}",
474 event.operation,
475 parent,
476 event.page_number,
477 event.nodes_received,
478 event.page_size,
479 reduction,
480 if event.completed { "complete" } else { "running" }
481 );
482 }
483 }
484
485 async fn query<T: serde::de::DeserializeOwned>(
486 &self,
487 query: &str,
488 variables: serde_json::Value,
489 ) -> Result<T> {
490 self.query_operation("GraphQL query", None, query, variables)
491 .await
492 }
493
494 async fn query_operation<T: serde::de::DeserializeOwned>(
495 &self,
496 operation: &str,
497 cursor: Option<&str>,
498 query: &str,
499 variables: serde_json::Value,
500 ) -> Result<T> {
501 let body = serde_json::json!({
502 "query": query,
503 "variables": variables,
504 });
505
506 let resp = self
507 .client
508 .post(&self.api_url)
509 .header("Authorization", &self.api_key)
510 .header("Content-Type", "application/json")
511 .json(&body)
512 .send()
513 .await
514 .map_err(|error| {
515 LinearOperationError::new(
516 LinearErrorKind::Transport,
517 operation,
518 cursor,
519 error.to_string(),
520 )
521 })?;
522
523 let status = resp.status();
524 if !status.is_success() {
525 let retry_after = resp
526 .headers()
527 .get(reqwest::header::RETRY_AFTER)
528 .and_then(|value| value.to_str().ok())
529 .and_then(|value| value.parse::<u64>().ok())
530 .map(Duration::from_secs);
531 let kind = classify_http_status(status.as_u16());
532 return Err(LinearOperationError::new(
533 kind,
534 operation,
535 cursor,
536 format!("HTTP {status} (response body omitted)"),
537 )
538 .with_retry_after(retry_after)
539 .into());
540 }
541
542 let response: GraphQLResponse<T> = resp
543 .json()
544 .await
545 .map_err(|error| {
546 LinearOperationError::new(
547 LinearErrorKind::Api,
548 operation,
549 cursor,
550 format!("failed to parse response: {error}"),
551 )
552 })?;
553
554 if let Some(errors) = response.errors {
555 let message = errors
556 .iter()
557 .map(|error| error.message.as_str())
558 .collect::<Vec<_>>()
559 .join(", ");
560 let kind = classify_graphql_message(&message);
561 return Err(LinearOperationError::new(kind, operation, cursor, message).into());
562 }
563
564 response.data.ok_or_else(|| {
565 LinearOperationError::new(
566 LinearErrorKind::Api,
567 operation,
568 cursor,
569 "response did not contain data",
570 )
571 .into()
572 })
573 }
574
575 pub async fn list_teams(&self) -> Result<Vec<TeamNode>> {
576 let query = r#"
577 query($first: Int!, $after: String) {
578 teams(first: $first, after: $after, orderBy: updatedAt) {
579 nodes { id key name }
580 pageInfo { hasNextPage endCursor }
581 }
582 }
583 "#;
584 let mut teams = Vec::new();
585 paginate(
586 &self.sync_query_config,
587 LinearOperation::Teams,
588 None,
589 |request| async move {
590 let data: TeamsData = self
591 .query_operation(
592 LinearOperation::Teams.name(),
593 request.cursor.as_deref(),
594 query,
595 serde_json::json!({
596 "first": request.page_size,
597 "after": request.cursor,
598 }),
599 )
600 .await?;
601 Ok(ConnectionPage {
602 nodes: data.teams.nodes,
603 page_info: data.teams.page_info,
604 })
605 },
606 |nodes, _| {
607 teams.extend(nodes);
608 ready(Ok(()))
609 },
610 |team| team.id.clone(),
611 |event| self.observe_sync_event(event),
612 )
613 .await?;
614 Ok(teams)
615 }
616
617 fn extract_relations(issue_id: &str, linear_issue: &LinearIssue) -> Vec<db::Relation> {
618 linear_issue
619 .relations
620 .nodes
621 .iter()
622 .map(|r| db::Relation {
623 id: r.id.clone(),
624 issue_id: issue_id.to_string(),
625 related_issue_id: r.related_issue.id.clone(),
626 related_issue_identifier: r.related_issue.identifier.clone(),
627 relation_type: r.relation_type.clone(),
628 })
629 .collect()
630 }
631
632 fn convert_linear_relation(issue_id: &str, relation: LinearRelation) -> db::Relation {
633 db::Relation {
634 id: relation.id,
635 issue_id: issue_id.to_string(),
636 related_issue_id: relation.related_issue.id,
637 related_issue_identifier: relation.related_issue.identifier,
638 relation_type: relation.relation_type,
639 }
640 }
641
642 pub async fn fetch_issues(
643 &self,
644 team_key: &str,
645 after_cursor: Option<&str>,
646 updated_after: Option<&str>,
647 include_archived: bool,
648 ) -> Result<(Vec<(db::Issue, Vec<db::Relation>, Vec<String>)>, bool, Option<String>)> {
649 let page = self
650 .fetch_issues_page(
651 team_key,
652 after_cursor,
653 updated_after,
654 include_archived,
655 self.sync_query_config.page_size(LinearOperation::Issues),
656 )
657 .await?;
658 Ok((
659 page.nodes,
660 page.page_info.has_next_page,
661 page.page_info.end_cursor,
662 ))
663 }
664
665 async fn fetch_issues_page(
666 &self,
667 team_key: &str,
668 after_cursor: Option<&str>,
669 updated_after: Option<&str>,
670 include_archived: bool,
671 page_size: usize,
672 ) -> Result<ConnectionPage<(db::Issue, Vec<db::Relation>, Vec<String>)>> {
673 let mut filter_parts = vec![format!("team: {{ key: {{ eq: \"{}\" }} }}", team_key)];
674 if let Some(after) = updated_after {
675 filter_parts.push(format!("updatedAt: {{ gt: \"{}\" }}", after));
676 }
677 let filter = filter_parts.join(", ");
678 let query = format!(
679 r#"query($first: Int!, $after: String, $includeArchived: Boolean!) {{
680 issues(
681 first: $first,
682 after: $after,
683 filter: {{ {} }},
684 includeArchived: $includeArchived,
685 orderBy: updatedAt
686 ) {{
687 nodes {{
688 id identifier url title description priority branchName
689 createdAt updatedAt
690 state {{ name type }}
691 team {{ key }}
692 assignee {{ name }}
693 project {{ id name }}
694 projectMilestone {{ id name }}
695 cycle {{ id name number }}
696 }}
697 pageInfo {{ hasNextPage endCursor }}
698 }}
699 }}"#,
700 filter
701 );
702
703 let data: IssuesData = self
704 .query_operation(
705 LinearOperation::Issues.name(),
706 after_cursor,
707 &query,
708 issue_page_variables(page_size, after_cursor, include_archived),
709 )
710 .await?;
711
712 let issues: Vec<(db::Issue, Vec<db::Relation>, Vec<String>)> = data
713 .issues
714 .nodes
715 .into_iter()
716 .map(Self::convert_linear_issue)
717 .collect();
718
719 Ok(ConnectionPage { nodes: issues, page_info: data.issues.page_info })
720 }
721
722 pub async fn sync_team(
723 &self,
724 db: &Database,
725 team_key: &str,
726 workspace_id: &str,
727 full: bool,
728 include_archived: bool,
729 progress: Option<&(dyn Fn(usize) + Send + Sync)>,
730 ) -> Result<usize> {
731 self.sync_projects_for_team(db, workspace_id, team_key, include_archived).await.with_context(|| {
732 format!("project synchronization failed for workspace '{workspace_id}'")
733 })?;
734 self.sync_labels_catalog(db, workspace_id)
735 .await
736 .with_context(|| format!("label synchronization failed for workspace '{workspace_id}'"))?;
737 self.sync_cycles(db, team_key, workspace_id, include_archived)
738 .await
739 .with_context(|| format!("cycle synchronization failed for team '{team_key}'"))?;
740
741 let updated_after = if full {
742 None
743 } else {
744 db.get_sync_cursor(workspace_id, team_key)?
745 };
746
747 let sync_token = Uuid::new_v4().to_string();
748 let mut max_updated: Option<String> = None;
749 let mut persisted_total = 0;
750 db.mark_sync_family_running(
751 workspace_id,
752 team_key,
753 "issues",
754 None,
755 Some(self.sync_query_config.page_size(LinearOperation::Issues)),
756 &sync_token,
757 )?;
758 let issue_result = paginate(
759 &self.sync_query_config,
760 LinearOperation::Issues,
761 Some(team_key.to_string()),
762 |request| {
763 let updated_after = updated_after.clone();
764 async move {
765 self.fetch_issues_page(
766 team_key,
767 request.cursor.as_deref(),
768 updated_after.as_deref(),
769 include_archived,
770 request.page_size,
771 )
772 .await
773 }
774 },
775 |issues, context| {
776 let count = issues.len();
777 let result = (|| {
778 for (mut issue, _relations, _label_ids) in issues {
779 issue.workspace_id = workspace_id.to_string();
780 if max_updated.is_none()
781 || Some(&issue.updated_at) > max_updated.as_ref()
782 {
783 max_updated = Some(issue.updated_at.clone());
784 }
785 db.upsert_issue_preserving_labels(&issue)?;
786 db.mark_issue_sync_token(&issue.id, &sync_token)?;
787 }
788 persisted_total += count;
789 db.mark_sync_family_running(
790 workspace_id,
791 team_key,
792 "issues",
793 context.cursor.as_deref(),
794 Some(context.page_size),
795 &sync_token,
796 )?;
797 if let Some(callback) = progress {
798 callback(persisted_total);
799 }
800 Ok(())
801 })();
802 ready(result)
803 },
804 |(issue, _, _)| issue.id.clone(),
805 |event| self.observe_sync_event(event),
806 )
807 .await;
808
809 let stats = match issue_result {
810 Ok(stats) => stats,
811 Err(error) => {
812 let message = self.redacted_error_message(&error);
813 db.mark_sync_family_failed(
814 workspace_id,
815 team_key,
816 "issues",
817 &sync_token,
818 &message,
819 )?;
820 return Err(error);
821 }
822 };
823 if full {
824 db.reconcile_full_issue_sync(workspace_id, team_key, &sync_token)?;
825 }
826 db.mark_sync_family_complete(
827 workspace_id,
828 team_key,
829 "issues",
830 Some(self.sync_query_config.page_size(LinearOperation::Issues)),
831 &sync_token,
832 )?;
833
834 db.mark_sync_family_running(
835 workspace_id,
836 team_key,
837 "issue labels",
838 None,
839 None,
840 &sync_token,
841 )?;
842 let label_result: Result<()> = async {
843 let mut after_id = None;
844 loop {
845 let issue_refs = db.list_issue_sync_refs(
846 workspace_id,
847 team_key,
848 &sync_token,
849 after_id.as_deref(),
850 100,
851 )?;
852 if issue_refs.is_empty() {
853 break;
854 }
855 for issue in &issue_refs {
856 self.sync_issue_labels(db, &issue.id)
857 .await
858 .with_context(|| {
859 format!("label synchronization failed for {}", issue.identifier)
860 })?;
861 }
862 after_id = issue_refs.last().map(|issue| issue.id.clone());
863 }
864 Ok(())
865 }
866 .await;
867 if let Err(error) = label_result {
868 let message = self.redacted_error_message(&error);
869 db.mark_sync_family_failed(
870 workspace_id,
871 team_key,
872 "issue labels",
873 &sync_token,
874 &message,
875 )?;
876 return Err(error);
877 }
878 db.mark_sync_family_complete(workspace_id, team_key, "issue labels", None, &sync_token)?;
879
880 db.mark_sync_family_running(workspace_id, team_key, "relations", None, None, &sync_token)?;
881 let relation_result: Result<()> = async {
882 let mut after_id = None;
883 loop {
884 let issue_refs = db.list_issue_sync_refs(
885 workspace_id,
886 team_key,
887 &sync_token,
888 after_id.as_deref(),
889 100,
890 )?;
891 if issue_refs.is_empty() {
892 break;
893 }
894 for issue in &issue_refs {
895 self.sync_issue_relations(db, &issue.id)
896 .await
897 .with_context(|| {
898 format!("relation synchronization failed for {}", issue.identifier)
899 })?;
900 }
901 after_id = issue_refs.last().map(|issue| issue.id.clone());
902 }
903 Ok(())
904 }
905 .await;
906 if let Err(error) = relation_result {
907 let message = self.redacted_error_message(&error);
908 db.mark_sync_family_failed(workspace_id, team_key, "relations", &sync_token, &message)?;
909 return Err(error);
910 }
911 db.mark_sync_family_complete(workspace_id, team_key, "relations", None, &sync_token)?;
912
913 db.mark_sync_family_running(workspace_id, team_key, "comments", None, None, &sync_token)?;
914 let mut comment_failures = Vec::new();
915 let mut after_id = None;
916 loop {
917 let issue_refs = db.list_comment_hydration_refs(
918 workspace_id,
919 team_key,
920 &sync_token,
921 after_id.as_deref(),
922 100,
923 )?;
924 if issue_refs.is_empty() {
925 break;
926 }
927 for issue in &issue_refs {
928 if let Err(error) = self
929 .sync_issue_comments(db, &issue.id, workspace_id)
930 .await
931 .with_context(|| {
932 format!("comment synchronization failed for {}", issue.identifier)
933 })
934 {
935 let message = self.redacted_error_message(&error);
936 if self.sync_query_config.verbose {
937 eprintln!(
938 "sync operation=comments issue={} status=failed continuing=true error={}",
939 issue.identifier, message
940 );
941 }
942 comment_failures.push(issue.identifier.clone());
943 }
944 }
945 after_id = issue_refs.last().map(|issue| issue.id.clone());
946 }
947 if comment_failures.is_empty() {
948 db.mark_sync_family_complete(workspace_id, team_key, "comments", None, &sync_token)?;
949 } else {
950 let summary = format!(
951 "{} comment hydration(s) failed: {}",
952 comment_failures.len(),
953 comment_failures.join(", ")
954 );
955 db.mark_sync_family_partial(workspace_id, team_key, "comments", &sync_token, &summary)?;
956 }
957
958 let next_updated = max_updated
959 .or(updated_after)
960 .unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
961 db.set_sync_cursor(workspace_id, team_key, &next_updated)?;
962 Ok(stats.nodes)
963 }
964
965 pub async fn create_issue(&self, create: CreateIssueInput<'_>) -> Result<(String, String)> {
966 let input = create_issue_value(&create);
967
968 let query = r#"
969 mutation($input: IssueCreateInput!) {
970 issueCreate(input: $input) {
971 success
972 issue { id identifier }
973 }
974 }
975 "#;
976
977 let data: CreateIssueData = self
978 .query(query, serde_json::json!({ "input": input }))
979 .await?;
980
981 if !data.issue_create.success {
982 anyhow::bail!("Failed to create issue");
983 }
984
985 let issue = data.issue_create.issue.context("No issue returned")?;
986 Ok((issue.id, issue.identifier))
987 }
988
989 pub async fn add_comment(&self, issue_id: &str, body: &str) -> Result<()> {
990 let query = r#"
991 mutation($input: CommentCreateInput!) {
992 commentCreate(input: $input) {
993 success
994 }
995 }
996 "#;
997
998 let input = serde_json::json!({
999 "issueId": issue_id,
1000 "body": body,
1001 });
1002
1003 let data: CreateCommentData = self
1004 .query(query, serde_json::json!({ "input": input }))
1005 .await?;
1006
1007 if !data.comment_create.success {
1008 anyhow::bail!("Failed to create comment");
1009 }
1010
1011 Ok(())
1012 }
1013
1014 async fn fetch_issue_comments_page(
1015 &self,
1016 issue_id: &str,
1017 cursor: Option<&str>,
1018 page_size: usize,
1019 ) -> Result<ConnectionPage<db::Comment>> {
1020 let query = r#"
1021 query($issueId: ID!, $first: Int!, $after: String) {
1022 comments(
1023 filter: { issue: { id: { eq: $issueId } } },
1024 first: $first,
1025 after: $after,
1026 includeArchived: true,
1027 orderBy: createdAt
1028 ) {
1029 nodes {
1030 id body createdAt updatedAt parentId url
1031 user { name }
1032 externalUser { displayName name }
1033 }
1034 pageInfo { hasNextPage endCursor }
1035 }
1036 }
1037 "#;
1038 let data: CommentsData = self
1039 .query_operation(
1040 LinearOperation::Comments.name(),
1041 cursor,
1042 query,
1043 serde_json::json!({
1044 "issueId": issue_id,
1045 "first": page_size,
1046 "after": cursor,
1047 }),
1048 )
1049 .await?;
1050 Ok(ConnectionPage {
1051 nodes: data
1052 .comments
1053 .nodes
1054 .into_iter()
1055 .map(|comment| Self::convert_linear_comment(issue_id, comment))
1056 .collect(),
1057 page_info: data.comments.page_info,
1058 })
1059 }
1060
1061 pub async fn fetch_issue_comments(&self, issue_id: &str) -> Result<Vec<db::Comment>> {
1062 let mut comments = Vec::new();
1063 paginate(
1064 &self.sync_query_config,
1065 LinearOperation::Comments,
1066 Some(issue_id.to_string()),
1067 |request| async move {
1068 self.fetch_issue_comments_page(
1069 issue_id,
1070 request.cursor.as_deref(),
1071 request.page_size,
1072 )
1073 .await
1074 },
1075 |nodes, _| {
1076 comments.extend(nodes);
1077 ready(Ok(()))
1078 },
1079 |comment| comment.id.clone(),
1080 |event| self.observe_sync_event(event),
1081 )
1082 .await?;
1083 Ok(comments)
1084 }
1085
1086 pub async fn sync_issue_comments(
1087 &self,
1088 db: &Database,
1089 issue_id: &str,
1090 workspace_id: &str,
1091 ) -> Result<usize> {
1092 let sync_token = Uuid::new_v4().to_string();
1093 let result = paginate(
1094 &self.sync_query_config,
1095 LinearOperation::Comments,
1096 Some(issue_id.to_string()),
1097 |request| async move {
1098 self.fetch_issue_comments_page(
1099 issue_id,
1100 request.cursor.as_deref(),
1101 request.page_size,
1102 )
1103 .await
1104 },
1105 |mut comments, _| {
1106 for comment in &mut comments {
1107 comment.workspace_id = workspace_id.to_string();
1108 }
1109 ready(db.upsert_comment_page(
1110 issue_id,
1111 workspace_id,
1112 &comments,
1113 &sync_token,
1114 ))
1115 },
1116 |comment| comment.id.clone(),
1117 |event| self.observe_sync_event(event),
1118 )
1119 .await;
1120 match result {
1121 Ok(stats) => {
1122 db.complete_comment_sync(issue_id, workspace_id, &sync_token)?;
1123 db.mark_comments_synced(issue_id, workspace_id, stats.nodes)?;
1124 Ok(stats.nodes)
1125 }
1126 Err(error) => {
1127 let status = Self::comment_error_status(&error);
1128 let message = self.redacted_error_message(&error);
1129 db.mark_comments_sync_failed(issue_id, workspace_id, status, &message)?;
1130 Err(error)
1131 }
1132 }
1133 }
1134
1135 async fn fetch_issue_relations_page(
1136 &self,
1137 issue_id: &str,
1138 cursor: Option<&str>,
1139 page_size: usize,
1140 ) -> Result<ConnectionPage<db::Relation>> {
1141 let query = r#"
1142 query($issueId: String!, $first: Int!, $after: String) {
1143 issue(id: $issueId) {
1144 relations(first: $first, after: $after) {
1145 nodes { id type relatedIssue { id identifier } }
1146 pageInfo { hasNextPage endCursor }
1147 }
1148 }
1149 }
1150 "#;
1151 let data: IssueRelationsData = self
1152 .query_operation(
1153 LinearOperation::Relations.name(),
1154 cursor,
1155 query,
1156 serde_json::json!({
1157 "issueId": issue_id,
1158 "first": page_size,
1159 "after": cursor,
1160 }),
1161 )
1162 .await?;
1163 Ok(ConnectionPage {
1164 nodes: data
1165 .issue
1166 .relations
1167 .nodes
1168 .into_iter()
1169 .map(|relation| Self::convert_linear_relation(issue_id, relation))
1170 .collect(),
1171 page_info: data.issue.relations.page_info,
1172 })
1173 }
1174
1175 pub async fn sync_issue_relations(
1176 &self,
1177 db: &Database,
1178 issue_id: &str,
1179 ) -> Result<usize> {
1180 let sync_token = Uuid::new_v4().to_string();
1181 let stats = paginate(
1182 &self.sync_query_config,
1183 LinearOperation::Relations,
1184 Some(issue_id.to_string()),
1185 |request| async move {
1186 self.fetch_issue_relations_page(
1187 issue_id,
1188 request.cursor.as_deref(),
1189 request.page_size,
1190 )
1191 .await
1192 },
1193 |relations, _| {
1194 ready(db.upsert_relation_page(issue_id, &relations, &sync_token))
1195 },
1196 |relation| relation.id.clone(),
1197 |event| self.observe_sync_event(event),
1198 )
1199 .await?;
1200 db.complete_relation_sync(issue_id, &sync_token)?;
1201 Ok(stats.nodes)
1202 }
1203
1204 async fn fetch_issue_labels_page(
1205 &self,
1206 issue_id: &str,
1207 cursor: Option<&str>,
1208 page_size: usize,
1209 ) -> Result<ConnectionPage<LinearLabel>> {
1210 let query = r#"
1211 query($issueId: String!, $first: Int!, $after: String) {
1212 issue(id: $issueId) {
1213 labels(first: $first, after: $after, orderBy: updatedAt) {
1214 nodes { id name }
1215 pageInfo { hasNextPage endCursor }
1216 }
1217 }
1218 }
1219 "#;
1220 let data: IssueLabelsForIssueData = self
1221 .query_operation(
1222 "issue labels",
1223 cursor,
1224 query,
1225 serde_json::json!({
1226 "issueId": issue_id,
1227 "first": page_size,
1228 "after": cursor,
1229 }),
1230 )
1231 .await?;
1232 Ok(ConnectionPage {
1233 nodes: data.issue.labels.nodes,
1234 page_info: data.issue.labels.page_info,
1235 })
1236 }
1237
1238 pub async fn sync_issue_labels(&self, db: &Database, issue_id: &str) -> Result<usize> {
1239 let sync_token = Uuid::new_v4().to_string();
1240 let mut names = Vec::new();
1241 let stats = paginate(
1242 &self.sync_query_config,
1243 LinearOperation::Labels,
1244 Some(issue_id.to_string()),
1245 |request| async move {
1246 self.fetch_issue_labels_page(
1247 issue_id,
1248 request.cursor.as_deref(),
1249 request.page_size,
1250 )
1251 .await
1252 },
1253 |labels, _| {
1254 let ids = labels
1255 .iter()
1256 .map(|label| label.id.clone())
1257 .collect::<Vec<_>>();
1258 names.extend(labels.into_iter().map(|label| label.name));
1259 ready(db.upsert_issue_label_page(issue_id, &ids, &sync_token))
1260 },
1261 |label| label.id.clone(),
1262 |event| self.observe_sync_event(event),
1263 )
1264 .await?;
1265 db.complete_issue_label_sync(issue_id, &sync_token)?;
1266
1267 let mut issue = db
1268 .get_issue(issue_id)?
1269 .with_context(|| format!("issue '{issue_id}' disappeared during label sync"))?;
1270 issue.labels_json = serde_json::to_string(&names)?;
1271 let mut hasher = Sha256::new();
1272 hasher.update(&issue.title);
1273 hasher.update(issue.description.as_deref().unwrap_or(""));
1274 hasher.update(&issue.labels_json);
1275 issue.content_hash = hex::encode(hasher.finalize());
1276 db.upsert_issue(&issue)?;
1277 Ok(stats.nodes)
1278 }
1279
1280 async fn fetch_all_issue_labels_remote(&self, issue_id: &str) -> Result<Vec<LinearLabel>> {
1281 let mut labels = Vec::new();
1282 paginate(
1283 &self.sync_query_config,
1284 LinearOperation::Labels,
1285 Some(issue_id.to_string()),
1286 |request| async move {
1287 self.fetch_issue_labels_page(
1288 issue_id,
1289 request.cursor.as_deref(),
1290 request.page_size,
1291 )
1292 .await
1293 },
1294 |nodes, _| {
1295 labels.extend(nodes);
1296 ready(Ok(()))
1297 },
1298 |label| label.id.clone(),
1299 |event| self.observe_sync_event(event),
1300 )
1301 .await?;
1302 Ok(labels)
1303 }
1304
1305 async fn fetch_all_issue_relations_remote(
1306 &self,
1307 issue_id: &str,
1308 ) -> Result<Vec<db::Relation>> {
1309 let mut relations = Vec::new();
1310 paginate(
1311 &self.sync_query_config,
1312 LinearOperation::Relations,
1313 Some(issue_id.to_string()),
1314 |request| async move {
1315 self.fetch_issue_relations_page(
1316 issue_id,
1317 request.cursor.as_deref(),
1318 request.page_size,
1319 )
1320 .await
1321 },
1322 |nodes, _| {
1323 relations.extend(nodes);
1324 ready(Ok(()))
1325 },
1326 |relation| relation.id.clone(),
1327 |event| self.observe_sync_event(event),
1328 )
1329 .await?;
1330 Ok(relations)
1331 }
1332
1333 pub fn comment_error_status(error: &anyhow::Error) -> &'static str {
1334 if operation_error(error)
1335 .is_some_and(|classified| classified.kind == LinearErrorKind::Authentication)
1336 {
1337 return "permission_denied";
1338 }
1339 let message = format!("{error:#}").to_lowercase();
1340 if message.contains("permission")
1341 || message.contains("forbidden")
1342 || message.contains("unauthorized")
1343 || message.contains("access denied")
1344 {
1345 "permission_denied"
1346 } else {
1347 "unavailable"
1348 }
1349 }
1350
1351 fn redacted_error_message(&self, error: &anyhow::Error) -> String {
1352 self.redacted_message(format!("{error:#}"))
1353 }
1354
1355 fn redacted_message(&self, mut message: String) -> String {
1356 if !self.api_key.is_empty() {
1357 message = message.replace(&self.api_key, "[REDACTED]");
1358 }
1359 message = redact_sensitive_fragments(message);
1360 message.chars().take(500).collect()
1361 }
1362
1363 pub async fn update_issue(
1364 &self,
1365 issue_id: &str,
1366 update: UpdateIssueInput<'_>,
1367 ) -> Result<()> {
1368 let mut input = serde_json::Map::new();
1369 if let Some(t) = update.title {
1370 input.insert("title".into(), serde_json::Value::String(t.to_string()));
1371 }
1372 if let Some(d) = update.description {
1373 input.insert(
1374 "description".into(),
1375 serde_json::Value::String(d.to_string()),
1376 );
1377 }
1378 if let Some(p) = update.priority {
1379 input.insert("priority".into(), serde_json::Value::Number(p.into()));
1380 }
1381 if let Some(sid) = update.state_id {
1382 input.insert("stateId".into(), serde_json::Value::String(sid.to_string()));
1383 }
1384 if let Some(lids) = update.label_ids {
1385 input.insert("labelIds".into(), serde_json::json!(lids));
1386 }
1387 if let Some(pid) = update.project_id {
1388 let value = if pid.is_empty() {
1389 serde_json::Value::Null
1390 } else {
1391 serde_json::Value::String(pid.to_string())
1392 };
1393 input.insert("projectId".into(), value);
1394 }
1395 if let Some(aid) = update.assignee_id {
1396 let value = if aid.is_empty() {
1397 serde_json::Value::Null
1398 } else {
1399 serde_json::Value::String(aid.to_string())
1400 };
1401 input.insert("assigneeId".into(), value);
1402 }
1403 if let Some(mid) = update.project_milestone_id {
1404 let value = if mid.is_empty() {
1405 serde_json::Value::Null
1406 } else {
1407 serde_json::Value::String(mid.to_string())
1408 };
1409 input.insert("projectMilestoneId".into(), value);
1410 }
1411
1412 let query = r#"
1413 mutation($id: String!, $input: IssueUpdateInput!) {
1414 issueUpdate(id: $id, input: $input) {
1415 success
1416 }
1417 }
1418 "#;
1419
1420 let data: UpdateIssueData = self
1421 .query(query, serde_json::json!({ "id": issue_id, "input": input }))
1422 .await?;
1423
1424 if !data.issue_update.success {
1425 anyhow::bail!("Failed to update issue");
1426 }
1427
1428 Ok(())
1429 }
1430
1431 pub async fn fetch_single_issue(
1432 &self,
1433 issue_id: &str,
1434 ) -> Result<(db::Issue, Vec<db::Relation>, Vec<String>)> {
1435 let query = r#"
1436 query($id: String!) {
1437 issue(id: $id) {
1438 id identifier url title description priority branchName
1439 createdAt updatedAt
1440 state { name type }
1441 team { key }
1442 assignee { name }
1443 project { id name }
1444 projectMilestone { id name }
1445 cycle { id name number }
1446 }
1447 }
1448 "#;
1449
1450 let data: SingleIssueData = self
1451 .query(query, serde_json::json!({ "id": issue_id }))
1452 .await?;
1453 let issue_id = data.issue.id.clone();
1454 let (mut issue, _, _) = Self::convert_linear_issue(data.issue);
1455 let labels = self.fetch_all_issue_labels_remote(&issue_id).await?;
1456 let label_ids = apply_issue_labels(&mut issue, labels);
1457 let relations = self.fetch_all_issue_relations_remote(&issue_id).await?;
1458 Ok((issue, relations, label_ids))
1459 }
1460
1461 pub async fn fetch_issue_by_identifier(
1464 &self,
1465 identifier: &str,
1466 ) -> Result<Option<(db::Issue, Vec<db::Relation>, Vec<String>)>> {
1467 let parts: Vec<&str> = identifier.rsplitn(2, '-').collect();
1469 if parts.len() != 2 {
1470 anyhow::bail!(
1471 "Invalid issue identifier '{}': expected format like 'ENG-123'",
1472 identifier
1473 );
1474 }
1475 let number: i32 = parts[0]
1476 .parse()
1477 .with_context(|| format!("Invalid issue number in '{}'", identifier))?;
1478 let team_key = parts[1];
1479
1480 let query = format!(
1481 r#"query {{
1482 issues(
1483 filter: {{
1484 team: {{ key: {{ eq: "{}" }} }},
1485 number: {{ eq: {} }}
1486 }},
1487 first: 1,
1488 includeArchived: true
1489 ) {{
1490 nodes {{
1491 id identifier url title description priority branchName
1492 createdAt updatedAt
1493 state {{ name type }}
1494 team {{ key }}
1495 assignee {{ name }}
1496 project {{ id name }}
1497 projectMilestone {{ id name }}
1498 cycle {{ id name number }}
1499 }}
1500 pageInfo {{ hasNextPage endCursor }}
1501 }}
1502 }}"#,
1503 team_key, number
1504 );
1505
1506 let data: IssuesData = self.query(&query, serde_json::json!({})).await?;
1507
1508 let Some(linear_issue) = data.issues.nodes.into_iter().next() else {
1509 return Ok(None);
1510 };
1511 let issue_id = linear_issue.id.clone();
1512 let (mut issue, _, _) = Self::convert_linear_issue(linear_issue);
1513 let labels = self.fetch_all_issue_labels_remote(&issue_id).await?;
1514 let label_ids = apply_issue_labels(&mut issue, labels);
1515 let relations = self.fetch_all_issue_relations_remote(&issue_id).await?;
1516 Ok(Some((issue, relations, label_ids)))
1517 }
1518
1519 fn convert_linear_issue(i: LinearIssue) -> (db::Issue, Vec<db::Relation>, Vec<String>) {
1520 let labels: Vec<String> = i.labels.nodes.iter().map(|l| l.name.clone()).collect();
1521 let label_ids: Vec<String> = i.labels.nodes.iter().map(|l| l.id.clone()).collect();
1522 let labels_json = serde_json::to_string(&labels).unwrap_or_else(|_| "[]".to_string());
1523
1524 let mut hasher = Sha256::new();
1525 hasher.update(&i.title);
1526 hasher.update(i.description.as_deref().unwrap_or(""));
1527 hasher.update(&labels_json);
1528 let content_hash = hex::encode(hasher.finalize());
1529
1530 let relations = Self::extract_relations(&i.id, &i);
1531
1532 let project_id = i.project.as_ref().map(|project| project.id.clone());
1533 let project_name = i.project.map(|project| project.name);
1534 let project_milestone_id = i
1535 .project_milestone
1536 .as_ref()
1537 .map(|milestone| milestone.id.clone());
1538 let project_milestone_name = i.project_milestone.map(|milestone| milestone.name);
1539 let cycle_id = i.cycle.as_ref().map(|cycle| cycle.id.clone());
1540 let cycle_name = i.cycle.map(|cycle| {
1541 cycle
1542 .name
1543 .unwrap_or_else(|| format!("Cycle {}", cycle.number))
1544 });
1545
1546 let issue = db::Issue {
1547 id: i.id,
1548 identifier: i.identifier,
1549 url: i.url,
1550 team_key: i.team.key,
1551 title: i.title,
1552 description: i.description,
1553 state_name: i.state.name,
1554 state_type: i.state.state_type,
1555 priority: i.priority,
1556 assignee_name: i.assignee.map(|a| a.name),
1557 project_name,
1558 labels_json,
1559 created_at: i.created_at,
1560 updated_at: i.updated_at,
1561 content_hash,
1562 synced_at: None,
1563 branch_name: i.branch_name,
1564 workspace_id: "default".to_string(),
1565 project_id,
1566 project_milestone_id,
1567 project_milestone_name,
1568 cycle_id,
1569 cycle_name,
1570 };
1571
1572 (issue, relations, label_ids)
1573 }
1574
1575 fn convert_linear_comment(issue_id: &str, comment: LinearComment) -> db::Comment {
1576 let external_name = comment
1577 .external_user
1578 .and_then(|u| u.display_name.or(u.name));
1579 db::Comment {
1580 id: comment.id,
1581 issue_id: issue_id.to_string(),
1582 body: comment.body,
1583 user_name: comment.user.map(|u| u.name).or(external_name),
1584 created_at: comment.created_at,
1585 updated_at: Some(comment.updated_at),
1586 parent_id: comment.parent_id,
1587 url: Some(comment.url),
1588 workspace_id: "default".to_string(),
1589 }
1590 }
1591
1592 pub async fn get_team_id(&self, team_key: &str) -> Result<String> {
1594 let teams = self.list_teams().await?;
1595 teams
1596 .iter()
1597 .find(|t| t.key.eq_ignore_ascii_case(team_key))
1598 .map(|t| t.id.clone())
1599 .with_context(|| format!("Team '{}' not found", team_key))
1600 }
1601
1602 pub async fn get_state_id(&self, team_key: &str, state_name: &str) -> Result<String> {
1605 let team_id = self.get_team_id(team_key).await?;
1606 let query = r#"
1607 query($teamId: String!) {
1608 team(id: $teamId) {
1609 states { nodes { id name type } }
1610 }
1611 }
1612 "#;
1613
1614 let data: serde_json::Value = self
1615 .query(query, serde_json::json!({ "teamId": team_id }))
1616 .await?;
1617
1618 let states = data["team"]["states"]["nodes"]
1619 .as_array()
1620 .context("No states in response")?;
1621
1622 for state in states {
1623 if let Some(name) = state["name"].as_str() {
1624 if name.eq_ignore_ascii_case(state_name) {
1625 return state["id"]
1626 .as_str()
1627 .map(|s| s.to_string())
1628 .context("State has no id");
1629 }
1630 }
1631 }
1632
1633 for state in states {
1635 if let Some(t) = state["type"].as_str() {
1636 if t.eq_ignore_ascii_case(state_name) {
1637 return state["id"]
1638 .as_str()
1639 .map(|s| s.to_string())
1640 .context("State has no id");
1641 }
1642 }
1643 }
1644
1645 let available: Vec<&str> = states.iter().filter_map(|s| s["name"].as_str()).collect();
1646 anyhow::bail!(
1647 "State '{}' not found for team {}. Available: {}",
1648 state_name,
1649 team_key,
1650 available.join(", ")
1651 )
1652 }
1653
1654 pub async fn get_label_ids(&self, label_names: &[String]) -> Result<Vec<String>> {
1658 if label_names.is_empty() {
1659 return Ok(Vec::new());
1660 }
1661
1662 let labels = self.fetch_labels().await?;
1663
1664 let mut ids = Vec::new();
1665 for name in label_names {
1666 let found = labels
1667 .iter()
1668 .find(|label| label.name.eq_ignore_ascii_case(name));
1669 match found {
1670 Some(label) => ids.push(label.id.clone()),
1671 None => {
1672 let available = labels
1673 .iter()
1674 .map(|label| label.name.as_str())
1675 .collect::<Vec<_>>();
1676 anyhow::bail!(
1677 "Label '{}' not found. Available: {}",
1678 name,
1679 available.join(", ")
1680 );
1681 }
1682 }
1683 }
1684
1685 Ok(ids)
1686 }
1687
1688 pub async fn resolve_assignee_id(&self, input: &str) -> Result<String> {
1695 let trimmed = input.trim();
1696 if trimmed.eq_ignore_ascii_case("none") {
1697 return Ok(String::new());
1698 }
1699 if trimmed.eq_ignore_ascii_case("me") {
1700 if let Some(cached) = self.viewer_id.read().unwrap().clone() {
1701 return Ok(cached);
1702 }
1703 let data: serde_json::Value = self
1704 .query("query { viewer { id } }", serde_json::json!({}))
1705 .await?;
1706 let id = data["viewer"]["id"]
1707 .as_str()
1708 .context("viewer query returned no id")?
1709 .to_string();
1710 *self.viewer_id.write().unwrap() = Some(id.clone());
1711 return Ok(id);
1712 }
1713
1714 let data: serde_json::Value = self
1716 .query(
1717 "query { users(first: 250) { nodes { id name } } }",
1718 serde_json::json!({}),
1719 )
1720 .await?;
1721 let nodes = data["users"]["nodes"]
1722 .as_array()
1723 .context("users query returned no nodes")?;
1724 let matches: Vec<(String, String)> = nodes
1725 .iter()
1726 .filter_map(|n| {
1727 let name = n["name"].as_str()?;
1728 if name.eq_ignore_ascii_case(trimmed) {
1729 Some((n["id"].as_str()?.to_string(), name.to_string()))
1730 } else {
1731 None
1732 }
1733 })
1734 .collect();
1735
1736 match matches.len() {
1737 0 => anyhow::bail!("Assignee '{}' not found in Linear users.", trimmed),
1738 1 => Ok(matches.into_iter().next().unwrap().0),
1739 _ => {
1740 let names: Vec<&str> = matches.iter().map(|(_, n)| n.as_str()).collect();
1741 anyhow::bail!(
1742 "Assignee '{}' matched multiple users: {}. Use a more specific name.",
1743 trimmed,
1744 names.join(", ")
1745 )
1746 }
1747 }
1748 }
1749
1750 pub async fn fetch_labels(&self) -> Result<Vec<LabelCatalogEntry>> {
1752 let mut out = Vec::new();
1753 paginate(
1754 &self.sync_query_config,
1755 LinearOperation::Labels,
1756 None,
1757 |request| async move {
1758 self.fetch_labels_page(request.cursor.as_deref(), request.page_size)
1759 .await
1760 },
1761 |nodes, _| {
1762 out.extend(nodes);
1763 ready(Ok(()))
1764 },
1765 |label| label.id.clone(),
1766 |event| self.observe_sync_event(event),
1767 )
1768 .await?;
1769 Ok(out)
1770 }
1771
1772 async fn fetch_labels_page(
1773 &self,
1774 cursor: Option<&str>,
1775 page_size: usize,
1776 ) -> Result<ConnectionPage<LabelCatalogEntry>> {
1777 let query = r#"
1778 query($first: Int!, $after: String) {
1779 issueLabels(first: $first, after: $after, orderBy: updatedAt) {
1780 nodes { id name color parent { id } }
1781 pageInfo { hasNextPage endCursor }
1782 }
1783 }
1784 "#;
1785 let data: IssueLabelsData = self
1786 .query_operation(
1787 LinearOperation::Labels.name(),
1788 cursor,
1789 query,
1790 serde_json::json!({ "first": page_size, "after": cursor }),
1791 )
1792 .await?;
1793 Ok(ConnectionPage {
1794 nodes: data
1795 .issue_labels
1796 .nodes
1797 .into_iter()
1798 .map(|label| LabelCatalogEntry {
1799 id: label.id,
1800 name: label.name,
1801 color: label.color,
1802 parent_id: label.parent.map(|parent| parent.id),
1803 })
1804 .collect(),
1805 page_info: data.issue_labels.page_info,
1806 })
1807 }
1808
1809 pub async fn sync_labels_catalog(&self, db: &Database, workspace_id: &str) -> Result<usize> {
1812 let sync_token = Uuid::new_v4().to_string();
1813 db.mark_sync_family_running(
1814 workspace_id,
1815 "*",
1816 "labels",
1817 None,
1818 Some(self.sync_query_config.page_size(LinearOperation::Labels)),
1819 &sync_token,
1820 )?;
1821 let result = paginate(
1822 &self.sync_query_config,
1823 LinearOperation::Labels,
1824 None,
1825 |request| async move {
1826 self.fetch_labels_page(request.cursor.as_deref(), request.page_size)
1827 .await
1828 },
1829 |entries, context| {
1830 let result = (|| {
1831 for entry in entries {
1832 db.upsert_label(&db::Label {
1833 id: entry.id.clone(),
1834 workspace_id: workspace_id.to_string(),
1835 name: entry.name,
1836 color: entry.color,
1837 parent_id: entry.parent_id,
1838 })?;
1839 db.mark_label_sync_token(&entry.id, &sync_token)?;
1840 }
1841 db.mark_sync_family_running(
1842 workspace_id,
1843 "*",
1844 "labels",
1845 context.cursor.as_deref(),
1846 Some(context.page_size),
1847 &sync_token,
1848 )
1849 })();
1850 ready(result)
1851 },
1852 |label| label.id.clone(),
1853 |event| self.observe_sync_event(event),
1854 )
1855 .await;
1856 match result {
1857 Ok(stats) => {
1858 db.reconcile_label_sync(workspace_id, &sync_token)?;
1859 db.mark_sync_family_complete(
1860 workspace_id,
1861 "*",
1862 "labels",
1863 Some(self.sync_query_config.page_size(LinearOperation::Labels)),
1864 &sync_token,
1865 )?;
1866 Ok(stats.nodes)
1867 }
1868 Err(error) => {
1869 let message = self.redacted_error_message(&error);
1870 db.mark_sync_family_failed(
1871 workspace_id,
1872 "*",
1873 "labels",
1874 &sync_token,
1875 &message,
1876 )?;
1877 Err(error)
1878 }
1879 }
1880 }
1881
1882 pub async fn get_project_id(&self, project_name: &str) -> Result<String> {
1884 self.find_project_by_name(project_name).await
1885 }
1886
1887 pub async fn create_relation(
1891 &self,
1892 issue_id: &str,
1893 related_issue_id: &str,
1894 relation_type: &str,
1895 ) -> Result<String> {
1896 let (actual_issue_id, actual_related_id, api_type) = if relation_type == "blocked_by" {
1897 (related_issue_id, issue_id, "blocks")
1898 } else {
1899 (issue_id, related_issue_id, relation_type)
1900 };
1901
1902 let query = r#"
1903 mutation($input: IssueRelationCreateInput!) {
1904 issueRelationCreate(input: $input) {
1905 success
1906 issueRelation { id }
1907 }
1908 }
1909 "#;
1910
1911 let input = serde_json::json!({
1912 "issueId": actual_issue_id,
1913 "relatedIssueId": actual_related_id,
1914 "type": api_type,
1915 });
1916
1917 let data: CreateRelationData = self
1918 .query(query, serde_json::json!({ "input": input }))
1919 .await?;
1920
1921 if !data.issue_relation_create.success {
1922 anyhow::bail!("Failed to create relation");
1923 }
1924
1925 let relation = data
1926 .issue_relation_create
1927 .issue_relation
1928 .context("No relation returned")?;
1929 Ok(relation.id)
1930 }
1931
1932 pub async fn delete_relation(&self, relation_id: &str) -> Result<()> {
1934 let query = r#"
1935 mutation($id: String!) {
1936 issueRelationDelete(id: $id) {
1937 success
1938 }
1939 }
1940 "#;
1941
1942 let data: DeleteRelationData = self
1943 .query(query, serde_json::json!({ "id": relation_id }))
1944 .await?;
1945
1946 if !data.issue_relation_delete.success {
1947 anyhow::bail!("Failed to delete relation");
1948 }
1949
1950 Ok(())
1951 }
1952}
1953
1954fn create_issue_value(create: &CreateIssueInput<'_>) -> serde_json::Value {
1955 let mut input = serde_json::json!({
1956 "teamId": create.team_id,
1957 "title": create.title,
1958 });
1959 if let Some(desc) = create.description {
1960 input["description"] = serde_json::Value::String(desc.to_string());
1961 }
1962 if let Some(priority) = create.priority {
1963 input["priority"] = serde_json::Value::Number(priority.into());
1964 }
1965 if !create.label_ids.is_empty() {
1966 input["labelIds"] = serde_json::json!(create.label_ids);
1967 }
1968 if let Some(assignee_id) = create.assignee_id {
1969 input["assigneeId"] = serde_json::Value::String(assignee_id.to_string());
1970 }
1971 if let Some(parent_id) = create.parent_id {
1972 input["parentId"] = serde_json::Value::String(parent_id.to_string());
1973 }
1974 if let Some(project_id) = create.project_id {
1975 input["projectId"] = serde_json::Value::String(project_id.to_string());
1976 }
1977 if let Some(milestone_id) = create.project_milestone_id {
1978 input["projectMilestoneId"] = serde_json::Value::String(milestone_id.to_string());
1979 }
1980 input
1981}
1982
1983fn issue_page_variables(
1984 page_size: usize,
1985 cursor: Option<&str>,
1986 include_archived: bool,
1987) -> serde_json::Value {
1988 serde_json::json!({
1989 "first": page_size,
1990 "after": cursor,
1991 "includeArchived": include_archived,
1992 })
1993}
1994
1995fn apply_issue_labels(issue: &mut db::Issue, labels: Vec<LinearLabel>) -> Vec<String> {
1996 let label_names = labels
1997 .iter()
1998 .map(|label| label.name.clone())
1999 .collect::<Vec<_>>();
2000 let label_ids = labels.into_iter().map(|label| label.id).collect::<Vec<_>>();
2001 issue.labels_json = serde_json::to_string(&label_names).unwrap_or_else(|_| "[]".to_string());
2002 let mut hasher = Sha256::new();
2003 hasher.update(&issue.title);
2004 hasher.update(issue.description.as_deref().unwrap_or(""));
2005 hasher.update(&issue.labels_json);
2006 issue.content_hash = hex::encode(hasher.finalize());
2007 label_ids
2008}
2009
2010fn redact_sensitive_fragments(mut message: String) -> String {
2011 message = redact_token_after_marker(message, "bearer ");
2012 for marker in [
2013 "authorization:",
2014 "authorization=",
2015 "api_key:",
2016 "api_key=",
2017 "api-key:",
2018 "api-key=",
2019 "access_token:",
2020 "access_token=",
2021 "password:",
2022 "password=",
2023 "secret:",
2024 "secret=",
2025 ] {
2026 message = redact_token_after_marker(message, marker);
2027 }
2028 message
2029}
2030
2031fn redact_token_after_marker(mut message: String, marker: &str) -> String {
2032 let mut search_from = 0;
2033 loop {
2034 let lower = message.to_ascii_lowercase();
2035 let Some(relative_start) = lower[search_from..].find(marker) else {
2036 break;
2037 };
2038 let marker_end = search_from + relative_start + marker.len();
2039 let bytes = message.as_bytes();
2040 let mut value_start = marker_end;
2041 while value_start < bytes.len()
2042 && (bytes[value_start].is_ascii_whitespace()
2043 || matches!(bytes[value_start], b'\'' | b'"'))
2044 {
2045 value_start += 1;
2046 }
2047 let mut value_end = value_start;
2048 while value_end < bytes.len()
2049 && !bytes[value_end].is_ascii_whitespace()
2050 && !matches!(bytes[value_end], b',' | b';' | b'\'' | b'"' | b')')
2051 {
2052 value_end += 1;
2053 }
2054 if value_start == value_end {
2055 search_from = marker_end;
2056 continue;
2057 }
2058 message.replace_range(value_start..value_end, "[REDACTED]");
2059 search_from = value_start + "[REDACTED]".len();
2060 }
2061 message
2062}
2063
2064fn classify_http_status(status: u16) -> LinearErrorKind {
2065 match status {
2066 401 | 403 => LinearErrorKind::Authentication,
2067 429 => LinearErrorKind::RateLimit,
2068 408 | 500..=599 => LinearErrorKind::Transient,
2069 _ => LinearErrorKind::Api,
2070 }
2071}
2072
2073fn classify_graphql_message(message: &str) -> LinearErrorKind {
2074 let lower = message.to_lowercase();
2075 if lower.contains("complexity")
2076 || lower.contains("maximum allowed")
2077 || lower.contains("query cost")
2078 {
2079 LinearErrorKind::Complexity
2080 } else if lower.contains("rate limit") || lower.contains("too many requests") {
2081 LinearErrorKind::RateLimit
2082 } else if lower.contains("unauthorized")
2083 || lower.contains("forbidden")
2084 || lower.contains("authentication")
2085 {
2086 LinearErrorKind::Authentication
2087 } else if lower.contains("validation")
2088 || lower.contains("cannot query field")
2089 || lower.contains("unknown argument")
2090 {
2091 LinearErrorKind::Validation
2092 } else if lower.contains("internal server")
2093 || lower.contains("internal error")
2094 || lower.contains("temporarily unavailable")
2095 || lower.contains("service unavailable")
2096 || lower.contains("timeout")
2097 || lower.contains("timed out")
2098 || lower.contains("try again")
2099 {
2100 LinearErrorKind::Transient
2101 } else {
2102 LinearErrorKind::Api
2103 }
2104}
2105
2106#[cfg(test)]
2107mod tests {
2108 use std::collections::HashMap;
2109 use std::io::{Read, Write};
2110 use std::net::{TcpListener, TcpStream};
2111 use std::sync::atomic::{AtomicBool, Ordering};
2112 use std::sync::{Arc, Mutex};
2113 use std::thread;
2114 use std::time::Duration;
2115
2116 use crate::db::SyncFamilyState;
2117
2118 use super::*;
2119
2120 static SYNC_HTTP_TEST_LOCK: Mutex<()> = Mutex::new(());
2121
2122 struct MockResponse {
2123 status: u16,
2124 body: String,
2125 }
2126
2127 impl MockResponse {
2128 fn json(body: serde_json::Value) -> Self {
2129 Self {
2130 status: 200,
2131 body: body.to_string(),
2132 }
2133 }
2134
2135 fn status(status: u16) -> Self {
2136 Self {
2137 status,
2138 body: "{}".to_string(),
2139 }
2140 }
2141 }
2142
2143 struct MockLinearServer {
2144 url: String,
2145 stop: Arc<AtomicBool>,
2146 worker: Option<thread::JoinHandle<()>>,
2147 }
2148
2149 impl MockLinearServer {
2150 fn start<F>(mut handler: F) -> Self
2151 where
2152 F: FnMut(serde_json::Value) -> MockResponse + Send + 'static,
2153 {
2154 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2155 listener.set_nonblocking(true).unwrap();
2156 let address = listener.local_addr().unwrap();
2157 let stop = Arc::new(AtomicBool::new(false));
2158 let worker_stop = Arc::clone(&stop);
2159 let worker = thread::spawn(move || {
2160 while !worker_stop.load(Ordering::Relaxed) {
2161 match listener.accept() {
2162 Ok((mut stream, _)) => {
2163 if let Some(request) = read_json_request(&mut stream) {
2164 write_mock_response(&mut stream, handler(request));
2165 }
2166 }
2167 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
2168 thread::sleep(Duration::from_millis(1));
2169 }
2170 Err(_) => break,
2171 }
2172 }
2173 });
2174 Self {
2175 url: format!("http://{address}/graphql"),
2176 stop,
2177 worker: Some(worker),
2178 }
2179 }
2180 }
2181
2182 impl Drop for MockLinearServer {
2183 fn drop(&mut self) {
2184 self.stop.store(true, Ordering::Relaxed);
2185 if let Some(worker) = self.worker.take() {
2186 let _ = worker.join();
2187 }
2188 }
2189 }
2190
2191 fn read_json_request(stream: &mut TcpStream) -> Option<serde_json::Value> {
2192 stream.set_read_timeout(Some(Duration::from_secs(2))).ok()?;
2193 let mut request = Vec::new();
2194 let mut buffer = [0_u8; 4096];
2195 let (header_end, content_length) = loop {
2196 let count = stream.read(&mut buffer).ok()?;
2197 if count == 0 {
2198 return None;
2199 }
2200 request.extend_from_slice(&buffer[..count]);
2201 if let Some(header_end) = request.windows(4).position(|window| window == b"\r\n\r\n") {
2202 let headers = String::from_utf8_lossy(&request[..header_end]);
2203 let content_length = headers
2204 .lines()
2205 .find_map(|line| {
2206 let (name, value) = line.split_once(':')?;
2207 name.eq_ignore_ascii_case("content-length")
2208 .then(|| value.trim().parse::<usize>().ok())
2209 .flatten()
2210 })
2211 .unwrap_or(0);
2212 break (header_end + 4, content_length);
2213 }
2214 };
2215 while request.len() < header_end + content_length {
2216 let count = stream.read(&mut buffer).ok()?;
2217 if count == 0 {
2218 return None;
2219 }
2220 request.extend_from_slice(&buffer[..count]);
2221 }
2222 serde_json::from_slice(&request[header_end..header_end + content_length]).ok()
2223 }
2224
2225 fn write_mock_response(stream: &mut TcpStream, response: MockResponse) {
2226 let reason = match response.status {
2227 200 => "OK",
2228 403 => "Forbidden",
2229 500 => "Internal Server Error",
2230 _ => "Mock",
2231 };
2232 let headers = format!(
2233 "HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2234 response.status,
2235 reason,
2236 response.body.len()
2237 );
2238 let _ = stream.write_all(headers.as_bytes());
2239 let _ = stream.write_all(response.body.as_bytes());
2240 }
2241
2242 fn issue_node(id: &str, identifier: &str, updated_at: &str) -> serde_json::Value {
2243 serde_json::json!({
2244 "id": id,
2245 "identifier": identifier,
2246 "url": format!("https://linear.app/issue/{identifier}"),
2247 "title": format!("Issue {identifier}"),
2248 "description": "Mock issue",
2249 "priority": 2,
2250 "branchName": null,
2251 "createdAt": "2026-01-01T00:00:00Z",
2252 "updatedAt": updated_at,
2253 "state": { "name": "Todo", "type": "unstarted" },
2254 "team": { "key": "CUT" },
2255 "assignee": null,
2256 "project": null,
2257 "projectMilestone": null,
2258 "cycle": null
2259 })
2260 }
2261
2262 fn standard_sync_response(request: &serde_json::Value) -> Option<MockResponse> {
2263 let query = request["query"].as_str().unwrap_or_default();
2264 if query.contains("teams(first:") {
2265 return Some(MockResponse::json(serde_json::json!({
2266 "data": { "teams": {
2267 "nodes": [{ "id": "team-1", "key": "CUT", "name": "Cuttlefish" }],
2268 "pageInfo": { "hasNextPage": false, "endCursor": null }
2269 }}
2270 })));
2271 }
2272 if query.contains("projects(") {
2273 return Some(MockResponse::json(serde_json::json!({
2274 "data": { "projects": {
2275 "nodes": [],
2276 "pageInfo": { "hasNextPage": false, "endCursor": null }
2277 }}
2278 })));
2279 }
2280 if query.contains("issueLabels(") {
2281 return Some(MockResponse::json(serde_json::json!({
2282 "data": { "issueLabels": {
2283 "nodes": [],
2284 "pageInfo": { "hasNextPage": false, "endCursor": null }
2285 }}
2286 })));
2287 }
2288 if query.contains("cycles(") {
2289 return Some(MockResponse::json(serde_json::json!({
2290 "data": { "cycles": {
2291 "nodes": [],
2292 "pageInfo": { "hasNextPage": false, "endCursor": null }
2293 }}
2294 })));
2295 }
2296 if query.contains("issues(") {
2297 let nodes = if query.contains("updatedAt: { gt:") {
2298 Vec::new()
2299 } else {
2300 vec![
2301 issue_node("issue-1", "CUT-1", "2026-02-01T00:00:00Z"),
2302 issue_node("issue-2", "CUT-2", "2026-02-02T00:00:00Z"),
2303 ]
2304 };
2305 return Some(MockResponse::json(serde_json::json!({
2306 "data": { "issues": {
2307 "nodes": nodes,
2308 "pageInfo": { "hasNextPage": false, "endCursor": null }
2309 }}
2310 })));
2311 }
2312 if query.contains("labels(first:") {
2313 return Some(MockResponse::json(serde_json::json!({
2314 "data": { "issue": { "labels": {
2315 "nodes": [],
2316 "pageInfo": { "hasNextPage": false, "endCursor": null }
2317 }}}
2318 })));
2319 }
2320 if query.contains("relations(first:") {
2321 return Some(MockResponse::json(serde_json::json!({
2322 "data": { "issue": { "relations": {
2323 "nodes": [],
2324 "pageInfo": { "hasNextPage": false, "endCursor": null }
2325 }}}
2326 })));
2327 }
2328 None
2329 }
2330
2331 fn successful_comments() -> MockResponse {
2332 MockResponse::json(serde_json::json!({
2333 "data": { "comments": {
2334 "nodes": [],
2335 "pageInfo": { "hasNextPage": false, "endCursor": null }
2336 }}
2337 }))
2338 }
2339
2340 fn comment_issue_id(request: &serde_json::Value) -> Option<&str> {
2341 request["query"]
2342 .as_str()
2343 .is_some_and(|query| query.contains("comments("))
2344 .then(|| request["variables"]["issueId"].as_str())
2345 .flatten()
2346 }
2347
2348 fn test_client(api_url: &str) -> LinearClient {
2349 let mut config = SyncQueryConfig::default();
2350 config.max_retry_attempts = 2;
2351 config.retry_base_delay = Duration::ZERO;
2352 LinearClient::with_api_key("test-api-key")
2353 .with_api_url(api_url)
2354 .with_sync_query_config(config)
2355 }
2356
2357 fn runtime() -> tokio::runtime::Runtime {
2358 tokio::runtime::Builder::new_current_thread()
2359 .enable_all()
2360 .build()
2361 .unwrap()
2362 }
2363
2364 fn test_db() -> (Database, tempfile::TempDir) {
2365 let directory = tempfile::tempdir().unwrap();
2366 let db = Database::open(&directory.path().join("test.db")).unwrap();
2367 (db, directory)
2368 }
2369
2370 fn family_status(db: &Database, family: &str) -> SyncFamilyState {
2371 db.get_sync_family_state("default", "CUT", family)
2372 .unwrap()
2373 .unwrap()
2374 }
2375
2376 #[test]
2377 fn issue_create_serializes_project_and_milestone_relationships() {
2378 let labels = vec!["label-1".to_string()];
2379 let value = create_issue_value(&CreateIssueInput {
2380 team_id: "team-1",
2381 title: "Add request tracing",
2382 description: None,
2383 priority: Some(2),
2384 label_ids: &labels,
2385 assignee_id: None,
2386 parent_id: None,
2387 project_id: Some("project-1"),
2388 project_milestone_id: Some("milestone-1"),
2389 });
2390 assert_eq!(value["projectId"], serde_json::json!("project-1"));
2391 assert_eq!(
2392 value["projectMilestoneId"],
2393 serde_json::json!("milestone-1")
2394 );
2395 assert_eq!(value["labelIds"], serde_json::json!(["label-1"]));
2396 }
2397
2398 #[test]
2399 fn issue_pages_explicitly_toggle_archived_records() {
2400 assert_eq!(
2401 issue_page_variables(50, None, false)["includeArchived"],
2402 serde_json::json!(false)
2403 );
2404 assert_eq!(
2405 issue_page_variables(50, Some("cursor-1"), true)["includeArchived"],
2406 serde_json::json!(true)
2407 );
2408 }
2409
2410 #[test]
2411 fn graphql_errors_are_classified_without_stringly_typed_callers() {
2412 assert_eq!(
2413 classify_graphql_message("Query complexity: 72,400; maximum allowed: 10,000"),
2414 LinearErrorKind::Complexity
2415 );
2416 assert_eq!(
2417 classify_graphql_message("Cannot query field 'cycles'"),
2418 LinearErrorKind::Validation
2419 );
2420 assert_eq!(
2421 classify_graphql_message("Unauthorized"),
2422 LinearErrorKind::Authentication
2423 );
2424 assert_eq!(
2425 classify_graphql_message("Internal server error; try again"),
2426 LinearErrorKind::Transient
2427 );
2428 assert_eq!(classify_http_status(429), LinearErrorKind::RateLimit);
2429 assert_eq!(classify_http_status(500), LinearErrorKind::Transient);
2430 }
2431
2432 #[test]
2433 fn team_sync_retries_http_500_comments_then_completes() {
2434 let _serial = SYNC_HTTP_TEST_LOCK.lock().unwrap();
2435 let attempts = Arc::new(Mutex::new(HashMap::<String, usize>::new()));
2436 let server_attempts = Arc::clone(&attempts);
2437 let server = MockLinearServer::start(move |request| {
2438 if let Some(issue_id) = comment_issue_id(&request) {
2439 let mut attempts = server_attempts.lock().unwrap();
2440 let count = attempts.entry(issue_id.to_string()).or_default();
2441 *count += 1;
2442 if issue_id == "issue-1" && *count == 1 {
2443 return MockResponse::status(500);
2444 }
2445 return successful_comments();
2446 }
2447 standard_sync_response(&request).expect("unexpected GraphQL operation")
2448 });
2449 let client = test_client(&server.url);
2450 let (db, _dir) = test_db();
2451
2452 let count = runtime()
2453 .block_on(client.sync_team(&db, "CUT", "default", true, true, None))
2454 .unwrap();
2455
2456 assert_eq!(count, 2);
2457 assert_eq!(attempts.lock().unwrap().get("issue-1"), Some(&2));
2458 assert_eq!(
2459 db.get_comment_sync_state("issue-1").unwrap().status,
2460 "none_found"
2461 );
2462 assert_eq!(family_status(&db, "comments").status, "complete");
2463 }
2464
2465 #[test]
2466 fn exhausted_comment_retries_are_partial_and_do_not_block_later_issues_or_cursor() {
2467 let _serial = SYNC_HTTP_TEST_LOCK.lock().unwrap();
2468 let attempts = Arc::new(Mutex::new(HashMap::<String, usize>::new()));
2469 let server_attempts = Arc::clone(&attempts);
2470 let server = MockLinearServer::start(move |request| {
2471 if let Some(issue_id) = comment_issue_id(&request) {
2472 let mut attempts = server_attempts.lock().unwrap();
2473 *attempts.entry(issue_id.to_string()).or_default() += 1;
2474 return if issue_id == "issue-1" {
2475 MockResponse::status(500)
2476 } else {
2477 successful_comments()
2478 };
2479 }
2480 standard_sync_response(&request).expect("unexpected GraphQL operation")
2481 });
2482 let client = test_client(&server.url);
2483 let (db, _dir) = test_db();
2484
2485 let count = runtime()
2486 .block_on(client.sync_team(&db, "CUT", "default", true, true, None))
2487 .unwrap();
2488
2489 assert_eq!(count, 2);
2490 assert_eq!(attempts.lock().unwrap().get("issue-1"), Some(&3));
2491 assert_eq!(attempts.lock().unwrap().get("issue-2"), Some(&1));
2492 let failed = db.get_comment_sync_state("issue-1").unwrap();
2493 assert_eq!(failed.status, "unavailable");
2494 let diagnostic = failed.sync_error.unwrap();
2495 assert!(diagnostic.contains("failed to paginate comments at cursor None"));
2496 assert!(diagnostic.contains("HTTP 500"));
2497 assert!(diagnostic.chars().count() <= 500);
2498 assert_eq!(
2499 db.get_comment_sync_state("issue-2").unwrap().status,
2500 "none_found"
2501 );
2502 assert_eq!(family_status(&db, "issue labels").status, "complete");
2503 assert_eq!(family_status(&db, "relations").status, "complete");
2504 let comments = family_status(&db, "comments");
2505 assert_eq!(comments.status, "partial");
2506 assert_eq!(
2507 comments.error.as_deref(),
2508 Some("1 comment hydration(s) failed: CUT-1")
2509 );
2510 assert_eq!(
2511 db.get_sync_cursor("default", "CUT").unwrap().as_deref(),
2512 Some("2026-02-02T00:00:00Z")
2513 );
2514 }
2515
2516 #[test]
2517 fn permission_comment_failure_is_not_retried_and_other_issues_continue() {
2518 let _serial = SYNC_HTTP_TEST_LOCK.lock().unwrap();
2519 let attempts = Arc::new(Mutex::new(HashMap::<String, usize>::new()));
2520 let server_attempts = Arc::clone(&attempts);
2521 let server = MockLinearServer::start(move |request| {
2522 if let Some(issue_id) = comment_issue_id(&request) {
2523 let mut attempts = server_attempts.lock().unwrap();
2524 *attempts.entry(issue_id.to_string()).or_default() += 1;
2525 if issue_id == "issue-1" {
2526 return MockResponse::json(serde_json::json!({
2527 "errors": [{
2528 "message": "Forbidden: Authorization: Bearer super-secret-token"
2529 }]
2530 }));
2531 }
2532 return successful_comments();
2533 }
2534 standard_sync_response(&request).expect("unexpected GraphQL operation")
2535 });
2536 let client = test_client(&server.url);
2537 let (db, _dir) = test_db();
2538
2539 runtime()
2540 .block_on(client.sync_team(&db, "CUT", "default", true, true, None))
2541 .unwrap();
2542
2543 assert_eq!(attempts.lock().unwrap().get("issue-1"), Some(&1));
2544 assert_eq!(attempts.lock().unwrap().get("issue-2"), Some(&1));
2545 let failed = db.get_comment_sync_state("issue-1").unwrap();
2546 assert_eq!(failed.status, "permission_denied");
2547 let diagnostic = failed.sync_error.unwrap();
2548 assert!(diagnostic.contains("Forbidden"));
2549 assert!(!diagnostic.contains("super-secret-token"));
2550 assert_eq!(
2551 db.get_comment_sync_state("issue-2").unwrap().status,
2552 "none_found"
2553 );
2554 }
2555
2556 #[test]
2557 fn later_incremental_sync_recovers_failed_comment_state_and_clears_error() {
2558 let _serial = SYNC_HTTP_TEST_LOCK.lock().unwrap();
2559 let should_fail = Arc::new(AtomicBool::new(true));
2560 let server_should_fail = Arc::clone(&should_fail);
2561 let attempts = Arc::new(Mutex::new(HashMap::<String, usize>::new()));
2562 let server_attempts = Arc::clone(&attempts);
2563 let server = MockLinearServer::start(move |request| {
2564 if let Some(issue_id) = comment_issue_id(&request) {
2565 let mut attempts = server_attempts.lock().unwrap();
2566 *attempts.entry(issue_id.to_string()).or_default() += 1;
2567 if issue_id == "issue-1" && server_should_fail.load(Ordering::Relaxed) {
2568 return MockResponse::status(500);
2569 }
2570 return successful_comments();
2571 }
2572 standard_sync_response(&request).expect("unexpected GraphQL operation")
2573 });
2574 let client = test_client(&server.url);
2575 let (db, _dir) = test_db();
2576 let rt = runtime();
2577
2578 rt.block_on(client.sync_team(&db, "CUT", "default", true, true, None))
2579 .unwrap();
2580 assert_eq!(
2581 db.get_comment_sync_state("issue-1").unwrap().status,
2582 "unavailable"
2583 );
2584 let first_cursor = db.get_sync_cursor("default", "CUT").unwrap();
2585 should_fail.store(false, Ordering::Relaxed);
2586
2587 let count = rt
2588 .block_on(client.sync_team(&db, "CUT", "default", false, false, None))
2589 .unwrap();
2590
2591 assert_eq!(count, 0);
2592 let recovered = db.get_comment_sync_state("issue-1").unwrap();
2593 assert_eq!(recovered.status, "none_found");
2594 assert!(recovered.sync_error.is_none());
2595 assert_eq!(family_status(&db, "comments").status, "complete");
2596 assert_eq!(db.get_sync_cursor("default", "CUT").unwrap(), first_cursor);
2597 assert_eq!(attempts.lock().unwrap().get("issue-1"), Some(&4));
2598 }
2599
2600 #[test]
2601 fn error_diagnostics_keep_chains_bounded_and_redact_credentials() {
2602 let client = LinearClient::with_api_key("top-secret-api-key");
2603 let error: anyhow::Error = LinearOperationError::new(
2604 LinearErrorKind::Transient,
2605 "comments",
2606 None,
2607 format!(
2608 "upstream timeout Authorization: Bearer top-secret-api-key {}",
2609 "x".repeat(700)
2610 ),
2611 )
2612 .into();
2613 let error = error.context("comment synchronization failed for CUT-249");
2614
2615 let diagnostic = client.redacted_error_message(&error);
2616
2617 assert!(diagnostic.contains("comment synchronization failed for CUT-249"));
2618 assert!(diagnostic.contains("upstream timeout"));
2619 assert!(diagnostic.contains("[REDACTED]"));
2620 assert!(!diagnostic.contains("top-secret-api-key"));
2621 assert!(diagnostic.chars().count() <= 500);
2622 }
2623}