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