storkit: merge 466_story_configurable_timezone_in_project_toml_for_timer_scheduling

This commit is contained in:
dave
2026-04-03 13:12:52 +00:00
parent e1cea8f958
commit e9954d244b
6 changed files with 147 additions and 32 deletions
Generated
+35 -6
View File
@@ -430,6 +430,16 @@ dependencies = [
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
[[package]]
name = "chrono-tz"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
dependencies = [
"chrono",
"phf 0.12.1",
]
[[package]] [[package]]
name = "cipher" name = "cipher"
version = "0.4.4" version = "0.4.4"
@@ -2726,7 +2736,16 @@ version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [ dependencies = [
"phf_shared", "phf_shared 0.11.3",
]
[[package]]
name = "phf"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
dependencies = [
"phf_shared 0.12.1",
] ]
[[package]] [[package]]
@@ -2736,7 +2755,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [ dependencies = [
"phf_generator", "phf_generator",
"phf_shared", "phf_shared 0.11.3",
] ]
[[package]] [[package]]
@@ -2745,7 +2764,7 @@ version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [ dependencies = [
"phf_shared", "phf_shared 0.11.3",
"rand 0.8.5", "rand 0.8.5",
] ]
@@ -2758,6 +2777,15 @@ dependencies = [
"siphasher", "siphasher",
] ]
[[package]]
name = "phf_shared"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
dependencies = [
"siphasher",
]
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
@@ -4083,6 +4111,7 @@ dependencies = [
"async-trait", "async-trait",
"bytes", "bytes",
"chrono", "chrono",
"chrono-tz",
"eventsource-stream", "eventsource-stream",
"filetime", "filetime",
"futures", "futures",
@@ -4124,7 +4153,7 @@ checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
dependencies = [ dependencies = [
"new_debug_unreachable", "new_debug_unreachable",
"parking_lot", "parking_lot",
"phf_shared", "phf_shared 0.11.3",
"precomputed-hash", "precomputed-hash",
"serde", "serde",
] ]
@@ -4136,7 +4165,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
dependencies = [ dependencies = [
"phf_generator", "phf_generator",
"phf_shared", "phf_shared 0.11.3",
"proc-macro2", "proc-macro2",
"quote", "quote",
] ]
@@ -5068,7 +5097,7 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
dependencies = [ dependencies = [
"phf", "phf 0.11.3",
"phf_codegen", "phf_codegen",
"string_cache", "string_cache",
"string_cache_codegen", "string_cache_codegen",
+1
View File
@@ -7,6 +7,7 @@ async-stream = "0.3"
async-trait = "0.1.89" async-trait = "0.1.89"
bytes = "1" bytes = "1"
chrono = { version = "0.4.44", features = ["serde"] } chrono = { version = "0.4.44", features = ["serde"] }
chrono-tz = "0.10"
eventsource-stream = "0.2.3" eventsource-stream = "0.2.3"
futures = "0.3" futures = "0.3"
homedir = "0.3.6" homedir = "0.3.6"
+1
View File
@@ -9,6 +9,7 @@ async-stream = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
bytes = { workspace = true } bytes = { workspace = true }
chrono = { workspace = true, features = ["serde"] } chrono = { workspace = true, features = ["serde"] }
chrono-tz = { workspace = true }
eventsource-stream = { workspace = true } eventsource-stream = { workspace = true }
futures = { workspace = true } futures = { workspace = true }
homedir = { workspace = true } homedir = { workspace = true }
+76 -15
View File
@@ -5,6 +5,7 @@
//! and command parsing / handling for the `timer` bot command. //! and command parsing / handling for the `timer` bot command.
use chrono::{DateTime, Duration, Local, NaiveTime, TimeZone, Utc}; use chrono::{DateTime, Duration, Local, NaiveTime, TimeZone, Utc};
use chrono_tz::Tz;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@@ -375,6 +376,12 @@ pub async fn handle_timer_command(
store: &TimerStore, store: &TimerStore,
project_root: &Path, project_root: &Path,
) -> String { ) -> String {
// Load the configured timezone (if any) from project.toml.
let config_tz: Option<String> = crate::config::ProjectConfig::load(project_root)
.ok()
.and_then(|c| c.timezone);
let tz_str: Option<&str> = config_tz.as_deref();
match cmd { match cmd {
TimerCommand::Schedule { TimerCommand::Schedule {
story_number_or_id, story_number_or_id,
@@ -400,7 +407,7 @@ pub async fn handle_timer_command(
); );
} }
let scheduled_at = match next_occurrence_of_hhmm(&hhmm) { let scheduled_at = match next_occurrence_of_hhmm(&hhmm, tz_str) {
Some(t) => t, Some(t) => t,
None => { None => {
return format!( return format!(
@@ -411,10 +418,9 @@ pub async fn handle_timer_command(
match store.add(story_id.clone(), scheduled_at) { match store.add(story_id.clone(), scheduled_at) {
Ok(()) => { Ok(()) => {
let local_time = scheduled_at.with_timezone(&Local); let (display_time, tz_label) = format_in_timezone(scheduled_at, tz_str);
format!( format!(
"Timer set for **{story_id}** at **{}** (server local time).", "Timer set for **{story_id}** at **{display_time}** ({tz_label})."
local_time.format("%Y-%m-%d %H:%M")
) )
} }
Err(e) => format!("Failed to save timer: {e}"), Err(e) => format!("Failed to save timer: {e}"),
@@ -427,11 +433,11 @@ pub async fn handle_timer_command(
} }
let mut lines = vec!["**Pending timers:**".to_string()]; let mut lines = vec!["**Pending timers:**".to_string()];
for t in &timers { for t in &timers {
let local_time = t.scheduled_at.with_timezone(&Local); let (display_time, _) = format_in_timezone(t.scheduled_at, tz_str);
lines.push(format!( lines.push(format!(
"- **{}** → {}", "- **{}** → {}",
t.story_id, t.story_id,
local_time.format("%Y-%m-%d %H:%M") display_time
)); ));
} }
lines.join("\n") lines.join("\n")
@@ -457,10 +463,10 @@ pub async fn handle_timer_command(
// ── Helpers ──────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
/// Parse `HH:MM` and return the next UTC instant at which the server-local /// Parse `HH:MM` and return the next UTC instant at which the given timezone
/// clock will read that time. If the time has already passed today, returns /// (or the server-local clock when `timezone` is `None`) will read that time.
/// tomorrow's occurrence. /// If the time has already passed today, returns tomorrow's occurrence.
pub fn next_occurrence_of_hhmm(hhmm: &str) -> Option<DateTime<Utc>> { pub fn next_occurrence_of_hhmm(hhmm: &str, timezone: Option<&str>) -> Option<DateTime<Utc>> {
let (hh, mm) = hhmm.split_once(':')?; let (hh, mm) = hhmm.split_once(':')?;
let hours: u32 = hh.parse().ok()?; let hours: u32 = hh.parse().ok()?;
let minutes: u32 = mm.parse().ok()?; let minutes: u32 = mm.parse().ok()?;
@@ -468,6 +474,24 @@ pub fn next_occurrence_of_hhmm(hhmm: &str) -> Option<DateTime<Utc>> {
return None; return None;
} }
let target_time = NaiveTime::from_hms_opt(hours, minutes, 0)?; let target_time = NaiveTime::from_hms_opt(hours, minutes, 0)?;
match timezone.and_then(|s| s.parse::<Tz>().ok()) {
Some(tz) => {
let now_tz = Utc::now().with_timezone(&tz);
let today = now_tz.date_naive();
let candidate = today.and_time(target_time);
let candidate_tz = tz.from_local_datetime(&candidate).single()?;
if candidate_tz > now_tz {
Some(candidate_tz.to_utc())
} else {
let tomorrow = today + Duration::days(1);
let tomorrow_candidate = tomorrow.and_time(target_time);
let tomorrow_tz = tz.from_local_datetime(&tomorrow_candidate).single()?;
Some(tomorrow_tz.to_utc())
}
}
None => {
// Fall back to host/container local timezone.
let now_local = Local::now(); let now_local = Local::now();
let today = now_local.date_naive(); let today = now_local.date_naive();
let candidate = today.and_time(target_time); let candidate = today.and_time(target_time);
@@ -481,6 +505,30 @@ pub fn next_occurrence_of_hhmm(hhmm: &str) -> Option<DateTime<Utc>> {
Some(tomorrow_local.to_utc()) Some(tomorrow_local.to_utc())
} }
} }
}
}
/// Format a UTC instant for display in the given timezone (or local time when
/// `timezone` is `None`). Returns `(formatted_string, label)` where `label`
/// is either the IANA timezone name or `"server local time"`.
fn format_in_timezone(dt: DateTime<Utc>, timezone: Option<&str>) -> (String, String) {
match timezone.and_then(|s| s.parse::<Tz>().ok()) {
Some(tz) => {
let tz_time = dt.with_timezone(&tz);
(
tz_time.format("%Y-%m-%d %H:%M").to_string(),
tz.to_string(),
)
}
None => {
let local_time = dt.with_timezone(&Local);
(
local_time.format("%Y-%m-%d %H:%M").to_string(),
"server local time".to_string(),
)
}
}
}
/// Resolve a story ID from a numeric story number or a full ID string. /// Resolve a story ID from a numeric story number or a full ID string.
/// ///
@@ -544,31 +592,44 @@ mod tests {
#[test] #[test]
fn valid_hhmm_returns_some() { fn valid_hhmm_returns_some() {
let result = next_occurrence_of_hhmm("14:30"); let result = next_occurrence_of_hhmm("14:30", None);
assert!(result.is_some(), "valid HH:MM should return Some"); assert!(result.is_some(), "valid HH:MM should return Some");
} }
#[test] #[test]
fn invalid_hhmm_missing_colon_returns_none() { fn invalid_hhmm_missing_colon_returns_none() {
assert!(next_occurrence_of_hhmm("1430").is_none()); assert!(next_occurrence_of_hhmm("1430", None).is_none());
} }
#[test] #[test]
fn invalid_hhmm_bad_hours_returns_none() { fn invalid_hhmm_bad_hours_returns_none() {
assert!(next_occurrence_of_hhmm("25:00").is_none()); assert!(next_occurrence_of_hhmm("25:00", None).is_none());
} }
#[test] #[test]
fn invalid_hhmm_bad_minutes_returns_none() { fn invalid_hhmm_bad_minutes_returns_none() {
assert!(next_occurrence_of_hhmm("12:60").is_none()); assert!(next_occurrence_of_hhmm("12:60", None).is_none());
} }
#[test] #[test]
fn next_occurrence_is_in_the_future() { fn next_occurrence_is_in_the_future() {
let result = next_occurrence_of_hhmm("14:30").unwrap(); let result = next_occurrence_of_hhmm("14:30", None).unwrap();
assert!(result > Utc::now(), "next occurrence must be in the future"); assert!(result > Utc::now(), "next occurrence must be in the future");
} }
#[test]
fn next_occurrence_with_named_timezone_is_in_the_future() {
let result = next_occurrence_of_hhmm("14:30", Some("Europe/London")).unwrap();
assert!(result > Utc::now(), "next occurrence (Europe/London) must be in the future");
}
#[test]
fn next_occurrence_with_invalid_timezone_falls_back_to_local() {
// An unrecognised timezone name falls back to chrono::Local (returns Some).
let result = next_occurrence_of_hhmm("14:30", Some("Invalid/Zone"));
assert!(result.is_some(), "invalid timezone should fall back to local and return Some");
}
// ── extract_timer_command ─────────────────────────────────────────── // ── extract_timer_command ───────────────────────────────────────────
#[test] #[test]
+11
View File
@@ -42,6 +42,11 @@ pub struct ProjectConfig {
/// Default: `true`. /// Default: `true`.
#[serde(default = "default_rate_limit_notifications")] #[serde(default = "default_rate_limit_notifications")]
pub rate_limit_notifications: bool, pub rate_limit_notifications: bool,
/// IANA timezone name (e.g. `"Europe/London"`, `"America/New_York"`).
/// When set, timer HH:MM inputs are interpreted in this timezone instead
/// of the container/host local time. Falls back to `chrono::Local` when absent.
#[serde(default)]
pub timezone: Option<String>,
} }
/// Configuration for the filesystem watcher's sweep behaviour. /// Configuration for the filesystem watcher's sweep behaviour.
@@ -184,6 +189,8 @@ struct LegacyProjectConfig {
base_branch: Option<String>, base_branch: Option<String>,
#[serde(default = "default_rate_limit_notifications")] #[serde(default = "default_rate_limit_notifications")]
rate_limit_notifications: bool, rate_limit_notifications: bool,
#[serde(default)]
timezone: Option<String>,
} }
impl Default for ProjectConfig { impl Default for ProjectConfig {
@@ -212,6 +219,7 @@ impl Default for ProjectConfig {
max_retries: default_max_retries(), max_retries: default_max_retries(),
base_branch: None, base_branch: None,
rate_limit_notifications: default_rate_limit_notifications(), rate_limit_notifications: default_rate_limit_notifications(),
timezone: None,
} }
} }
} }
@@ -259,6 +267,7 @@ impl ProjectConfig {
max_retries: legacy.max_retries, max_retries: legacy.max_retries,
base_branch: legacy.base_branch, base_branch: legacy.base_branch,
rate_limit_notifications: legacy.rate_limit_notifications, rate_limit_notifications: legacy.rate_limit_notifications,
timezone: legacy.timezone,
}; };
validate_agents(&config.agent)?; validate_agents(&config.agent)?;
return Ok(config); return Ok(config);
@@ -285,6 +294,7 @@ impl ProjectConfig {
max_retries: legacy.max_retries, max_retries: legacy.max_retries,
base_branch: legacy.base_branch, base_branch: legacy.base_branch,
rate_limit_notifications: legacy.rate_limit_notifications, rate_limit_notifications: legacy.rate_limit_notifications,
timezone: legacy.timezone,
}; };
validate_agents(&config.agent)?; validate_agents(&config.agent)?;
Ok(config) Ok(config)
@@ -299,6 +309,7 @@ impl ProjectConfig {
max_retries: legacy.max_retries, max_retries: legacy.max_retries,
base_branch: legacy.base_branch, base_branch: legacy.base_branch,
rate_limit_notifications: legacy.rate_limit_notifications, rate_limit_notifications: legacy.rate_limit_notifications,
timezone: legacy.timezone,
}) })
} }
} }
+12
View File
@@ -527,6 +527,7 @@ mod tests {
max_retries: 2, max_retries: 2,
base_branch: None, base_branch: None,
rate_limit_notifications: true, rate_limit_notifications: true,
timezone: None,
}; };
// Should complete without panic // Should complete without panic
run_setup_commands(tmp.path(), &config).await; run_setup_commands(tmp.path(), &config).await;
@@ -550,6 +551,7 @@ mod tests {
max_retries: 2, max_retries: 2,
base_branch: None, base_branch: None,
rate_limit_notifications: true, rate_limit_notifications: true,
timezone: None,
}; };
// Should complete without panic // Should complete without panic
run_setup_commands(tmp.path(), &config).await; run_setup_commands(tmp.path(), &config).await;
@@ -573,6 +575,7 @@ mod tests {
max_retries: 2, max_retries: 2,
base_branch: None, base_branch: None,
rate_limit_notifications: true, rate_limit_notifications: true,
timezone: None,
}; };
// Setup command failures are non-fatal — should not panic or propagate // Setup command failures are non-fatal — should not panic or propagate
run_setup_commands(tmp.path(), &config).await; run_setup_commands(tmp.path(), &config).await;
@@ -596,6 +599,7 @@ mod tests {
max_retries: 2, max_retries: 2,
base_branch: None, base_branch: None,
rate_limit_notifications: true, rate_limit_notifications: true,
timezone: None,
}; };
// Teardown failures are best-effort — should not propagate // Teardown failures are best-effort — should not propagate
assert!(run_teardown_commands(tmp.path(), &config).await.is_ok()); assert!(run_teardown_commands(tmp.path(), &config).await.is_ok());
@@ -618,6 +622,7 @@ mod tests {
max_retries: 2, max_retries: 2,
base_branch: None, base_branch: None,
rate_limit_notifications: true, rate_limit_notifications: true,
timezone: None,
}; };
let info = create_worktree(&project_root, "42_fresh_test", &config, 3001) let info = create_worktree(&project_root, "42_fresh_test", &config, 3001)
.await .await
@@ -647,6 +652,7 @@ mod tests {
max_retries: 2, max_retries: 2,
base_branch: None, base_branch: None,
rate_limit_notifications: true, rate_limit_notifications: true,
timezone: None,
}; };
// First creation // First creation
let _info1 = create_worktree(&project_root, "43_reuse_test", &config, 3001) let _info1 = create_worktree(&project_root, "43_reuse_test", &config, 3001)
@@ -717,6 +723,7 @@ mod tests {
max_retries: 2, max_retries: 2,
base_branch: None, base_branch: None,
rate_limit_notifications: true, rate_limit_notifications: true,
timezone: None,
}; };
let result = remove_worktree_by_story_id(tmp.path(), "99_nonexistent", &config).await; let result = remove_worktree_by_story_id(tmp.path(), "99_nonexistent", &config).await;
@@ -745,6 +752,7 @@ mod tests {
max_retries: 2, max_retries: 2,
base_branch: None, base_branch: None,
rate_limit_notifications: true, rate_limit_notifications: true,
timezone: None,
}; };
create_worktree(&project_root, "88_remove_by_id", &config, 3001) create_worktree(&project_root, "88_remove_by_id", &config, 3001)
.await .await
@@ -820,6 +828,7 @@ mod tests {
max_retries: 2, max_retries: 2,
base_branch: None, base_branch: None,
rate_limit_notifications: true, rate_limit_notifications: true,
timezone: None,
}; };
// Even though setup commands fail, create_worktree must succeed // Even though setup commands fail, create_worktree must succeed
// so the agent can start and fix the problem itself. // so the agent can start and fix the problem itself.
@@ -851,6 +860,7 @@ mod tests {
max_retries: 2, max_retries: 2,
base_branch: None, base_branch: None,
rate_limit_notifications: true, rate_limit_notifications: true,
timezone: None,
}; };
// First creation — no setup commands, should succeed // First creation — no setup commands, should succeed
create_worktree(&project_root, "173_reuse_fail", &empty_config, 3001) create_worktree(&project_root, "173_reuse_fail", &empty_config, 3001)
@@ -872,6 +882,7 @@ mod tests {
max_retries: 2, max_retries: 2,
base_branch: None, base_branch: None,
rate_limit_notifications: true, rate_limit_notifications: true,
timezone: None,
}; };
// Second call — worktree exists, setup commands fail, must still succeed // Second call — worktree exists, setup commands fail, must still succeed
let result = create_worktree(&project_root, "173_reuse_fail", &failing_config, 3002).await; let result = create_worktree(&project_root, "173_reuse_fail", &failing_config, 3002).await;
@@ -899,6 +910,7 @@ mod tests {
max_retries: 2, max_retries: 2,
base_branch: None, base_branch: None,
rate_limit_notifications: true, rate_limit_notifications: true,
timezone: None,
}; };
let info = create_worktree(&project_root, "77_remove_async", &config, 3001) let info = create_worktree(&project_root, "77_remove_async", &config, 3001)
.await .await