use super::SprintCloseAction;
use crate::backlog::{BacklogItem, ItemId};
use crate::error::{Error, Result};
use crate::service::open_board_locked;
use crate::sprint::{Sprint, SprintId, SprintSpillover, SprintState};
use crate::storage::{Backend, BacklogItemRepository, SprintRepository};
use chrono::{DateTime, Utc};
use rayon::prelude::*;
use std::path::Path;
pub async fn create_sprint(
project_dir: &Path,
id: &SprintId,
title: &str,
goal: Option<String>,
period: Option<(DateTime<Utc>, DateTime<Utc>)>,
) -> Result<Sprint> {
if let Some((start, end)) = period
&& start > end
{
return Err(Error::InvalidSprintPeriod {
start: start.date_naive(),
end: end.date_naive(),
});
}
let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
match SprintRepository::load(&repo, id).await {
Ok(_) => return Err(Error::SprintExists(id.clone())),
Err(Error::SprintNotFound(_)) => {}
Err(e) => return Err(e),
}
let mut sprint = Sprint::new(id.clone(), title, Utc::now())?;
if let Some(goal) = goal {
sprint.goal = goal;
}
if let Some((start, end)) = period {
sprint.start = Some(start);
sprint.end = Some(end);
}
SprintRepository::save(&repo, &sprint).await?;
repo.commit(&format!("pinto: add {}", sprint.id)).await?;
Ok(sprint)
}
pub async fn edit_sprint(
project_dir: &Path,
id: &SprintId,
title: Option<String>,
goal: Option<String>,
period: Option<(DateTime<Utc>, DateTime<Utc>)>,
) -> Result<Sprint> {
let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
let mut sprint = SprintRepository::load(&repo, id).await?;
sprint.update_details(title, goal, period, Utc::now())?;
SprintRepository::save(&repo, &sprint).await?;
repo.commit(&format!("pinto: update {}", sprint.id)).await?;
Ok(sprint)
}
pub async fn delete_sprint(project_dir: &Path, id: &SprintId) -> Result<()> {
let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
SprintRepository::load(&repo, id).await?;
let now = Utc::now();
let assigned = BacklogItemRepository::list(&repo)
.await?
.into_iter()
.filter(|item| item.sprint.as_deref() == Some(id.as_str()))
.collect::<Vec<_>>();
for mut item in assigned {
item.sprint = None;
item.updated = now;
BacklogItemRepository::save(&repo, &item).await?;
}
SprintRepository::delete(&repo, id).await?;
repo.commit(&format!("pinto: delete {id}")).await?;
Ok(())
}
pub async fn start_sprint(project_dir: &Path, id: &SprintId) -> Result<Sprint> {
transition_sprint(project_dir, id, Sprint::start).await
}
pub async fn close_sprint(
project_dir: &Path,
id: &SprintId,
action: SprintCloseAction,
) -> Result<Sprint> {
let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
let original_sprint = SprintRepository::load(&repo, id).await?;
if original_sprint.state != SprintState::Active {
return Err(Error::InvalidSprintTransition {
from: original_sprint.state,
to: SprintState::Closed,
});
}
if let SprintCloseAction::Rollover(target) = &action {
if target == id {
return Err(Error::InvalidFilterOption(
"a sprint cannot roll unfinished PBIs over to itself".to_string(),
));
}
validate_sprint_assignment(&repo, target.as_str()).await?;
}
let original_items = BacklogItemRepository::list(&repo)
.await?
.into_par_iter()
.filter(|item| item.sprint.as_deref() == Some(id.as_str()) && item.done_at.is_none())
.collect::<Vec<_>>();
let spillover = original_items
.par_iter()
.map(|item| SprintSpillover {
points: item.points.unwrap_or(0),
items: 1,
unestimated_items: u32::from(item.points.is_none()),
})
.reduce(SprintSpillover::default, |left, right| SprintSpillover {
points: left.points.saturating_add(right.points),
items: left.items.saturating_add(right.items),
unestimated_items: left
.unestimated_items
.saturating_add(right.unestimated_items),
});
let now = Utc::now();
let mut sprint = original_sprint.clone();
sprint.close(now, spillover)?;
let mut updated_items = if action == SprintCloseAction::Retain {
Vec::new()
} else {
original_items.clone()
};
for item in &mut updated_items {
match &action {
SprintCloseAction::Retain => {}
SprintCloseAction::Rollover(target) => item.sprint = Some(target.to_string()),
SprintCloseAction::Release => item.sprint = None,
}
item.updated = now;
}
for (index, item) in updated_items.iter().enumerate() {
if let Err(error) = BacklogItemRepository::save(&repo, item).await {
rollback_sprint_close(&repo, &original_sprint, &original_items[..=index], &error)
.await?;
return Err(error);
}
}
if let Err(error) = SprintRepository::save(&repo, &sprint).await {
rollback_sprint_close(&repo, &original_sprint, &original_items, &error).await?;
return Err(error);
}
repo.commit(&format!("pinto: update {}", sprint.id)).await?;
Ok(sprint)
}
async fn transition_sprint(
project_dir: &Path,
id: &SprintId,
transition: impl FnOnce(&mut Sprint, chrono::DateTime<Utc>) -> Result<()>,
) -> Result<Sprint> {
let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
let mut sprint = SprintRepository::load(&repo, id).await?;
transition(&mut sprint, Utc::now())?;
SprintRepository::save(&repo, &sprint).await?;
repo.commit(&format!("pinto: update {}", sprint.id)).await?;
Ok(sprint)
}
async fn rollback_sprint_close(
repo: &Backend,
original_sprint: &Sprint,
original_items: &[BacklogItem],
operation_error: &Error,
) -> Result<()> {
for item in original_items.iter().rev() {
if let Err(rollback_error) = BacklogItemRepository::save(repo, item).await {
return Err(Error::task(format!(
"{operation_error}; failed to roll back sprint close: {rollback_error}"
)));
}
}
if let Err(rollback_error) = SprintRepository::save(repo, original_sprint).await {
return Err(Error::task(format!(
"{operation_error}; failed to roll back sprint close: {rollback_error}"
)));
}
Ok(())
}
pub(crate) async fn validate_sprint_assignment(repo: &Backend, raw: &str) -> Result<SprintId> {
let id = SprintId::new(raw)?;
let sprint = SprintRepository::load(repo, &id).await?;
if sprint.state == SprintState::Closed {
return Err(Error::SprintClosed(id));
}
Ok(id)
}
pub async fn assign_sprint(
project_dir: &Path,
sprint_id: &SprintId,
item_id: &ItemId,
) -> Result<BacklogItem> {
assign_sprint_raw(project_dir, sprint_id.as_str(), item_id).await
}
pub async fn assign_sprint_raw(
project_dir: &Path,
raw_sprint_id: &str,
item_id: &ItemId,
) -> Result<BacklogItem> {
let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
let sprint_id = validate_sprint_assignment(&repo, raw_sprint_id).await?;
let mut item = BacklogItemRepository::load(&repo, item_id).await?;
item.sprint = Some(sprint_id.to_string());
item.updated = Utc::now();
BacklogItemRepository::save(&repo, &item).await?;
repo.commit(&format!("pinto: update {}", item.id)).await?;
Ok(item)
}
pub async fn assign_sprint_by_status(
project_dir: &Path,
sprint_id: &SprintId,
status: &str,
limit: Option<usize>,
) -> Result<Vec<BacklogItem>> {
let (_board_dir, repo, config, _lock) = open_board_locked(project_dir).await?;
if !config.columns.iter().any(|column| column == status) {
return Err(Error::UnknownStatus(status.to_string()));
}
if limit == Some(0) {
return Err(Error::InvalidFilterOption(
"--limit must be at least 1".to_string(),
));
}
validate_sprint_assignment(&repo, sprint_id.as_str()).await?;
let mut candidates = BacklogItemRepository::list(&repo)
.await?
.into_iter()
.filter(|item| item.status.as_str() == status)
.filter(|item| item.sprint.as_deref() != Some(sprint_id.as_str()))
.collect::<Vec<_>>();
if let Some(limit) = limit {
candidates.truncate(limit);
}
if let Some(item) = candidates.iter().find(|item| item.sprint.is_some())
&& let Some(assigned_sprint) = item.sprint.as_deref()
{
return Err(Error::InvalidFilterOption(format!(
"{} is already assigned to sprint {}; remove it before bulk assignment",
item.id, assigned_sprint
)));
}
let original = candidates.clone();
let now = Utc::now();
let mut assigned = Vec::with_capacity(candidates.len());
for (index, mut item) in candidates.into_iter().enumerate() {
item.sprint = Some(sprint_id.to_string());
item.updated = now;
if let Err(error) = BacklogItemRepository::save(&repo, &item).await {
rollback_bulk_assignment(&repo, &original[..=index], &error).await?;
return Err(error);
}
assigned.push(item);
}
if !assigned.is_empty()
&& let Err(error) = repo
.commit(&format!(
"pinto: assign {} item(s) to {}",
assigned.len(),
sprint_id
))
.await
{
rollback_bulk_assignment(&repo, &original, &error).await?;
return Err(error);
}
Ok(assigned)
}
async fn rollback_bulk_assignment(
repo: &Backend,
original: &[BacklogItem],
operation_error: &Error,
) -> Result<()> {
for item in original.iter().rev() {
if let Err(rollback_error) = BacklogItemRepository::save(repo, item).await {
return Err(Error::InvalidFilterOption(format!(
"{operation_error}; failed to roll back bulk assignment: {rollback_error}"
)));
}
}
Ok(())
}
pub async fn unassign_sprint(
project_dir: &Path,
sprint_id: &SprintId,
item_id: &ItemId,
) -> Result<BacklogItem> {
let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
let mut item = BacklogItemRepository::load(&repo, item_id).await?;
if item.sprint.as_deref() != Some(sprint_id.as_str()) {
return Err(Error::NotInSprint {
item: item_id.clone(),
sprint: sprint_id.clone(),
});
}
item.sprint = None;
item.updated = Utc::now();
BacklogItemRepository::save(&repo, &item).await?;
repo.commit(&format!("pinto: update {}", item.id)).await?;
Ok(item)
}