loopflow 0.10.0

Run steps and flows with coding agents
Documentation
use std::collections::HashSet;
use std::path::Path;
use std::process::{Command, Output};

use serde::Deserialize;

use crate::engine::git::{current_branch, delete_local_branch, get_default_branch, is_clean};
use crate::engine::naming::sanitize_for_branch;
use crate::ops::error::{OpsError, OpsResult};
use crate::ops::progress::Progress;
use crate::ops::util::command_exists;

#[derive(Debug, Clone, Default)]
pub struct CombineOptions {
    pub wave_name: Option<String>,
}

#[derive(Debug, Clone)]
pub struct CombineResult {
    pub new_pr_url: Option<String>,
    pub closed_prs: Vec<u64>,
}

#[derive(Debug, Clone, Deserialize)]
struct GhOpenPr {
    number: u64,
    #[serde(rename = "headRefName")]
    head_ref_name: String,
}

pub fn combine_prs(
    repo: &Path,
    options: &CombineOptions,
    progress: &impl Progress,
) -> OpsResult<CombineResult> {
    ensure_gh_available()?;

    if !is_clean(repo)? {
        return Err(OpsError::Message(
            "uncommitted changes; commit or stash before combining PRs".to_string(),
        ));
    }

    let wave_name = options
        .wave_name
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| OpsError::Message("wave name is required for combine".to_string()))?;

    let base_branch = get_default_branch(repo)?;
    run_git(repo, ["fetch", "origin", &base_branch])?;

    let wave_branch_token = sanitize_for_branch(wave_name);
    let mut open_prs = list_open_prs(repo)?
        .into_iter()
        .filter(|pr| pr.head_ref_name.contains(&wave_branch_token))
        .collect::<Vec<_>>();

    open_prs.sort_by_key(|pr| pr.number);

    if open_prs.len() < 2 {
        return Err(OpsError::Message(
            "need at least 2 open PRs to combine".to_string(),
        ));
    }

    let mut commits = Vec::new();
    let mut seen = HashSet::new();
    for pr in &open_prs {
        let branch_commits = commit_range_from_ref(
            repo,
            &format!("origin/{base_branch}"),
            &format!("origin/{}", pr.head_ref_name),
        )?;
        for sha in branch_commits {
            if seen.insert(sha.clone()) {
                commits.push(sha);
            }
        }
    }

    if commits.is_empty() {
        return Err(OpsError::Message(
            "no commits found across open PR branches".to_string(),
        ));
    }

    let branch_base = format!("{}-combined", sanitize_for_branch(wave_name));
    let combined_branch = unique_branch_name(repo, &branch_base)?;
    let original_branch =
        current_branch(repo)?.ok_or_else(|| OpsError::Message("not on a branch".to_string()))?;

    progress.status(&format!(
        "Creating {combined_branch} from origin/{base_branch}..."
    ));
    run_git(
        repo,
        [
            "checkout",
            "-b",
            &combined_branch,
            &format!("origin/{base_branch}"),
        ],
    )?;

    if let Err(err) = cherry_pick_commits(repo, &commits) {
        let _ = run_git(repo, ["checkout", &original_branch]);
        let _ = delete_local_branch(repo, &combined_branch);
        return Err(err);
    }

    progress.status("Pushing combined branch...");
    run_git(repo, ["push", "-u", "origin", &combined_branch])?;

    let title = format!("Combine PRs for {wave_name}");
    let body = combine_pr_body(&open_prs);

    progress.status("Creating combined PR...");
    let pr_output = run_gh(
        repo,
        [
            "pr",
            "create",
            "--title",
            &title,
            "--body",
            &body,
            "--base",
            &base_branch,
            "--head",
            &combined_branch,
        ],
    )?;
    let pr_url = output_stdout(&pr_output);
    let new_pr_url = if pr_url.is_empty() {
        None
    } else {
        Some(pr_url)
    };

    progress.status("Closing previous PRs...");
    let mut closed_prs = Vec::new();
    for pr in &open_prs {
        close_pr(repo, pr.number)?;
        closed_prs.push(pr.number);
    }

    Ok(CombineResult {
        new_pr_url,
        closed_prs,
    })
}

fn ensure_gh_available() -> OpsResult<()> {
    if !command_exists("gh") {
        return Err(OpsError::Message("gh CLI not found".to_string()));
    }
    Ok(())
}

fn list_open_prs(repo: &Path) -> OpsResult<Vec<GhOpenPr>> {
    let output = run_gh(
        repo,
        [
            "pr",
            "list",
            "--author",
            "@me",
            "--state",
            "open",
            "--json",
            "number,headRefName",
        ],
    )?;
    let stdout = output_stdout(&output);
    serde_json::from_str::<Vec<GhOpenPr>>(&stdout)
        .map_err(|err| OpsError::Parse(format!("failed to parse gh pr list: {err}")))
}

fn commit_range_from_ref(repo: &Path, base_ref: &str, head_ref: &str) -> OpsResult<Vec<String>> {
    let output = run_git(
        repo,
        [
            "log",
            "--reverse",
            "--format=%H",
            &format!("{base_ref}..{head_ref}"),
        ],
    )?;
    Ok(output_stdout(&output)
        .lines()
        .map(str::trim)
        .filter(|line| !line.is_empty())
        .map(ToString::to_string)
        .collect())
}

fn cherry_pick_commits(repo: &Path, commits: &[String]) -> OpsResult<()> {
    for sha in commits {
        if let Err(err) = run_git(repo, ["cherry-pick", sha]) {
            let _ = run_git(repo, ["cherry-pick", "--abort"]);
            match err {
                OpsError::CommandFailed { stderr, .. } => {
                    return Err(OpsError::Message(format!(
                        "cherry-pick conflict while applying {sha}: {stderr}"
                    )));
                }
                other => return Err(other),
            }
        }
    }
    Ok(())
}

fn unique_branch_name(repo: &Path, base: &str) -> OpsResult<String> {
    let mut candidate = base.to_string();
    let mut suffix = 2;

    while branch_exists(repo, &candidate)? {
        candidate = format!("{base}-{suffix}");
        suffix += 1;
    }

    Ok(candidate)
}

fn branch_exists(repo: &Path, branch: &str) -> OpsResult<bool> {
    let local_ref = format!("refs/heads/{branch}");
    let remote_ref = format!("refs/remotes/origin/{branch}");

    Ok(ref_exists(repo, &local_ref)? || ref_exists(repo, &remote_ref)?)
}

fn ref_exists(repo: &Path, reference: &str) -> OpsResult<bool> {
    let status = Command::new("git")
        .args(["show-ref", "--verify", "--quiet", reference])
        .current_dir(repo)
        .status()?;
    Ok(status.success())
}

fn close_pr(repo: &Path, number: u64) -> OpsResult<()> {
    let number = number.to_string();
    match run_gh(repo, ["pr", "close", &number, "--delete-branch"]) {
        Ok(_) => Ok(()),
        Err(OpsError::CommandFailed { command, stderr }) => {
            let lowered = stderr.to_lowercase();
            if lowered.contains("already closed") || lowered.contains("pull request is closed") {
                Ok(())
            } else {
                Err(OpsError::CommandFailed { command, stderr })
            }
        }
        Err(err) => Err(err),
    }
}

fn combine_pr_body(prs: &[GhOpenPr]) -> String {
    let mut body = String::from("This PR combines the following open PRs:\n");
    for pr in prs {
        body.push_str(&format!("- #{} ({})\n", pr.number, pr.head_ref_name));
    }
    body
}

fn run_git<const N: usize>(repo: &Path, args: [&str; N]) -> OpsResult<Output> {
    run_command(repo, "git", args)
}

fn run_gh<const N: usize>(repo: &Path, args: [&str; N]) -> OpsResult<Output> {
    run_command(repo, "gh", args)
}

fn run_command<const N: usize>(repo: &Path, program: &str, args: [&str; N]) -> OpsResult<Output> {
    let output = Command::new(program)
        .args(args)
        .current_dir(repo)
        .output()?;
    if output.status.success() {
        Ok(output)
    } else {
        Err(OpsError::CommandFailed {
            command: format!("{program} {}", args.join(" ")),
            stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
        })
    }
}

fn output_stdout(output: &Output) -> String {
    String::from_utf8_lossy(&output.stdout).trim().to_string()
}