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 pagination;
14mod progressive;
15mod projects;
16pub use pagination::{
17 LinearErrorKind, LinearOperation, LinearOperationError, SyncEvent, SyncQueryConfig,
18};
19pub use progressive::*;
20pub use projects::*;
21
22use pagination::{operation_error, paginate, ConnectionPage, PageInfo};
23
24const LINEAR_API_URL: &str = "https://api.linear.app/graphql";
25
26#[derive(Clone)]
27pub struct LinearClient {
28 client: reqwest::Client,
29 api_key: String,
30 api_url: String,
31 viewer_id: std::sync::Arc<std::sync::RwLock<Option<String>>>,
32 sync_query_config: SyncQueryConfig,
33}
34
35#[derive(Debug, Deserialize)]
36struct GraphQLResponse<T> {
37 data: Option<T>,
38 errors: Option<Vec<GraphQLError>>,
39}
40
41#[derive(Debug, Deserialize)]
42struct GraphQLError {
43 message: String,
44 #[serde(default)]
45 extensions: Option<GraphQLErrorExtensions>,
46}
47
48#[derive(Debug, Deserialize)]
49struct GraphQLErrorExtensions {
50 #[serde(default)]
51 code: Option<String>,
52}
53
54#[derive(Debug, Deserialize)]
57struct IssuesData {
58 issues: IssueConnection,
59}
60
61#[derive(Debug, Deserialize)]
62struct IssueConnection {
63 nodes: Vec<LinearIssue>,
64 #[serde(rename = "pageInfo")]
65 page_info: PageInfo,
66}
67
68#[derive(Debug, Deserialize)]
69struct LinearIssue {
70 id: String,
71 identifier: String,
72 url: String,
73 title: String,
74 description: Option<String>,
75 priority: i32,
76 #[serde(rename = "createdAt")]
77 created_at: String,
78 #[serde(rename = "updatedAt")]
79 updated_at: String,
80 #[serde(rename = "archivedAt", default)]
81 archived_at: Option<String>,
82 state: LinearState,
83 team: LinearTeam,
84 assignee: Option<LinearUser>,
85 project: Option<LinearProject>,
86 #[serde(rename = "projectMilestone")]
87 project_milestone: Option<LinearProjectMilestoneRef>,
88 cycle: Option<LinearCycleRef>,
89 #[serde(default)]
90 labels: LinearLabelConnection,
91 #[serde(default)]
92 relations: LinearRelationConnection,
93 #[serde(rename = "branchName")]
94 branch_name: Option<String>,
95}
96
97#[derive(Debug, Deserialize, Default)]
98struct LinearRelationConnection {
99 nodes: Vec<LinearRelation>,
100}
101
102#[derive(Debug, Deserialize)]
103struct IssueRelationsData {
104 issue: IssueRelationsNode,
105}
106
107#[derive(Debug, Deserialize)]
108struct IssueRelationsNode {
109 relations: PaginatedRelationConnection,
110}
111
112#[derive(Debug, Deserialize)]
113struct PaginatedRelationConnection {
114 nodes: Vec<LinearRelation>,
115 #[serde(rename = "pageInfo")]
116 page_info: PageInfo,
117}
118
119#[derive(Debug, Deserialize)]
120struct LinearRelation {
121 id: String,
122 #[serde(rename = "type")]
123 relation_type: String,
124 #[serde(rename = "relatedIssue")]
125 related_issue: LinearRelatedIssue,
126}
127
128#[derive(Debug, Deserialize)]
129struct LinearRelatedIssue {
130 id: String,
131 identifier: String,
132}
133
134#[derive(Debug, Deserialize)]
135struct LinearState {
136 name: String,
137 #[serde(rename = "type")]
138 state_type: String,
139}
140
141#[derive(Debug, Deserialize)]
142struct LinearTeam {
143 key: String,
144}
145
146#[derive(Debug, Deserialize)]
147struct LinearUser {
148 name: String,
149}
150
151#[derive(Debug, Deserialize)]
152struct LinearExternalUser {
153 name: Option<String>,
154 #[serde(rename = "displayName")]
155 display_name: Option<String>,
156}
157
158#[derive(Debug, Deserialize)]
159struct LinearProject {
160 id: String,
161 name: String,
162}
163
164#[derive(Debug, Deserialize)]
165struct LinearProjectMilestoneRef {
166 id: String,
167 name: String,
168}
169
170#[derive(Debug, Deserialize)]
171struct LinearCycleRef {
172 id: String,
173 name: Option<String>,
174 number: i32,
175}
176
177#[derive(Debug, Deserialize, Default)]
178struct LinearLabelConnection {
179 nodes: Vec<LinearLabel>,
180}
181
182#[derive(Debug, Deserialize)]
183struct IssueLabelsForIssueData {
184 issue: IssueLabelsForIssueNode,
185}
186
187#[derive(Debug, Deserialize)]
188struct IssueLabelsForIssueNode {
189 labels: PaginatedIssueLabelConnection,
190}
191
192#[derive(Debug, Deserialize)]
193struct PaginatedIssueLabelConnection {
194 nodes: Vec<LinearLabel>,
195 #[serde(rename = "pageInfo")]
196 page_info: PageInfo,
197}
198
199#[derive(Debug, Deserialize)]
200struct LinearLabel {
201 id: String,
202 name: String,
203}
204
205#[derive(Debug, Deserialize)]
208struct TeamsData {
209 teams: TeamConnection,
210}
211
212#[derive(Debug, Deserialize)]
213struct TeamConnection {
214 nodes: Vec<TeamNode>,
215 #[serde(rename = "pageInfo")]
216 page_info: PageInfo,
217}
218
219#[derive(Debug, Deserialize)]
220#[allow(dead_code)]
221pub struct TeamNode {
222 pub id: String,
223 pub key: String,
224 pub name: String,
225}
226
227#[derive(Debug, Clone)]
228pub struct LabelCatalogEntry {
229 pub id: String,
230 pub name: String,
231 pub color: Option<String>,
232 pub parent_id: Option<String>,
233}
234
235#[derive(Debug, Deserialize)]
236struct IssueLabelsData {
237 #[serde(rename = "issueLabels")]
238 issue_labels: IssueLabelCatalogConnection,
239}
240
241#[derive(Debug, Deserialize)]
242struct IssueLabelCatalogConnection {
243 nodes: Vec<IssueLabelCatalogNode>,
244 #[serde(rename = "pageInfo")]
245 page_info: PageInfo,
246}
247
248#[derive(Debug, Deserialize)]
249struct IssueLabelCatalogNode {
250 id: String,
251 name: String,
252 color: Option<String>,
253 parent: Option<IssueLabelParent>,
254}
255
256#[derive(Debug, Deserialize)]
257struct IssueLabelParent {
258 id: String,
259}
260
261#[derive(Debug, Deserialize)]
264struct CreateIssueData {
265 #[serde(rename = "issueCreate")]
266 issue_create: CreateIssuePayload,
267}
268
269#[derive(Debug, Deserialize)]
270struct CreateIssuePayload {
271 success: bool,
272 issue: Option<CreatedIssue>,
273}
274
275#[derive(Debug, Deserialize)]
276struct CreatedIssue {
277 id: String,
278 identifier: String,
279}
280
281#[derive(Debug)]
282pub struct CreateIssueInput<'a> {
283 pub team_id: &'a str,
284 pub title: &'a str,
285 pub description: Option<&'a str>,
286 pub priority: Option<i32>,
287 pub label_ids: &'a [String],
288 pub assignee_id: Option<&'a str>,
289 pub parent_id: Option<&'a str>,
290 pub project_id: Option<&'a str>,
291 pub project_milestone_id: Option<&'a str>,
292}
293
294#[derive(Debug, Deserialize)]
297struct CreateCommentData {
298 #[serde(rename = "commentCreate")]
299 comment_create: CreateCommentPayload,
300}
301
302#[derive(Debug, Deserialize)]
303struct CreateCommentPayload {
304 success: bool,
305}
306
307#[derive(Debug, Deserialize)]
310struct CommentsData {
311 comments: LinearCommentConnection,
312}
313
314#[derive(Debug, Deserialize)]
315struct LinearCommentConnection {
316 nodes: Vec<LinearComment>,
317 #[serde(rename = "pageInfo")]
318 page_info: PageInfo,
319}
320
321#[derive(Debug, Deserialize)]
322struct LinearComment {
323 id: String,
324 body: String,
325 #[serde(rename = "createdAt")]
326 created_at: String,
327 #[serde(rename = "updatedAt")]
328 updated_at: String,
329 #[serde(rename = "parentId")]
330 parent_id: Option<String>,
331 url: String,
332 user: Option<LinearUser>,
333 #[serde(rename = "externalUser")]
334 external_user: Option<LinearExternalUser>,
335}
336
337#[derive(Debug, Deserialize)]
340struct UpdateIssueData {
341 #[serde(rename = "issueUpdate")]
342 issue_update: UpdateIssuePayload,
343}
344
345#[derive(Debug, Deserialize)]
346struct UpdateIssuePayload {
347 success: bool,
348}
349
350#[derive(Debug, Default)]
351pub struct UpdateIssueInput<'a> {
352 pub title: Option<&'a str>,
353 pub description: Option<&'a str>,
354 pub priority: Option<i32>,
355 pub state_id: Option<&'a str>,
356 pub label_ids: Option<&'a [String]>,
357 pub project_id: Option<&'a str>,
358 pub assignee_id: Option<&'a str>,
359 pub project_milestone_id: Option<&'a str>,
360}
361
362#[derive(Debug, Deserialize)]
365struct CreateRelationData {
366 #[serde(rename = "issueRelationCreate")]
367 issue_relation_create: CreateRelationPayload,
368}
369
370#[derive(Debug, Deserialize)]
371struct CreateRelationPayload {
372 success: bool,
373 #[serde(rename = "issueRelation")]
374 issue_relation: Option<CreatedRelation>,
375}
376
377#[derive(Debug, Deserialize)]
378struct CreatedRelation {
379 id: String,
380}
381
382#[derive(Debug, Deserialize)]
383struct DeleteRelationData {
384 #[serde(rename = "issueRelationDelete")]
385 issue_relation_delete: DeleteRelationPayload,
386}
387
388#[derive(Debug, Deserialize)]
389struct DeleteRelationPayload {
390 success: bool,
391}
392
393#[derive(Debug, Deserialize)]
396struct SingleIssueData {
397 issue: LinearIssue,
398}
399
400impl LinearClient {
401 pub fn new(config: &Config) -> Result<Self> {
402 let api_key = config.linear_api_key()?.to_string();
403 let client = reqwest::Client::new();
404 Ok(Self {
405 client,
406 api_key,
407 api_url: LINEAR_API_URL.to_string(),
408 viewer_id: std::sync::Arc::new(std::sync::RwLock::new(None)),
409 sync_query_config: SyncQueryConfig::from_environment(),
410 })
411 }
412
413 pub fn with_api_key(api_key: &str) -> Self {
415 Self {
416 client: reqwest::Client::new(),
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_http_client(client: reqwest::Client, api_key: &str) -> Self {
429 Self {
430 client,
431 api_key: api_key.to_string(),
432 api_url: LINEAR_API_URL.to_string(),
433 viewer_id: std::sync::Arc::new(std::sync::RwLock::new(None)),
434 sync_query_config: SyncQueryConfig::from_environment(),
435 }
436 }
437
438 pub fn with_sync_query_config(mut self, sync_query_config: SyncQueryConfig) -> Self {
439 self.sync_query_config = sync_query_config;
440 self
441 }
442
443 #[cfg(test)]
444 fn with_api_url(mut self, api_url: impl Into<String>) -> Self {
445 self.api_url = api_url.into();
446 self
447 }
448
449 pub fn sync_query_config(&self) -> &SyncQueryConfig {
450 &self.sync_query_config
451 }
452
453 fn observe_sync_event(&self, event: SyncEvent) {
454 if !self.sync_query_config.verbose {
455 return;
456 }
457 let parent = event
458 .parent
459 .as_deref()
460 .map(|value| format!(" parent={value}"))
461 .unwrap_or_default();
462 let reduction = if event.adaptive_reduction {
463 " adaptive-page-size=true"
464 } else {
465 ""
466 };
467 if let Some(failure) = event.failure {
468 let failure = self.redacted_message(failure);
469 let status = if failure.starts_with("retrying attempt ") {
470 "retrying"
471 } else {
472 "failed"
473 };
474 eprintln!(
475 "sync operation={}{} page={} nodes={} page_size={}{} status={} error={}",
476 event.operation,
477 parent,
478 event.page_number,
479 event.nodes_received,
480 event.page_size,
481 reduction,
482 status,
483 failure
484 );
485 } else {
486 eprintln!(
487 "sync operation={}{} page={} nodes={} page_size={}{} status={}",
488 event.operation,
489 parent,
490 event.page_number,
491 event.nodes_received,
492 event.page_size,
493 reduction,
494 if event.completed {
495 "complete"
496 } else {
497 "running"
498 }
499 );
500 }
501 }
502
503 async fn query<T: serde::de::DeserializeOwned>(
504 &self,
505 query: &str,
506 variables: serde_json::Value,
507 ) -> Result<T> {
508 self.query_operation("GraphQL query", None, query, variables)
509 .await
510 }
511
512 async fn query_operation<T: serde::de::DeserializeOwned>(
513 &self,
514 operation: &str,
515 cursor: Option<&str>,
516 query: &str,
517 variables: serde_json::Value,
518 ) -> Result<T> {
519 let body = serde_json::json!({
520 "query": query,
521 "variables": variables,
522 });
523
524 let resp = self
525 .client
526 .post(&self.api_url)
527 .header("Authorization", &self.api_key)
528 .header("Content-Type", "application/json")
529 .json(&body)
530 .send()
531 .await
532 .map_err(|error| {
533 LinearOperationError::new(
534 LinearErrorKind::Transport,
535 operation,
536 cursor,
537 error.to_string(),
538 )
539 })?;
540
541 let status = resp.status();
542 let retry_after = retry_after_from_headers(resp.headers());
543 let body = resp.bytes().await.map_err(|error| {
544 LinearOperationError::new(
545 LinearErrorKind::Transport,
546 operation,
547 cursor,
548 format!("failed to read response: {error}"),
549 )
550 })?;
551 let parsed: std::result::Result<GraphQLResponse<T>, _> = serde_json::from_slice(&body);
552
553 if let Ok(response) = parsed {
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 message = self.redacted_message(message);
561 let kind = classify_graphql_errors(&errors);
562 return Err(LinearOperationError::new(kind, operation, cursor, message)
563 .with_retry_after(retry_after)
564 .into());
565 }
566 if !status.is_success() {
567 return Err(LinearOperationError::new(
568 classify_http_status(status.as_u16()),
569 operation,
570 cursor,
571 format!("HTTP {status} (response body omitted)"),
572 )
573 .with_retry_after(retry_after)
574 .into());
575 }
576 return response.data.ok_or_else(|| {
577 LinearOperationError::new(
578 LinearErrorKind::Api,
579 operation,
580 cursor,
581 "response did not contain data",
582 )
583 .into()
584 });
585 }
586
587 if !status.is_success() {
588 return Err(LinearOperationError::new(
589 classify_http_status(status.as_u16()),
590 operation,
591 cursor,
592 format!("HTTP {status} (unparseable response body omitted)"),
593 )
594 .with_retry_after(retry_after)
595 .into());
596 }
597 Err(LinearOperationError::new(
598 LinearErrorKind::Api,
599 operation,
600 cursor,
601 "failed to parse GraphQL response",
602 )
603 .into())
604 }
605
606 pub async fn list_teams(&self) -> Result<Vec<TeamNode>> {
607 let query = r#"
608 query($first: Int!, $after: String) {
609 teams(first: $first, after: $after, orderBy: updatedAt) {
610 nodes { id key name }
611 pageInfo { hasNextPage endCursor }
612 }
613 }
614 "#;
615 let mut teams = Vec::new();
616 paginate(
617 &self.sync_query_config,
618 LinearOperation::Teams,
619 None,
620 |request| async move {
621 let data: TeamsData = self
622 .query_operation(
623 LinearOperation::Teams.name(),
624 request.cursor.as_deref(),
625 query,
626 serde_json::json!({
627 "first": request.page_size,
628 "after": request.cursor,
629 }),
630 )
631 .await?;
632 Ok(ConnectionPage {
633 nodes: data.teams.nodes,
634 page_info: data.teams.page_info,
635 })
636 },
637 |nodes, _| {
638 teams.extend(nodes);
639 ready(Ok(()))
640 },
641 |team| team.id.clone(),
642 |event| self.observe_sync_event(event),
643 )
644 .await?;
645 Ok(teams)
646 }
647
648 fn extract_relations(issue_id: &str, linear_issue: &LinearIssue) -> Vec<db::Relation> {
649 linear_issue
650 .relations
651 .nodes
652 .iter()
653 .map(|r| db::Relation {
654 id: r.id.clone(),
655 issue_id: issue_id.to_string(),
656 related_issue_id: r.related_issue.id.clone(),
657 related_issue_identifier: r.related_issue.identifier.clone(),
658 relation_type: r.relation_type.clone(),
659 })
660 .collect()
661 }
662
663 fn convert_linear_relation(issue_id: &str, relation: LinearRelation) -> db::Relation {
664 db::Relation {
665 id: relation.id,
666 issue_id: issue_id.to_string(),
667 related_issue_id: relation.related_issue.id,
668 related_issue_identifier: relation.related_issue.identifier,
669 relation_type: relation.relation_type,
670 }
671 }
672
673 pub async fn fetch_issues(
674 &self,
675 team_key: &str,
676 after_cursor: Option<&str>,
677 updated_after: Option<&str>,
678 include_archived: bool,
679 ) -> Result<(
680 Vec<(db::Issue, Vec<db::Relation>, Vec<String>)>,
681 bool,
682 Option<String>,
683 )> {
684 let page = self
685 .fetch_issues_page(
686 team_key,
687 after_cursor,
688 updated_after,
689 include_archived,
690 self.sync_query_config.page_size(LinearOperation::Issues),
691 )
692 .await?;
693 Ok((
694 page.nodes,
695 page.page_info.has_next_page,
696 page.page_info.end_cursor,
697 ))
698 }
699
700 async fn fetch_issues_page(
701 &self,
702 team_key: &str,
703 after_cursor: Option<&str>,
704 updated_after: Option<&str>,
705 include_archived: bool,
706 page_size: usize,
707 ) -> Result<ConnectionPage<(db::Issue, Vec<db::Relation>, Vec<String>)>> {
708 let mut filter_parts = vec![format!("team: {{ key: {{ eq: \"{}\" }} }}", team_key)];
709 if let Some(after) = updated_after {
710 filter_parts.push(format!("updatedAt: {{ gt: \"{}\" }}", after));
711 }
712 let filter = filter_parts.join(", ");
713 let query = format!(
714 r#"query($first: Int!, $after: String, $includeArchived: Boolean!) {{
715 issues(
716 first: $first,
717 after: $after,
718 filter: {{ {} }},
719 includeArchived: $includeArchived,
720 orderBy: updatedAt
721 ) {{
722 nodes {{
723 id identifier url title description priority branchName
724 createdAt updatedAt archivedAt
725 state {{ name type }}
726 team {{ key }}
727 assignee {{ name }}
728 project {{ id name }}
729 projectMilestone {{ id name }}
730 cycle {{ id name number }}
731 }}
732 pageInfo {{ hasNextPage endCursor }}
733 }}
734 }}"#,
735 filter
736 );
737
738 let data: IssuesData = self
739 .query_operation(
740 LinearOperation::Issues.name(),
741 after_cursor,
742 &query,
743 issue_page_variables(page_size, after_cursor, include_archived),
744 )
745 .await?;
746
747 let issues: Vec<(db::Issue, Vec<db::Relation>, Vec<String>)> = data
748 .issues
749 .nodes
750 .into_iter()
751 .map(Self::convert_linear_issue)
752 .collect();
753
754 Ok(ConnectionPage {
755 nodes: issues,
756 page_info: data.issues.page_info,
757 })
758 }
759
760 pub async fn sync_team(
763 &self,
764 db: &Database,
765 team_key: &str,
766 workspace_id: &str,
767 full: bool,
768 include_archived: bool,
769 progress: Option<&(dyn Fn(usize) + Send + Sync)>,
770 ) -> Result<usize> {
771 self.sync_projects_for_team(db, workspace_id, team_key, include_archived)
772 .await
773 .with_context(|| {
774 format!("project synchronization failed for workspace '{workspace_id}'")
775 })?;
776 self.sync_labels_catalog(db, workspace_id)
777 .await
778 .with_context(|| {
779 format!("label synchronization failed for workspace '{workspace_id}'")
780 })?;
781 self.sync_cycles(db, team_key, workspace_id, include_archived)
782 .await
783 .with_context(|| format!("cycle synchronization failed for team '{team_key}'"))?;
784
785 let progress_adapter = |update: SyncProgressUpdate| {
786 if matches!(
787 update.phase,
788 SyncProgressPhase::IndexingIssues | SyncProgressPhase::IndexComplete
789 ) {
790 if let Some(callback) = progress {
791 callback(update.completed);
792 }
793 }
794 };
795 let index = self
796 .sync_team_index(db, team_key, workspace_id, full, Some(&progress_adapter))
797 .await?;
798
799 if full {
800 db.requeue_team_hydration(workspace_id, team_key, "legacy_full")?;
801 } else {
802 db.requeue_team_retryable_comments(workspace_id, team_key)?;
803 }
804 let issue_count = db.count_issues(Some(team_key), workspace_id)?;
805 let hydration = self
806 .hydrate_pending_issues(
807 db,
808 team_key,
809 workspace_id,
810 issue_count.max(1),
811 db::HydrationPolicy::All,
812 None,
813 )
814 .await?;
815
816 let family_token = Uuid::new_v4().to_string();
817 if hydration.required_failures == 0 {
818 db.mark_sync_family_complete(
819 workspace_id,
820 team_key,
821 "issue labels",
822 None,
823 &family_token,
824 )?;
825 db.mark_sync_family_complete(workspace_id, team_key, "relations", None, &family_token)?;
826 } else {
827 let summary = format!(
828 "{} required hydration resource(s) failed",
829 hydration.required_failures
830 );
831 db.mark_sync_family_partial(
832 workspace_id,
833 team_key,
834 "issue labels",
835 &family_token,
836 &summary,
837 )?;
838 db.mark_sync_family_partial(
839 workspace_id,
840 team_key,
841 "relations",
842 &family_token,
843 &summary,
844 )?;
845 }
846 if hydration.comment_failures == 0 {
847 db.mark_sync_family_complete(workspace_id, team_key, "comments", None, &family_token)?;
848 } else {
849 let summary = format!("{} comment hydration(s) failed", hydration.comment_failures);
850 db.mark_sync_family_partial(
851 workspace_id,
852 team_key,
853 "comments",
854 &family_token,
855 &summary,
856 )?;
857 }
858 if hydration.required_failures > 0 || hydration.rate_limited {
859 anyhow::bail!(
860 "issue hydration incomplete after the issue index committed ({} required failures, rate_limited={})",
861 hydration.required_failures,
862 hydration.rate_limited
863 );
864 }
865 Ok(index.indexed)
866 }
867
868 pub async fn create_issue(&self, create: CreateIssueInput<'_>) -> Result<(String, String)> {
869 let input = create_issue_value(&create);
870
871 let query = r#"
872 mutation($input: IssueCreateInput!) {
873 issueCreate(input: $input) {
874 success
875 issue { id identifier }
876 }
877 }
878 "#;
879
880 let data: CreateIssueData = self
881 .query(query, serde_json::json!({ "input": input }))
882 .await?;
883
884 if !data.issue_create.success {
885 anyhow::bail!("Failed to create issue");
886 }
887
888 let issue = data.issue_create.issue.context("No issue returned")?;
889 Ok((issue.id, issue.identifier))
890 }
891
892 pub async fn add_comment(&self, issue_id: &str, body: &str) -> Result<()> {
893 let query = r#"
894 mutation($input: CommentCreateInput!) {
895 commentCreate(input: $input) {
896 success
897 }
898 }
899 "#;
900
901 let input = serde_json::json!({
902 "issueId": issue_id,
903 "body": body,
904 });
905
906 let data: CreateCommentData = self
907 .query(query, serde_json::json!({ "input": input }))
908 .await?;
909
910 if !data.comment_create.success {
911 anyhow::bail!("Failed to create comment");
912 }
913
914 Ok(())
915 }
916
917 async fn fetch_issue_comments_page(
918 &self,
919 issue_id: &str,
920 cursor: Option<&str>,
921 page_size: usize,
922 ) -> Result<ConnectionPage<db::Comment>> {
923 let query = r#"
924 query($issueId: ID!, $first: Int!, $after: String) {
925 comments(
926 filter: { issue: { id: { eq: $issueId } } },
927 first: $first,
928 after: $after,
929 includeArchived: true,
930 orderBy: createdAt
931 ) {
932 nodes {
933 id body createdAt updatedAt parentId url
934 user { name }
935 externalUser { displayName name }
936 }
937 pageInfo { hasNextPage endCursor }
938 }
939 }
940 "#;
941 let data: CommentsData = self
942 .query_operation(
943 LinearOperation::Comments.name(),
944 cursor,
945 query,
946 serde_json::json!({
947 "issueId": issue_id,
948 "first": page_size,
949 "after": cursor,
950 }),
951 )
952 .await?;
953 Ok(ConnectionPage {
954 nodes: data
955 .comments
956 .nodes
957 .into_iter()
958 .map(|comment| Self::convert_linear_comment(issue_id, comment))
959 .collect(),
960 page_info: data.comments.page_info,
961 })
962 }
963
964 pub async fn fetch_issue_comments(&self, issue_id: &str) -> Result<Vec<db::Comment>> {
965 let mut comments = Vec::new();
966 paginate(
967 &self.sync_query_config,
968 LinearOperation::Comments,
969 Some(issue_id.to_string()),
970 |request| async move {
971 self.fetch_issue_comments_page(
972 issue_id,
973 request.cursor.as_deref(),
974 request.page_size,
975 )
976 .await
977 },
978 |nodes, _| {
979 comments.extend(nodes);
980 ready(Ok(()))
981 },
982 |comment| comment.id.clone(),
983 |event| self.observe_sync_event(event),
984 )
985 .await?;
986 Ok(comments)
987 }
988
989 pub async fn sync_issue_comments(
990 &self,
991 db: &Database,
992 issue_id: &str,
993 workspace_id: &str,
994 ) -> Result<usize> {
995 let sync_token = Uuid::new_v4().to_string();
996 let result = paginate(
997 &self.sync_query_config,
998 LinearOperation::Comments,
999 Some(issue_id.to_string()),
1000 |request| async move {
1001 self.fetch_issue_comments_page(
1002 issue_id,
1003 request.cursor.as_deref(),
1004 request.page_size,
1005 )
1006 .await
1007 },
1008 |mut comments, _| {
1009 for comment in &mut comments {
1010 comment.workspace_id = workspace_id.to_string();
1011 }
1012 ready(db.upsert_comment_page(issue_id, workspace_id, &comments, &sync_token))
1013 },
1014 |comment| comment.id.clone(),
1015 |event| self.observe_sync_event(event),
1016 )
1017 .await;
1018 match result {
1019 Ok(stats) => {
1020 db.complete_comment_sync(issue_id, workspace_id, &sync_token)?;
1021 db.mark_comments_synced(issue_id, workspace_id, stats.nodes)?;
1022 Ok(stats.nodes)
1023 }
1024 Err(error) => {
1025 let status = Self::comment_error_status(&error);
1026 let message = self.redacted_error_message(&error);
1027 db.mark_comments_sync_failed(issue_id, workspace_id, status, &message)?;
1028 Err(error)
1029 }
1030 }
1031 }
1032
1033 async fn fetch_issue_relations_page(
1034 &self,
1035 issue_id: &str,
1036 cursor: Option<&str>,
1037 page_size: usize,
1038 ) -> Result<ConnectionPage<db::Relation>> {
1039 let query = r#"
1040 query($issueId: String!, $first: Int!, $after: String) {
1041 issue(id: $issueId) {
1042 relations(first: $first, after: $after) {
1043 nodes { id type relatedIssue { id identifier } }
1044 pageInfo { hasNextPage endCursor }
1045 }
1046 }
1047 }
1048 "#;
1049 let data: IssueRelationsData = self
1050 .query_operation(
1051 LinearOperation::Relations.name(),
1052 cursor,
1053 query,
1054 serde_json::json!({
1055 "issueId": issue_id,
1056 "first": page_size,
1057 "after": cursor,
1058 }),
1059 )
1060 .await?;
1061 Ok(ConnectionPage {
1062 nodes: data
1063 .issue
1064 .relations
1065 .nodes
1066 .into_iter()
1067 .map(|relation| Self::convert_linear_relation(issue_id, relation))
1068 .collect(),
1069 page_info: data.issue.relations.page_info,
1070 })
1071 }
1072
1073 pub async fn sync_issue_relations(&self, db: &Database, issue_id: &str) -> Result<usize> {
1074 let sync_token = Uuid::new_v4().to_string();
1075 let stats = paginate(
1076 &self.sync_query_config,
1077 LinearOperation::Relations,
1078 Some(issue_id.to_string()),
1079 |request| async move {
1080 self.fetch_issue_relations_page(
1081 issue_id,
1082 request.cursor.as_deref(),
1083 request.page_size,
1084 )
1085 .await
1086 },
1087 |relations, _| ready(db.upsert_relation_page(issue_id, &relations, &sync_token)),
1088 |relation| relation.id.clone(),
1089 |event| self.observe_sync_event(event),
1090 )
1091 .await?;
1092 db.complete_relation_sync(issue_id, &sync_token)?;
1093 Ok(stats.nodes)
1094 }
1095
1096 async fn fetch_issue_labels_page(
1097 &self,
1098 issue_id: &str,
1099 cursor: Option<&str>,
1100 page_size: usize,
1101 ) -> Result<ConnectionPage<LinearLabel>> {
1102 let query = r#"
1103 query($issueId: String!, $first: Int!, $after: String) {
1104 issue(id: $issueId) {
1105 labels(first: $first, after: $after, orderBy: updatedAt) {
1106 nodes { id name }
1107 pageInfo { hasNextPage endCursor }
1108 }
1109 }
1110 }
1111 "#;
1112 let data: IssueLabelsForIssueData = self
1113 .query_operation(
1114 "issue labels",
1115 cursor,
1116 query,
1117 serde_json::json!({
1118 "issueId": issue_id,
1119 "first": page_size,
1120 "after": cursor,
1121 }),
1122 )
1123 .await?;
1124 Ok(ConnectionPage {
1125 nodes: data.issue.labels.nodes,
1126 page_info: data.issue.labels.page_info,
1127 })
1128 }
1129
1130 pub async fn sync_issue_labels(&self, db: &Database, issue_id: &str) -> Result<usize> {
1131 let workspace_id = db
1132 .get_issue(issue_id)?
1133 .with_context(|| format!("issue '{issue_id}' disappeared before label sync"))?
1134 .workspace_id;
1135 self.sync_issue_labels_in_workspace(db, issue_id, &workspace_id)
1136 .await
1137 }
1138
1139 pub async fn sync_issue_labels_in_workspace(
1140 &self,
1141 db: &Database,
1142 issue_id: &str,
1143 workspace_id: &str,
1144 ) -> Result<usize> {
1145 let sync_token = Uuid::new_v4().to_string();
1146 let mut names = Vec::new();
1147 let stats = paginate(
1148 &self.sync_query_config,
1149 LinearOperation::Labels,
1150 Some(issue_id.to_string()),
1151 |request| async move {
1152 self.fetch_issue_labels_page(issue_id, request.cursor.as_deref(), request.page_size)
1153 .await
1154 },
1155 |labels, _| {
1156 let catalog_result = labels.iter().try_for_each(|label| {
1157 db.upsert_label(&db::Label {
1158 id: label.id.clone(),
1159 workspace_id: workspace_id.to_string(),
1160 name: label.name.clone(),
1161 color: None,
1162 parent_id: None,
1163 })
1164 });
1165 if let Err(error) = catalog_result {
1166 return ready(Err(error));
1167 }
1168 let ids = labels
1169 .iter()
1170 .map(|label| label.id.clone())
1171 .collect::<Vec<_>>();
1172 names.extend(labels.into_iter().map(|label| label.name));
1173 ready(db.upsert_issue_label_page(issue_id, &ids, &sync_token))
1174 },
1175 |label| label.id.clone(),
1176 |event| self.observe_sync_event(event),
1177 )
1178 .await?;
1179 db.complete_issue_label_sync(issue_id, &sync_token)?;
1180
1181 let mut issue = db
1182 .get_issue(issue_id)?
1183 .with_context(|| format!("issue '{issue_id}' disappeared during label sync"))?;
1184 issue.labels_json = serde_json::to_string(&names)?;
1185 let mut hasher = Sha256::new();
1186 hasher.update(&issue.title);
1187 hasher.update(issue.description.as_deref().unwrap_or(""));
1188 hasher.update(&issue.labels_json);
1189 issue.content_hash = hex::encode(hasher.finalize());
1190 db.upsert_issue(&issue)?;
1191 Ok(stats.nodes)
1192 }
1193
1194 async fn fetch_all_issue_labels_remote(&self, issue_id: &str) -> Result<Vec<LinearLabel>> {
1195 let mut labels = Vec::new();
1196 paginate(
1197 &self.sync_query_config,
1198 LinearOperation::Labels,
1199 Some(issue_id.to_string()),
1200 |request| async move {
1201 self.fetch_issue_labels_page(issue_id, request.cursor.as_deref(), request.page_size)
1202 .await
1203 },
1204 |nodes, _| {
1205 labels.extend(nodes);
1206 ready(Ok(()))
1207 },
1208 |label| label.id.clone(),
1209 |event| self.observe_sync_event(event),
1210 )
1211 .await?;
1212 Ok(labels)
1213 }
1214
1215 async fn fetch_all_issue_relations_remote(&self, issue_id: &str) -> Result<Vec<db::Relation>> {
1216 let mut relations = Vec::new();
1217 paginate(
1218 &self.sync_query_config,
1219 LinearOperation::Relations,
1220 Some(issue_id.to_string()),
1221 |request| async move {
1222 self.fetch_issue_relations_page(
1223 issue_id,
1224 request.cursor.as_deref(),
1225 request.page_size,
1226 )
1227 .await
1228 },
1229 |nodes, _| {
1230 relations.extend(nodes);
1231 ready(Ok(()))
1232 },
1233 |relation| relation.id.clone(),
1234 |event| self.observe_sync_event(event),
1235 )
1236 .await?;
1237 Ok(relations)
1238 }
1239
1240 pub fn comment_error_status(error: &anyhow::Error) -> &'static str {
1241 if operation_error(error)
1242 .is_some_and(|classified| classified.kind == LinearErrorKind::Authentication)
1243 {
1244 return "permission_denied";
1245 }
1246 let message = format!("{error:#}").to_lowercase();
1247 if message.contains("permission")
1248 || message.contains("forbidden")
1249 || message.contains("unauthorized")
1250 || message.contains("access denied")
1251 {
1252 "permission_denied"
1253 } else {
1254 "unavailable"
1255 }
1256 }
1257
1258 fn redacted_error_message(&self, error: &anyhow::Error) -> String {
1259 self.redacted_message(format!("{error:#}"))
1260 }
1261
1262 fn redacted_message(&self, mut message: String) -> String {
1263 if !self.api_key.is_empty() {
1264 message = message.replace(&self.api_key, "[REDACTED]");
1265 }
1266 message = redact_sensitive_fragments(message);
1267 message.chars().take(500).collect()
1268 }
1269
1270 pub async fn update_issue(&self, issue_id: &str, update: UpdateIssueInput<'_>) -> Result<()> {
1271 let mut input = serde_json::Map::new();
1272 if let Some(t) = update.title {
1273 input.insert("title".into(), serde_json::Value::String(t.to_string()));
1274 }
1275 if let Some(d) = update.description {
1276 input.insert(
1277 "description".into(),
1278 serde_json::Value::String(d.to_string()),
1279 );
1280 }
1281 if let Some(p) = update.priority {
1282 input.insert("priority".into(), serde_json::Value::Number(p.into()));
1283 }
1284 if let Some(sid) = update.state_id {
1285 input.insert("stateId".into(), serde_json::Value::String(sid.to_string()));
1286 }
1287 if let Some(lids) = update.label_ids {
1288 input.insert("labelIds".into(), serde_json::json!(lids));
1289 }
1290 if let Some(pid) = update.project_id {
1291 let value = if pid.is_empty() {
1292 serde_json::Value::Null
1293 } else {
1294 serde_json::Value::String(pid.to_string())
1295 };
1296 input.insert("projectId".into(), value);
1297 }
1298 if let Some(aid) = update.assignee_id {
1299 let value = if aid.is_empty() {
1300 serde_json::Value::Null
1301 } else {
1302 serde_json::Value::String(aid.to_string())
1303 };
1304 input.insert("assigneeId".into(), value);
1305 }
1306 if let Some(mid) = update.project_milestone_id {
1307 let value = if mid.is_empty() {
1308 serde_json::Value::Null
1309 } else {
1310 serde_json::Value::String(mid.to_string())
1311 };
1312 input.insert("projectMilestoneId".into(), value);
1313 }
1314
1315 let query = r#"
1316 mutation($id: String!, $input: IssueUpdateInput!) {
1317 issueUpdate(id: $id, input: $input) {
1318 success
1319 }
1320 }
1321 "#;
1322
1323 let data: UpdateIssueData = self
1324 .query(query, serde_json::json!({ "id": issue_id, "input": input }))
1325 .await?;
1326
1327 if !data.issue_update.success {
1328 anyhow::bail!("Failed to update issue");
1329 }
1330
1331 Ok(())
1332 }
1333
1334 pub async fn fetch_single_issue(
1335 &self,
1336 issue_id: &str,
1337 ) -> Result<(db::Issue, Vec<db::Relation>, Vec<String>)> {
1338 let query = r#"
1339 query($id: String!) {
1340 issue(id: $id) {
1341 id identifier url title description priority branchName
1342 createdAt updatedAt
1343 state { name type }
1344 team { key }
1345 assignee { name }
1346 project { id name }
1347 projectMilestone { id name }
1348 cycle { id name number }
1349 }
1350 }
1351 "#;
1352
1353 let data: SingleIssueData = self
1354 .query(query, serde_json::json!({ "id": issue_id }))
1355 .await?;
1356 let issue_id = data.issue.id.clone();
1357 let (mut issue, _, _) = Self::convert_linear_issue(data.issue);
1358 let labels = self.fetch_all_issue_labels_remote(&issue_id).await?;
1359 let label_ids = apply_issue_labels(&mut issue, labels);
1360 let relations = self.fetch_all_issue_relations_remote(&issue_id).await?;
1361 Ok((issue, relations, label_ids))
1362 }
1363
1364 pub async fn fetch_issue_by_identifier(
1367 &self,
1368 identifier: &str,
1369 ) -> Result<Option<(db::Issue, Vec<db::Relation>, Vec<String>)>> {
1370 let parts: Vec<&str> = identifier.rsplitn(2, '-').collect();
1372 if parts.len() != 2 {
1373 anyhow::bail!(
1374 "Invalid issue identifier '{}': expected format like 'ENG-123'",
1375 identifier
1376 );
1377 }
1378 let number: i32 = parts[0]
1379 .parse()
1380 .with_context(|| format!("Invalid issue number in '{}'", identifier))?;
1381 let team_key = parts[1];
1382
1383 let query = format!(
1384 r#"query {{
1385 issues(
1386 filter: {{
1387 team: {{ key: {{ eq: "{}" }} }},
1388 number: {{ eq: {} }}
1389 }},
1390 first: 1,
1391 includeArchived: true
1392 ) {{
1393 nodes {{
1394 id identifier url title description priority branchName
1395 createdAt updatedAt
1396 state {{ name type }}
1397 team {{ key }}
1398 assignee {{ name }}
1399 project {{ id name }}
1400 projectMilestone {{ id name }}
1401 cycle {{ id name number }}
1402 }}
1403 pageInfo {{ hasNextPage endCursor }}
1404 }}
1405 }}"#,
1406 team_key, number
1407 );
1408
1409 let data: IssuesData = self.query(&query, serde_json::json!({})).await?;
1410
1411 let Some(linear_issue) = data.issues.nodes.into_iter().next() else {
1412 return Ok(None);
1413 };
1414 let issue_id = linear_issue.id.clone();
1415 let (mut issue, _, _) = Self::convert_linear_issue(linear_issue);
1416 let labels = self.fetch_all_issue_labels_remote(&issue_id).await?;
1417 let label_ids = apply_issue_labels(&mut issue, labels);
1418 let relations = self.fetch_all_issue_relations_remote(&issue_id).await?;
1419 Ok(Some((issue, relations, label_ids)))
1420 }
1421
1422 fn convert_linear_issue(i: LinearIssue) -> (db::Issue, Vec<db::Relation>, Vec<String>) {
1423 let labels: Vec<String> = i.labels.nodes.iter().map(|l| l.name.clone()).collect();
1424 let label_ids: Vec<String> = i.labels.nodes.iter().map(|l| l.id.clone()).collect();
1425 let labels_json = serde_json::to_string(&labels).unwrap_or_else(|_| "[]".to_string());
1426
1427 let mut hasher = Sha256::new();
1428 hasher.update(&i.title);
1429 hasher.update(i.description.as_deref().unwrap_or(""));
1430 hasher.update(&labels_json);
1431 let content_hash = hex::encode(hasher.finalize());
1432
1433 let relations = Self::extract_relations(&i.id, &i);
1434
1435 let project_id = i.project.as_ref().map(|project| project.id.clone());
1436 let project_name = i.project.map(|project| project.name);
1437 let project_milestone_id = i
1438 .project_milestone
1439 .as_ref()
1440 .map(|milestone| milestone.id.clone());
1441 let project_milestone_name = i.project_milestone.map(|milestone| milestone.name);
1442 let cycle_id = i.cycle.as_ref().map(|cycle| cycle.id.clone());
1443 let cycle_name = i.cycle.map(|cycle| {
1444 cycle
1445 .name
1446 .unwrap_or_else(|| format!("Cycle {}", cycle.number))
1447 });
1448
1449 let issue = db::Issue {
1450 id: i.id,
1451 identifier: i.identifier,
1452 url: i.url,
1453 team_key: i.team.key,
1454 title: i.title,
1455 description: i.description,
1456 state_name: i.state.name,
1457 state_type: i.state.state_type,
1458 priority: i.priority,
1459 assignee_name: i.assignee.map(|a| a.name),
1460 project_name,
1461 labels_json,
1462 created_at: i.created_at,
1463 updated_at: i.updated_at,
1464 content_hash,
1465 synced_at: None,
1466 branch_name: i.branch_name,
1467 workspace_id: "default".to_string(),
1468 project_id,
1469 project_milestone_id,
1470 project_milestone_name,
1471 cycle_id,
1472 cycle_name,
1473 archived_at: i.archived_at,
1474 };
1475
1476 (issue, relations, label_ids)
1477 }
1478
1479 fn convert_linear_comment(issue_id: &str, comment: LinearComment) -> db::Comment {
1480 let external_name = comment
1481 .external_user
1482 .and_then(|u| u.display_name.or(u.name));
1483 db::Comment {
1484 id: comment.id,
1485 issue_id: issue_id.to_string(),
1486 body: comment.body,
1487 user_name: comment.user.map(|u| u.name).or(external_name),
1488 created_at: comment.created_at,
1489 updated_at: Some(comment.updated_at),
1490 parent_id: comment.parent_id,
1491 url: Some(comment.url),
1492 workspace_id: "default".to_string(),
1493 }
1494 }
1495
1496 pub async fn get_team_id(&self, team_key: &str) -> Result<String> {
1498 let teams = self.list_teams().await?;
1499 teams
1500 .iter()
1501 .find(|t| t.key.eq_ignore_ascii_case(team_key))
1502 .map(|t| t.id.clone())
1503 .with_context(|| format!("Team '{}' not found", team_key))
1504 }
1505
1506 pub async fn get_state_id(&self, team_key: &str, state_name: &str) -> Result<String> {
1509 let team_id = self.get_team_id(team_key).await?;
1510 let query = r#"
1511 query($teamId: String!) {
1512 team(id: $teamId) {
1513 states { nodes { id name type } }
1514 }
1515 }
1516 "#;
1517
1518 let data: serde_json::Value = self
1519 .query(query, serde_json::json!({ "teamId": team_id }))
1520 .await?;
1521
1522 let states = data["team"]["states"]["nodes"]
1523 .as_array()
1524 .context("No states in response")?;
1525
1526 for state in states {
1527 if let Some(name) = state["name"].as_str() {
1528 if name.eq_ignore_ascii_case(state_name) {
1529 return state["id"]
1530 .as_str()
1531 .map(|s| s.to_string())
1532 .context("State has no id");
1533 }
1534 }
1535 }
1536
1537 for state in states {
1539 if let Some(t) = state["type"].as_str() {
1540 if t.eq_ignore_ascii_case(state_name) {
1541 return state["id"]
1542 .as_str()
1543 .map(|s| s.to_string())
1544 .context("State has no id");
1545 }
1546 }
1547 }
1548
1549 let available: Vec<&str> = states.iter().filter_map(|s| s["name"].as_str()).collect();
1550 anyhow::bail!(
1551 "State '{}' not found for team {}. Available: {}",
1552 state_name,
1553 team_key,
1554 available.join(", ")
1555 )
1556 }
1557
1558 pub async fn get_label_ids(&self, label_names: &[String]) -> Result<Vec<String>> {
1562 if label_names.is_empty() {
1563 return Ok(Vec::new());
1564 }
1565
1566 let labels = self.fetch_labels().await?;
1567
1568 let mut ids = Vec::new();
1569 for name in label_names {
1570 let found = labels
1571 .iter()
1572 .find(|label| label.name.eq_ignore_ascii_case(name));
1573 match found {
1574 Some(label) => ids.push(label.id.clone()),
1575 None => {
1576 let available = labels
1577 .iter()
1578 .map(|label| label.name.as_str())
1579 .collect::<Vec<_>>();
1580 anyhow::bail!(
1581 "Label '{}' not found. Available: {}",
1582 name,
1583 available.join(", ")
1584 );
1585 }
1586 }
1587 }
1588
1589 Ok(ids)
1590 }
1591
1592 pub async fn resolve_assignee_id(&self, input: &str) -> Result<String> {
1599 let trimmed = input.trim();
1600 if trimmed.eq_ignore_ascii_case("none") {
1601 return Ok(String::new());
1602 }
1603 if trimmed.eq_ignore_ascii_case("me") {
1604 if let Some(cached) = self.viewer_id.read().unwrap().clone() {
1605 return Ok(cached);
1606 }
1607 let data: serde_json::Value = self
1608 .query("query { viewer { id } }", serde_json::json!({}))
1609 .await?;
1610 let id = data["viewer"]["id"]
1611 .as_str()
1612 .context("viewer query returned no id")?
1613 .to_string();
1614 *self.viewer_id.write().unwrap() = Some(id.clone());
1615 return Ok(id);
1616 }
1617
1618 let data: serde_json::Value = self
1620 .query(
1621 "query { users(first: 250) { nodes { id name } } }",
1622 serde_json::json!({}),
1623 )
1624 .await?;
1625 let nodes = data["users"]["nodes"]
1626 .as_array()
1627 .context("users query returned no nodes")?;
1628 let matches: Vec<(String, String)> = nodes
1629 .iter()
1630 .filter_map(|n| {
1631 let name = n["name"].as_str()?;
1632 if name.eq_ignore_ascii_case(trimmed) {
1633 Some((n["id"].as_str()?.to_string(), name.to_string()))
1634 } else {
1635 None
1636 }
1637 })
1638 .collect();
1639
1640 match matches.len() {
1641 0 => anyhow::bail!("Assignee '{}' not found in Linear users.", trimmed),
1642 1 => Ok(matches.into_iter().next().unwrap().0),
1643 _ => {
1644 let names: Vec<&str> = matches.iter().map(|(_, n)| n.as_str()).collect();
1645 anyhow::bail!(
1646 "Assignee '{}' matched multiple users: {}. Use a more specific name.",
1647 trimmed,
1648 names.join(", ")
1649 )
1650 }
1651 }
1652 }
1653
1654 pub async fn fetch_labels(&self) -> Result<Vec<LabelCatalogEntry>> {
1656 let mut out = Vec::new();
1657 paginate(
1658 &self.sync_query_config,
1659 LinearOperation::Labels,
1660 None,
1661 |request| async move {
1662 self.fetch_labels_page(request.cursor.as_deref(), request.page_size)
1663 .await
1664 },
1665 |nodes, _| {
1666 out.extend(nodes);
1667 ready(Ok(()))
1668 },
1669 |label| label.id.clone(),
1670 |event| self.observe_sync_event(event),
1671 )
1672 .await?;
1673 Ok(out)
1674 }
1675
1676 async fn fetch_labels_page(
1677 &self,
1678 cursor: Option<&str>,
1679 page_size: usize,
1680 ) -> Result<ConnectionPage<LabelCatalogEntry>> {
1681 let query = r#"
1682 query($first: Int!, $after: String) {
1683 issueLabels(first: $first, after: $after, orderBy: updatedAt) {
1684 nodes { id name color parent { id } }
1685 pageInfo { hasNextPage endCursor }
1686 }
1687 }
1688 "#;
1689 let data: IssueLabelsData = self
1690 .query_operation(
1691 LinearOperation::Labels.name(),
1692 cursor,
1693 query,
1694 serde_json::json!({ "first": page_size, "after": cursor }),
1695 )
1696 .await?;
1697 Ok(ConnectionPage {
1698 nodes: data
1699 .issue_labels
1700 .nodes
1701 .into_iter()
1702 .map(|label| LabelCatalogEntry {
1703 id: label.id,
1704 name: label.name,
1705 color: label.color,
1706 parent_id: label.parent.map(|parent| parent.id),
1707 })
1708 .collect(),
1709 page_info: data.issue_labels.page_info,
1710 })
1711 }
1712
1713 pub async fn sync_labels_catalog(&self, db: &Database, workspace_id: &str) -> Result<usize> {
1716 let sync_token = Uuid::new_v4().to_string();
1717 db.mark_sync_family_running(
1718 workspace_id,
1719 "*",
1720 "labels",
1721 None,
1722 Some(self.sync_query_config.page_size(LinearOperation::Labels)),
1723 &sync_token,
1724 )?;
1725 let result = paginate(
1726 &self.sync_query_config,
1727 LinearOperation::Labels,
1728 None,
1729 |request| async move {
1730 self.fetch_labels_page(request.cursor.as_deref(), request.page_size)
1731 .await
1732 },
1733 |entries, context| {
1734 let result = (|| {
1735 for entry in entries {
1736 db.upsert_label(&db::Label {
1737 id: entry.id.clone(),
1738 workspace_id: workspace_id.to_string(),
1739 name: entry.name,
1740 color: entry.color,
1741 parent_id: entry.parent_id,
1742 })?;
1743 db.mark_label_sync_token(&entry.id, &sync_token)?;
1744 }
1745 db.mark_sync_family_running(
1746 workspace_id,
1747 "*",
1748 "labels",
1749 context.cursor.as_deref(),
1750 Some(context.page_size),
1751 &sync_token,
1752 )
1753 })();
1754 ready(result)
1755 },
1756 |label| label.id.clone(),
1757 |event| self.observe_sync_event(event),
1758 )
1759 .await;
1760 match result {
1761 Ok(stats) => {
1762 db.reconcile_label_sync(workspace_id, &sync_token)?;
1763 db.mark_sync_family_complete(
1764 workspace_id,
1765 "*",
1766 "labels",
1767 Some(self.sync_query_config.page_size(LinearOperation::Labels)),
1768 &sync_token,
1769 )?;
1770 Ok(stats.nodes)
1771 }
1772 Err(error) => {
1773 let message = self.redacted_error_message(&error);
1774 db.mark_sync_family_failed(workspace_id, "*", "labels", &sync_token, &message)?;
1775 Err(error)
1776 }
1777 }
1778 }
1779
1780 pub async fn get_project_id(&self, project_name: &str) -> Result<String> {
1782 self.find_project_by_name(project_name).await
1783 }
1784
1785 pub async fn create_relation(
1789 &self,
1790 issue_id: &str,
1791 related_issue_id: &str,
1792 relation_type: &str,
1793 ) -> Result<String> {
1794 let (actual_issue_id, actual_related_id, api_type) = if relation_type == "blocked_by" {
1795 (related_issue_id, issue_id, "blocks")
1796 } else {
1797 (issue_id, related_issue_id, relation_type)
1798 };
1799
1800 let query = r#"
1801 mutation($input: IssueRelationCreateInput!) {
1802 issueRelationCreate(input: $input) {
1803 success
1804 issueRelation { id }
1805 }
1806 }
1807 "#;
1808
1809 let input = serde_json::json!({
1810 "issueId": actual_issue_id,
1811 "relatedIssueId": actual_related_id,
1812 "type": api_type,
1813 });
1814
1815 let data: CreateRelationData = self
1816 .query(query, serde_json::json!({ "input": input }))
1817 .await?;
1818
1819 if !data.issue_relation_create.success {
1820 anyhow::bail!("Failed to create relation");
1821 }
1822
1823 let relation = data
1824 .issue_relation_create
1825 .issue_relation
1826 .context("No relation returned")?;
1827 Ok(relation.id)
1828 }
1829
1830 pub async fn delete_relation(&self, relation_id: &str) -> Result<()> {
1832 let query = r#"
1833 mutation($id: String!) {
1834 issueRelationDelete(id: $id) {
1835 success
1836 }
1837 }
1838 "#;
1839
1840 let data: DeleteRelationData = self
1841 .query(query, serde_json::json!({ "id": relation_id }))
1842 .await?;
1843
1844 if !data.issue_relation_delete.success {
1845 anyhow::bail!("Failed to delete relation");
1846 }
1847
1848 Ok(())
1849 }
1850}
1851
1852fn create_issue_value(create: &CreateIssueInput<'_>) -> serde_json::Value {
1853 let mut input = serde_json::json!({
1854 "teamId": create.team_id,
1855 "title": create.title,
1856 });
1857 if let Some(desc) = create.description {
1858 input["description"] = serde_json::Value::String(desc.to_string());
1859 }
1860 if let Some(priority) = create.priority {
1861 input["priority"] = serde_json::Value::Number(priority.into());
1862 }
1863 if !create.label_ids.is_empty() {
1864 input["labelIds"] = serde_json::json!(create.label_ids);
1865 }
1866 if let Some(assignee_id) = create.assignee_id {
1867 input["assigneeId"] = serde_json::Value::String(assignee_id.to_string());
1868 }
1869 if let Some(parent_id) = create.parent_id {
1870 input["parentId"] = serde_json::Value::String(parent_id.to_string());
1871 }
1872 if let Some(project_id) = create.project_id {
1873 input["projectId"] = serde_json::Value::String(project_id.to_string());
1874 }
1875 if let Some(milestone_id) = create.project_milestone_id {
1876 input["projectMilestoneId"] = serde_json::Value::String(milestone_id.to_string());
1877 }
1878 input
1879}
1880
1881fn issue_page_variables(
1882 page_size: usize,
1883 cursor: Option<&str>,
1884 include_archived: bool,
1885) -> serde_json::Value {
1886 serde_json::json!({
1887 "first": page_size,
1888 "after": cursor,
1889 "includeArchived": include_archived,
1890 })
1891}
1892
1893fn apply_issue_labels(issue: &mut db::Issue, labels: Vec<LinearLabel>) -> Vec<String> {
1894 let label_names = labels
1895 .iter()
1896 .map(|label| label.name.clone())
1897 .collect::<Vec<_>>();
1898 let label_ids = labels.into_iter().map(|label| label.id).collect::<Vec<_>>();
1899 issue.labels_json = serde_json::to_string(&label_names).unwrap_or_else(|_| "[]".to_string());
1900 let mut hasher = Sha256::new();
1901 hasher.update(&issue.title);
1902 hasher.update(issue.description.as_deref().unwrap_or(""));
1903 hasher.update(&issue.labels_json);
1904 issue.content_hash = hex::encode(hasher.finalize());
1905 label_ids
1906}
1907
1908fn redact_sensitive_fragments(mut message: String) -> String {
1909 message = redact_token_after_marker(message, "bearer ");
1910 for marker in [
1911 "authorization:",
1912 "authorization=",
1913 "api_key:",
1914 "api_key=",
1915 "api-key:",
1916 "api-key=",
1917 "access_token:",
1918 "access_token=",
1919 "password:",
1920 "password=",
1921 "secret:",
1922 "secret=",
1923 ] {
1924 message = redact_token_after_marker(message, marker);
1925 }
1926 message
1927}
1928
1929fn redact_token_after_marker(mut message: String, marker: &str) -> String {
1930 let mut search_from = 0;
1931 loop {
1932 let lower = message.to_ascii_lowercase();
1933 let Some(relative_start) = lower[search_from..].find(marker) else {
1934 break;
1935 };
1936 let marker_end = search_from + relative_start + marker.len();
1937 let bytes = message.as_bytes();
1938 let mut value_start = marker_end;
1939 while value_start < bytes.len()
1940 && (bytes[value_start].is_ascii_whitespace()
1941 || matches!(bytes[value_start], b'\'' | b'"'))
1942 {
1943 value_start += 1;
1944 }
1945 let mut value_end = value_start;
1946 while value_end < bytes.len()
1947 && !bytes[value_end].is_ascii_whitespace()
1948 && !matches!(bytes[value_end], b',' | b';' | b'\'' | b'"' | b')')
1949 {
1950 value_end += 1;
1951 }
1952 if value_start == value_end {
1953 search_from = marker_end;
1954 continue;
1955 }
1956 message.replace_range(value_start..value_end, "[REDACTED]");
1957 search_from = value_start + "[REDACTED]".len();
1958 }
1959 message
1960}
1961
1962fn classify_http_status(status: u16) -> LinearErrorKind {
1963 match status {
1964 401 | 403 => LinearErrorKind::Authentication,
1965 429 => LinearErrorKind::RateLimit,
1966 408 | 500..=599 => LinearErrorKind::Transient,
1967 _ => LinearErrorKind::Api,
1968 }
1969}
1970
1971fn classify_graphql_message(message: &str) -> LinearErrorKind {
1972 let lower = message.to_lowercase();
1973 if lower.contains("complexity")
1974 || lower.contains("maximum allowed")
1975 || lower.contains("query cost")
1976 {
1977 LinearErrorKind::Complexity
1978 } else if lower.contains("rate limit") || lower.contains("too many requests") {
1979 LinearErrorKind::RateLimit
1980 } else if lower.contains("unauthorized")
1981 || lower.contains("forbidden")
1982 || lower.contains("authentication")
1983 {
1984 LinearErrorKind::Authentication
1985 } else if lower.contains("validation")
1986 || lower.contains("cannot query field")
1987 || lower.contains("unknown argument")
1988 {
1989 LinearErrorKind::Validation
1990 } else if lower.contains("internal server")
1991 || lower.contains("internal error")
1992 || lower.contains("temporarily unavailable")
1993 || lower.contains("service unavailable")
1994 || lower.contains("timeout")
1995 || lower.contains("timed out")
1996 || lower.contains("try again")
1997 {
1998 LinearErrorKind::Transient
1999 } else {
2000 LinearErrorKind::Api
2001 }
2002}
2003
2004fn classify_graphql_errors(errors: &[GraphQLError]) -> LinearErrorKind {
2005 if errors.iter().any(|error| {
2006 error
2007 .extensions
2008 .as_ref()
2009 .and_then(|extensions| extensions.code.as_deref())
2010 .is_some_and(|code| code.eq_ignore_ascii_case("RATELIMITED"))
2011 }) {
2012 return LinearErrorKind::RateLimit;
2013 }
2014 let message = errors
2015 .iter()
2016 .map(|error| error.message.as_str())
2017 .collect::<Vec<_>>()
2018 .join(", ");
2019 classify_graphql_message(&message)
2020}
2021
2022fn retry_after_from_headers(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
2023 if let Some(seconds) = headers
2024 .get(reqwest::header::RETRY_AFTER)
2025 .and_then(|value| value.to_str().ok())
2026 .and_then(|value| value.parse::<u64>().ok())
2027 {
2028 return Some(Duration::from_secs(seconds).min(Duration::from_secs(6 * 60 * 60)));
2029 }
2030 for name in ["x-ratelimit-requests-reset", "x-ratelimit-reset"] {
2031 let Some(value) = headers
2032 .get(name)
2033 .and_then(|value| value.to_str().ok())
2034 .and_then(|value| value.parse::<u64>().ok())
2035 else {
2036 continue;
2037 };
2038 let value = if value > 1_000_000_000_000 {
2039 value / 1_000
2040 } else {
2041 value
2042 };
2043 let now = chrono::Utc::now().timestamp().max(0) as u64;
2044 let seconds = if value > now { value - now } else { value };
2045 return Some(Duration::from_secs(seconds).min(Duration::from_secs(6 * 60 * 60)));
2046 }
2047 None
2048}
2049
2050#[cfg(test)]
2051mod tests {
2052 use std::collections::HashMap;
2053 use std::io::{Read, Write};
2054 use std::net::{TcpListener, TcpStream};
2055 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
2056 use std::sync::{Arc, Mutex};
2057 use std::thread;
2058 use std::time::Duration;
2059
2060 use crate::db::SyncFamilyState;
2061
2062 use super::*;
2063
2064 static SYNC_HTTP_TEST_LOCK: Mutex<()> = Mutex::new(());
2065
2066 struct MockResponse {
2067 status: u16,
2068 body: String,
2069 }
2070
2071 impl MockResponse {
2072 fn json(body: serde_json::Value) -> Self {
2073 Self {
2074 status: 200,
2075 body: body.to_string(),
2076 }
2077 }
2078
2079 fn status(status: u16) -> Self {
2080 Self {
2081 status,
2082 body: "{}".to_string(),
2083 }
2084 }
2085 }
2086
2087 struct MockLinearServer {
2088 url: String,
2089 stop: Arc<AtomicBool>,
2090 worker: Option<thread::JoinHandle<()>>,
2091 }
2092
2093 impl MockLinearServer {
2094 fn start<F>(mut handler: F) -> Self
2095 where
2096 F: FnMut(serde_json::Value) -> MockResponse + Send + 'static,
2097 {
2098 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2099 listener.set_nonblocking(true).unwrap();
2100 let address = listener.local_addr().unwrap();
2101 let stop = Arc::new(AtomicBool::new(false));
2102 let worker_stop = Arc::clone(&stop);
2103 let worker = thread::spawn(move || {
2104 while !worker_stop.load(Ordering::Relaxed) {
2105 match listener.accept() {
2106 Ok((mut stream, _)) => {
2107 if let Some(request) = read_json_request(&mut stream) {
2108 write_mock_response(&mut stream, handler(request));
2109 }
2110 }
2111 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
2112 thread::sleep(Duration::from_millis(1));
2113 }
2114 Err(_) => break,
2115 }
2116 }
2117 });
2118 Self {
2119 url: format!("http://{address}/graphql"),
2120 stop,
2121 worker: Some(worker),
2122 }
2123 }
2124 }
2125
2126 impl Drop for MockLinearServer {
2127 fn drop(&mut self) {
2128 self.stop.store(true, Ordering::Relaxed);
2129 if let Some(worker) = self.worker.take() {
2130 let _ = worker.join();
2131 }
2132 }
2133 }
2134
2135 fn read_json_request(stream: &mut TcpStream) -> Option<serde_json::Value> {
2136 stream.set_read_timeout(Some(Duration::from_secs(2))).ok()?;
2137 let mut request = Vec::new();
2138 let mut buffer = [0_u8; 4096];
2139 let (header_end, content_length) = loop {
2140 let count = stream.read(&mut buffer).ok()?;
2141 if count == 0 {
2142 return None;
2143 }
2144 request.extend_from_slice(&buffer[..count]);
2145 if let Some(header_end) = request.windows(4).position(|window| window == b"\r\n\r\n") {
2146 let headers = String::from_utf8_lossy(&request[..header_end]);
2147 let content_length = headers
2148 .lines()
2149 .find_map(|line| {
2150 let (name, value) = line.split_once(':')?;
2151 name.eq_ignore_ascii_case("content-length")
2152 .then(|| value.trim().parse::<usize>().ok())
2153 .flatten()
2154 })
2155 .unwrap_or(0);
2156 break (header_end + 4, content_length);
2157 }
2158 };
2159 while request.len() < header_end + content_length {
2160 let count = stream.read(&mut buffer).ok()?;
2161 if count == 0 {
2162 return None;
2163 }
2164 request.extend_from_slice(&buffer[..count]);
2165 }
2166 serde_json::from_slice(&request[header_end..header_end + content_length]).ok()
2167 }
2168
2169 fn write_mock_response(stream: &mut TcpStream, response: MockResponse) {
2170 let reason = match response.status {
2171 200 => "OK",
2172 403 => "Forbidden",
2173 500 => "Internal Server Error",
2174 _ => "Mock",
2175 };
2176 let headers = format!(
2177 "HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2178 response.status,
2179 reason,
2180 response.body.len()
2181 );
2182 let _ = stream.write_all(headers.as_bytes());
2183 let _ = stream.write_all(response.body.as_bytes());
2184 }
2185
2186 fn issue_node(id: &str, identifier: &str, updated_at: &str) -> serde_json::Value {
2187 serde_json::json!({
2188 "id": id,
2189 "identifier": identifier,
2190 "url": format!("https://linear.app/issue/{identifier}"),
2191 "title": format!("Issue {identifier}"),
2192 "description": "Mock issue",
2193 "priority": 2,
2194 "branchName": null,
2195 "createdAt": "2026-01-01T00:00:00Z",
2196 "updatedAt": updated_at,
2197 "state": { "name": "Todo", "type": "unstarted" },
2198 "team": { "key": "CUT" },
2199 "assignee": null,
2200 "project": null,
2201 "projectMilestone": null,
2202 "cycle": null
2203 })
2204 }
2205
2206 fn project_node(id: &str, name: &str) -> serde_json::Value {
2207 serde_json::json!({
2208 "id": id,
2209 "slugId": id,
2210 "name": name,
2211 "description": "Project description",
2212 "content": null,
2213 "icon": null,
2214 "color": "#123456",
2215 "priority": 2,
2216 "startDate": null,
2217 "targetDate": null,
2218 "createdAt": "2026-01-01T00:00:00Z",
2219 "updatedAt": "2026-02-01T00:00:00Z",
2220 "archivedAt": null,
2221 "url": format!("https://linear.app/project/{id}"),
2222 "progress": 0.25,
2223 "status": { "id": "status-1", "name": "Planned", "type": "planned", "color": "#abcdef" },
2224 "lead": null
2225 })
2226 }
2227
2228 fn milestone_node(project_id: &str) -> serde_json::Value {
2229 serde_json::json!({
2230 "id": format!("milestone-{project_id}"),
2231 "name": format!("Milestone {project_id}"),
2232 "description": null,
2233 "targetDate": null,
2234 "status": "next",
2235 "progress": 0.5,
2236 "sortOrder": 1.0,
2237 "createdAt": "2026-01-01T00:00:00Z",
2238 "updatedAt": "2026-02-01T00:00:00Z",
2239 "archivedAt": null,
2240 "project": { "id": project_id, "name": format!("Project {project_id}") }
2241 })
2242 }
2243
2244 fn project_sync_response(
2245 request: &serde_json::Value,
2246 team_project_count: usize,
2247 ) -> Option<MockResponse> {
2248 let query = request["query"].as_str().unwrap_or_default();
2249 if query.contains("teams(first:") && !query.contains("project(id:") {
2250 return Some(MockResponse::json(serde_json::json!({
2251 "data": { "teams": {
2252 "nodes": [{ "id": "team-cut", "key": "CUT", "name": "Cuttlefish" }],
2253 "pageInfo": { "hasNextPage": false, "endCursor": null }
2254 }}
2255 })));
2256 }
2257 if query.contains("projects(") {
2258 let team_scoped = request["variables"]["teamId"].is_string();
2259 let count = if team_scoped { team_project_count } else { 2 };
2260 let nodes = (1..=count)
2261 .map(|number| {
2262 project_node(&format!("project-{number}"), &format!("Project {number}"))
2263 })
2264 .collect::<Vec<_>>();
2265 return Some(MockResponse::json(serde_json::json!({
2266 "data": { "projects": {
2267 "nodes": nodes,
2268 "pageInfo": { "hasNextPage": false, "endCursor": null }
2269 }}
2270 })));
2271 }
2272 let project_id = request["variables"]["id"].as_str().unwrap_or("project-1");
2273 if query.contains("projectMilestones(") {
2274 return Some(MockResponse::json(serde_json::json!({
2275 "data": { "project": { "projectMilestones": {
2276 "nodes": [milestone_node(project_id)],
2277 "pageInfo": { "hasNextPage": false, "endCursor": null }
2278 }}}
2279 })));
2280 }
2281 if query.contains("teams(first:") {
2282 return Some(MockResponse::json(serde_json::json!({
2283 "data": { "project": { "teams": {
2284 "nodes": [{ "id": "team-cut", "key": "CUT", "name": "Cuttlefish" }],
2285 "pageInfo": { "hasNextPage": false, "endCursor": null }
2286 }}}
2287 })));
2288 }
2289 if query.contains("members(first:") {
2290 return Some(MockResponse::json(serde_json::json!({
2291 "data": { "project": { "members": {
2292 "nodes": [],
2293 "pageInfo": { "hasNextPage": false, "endCursor": null }
2294 }}}
2295 })));
2296 }
2297 if query.contains("labels(first:") {
2298 return Some(MockResponse::json(serde_json::json!({
2299 "data": { "project": { "labels": {
2300 "nodes": [],
2301 "pageInfo": { "hasNextPage": false, "endCursor": null }
2302 }}}
2303 })));
2304 }
2305 None
2306 }
2307
2308 fn standard_sync_response(request: &serde_json::Value) -> Option<MockResponse> {
2309 let query = request["query"].as_str().unwrap_or_default();
2310 if query.contains("issue(id: $id)") && query.contains("description priority") {
2311 let issue_id = request["variables"]["id"].as_str().unwrap_or("issue-1");
2312 let (identifier, updated_at) = if issue_id == "issue-2" {
2313 ("CUT-2", "2026-02-02T00:00:00Z")
2314 } else {
2315 ("CUT-1", "2026-02-01T00:00:00Z")
2316 };
2317 return Some(MockResponse::json(serde_json::json!({
2318 "data": { "issue": issue_node(issue_id, identifier, updated_at) }
2319 })));
2320 }
2321 if query.contains("teams(first:") {
2322 return Some(MockResponse::json(serde_json::json!({
2323 "data": { "teams": {
2324 "nodes": [{ "id": "team-1", "key": "CUT", "name": "Cuttlefish" }],
2325 "pageInfo": { "hasNextPage": false, "endCursor": null }
2326 }}
2327 })));
2328 }
2329 if query.contains("projects(") {
2330 return Some(MockResponse::json(serde_json::json!({
2331 "data": { "projects": {
2332 "nodes": [],
2333 "pageInfo": { "hasNextPage": false, "endCursor": null }
2334 }}
2335 })));
2336 }
2337 if query.contains("issueLabels(") {
2338 return Some(MockResponse::json(serde_json::json!({
2339 "data": { "issueLabels": {
2340 "nodes": [],
2341 "pageInfo": { "hasNextPage": false, "endCursor": null }
2342 }}
2343 })));
2344 }
2345 if query.contains("cycles(") {
2346 return Some(MockResponse::json(serde_json::json!({
2347 "data": { "cycles": {
2348 "nodes": [],
2349 "pageInfo": { "hasNextPage": false, "endCursor": null }
2350 }}
2351 })));
2352 }
2353 if query.contains("issues(") {
2354 let nodes = if request["variables"]["lower"].is_string()
2355 || query.contains("updatedAt: { gt:")
2356 {
2357 Vec::new()
2358 } else {
2359 vec![
2360 issue_node("issue-1", "CUT-1", "2026-02-01T00:00:00Z"),
2361 issue_node("issue-2", "CUT-2", "2026-02-02T00:00:00Z"),
2362 ]
2363 };
2364 return Some(MockResponse::json(serde_json::json!({
2365 "data": { "issues": {
2366 "nodes": nodes,
2367 "pageInfo": { "hasNextPage": false, "endCursor": null }
2368 }}
2369 })));
2370 }
2371 if query.contains("labels(first:") {
2372 return Some(MockResponse::json(serde_json::json!({
2373 "data": { "issue": { "labels": {
2374 "nodes": [],
2375 "pageInfo": { "hasNextPage": false, "endCursor": null }
2376 }}}
2377 })));
2378 }
2379 if query.contains("relations(first:") {
2380 return Some(MockResponse::json(serde_json::json!({
2381 "data": { "issue": { "relations": {
2382 "nodes": [],
2383 "pageInfo": { "hasNextPage": false, "endCursor": null }
2384 }}}
2385 })));
2386 }
2387 None
2388 }
2389
2390 fn successful_comments() -> MockResponse {
2391 MockResponse::json(serde_json::json!({
2392 "data": { "comments": {
2393 "nodes": [],
2394 "pageInfo": { "hasNextPage": false, "endCursor": null }
2395 }}
2396 }))
2397 }
2398
2399 fn comment_issue_id(request: &serde_json::Value) -> Option<&str> {
2400 request["query"]
2401 .as_str()
2402 .is_some_and(|query| query.contains("comments("))
2403 .then(|| request["variables"]["issueId"].as_str())
2404 .flatten()
2405 }
2406
2407 fn test_client(api_url: &str) -> LinearClient {
2408 let mut config = SyncQueryConfig::default();
2409 config.max_retry_attempts = 2;
2410 config.retry_base_delay = Duration::ZERO;
2411 LinearClient::with_api_key("test-api-key")
2412 .with_api_url(api_url)
2413 .with_sync_query_config(config)
2414 }
2415
2416 fn runtime() -> tokio::runtime::Runtime {
2417 tokio::runtime::Builder::new_current_thread()
2418 .enable_all()
2419 .build()
2420 .unwrap()
2421 }
2422
2423 fn test_db() -> (Database, tempfile::TempDir) {
2424 let directory = tempfile::tempdir().unwrap();
2425 let db = Database::open(&directory.path().join("test.db")).unwrap();
2426 (db, directory)
2427 }
2428
2429 fn family_status(db: &Database, family: &str) -> SyncFamilyState {
2430 db.get_sync_family_state("default", "CUT", family)
2431 .unwrap()
2432 .unwrap()
2433 }
2434
2435 #[test]
2436 fn issue_create_serializes_project_and_milestone_relationships() {
2437 let labels = vec!["label-1".to_string()];
2438 let value = create_issue_value(&CreateIssueInput {
2439 team_id: "team-1",
2440 title: "Add request tracing",
2441 description: None,
2442 priority: Some(2),
2443 label_ids: &labels,
2444 assignee_id: None,
2445 parent_id: None,
2446 project_id: Some("project-1"),
2447 project_milestone_id: Some("milestone-1"),
2448 });
2449 assert_eq!(value["projectId"], serde_json::json!("project-1"));
2450 assert_eq!(
2451 value["projectMilestoneId"],
2452 serde_json::json!("milestone-1")
2453 );
2454 assert_eq!(value["labelIds"], serde_json::json!(["label-1"]));
2455 }
2456
2457 #[test]
2458 fn issue_pages_explicitly_toggle_archived_records() {
2459 assert_eq!(
2460 issue_page_variables(50, None, false)["includeArchived"],
2461 serde_json::json!(false)
2462 );
2463 assert_eq!(
2464 issue_page_variables(50, Some("cursor-1"), true)["includeArchived"],
2465 serde_json::json!(true)
2466 );
2467 }
2468
2469 #[test]
2470 fn graphql_errors_are_classified_without_stringly_typed_callers() {
2471 assert_eq!(
2472 classify_graphql_message("Query complexity: 72,400; maximum allowed: 10,000"),
2473 LinearErrorKind::Complexity
2474 );
2475 assert_eq!(
2476 classify_graphql_message("Cannot query field 'cycles'"),
2477 LinearErrorKind::Validation
2478 );
2479 assert_eq!(
2480 classify_graphql_message("Unauthorized"),
2481 LinearErrorKind::Authentication
2482 );
2483 assert_eq!(
2484 classify_graphql_message("Internal server error; try again"),
2485 LinearErrorKind::Transient
2486 );
2487 assert_eq!(classify_http_status(429), LinearErrorKind::RateLimit);
2488 assert_eq!(classify_http_status(500), LinearErrorKind::Transient);
2489 }
2490
2491 #[test]
2492 fn team_sync_retries_http_500_comments_then_completes() {
2493 let _serial = SYNC_HTTP_TEST_LOCK.lock().unwrap();
2494 let attempts = Arc::new(Mutex::new(HashMap::<String, usize>::new()));
2495 let server_attempts = Arc::clone(&attempts);
2496 let server = MockLinearServer::start(move |request| {
2497 if let Some(issue_id) = comment_issue_id(&request) {
2498 let mut attempts = server_attempts.lock().unwrap();
2499 let count = attempts.entry(issue_id.to_string()).or_default();
2500 *count += 1;
2501 if issue_id == "issue-1" && *count == 1 {
2502 return MockResponse::status(500);
2503 }
2504 return successful_comments();
2505 }
2506 standard_sync_response(&request).expect("unexpected GraphQL operation")
2507 });
2508 let client = test_client(&server.url);
2509 let (db, _dir) = test_db();
2510
2511 let count = runtime()
2512 .block_on(client.sync_team(&db, "CUT", "default", true, true, None))
2513 .unwrap();
2514
2515 assert_eq!(count, 2);
2516 assert_eq!(attempts.lock().unwrap().get("issue-1"), Some(&2));
2517 assert_eq!(
2518 db.get_comment_sync_state("issue-1").unwrap().status,
2519 "none_found"
2520 );
2521 assert_eq!(family_status(&db, "comments").status, "complete");
2522 }
2523
2524 #[test]
2525 fn exhausted_comment_retries_are_partial_and_do_not_block_later_issues_or_cursor() {
2526 let _serial = SYNC_HTTP_TEST_LOCK.lock().unwrap();
2527 let attempts = Arc::new(Mutex::new(HashMap::<String, usize>::new()));
2528 let server_attempts = Arc::clone(&attempts);
2529 let server = MockLinearServer::start(move |request| {
2530 if let Some(issue_id) = comment_issue_id(&request) {
2531 let mut attempts = server_attempts.lock().unwrap();
2532 *attempts.entry(issue_id.to_string()).or_default() += 1;
2533 return if issue_id == "issue-1" {
2534 MockResponse::status(500)
2535 } else {
2536 successful_comments()
2537 };
2538 }
2539 standard_sync_response(&request).expect("unexpected GraphQL operation")
2540 });
2541 let client = test_client(&server.url);
2542 let (db, _dir) = test_db();
2543
2544 let count = runtime()
2545 .block_on(client.sync_team(&db, "CUT", "default", true, true, None))
2546 .unwrap();
2547
2548 assert_eq!(count, 2);
2549 assert_eq!(attempts.lock().unwrap().get("issue-1"), Some(&3));
2550 assert_eq!(attempts.lock().unwrap().get("issue-2"), Some(&1));
2551 let failed = db.get_comment_sync_state("issue-1").unwrap();
2552 assert_eq!(failed.status, "unavailable");
2553 let diagnostic = failed.sync_error.unwrap();
2554 assert!(diagnostic.contains("failed to paginate comments at cursor None"));
2555 assert!(diagnostic.contains("HTTP 500"));
2556 assert!(diagnostic.chars().count() <= 500);
2557 assert_eq!(
2558 db.get_comment_sync_state("issue-2").unwrap().status,
2559 "none_found"
2560 );
2561 assert_eq!(family_status(&db, "issue labels").status, "complete");
2562 assert_eq!(family_status(&db, "relations").status, "complete");
2563 let comments = family_status(&db, "comments");
2564 assert_eq!(comments.status, "partial");
2565 assert_eq!(
2566 comments.error.as_deref(),
2567 Some("1 comment hydration(s) failed")
2568 );
2569 assert!(db.get_sync_cursor("default", "CUT").unwrap().is_some());
2570 }
2571
2572 #[test]
2573 fn permission_comment_failure_is_not_retried_and_other_issues_continue() {
2574 let _serial = SYNC_HTTP_TEST_LOCK.lock().unwrap();
2575 let attempts = Arc::new(Mutex::new(HashMap::<String, usize>::new()));
2576 let server_attempts = Arc::clone(&attempts);
2577 let server = MockLinearServer::start(move |request| {
2578 if let Some(issue_id) = comment_issue_id(&request) {
2579 let mut attempts = server_attempts.lock().unwrap();
2580 *attempts.entry(issue_id.to_string()).or_default() += 1;
2581 if issue_id == "issue-1" {
2582 return MockResponse::json(serde_json::json!({
2583 "errors": [{
2584 "message": "Forbidden: Authorization: Bearer super-secret-token"
2585 }]
2586 }));
2587 }
2588 return successful_comments();
2589 }
2590 standard_sync_response(&request).expect("unexpected GraphQL operation")
2591 });
2592 let client = test_client(&server.url);
2593 let (db, _dir) = test_db();
2594
2595 runtime()
2596 .block_on(client.sync_team(&db, "CUT", "default", true, true, None))
2597 .unwrap();
2598
2599 assert_eq!(attempts.lock().unwrap().get("issue-1"), Some(&1));
2600 assert_eq!(attempts.lock().unwrap().get("issue-2"), Some(&1));
2601 let failed = db.get_comment_sync_state("issue-1").unwrap();
2602 assert_eq!(failed.status, "permission_denied");
2603 let diagnostic = failed.sync_error.unwrap();
2604 assert!(diagnostic.contains("Forbidden"));
2605 assert!(!diagnostic.contains("super-secret-token"));
2606 assert_eq!(
2607 db.get_comment_sync_state("issue-2").unwrap().status,
2608 "none_found"
2609 );
2610 }
2611
2612 #[test]
2613 fn later_incremental_sync_recovers_failed_comment_state_and_clears_error() {
2614 let _serial = SYNC_HTTP_TEST_LOCK.lock().unwrap();
2615 let should_fail = Arc::new(AtomicBool::new(true));
2616 let server_should_fail = Arc::clone(&should_fail);
2617 let attempts = Arc::new(Mutex::new(HashMap::<String, usize>::new()));
2618 let server_attempts = Arc::clone(&attempts);
2619 let server = MockLinearServer::start(move |request| {
2620 if let Some(issue_id) = comment_issue_id(&request) {
2621 let mut attempts = server_attempts.lock().unwrap();
2622 *attempts.entry(issue_id.to_string()).or_default() += 1;
2623 if issue_id == "issue-1" && server_should_fail.load(Ordering::Relaxed) {
2624 return MockResponse::status(500);
2625 }
2626 return successful_comments();
2627 }
2628 standard_sync_response(&request).expect("unexpected GraphQL operation")
2629 });
2630 let client = test_client(&server.url);
2631 let (db, _dir) = test_db();
2632 let rt = runtime();
2633
2634 rt.block_on(client.sync_team(&db, "CUT", "default", true, true, None))
2635 .unwrap();
2636 assert_eq!(
2637 db.get_comment_sync_state("issue-1").unwrap().status,
2638 "unavailable"
2639 );
2640 let first_cursor = db.get_sync_cursor("default", "CUT").unwrap();
2641 should_fail.store(false, Ordering::Relaxed);
2642
2643 let count = rt
2644 .block_on(client.sync_team(&db, "CUT", "default", false, false, None))
2645 .unwrap();
2646
2647 assert_eq!(count, 0);
2648 let recovered = db.get_comment_sync_state("issue-1").unwrap();
2649 assert_eq!(recovered.status, "none_found");
2650 assert!(recovered.sync_error.is_none());
2651 assert_eq!(family_status(&db, "comments").status, "complete");
2652 assert_ne!(db.get_sync_cursor("default", "CUT").unwrap(), first_cursor);
2653 assert_eq!(attempts.lock().unwrap().get("issue-1"), Some(&4));
2654 }
2655
2656 #[test]
2657 fn index_only_traverses_bounded_pages_without_supplemental_requests() {
2658 let _serial = SYNC_HTTP_TEST_LOCK.lock().unwrap();
2659 let requests = Arc::new(Mutex::new(Vec::<serde_json::Value>::new()));
2660 let server_requests = Arc::clone(&requests);
2661 let server = MockLinearServer::start(move |request| {
2662 server_requests.lock().unwrap().push(request.clone());
2663 let after = request["variables"]["after"].as_str();
2664 let (nodes, has_next_page, end_cursor) = if after.is_none() {
2665 (
2666 vec![issue_node("issue-1", "CUT-1", "2026-02-01T00:00:00Z")],
2667 true,
2668 Some("cursor-1"),
2669 )
2670 } else {
2671 (
2672 vec![issue_node("issue-2", "CUT-2", "2026-02-02T00:00:00Z")],
2673 false,
2674 None,
2675 )
2676 };
2677 MockResponse::json(serde_json::json!({
2678 "data": { "issues": {
2679 "nodes": nodes,
2680 "pageInfo": {
2681 "hasNextPage": has_next_page,
2682 "endCursor": end_cursor
2683 }
2684 }}
2685 }))
2686 });
2687 let client = test_client(&server.url);
2688 let (db, _dir) = test_db();
2689 let upper = chrono::DateTime::parse_from_rfc3339("2026-03-01T00:00:00Z")
2690 .unwrap()
2691 .with_timezone(&chrono::Utc);
2692
2693 let result = runtime()
2694 .block_on(client.sync_team_index_window(&db, "CUT", "default", true, upper, None))
2695 .unwrap();
2696
2697 assert_eq!(result.indexed, 2);
2698 assert_eq!(result.inserted, 2);
2699 assert_eq!(result.queued_for_hydration, 2);
2700 assert_eq!(result.committed_checkpoint, "2026-03-01T00:00:00+00:00");
2701 assert_eq!(
2702 db.list_all_issues(Some("CUT"), None, 10, 0, "default")
2703 .unwrap()
2704 .len(),
2705 2
2706 );
2707 let requests = requests.lock().unwrap();
2708 assert_eq!(requests.len(), 2);
2709 assert!(requests.iter().all(|request| {
2710 let query = request["query"].as_str().unwrap();
2711 query.contains("$upper")
2712 && query.contains("orderBy: updatedAt")
2713 && !query.contains("description")
2714 && !query.contains("comments(")
2715 && !query.contains("relations(")
2716 && !query.contains("labels(")
2717 }));
2718 }
2719
2720 #[test]
2721 fn team_project_sync_is_scoped_and_returns_project_and_milestone_counts() {
2722 let _serial = SYNC_HTTP_TEST_LOCK.lock().unwrap();
2723 let project_requests = Arc::new(Mutex::new(Vec::<serde_json::Value>::new()));
2724 let server_requests = Arc::clone(&project_requests);
2725 let server = MockLinearServer::start(move |request| {
2726 if request["query"]
2727 .as_str()
2728 .is_some_and(|query| query.contains("projects("))
2729 {
2730 server_requests.lock().unwrap().push(request.clone());
2731 }
2732 project_sync_response(&request, 1).expect("unexpected GraphQL operation")
2733 });
2734 let client = test_client(&server.url);
2735 let (db, _dir) = test_db();
2736
2737 let result = runtime()
2738 .block_on(client.sync_team_projects(&db, "CUT", "default"))
2739 .unwrap();
2740
2741 assert_eq!(result.projects, 1);
2742 assert_eq!(result.milestones, 1);
2743 let projects = db.list_projects("default", true).unwrap();
2744 assert_eq!(projects.len(), 1);
2745 assert_eq!(projects[0].teams[0].key, "CUT");
2746 let requests = project_requests.lock().unwrap();
2747 assert_eq!(requests.len(), 1);
2748 assert_eq!(requests[0]["variables"]["teamId"], "team-cut");
2749 }
2750
2751 #[test]
2752 fn interrupted_team_project_sync_does_not_reconcile_missing_projects() {
2753 let _serial = SYNC_HTTP_TEST_LOCK.lock().unwrap();
2754 let fail_members = Arc::new(AtomicBool::new(false));
2755 let server_fail_members = Arc::clone(&fail_members);
2756 let server = MockLinearServer::start(move |request| {
2757 let query = request["query"].as_str().unwrap_or_default();
2758 if server_fail_members.load(Ordering::Relaxed) && query.contains("members(first:") {
2759 return MockResponse::status(500);
2760 }
2761 let count = if server_fail_members.load(Ordering::Relaxed) {
2762 1
2763 } else {
2764 2
2765 };
2766 project_sync_response(&request, count).expect("unexpected GraphQL operation")
2767 });
2768 let client = test_client(&server.url);
2769 let (db, _dir) = test_db();
2770 let rt = runtime();
2771
2772 let initial = rt
2773 .block_on(client.sync_team_projects(&db, "CUT", "default"))
2774 .unwrap();
2775 assert_eq!(initial.projects, 2);
2776 fail_members.store(true, Ordering::Relaxed);
2777 assert!(rt
2778 .block_on(client.sync_team_projects(&db, "CUT", "default"))
2779 .is_err());
2780 let projects = db.list_projects("default", true).unwrap();
2781 assert_eq!(projects.len(), 2);
2782 assert!(projects.iter().any(|project| project.id == "project-2"));
2783 }
2784
2785 #[test]
2786 fn workspace_project_sync_compatibility_api_still_traverses_all_projects() {
2787 let _serial = SYNC_HTTP_TEST_LOCK.lock().unwrap();
2788 let server = MockLinearServer::start(move |request| {
2789 project_sync_response(&request, 1).expect("unexpected GraphQL operation")
2790 });
2791 let client = test_client(&server.url);
2792 let (db, _dir) = test_db();
2793
2794 let (projects, milestones) = runtime()
2795 .block_on(client.sync_projects(&db, "default"))
2796 .unwrap();
2797 assert_eq!((projects, milestones), (2, 2));
2798 let stored = db.list_projects("default", true).unwrap();
2799 assert_eq!(stored.len(), 2);
2800 assert!(stored.iter().any(|project| project.teams[0].key == "CUT"));
2801 }
2802
2803 #[test]
2804 fn failed_index_page_leaves_checkpoint_unchanged() {
2805 let _serial = SYNC_HTTP_TEST_LOCK.lock().unwrap();
2806 let server = MockLinearServer::start(move |request| {
2807 if request["variables"]["after"].is_null() {
2808 MockResponse::json(serde_json::json!({
2809 "data": { "issues": {
2810 "nodes": [issue_node("issue-1", "CUT-1", "2026-02-01T00:00:00Z")],
2811 "pageInfo": { "hasNextPage": true, "endCursor": "cursor-1" }
2812 }}
2813 }))
2814 } else {
2815 MockResponse::status(500)
2816 }
2817 });
2818 let client = test_client(&server.url);
2819 let (db, _dir) = test_db();
2820 let mut stale = db::test_helpers::make_issue("CUT-99", "CUT");
2821 stale.id = "stale-issue".into();
2822 db.upsert_issue(&stale).unwrap();
2823 db.set_sync_cursor("default", "CUT", "2026-01-01T00:00:00Z")
2824 .unwrap();
2825 let upper = chrono::DateTime::parse_from_rfc3339("2026-03-01T00:00:00Z")
2826 .unwrap()
2827 .with_timezone(&chrono::Utc);
2828
2829 assert!(runtime()
2830 .block_on(client.sync_team_index_window(&db, "CUT", "default", true, upper, None,))
2831 .is_err());
2832 assert_eq!(
2833 db.get_synced_through_at("default", "CUT")
2834 .unwrap()
2835 .as_deref(),
2836 Some("2026-01-01T00:00:00Z")
2837 );
2838 assert!(db.get_issue("CUT-1").unwrap().is_some());
2839 assert!(db.get_issue("CUT-99").unwrap().is_some());
2840 }
2841
2842 #[test]
2843 fn empty_index_window_advances_checkpoint_and_uses_overlap() {
2844 let _serial = SYNC_HTTP_TEST_LOCK.lock().unwrap();
2845 let lower = Arc::new(Mutex::new(None::<String>));
2846 let server_lower = Arc::clone(&lower);
2847 let server = MockLinearServer::start(move |request| {
2848 *server_lower.lock().unwrap() = request["variables"]["lower"]
2849 .as_str()
2850 .map(ToString::to_string);
2851 MockResponse::json(serde_json::json!({
2852 "data": { "issues": {
2853 "nodes": [],
2854 "pageInfo": { "hasNextPage": false, "endCursor": null }
2855 }}
2856 }))
2857 });
2858 let client = test_client(&server.url);
2859 let (db, _dir) = test_db();
2860 db.set_sync_cursor("default", "CUT", "2026-02-01T00:00:00Z")
2861 .unwrap();
2862 let upper = chrono::DateTime::parse_from_rfc3339("2026-03-01T00:00:00Z")
2863 .unwrap()
2864 .with_timezone(&chrono::Utc);
2865
2866 let result = runtime()
2867 .block_on(client.sync_team_index_window(&db, "CUT", "default", false, upper, None))
2868 .unwrap();
2869 assert_eq!(result.indexed, 0);
2870 assert_eq!(
2871 lower.lock().unwrap().as_deref(),
2872 Some("2026-01-31T23:55:00+00:00")
2873 );
2874 assert_eq!(
2875 db.get_synced_through_at("default", "CUT")
2876 .unwrap()
2877 .as_deref(),
2878 Some("2026-03-01T00:00:00+00:00")
2879 );
2880 }
2881
2882 #[test]
2883 fn subsequent_overlap_finds_an_issue_at_the_previous_upper_boundary() {
2884 let _serial = SYNC_HTTP_TEST_LOCK.lock().unwrap();
2885 let calls = Arc::new(AtomicUsize::new(0));
2886 let server_calls = Arc::clone(&calls);
2887 let server = MockLinearServer::start(move |_request| {
2888 let call = server_calls.fetch_add(1, Ordering::Relaxed);
2889 let nodes = if call == 0 {
2890 Vec::new()
2891 } else {
2892 vec![issue_node(
2893 "issue-boundary",
2894 "CUT-9",
2895 "2026-01-01T00:00:00Z",
2896 )]
2897 };
2898 MockResponse::json(serde_json::json!({
2899 "data": { "issues": {
2900 "nodes": nodes,
2901 "pageInfo": { "hasNextPage": false, "endCursor": null }
2902 }}
2903 }))
2904 });
2905 let client = test_client(&server.url);
2906 let (db, _dir) = test_db();
2907 let first_upper = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
2908 .unwrap()
2909 .with_timezone(&chrono::Utc);
2910 let second_upper = chrono::DateTime::parse_from_rfc3339("2026-01-02T00:00:00Z")
2911 .unwrap()
2912 .with_timezone(&chrono::Utc);
2913 let rt = runtime();
2914
2915 let first = rt
2916 .block_on(client.sync_team_index_window(
2917 &db,
2918 "CUT",
2919 "default",
2920 false,
2921 first_upper,
2922 None,
2923 ))
2924 .unwrap();
2925 let second = rt
2926 .block_on(client.sync_team_index_window(
2927 &db,
2928 "CUT",
2929 "default",
2930 false,
2931 second_upper,
2932 None,
2933 ))
2934 .unwrap();
2935
2936 assert_eq!(first.indexed, 0);
2937 assert_eq!(second.indexed, 1);
2938 assert!(db.get_issue("CUT-9").unwrap().is_some());
2939 }
2940
2941 #[test]
2942 fn issue_hydration_families_complete_independently() {
2943 let _serial = SYNC_HTTP_TEST_LOCK.lock().unwrap();
2944 let server = MockLinearServer::start(move |request| {
2945 let query = request["query"].as_str().unwrap_or_default();
2946 if query.contains("labels(first:") {
2947 return MockResponse::json(serde_json::json!({
2948 "errors": [{ "message": "Forbidden" }]
2949 }));
2950 }
2951 if query.contains("relations(first:") {
2952 return MockResponse::json(serde_json::json!({
2953 "data": { "issue": { "relations": {
2954 "nodes": [],
2955 "pageInfo": { "hasNextPage": false, "endCursor": null }
2956 }}}
2957 }));
2958 }
2959 if query.contains("comments(") {
2960 return successful_comments();
2961 }
2962 standard_sync_response(&request).expect("unexpected GraphQL operation")
2963 });
2964 let client = test_client(&server.url);
2965 let (db, _dir) = test_db();
2966 let mut issue = db::test_helpers::make_issue("CUT-1", "CUT");
2967 issue.id = "issue-1".into();
2968 db.upsert_issue(&issue).unwrap();
2969 db.ensure_hydration_state_for_issue("default", &issue, "initial")
2970 .unwrap();
2971 db.mark_hydration_complete(
2972 "default",
2973 "issue-1",
2974 db::HydrationResource::Details,
2975 &issue.updated_at,
2976 "2026-01-03T00:00:00Z",
2977 )
2978 .unwrap();
2979
2980 let batch = runtime()
2981 .block_on(client.hydrate_pending_issues(
2982 &db,
2983 "CUT",
2984 "default",
2985 1,
2986 db::HydrationPolicy::OpenOnly,
2987 None,
2988 ))
2989 .unwrap();
2990 let result = db.get_issue_hydration_state("default", "issue-1").unwrap();
2991 assert_eq!(
2992 result.status,
2993 db::HydrationStatus::Partial,
2994 "{:?}",
2995 result.resources
2996 );
2997 assert_eq!(batch.partial, 1);
2998 assert_eq!(batch.permanent_failures, 1);
2999 let status = |resource| {
3000 result
3001 .resources
3002 .iter()
3003 .find(|state| state.resource == resource)
3004 .unwrap()
3005 .status
3006 };
3007 assert_eq!(
3008 status(db::HydrationResource::Details),
3009 db::HydrationStatus::Hydrated
3010 );
3011 assert_eq!(
3012 status(db::HydrationResource::Labels),
3013 db::HydrationStatus::PermissionDenied
3014 );
3015 assert_eq!(
3016 status(db::HydrationResource::Relations),
3017 db::HydrationStatus::Hydrated
3018 );
3019 assert_eq!(
3020 status(db::HydrationResource::Comments),
3021 db::HydrationStatus::Hydrated
3022 );
3023 }
3024
3025 #[test]
3026 fn http_400_ratelimited_is_persisted_and_stops_background_batch() {
3027 let _serial = SYNC_HTTP_TEST_LOCK.lock().unwrap();
3028 let calls = Arc::new(AtomicUsize::new(0));
3029 let server_calls = Arc::clone(&calls);
3030 let server = MockLinearServer::start(move |_request| {
3031 server_calls.fetch_add(1, Ordering::Relaxed);
3032 MockResponse {
3033 status: 400,
3034 body: serde_json::json!({
3035 "errors": [{
3036 "message": "request budget exhausted",
3037 "extensions": { "code": "RATELIMITED" }
3038 }]
3039 })
3040 .to_string(),
3041 }
3042 });
3043 let client = test_client(&server.url);
3044 let (db, _dir) = test_db();
3045 for number in 1..=2 {
3046 let mut issue = db::test_helpers::make_issue(&format!("CUT-{number}"), "CUT");
3047 issue.id = format!("issue-{number}");
3048 db.upsert_issue(&issue).unwrap();
3049 db.ensure_hydration_state_for_issue("default", &issue, "initial")
3050 .unwrap();
3051 }
3052
3053 let result = runtime()
3054 .block_on(client.hydrate_pending_issues(
3055 &db,
3056 "CUT",
3057 "default",
3058 2,
3059 db::HydrationPolicy::OpenOnly,
3060 None,
3061 ))
3062 .unwrap();
3063 assert!(result.rate_limited);
3064 assert_eq!(result.deferred, 1);
3065 assert!((1..=3).contains(&calls.load(Ordering::Relaxed)));
3066 let state = db.get_issue_hydration_state("default", "issue-1").unwrap();
3067 assert_eq!(
3068 state
3069 .resources
3070 .iter()
3071 .find(|resource| resource.resource == db::HydrationResource::Details)
3072 .unwrap()
3073 .status,
3074 db::HydrationStatus::Retryable
3075 );
3076 assert!(state.resources[0].next_retry_at.is_some());
3077 }
3078
3079 #[test]
3080 fn permanent_permission_failure_is_not_background_retried() {
3081 let _serial = SYNC_HTTP_TEST_LOCK.lock().unwrap();
3082 let calls = Arc::new(AtomicUsize::new(0));
3083 let server_calls = Arc::clone(&calls);
3084 let server = MockLinearServer::start(move |_request| {
3085 server_calls.fetch_add(1, Ordering::Relaxed);
3086 MockResponse::json(serde_json::json!({
3087 "errors": [{ "message": "Forbidden" }]
3088 }))
3089 });
3090 let client = test_client(&server.url);
3091 let (db, _dir) = test_db();
3092 let mut issue = db::test_helpers::make_issue("CUT-1", "CUT");
3093 issue.id = "issue-1".into();
3094 db.upsert_issue(&issue).unwrap();
3095 db.ensure_hydration_state_for_issue("default", &issue, "initial")
3096 .unwrap();
3097 for resource in [
3098 db::HydrationResource::Labels,
3099 db::HydrationResource::Relations,
3100 db::HydrationResource::Comments,
3101 ] {
3102 db.mark_hydration_complete(
3103 "default",
3104 "issue-1",
3105 resource,
3106 &issue.updated_at,
3107 "2026-01-03T00:00:00Z",
3108 )
3109 .unwrap();
3110 }
3111
3112 let rt = runtime();
3113 rt.block_on(client.hydrate_pending_issues(
3114 &db,
3115 "CUT",
3116 "default",
3117 1,
3118 db::HydrationPolicy::OpenOnly,
3119 None,
3120 ))
3121 .unwrap();
3122 let first_calls = calls.load(Ordering::Relaxed);
3123 let second = rt
3124 .block_on(client.hydrate_pending_issues(
3125 &db,
3126 "CUT",
3127 "default",
3128 1,
3129 db::HydrationPolicy::OpenOnly,
3130 None,
3131 ))
3132 .unwrap();
3133 assert_eq!(
3134 db.get_issue_hydration_state("default", "issue-1")
3135 .unwrap()
3136 .resources
3137 .iter()
3138 .find(|resource| resource.resource == db::HydrationResource::Details)
3139 .unwrap()
3140 .attempt_count,
3141 1
3142 );
3143 assert_eq!(calls.load(Ordering::Relaxed), first_calls);
3144 assert_eq!(second.requested, 0);
3145 }
3146
3147 #[test]
3148 fn if_needed_selected_hydration_skips_fresh_and_deferred_resources() {
3149 let _serial = SYNC_HTTP_TEST_LOCK.lock().unwrap();
3150 let calls = Arc::new(AtomicUsize::new(0));
3151 let server_calls = Arc::clone(&calls);
3152 let server = MockLinearServer::start(move |_request| {
3153 server_calls.fetch_add(1, Ordering::Relaxed);
3154 MockResponse::status(500)
3155 });
3156 let client = test_client(&server.url);
3157 let (db, _dir) = test_db();
3158 let mut issue = db::test_helpers::make_issue("CUT-1", "CUT");
3159 issue.id = "issue-1".into();
3160 db.upsert_issue(&issue).unwrap();
3161 db.ensure_hydration_state_for_issue("default", &issue, "initial")
3162 .unwrap();
3163 let now = chrono::Utc::now().to_rfc3339();
3164 for resource in db::HYDRATION_RESOURCES {
3165 db.mark_hydration_complete("default", &issue.id, resource, &issue.updated_at, &now)
3166 .unwrap();
3167 }
3168 db.mark_hydration_failed(
3169 "default",
3170 &issue.id,
3171 db::HydrationResource::Relations,
3172 db::HydrationStatus::Retryable,
3173 Some("2999-01-01T00:00:00Z"),
3174 "later",
3175 )
3176 .unwrap();
3177 db.mark_hydration_failed(
3178 "default",
3179 &issue.id,
3180 db::HydrationResource::Labels,
3181 db::HydrationStatus::PermissionDenied,
3182 None,
3183 "forbidden",
3184 )
3185 .unwrap();
3186
3187 let rt = runtime();
3188 for _ in 0..2 {
3189 rt.block_on(client.hydrate_issue_with_mode(
3190 &db,
3191 &issue.id,
3192 "default",
3193 db::HydrationMode::IfNeeded,
3194 None,
3195 ))
3196 .unwrap();
3197 }
3198 assert_eq!(calls.load(Ordering::Relaxed), 0);
3199 let state = db.get_issue_hydration_state("default", &issue.id).unwrap();
3200 assert_eq!(
3201 state
3202 .resources
3203 .iter()
3204 .find(|resource| resource.resource == db::HydrationResource::Relations)
3205 .unwrap()
3206 .attempt_count,
3207 0
3208 );
3209 }
3210
3211 #[test]
3212 fn if_needed_hydrates_pending_resources_without_refreshing_fresh_ones() {
3213 let _serial = SYNC_HTTP_TEST_LOCK.lock().unwrap();
3214 let requests = Arc::new(Mutex::new(Vec::<String>::new()));
3215 let server_requests = Arc::clone(&requests);
3216 let server = MockLinearServer::start(move |request| {
3217 let query = request["query"].as_str().unwrap_or_default().to_string();
3218 server_requests.lock().unwrap().push(query);
3219 standard_sync_response(&request).expect("unexpected GraphQL operation")
3220 });
3221 let client = test_client(&server.url);
3222 let (db, _dir) = test_db();
3223 let mut issue = db::test_helpers::make_issue("CUT-1", "CUT");
3224 issue.id = "issue-1".into();
3225 db.upsert_issue(&issue).unwrap();
3226 db.ensure_hydration_state_for_issue("default", &issue, "initial")
3227 .unwrap();
3228 let now = chrono::Utc::now().to_rfc3339();
3229 for resource in [
3230 db::HydrationResource::Details,
3231 db::HydrationResource::Relations,
3232 db::HydrationResource::Comments,
3233 ] {
3234 db.mark_hydration_complete("default", &issue.id, resource, &issue.updated_at, &now)
3235 .unwrap();
3236 }
3237
3238 runtime()
3239 .block_on(client.hydrate_issue_with_mode(
3240 &db,
3241 &issue.id,
3242 "default",
3243 db::HydrationMode::IfNeeded,
3244 None,
3245 ))
3246 .unwrap();
3247 let requests = requests.lock().unwrap();
3248 assert_eq!(requests.len(), 1);
3249 assert!(requests[0].contains("labels(first:"));
3250 }
3251
3252 #[test]
3253 fn force_refresh_requeues_all_resources_and_recovers_permanent_failures() {
3254 let _serial = SYNC_HTTP_TEST_LOCK.lock().unwrap();
3255 let requests = Arc::new(AtomicUsize::new(0));
3256 let server_requests = Arc::clone(&requests);
3257 let server = MockLinearServer::start(move |request| {
3258 server_requests.fetch_add(1, Ordering::Relaxed);
3259 if request["query"]
3260 .as_str()
3261 .is_some_and(|query| query.contains("comments("))
3262 {
3263 return successful_comments();
3264 }
3265 standard_sync_response(&request).expect("unexpected GraphQL operation")
3266 });
3267 let client = test_client(&server.url);
3268 let (db, _dir) = test_db();
3269 let mut issue = db::test_helpers::make_issue("CUT-1", "CUT");
3270 issue.id = "issue-1".into();
3271 db.upsert_issue(&issue).unwrap();
3272 db.ensure_hydration_state_for_issue("default", &issue, "initial")
3273 .unwrap();
3274 for resource in db::HYDRATION_RESOURCES {
3275 db.mark_hydration_failed(
3276 "default",
3277 &issue.id,
3278 resource,
3279 db::HydrationStatus::PermissionDenied,
3280 None,
3281 "forbidden",
3282 )
3283 .unwrap();
3284 }
3285
3286 let result = runtime()
3287 .block_on(client.hydrate_issue_with_mode(
3288 &db,
3289 &issue.id,
3290 "default",
3291 db::HydrationMode::ForceRefresh,
3292 None,
3293 ))
3294 .unwrap();
3295 assert_eq!(result.status, db::HydrationStatus::Hydrated);
3296 assert_eq!(result.hydrated_resources, 4);
3297 assert!(requests.load(Ordering::Relaxed) >= 4);
3298 }
3299
3300 #[test]
3301 fn error_diagnostics_keep_chains_bounded_and_redact_credentials() {
3302 let client = LinearClient::with_api_key("top-secret-api-key");
3303 let error: anyhow::Error = LinearOperationError::new(
3304 LinearErrorKind::Transient,
3305 "comments",
3306 None,
3307 format!(
3308 "upstream timeout Authorization: Bearer top-secret-api-key {}",
3309 "x".repeat(700)
3310 ),
3311 )
3312 .into();
3313 let error = error.context("comment synchronization failed for CUT-249");
3314
3315 let diagnostic = client.redacted_error_message(&error);
3316
3317 assert!(diagnostic.contains("comment synchronization failed for CUT-249"));
3318 assert!(diagnostic.contains("upstream timeout"));
3319 assert!(diagnostic.contains("[REDACTED]"));
3320 assert!(!diagnostic.contains("top-secret-api-key"));
3321 assert!(diagnostic.chars().count() <= 500);
3322 }
3323}