apm-cli 0.1.17

CLI project manager for running AI coding agents in parallel, isolated by design.
Documentation
use anyhow::{bail, Result};
use apm_core::{config::{Config, LocalConfig}, git, ticket, ticket_fmt};
use chrono::Utc;
use std::path::Path;

pub fn run(root: &Path, id_arg: &str, username: &str, no_aggressive: bool, force: bool) -> Result<()> {
    run_inner(root, id_arg, username, no_aggressive, force, None)
}

pub fn run_inner(root: &Path, id_arg: &str, username: &str, no_aggressive: bool, force: bool, confirm_override: Option<bool>) -> Result<()> {
    let config = Config::load(root)?;
    let local = LocalConfig::load(root);
    apm_core::validate::validate_owner(&config, &local, username)?;
    let aggressive = config.sync.aggressive && !no_aggressive;
    let mut tickets = ticket::load_all_from_git(root, &config.tickets.dir)?;
    let id = ticket::resolve_id_in_slice(&tickets, id_arg)?;

    if aggressive {
        let branches = git::ticket_branches(root).unwrap_or_default();
        if let Some(b) = branches.iter().find(|b| {
            b.strip_prefix("ticket/")
                .and_then(|s| s.split('-').next())
                .map(|bid| bid == id.as_str())
                .unwrap_or(false)
        }) {
            crate::util::fetch_branch_if_aggressive(root, b, aggressive);
        }
    }

    let Some(t) = tickets.iter_mut().find(|t| t.frontmatter.id == id) else {
        bail!("ticket {id:?} not found");
    };

    if force {
        let is_terminal = config.workflow.states.iter()
            .find(|s| s.id == t.frontmatter.state)
            .map(|s| s.terminal)
            .unwrap_or(false);
        if is_terminal {
            bail!("cannot change owner of a closed ticket");
        }
        if let Some(current_owner) = &t.frontmatter.owner.clone() {
            let confirmed = match confirm_override {
                Some(b) => b,
                None => crate::util::prompt_yes_no(&format!(
                    "Ticket {id} is currently owned by {current_owner}. Reassign to {username}? [y/N] "
                ))?
            };
            if !confirmed {
                println!("aborted");
                return Ok(());
            }
        }
    } else {
        ticket::check_owner(root, t)?;
    }

    ticket::set_field(&mut t.frontmatter, "owner", username)?;
    t.frontmatter.updated_at = Some(Utc::now());

    let content = t.serialize()?;
    let rel_path = format!(
        "{}/{}",
        config.tickets.dir.to_string_lossy(),
        t.path.file_name().unwrap().to_string_lossy()
    );
    let branch = t
        .frontmatter
        .branch
        .clone()
        .or_else(|| ticket_fmt::branch_name_from_path(&t.path))
        .unwrap_or_else(|| format!("ticket/{id}"));

    let commit_msg = if username == "-" {
        format!("ticket({id}): assign owner = -")
    } else {
        format!("ticket({id}): assign owner = {username}")
    };

    git::commit_to_branch(root, &branch, &rel_path, &content, &commit_msg)?;

    if aggressive {
        if let Err(e) = git::push_branch(root, &branch) {
            eprintln!("warning: push failed: {e:#}");
        }
    }

    if username == "-" {
        println!("{id}: owner cleared");
    } else {
        println!("{id}: owner = {username}");
    }
    Ok(())
}