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}
15
16#[derive(Debug, Deserialize)]
17struct GraphQLResponse<T> {
18    data: Option<T>,
19    errors: Option<Vec<GraphQLError>>,
20}
21
22#[derive(Debug, Deserialize)]
23struct GraphQLError {
24    message: String,
25}
26
27// --- Query response types ---
28
29#[derive(Debug, Deserialize)]
30struct IssuesData {
31    issues: IssueConnection,
32}
33
34#[derive(Debug, Deserialize)]
35struct IssueConnection {
36    nodes: Vec<LinearIssue>,
37    #[serde(rename = "pageInfo")]
38    page_info: PageInfo,
39}
40
41#[derive(Debug, Deserialize)]
42struct PageInfo {
43    #[serde(rename = "hasNextPage")]
44    has_next_page: bool,
45    #[serde(rename = "endCursor")]
46    end_cursor: Option<String>,
47}
48
49#[derive(Debug, Deserialize)]
50struct LinearIssue {
51    id: String,
52    identifier: String,
53    url: String,
54    title: String,
55    description: Option<String>,
56    priority: i32,
57    #[serde(rename = "createdAt")]
58    created_at: String,
59    #[serde(rename = "updatedAt")]
60    updated_at: String,
61    state: LinearState,
62    team: LinearTeam,
63    assignee: Option<LinearUser>,
64    project: Option<LinearProject>,
65    labels: LinearLabelConnection,
66    #[serde(default)]
67    relations: LinearRelationConnection,
68    #[serde(rename = "branchName")]
69    branch_name: Option<String>,
70}
71
72#[derive(Debug, Deserialize, Default)]
73struct LinearRelationConnection {
74    nodes: Vec<LinearRelation>,
75}
76
77#[derive(Debug, Deserialize)]
78struct LinearRelation {
79    id: String,
80    #[serde(rename = "type")]
81    relation_type: String,
82    #[serde(rename = "relatedIssue")]
83    related_issue: LinearRelatedIssue,
84}
85
86#[derive(Debug, Deserialize)]
87struct LinearRelatedIssue {
88    id: String,
89    identifier: String,
90}
91
92#[derive(Debug, Deserialize)]
93struct LinearState {
94    name: String,
95    #[serde(rename = "type")]
96    state_type: String,
97}
98
99#[derive(Debug, Deserialize)]
100struct LinearTeam {
101    key: String,
102}
103
104#[derive(Debug, Deserialize)]
105struct LinearUser {
106    name: String,
107}
108
109#[derive(Debug, Deserialize)]
110struct LinearProject {
111    name: String,
112}
113
114#[derive(Debug, Deserialize)]
115struct LinearLabelConnection {
116    nodes: Vec<LinearLabel>,
117}
118
119#[derive(Debug, Deserialize)]
120struct LinearLabel {
121    name: String,
122}
123
124// --- Team query types ---
125
126#[derive(Debug, Deserialize)]
127struct TeamsData {
128    teams: TeamConnection,
129}
130
131#[derive(Debug, Deserialize)]
132struct TeamConnection {
133    nodes: Vec<TeamNode>,
134}
135
136#[derive(Debug, Deserialize)]
137#[allow(dead_code)]
138pub struct TeamNode {
139    pub id: String,
140    pub key: String,
141    pub name: String,
142}
143
144// --- Issue creation types ---
145
146#[derive(Debug, Deserialize)]
147struct CreateIssueData {
148    #[serde(rename = "issueCreate")]
149    issue_create: CreateIssuePayload,
150}
151
152#[derive(Debug, Deserialize)]
153struct CreateIssuePayload {
154    success: bool,
155    issue: Option<CreatedIssue>,
156}
157
158#[derive(Debug, Deserialize)]
159struct CreatedIssue {
160    id: String,
161    identifier: String,
162}
163
164// --- Comment creation types ---
165
166#[derive(Debug, Deserialize)]
167struct CreateCommentData {
168    #[serde(rename = "commentCreate")]
169    comment_create: CreateCommentPayload,
170}
171
172#[derive(Debug, Deserialize)]
173struct CreateCommentPayload {
174    success: bool,
175}
176
177// --- Issue update types ---
178
179#[derive(Debug, Deserialize)]
180struct UpdateIssueData {
181    #[serde(rename = "issueUpdate")]
182    issue_update: UpdateIssuePayload,
183}
184
185#[derive(Debug, Deserialize)]
186struct UpdateIssuePayload {
187    success: bool,
188}
189
190// --- Relation mutation types ---
191
192#[derive(Debug, Deserialize)]
193struct CreateRelationData {
194    #[serde(rename = "issueRelationCreate")]
195    issue_relation_create: CreateRelationPayload,
196}
197
198#[derive(Debug, Deserialize)]
199struct CreateRelationPayload {
200    success: bool,
201    #[serde(rename = "issueRelation")]
202    issue_relation: Option<CreatedRelation>,
203}
204
205#[derive(Debug, Deserialize)]
206struct CreatedRelation {
207    id: String,
208}
209
210#[derive(Debug, Deserialize)]
211struct DeleteRelationData {
212    #[serde(rename = "issueRelationDelete")]
213    issue_relation_delete: DeleteRelationPayload,
214}
215
216#[derive(Debug, Deserialize)]
217struct DeleteRelationPayload {
218    success: bool,
219}
220
221// --- Single issue query ---
222
223#[derive(Debug, Deserialize)]
224struct SingleIssueData {
225    issue: LinearIssue,
226}
227
228impl LinearClient {
229    pub fn new(config: &Config) -> Result<Self> {
230        let api_key = config.linear_api_key()?.to_string();
231        let client = reqwest::Client::new();
232        Ok(Self { client, api_key })
233    }
234
235    /// Create a client with an explicit API key (for FFI callers).
236    pub fn with_api_key(api_key: &str) -> Self {
237        Self {
238            client: reqwest::Client::new(),
239            api_key: api_key.to_string(),
240        }
241    }
242
243    /// Create a client reusing an existing `reqwest::Client`.
244    ///
245    /// Use this when the HTTP client was already constructed inside a tokio
246    /// runtime context (e.g. from the FFI layer).
247    pub fn with_http_client(client: reqwest::Client, api_key: &str) -> Self {
248        Self {
249            client,
250            api_key: api_key.to_string(),
251        }
252    }
253
254    async fn query<T: serde::de::DeserializeOwned>(
255        &self,
256        query: &str,
257        variables: serde_json::Value,
258    ) -> Result<T> {
259        let body = serde_json::json!({
260            "query": query,
261            "variables": variables,
262        });
263
264        let resp = self
265            .client
266            .post(LINEAR_API_URL)
267            .header("Authorization", &self.api_key)
268            .header("Content-Type", "application/json")
269            .json(&body)
270            .send()
271            .await
272            .context("Failed to send request to Linear API")?;
273
274        let status = resp.status();
275        if !status.is_success() {
276            let text = resp.text().await.unwrap_or_default();
277            anyhow::bail!("Linear API returned {}: {}", status, text);
278        }
279
280        let response: GraphQLResponse<T> = resp
281            .json()
282            .await
283            .context("Failed to parse Linear response")?;
284
285        if let Some(errors) = response.errors {
286            let msgs: Vec<_> = errors.iter().map(|e| e.message.as_str()).collect();
287            anyhow::bail!("Linear API errors: {}", msgs.join(", "));
288        }
289
290        response.data.context("No data in Linear response")
291    }
292
293    pub async fn list_teams(&self) -> Result<Vec<TeamNode>> {
294        let data: TeamsData = self
295            .query(
296                "query { teams { nodes { id key name } } }",
297                serde_json::json!({}),
298            )
299            .await?;
300        Ok(data.teams.nodes)
301    }
302
303    fn extract_relations(issue_id: &str, linear_issue: &LinearIssue) -> Vec<db::Relation> {
304        linear_issue
305            .relations
306            .nodes
307            .iter()
308            .map(|r| db::Relation {
309                id: r.id.clone(),
310                issue_id: issue_id.to_string(),
311                related_issue_id: r.related_issue.id.clone(),
312                related_issue_identifier: r.related_issue.identifier.clone(),
313                relation_type: r.relation_type.clone(),
314            })
315            .collect()
316    }
317
318    pub async fn fetch_issues(
319        &self,
320        team_key: &str,
321        after_cursor: Option<&str>,
322        updated_after: Option<&str>,
323        include_archived: bool,
324    ) -> Result<(Vec<(db::Issue, Vec<db::Relation>)>, bool, Option<String>)> {
325        let mut filter_parts = vec![format!("team: {{ key: {{ eq: \"{}\" }} }}", team_key)];
326        if let Some(after) = updated_after {
327            filter_parts.push(format!("updatedAt: {{ gt: \"{}\" }}", after));
328        }
329        let filter = filter_parts.join(", ");
330
331        let after_param = if let Some(c) = after_cursor {
332            format!(", after: \"{}\"", c)
333        } else {
334            String::new()
335        };
336
337        let include_archive = if include_archived { "true" } else { "false" };
338
339        let query = format!(
340            r#"query {{
341                issues(
342                    first: 250,
343                    filter: {{ {} }},
344                    includeArchived: {}
345                    orderBy: updatedAt
346                    {}
347                ) {{
348                    nodes {{
349                        id identifier url title description priority branchName
350                        createdAt updatedAt
351                        state {{ name type }}
352                        team {{ key }}
353                        assignee {{ name }}
354                        project {{ name }}
355                        labels {{ nodes {{ name }} }}
356                        relations {{ nodes {{ id type relatedIssue {{ id identifier }} }} }}
357                    }}
358                    pageInfo {{ hasNextPage endCursor }}
359                }}
360            }}"#,
361            filter, include_archive, after_param
362        );
363
364        let data: IssuesData = self.query(&query, serde_json::json!({})).await?;
365
366        let issues: Vec<(db::Issue, Vec<db::Relation>)> = data
367            .issues
368            .nodes
369            .into_iter()
370            .map(Self::convert_linear_issue)
371            .collect();
372
373        Ok((
374            issues,
375            data.issues.page_info.has_next_page,
376            data.issues.page_info.end_cursor,
377        ))
378    }
379
380    pub async fn sync_team(
381        &self,
382        db: &Database,
383        team_key: &str,
384        workspace_id: &str,
385        full: bool,
386        include_archived: bool,
387        progress: Option<&(dyn Fn(usize) + Send + Sync)>,
388    ) -> Result<usize> {
389        let updated_after = if full {
390            None
391        } else {
392            db.get_sync_cursor(workspace_id, team_key)?
393        };
394
395        let mut total = 0;
396        let mut cursor: Option<String> = None;
397        let mut max_updated: Option<String> = None;
398
399        loop {
400            let (issues, has_next, next_cursor) = self
401                .fetch_issues(
402                    team_key,
403                    cursor.as_deref(),
404                    updated_after.as_deref(),
405                    include_archived,
406                )
407                .await?;
408
409            let count = issues.len();
410            for (mut issue, relations) in issues {
411                issue.workspace_id = workspace_id.to_string();
412                if max_updated.is_none() || Some(&issue.updated_at) > max_updated.as_ref() {
413                    max_updated = Some(issue.updated_at.clone());
414                }
415                db.upsert_issue(&issue)?;
416                db.upsert_relations(&issue.id, &relations)?;
417            }
418            total += count;
419
420            if let Some(cb) = progress {
421                cb(total);
422            }
423
424            if !has_next || count == 0 {
425                break;
426            }
427            cursor = next_cursor;
428        }
429
430        if let Some(max) = max_updated {
431            db.set_sync_cursor(workspace_id, team_key, &max)?;
432        }
433
434        Ok(total)
435    }
436
437    pub async fn create_issue(
438        &self,
439        team_id: &str,
440        title: &str,
441        description: Option<&str>,
442        priority: Option<i32>,
443        label_ids: &[String],
444        parent_id: Option<&str>,
445    ) -> Result<(String, String)> {
446        let mut input = serde_json::json!({
447            "teamId": team_id,
448            "title": title,
449        });
450
451        if let Some(desc) = description {
452            input["description"] = serde_json::Value::String(desc.to_string());
453        }
454        if let Some(p) = priority {
455            input["priority"] = serde_json::Value::Number(p.into());
456        }
457        if !label_ids.is_empty() {
458            input["labelIds"] = serde_json::json!(label_ids);
459        }
460        if let Some(pid) = parent_id {
461            input["parentId"] = serde_json::Value::String(pid.to_string());
462        }
463
464        let query = r#"
465            mutation($input: IssueCreateInput!) {
466                issueCreate(input: $input) {
467                    success
468                    issue { id identifier }
469                }
470            }
471        "#;
472
473        let data: CreateIssueData = self
474            .query(query, serde_json::json!({ "input": input }))
475            .await?;
476
477        if !data.issue_create.success {
478            anyhow::bail!("Failed to create issue");
479        }
480
481        let issue = data.issue_create.issue.context("No issue returned")?;
482        Ok((issue.id, issue.identifier))
483    }
484
485    pub async fn add_comment(&self, issue_id: &str, body: &str) -> Result<()> {
486        let query = r#"
487            mutation($input: CommentCreateInput!) {
488                commentCreate(input: $input) {
489                    success
490                }
491            }
492        "#;
493
494        let input = serde_json::json!({
495            "issueId": issue_id,
496            "body": body,
497        });
498
499        let data: CreateCommentData = self
500            .query(query, serde_json::json!({ "input": input }))
501            .await?;
502
503        if !data.comment_create.success {
504            anyhow::bail!("Failed to create comment");
505        }
506
507        Ok(())
508    }
509
510    pub async fn update_issue(
511        &self,
512        issue_id: &str,
513        title: Option<&str>,
514        description: Option<&str>,
515        priority: Option<i32>,
516        state_id: Option<&str>,
517        label_ids: Option<&[String]>,
518        project_id: Option<&str>,
519    ) -> Result<()> {
520        let mut input = serde_json::Map::new();
521        if let Some(t) = title {
522            input.insert("title".into(), serde_json::Value::String(t.to_string()));
523        }
524        if let Some(d) = description {
525            input.insert(
526                "description".into(),
527                serde_json::Value::String(d.to_string()),
528            );
529        }
530        if let Some(p) = priority {
531            input.insert("priority".into(), serde_json::Value::Number(p.into()));
532        }
533        if let Some(sid) = state_id {
534            input.insert("stateId".into(), serde_json::Value::String(sid.to_string()));
535        }
536        if let Some(lids) = label_ids {
537            input.insert("labelIds".into(), serde_json::json!(lids));
538        }
539        if let Some(pid) = project_id {
540            input.insert(
541                "projectId".into(),
542                serde_json::Value::String(pid.to_string()),
543            );
544        }
545
546        let query = r#"
547            mutation($id: String!, $input: IssueUpdateInput!) {
548                issueUpdate(id: $id, input: $input) {
549                    success
550                }
551            }
552        "#;
553
554        let data: UpdateIssueData = self
555            .query(query, serde_json::json!({ "id": issue_id, "input": input }))
556            .await?;
557
558        if !data.issue_update.success {
559            anyhow::bail!("Failed to update issue");
560        }
561
562        Ok(())
563    }
564
565    pub async fn fetch_single_issue(
566        &self,
567        issue_id: &str,
568    ) -> Result<(db::Issue, Vec<db::Relation>)> {
569        let query = r#"
570            query($id: String!) {
571                issue(id: $id) {
572                    id identifier url title description priority branchName
573                    createdAt updatedAt
574                    state { name type }
575                    team { key }
576                    assignee { name }
577                    project { name }
578                    labels { nodes { name } }
579                    relations { nodes { id type relatedIssue { id identifier } } }
580                }
581            }
582        "#;
583
584        let data: SingleIssueData = self
585            .query(query, serde_json::json!({ "id": issue_id }))
586            .await?;
587
588        Ok(Self::convert_linear_issue(data.issue))
589    }
590
591    /// Fetch a single issue from Linear by its identifier (e.g., "CUT-537").
592    /// Parses the identifier into team key + number and queries via the issues filter.
593    pub async fn fetch_issue_by_identifier(
594        &self,
595        identifier: &str,
596    ) -> Result<Option<(db::Issue, Vec<db::Relation>)>> {
597        // Parse "CUT-537" into team_key="CUT", number=537
598        let parts: Vec<&str> = identifier.rsplitn(2, '-').collect();
599        if parts.len() != 2 {
600            anyhow::bail!(
601                "Invalid issue identifier '{}': expected format like 'ENG-123'",
602                identifier
603            );
604        }
605        let number: i32 = parts[0]
606            .parse()
607            .with_context(|| format!("Invalid issue number in '{}'", identifier))?;
608        let team_key = parts[1];
609
610        let query = format!(
611            r#"query {{
612                issues(
613                    filter: {{
614                        team: {{ key: {{ eq: "{}" }} }},
615                        number: {{ eq: {} }}
616                    }},
617                    first: 1
618                ) {{
619                    nodes {{
620                        id identifier url title description priority branchName
621                        createdAt updatedAt
622                        state {{ name type }}
623                        team {{ key }}
624                        assignee {{ name }}
625                        project {{ name }}
626                        labels {{ nodes {{ name }} }}
627                        relations {{ nodes {{ id type relatedIssue {{ id identifier }} }} }}
628                    }}
629                    pageInfo {{ hasNextPage endCursor }}
630                }}
631            }}"#,
632            team_key, number
633        );
634
635        let data: IssuesData = self.query(&query, serde_json::json!({})).await?;
636
637        Ok(data
638            .issues
639            .nodes
640            .into_iter()
641            .next()
642            .map(Self::convert_linear_issue))
643    }
644
645    fn convert_linear_issue(i: LinearIssue) -> (db::Issue, Vec<db::Relation>) {
646        let labels: Vec<String> = i.labels.nodes.iter().map(|l| l.name.clone()).collect();
647        let labels_json = serde_json::to_string(&labels).unwrap_or_else(|_| "[]".to_string());
648
649        let mut hasher = Sha256::new();
650        hasher.update(&i.title);
651        hasher.update(i.description.as_deref().unwrap_or(""));
652        hasher.update(&labels_json);
653        let content_hash = hex::encode(hasher.finalize());
654
655        let relations = Self::extract_relations(&i.id, &i);
656
657        let issue = db::Issue {
658            id: i.id,
659            identifier: i.identifier,
660            url: i.url,
661            team_key: i.team.key,
662            title: i.title,
663            description: i.description,
664            state_name: i.state.name,
665            state_type: i.state.state_type,
666            priority: i.priority,
667            assignee_name: i.assignee.map(|a| a.name),
668            project_name: i.project.map(|p| p.name),
669            labels_json,
670            created_at: i.created_at,
671            updated_at: i.updated_at,
672            content_hash,
673            synced_at: None,
674            branch_name: i.branch_name,
675            workspace_id: "default".to_string(),
676        };
677
678        (issue, relations)
679    }
680
681    /// Get a team's ID from its key
682    pub async fn get_team_id(&self, team_key: &str) -> Result<String> {
683        let teams = self.list_teams().await?;
684        teams
685            .iter()
686            .find(|t| t.key.eq_ignore_ascii_case(team_key))
687            .map(|t| t.id.clone())
688            .with_context(|| format!("Team '{}' not found", team_key))
689    }
690
691    /// Look up a workflow state ID by name for a given team.
692    /// Matches case-insensitively (e.g. "done", "cancelled", "duplicate").
693    pub async fn get_state_id(&self, team_key: &str, state_name: &str) -> Result<String> {
694        let team_id = self.get_team_id(team_key).await?;
695        let query = r#"
696            query($teamId: String!) {
697                team(id: $teamId) {
698                    states { nodes { id name type } }
699                }
700            }
701        "#;
702
703        let data: serde_json::Value = self
704            .query(query, serde_json::json!({ "teamId": team_id }))
705            .await?;
706
707        let states = data["team"]["states"]["nodes"]
708            .as_array()
709            .context("No states in response")?;
710
711        for state in states {
712            if let Some(name) = state["name"].as_str() {
713                if name.eq_ignore_ascii_case(state_name) {
714                    return state["id"]
715                        .as_str()
716                        .map(|s| s.to_string())
717                        .context("State has no id");
718                }
719            }
720        }
721
722        // Also try matching by type (e.g. "completed", "canceled")
723        for state in states {
724            if let Some(t) = state["type"].as_str() {
725                if t.eq_ignore_ascii_case(state_name) {
726                    return state["id"]
727                        .as_str()
728                        .map(|s| s.to_string())
729                        .context("State has no id");
730                }
731            }
732        }
733
734        let available: Vec<&str> = states.iter().filter_map(|s| s["name"].as_str()).collect();
735        anyhow::bail!(
736            "State '{}' not found for team {}. Available: {}",
737            state_name,
738            team_key,
739            available.join(", ")
740        )
741    }
742
743    /// Resolve label names to IDs for a workspace.
744    /// Linear labels are workspace-scoped, not team-scoped.
745    /// Returns IDs for all matched labels and errors for any not found.
746    pub async fn get_label_ids(&self, label_names: &[String]) -> Result<Vec<String>> {
747        if label_names.is_empty() {
748            return Ok(Vec::new());
749        }
750
751        let query = r#"
752            query {
753                issueLabels(first: 250) {
754                    nodes { id name }
755                }
756            }
757        "#;
758
759        let data: serde_json::Value = self.query(query, serde_json::json!({})).await?;
760
761        let labels = data["issueLabels"]["nodes"]
762            .as_array()
763            .context("No labels in response")?;
764
765        let mut ids = Vec::new();
766        for name in label_names {
767            let found = labels.iter().find(|l| {
768                l["name"]
769                    .as_str()
770                    .is_some_and(|n| n.eq_ignore_ascii_case(name))
771            });
772            match found {
773                Some(l) => {
774                    ids.push(l["id"].as_str().context("Label has no id")?.to_string());
775                }
776                None => {
777                    let available: Vec<&str> =
778                        labels.iter().filter_map(|l| l["name"].as_str()).collect();
779                    anyhow::bail!(
780                        "Label '{}' not found. Available: {}",
781                        name,
782                        available.join(", ")
783                    );
784                }
785            }
786        }
787
788        Ok(ids)
789    }
790
791    /// Resolve a project name to its ID. Matches case-insensitively.
792    pub async fn get_project_id(&self, project_name: &str) -> Result<String> {
793        let query = r#"
794            query {
795                projects(first: 250) {
796                    nodes { id name }
797                }
798            }
799        "#;
800
801        let data: serde_json::Value = self.query(query, serde_json::json!({})).await?;
802
803        let projects = data["projects"]["nodes"]
804            .as_array()
805            .context("No projects in response")?;
806
807        for project in projects {
808            if let Some(name) = project["name"].as_str() {
809                if name.eq_ignore_ascii_case(project_name) {
810                    return project["id"]
811                        .as_str()
812                        .map(|s| s.to_string())
813                        .context("Project has no id");
814                }
815            }
816        }
817
818        let available: Vec<&str> = projects.iter().filter_map(|p| p["name"].as_str()).collect();
819        anyhow::bail!(
820            "Project '{}' not found. Available: {}",
821            project_name,
822            available.join(", ")
823        )
824    }
825
826    /// Create a relation between two issues.
827    /// Linear API types: "blocks", "duplicate", "related".
828    /// If relation_type is "blocked_by", we swap the issues and create a "blocks" relation.
829    pub async fn create_relation(
830        &self,
831        issue_id: &str,
832        related_issue_id: &str,
833        relation_type: &str,
834    ) -> Result<String> {
835        let (actual_issue_id, actual_related_id, api_type) = if relation_type == "blocked_by" {
836            (related_issue_id, issue_id, "blocks")
837        } else {
838            (issue_id, related_issue_id, relation_type)
839        };
840
841        let query = r#"
842            mutation($input: IssueRelationCreateInput!) {
843                issueRelationCreate(input: $input) {
844                    success
845                    issueRelation { id }
846                }
847            }
848        "#;
849
850        let input = serde_json::json!({
851            "issueId": actual_issue_id,
852            "relatedIssueId": actual_related_id,
853            "type": api_type,
854        });
855
856        let data: CreateRelationData = self
857            .query(query, serde_json::json!({ "input": input }))
858            .await?;
859
860        if !data.issue_relation_create.success {
861            anyhow::bail!("Failed to create relation");
862        }
863
864        let relation = data
865            .issue_relation_create
866            .issue_relation
867            .context("No relation returned")?;
868        Ok(relation.id)
869    }
870
871    /// Delete a relation by its ID.
872    pub async fn delete_relation(&self, relation_id: &str) -> Result<()> {
873        let query = r#"
874            mutation($id: String!) {
875                issueRelationDelete(id: $id) {
876                    success
877                }
878            }
879        "#;
880
881        let data: DeleteRelationData = self
882            .query(query, serde_json::json!({ "id": relation_id }))
883            .await?;
884
885        if !data.issue_relation_delete.success {
886            anyhow::bail!("Failed to delete relation");
887        }
888
889        Ok(())
890    }
891}