fix(db): uniformiser les timestamps sqlite

closes #5
This commit is contained in:
thibaud-lclr 2026-04-16 17:23:36 +02:00
parent 91459c16cc
commit 0a2e7daec9
10 changed files with 202 additions and 40 deletions

View file

@ -4,7 +4,7 @@ CREATE TABLE IF NOT EXISTS projects (
path TEXT NOT NULL,
cloned_from TEXT,
base_branch TEXT NOT NULL DEFAULT 'main',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE IF NOT EXISTS tuleap_credentials (
@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS watched_trackers (
polling_interval INTEGER NOT NULL DEFAULT 10,
agent_config_json TEXT NOT NULL,
filters_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE IF NOT EXISTS processed_tickets (
@ -36,7 +36,7 @@ CREATE TABLE IF NOT EXISTS processed_tickets (
developer_report TEXT,
worktree_path TEXT,
branch_name TEXT,
detected_at TEXT NOT NULL DEFAULT (datetime('now')),
detected_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
processed_at TEXT
);
@ -49,7 +49,7 @@ CREATE TABLE IF NOT EXISTS worktrees (
path TEXT NOT NULL,
branch_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'Active',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
merged_at TEXT,
merged_into TEXT
);
@ -62,5 +62,5 @@ CREATE TABLE IF NOT EXISTS notifications (
title TEXT NOT NULL,
message TEXT NOT NULL,
read INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);

View file

@ -4,8 +4,8 @@ CREATE TABLE IF NOT EXISTS agents (
role TEXT NOT NULL,
tool TEXT NOT NULL,
custom_prompt TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
ALTER TABLE watched_trackers ADD COLUMN analyst_agent_id TEXT;

View file

@ -12,8 +12,8 @@ SELECT
'codex',
'',
1,
datetime('now'),
datetime('now')
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE NOT EXISTS (
SELECT 1 FROM agents WHERE role = 'analyst' AND is_default = 1
);
@ -26,8 +26,8 @@ SELECT
'claude_code',
'',
1,
datetime('now'),
datetime('now')
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE NOT EXISTS (
SELECT 1 FROM agents WHERE role = 'developer' AND is_default = 1
);

View file

@ -6,8 +6,8 @@ CREATE TABLE IF NOT EXISTS project_modules (
description TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
config_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
UNIQUE(project_id, module_key)
);
@ -19,8 +19,8 @@ CREATE TABLE IF NOT EXISTS project_live_sessions (
agent_id TEXT NOT NULL REFERENCES agents(id),
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_live_sessions_project_id ON project_live_sessions(project_id);
@ -30,7 +30,7 @@ CREATE TABLE IF NOT EXISTS project_live_messages (
session_id TEXT NOT NULL REFERENCES project_live_sessions(id) ON DELETE CASCADE,
sender TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_live_messages_session_id ON project_live_messages(session_id);
@ -44,7 +44,7 @@ CREATE TABLE IF NOT EXISTS project_agent_tasks (
status TEXT NOT NULL DEFAULT 'pending',
result TEXT,
error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
started_at TEXT,
finished_at TEXT
);
@ -71,8 +71,8 @@ SELECT
'Surveille Tuleap et lance le pipeline analyste/developpeur.',
1,
'{}',
datetime('now'),
datetime('now')
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
FROM projects p;
INSERT OR IGNORE INTO project_modules (
@ -94,8 +94,8 @@ SELECT
'Discussion live avec un agent sur le contexte du projet.',
1,
'{}',
datetime('now'),
datetime('now')
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
FROM projects p;
INSERT OR IGNORE INTO project_modules (
@ -117,6 +117,6 @@ SELECT
'File de tâches asynchrones traitées par des agents pré-définis.',
1,
'{}',
datetime('now'),
datetime('now')
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
FROM projects p;

View file

@ -0,0 +1,93 @@
BEGIN;
UPDATE projects
SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', created_at)
WHERE created_at IS NOT NULL
AND strftime('%Y-%m-%dT%H:%M:%fZ', created_at) IS NOT NULL;
UPDATE watched_trackers
SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', created_at)
WHERE created_at IS NOT NULL
AND strftime('%Y-%m-%dT%H:%M:%fZ', created_at) IS NOT NULL;
UPDATE watched_trackers
SET last_polled_at = strftime('%Y-%m-%dT%H:%M:%fZ', last_polled_at)
WHERE last_polled_at IS NOT NULL
AND strftime('%Y-%m-%dT%H:%M:%fZ', last_polled_at) IS NOT NULL;
UPDATE processed_tickets
SET detected_at = strftime('%Y-%m-%dT%H:%M:%fZ', detected_at)
WHERE detected_at IS NOT NULL
AND strftime('%Y-%m-%dT%H:%M:%fZ', detected_at) IS NOT NULL;
UPDATE processed_tickets
SET processed_at = strftime('%Y-%m-%dT%H:%M:%fZ', processed_at)
WHERE processed_at IS NOT NULL
AND strftime('%Y-%m-%dT%H:%M:%fZ', processed_at) IS NOT NULL;
UPDATE worktrees
SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', created_at)
WHERE created_at IS NOT NULL
AND strftime('%Y-%m-%dT%H:%M:%fZ', created_at) IS NOT NULL;
UPDATE worktrees
SET merged_at = strftime('%Y-%m-%dT%H:%M:%fZ', merged_at)
WHERE merged_at IS NOT NULL
AND strftime('%Y-%m-%dT%H:%M:%fZ', merged_at) IS NOT NULL;
UPDATE notifications
SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', created_at)
WHERE created_at IS NOT NULL
AND strftime('%Y-%m-%dT%H:%M:%fZ', created_at) IS NOT NULL;
UPDATE agents
SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', created_at)
WHERE created_at IS NOT NULL
AND strftime('%Y-%m-%dT%H:%M:%fZ', created_at) IS NOT NULL;
UPDATE agents
SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', updated_at)
WHERE updated_at IS NOT NULL
AND strftime('%Y-%m-%dT%H:%M:%fZ', updated_at) IS NOT NULL;
UPDATE project_modules
SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', created_at)
WHERE created_at IS NOT NULL
AND strftime('%Y-%m-%dT%H:%M:%fZ', created_at) IS NOT NULL;
UPDATE project_modules
SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', updated_at)
WHERE updated_at IS NOT NULL
AND strftime('%Y-%m-%dT%H:%M:%fZ', updated_at) IS NOT NULL;
UPDATE project_live_sessions
SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', created_at)
WHERE created_at IS NOT NULL
AND strftime('%Y-%m-%dT%H:%M:%fZ', created_at) IS NOT NULL;
UPDATE project_live_sessions
SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', updated_at)
WHERE updated_at IS NOT NULL
AND strftime('%Y-%m-%dT%H:%M:%fZ', updated_at) IS NOT NULL;
UPDATE project_live_messages
SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', created_at)
WHERE created_at IS NOT NULL
AND strftime('%Y-%m-%dT%H:%M:%fZ', created_at) IS NOT NULL;
UPDATE project_agent_tasks
SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', created_at)
WHERE created_at IS NOT NULL
AND strftime('%Y-%m-%dT%H:%M:%fZ', created_at) IS NOT NULL;
UPDATE project_agent_tasks
SET started_at = strftime('%Y-%m-%dT%H:%M:%fZ', started_at)
WHERE started_at IS NOT NULL
AND strftime('%Y-%m-%dT%H:%M:%fZ', started_at) IS NOT NULL;
UPDATE project_agent_tasks
SET finished_at = strftime('%Y-%m-%dT%H:%M:%fZ', finished_at)
WHERE finished_at IS NOT NULL
AND strftime('%Y-%m-%dT%H:%M:%fZ', finished_at) IS NOT NULL;
COMMIT;

View file

@ -7,6 +7,7 @@ const MIGRATION_003: &str = include_str!("../migrations/003_add_agents.sql");
const MIGRATION_004: &str = include_str!("../migrations/004_default_agents.sql");
const MIGRATION_005: &str = include_str!("../migrations/005_orchestration_modules_chat_tasks.sql");
const MIGRATION_006: &str = include_str!("../migrations/006_processed_tickets_unique_index.sql");
const MIGRATION_007: &str = include_str!("../migrations/007_normalize_timestamps_rfc3339.sql");
pub fn init(db_path: &Path) -> Result<Connection> {
let conn = Connection::open(db_path)?;
@ -56,6 +57,10 @@ fn migrate(conn: &Connection) -> Result<()> {
conn.execute_batch(MIGRATION_006)?;
conn.pragma_update(None, "user_version", 6)?;
}
if version < 7 {
conn.execute_batch(MIGRATION_007)?;
conn.pragma_update(None, "user_version", 7)?;
}
Ok(())
}
@ -111,7 +116,7 @@ mod tests {
let version: i32 = conn
.pragma_query_value(None, "user_version", |row| row.get(0))
.unwrap();
assert_eq!(version, 6);
assert_eq!(version, 7);
}
#[test]

View file

@ -146,9 +146,10 @@ impl ProcessedTicket {
}
pub fn set_developer_report(conn: &Connection, id: &str, report: &str) -> Result<()> {
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"UPDATE processed_tickets SET developer_report = ?1, processed_at = datetime('now') WHERE id = ?2",
params![report, id],
"UPDATE processed_tickets SET developer_report = ?1, processed_at = ?2 WHERE id = ?3",
params![report, now, id],
)?;
Ok(())
}
@ -177,9 +178,10 @@ impl ProcessedTicket {
}
pub fn set_error(conn: &Connection, id: &str, error_message: &str) -> Result<()> {
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"UPDATE processed_tickets SET status = 'Error', analyst_report = COALESCE(analyst_report, '') || ?1, processed_at = datetime('now') WHERE id = ?2",
params![error_message, id],
"UPDATE processed_tickets SET status = 'Error', analyst_report = COALESCE(analyst_report, '') || ?1, processed_at = ?2 WHERE id = ?3",
params![error_message, now, id],
)?;
Ok(())
}
@ -378,8 +380,15 @@ mod tests {
ProcessedTicket::set_developer_report(&conn, &ticket.id, "Fixed in main.rs").unwrap();
let updated = ProcessedTicket::get_by_id(&conn, &ticket.id).unwrap();
assert_eq!(updated.developer_report.unwrap(), "Fixed in main.rs");
assert!(updated.processed_at.is_some());
assert_eq!(
updated.developer_report.as_deref(),
Some("Fixed in main.rs")
);
let processed_at = updated
.processed_at
.as_deref()
.expect("processed_at should be set");
assert!(chrono::DateTime::parse_from_rfc3339(processed_at).is_ok());
}
#[test]
@ -422,6 +431,14 @@ mod tests {
ProcessedTicket::set_error(&conn, &ticket.id, "CLI timeout after 600s").unwrap();
let updated = ProcessedTicket::get_by_id(&conn, &ticket.id).unwrap();
assert_eq!(updated.status, "Error");
assert_eq!(updated.analyst_report.unwrap(), "CLI timeout after 600s");
assert_eq!(
updated.analyst_report.as_deref(),
Some("CLI timeout after 600s")
);
let processed_at = updated
.processed_at
.as_deref()
.expect("processed_at should be set");
assert!(chrono::DateTime::parse_from_rfc3339(processed_at).is_ok());
}
}

View file

@ -516,7 +516,11 @@ mod tests {
.expect("update_last_polled should succeed");
let updated = WatchedTracker::get_by_id(&conn, &created.id).unwrap();
assert!(updated.last_polled_at.is_some());
let last_polled_at = updated
.last_polled_at
.as_deref()
.expect("last_polled_at should be set");
assert!(chrono::DateTime::parse_from_rfc3339(last_polled_at).is_ok());
}
#[test]

View file

@ -88,9 +88,10 @@ impl Worktree {
}
pub fn set_merged(conn: &Connection, id: &str, target_branch: &str) -> Result<()> {
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"UPDATE worktrees SET status = 'Merged', merged_at = datetime('now'), merged_into = ?1 WHERE id = ?2",
params![target_branch, id],
"UPDATE worktrees SET status = 'Merged', merged_at = ?1, merged_into = ?2 WHERE id = ?3",
params![now, target_branch, id],
)?;
Ok(())
}
@ -206,7 +207,11 @@ mod tests {
let updated = Worktree::get_by_id(&conn, &wt.id).unwrap();
assert_eq!(updated.status, "Merged");
assert_eq!(updated.merged_into.unwrap(), "feature/login");
assert!(updated.merged_at.is_some());
let merged_at = updated
.merged_at
.as_deref()
.expect("merged_at should be set");
assert!(chrono::DateTime::parse_from_rfc3339(merged_at).is_ok());
}
#[test]

View file

@ -84,12 +84,12 @@ fn should_poll(tracker: &WatchedTracker) -> bool {
Some(s) => s,
};
let last = match chrono::DateTime::parse_from_rfc3339(last_polled_at) {
Ok(dt) => dt,
Err(e) => {
let last = match parse_timestamp(last_polled_at) {
Some(dt) => dt,
None => {
eprintln!(
"poller: failed to parse last_polled_at '{}': {}",
last_polled_at, e
"poller: failed to parse last_polled_at '{}': unsupported format",
last_polled_at
);
return true; // Treat as never polled on parse error
}
@ -99,6 +99,21 @@ fn should_poll(tracker: &WatchedTracker) -> bool {
elapsed >= tracker.polling_interval as i64
}
fn parse_timestamp(value: &str) -> Option<chrono::DateTime<chrono::Utc>> {
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(value) {
return Some(dt.with_timezone(&chrono::Utc));
}
if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S%.f") {
return Some(chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
naive,
chrono::Utc,
));
}
None
}
async fn poll_single_tracker(
db: &Arc<Mutex<Connection>>,
client: &TuleapClient,
@ -230,3 +245,26 @@ async fn poll_single_tracker(
}),
);
}
#[cfg(test)]
mod tests {
use super::parse_timestamp;
#[test]
fn parse_timestamp_supports_rfc3339() {
let parsed = parse_timestamp("2026-04-16T10:15:30.123Z");
assert!(parsed.is_some());
}
#[test]
fn parse_timestamp_supports_legacy_sqlite_datetime() {
let parsed = parse_timestamp("2026-04-16 10:15:30");
assert!(parsed.is_some());
}
#[test]
fn parse_timestamp_rejects_invalid_values() {
let parsed = parse_timestamp("not-a-date");
assert!(parsed.is_none());
}
}