TaskBot/src/collect.rs
Rahix 64d28bb868 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.
2026-03-05 20:42:25 +01:00

255 lines
8.7 KiB
Rust

use crate::task;
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 = 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)
)
})
})
.buffer_unordered(8)
.try_collect::<Vec<task::Task>>()
.await?;
Ok(tasks)
}
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(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 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);
};
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<Option<&str>> {
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<Vec<Issue>> {
let issues = ctx
.forgejo
.issue_list_issues(
&ctx.owner,
&ctx.repo,
forgejo_api::structs::IssueListIssuesQuery {
// We also want the closed issues
state: Some(forgejo_api::structs::IssueListIssuesQueryState::All),
// Only issues
r#type: Some(forgejo_api::structs::IssueListIssuesQueryType::Issues),
..Default::default()
},
)
.all()
.await
.with_context(|| format!("Failed fetching issue list"))?;
Ok(issues)
}
trait IssueExt {
fn get_number(&self) -> anyhow::Result<u32>;
fn get_title(&self) -> anyhow::Result<String>;
fn get_state(&self) -> anyhow::Result<forgejo_api::structs::StateType>;
fn get_label_names(&self) -> anyhow::Result<Vec<String>>;
}
impl IssueExt for Issue {
fn get_number(&self) -> anyhow::Result<u32> {
Ok(self
.number
.context("Missing issue number")?
.try_into()
.context("Failed converting issue number to u32")?)
}
fn get_title(&self) -> anyhow::Result<String> {
Ok(self.title.as_ref().context("Missing issue title")?.clone())
}
fn get_state(&self) -> anyhow::Result<forgejo_api::structs::StateType> {
Ok(self.state.context("Issue has no state")?)
}
fn get_label_names(&self) -> anyhow::Result<Vec<std::string::String>> {
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::<anyhow::Result<Vec<_>>>()?;
Ok(label_names)
}
}