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) }