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> { 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::>() .await?; Ok(tasks) } 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(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 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); }; 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( &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; 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 u32")?) } 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) } }