Skip to main content

rectilinear_core/linear/
mod.rs

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