Skip to main content

rectilinear_core/linear/
mod.rs

1use anyhow::{Context, Result};
2use serde::Deserialize;
3use sha2::{Digest, Sha256};
4
5use crate::config::Config;
6use crate::db::{self, Database};
7
8const LINEAR_API_URL: &str = "https://api.linear.app/graphql";
9
10#[derive(Clone)]
11pub struct LinearClient {
12    client: reqwest::Client,
13    api_key: String,
14    viewer_id: std::sync::Arc<std::sync::RwLock<Option<String>>>,
15}
16
17#[derive(Debug, Deserialize)]
18struct GraphQLResponse<T> {
19    data: Option<T>,
20    errors: Option<Vec<GraphQLError>>,
21}
22
23#[derive(Debug, Deserialize)]
24struct GraphQLError {
25    message: String,
26}
27
28// --- Query response types ---
29
30#[derive(Debug, Deserialize)]
31struct IssuesData {
32    issues: IssueConnection,
33}
34
35#[derive(Debug, Deserialize)]
36struct IssueConnection {
37    nodes: Vec<LinearIssue>,
38    #[serde(rename = "pageInfo")]
39    page_info: PageInfo,
40}
41
42#[derive(Debug, Deserialize)]
43struct PageInfo {
44    #[serde(rename = "hasNextPage")]
45    has_next_page: bool,
46    #[serde(rename = "endCursor")]
47    end_cursor: Option<String>,
48}
49
50#[derive(Debug, Deserialize)]
51struct LinearIssue {
52    id: String,
53    identifier: String,
54    url: String,
55    title: String,
56    description: Option<String>,
57    priority: i32,
58    #[serde(rename = "createdAt")]
59    created_at: String,
60    #[serde(rename = "updatedAt")]
61    updated_at: String,
62    state: LinearState,
63    team: LinearTeam,
64    assignee: Option<LinearUser>,
65    project: Option<LinearProject>,
66    labels: LinearLabelConnection,
67    #[serde(default)]
68    relations: LinearRelationConnection,
69    #[serde(rename = "branchName")]
70    branch_name: Option<String>,
71}
72
73#[derive(Debug, Deserialize, Default)]
74struct LinearRelationConnection {
75    nodes: Vec<LinearRelation>,
76}
77
78#[derive(Debug, Deserialize)]
79struct LinearRelation {
80    id: String,
81    #[serde(rename = "type")]
82    relation_type: String,
83    #[serde(rename = "relatedIssue")]
84    related_issue: LinearRelatedIssue,
85}
86
87#[derive(Debug, Deserialize)]
88struct LinearRelatedIssue {
89    id: String,
90    identifier: String,
91}
92
93#[derive(Debug, Deserialize)]
94struct LinearState {
95    name: String,
96    #[serde(rename = "type")]
97    state_type: String,
98}
99
100#[derive(Debug, Deserialize)]
101struct LinearTeam {
102    key: String,
103}
104
105#[derive(Debug, Deserialize)]
106struct LinearUser {
107    name: String,
108}
109
110#[derive(Debug, Deserialize)]
111struct LinearExternalUser {
112    name: Option<String>,
113    #[serde(rename = "displayName")]
114    display_name: Option<String>,
115}
116
117#[derive(Debug, Deserialize)]
118struct LinearProject {
119    name: String,
120}
121
122#[derive(Debug, Deserialize)]
123struct LinearLabelConnection {
124    nodes: Vec<LinearLabel>,
125}
126
127#[derive(Debug, Deserialize)]
128struct LinearLabel {
129    id: String,
130    name: String,
131}
132
133// --- Team query types ---
134
135#[derive(Debug, Deserialize)]
136struct TeamsData {
137    teams: TeamConnection,
138}
139
140#[derive(Debug, Deserialize)]
141struct TeamConnection {
142    nodes: Vec<TeamNode>,
143}
144
145#[derive(Debug, Deserialize)]
146#[allow(dead_code)]
147pub struct TeamNode {
148    pub id: String,
149    pub key: String,
150    pub name: String,
151}
152
153#[derive(Debug, Clone)]
154pub struct LabelCatalogEntry {
155    pub id: String,
156    pub name: String,
157    pub color: Option<String>,
158    pub parent_id: Option<String>,
159}
160
161// --- Issue creation types ---
162
163#[derive(Debug, Deserialize)]
164struct CreateIssueData {
165    #[serde(rename = "issueCreate")]
166    issue_create: CreateIssuePayload,
167}
168
169#[derive(Debug, Deserialize)]
170struct CreateIssuePayload {
171    success: bool,
172    issue: Option<CreatedIssue>,
173}
174
175#[derive(Debug, Deserialize)]
176struct CreatedIssue {
177    id: String,
178    identifier: String,
179}
180
181// --- Comment creation types ---
182
183#[derive(Debug, Deserialize)]
184struct CreateCommentData {
185    #[serde(rename = "commentCreate")]
186    comment_create: CreateCommentPayload,
187}
188
189#[derive(Debug, Deserialize)]
190struct CreateCommentPayload {
191    success: bool,
192}
193
194// --- Comment query types ---
195
196#[derive(Debug, Deserialize)]
197struct CommentsData {
198    comments: LinearCommentConnection,
199}
200
201#[derive(Debug, Deserialize)]
202struct LinearCommentConnection {
203    nodes: Vec<LinearComment>,
204    #[serde(rename = "pageInfo")]
205    page_info: PageInfo,
206}
207
208#[derive(Debug, Deserialize)]
209struct LinearComment {
210    id: String,
211    body: String,
212    #[serde(rename = "createdAt")]
213    created_at: String,
214    #[serde(rename = "updatedAt")]
215    updated_at: String,
216    #[serde(rename = "parentId")]
217    parent_id: Option<String>,
218    url: String,
219    user: Option<LinearUser>,
220    #[serde(rename = "externalUser")]
221    external_user: Option<LinearExternalUser>,
222}
223
224// --- Issue update types ---
225
226#[derive(Debug, Deserialize)]
227struct UpdateIssueData {
228    #[serde(rename = "issueUpdate")]
229    issue_update: UpdateIssuePayload,
230}
231
232#[derive(Debug, Deserialize)]
233struct UpdateIssuePayload {
234    success: bool,
235}
236
237// --- Relation mutation types ---
238
239#[derive(Debug, Deserialize)]
240struct CreateRelationData {
241    #[serde(rename = "issueRelationCreate")]
242    issue_relation_create: CreateRelationPayload,
243}
244
245#[derive(Debug, Deserialize)]
246struct CreateRelationPayload {
247    success: bool,
248    #[serde(rename = "issueRelation")]
249    issue_relation: Option<CreatedRelation>,
250}
251
252#[derive(Debug, Deserialize)]
253struct CreatedRelation {
254    id: String,
255}
256
257#[derive(Debug, Deserialize)]
258struct DeleteRelationData {
259    #[serde(rename = "issueRelationDelete")]
260    issue_relation_delete: DeleteRelationPayload,
261}
262
263#[derive(Debug, Deserialize)]
264struct DeleteRelationPayload {
265    success: bool,
266}
267
268// --- Single issue query ---
269
270#[derive(Debug, Deserialize)]
271struct SingleIssueData {
272    issue: LinearIssue,
273}
274
275impl LinearClient {
276    pub fn new(config: &Config) -> Result<Self> {
277        let api_key = config.linear_api_key()?.to_string();
278        let client = reqwest::Client::new();
279        Ok(Self { client, api_key, viewer_id: std::sync::Arc::new(std::sync::RwLock::new(None)) })
280    }
281
282    /// Create a client with an explicit API key (for FFI callers).
283    pub fn with_api_key(api_key: &str) -> Self {
284        Self {
285            client: reqwest::Client::new(),
286            api_key: api_key.to_string(),
287            viewer_id: std::sync::Arc::new(std::sync::RwLock::new(None)),
288        }
289    }
290
291    /// Create a client reusing an existing `reqwest::Client`.
292    ///
293    /// Use this when the HTTP client was already constructed inside a tokio
294    /// runtime context (e.g. from the FFI layer).
295    pub fn with_http_client(client: reqwest::Client, api_key: &str) -> Self {
296        Self {
297            client,
298            api_key: api_key.to_string(),
299            viewer_id: std::sync::Arc::new(std::sync::RwLock::new(None)),
300        }
301    }
302
303    async fn query<T: serde::de::DeserializeOwned>(
304        &self,
305        query: &str,
306        variables: serde_json::Value,
307    ) -> Result<T> {
308        let body = serde_json::json!({
309            "query": query,
310            "variables": variables,
311        });
312
313        let resp = self
314            .client
315            .post(LINEAR_API_URL)
316            .header("Authorization", &self.api_key)
317            .header("Content-Type", "application/json")
318            .json(&body)
319            .send()
320            .await
321            .context("Failed to send request to Linear API")?;
322
323        let status = resp.status();
324        if !status.is_success() {
325            let text = resp.text().await.unwrap_or_default();
326            anyhow::bail!("Linear API returned {}: {}", status, text);
327        }
328
329        let response: GraphQLResponse<T> = resp
330            .json()
331            .await
332            .context("Failed to parse Linear response")?;
333
334        if let Some(errors) = response.errors {
335            let msgs: Vec<_> = errors.iter().map(|e| e.message.as_str()).collect();
336            anyhow::bail!("Linear API errors: {}", msgs.join(", "));
337        }
338
339        response.data.context("No data in Linear response")
340    }
341
342    pub async fn list_teams(&self) -> Result<Vec<TeamNode>> {
343        let data: TeamsData = self
344            .query(
345                "query { teams { nodes { id key name } } }",
346                serde_json::json!({}),
347            )
348            .await?;
349        Ok(data.teams.nodes)
350    }
351
352    fn extract_relations(issue_id: &str, linear_issue: &LinearIssue) -> Vec<db::Relation> {
353        linear_issue
354            .relations
355            .nodes
356            .iter()
357            .map(|r| db::Relation {
358                id: r.id.clone(),
359                issue_id: issue_id.to_string(),
360                related_issue_id: r.related_issue.id.clone(),
361                related_issue_identifier: r.related_issue.identifier.clone(),
362                relation_type: r.relation_type.clone(),
363            })
364            .collect()
365    }
366
367    pub async fn fetch_issues(
368        &self,
369        team_key: &str,
370        after_cursor: Option<&str>,
371        updated_after: Option<&str>,
372        include_archived: bool,
373    ) -> Result<(Vec<(db::Issue, Vec<db::Relation>, Vec<String>)>, bool, Option<String>)> {
374        let mut filter_parts = vec![format!("team: {{ key: {{ eq: \"{}\" }} }}", team_key)];
375        if let Some(after) = updated_after {
376            filter_parts.push(format!("updatedAt: {{ gt: \"{}\" }}", after));
377        }
378        let filter = filter_parts.join(", ");
379
380        let after_param = if let Some(c) = after_cursor {
381            format!(", after: \"{}\"", c)
382        } else {
383            String::new()
384        };
385
386        let include_archive = if include_archived { "true" } else { "false" };
387
388        let query = format!(
389            r#"query {{
390                issues(
391                    first: 250,
392                    filter: {{ {} }},
393                    includeArchived: {}
394                    orderBy: updatedAt
395                    {}
396                ) {{
397                    nodes {{
398                        id identifier url title description priority branchName
399                        createdAt updatedAt
400                        state {{ name type }}
401                        team {{ key }}
402                        assignee {{ name }}
403                        project {{ name }}
404                        labels {{ nodes {{ id name }} }}
405                        relations {{ nodes {{ id type relatedIssue {{ id identifier }} }} }}
406                    }}
407                    pageInfo {{ hasNextPage endCursor }}
408                }}
409            }}"#,
410            filter, include_archive, after_param
411        );
412
413        let data: IssuesData = self.query(&query, serde_json::json!({})).await?;
414
415        let issues: Vec<(db::Issue, Vec<db::Relation>, Vec<String>)> = data
416            .issues
417            .nodes
418            .into_iter()
419            .map(Self::convert_linear_issue)
420            .collect();
421
422        Ok((
423            issues,
424            data.issues.page_info.has_next_page,
425            data.issues.page_info.end_cursor,
426        ))
427    }
428
429    pub async fn sync_team(
430        &self,
431        db: &Database,
432        team_key: &str,
433        workspace_id: &str,
434        full: bool,
435        include_archived: bool,
436        progress: Option<&(dyn Fn(usize) + Send + Sync)>,
437    ) -> Result<usize> {
438        // Refresh workspace label catalog before syncing issues so issue_labels
439        // can be populated. Linear labels are workspace-scoped, so this runs
440        // per-call (cheap: one paginated query).
441        if let Err(e) = self.sync_labels_catalog(db, workspace_id).await {
442            eprintln!("warning: failed to sync label catalog for workspace '{}': {}", workspace_id, e);
443        }
444
445        let updated_after = if full {
446            None
447        } else {
448            db.get_sync_cursor(workspace_id, team_key)?
449        };
450
451        let mut total = 0;
452        let mut cursor: Option<String> = None;
453        let mut max_updated: Option<String> = None;
454
455        loop {
456            let (issues, has_next, next_cursor) = self
457                .fetch_issues(
458                    team_key,
459                    cursor.as_deref(),
460                    updated_after.as_deref(),
461                    include_archived,
462                )
463                .await?;
464
465            let count = issues.len();
466            for (mut issue, relations, label_ids) in issues {
467                issue.workspace_id = workspace_id.to_string();
468                if max_updated.is_none() || Some(&issue.updated_at) > max_updated.as_ref() {
469                    max_updated = Some(issue.updated_at.clone());
470                }
471                db.upsert_issue(&issue)?;
472                db.upsert_relations(&issue.id, &relations)?;
473                db.replace_issue_labels(&issue.id, &label_ids)?;
474                if let Err(e) = self
475                    .sync_issue_comments(db, &issue.id, workspace_id)
476                    .await
477                {
478                    eprintln!(
479                        "warning: failed to sync comments for issue {}: {}",
480                        issue.identifier,
481                        Self::redacted_error_message(&e)
482                    );
483                }
484            }
485            total += count;
486
487            if let Some(cb) = progress {
488                cb(total);
489            }
490
491            if !has_next || count == 0 {
492                break;
493            }
494            cursor = next_cursor;
495        }
496
497        if let Some(max) = max_updated {
498            db.set_sync_cursor(workspace_id, team_key, &max)?;
499        }
500
501        Ok(total)
502    }
503
504    pub async fn create_issue(
505        &self,
506        team_id: &str,
507        title: &str,
508        description: Option<&str>,
509        priority: Option<i32>,
510        label_ids: &[String],
511        assignee_id: Option<&str>,
512        parent_id: Option<&str>,
513    ) -> Result<(String, String)> {
514        let mut input = serde_json::json!({
515            "teamId": team_id,
516            "title": title,
517        });
518
519        if let Some(desc) = description {
520            input["description"] = serde_json::Value::String(desc.to_string());
521        }
522        if let Some(p) = priority {
523            input["priority"] = serde_json::Value::Number(p.into());
524        }
525        if !label_ids.is_empty() {
526            input["labelIds"] = serde_json::json!(label_ids);
527        }
528        if let Some(aid) = assignee_id {
529            input["assigneeId"] = serde_json::Value::String(aid.to_string());
530        }
531        if let Some(pid) = parent_id {
532            input["parentId"] = serde_json::Value::String(pid.to_string());
533        }
534
535        let query = r#"
536            mutation($input: IssueCreateInput!) {
537                issueCreate(input: $input) {
538                    success
539                    issue { id identifier }
540                }
541            }
542        "#;
543
544        let data: CreateIssueData = self
545            .query(query, serde_json::json!({ "input": input }))
546            .await?;
547
548        if !data.issue_create.success {
549            anyhow::bail!("Failed to create issue");
550        }
551
552        let issue = data.issue_create.issue.context("No issue returned")?;
553        Ok((issue.id, issue.identifier))
554    }
555
556    pub async fn add_comment(&self, issue_id: &str, body: &str) -> Result<()> {
557        let query = r#"
558            mutation($input: CommentCreateInput!) {
559                commentCreate(input: $input) {
560                    success
561                }
562            }
563        "#;
564
565        let input = serde_json::json!({
566            "issueId": issue_id,
567            "body": body,
568        });
569
570        let data: CreateCommentData = self
571            .query(query, serde_json::json!({ "input": input }))
572            .await?;
573
574        if !data.comment_create.success {
575            anyhow::bail!("Failed to create comment");
576        }
577
578        Ok(())
579    }
580
581    pub async fn fetch_issue_comments(&self, issue_id: &str) -> Result<Vec<db::Comment>> {
582        let query = r#"
583            query($issueId: ID!, $after: String) {
584                comments(
585                    filter: { issue: { id: { eq: $issueId } } },
586                    first: 100,
587                    after: $after,
588                    includeArchived: true,
589                    orderBy: createdAt
590                ) {
591                    nodes {
592                        id body createdAt updatedAt parentId url
593                        user { name }
594                        externalUser { displayName name }
595                    }
596                    pageInfo { hasNextPage endCursor }
597                }
598            }
599        "#;
600
601        let mut comments = Vec::new();
602        let mut cursor: Option<String> = None;
603
604        loop {
605            let data: CommentsData = self
606                .query(
607                    query,
608                    serde_json::json!({
609                        "issueId": issue_id,
610                        "after": cursor.as_deref(),
611                    }),
612                )
613                .await?;
614
615            comments.extend(
616                data.comments
617                    .nodes
618                    .into_iter()
619                    .map(|comment| Self::convert_linear_comment(issue_id, comment)),
620            );
621
622            if !data.comments.page_info.has_next_page {
623                break;
624            }
625            cursor = data.comments.page_info.end_cursor;
626            if cursor.is_none() {
627                break;
628            }
629        }
630
631        Ok(comments)
632    }
633
634    pub async fn sync_issue_comments(
635        &self,
636        db: &Database,
637        issue_id: &str,
638        workspace_id: &str,
639    ) -> Result<usize> {
640        match self.fetch_issue_comments(issue_id).await {
641            Ok(mut comments) => {
642                for comment in &mut comments {
643                    comment.workspace_id = workspace_id.to_string();
644                }
645                let count = comments.len();
646                db.replace_issue_comments(issue_id, workspace_id, &comments)?;
647                db.mark_comments_synced(issue_id, workspace_id, count)?;
648                Ok(count)
649            }
650            Err(error) => {
651                let status = Self::comment_error_status(&error);
652                let message = Self::redacted_error_message(&error);
653                db.mark_comments_sync_failed(issue_id, workspace_id, status, &message)?;
654                Err(error)
655            }
656        }
657    }
658
659    pub fn comment_error_status(error: &anyhow::Error) -> &'static str {
660        let message = error.to_string().to_lowercase();
661        if message.contains("permission")
662            || message.contains("forbidden")
663            || message.contains("unauthorized")
664            || message.contains("access")
665        {
666            "permission_denied"
667        } else {
668            "unavailable"
669        }
670    }
671
672    fn redacted_error_message(error: &anyhow::Error) -> String {
673        error.to_string().chars().take(500).collect()
674    }
675
676    pub async fn update_issue(
677        &self,
678        issue_id: &str,
679        title: Option<&str>,
680        description: Option<&str>,
681        priority: Option<i32>,
682        state_id: Option<&str>,
683        label_ids: Option<&[String]>,
684        project_id: Option<&str>,
685        assignee_id: Option<&str>,
686    ) -> Result<()> {
687        let mut input = serde_json::Map::new();
688        if let Some(t) = title {
689            input.insert("title".into(), serde_json::Value::String(t.to_string()));
690        }
691        if let Some(d) = description {
692            input.insert(
693                "description".into(),
694                serde_json::Value::String(d.to_string()),
695            );
696        }
697        if let Some(p) = priority {
698            input.insert("priority".into(), serde_json::Value::Number(p.into()));
699        }
700        if let Some(sid) = state_id {
701            input.insert("stateId".into(), serde_json::Value::String(sid.to_string()));
702        }
703        if let Some(lids) = label_ids {
704            input.insert("labelIds".into(), serde_json::json!(lids));
705        }
706        if let Some(pid) = project_id {
707            let value = if pid.is_empty() {
708                serde_json::Value::Null
709            } else {
710                serde_json::Value::String(pid.to_string())
711            };
712            input.insert("projectId".into(), value);
713        }
714        if let Some(aid) = assignee_id {
715            let value = if aid.is_empty() {
716                serde_json::Value::Null
717            } else {
718                serde_json::Value::String(aid.to_string())
719            };
720            input.insert("assigneeId".into(), value);
721        }
722
723        let query = r#"
724            mutation($id: String!, $input: IssueUpdateInput!) {
725                issueUpdate(id: $id, input: $input) {
726                    success
727                }
728            }
729        "#;
730
731        let data: UpdateIssueData = self
732            .query(query, serde_json::json!({ "id": issue_id, "input": input }))
733            .await?;
734
735        if !data.issue_update.success {
736            anyhow::bail!("Failed to update issue");
737        }
738
739        Ok(())
740    }
741
742    pub async fn fetch_single_issue(
743        &self,
744        issue_id: &str,
745    ) -> Result<(db::Issue, Vec<db::Relation>, Vec<String>)> {
746        let query = r#"
747            query($id: String!) {
748                issue(id: $id) {
749                    id identifier url title description priority branchName
750                    createdAt updatedAt
751                    state { name type }
752                    team { key }
753                    assignee { name }
754                    project { name }
755                    labels { nodes { id name } }
756                    relations { nodes { id type relatedIssue { id identifier } } }
757                }
758            }
759        "#;
760
761        let data: SingleIssueData = self
762            .query(query, serde_json::json!({ "id": issue_id }))
763            .await?;
764
765        Ok(Self::convert_linear_issue(data.issue))
766    }
767
768    /// Fetch a single issue from Linear by its identifier (e.g., "CUT-537").
769    /// Parses the identifier into team key + number and queries via the issues filter.
770    pub async fn fetch_issue_by_identifier(
771        &self,
772        identifier: &str,
773    ) -> Result<Option<(db::Issue, Vec<db::Relation>, Vec<String>)>> {
774        // Parse "CUT-537" into team_key="CUT", number=537
775        let parts: Vec<&str> = identifier.rsplitn(2, '-').collect();
776        if parts.len() != 2 {
777            anyhow::bail!(
778                "Invalid issue identifier '{}': expected format like 'ENG-123'",
779                identifier
780            );
781        }
782        let number: i32 = parts[0]
783            .parse()
784            .with_context(|| format!("Invalid issue number in '{}'", identifier))?;
785        let team_key = parts[1];
786
787        let query = format!(
788            r#"query {{
789                issues(
790                    filter: {{
791                        team: {{ key: {{ eq: "{}" }} }},
792                        number: {{ eq: {} }}
793                    }},
794                    first: 1,
795                    includeArchived: true
796                ) {{
797                    nodes {{
798                        id identifier url title description priority branchName
799                        createdAt updatedAt
800                        state {{ name type }}
801                        team {{ key }}
802                        assignee {{ name }}
803                        project {{ name }}
804                        labels {{ nodes {{ id name }} }}
805                        relations {{ nodes {{ id type relatedIssue {{ id identifier }} }} }}
806                    }}
807                    pageInfo {{ hasNextPage endCursor }}
808                }}
809            }}"#,
810            team_key, number
811        );
812
813        let data: IssuesData = self.query(&query, serde_json::json!({})).await?;
814
815        Ok(data
816            .issues
817            .nodes
818            .into_iter()
819            .next()
820            .map(Self::convert_linear_issue))
821    }
822
823    fn convert_linear_issue(i: LinearIssue) -> (db::Issue, Vec<db::Relation>, Vec<String>) {
824        let labels: Vec<String> = i.labels.nodes.iter().map(|l| l.name.clone()).collect();
825        let label_ids: Vec<String> = i.labels.nodes.iter().map(|l| l.id.clone()).collect();
826        let labels_json = serde_json::to_string(&labels).unwrap_or_else(|_| "[]".to_string());
827
828        let mut hasher = Sha256::new();
829        hasher.update(&i.title);
830        hasher.update(i.description.as_deref().unwrap_or(""));
831        hasher.update(&labels_json);
832        let content_hash = hex::encode(hasher.finalize());
833
834        let relations = Self::extract_relations(&i.id, &i);
835
836        let issue = db::Issue {
837            id: i.id,
838            identifier: i.identifier,
839            url: i.url,
840            team_key: i.team.key,
841            title: i.title,
842            description: i.description,
843            state_name: i.state.name,
844            state_type: i.state.state_type,
845            priority: i.priority,
846            assignee_name: i.assignee.map(|a| a.name),
847            project_name: i.project.map(|p| p.name),
848            labels_json,
849            created_at: i.created_at,
850            updated_at: i.updated_at,
851            content_hash,
852            synced_at: None,
853            branch_name: i.branch_name,
854            workspace_id: "default".to_string(),
855        };
856
857        (issue, relations, label_ids)
858    }
859
860    fn convert_linear_comment(issue_id: &str, comment: LinearComment) -> db::Comment {
861        let external_name = comment
862            .external_user
863            .and_then(|u| u.display_name.or(u.name));
864        db::Comment {
865            id: comment.id,
866            issue_id: issue_id.to_string(),
867            body: comment.body,
868            user_name: comment.user.map(|u| u.name).or(external_name),
869            created_at: comment.created_at,
870            updated_at: Some(comment.updated_at),
871            parent_id: comment.parent_id,
872            url: Some(comment.url),
873            workspace_id: "default".to_string(),
874        }
875    }
876
877    /// Get a team's ID from its key
878    pub async fn get_team_id(&self, team_key: &str) -> Result<String> {
879        let teams = self.list_teams().await?;
880        teams
881            .iter()
882            .find(|t| t.key.eq_ignore_ascii_case(team_key))
883            .map(|t| t.id.clone())
884            .with_context(|| format!("Team '{}' not found", team_key))
885    }
886
887    /// Look up a workflow state ID by name for a given team.
888    /// Matches case-insensitively (e.g. "done", "cancelled", "duplicate").
889    pub async fn get_state_id(&self, team_key: &str, state_name: &str) -> Result<String> {
890        let team_id = self.get_team_id(team_key).await?;
891        let query = r#"
892            query($teamId: String!) {
893                team(id: $teamId) {
894                    states { nodes { id name type } }
895                }
896            }
897        "#;
898
899        let data: serde_json::Value = self
900            .query(query, serde_json::json!({ "teamId": team_id }))
901            .await?;
902
903        let states = data["team"]["states"]["nodes"]
904            .as_array()
905            .context("No states in response")?;
906
907        for state in states {
908            if let Some(name) = state["name"].as_str() {
909                if name.eq_ignore_ascii_case(state_name) {
910                    return state["id"]
911                        .as_str()
912                        .map(|s| s.to_string())
913                        .context("State has no id");
914                }
915            }
916        }
917
918        // Also try matching by type (e.g. "completed", "canceled")
919        for state in states {
920            if let Some(t) = state["type"].as_str() {
921                if t.eq_ignore_ascii_case(state_name) {
922                    return state["id"]
923                        .as_str()
924                        .map(|s| s.to_string())
925                        .context("State has no id");
926                }
927            }
928        }
929
930        let available: Vec<&str> = states.iter().filter_map(|s| s["name"].as_str()).collect();
931        anyhow::bail!(
932            "State '{}' not found for team {}. Available: {}",
933            state_name,
934            team_key,
935            available.join(", ")
936        )
937    }
938
939    /// Resolve label names to IDs for a workspace.
940    /// Linear labels are workspace-scoped, not team-scoped.
941    /// Returns IDs for all matched labels and errors for any not found.
942    pub async fn get_label_ids(&self, label_names: &[String]) -> Result<Vec<String>> {
943        if label_names.is_empty() {
944            return Ok(Vec::new());
945        }
946
947        let query = r#"
948            query {
949                issueLabels(first: 250) {
950                    nodes { id name }
951                }
952            }
953        "#;
954
955        let data: serde_json::Value = self.query(query, serde_json::json!({})).await?;
956
957        let labels = data["issueLabels"]["nodes"]
958            .as_array()
959            .context("No labels in response")?;
960
961        let mut ids = Vec::new();
962        for name in label_names {
963            let found = labels.iter().find(|l| {
964                l["name"]
965                    .as_str()
966                    .is_some_and(|n| n.eq_ignore_ascii_case(name))
967            });
968            match found {
969                Some(l) => {
970                    ids.push(l["id"].as_str().context("Label has no id")?.to_string());
971                }
972                None => {
973                    let available: Vec<&str> =
974                        labels.iter().filter_map(|l| l["name"].as_str()).collect();
975                    anyhow::bail!(
976                        "Label '{}' not found. Available: {}",
977                        name,
978                        available.join(", ")
979                    );
980                }
981            }
982        }
983
984        Ok(ids)
985    }
986
987    /// Resolve an assignee identifier to a Linear user id.
988    ///
989    /// - `"me"` (case-insensitive) → cached `viewer.id`.
990    /// - `"none"` (case-insensitive) → empty string (caller decides whether that's allowed).
991    /// - Anything else → case-insensitive `name` lookup against the workspace's users.
992    ///   Errors if zero or multiple matches.
993    pub async fn resolve_assignee_id(&self, input: &str) -> Result<String> {
994        let trimmed = input.trim();
995        if trimmed.eq_ignore_ascii_case("none") {
996            return Ok(String::new());
997        }
998        if trimmed.eq_ignore_ascii_case("me") {
999            if let Some(cached) = self.viewer_id.read().unwrap().clone() {
1000                return Ok(cached);
1001            }
1002            let data: serde_json::Value = self
1003                .query("query { viewer { id } }", serde_json::json!({}))
1004                .await?;
1005            let id = data["viewer"]["id"]
1006                .as_str()
1007                .context("viewer query returned no id")?
1008                .to_string();
1009            *self.viewer_id.write().unwrap() = Some(id.clone());
1010            return Ok(id);
1011        }
1012
1013        // Name lookup. Linear's `users` query has no `eqIgnoreCase` filter; fetch and filter locally.
1014        let data: serde_json::Value = self
1015            .query(
1016                "query { users(first: 250) { nodes { id name } } }",
1017                serde_json::json!({}),
1018            )
1019            .await?;
1020        let nodes = data["users"]["nodes"]
1021            .as_array()
1022            .context("users query returned no nodes")?;
1023        let matches: Vec<(String, String)> = nodes
1024            .iter()
1025            .filter_map(|n| {
1026                let name = n["name"].as_str()?;
1027                if name.eq_ignore_ascii_case(trimmed) {
1028                    Some((n["id"].as_str()?.to_string(), name.to_string()))
1029                } else {
1030                    None
1031                }
1032            })
1033            .collect();
1034
1035        match matches.len() {
1036            0 => anyhow::bail!("Assignee '{}' not found in Linear users.", trimmed),
1037            1 => Ok(matches.into_iter().next().unwrap().0),
1038            _ => {
1039                let names: Vec<&str> = matches.iter().map(|(_, n)| n.as_str()).collect();
1040                anyhow::bail!(
1041                    "Assignee '{}' matched multiple users: {}. Use a more specific name.",
1042                    trimmed,
1043                    names.join(", ")
1044                )
1045            }
1046        }
1047    }
1048
1049    /// Fetch the full label catalog for the workspace (all pages).
1050    pub async fn fetch_labels(&self) -> Result<Vec<LabelCatalogEntry>> {
1051        let mut out = Vec::new();
1052        let mut cursor: Option<String> = None;
1053        loop {
1054            let after_param = match cursor {
1055                Some(ref c) => format!(", after: \"{}\"", c),
1056                None => String::new(),
1057            };
1058            let query = format!(
1059                r#"query {{
1060                    issueLabels(first: 250{}) {{
1061                        nodes {{ id name color parent {{ id }} }}
1062                        pageInfo {{ hasNextPage endCursor }}
1063                    }}
1064                }}"#,
1065                after_param
1066            );
1067            let data: serde_json::Value = self.query(&query, serde_json::json!({})).await?;
1068            let nodes = data["issueLabels"]["nodes"]
1069                .as_array()
1070                .context("No issueLabels.nodes in response")?;
1071            for n in nodes {
1072                let id = n["id"].as_str().context("label has no id")?.to_string();
1073                let name = n["name"].as_str().unwrap_or("").to_string();
1074                let color = n["color"].as_str().map(|s| s.to_string());
1075                let parent_id = n["parent"]["id"].as_str().map(|s| s.to_string());
1076                out.push(LabelCatalogEntry { id, name, color, parent_id });
1077            }
1078            let has_next = data["issueLabels"]["pageInfo"]["hasNextPage"].as_bool().unwrap_or(false);
1079            if !has_next { break; }
1080            cursor = data["issueLabels"]["pageInfo"]["endCursor"].as_str().map(|s| s.to_string());
1081            if cursor.is_none() { break; }
1082        }
1083        Ok(out)
1084    }
1085
1086    /// Sync the workspace's label catalog into the local database.
1087    /// Upserts labels by id and removes labels that no longer exist remotely.
1088    pub async fn sync_labels_catalog(&self, db: &Database, workspace_id: &str) -> Result<usize> {
1089        let entries = self.fetch_labels().await?;
1090        let keep_ids: Vec<String> = entries.iter().map(|e| e.id.clone()).collect();
1091        for e in &entries {
1092            db.upsert_label(&db::Label {
1093                id: e.id.clone(),
1094                workspace_id: workspace_id.to_string(),
1095                name: e.name.clone(),
1096                color: e.color.clone(),
1097                parent_id: e.parent_id.clone(),
1098            })?;
1099        }
1100        db.delete_labels_for_workspace_not_in(workspace_id, &keep_ids)?;
1101        Ok(entries.len())
1102    }
1103
1104    /// Resolve a project name to its ID. Matches case-insensitively.
1105    pub async fn get_project_id(&self, project_name: &str) -> Result<String> {
1106        let query = r#"
1107            query {
1108                projects(first: 250) {
1109                    nodes { id name }
1110                }
1111            }
1112        "#;
1113
1114        let data: serde_json::Value = self.query(query, serde_json::json!({})).await?;
1115
1116        let projects = data["projects"]["nodes"]
1117            .as_array()
1118            .context("No projects in response")?;
1119
1120        for project in projects {
1121            if let Some(name) = project["name"].as_str() {
1122                if name.eq_ignore_ascii_case(project_name) {
1123                    return project["id"]
1124                        .as_str()
1125                        .map(|s| s.to_string())
1126                        .context("Project has no id");
1127                }
1128            }
1129        }
1130
1131        let available: Vec<&str> = projects.iter().filter_map(|p| p["name"].as_str()).collect();
1132        anyhow::bail!(
1133            "Project '{}' not found. Available: {}",
1134            project_name,
1135            available.join(", ")
1136        )
1137    }
1138
1139    /// Create a relation between two issues.
1140    /// Linear API types: "blocks", "duplicate", "related".
1141    /// If relation_type is "blocked_by", we swap the issues and create a "blocks" relation.
1142    pub async fn create_relation(
1143        &self,
1144        issue_id: &str,
1145        related_issue_id: &str,
1146        relation_type: &str,
1147    ) -> Result<String> {
1148        let (actual_issue_id, actual_related_id, api_type) = if relation_type == "blocked_by" {
1149            (related_issue_id, issue_id, "blocks")
1150        } else {
1151            (issue_id, related_issue_id, relation_type)
1152        };
1153
1154        let query = r#"
1155            mutation($input: IssueRelationCreateInput!) {
1156                issueRelationCreate(input: $input) {
1157                    success
1158                    issueRelation { id }
1159                }
1160            }
1161        "#;
1162
1163        let input = serde_json::json!({
1164            "issueId": actual_issue_id,
1165            "relatedIssueId": actual_related_id,
1166            "type": api_type,
1167        });
1168
1169        let data: CreateRelationData = self
1170            .query(query, serde_json::json!({ "input": input }))
1171            .await?;
1172
1173        if !data.issue_relation_create.success {
1174            anyhow::bail!("Failed to create relation");
1175        }
1176
1177        let relation = data
1178            .issue_relation_create
1179            .issue_relation
1180            .context("No relation returned")?;
1181        Ok(relation.id)
1182    }
1183
1184    /// Delete a relation by its ID.
1185    pub async fn delete_relation(&self, relation_id: &str) -> Result<()> {
1186        let query = r#"
1187            mutation($id: String!) {
1188                issueRelationDelete(id: $id) {
1189                    success
1190                }
1191            }
1192        "#;
1193
1194        let data: DeleteRelationData = self
1195            .query(query, serde_json::json!({ "id": relation_id }))
1196            .await?;
1197
1198        if !data.issue_relation_delete.success {
1199            anyhow::bail!("Failed to delete relation");
1200        }
1201
1202        Ok(())
1203    }
1204}