lgtm-cli 0.3.2

Run LGTM code reviews with a bundled web UI and gRPC server.
//! Shared target selection, identity and output helpers used by CLI command groups.
pub mod comments;
pub mod review;
pub mod session;

use crate::{
    models::{ActorArgs, ContextArgs},
    rpc_client::Client,
    store::Store,
};
use anyhow::{Context as _, Result, bail};
use serde_json::{Value, json};
use std::{path::PathBuf, process::Command};

// Private ---------------------------------------------------------------------

fn location(args: &ContextArgs) -> Result<PathBuf> {
    let supplied = args
        .notes
        .as_ref()
        .or(args.path.as_ref())
        .cloned()
        .unwrap_or(std::env::current_dir()?);
    let path = if supplied.is_absolute() {
        supplied
    } else {
        std::env::current_dir()?.join(supplied)
    };
    Ok(path.canonicalize().unwrap_or(path))
}

fn target_input(args: &ContextArgs) -> Result<Value> {
    if args.base.is_some() && args.mode != Some(crate::models::ReviewMode::Branch) {
        bail!("--base is only valid with --mode branch");
    }
    if let Some(pr) = &args.pr {
        let (repository, number) =
            if let Some((repository, number)) = pr.trim_end_matches('/').rsplit_once("/pull/") {
                (repository.to_owned(), number.parse::<i64>()?)
            } else {
                let repository = args
                    .repo
                    .clone()
                    .or_else(|| {
                        git_value(
                            &["remote", "get-url", "origin"],
                            Some(&location(args).ok()?),
                        )
                    })
                    .context("--pr NUMBER requires --repo URL or a GitHub origin")?;
                (repository, pr.parse::<i64>()?)
            };
        return Ok(
            json!({"pullRequest":{"repositoryUrl":repository_url(&repository,&args.host)?,"number":number.to_string()}}),
        );
    }
    let path = location(args)?;
    if args.notes.is_some() {
        return Ok(json!({"directory":{"path":path}}));
    }
    if let Some(root) = git_value(&["rev-parse", "--show-toplevel"], Some(&path)) {
        let branch = args
            .branch
            .clone()
            .or_else(|| git_value(&["symbolic-ref", "HEAD"], Some(&path)))
            .context("Check out a branch for local Git review")?;
        let branch = if branch.starts_with("refs/heads/") {
            branch
        } else {
            format!("refs/heads/{branch}")
        };
        Ok(json!({"localGit":{"path":root,"branch":branch}}))
    } else if args.branch.is_some() {
        bail!("Branch review requires a Git repository")
    } else {
        Ok(json!({"directory":{"path":path}}))
    }
}

fn repository_url(input: &str, host: &str) -> Result<String> {
    let path = input.trim().trim_end_matches('/').trim_end_matches(".git");
    if path.starts_with("https://") {
        return Ok(path.into());
    }
    let prefix = format!("git@{host}:");
    let ssh = format!("ssh://git@{host}/");
    let repository = path
        .strip_prefix(&prefix)
        .or_else(|| path.strip_prefix(&ssh))
        .unwrap_or(path);
    if repository.split('/').count() != 2 {
        bail!("Use a complete repository URL or owner/repository");
    }
    Ok(format!("https://{host}/{repository}"))
}

async fn start(client: &Client, args: &ContextArgs) -> Result<Value> {
    let target = target_input(args)?;
    if target["directory"].is_object() && !location(args)?.is_dir() {
        bail!("File notes need no review; use comments add --notes FILE");
    }
    let comparison = if target["localGit"].is_object() {
        let root = std::path::Path::new(
            target["localGit"]["path"]
                .as_str()
                .context("Missing local repository")?,
        );
        let head = git_value(&["rev-parse", "HEAD"], Some(root))
            .context("Repository has no HEAD commit")?;
        match args.mode.unwrap_or(crate::models::ReviewMode::Working) {
            crate::models::ReviewMode::Working => json!({"working":{"baseCommit":head}}),
            crate::models::ReviewMode::Staged => json!({"staged":{"baseCommit":head}}),
            crate::models::ReviewMode::Branch => {
                let base = git_value(
                    &["merge-base", args.base.as_deref().unwrap_or("main"), "HEAD"],
                    Some(root),
                )
                .context("Cannot find a common ancestor with the selected base")?;
                json!({"branch":{"baseCommit":base,"headCommit":head}})
            }
        }
    } else {
        Value::Null
    };
    let mut input = json!({"target":target,"requestKey":key(None),"resumePolicy":"RESUME_POLICY_MATCHING_ONLY"});
    if !comparison.is_null() {
        input["comparison"] = comparison;
    }
    if target["pullRequest"].is_object()
        && let Some(root) = git_value(&["rev-parse", "--show-toplevel"], Some(&location(args)?))
    {
        input["localGitPath"] = json!(root);
    }
    client.call("StartReview", input).await
}

async fn get_reviews(
    client: &Client,
    target: Option<&Value>,
    state: Option<&str>,
) -> Result<Vec<Value>> {
    let mut reviews = Vec::new();
    let mut token = String::new();
    loop {
        let mut input = json!({"pageToken":token,"pageSize":200});
        if let Some(target) = target {
            input["target"] = target.clone();
        }
        if let Some(state) = state {
            input["state"] = json!(state);
        }
        let page = client.call("ListReviews", input).await?;
        reviews.extend(page["reviews"].as_array().into_iter().flatten().cloned());
        token = page["nextPageToken"].as_str().unwrap_or("").into();
        if token.is_empty() {
            break;
        }
    }
    Ok(reviews)
}

async fn get_review(client: &Client, args: &ContextArgs) -> Result<Value> {
    let target = target_input(args)?;
    get_reviews(client, Some(&target), None)
        .await?
        .into_iter()
        .max_by_key(|review| {
            (
                review["state"] == "active",
                review["createdAt"].as_str().unwrap_or("").to_owned(),
            )
        })
        .context("No saved review for this target; run lgtm session start first")
}

async fn get_scope(
    client: &Client,
    args: &ContextArgs,
    file: Option<&str>,
    write: bool,
) -> Result<Value> {
    if args.notes.is_some() {
        let path = location(args)?;
        if !path.is_dir() {
            return Ok(json!({"notes":{"path":path}}));
        }
        let target = target_input(args)?;
        if let Some(review) = get_reviews(client, Some(&target), None)
            .await?
            .into_iter()
            .max_by_key(|review| {
                (
                    review["state"] == "active",
                    review["createdAt"].as_str().unwrap_or("").to_owned(),
                )
            })
        {
            return Ok(json!({"reviewId":review["id"]}));
        }
        if let Some(file) = file {
            return Ok(json!({"notes":{"path":path.join(file)}}));
        }
        bail!("Use --notes FILE for ordinary notes, or start a directory review first");
    }
    match get_review(client, args).await {
        Ok(review)
            if write
                && args.pr.is_some()
                && review["state"] == "completed"
                && review["submissionStatus"] == "confirmed" =>
        {
            Ok(json!({"reviewId":start(client, args).await?["review"]["id"]}))
        }
        Ok(review) => Ok(json!({"reviewId":review["id"]})),
        Err(error) if !write => Err(error),
        Err(_) => Ok(json!({"reviewId":start(client,args).await?["review"]["id"]})),
    }
}

async fn get_conversation(client: &Client, scope: &Value) -> Result<Value> {
    let mut result = json!({"threads":[],"comments":[]});
    let mut token = String::new();
    loop {
        let page = client
            .call(
                "GetConversation",
                json!({"scope":scope,"includeDrafts":true,"pageToken":token,"pageSize":500}),
            )
            .await?;
        if !result["version"].is_null() && result["version"] != page["version"] {
            bail!("Conversation changed while loading; try again");
        }
        result["version"] = page["version"].clone();
        for thread in page["threads"].as_array().into_iter().flatten() {
            if !result["threads"]
                .as_array()
                .unwrap()
                .iter()
                .any(|existing| existing["id"] == thread["id"])
            {
                result["threads"]
                    .as_array_mut()
                    .unwrap()
                    .push(thread.clone());
            }
        }
        result["comments"]
            .as_array_mut()
            .unwrap()
            .extend(page["comments"].as_array().into_iter().flatten().cloned());
        token = page["nextPageToken"].as_str().unwrap_or("").into();
        if token.is_empty() {
            break;
        }
    }
    Ok(result)
}

fn key(supplied: Option<String>) -> String {
    supplied.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
}

async fn client_for_actor(store: &Store, actor: &ActorArgs) -> Result<Client> {
    let client = Client::new(store)?;
    if let Some(id) = &actor.identity {
        return Ok(client.with_identity(id.clone()));
    }
    let name = actor
        .as_name
        .clone()
        .or_else(|| std::env::var("LGTM_NAME").ok())
        .or_else(|| git_value(&["config", "user.name"], None))
        .context("Set git user.name, LGTM_NAME, or pass --as")?;
    let input = if let Some(model) = &actor.model {
        json!({"name":name,"model":model,"provider":actor.provider})
    } else {
        json!({"name":name,"email":std::env::var("LGTM_EMAIL").ok().or_else(||git_value(&["config","user.email"],None)).context("Set git user.email or LGTM_EMAIL")?})
    };
    let value = client.call("EnsureIdentity", input).await?;
    Ok(client.with_identity(
        value["actor"]["id"]
            .as_str()
            .context("Missing identity ID")?
            .into(),
    ))
}

fn git_value(args: &[&str], path: Option<&std::path::Path>) -> Option<String> {
    let mut command = Command::new("git");
    command.args(args);
    if let Some(path) = path {
        command.current_dir(path);
    }
    let output = command.output().ok()?;
    if !output.status.success() {
        return None;
    }
    let value = String::from_utf8(output.stdout).ok()?.trim().to_owned();
    if value.is_empty() { None } else { Some(value) }
}

fn print_result(value: &Value, _json_output: bool) -> Result<()> {
    println!("{}", serde_json::to_string_pretty(value)?);
    Ok(())
}