diff --git a/Cargo.lock b/Cargo.lock index 5f40134..391b5a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -217,7 +217,6 @@ dependencies = [ "anyhow", "env_logger", "forgejo-api", - "futures", "jiff", "log", "serde", diff --git a/Cargo.toml b/Cargo.toml index 0eb5913..ac1f8a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,4 +17,3 @@ 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 9113b7f..6d73e81 100644 --- a/src/collect.rs +++ b/src/collect.rs @@ -3,149 +3,51 @@ 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 mut tasks = futures::stream::iter(issues) - .map(|issue| async move { - task_from_issue(ctx, &issue).await.with_context(|| { + let tasks = issues + .into_iter() + .map(|issue| { + task_from_issue(&issue).with_context(|| { format!( "Error while converting issue #{} to task", issue.number.unwrap_or(-1) ) }) }) - .buffer_unordered(8) - .try_collect::>() - .await?; - - tasks.sort_by_key(|t| t.issue_number); + .collect::>>()?; Ok(tasks) } -async fn task_from_issue(ctx: &crate::Context, issue: &Issue) -> anyhow::Result { - let task = task::Task { +fn task_from_issue(issue: &Issue) -> anyhow::Result { + Ok(task::Task { issue_number: issue.get_number()?, title: issue.get_title()?, - state: task_state_from_issue(ctx, issue).await?, + state: task_state_from_issue(issue)?, 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 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_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 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/context.rs b/src/context.rs index 2ee3933..ff164c6 100644 --- a/src/context.rs +++ b/src/context.rs @@ -12,7 +12,6 @@ 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 @@ -38,7 +37,7 @@ impl Context { } pub fn new_from_dev_fallback() -> anyhow::Result { - let f = std::fs::File::open(&dev_environment_path()?) + let mut 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 219c28b..76e192c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +#![allow(unused)] + mod collect; mod context; mod reminder; diff --git a/src/reminder.rs b/src/reminder.rs index 8d102c1..aba0f3c 100644 --- a/src/reminder.rs +++ b/src/reminder.rs @@ -1,4 +1,5 @@ use crate::task; +use crate::util; use anyhow::Context as _; @@ -18,43 +19,37 @@ 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 task::State::Scheduled(info) = &task.state else { + let Some(due_date) = task.state.due_date_if_open() else { continue; }; - let batching_interval = find_batching_interval(task, info); - log::debug!("Reminder interval for {task} is {batching_interval:?}."); + let batching_interval = find_batching_interval(ctx, due_date, task); + log::debug!("Reminding {task} with interval {batching_interval:?}."); - if !is_time_to_remind(ctx, &info.due_date, batching_interval) { - log::debug!("Not yet time to remind, skipping."); + if !is_time_to_remind(ctx, due_date, batching_interval) { + log::debug!("Not yet time, skipping."); continue; } - if is_overdue(ctx, &info.due_date) { + if is_overdue(ctx, due_date) { log::debug!("Task {task} is already overdue!"); } - match &info.reminded { - task::ReminderState::RemindedUrgently { .. } => { + 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."); } - - 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; - } + continue; } + + log::info!("Reminding {task} ..."); + remind_task(ctx, task, ReminderType::Basic, batching_interval).await?; + reminded += 1; } if reminded == 0 { @@ -66,8 +61,64 @@ pub async fn remind_all_tasks(ctx: &crate::Context, tasks: &[task::Task]) -> any Ok(()) } -pub const SIGNATURE_BASIC: &'static str = "\\[TaskBot Comment\\]"; -pub const SIGNATURE_URGENT: &'static str = "\\[TaskBot Comment (Urgent)\\]"; +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, @@ -75,7 +126,7 @@ async fn remind_task( reminder_type: ReminderType, batching_interval: BatchingInterval, ) -> anyhow::Result<()> { - let body = match (reminder_type, batching_interval) { + 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!" @@ -117,7 +168,13 @@ Hello again. This task is overdue, please take care of it ASAP!" Ok(()) } -fn find_batching_interval(task: &task::Task, info: &task::ScheduledInfo) -> BatchingInterval { +fn find_batching_interval( + ctx: &crate::Context, + due_date: &jiff::Zoned, + task: &task::Task, +) -> BatchingInterval { + let time_until_due = due_date - &ctx.timestamp; + if let Some(recurring) = &task.recurring { match &recurring.interval { task::RecurringInterval::Months(_) => BatchingInterval::Monthly, @@ -128,19 +185,12 @@ fn find_batching_interval(task: &task::Task, info: &task::ScheduledInfo) -> Batc // For tasks that are not recurring, the batching interval is determined based on how // far in the future the task is due. - let task_duration = &info.due_date - &info.start_date; - - let task_weeks = task_duration - .total((jiff::Unit::Week, &info.start_date)) + let weeks_until_due = time_until_due + .total((jiff::Unit::Week, jiff::SpanRelativeTo::days_are_24_hours())) .unwrap(); - - let task_months = task_duration - .total((jiff::Unit::Month, &info.start_date)) - .unwrap(); - - if task_months >= 1. { + if weeks_until_due >= 3. { BatchingInterval::Monthly - } else if task_weeks >= 1. { + } else if weeks_until_due >= 1. { BatchingInterval::Weekly } else { BatchingInterval::OnTheDay diff --git a/src/scheduler.rs b/src/scheduler.rs index 5a1c46b..1079b81 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::Scheduled(_) => { + task::State::Open { due: Some(_) } => { log::debug!("Task {task} is already scheduled. No action."); - continue; - } + continue + }, // Invalid state, will warn about this - task::State::Open => { + task::State::Open { due: None } => { 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 c59d79a..1699ff1 100644 --- a/src/task.rs +++ b/src/task.rs @@ -15,28 +15,22 @@ pub struct Task { #[derive(Debug, Clone)] pub enum State { - /// The task is open but has no due date. - Open, - - /// The task is scheduled and waiting for completion. - Scheduled(ScheduledInfo), + /// The task is open and pending completion. + /// + /// An optional due date may be present. + Open { due: Option }, /// The task has been completed at the specified time. Completed { date: jiff::Zoned }, } -#[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 }, +impl State { + pub fn due_date_if_open(&self) -> Option<&jiff::Zoned> { + match self { + State::Open { due } => due.as_ref(), + _ => None, + } + } } #[derive(Debug, Clone)] diff --git a/src/util.rs b/src/util.rs index 81e1e47..a80429d 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,8 +1,7 @@ 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) }