1use super::SprintCloseAction;
4use crate::backlog::{BacklogItem, ItemId};
5use crate::error::{Error, Result};
6use crate::service::open_board_locked;
7use crate::sprint::{Sprint, SprintId, SprintSpillover, SprintState};
8use crate::storage::{Backend, BacklogItemRepository, SprintRepository};
9use chrono::{DateTime, Utc};
10use rayon::prelude::*;
11use std::path::Path;
12
13pub async fn create_sprint(
22 project_dir: &Path,
23 id: &SprintId,
24 title: &str,
25 goal: Option<String>,
26 period: Option<(DateTime<Utc>, DateTime<Utc>)>,
27) -> Result<Sprint> {
28 if let Some((start, end)) = period
30 && start > end
31 {
32 return Err(Error::InvalidSprintPeriod {
33 start: start.date_naive(),
34 end: end.date_naive(),
35 });
36 }
37
38 let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
39
40 match SprintRepository::load(&repo, id).await {
42 Ok(_) => return Err(Error::SprintExists(id.clone())),
43 Err(Error::SprintNotFound(_)) => {}
44 Err(e) => return Err(e),
45 }
46
47 let mut sprint = Sprint::new(id.clone(), title, Utc::now())?;
48 if let Some(goal) = goal {
49 sprint.goal = goal;
50 }
51 if let Some((start, end)) = period {
52 sprint.start = Some(start);
53 sprint.end = Some(end);
54 }
55 SprintRepository::save(&repo, &sprint).await?;
56 repo.commit(&format!("pinto: add {}", sprint.id)).await?;
57 Ok(sprint)
58}
59
60pub async fn edit_sprint(
66 project_dir: &Path,
67 id: &SprintId,
68 title: Option<String>,
69 goal: Option<String>,
70 period: Option<(DateTime<Utc>, DateTime<Utc>)>,
71) -> Result<Sprint> {
72 let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
73 let mut sprint = SprintRepository::load(&repo, id).await?;
74 sprint.update_details(title, goal, period, Utc::now())?;
75 SprintRepository::save(&repo, &sprint).await?;
76 repo.commit(&format!("pinto: update {}", sprint.id)).await?;
77 Ok(sprint)
78}
79
80pub async fn delete_sprint(project_dir: &Path, id: &SprintId) -> Result<()> {
85 let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
86 SprintRepository::load(&repo, id).await?;
87
88 let now = Utc::now();
89 let assigned = BacklogItemRepository::list(&repo)
90 .await?
91 .into_iter()
92 .filter(|item| item.sprint.as_deref() == Some(id.as_str()))
93 .collect::<Vec<_>>();
94 for mut item in assigned {
95 item.sprint = None;
96 item.updated = now;
97 BacklogItemRepository::save(&repo, &item).await?;
98 }
99
100 SprintRepository::delete(&repo, id).await?;
101 repo.commit(&format!("pinto: delete {id}")).await?;
102 Ok(())
103}
104
105pub async fn start_sprint(project_dir: &Path, id: &SprintId) -> Result<Sprint> {
111 transition_sprint(project_dir, id, Sprint::start).await
112}
113
114pub async fn close_sprint(
121 project_dir: &Path,
122 id: &SprintId,
123 action: SprintCloseAction,
124) -> Result<Sprint> {
125 let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
126 let original_sprint = SprintRepository::load(&repo, id).await?;
127 if original_sprint.state != SprintState::Active {
128 return Err(Error::InvalidSprintTransition {
129 from: original_sprint.state,
130 to: SprintState::Closed,
131 });
132 }
133
134 if let SprintCloseAction::Rollover(target) = &action {
135 if target == id {
136 return Err(Error::InvalidFilterOption(
137 "a sprint cannot roll unfinished PBIs over to itself".to_string(),
138 ));
139 }
140 validate_sprint_assignment(&repo, target.as_str()).await?;
141 }
142
143 let original_items = BacklogItemRepository::list(&repo)
144 .await?
145 .into_par_iter()
146 .filter(|item| item.sprint.as_deref() == Some(id.as_str()) && item.done_at.is_none())
147 .collect::<Vec<_>>();
148 let spillover = original_items
149 .par_iter()
150 .map(|item| SprintSpillover {
151 points: item.points.unwrap_or(0),
152 items: 1,
153 unestimated_items: u32::from(item.points.is_none()),
154 })
155 .reduce(SprintSpillover::default, |left, right| SprintSpillover {
156 points: left.points.saturating_add(right.points),
157 items: left.items.saturating_add(right.items),
158 unestimated_items: left
159 .unestimated_items
160 .saturating_add(right.unestimated_items),
161 });
162
163 let now = Utc::now();
164 let mut sprint = original_sprint.clone();
165 sprint.close(now, spillover)?;
166 let mut updated_items = if action == SprintCloseAction::Retain {
167 Vec::new()
168 } else {
169 original_items.clone()
170 };
171 for item in &mut updated_items {
172 match &action {
173 SprintCloseAction::Retain => {}
174 SprintCloseAction::Rollover(target) => item.sprint = Some(target.to_string()),
175 SprintCloseAction::Release => item.sprint = None,
176 }
177 item.updated = now;
178 }
179
180 for (index, item) in updated_items.iter().enumerate() {
181 if let Err(error) = BacklogItemRepository::save(&repo, item).await {
182 rollback_sprint_close(&repo, &original_sprint, &original_items[..=index], &error)
183 .await?;
184 return Err(error);
185 }
186 }
187 if let Err(error) = SprintRepository::save(&repo, &sprint).await {
188 rollback_sprint_close(&repo, &original_sprint, &original_items, &error).await?;
189 return Err(error);
190 }
191 repo.commit(&format!("pinto: update {}", sprint.id)).await?;
194 Ok(sprint)
195}
196
197async fn transition_sprint(
202 project_dir: &Path,
203 id: &SprintId,
204 transition: impl FnOnce(&mut Sprint, chrono::DateTime<Utc>) -> Result<()>,
205) -> Result<Sprint> {
206 let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
207 let mut sprint = SprintRepository::load(&repo, id).await?;
208 transition(&mut sprint, Utc::now())?;
209 SprintRepository::save(&repo, &sprint).await?;
210 repo.commit(&format!("pinto: update {}", sprint.id)).await?;
211 Ok(sprint)
212}
213
214async fn rollback_sprint_close(
216 repo: &Backend,
217 original_sprint: &Sprint,
218 original_items: &[BacklogItem],
219 operation_error: &Error,
220) -> Result<()> {
221 for item in original_items.iter().rev() {
222 if let Err(rollback_error) = BacklogItemRepository::save(repo, item).await {
223 return Err(Error::task(format!(
224 "{operation_error}; failed to roll back sprint close: {rollback_error}"
225 )));
226 }
227 }
228 if let Err(rollback_error) = SprintRepository::save(repo, original_sprint).await {
229 return Err(Error::task(format!(
230 "{operation_error}; failed to roll back sprint close: {rollback_error}"
231 )));
232 }
233 Ok(())
234}
235
236pub(crate) async fn validate_sprint_assignment(repo: &Backend, raw: &str) -> Result<SprintId> {
238 let id = SprintId::new(raw)?;
239 let sprint = SprintRepository::load(repo, &id).await?;
240 if sprint.state == SprintState::Closed {
241 return Err(Error::SprintClosed(id));
242 }
243 Ok(id)
244}
245
246pub async fn assign_sprint(
254 project_dir: &Path,
255 sprint_id: &SprintId,
256 item_id: &ItemId,
257) -> Result<BacklogItem> {
258 assign_sprint_raw(project_dir, sprint_id.as_str(), item_id).await
259}
260
261pub async fn assign_sprint_raw(
263 project_dir: &Path,
264 raw_sprint_id: &str,
265 item_id: &ItemId,
266) -> Result<BacklogItem> {
267 let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
268 let sprint_id = validate_sprint_assignment(&repo, raw_sprint_id).await?;
269 let mut item = BacklogItemRepository::load(&repo, item_id).await?;
270 item.sprint = Some(sprint_id.to_string());
271 item.updated = Utc::now();
272 BacklogItemRepository::save(&repo, &item).await?;
273 repo.commit(&format!("pinto: update {}", item.id)).await?;
274 Ok(item)
275}
276
277pub async fn assign_sprint_by_status(
285 project_dir: &Path,
286 sprint_id: &SprintId,
287 status: &str,
288 limit: Option<usize>,
289) -> Result<Vec<BacklogItem>> {
290 let (_board_dir, repo, config, _lock) = open_board_locked(project_dir).await?;
291
292 if !config.columns.iter().any(|column| column == status) {
293 return Err(Error::UnknownStatus(status.to_string()));
294 }
295 if limit == Some(0) {
296 return Err(Error::InvalidFilterOption(
297 "--limit must be at least 1".to_string(),
298 ));
299 }
300 validate_sprint_assignment(&repo, sprint_id.as_str()).await?;
301
302 let mut candidates = BacklogItemRepository::list(&repo)
305 .await?
306 .into_iter()
307 .filter(|item| item.status.as_str() == status)
308 .filter(|item| item.sprint.as_deref() != Some(sprint_id.as_str()))
309 .collect::<Vec<_>>();
310 if let Some(limit) = limit {
311 candidates.truncate(limit);
312 }
313
314 if let Some(item) = candidates.iter().find(|item| item.sprint.is_some())
317 && let Some(assigned_sprint) = item.sprint.as_deref()
318 {
319 return Err(Error::InvalidFilterOption(format!(
320 "{} is already assigned to sprint {}; remove it before bulk assignment",
321 item.id, assigned_sprint
322 )));
323 }
324
325 let original = candidates.clone();
326 let now = Utc::now();
327 let mut assigned = Vec::with_capacity(candidates.len());
328 for (index, mut item) in candidates.into_iter().enumerate() {
329 item.sprint = Some(sprint_id.to_string());
330 item.updated = now;
331 if let Err(error) = BacklogItemRepository::save(&repo, &item).await {
332 rollback_bulk_assignment(&repo, &original[..=index], &error).await?;
333 return Err(error);
334 }
335 assigned.push(item);
336 }
337 if !assigned.is_empty()
338 && let Err(error) = repo
339 .commit(&format!(
340 "pinto: assign {} item(s) to {}",
341 assigned.len(),
342 sprint_id
343 ))
344 .await
345 {
346 rollback_bulk_assignment(&repo, &original, &error).await?;
347 return Err(error);
348 }
349 Ok(assigned)
350}
351
352async fn rollback_bulk_assignment(
354 repo: &Backend,
355 original: &[BacklogItem],
356 operation_error: &Error,
357) -> Result<()> {
358 for item in original.iter().rev() {
359 if let Err(rollback_error) = BacklogItemRepository::save(repo, item).await {
360 return Err(Error::InvalidFilterOption(format!(
361 "{operation_error}; failed to roll back bulk assignment: {rollback_error}"
362 )));
363 }
364 }
365 Ok(())
366}
367
368pub async fn unassign_sprint(
374 project_dir: &Path,
375 sprint_id: &SprintId,
376 item_id: &ItemId,
377) -> Result<BacklogItem> {
378 let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
379 let mut item = BacklogItemRepository::load(&repo, item_id).await?;
380 if item.sprint.as_deref() != Some(sprint_id.as_str()) {
381 return Err(Error::NotInSprint {
382 item: item_id.clone(),
383 sprint: sprint_id.clone(),
384 });
385 }
386 item.sprint = None;
387 item.updated = Utc::now();
388 BacklogItemRepository::save(&repo, &item).await?;
389 repo.commit(&format!("pinto: update {}", item.id)).await?;
390 Ok(item)
391}