From 37140b5b5934da7f16b89816f772bb6827fa026c Mon Sep 17 00:00:00 2001 From: Rahix Date: Mon, 2 Mar 2026 10:13:11 +0100 Subject: [PATCH] Implement reminding --- src/main.rs | 2 +- src/reminder.rs | 140 ++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 138 insertions(+), 4 deletions(-) diff --git a/src/main.rs b/src/main.rs index e7a429d..22cbfc4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,7 +17,7 @@ async fn run() -> anyhow::Result<()> { scheduler::reschedule_recurring_tasks(&ctx, &tasks).await?; - reminder::remind_due_tasks(&ctx, &tasks).await?; + reminder::remind_all_tasks(&ctx, &tasks).await?; Ok(()) } diff --git a/src/reminder.rs b/src/reminder.rs index 6c28026..aba0f3c 100644 --- a/src/reminder.rs +++ b/src/reminder.rs @@ -10,7 +10,14 @@ enum BatchingInterval { OnTheDay, } -pub async fn remind_due_tasks(ctx: &crate::Context, tasks: &[task::Task]) -> anyhow::Result<()> { +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ReminderType { + Basic, + Urgent, +} + +pub async fn remind_all_tasks(ctx: &crate::Context, tasks: &[task::Task]) -> anyhow::Result<()> { + let mut reminded = 0; for task in tasks { let Some(due_date) = task.state.due_date_if_open() else { continue; @@ -25,12 +32,139 @@ pub async fn remind_due_tasks(ctx: &crate::Context, tasks: &[task::Task]) -> any } if is_overdue(ctx, due_date) { - log::info!("Task {task} is already overdue!"); + log::debug!("Task {task} is already overdue!"); } - log::warn!("TODO: Remind {task}"); + if let Some((reminded_date, reminder_type)) = check_task_reminded(ctx, task).await? { + if reminder_type == ReminderType::Urgent { + log::debug!("Was already reminded urgently, skipping."); + } else if &reminded_date < due_date && is_overdue(ctx, due_date) { + log::info!("Task {task} is now overdue, reminding again urgently..."); + remind_task(ctx, task, ReminderType::Urgent, batching_interval).await?; + } else { + log::debug!("Was already reminded, skipping."); + } + continue; + } + + log::info!("Reminding {task} ..."); + remind_task(ctx, task, ReminderType::Basic, batching_interval).await?; + reminded += 1; } + if reminded == 0 { + log::info!("No tasks needed to be reminded."); + } else { + log::debug!("Reminded {reminded} tasks."); + } + + Ok(()) +} + +const SIGNATURE_BASIC: &'static str = "\\[TaskBot Comment\\]"; +const SIGNATURE_URGENT: &'static str = "\\[TaskBot Comment (Urgent)\\]"; + +async fn check_task_reminded( + ctx: &crate::Context, + task: &task::Task, +) -> anyhow::Result> { + let mut timeline = ctx + .forgejo + .issue_get_comments_and_timeline( + &ctx.owner, + &ctx.repo, + task.issue_number.into(), + forgejo_api::structs::IssueGetCommentsAndTimelineQuery { + ..Default::default() + }, + ) + .all() + .await + .with_context(|| format!("Failed to fetch timeline for {task}"))?; + + // Should not be necessary, but let's be safe. + timeline.sort_by_key(|event| event.created_at); + + let mut has_reminder = None; + let mut has_urgent_reminder = None; + for event in timeline.iter().rev() { + match (event.r#type.as_deref(), &event.body) { + (Some("reopen"), _) => { + // When we find the event where the issue was last reopened, we stop. + break; + } + (Some("comment"), Some(body)) => { + if body.contains(SIGNATURE_URGENT) { + log::debug!("Found urgent reminder for issue #{}.", task.issue_number); + has_urgent_reminder = Some(created_at_time(event)?); + } else if body.contains(SIGNATURE_BASIC) { + log::debug!("Found reminder for issue #{}.", task.issue_number); + has_reminder = Some(created_at_time(event)?); + } + } + // Ignore all other events. + _ => (), + } + } + + let res = has_urgent_reminder + .map(|t| (t, ReminderType::Urgent)) + .or_else(|| has_reminder.map(|t| (t, ReminderType::Basic))); + + Ok(res) +} + +fn created_at_time(ev: &forgejo_api::structs::TimelineComment) -> anyhow::Result { + Ok(util::time_to_jiff(ev.created_at.with_context(|| { + format!("Timeline event {:?} without created_at time", ev.r#type,) + })?)) +} + +async fn remind_task( + ctx: &crate::Context, + task: &task::Task, + reminder_type: ReminderType, + batching_interval: BatchingInterval, +) -> anyhow::Result<()> { + let mut body = match (reminder_type, batching_interval) { + (ReminderType::Basic, BatchingInterval::Monthly) => format!( + "\ +Beep boop. This task is due this month, go take care of it!" + ), + (ReminderType::Basic, BatchingInterval::Weekly) => format!( + "\ +Beep boop. This task is due this week, go take care of it!" + ), + (ReminderType::Basic, BatchingInterval::OnTheDay) => format!( + "\ +Beep boop. This task is due today, go take care of it!" + ), + (ReminderType::Urgent, _) => format!( + "\ +Hello again. This task is overdue, please take care of it ASAP!" + ), + }; + + let signature = match reminder_type { + ReminderType::Basic => SIGNATURE_BASIC, + ReminderType::Urgent => SIGNATURE_URGENT, + }; + + let body = body + "\n\n" + signature; + + ctx.forgejo + .issue_create_comment( + &ctx.owner, + &ctx.repo, + task.issue_number.into(), + forgejo_api::structs::CreateIssueCommentOption { + body: body, + updated_at: None, + }, + ) + .await + .with_context(|| format!("Could not create comment to remind task {task}"))?; + Ok(()) }