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