Skip to main content

ytcli/cli/
sprint.rs

1//! Sprints across the organisation.
2//!
3//! `board sprints ID` needs the board first. A sprint name is a thing people
4//! say without knowing which board it belongs to, and this is the listing that
5//! answers that — read-only, like every other view of a board.
6
7use 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    /// List every sprint in the organisation.
17    #[command(long_about = crate::cli::help::md(crate::cli::help::SPRINT_LIST))]
18    List {
19        /// Show only the sprint to plan into: the nearest draft, or the running
20        /// one when there is no draft.
21        #[arg(long)]
22        planning: bool,
23    },
24    /// Show one sprint: its dates, and how far through it is.
25    #[command(long_about = crate::cli::help::md(crate::cli::help::SPRINT_GET))]
26    Get {
27        id: String,
28        /// Skip the two counts that say how many of its issues are resolved.
29        #[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    // A filter, not a different answer: the tally still counts what was found,
56    // so `shown 1 of 9` says plainly that eight were left out.
57    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
77/// The sprint to put new work into.
78///
79/// Not the running one: work planned now belongs to the next sprint, and the
80/// running one is what people are already doing. So the nearest draft wins, and
81/// the running sprint is the answer only when no draft exists — which is the
82/// case where "plan into the current one" is genuinely what was meant.
83///
84/// Two spellings for a sprint that has not started — Tracker has answered with
85/// both `draft` and `planned` — and both are accepted rather than one being
86/// picked as the true one.
87fn 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    // Two counts, and only when they were asked for. A sprint that Tracker
139    // cannot count issues for is still a sprint worth printing: the dates are
140    // the part that was read successfully, and losing them to report a failed
141    // count would answer less than was already known.
142    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
153/// `(resolved, total)` for a sprint, or nothing if either count failed.
154async 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    /// The whole point of the flag: new work goes into the next sprint, not the
179    /// one people are in the middle of.
180    #[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    /// With nothing planned, "plan into the current one" is what was meant.
196    #[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}