storkit: merge 466_story_configurable_timezone_in_project_toml_for_timer_scheduling
This commit is contained in:
Generated
+35
-6
@@ -430,6 +430,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
@@ -2726,7 +2736,16 @@ version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||
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]]
|
||||
@@ -2736,7 +2755,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"phf_shared 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2745,7 +2764,7 @@ version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"phf_shared 0.11.3",
|
||||
"rand 0.8.5",
|
||||
]
|
||||
|
||||
@@ -2758,6 +2777,15 @@ dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
@@ -4083,6 +4111,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"eventsource-stream",
|
||||
"filetime",
|
||||
"futures",
|
||||
@@ -4124,7 +4153,7 @@ checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"parking_lot",
|
||||
"phf_shared",
|
||||
"phf_shared 0.11.3",
|
||||
"precomputed-hash",
|
||||
"serde",
|
||||
]
|
||||
@@ -4136,7 +4165,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"phf_shared 0.11.3",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
@@ -5068,7 +5097,7 @@ version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
|
||||
dependencies = [
|
||||
"phf",
|
||||
"phf 0.11.3",
|
||||
"phf_codegen",
|
||||
"string_cache",
|
||||
"string_cache_codegen",
|
||||
|
||||
@@ -7,6 +7,7 @@ async-stream = "0.3"
|
||||
async-trait = "0.1.89"
|
||||
bytes = "1"
|
||||
chrono = { version = "0.4.44", features = ["serde"] }
|
||||
chrono-tz = "0.10"
|
||||
eventsource-stream = "0.2.3"
|
||||
futures = "0.3"
|
||||
homedir = "0.3.6"
|
||||
|
||||
@@ -9,6 +9,7 @@ async-stream = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
chrono-tz = { workspace = true }
|
||||
eventsource-stream = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
homedir = { workspace = true }
|
||||
|
||||
+76
-15
@@ -5,6 +5,7 @@
|
||||
//! and command parsing / handling for the `timer` bot command.
|
||||
|
||||
use chrono::{DateTime, Duration, Local, NaiveTime, TimeZone, Utc};
|
||||
use chrono_tz::Tz;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -375,6 +376,12 @@ pub async fn handle_timer_command(
|
||||
store: &TimerStore,
|
||||
project_root: &Path,
|
||||
) -> 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 {
|
||||
TimerCommand::Schedule {
|
||||
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,
|
||||
None => {
|
||||
return format!(
|
||||
@@ -411,10 +418,9 @@ pub async fn handle_timer_command(
|
||||
|
||||
match store.add(story_id.clone(), scheduled_at) {
|
||||
Ok(()) => {
|
||||
let local_time = scheduled_at.with_timezone(&Local);
|
||||
let (display_time, tz_label) = format_in_timezone(scheduled_at, tz_str);
|
||||
format!(
|
||||
"Timer set for **{story_id}** at **{}** (server local time).",
|
||||
local_time.format("%Y-%m-%d %H:%M")
|
||||
"Timer set for **{story_id}** at **{display_time}** ({tz_label})."
|
||||
)
|
||||
}
|
||||
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()];
|
||||
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!(
|
||||
"- **{}** → {}",
|
||||
t.story_id,
|
||||
local_time.format("%Y-%m-%d %H:%M")
|
||||
display_time
|
||||
));
|
||||
}
|
||||
lines.join("\n")
|
||||
@@ -457,10 +463,10 @@ pub async fn handle_timer_command(
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Parse `HH:MM` and return the next UTC instant at which the server-local
|
||||
/// clock will read that time. If the time has already passed today, returns
|
||||
/// tomorrow's occurrence.
|
||||
pub fn next_occurrence_of_hhmm(hhmm: &str) -> Option<DateTime<Utc>> {
|
||||
/// Parse `HH:MM` and return the next UTC instant at which the given timezone
|
||||
/// (or the server-local clock when `timezone` is `None`) will read that time.
|
||||
/// If the time has already passed today, returns tomorrow's occurrence.
|
||||
pub fn next_occurrence_of_hhmm(hhmm: &str, timezone: Option<&str>) -> Option<DateTime<Utc>> {
|
||||
let (hh, mm) = hhmm.split_once(':')?;
|
||||
let hours: u32 = hh.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;
|
||||
}
|
||||
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 today = now_local.date_naive();
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
@@ -544,31 +592,44 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
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]
|
||||
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]
|
||||
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]
|
||||
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");
|
||||
}
|
||||
|
||||
#[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 ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -42,6 +42,11 @@ pub struct ProjectConfig {
|
||||
/// Default: `true`.
|
||||
#[serde(default = "default_rate_limit_notifications")]
|
||||
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.
|
||||
@@ -184,6 +189,8 @@ struct LegacyProjectConfig {
|
||||
base_branch: Option<String>,
|
||||
#[serde(default = "default_rate_limit_notifications")]
|
||||
rate_limit_notifications: bool,
|
||||
#[serde(default)]
|
||||
timezone: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for ProjectConfig {
|
||||
@@ -212,6 +219,7 @@ impl Default for ProjectConfig {
|
||||
max_retries: default_max_retries(),
|
||||
base_branch: None,
|
||||
rate_limit_notifications: default_rate_limit_notifications(),
|
||||
timezone: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -259,6 +267,7 @@ impl ProjectConfig {
|
||||
max_retries: legacy.max_retries,
|
||||
base_branch: legacy.base_branch,
|
||||
rate_limit_notifications: legacy.rate_limit_notifications,
|
||||
timezone: legacy.timezone,
|
||||
};
|
||||
validate_agents(&config.agent)?;
|
||||
return Ok(config);
|
||||
@@ -285,6 +294,7 @@ impl ProjectConfig {
|
||||
max_retries: legacy.max_retries,
|
||||
base_branch: legacy.base_branch,
|
||||
rate_limit_notifications: legacy.rate_limit_notifications,
|
||||
timezone: legacy.timezone,
|
||||
};
|
||||
validate_agents(&config.agent)?;
|
||||
Ok(config)
|
||||
@@ -299,6 +309,7 @@ impl ProjectConfig {
|
||||
max_retries: legacy.max_retries,
|
||||
base_branch: legacy.base_branch,
|
||||
rate_limit_notifications: legacy.rate_limit_notifications,
|
||||
timezone: legacy.timezone,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,6 +527,7 @@ mod tests {
|
||||
max_retries: 2,
|
||||
base_branch: None,
|
||||
rate_limit_notifications: true,
|
||||
timezone: None,
|
||||
};
|
||||
// Should complete without panic
|
||||
run_setup_commands(tmp.path(), &config).await;
|
||||
@@ -550,6 +551,7 @@ mod tests {
|
||||
max_retries: 2,
|
||||
base_branch: None,
|
||||
rate_limit_notifications: true,
|
||||
timezone: None,
|
||||
};
|
||||
// Should complete without panic
|
||||
run_setup_commands(tmp.path(), &config).await;
|
||||
@@ -573,6 +575,7 @@ mod tests {
|
||||
max_retries: 2,
|
||||
base_branch: None,
|
||||
rate_limit_notifications: true,
|
||||
timezone: None,
|
||||
};
|
||||
// Setup command failures are non-fatal — should not panic or propagate
|
||||
run_setup_commands(tmp.path(), &config).await;
|
||||
@@ -596,6 +599,7 @@ mod tests {
|
||||
max_retries: 2,
|
||||
base_branch: None,
|
||||
rate_limit_notifications: true,
|
||||
timezone: None,
|
||||
};
|
||||
// Teardown failures are best-effort — should not propagate
|
||||
assert!(run_teardown_commands(tmp.path(), &config).await.is_ok());
|
||||
@@ -618,6 +622,7 @@ mod tests {
|
||||
max_retries: 2,
|
||||
base_branch: None,
|
||||
rate_limit_notifications: true,
|
||||
timezone: None,
|
||||
};
|
||||
let info = create_worktree(&project_root, "42_fresh_test", &config, 3001)
|
||||
.await
|
||||
@@ -647,6 +652,7 @@ mod tests {
|
||||
max_retries: 2,
|
||||
base_branch: None,
|
||||
rate_limit_notifications: true,
|
||||
timezone: None,
|
||||
};
|
||||
// First creation
|
||||
let _info1 = create_worktree(&project_root, "43_reuse_test", &config, 3001)
|
||||
@@ -717,6 +723,7 @@ mod tests {
|
||||
max_retries: 2,
|
||||
base_branch: None,
|
||||
rate_limit_notifications: true,
|
||||
timezone: None,
|
||||
};
|
||||
|
||||
let result = remove_worktree_by_story_id(tmp.path(), "99_nonexistent", &config).await;
|
||||
@@ -745,6 +752,7 @@ mod tests {
|
||||
max_retries: 2,
|
||||
base_branch: None,
|
||||
rate_limit_notifications: true,
|
||||
timezone: None,
|
||||
};
|
||||
create_worktree(&project_root, "88_remove_by_id", &config, 3001)
|
||||
.await
|
||||
@@ -820,6 +828,7 @@ mod tests {
|
||||
max_retries: 2,
|
||||
base_branch: None,
|
||||
rate_limit_notifications: true,
|
||||
timezone: None,
|
||||
};
|
||||
// Even though setup commands fail, create_worktree must succeed
|
||||
// so the agent can start and fix the problem itself.
|
||||
@@ -851,6 +860,7 @@ mod tests {
|
||||
max_retries: 2,
|
||||
base_branch: None,
|
||||
rate_limit_notifications: true,
|
||||
timezone: None,
|
||||
};
|
||||
// First creation — no setup commands, should succeed
|
||||
create_worktree(&project_root, "173_reuse_fail", &empty_config, 3001)
|
||||
@@ -872,6 +882,7 @@ mod tests {
|
||||
max_retries: 2,
|
||||
base_branch: None,
|
||||
rate_limit_notifications: true,
|
||||
timezone: None,
|
||||
};
|
||||
// Second call — worktree exists, setup commands fail, must still succeed
|
||||
let result = create_worktree(&project_root, "173_reuse_fail", &failing_config, 3002).await;
|
||||
@@ -899,6 +910,7 @@ mod tests {
|
||||
max_retries: 2,
|
||||
base_branch: None,
|
||||
rate_limit_notifications: true,
|
||||
timezone: None,
|
||||
};
|
||||
let info = create_worktree(&project_root, "77_remove_async", &config, 3001)
|
||||
.await
|
||||
|
||||
Reference in New Issue
Block a user