ghr-cli 0.6.0

A fast terminal dashboard for GitHub pull requests, issues, and notifications.
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;

use tokio::process::Command as TokioCommand;
use tracing::debug;

use super::text::truncate_text;
use crate::config::{Config, github_repo_from_remote_url};
use crate::model::{PullRequestBranch, WorkItem};

#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct PrCheckoutResult {
    pub(super) command: String,
    pub(super) directory: PathBuf,
    pub(super) output: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct PrCheckoutPlan {
    pub(super) directory: PathBuf,
    pub(super) branch: Option<PullRequestBranch>,
}

pub(super) async fn run_pr_checkout(
    item: WorkItem,
    directory: PathBuf,
) -> std::result::Result<PrCheckoutResult, String> {
    let number = item
        .number
        .ok_or_else(|| "selected item has no pull request number".to_string())?;
    let args = pr_checkout_command_args(&item.repo, number);
    let command = pr_checkout_command_display(&args);
    debug!(
        command = %command,
        cwd = %directory.display(),
        "gh request started"
    );
    let output = TokioCommand::new("gh")
        .env("GH_PROMPT_DISABLED", "1")
        .current_dir(&directory)
        .args(&args)
        .output()
        .await
        .map_err(|error| {
            debug!(
                command = %command,
                cwd = %directory.display(),
                error = %error,
                "gh request failed to start"
            );
            if error.kind() == io::ErrorKind::NotFound {
                format!(
                    "GitHub CLI `gh` is required for local checkout. Install it, run `gh auth login`, then retry.\n\n{}\n\nTried: {command}",
                    checkout_directory_notice(&directory),
                )
            } else {
                format!(
                    "failed to run {command}: {error}\n\n{}",
                    checkout_directory_notice(&directory),
                )
            }
        })?;
    debug!(
        command = %command,
        cwd = %directory.display(),
        status = %output.status,
        success = output.status.success(),
        stdout_bytes = output.stdout.len(),
        stderr_bytes = output.stderr.len(),
        "gh request finished"
    );

    let output_text = command_output_text(&output.stdout, &output.stderr);
    if !output.status.success() {
        let detail = if output_text.is_empty() {
            "gh did not return any output".to_string()
        } else {
            output_text
        };
        return Err(format!(
            "{} failed.\n\n{}\n\n{}",
            command,
            checkout_directory_notice(&directory),
            truncate_text(&detail, 900),
        ));
    }

    let output = if output_text.is_empty() {
        "gh pr checkout completed successfully.".to_string()
    } else {
        truncate_text(&output_text, 900)
    };
    Ok(PrCheckoutResult {
        command,
        directory,
        output,
    })
}

pub(super) fn pr_checkout_command_args(repository: &str, number: u64) -> Vec<String> {
    vec![
        "pr".to_string(),
        "checkout".to_string(),
        number.to_string(),
        "--repo".to_string(),
        repository.to_string(),
    ]
}

pub(super) fn pr_checkout_command_display(args: &[String]) -> String {
    format!("gh {}", args.join(" "))
}

pub(super) fn command_output_text(stdout: &[u8], stderr: &[u8]) -> String {
    let stdout = String::from_utf8_lossy(stdout).trim().to_string();
    let stderr = String::from_utf8_lossy(stderr).trim().to_string();
    match (stdout.is_empty(), stderr.is_empty()) {
        (true, true) => String::new(),
        (false, true) => stdout,
        (true, false) => stderr,
        (false, false) => format!("{stdout}\n{stderr}"),
    }
}

pub(super) fn checkout_directory_notice(directory: &Path) -> String {
    format!("Checkout runs from {}.", directory.display())
}

pub(super) fn resolve_pr_checkout_directory(
    config: &Config,
    repository: &str,
) -> std::result::Result<PathBuf, String> {
    if let Some(directory) = configured_local_dir_for_repo(config, repository) {
        ensure_directory_tracks_repo(&directory, repository).map_err(|error| {
            format!(
                "Configured local_dir for {repository} cannot be used.\n\n{error}\n\nSet [[repos]].local_dir to a checkout whose git remote points at {repository}."
            )
        })?;
        return Ok(directory);
    }

    let cwd = std::env::current_dir().map_err(|error| {
        format!(
            "Could not inspect the current working directory for {repository}: {error}\n\nSet [[repos]].local_dir for this repository."
        )
    })?;
    ensure_directory_tracks_repo(&cwd, repository).map_err(|error| {
        format!(
            "No local checkout found for {repository}.\n\n{error}\n\nLaunch ghr inside a checkout whose git remote points at {repository}, or set [[repos]].local_dir for this repository."
        )
    })?;
    Ok(cwd)
}

pub(super) fn configured_local_dir_for_repo(config: &Config, repository: &str) -> Option<PathBuf> {
    config
        .repos
        .iter()
        .find(|repo| repo.repo.eq_ignore_ascii_case(repository))
        .and_then(|repo| repo.local_dir.as_deref())
        .map(str::trim)
        .filter(|local_dir| !local_dir.is_empty())
        .map(expand_user_path)
}

fn expand_user_path(value: &str) -> PathBuf {
    if value == "~" {
        return home_dir().unwrap_or_else(|| PathBuf::from(value));
    }
    if let Some(rest) = value.strip_prefix("~/")
        && let Some(home) = home_dir()
    {
        return home.join(rest);
    }
    PathBuf::from(value)
}

fn home_dir() -> Option<PathBuf> {
    std::env::var_os("HOME")
        .filter(|home| !home.is_empty())
        .map(PathBuf::from)
        .or_else(::dirs::home_dir)
}

pub(super) fn ensure_directory_tracks_repo(
    directory: &Path,
    repository: &str,
) -> std::result::Result<(), String> {
    if !directory.is_dir() {
        return Err(format!("{} is not a directory.", directory.display()));
    }
    let remotes = git_remotes_for_directory(directory)?;
    if remotes
        .iter()
        .any(|(_, repo)| repo.eq_ignore_ascii_case(repository))
    {
        return Ok(());
    }

    let remote_list = if remotes.is_empty() {
        "no GitHub remotes found".to_string()
    } else {
        remotes
            .iter()
            .map(|(remote, repo)| format!("{remote} -> {repo}"))
            .collect::<Vec<_>>()
            .join(", ")
    };
    Err(format!(
        "{} does not track {repository}; found {remote_list}.",
        directory.display()
    ))
}

pub(super) fn current_git_branch_for_directory(
    directory: &Path,
) -> std::result::Result<String, String> {
    let output = Command::new("git")
        .arg("-C")
        .arg(directory)
        .args(["symbolic-ref", "--quiet", "--short", "HEAD"])
        .output()
        .map_err(|error| {
            format!(
                "failed to inspect current git branch in {}: {error}",
                directory.display()
            )
        })?;
    let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if output.status.success() && !branch.is_empty() {
        return Ok(branch);
    }

    let detail = command_output_text(&output.stdout, &output.stderr);
    let detail = if detail.is_empty() {
        "detached HEAD or no branch is checked out".to_string()
    } else {
        detail
    };
    Err(format!(
        "cannot create PR from {}: {detail}",
        directory.display()
    ))
}

fn git_remotes_for_directory(
    directory: &Path,
) -> std::result::Result<Vec<(String, String)>, String> {
    let output = Command::new("git")
        .arg("-C")
        .arg(directory)
        .arg("remote")
        .output()
        .map_err(|error| {
            format!(
                "failed to run git remote in {}: {error}",
                directory.display()
            )
        })?;
    if !output.status.success() {
        return Err(format!(
            "{} is not a usable git checkout: {}",
            directory.display(),
            command_output_text(&output.stdout, &output.stderr)
        ));
    }

    let mut remotes = Vec::new();
    let names = String::from_utf8_lossy(&output.stdout);
    for remote in names
        .lines()
        .map(str::trim)
        .filter(|remote| !remote.is_empty())
    {
        if let Some(repo) = git_remote_repo(directory, remote) {
            remotes.push((remote.to_string(), repo));
        }
    }
    Ok(remotes)
}

fn git_remote_repo(directory: &Path, remote: &str) -> Option<String> {
    let output = Command::new("git")
        .arg("-C")
        .arg(directory)
        .args(["remote", "get-url", remote])
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    let url = String::from_utf8(output.stdout).ok()?;
    github_repo_from_remote_url(url.trim())
}