loopflow 0.9.10

Run steps and flows with coding agents
Documentation
use std::fs;
use std::path::Path;
use std::process::Command;

use loopflow::engine::config::{load_config_or_default, AsanaConfig, LinearConfig};
use loopflow::lfd;
use loopflow::lfd::pm::asana::AsanaClient;
use loopflow::lfd::pm::linear::LinearClient;
use loopflow::lfd::pm::{PmItem, PmProvider, PmProviderKind};
use loopflow::lfd::store::open_store;
use loopflow::ops::pm::{pm_init, PmInitOptions};
use loopflow::ops::NullProgress;
use tempfile::TempDir;

fn main() -> Result<(), String> {
    let provider = parse_provider()?;
    let name = format!(
        "pm-priority-live-{}",
        time::OffsetDateTime::now_utc().unix_timestamp()
    );
    let temp = TempDir::new().map_err(|err| format!("temp dir: {err}"))?;
    init_git_repo(temp.path())?;
    write_test_repo(temp.path(), &name, provider)?;

    let result = pm_init(
        temp.path(),
        &PmInitOptions {
            wave: Some(name.clone()),
        },
        &NullProgress,
    )
    .map_err(|err| err.to_string())?;

    let items = fetch_remote_items(temp.path(), provider, &result.project_id)?;
    let names = items
        .iter()
        .map(|item| item.name.as_str())
        .collect::<Vec<_>>();
    let expected = vec!["First priority", "Second priority", "Third priority"];

    println!("provider: {provider:?}");
    println!("wave: {name}");
    println!("project_id: {}", result.project_id);
    println!("remote order:");
    for (index, item) in items.iter().enumerate() {
        println!("  {}. {}", index + 1, item.name);
    }

    if names == expected {
        println!("PASS: provider returned priorities in local file order");
        return Ok(());
    }

    Err(format!("FAIL: expected {:?}, got {:?}", expected, names))
}

fn parse_provider() -> Result<PmProviderKind, String> {
    let mut args = std::env::args().skip(1);
    match args.next().as_deref() {
        Some("linear") => Ok(PmProviderKind::Linear),
        Some("asana") => Ok(PmProviderKind::Asana),
        Some(value) => Err(format!(
            "unknown provider `{value}`; expected `linear` or `asana`"
        )),
        None => Err("usage: cargo run --example pm_priority_live -- <linear|asana>".to_string()),
    }
}

fn init_git_repo(repo: &Path) -> Result<(), String> {
    run_git(repo, ["init"])?;
    run_git(repo, ["config", "user.name", "Loopflow Test"])?;
    run_git(repo, ["config", "user.email", "loopflow@example.com"])?;
    Ok(())
}

fn run_git<const N: usize>(repo: &Path, args: [&str; N]) -> Result<(), String> {
    let status = Command::new("git")
        .args(args)
        .current_dir(repo)
        .status()
        .map_err(|err| format!("git {:?}: {err}", args))?;
    if status.success() {
        Ok(())
    } else {
        Err(format!("git {:?} failed with status {status}", args))
    }
}

fn write_test_repo(repo: &Path, wave: &str, provider: PmProviderKind) -> Result<(), String> {
    let config = load_config_or_default(Some(Path::new(".")));
    let lf_dir = repo.join(".lf");
    fs::create_dir_all(&lf_dir).map_err(|err| format!("create .lf/: {err}"))?;
    fs::write(
        lf_dir.join("config.yaml"),
        render_config(provider, &config.asana, &config.linear),
    )
    .map_err(|err| format!("write .lf/config.yaml: {err}"))?;

    let wave_dir = repo.join("wave").join(wave);
    fs::create_dir_all(&wave_dir).map_err(|err| format!("create wave dir: {err}"))?;
    fs::write(
        wave_dir.join(format!("{wave}.yaml")),
        format!(
            "flow: build\npm:\n  provider: {}\n",
            provider_name(provider)
        ),
    )
    .map_err(|err| format!("write wave yaml: {err}"))?;
    fs::write(
        wave_dir.join("README.md"),
        format!("# {wave}\n\nLive priority-order validation.\n"),
    )
    .map_err(|err| format!("write README: {err}"))?;
    write_item(&wave_dir.join("01-first-priority.md"), "First priority")?;
    write_item(&wave_dir.join("02-second-priority.md"), "Second priority")?;
    write_item(&wave_dir.join("03-third-priority.md"), "Third priority")?;
    Ok(())
}

fn write_item(path: &Path, title: &str) -> Result<(), String> {
    fs::write(path, format!("# {title}\n\nPriority test item.\n"))
        .map_err(|err| format!("write {}: {err}", path.display()))
}

fn render_config(provider: PmProviderKind, asana: &AsanaConfig, linear: &LinearConfig) -> String {
    let mut lines = vec![format!("pm:\n  provider: {}", provider_name(provider))];
    if asana.workspace.is_some() || asana.default_team.is_some() {
        lines.push("asana:".to_string());
        if let Some(workspace) = &asana.workspace {
            lines.push(format!("  workspace: \"{workspace}\""));
        }
        if let Some(default_team) = &asana.default_team {
            lines.push(format!("  default_team: \"{default_team}\""));
        }
    }
    if let Some(team) = &linear.team {
        lines.push("linear:".to_string());
        lines.push(format!("  team: \"{team}\""));
    }
    lines.join("\n") + "\n"
}

fn provider_name(provider: PmProviderKind) -> &'static str {
    match provider {
        PmProviderKind::Asana => "asana",
        PmProviderKind::Linear => "linear",
        _ => "unknown",
    }
}

fn fetch_remote_items(
    repo: &Path,
    provider: PmProviderKind,
    project_id: &str,
) -> Result<Vec<PmItem>, String> {
    let rt = tokio::runtime::Runtime::new().map_err(|err| format!("runtime: {err}"))?;
    rt.block_on(async move {
        let cfg = lfd::storage_config_from_env().map_err(|err| err.to_string())?;
        let store = open_store(&cfg)
            .await
            .map_err(|err| format!("open store: {err}"))?;
        let token = store
            .get_provider_token(provider_name(provider))
            .await
            .map_err(|err| format!("load token: {err}"))?
            .ok_or_else(|| format!("no stored {} credential", provider_name(provider)))?
            .access_token;
        let config = load_config_or_default(Some(repo));
        match provider {
            PmProviderKind::Asana => {
                let client = AsanaClient::new(token, config.asana.clone());
                client
                    .list_items(project_id)
                    .await
                    .map_err(|err| err.to_string())
            }
            PmProviderKind::Linear => {
                let client = LinearClient::new(token, config.linear.team.clone());
                client
                    .list_items(project_id)
                    .await
                    .map_err(|err| err.to_string())
            }
            _ => Err(format!("unsupported provider {:?}", provider)),
        }
    })
}