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.
This commit is contained in:
Rahix 2026-03-05 20:39:26 +01:00
parent 7dc6eb305d
commit 64d28bb868
7 changed files with 148 additions and 43 deletions

1
Cargo.lock generated
View file

@ -217,6 +217,7 @@ dependencies = [
"anyhow",
"env_logger",
"forgejo-api",
"futures",
"jiff",
"log",
"serde",

View file

@ -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"

View file

@ -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<Vec<task::Task>> {
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::<anyhow::Result<Vec<_>>>()?;
.buffer_unordered(8)
.try_collect::<Vec<task::Task>>()
.await?;
Ok(tasks)
}
fn task_from_issue(issue: &Issue) -> anyhow::Result<task::Task> {
Ok(task::Task {
async fn task_from_issue(ctx: &crate::Context, issue: &Issue) -> anyhow::Result<task::Task> {
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<task::State> {
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<task::ScheduledInfo> {
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<task::State> {
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<jiff::Zoned> {
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<Option<task::Recurring>> {
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 {

View file

@ -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 = "<small>\\[TaskBot Comment\\]</small>";
const SIGNATURE_URGENT: &'static str = "<small>\\[TaskBot Comment (Urgent)\\]</small>";
pub const SIGNATURE_BASIC: &'static str = "<small>\\[TaskBot Comment\\]</small>";
pub const SIGNATURE_URGENT: &'static str = "<small>\\[TaskBot Comment (Urgent)\\]</small>";
async fn check_task_reminded(
ctx: &crate::Context,

View file

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

View file

@ -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<jiff::Zoned> },
/// 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)]

View file

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