From 64d28bb868d7bde95fea3ff870b5c0f092556256 Mon Sep 17 00:00:00 2001 From: Rahix Date: Thu, 5 Mar 2026 20:39:26 +0100 Subject: [PATCH 1/5] Collect start date and reminder state Already fetch the start date and reminder state for issues immediately and have them available in the struct Task. --- Cargo.lock | 1 + Cargo.toml | 1 + src/collect.rs | 136 ++++++++++++++++++++++++++++++++++++++++------- src/reminder.rs | 14 ++--- src/scheduler.rs | 8 +-- src/task.rs | 28 ++++++---- src/util.rs | 3 +- 7 files changed, 148 insertions(+), 43 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 391b5a6..5f40134 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -217,6 +217,7 @@ dependencies = [ "anyhow", "env_logger", "forgejo-api", + "futures", "jiff", "log", "serde", diff --git a/Cargo.toml b/Cargo.toml index ac1f8a8..0eb5913 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,3 +17,4 @@ serde_json = "1.0.140" tokio = { version = "1.45.0", features = ["full"] } time = "0.3.41" jiff = "0.2.22" +futures = "0.3.32" diff --git a/src/collect.rs b/src/collect.rs index 6d73e81..b1248d4 100644 --- a/src/collect.rs +++ b/src/collect.rs @@ -3,51 +3,147 @@ use crate::util; use anyhow::Context as _; use forgejo_api::structs::Issue; +use futures::stream::StreamExt as _; +use futures::stream::TryStreamExt as _; pub async fn collect_tasks(ctx: &crate::Context) -> anyhow::Result> { let issues = list_all_issues(ctx).await?; - let tasks = issues - .into_iter() - .map(|issue| { - task_from_issue(&issue).with_context(|| { + let tasks = futures::stream::iter(issues) + .map(|issue| async move { + task_from_issue(ctx, &issue).await.with_context(|| { format!( "Error while converting issue #{} to task", issue.number.unwrap_or(-1) ) }) }) - .collect::>>()?; + .buffer_unordered(8) + .try_collect::>() + .await?; Ok(tasks) } -fn task_from_issue(issue: &Issue) -> anyhow::Result { - Ok(task::Task { +async fn task_from_issue(ctx: &crate::Context, issue: &Issue) -> anyhow::Result { + let task = task::Task { issue_number: issue.get_number()?, title: issue.get_title()?, - state: task_state_from_issue(issue)?, + state: task_state_from_issue(ctx, issue).await?, recurring: task_recurring_from_issue_labels(issue)?, + }; + log::debug!( + "\ +Collected Task #{} — {:?} +- State: {:?} +- Recurring: {:?}", + task.issue_number, + task.title, + task.state, + task.recurring, + ); + Ok(task) +} + +async fn task_state_from_issue(ctx: &crate::Context, issue: &Issue) -> anyhow::Result { + match issue.get_state()? { + forgejo_api::structs::StateType::Open => { + if issue.due_date.is_some() { + Ok(task::State::Scheduled( + issue_get_schedule_info(ctx, issue).await?, + )) + } else { + Ok(task::State::Open) + } + } + forgejo_api::structs::StateType::Closed => Ok(task::State::Completed { + date: util::time_to_jiff( + issue + .closed_at + .context("Closed issue without a closed_at date")?, + ), + }), + } +} + +async fn issue_get_schedule_info( + ctx: &crate::Context, + issue: &Issue, +) -> anyhow::Result { + let due_date = util::time_to_jiff( + issue + .due_date + .expect("schedule info for task without due_date"), + ); + + let issue_number = issue.number.context("issue without number")?; + + let mut timeline = ctx + .forgejo + .issue_get_comments_and_timeline( + &ctx.owner, + &ctx.repo, + issue_number, + forgejo_api::structs::IssueGetCommentsAndTimelineQuery { + ..Default::default() + }, + ) + .all() + .await + .context("Failed to fetch timeline")?; + + // Should not be necessary, but let's be safe. + timeline.sort_by_key(|event| event.created_at); + + let mut start_date = + util::time_to_jiff(issue.created_at.context("no created_at date for issue")?); + + 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. + start_date = created_at_time(event)?; + break; + } + (Some("comment"), Some(body)) => { + if body.contains(crate::reminder::SIGNATURE_URGENT) { + has_urgent_reminder = Some(created_at_time(event)?); + } else if body.contains(crate::reminder::SIGNATURE_BASIC) { + has_reminder = Some(created_at_time(event)?); + } + } + // Ignore all other events. + _ => (), + } + } + + let reminded = if let Some(date) = has_urgent_reminder { + task::ReminderState::RemindedUrgently { date } + } else if let Some(date) = has_reminder { + task::ReminderState::Reminded { date } + } else { + task::ReminderState::NotReminded + }; + + Ok(task::ScheduledInfo { + due_date, + start_date, + reminded, }) } -fn task_state_from_issue(issue: &Issue) -> anyhow::Result { - match issue.get_state()? { - forgejo_api::structs::StateType::Open => Ok(task::State::Open { - due: issue.due_date.map(util::time_to_jiff), - }), - forgejo_api::structs::StateType::Closed => Ok(task::State::Completed { - date: util::time_to_jiff(issue - .closed_at - .context("Closed issue without a closed_at date")?), - }), - } +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,) + })?)) } fn task_recurring_from_issue_labels(issue: &Issue) -> anyhow::Result> { let labels = issue.get_label_names()?; let Some(recurring_label) = get_recurring_label(&labels)? else { - return Ok(None) + return Ok(None); }; let interval = match recurring_label { diff --git a/src/reminder.rs b/src/reminder.rs index aba0f3c..7a599e0 100644 --- a/src/reminder.rs +++ b/src/reminder.rs @@ -19,26 +19,26 @@ enum ReminderType { 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 { + let task::State::Scheduled(info) = &task.state else { continue; }; - let batching_interval = find_batching_interval(ctx, due_date, task); + let batching_interval = find_batching_interval(ctx, &info.due_date, task); log::debug!("Reminding {task} with interval {batching_interval:?}."); - if !is_time_to_remind(ctx, due_date, batching_interval) { + if !is_time_to_remind(ctx, &info.due_date, batching_interval) { log::debug!("Not yet time, skipping."); continue; } - if is_overdue(ctx, due_date) { + if is_overdue(ctx, &info.due_date) { log::debug!("Task {task} is already overdue!"); } 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) { + } else if &reminded_date < &info.due_date && is_overdue(ctx, &info.due_date) { log::info!("Task {task} is now overdue, reminding again urgently..."); remind_task(ctx, task, ReminderType::Urgent, batching_interval).await?; } else { @@ -61,8 +61,8 @@ pub async fn remind_all_tasks(ctx: &crate::Context, tasks: &[task::Task]) -> any Ok(()) } -const SIGNATURE_BASIC: &'static str = "\\[TaskBot Comment\\]"; -const SIGNATURE_URGENT: &'static str = "\\[TaskBot Comment (Urgent)\\]"; +pub const SIGNATURE_BASIC: &'static str = "\\[TaskBot Comment\\]"; +pub const SIGNATURE_URGENT: &'static str = "\\[TaskBot Comment (Urgent)\\]"; async fn check_task_reminded( ctx: &crate::Context, diff --git a/src/scheduler.rs b/src/scheduler.rs index 1079b81..5a1c46b 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -15,13 +15,13 @@ pub async fn reschedule_recurring_tasks( let completed_date = match &task.state { // Already scheduled - task::State::Open { due: Some(_) } => { + task::State::Scheduled(_) => { log::debug!("Task {task} is already scheduled. No action."); - continue - }, + continue; + } // Invalid state, will warn about this - task::State::Open { due: None } => { + task::State::Open => { log::warn!("Task {task} is recurring but has no due date. Scheduling from today."); &ctx.timestamp } diff --git a/src/task.rs b/src/task.rs index 1699ff1..c59d79a 100644 --- a/src/task.rs +++ b/src/task.rs @@ -15,22 +15,28 @@ pub struct Task { #[derive(Debug, Clone)] pub enum State { - /// The task is open and pending completion. - /// - /// An optional due date may be present. - Open { due: Option }, + /// The task is open but has no due date. + Open, + + /// The task is scheduled and waiting for completion. + Scheduled(ScheduledInfo), /// The task has been completed at the specified time. Completed { date: jiff::Zoned }, } -impl State { - pub fn due_date_if_open(&self) -> Option<&jiff::Zoned> { - match self { - State::Open { due } => due.as_ref(), - _ => None, - } - } +#[derive(Debug, Clone)] +pub struct ScheduledInfo { + pub due_date: jiff::Zoned, + pub start_date: jiff::Zoned, + pub reminded: ReminderState, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReminderState { + NotReminded, + Reminded { date: jiff::Zoned }, + RemindedUrgently { date: jiff::Zoned }, } #[derive(Debug, Clone)] diff --git a/src/util.rs b/src/util.rs index a80429d..81e1e47 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,7 +1,8 @@ pub fn time_to_jiff(t: time::OffsetDateTime) -> jiff::Zoned { let tz = jiff::tz::TimeZone::fixed(jiff::tz::offset(t.offset().whole_hours())); - jiff::Timestamp::new(t.unix_timestamp(), 0).unwrap() + jiff::Timestamp::new(t.unix_timestamp(), 0) + .unwrap() .to_zoned(tz) } From ceff3608c8017477202a87091451b5dcdd8a4d78 Mon Sep 17 00:00:00 2001 From: Rahix Date: Thu, 5 Mar 2026 20:49:45 +0100 Subject: [PATCH 2/5] Use precollected info about reminder status --- src/reminder.rs | 90 +++++++++++-------------------------------------- 1 file changed, 20 insertions(+), 70 deletions(-) diff --git a/src/reminder.rs b/src/reminder.rs index 7a599e0..6388997 100644 --- a/src/reminder.rs +++ b/src/reminder.rs @@ -24,10 +24,10 @@ pub async fn remind_all_tasks(ctx: &crate::Context, tasks: &[task::Task]) -> any }; let batching_interval = find_batching_interval(ctx, &info.due_date, task); - log::debug!("Reminding {task} with interval {batching_interval:?}."); + log::debug!("Reminder interval for {task} is {batching_interval:?}."); if !is_time_to_remind(ctx, &info.due_date, batching_interval) { - log::debug!("Not yet time, skipping."); + log::debug!("Not yet time to remind, skipping."); continue; } @@ -35,21 +35,27 @@ pub async fn remind_all_tasks(ctx: &crate::Context, tasks: &[task::Task]) -> any log::debug!("Task {task} is already overdue!"); } - if let Some((reminded_date, reminder_type)) = check_task_reminded(ctx, task).await? { - if reminder_type == ReminderType::Urgent { + match &info.reminded { + task::ReminderState::RemindedUrgently { .. } => { log::debug!("Was already reminded urgently, skipping."); - } else if &reminded_date < &info.due_date && is_overdue(ctx, &info.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; + task::ReminderState::Reminded { date } => { + if date < info.due_date && is_overdue(ctx, &info.due_date) { + log::info!("Task {task} is now overdue, reminding again urgently..."); + remind_task(ctx, task, ReminderType::Urgent, batching_interval).await?; + reminded += 1; + } else { + log::debug!("Was already reminded, skipping."); + } + } + + task::ReminderState::NotReminded => { + log::info!("Reminding {task} ..."); + remind_task(ctx, task, ReminderType::Basic, batching_interval).await?; + reminded += 1; + } + } } if reminded == 0 { @@ -64,62 +70,6 @@ pub async fn remind_all_tasks(ctx: &crate::Context, tasks: &[task::Task]) -> any pub const SIGNATURE_BASIC: &'static str = "\\[TaskBot Comment\\]"; pub 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, From 6c58cd177499dcb1f3c25ece4a449ccaa92dd692 Mon Sep 17 00:00:00 2001 From: Rahix Date: Thu, 5 Mar 2026 20:51:15 +0100 Subject: [PATCH 3/5] Sort tasks after collection Makes debug output easier to read... --- src/collect.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/collect.rs b/src/collect.rs index b1248d4..9113b7f 100644 --- a/src/collect.rs +++ b/src/collect.rs @@ -9,7 +9,7 @@ use futures::stream::TryStreamExt as _; pub async fn collect_tasks(ctx: &crate::Context) -> anyhow::Result> { let issues = list_all_issues(ctx).await?; - let tasks = futures::stream::iter(issues) + let mut tasks = futures::stream::iter(issues) .map(|issue| async move { task_from_issue(ctx, &issue).await.with_context(|| { format!( @@ -22,6 +22,8 @@ pub async fn collect_tasks(ctx: &crate::Context) -> anyhow::Result>() .await?; + tasks.sort_by_key(|t| t.issue_number); + Ok(tasks) } From 9d8d77a356f2000443d959b4d58a188f174146b0 Mon Sep 17 00:00:00 2001 From: Rahix Date: Thu, 5 Mar 2026 20:55:39 +0100 Subject: [PATCH 4/5] Determine batching interval for non-recurring tasks deterministically Don't use current time to estimate batching interval for non-recurring tasks. --- src/reminder.rs | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/reminder.rs b/src/reminder.rs index 6388997..42be76d 100644 --- a/src/reminder.rs +++ b/src/reminder.rs @@ -23,7 +23,7 @@ pub async fn remind_all_tasks(ctx: &crate::Context, tasks: &[task::Task]) -> any continue; }; - let batching_interval = find_batching_interval(ctx, &info.due_date, task); + let batching_interval = find_batching_interval(task, info); log::debug!("Reminder interval for {task} is {batching_interval:?}."); if !is_time_to_remind(ctx, &info.due_date, batching_interval) { @@ -118,13 +118,7 @@ Hello again. This task is overdue, please take care of it ASAP!" Ok(()) } -fn find_batching_interval( - ctx: &crate::Context, - due_date: &jiff::Zoned, - task: &task::Task, -) -> BatchingInterval { - let time_until_due = due_date - &ctx.timestamp; - +fn find_batching_interval(task: &task::Task, info: &task::ScheduledInfo) -> BatchingInterval { if let Some(recurring) = &task.recurring { match &recurring.interval { task::RecurringInterval::Months(_) => BatchingInterval::Monthly, @@ -135,12 +129,19 @@ fn find_batching_interval( // For tasks that are not recurring, the batching interval is determined based on how // far in the future the task is due. - let weeks_until_due = time_until_due - .total((jiff::Unit::Week, jiff::SpanRelativeTo::days_are_24_hours())) + let task_duration = &info.due_date - &info.start_date; + + let task_weeks = task_duration + .total((jiff::Unit::Week, &info.start_date)) .unwrap(); - if weeks_until_due >= 3. { + + let task_months = task_duration + .total((jiff::Unit::Month, &info.start_date)) + .unwrap(); + + if task_months >= 1. { BatchingInterval::Monthly - } else if weeks_until_due >= 1. { + } else if task_weeks >= 1. { BatchingInterval::Weekly } else { BatchingInterval::OnTheDay From e0b0ae4bc86c70b61539c8c88a0b1ab8a5816ac3 Mon Sep 17 00:00:00 2001 From: Rahix Date: Thu, 5 Mar 2026 20:59:06 +0100 Subject: [PATCH 5/5] Clear all unused warnings --- src/context.rs | 3 ++- src/main.rs | 2 -- src/reminder.rs | 3 +-- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/context.rs b/src/context.rs index ff164c6..2ee3933 100644 --- a/src/context.rs +++ b/src/context.rs @@ -12,6 +12,7 @@ pub struct Context { /// URL of the repository page pub repo_url: url::Url, /// URL of the repository with authentication information attached + #[expect(unused)] pub repo_auth_url: url::Url, /// Timestamp "now" to be used in comparisons @@ -37,7 +38,7 @@ impl Context { } pub fn new_from_dev_fallback() -> anyhow::Result { - let mut f = std::fs::File::open(&dev_environment_path()?) + let f = std::fs::File::open(&dev_environment_path()?) .context("Failed to open dev environment config")?; let config: DevEnvironment = diff --git a/src/main.rs b/src/main.rs index 76e192c..219c28b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,3 @@ -#![allow(unused)] - mod collect; mod context; mod reminder; diff --git a/src/reminder.rs b/src/reminder.rs index 42be76d..8d102c1 100644 --- a/src/reminder.rs +++ b/src/reminder.rs @@ -1,5 +1,4 @@ use crate::task; -use crate::util; use anyhow::Context as _; @@ -76,7 +75,7 @@ async fn remind_task( reminder_type: ReminderType, batching_interval: BatchingInterval, ) -> anyhow::Result<()> { - let mut body = match (reminder_type, batching_interval) { + let body = match (reminder_type, batching_interval) { (ReminderType::Basic, BatchingInterval::Monthly) => format!( "\ Beep boop. This task is due this month, go take care of it!"