use clap::Args;
use color_eyre::eyre::{Report, Result, bail, eyre};
use jiff::Timestamp;
use tedi_adapters::github::GithubMilestone;
use tedi_core::RepoInfo;
use tedi_task_operations::{
IssueLink, MilestoneLink, TaskView,
clockify_tracking::{HaltArgs, ResumeArgs},
mv::move_issues,
open_interactions::SyncMode,
search::SearchArgs,
sprints::{expand_and_refresh, materialize_new_tasks, sync_embedded_issue_changes, sync_milestone_changes},
};
use tedi_utils::search::SearchKind;
use v_utils::{Timeframe, TimeframeDesignator};
use crate::config::LiveSettings;
#[derive(clap::Subcommand)]
pub enum SprintsCommands {
Get {
sprint: SprintRef,
},
Edit {
sprint: SprintRef,
},
Healthcheck,
Select {
pattern: Option<String>,
#[command(flatten)]
search: SearchArgs,
#[arg(long, short)]
next: bool,
#[arg(long, short)]
prev: bool,
#[arg(long, short)]
up: bool,
#[arg(long, short)]
down: bool,
#[arg(long = "in", short = 'i')]
enter: bool,
#[arg(long, short)]
out: bool,
#[arg(long, short)]
top: bool,
},
Selected {
#[command(subcommand)]
op: SelectedOp,
},
Search {
query: String,
#[command(flatten)]
search: SearchArgs,
},
}
#[derive(clap::Subcommand)]
pub enum SelectedOp {
Open,
List {
#[arg(long)]
markup: bool,
},
Current {
#[arg(long)]
markup: bool,
},
Add {
text: String,
#[arg(short = 'n', long)]
nest: bool,
},
Pop {
#[arg(short = 'p', long = "parent", action = clap::ArgAction::Count)]
parents: u8,
},
Set { text: String },
Resume(ResumeArgs),
Halt(HaltArgs),
}
#[derive(Args)]
pub struct SprintsArgs {
#[command(subcommand)]
command: SprintsCommands,
}
#[derive(Clone)]
pub enum SprintRef {
Urgent,
Tf(Timeframe),
}
impl std::str::FromStr for SprintRef {
type Err = Report;
fn from_str(s: &str) -> Result<Self> {
match s {
"urgent" => Ok(Self::Urgent),
_ => s.parse().map(Self::Tf),
}
}
}
pub static HEALTHCHECK_REL_PATH: &str = "healthcheck.status";
pub static SPRINT_HEADER_REL_PATH: &str = "sprint_header.md";
pub async fn sprints_command(settings: &LiveSettings, args: SprintsArgs, mock: Option<tedi_task_operations::MockType>, mode: SyncMode) -> Result<()> {
use tedi_task_operations::sprints as ops;
let yes = || settings.config().map(|c| c.yes).unwrap_or(false);
match args.command {
SprintsCommands::Get { sprint } => {
let raw = match sprint {
SprintRef::Urgent => match std::fs::read_to_string(tedi_task_operations::selection::urgent_path()) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => bail!("No urgent sprint. Create one with `sprints edit urgent`."),
Err(e) => return Err(e.into()),
},
SprintRef::Tf(tf) => {
let retrieved_milestones = request_milestones(settings).await?;
let raw = get_milestone(tf, &retrieved_milestones)?;
refresh_sprint_milestones(&raw).await;
raw
}
};
let expanded = expand_and_refresh(&raw, false).await?;
println!("{expanded}");
Ok(())
}
SprintsCommands::Edit { sprint } => match sprint {
SprintRef::Urgent => edit_urgent(mode).await,
SprintRef::Tf(tf) => edit_milestone(settings, tf, mode, mock.is_some()).await,
},
SprintsCommands::Healthcheck => healthcheck(settings).await,
SprintsCommands::Select {
pattern,
search,
next,
prev,
up,
down,
enter,
out,
top,
} => {
if mode != SyncMode::Offline
&& !next && !prev
&& !up && !down
&& !enter && !out
&& !top && let Some(p) = pattern.as_deref()
&& p.chars().any(|c| c.is_ascii_alphabetic())
&& let Ok(tf) = p.parse::<Timeframe>()
{
focus_sprint(settings, tf, yes()).await
} else {
ensure_selection_cache(settings, mode).await?;
ops::select(pattern, SearchKind::try_from(search)?, next, prev, up, down, enter, out, top, yes()).await
}
}
SprintsCommands::Selected { op } => {
ensure_selection_cache(settings, mode).await?;
match op {
SelectedOp::Open => ops::selected_open(mode, yes()).await,
SelectedOp::List { markup } => ops::selected_list(markup),
SelectedOp::Current { markup } => ops::selected_current(markup),
SelectedOp::Add { text, nest } => ops::selected_add(text, nest, mode, yes()).await,
SelectedOp::Pop { parents } => ops::selected_pop(parents as usize, mode, yes()).await,
SelectedOp::Set { text } => ops::selected_set(text, mode, yes()).await,
SelectedOp::Resume(resume_args) => ops::selected_resume(resume_args, yes()).await,
SelectedOp::Halt(halt_args) => ops::selected_halt(halt_args).await,
}
}
SprintsCommands::Search { query, search } => ops::search(&query, SearchKind::try_from(search)?).await,
}
}
async fn refresh_sprint_milestones(content: &str) {
let links = TaskView::parse(content).milestone_links();
if links.is_empty() {
return;
}
if let Err(e) = tedi_task_operations::sprints::refresh_milestone_cache(&links).await {
tracing::warn!("failed to refresh milestone cache: {e}");
eprintln!("warning: failed to refresh milestone cache: {e}");
}
}
async fn ensure_selection_cache(settings: &LiveSettings, mode: SyncMode) -> Result<()> {
let offline = mode == SyncMode::Offline;
if !offline {
let missing = tedi_task_operations::selection::Selected::uncached_active_milestones();
if !missing.is_empty()
&& let Err(e) = tedi_task_operations::sprints::refresh_milestone_cache(&missing).await
{
tracing::warn!("failed to refresh milestone cache: {e}");
eprintln!("warning: failed to refresh milestone cache: {e}");
}
}
if offline || tedi_task_operations::selection::Selected::active_ready() {
return Ok(());
}
let config = settings.config()?;
let blocker_tf = config.milestones.as_ref().map(|m| m.blocker_tf()).unwrap_or("1d").to_string();
let milestones = request_milestones(settings).await?;
let desc = milestones
.iter()
.find(|m| m.title == blocker_tf)
.and_then(|m| m.description.clone())
.ok_or_else(|| eyre!("No `{blocker_tf}` sprint on GitHub yet. Add issues to the '{blocker_tf}' milestone (or create an urgent list)."))?;
tedi_task_operations::sprints::refresh_selection_cache(&blocker_tf, &desc);
refresh_sprint_milestones(&desc).await;
Ok(())
}
async fn focus_sprint(settings: &LiveSettings, tf: Timeframe, yes: bool) -> Result<()> {
let milestones = request_milestones(settings).await?;
let desc = milestones
.iter()
.find(|m| m.title == tf.to_string())
.and_then(|m| m.description.clone())
.ok_or_else(|| eyre!("Sprint '{tf}' not found on GitHub (or it has no description)."))?;
tedi_task_operations::sprints::refresh_selection_cache(&tf.to_string(), &desc);
refresh_sprint_milestones(&desc).await;
tedi_task_operations::sprints::selected_show(yes).await
}
async fn request_milestones(settings: &LiveSettings) -> Result<Vec<GithubMilestone>> {
let config = settings.config()?;
let milestones_config = config
.milestones
.as_ref()
.ok_or_else(|| eyre!("milestones config section is required. Add [milestones] section with url to your config"))?;
let (owner, repo) = parse_github_repo(&milestones_config.url)?;
let client = tedi_adapters::github::client::get()?;
Ok(client.list_milestones(RepoInfo::new(&owner, &repo)).await?)
}
fn parse_github_repo(url: &str) -> Result<(String, String)> {
let url = url.trim();
if !url.contains(':') && !url.contains("//") {
let parts: Vec<&str> = url.split('/').collect();
if parts.len() == 2 {
return Ok((parts[0].to_string(), parts[1].trim_end_matches(".git").to_string()));
}
}
let sections: Vec<&str> = url.split('/').collect();
if sections.len() >= 2 {
let owner = sections[sections.len() - 2];
let repo = sections[sections.len() - 1].trim_end_matches(".git");
let owner = if owner.contains(':') { owner.split(':').next_back().unwrap_or(owner) } else { owner };
return Ok((owner.to_string(), repo.to_string()));
}
Err(eyre!("Could not parse Github repo from URL: {url}"))
}
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
#[error("Error on `{requested_tf}` milestone: {source}")]
struct GetMilestoneError {
requested_tf: Timeframe,
#[source]
source: MilestoneError,
}
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
enum MilestoneError {
#[error("Milestone is missing due_on date")]
MissingDueOn,
#[error("Milestone is outdated (due_on: {due_on}). Try moving it to a later date.")]
MilestoneOutdated { due_on: Timestamp },
#[error("Requested milestone on minute-designated timeframe (`m`). You likely meant to request Monthly (`M`).")]
MinuteMilestone,
#[error("Milestone not found. Here are all the existing milestones:\n{existing_milestones:?}")]
MilestoneNotFound { existing_milestones: Vec<String> },
#[error("Missing description")]
MissingDescription,
}
fn get_milestone(tf: Timeframe, retrieved_milestones: &[GithubMilestone]) -> Result<String, GetMilestoneError> {
if tf.designator() == TimeframeDesignator::Minutes {
return Err(GetMilestoneError {
requested_tf: tf,
source: MilestoneError::MinuteMilestone,
});
}
match retrieved_milestones.iter().find(|m| m.title == tf.to_string()) {
Some(milestone) => {
let due_on = milestone.due_on.as_ref().ok_or(GetMilestoneError {
requested_tf: tf,
source: MilestoneError::MissingDueOn,
})?;
if *due_on + tedi_eval::same_day_buffer() < Timestamp::now() {
return Err(GetMilestoneError {
requested_tf: tf,
source: MilestoneError::MilestoneOutdated { due_on: *due_on },
});
}
match milestone.description.clone() {
Some(description) => Ok(description),
None => Err(GetMilestoneError {
requested_tf: tf,
source: MilestoneError::MissingDescription,
}),
}
}
None => {
let milestone_titles = retrieved_milestones.iter().map(|m| m.title.clone()).collect::<Vec<String>>();
Err(GetMilestoneError {
requested_tf: tf,
source: MilestoneError::MilestoneNotFound {
existing_milestones: milestone_titles,
},
})
}
}
}
static KEY_MILESTONES: [Timeframe; 6] = [
Timeframe::from_naive(1, TimeframeDesignator::Days),
Timeframe::from_naive(2, TimeframeDesignator::Weeks),
Timeframe::from_naive(1, TimeframeDesignator::Quarters),
Timeframe::from_naive(1, TimeframeDesignator::Years),
Timeframe::from_naive(3, TimeframeDesignator::Years),
Timeframe::from_naive(7, TimeframeDesignator::Years),
];
async fn healthcheck(settings: &LiveSettings) -> Result<()> {
use std::fs;
let healthcheck_path = v_utils::xdg_data_file!(HEALTHCHECK_REL_PATH);
let retrieved_milestones = request_milestones(settings).await?;
let results = KEY_MILESTONES
.iter()
.map(|tf| get_milestone(*tf, &retrieved_milestones))
.collect::<Vec<Result<String, GetMilestoneError>>>();
{
let mut health = String::new();
for result in &results {
match result {
Ok(_) => {}
Err(e) => {
if !health.is_empty() {
health.push('\n');
}
health.push_str(&e.to_string());
}
}
}
if health.is_empty() {
health = "OK".to_string();
}
println!("{}\n{health}", healthcheck_path.display());
fs::write(healthcheck_path, health).unwrap();
}
{
let sprint_ms =
&<std::result::Result<std::string::String, GetMilestoneError> as Clone>::clone(&results[1]).map_err(|e| eyre!("Couldn't parse 2w milestone which MUST be defined: {e}"))?;
let sprint_header = sprint_ms
.lines()
.next()
.ok_or_else(|| eyre!("2w milestone does not have a description. MUST have a description."))?;
if !sprint_header.starts_with("# ") {
eprintln!("2w milestone description does not start with a header. It SHOULD start with '# '.");
}
fs::write(v_utils::xdg_data_file!(SPRINT_HEADER_REL_PATH), sprint_header).unwrap();
}
cache_blocker_milestone(settings, &retrieved_milestones).await;
Ok(())
}
async fn cache_blocker_milestone(settings: &LiveSettings, milestones: &[GithubMilestone]) {
let config = match settings.config() {
Ok(c) => c,
Err(_) => return,
};
let blocker_tf = config.milestones.as_ref().map(|m| m.blocker_tf()).unwrap_or("1d");
if let Some(ms) = milestones.iter().find(|m| m.title == blocker_tf)
&& let Some(ref desc) = ms.description
{
tedi_task_operations::sprints::refresh_selection_cache(blocker_tf, desc);
refresh_sprint_milestones(desc).await;
}
}
async fn edit_urgent(mode: SyncMode) -> Result<()> {
use std::fs;
let _lock = tedi_task_operations::selection::lock_sprint(tedi_task_operations::selection::URGENT_KEY)?;
let path = tedi_task_operations::selection::urgent_path();
let original = match fs::read_to_string(&path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e.into()),
};
let expanded = expand_and_refresh(&original, mode == SyncMode::Full).await?;
let tmp_path = tempfile::tempdir()?.keep().join("milestone_urgent.md");
fs::write(&tmp_path, &expanded)?;
eprintln!("[milestone] tmp_path: {}", tmp_path.display());
tedi_task_operations::utils::open_file(&tmp_path, None).await?;
let _syncing = v_utils::io::Spinner::start();
let raw = fs::read_to_string(&tmp_path)?;
if raw == expanded {
sync_embedded_issue_changes(&raw, mode).await?; println!("No changes made to urgent sprint");
return Ok(());
}
let (edited_content, moves) = match take_moves(&raw, mode) {
Ok(taken) => taken,
Err(e) => return dump_rejected(&raw, e),
};
let mut edited_doc = TaskView::parse(&edited_content);
let milestone_links = edited_doc.milestone_links();
if !milestone_links.is_empty() {
return dump_rejected(
&raw,
eyre!(
"urgent sprint cannot contain milestone refs: {}",
milestone_links.iter().map(|l| l.as_str()).collect::<Vec<_>>().join(", ")
),
);
}
if let Err(e) = sync_embedded_issue_changes(&edited_content, mode).await {
return dump_rejected(&raw, e);
}
if !moves.is_empty() {
match move_issues(&moves, None, mode).await {
Ok(moved) => edited_doc.relink(&moved),
Err(e) => return dump_rejected(&raw, e),
}
}
if let Err(e) = materialize_new_tasks(&mut edited_doc, None, mode).await {
return dump_rejected(&raw, e);
}
edited_doc.collapse_to_links();
edited_doc.remove_issue_links(&tedi_task_operations::selection::closed_links(&edited_doc)); let stored = edited_doc.serialize();
let headers: Vec<&str> = stored.lines().filter(|l| l.starts_with('#')).collect();
if !headers.is_empty() {
eprintln!("warning: urgent is a flat list — these headers carry no meaning in it: {}", headers.join(", "));
}
if stored.trim().is_empty() {
if path.exists() {
fs::remove_file(&path)?;
}
println!("Urgent sprint is empty; removed");
return Ok(());
}
fs::create_dir_all(path.parent().expect("urgent_path is always nested under data dir"))?;
fs::write(&path, stored)?;
println!("Updated urgent sprint");
Ok(())
}
async fn edit_milestone(settings: &LiveSettings, tf: Timeframe, mode: SyncMode, mock: bool) -> Result<()> {
use std::fs;
use tedi_task_operations::{
MilestoneBody,
local::{FsReader, Local},
open_interactions::{MergeMode, MilestoneModifier, SyncOptions, modify_and_sync_milestone, pull_milestone},
remote::load_remote_milestone,
};
let _lock = tedi_task_operations::selection::lock_sprint(&tf.to_string())?;
let mode = match mode == SyncMode::Full && !mock && !tedi_adapters::github::reachable().await {
true => SyncMode::Offline,
false => mode,
};
if let Ok(mock_milestone_path) = std::env::var(concat!(env!("CARGO_PKG_NAME"), "_MOCK_MILESTONE")) {
return edit_milestone_mock_file(&mock_milestone_path, tf, mode).await;
}
let (owner, repo) = milestone_repo(settings)?;
let repo_info = RepoInfo::new(&owner, &repo);
let cached_number = || {
Local::load_milestone_project_meta(repo_info, &FsReader)
.milestones
.iter()
.find(|(_, m)| m.title == tf.to_string())
.map(|(n, _)| *n)
};
let number = match (mode, cached_number()) {
(SyncMode::OnExit | SyncMode::Offline, Some(n)) => n,
(SyncMode::Offline, None) => bail!("Cannot edit '{tf}' offline — it was never fetched while online (no local milestone cache)."),
(SyncMode::Full, _) | (SyncMode::OnExit, None) => {
let retrieved = request_milestones(settings).await?;
retrieved.iter().find(|m| m.title == tf.to_string()).map(|m| m.number).ok_or_else(|| {
let existing = retrieved.iter().map(|m| m.title.clone()).collect::<Vec<_>>();
eyre!("Milestone '{tf}' not found. Existing milestones: {existing:?}")
})?
}
};
let link = MilestoneLink::parse(&format!("https://github.com/{owner}/{repo}/milestone/{number}")).expect("well-formed milestone url");
let mut milestone = match Local::load_milestone(&link, &FsReader)? {
Some(m) => m,
None if mode == SyncMode::Offline => bail!("Cannot edit '{tf}' offline — it was never fetched while online (no local milestone cache)."),
None => load_remote_milestone(&link).await?,
};
match mode {
SyncMode::Full => {
pull_milestone(&mut milestone, MergeMode::Normal).await?;
refresh_sprint_milestones(&milestone.to_string()).await;
}
SyncMode::OnExit =>
if let Some(conflict_file) = tedi_task_operations::conflict_resolve::check_for_existing_milestone_conflict(&link).await? {
bail!("Unresolved milestone conflict in {}. Resolve with git tools, then re-run.", conflict_file.display());
},
SyncMode::Offline => {}
}
let milestone_number = milestone.number();
let original_description = milestone.to_string();
let expanded_description = expand_and_refresh(&original_description, mode == SyncMode::Full).await?;
let tmp_path = tempfile::tempdir()?.keep().join(format!("milestone_{tf}.md"));
fs::write(&tmp_path, &expanded_description)?;
eprintln!("[milestone] tmp_path: {}", tmp_path.display());
tedi_task_operations::utils::open_file(&tmp_path, None).await?;
let _syncing = v_utils::io::Spinner::start();
let mode = match mode == SyncMode::OnExit && !mock && !tedi_adapters::github::reachable().await {
true => SyncMode::Offline,
false => mode,
};
let raw = fs::read_to_string(&tmp_path)?;
let changed = raw != expanded_description;
let (edited_content, moves) = match take_moves(&raw, mode) {
Ok(taken) => taken,
Err(e) => return dump_rejected(&raw, e),
};
let mut edited_doc = TaskView::parse(&edited_content);
if let Err(e) = sync_embedded_issue_changes(&edited_content, mode).await {
return dump_rejected(&raw, e);
}
let moved = match moves.is_empty() {
true => Default::default(),
false => match move_issues(&moves, Some(&link), mode).await {
Ok(moved) => moved,
Err(e) => return dump_rejected(&raw, e),
},
};
edited_doc.relink(&moved);
if changed {
if let Err(e) = materialize_new_tasks(&mut edited_doc, Some(link.clone()), mode).await {
return dump_rejected(&raw, e);
}
if let Err(e) = sync_milestone_changes(&edited_doc, mode).await {
return dump_rejected(&raw, e);
}
edited_doc.collapse_to_links();
}
let new_body = if changed {
MilestoneBody::parse(&edited_doc.serialize(), milestone.identity.link.repo_info())
} else {
milestone.body.clone()
};
if mode != SyncMode::Offline && changed {
let mut orig_doc = TaskView::parse(&original_description);
orig_doc.resolve_bare_refs(None);
orig_doc.relink(&moved); let old_links = orig_doc.issue_links();
let new_links = new_body.0.issue_links();
if let Err(e) = sync_milestone_assignments(settings, milestone_number, &old_links, &new_links).await {
tracing::warn!("failed to sync milestone assignments (will reconcile next online edit): {e}");
eprintln!("warning: failed to sync milestone assignments: {e}");
}
}
let mut milestone = modify_and_sync_milestone(milestone, mode, MilestoneModifier::BodyWrite { body: new_body }, SyncOptions::default()).await?;
let is_outdated = milestone.identity.due_on.map(|d| d + tedi_eval::same_day_buffer() < Timestamp::now()).unwrap_or(true);
if mode != SyncMode::Offline && is_outdated {
let new_date = Timestamp::now() + tf.duration();
println!("Milestone was outdated, updating due date to {}", new_date.strftime("%Y-%m-%d"));
let archive_title = format!("{}_{tf}", Timestamp::now().strftime("%Y/%m/%d"));
let description = milestone.to_string();
let (update_result, archive_result) = tokio::join!(
update_milestone(settings, milestone_number, &description, Some(new_date)),
create_closed_milestone(settings, &archive_title, &original_description)
);
if let Err(e) = update_result {
tracing::warn!("failed to bump milestone due date (will reconcile next online edit): {e}");
}
match archive_result {
Err(e) => tracing::warn!("Failed to archive old milestone contents (its closed issues stay until the next rollover): {e}"),
Ok(()) => {
let archived = MilestoneBody::parse(&original_description, link.repo_info()).hosted();
let pruned: Vec<IssueLink> = tedi_task_operations::selection::closed_links(&milestone.body.0)
.into_iter()
.filter(|l| archived.contains(l))
.collect();
if !pruned.is_empty() {
let mut body = milestone.body.clone();
body.0.remove_issue_links(&pruned);
milestone = modify_and_sync_milestone(milestone, mode, MilestoneModifier::BodyWrite { body }, SyncOptions::default()).await?;
}
}
}
}
let blocker_tf = settings.config()?.milestones.as_ref().map(|m| m.blocker_tf().to_string()).unwrap_or_else(|| "1d".to_string());
if tf.to_string() == blocker_tf {
tedi_task_operations::sprints::refresh_selection_cache(&blocker_tf, &milestone.to_string());
}
println!("Updated milestone '{tf}'");
if mode != SyncMode::Offline
&& let Err(e) = healthcheck(settings).await
{
tracing::warn!("post-edit healthcheck failed (your edit was already saved): {e}");
eprintln!("warning: post-edit healthcheck failed (your edit was already saved): {e}");
}
Ok(())
}
fn take_moves(buffer: &str, mode: SyncMode) -> Result<(String, Vec<(IssueLink, RepoInfo)>)> {
let (rest, moves) = tedi_core::take_moves(buffer)?;
if mode == SyncMode::Offline && !moves.is_empty() {
bail!("`!mv` needs GitHub, and this edit runs offline");
}
Ok((rest, moves))
}
fn dump_rejected(edited_content: &str, e: Report) -> Result<()> {
tedi_task_operations::utils::persist_rejected_changes(edited_content)?;
eprintln!("Your changes were saved to /tmp/tedi/rejected-changes.md — you can recover them from there.");
Err(e)
}
async fn edit_milestone_mock_file(mock_milestone_path: &str, tf: Timeframe, mode: SyncMode) -> Result<()> {
use std::fs;
let original_description = fs::read_to_string(mock_milestone_path)?;
let expanded_description = expand_and_refresh(&original_description, false).await?;
let tmp_path = tempfile::tempdir()?.keep().join(format!("milestone_{tf}.md"));
fs::write(&tmp_path, &expanded_description)?;
eprintln!("[milestone] tmp_path: {}", tmp_path.display());
tedi_task_operations::utils::open_file(&tmp_path, None).await?;
let edited_content = fs::read_to_string(&tmp_path)?;
if edited_content == expanded_description {
println!("No changes made to milestone '{tf}'");
return Ok(());
}
if let Err(e) = sync_embedded_issue_changes(&edited_content, mode).await {
return dump_rejected(&edited_content, e);
}
let mut edited_doc = TaskView::parse(&edited_content);
if let Err(e) = materialize_new_tasks(&mut edited_doc, None, mode).await {
return dump_rejected(&edited_content, e);
}
if let Err(e) = sync_milestone_changes(&edited_doc, SyncMode::Offline).await {
return dump_rejected(&edited_content, e);
}
edited_doc.collapse_to_links();
fs::write(mock_milestone_path, edited_doc.serialize())?;
println!("Updated milestone '{tf}'");
Ok(())
}
async fn update_milestone(settings: &LiveSettings, milestone_number: u64, description: &str, due_on: Option<Timestamp>) -> Result<()> {
let (owner, repo) = milestone_repo(settings)?;
let client = tedi_adapters::github::client::get()?;
client.update_milestone(RepoInfo::new(&owner, &repo), milestone_number, description, due_on).await?;
Ok(())
}
async fn create_closed_milestone(settings: &LiveSettings, title: &str, description: &str) -> Result<()> {
let (owner, repo) = milestone_repo(settings)?;
let client = tedi_adapters::github::client::get()?;
client.create_milestone(RepoInfo::new(&owner, &repo), title, description, true).await?;
Ok(())
}
fn milestone_repo(settings: &LiveSettings) -> Result<(String, String)> {
let config = settings.config()?;
let milestones_config = config.milestones.as_ref().ok_or_else(|| eyre!("milestones config section is required"))?;
parse_github_repo(&milestones_config.url)
}
async fn sync_milestone_assignments(settings: &LiveSettings, milestone_number: u64, old_links: &[IssueLink], new_links: &[IssueLink]) -> Result<()> {
use std::collections::HashSet;
let config = settings.config()?;
let milestones_config = config.milestones.as_ref().ok_or_else(|| eyre!("milestones config section is required"))?;
let (ms_owner, ms_repo) = parse_github_repo(&milestones_config.url)?;
let ms_repo_info: tedi_core::RepoInfo = (ms_owner.as_str(), ms_repo.as_str()).into();
let old_numbers: HashSet<u64> = old_links.iter().filter(|l| l.project() == ms_repo_info).map(|l| l.number()).collect();
let new_numbers: HashSet<u64> = new_links.iter().filter(|l| l.project() == ms_repo_info).map(|l| l.number()).collect();
let to_assign: Vec<u64> = new_numbers.difference(&old_numbers).copied().collect();
let to_unassign: Vec<u64> = old_numbers.difference(&new_numbers).copied().collect();
if to_assign.is_empty() && to_unassign.is_empty() {
return Ok(());
}
let client = tedi_adapters::github::client::get()?;
let repo = tedi_core::RepoInfo::new(&ms_owner, &ms_repo);
let mut futs = Vec::new();
for num in &to_assign {
println!("Assigning #{num} to milestone");
futs.push(client.set_issue_milestone(repo, *num, Some(milestone_number)));
}
for num in &to_unassign {
println!("Unassigning #{num} from milestone");
futs.push(client.set_issue_milestone(repo, *num, None));
}
for fut in futs {
fut.await?;
}
Ok(())
}