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}