Skip to main content

yt/api/
links.rs

1use serde::Deserialize;
2
3use crate::api::custom_fields::IssueCustomField;
4use crate::api::{Client, Projection};
5use crate::error::Result;
6use crate::fields::{choices, closest, same, usage};
7
8#[derive(Clone, Debug, Deserialize)]
9#[serde(rename_all = "camelCase")]
10pub struct LinkType {
11    pub name: String,
12    pub source_to_target: Option<String>,
13    pub target_to_source: Option<String>,
14    #[serde(default)]
15    pub directed: bool,
16}
17
18impl Projection for LinkType {
19    const FIELDS: &'static str = "name,sourceToTarget,targetToSource,directed";
20}
21
22#[derive(Clone, Debug, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct IssueLink {
25    pub direction: String,
26    pub link_type: LinkType,
27    #[serde(default)]
28    pub issues: Vec<LinkedIssue>,
29}
30
31#[derive(Clone, Debug, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct LinkedIssue {
34    pub id_readable: String,
35    pub summary: Option<String>,
36    #[serde(default)]
37    pub custom_fields: Vec<IssueCustomField>,
38}
39
40impl Projection for IssueLink {
41    const FIELDS: &'static str = "direction,linkType(name,sourceToTarget,targetToSource,directed),issues(idReadable,summary,customFields(name,$type,projectCustomField(field(fieldType(id))),value(name,login,fullName,isResolved,minutes,presentation,text,$type)))";
42}
43
44impl IssueLink {
45    pub fn inward(&self) -> bool {
46        self.direction == "INWARD"
47    }
48
49    /// How the link reads from this issue (`depends on`, `is required for`).
50    pub fn word(&self) -> &str {
51        let word = if self.inward() {
52            &self.link_type.target_to_source
53        } else {
54            &self.link_type.source_to_target
55        };
56        word.as_deref().unwrap_or(&self.link_type.name)
57    }
58}
59
60pub async fn types(client: &Client) -> Result<Vec<LinkType>> {
61    client.get("issueLinkTypes", &[("$top", "500")]).await
62}
63
64pub async fn list(client: &Client, issue: &str) -> Result<Vec<IssueLink>> {
65    client.get_all(&format!("issues/{issue}/links")).await
66}
67
68/// The instance's spelling of a link's direction words.
69pub fn resolve_word(types: &[LinkType], word: &str) -> Result<String> {
70    let mut words: Vec<String> = Vec::new();
71    for link_type in types {
72        for candidate in [&link_type.source_to_target, &link_type.target_to_source]
73            .into_iter()
74            .flatten()
75        {
76            if !words.contains(candidate) {
77                words.push(candidate.clone());
78            }
79        }
80    }
81    if let Some(found) = words.iter().find(|candidate| same(candidate, word)) {
82        return Ok(found.clone());
83    }
84    let mut message = format!("'{word}' is not a link type; use {}", choices(&words));
85    if let Some(close) = closest(word, words.iter().map(String::as_str)) {
86        message.push_str(&format!(" (did you mean '{close}'?)"));
87    }
88    Err(usage(message))
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    fn link_types() -> Vec<LinkType> {
96        vec![
97            LinkType {
98                name: "Depend".into(),
99                source_to_target: Some("depends on".into()),
100                target_to_source: Some("is required for".into()),
101                directed: true,
102            },
103            LinkType {
104                name: "Relates".into(),
105                source_to_target: Some("relates to".into()),
106                target_to_source: Some("relates to".into()),
107                directed: false,
108            },
109        ]
110    }
111
112    #[test]
113    fn direction_words_take_the_instance_spelling() {
114        assert_eq!(
115            resolve_word(&link_types(), "Depends On").unwrap(),
116            "depends on"
117        );
118        assert_eq!(
119            resolve_word(&link_types(), "is required for").unwrap(),
120            "is required for"
121        );
122        let error = resolve_word(&link_types(), "depend on").unwrap_err();
123        assert!(
124            error.to_string().contains("did you mean 'depends on'"),
125            "{error}"
126        );
127    }
128
129    #[test]
130    fn a_link_reads_in_its_direction() {
131        let link = IssueLink {
132            direction: "INWARD".into(),
133            link_type: link_types().remove(0),
134            issues: vec![],
135        };
136        assert_eq!(link.word(), "is required for");
137        assert!(link.inward());
138    }
139}