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}