apm-server 0.1.25

Web UI and agent dispatcher for APM, a git-native project manager for parallel AI coding agents.
use std::sync::Arc;
use axum::{
    extract::State,
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde::Deserialize;
use crate::{AppError, AppState};
use crate::models::CleanRequest;

#[derive(Deserialize, Default)]
pub(crate) struct SyncRequest {
    push_default: Option<bool>,
    push_refs: Option<bool>,
}

pub async fn sync_handler(
    State(state): State<Arc<AppState>>,
    body: Option<Json<SyncRequest>>,
) -> Result<Response, AppError> {
    let root = match state.git_root() {
        Some(r) => r.clone(),
        None => return Ok((StatusCode::NOT_IMPLEMENTED, "no git root").into_response()),
    };
    let req = body.map(|Json(b)| b).unwrap_or_default();
    let push_default = req.push_default.unwrap_or(false);
    let push_refs = req.push_refs.unwrap_or(false);

    let (log, branches, closed, ahead_branches, default_branch) = tokio::task::spawn_blocking(move || {
        let mut log: Vec<String> = Vec::new();
        if let Err(e) = apm_core::git::fetch_all(&root) {
            log.push(format!("warning: git fetch failed: {e}"));
        }
        let mut ref_warnings: Vec<String> = Vec::new();
        let ahead_refs = apm_core::git::sync_non_checked_out_refs(&root, &mut ref_warnings);
        log.extend(ref_warnings);
        log.push("synced non-checked-out refs".to_string());

        let branches = apm_core::git::ticket_branches(&root)
            .map(|b| b.len())
            .unwrap_or(0);

        let (closed, default_branch_name, remaining_ahead) = match apm_core::config::Config::load(&root) {
            Ok(config) => {
                let default_branch = config.project.default_branch.clone();
                let mut sync_warnings: Vec<String> = Vec::new();
                let default_is_ahead = apm_core::git::sync_default_branch(&root, &default_branch, &mut sync_warnings);

                let mut default_still_ahead = default_is_ahead;

                if push_default && default_is_ahead {
                    match apm_core::git::push_branch(&root, &default_branch) {
                        Ok(()) => {
                            log.push(format!("pushed {default_branch} to origin"));
                            default_still_ahead = false;
                            sync_warnings.retain(|w| !w.contains(&default_branch) || !w.contains("ahead"));
                        }
                        Err(e) => {
                            log.push(format!("warning: push {default_branch} failed: {e:#}"));
                        }
                    }
                }
                log.extend(sync_warnings);

                let mut refs_still_ahead: Vec<String> = ahead_refs.clone();
                if push_refs && !ahead_refs.is_empty() {
                    refs_still_ahead = Vec::new();
                    for branch in &ahead_refs {
                        match apm_core::git::push_branch(&root, branch) {
                            Ok(()) => {
                                log.push(format!("pushed {branch} to origin"));
                            }
                            Err(e) => {
                                log.push(format!("warning: push {branch} failed: {e:#}"));
                                refs_still_ahead.push(branch.clone());
                            }
                        }
                    }
                }

                let closed = match apm_core::sync::detect(&root, &config) {
                    Ok(candidates) => {
                        let n = candidates.close.len();
                        if n > 0 {
                            let aggressive = config.sync.aggressive;
                            let author = apm_core::config::resolve_identity(&root);
                            let _ = apm_core::sync::apply(&root, &config, &candidates, &author, aggressive);
                        }
                        n
                    }
                    Err(_) => 0,
                };

                let mut remaining: Vec<String> = refs_still_ahead;
                if default_still_ahead {
                    remaining.push(default_branch.clone());
                }

                (closed, default_branch, remaining)
            }
            Err(_) => (0, String::new(), ahead_refs),
        };

        if closed > 0 {
            log.push(format!("closed {closed} ticket(s)"));
        } else {
            log.push("no tickets to close".to_string());
        }
        log.push(format!("{branches} ticket branch(es) visible"));
        (log, branches, closed, remaining_ahead, default_branch_name)
    })
    .await?;
    Ok(Json(serde_json::json!({
        "log": log.join("\n"),
        "branches": branches,
        "closed": closed,
        "ahead_branches": ahead_branches,
        "default_branch": default_branch,
    })).into_response())
}

pub async fn clean_handler(
    State(state): State<Arc<AppState>>,
    body: Option<Json<CleanRequest>>,
) -> Result<Response, AppError> {
    let root = match state.git_root() {
        Some(r) => r.clone(),
        None => return Ok((StatusCode::NOT_IMPLEMENTED, "no git root").into_response()),
    };
    let req = body.map(|Json(b)| b).unwrap_or_default();
    let dry_run   = req.dry_run.unwrap_or(false);
    let force     = req.force.unwrap_or(false);
    let branches  = req.branches.unwrap_or(false);
    let untracked = req.untracked.unwrap_or(false);
    let epics     = req.epics.unwrap_or(false);
    let older_than = req.older_than;

    let (log, removed) = crate::util::blocking(move || -> anyhow::Result<(Vec<String>, usize)> {
        let mut log: Vec<String> = Vec::new();
        let mut count = 0usize;

        let config = apm_core::config::Config::load(&root)?;
        let (mut candidates, dirty, candidate_warnings) =
            apm_core::clean::candidates(&root, &config, force, untracked, dry_run)?;
        for w in &candidate_warnings {
            log.push(w.clone());
        }

        if branches {
            let local_branch_set: std::collections::HashSet<String> =
                candidates.iter().map(|c| c.branch.clone()).collect();
            candidates.extend(apm_core::clean::remote_only_candidates(&root, &config, &local_branch_set)?);
        }

        if let Some(threshold_str) = older_than.as_deref() {
            let threshold = apm_core::clean::parse_older_than(threshold_str)?;
            candidates.retain(|c| match c.updated_at {
                Some(ts) => ts < threshold,
                None => false,
            });
        }

        for dw in &dirty {
            if !dw.modified_tracked.is_empty() {
                for f in &dw.modified_tracked {
                    log.push(format!("  M {}", f.display()));
                }
                log.push(format!(
                    "warning: {} has modified tracked files — manual cleanup required — skipping",
                    dw.branch
                ));
            } else {
                for f in &dw.other_untracked {
                    log.push(format!("  ? {}", f.display()));
                }
                log.push(format!(
                    "warning: {} has untracked files — re-run with --untracked to remove — skipping",
                    dw.branch
                ));
            }
        }

        for candidate in &candidates {
            let scope = match (candidate.local_branch_exists, candidate.remote_branch_exists) {
                (true, true) => "local + remote",
                (true, false) => "local",
                (false, true) => "remote",
                (false, false) => "registry only",
            };
            if dry_run {
                if let Some(ref path) = candidate.worktree {
                    log.push(format!(
                        "would remove worktree {} (ticket #{}, state: {})",
                        path.display(),
                        candidate.ticket_id,
                        candidate.reason
                    ));
                }
                if branches {
                    log.push(format!(
                        "would remove branch {} ({}, state: {})",
                        candidate.branch, scope, candidate.reason
                    ));
                }
            } else {
                let remove_out = apm_core::clean::remove(&root, candidate, force, branches)?;
                if let Some(ref path) = candidate.worktree {
                    log.push(format!("removed worktree {}", path.display()));
                    count += 1;
                }
                if branches {
                    log.push(format!("removed branch {} ({})", candidate.branch, scope));
                }
                for w in &remove_out.warnings {
                    log.push(w.clone());
                }
            }
        }

        if epics {
            let local_output = std::process::Command::new("git")
                .current_dir(&root)
                .args(["branch", "--list", "epic/*"])
                .output()?;
            let local_branches: Vec<String> = String::from_utf8_lossy(&local_output.stdout)
                .lines()
                .map(|l| l.trim().trim_start_matches(['*', '+']).trim().to_string())
                .filter(|l| !l.is_empty())
                .collect();

            let tickets = apm_core::ticket::load_all_from_git(&root, &config.tickets.dir)?;

            let mut epic_candidates: Vec<String> = Vec::new();
            for branch in &local_branches {
                let after_prefix = branch.trim_start_matches("epic/");
                let id_end = after_prefix.find('-').unwrap_or(after_prefix.len()).min(8);
                let id = &after_prefix[..id_end];
                let epic_tickets: Vec<_> = tickets
                    .iter()
                    .filter(|t| t.frontmatter.epic.as_deref() == Some(id))
                    .collect();
                let state_configs: Vec<&apm_core::config::StateConfig> = epic_tickets
                    .iter()
                    .filter_map(|t| config.workflow.states.iter().find(|s| s.id == t.frontmatter.state))
                    .collect();
                if apm_core::epic::derive_epic_state(&state_configs) == "done" {
                    epic_candidates.push(branch.clone());
                }
            }

            if epic_candidates.is_empty() {
                log.push("No done epics to clean.".to_string());
            }

            for branch in &epic_candidates {
                let after_prefix = branch.trim_start_matches("epic/");
                let id_end = after_prefix.find('-').unwrap_or(after_prefix.len()).min(8);
                let id = after_prefix[..id_end].to_string();

                if dry_run {
                    log.push(format!("would delete epic branch {branch}"));
                    continue;
                }

                let del_local = std::process::Command::new("git")
                    .current_dir(&root)
                    .args(["branch", "-d", branch])
                    .output()?;
                if !del_local.status.success() {
                    log.push(format!(
                        "error: failed to delete local branch {branch}: {}",
                        String::from_utf8_lossy(&del_local.stderr).trim()
                    ));
                    continue;
                }

                let del_remote = std::process::Command::new("git")
                    .current_dir(&root)
                    .args(["push", "origin", "--delete", branch])
                    .output()?;
                if !del_remote.status.success() {
                    let stderr = String::from_utf8_lossy(&del_remote.stderr);
                    if !stderr.contains("remote ref does not exist")
                        && !stderr.contains("error: unable to delete")
                    {
                        log.push(format!("warning: failed to delete remote {branch}: {}", stderr.trim()));
                    }
                }

                log.push(format!("deleted epic {branch}"));

                let epics_path = root.join(".apm").join("epics.toml");
                if epics_path.exists() {
                    let raw = std::fs::read_to_string(&epics_path)?;
                    let mut table: toml::value::Table = toml::from_str(&raw)?;
                    if table.remove(&id).is_some() {
                        std::fs::write(&epics_path, toml::to_string(&table)?)?;
                    }
                }
            }
        }

        Ok((log, count))
    }).await?;

    Ok(Json(serde_json::json!({ "log": log.join("\n"), "removed": removed })).into_response())
}

#[cfg(test)]
mod tests {
    use axum::body::Body;
    use axum::http::{Request, StatusCode};
    use tower::ServiceExt;

    #[tokio::test]
    async fn sync_in_memory_returns_not_implemented() {
        let app = crate::build_app_with_tickets(crate::tests::test_tickets());
        let response = app
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/sync")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
    }
}