Skip to main content

linear_api/
labels.rs

1//! Issue labels: listing, create/update/delete, and the
2//! [`LabelsService::ensure`] find-or-create convenience.
3
4use bon::Builder;
5use futures::Stream;
6use serde::{Deserialize, Serialize};
7
8use crate::client::LinearClient;
9use crate::error::{Error, LinearErrorType, Result};
10use crate::filter::{IdComparator, IssueLabelFilter, StringComparator, TeamFilter};
11use crate::ids::{LabelId, TeamId};
12use crate::pagination::{Page, PageInfo, paginate};
13use crate::types::{LabelRef, TeamRef, Undefinable, ensure_success};
14
15/// Appends the label fragments to an operation. The `LabelRefFields` and
16/// `TeamRefFields` texts are the canonical fragments from `src/types.rs`.
17macro_rules! doc_with_label_fields {
18    ($op:literal) => {
19        concat!(
20            $op,
21            " fragment LabelFields on IssueLabel { id name color description isGroup \
22             parent { ...LabelRefFields } team { ...TeamRefFields } }",
23            " fragment LabelRefFields on IssueLabel { id name color }",
24            " fragment TeamRefFields on Team { id key name }"
25        )
26    };
27}
28
29const LABEL_LIST: &str = doc_with_label_fields!(
30    "query LabelList($filter: IssueLabelFilter, $first: Int, $after: String) { \
31     issueLabels(filter: $filter, first: $first, after: $after) { \
32     nodes { ...LabelFields } \
33     pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } }"
34);
35
36const LABEL_CREATE: &str = doc_with_label_fields!(
37    "mutation LabelCreate($input: IssueLabelCreateInput!) { \
38     issueLabelCreate(input: $input) { success issueLabel { ...LabelFields } } }"
39);
40
41const LABEL_UPDATE: &str = doc_with_label_fields!(
42    "mutation LabelUpdate($id: String!, $input: IssueLabelUpdateInput!) { \
43     issueLabelUpdate(id: $id, input: $input) { success issueLabel { ...LabelFields } } }"
44);
45
46const LABEL_DELETE: &str =
47    "mutation LabelDelete($id: String!) { issueLabelDelete(id: $id) { success } }";
48
49pub(crate) const DOCUMENTS: &[(&str, &str)] = &[
50    ("LabelList", LABEL_LIST),
51    ("LabelCreate", LABEL_CREATE),
52    ("LabelUpdate", LABEL_UPDATE),
53    ("LabelDelete", LABEL_DELETE),
54];
55
56/// An issue label.
57///
58/// `team == None` means the label is a **workspace-level** label, available
59/// to every team; otherwise it is scoped to the given team.
60///
61/// Wire fragment:
62/// `fragment LabelFields on IssueLabel { id name color description isGroup
63/// parent { ...LabelRefFields } team { ...TeamRefFields } }`
64#[derive(Debug, Clone, Deserialize)]
65#[serde(rename_all = "camelCase")]
66#[non_exhaustive]
67pub struct Label {
68    /// Label ID.
69    pub id: LabelId,
70    /// Label name.
71    pub name: String,
72    /// Label color as a hex string, e.g. `"#BB87FC"`.
73    pub color: String,
74    /// Optional description.
75    pub description: Option<String>,
76    /// Whether the label is a group (a container for child labels).
77    pub is_group: bool,
78    /// The group this label belongs to, when nested.
79    pub parent: Option<LabelRef>,
80    /// The owning team; `None` for workspace-level labels.
81    pub team: Option<TeamRef>,
82}
83
84/// Variables for [`LabelsService::list`].
85///
86/// # Example
87///
88/// ```no_run
89/// # async fn run() -> linear_api::Result<()> {
90/// use linear_api::labels::ListLabelsRequest;
91///
92/// let client = linear_api::LinearClient::from_env()?;
93/// let page = client
94///     .labels()
95///     .list(ListLabelsRequest::builder().first(10).build())
96///     .await?;
97/// # Ok(()) }
98/// ```
99#[derive(Debug, Clone, Default, Serialize, Builder)]
100#[serde(rename_all = "camelCase")]
101#[non_exhaustive]
102pub struct ListLabelsRequest {
103    /// Filter the returned labels.
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub filter: Option<IssueLabelFilter>,
106    /// Page size (server default 50). Page size multiplies query complexity;
107    /// prefer modest values.
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub first: Option<i32>,
110    /// Cursor to paginate forward from (a previous page's `end_cursor`).
111    #[serde(skip_serializing_if = "Option::is_none")]
112    #[builder(into)]
113    pub after: Option<String>,
114}
115
116/// Input for [`LabelsService::create`] (`IssueLabelCreateInput`).
117///
118/// # Example
119///
120/// ```no_run
121/// # async fn run() -> linear_api::Result<()> {
122/// use linear_api::TeamId;
123/// use linear_api::labels::LabelCreateInput;
124///
125/// let client = linear_api::LinearClient::from_env()?;
126/// let label = client
127///     .labels()
128///     .create(
129///         LabelCreateInput::builder()
130///             .name("bug")
131///             .color("#BB87FC")
132///             .team_id(TeamId::new("9c2c88a6-99d3-4a63-a201-8ee5c7dcc374"))
133///             .build(),
134///     )
135///     .await?;
136/// # Ok(()) }
137/// ```
138#[derive(Debug, Clone, Serialize, Builder)]
139#[serde(rename_all = "camelCase")]
140#[non_exhaustive]
141pub struct LabelCreateInput {
142    /// Label name (required).
143    #[builder(into)]
144    pub name: String,
145    /// Label color as a hex string like `"#BB87FC"`. Server-assigned when
146    /// omitted.
147    #[serde(skip_serializing_if = "Option::is_none")]
148    #[builder(into)]
149    pub color: Option<String>,
150    /// Optional description.
151    #[serde(skip_serializing_if = "Option::is_none")]
152    #[builder(into)]
153    pub description: Option<String>,
154    /// Owning team. `None` creates a **workspace-level** label.
155    #[serde(skip_serializing_if = "Option::is_none")]
156    #[builder(into)]
157    pub team_id: Option<TeamId>,
158    /// Parent group to nest the label under.
159    #[serde(skip_serializing_if = "Option::is_none")]
160    #[builder(into)]
161    pub parent_id: Option<LabelId>,
162    /// Whether the label is a group.
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub is_group: Option<bool>,
165}
166
167/// Input for [`LabelsService::update`] (`IssueLabelUpdateInput`).
168///
169/// `description` and `parent_id` are clearable: [`Undefinable::Undefined`]
170/// (the default) leaves the field unchanged, [`Undefinable::Null`] clears it,
171/// and a value sets it.
172///
173/// # Example
174///
175/// ```no_run
176/// # async fn run() -> linear_api::Result<()> {
177/// use linear_api::{LabelId, Undefinable};
178/// use linear_api::labels::LabelUpdateInput;
179///
180/// let client = linear_api::LinearClient::from_env()?;
181/// let label = client
182///     .labels()
183///     .update(
184///         &LabelId::new("3d5a8ea4-9e4b-4caf-8d6f-1d914a518790"),
185///         LabelUpdateInput::builder()
186///             .name("triage")
187///             .description(Undefinable::Null) // clear the description
188///             .build(),
189///     )
190///     .await?;
191/// # Ok(()) }
192/// ```
193#[derive(Debug, Clone, Default, Serialize, Builder)]
194#[serde(rename_all = "camelCase")]
195#[non_exhaustive]
196pub struct LabelUpdateInput {
197    /// New label name.
198    #[serde(skip_serializing_if = "Option::is_none")]
199    #[builder(into)]
200    pub name: Option<String>,
201    /// New color as a hex string like `"#BB87FC"`.
202    #[serde(skip_serializing_if = "Option::is_none")]
203    #[builder(into)]
204    pub color: Option<String>,
205    /// Description: set, clear (`Null`), or leave unchanged (`Undefined`).
206    #[serde(skip_serializing_if = "Undefinable::is_undefined", default)]
207    #[builder(default, into)]
208    pub description: Undefinable<String>,
209    /// Parent group: set, clear (`Null`), or leave unchanged (`Undefined`).
210    #[serde(skip_serializing_if = "Undefinable::is_undefined", default)]
211    #[builder(default, into)]
212    pub parent_id: Undefinable<LabelId>,
213}
214
215/// The `IssueLabelPayload` shape shared by the create and update mutations.
216#[derive(Deserialize)]
217#[serde(rename_all = "camelCase")]
218struct LabelPayload {
219    success: bool,
220    issue_label: Option<Label>,
221}
222
223fn unwrap_payload(operation: &'static str, payload: LabelPayload) -> Result<Label> {
224    ensure_success(operation, payload.success)?;
225    payload.issue_label.ok_or(Error::MissingData { operation })
226}
227
228/// `true` for the create-failure shapes a concurrent duplicate produces: an
229/// [`Error::Api`] mentioning a duplicate or typed `InvalidInput`.
230fn create_conflict(err: &Error) -> bool {
231    let Error::Api { errors, .. } = err else {
232        return false;
233    };
234    errors
235        .iter()
236        .any(|e| e.message.to_ascii_lowercase().contains("duplicate"))
237        || err.error_types().contains(&LinearErrorType::InvalidInput)
238}
239
240/// Issue label operations. Obtain via [`LinearClient::labels`].
241#[derive(Clone, Copy)]
242pub struct LabelsService<'a> {
243    client: &'a LinearClient,
244}
245
246impl LinearClient {
247    /// Issue label operations: listing, CRUD, and
248    /// [find-or-create](LabelsService::ensure).
249    ///
250    /// # Example
251    ///
252    /// ```no_run
253    /// # async fn run() -> linear_api::Result<()> {
254    /// let client = linear_api::LinearClient::from_env()?;
255    /// let labels = client.labels().list(Default::default()).await?;
256    /// # Ok(()) }
257    /// ```
258    pub fn labels(&self) -> LabelsService<'_> {
259        LabelsService { client: self }
260    }
261}
262
263impl<'a> LabelsService<'a> {
264    /// Fetches one page of issue labels (`issueLabels`).
265    ///
266    /// # Example
267    ///
268    /// ```no_run
269    /// # async fn run() -> linear_api::Result<()> {
270    /// use linear_api::labels::ListLabelsRequest;
271    ///
272    /// let client = linear_api::LinearClient::from_env()?;
273    /// let page = client
274    ///     .labels()
275    ///     .list(ListLabelsRequest::builder().first(25).build())
276    ///     .await?;
277    /// for label in &page.nodes {
278    ///     println!("{} ({})", label.name, label.color);
279    /// }
280    /// # Ok(()) }
281    /// ```
282    pub async fn list(&self, req: ListLabelsRequest) -> Result<Page<Label>> {
283        #[derive(Deserialize)]
284        #[serde(rename_all = "camelCase")]
285        struct Data {
286            issue_labels: Connection,
287        }
288        #[derive(Deserialize)]
289        #[serde(rename_all = "camelCase")]
290        struct Connection {
291            nodes: Vec<Label>,
292            page_info: PageInfo,
293        }
294        let data: Data = self.client.query("LabelList", LABEL_LIST, req).await?;
295        Ok(Page {
296            nodes: data.issue_labels.nodes,
297            page_info: data.issue_labels.page_info,
298        })
299    }
300
301    /// Lazily streams every label matching the request across pages,
302    /// starting from `req.after` when set (see [`paginate`] for the
303    /// complexity note).
304    ///
305    /// # Example
306    ///
307    /// ```no_run
308    /// # async fn run() -> linear_api::Result<()> {
309    /// use futures::TryStreamExt;
310    ///
311    /// let client = linear_api::LinearClient::from_env()?;
312    /// let labels = client.labels();
313    /// let mut stream = labels.list_stream(Default::default());
314    /// futures::pin_mut!(stream);
315    /// while let Some(label) = stream.try_next().await? {
316    ///     println!("{}", label.name);
317    /// }
318    /// # Ok(()) }
319    /// ```
320    pub fn list_stream(&self, req: ListLabelsRequest) -> impl Stream<Item = Result<Label>> + 'a {
321        let service = *self;
322        paginate(move |cursor| {
323            let mut req = req.clone();
324            // The first call keeps a caller-seeded `req.after`; later calls
325            // advance to each page's end cursor.
326            if cursor.is_some() {
327                req.after = cursor;
328            }
329            async move { service.list(req).await }
330        })
331    }
332
333    /// Creates a label (`issueLabelCreate`).
334    ///
335    /// # Example
336    ///
337    /// ```no_run
338    /// # async fn run() -> linear_api::Result<()> {
339    /// use linear_api::labels::LabelCreateInput;
340    ///
341    /// let client = linear_api::LinearClient::from_env()?;
342    /// // No `team_id`: a workspace-level label.
343    /// let label = client
344    ///     .labels()
345    ///     .create(LabelCreateInput::builder().name("infra").build())
346    ///     .await?;
347    /// # Ok(()) }
348    /// ```
349    pub async fn create(&self, input: LabelCreateInput) -> Result<Label> {
350        #[derive(Deserialize)]
351        #[serde(rename_all = "camelCase")]
352        struct Data {
353            issue_label_create: LabelPayload,
354        }
355        let data: Data = self
356            .client
357            .mutation(
358                "LabelCreate",
359                LABEL_CREATE,
360                serde_json::json!({ "input": input }),
361            )
362            .await?;
363        unwrap_payload("LabelCreate", data.issue_label_create)
364    }
365
366    /// Updates a label (`issueLabelUpdate`).
367    ///
368    /// # Example
369    ///
370    /// ```no_run
371    /// # async fn run() -> linear_api::Result<()> {
372    /// use linear_api::{LabelId, Undefinable};
373    /// use linear_api::labels::LabelUpdateInput;
374    ///
375    /// let client = linear_api::LinearClient::from_env()?;
376    /// let label = client
377    ///     .labels()
378    ///     .update(
379    ///         &LabelId::new("3d5a8ea4-9e4b-4caf-8d6f-1d914a518790"),
380    ///         LabelUpdateInput::builder().color("#26B5CE").build(),
381    ///     )
382    ///     .await?;
383    /// # Ok(()) }
384    /// ```
385    pub async fn update(&self, id: &LabelId, input: LabelUpdateInput) -> Result<Label> {
386        #[derive(Deserialize)]
387        #[serde(rename_all = "camelCase")]
388        struct Data {
389            issue_label_update: LabelPayload,
390        }
391        let data: Data = self
392            .client
393            .mutation(
394                "LabelUpdate",
395                LABEL_UPDATE,
396                serde_json::json!({ "id": id, "input": input }),
397            )
398            .await?;
399        unwrap_payload("LabelUpdate", data.issue_label_update)
400    }
401
402    /// Deletes a label (`issueLabelDelete`).
403    ///
404    /// # Example
405    ///
406    /// ```no_run
407    /// # async fn run() -> linear_api::Result<()> {
408    /// use linear_api::LabelId;
409    ///
410    /// let client = linear_api::LinearClient::from_env()?;
411    /// client
412    ///     .labels()
413    ///     .delete(&LabelId::new("3d5a8ea4-9e4b-4caf-8d6f-1d914a518790"))
414    ///     .await?;
415    /// # Ok(()) }
416    /// ```
417    pub async fn delete(&self, id: &LabelId) -> Result<()> {
418        #[derive(Deserialize)]
419        #[serde(rename_all = "camelCase")]
420        struct Data {
421            issue_label_delete: Payload,
422        }
423        #[derive(Deserialize)]
424        struct Payload {
425            success: bool,
426        }
427        let data: Data = self
428            .client
429            .mutation("LabelDelete", LABEL_DELETE, serde_json::json!({ "id": id }))
430            .await?;
431        ensure_success("LabelDelete", data.issue_label_delete.success)
432    }
433
434    /// Finds a label by name (case-insensitive) or creates it — the
435    /// find-or-create primitive behind "labels found or created on the team"
436    /// in plan-apply flows.
437    ///
438    /// With `team == Some(_)` the lookup is scoped to that team and a created
439    /// label is team-scoped. With `team == None` the lookup is
440    /// workspace-wide: a workspace-level match (label with no team) is
441    /// preferred, any exact-name match is accepted otherwise, and a created
442    /// label is workspace-level.
443    ///
444    /// # Race tolerance
445    ///
446    /// Linear has no idempotency keys, so two concurrent `ensure` calls can
447    /// both miss the lookup and race the create. When the create fails with
448    /// a duplicate-name/`InvalidInput` API error, the lookup is re-run once
449    /// and the winner's label is returned; if that re-lookup still finds
450    /// nothing, the original create error is surfaced.
451    ///
452    /// # Example
453    ///
454    /// ```no_run
455    /// # async fn run() -> linear_api::Result<()> {
456    /// use linear_api::TeamId;
457    ///
458    /// let client = linear_api::LinearClient::from_env()?;
459    /// let team = TeamId::new("9c2c88a6-99d3-4a63-a201-8ee5c7dcc374");
460    /// let label = client.labels().ensure(Some(&team), "bug").await?;
461    /// # Ok(()) }
462    /// ```
463    pub async fn ensure(&self, team: Option<&TeamId>, name: &str) -> Result<Label> {
464        if let Some(label) = self.find_by_name(team, name).await? {
465            return Ok(label);
466        }
467        let input = LabelCreateInput {
468            name: name.to_owned(),
469            color: None,
470            description: None,
471            team_id: team.cloned(),
472            parent_id: None,
473            is_group: None,
474        };
475        match self.create(input).await {
476            Ok(label) => Ok(label),
477            Err(err) if create_conflict(&err) => match self.find_by_name(team, name).await? {
478                Some(label) => Ok(label),
479                None => Err(err),
480            },
481            Err(err) => Err(err),
482        }
483    }
484
485    /// Looks up a label by exact name, case-insensitively. Scoped to `team`
486    /// when given; workspace-wide otherwise, preferring a workspace-level
487    /// (team-less) match over team-scoped ones.
488    async fn find_by_name(&self, team: Option<&TeamId>, name: &str) -> Result<Option<Label>> {
489        let filter = IssueLabelFilter {
490            name: Some(StringComparator {
491                eq_ignore_case: Some(name.to_owned()),
492                ..Default::default()
493            }),
494            team: team.map(|team| TeamFilter {
495                id: Some(IdComparator {
496                    eq: Some(team.to_string()),
497                    ..Default::default()
498                }),
499                ..Default::default()
500            }),
501            ..Default::default()
502        };
503        let page = self
504            .list(ListLabelsRequest {
505                filter: Some(filter),
506                first: None,
507                after: None,
508            })
509            .await?;
510        let mut team_scoped_match = None;
511        for label in page.nodes {
512            // The server filter is already exact-and-case-insensitive; this
513            // guard keeps the invariant even if the filter loosens.
514            if !label.name.eq_ignore_ascii_case(name) {
515                continue;
516            }
517            if team.is_some() || label.team.is_none() {
518                return Ok(Some(label));
519            }
520            if team_scoped_match.is_none() {
521                team_scoped_match = Some(label);
522            }
523        }
524        Ok(team_scoped_match)
525    }
526}