Skip to main content

linear_api/
relations.rs

1//! Issue relations — the blocks/blocked-by DAG edges between issues.
2//!
3//! # Direction semantics
4//!
5//! Linear's `IssueRelationType` enum has **no** `blockedBy` value — direction
6//! is positional. `type: blocks` means *`issueId` **blocks**
7//! `relatedIssueId`*. To express "X is blocked by Y", create the relation as
8//! `create(issue = Y, related = X, Blocks)`, or read it from X's
9//! `inverseRelations`.
10//!
11//! On an issue, `Issue.relations` holds the **outgoing** edges (this issue →
12//! related issue) and `Issue.inverseRelations` holds the **incoming** edges
13//! (other issue → this issue). [`RelationsService::of_issue`] returns both
14//! sides as an [`IssueRelations`], whose [`blocks`](IssueRelations::blocks)
15//! and [`blocked_by`](IssueRelations::blocked_by) views apply these rules so
16//! callers never have to.
17
18use serde::{Deserialize, Serialize};
19
20use crate::client::LinearClient;
21use crate::error::{Error, Result};
22use crate::ids::{IssueRef, IssueRelationId};
23use crate::types::{IssueRelationType, IssueStub, ensure_success};
24
25/// Appends the canonical fragments to an operation. Documents must be
26/// self-contained: each one that spreads `...RelationFields` carries the
27/// fragment definitions itself. The field sets MUST stay in sync with
28/// [`IssueRelation`] and [`IssueStub`].
29macro_rules! with_fragments {
30    ($doc:literal) => {
31        concat!(
32            $doc,
33            " fragment RelationFields on IssueRelation",
34            " { id type issue { ...IssueStubFields } relatedIssue { ...IssueStubFields } }",
35            " fragment IssueStubFields on Issue { id identifier title }"
36        )
37    };
38}
39
40const ISSUE_RELATION_CREATE: &str = with_fragments!(
41    "mutation IssueRelationCreate($input: IssueRelationCreateInput!) \
42     { issueRelationCreate(input: $input) { success issueRelation { ...RelationFields } } }"
43);
44
45const ISSUE_RELATION_DELETE: &str =
46    "mutation IssueRelationDelete($id: String!) { issueRelationDelete(id: $id) { success } }";
47
48const ISSUE_RELATIONS_OF: &str = with_fragments!(
49    "query IssueRelationsOf($id: String!) \
50     { issue(id: $id) \
51     { relations(first: 50) { nodes { ...RelationFields } } \
52     inverseRelations(first: 50) { nodes { ...RelationFields } } } }"
53);
54
55pub(crate) const DOCUMENTS: &[(&str, &str)] = &[
56    ("IssueRelationCreate", ISSUE_RELATION_CREATE),
57    ("IssueRelationDelete", ISSUE_RELATION_DELETE),
58    ("IssueRelationsOf", ISSUE_RELATIONS_OF),
59];
60
61/// One directed relation edge between two issues.
62///
63/// Direction is positional: for [`IssueRelationType::Blocks`], [`issue`]
64/// blocks [`related_issue`] — there is no `blockedBy` relation type.
65///
66/// [`issue`]: IssueRelation::issue
67/// [`related_issue`]: IssueRelation::related_issue
68#[derive(Debug, Clone, Deserialize)]
69#[serde(rename_all = "camelCase")]
70#[non_exhaustive]
71pub struct IssueRelation {
72    /// Relation ID.
73    pub id: IssueRelationId,
74    /// How [`issue`](IssueRelation::issue) relates to
75    /// [`related_issue`](IssueRelation::related_issue).
76    #[serde(rename = "type")]
77    pub relation_type: IssueRelationType,
78    /// The source issue the relation originates from.
79    pub issue: IssueStub,
80    /// The target issue the relation points at.
81    pub related_issue: IssueStub,
82}
83
84/// Both directions of an issue's relation edges, as returned by
85/// [`RelationsService::of_issue`].
86///
87/// `outgoing` mirrors `Issue.relations` (this issue is the source);
88/// `incoming` mirrors `Issue.inverseRelations` (this issue is the target).
89/// With `type: blocks` meaning *source blocks target*: the issues this one
90/// blocks live in `outgoing`, and the issues blocking this one live in
91/// `incoming` — use [`blocks`](Self::blocks) / [`blocked_by`](Self::blocked_by)
92/// instead of filtering by hand.
93#[derive(Debug, Clone)]
94#[non_exhaustive]
95pub struct IssueRelations {
96    /// Edges where this issue is the source (`Issue.relations`).
97    pub outgoing: Vec<IssueRelation>,
98    /// Edges where this issue is the target (`Issue.inverseRelations`).
99    pub incoming: Vec<IssueRelation>,
100}
101
102impl IssueRelations {
103    /// The issues this issue **blocks**: outgoing edges with
104    /// [`IssueRelationType::Blocks`], yielding each edge's target.
105    ///
106    /// # Examples
107    ///
108    /// ```no_run
109    /// # async fn example() -> linear_api::Result<()> {
110    /// let client = linear_api::LinearClient::from_env()?;
111    /// let relations = client
112    ///     .relations()
113    ///     .of_issue(linear_api::IssueRef::identifier("ENG-3"))
114    ///     .await?;
115    /// for blocked in relations.blocks() {
116    ///     println!("ENG-3 blocks {}", blocked.identifier);
117    /// }
118    /// # Ok(()) }
119    /// ```
120    pub fn blocks(&self) -> Vec<&IssueStub> {
121        self.outgoing
122            .iter()
123            .filter(|r| r.relation_type == IssueRelationType::Blocks)
124            .map(|r| &r.related_issue)
125            .collect()
126    }
127
128    /// The issues this issue is **blocked by**: incoming edges with
129    /// [`IssueRelationType::Blocks`], yielding each edge's source.
130    ///
131    /// # Examples
132    ///
133    /// ```no_run
134    /// # async fn example() -> linear_api::Result<()> {
135    /// let client = linear_api::LinearClient::from_env()?;
136    /// let relations = client
137    ///     .relations()
138    ///     .of_issue(linear_api::IssueRef::identifier("ENG-3"))
139    ///     .await?;
140    /// for blocker in relations.blocked_by() {
141    ///     println!("ENG-3 is blocked by {}", blocker.identifier);
142    /// }
143    /// # Ok(()) }
144    /// ```
145    pub fn blocked_by(&self) -> Vec<&IssueStub> {
146        self.incoming
147            .iter()
148            .filter(|r| r.relation_type == IssueRelationType::Blocks)
149            .map(|r| &r.issue)
150            .collect()
151    }
152}
153
154/// Wire shape of `IssueRelationCreateInput`.
155#[derive(Debug, Clone, Serialize)]
156#[serde(rename_all = "camelCase")]
157struct CreateVarsInput {
158    issue_id: String,
159    related_issue_id: String,
160    #[serde(rename = "type")]
161    relation_type: IssueRelationType,
162}
163
164/// Issue-relation operations: the blocks/blocked-by DAG edges between
165/// issues. Obtain via [`LinearClient::relations`].
166///
167/// # Direction semantics
168///
169/// The relation type enum has **no** `blockedBy` value; direction is
170/// positional. `type: blocks` means the **source** issue (`issueId`) blocks
171/// the **target** issue (`relatedIssueId`). "X is blocked by Y" is therefore
172/// created as `create(Y, X, Blocks)` — or with
173/// [`create_blocks`](Self::create_blocks), which names its parameters — and
174/// read from X's incoming edges via
175/// [`IssueRelations::blocked_by`].
176#[derive(Clone, Copy)]
177pub struct RelationsService<'a> {
178    client: &'a LinearClient,
179}
180
181impl LinearClient {
182    /// Issue-relation operations (blocks/blocked-by DAG edges).
183    ///
184    /// # Examples
185    ///
186    /// ```no_run
187    /// # async fn example() -> linear_api::Result<()> {
188    /// let client = linear_api::LinearClient::from_env()?;
189    /// let relations = client
190    ///     .relations()
191    ///     .of_issue(linear_api::IssueRef::identifier("ENG-3"))
192    ///     .await?;
193    /// # Ok(()) }
194    /// ```
195    pub fn relations(&self) -> RelationsService<'_> {
196        RelationsService { client: self }
197    }
198}
199
200impl RelationsService<'_> {
201    /// Creates a relation edge: `issue` → `related` with the given type.
202    ///
203    /// **Direction matters**: for [`IssueRelationType::Blocks`], `issue`
204    /// blocks `related` (there is no `blockedBy` type). To record "X is
205    /// blocked by Y", call `create(Y, X, Blocks)`. Both sides accept a UUID
206    /// [`IssueId`](crate::IssueId) or a human identifier via
207    /// [`IssueRef::identifier`].
208    ///
209    /// # Examples
210    ///
211    /// ```no_run
212    /// # async fn example() -> linear_api::Result<()> {
213    /// use linear_api::{IssueRef, IssueRelationType, LinearClient};
214    ///
215    /// let client = LinearClient::from_env()?;
216    /// // ENG-1 blocks ENG-2:
217    /// let relation = client
218    ///     .relations()
219    ///     .create(
220    ///         IssueRef::identifier("ENG-1"),
221    ///         IssueRef::identifier("ENG-2"),
222    ///         IssueRelationType::Blocks,
223    ///     )
224    ///     .await?;
225    /// assert_eq!(relation.issue.identifier, "ENG-1");
226    /// # Ok(()) }
227    /// ```
228    pub async fn create(
229        &self,
230        issue: impl Into<IssueRef>,
231        related: impl Into<IssueRef>,
232        relation_type: IssueRelationType,
233    ) -> Result<IssueRelation> {
234        #[derive(Deserialize)]
235        #[serde(rename_all = "camelCase")]
236        struct Payload {
237            success: bool,
238            issue_relation: Option<IssueRelation>,
239        }
240        #[derive(Deserialize)]
241        #[serde(rename_all = "camelCase")]
242        struct Data {
243            issue_relation_create: Payload,
244        }
245
246        let input = CreateVarsInput {
247            issue_id: issue.into().api_string().to_owned(),
248            related_issue_id: related.into().api_string().to_owned(),
249            relation_type,
250        };
251        let payload = self
252            .client
253            .mutation::<_, Data>(
254                "IssueRelationCreate",
255                ISSUE_RELATION_CREATE,
256                serde_json::json!({ "input": input }),
257            )
258            .await?
259            .issue_relation_create;
260        ensure_success("IssueRelationCreate", payload.success)?;
261        payload.issue_relation.ok_or(Error::MissingData {
262            operation: "IssueRelationCreate",
263        })
264    }
265
266    /// Records that `blocker` **blocks** `blocked` — shorthand for
267    /// [`create(blocker, blocked, Blocks)`](Self::create) with the direction
268    /// spelled out in the parameter names.
269    ///
270    /// # Examples
271    ///
272    /// ```no_run
273    /// # async fn example() -> linear_api::Result<()> {
274    /// use linear_api::{IssueRef, LinearClient};
275    ///
276    /// let client = LinearClient::from_env()?;
277    /// // ENG-2 cannot start until ENG-1 is done:
278    /// client
279    ///     .relations()
280    ///     .create_blocks(
281    ///         IssueRef::identifier("ENG-1"), // blocker
282    ///         IssueRef::identifier("ENG-2"), // blocked
283    ///     )
284    ///     .await?;
285    /// # Ok(()) }
286    /// ```
287    pub async fn create_blocks(
288        &self,
289        blocker: impl Into<IssueRef>,
290        blocked: impl Into<IssueRef>,
291    ) -> Result<IssueRelation> {
292        self.create(blocker, blocked, IssueRelationType::Blocks)
293            .await
294    }
295
296    /// Deletes a relation edge by its ID.
297    ///
298    /// # Examples
299    ///
300    /// ```no_run
301    /// # async fn example() -> linear_api::Result<()> {
302    /// use linear_api::{IssueRef, LinearClient};
303    ///
304    /// let client = LinearClient::from_env()?;
305    /// let relations = client
306    ///     .relations()
307    ///     .of_issue(IssueRef::identifier("ENG-3"))
308    ///     .await?;
309    /// if let Some(edge) = relations.outgoing.first() {
310    ///     client.relations().delete(&edge.id).await?;
311    /// }
312    /// # Ok(()) }
313    /// ```
314    pub async fn delete(&self, id: &IssueRelationId) -> Result<()> {
315        #[derive(Deserialize)]
316        struct Payload {
317            success: bool,
318        }
319        #[derive(Deserialize)]
320        #[serde(rename_all = "camelCase")]
321        struct Data {
322            issue_relation_delete: Payload,
323        }
324
325        let data = self
326            .client
327            .mutation::<_, Data>(
328                "IssueRelationDelete",
329                ISSUE_RELATION_DELETE,
330                serde_json::json!({ "id": id }),
331            )
332            .await?;
333        ensure_success("IssueRelationDelete", data.issue_relation_delete.success)
334    }
335
336    /// Fetches both directions of an issue's relation edges: `outgoing` =
337    /// `Issue.relations` (this issue is the source), `incoming` =
338    /// `Issue.inverseRelations` (this issue is the target).
339    ///
340    /// Fetches up to 50 edges per direction and does **not** paginate —
341    /// far beyond any sane card's edge count. Accepts a UUID or a human
342    /// identifier via [`IssueRef::identifier`].
343    ///
344    /// # Examples
345    ///
346    /// ```no_run
347    /// # async fn example() -> linear_api::Result<()> {
348    /// let client = linear_api::LinearClient::from_env()?;
349    /// let relations = client
350    ///     .relations()
351    ///     .of_issue(linear_api::IssueRef::identifier("ENG-3"))
352    ///     .await?;
353    /// println!(
354    ///     "blocks {} issue(s), blocked by {}",
355    ///     relations.blocks().len(),
356    ///     relations.blocked_by().len(),
357    /// );
358    /// # Ok(()) }
359    /// ```
360    pub async fn of_issue(&self, issue: impl Into<IssueRef>) -> Result<IssueRelations> {
361        #[derive(Deserialize)]
362        #[serde(rename_all = "camelCase")]
363        struct IssueNode {
364            #[serde(deserialize_with = "crate::types::nodes")]
365            relations: Vec<IssueRelation>,
366            #[serde(deserialize_with = "crate::types::nodes")]
367            inverse_relations: Vec<IssueRelation>,
368        }
369        #[derive(Deserialize)]
370        struct Data {
371            issue: IssueNode,
372        }
373
374        let data = self
375            .client
376            .query::<_, Data>(
377                "IssueRelationsOf",
378                ISSUE_RELATIONS_OF,
379                serde_json::json!({ "id": issue.into().api_string() }),
380            )
381            .await?;
382        Ok(IssueRelations {
383            outgoing: data.issue.relations,
384            incoming: data.issue.inverse_relations,
385        })
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use crate::ids::IssueId;
393
394    #[test]
395    fn create_input_wire_shape() {
396        let input = CreateVarsInput {
397            issue_id: IssueRef::identifier("ENG-1").api_string().to_owned(),
398            related_issue_id: IssueRef::from(IssueId::new("uuid-2"))
399                .api_string()
400                .to_owned(),
401            relation_type: IssueRelationType::Blocks,
402        };
403        assert_eq!(
404            serde_json::to_value(&input).unwrap(),
405            serde_json::json!({
406                "issueId": "ENG-1",
407                "relatedIssueId": "uuid-2",
408                "type": "blocks"
409            })
410        );
411    }
412
413    #[test]
414    fn blocks_and_blocked_by_filter_by_type_and_direction() {
415        let stub = |identifier: &str| IssueStub {
416            id: IssueId::new(format!("uuid-{identifier}")),
417            identifier: identifier.to_owned(),
418            title: format!("Issue {identifier}"),
419        };
420        let edge = |relation_type: IssueRelationType, from: &str, to: &str| IssueRelation {
421            id: IssueRelationId::new(format!("rel-{from}-{to}")),
422            relation_type,
423            issue: stub(from),
424            related_issue: stub(to),
425        };
426        let relations = IssueRelations {
427            outgoing: vec![
428                edge(IssueRelationType::Blocks, "ENG-3", "ENG-4"),
429                edge(IssueRelationType::Related, "ENG-3", "ENG-5"),
430            ],
431            incoming: vec![
432                edge(IssueRelationType::Blocks, "ENG-2", "ENG-3"),
433                edge(IssueRelationType::Duplicate, "ENG-6", "ENG-3"),
434            ],
435        };
436
437        let blocks: Vec<&str> = relations
438            .blocks()
439            .iter()
440            .map(|s| s.identifier.as_str())
441            .collect();
442        assert_eq!(blocks, vec!["ENG-4"]);
443
444        let blocked_by: Vec<&str> = relations
445            .blocked_by()
446            .iter()
447            .map(|s| s.identifier.as_str())
448            .collect();
449        assert_eq!(blocked_by, vec!["ENG-2"]);
450    }
451}