From adc6b7866a6ad7c281a45d6a3fcebb9bcb22e164 Mon Sep 17 00:00:00 2001 From: Rahix Date: Sun, 1 Mar 2026 16:42:18 +0100 Subject: [PATCH] Stub rescheduling algo --- src/collect.rs | 121 +++++++++++++++++++++++++++++++++++++++++++---- src/main.rs | 2 +- src/scheduler.rs | 35 +++++++++++++- src/task.rs | 40 +++++++++++++++- 4 files changed, 186 insertions(+), 12 deletions(-) diff --git a/src/collect.rs b/src/collect.rs index 48014c8..5e4a62e 100644 --- a/src/collect.rs +++ b/src/collect.rs @@ -1,6 +1,7 @@ use crate::task; use anyhow::Context as _; +use forgejo_api::structs::Issue; pub async fn collect_tasks(ctx: &crate::Context) -> anyhow::Result> { let issues = list_all_issues(ctx).await?; @@ -20,18 +21,88 @@ pub async fn collect_tasks(ctx: &crate::Context) -> anyhow::Result anyhow::Result { +fn task_from_issue(issue: &Issue) -> anyhow::Result { Ok(task::Task { - issue_number: issue - .number - .context("Missing issue number")? - .try_into() - .context("Failed converting issue number")?, - title: issue.title.as_ref().context("Missing issue title")?.clone(), + issue_number: issue.get_number()?, + title: issue.get_title()?, + state: task_state_from_issue(issue)?, + recurring: task_recurring_from_issue_labels(issue)?, }) } -async fn list_all_issues(ctx: &crate::Context) -> anyhow::Result> { +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, + }), + forgejo_api::structs::StateType::Closed => Ok(task::State::Completed { + date: 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) + }; + + let interval = match recurring_label { + // Months + "Every Month" => task::RecurringInterval::Months(1), + "Every 2 Months" => task::RecurringInterval::Months(2), + "Every 3 Months" => task::RecurringInterval::Months(3), + "Every 4 Months" => task::RecurringInterval::Months(4), + "Every 5 Months" => task::RecurringInterval::Months(5), + "Every 6 Months" => task::RecurringInterval::Months(6), + "Every 7 Months" => task::RecurringInterval::Months(7), + "Every 8 Months" => task::RecurringInterval::Months(8), + "Every 9 Months" => task::RecurringInterval::Months(9), + "Every 10 Months" => task::RecurringInterval::Months(10), + "Every 11 Months" => task::RecurringInterval::Months(11), + "Every Year" => task::RecurringInterval::Months(12), + // Weeks + "Every Week" => task::RecurringInterval::Weeks(1), + "Every 2 Weeks" => task::RecurringInterval::Weeks(2), + "Every 3 Weeks" => task::RecurringInterval::Weeks(3), + "Every 4 Weeks" => task::RecurringInterval::Weeks(4), + "Every 5 Weeks" => task::RecurringInterval::Weeks(5), + "Every 6 Weeks" => task::RecurringInterval::Weeks(6), + "Every 7 Weeks" => task::RecurringInterval::Weeks(7), + "Every 8 Weeks" => task::RecurringInterval::Weeks(8), + "Every 9 Weeks" => task::RecurringInterval::Weeks(9), + "Every 10 Weeks" => task::RecurringInterval::Weeks(10), + // Days + "Every Day" => task::RecurringInterval::Days(1), + "Every 2 Days" => task::RecurringInterval::Days(2), + "Every 3 Days" => task::RecurringInterval::Days(3), + "Every 4 Days" => task::RecurringInterval::Days(4), + "Every 5 Days" => task::RecurringInterval::Days(5), + "Every 6 Days" => task::RecurringInterval::Days(6), + // Fallback + s => anyhow::bail!("Unknown recurring interval: {s:?}"), + }; + + Ok(Some(task::Recurring { interval })) +} + +fn get_recurring_label(labels: &[String]) -> anyhow::Result> { + let mut recurring_labels_iter = labels + .iter() + .filter_map(|label| label.strip_prefix("Recurring/")); + let Some(recurring_label) = recurring_labels_iter.next() else { + // No recurring label means this is not a recurring task + return Ok(None); + }; + if recurring_labels_iter.next().is_some() { + anyhow::bail!("More than one Recurring/ label found on issue"); + } + Ok(Some(recurring_label)) +} + +async fn list_all_issues(ctx: &crate::Context) -> anyhow::Result> { let issues = ctx .forgejo .issue_list_issues( @@ -51,3 +122,37 @@ async fn list_all_issues(ctx: &crate::Context) -> anyhow::Result anyhow::Result; + fn get_title(&self) -> anyhow::Result; + fn get_state(&self) -> anyhow::Result; + fn get_label_names(&self) -> anyhow::Result>; +} + +impl IssueExt for Issue { + fn get_number(&self) -> anyhow::Result { + Ok(self + .number + .context("Missing issue number")? + .try_into() + .context("Failed converting issue number to u64")?) + } + + fn get_title(&self) -> anyhow::Result { + Ok(self.title.as_ref().context("Missing issue title")?.clone()) + } + + fn get_state(&self) -> anyhow::Result { + Ok(self.state.context("Issue has no state")?) + } + + fn get_label_names(&self) -> anyhow::Result> { + let labels = self.labels.as_ref().context("Issue without labels list")?; + let label_names = labels + .into_iter() + .map(|label| label.name.as_ref().context("Label without name").cloned()) + .collect::>>()?; + Ok(label_names) + } +} diff --git a/src/main.rs b/src/main.rs index 990ee8e..d0c3b8c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,7 @@ async fn run() -> anyhow::Result<()> { let tasks = collect::collect_tasks(&ctx).await?; - scheduler::schedule_recurring_tasks(&ctx, &tasks).await?; + scheduler::reschedule_recurring_tasks(&ctx, &tasks).await?; Ok(()) } diff --git a/src/scheduler.rs b/src/scheduler.rs index a6a2352..bff5520 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -1,9 +1,40 @@ use crate::task; -pub async fn schedule_recurring_tasks( +pub async fn reschedule_recurring_tasks( ctx: &crate::Context, tasks: &[task::Task], ) -> anyhow::Result<()> { - dbg!(tasks); + let mut rescheduled = 0; + for task in tasks { + let task::State::Completed { + date: completed_date, + } = &task.state + else { + continue; + }; + + let Some(recurring) = &task.recurring else { + continue; + }; + + let due_date = next_due_date(*completed_date, recurring.interval); + + log::info!("Rescheduling {task} for {due_date}..."); + reopen_issue_with_due_date(ctx, task, due_date).await?; + rescheduled += 1; + } + + if rescheduled == 0 { + log::info!("No tasks need rescheduling."); + } + + Ok(()) +} + +fn next_due_date(completed_date: time::OffsetDateTime, interval: task::RecurringInterval) -> time::OffsetDateTime { + todo!() +} + +async fn reopen_issue_with_due_date(ctx: &crate::Context, task: &task::Task, due_date: time::OffsetDateTime) -> anyhow::Result<()> { todo!() } diff --git a/src/task.rs b/src/task.rs index d522cae..97de9ff 100644 --- a/src/task.rs +++ b/src/task.rs @@ -1,5 +1,43 @@ -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone)] pub struct Task { + /// Issue Number for referencing the task pub issue_number: u64, + + /// Human-readable summary of the task pub title: String, + + /// Whether the task is open or has been completed + pub state: State, + + /// Whether the task is a recurring one and metadata for rescheduling + pub recurring: Option, +} + +#[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 has been completed at the specified time. + Completed { date: time::OffsetDateTime }, +} + +#[derive(Debug, Clone)] +pub struct Recurring { + pub interval: RecurringInterval, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RecurringInterval { + Months(u32), + Weeks(u32), + Days(u32), +} + +impl std::fmt::Display for Task { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "#{} — \"{}\"", self.issue_number, self.title) + } }