Skip to main content

jira_cli/api/client/
hierarchy.rs

1//! Parent and epic membership as read back from issues.
2
3use super::metadata::EPIC_LINK_SCHEMA;
4use super::{ApiError, JiraClient};
5use crate::api::types::Issue;
6use std::sync::atomic::Ordering;
7
8impl JiraClient {
9    /// Fill in `Issue::epic` on later issue reads.
10    ///
11    /// Off by default: on Data Center it costs a field lookup and can fail on
12    /// conflicting Epic Link values, which must not break commands that never
13    /// report the epic.
14    pub fn enable_epic_lookup(&self) {
15        self.epic_lookup.store(true, Ordering::Relaxed);
16    }
17
18    /// The fields to request for an issue read, plus the Data Center Epic Link
19    /// fields when the instance has any.
20    pub(super) async fn issue_fields(&self, base: &[&str]) -> Result<Vec<String>, ApiError> {
21        let mut fields: Vec<String> = base.iter().map(|f| (*f).to_owned()).collect();
22        fields.extend(self.epic_link_fields().await?.iter().cloned());
23        Ok(fields)
24    }
25
26    /// Resolve the Data Center Epic Link fields once per client.
27    ///
28    /// Cloud expresses epic membership through `parent`, so no lookup happens
29    /// there. Only the Jira Software schema identifies the field: a custom
30    /// field that merely carries the name "Epic Link" is not epic membership.
31    /// An instance without Jira Software has no such field, and so no epics.
32    async fn epic_link_fields(&self) -> Result<&[String], ApiError> {
33        if self.api_version >= 3 || !self.epic_lookup.load(Ordering::Relaxed) {
34            return Ok(&[]);
35        }
36        let fields = self
37            .epic_link_fields
38            .get_or_try_init(|| async {
39                Ok::<_, ApiError>(
40                    self.list_fields()
41                        .await?
42                        .into_iter()
43                        .filter(|f| {
44                            f.schema
45                                .as_ref()
46                                .is_some_and(|s| s.custom.as_deref() == Some(EPIC_LINK_SCHEMA))
47                        })
48                        .map(|f| f.id)
49                        .collect(),
50                )
51            })
52            .await?;
53        Ok(fields)
54    }
55
56    /// Set `Issue::epic` on each fetched issue.
57    pub(super) async fn fill_epics(&self, issues: &mut [Issue]) -> Result<(), ApiError> {
58        if !self.epic_lookup.load(Ordering::Relaxed) {
59            return Ok(());
60        }
61        if self.api_version >= 3 {
62            for issue in issues {
63                issue.epic = cloud_epic(issue);
64            }
65            return Ok(());
66        }
67        let fields = self.epic_link_fields().await?;
68        for issue in issues {
69            issue.epic = data_center_epic(issue, fields)?;
70        }
71        Ok(())
72    }
73}
74
75/// An instance can carry more than one Epic Link field. They agree when at
76/// most one distinct epic is set across them; anything else cannot be read as
77/// a single epic.
78fn data_center_epic(issue: &Issue, fields: &[String]) -> Result<Option<String>, ApiError> {
79    let mut epic: Option<&str> = None;
80    for field in fields {
81        let key = match issue.fields.extra.get(field) {
82            None | Some(serde_json::Value::Null) => continue,
83            Some(serde_json::Value::String(key)) => key.as_str(),
84            Some(other) => {
85                return Err(ApiError::Other(format!(
86                    "Unexpected Epic Link value on {} in {field}: {other}",
87                    issue.key
88                )));
89            }
90        };
91        match epic {
92            Some(seen) if seen != key => {
93                return Err(ApiError::Other(format!(
94                    "{} has conflicting Epic Link values in {}: {seen} and {key}",
95                    issue.key,
96                    fields.join(", ")
97                )));
98            }
99            _ => epic = Some(key),
100        }
101    }
102    Ok(epic.map(str::to_owned))
103}
104
105/// On Cloud an issue's epic is its parent when that parent sits at the epic
106/// hierarchy level. The name is consulted only when Jira omits the level.
107fn cloud_epic(issue: &Issue) -> Option<String> {
108    let parent = issue.fields.parent.as_ref()?;
109    let issue_type = parent.fields.as_ref()?.issuetype.as_ref()?;
110    let is_epic = match issue_type.hierarchy_level {
111        Some(level) => level == 1,
112        None => issue_type.name.eq_ignore_ascii_case("Epic"),
113    };
114    is_epic.then(|| parent.key.clone())
115}