1use clap::Subcommand;
8
9use crate::api::Sprint;
10use crate::cli::{Session, emit, report};
11use crate::exit::ExitCode;
12use crate::render::{Format, board as render, machine};
13
14#[derive(Debug, Subcommand)]
15pub enum SprintCommand {
16 #[command(long_about = crate::cli::help::md(crate::cli::help::SPRINT_LIST))]
18 List {
19 #[arg(long)]
22 planning: bool,
23 },
24 #[command(long_about = crate::cli::help::md(crate::cli::help::SPRINT_GET))]
26 Get {
27 id: String,
28 #[arg(long)]
30 no_issues: bool,
31 },
32}
33
34pub async fn run(command: &SprintCommand, session: &Session) -> ExitCode {
35 let client = match session.client() {
36 Ok(client) => client,
37 Err(code) => return code,
38 };
39
40 match command {
41 SprintCommand::List { planning } => list(&client, *planning, session).await,
42 SprintCommand::Get { id, no_issues } => get(&client, id, *no_issues, session).await,
43 }
44}
45
46async fn list(client: &crate::api::Client, planning: bool, session: &Session) -> ExitCode {
47 let sprints = match client.all_sprints().await {
48 Ok(sprints) => sprints,
49 Err(error) => {
50 let code = error.exit_code();
51 return report(&error, code);
52 }
53 };
54
55 let shown: Vec<Sprint> = if planning {
58 planning_sprint(&sprints).cloned().into_iter().collect()
59 } else {
60 sprints.clone()
61 };
62
63 let rendered = match session.render.format {
64 Format::Text => Ok(render::all_sprints(&shown, &session.render)),
65 Format::JsonRaw => machine(&shown, Format::Json),
66 other => machine(&shown, other),
67 };
68 match rendered {
69 Ok(text) => {
70 emit(&text);
71 ExitCode::Success
72 }
73 Err(error) => report(&error, ExitCode::Failure),
74 }
75}
76
77fn planning_sprint(sprints: &[Sprint]) -> Option<&Sprint> {
88 let live = |sprint: &&Sprint| {
89 !matches!(
90 sprint.status.as_deref(),
91 Some("archived" | "closed" | "completed")
92 )
93 };
94 let by_start = |sprint: &&Sprint| sprint.start.clone().unwrap_or_else(|| "9999".to_owned());
95
96 sprints
97 .iter()
98 .filter(live)
99 .filter(|sprint| matches!(sprint.status.as_deref(), Some("draft" | "planned")))
100 .min_by_key(by_start)
101 .or_else(|| {
102 sprints
103 .iter()
104 .filter(live)
105 .filter(|sprint| sprint.status.as_deref() == Some("in_progress"))
106 .min_by_key(by_start)
107 })
108}
109
110async fn get(
111 client: &crate::api::Client,
112 id: &str,
113 no_issues: bool,
114 session: &Session,
115) -> ExitCode {
116 let sprint = match client.sprint(id).await {
117 Ok(sprint) => sprint,
118 Err(error) => {
119 let code = error.exit_code();
120 return report(&error, code);
121 }
122 };
123
124 if session.render.format != Format::Text {
125 let format = match session.render.format {
126 Format::JsonRaw => Format::Json,
127 other => other,
128 };
129 return match machine(&sprint, format) {
130 Ok(text) => {
131 emit(&text);
132 ExitCode::Success
133 }
134 Err(error) => report(&error, ExitCode::Failure),
135 };
136 }
137
138 let counts = if no_issues {
143 None
144 } else {
145 issue_counts(client, id).await
146 };
147
148 let today = jiff::Zoned::now().strftime("%Y-%m-%d").to_string();
149 emit(&render::sprint(&sprint, counts, &today, &session.render));
150 ExitCode::Success
151}
152
153async fn issue_counts(client: &crate::api::Client, id: &str) -> Option<(u64, u64)> {
155 let total = client.count(&format!("Sprint: {id}")).await.ok()?;
156 let open = client
157 .count(&format!("Sprint: {id} AND Resolution: empty()"))
158 .await
159 .ok()?;
160 Some((total.saturating_sub(open), total))
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 fn sprint(id: &str, status: &str, start: &str) -> Sprint {
168 Sprint {
169 id: id.to_owned(),
170 name: format!("Sprint {id}"),
171 status: Some(status.to_owned()),
172 start: Some(start.to_owned()),
173 end: None,
174 board: None,
175 }
176 }
177
178 #[test]
181 fn a_draft_beats_the_running_sprint() {
182 let sprints = [
183 sprint("1", "in_progress", "2026-08-01"),
184 sprint("2", "planned", "2026-08-15"),
185 sprint("3", "draft", "2026-09-01"),
186 ];
187
188 assert_eq!(
189 planning_sprint(&sprints).map(|sprint| sprint.id.as_str()),
190 Some("2"),
191 "the nearest draft, not the furthest"
192 );
193 }
194
195 #[test]
197 fn without_a_draft_the_running_sprint_is_the_answer() {
198 let sprints = [
199 sprint("1", "archived", "2026-07-01"),
200 sprint("2", "in_progress", "2026-08-01"),
201 ];
202
203 assert_eq!(
204 planning_sprint(&sprints).map(|sprint| sprint.id.as_str()),
205 Some("2")
206 );
207 }
208
209 #[test]
210 fn a_board_with_nothing_live_has_no_answer() {
211 let sprints = [sprint("1", "archived", "2026-07-01")];
212 assert!(planning_sprint(&sprints).is_none());
213 }
214}