Skip to main content

jira_cli/api/client/
sprints.rs

1use super::{ApiError, JiraClient, Sprint, validate_issue_key};
2use std::collections::{BTreeMap, BTreeSet};
3
4impl JiraClient {
5    /// Resolve a globally unique sprint ID, exact name, substring, or "active".
6    pub async fn resolve_sprint(&self, specifier: &str) -> Result<Sprint, ApiError> {
7        self.resolve_sprint_scoped(specifier, None, None).await
8    }
9
10    /// Names are scoped to a project's sprint-capable boards unless an explicit board
11    /// overrides that scope. Numeric sprint IDs are already globally unique;
12    /// when a board is supplied, also verify membership on that board.
13    pub async fn resolve_sprint_scoped(
14        &self,
15        specifier: &str,
16        project: Option<&str>,
17        board: Option<u64>,
18    ) -> Result<Sprint, ApiError> {
19        let specifier = specifier.trim();
20        if specifier.is_empty() || board == Some(0) {
21            return Err(ApiError::InvalidInput(
22                "Sprint must not be empty and board IDs must be positive".into(),
23            ));
24        }
25        if let Ok(id) = specifier.parse::<u64>() {
26            if id == 0 {
27                return Err(ApiError::InvalidInput("Sprint IDs must be positive".into()));
28            }
29            let sprint = self.get_sprint(id).await?;
30            if let Some(board_id) = board
31                && !self
32                    .list_sprints(board_id, None)
33                    .await?
34                    .iter()
35                    .any(|s| s.id == id)
36            {
37                return Err(ApiError::InvalidInput(format!(
38                    "Sprint {id} is not on board {board_id}"
39                )));
40            }
41            return Ok(sprint);
42        }
43
44        let board_ids = match board {
45            Some(id) => vec![id],
46            None => self
47                .list_boards_for_project(project)
48                .await?
49                .into_iter()
50                .filter(|b| b.may_support_sprints())
51                .map(|b| b.id)
52                .collect::<Vec<_>>(),
53        };
54        if board_ids.is_empty() {
55            let scope = project
56                .map(|p| format!(" for project {p}"))
57                .unwrap_or_default();
58            return Err(ApiError::NotFound(format!(
59                "No sprint-capable boards found{scope}; use --board <ID> or a numeric --sprint <ID>"
60            )));
61        }
62        let active = specifier.eq_ignore_ascii_case("active");
63        let query = specifier.to_lowercase();
64        let mut candidates: BTreeMap<u64, (Sprint, BTreeSet<u64>)> = BTreeMap::new();
65        for board_id in board_ids {
66            let state = if active { Some("active") } else { None };
67            let sprints = if board.is_none() {
68                self.list_sprints_for_discovery(board_id, state)
69                    .await?
70                    .unwrap_or_default()
71            } else {
72                self.list_sprints(board_id, state).await?
73            };
74            for sprint in sprints {
75                let matches = if active {
76                    sprint.state.eq_ignore_ascii_case("active")
77                } else {
78                    sprint.name.to_lowercase().contains(&query)
79                };
80                if matches {
81                    candidates
82                        .entry(sprint.id)
83                        .or_insert_with(|| (sprint, BTreeSet::new()))
84                        .1
85                        .insert(board_id);
86                }
87            }
88        }
89        // An exact name wins over substring matches, but duplicate exact names
90        // still require an ID. Shared sprints count once, even on multiple boards.
91        if !active
92            && candidates
93                .values()
94                .any(|(s, _)| s.name.eq_ignore_ascii_case(specifier))
95        {
96            candidates.retain(|_, (s, _)| s.name.eq_ignore_ascii_case(specifier));
97        }
98        if candidates.len() == 1 {
99            return Ok(candidates.into_values().next().expect("one candidate").0);
100        }
101        let scope = match (board, project) {
102            (Some(id), _) => format!(" on board {id}"),
103            (_, Some(p)) => format!(" for project {p}"),
104            _ => String::new(),
105        };
106        if candidates.is_empty() {
107            return Err(ApiError::NotFound(format!(
108                "No sprint found matching {specifier:?}{scope}"
109            )));
110        }
111        let choices = candidates
112            .values()
113            .map(|(s, boards)| {
114                format!(
115                    "{} ({:?}, boards {})",
116                    s.id,
117                    s.name,
118                    boards
119                        .iter()
120                        .map(u64::to_string)
121                        .collect::<Vec<_>>()
122                        .join(", ")
123                )
124            })
125            .collect::<Vec<_>>()
126            .join("; ");
127        Err(ApiError::InvalidInput(format!(
128            "Ambiguous sprint {specifier:?}{scope}; candidates: {choices}. Use --sprint <ID> or --board <ID>."
129        )))
130    }
131
132    /// Resolve the actual project even when the user supplied an old issue key.
133    pub async fn issue_project(&self, key: &str) -> Result<String, ApiError> {
134        validate_issue_key(key)?;
135        let issue: serde_json::Value = self.get(&format!("issue/{key}?fields=project")).await?;
136        issue["fields"]["project"]["key"].as_str()
137            .or_else(|| issue["fields"]["project"]["id"].as_str())
138            .filter(|p| !p.is_empty())
139            .map(str::to_owned)
140            .ok_or_else(|| ApiError::Other("Jira did not return the issue's project; supply --board <ID> or a numeric --sprint <ID>".into()))
141    }
142}