use serde::{Deserialize, Serialize};
use crate::client::LinearClient;
use crate::error::{Error, Result};
use crate::ids::{IssueRef, IssueRelationId};
use crate::types::{IssueRelationType, IssueStub, ensure_success};
macro_rules! with_fragments {
($doc:literal) => {
concat!(
$doc,
" fragment RelationFields on IssueRelation",
" { id type issue { ...IssueStubFields } relatedIssue { ...IssueStubFields } }",
" fragment IssueStubFields on Issue { id identifier title }"
)
};
}
const ISSUE_RELATION_CREATE: &str = with_fragments!(
"mutation IssueRelationCreate($input: IssueRelationCreateInput!) \
{ issueRelationCreate(input: $input) { success issueRelation { ...RelationFields } } }"
);
const ISSUE_RELATION_DELETE: &str =
"mutation IssueRelationDelete($id: String!) { issueRelationDelete(id: $id) { success } }";
const ISSUE_RELATIONS_OF: &str = with_fragments!(
"query IssueRelationsOf($id: String!) \
{ issue(id: $id) \
{ relations(first: 50) { nodes { ...RelationFields } } \
inverseRelations(first: 50) { nodes { ...RelationFields } } } }"
);
pub(crate) const DOCUMENTS: &[(&str, &str)] = &[
("IssueRelationCreate", ISSUE_RELATION_CREATE),
("IssueRelationDelete", ISSUE_RELATION_DELETE),
("IssueRelationsOf", ISSUE_RELATIONS_OF),
];
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct IssueRelation {
pub id: IssueRelationId,
#[serde(rename = "type")]
pub relation_type: IssueRelationType,
pub issue: IssueStub,
pub related_issue: IssueStub,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct IssueRelations {
pub outgoing: Vec<IssueRelation>,
pub incoming: Vec<IssueRelation>,
}
impl IssueRelations {
pub fn blocks(&self) -> Vec<&IssueStub> {
self.outgoing
.iter()
.filter(|r| r.relation_type == IssueRelationType::Blocks)
.map(|r| &r.related_issue)
.collect()
}
pub fn blocked_by(&self) -> Vec<&IssueStub> {
self.incoming
.iter()
.filter(|r| r.relation_type == IssueRelationType::Blocks)
.map(|r| &r.issue)
.collect()
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct CreateVarsInput {
issue_id: String,
related_issue_id: String,
#[serde(rename = "type")]
relation_type: IssueRelationType,
}
#[derive(Clone, Copy)]
pub struct RelationsService<'a> {
client: &'a LinearClient,
}
impl LinearClient {
pub fn relations(&self) -> RelationsService<'_> {
RelationsService { client: self }
}
}
impl RelationsService<'_> {
pub async fn create(
&self,
issue: impl Into<IssueRef>,
related: impl Into<IssueRef>,
relation_type: IssueRelationType,
) -> Result<IssueRelation> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Payload {
success: bool,
issue_relation: Option<IssueRelation>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Data {
issue_relation_create: Payload,
}
let input = CreateVarsInput {
issue_id: issue.into().api_string().to_owned(),
related_issue_id: related.into().api_string().to_owned(),
relation_type,
};
let payload = self
.client
.mutation::<_, Data>(
"IssueRelationCreate",
ISSUE_RELATION_CREATE,
serde_json::json!({ "input": input }),
)
.await?
.issue_relation_create;
ensure_success("IssueRelationCreate", payload.success)?;
payload.issue_relation.ok_or(Error::MissingData {
operation: "IssueRelationCreate",
})
}
pub async fn create_blocks(
&self,
blocker: impl Into<IssueRef>,
blocked: impl Into<IssueRef>,
) -> Result<IssueRelation> {
self.create(blocker, blocked, IssueRelationType::Blocks)
.await
}
pub async fn delete(&self, id: &IssueRelationId) -> Result<()> {
#[derive(Deserialize)]
struct Payload {
success: bool,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Data {
issue_relation_delete: Payload,
}
let data = self
.client
.mutation::<_, Data>(
"IssueRelationDelete",
ISSUE_RELATION_DELETE,
serde_json::json!({ "id": id }),
)
.await?;
ensure_success("IssueRelationDelete", data.issue_relation_delete.success)
}
pub async fn of_issue(&self, issue: impl Into<IssueRef>) -> Result<IssueRelations> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct IssueNode {
#[serde(deserialize_with = "crate::types::nodes")]
relations: Vec<IssueRelation>,
#[serde(deserialize_with = "crate::types::nodes")]
inverse_relations: Vec<IssueRelation>,
}
#[derive(Deserialize)]
struct Data {
issue: IssueNode,
}
let data = self
.client
.query::<_, Data>(
"IssueRelationsOf",
ISSUE_RELATIONS_OF,
serde_json::json!({ "id": issue.into().api_string() }),
)
.await?;
Ok(IssueRelations {
outgoing: data.issue.relations,
incoming: data.issue.inverse_relations,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ids::IssueId;
#[test]
fn create_input_wire_shape() {
let input = CreateVarsInput {
issue_id: IssueRef::identifier("ENG-1").api_string().to_owned(),
related_issue_id: IssueRef::from(IssueId::new("uuid-2"))
.api_string()
.to_owned(),
relation_type: IssueRelationType::Blocks,
};
assert_eq!(
serde_json::to_value(&input).unwrap(),
serde_json::json!({
"issueId": "ENG-1",
"relatedIssueId": "uuid-2",
"type": "blocks"
})
);
}
#[test]
fn blocks_and_blocked_by_filter_by_type_and_direction() {
let stub = |identifier: &str| IssueStub {
id: IssueId::new(format!("uuid-{identifier}")),
identifier: identifier.to_owned(),
title: format!("Issue {identifier}"),
};
let edge = |relation_type: IssueRelationType, from: &str, to: &str| IssueRelation {
id: IssueRelationId::new(format!("rel-{from}-{to}")),
relation_type,
issue: stub(from),
related_issue: stub(to),
};
let relations = IssueRelations {
outgoing: vec![
edge(IssueRelationType::Blocks, "ENG-3", "ENG-4"),
edge(IssueRelationType::Related, "ENG-3", "ENG-5"),
],
incoming: vec![
edge(IssueRelationType::Blocks, "ENG-2", "ENG-3"),
edge(IssueRelationType::Duplicate, "ENG-6", "ENG-3"),
],
};
let blocks: Vec<&str> = relations
.blocks()
.iter()
.map(|s| s.identifier.as_str())
.collect();
assert_eq!(blocks, vec!["ENG-4"]);
let blocked_by: Vec<&str> = relations
.blocked_by()
.iter()
.map(|s| s.identifier.as_str())
.collect();
assert_eq!(blocked_by, vec!["ENG-2"]);
}
}