use bon::Builder;
use futures::Stream;
use serde::{Deserialize, Serialize};
use crate::client::LinearClient;
use crate::error::{Error, LinearErrorType, Result};
use crate::filter::{IdComparator, IssueLabelFilter, StringComparator, TeamFilter};
use crate::ids::{LabelId, TeamId};
use crate::pagination::{Page, PageInfo, paginate};
use crate::types::{LabelRef, TeamRef, Undefinable, ensure_success};
macro_rules! doc_with_label_fields {
($op:literal) => {
concat!(
$op,
" fragment LabelFields on IssueLabel { id name color description isGroup \
parent { ...LabelRefFields } team { ...TeamRefFields } }",
" fragment LabelRefFields on IssueLabel { id name color }",
" fragment TeamRefFields on Team { id key name }"
)
};
}
const LABEL_LIST: &str = doc_with_label_fields!(
"query LabelList($filter: IssueLabelFilter, $first: Int, $after: String) { \
issueLabels(filter: $filter, first: $first, after: $after) { \
nodes { ...LabelFields } \
pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } }"
);
const LABEL_CREATE: &str = doc_with_label_fields!(
"mutation LabelCreate($input: IssueLabelCreateInput!) { \
issueLabelCreate(input: $input) { success issueLabel { ...LabelFields } } }"
);
const LABEL_UPDATE: &str = doc_with_label_fields!(
"mutation LabelUpdate($id: String!, $input: IssueLabelUpdateInput!) { \
issueLabelUpdate(id: $id, input: $input) { success issueLabel { ...LabelFields } } }"
);
const LABEL_DELETE: &str =
"mutation LabelDelete($id: String!) { issueLabelDelete(id: $id) { success } }";
pub(crate) const DOCUMENTS: &[(&str, &str)] = &[
("LabelList", LABEL_LIST),
("LabelCreate", LABEL_CREATE),
("LabelUpdate", LABEL_UPDATE),
("LabelDelete", LABEL_DELETE),
];
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct Label {
pub id: LabelId,
pub name: String,
pub color: String,
pub description: Option<String>,
pub is_group: bool,
pub parent: Option<LabelRef>,
pub team: Option<TeamRef>,
}
#[derive(Debug, Clone, Default, Serialize, Builder)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ListLabelsRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub filter: Option<IssueLabelFilter>,
#[serde(skip_serializing_if = "Option::is_none")]
pub first: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub after: Option<String>,
}
#[derive(Debug, Clone, Serialize, Builder)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct LabelCreateInput {
#[builder(into)]
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub team_id: Option<TeamId>,
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub parent_id: Option<LabelId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_group: Option<bool>,
}
#[derive(Debug, Clone, Default, Serialize, Builder)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct LabelUpdateInput {
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub color: Option<String>,
#[serde(skip_serializing_if = "Undefinable::is_undefined", default)]
#[builder(default, into)]
pub description: Undefinable<String>,
#[serde(skip_serializing_if = "Undefinable::is_undefined", default)]
#[builder(default, into)]
pub parent_id: Undefinable<LabelId>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct LabelPayload {
success: bool,
issue_label: Option<Label>,
}
fn unwrap_payload(operation: &'static str, payload: LabelPayload) -> Result<Label> {
ensure_success(operation, payload.success)?;
payload.issue_label.ok_or(Error::MissingData { operation })
}
fn create_conflict(err: &Error) -> bool {
let Error::Api { errors, .. } = err else {
return false;
};
errors
.iter()
.any(|e| e.message.to_ascii_lowercase().contains("duplicate"))
|| err.error_types().contains(&LinearErrorType::InvalidInput)
}
#[derive(Clone, Copy)]
pub struct LabelsService<'a> {
client: &'a LinearClient,
}
impl LinearClient {
pub fn labels(&self) -> LabelsService<'_> {
LabelsService { client: self }
}
}
impl<'a> LabelsService<'a> {
pub async fn list(&self, req: ListLabelsRequest) -> Result<Page<Label>> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Data {
issue_labels: Connection,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Connection {
nodes: Vec<Label>,
page_info: PageInfo,
}
let data: Data = self.client.query("LabelList", LABEL_LIST, req).await?;
Ok(Page {
nodes: data.issue_labels.nodes,
page_info: data.issue_labels.page_info,
})
}
pub fn list_stream(&self, req: ListLabelsRequest) -> impl Stream<Item = Result<Label>> + 'a {
let service = *self;
paginate(move |cursor| {
let mut req = req.clone();
if cursor.is_some() {
req.after = cursor;
}
async move { service.list(req).await }
})
}
pub async fn create(&self, input: LabelCreateInput) -> Result<Label> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Data {
issue_label_create: LabelPayload,
}
let data: Data = self
.client
.mutation(
"LabelCreate",
LABEL_CREATE,
serde_json::json!({ "input": input }),
)
.await?;
unwrap_payload("LabelCreate", data.issue_label_create)
}
pub async fn update(&self, id: &LabelId, input: LabelUpdateInput) -> Result<Label> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Data {
issue_label_update: LabelPayload,
}
let data: Data = self
.client
.mutation(
"LabelUpdate",
LABEL_UPDATE,
serde_json::json!({ "id": id, "input": input }),
)
.await?;
unwrap_payload("LabelUpdate", data.issue_label_update)
}
pub async fn delete(&self, id: &LabelId) -> Result<()> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Data {
issue_label_delete: Payload,
}
#[derive(Deserialize)]
struct Payload {
success: bool,
}
let data: Data = self
.client
.mutation("LabelDelete", LABEL_DELETE, serde_json::json!({ "id": id }))
.await?;
ensure_success("LabelDelete", data.issue_label_delete.success)
}
pub async fn ensure(&self, team: Option<&TeamId>, name: &str) -> Result<Label> {
if let Some(label) = self.find_by_name(team, name).await? {
return Ok(label);
}
let input = LabelCreateInput {
name: name.to_owned(),
color: None,
description: None,
team_id: team.cloned(),
parent_id: None,
is_group: None,
};
match self.create(input).await {
Ok(label) => Ok(label),
Err(err) if create_conflict(&err) => match self.find_by_name(team, name).await? {
Some(label) => Ok(label),
None => Err(err),
},
Err(err) => Err(err),
}
}
async fn find_by_name(&self, team: Option<&TeamId>, name: &str) -> Result<Option<Label>> {
let filter = IssueLabelFilter {
name: Some(StringComparator {
eq_ignore_case: Some(name.to_owned()),
..Default::default()
}),
team: team.map(|team| TeamFilter {
id: Some(IdComparator {
eq: Some(team.to_string()),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let page = self
.list(ListLabelsRequest {
filter: Some(filter),
first: None,
after: None,
})
.await?;
let mut team_scoped_match = None;
for label in page.nodes {
if !label.name.eq_ignore_ascii_case(name) {
continue;
}
if team.is_some() || label.team.is_none() {
return Ok(Some(label));
}
if team_scoped_match.is_none() {
team_scoped_match = Some(label);
}
}
Ok(team_scoped_match)
}
}