Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .pinto/issued_ids
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,5 @@ P-73
P-74
P-75
P-76
P-77
P-78
30 changes: 30 additions & 0 deletions .pinto/tasks/P-77.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
+++
id = "P-77"
title = "Independently configure sprint start and end datetimes"
status = "done"
rank = "zy"
points = 5
start_at = "2026-09-11T09:38:00.627760Z"
done_at = "2026-09-11T10:08:31.090702Z"
created = "2026-09-11T09:32:56.920845Z"
updated = "2026-09-11T10:08:31.090702Z"
+++

# **Summary**

Allow users to update a Sprint's planned start or end datetime independently through `sprint edit`, preserving the endpoint they did not supply. Keep the existing paired-period invariant for persisted Sprints and keep `sprint new` requiring both endpoints.

# **Implementation Plan**

- Carry independent start/end edit intent from the CLI through the service boundary.
- Merge a supplied endpoint with the existing counterpart before validating and saving.
- Add focused domain/service and CLI integration coverage for preservation and rejection paths.

# **Acceptance Criteria**

- [x] Updating only the start datetime changes the start datetime and preserves the existing end datetime.
- [x] Updating only the end datetime changes the end datetime and preserves the existing start datetime.
- [x] Updating both the start and end datetimes continues to succeed.
- [x] Invalid datetime input is rejected.
- [x] An update where the start datetime is later than the end datetime is rejected.
- [x] An update with neither datetime supplied preserves the existing behavior.
69 changes: 69 additions & 0 deletions .pinto/tasks/P-78.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
+++
id = "P-78"
title = "Allow independent Sprint Edit schedule endpoints"
status = "done"
rank = "zz"
parent = "P-77"
start_at = "2026-09-11T09:38:00.892159Z"
done_at = "2026-09-11T10:05:04.876284Z"
created = "2026-09-11T09:38:00.432197Z"
updated = "2026-09-11T10:06:29.100040Z"
+++

# **Summary**

Implement independently configurable start and end datetimes for `sprint edit` without weakening the persisted Sprint paired-period invariant.

# **Planning Estimate**

5 points.

# **Dependencies**

None. Existing datetime parsing, persistence, and paired-period validation are reusable.

# **Owner**

Sprint CLI/service implementation sub-agent.

# **Verification**

Focused checks passed:

- `vfy run --json -- cargo test --all-features --locked --test cli sprint_edit_` — passed (6 tests).
- `vfy run --json -- cargo test --lib service::sprint::tests::edit_` — passed (4 tests).
- `vfy run --json -- cargo test --test cli sprint_new_start_` — passed (2 tests).
- `vfy run --json -- cargo clippy --all-targets -- -D warnings` — passed.
- `vfy run --json -- cargo fmt --all -- --check` — passed.

The full `mise run check` gate compiled the project and reported 4 failures in pre-existing editor/template integration tests (`add`, `retro`, and `review`); no Sprint endpoint test failed.

Code review found no issues.

# **Completion Conditions**

- Independent `sprint edit --start` and `sprint edit --end` updates preserve the omitted endpoint — passed.
- Invalid requests do not mutate or persist the Sprint — passed.
- Existing paired edits and `sprint new` behavior remain valid — passed.
- Focused tests pass — passed.

# **Decision**

Decision: Apply independent endpoint behavior to `sprint edit`, not `sprint new`.
Why: The Acceptance Criteria specify updating, while new Sprints have no counterpart to preserve and the persisted model requires a complete period.
Next verification: Focused CLI and service tests exercise endpoint preservation and reject incomplete or inverted effective periods.

# **Checkpoint**

What differed from the plan: The service schedule intent uses a seven-argument tuple alias to satisfy the repository's clippy argument-count policy while preserving independent endpoints.
What blocks the next work: Nothing in the selected SBI; the broad gate remains red on four unrelated existing editor/template tests.
Plan, split, or Flow Mode change: No change; the one-SBI Standard split remains sufficient.

# **Residual Risks and Unverified Items**

- The full project gate is not green because of unrelated existing integration-test failures.
- Demo data and the repository-wide gate were not added or rerun because the human requested only implementation and tests, not the full Sprint/DoD ceremony.

# **Risks**

The existing commit-failure durability caveat in the service contract is unchanged.
6 changes: 4 additions & 2 deletions src/cli/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -519,10 +519,12 @@ pub(super) enum SprintCommand {
)]
clear_goal_achieved: bool,
/// Replacement planned start date and time (`YYYY-MM-DD` or `YYYY-MM-DDTHH:MM`, in UTC).
#[arg(long, short = 's', requires = "end", value_parser = parse_utc_datetime)]
/// When supplied alone, preserves the existing end date.
#[arg(long, short = 's', value_parser = parse_utc_datetime)]
start: Option<chrono::DateTime<chrono::Utc>>,
/// Replacement planned end date and time (`YYYY-MM-DD` or `YYYY-MM-DDTHH:MM`, in UTC).
#[arg(long, short = 'e', requires = "start", value_parser = parse_utc_datetime)]
/// When supplied alone, preserves the existing start date.
#[arg(long, short = 'e', value_parser = parse_utc_datetime)]
end: Option<chrono::DateTime<chrono::Utc>>,
},
/// Remove a sprint and release any assigned PBIs back to the backlog.
Expand Down
5 changes: 1 addition & 4 deletions src/cli/commands/sprint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -384,10 +384,7 @@ pub(super) async fn cmd_sprint_with_localizer(
end,
} => {
let id: SprintId = id.parse()?;
let period = match (start, end) {
(Some(start), Some(end)) => Some((start, end)),
_ => None,
};
let period = (start.is_some() || end.is_some()).then_some((start, end));
let sprint = edit_sprint(
&dir,
&id,
Expand Down
23 changes: 18 additions & 5 deletions src/service/sprint/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ use std::path::Path;

use super::SprintDeletionOptions;

type SprintEditPeriod = (Option<DateTime<Utc>>, Option<DateTime<Utc>>);

/// Create a sprint on the board in `project_dir` and return the saved [`Sprint`].
///
/// The state is [`crate::sprint::SprintState::Planned`]. `goal` is persisted after the frontmatter
Expand Down Expand Up @@ -69,9 +71,11 @@ pub async fn create_sprint(

/// Update the title, goal, and/or planned period of an existing sprint.
///
/// Fields set to `None` remain unchanged. Return [`Error::NothingToUpdate`] when no field is
/// supplied, [`Error::EmptySprintTitle`] for a blank title, [`Error::InvalidSprintPeriod`] for an
/// inverted period, or [`Error::SprintNotFound`] when the sprint does not exist.
/// Fields set to `None` remain unchanged. Supplying only one schedule endpoint preserves the
/// existing counterpart. Return [`Error::NothingToUpdate`] when no field is supplied,
/// [`Error::EmptySprintTitle`] for a blank title, [`Error::SprintPeriodIncomplete`] when the
/// effective schedule has only one endpoint, [`Error::InvalidSprintPeriod`] for an inverted period,
/// or [`Error::SprintNotFound`] when the sprint does not exist.
///
/// # Errors
///
Expand All @@ -83,14 +87,23 @@ pub async fn edit_sprint(
id: &SprintId,
title: Option<String>,
goal: Option<String>,
period: Option<(DateTime<Utc>, DateTime<Utc>)>,
period: Option<SprintEditPeriod>,
goal_achieved: Option<bool>,
clear_goal_achieved: bool,
) -> Result<Sprint> {
let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
let mut sprint = SprintRepository::load(&repo, id).await?;
let now = Utc::now();
if title.is_some() || goal.is_some() || period.is_some() {
let schedule_update = period.is_some();
let period = if let Some((start, end)) = period {
match (start.or(sprint.start), end.or(sprint.end)) {
(Some(start), Some(end)) => Some((start, end)),
_ => return Err(Error::SprintPeriodIncomplete(sprint.id.clone())),
}
} else {
None
};
if title.is_some() || goal.is_some() || schedule_update {
sprint.update_details(title, goal, period, now)?;
} else if goal_achieved.is_none() && !clear_goal_achieved {
return Err(Error::NothingToUpdate);
Expand Down
108 changes: 107 additions & 1 deletion src/service/sprint/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ async fn edit_updates_title_goal_and_period() {
&sid("S-1"),
Some("Updated title".to_string()),
Some("Updated goal".to_string()),
Some((start, end)),
Some((Some(start), Some(end))),
None,
false,
)
Expand All @@ -213,6 +213,112 @@ async fn edit_updates_title_goal_and_period() {
assert_eq!(edited.end, Some(end));
}

#[tokio::test]
async fn edit_preserves_omitted_period_endpoint() {
let dir = init_temp().await;
let original_start = date(2026, 8, 3);
let original_end = date(2026, 8, 14);
create_sprint(
dir.path(),
&sid("S-1"),
"Sprint 1",
None,
Some((original_start, original_end)),
)
.await
.unwrap();

let edited = edit_sprint(
dir.path(),
&sid("S-1"),
None,
None,
Some((Some(date(2026, 8, 5)), None)),
None,
false,
)
.await
.unwrap();
assert_eq!(edited.start, Some(date(2026, 8, 5)));
assert_eq!(edited.end, Some(original_end));

let edited = edit_sprint(
dir.path(),
&sid("S-1"),
None,
None,
Some((None, Some(date(2026, 8, 18)))),
None,
false,
)
.await
.unwrap();
assert_eq!(edited.start, Some(date(2026, 8, 5)));
assert_eq!(edited.end, Some(date(2026, 8, 18)));
}

#[tokio::test]
async fn edit_rejects_inverted_effective_period_without_mutation() {
let dir = init_temp().await;
let start = date(2026, 8, 3);
let end = date(2026, 8, 14);
create_sprint(
dir.path(),
&sid("S-1"),
"Sprint 1",
None,
Some((start, end)),
)
.await
.unwrap();

let err = edit_sprint(
dir.path(),
&sid("S-1"),
Some("Changed".to_string()),
None,
Some((Some(date(2026, 8, 15)), None)),
None,
false,
)
.await
.unwrap_err();
assert_eq!(
err,
Error::InvalidSprintPeriod {
start: date(2026, 8, 15).date_naive(),
end: end.date_naive(),
}
);

let repo = FileRepository::new(dir.path().join(".pinto"));
let unchanged = SprintRepository::load(&repo, &sid("S-1")).await.unwrap();
assert_eq!(unchanged.title, "Sprint 1");
assert_eq!(unchanged.start, Some(start));
assert_eq!(unchanged.end, Some(end));
}

#[tokio::test]
async fn edit_rejects_one_sided_effective_period() {
let dir = init_temp().await;
create_sprint(dir.path(), &sid("S-1"), "Sprint 1", None, None)
.await
.unwrap();

let err = edit_sprint(
dir.path(),
&sid("S-1"),
None,
None,
Some((Some(date(2026, 8, 3)), None)),
None,
false,
)
.await
.unwrap_err();
assert_eq!(err, Error::SprintPeriodIncomplete(sid("S-1")));
}

#[tokio::test]
async fn start_moves_planned_to_active_and_persists() {
let dir = init_temp().await;
Expand Down
1 change: 1 addition & 0 deletions tests/cli/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub(crate) fn pinto(dir: &Path) -> Command {
// own command and set `LC_ALL`/`LANG` explicitly.
cmd.current_dir(dir)
.env("XDG_CONFIG_HOME", dir.join("test-xdg-config"))
.env_remove("VISUAL")
.env("LC_ALL", "en_US.UTF-8")
.env("LANG", "en_US.UTF-8");
cmd
Expand Down
Loading