diff --git a/.pinto/issued_ids b/.pinto/issued_ids index 5259cc2..9ff084a 100644 --- a/.pinto/issued_ids +++ b/.pinto/issued_ids @@ -74,3 +74,5 @@ P-73 P-74 P-75 P-76 +P-77 +P-78 diff --git a/.pinto/tasks/P-77.md b/.pinto/tasks/P-77.md new file mode 100644 index 0000000..dd3ef61 --- /dev/null +++ b/.pinto/tasks/P-77.md @@ -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. diff --git a/.pinto/tasks/P-78.md b/.pinto/tasks/P-78.md new file mode 100644 index 0000000..a729978 --- /dev/null +++ b/.pinto/tasks/P-78.md @@ -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. diff --git a/src/cli/args.rs b/src/cli/args.rs index db0cd3c..8927061 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -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>, /// 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>, }, /// Remove a sprint and release any assigned PBIs back to the backlog. diff --git a/src/cli/commands/sprint.rs b/src/cli/commands/sprint.rs index 24ca0c1..113f2ee 100644 --- a/src/cli/commands/sprint.rs +++ b/src/cli/commands/sprint.rs @@ -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, diff --git a/src/service/sprint/lifecycle.rs b/src/service/sprint/lifecycle.rs index 2bf927b..d12b1d9 100644 --- a/src/service/sprint/lifecycle.rs +++ b/src/service/sprint/lifecycle.rs @@ -13,6 +13,8 @@ use std::path::Path; use super::SprintDeletionOptions; +type SprintEditPeriod = (Option>, Option>); + /// 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 @@ -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 /// @@ -83,14 +87,23 @@ pub async fn edit_sprint( id: &SprintId, title: Option, goal: Option, - period: Option<(DateTime, DateTime)>, + period: Option, goal_achieved: Option, clear_goal_achieved: bool, ) -> Result { 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); diff --git a/src/service/sprint/tests.rs b/src/service/sprint/tests.rs index 6df877b..3e2c02f 100644 --- a/src/service/sprint/tests.rs +++ b/src/service/sprint/tests.rs @@ -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, ) @@ -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; diff --git a/tests/cli/common.rs b/tests/cli/common.rs index 8772cfd..edb7e8b 100644 --- a/tests/cli/common.rs +++ b/tests/cli/common.rs @@ -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 diff --git a/tests/cli/sprint.rs b/tests/cli/sprint.rs index 6ae507f..180fa7f 100644 --- a/tests/cli/sprint.rs +++ b/tests/cli/sprint.rs @@ -366,6 +366,113 @@ fn sprint_edit_updates_goal_title_and_period_then_allows_start() { "active" ); } +#[test] +fn sprint_edit_start_preserves_existing_end() { + let dir = TempDir::new().expect("temp dir"); + pinto(dir.path()).arg("init").assert().success(); + pinto(dir.path()) + .args([ + "sprint", + "new", + "S-1", + "Planning", + "--start", + "2026-07-06", + "--end", + "2026-07-20", + ]) + .assert() + .success(); + + pinto(dir.path()) + .args(["sprint", "edit", "S-1", "--start", "2026-07-08"]) + .assert() + .success(); + + let edited = json_stdout(pinto(dir.path()).args(["sprint", "list", "--json"])); + assert_eq!(edited[0]["start"], "2026-07-08T00:00:00+00:00"); + assert_eq!(edited[0]["end"], "2026-07-20T00:00:00+00:00"); +} + +#[test] +fn sprint_edit_end_preserves_existing_start() { + let dir = TempDir::new().expect("temp dir"); + pinto(dir.path()).arg("init").assert().success(); + pinto(dir.path()) + .args([ + "sprint", + "new", + "S-1", + "Planning", + "--start", + "2026-07-06", + "--end", + "2026-07-20", + ]) + .assert() + .success(); + + pinto(dir.path()) + .args(["sprint", "edit", "S-1", "--end", "2026-07-22"]) + .assert() + .success(); + + let edited = json_stdout(pinto(dir.path()).args(["sprint", "list", "--json"])); + assert_eq!(edited[0]["start"], "2026-07-06T00:00:00+00:00"); + assert_eq!(edited[0]["end"], "2026-07-22T00:00:00+00:00"); +} + +#[test] +fn sprint_edit_rejects_inverted_effective_period_without_mutation() { + let dir = TempDir::new().expect("temp dir"); + pinto(dir.path()).arg("init").assert().success(); + pinto(dir.path()) + .args([ + "sprint", + "new", + "S-1", + "Planning", + "--start", + "2026-07-06", + "--end", + "2026-07-20", + ]) + .assert() + .success(); + + pinto(dir.path()) + .args([ + "sprint", + "edit", + "S-1", + "--title", + "Changed", + "--start", + "2026-07-21", + ]) + .assert() + .failure() + .code(1) + .stderr(predicate::str::contains("invalid sprint period")); + + let unchanged = json_stdout(pinto(dir.path()).args(["sprint", "list", "--json"])); + assert_eq!(unchanged[0]["title"], "Planning"); + assert_eq!(unchanged[0]["start"], "2026-07-06T00:00:00+00:00"); + assert_eq!(unchanged[0]["end"], "2026-07-20T00:00:00+00:00"); +} + +#[test] +fn sprint_edit_rejects_malformed_schedule_input() { + let dir = TempDir::new().expect("temp dir"); + pinto(dir.path()).arg("init").assert().success(); + + pinto(dir.path()) + .args(["sprint", "edit", "S-1", "--start", "not-a-date"]) + .assert() + .failure() + .code(1) + .stderr(predicate::str::contains("invalid value")); +} #[test] fn sprint_edit_rejects_empty_title_and_no_fields_without_mutation() {