Skip to main content

rectilinear_core/linear/
mod.rs

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