Skip to main content

pinto/service/sprint/
lifecycle.rs

1//! Sprint lifecycle services: creation, editing, state transitions, and PBI assignment.
2
3use 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
13/// Create a sprint on the board in `project_dir` and return the saved [`Sprint`].
14///
15/// The state is [`crate::sprint::SprintState::Planned`]. `goal` is persisted after the frontmatter
16/// as the sprint Markdown body.
17/// When `period` is provided, retain the planned start and end dates. Return
18/// [`Error::InvalidSprintPeriod`] when the start is after the end, [`Error::SprintExists`] when
19/// the ID is already used, [`Error::NotInitialized`] for an uninitialized board, or
20/// [`Error::EmptySprintTitle`] for an empty title.
21pub 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    // Reject an inverted period because the burndown time axis would be invalid.
29    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    // Check for an existing ID before saving so creation never overwrites a sprint.
41    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
60/// Update the title, goal, and/or planned period of an existing sprint.
61///
62/// Fields set to `None` remain unchanged. Return [`Error::NothingToUpdate`] when no field is
63/// supplied, [`Error::EmptySprintTitle`] for a blank title, [`Error::InvalidSprintPeriod`] for an
64/// inverted period, or [`Error::SprintNotFound`] when the sprint does not exist.
65pub 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
80/// Delete a sprint and clear its assignment from every PBI that references it.
81///
82/// The PBIs remain in the backlog. All reads and writes happen while the board lock is held, and
83/// Git-backed boards commit the sprint deletion and assignment changes as one service operation.
84pub 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
105/// Start a sprint (`planned` → `active`). Returns the saved [`Sprint`].
106///
107/// Return [`Error::NotInitialized`] when the board is uninitialized or
108/// [`Error::SprintNotFound`] when no sprint with `id` exists. Starting from anything other than
109/// `planned` returns [`Error::InvalidSprintTransition`].
110pub async fn start_sprint(project_dir: &Path, id: &SprintId) -> Result<Sprint> {
111    transition_sprint(project_dir, id, Sprint::start).await
112}
113
114/// Close the sprint (`active` → `closed`) and return the saved [`Sprint`].
115///
116/// A rollover target is validated before the first write. Only unfinished PBIs are reassigned or
117/// released; completed PBIs remain byte-for-byte equivalent at the domain level. The sprint stores
118/// the actual close time and a snapshot of unfinished estimated points and item counts for
119/// retrospective display, separate from velocity.
120pub 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    // Match the repository-wide Git failure contract: once durable files are saved, a commit
192    // failure leaves them available for inspection and manual recovery.
193    repo.commit(&format!("pinto: update {}", sprint.id)).await?;
194    Ok(sprint)
195}
196
197/// Load the sprint, apply a state transition, and save.
198///
199/// The domain layer validates the transition before the updated sprint is saved, so failures leave
200/// the on-disk state unchanged.
201async 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
214/// Restore sprint and unfinished-PBI contents after a failed close persistence operation.
215async 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
236/// Validate a raw sprint assignment while the caller holds the board write lock.
237pub(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
246/// Assign PBI `item_id` to sprint `sprint_id` and return the saved [`BacklogItem`].
247///
248/// Validate that the sprint exists and is not closed before assigning, preventing dangling or
249/// semantically invalid assignments.
250/// Return [`Error::NotInitialized`] when the board is uninitialized, [`Error::SprintNotFound`]
251/// when the sprint does not exist, [`Error::SprintClosed`] when it is closed, or
252/// [`Error::NotFound`] when the PBI does not exist.
253pub 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
261/// Assign a PBI from a raw CLI sprint ID, validating its grammar and existence before saving.
262pub 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
277/// Assign matching PBIs to a sprint in backlog rank order.
278///
279/// `status` must be a configured workflow column. When `limit` is `Some`, only the first `limit`
280/// matching PBIs are considered; omitting it considers every matching PBI. Items already assigned
281/// to the target sprint are skipped without consuming the limit. An item assigned to another
282/// sprint causes the operation to fail before any item is saved, so validation errors do not leave
283/// a partially assigned set.
284pub 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    // BacklogItemRepository::list returns canonical rank order. Exclude target-sprint members
303    // before applying the limit so rerunning a command fills the requested number of new slots.
304    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    // Validate every selected assignment before the first save. This makes conflicts with another
315    // sprint all-or-nothing from the user's perspective.
316    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
352/// Restore the original item contents after a failed multi-item persistence operation.
353async 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
368/// Remove PBI `item_id` from sprint `sprint_id` and return the saved [`BacklogItem`].
369///
370/// Return [`Error::NotInSprint`] when the item is assigned to another sprint or none. Return
371/// [`Error::NotInitialized`] for an uninitialized board or [`Error::NotFound`] when the item does
372/// not exist.
373pub 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}