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