Already fetch the start date and reminder state for issues immediately and have them available in the struct Task.
255 lines
8.7 KiB
Rust
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)
|
|
}
|
|
}
|