jira_cli/api/client/
sprints.rs1use super::{ApiError, JiraClient, Sprint, validate_issue_key};
2use std::collections::{BTreeMap, BTreeSet};
3
4impl JiraClient {
5 pub async fn resolve_sprint(&self, specifier: &str) -> Result<Sprint, ApiError> {
7 self.resolve_sprint_scoped(specifier, None, None).await
8 }
9
10 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 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 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}